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}