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        }
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}