Skip to content

feat(query): bare .exists() on reverse FK — existence-test tracer bullet#319

Merged
0x054 merged 1 commit into
mainfrom
feat/existence-tests-314-bare-exists
Jul 18, 2026
Merged

feat(query): bare .exists() on reverse FK — existence-test tracer bullet#319
0x054 merged 1 commit into
mainfrom
feat/existence-tests-314-bare-exists

Conversation

@0x054

@0x054 0x054 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes #314

The tracer bullet for existence tests (PRD #311, ADR-0007): bare .exists() on a reverse (BackRef) relation, end-to-end — proxy spelling through the wire IR to a correlated EXISTS on both database backends.

What ships

# 307: is_transfer true / false — both branches pass e2e on SQLite and Postgres
Txn.where(lambda t: t.transfer_out.exists() | t.transfer_in.exists())
Txn.where(lambda t: ~t.transfer_out.exists() & ~t.transfer_in.exists())

renders (Postgres, one branch shown):

SELECT ... FROM "txn"
WHERE EXISTS (SELECT 1 FROM "transfer" AS "x1_transfer_out"
              WHERE "x1_transfer_out"."outflow_transaction_id" = "txn"."id")
   OR EXISTS (SELECT 1 FROM "transfer" AS "x2_transfer_in"
              WHERE "x2_transfer_in"."inflow_transaction_id" = "txn"."id")
  • One verb, one rendering. .exists() at every cardinality — one-to-one and to-many BackRefs spell and render identically (always correlated EXISTS, no LEFT JOIN specialization). Each matching root returns exactly once; the result stays root-shaped and composes with &/|, root order_by, limit, and count().
  • Reverse-spec map at the compile choke point. ReverseSpec is derived beside __ferro_relation_specs__ from facts resolve_relationships already computes on RelationshipDescriptor (child model, child FK column, one-to-one flag). M2M entries are skipped until slice 3/4 (feat Existence tests 3/4: many-to-many — the two-hop correlation path #316).
  • The proxy accepts .exists() and nothing else. Column access, comparisons (including != None / == None — the feat Reverse-relation membership predicates: EXISTS from the root query (PRD from Pinch) #307 repro's first guess), in_, like, and a bare proxy returned from where() all raise at build time naming .exists() (messages pinned by tests). A reverse name reached through forward traversal fails pointedly too.
  • Wire: QueryIR v7. New recursive exists node kind {hops, where} beside leaf/compound/nothops reuses the QueryJoinHop vocabulary (1 hop for a reverse FK), where is the ordinary inner condition tree (empty in this slice; scoped tests are slice 2/4, feat Existence tests 2/4: scoped inner predicates — forward traversal, nesting, cross-scope guard #315). Hand-authored golden vectors pin the bare 1-hop and not-of-exists shapes, asserted from the Python emitter and the Rust decoder; existing query vectors move to v7 and the version gate re-pins its actionable rejection for v6.
  • Rust: one render loop. First hop's table is the subquery FROM correlated to the enclosing scope's alias; remaining hops already render as inner joins inside the subquery (the M2M-ready shape); the inner tree recurses through the existing condition builder under a new ExistsScope qualifier. Statement-wide x{n}_{relation} aliases keep sibling and nested tests collision-free. NOT EXISTS arrives via the feat Uniform negation 1/2: ~ on leaf predicates — the tracer bullet #312 NOT node — the exists node carries no negation flag.
  • Guard rails. update()/delete() reject existence tests at build time (single-table write shapes), with a Rust boundary-defense arm behind the Python guard. The pinned include() population rejection (feat Joined-row hydration 3/4: multi-hop, interplay, refresh rule, guardrails #287) is preserved now that the predicate proxies resolve reverse names before the AttributeError path.

Acceptance criteria (#314)

  • Bare .exists() works on one-to-one and to-many BackRefs, uniform spelling and rendering
  • Each matching root returns exactly once; result stays root-shaped and composes with &/|, root order_by, limit
  • ~t.rel.exists() renders NOT EXISTS; both feat Reverse-relation membership predicates: EXISTS from the root query (PRD from Pinch) #307 is_transfer branches pass e2e on both backends
  • Reverse proxy rejects everything except .exists() with build-time errors naming .exists() (messages pinned)
  • QueryIR version bumped; hand-authored golden vector for the bare 1-hop exists node asserted by Python emitter and Rust decoder
  • e2e tests follow the per-feature query test file style on the existing backend matrix

Testing

  • cargo test: 196 + 25 (ferro-schema-ir) passed, including 4 new SQL-render tests and the two new vector round-trips
  • pytest --db-backends=sqlite,postgres: 1627 passed (full matrix, local Postgres)
  • ty check: clean

A reverse (BackRef) relation now appears in a predicate in exactly one
form: the existence test t.rel.exists(), rendered as a correlated
EXISTS (SELECT 1 FROM child WHERE child.fk = root.pk) at every
cardinality — one-to-one BackRefs included, no LEFT JOIN
specialization — and negated with the uniform ~ (NOT node, ADR-0008).
The result stays root-shaped, so it composes with any other predicate,
root order_by, and limit (ADR-0007: reverse relations are tested, not
traversed).

- A reverse-spec map (ReverseSpec) is derived at the compile choke
  point beside __ferro_relation_specs__, from the facts
  resolve_relationships already computes on RelationshipDescriptor
  (child model, child FK column, one-to-one flag). M2M entries are
  skipped until the two-hop slice.
- QueryProxy consults it ahead of the column fallback and returns a
  ReverseRelationProxy exposing .exists() only: column access,
  comparisons (including != None / == None), in_, like, and a bare
  proxy in where() all raise at build time naming .exists().
- Wire: QueryIR v7 adds the recursive `exists` node kind
  {hops, where} beside leaf/compound/not — hops reuses the
  QueryJoinHop vocabulary (one hop for a reverse FK), where is an
  ordinary inner condition tree (empty in this slice). Hand-authored
  golden vectors pin the bare 1-hop and not-of-exists shapes, asserted
  from the Python emitter and the Rust decoder; existing query vectors
  move to v7.
- Rust: one render loop — the first hop's table is the subquery FROM
  correlated to the enclosing scope's alias, remaining hops render as
  inner joins inside the subquery (M2M-ready), the inner tree recurses
  through the existing condition builder under an ExistsScope
  qualifier. Statement-wide x{n}_{relation} aliases keep sibling and
  nested tests collision-free.
- Mutating verbs reject existence tests at build time (single-table
  write shapes), with Rust boundary defense behind the Python guard.
- include() population rejection (#287) is preserved now that the
  proxy resolves reverse names before the AttributeError path.
- End-to-end on both backends: both #307 is_transfer branches
  (membership via either FK column, each root exactly once), to-many
  exactly-once semantics, &/| and order_by/limit composition, count(),
  and every pinned error surface.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat Existence tests 1/4: bare .exists() on reverse FK — the tracer bullet

1 participant