Skip to main content

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}