Skip to content

feat(query): existence tests — scoped predicates, M2M, error surfaces & docs (slices 2–4)#323

Merged
0x054 merged 3 commits into
mainfrom
feat/existence-tests-315-317
Jul 18, 2026
Merged

feat(query): existence tests — scoped predicates, M2M, error surfaces & docs (slices 2–4)#323
0x054 merged 3 commits into
mainfrom
feat/existence-tests-315-317

Conversation

@0x054

@0x054 0x054 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes #315, closes #316, closes #317, closes #311, closes #307, closes #308

Slices 2–4 of the existence-tests PRD (#311, ADR-0007), rebased onto main after the tracer bullet (#319) merged. Three commits, one per slice, structured for a rebase merge — each is independently green (tests, both backends, wire vectors) and matches its sub-issue's acceptance criteria. Supersedes the stacked PRs #320, #321, #322 (closed; identical content, tree byte-identical to the verified stack tip).


Commit 1 — scoped inner predicates (#315)

The optional inner lambda is a full ferro predicate over the child model: every operator, &/|/~, forward traversal, nested tests to arbitrary depth.

# 308: line-aware category filter; child-less roots survive the OR
Txn.where(lambda t: t.category_id.in_(ids)
                  | t.lines.exists(lambda line: line.category_id.in_(ids)))
  • Forward traversal renders its joins inside the EXISTS subquery under unchanged ADR-0006 semantics (INNER, narrowing). Hop facts ride the exists node's own joins section, present only when non-empty — bare-test wire bytes unchanged, no version bump.
  • Nesting is recursion: a nested test correlates to the enclosing subquery's alias (pinned by a Rust render test, depth-2 e2e, and a golden vector).
  • Cross-scope guard (feat Cross-scope correlation in existence tests (deferred from ADR-0007) #309): a leaf built from any proxy other than the inner lambda's parameter (via a compile-side owner scope tag — same-named columns can't silently rescope), a FieldProxy as comparison RHS, and a foreign nested test all raise at build time naming feat Cross-scope correlation in existence tests (deferred from ADR-0007) #309.
  • Typed binds inside the subquery resolve against the leaf's owning table (walkers populate registrations/enum catalogs for every table a test reaches).
  • e2e: the full feat OR-composition across a left-joined reverse child table (PRD from Pinch) #308 shape (multi-line match → one root row; child-less root survives the OR; keyset order_by + limit compose), the explicit grouping contrast (exists(A & B) vs exists(A) & exists(B) — different, correct row sets), golden vectors for scoped + nested shapes.

Commit 2 — M2M two-hop path (#316)

u.tags.exists(...) bare, scoped, negated — same verb, same node, a two-hop correlation path (join table correlated to the enclosing scope, then the target). The reverse-spec map learns M2M entries from each side's descriptor (which orients the join-table triple per side, so both sides spell identically). The existing Rust render loop covered it unchanged — zero new mechanism, as the design predicted. Golden vector + Rust render test pin the 2-hop shape; e2e covers exactly-once semantics regardless of matching join-row count.

Commit 3 — error surfaces & docs (#317)


Testing (at the tip)

  • pytest --db-backends=sqlite,postgres: 1679 passed (full matrix, local Postgres)
  • cargo test: 196 + 28 (ferro-schema-ir) passed
  • ty check / ruff check: clean; docs example harness green

Must-pass demos from the PRD, all covered e2e on both backends: both #307 is_transfer branches (membership via either FK column, each root exactly once), the #308 shape, the grouping contrast, depth-2 nesting, M2M bare + scoped + negated.

0x054 added 3 commits July 18, 2026 11:41
…s-scope guard

The existence test's optional inner lambda is a full ferro predicate
over the child model, resolved through the same validating QueryProxy a
root where() receives: every operator, &/|/~ composition, forward-FK
traversal, and nested existence tests to arbitrary depth. The explicit
lambda scope is what makes multi-condition grouping unambiguous —
exists(lambda l: A & B) (one child row matches both) vs
exists(A-test) & exists(B-test) (some child row each) — the
Django-style implicit-traversal ambiguity ADR-0007 rejects.

- Forward traversal inside the test renders its joins INSIDE the EXISTS
  subquery under unchanged ADR-0006 semantics (INNER at every hop,
  narrowing). The hop facts ride the exists node's own `joins` section
  (QueryJoin entries, always "inner"), serialized only when non-empty —
  bare-test wire bytes are unchanged from the tracer bullet.
- Nesting is recursion, not a second mechanism: a nested test's
  correlation hop resolves against the enclosing SUBQUERY's alias
  through the ExistsScope qualifier, pinned by a Rust render test and
  depth-2 e2e coverage.
- Cross-scope references are rejected at build time pointing at the
  deferred capability (#309): a leaf built from any proxy other than
  the inner lambda's parameter (detected via a compile-side `owner`
  scope tag the proxies now stamp on comparison nodes), a FieldProxy as
  comparison RHS (column-to-column), and a nested test built from
  another scope's proxy. Silent misrendering is the failure mode this
  guard exists to prevent.
- Typed binds inside the subquery resolve against the leaf's OWNING
  table: the SELECT walkers now populate registrations and enum
  catalogs for every table an existence test reaches (correlation hops,
  inner join hops, nested tests).
- Hand-authored golden vectors pin the scoped shape (inner tree +
  inner-traversal joins) and the nested exists-in-exists shape,
  asserted from the Python emitter and the Rust decoder.
- End-to-end on both backends: the full #308 line-aware category filter
  (three matching lines → one root row; child-less roots survive the
  OR; keyset order_by + limit compose), the explicit grouping contrast,
  forward traversal inside the subquery, depth-2 nesting, negated and
  counted scoped tests, and every pinned cross-scope error.

Refs #311, #315
t.tags.exists(...) works on many-to-many relations, bare, scoped, and
~-negated, spelled and behaving exactly like the reverse-FK form
(ADR-0007: one verb at every cardinality and relation kind). The same
exists node carries a two-hop correlation path — join table first,
correlated to the enclosing scope, then the target — and the existing
Rust render loop covers it unchanged: no new render mechanism, proving
the design's claim that M2M is test surface, not a second mechanism.

- build_reverse_specs maps M2M descriptors to join-table specs; each
  side's descriptor already orients source_col/target_col for that
  side, so both the declaring and the related_name side spell
  identically.
- The proxy builds hops from the spec: join_table.source_col against
  the root PK, then target on join_table.target_col = target.pk. The
  inner lambda resolves over the M2M target with full predicate power
  (operators, combinators, negation, cross-scope guard) — the #315
  machinery, untouched.
- Hand-authored golden vector pins the 2-hop shape, asserted from the
  Python emitter and the Rust decoder; a Rust render test pins
  join-table-FROM + inner-join-target + last-hop-alias qualification.
- e2e on both backends: bare (each root exactly once regardless of how
  many join rows match), scoped, negated, both sides of the relation,
  root-predicate/count composition, and the proxy's single-verb error
  surfaces.

Refs #311, #316
…dges

Every remaining surface where a reverse or M2M relation can be named
now answers with the supported spelling, and the docs teach existence
tests end to end.

Error surfaces beyond the proxy's own (those shipped in the tracer
bullet):

- join()/left_join() on a reverse/M2M edge stays rejected — the pinned
  "a join never multiplies root rows" property is preserved by
  rejection, not implicit DISTINCT — with the error naming .exists()
  (previously an opaque "must return a relation path" TypeError).
- in_() with a query RHS stays a TypeError whose message names the
  existence test when the RHS is a query (the #307 repro's second
  guess); a non-query RHS keeps the plain message.
- include() rejection verified unchanged for M2M edges too —
  population remains a separate future mechanism.

Docs:

- New guide section "Existence Tests on Reverse & Many-to-Many
  Relations": real models (Assignment + Annotated tabs), lambda-style
  predicates throughout, rendered SQL for the #307/#308 shapes, the
  explicit grouping contrast, negation, traversal-inside-the-test,
  nesting, M2M, the loud dead ends, and the exists-test vs
  query-terminal disambiguation. Backed by two new runnable example
  scripts exercised by the docs harness.
- Operators table gains the .exists() row; the forward
  existence/absence section and the reverse-relation filtering section
  cross-link the reverse direction.
- Reference (api/queries.md) states the one-verb rule and its
  rejections.
- Roadmap and "Not Yet Supported" now distinguish reverse predicates
  (shipped — the existence test) from reverse population (still
  future; the include() rejection stays), and track cross-scope
  correlation as #309.

Refs #311, #317
@0x054
0x054 merged commit be88aed into main Jul 18, 2026
7 checks passed
@0x054
0x054 deleted the feat/existence-tests-315-317 branch July 18, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment