feat(query): bare .exists() on reverse FK — existence-test tracer bullet#319
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
renders (Postgres, one branch shown):
.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&/|, rootorder_by,limit, andcount().ReverseSpecis derived beside__ferro_relation_specs__from factsresolve_relationshipsalready computes onRelationshipDescriptor(child model, child FK column, one-to-one flag). M2M entries are skipped until slice 3/4 (featExistence tests 3/4: many-to-many — the two-hop correlation path #316)..exists()and nothing else. Column access, comparisons (including!= None/== None— thefeatReverse-relation membership predicates: EXISTS from the root query (PRD from Pinch) #307 repro's first guess),in_,like, and a bare proxy returned fromwhere()all raise at build time naming.exists()(messages pinned by tests). A reverse name reached through forward traversal fails pointedly too.existsnode kind{hops, where}besideleaf/compound/not—hopsreuses theQueryJoinHopvocabulary (1 hop for a reverse FK),whereis the ordinary inner condition tree (empty in this slice; scoped tests are slice 2/4,featExistence 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.ExistsScopequalifier. Statement-widex{n}_{relation}aliases keep sibling and nested tests collision-free. NOT EXISTS arrives via thefeatUniform negation 1/2:~on leaf predicates — the tracer bullet #312 NOT node — the exists node carries no negation flag.update()/delete()reject existence tests at build time (single-table write shapes), with a Rust boundary-defense arm behind the Python guard. The pinnedinclude()population rejection (featJoined-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)
.exists()works on one-to-one and to-many BackRefs, uniform spelling and rendering&/|, rootorder_by,limit~t.rel.exists()renders NOT EXISTS; bothfeatReverse-relation membership predicates: EXISTS from the root query (PRD from Pinch) #307is_transferbranches pass e2e on both backends.exists()with build-time errors naming.exists()(messages pinned)existsnode asserted by Python emitter and Rust decoderTesting
cargo test: 196 + 25 (ferro-schema-ir) passed, including 4 new SQL-render tests and the two new vector round-tripspytest --db-backends=sqlite,postgres: 1627 passed (full matrix, local Postgres)ty check: clean