Skip to content

Declarative filter operation for safe-mode collection filtering #293

Description

@amc-corey-cox

The flagship personinfo_basic example can't run under default flags. Three of its Agent derivations need to filter a multivalued slot and pluck a field off the match:

driving_since:
  expr: |
    d_test = [x.important_event_date for x in src.has_important_life_events if str(x.event_name) == "PASSED_DRIVING_TEST"]
    if len(d_test):
        target = d_test[0]

That's a list comprehension plus multi-statement assignment, so it requires --unrestricted-eval. #270 documented the requirement and pinned it with tests; this issue is about closing the capability gap so the shape doesn't need unsafe eval at all.

What's actually missing

Filtering is only one of three capabilities the derivation needs, and none of them have a declarative shape today:

  1. Filter a collection by a predicate on a member slot
  2. Project a field off each match (the spec's own commented-out examples do this with expr: "[x.id for x in {friends}]")
  3. Pick one — AggregationType has SUM/AVERAGE/COUNT/MIN/MAX/STD_DEV/VARIANCE/MEDIAN, but no FIRST/LAST

Adding any one alone still leaves the example needing the flag.

Preferred shape: declarative, not new safe functions

transformer_model.yaml already has the extension point — an abstract TransformationOperation with AggregationOperation, GroupingOperation, and PivotOperation as siblings. A FilterOperation is the missing fourth. Sketch:

driving_since:
  populated_from: has_important_life_events
  filter:
    slot: event_name
    operator: EQ
    value: PASSED_DRIVING_TEST
  select: important_event_date
  aggregation_operation:
    operator: FIRST

The function route looks cheaper and isn't. Without lambdas you can't pass a predicate, so you land on filter_eq(list, "event_name", "DEATH") — stringly-typed, equality-only — and then the pressure is unbounded: ne, gt/lt, in, contains, startswith, matches, is_null, then pluck (you can't finish without it), then sort_by, distinct, flatten, group_by. That's hand-rolling JMESPath one allowlist entry at a time with no spec for the semantics. A declarative op makes the operator set an explicit, reviewable enum instead of an open backlog, and is safe by construction rather than safe by allowlist.

The WHERE / DuckDB facet

This is the part worth settling before designing anything: a filter predicate is a WHERE clause. The set-based DuckDB join engine landed in 510b0a5, and the joins rework is ongoing. If a query engine is emerging under that work, filter semantics should be defined in terms both engines can execute rather than invented separately in ObjectTransformer — otherwise we get two dialects with subtly different null and type-coercion behavior, which is exactly the class of bug that's expensive to find later.

Concretely, a FilterOperation should lower to a DuckDB WHERE on the set-based path and to a per-object predicate on the object path, with one definition of what EQ means for nulls, enums (note event_name's range is an enum, and the current expr works around this with str(x.event_name)), and numeric/string coercion. Aligning the operator enum with what the DuckDB path can express for free seems like the right forcing function on scope.

Open questions

  • Operator enum scope: EQ/NE only to start, or include IN/GT/LT/MATCHES?
  • Is select the right projection shape, or should projection be a separate operation?
  • FIRST/LAST as AggregationType values, or a distinct selection operation? FIRST isn't really an aggregation.
  • Does this wait on the joins/query work settling?

Related: #270 (documented the requirement), #292 (hidden slots skip reshaping — blocks expressing this via the existing multi-step hide/slot() mechanism), #206 (added safe built-ins to reduce reliance on unsafe eval), #241 (transform-time validation).

Activity

  1. github-actions commented on Jul 16, 2026

    @github-actions

    Summary

    This is a well-scoped design proposal, not an epic — no linked papers/external resources to check. I dug into the referenced issues and the current code to ground the open questions below. Two pieces of prior art on this exact terrain aren't cross-linked yet and are worth pulling in.

    Prior art: #187 and #188 cover adjacent ground, unimplemented

    Both filed by you, both still open, both predate this issue by ~3 months:

    The distinction worth naming explicitly: #187's filter_rows filters which source rows become target instances (class-level, whole-row). This issue's FilterOperation filters elements inside an already-selected multivalued slot (slot-level, sub-row). Related concerns, different granularity — worth linking all three issues together so whoever picks this up doesn't design the operator enum twice.

    The DuckDB/WHERE framing may be over-scoping the immediate unblock

    I checked has_important_life_events in personinfo-to-agent.transform.yaml and the source schema: it's a locally inlined multivalued slot on Person, populated straight from the source object — no joins: involved anywhere in this transform spec. So the concrete thing blocking #270's flagship example is a plain in-memory list-comprehension-shaped filter, not a cross-table query.

    I also checked the DuckDB/join engine code that exists in the tree (join_engine.py, duckdb_transformer.py, sql_compiler.py, ~500 lines total) — there's currently no WHERE/predicate/filter concept anywhere in it. So "align the operator enum with what the DuckDB path can express" is aspirational alignment with a facet that doesn't exist yet, not alignment with landed work. That's still the right instinct for avoiding two dialects later, but it means the object-path FilterOperation doesn't actually have an implementation to wait on — the dependency this issue's last open question raises ("does this wait on the joins/query work settling?") looks like it can be answered no for the object path specifically, provided the operator semantics (null handling, enum coercion) are specified narrowly enough now to stay a compatible subset whenever the SQL side catches up.

    On the open questions

    Recommend cross-linking #187 and #188 from here (and vice versa) so the operator-enum design happens once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions