Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 169 additions & 20 deletions crates/ferro-schema-ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -334,8 +335,9 @@ pub struct QueryOrderBy {
pub path: Vec<String>,
}

/// 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 {
Expand Down Expand Up @@ -370,6 +372,29 @@ pub enum QueryNode {
/// The negated subtree.
child: Box<QueryNode>,
},
/// 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<QueryJoinHop>,
/// 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<QueryNode>,
/// 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<QueryJoin>,
},
}

/// Typed literal or parameter value on the RHS of a leaf predicate.
Expand Down Expand Up @@ -448,7 +473,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
Expand All @@ -473,7 +498,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
Expand All @@ -500,7 +525,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
Expand All @@ -521,12 +546,136 @@ 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<QueryIrPayload> =
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<QueryIrPayload> =
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_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<QueryIrPayload> =
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<QueryIrPayload> =
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
// 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");
Expand All @@ -552,7 +701,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
Expand All @@ -578,7 +727,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
Expand Down Expand Up @@ -777,7 +926,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");
Expand Down Expand Up @@ -815,7 +964,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");
Expand Down Expand Up @@ -853,7 +1002,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");
Expand Down Expand Up @@ -921,7 +1070,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
Expand Down
70 changes: 70 additions & 0 deletions src/ferro/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions src/ferro/ir/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading