cratestack_sql/relation_path.rs
1//! Runtime representation of a traversed relation path.
2//!
3//! Generated relation accessors (`post::author().profile().nickname()`)
4//! accumulate a `RelationHop` per traversed relation and fold them into a
5//! `FilterExpr` or an `OrderClause` at call time.
6//!
7//! This is deliberately a *runtime* value rather than a compile-time type
8//! chain. Encoding the path in the module/type tree — one `Path` type per
9//! distinct path — makes the emitted code exponential in relation-graph
10//! connectivity, because the number of simple paths through a graph is
11//! exponential in its connectivity (cratestack#252: 6 chained models cost
12//! 9.5 min / 10.5 GB to expand; a 16-model schema could not build at all).
13//! Carrying the path as data makes codegen linear in `models × fields`:
14//! each model emits exactly one `Path`, and the fold below replaces the
15//! per-path token duplication.
16//!
17//! Every hop's table/column names are `&'static str` baked in by the macro,
18//! so filter folding allocates nothing.
19
20use crate::filter::{FilterExpr, RelationFilter, RelationQuantifier};
21use crate::relation_scope::RelatedReadScope;
22
23/// Marker for a path whose hops are all to-one, so a scalar at the end of
24/// it can be rendered as a correlated subquery and used for ordering.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Orderable;
27
28/// Marker for a path that has crossed a to-many hop. Ordering accessors are
29/// not implemented for this marker, which reproduces the old guarantee that
30/// `asc()`/`desc()` simply did not exist past a to-many relation — a
31/// compile error, not a runtime failure.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Unorderable;
34
35/// One traversed relation edge: the FK linkage, how the related rows are
36/// quantified (`ToOne` for a plain to-one hop, `Some`/`Every`/`None` for a
37/// to-many hop under a quantifier), and the related model's read scope,
38/// which every backend that enforces policy applies inside the subquery
39/// this hop renders to (GHSA-p55v-6xv5-93p3).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct RelationHop {
42 pub parent_table: &'static str,
43 pub parent_column: &'static str,
44 pub related_table: &'static str,
45 pub related_column: &'static str,
46 pub quantifier: RelationQuantifier,
47 /// Required, never defaulted: see [`RelatedReadScope`].
48 pub scope: RelatedReadScope,
49}
50
51impl RelationHop {
52 pub const fn new(
53 parent_table: &'static str,
54 parent_column: &'static str,
55 related_table: &'static str,
56 related_column: &'static str,
57 quantifier: RelationQuantifier,
58 scope: RelatedReadScope,
59 ) -> Self {
60 Self {
61 parent_table,
62 parent_column,
63 related_table,
64 related_column,
65 quantifier,
66 scope,
67 }
68 }
69
70 /// Same linkage, re-quantified. Used when a to-many hop is recorded
71 /// before the caller has picked `some`/`every`/`none`.
72 pub const fn with_quantifier(self, quantifier: RelationQuantifier) -> Self {
73 Self { quantifier, ..self }
74 }
75
76 /// Whether the related table is the parent table (`User.manager`). The
77 /// subquery's own `FROM` then shadows the parent's name, so backends
78 /// must correlate through a derived table instead of `related.col =
79 /// parent.col`, which would compare the inner row with itself.
80 pub fn is_self_relation(&self) -> bool {
81 self.parent_table == self.related_table
82 }
83}
84
85/// Fold a scalar `FilterExpr` outward through the traversed path, applying
86/// each hop's quantifier and carrying each hop's read scope onto the
87/// relation node it becomes.
88pub fn wrap_filter(hops: &[RelationHop], inner: FilterExpr) -> FilterExpr {
89 hops.iter().rev().fold(inner, |acc, hop| {
90 FilterExpr::Relation(RelationFilter::new(
91 hop.quantifier,
92 hop.parent_table,
93 hop.parent_column,
94 hop.related_table,
95 hop.related_column,
96 acc,
97 hop.scope,
98 ))
99 })
100}
101
102/// Build the correlated-subquery expression that yields `column` at the end
103/// of `hops`, relative to the table reached by the *first* hop.
104///
105/// **Unscoped:** this string ignores every hop's [`RelatedReadScope`] and
106/// does not handle self-relation hops. It is only suitable for a backend
107/// that enforces no read policy (the embedded rusqlite renderer); the
108/// Postgres backend renders relation sorts from the hops themselves,
109/// applying each hop's scope.
110///
111/// `hops[0]` is rendered by the caller (it becomes the outermost
112/// subquery's linkage), so only `hops[1..]` are nested here.
113///
114/// Panics if `hops` is empty; callers only reach this from a generated
115/// accessor that has traversed at least one relation.
116pub fn order_value_sql(hops: &[RelationHop], column: &str) -> String {
117 assert!(
118 !hops.is_empty(),
119 "order_value_sql requires at least one relation hop",
120 );
121 let mut sql = format!("{}.{}", hops[hops.len() - 1].related_table, column,);
122 for index in (1..hops.len()).rev() {
123 let hop = &hops[index];
124 let current_table = hops[index - 1].related_table;
125 sql = format!(
126 "(SELECT {} FROM {} WHERE {}.{} = {}.{} LIMIT 1)",
127 sql,
128 hop.related_table,
129 hop.related_table,
130 hop.related_column,
131 current_table,
132 hop.parent_column,
133 );
134 }
135 sql
136}
137
138/// Whether every hop is to-one. Ordering through a to-many hop is not
139/// expressible as a scalar correlated subquery, so generated `asc()`/
140/// `desc()` accessors are gated on this (previously enforced by simply not
141/// emitting those methods past a to-many hop).
142pub fn is_orderable(hops: &[RelationHop]) -> bool {
143 hops.iter()
144 .all(|hop| matches!(hop.quantifier, RelationQuantifier::ToOne))
145}
146
147#[cfg(test)]
148#[path = "relation_path_tests.rs"]
149mod tests;