mini_app_core/order_by.rs
1/// `OrderByItem` and `Direction` — server-side ORDER BY primitive for the
2/// `list` tool.
3///
4/// Sort keys are validated against the table's `schema.yaml` before any SQL
5/// is generated. Unknown field names are rejected with
6/// [`crate::error::MiniAppError::Validation`]. An empty `order_by` slice is
7/// also rejected (callers should omit the argument entirely to use the default
8/// `ORDER BY created_at DESC`).
9///
10/// # SQL injection safety
11///
12/// `build_order_by_sql` is **infallible** and emits only pre-validated field
13/// names (schema-whitelist-checked by `validate_order_by`) combined with an
14/// enum-to-literal direction (`"ASC"` / `"DESC"`). No external string is
15/// interpolated without prior validation. Direction keywords are **not** bound
16/// via `?` parameters because SQLite treats `ORDER BY` direction as a SQL
17/// syntax keyword, not a parameterisable value.
18///
19/// # Crux constraints
20/// - multi-key sort via `Vec<OrderByItem>` preserves caller-specified order.
21/// - `Direction::Asc` → `"ASC"` / `Direction::Desc` → `"DESC"` literals only.
22/// - No `HashMap` / `BTreeMap` representation (order guarantee lost).
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::error::MiniAppError;
27use crate::schema::SchemaConfig;
28
29// ---------------------------------------------------------------------------
30// Direction enum
31// ---------------------------------------------------------------------------
32
33/// Sort direction for a single [`OrderByItem`].
34///
35/// Serialised as a lowercase string (`"asc"` / `"desc"`) so the JSON wire
36/// format matches the issue specification examples.
37///
38/// # Example JSON
39/// ```json
40/// {"field": "priority", "direction": "asc"}
41/// ```
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
43#[serde(rename_all = "lowercase")]
44pub enum Direction {
45 /// Sort ascending: smallest value first.
46 Asc,
47 /// Sort descending: largest value first.
48 Desc,
49}
50
51impl Direction {
52 /// Returns the SQL keyword literal for this direction.
53 ///
54 /// Used inside [`build_order_by_sql`] to construct the ORDER BY clause.
55 /// Returns `"ASC"` or `"DESC"` (uppercase, as conventional in SQL).
56 #[inline]
57 pub fn as_sql_literal(self) -> &'static str {
58 match self {
59 Direction::Asc => "ASC",
60 Direction::Desc => "DESC",
61 }
62 }
63}
64
65// ---------------------------------------------------------------------------
66// OrderByItem struct
67// ---------------------------------------------------------------------------
68
69/// A single sort key for the `order_by` argument of [`crate::store::Store::list`].
70///
71/// `field` must be a name registered in `schema.yaml`; it is validated by
72/// [`validate_order_by`] before any SQL is generated. `direction` controls
73/// whether the sort is ascending or descending.
74///
75/// # Example JSON
76/// ```json
77/// {"field": "priority", "direction": "asc"}
78/// ```
79#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
80pub struct OrderByItem {
81 /// Schema-registered field name to sort by.
82 ///
83 /// Must exist in `schema.yaml`; unknown names are rejected by
84 /// [`validate_order_by`] with [`MiniAppError::Validation`].
85 pub field: String,
86 /// Sort direction: `"asc"` (ascending) or `"desc"` (descending).
87 pub direction: Direction,
88}
89
90// ---------------------------------------------------------------------------
91// validate_order_by
92// ---------------------------------------------------------------------------
93
94/// Validate a slice of [`OrderByItem`] values against a table schema.
95///
96/// Checks:
97/// 1. The slice is non-empty (an empty `order_by` is ambiguous; callers should
98/// omit the argument to use the default `ORDER BY created_at DESC`).
99/// 2. Each `field` name exists in `schema.yaml`.
100///
101/// Type-checking is **not** performed: SQLite's `json_extract(data, '$.field')`
102/// supports `ORDER BY` across all JSON types, so restricting by type would
103/// produce spurious errors and add no safety value.
104///
105/// # Errors
106///
107/// Returns [`MiniAppError::Validation`] when:
108/// - `items` is empty — `field` is `"order_by"` and `reason` contains
109/// `"must not be empty"`.
110/// - An item's `field` is not registered in the schema — `field` is the
111/// unknown name and `reason` contains `"unknown field"`.
112pub fn validate_order_by(items: &[OrderByItem], schema: &SchemaConfig) -> Result<(), MiniAppError> {
113 if items.is_empty() {
114 return Err(MiniAppError::Validation {
115 field: "order_by".to_string(),
116 reason: "order_by must not be empty when supplied \
117 (omit the argument to use the default created_at DESC)"
118 .to_string(),
119 });
120 }
121 for item in items {
122 if !schema.fields.iter().any(|f| f.name == item.field) {
123 return Err(MiniAppError::Validation {
124 field: item.field.clone(),
125 reason: format!(
126 "unknown field '{}' — only schema-registered fields are allowed in order_by",
127 item.field
128 ),
129 });
130 }
131 }
132 Ok(())
133}
134
135// ---------------------------------------------------------------------------
136// build_order_by_sql
137// ---------------------------------------------------------------------------
138
139/// Build a SQL ORDER BY clause body from a validated slice of [`OrderByItem`].
140///
141/// # Safety invariant
142///
143/// **Must only be called after [`validate_order_by`] returns `Ok`.** Field
144/// names are interpolated as SQL literals (inside `json_extract` paths) and
145/// must have been verified against the schema whitelist first. Direction
146/// keywords are produced by [`Direction::as_sql_literal`] — no external string
147/// is ever used for the direction.
148///
149/// # Returns
150///
151/// A `String` in the form:
152/// ```text
153/// json_extract(data, '$.field1') ASC, json_extract(data, '$.field2') DESC
154/// ```
155///
156/// The string does **not** include the `ORDER BY` keyword itself; the caller
157/// wraps it as needed (e.g. `format!(" ORDER BY {}", build_order_by_sql(…))`).
158///
159/// # Panics
160///
161/// Does not panic. `items` must be non-empty (ensured by `validate_order_by`);
162/// an empty slice produces an empty string.
163pub fn build_order_by_sql(items: &[OrderByItem]) -> String {
164 let parts: Vec<String> = items
165 .iter()
166 .map(|item| {
167 format!(
168 "json_extract(data, '$.{}') {}",
169 item.field,
170 item.direction.as_sql_literal()
171 )
172 })
173 .collect();
174 parts.join(", ")
175}
176
177// ---------------------------------------------------------------------------
178// Tests
179// ---------------------------------------------------------------------------
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::schema::{FieldDef, FieldType, SchemaConfig};
185
186 /// Build a minimal [`SchemaConfig`] with the given field names (all `string` type).
187 fn make_schema(field_names: &[&str]) -> SchemaConfig {
188 SchemaConfig {
189 table: "test_table".to_string(),
190 title: None,
191 description: None,
192 fields: field_names
193 .iter()
194 .map(|name| FieldDef {
195 name: name.to_string(),
196 ty: FieldType::String,
197 required: false,
198 description: None,
199 })
200 .collect(),
201 dump: None,
202 history: Default::default(),
203 }
204 }
205
206 // -----------------------------------------------------------------------
207 // Serde roundtrip tests
208 // -----------------------------------------------------------------------
209
210 /// `Direction` serialises to `"asc"` / `"desc"` (lowercase) and
211 /// deserialises back to the correct variant.
212 #[test]
213 fn direction_serde_roundtrip() {
214 // Asc → "asc"
215 let asc_json = serde_json::to_string(&Direction::Asc).unwrap();
216 assert_eq!(asc_json, r#""asc""#);
217 let asc_back: Direction = serde_json::from_str(&asc_json).unwrap();
218 assert_eq!(asc_back, Direction::Asc);
219
220 // Desc → "desc"
221 let desc_json = serde_json::to_string(&Direction::Desc).unwrap();
222 assert_eq!(desc_json, r#""desc""#);
223 let desc_back: Direction = serde_json::from_str(&desc_json).unwrap();
224 assert_eq!(desc_back, Direction::Desc);
225 }
226
227 /// `OrderByItem` roundtrips through JSON with the literal form used in the
228 /// issue specification.
229 #[test]
230 fn order_by_item_serde_roundtrip() {
231 let json = r#"{"field": "priority", "direction": "asc"}"#;
232 let item: OrderByItem = serde_json::from_str(json).unwrap();
233 assert_eq!(item.field, "priority");
234 assert_eq!(item.direction, Direction::Asc);
235
236 let re_serialised = serde_json::to_value(&item).unwrap();
237 assert_eq!(re_serialised["field"], "priority");
238 assert_eq!(re_serialised["direction"], "asc");
239 }
240
241 // -----------------------------------------------------------------------
242 // validate_order_by tests
243 // -----------------------------------------------------------------------
244
245 /// A valid single-field order_by passes validation.
246 #[test]
247 fn validate_order_by_ok() {
248 let schema = make_schema(&["priority", "due", "status"]);
249 let items = vec![OrderByItem {
250 field: "priority".to_string(),
251 direction: Direction::Asc,
252 }];
253 assert!(validate_order_by(&items, &schema).is_ok());
254 }
255
256 /// An unknown field name is rejected with a reason containing "unknown field".
257 #[test]
258 fn validate_order_by_unknown_field_reject() {
259 let schema = make_schema(&["priority", "due"]);
260 let items = vec![OrderByItem {
261 field: "nonexistent".to_string(),
262 direction: Direction::Asc,
263 }];
264 let err = validate_order_by(&items, &schema).unwrap_err();
265 match &err {
266 MiniAppError::Validation { field, reason } => {
267 assert_eq!(field, "nonexistent");
268 assert!(
269 reason.contains("unknown field"),
270 "reason should contain 'unknown field', got: {reason}"
271 );
272 }
273 other => panic!("expected Validation error, got: {other:?}"),
274 }
275 }
276
277 /// An empty slice is rejected with a reason containing "must not be empty".
278 #[test]
279 fn validate_order_by_empty_reject() {
280 let schema = make_schema(&["priority"]);
281 let err = validate_order_by(&[], &schema).unwrap_err();
282 match &err {
283 MiniAppError::Validation { field, reason } => {
284 assert_eq!(field, "order_by");
285 assert!(
286 reason.contains("must not be empty"),
287 "reason should contain 'must not be empty', got: {reason}"
288 );
289 }
290 other => panic!("expected Validation error, got: {other:?}"),
291 }
292 }
293
294 // -----------------------------------------------------------------------
295 // build_order_by_sql tests
296 // -----------------------------------------------------------------------
297
298 /// Single-key ASC produces the expected literal.
299 #[test]
300 fn build_order_by_sql_single_asc() {
301 let items = vec![OrderByItem {
302 field: "priority".to_string(),
303 direction: Direction::Asc,
304 }];
305 let sql = build_order_by_sql(&items);
306 assert_eq!(sql, "json_extract(data, '$.priority') ASC");
307 }
308
309 /// Single-key DESC produces the expected literal.
310 #[test]
311 fn build_order_by_sql_single_desc() {
312 let items = vec![OrderByItem {
313 field: "priority".to_string(),
314 direction: Direction::Desc,
315 }];
316 let sql = build_order_by_sql(&items);
317 assert_eq!(sql, "json_extract(data, '$.priority') DESC");
318 }
319
320 /// Multi-key produces comma-separated entries preserving caller order.
321 ///
322 /// Crux: `priority ASC, due ASC` matches the issue specification example.
323 #[test]
324 fn build_order_by_sql_multi_key() {
325 let items = vec![
326 OrderByItem {
327 field: "priority".to_string(),
328 direction: Direction::Asc,
329 },
330 OrderByItem {
331 field: "due".to_string(),
332 direction: Direction::Asc,
333 },
334 ];
335 let sql = build_order_by_sql(&items);
336 assert_eq!(
337 sql,
338 "json_extract(data, '$.priority') ASC, json_extract(data, '$.due') ASC"
339 );
340 }
341
342 /// Multi-key with mixed directions preserves order and direction literals.
343 #[test]
344 fn build_order_by_sql_multi_key_mixed_directions() {
345 let items = vec![
346 OrderByItem {
347 field: "priority".to_string(),
348 direction: Direction::Asc,
349 },
350 OrderByItem {
351 field: "created_at".to_string(),
352 direction: Direction::Desc,
353 },
354 ];
355 let sql = build_order_by_sql(&items);
356 assert_eq!(
357 sql,
358 "json_extract(data, '$.priority') ASC, json_extract(data, '$.created_at') DESC"
359 );
360 }
361
362 // -----------------------------------------------------------------------
363 // schemars JsonSchema derivation test
364 // -----------------------------------------------------------------------
365
366 /// Verify that `schemars::schema_for!(Vec<OrderByItem>)` does not panic.
367 ///
368 /// Confirms that the `JsonSchema` derive on `OrderByItem` and `Direction`
369 /// produces valid schemas (no infinite recursion, no unsupported types).
370 #[test]
371 fn schema_for_order_by_succeeds() {
372 // Must not panic.
373 let _schema = schemars::schema_for!(Vec<OrderByItem>);
374 }
375}