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; only order rendering builds a
19//! `String`, because the correlated-subquery chain is genuinely
20//! path-dependent.
21
22use crate::filter::{FilterExpr, RelationQuantifier};
23
24/// Marker for a path whose hops are all to-one, so a scalar at the end of
25/// it can be rendered as a correlated subquery and used for ordering.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Orderable;
28
29/// Marker for a path that has crossed a to-many hop. Ordering accessors are
30/// not implemented for this marker, which reproduces the old guarantee that
31/// `asc()`/`desc()` simply did not exist past a to-many relation — a
32/// compile error, not a runtime failure.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Unorderable;
35
36/// One traversed relation edge: the FK linkage plus how the related rows
37/// are quantified (`ToOne` for a plain to-one hop, `Some`/`Every`/`None`
38/// for a to-many hop under a quantifier).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct RelationHop {
41 pub parent_table: &'static str,
42 pub parent_column: &'static str,
43 pub related_table: &'static str,
44 pub related_column: &'static str,
45 pub quantifier: RelationQuantifier,
46}
47
48impl RelationHop {
49 pub const fn new(
50 parent_table: &'static str,
51 parent_column: &'static str,
52 related_table: &'static str,
53 related_column: &'static str,
54 quantifier: RelationQuantifier,
55 ) -> Self {
56 Self {
57 parent_table,
58 parent_column,
59 related_table,
60 related_column,
61 quantifier,
62 }
63 }
64
65 /// Same linkage, re-quantified. Used when a to-many hop is recorded
66 /// before the caller has picked `some`/`every`/`none`.
67 pub const fn with_quantifier(self, quantifier: RelationQuantifier) -> Self {
68 Self { quantifier, ..self }
69 }
70}
71
72/// Fold a scalar `FilterExpr` outward through the traversed path, applying
73/// each hop's quantifier. Mirrors what the macro previously emitted as
74/// nested `FilterExpr::relation*(...)` token trees.
75pub fn wrap_filter(hops: &[RelationHop], inner: FilterExpr) -> FilterExpr {
76 hops.iter()
77 .rev()
78 .fold(inner, |acc, hop| match hop.quantifier {
79 RelationQuantifier::ToOne => FilterExpr::relation(
80 hop.parent_table,
81 hop.parent_column,
82 hop.related_table,
83 hop.related_column,
84 acc,
85 ),
86 RelationQuantifier::Some => FilterExpr::relation_some(
87 hop.parent_table,
88 hop.parent_column,
89 hop.related_table,
90 hop.related_column,
91 acc,
92 ),
93 RelationQuantifier::Every => FilterExpr::relation_every(
94 hop.parent_table,
95 hop.parent_column,
96 hop.related_table,
97 hop.related_column,
98 acc,
99 ),
100 RelationQuantifier::None => FilterExpr::relation_none(
101 hop.parent_table,
102 hop.parent_column,
103 hop.related_table,
104 hop.related_column,
105 acc,
106 ),
107 })
108}
109
110/// Build the correlated-subquery expression that yields `column` at the end
111/// of `hops`, relative to the table reached by the *first* hop.
112///
113/// `hops[0]` is carried on the `OrderClause` itself (it becomes the clause's
114/// parent/related linkage), so only `hops[1..]` are nested here — matching
115/// the shape the macro used to compute at expansion time.
116///
117/// Panics if `hops` is empty; callers only reach this from a generated
118/// accessor that has traversed at least one relation.
119pub fn order_value_sql(hops: &[RelationHop], column: &str) -> String {
120 assert!(
121 !hops.is_empty(),
122 "order_value_sql requires at least one relation hop",
123 );
124 let mut sql = format!("{}.{}", hops[hops.len() - 1].related_table, column,);
125 for index in (1..hops.len()).rev() {
126 let hop = &hops[index];
127 let current_table = hops[index - 1].related_table;
128 sql = format!(
129 "(SELECT {} FROM {} WHERE {}.{} = {}.{} LIMIT 1)",
130 sql,
131 hop.related_table,
132 hop.related_table,
133 hop.related_column,
134 current_table,
135 hop.parent_column,
136 );
137 }
138 sql
139}
140
141/// Whether every hop is to-one. Ordering through a to-many hop is not
142/// expressible as a scalar correlated subquery, so generated `asc()`/
143/// `desc()` accessors are gated on this (previously enforced by simply not
144/// emitting those methods past a to-many hop).
145pub fn is_orderable(hops: &[RelationHop]) -> bool {
146 hops.iter()
147 .all(|hop| matches!(hop.quantifier, RelationQuantifier::ToOne))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 const fn to_one(
155 parent_table: &'static str,
156 parent_column: &'static str,
157 related_table: &'static str,
158 related_column: &'static str,
159 ) -> RelationHop {
160 RelationHop::new(
161 parent_table,
162 parent_column,
163 related_table,
164 related_column,
165 RelationQuantifier::ToOne,
166 )
167 }
168
169 #[test]
170 fn single_hop_reads_the_related_table_directly() {
171 let hops = [to_one("posts", "author_id", "users", "id")];
172 assert_eq!(order_value_sql(&hops, "email"), "users.email");
173 }
174
175 #[test]
176 fn two_hops_nest_a_correlated_subquery() {
177 let hops = [
178 to_one("posts", "author_id", "users", "id"),
179 to_one("users", "profile_id", "profiles", "id"),
180 ];
181 assert_eq!(
182 order_value_sql(&hops, "nickname"),
183 "(SELECT profiles.nickname FROM profiles \
184 WHERE profiles.id = users.profile_id LIMIT 1)",
185 );
186 }
187
188 #[test]
189 fn a_to_many_hop_makes_the_path_unorderable() {
190 let hops = [
191 to_one("posts", "author_id", "users", "id"),
192 RelationHop::new(
193 "users",
194 "id",
195 "comments",
196 "user_id",
197 RelationQuantifier::Some,
198 ),
199 ];
200 assert!(!is_orderable(&hops));
201 assert!(is_orderable(&hops[..1]));
202 }
203}