From 6767efd129ad3e414ccc6f7d60f2d303636c3ee7 Mon Sep 17 00:00:00 2001 From: Taylor Date: Sat, 18 Jul 2026 10:58:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(query):=20bare=20.exists()=20on=20reve?= =?UTF-8?q?rse=20FK=20relations=20=E2=80=94=20correlated=20EXISTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ferro-schema-ir/src/lib.rs | 116 ++++++-- src/ferro/columns.py | 70 +++++ src/ferro/ir/compiler.py | 5 + src/ferro/models.py | 5 +- src/ferro/query/builder.py | 42 ++- src/ferro/query/nodes.py | 168 +++++++++++- src/ferro/query/wire.py | 20 +- src/operations.rs | 90 ++++-- src/query.rs | 257 +++++++++++++++++- tests/fixtures/ir_vectors/README.md | 11 +- .../ir_vectors/query_account_exists_v7.json | 40 +++ .../ir_vectors/query_owner_not_exists_v7.json | 37 +++ ...on => query_transaction_aggregate_v7.json} | 4 +- ...uery_transaction_global_aggregate_v7.json} | 4 +- ...json => query_transaction_include_v7.json} | 4 +- ...on => query_transaction_left_join_v7.json} | 4 +- ....json => query_transaction_record_v7.json} | 4 +- ...on => query_transaction_traversal_v7.json} | 4 +- ...uery_transaction_traversed_record_v7.json} | 4 +- ...nd_v6.json => query_user_compound_v7.json} | 4 +- ...6.json => query_user_not_compound_v7.json} | 4 +- ...af_v6.json => query_user_not_leaf_v7.json} | 4 +- tests/test_ir_vectors_contract.py | 29 +- tests/test_query_builder.py | 2 +- tests/test_query_exists.py | 218 +++++++++++++++ tests/test_query_wire_vectors.py | 37 ++- 26 files changed, 1076 insertions(+), 111 deletions(-) create mode 100644 tests/fixtures/ir_vectors/query_account_exists_v7.json create mode 100644 tests/fixtures/ir_vectors/query_owner_not_exists_v7.json rename tests/fixtures/ir_vectors/{query_transaction_aggregate_v6.json => query_transaction_aggregate_v7.json} (95%) rename tests/fixtures/ir_vectors/{query_transaction_global_aggregate_v6.json => query_transaction_global_aggregate_v7.json} (95%) rename tests/fixtures/ir_vectors/{query_transaction_include_v6.json => query_transaction_include_v7.json} (92%) rename tests/fixtures/ir_vectors/{query_transaction_left_join_v6.json => query_transaction_left_join_v7.json} (94%) rename tests/fixtures/ir_vectors/{query_transaction_record_v6.json => query_transaction_record_v7.json} (92%) rename tests/fixtures/ir_vectors/{query_transaction_traversal_v6.json => query_transaction_traversal_v7.json} (96%) rename tests/fixtures/ir_vectors/{query_transaction_traversed_record_v6.json => query_transaction_traversed_record_v7.json} (95%) rename tests/fixtures/ir_vectors/{query_user_compound_v6.json => query_user_compound_v7.json} (95%) rename tests/fixtures/ir_vectors/{query_user_not_compound_v6.json => query_user_not_compound_v7.json} (93%) rename tests/fixtures/ir_vectors/{query_user_not_leaf_v6.json => query_user_not_leaf_v7.json} (92%) create mode 100644 tests/test_query_exists.py diff --git a/crates/ferro-schema-ir/src/lib.rs b/crates/ferro-schema-ir/src/lib.rs index 7fa427e..3f27977 100644 --- a/crates/ferro-schema-ir/src/lib.rs +++ b/crates/ferro-schema-ir/src/lib.rs @@ -137,14 +137,15 @@ pub struct SchemaCheck { /// Query IR: filter, sort, pagination, joins, materialization plan, and /// optional M2M join context. /// -/// `ir_version: 6` (unconditional, no earlier version emitted anywhere — -/// #269, #278, #285, #292, #310). `joins` is required and always present -/// (`[]` when the query traverses no relation); every leaf and `order_by` -/// entry carries a `path` (required, `[]` = root model); `materialization` is -/// required — the plan travels with the query as data (ADR-0007), never -/// inferred from a column list. v5 gave `record` fields expression sources -/// (#292, ADR-0009); v6 gives predicate trees the recursive `not` node kind -/// (uniform negation, #310, ADR-0008). +/// `ir_version: 7` (unconditional, no earlier version emitted anywhere — +/// #269, #278, #285, #292, #310, #314). `joins` is required and always +/// present (`[]` when the query traverses no relation); every leaf and +/// `order_by` entry carries a `path` (required, `[]` = root model); +/// `materialization` is required — the plan travels with the query as data +/// (ADR-0007), never inferred from a column list. v5 gave `record` fields +/// expression sources (#292, ADR-0009); v6 gave predicate trees the +/// recursive `not` node kind (uniform negation, #310, ADR-0008); v7 adds the +/// recursive `exists` node kind (existence tests, #314, ADR-0007). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct QueryIrPayload { /// Model class name the query targets. @@ -334,8 +335,9 @@ pub struct QueryOrderBy { pub path: Vec, } -/// Predicate tree node in query IR: leaf comparison, compound AND/OR, or a -/// recursive NOT wrapping any child (uniform negation, v6, ADR-0008). +/// Predicate tree node in query IR: leaf comparison, compound AND/OR, a +/// recursive NOT wrapping any child (uniform negation, v6, ADR-0008), or a +/// recursive existence test over a reverse/M2M relation (v7, ADR-0007). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "node_kind")] pub enum QueryNode { @@ -370,6 +372,23 @@ pub enum QueryNode { /// The negated subtree. child: Box, }, + /// Existence test on a reverse or M2M relation (ADR-0007): a correlated + /// `EXISTS (SELECT 1 ...)` at every cardinality. Carries no negation + /// flag — NOT EXISTS is the `not` node over this one. + #[serde(rename = "exists")] + Exists { + /// Correlation hop path in the `joins`-section hop-fact shape: one + /// hop for a reverse FK (`FROM child WHERE child.fk = root.pk`), two + /// for M2M (join table, then target). 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. + hops: Vec, + /// Ordinary inner condition tree over the relation's target model + /// (`[]` = bare test). May itself contain `exists`/`not` nodes — + /// nesting is recursion, not a second mechanism. + #[serde(rename = "where")] + where_clause: Vec, + }, } /// Typed literal or parameter value on the RHS of a leaf predicate. @@ -448,7 +467,7 @@ mod tests { #[test] fn query_fixture_roundtrip() { let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query fixture must parse"); let ir = parsed @@ -473,7 +492,7 @@ mod tests { // leaf `IN` comparison (the NOT IN spelling) — and must survive a // deserialize/serialize round-trip without drift. let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_user_not_leaf_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_user_not_leaf_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query not-leaf fixture must parse"); let ir = parsed @@ -500,7 +519,7 @@ mod tests { // OR compound whole — no De Morgan expansion on the wire — and must // survive a deserialize/serialize round-trip without drift. let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_user_not_compound_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_user_not_compound_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query not-compound fixture must parse"); let ir = parsed @@ -521,12 +540,69 @@ mod tests { assert_eq!(encoded, ir, "query not-compound round-trip must not drift"); } + #[test] + fn query_exists_fixture_roundtrip() { + // The bare 1-hop existence-test golden vector (#314, ADR-0007): an + // `exists` node carries its correlation hop path plus an empty inner + // condition tree, and must survive a deserialize/serialize + // round-trip without drift. + let fixture = + include_str!("../../../tests/fixtures/ir_vectors/query_account_exists_v7.json"); + let parsed: serde_json::Value = + serde_json::from_str(fixture).expect("query exists fixture must parse"); + let ir = parsed + .get("ir") + .cloned() + .expect("fixture must contain ir envelope"); + let envelope: IrEnvelope = + serde_json::from_value(ir.clone()).expect("query exists IR must deserialize"); + match &envelope.payload.where_clause[0] { + QueryNode::Exists { hops, where_clause } => { + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].relation, "transactions"); + assert_eq!(hops[0].from_column, "id"); + assert_eq!(hops[0].to_table, "transaction"); + assert_eq!(hops[0].to_column, "account_id"); + assert!(where_clause.is_empty(), "bare test carries no inner tree"); + } + other => panic!("where[0] must be an exists node, got {other:?}"), + } + let encoded = serde_json::to_value(&envelope).expect("query exists IR must serialize"); + assert_eq!(encoded, ir, "query exists round-trip must not drift"); + } + + #[test] + fn query_not_exists_fixture_roundtrip() { + // NOT EXISTS on the wire is the ordinary `not` node over an `exists` + // node — the exists node carries no negation flag (ADR-0008 + // composition; #314). + let fixture = + include_str!("../../../tests/fixtures/ir_vectors/query_owner_not_exists_v7.json"); + let parsed: serde_json::Value = + serde_json::from_str(fixture).expect("query not-exists fixture must parse"); + let ir = parsed + .get("ir") + .cloned() + .expect("fixture must contain ir envelope"); + let envelope: IrEnvelope = + serde_json::from_value(ir.clone()).expect("query not-exists IR must deserialize"); + match &envelope.payload.where_clause[0] { + QueryNode::Not { child } => match child.as_ref() { + QueryNode::Exists { hops, .. } => assert_eq!(hops[0].relation, "accounts"), + other => panic!("not child must be the exists node, got {other:?}"), + }, + other => panic!("where[0] must be a not node, got {other:?}"), + } + let encoded = serde_json::to_value(&envelope).expect("query not-exists IR must serialize"); + assert_eq!(encoded, ir, "query not-exists round-trip must not drift"); + } + #[test] fn query_traversal_fixture_roundtrip() { // Multi-hop `joins` section + path-carrying leaves must survive a // deserialize/serialize round-trip without drift (#270 wire stability). let fixture = include_str!( - "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v6.json" + "../../../tests/fixtures/ir_vectors/query_transaction_traversal_v7.json" ); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query traversal fixture must parse"); @@ -552,7 +628,7 @@ mod tests { // deserialize/serialize round-trip without drift, and the join_type // tokens must reach Rust exactly as written on the wire. let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_transaction_left_join_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query left_join fixture must parse"); let ir = parsed @@ -578,7 +654,7 @@ mod tests { // payload — predicate, order, limit, and a two-field record plan — // survives a deserialize/serialize round-trip without drift. let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_transaction_record_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query record fixture must parse"); let ir = parsed @@ -777,7 +853,7 @@ mod tests { // identity) — survives a deserialize/serialize round-trip without // drift. No group keys: the whole result collapses to one record. let fixture = include_str!( - "../../../tests/fixtures/ir_vectors/query_transaction_global_aggregate_v6.json" + "../../../tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json" ); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("global aggregate fixture must parse"); @@ -815,7 +891,7 @@ mod tests { // joins section included — survives a deserialize/serialize // round-trip without drift. let fixture = include_str!( - "../../../tests/fixtures/ir_vectors/query_transaction_traversed_record_v6.json" + "../../../tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json" ); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query traversed record fixture must parse"); @@ -853,7 +929,7 @@ mod tests { // a deserialize/serialize round-trip without drift. GROUP BY does not // travel: the renderer derives it from the non-expr fields (ADR-0009). let fixture = include_str!( - "../../../tests/fixtures/ir_vectors/query_transaction_aggregate_v6.json" + "../../../tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json" ); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query aggregate fixture must parse"); @@ -921,7 +997,7 @@ mod tests { // payload — root predicate, empty `joins`, and a one-path instances // plan — survives a deserialize/serialize round-trip without drift. let fixture = - include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v6.json"); + include_str!("../../../tests/fixtures/ir_vectors/query_transaction_include_v7.json"); let parsed: serde_json::Value = serde_json::from_str(fixture).expect("query include fixture must parse"); let ir = parsed diff --git a/src/ferro/columns.py b/src/ferro/columns.py index 6de4f8d..aeb8af5 100644 --- a/src/ferro/columns.py +++ b/src/ferro/columns.py @@ -27,8 +27,10 @@ "ColumnSpec", "ForeignKeyRef", "RelationSpec", + "ReverseSpec", "build_column_specs", "build_relation_specs", + "build_reverse_specs", "fk_shadow_spec", "pk_spec", "primary_key_field_name", @@ -85,6 +87,29 @@ class RelationSpec: nullable: bool +@dataclass(frozen=True, slots=True) +class ReverseSpec: + """One reverse (BackRef) relation's existence-test facts (#314, ADR-0007). + + Built beside :class:`RelationSpec` at the same compile choke point and + exposed as ``cls.__ferro_reverse_specs__``. A reverse relation supports + exactly one predicate form — the existence test ``t.rel.exists()`` — so + the spec carries only what a correlated EXISTS needs: the child model and + the child-side FK column the subquery correlates on. Reverse relations are + *tested*, never *traversed*; traversal facts stay on :class:`RelationSpec`. + """ + + field_name: str + #: The child model — the side declaring the ``ForeignKey``. + target: type + #: Shadow FK column on the child table the EXISTS correlates against. + child_fk_column: str + #: True when the child FK is ``unique=True`` (one-to-one BackRef). The + #: rendering is identical at every cardinality (always correlated EXISTS); + #: carried as a compile-side fact, not a render switch. + is_one_to_one: bool + + @dataclass(frozen=True, slots=True) class ColumnSpec: name: str @@ -465,6 +490,51 @@ def build_relation_specs( return specs +def build_reverse_specs(model_cls: type[Any]) -> dict[str, ReverseSpec]: + """Build one ReverseSpec per resolved reverse (BackRef) relation (#314). + + Reads the :class:`~ferro.relations.descriptors.RelationshipDescriptor` + instances ``resolve_relationships`` installs on the class — the facts a + correlated EXISTS needs (child model, child FK column, one-to-one flag) + already live there, so this derives, never re-computes. At provisional + class-body time no descriptors exist yet and the map is empty; the + resolved second pass recompiles with descriptors installed, so the lookup + is complete once resolution finishes (same lifecycle as + :func:`build_relation_specs`). + + M2M descriptors are skipped for now: the existence test's M2M form (a + two-hop correlation through the join table) lands in #316. + + Callers store the result on ``cls.__ferro_reverse_specs__`` and must + replace, never mutate — same convention as ``__ferro_columns__``. + """ + # Late imports: columns.py is a low-level leaf module; the relations + # package (and the registry it pulls in) imports back into it. + from .registry import REGISTRY + from .relations.descriptors import RelationshipDescriptor + + specs: dict[str, ReverseSpec] = {} + for klass in model_cls.__mro__: + for name, attr in vars(klass).items(): + if not isinstance(attr, RelationshipDescriptor) or name in specs: + continue + if attr.is_m2m: + continue + target = REGISTRY.resolve_reference(attr.target_model_name, default=None) + if target is None: + continue + specs[name] = ReverseSpec( + field_name=name, + target=target, + # The child FK field is the descriptor's `field_name`; its + # shadow column follows the single `{field}_id` convention + # (same derivation as build_relation_specs). + child_fk_column=f"{attr.field_name}_id", + is_one_to_one=attr.is_one_to_one, + ) + return specs + + _PK_TYPE_FACTS: dict[Any, tuple[str, str | None]] = { int: ("integer", None), str: ("string", None), diff --git a/src/ferro/ir/compiler.py b/src/ferro/ir/compiler.py index 074c200..f621545 100644 --- a/src/ferro/ir/compiler.py +++ b/src/ferro/ir/compiler.py @@ -22,6 +22,7 @@ ColumnSpec, build_column_specs, build_relation_specs, + build_reverse_specs, primary_key_field_name, ) from ..composite_indexes import drop_overlap_with_uniques, normalized_composite_indexes @@ -330,6 +331,10 @@ def compile_model_schema_ir( # the resolved second pass) refreshes ``__ferro_relation_specs__`` too, so # the latter is never stale relative to the former. model_cls.__ferro_relation_specs__ = build_relation_specs(model_cls, specs) + # And the reverse-relation facts (#314, ADR-0007): the same refresh keeps + # ``__ferro_reverse_specs__`` in step, so existence tests resolve against + # descriptors the moment relationship resolution recompiles this model. + model_cls.__ferro_reverse_specs__ = build_reverse_specs(model_cls) return envelope diff --git a/src/ferro/models.py b/src/ferro/models.py index 4ada5bc..5a9f06e 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -12,7 +12,7 @@ ) if TYPE_CHECKING: - from .columns import ColumnSpec, RelationSpec + from .columns import ColumnSpec, RelationSpec, ReverseSpec from .query import Predicate, ProjectedQuery, RowSelector from .session import Session @@ -215,6 +215,9 @@ class Model(BaseModel, metaclass=ModelMetaclass): #: alongside ``__ferro_columns__``; ``None`` for a PK-less model. __ferro_pk__: ClassVar[str | None] = None __ferro_relation_specs__: ClassVar[dict[str, "RelationSpec"]] = {} + #: Reverse (BackRef) relation facts for existence tests (#314, ADR-0007), + #: refreshed at the same compile choke point as ``__ferro_relation_specs__``. + __ferro_reverse_specs__: ClassVar[dict[str, "ReverseSpec"]] = {} _enum_fields: ClassVar[dict[str, type[Enum]]] = {} @classmethod diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index b9b4f16..47a64e5 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -39,6 +39,7 @@ QueryNode, QueryProxy, RelationProxy, + ReverseRelationProxy, RowSelector, validate_query_column, ) @@ -78,6 +79,13 @@ def _resolve_where_node(predicate: "Predicate[Any]", model_cls: type) -> QueryNo f"a column (e.g. t.{relation}. == ...) or use == None / " "== an instance to filter by the relation." ) + if isinstance(result, ReverseRelationProxy): + relation = result._name + raise TypeError( + f"where() predicate returned the bare reverse relation " + f"{relation!r}; test membership with t.{relation}.exists() " + f"(negate with ~t.{relation}.exists(), ADR-0007)." + ) if isinstance(result, AggregateExpr): # Comparing an aggregate already raises inside the lambda (#294); a # BARE aggregate returned from where() gets the same having() story. @@ -176,15 +184,27 @@ def _reject_reverse_relation_include(exc: AttributeError) -> None: if not name or model is None: return if isinstance(getattr(model, name, None), RelationshipDescriptor): - raise TypeError( - f"include() cannot populate {name!r}: it is a reverse (BackRef) " - "or many-to-many relation, and include() populates forward " - "foreign-key paths only (e.g. lambda t: t.account). Reverse and " - "M2M population will be a separate mechanism — a batched second " - "query stitched onto the results — not include(). Until it " - f"lands, fetch the collection through the relation itself " - f"(await instance.{name}.all())." - ) from None + raise _reverse_population_error(name) from None + + +def _reverse_population_error(name: str) -> TypeError: + """The pinned include()-cannot-populate-a-reverse-relation error (#287). + + One construction site for the two paths that reach it: a reverse relation + the predicate proxies resolve (a :class:`ReverseRelationProxy` selector + result, #314) and one they don't yet (an M2M name, surfaced as an + ``AttributeError`` and re-raised by + :func:`_reject_reverse_relation_include`). + """ + return TypeError( + f"include() cannot populate {name!r}: it is a reverse (BackRef) " + "or many-to-many relation, and include() populates forward " + "foreign-key paths only (e.g. lambda t: t.account). Reverse and " + "M2M population will be a separate mechanism — a batched second " + "query stitched onto the results — not include(). Until it " + f"lands, fetch the collection through the relation itself " + f"(await instance.{name}.all())." + ) def _resolve_include_selector( @@ -233,6 +253,10 @@ def _resolve_include_selector( "(e.g. `lambda t: t.account`) — every populated hop is a complete " "row, so there is nothing to select per column." ) + if isinstance(result, ReverseRelationProxy): + # The predicate proxies resolve reverse relations now (#314), so the + # population rejection fires here rather than via AttributeError. + raise _reverse_population_error(result._name) if not isinstance(result, RelationProxy): raise TypeError( "include() selector must return a relation path " diff --git a/src/ferro/query/nodes.py b/src/ferro/query/nodes.py index 94a0d25..132467c 100644 --- a/src/ferro/query/nodes.py +++ b/src/ferro/query/nodes.py @@ -3,6 +3,7 @@ import difflib import uuid from collections.abc import Callable +from dataclasses import dataclass from decimal import Decimal from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeAlias, TypeVar @@ -54,6 +55,7 @@ class QueryNode: right: Right child node for compound expressions. is_compound: Flag indicating whether the node combines two child nodes. child: Negated child node for NOT nodes (``~``, ADR-0008). + exists: The existence test's facts for exists nodes (ADR-0007). Examples: >>> active_filter = FieldProxy("active") == True @@ -73,6 +75,7 @@ def __init__( is_compound: bool = False, path: tuple[str, ...] = (), child: "QueryNode | None" = None, + exists: "ExistsTest | None" = None, ): """Initialize a query expression node @@ -89,6 +92,9 @@ def __init__( never produces a non-empty path. child: The negated child for a NOT node (built by ``~``, ADR-0008); ``None`` for leaf and compound nodes. + exists: The correlation hops and inner condition tree of an + existence test (built by ``t.rel.exists()``, ADR-0007); + ``None`` for every other node kind. """ self.column = column self.operator = operator @@ -98,6 +104,7 @@ def __init__( self.is_compound = is_compound self.path = path self.child = child + self.exists = exists def __or__(self, other: "QueryNode") -> "QueryNode": """Combine two nodes with logical OR @@ -161,6 +168,12 @@ def to_ir_dict(self) -> dict[str, Any]: """Serialize the query node tree into a QueryIR payload shape.""" if self.child is not None: return {"node_kind": "not", "child": self.child.to_ir_dict()} + if self.exists is not None: + return { + "node_kind": "exists", + "hops": [hop.to_ir_dict() for hop in self.exists.hops], + "where": [node.to_ir_dict() for node in self.exists.where], + } if not self.is_compound: serialized = _serialize_query_value(self.value) return { @@ -203,6 +216,9 @@ def __repr__(self): """Return a developer-friendly representation of the node""" if self.child is not None: return f"QueryNode(NOT {self.child!r})" + if self.exists is not None: + relation = self.exists.hops[0].relation if self.exists.hops else "?" + return f"QueryNode(EXISTS {relation!r}, where={self.exists.where!r})" if not self.is_compound: return f"QueryNode(column={self.column!r}, operator={self.operator!r}, value={self.value!r})" return ( @@ -223,6 +239,23 @@ def _serialize_query_value(value: Any) -> Any: return value +@dataclass(frozen=True) +class ExistsTest: + """The facts of one existence test (CONTEXT.md, ADR-0007). + + ``hops`` is the correlation hop path in the ``joins``-section hop-fact + shape (``QueryJoinHop`` — typed loosely here because the wire module + imports this one): one hop for a reverse FK, two for M2M. ``where`` is + the ordinary inner condition tree over the related model — empty for a + bare test; nesting and negation come free from :class:`QueryNode` + recursion. There is no negation flag: NOT EXISTS is ``~`` (a ``not`` + node) over the exists node, like every other predicate (ADR-0008). + """ + + hops: tuple[Any, ...] + where: tuple["QueryNode", ...] + + def _query_value_kind(value: Any) -> str: if value is None: return "null" @@ -593,7 +626,9 @@ def __getattr__(self, name: str) -> "FieldProxy[Any]": Relation specs are consulted FIRST (#270): a declared forward-FK field name yields a :class:`RelationProxy` for traversal (``t.account`` → - proxy → ``.ledger_id``); every other name falls through to column + proxy → ``.ledger_id``). A reverse (BackRef) relation name yields a + :class:`ReverseRelationProxy` exposing the existence test and nothing + else (#314, ADR-0007). Every other name falls through to column validation and returns a :class:`FieldProxy`. """ relations = getattr(self._model_cls, "__ferro_relation_specs__", None) or {} @@ -606,6 +641,12 @@ def __getattr__(self, name: str) -> "FieldProxy[Any]": return RelationProxy( # ty: ignore[invalid-return-type] self._model_cls, (name,), spec.target ) + reverse = getattr(self._model_cls, "__ferro_reverse_specs__", None) or {} + rspec = reverse.get(name) + if rspec is not None: + return ReverseRelationProxy( # ty: ignore[invalid-return-type] + self._model_cls, name, rspec + ) validate_query_column(self._model_cls, name) return FieldProxy(name, owner=self._model_cls) @@ -658,6 +699,22 @@ def __getattr__(self, name: str) -> "RelationProxy | FieldProxy[Any]": spec = relations.get(name) if spec is not None: return RelationProxy(self._root_model, self._path + (name,), spec.target) + reverse = getattr(self._target, "__ferro_reverse_specs__", None) or {} + if name in reverse: + # A reverse relation reached through forward traversal + # (`t.account.transactions`) is recognized but has no supported + # predicate form yet: an existence test correlates to the ROOT + # scope only (#314). Fail pointedly rather than fall through to + # the column error's misleading "no queryable column" message. + dotted = ".".join(("t", *self._path, name)) + raise AttributeError( + f"{dotted} names the reverse relation {name!r} on a traversed " + "scope; existence tests are supported on the query root only " + f"(t.{name}.exists() on a {self._target.__name__} query). " + "Reverse relations are tested, not traversed (ADR-0007).", + name=name, + obj=self._target, + ) validate_query_column(self._target, name) return FieldProxy(name, path=self._path, owner=self._target) @@ -769,6 +826,115 @@ def __repr__(self) -> str: return f"RelationProxy(path={joined!r}, target={self._target.__name__!r})" +class ReverseRelationProxy: + """Predicate proxy for a reverse (BackRef) relation (#314, ADR-0007). + + Returned by attribute access on a :class:`QueryProxy` when the accessed + name is a resolved reverse relation. It exposes exactly one verb — the + existence test :meth:`exists` — because reverse relations are *tested*, + never *traversed*: column access, comparisons (including ``!= None`` / + ``== None``), and ``in_`` raise at build time with the supported spelling + in the message. Negation is uniform ``~`` over the returned node + (ADR-0008), so NOT EXISTS is ``~t.rel.exists()``. + """ + + __slots__ = ("_root_model", "_name", "_spec") + + def __init__(self, root_model: type, name: str, spec: Any) -> None: + self._root_model = root_model + self._name = name + self._spec = spec + + def exists(self) -> QueryNode: + """Build the existence test: a correlated EXISTS over the child rows. + + Always a correlated EXISTS at every cardinality (a one-to-one BackRef + renders identically to a to-many one); the result stays root-shaped, + so the node composes with any other predicate, ordering, and paging. + + Returns: + An exists :class:`QueryNode` carrying the one-hop correlation + path (child table, child FK column against the root PK). + + Raises: + ValueError: If the root model declares no primary-key column — + the EXISTS correlates child FK to root PK, so a PK-less root + is a loud error, never a guess. + """ + # Late import: wire.py imports this module (nodes owns the predicate + # shape, wire owns the hop-fact shape). + from .wire import QueryJoinHop + + root_pk = getattr(self._root_model, "__ferro_pk__", None) + if root_pk is None: + raise ValueError( + f"t.{self._name}.exists() requires " + f"{self._root_model.__name__} to declare a primary-key " + "column: the existence test correlates the child's FK " + "against the root primary key." + ) + hop = QueryJoinHop( + relation=self._name, + from_column=root_pk, + to_table=self._spec.target.__ferro_table__, + to_column=self._spec.child_fk_column, + target=self._spec.target, + ) + return QueryNode(exists=ExistsTest(hops=(hop,), where=())) + + def _reject_operator(self, symbol: str) -> NoReturn: + raise TypeError( + f"reverse relation {self._name!r} does not support {symbol}: a " + "reverse relation appears in a predicate only as an existence " + f"test — t.{self._name}.exists(), negated with " + f"~t.{self._name}.exists(). Reverse relations are tested, not " + "traversed (ADR-0007)." + ) + + def __getattr__(self, name: str) -> NoReturn: + raise AttributeError( + f"reverse relation {self._name!r} has no queryable column " + f"{name!r}: reverse relations are tested, not traversed " + f"(ADR-0007). Test membership with t.{self._name}.exists(), " + f"negated with ~t.{self._name}.exists().", + name=name, + obj=self._root_model, + ) + + def __eq__(self, other: object) -> QueryNode: # type: ignore[override] # ty: ignore[invalid-method-override] + return self._reject_operator("== None" if other is None else "==") + + def __ne__(self, other: object) -> QueryNode: # type: ignore[override] # ty: ignore[invalid-method-override] + return self._reject_operator("!= None" if other is None else "!=") + + def __lt__(self, other: object) -> QueryNode: + return self._reject_operator("<") + + def __le__(self, other: object) -> QueryNode: + return self._reject_operator("<=") + + def __gt__(self, other: object) -> QueryNode: + return self._reject_operator(">") + + def __ge__(self, other: object) -> QueryNode: + return self._reject_operator(">=") + + def in_(self, other: object) -> QueryNode: + return self._reject_operator("in_") + + def like(self, other: object) -> QueryNode: + return self._reject_operator("like") + + def __lshift__(self, other: object) -> QueryNode: + return self._reject_operator("<<") + + def __repr__(self) -> str: + return ( + f"ReverseRelationProxy(relation={self._name!r}, " + f"child={self._spec.target.__name__!r})" + ) + + Predicate: TypeAlias = Callable[[QueryProxy[TModel]], QueryNode] """Type alias for lambda predicates accepted by :meth:`Query.where`.""" diff --git a/src/ferro/query/wire.py b/src/ferro/query/wire.py index dad3120..25b1435 100644 --- a/src/ferro/query/wire.py +++ b/src/ferro/query/wire.py @@ -32,13 +32,13 @@ # mutate arm; they stay distinct values so guardrail errors name the caller. QueryVerb = Literal["fetch", "count", "update", "delete"] -# Always 6 (#310 — unconditional bump, exactly like v5 at #292, v4 at #285, -# v3 at #278, and v2 at #269; there is no earlier envelope left anywhere). v6 -# gives predicate trees the recursive ``not`` node kind beside ``leaf`` and -# ``compound`` (uniform negation, ADR-0008). Python and Rust ship in one -# wheel, so a single supported version is the whole contract (#267 -# Implementation Decisions). -_IR_VERSION = 6 +# Always 7 (#314 — unconditional bump, exactly like v6 at #310, v5 at #292, +# v4 at #285, v3 at #278, and v2 at #269; there is no earlier envelope left +# anywhere). v7 gives predicate trees the recursive ``exists`` node kind +# beside ``leaf``/``compound``/``not`` (existence tests, ADR-0007). Python +# and Rust ship in one wheel, so a single supported version is the whole +# contract (#267 Implementation Decisions). +_IR_VERSION = 7 class _AbsentType: @@ -329,10 +329,14 @@ def _where_node_traverses(node: QueryNode) -> bool: Used by the mutate guardrails to reject relation traversal on ``update()``/``delete()``. A join-free shadow-FK leaf (``t.account == instance`` desugars to ``path=()``) is NOT traversal and stays allowed. - A negation traverses iff its child does (``~`` never adds a path). + A negation traverses iff its child does (``~`` never adds a path). An + existence test always counts as traversal: it correlates a subquery to + another table, and mutations stay single-table write shapes (#314). """ if node.child is not None: return _where_node_traverses(node.child) + if node.exists is not None: + return True if node.is_compound: return (node.left is not None and _where_node_traverses(node.left)) or ( node.right is not None and _where_node_traverses(node.right) diff --git a/src/operations.rs b/src/operations.rs index b8a9d90..5cc2fe2 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -243,12 +243,12 @@ fn tx_remove(session_id: Option<&str>, tx_id: &str) -> PyResult String { serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Widget", "where": [{ @@ -4804,10 +4804,10 @@ mod mutation_pagination_guard_tests { mod query_ir_version_gate_tests { use super::query_plan_from_ir_json; - fn v6_envelope() -> serde_json::Value { + fn v7_envelope() -> serde_json::Value { serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Widget", "where": [], @@ -4822,9 +4822,9 @@ mod query_ir_version_gate_tests { } /// Assert a rejected envelope's message names the received version, this - /// build's supported version (6), and the one-wheel fix — the actionable + /// build's supported version (7), and the one-wheel fix — the actionable /// shape pinned since the v1-at-v2 bump (#269), re-pinned at v3 (#278), - /// v4 (#285), v5 (#292), and v6 (#310). + /// v4 (#285), v5 (#292), v6 (#310), and v7 (#314). fn assert_actionable_version_rejection(err: pyo3::PyErr, received: char) { pyo3::Python::attach(|py| { assert!(err.is_instance_of::(py)); @@ -4835,7 +4835,7 @@ mod query_ir_version_gate_tests { "message should name the received version: {msg}" ); assert!( - msg.contains('6'), + msg.contains('7'), "message should name the supported version: {msg}" ); assert!( @@ -4849,9 +4849,47 @@ mod query_ir_version_gate_tests { } #[test] - fn accepts_version_6() { - query_plan_from_ir_json(&v6_envelope().to_string()) - .expect("a well-formed v6 envelope must be accepted"); + fn accepts_version_7() { + query_plan_from_ir_json(&v7_envelope().to_string()) + .expect("a well-formed v7 envelope must be accepted"); + } + + /// Contract test (#314 acceptance criteria): a v6 envelope must be + /// rejected on the version check with the actionable one-wheel message. + /// v7 is a pure widening of the v6 predicate-tree shape (the recursive + /// `exists` node kind joined `leaf`/`compound`/`not`, ADR-0007), so + /// every real v6 payload would still strict-parse — the version gate + /// alone retires it, keeping "exactly one supported version" true rather + /// than silently accepting a stale wheel's envelopes. + #[test] + fn rejects_v6_envelope_with_actionable_message() { + let v6_envelope = serde_json::json!({ + "ir_kind": "query", + "ir_version": 6, + "payload": { + "model_name": "Widget", + "where": [{ + "node_kind": "not", + "child": { + "node_kind": "leaf", + "operator": "==", + "column": "name", + "value": {"kind": "string", "value": "a"}, + "path": [] + } + }], + "order_by": [], + "limit": null, + "offset": null, + "m2m": null, + "joins": [], + "materialization": {"kind": "root_instances"} + } + }); + + let err = query_plan_from_ir_json(&v6_envelope.to_string()) + .expect_err("a v6 envelope must be rejected"); + assert_actionable_version_rejection(err, '6'); } /// Contract test (#310 acceptance criteria): a v5 envelope must be @@ -4999,14 +5037,14 @@ mod query_ir_version_gate_tests { #[test] fn rejects_unsupported_future_version() { - let mut envelope = v6_envelope(); - envelope["ir_version"] = serde_json::json!(7); + let mut envelope = v7_envelope(); + envelope["ir_version"] = serde_json::json!(8); let err = query_plan_from_ir_json(&envelope.to_string()) .expect_err("an unsupported future version must be rejected"); let msg = err.to_string(); assert!( - msg.contains('7'), + msg.contains('8'), "message should name the received version: {msg}" ); } @@ -5015,7 +5053,7 @@ mod query_ir_version_gate_tests { /// naming the bad kind and the supported ones (#278). #[test] fn rejects_unknown_materialization_kind() { - let mut envelope = v6_envelope(); + let mut envelope = v7_envelope(); envelope["payload"]["materialization"] = serde_json::json!({"kind": "row_dicts"}); let err = query_plan_from_ir_json(&envelope.to_string()) @@ -5028,18 +5066,18 @@ mod query_ir_version_gate_tests { ); } - /// A v6 payload with NO materialization section fails the strict parse: + /// A v7 payload with NO materialization section fails the strict parse: /// the plan travels with the query as data (ADR-0007), never defaulted. #[test] - fn rejects_v6_payload_missing_materialization() { - let mut envelope = v6_envelope(); + fn rejects_v7_payload_missing_materialization() { + let mut envelope = v7_envelope(); envelope["payload"] .as_object_mut() .unwrap() .remove("materialization"); let err = query_plan_from_ir_json(&envelope.to_string()) - .expect_err("a v6 payload without a materialization section must be rejected"); + .expect_err("a v7 payload without a materialization section must be rejected"); let msg = err.to_string(); assert!( msg.contains("materialization"), @@ -5059,7 +5097,7 @@ mod materialization_walker_gate_tests { query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Widget", "where": [], @@ -5151,7 +5189,7 @@ mod record_select_list_tests { query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": where_nodes, @@ -5622,7 +5660,7 @@ mod instances_select_list_tests { let mut plan = query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [], "order_by": [], "limit": null, "offset": null, @@ -5784,7 +5822,7 @@ mod mutation_qualification_tests { query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Widget", "where": [{ @@ -5870,7 +5908,7 @@ mod select_join_render_tests { query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [{ @@ -5976,7 +6014,7 @@ mod select_join_render_tests { query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": model_name, "where": [], "order_by": [], @@ -5998,7 +6036,7 @@ mod select_join_render_tests { let plan = query_plan_from_ir_json( &serde_json::json!({ "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [{ diff --git a/src/query.rs b/src/query.rs index 9573e26..379e0f4 100644 --- a/src/query.rs +++ b/src/query.rs @@ -6,7 +6,7 @@ use crate::state::Dialect; use ferro_schema_ir::{Materialization, QueryIrPayload, QueryJoin, QueryNode, QueryOrderBy}; -use sea_query::{Alias, Condition, Expr, SimpleExpr}; +use sea_query::{Alias, Condition, Expr, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -34,6 +34,13 @@ fn reject_non_empty_leaf_path(node: &QueryNode) -> Result<(), String> { reject_non_empty_leaf_path(right) } QueryNode::Not { child } => reject_non_empty_leaf_path(child), + // An existence test correlates a subquery to another table — mutations + // stay single-table write shapes, and the Python guard already rejects + // this at build time (#314); failing loud here is boundary defense. + QueryNode::Exists { hops, .. } => Err(format!( + "WHERE carries an existence test over relation {:?}", + hops.first().map(|h| h.relation.as_str()).unwrap_or("?") + )), } } @@ -56,6 +63,11 @@ enum ColumnQualifier<'a> { root_table: &'a str, join_plan: &'a JoinPlan, }, + /// Inside an EXISTS subquery (#314, ADR-0007): an empty-path leaf + /// qualifies by the subquery scope's `alias` and binds against `table`'s + /// model. The variant owns its strings — subquery aliases are minted + /// during the render walk, not borrowed from the plan. + ExistsScope { alias: String, table: String }, } /// Qualify a column reference (a WHERE leaf or an `ORDER BY` term) by its @@ -209,6 +221,14 @@ impl JoinPlanBuilder { } } +/// Empty native-enum catalog for EXISTS-scope leaves whose table has no +/// probed catalog yet (#314) — the `&HashMap` return shape needs a `'static` +/// fallback. +fn empty_enum_udt() -> &'static HashMap { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + EMPTY.get_or_init(HashMap::new) +} + /// Validate a join_type token from the IR (`"inner"`/`"left"`), erroring loudly /// on anything else so an unknown type can never silently mis-render. fn validate_join_type(join_type: &str) -> Result<&'static str, String> { @@ -266,6 +286,16 @@ fn qualify_leaf_column( })?; Ok(Expr::col((Alias::new(alias.as_str()), Alias::new(column)))) } + ColumnQualifier::ExistsScope { alias, .. } => { + if !path.is_empty() { + return Err(format!( + "WHERE column {column:?} carries relation path {path:?} \ + inside an existence test; forward traversal inside the \ + subquery is not rendered yet (#315)" + )); + } + Ok(Expr::col((Alias::new(alias.as_str()), Alias::new(column)))) + } } } @@ -523,8 +553,18 @@ impl QueryPlan { qualifier: &ColumnQualifier<'_>, ) -> Result { let mut condition = Condition::all(); + // Statement-wide EXISTS subquery alias counter (#314): every exists + // node rendered anywhere in this WHERE tree mints globally unique + // `x{n}_{relation}` aliases, so nested tests and repeated tests over + // the same table can never collide with each other or the outer scope. + let mut exists_counter = 0usize; for node in &self.where_clause { - condition = condition.add(self.node_to_condition_for_backend(node, backend, qualifier)?); + condition = condition.add(self.node_to_condition_for_backend( + node, + backend, + qualifier, + &mut exists_counter, + )?); } Ok(condition) } @@ -534,6 +574,7 @@ impl QueryPlan { node: &QueryNode, backend: Dialect, qualifier: &ColumnQualifier<'_>, + exists_counter: &mut usize, ) -> Result { match node { QueryNode::Compound { @@ -541,8 +582,10 @@ impl QueryPlan { left, right, } => { - let left_cond = self.node_to_condition_for_backend(left, backend, qualifier)?; - let right_cond = self.node_to_condition_for_backend(right, backend, qualifier)?; + let left_cond = + self.node_to_condition_for_backend(left, backend, qualifier, exists_counter)?; + let right_cond = + self.node_to_condition_for_backend(right, backend, qualifier, exists_counter)?; Ok(match operator.as_str() { "OR" => Condition::any().add(left_cond).add(right_cond), "AND" => Condition::all().add(left_cond).add(right_cond), @@ -553,9 +596,69 @@ impl QueryPlan { // Uniform negation (ADR-0008): a faithful SQL `NOT (...)` over // the rebuilt child — no operator rewriting, no De Morgan. Ok(self - .node_to_condition_for_backend(child, backend, qualifier)? + .node_to_condition_for_backend(child, backend, qualifier, exists_counter)? .not()) } + QueryNode::Exists { hops, where_clause } => { + // Existence test (#314, ADR-0007): one render loop for every + // hop count. The first hop's table is the subquery FROM, + // correlated to the enclosing scope's alias; remaining hops + // (M2M target) render as inner joins inside the subquery; the + // inner tree recurses through this same builder scoped to the + // last hop. NOT EXISTS arrives as the `not` node above. + let enclosing: &str = match qualifier { + ColumnQualifier::RootTable(table) => table, + ColumnQualifier::Joined { root_table, .. } => root_table, + ColumnQualifier::ExistsScope { alias, .. } => alias.as_str(), + ColumnQualifier::Unqualified => { + return Err("an existence test requires a table-qualified enclosing \ + scope to correlate against" + .to_string()); + } + }; + let first = hops + .first() + .ok_or_else(|| "existence test carries no correlation hops".to_string())?; + *exists_counter += 1; + let sub_alias = format!("x{}_{}", exists_counter, first.relation); + let mut subquery = sea_query::Query::select(); + subquery + .expr(Expr::val(1)) + .from_as(Alias::new(&first.to_table), Alias::new(&sub_alias)); + let mut inner = Condition::all().add( + Expr::col((Alias::new(&sub_alias), Alias::new(&first.to_column))) + .equals((Alias::new(enclosing), Alias::new(&first.from_column))), + ); + let mut last_alias = sub_alias; + let mut last_table = first.to_table.clone(); + for hop in &hops[1..] { + *exists_counter += 1; + let hop_alias = format!("x{}_{}", exists_counter, hop.relation); + subquery.join_as( + JoinType::InnerJoin, + Alias::new(&hop.to_table), + Alias::new(&hop_alias), + Expr::col((Alias::new(&last_alias), Alias::new(&hop.from_column))) + .equals((Alias::new(&hop_alias), Alias::new(&hop.to_column))), + ); + last_alias = hop_alias; + last_table = hop.to_table.clone(); + } + let inner_scope = ColumnQualifier::ExistsScope { + alias: last_alias, + table: last_table, + }; + for node in where_clause { + inner = inner.add(self.node_to_condition_for_backend( + node, + backend, + &inner_scope, + exists_counter, + )?); + } + subquery.cond_where(inner); + Ok(Condition::all().add(Expr::exists(subquery))) + } QueryNode::Leaf { operator, column, @@ -688,6 +791,25 @@ impl QueryPlan { qualifier: &ColumnQualifier<'_>, path: &[String], ) -> Result<(Option<&crate::codec_plan::ModelCodecPlan>, &HashMap), String> { + // Inside an EXISTS subquery (#314) an empty-path leaf belongs to the + // subquery scope's table, NEVER the root model — resolve before the + // root early-return below. Registration is optional here (mirroring + // the root's own `Option`): an inner leaf on an unregistered/ + // unprobed table degrades to generic binds, exactly like an + // unregistered root model. The walkers populate both maps for scoped + // tests (#315). + if let ColumnQualifier::ExistsScope { table, .. } = qualifier { + if !path.is_empty() { + return Err(format!( + "relation path {path:?} inside an existence test is not \ + rendered yet (#315); cannot resolve typed binds" + )); + } + return Ok(( + self.hop_registrations.get(table).map(|m| &m.codec_plan), + self.hop_enum_udt.get(table).unwrap_or(empty_enum_udt()), + )); + } if path.is_empty() { return Ok(( self.registration.as_ref().map(|m| &m.codec_plan), @@ -884,6 +1006,131 @@ mod tests { ); } + #[test] + fn exists_node_renders_correlated_exists_subquery() { + // Existence test (#314, ADR-0007): a 1-hop `exists` node lowers to a + // correlated `EXISTS (SELECT 1 FROM child AS x1_rel WHERE + // x1_rel.fk = root.pk)` against the enclosing scope's alias. + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Txn", + "where": [ + {"node_kind": "exists", + "hops": [{"relation": "transfer_out", "from_column": "id", + "to_table": "transfer", "to_column": "outflow_transaction_id"}], + "where": []} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let mut select = Query::select(); + select.from(Alias::new("txn")).cond_where( + plan.to_condition_for_backend(Dialect::Sqlite, Some("txn")) + .expect("valid test query"), + ); + let sql = select.to_string(SqliteQueryBuilder).to_lowercase(); + assert!( + sql.contains("exists(select 1 from \"transfer\" as \"x1_transfer_out\""), + "expected an EXISTS(SELECT 1 ...) subquery, got {sql}" + ); + assert!( + sql.contains("\"x1_transfer_out\".\"outflow_transaction_id\" = \"txn\".\"id\""), + "subquery must correlate the child FK to the enclosing alias: {sql}" + ); + } + + #[test] + fn not_over_exists_renders_not_exists() { + // NOT EXISTS is the ordinary `not` node over an `exists` node — the + // exists node carries no negation flag (ADR-0008 composition). + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Txn", + "where": [ + {"node_kind": "not", "child": + {"node_kind": "exists", + "hops": [{"relation": "lines", "from_column": "id", + "to_table": "split_line", "to_column": "transaction_id"}], + "where": []}} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let mut select = Query::select(); + select.from(Alias::new("txn")).cond_where( + plan.to_condition_for_backend(Dialect::Sqlite, Some("txn")) + .expect("valid test query"), + ); + let sql = select.to_string(SqliteQueryBuilder).to_lowercase(); + assert!( + sql.contains("not exists(select 1 from \"split_line\""), + "expected NOT EXISTS over the subquery, got {sql}" + ); + } + + #[test] + fn sibling_exists_nodes_mint_distinct_subquery_aliases() { + // The #307 OR shape — membership via either FK column — renders two + // independent subqueries whose aliases cannot collide even though + // both correlate to the same root. + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Txn", + "where": [ + {"node_kind": "compound", "operator": "OR", + "left": {"node_kind": "exists", + "hops": [{"relation": "transfer_out", "from_column": "id", + "to_table": "transfer", "to_column": "outflow_transaction_id"}], + "where": []}, + "right": {"node_kind": "exists", + "hops": [{"relation": "transfer_in", "from_column": "id", + "to_table": "transfer", "to_column": "inflow_transaction_id"}], + "where": []}} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let mut select = Query::select(); + select.from(Alias::new("txn")).cond_where( + plan.to_condition_for_backend(Dialect::Postgres, Some("txn")) + .expect("valid test query"), + ); + let sql = select.to_string(PostgresQueryBuilder).to_lowercase(); + assert!(sql.contains("\"x1_transfer_out\""), "first alias: {sql}"); + assert!(sql.contains("\"x2_transfer_in\""), "second alias: {sql}"); + assert!(sql.contains(" or "), "OR composition preserved: {sql}"); + } + + #[test] + fn exists_requires_a_qualified_enclosing_scope() { + // Correlation needs an alias to correlate against; the unqualified + // unit-test path fails loudly rather than emitting an uncorrelated + // (always-true) subquery. + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Txn", + "where": [ + {"node_kind": "exists", + "hops": [{"relation": "lines", "from_column": "id", + "to_table": "split_line", "to_column": "transaction_id"}], + "where": []} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let err = plan + .to_condition_for_backend(Dialect::Sqlite, None) + .expect_err("unqualified scope must be rejected"); + assert!( + err.contains("existence test"), + "error names the existence test: {err}" + ); + } + #[test] fn double_not_renders_as_the_plain_child() { // `~~p` reaches Rust as two nested `not` wire nodes; SeaQuery's diff --git a/tests/fixtures/ir_vectors/README.md b/tests/fixtures/ir_vectors/README.md index 3e9a1ab..bfa3ab4 100644 --- a/tests/fixtures/ir_vectors/README.md +++ b/tests/fixtures/ir_vectors/README.md @@ -28,10 +28,13 @@ Rules: - `domain` and `ir.ir_kind` must match. - `ir.ir_version` must equal `1` for `schema` and `codec` vectors. `query` - vectors are on `ir_version: 6` (#310 — unconditional bump, exactly like v5 - at #292; predicate trees gain the recursive `not` node kind beside `leaf` - and `compound` (ADR-0008): `{"node_kind": "not", "child": {...}}`, any - child, any depth); there is no earlier `query` vector left. + vectors are on `ir_version: 7` (#314 — unconditional bump, exactly like v6 + at #310; predicate trees gain the recursive `exists` node kind beside + `leaf`/`compound`/`not` (existence tests, ADR-0007): + `{"node_kind": "exists", "hops": [...], "where": [...]}` — `hops` is the + correlation hop path in the `joins`-section hop shape (1 hop reverse FK, + 2 hops M2M), `where` the ordinary inner condition tree, `[]` = bare test); + there is no earlier `query` vector left. - `expect_valid` currently supports only `true` fixtures (negative vectors can be added later). - Fixture file names use `__v.json` (matching that domain's current `ir_version`). diff --git a/tests/fixtures/ir_vectors/query_account_exists_v7.json b/tests/fixtures/ir_vectors/query_account_exists_v7.json new file mode 100644 index 0000000..df5c995 --- /dev/null +++ b/tests/fixtures/ir_vectors/query_account_exists_v7.json @@ -0,0 +1,40 @@ +{ + "vector_name": "query_account_exists_v7", + "domain": "query", + "expect_valid": true, + "ir": { + "ir_kind": "query", + "ir_version": 7, + "payload": { + "model_name": "Account", + "where": [ + { + "node_kind": "exists", + "hops": [ + { + "relation": "transactions", + "from_column": "id", + "to_table": "transaction", + "to_column": "account_id" + } + ], + "where": [] + } + ], + "order_by": [ + { + "column": "id", + "direction": "asc", + "path": [] + } + ], + "limit": null, + "offset": null, + "m2m": null, + "joins": [], + "materialization": { + "kind": "root_instances" + } + } + } +} diff --git a/tests/fixtures/ir_vectors/query_owner_not_exists_v7.json b/tests/fixtures/ir_vectors/query_owner_not_exists_v7.json new file mode 100644 index 0000000..2fe75e4 --- /dev/null +++ b/tests/fixtures/ir_vectors/query_owner_not_exists_v7.json @@ -0,0 +1,37 @@ +{ + "vector_name": "query_owner_not_exists_v7", + "domain": "query", + "expect_valid": true, + "ir": { + "ir_kind": "query", + "ir_version": 7, + "payload": { + "model_name": "Owner", + "where": [ + { + "node_kind": "not", + "child": { + "node_kind": "exists", + "hops": [ + { + "relation": "accounts", + "from_column": "id", + "to_table": "account", + "to_column": "owner_id" + } + ], + "where": [] + } + } + ], + "order_by": [], + "limit": null, + "offset": null, + "m2m": null, + "joins": [], + "materialization": { + "kind": "root_instances" + } + } + } +} diff --git a/tests/fixtures/ir_vectors/query_transaction_aggregate_v6.json b/tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json similarity index 95% rename from tests/fixtures/ir_vectors/query_transaction_aggregate_v6.json rename to tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json index 8a7b474..8b5d697 100644 --- a/tests/fixtures/ir_vectors/query_transaction_aggregate_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_aggregate_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_aggregate_v6", + "vector_name": "query_transaction_aggregate_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v6.json b/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json similarity index 95% rename from tests/fixtures/ir_vectors/query_transaction_global_aggregate_v6.json rename to tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json index d855276..5a02a51 100644 --- a/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_global_aggregate_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_global_aggregate_v6", + "vector_name": "query_transaction_global_aggregate_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_include_v6.json b/tests/fixtures/ir_vectors/query_transaction_include_v7.json similarity index 92% rename from tests/fixtures/ir_vectors/query_transaction_include_v6.json rename to tests/fixtures/ir_vectors/query_transaction_include_v7.json index 9857f51..4a39164 100644 --- a/tests/fixtures/ir_vectors/query_transaction_include_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_include_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_include_v6", + "vector_name": "query_transaction_include_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_left_join_v6.json b/tests/fixtures/ir_vectors/query_transaction_left_join_v7.json similarity index 94% rename from tests/fixtures/ir_vectors/query_transaction_left_join_v6.json rename to tests/fixtures/ir_vectors/query_transaction_left_join_v7.json index 40e7814..690759f 100644 --- a/tests/fixtures/ir_vectors/query_transaction_left_join_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_left_join_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_left_join_v6", + "vector_name": "query_transaction_left_join_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_record_v6.json b/tests/fixtures/ir_vectors/query_transaction_record_v7.json similarity index 92% rename from tests/fixtures/ir_vectors/query_transaction_record_v6.json rename to tests/fixtures/ir_vectors/query_transaction_record_v7.json index 2f55727..ad78a8a 100644 --- a/tests/fixtures/ir_vectors/query_transaction_record_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_record_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_record_v6", + "vector_name": "query_transaction_record_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_traversal_v6.json b/tests/fixtures/ir_vectors/query_transaction_traversal_v7.json similarity index 96% rename from tests/fixtures/ir_vectors/query_transaction_traversal_v6.json rename to tests/fixtures/ir_vectors/query_transaction_traversal_v7.json index f1a690b..9b523c1 100644 --- a/tests/fixtures/ir_vectors/query_transaction_traversal_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_traversal_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_traversal_v6", + "vector_name": "query_transaction_traversal_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_transaction_traversed_record_v6.json b/tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json similarity index 95% rename from tests/fixtures/ir_vectors/query_transaction_traversed_record_v6.json rename to tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json index 6be5b3b..91d67c5 100644 --- a/tests/fixtures/ir_vectors/query_transaction_traversed_record_v6.json +++ b/tests/fixtures/ir_vectors/query_transaction_traversed_record_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_transaction_traversed_record_v6", + "vector_name": "query_transaction_traversed_record_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "Transaction", "where": [ diff --git a/tests/fixtures/ir_vectors/query_user_compound_v6.json b/tests/fixtures/ir_vectors/query_user_compound_v7.json similarity index 95% rename from tests/fixtures/ir_vectors/query_user_compound_v6.json rename to tests/fixtures/ir_vectors/query_user_compound_v7.json index 2343d5f..b3f0239 100644 --- a/tests/fixtures/ir_vectors/query_user_compound_v6.json +++ b/tests/fixtures/ir_vectors/query_user_compound_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_user_compound_v6", + "vector_name": "query_user_compound_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "User", "where": [ diff --git a/tests/fixtures/ir_vectors/query_user_not_compound_v6.json b/tests/fixtures/ir_vectors/query_user_not_compound_v7.json similarity index 93% rename from tests/fixtures/ir_vectors/query_user_not_compound_v6.json rename to tests/fixtures/ir_vectors/query_user_not_compound_v7.json index 8fbd630..c6cfe17 100644 --- a/tests/fixtures/ir_vectors/query_user_not_compound_v6.json +++ b/tests/fixtures/ir_vectors/query_user_not_compound_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_user_not_compound_v6", + "vector_name": "query_user_not_compound_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "User", "where": [ diff --git a/tests/fixtures/ir_vectors/query_user_not_leaf_v6.json b/tests/fixtures/ir_vectors/query_user_not_leaf_v7.json similarity index 92% rename from tests/fixtures/ir_vectors/query_user_not_leaf_v6.json rename to tests/fixtures/ir_vectors/query_user_not_leaf_v7.json index 02a6043..9006dcd 100644 --- a/tests/fixtures/ir_vectors/query_user_not_leaf_v6.json +++ b/tests/fixtures/ir_vectors/query_user_not_leaf_v7.json @@ -1,10 +1,10 @@ { - "vector_name": "query_user_not_leaf_v6", + "vector_name": "query_user_not_leaf_v7", "domain": "query", "expect_valid": true, "ir": { "ir_kind": "query", - "ir_version": 6, + "ir_version": 7, "payload": { "model_name": "User", "where": [ diff --git a/tests/test_ir_vectors_contract.py b/tests/test_ir_vectors_contract.py index 7637015..a74bfb8 100644 --- a/tests/test_ir_vectors_contract.py +++ b/tests/test_ir_vectors_contract.py @@ -11,10 +11,10 @@ VECTORS_DIR = Path(__file__).parent / "fixtures" / "ir_vectors" SUPPORTED_DOMAINS = {"schema", "query", "codec"} -# `query` is on ir_version 6 (#310 — unconditional bump; predicate trees -# gain the recursive `not` node kind beside `leaf`/`compound`, ADR-0008); -# `schema`/`codec` remain v1. -SUPPORTED_IR_VERSIONS = {"schema": 1, "query": 6, "codec": 1} +# `query` is on ir_version 7 (#314 — unconditional bump; predicate trees +# gain the recursive `exists` node kind beside `leaf`/`compound`/`not` +# (existence tests, ADR-0007)); `schema`/`codec` remain v1. +SUPPORTED_IR_VERSIONS = {"schema": 1, "query": 7, "codec": 1} QUERY_OPERATORS = {"==", "!=", "<", "<=", ">", ">=", "IN", "LIKE", "AND", "OR"} MATERIALIZATION_KINDS = {"root_instances", "record", "instances"} AGGREGATE_FNS = {"count", "sum", "avg", "min", "max"} @@ -40,7 +40,7 @@ def _require_keys(obj: dict[str, Any], required: set[str], label: str) -> None: def _validate_query_node(node: dict[str, Any], label: str) -> None: _require_keys(node, {"node_kind"}, label) node_kind = node["node_kind"] - assert node_kind in {"leaf", "compound", "not"}, ( + assert node_kind in {"leaf", "compound", "not", "exists"}, ( f"{label}.node_kind invalid: {node_kind!r}" ) @@ -52,6 +52,25 @@ def _validate_query_node(node: dict[str, Any], label: str) -> None: _validate_query_node(node["child"], f"{label}.child") return + if node_kind == "exists": + # v7 (ADR-0007): an existence test carries a correlation hop path (1 + # hop for a reverse FK, 2 for M2M — the `joins`-section hop shape) and + # an ordinary inner condition tree (empty = bare test). No negation + # flag: NOT EXISTS is the `not` node over this one. + _require_keys(node, {"hops", "where"}, label) + hops = node["hops"] + assert isinstance(hops, list) and hops, ( + f"{label}.hops must be a non-empty list" + ) + for h, hop in enumerate(hops): + _validate_hop(hop, f"{label}.hops[{h}]") + inner = node["where"] + assert isinstance(inner, list), f"{label}.where must be a list" + for i, child in enumerate(inner): + assert isinstance(child, dict), f"{label}.where[{i}] must be object" + _validate_query_node(child, f"{label}.where[{i}]") + return + _require_keys(node, {"operator"}, label) operator = node["operator"] assert operator in QUERY_OPERATORS, f"{label}.operator invalid: {operator!r}" diff --git a/tests/test_query_builder.py b/tests/test_query_builder.py index f201c54..c496208 100644 --- a/tests/test_query_builder.py +++ b/tests/test_query_builder.py @@ -67,7 +67,7 @@ class WireM2mPost(Model): assert query._m2m_context.source_id == source_id assert isinstance(query._m2m_context.source_id, uuid.UUID) assert payload["ir_kind"] == "query" - assert payload["ir_version"] == 6 + assert payload["ir_version"] == 7 assert payload["payload"]["m2m"]["source_id"] == str(source_id) diff --git a/tests/test_query_exists.py b/tests/test_query_exists.py new file mode 100644 index 0000000..ca9c03a --- /dev/null +++ b/tests/test_query_exists.py @@ -0,0 +1,218 @@ +"""End-to-end behavior of existence tests on reverse relations (#314, ADR-0007). + +A reverse (BackRef) relation appears in a predicate in exactly one form — the +existence test ``t.rel.exists()`` — rendered as a correlated EXISTS at every +cardinality (one-to-one BackRefs included), negated with ``~``. These tests +assert result sets only, on both database backends via the backend matrix; +the wire shape is pinned separately by the ``exists`` golden vectors. + +The model graph mirrors the #307 workload: a transaction with two one-to-one +BackRefs into a transfer link row (membership via either FK column) plus a +to-many BackRef onto split lines. +""" + +import pytest +from typing import Annotated + +from ferro import BackRef, FerroField, ForeignKey, Model, Relation, connect, engines + +pytestmark = pytest.mark.backend_matrix + + +class ExTxn(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + amount: int = 0 + transfer_out: "ExTransfer" = BackRef() + transfer_in: "ExTransfer" = BackRef() + lines: Relation[list["ExLine"]] = BackRef() + + +class ExTransfer(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + outflow_transaction: Annotated[ + ExTxn | None, ForeignKey(related_name="transfer_out", unique=True) + ] = None + inflow_transaction: Annotated[ + ExTxn | None, ForeignKey(related_name="transfer_in", unique=True) + ] = None + + +class ExLine(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + txn: Annotated[ExTxn, ForeignKey(related_name="lines", on_delete="CASCADE")] + category: str = "" + + +async def _seed_transfers() -> dict[str, ExTxn]: + """Four transactions: out is the outflow of a transfer, in the inflow of + another (untracked counterparties — one FK set per transfer), and two + plain transactions with no transfer membership at all.""" + out = await ExTxn.create(amount=-100) + inn = await ExTxn.create(amount=100) + plain_a = await ExTxn.create(amount=-20) + plain_b = await ExTxn.create(amount=-30) + await ExTransfer.create(outflow_transaction=out) + await ExTransfer.create(inflow_transaction=inn) + return {"out": out, "in": inn, "plain_a": plain_a, "plain_b": plain_b} + + +async def _amounts(query) -> list[int]: + return sorted(r.amount for r in await query.all()) + + +@pytest.mark.asyncio +async def test_bare_exists_on_one_to_one_backref(db_url): + """``t.transfer_out.exists()`` matches exactly the rows with a child row — + a one-to-one BackRef spells identically to a to-many one.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + await _seed_transfers() + + rows = await _amounts(ExTxn.where(lambda t: t.transfer_out.exists())) + assert rows == [-100] + + +@pytest.mark.asyncio +async def test_is_transfer_true_branch(db_url): + """The #307 ``is_transfer=true`` filter: membership via EITHER FK column, + each matching root exactly once.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + await _seed_transfers() + + rows = await _amounts( + ExTxn.where(lambda t: t.transfer_out.exists() | t.transfer_in.exists()) + ) + assert rows == [-100, 100] + + +@pytest.mark.asyncio +async def test_is_transfer_false_branch(db_url): + """The #307 ``is_transfer=false`` filter: ``~`` renders NOT EXISTS and the + conjunction keeps only transfer-free transactions.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + await _seed_transfers() + + rows = await _amounts( + ExTxn.where(lambda t: ~t.transfer_out.exists() & ~t.transfer_in.exists()) + ) + assert rows == [-30, -20] + + +@pytest.mark.asyncio +async def test_to_many_exists_returns_each_root_once(db_url): + """A transaction with three lines matches ``t.lines.exists()`` exactly + once — the result stays root-shaped (no DISTINCT bookkeeping).""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + split = await ExTxn.create(amount=-70) + for _ in range(3): + await ExLine.create(txn=split, category="groceries") + await ExTxn.create(amount=-10) + + results = await ExTxn.where(lambda t: t.lines.exists()).all() + assert [r.amount for r in results] == [-70] + + +@pytest.mark.asyncio +async def test_exists_composes_with_root_predicates_order_and_limit(db_url): + """An existence test is an ordinary predicate: it AND-composes with root + comparisons and leaves keyset ``order_by`` + ``limit`` untouched.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + for amount in (-40, -50, -60): + txn = await ExTxn.create(amount=amount) + await ExLine.create(txn=txn, category="travel") + await ExTxn.create(amount=-80) # child-less + + results = ( + await ExTxn.where(lambda t: t.lines.exists() & (t.amount <= -50)) + .order_by("amount", "desc") + .limit(1) + .all() + ) + assert [r.amount for r in results] == [-50] + + +@pytest.mark.asyncio +async def test_count_with_exists(db_url): + """``count()`` sees the same membership as ``all()``.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + await _seed_transfers() + + n = await ExTxn.where( + lambda t: t.transfer_out.exists() | t.transfer_in.exists() + ).count() + assert n == 2 + + +# --------------------------------------------------------------------------- +# Error surfaces (#314): the reverse proxy exposes .exists() and nothing else. +# Reverse relations are tested, not traversed (ADR-0007); every dead end names +# the supported spelling. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_reverse_column_access_raises_naming_exists(db_url): + await connect(db_url, auto_migrate=True) + with pytest.raises(AttributeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.lines.category == "groceries") + + +@pytest.mark.asyncio +async def test_reverse_none_comparison_raises_naming_exists(db_url): + """The #307 repro's first guess, ``t.transfer_out != None``, teaches the + supported spelling instead of failing opaquely.""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.transfer_out != None) # noqa: E711 + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.transfer_out == None) # noqa: E711 + + +@pytest.mark.asyncio +async def test_reverse_ordering_comparisons_raise_naming_exists(db_url): + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.lines < 5) + + +@pytest.mark.asyncio +async def test_reverse_in_raises_naming_exists(db_url): + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.lines.in_([1, 2])) + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.lines << [1, 2]) + + +@pytest.mark.asyncio +async def test_bare_reverse_proxy_in_where_raises_naming_exists(db_url): + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match=r"\.exists\(\)"): + ExTxn.where(lambda t: t.lines) + + +@pytest.mark.asyncio +async def test_include_rejection_survives_reverse_proxy_resolution(db_url): + """The pinned include() population rejection (#287) is unchanged now that + predicate proxies resolve reverse relations: population and membership + stay distinct axes (ADR-0007).""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match="reverse .BackRef. or many-to-many"): + ExTxn.select().include(lambda t: t.lines) + + +@pytest.mark.asyncio +async def test_exists_rejected_on_mutating_verbs(db_url): + """UPDATE/DELETE stay single-table write shapes: an existence test in the + predicate is rejected at build time, before any DB round-trip.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + with pytest.raises(ValueError, match="update"): + await ExTxn.where(lambda t: t.lines.exists()).update(amount=0) + with pytest.raises(ValueError, match="delete"): + await ExTxn.where(lambda t: t.lines.exists()).delete() diff --git a/tests/test_query_wire_vectors.py b/tests/test_query_wire_vectors.py index 088607a..25485a2 100644 --- a/tests/test_query_wire_vectors.py +++ b/tests/test_query_wire_vectors.py @@ -212,17 +212,32 @@ def _q_not_compound(m: dict[str, type]) -> Any: ) +def _q_exists_bare(m: dict[str, type]) -> Any: + # Bare 1-hop existence test on a reverse FK (#314, ADR-0007): one `exists` + # node whose hop correlates the child's shadow FK to the root PK; the + # inner condition tree is empty and no `joins` entry is registered. + return m["Account"].where(lambda a: a.transactions.exists()).order_by("id") + + +def _q_not_exists(m: dict[str, type]) -> Any: + # NOT EXISTS is the ordinary `not` node over an `exists` node — the + # exists node carries no negation flag (ADR-0008 composition). + return m["Owner"].where(lambda o: ~o.accounts.exists()) + + CASES: list[tuple[str, Callable[[dict[str, type]], Any], str]] = [ - ("query_user_compound_v6", _q_user_compound, "User"), - ("query_user_not_leaf_v6", _q_not_leaf, "User"), - ("query_user_not_compound_v6", _q_not_compound, "User"), - ("query_transaction_traversal_v6", _q_traversal, "Transaction"), - ("query_transaction_left_join_v6", _q_left_join, "Transaction"), - ("query_transaction_include_v6", _q_include, "Transaction"), - ("query_transaction_record_v6", _q_record, "Transaction"), - ("query_transaction_traversed_record_v6", _q_traversed_record, "Transaction"), - ("query_transaction_aggregate_v6", _q_aggregate, "Transaction"), - ("query_transaction_global_aggregate_v6", _q_global_aggregate, "Transaction"), + ("query_user_compound_v7", _q_user_compound, "User"), + ("query_user_not_leaf_v7", _q_not_leaf, "User"), + ("query_user_not_compound_v7", _q_not_compound, "User"), + ("query_account_exists_v7", _q_exists_bare, "Account"), + ("query_owner_not_exists_v7", _q_not_exists, "Owner"), + ("query_transaction_traversal_v7", _q_traversal, "Transaction"), + ("query_transaction_left_join_v7", _q_left_join, "Transaction"), + ("query_transaction_include_v7", _q_include, "Transaction"), + ("query_transaction_record_v7", _q_record, "Transaction"), + ("query_transaction_traversed_record_v7", _q_traversed_record, "Transaction"), + ("query_transaction_aggregate_v7", _q_aggregate, "Transaction"), + ("query_transaction_global_aggregate_v7", _q_global_aggregate, "Transaction"), ] @@ -303,4 +318,4 @@ def test_mutate_payload_omits_pagination_keys(models: dict[str, type]) -> None: def test_envelope_is_versioned(models: dict[str, type]) -> None: envelope = json.loads(compile_query(models["User"].select(), "fetch").wire_json) assert envelope["ir_kind"] == "query" - assert envelope["ir_version"] == 6 + assert envelope["ir_version"] == 7 From 0ac4cb5df2b19de3904c27bb0a4b247602cb0374 Mon Sep 17 00:00:00 2001 From: Taylor Date: Sat, 18 Jul 2026 11:14:40 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(query):=20scoped=20existence=20tests?= =?UTF-8?q?=20=E2=80=94=20inner=20predicates,=20nesting,=20cross-scope=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ferro-schema-ir/src/lib.rs | 75 ++++- src/ferro/query/nodes.py | 257 ++++++++++++-- src/operations.rs | 49 ++- src/query.rs | 226 +++++++++++-- .../query_account_scoped_exists_v7.json | 74 ++++ .../query_owner_nested_exists_v7.json | 47 +++ tests/test_ir_vectors_contract.py | 23 +- tests/test_query_exists.py | 316 +++++++++++++++++- tests/test_query_wire_vectors.py | 23 ++ 9 files changed, 1022 insertions(+), 68 deletions(-) create mode 100644 tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json create mode 100644 tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json diff --git a/crates/ferro-schema-ir/src/lib.rs b/crates/ferro-schema-ir/src/lib.rs index 3f27977..dbf6d50 100644 --- a/crates/ferro-schema-ir/src/lib.rs +++ b/crates/ferro-schema-ir/src/lib.rs @@ -388,6 +388,12 @@ pub enum QueryNode { /// nesting is recursion, not a second mechanism. #[serde(rename = "where")] where_clause: Vec, + /// Forward-traversal joins the inner tree references (#315), + /// rendered INSIDE the subquery as inner joins (ADR-0006 traversal + /// semantics are unchanged there; `left_join` has no inner-lambda + /// spelling). Absent on the wire when empty — pinned bytes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + joins: Vec, }, } @@ -557,7 +563,9 @@ mod tests { let envelope: IrEnvelope = serde_json::from_value(ir.clone()).expect("query exists IR must deserialize"); match &envelope.payload.where_clause[0] { - QueryNode::Exists { hops, where_clause } => { + QueryNode::Exists { + hops, where_clause, .. + } => { assert_eq!(hops.len(), 1); assert_eq!(hops[0].relation, "transactions"); assert_eq!(hops[0].from_column, "id"); @@ -597,6 +605,71 @@ mod tests { assert_eq!(encoded, ir, "query not-exists round-trip must not drift"); } + #[test] + fn query_scoped_exists_fixture_roundtrip() { + // The scoped existence-test golden vector (#315): the inner tree is + // an ordinary condition tree, and a traversed inner leaf's hop facts + // ride the exists node's own `joins` section (rendered INSIDE the + // subquery). Must survive a deserialize/serialize round-trip. + let fixture = + include_str!("../../../tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json"); + let parsed: serde_json::Value = + serde_json::from_str(fixture).expect("query scoped-exists fixture must parse"); + let ir = parsed + .get("ir") + .cloned() + .expect("fixture must contain ir envelope"); + let envelope: IrEnvelope = + serde_json::from_value(ir.clone()).expect("query scoped-exists IR must deserialize"); + match &envelope.payload.where_clause[0] { + QueryNode::Exists { + hops, + where_clause, + joins, + } => { + assert_eq!(hops.len(), 1); + assert_eq!(where_clause.len(), 1); + assert_eq!(joins.len(), 1); + assert_eq!(joins[0].join_type, "inner"); + assert_eq!(joins[0].path[0].relation, "account"); + } + other => panic!("where[0] must be an exists node, got {other:?}"), + } + let encoded = + serde_json::to_value(&envelope).expect("query scoped-exists IR must serialize"); + assert_eq!(encoded, ir, "query scoped-exists round-trip must not drift"); + } + + #[test] + fn query_nested_exists_fixture_roundtrip() { + // Nested exists-in-exists (#315): depth is recursion, not a second + // mechanism, and the bare inner node omits `joins` entirely (absent, + // not empty — pinned wire bytes via skip_serializing_if). + let fixture = + include_str!("../../../tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json"); + let parsed: serde_json::Value = + serde_json::from_str(fixture).expect("query nested-exists fixture must parse"); + let ir = parsed + .get("ir") + .cloned() + .expect("fixture must contain ir envelope"); + let envelope: IrEnvelope = + serde_json::from_value(ir.clone()).expect("query nested-exists IR must deserialize"); + match &envelope.payload.where_clause[0] { + QueryNode::Exists { where_clause, .. } => match &where_clause[0] { + QueryNode::Exists { hops, joins, .. } => { + assert_eq!(hops[0].relation, "transactions"); + assert!(joins.is_empty(), "bare nested test carries no joins"); + } + other => panic!("inner where[0] must be an exists node, got {other:?}"), + }, + other => panic!("where[0] must be an exists node, got {other:?}"), + } + let encoded = + serde_json::to_value(&envelope).expect("query nested-exists IR must serialize"); + assert_eq!(encoded, ir, "query nested-exists round-trip must not drift"); + } + #[test] fn query_traversal_fixture_roundtrip() { // Multi-hop `joins` section + path-carrying leaves must survive a diff --git a/src/ferro/query/nodes.py b/src/ferro/query/nodes.py index 132467c..33fc8b1 100644 --- a/src/ferro/query/nodes.py +++ b/src/ferro/query/nodes.py @@ -76,6 +76,7 @@ def __init__( path: tuple[str, ...] = (), child: "QueryNode | None" = None, exists: "ExistsTest | None" = None, + owner: type | None = None, ): """Initialize a query expression node @@ -95,6 +96,11 @@ def __init__( exists: The correlation hops and inner condition tree of an existence test (built by ``t.rel.exists()``, ADR-0007); ``None`` for every other node kind. + owner: The model class whose scope built this leaf (the proxy's + owner), when known. A compile-side fact, never serialized — + the cross-scope guard on scoped existence tests (#315) reads + it to catch a leaf smuggled in from another lambda's + parameter. """ self.column = column self.operator = operator @@ -105,6 +111,7 @@ def __init__( self.path = path self.child = child self.exists = exists + self.owner = owner def __or__(self, other: "QueryNode") -> "QueryNode": """Combine two nodes with logical OR @@ -169,11 +176,19 @@ def to_ir_dict(self) -> dict[str, Any]: if self.child is not None: return {"node_kind": "not", "child": self.child.to_ir_dict()} if self.exists is not None: - return { + serialized_exists: dict[str, Any] = { "node_kind": "exists", "hops": [hop.to_ir_dict() for hop in self.exists.hops], "where": [node.to_ir_dict() for node in self.exists.where], } + # The inner-traversal joins key is absent, not empty, on a + # traversal-free test (#315) — pinned wire bytes, mirroring the + # Rust skip_serializing_if. + if self.exists.joins: + serialized_exists["joins"] = [ + join.to_ir_dict() for join in self.exists.joins + ] + return serialized_exists if not self.is_compound: serialized = _serialize_query_value(self.value) return { @@ -250,10 +265,18 @@ class ExistsTest: bare test; nesting and negation come free from :class:`QueryNode` recursion. There is no negation flag: NOT EXISTS is ``~`` (a ``not`` node) over the exists node, like every other predicate (ADR-0008). + + ``joins`` carries the inner tree's forward-traversal hop facts (#315, + ``QueryJoin`` entries, always ``"inner"`` — ADR-0006 semantics inside the + subquery), serialized only when non-empty. ``owner`` is the model whose + proxy built this test — a compile-side scope tag for the cross-scope + guard, never serialized. """ hops: tuple[Any, ...] where: tuple["QueryNode", ...] + joins: tuple[Any, ...] = () + owner: type | None = None def _query_value_kind(value: Any) -> str: @@ -399,29 +422,29 @@ def __eq__( # type: ignore[override] # ty: ignore[invalid-method-override] self, other: "TField | FieldProxy[TField]" ) -> QueryNode: """Build an equality comparison node""" - return QueryNode(self.column, "==", other, path=self.path) + return QueryNode(self.column, "==", other, path=self.path, owner=self._owner) def __ne__( # type: ignore[override] # ty: ignore[invalid-method-override] self, other: "TField | FieldProxy[TField]" ) -> QueryNode: """Build an inequality comparison node""" - return QueryNode(self.column, "!=", other, path=self.path) + return QueryNode(self.column, "!=", other, path=self.path, owner=self._owner) def __lt__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a less-than comparison node""" - return QueryNode(self.column, "<", other, path=self.path) + return QueryNode(self.column, "<", other, path=self.path, owner=self._owner) def __le__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a less-than-or-equal comparison node""" - return QueryNode(self.column, "<=", other, path=self.path) + return QueryNode(self.column, "<=", other, path=self.path, owner=self._owner) def __gt__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a greater-than comparison node""" - return QueryNode(self.column, ">", other, path=self.path) + return QueryNode(self.column, ">", other, path=self.path, owner=self._owner) def __ge__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a greater-than-or-equal comparison node""" - return QueryNode(self.column, ">=", other, path=self.path) + return QueryNode(self.column, ">=", other, path=self.path, owner=self._owner) def in_( self, other: "list[TField] | tuple[TField, ...] | set[TField]" @@ -446,7 +469,9 @@ def in_( raise TypeError( f"The 'in_' operator expects a list, tuple, or set, got {type(other).__name__}" ) - return QueryNode(self.column, "IN", list(other), path=self.path) + return QueryNode( + self.column, "IN", list(other), path=self.path, owner=self._owner + ) def like(self: "FieldProxy[str]", pattern: str) -> QueryNode: """Build a ``LIKE`` comparison node @@ -466,7 +491,9 @@ def like(self: "FieldProxy[str]", pattern: str) -> QueryNode: >>> email_filter.operator 'LIKE' """ - return QueryNode(self.column, "LIKE", pattern, path=self.path) + return QueryNode( + self.column, "LIKE", pattern, path=self.path, owner=self._owner + ) def __lshift__( self, other: "list[TField] | tuple[TField, ...] | set[TField]" @@ -722,20 +749,24 @@ def _relation_name(self) -> str: """The last-hop relation field name (the one being compared).""" return self._path[-1] - def _last_hop_shadow_column(self) -> str: - """Shadow FK column of the LAST hop, resolved along the spec chain. + def _last_hop_shadow_column(self) -> tuple[str, type]: + """Shadow FK column of the LAST hop and the model declaring it. - For ``t.account`` this is ``account_id`` on the root table; for - ``t.account.owner`` it is ``owner_id`` on the hop-1 (account) table. + For ``t.account`` this is ``account_id`` on the root table (owner = + root model); for ``t.account.owner`` it is ``owner_id`` on the hop-1 + (account) table. The owner rides the comparison node as a + compile-side scope fact (#315 cross-scope guard). """ current = self._root_model + declaring = current spec = None for name in self._path: specs = getattr(current, "__ferro_relation_specs__", None) or {} spec = specs[name] + declaring = current current = spec.target assert spec is not None # a RelationProxy always has ≥ 1 hop - return spec.shadow_column + return spec.shadow_column, declaring def _instance_comparison(self, other: object, operator: str) -> QueryNode: """Desugar ``== instance`` / ``== None`` to a shadow-FK leaf (#273). @@ -751,11 +782,15 @@ def _instance_comparison(self, other: object, operator: str) -> QueryNode: the relation-vs-scalar guardrail, suggesting a column compare. """ relation = self._relation_name() - shadow = self._last_hop_shadow_column() + shadow, shadow_owner = self._last_hop_shadow_column() prefix_path = self._path[:-1] if other is None: return QueryNode( - column=shadow, operator=operator, value=None, path=prefix_path + column=shadow, + operator=operator, + value=None, + path=prefix_path, + owner=shadow_owner, ) if isinstance(other, self._target): # Reuse the Task 3 PK resolver (loud on zero/multiple PKs); local @@ -771,7 +806,11 @@ def _instance_comparison(self, other: object, operator: str) -> QueryNode: "save it first" ) return QueryNode( - column=shadow, operator=operator, value=pk_value, path=prefix_path + column=shadow, + operator=operator, + value=pk_value, + path=prefix_path, + owner=shadow_owner, ) raise TypeError( f"cannot compare relation {relation!r} to {other!r}: expected a " @@ -826,6 +865,150 @@ def __repr__(self) -> str: return f"RelationProxy(path={joined!r}, target={self._target.__name__!r})" +def _resolve_scoped_predicate( + inner: "Predicate[Any]", model_cls: type, relation: str +) -> QueryNode: + """Evaluate an existence test's inner lambda over the related model (#315). + + The inner predicate is ordinary ferro — the same validating + :class:`QueryProxy` a root ``where()`` receives, constructed for the + related model — so every operator, combinator, traversal, and nested + existence test works unchanged. The rejection shapes mirror + ``where()``'s own. + + Raises: + TypeError: If ``inner`` is not callable, returns a bare relation or + reverse relation, an aggregate, or any other non-predicate value. + """ + if not callable(inner): + raise TypeError( + f"t.{relation}.exists(...) expected a predicate callable over " + f"{model_cls.__name__} (e.g. `lambda l: l.amount < 0`), got " + f"{type(inner).__name__}" + ) + result = inner(QueryProxy(model_cls)) + if isinstance(result, RelationProxy): + bare = result._path[-1] + raise TypeError( + f"t.{relation}.exists(...) inner predicate returned the bare " + f"relation {bare!r}; compare a column (e.g. l.{bare}. == " + "...) or use == None / == an instance." + ) + if isinstance(result, ReverseRelationProxy): + bare = result._name + raise TypeError( + f"t.{relation}.exists(...) inner predicate returned the bare " + f"reverse relation {bare!r}; test membership with " + f"l.{bare}.exists()." + ) + if isinstance(result, AggregateExpr): + raise TypeError( + f"t.{relation}.exists(...) cannot filter on the aggregate " + f"{result._dotted()}: an existence test answers membership, not " + "aggregation." + ) + if not isinstance(result, QueryNode): + raise TypeError( + f"t.{relation}.exists(...) inner callable must return a " + f"predicate (QueryNode), got {type(result).__name__}" + ) + return result + + +def _validate_inner_scope(node: QueryNode, model_cls: type, relation: str) -> None: + """Reject cross-scope references in an inner condition tree (#315). + + The inner lambda may reference only its own parameter's scope. A leaf + built from any other proxy (the outer lambda's parameter), a + ``FieldProxy`` as a comparison right-hand side (column-to-column), and a + nested existence test built from another scope's proxy are all + build-time errors pointing at the deferred capability (#309) — silent + misrendering is the failure mode this guard exists to prevent. + + Detection reads compile-side facts the proxies stamp on their nodes: a + leaf's ``owner`` (the model whose proxy built it) checked against the + model its ``path`` resolves to FROM THE INNER SCOPE, and an exists + node's ``owner`` (the scope whose proxy built the test). + """ + if node.child is not None: + _validate_inner_scope(node.child, model_cls, relation) + return + if node.exists is not None: + if node.exists.owner is not None and node.exists.owner is not model_cls: + raise TypeError( + f"t.{relation}.exists(...) inner predicate contains an " + "existence test built from another scope's parameter " + f"(over {node.exists.owner.__name__}, not " + f"{model_cls.__name__}). Cross-scope references inside an " + "existence test are not supported yet (#309) — the inner " + "lambda may reference only its own parameter." + ) + # Its own inner tree was validated against its own scope when built. + return + if node.is_compound: + if node.left is not None: + _validate_inner_scope(node.left, model_cls, relation) + if node.right is not None: + _validate_inner_scope(node.right, model_cls, relation) + return + if isinstance( + node.value, + (FieldProxy, AggregateExpr, QueryProxy, RelationProxy, ReverseRelationProxy), + ): + raise TypeError( + f"t.{relation}.exists(...) inner predicate compares " + f"{node.column!r} against another column reference; " + "column-to-column comparison is cross-scope correlation, not " + "supported yet (#309) — compare against a value." + ) + expected = model_cls + for hop_name in node.path: + specs = getattr(expected, "__ferro_relation_specs__", None) or {} + spec = specs.get(hop_name) + if spec is None: + raise TypeError( + f"t.{relation}.exists(...) inner predicate leaf " + f"{node.column!r} traverses {hop_name!r}, which is not a " + f"relation of {expected.__name__} — the leaf was built from " + "another scope's parameter. Cross-scope references are not " + "supported yet (#309)." + ) + expected = spec.target + if node.owner is not None and node.owner is not expected: + raise TypeError( + f"t.{relation}.exists(...) inner predicate leaf {node.column!r} " + f"belongs to {node.owner.__name__}, not the inner scope " + f"({expected.__name__}) — it was built from another lambda's " + "parameter. Cross-scope correlation is not supported yet " + "(#309); the inner lambda may reference only its own parameter." + ) + + +def _collect_inner_traversal_paths( + node: QueryNode, paths: dict[tuple[str, ...], None] +) -> None: + """Collect an inner tree's forward-traversal paths in first-use order. + + The same walk as the builder's ``_register_join_paths`` but scoped to + one existence test: full paths only (the Rust render dedups shared + prefixes), and nested exists nodes are skipped — their traversal facts + ride their own ``joins`` section. + """ + if node.child is not None: + _collect_inner_traversal_paths(node.child, paths) + return + if node.exists is not None: + return + if node.is_compound: + if node.left is not None: + _collect_inner_traversal_paths(node.left, paths) + if node.right is not None: + _collect_inner_traversal_paths(node.right, paths) + return + if node.path: + paths.setdefault(tuple(node.path)) + + class ReverseRelationProxy: """Predicate proxy for a reverse (BackRef) relation (#314, ADR-0007). @@ -845,25 +1028,38 @@ def __init__(self, root_model: type, name: str, spec: Any) -> None: self._name = name self._spec = spec - def exists(self) -> QueryNode: + def exists(self, inner: "Predicate[Any] | None" = None) -> QueryNode: """Build the existence test: a correlated EXISTS over the child rows. Always a correlated EXISTS at every cardinality (a one-to-one BackRef renders identically to a to-many one); the result stays root-shaped, so the node composes with any other predicate, ordering, and paging. + Args: + inner: Optional scoping predicate — a full ferro predicate over + the related model (#315): every operator, ``&``/``|``/``~``, + forward traversal (joins rendered INSIDE the subquery, + ADR-0006 unchanged), and nested existence tests. It may + reference only its own parameter's scope; cross-scope + references raise at build time (#309). + Returns: An exists :class:`QueryNode` carrying the one-hop correlation - path (child table, child FK column against the root PK). + path (child table, child FK column against the root PK) and, + when scoped, the inner condition tree plus its forward-traversal + join facts. Raises: ValueError: If the root model declares no primary-key column — the EXISTS correlates child FK to root PK, so a PK-less root is a loud error, never a guess. + TypeError: If ``inner`` is not a predicate callable, does not + return a predicate, or references a scope other than its own + parameter (cross-scope, #309). """ # Late import: wire.py imports this module (nodes owns the predicate # shape, wire owns the hop-fact shape). - from .wire import QueryJoinHop + from .wire import QueryJoin, QueryJoinHop, resolve_join_hops root_pk = getattr(self._root_model, "__ferro_pk__", None) if root_pk is None: @@ -880,7 +1076,26 @@ def exists(self) -> QueryNode: to_column=self._spec.child_fk_column, target=self._spec.target, ) - return QueryNode(exists=ExistsTest(hops=(hop,), where=())) + where: tuple[QueryNode, ...] = () + joins: tuple[Any, ...] = () + if inner is not None: + node = _resolve_scoped_predicate(inner, self._spec.target, self._name) + _validate_inner_scope(node, self._spec.target, self._name) + paths: dict[tuple[str, ...], None] = {} + _collect_inner_traversal_paths(node, paths) + joins = tuple( + QueryJoin( + join_type="inner", + path=resolve_join_hops(self._spec.target, path), + ) + for path in paths + ) + where = (node,) + return QueryNode( + exists=ExistsTest( + hops=(hop,), where=where, joins=joins, owner=self._root_model + ) + ) def _reject_operator(self, symbol: str) -> NoReturn: raise TypeError( diff --git a/src/operations.rs b/src/operations.rs index 5cc2fe2..effaf1a 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -372,8 +372,20 @@ async fn populate_hop_bind_context( exec: Executor<'_>, backend: Dialect, ) -> PyResult<()> { - for edge in &join_plan.renders { - let table = &edge.to_table; + // Every table an existence test reaches (#314/#315) — correlation hops + // plus inner-traversal join hops, at any nesting depth — binds inner + // leaves against its own model, so it needs the same registration + enum + // catalog resolution as a rendered join edge. Collected up front (the + // walk borrows the plan immutably; the inserts below mutate it). + let mut tables: Vec = join_plan + .renders + .iter() + .map(|edge| edge.to_table.clone()) + .collect(); + for node in &plan.where_clause { + collect_exists_tables(node, &mut tables); + } + for table in &tables { if !plan.hop_registrations.contains_key(table) { let registration = crate::state::registration_for_table(table)?; plan.hop_registrations.insert(table.clone(), registration); @@ -387,6 +399,39 @@ async fn populate_hop_bind_context( Ok(()) } +/// Collect every table an existence test in `node`'s subtree touches: +/// correlation `hops`, inner-traversal `joins` hops, and nested tests +/// recursively (#315). Leaves contribute nothing — their tables are the +/// scope tables these very entries establish. +fn collect_exists_tables(node: &ferro_schema_ir::QueryNode, tables: &mut Vec) { + use ferro_schema_ir::QueryNode; + match node { + QueryNode::Leaf { .. } => {} + QueryNode::Compound { left, right, .. } => { + collect_exists_tables(left, tables); + collect_exists_tables(right, tables); + } + QueryNode::Not { child } => collect_exists_tables(child, tables), + QueryNode::Exists { + hops, + where_clause, + joins, + } => { + for hop in hops { + tables.push(hop.to_table.clone()); + } + for join in joins { + for hop in &join.path { + tables.push(hop.to_table.clone()); + } + } + for inner in where_clause { + collect_exists_tables(inner, tables); + } + } + } +} + /// Reject relation traversal on a mutating operation (#270 renders joins in SELECT /// only). The Python builder never emits joins on a mutating payload, so a `joins` /// list or a path-carrying WHERE leaf here is misuse — fail loud. diff --git a/src/query.rs b/src/query.rs index 379e0f4..1d9c44e 100644 --- a/src/query.rs +++ b/src/query.rs @@ -63,11 +63,17 @@ enum ColumnQualifier<'a> { root_table: &'a str, join_plan: &'a JoinPlan, }, - /// Inside an EXISTS subquery (#314, ADR-0007): an empty-path leaf + /// Inside an EXISTS subquery (#314/#315, ADR-0007): an empty-path leaf /// qualifies by the subquery scope's `alias` and binds against `table`'s - /// model. The variant owns its strings — subquery aliases are minted - /// during the render walk, not borrowed from the plan. - ExistsScope { alias: String, table: String }, + /// model; a path-carrying leaf resolves through the subquery's own + /// `join_plan` (the exists node's `joins` section, rendered inside the + /// subquery). The variant owns its data — subquery aliases are minted + /// during the render walk, not borrowed from the statement plan. + ExistsScope { + alias: String, + table: String, + join_plan: JoinPlan, + }, } /// Qualify a column reference (a WHERE leaf or an `ORDER BY` term) by its @@ -286,15 +292,22 @@ fn qualify_leaf_column( })?; Ok(Expr::col((Alias::new(alias.as_str()), Alias::new(column)))) } - ColumnQualifier::ExistsScope { alias, .. } => { - if !path.is_empty() { - return Err(format!( - "WHERE column {column:?} carries relation path {path:?} \ - inside an existence test; forward traversal inside the \ - subquery is not rendered yet (#315)" - )); + ColumnQualifier::ExistsScope { + alias, join_plan, .. + } => { + if path.is_empty() { + return Ok(Expr::col((Alias::new(alias.as_str()), Alias::new(column)))); } - Ok(Expr::col((Alias::new(alias.as_str()), Alias::new(column)))) + let hop_alias = join_plan.prefix_alias.get(path).ok_or_else(|| { + format!( + "column {column:?} carries relation path {path:?} with no \ + matching join entry inside the existence test" + ) + })?; + Ok(Expr::col(( + Alias::new(hop_alias.as_str()), + Alias::new(column), + ))) } } } @@ -599,9 +612,13 @@ impl QueryPlan { .node_to_condition_for_backend(child, backend, qualifier, exists_counter)? .not()) } - QueryNode::Exists { hops, where_clause } => { - // Existence test (#314, ADR-0007): one render loop for every - // hop count. The first hop's table is the subquery FROM, + QueryNode::Exists { + hops, + where_clause, + joins, + } => { + // Existence test (#314/#315, ADR-0007): one render loop for + // every hop count. The first hop's table is the subquery FROM, // correlated to the enclosing scope's alias; remaining hops // (M2M target) render as inner joins inside the subquery; the // inner tree recurses through this same builder scoped to the @@ -644,9 +661,51 @@ impl QueryPlan { last_alias = hop_alias; last_table = hop.to_table.clone(); } + // Inner forward-traversal joins (#315): rendered INSIDE the + // subquery, rooted at the inner scope, always INNER + // (ADR-0006 traversal semantics; there is no inner-lambda + // left_join spelling). Shared prefixes across entries dedup + // to one edge; aliases come from the same statement-wide + // counter, so they can never collide with any other scope. + let mut inner_plan = JoinPlan::default(); + for join in joins { + if join.join_type != "inner" { + return Err(format!( + "unsupported join_type {:?} inside an existence test; \ + traversal inside the subquery is always \"inner\"", + join.join_type + )); + } + let mut prefix: Vec = Vec::new(); + let mut prev_alias = last_alias.clone(); + for hop in &join.path { + prefix.push(hop.relation.clone()); + if let Some(existing) = inner_plan.prefix_alias.get(&prefix) { + prev_alias = existing.clone(); + continue; + } + *exists_counter += 1; + let hop_alias = format!("x{}_{}", exists_counter, hop.relation); + subquery.join_as( + JoinType::InnerJoin, + Alias::new(&hop.to_table), + Alias::new(&hop_alias), + Expr::col((Alias::new(&prev_alias), Alias::new(&hop.from_column))) + .equals((Alias::new(&hop_alias), Alias::new(&hop.to_column))), + ); + inner_plan + .prefix_alias + .insert(prefix.clone(), hop_alias.clone()); + inner_plan + .prefix_table + .insert(prefix.clone(), hop.to_table.clone()); + prev_alias = hop_alias; + } + } let inner_scope = ColumnQualifier::ExistsScope { alias: last_alias, table: last_table, + join_plan: inner_plan, }; for node in where_clause { inner = inner.add(self.node_to_condition_for_backend( @@ -791,23 +850,35 @@ impl QueryPlan { qualifier: &ColumnQualifier<'_>, path: &[String], ) -> Result<(Option<&crate::codec_plan::ModelCodecPlan>, &HashMap), String> { - // Inside an EXISTS subquery (#314) an empty-path leaf belongs to the - // subquery scope's table, NEVER the root model — resolve before the - // root early-return below. Registration is optional here (mirroring - // the root's own `Option`): an inner leaf on an unregistered/ - // unprobed table degrades to generic binds, exactly like an - // unregistered root model. The walkers populate both maps for scoped - // tests (#315). - if let ColumnQualifier::ExistsScope { table, .. } = qualifier { - if !path.is_empty() { - return Err(format!( - "relation path {path:?} inside an existence test is not \ - rendered yet (#315); cannot resolve typed binds" - )); - } + // Inside an EXISTS subquery (#314/#315) a leaf belongs to the + // subquery scope — the scope table for an empty path, the inner + // join plan's hop table for a traversed one — NEVER the root model; + // resolve before the root early-return below. Registration is + // optional here (mirroring the root's own `Option`): an inner leaf + // on an unregistered/unprobed table degrades to generic binds, + // exactly like an unregistered root model. The walkers populate + // both maps for every table an existence test reaches. + if let ColumnQualifier::ExistsScope { + table, join_plan, .. + } = qualifier + { + let leaf_table = if path.is_empty() { + table + } else { + join_plan.prefix_table.get(path).ok_or_else(|| { + format!( + "relation path {path:?} has no matching join entry \ + inside the existence test for typed binds" + ) + })? + }; return Ok(( - self.hop_registrations.get(table).map(|m| &m.codec_plan), - self.hop_enum_udt.get(table).unwrap_or(empty_enum_udt()), + self.hop_registrations + .get(leaf_table) + .map(|m| &m.codec_plan), + self.hop_enum_udt + .get(leaf_table) + .unwrap_or(empty_enum_udt()), )); } if path.is_empty() { @@ -1104,6 +1175,99 @@ mod tests { assert!(sql.contains(" or "), "OR composition preserved: {sql}"); } + #[test] + fn scoped_exists_renders_inner_conditions_and_traversal_joins() { + // Scoped existence test (#315): the inner tree's empty-path leaf + // qualifies by the subquery scope's alias, and a traversed inner + // leaf resolves through the exists node's own `joins` section — + // rendered as an INNER join inside the subquery (ADR-0006). + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Acct", + "where": [ + {"node_kind": "exists", + "hops": [{"relation": "transactions", "from_column": "id", + "to_table": "transaction", "to_column": "account_id"}], + "where": [ + {"node_kind": "compound", "operator": "AND", + "left": {"node_kind": "leaf", "column": "amount", "operator": ">=", + "value": {"kind": "int", "value": 100}, "path": []}, + "right": {"node_kind": "leaf", "column": "name", "operator": "==", + "value": {"kind": "string", "value": "checking"}, + "path": ["account"]}} + ], + "joins": [ + {"join_type": "inner", + "path": [{"relation": "account", "from_column": "account_id", + "to_table": "account", "to_column": "id"}]} + ]} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let mut select = Query::select(); + select.from(Alias::new("acct")).cond_where( + plan.to_condition_for_backend(Dialect::Sqlite, Some("acct")) + .expect("valid test query"), + ); + let sql = select.to_string(SqliteQueryBuilder).to_lowercase(); + assert!( + sql.contains( + "inner join \"account\" as \"x2_account\" on \ + \"x1_transactions\".\"account_id\" = \"x2_account\".\"id\"" + ), + "inner traversal join must render INSIDE the subquery: {sql}" + ); + assert!( + sql.contains("\"x1_transactions\".\"amount\" >= 100"), + "empty-path inner leaf qualifies by the subquery scope alias: {sql}" + ); + assert!( + sql.contains("\"x2_account\".\"name\" = 'checking'"), + "traversed inner leaf qualifies by its inner join alias: {sql}" + ); + } + + #[test] + fn nested_exists_correlates_to_the_inner_scope() { + // Depth-2 nesting (#315): the inner exists node's correlation hop + // resolves against the ENCLOSING SUBQUERY's alias, not the root — + // recursion through the ExistsScope qualifier, no second mechanism. + let payload: ferro_schema_ir::QueryIrPayload = serde_json::from_value(json!({ + "model_name": "Owner", + "where": [ + {"node_kind": "exists", + "hops": [{"relation": "accounts", "from_column": "id", + "to_table": "account", "to_column": "owner_id"}], + "where": [ + {"node_kind": "exists", + "hops": [{"relation": "transactions", "from_column": "id", + "to_table": "transaction", "to_column": "account_id"}], + "where": []} + ]} + ], + "order_by": [], + "limit": null, "offset": null, "m2m": null, "materialization": {"kind": "root_instances"}, "joins": [] + })) + .expect("payload deserializes"); + let plan = QueryPlan::from_ir_payload(payload).expect("plan builds"); + let mut select = Query::select(); + select.from(Alias::new("owner")).cond_where( + plan.to_condition_for_backend(Dialect::Postgres, Some("owner")) + .expect("valid test query"), + ); + let sql = select.to_string(PostgresQueryBuilder).to_lowercase(); + assert!( + sql.contains("\"x1_accounts\".\"owner_id\" = \"owner\".\"id\""), + "outer test correlates to the root alias: {sql}" + ); + assert!( + sql.contains("\"x2_transactions\".\"account_id\" = \"x1_accounts\".\"id\""), + "nested test correlates to the enclosing SUBQUERY alias: {sql}" + ); + } + #[test] fn exists_requires_a_qualified_enclosing_scope() { // Correlation needs an alias to correlate against; the unqualified diff --git a/tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json b/tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json new file mode 100644 index 0000000..acec1b4 --- /dev/null +++ b/tests/fixtures/ir_vectors/query_account_scoped_exists_v7.json @@ -0,0 +1,74 @@ +{ + "vector_name": "query_account_scoped_exists_v7", + "domain": "query", + "expect_valid": true, + "ir": { + "ir_kind": "query", + "ir_version": 7, + "payload": { + "model_name": "Account", + "where": [ + { + "node_kind": "exists", + "hops": [ + { + "relation": "transactions", + "from_column": "id", + "to_table": "transaction", + "to_column": "account_id" + } + ], + "where": [ + { + "node_kind": "compound", + "operator": "AND", + "left": { + "node_kind": "leaf", + "column": "amount", + "operator": ">=", + "value": { + "kind": "int", + "value": 100 + }, + "path": [] + }, + "right": { + "node_kind": "leaf", + "column": "name", + "operator": "==", + "value": { + "kind": "string", + "value": "checking" + }, + "path": [ + "account" + ] + } + } + ], + "joins": [ + { + "join_type": "inner", + "path": [ + { + "relation": "account", + "from_column": "account_id", + "to_table": "account", + "to_column": "id" + } + ] + } + ] + } + ], + "order_by": [], + "limit": null, + "offset": null, + "m2m": null, + "joins": [], + "materialization": { + "kind": "root_instances" + } + } + } +} diff --git a/tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json b/tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json new file mode 100644 index 0000000..cfb1b00 --- /dev/null +++ b/tests/fixtures/ir_vectors/query_owner_nested_exists_v7.json @@ -0,0 +1,47 @@ +{ + "vector_name": "query_owner_nested_exists_v7", + "domain": "query", + "expect_valid": true, + "ir": { + "ir_kind": "query", + "ir_version": 7, + "payload": { + "model_name": "Owner", + "where": [ + { + "node_kind": "exists", + "hops": [ + { + "relation": "accounts", + "from_column": "id", + "to_table": "account", + "to_column": "owner_id" + } + ], + "where": [ + { + "node_kind": "exists", + "hops": [ + { + "relation": "transactions", + "from_column": "id", + "to_table": "transaction", + "to_column": "account_id" + } + ], + "where": [] + } + ] + } + ], + "order_by": [], + "limit": null, + "offset": null, + "m2m": null, + "joins": [], + "materialization": { + "kind": "root_instances" + } + } + } +} diff --git a/tests/test_ir_vectors_contract.py b/tests/test_ir_vectors_contract.py index a74bfb8..04f4c4a 100644 --- a/tests/test_ir_vectors_contract.py +++ b/tests/test_ir_vectors_contract.py @@ -56,7 +56,10 @@ def _validate_query_node(node: dict[str, Any], label: str) -> None: # v7 (ADR-0007): an existence test carries a correlation hop path (1 # hop for a reverse FK, 2 for M2M — the `joins`-section hop shape) and # an ordinary inner condition tree (empty = bare test). No negation - # flag: NOT EXISTS is the `not` node over this one. + # flag: NOT EXISTS is the `not` node over this one. When the inner + # tree traverses forward FKs (#315), the hop facts ride the node's + # own `joins` section — present only when non-empty (absent on a + # traversal-free test, pinned wire bytes). _require_keys(node, {"hops", "where"}, label) hops = node["hops"] assert isinstance(hops, list) and hops, ( @@ -69,6 +72,24 @@ def _validate_query_node(node: dict[str, Any], label: str) -> None: for i, child in enumerate(inner): assert isinstance(child, dict), f"{label}.where[{i}] must be object" _validate_query_node(child, f"{label}.where[{i}]") + if "joins" in node: + joins = node["joins"] + assert isinstance(joins, list) and joins, ( + f"{label}.joins must be a non-empty list when present" + ) + for j, join in enumerate(joins): + join_label = f"{label}.joins[{j}]" + assert isinstance(join, dict), f"{join_label} must be object" + _require_keys(join, {"join_type", "path"}, join_label) + assert join["join_type"] == "inner", ( + f"{join_label}.join_type must be \"inner\" (ADR-0006: " + "traversal inside a subquery narrows)" + ) + assert isinstance(join["path"], list) and join["path"], ( + f"{join_label}.path must be a non-empty list" + ) + for h, hop in enumerate(join["path"]): + _validate_hop(hop, f"{join_label}.path[{h}]") return _require_keys(node, {"operator"}, label) diff --git a/tests/test_query_exists.py b/tests/test_query_exists.py index ca9c03a..f2a939b 100644 --- a/tests/test_query_exists.py +++ b/tests/test_query_exists.py @@ -1,14 +1,17 @@ -"""End-to-end behavior of existence tests on reverse relations (#314, ADR-0007). +"""End-to-end behavior of existence tests on reverse relations (ADR-0007). A reverse (BackRef) relation appears in a predicate in exactly one form — the -existence test ``t.rel.exists()`` — rendered as a correlated EXISTS at every -cardinality (one-to-one BackRefs included), negated with ``~``. These tests -assert result sets only, on both database backends via the backend matrix; -the wire shape is pinned separately by the ``exists`` golden vectors. - -The model graph mirrors the #307 workload: a transaction with two one-to-one -BackRefs into a transfer link row (membership via either FK column) plus a -to-many BackRef onto split lines. +existence test ``t.rel.exists(...)`` — rendered as a correlated EXISTS at +every cardinality (one-to-one BackRefs included), negated with ``~``, and +optionally scoped by a full ferro predicate over the related model (#315). +These tests assert result sets only, on both database backends via the +backend matrix; the wire shape is pinned separately by the ``exists`` golden +vectors. + +The model graph mirrors the #307/#308 workloads: a transaction with two +one-to-one BackRefs into a transfer link row (membership via either FK +column), a to-many BackRef onto split lines, and a category the lines and +transactions both point at (the line-aware category filter). """ import pytest @@ -19,9 +22,19 @@ pytestmark = pytest.mark.backend_matrix +class ExCat(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str = "" + txns: Relation[list["ExTxn"]] = BackRef() + lines: Relation[list["ExLine"]] = BackRef() + + class ExTxn(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None amount: int = 0 + category: Annotated[ + ExCat | None, ForeignKey(related_name="txns", on_delete="SET NULL") + ] = None transfer_out: "ExTransfer" = BackRef() transfer_in: "ExTransfer" = BackRef() lines: Relation[list["ExLine"]] = BackRef() @@ -40,7 +53,11 @@ class ExTransfer(Model): class ExLine(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None txn: Annotated[ExTxn, ForeignKey(related_name="lines", on_delete="CASCADE")] - category: str = "" + category: Annotated[ + ExCat | None, ForeignKey(related_name="lines", on_delete="SET NULL") + ] = None + amount: int = 0 + memo: str = "" async def _seed_transfers() -> dict[str, ExTxn]: @@ -108,7 +125,7 @@ async def test_to_many_exists_returns_each_root_once(db_url): async with engines.session(): split = await ExTxn.create(amount=-70) for _ in range(3): - await ExLine.create(txn=split, category="groceries") + await ExLine.create(txn=split) await ExTxn.create(amount=-10) results = await ExTxn.where(lambda t: t.lines.exists()).all() @@ -123,7 +140,7 @@ async def test_exists_composes_with_root_predicates_order_and_limit(db_url): async with engines.session(): for amount in (-40, -50, -60): txn = await ExTxn.create(amount=amount) - await ExLine.create(txn=txn, category="travel") + await ExLine.create(txn=txn) await ExTxn.create(amount=-80) # child-less results = ( @@ -216,3 +233,278 @@ async def test_exists_rejected_on_mutating_verbs(db_url): await ExTxn.where(lambda t: t.lines.exists()).update(amount=0) with pytest.raises(ValueError, match="delete"): await ExTxn.where(lambda t: t.lines.exists()).delete() + + +# --------------------------------------------------------------------------- +# Scoped inner predicates (#315): the optional inner lambda is a full ferro +# predicate over the child model — every operator, forward traversal (joins +# INSIDE the subquery, ADR-0006 unchanged), nesting — with cross-scope +# references rejected at build time (deferred to #309). +# --------------------------------------------------------------------------- + + +async def _seed_categories() -> dict[str, object]: + """The #308 fixture: a split transaction whose three lines carry the + category (its own category vacated), a plain transaction categorized at + the root, and a transaction matching neither.""" + groceries = await ExCat.create(name="Groceries") + travel = await ExCat.create(name="Travel") + split = await ExTxn.create(amount=-7000) # category vacated while split + for amount in (-3000, -2000, -2000): + await ExLine.create(txn=split, category=groceries, amount=amount) + plain = await ExTxn.create(amount=-1000, category=groceries) + other = await ExTxn.create(amount=-500, category=travel) + return { + "groceries": groceries, + "travel": travel, + "split": split, + "plain": plain, + "other": other, + } + + +@pytest.mark.asyncio +async def test_scoped_exists_filters_by_child_predicate(db_url): + """``t.lines.exists(lambda line: ...)`` keeps exactly the roots with a + matching child row.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + seeded = await _seed_categories() + + rows = await _amounts( + ExTxn.where( + lambda t: t.lines.exists( + lambda line, cat=seeded["groceries"]: line.category_id == cat.id + ) + ) + ) + assert rows == [-7000] + + +@pytest.mark.asyncio +async def test_308_line_aware_category_filter(db_url): + """The full #308 demo: root-or-line category membership. A three-line + split matches exactly once, the child-less root survives through the OR's + root branch, and keyset ``order_by`` + ``limit`` compose unchanged.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + seeded = await _seed_categories() + ids = [seeded["groceries"].id] + + query = ExTxn.where( + lambda t: ( + t.category_id.in_(ids) + | t.lines.exists(lambda line: line.category_id.in_(ids)) + ) + ) + rows = await _amounts(query) + assert rows == [-7000, -1000] + + paged = ( + await ExTxn.where( + lambda t: ( + t.category_id.in_(ids) + | t.lines.exists(lambda line: line.category_id.in_(ids)) + ) + ) + .order_by("amount", "desc") + .limit(1) + .all() + ) + assert [r.amount for r in paged] == [-1000] + + +@pytest.mark.asyncio +async def test_inner_lambda_supports_full_operator_set(db_url): + """Every operator and ``&``/``|``/``~`` composition works over the child + model — the inner predicate is ordinary ferro, not a sub-language.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + txn = await ExTxn.create(amount=-70) + await ExLine.create(txn=txn, amount=-30, memo="grocery run") + await ExLine.create(txn=txn, amount=-40, memo="fuel") + bare = await ExTxn.create(amount=-10) + await ExLine.create(txn=bare, amount=5, memo="refund") + + assert await _amounts( + ExTxn.where(lambda t: t.lines.exists(lambda line: line.memo.like("%fuel%"))) + ) == [-70] + assert await _amounts( + ExTxn.where( + lambda t: t.lines.exists( + lambda line: (line.amount <= -30) & ~line.memo.like("%grocery%") + ) + ) + ) == [-70] + assert await _amounts( + ExTxn.where( + lambda t: t.lines.exists( + lambda line: (line.amount > 0) | (line.amount < -35) + ) + ) + ) == [-70, -10] + + +@pytest.mark.asyncio +async def test_forward_traversal_inside_subquery(db_url): + """A forward-FK traversal inside the inner lambda renders its join INSIDE + the EXISTS subquery under unchanged ADR-0006 semantics (INNER, + narrowing).""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + await _seed_categories() + + rows = await _amounts( + ExTxn.where( + lambda t: t.lines.exists(lambda line: line.category.name == "Groceries") + ) + ) + assert rows == [-7000] + + # INNER semantics: a line with no category can never match a + # traversed inner predicate. + uncategorized = await ExTxn.create(amount=-42) + await ExLine.create(txn=uncategorized, amount=-42) + rows = await _amounts( + ExTxn.where( + lambda t: t.lines.exists(lambda line: line.category.name != "nope") + ) + ) + assert rows == [-7000] + + +@pytest.mark.asyncio +async def test_nested_exists_depth_two(db_url): + """Existence tests nest: categories with a transaction that has a + negative line — the exists node's inner tree is an ordinary condition + tree, so recursion is free.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + cat = await ExCat.create(name="Active") + idle = await ExCat.create(name="Idle") + txn = await ExTxn.create(amount=-100, category=cat) + await ExLine.create(txn=txn, amount=-60) + pos = await ExTxn.create(amount=200, category=idle) + await ExLine.create(txn=pos, amount=200) + + results = await ExCat.where( + lambda c: c.txns.exists( + lambda t: t.lines.exists(lambda line: line.amount < 0) + ) + ).all() + assert [r.name for r in results] == ["Active"] + + +@pytest.mark.asyncio +async def test_explicit_grouping_contrast(db_url): + """``exists(lambda line: A & B)`` (one child row matches both) and + ``exists(A-test) & exists(B-test)`` (some child row matches each) are + different, correct row sets — the ambiguity ADR-0007 rejects is + unspellable by construction.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + both_in_one = await ExTxn.create(amount=-10) + await ExLine.create(txn=both_in_one, amount=-50, memo="fuel") + spread = await ExTxn.create(amount=-20) + await ExLine.create(txn=spread, amount=-50, memo="snacks") + await ExLine.create(txn=spread, amount=-5, memo="fuel") + + one_row_matches_both = await _amounts( + ExTxn.where( + lambda t: t.lines.exists( + lambda line: (line.amount <= -50) & line.memo.like("%fuel%") + ) + ) + ) + assert one_row_matches_both == [-10] + + some_row_matches_each = await _amounts( + ExTxn.where( + lambda t: ( + t.lines.exists(lambda line: line.amount <= -50) + & t.lines.exists(lambda line: line.memo.like("%fuel%")) + ) + ) + ) + assert some_row_matches_each == [-20, -10] + + +@pytest.mark.asyncio +async def test_negated_scoped_exists(db_url): + """``~t.lines.exists(lambda line: ...)`` renders NOT EXISTS over the scoped + subquery.""" + await connect(db_url, auto_migrate=True) + async with engines.session(): + seeded = await _seed_categories() + ids = [seeded["groceries"].id] + + rows = await _amounts( + ExTxn.where( + lambda t: ~t.lines.exists(lambda line: line.category_id.in_(ids)) + ) + ) + assert rows == [-1000, -500] + + +@pytest.mark.asyncio +async def test_scoped_exists_count(db_url): + await connect(db_url, auto_migrate=True) + async with engines.session(): + seeded = await _seed_categories() + ids = [seeded["groceries"].id] + + n = await ExTxn.where( + lambda t: ( + t.category_id.in_(ids) + | t.lines.exists(lambda line: line.category_id.in_(ids)) + ) + ).count() + assert n == 2 + + +# --------------------------------------------------------------------------- +# Cross-scope guard (#315): the inner lambda may reference only its own +# parameter's scope; everything else fails at build time pointing at the +# deferred capability (#309). Silent misrendering is the failure mode this +# guard exists to prevent. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cross_scope_outer_column_rejected(db_url): + """An inner-tree leaf built from the OUTER lambda's parameter is a + build-time error, not a silently re-scoped column.""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match="#309"): + ExTxn.where(lambda t: t.lines.exists(lambda line: t.amount > 5)) + + +@pytest.mark.asyncio +async def test_cross_scope_field_proxy_rhs_rejected(db_url): + """A FieldProxy as a comparison right-hand side (column-to-column) is a + build-time error pointing at #309 — whichever scope it came from.""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match="#309"): + ExTxn.where( + lambda t: t.lines.exists(lambda line: line.category_id == t.category_id) + ) + with pytest.raises(TypeError, match="#309"): + ExTxn.where(lambda t: t.lines.exists(lambda line: line.amount == line.amount)) + + +@pytest.mark.asyncio +async def test_cross_scope_nested_exists_rejected(db_url): + """A nested existence test built from the OUTER proxy inside the inner + lambda is cross-scope too.""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match="#309"): + ExTxn.where(lambda t: t.lines.exists(lambda line: t.transfer_out.exists())) + + +@pytest.mark.asyncio +async def test_inner_lambda_must_return_a_predicate(db_url): + """A non-predicate inner lambda fails with the same pointed shape as + where() itself.""" + await connect(db_url, auto_migrate=True) + with pytest.raises(TypeError, match="predicate"): + ExTxn.where(lambda t: t.lines.exists(lambda line: line.amount)) diff --git a/tests/test_query_wire_vectors.py b/tests/test_query_wire_vectors.py index 25485a2..d7b4822 100644 --- a/tests/test_query_wire_vectors.py +++ b/tests/test_query_wire_vectors.py @@ -225,12 +225,35 @@ def _q_not_exists(m: dict[str, type]) -> Any: return m["Owner"].where(lambda o: ~o.accounts.exists()) +def _q_scoped_exists(m: dict[str, type]) -> Any: + # Scoped existence test (#315): the inner lambda is a full ferro + # predicate over the child model. The traversed inner leaf + # (`t.account.name`) puts its hop facts on the exists node's own `joins` + # section — rendered INSIDE the subquery, never on the root query. + return m["Account"].where( + lambda a: a.transactions.exists( + lambda t: (t.amount >= 100) & (t.account.name == "checking") + ) + ) + + +def _q_nested_exists(m: dict[str, type]) -> Any: + # Nested exists-in-exists (#315): the inner tree is an ordinary condition + # tree, so depth comes from recursion, not a second mechanism. The bare + # inner node carries no `joins` key at all (absent, not empty). + return m["Owner"].where( + lambda o: o.accounts.exists(lambda a: a.transactions.exists()) + ) + + CASES: list[tuple[str, Callable[[dict[str, type]], Any], str]] = [ ("query_user_compound_v7", _q_user_compound, "User"), ("query_user_not_leaf_v7", _q_not_leaf, "User"), ("query_user_not_compound_v7", _q_not_compound, "User"), ("query_account_exists_v7", _q_exists_bare, "Account"), ("query_owner_not_exists_v7", _q_not_exists, "Owner"), + ("query_account_scoped_exists_v7", _q_scoped_exists, "Account"), + ("query_owner_nested_exists_v7", _q_nested_exists, "Owner"), ("query_transaction_traversal_v7", _q_traversal, "Transaction"), ("query_transaction_left_join_v7", _q_left_join, "Transaction"), ("query_transaction_include_v7", _q_include, "Transaction"),