Skip to main content

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}