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 }
203 }
204
205 // -----------------------------------------------------------------------
206 // Serde roundtrip tests
207 // -----------------------------------------------------------------------
208
209 /// `Direction` serialises to `"asc"` / `"desc"` (lowercase) and
210 /// deserialises back to the correct variant.
211 #[test]
212 fn direction_serde_roundtrip() {
213 // Asc → "asc"
214 let asc_json = serde_json::to_string(&Direction::Asc).unwrap();
215 assert_eq!(asc_json, r#""asc""#);
216 let asc_back: Direction = serde_json::from_str(&asc_json).unwrap();
217 assert_eq!(asc_back, Direction::Asc);
218
219 // Desc → "desc"
220 let desc_json = serde_json::to_string(&Direction::Desc).unwrap();
221 assert_eq!(desc_json, r#""desc""#);
222 let desc_back: Direction = serde_json::from_str(&desc_json).unwrap();
223 assert_eq!(desc_back, Direction::Desc);
224 }
225
226 /// `OrderByItem` roundtrips through JSON with the literal form used in the
227 /// issue specification.
228 #[test]
229 fn order_by_item_serde_roundtrip() {
230 let json = r#"{"field": "priority", "direction": "asc"}"#;
231 let item: OrderByItem = serde_json::from_str(json).unwrap();
232 assert_eq!(item.field, "priority");
233 assert_eq!(item.direction, Direction::Asc);
234
235 let re_serialised = serde_json::to_value(&item).unwrap();
236 assert_eq!(re_serialised["field"], "priority");
237 assert_eq!(re_serialised["direction"], "asc");
238 }
239
240 // -----------------------------------------------------------------------
241 // validate_order_by tests
242 // -----------------------------------------------------------------------
243
244 /// A valid single-field order_by passes validation.
245 #[test]
246 fn validate_order_by_ok() {
247 let schema = make_schema(&["priority", "due", "status"]);
248 let items = vec![OrderByItem {
249 field: "priority".to_string(),
250 direction: Direction::Asc,
251 }];
252 assert!(validate_order_by(&items, &schema).is_ok());
253 }
254
255 /// An unknown field name is rejected with a reason containing "unknown field".
256 #[test]
257 fn validate_order_by_unknown_field_reject() {
258 let schema = make_schema(&["priority", "due"]);
259 let items = vec![OrderByItem {
260 field: "nonexistent".to_string(),
261 direction: Direction::Asc,
262 }];
263 let err = validate_order_by(&items, &schema).unwrap_err();
264 match &err {
265 MiniAppError::Validation { field, reason } => {
266 assert_eq!(field, "nonexistent");
267 assert!(
268 reason.contains("unknown field"),
269 "reason should contain 'unknown field', got: {reason}"
270 );
271 }
272 other => panic!("expected Validation error, got: {other:?}"),
273 }
274 }
275
276 /// An empty slice is rejected with a reason containing "must not be empty".
277 #[test]
278 fn validate_order_by_empty_reject() {
279 let schema = make_schema(&["priority"]);
280 let err = validate_order_by(&[], &schema).unwrap_err();
281 match &err {
282 MiniAppError::Validation { field, reason } => {
283 assert_eq!(field, "order_by");
284 assert!(
285 reason.contains("must not be empty"),
286 "reason should contain 'must not be empty', got: {reason}"
287 );
288 }
289 other => panic!("expected Validation error, got: {other:?}"),
290 }
291 }
292
293 // -----------------------------------------------------------------------
294 // build_order_by_sql tests
295 // -----------------------------------------------------------------------
296
297 /// Single-key ASC produces the expected literal.
298 #[test]
299 fn build_order_by_sql_single_asc() {
300 let items = vec![OrderByItem {
301 field: "priority".to_string(),
302 direction: Direction::Asc,
303 }];
304 let sql = build_order_by_sql(&items);
305 assert_eq!(sql, "json_extract(data, '$.priority') ASC");
306 }
307
308 /// Single-key DESC produces the expected literal.
309 #[test]
310 fn build_order_by_sql_single_desc() {
311 let items = vec![OrderByItem {
312 field: "priority".to_string(),
313 direction: Direction::Desc,
314 }];
315 let sql = build_order_by_sql(&items);
316 assert_eq!(sql, "json_extract(data, '$.priority') DESC");
317 }
318
319 /// Multi-key produces comma-separated entries preserving caller order.
320 ///
321 /// Crux: `priority ASC, due ASC` matches the issue specification example.
322 #[test]
323 fn build_order_by_sql_multi_key() {
324 let items = vec![
325 OrderByItem {
326 field: "priority".to_string(),
327 direction: Direction::Asc,
328 },
329 OrderByItem {
330 field: "due".to_string(),
331 direction: Direction::Asc,
332 },
333 ];
334 let sql = build_order_by_sql(&items);
335 assert_eq!(
336 sql,
337 "json_extract(data, '$.priority') ASC, json_extract(data, '$.due') ASC"
338 );
339 }
340
341 /// Multi-key with mixed directions preserves order and direction literals.
342 #[test]
343 fn build_order_by_sql_multi_key_mixed_directions() {
344 let items = vec![
345 OrderByItem {
346 field: "priority".to_string(),
347 direction: Direction::Asc,
348 },
349 OrderByItem {
350 field: "created_at".to_string(),
351 direction: Direction::Desc,
352 },
353 ];
354 let sql = build_order_by_sql(&items);
355 assert_eq!(
356 sql,
357 "json_extract(data, '$.priority') ASC, json_extract(data, '$.created_at') DESC"
358 );
359 }
360
361 // -----------------------------------------------------------------------
362 // schemars JsonSchema derivation test
363 // -----------------------------------------------------------------------
364
365 /// Verify that `schemars::schema_for!(Vec<OrderByItem>)` does not panic.
366 ///
367 /// Confirms that the `JsonSchema` derive on `OrderByItem` and `Direction`
368 /// produces valid schemas (no infinite recursion, no unsupported types).
369 #[test]
370 fn schema_for_order_by_succeeds() {
371 // Must not panic.
372 let _schema = schemars::schema_for!(Vec<OrderByItem>);
373 }
374}