Skip to main content

cratestack_sql/
order_catalog.rs

1//! Runtime dotted-key resolution for REST `?orderBy=`/`?sort=` keys that
2//! cross to-one relations (`"author.profile.nickname"`).
3//!
4//! Mirrors the type-space-to-value-space move `relation_path` already made
5//! for the typed builder (cratestack#253) — one `OrderCatalog` per model,
6//! carrying only that model's own scalar columns and its own to-one
7//! relation edges, rather than a pre-enumerated list of every dotted path
8//! through the graph. [`resolve_order_target`] walks a key hop by hop
9//! against these catalogs at request time, so codegen for the REST
10//! dispatch surface stays linear in `models × fields` however densely
11//! models are to-one-connected (cratestack#256 — the same exponential
12//! shape as #252, but in the REST string-key match arms rather than the
13//! typed builder's path types).
14
15use crate::relation_path::RelationHop;
16
17/// One model's order-by surface: its own sortable scalar columns
18/// (`(api_name, sql_column)`) and its own to-one relation edges. Exactly
19/// one `OrderCatalog` is emitted per model, regardless of how many
20/// distinct relation paths pass through it.
21pub struct OrderCatalog {
22    pub scalars: &'static [(&'static str, &'static str)],
23    pub relations: &'static [OrderRelationEdge],
24}
25
26/// One to-one relation edge out of a model. `target` points at the
27/// related model's own catalog so [`resolve_order_target`] can keep
28/// walking further segments; to-many relations are never represented
29/// here (mirroring the codegen's existing to-one-only walk), so a key
30/// that names one simply fails to resolve.
31pub struct OrderRelationEdge {
32    pub api_name: &'static str,
33    pub hop: RelationHop,
34    pub target: &'static OrderCatalog,
35}
36
37/// A dotted sort key resolved down to the relation hops to traverse plus
38/// the terminal scalar column, ready for [`crate::order_value_sql`].
39pub struct ResolvedOrderTarget {
40    pub hops: Vec<RelationHop>,
41    pub column: &'static str,
42}
43
44/// Walk `key` (dot-separated, e.g. `"author.profile.nickname"`) through
45/// `catalog`, following to-one relation edges one segment at a time and
46/// resolving the final segment against the current model's scalar
47/// columns.
48///
49/// Returns `None` for an unknown field, a relation segment with no
50/// matching edge (including any to-many hop, which is never present in
51/// the catalog), or a key whose last segment names a relation instead of
52/// a scalar — every one of which the caller reports as the same
53/// "unsupported sort field" validation error as any other bad key.
54pub fn resolve_order_target(
55    catalog: &'static OrderCatalog,
56    key: &str,
57) -> Option<ResolvedOrderTarget> {
58    let mut hops = Vec::new();
59    let mut current = catalog;
60    let mut segments = key.split('.').peekable();
61
62    loop {
63        let segment = segments.next()?;
64        if segments.peek().is_none() {
65            return current
66                .scalars
67                .iter()
68                .find(|(name, _)| *name == segment)
69                .map(|(_, column)| ResolvedOrderTarget { hops, column });
70        }
71        let edge = current
72            .relations
73            .iter()
74            .find(|edge| edge.api_name == segment)?;
75        hops.push(edge.hop);
76        current = edge.target;
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::filter::RelationQuantifier;
84
85    const fn to_one_hop(
86        parent_table: &'static str,
87        parent_column: &'static str,
88        related_table: &'static str,
89        related_column: &'static str,
90    ) -> RelationHop {
91        RelationHop::new(
92            parent_table,
93            parent_column,
94            related_table,
95            related_column,
96            RelationQuantifier::ToOne,
97        )
98    }
99
100    static PROFILE_CATALOG: OrderCatalog = OrderCatalog {
101        scalars: &[("nickname", "nickname")],
102        relations: &[],
103    };
104
105    static USER_CATALOG: OrderCatalog = OrderCatalog {
106        scalars: &[("email", "email")],
107        relations: &[OrderRelationEdge {
108            api_name: "profile",
109            hop: to_one_hop("users", "profile_id", "profiles", "id"),
110            target: &PROFILE_CATALOG,
111        }],
112    };
113
114    static POST_CATALOG: OrderCatalog = OrderCatalog {
115        scalars: &[("id", "id"), ("title", "title")],
116        relations: &[OrderRelationEdge {
117            api_name: "author",
118            hop: to_one_hop("posts", "author_id", "users", "id"),
119            target: &USER_CATALOG,
120        }],
121    };
122
123    #[test]
124    fn resolves_own_scalar_with_no_hops() {
125        let resolved = resolve_order_target(&POST_CATALOG, "title").expect("known scalar");
126        assert!(resolved.hops.is_empty());
127        assert_eq!(resolved.column, "title");
128    }
129
130    #[test]
131    fn resolves_single_hop_relation_scalar() {
132        let resolved = resolve_order_target(&POST_CATALOG, "author.email").expect("known path");
133        assert_eq!(
134            resolved.hops,
135            vec![to_one_hop("posts", "author_id", "users", "id")]
136        );
137        assert_eq!(resolved.column, "email");
138    }
139
140    #[test]
141    fn resolves_nested_two_hop_relation_scalar() {
142        let resolved =
143            resolve_order_target(&POST_CATALOG, "author.profile.nickname").expect("known path");
144        assert_eq!(
145            resolved.hops,
146            vec![
147                to_one_hop("posts", "author_id", "users", "id"),
148                to_one_hop("users", "profile_id", "profiles", "id"),
149            ]
150        );
151        assert_eq!(resolved.column, "nickname");
152    }
153
154    #[test]
155    fn resolved_hops_render_the_expected_nested_correlated_subquery() {
156        // Closes the loop between "the resolver walked the right edges"
157        // (above) and "those hops render the right SQL" -- the REST
158        // dispatcher's only other consumer of `resolved.hops` is
159        // `order_value_sql`, exercised here with the exact same output
160        // `resolve_order_target` produces for a two-hop key. Mirrors the
161        // typed-builder assertion in
162        // `cratestack-pg/tests/include_schema.rs`'s
163        // `generated_nested_relation_order_preview_renders_nested_subqueries`,
164        // which hits the identical `order_value_sql` primitive through the
165        // chained-accessor path instead of the REST dotted-key path.
166        let resolved =
167            resolve_order_target(&POST_CATALOG, "author.profile.nickname").expect("known path");
168        assert_eq!(
169            crate::order_value_sql(&resolved.hops, resolved.column),
170            "(SELECT profiles.nickname FROM profiles \
171             WHERE profiles.id = users.profile_id LIMIT 1)",
172        );
173    }
174
175    #[test]
176    fn rejects_unknown_top_level_field() {
177        assert!(resolve_order_target(&POST_CATALOG, "unknownField").is_none());
178    }
179
180    #[test]
181    fn rejects_unknown_relation_segment() {
182        assert!(resolve_order_target(&POST_CATALOG, "editor.email").is_none());
183    }
184
185    #[test]
186    fn rejects_a_relation_named_key_with_no_terminal_scalar() {
187        // "author" alone names a relation, not a scalar -- same
188        // "unsupported sort field" outcome as any other unresolved key.
189        assert!(resolve_order_target(&POST_CATALOG, "author").is_none());
190    }
191
192    #[test]
193    fn rejects_a_to_many_hop_because_it_is_never_in_the_catalog() {
194        // The macro only ever emits to-one edges into `relations`, so a
195        // key naming a to-many relation (e.g. "sessions") simply has no
196        // matching edge -- exercised end to end in
197        // `cratestack-pg/tests/include_schema.rs`'s
198        // `axum_model_route_rejects_to_many_relation_order_by`.
199        assert!(resolve_order_target(&USER_CATALOG, "sessions.label").is_none());
200    }
201}