use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::MiniAppError;
use crate::schema::SchemaConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Asc,
Desc,
}
impl Direction {
#[inline]
pub fn as_sql_literal(self) -> &'static str {
match self {
Direction::Asc => "ASC",
Direction::Desc => "DESC",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct OrderByItem {
pub field: String,
pub direction: Direction,
}
pub fn validate_order_by(items: &[OrderByItem], schema: &SchemaConfig) -> Result<(), MiniAppError> {
if items.is_empty() {
return Err(MiniAppError::Validation {
field: "order_by".to_string(),
reason: "order_by must not be empty when supplied \
(omit the argument to use the default created_at DESC)"
.to_string(),
});
}
for item in items {
if !schema.fields.iter().any(|f| f.name == item.field) {
return Err(MiniAppError::Validation {
field: item.field.clone(),
reason: format!(
"unknown field '{}' — only schema-registered fields are allowed in order_by",
item.field
),
});
}
}
Ok(())
}
pub fn build_order_by_sql(items: &[OrderByItem]) -> String {
let parts: Vec<String> = items
.iter()
.map(|item| {
format!(
"json_extract(data, '$.{}') {}",
item.field,
item.direction.as_sql_literal()
)
})
.collect();
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{FieldDef, FieldType, SchemaConfig};
fn make_schema(field_names: &[&str]) -> SchemaConfig {
SchemaConfig {
table: "test_table".to_string(),
title: None,
description: None,
fields: field_names
.iter()
.map(|name| FieldDef {
name: name.to_string(),
ty: FieldType::String,
required: false,
description: None,
})
.collect(),
dump: None,
}
}
#[test]
fn direction_serde_roundtrip() {
let asc_json = serde_json::to_string(&Direction::Asc).unwrap();
assert_eq!(asc_json, r#""asc""#);
let asc_back: Direction = serde_json::from_str(&asc_json).unwrap();
assert_eq!(asc_back, Direction::Asc);
let desc_json = serde_json::to_string(&Direction::Desc).unwrap();
assert_eq!(desc_json, r#""desc""#);
let desc_back: Direction = serde_json::from_str(&desc_json).unwrap();
assert_eq!(desc_back, Direction::Desc);
}
#[test]
fn order_by_item_serde_roundtrip() {
let json = r#"{"field": "priority", "direction": "asc"}"#;
let item: OrderByItem = serde_json::from_str(json).unwrap();
assert_eq!(item.field, "priority");
assert_eq!(item.direction, Direction::Asc);
let re_serialised = serde_json::to_value(&item).unwrap();
assert_eq!(re_serialised["field"], "priority");
assert_eq!(re_serialised["direction"], "asc");
}
#[test]
fn validate_order_by_ok() {
let schema = make_schema(&["priority", "due", "status"]);
let items = vec![OrderByItem {
field: "priority".to_string(),
direction: Direction::Asc,
}];
assert!(validate_order_by(&items, &schema).is_ok());
}
#[test]
fn validate_order_by_unknown_field_reject() {
let schema = make_schema(&["priority", "due"]);
let items = vec![OrderByItem {
field: "nonexistent".to_string(),
direction: Direction::Asc,
}];
let err = validate_order_by(&items, &schema).unwrap_err();
match &err {
MiniAppError::Validation { field, reason } => {
assert_eq!(field, "nonexistent");
assert!(
reason.contains("unknown field"),
"reason should contain 'unknown field', got: {reason}"
);
}
other => panic!("expected Validation error, got: {other:?}"),
}
}
#[test]
fn validate_order_by_empty_reject() {
let schema = make_schema(&["priority"]);
let err = validate_order_by(&[], &schema).unwrap_err();
match &err {
MiniAppError::Validation { field, reason } => {
assert_eq!(field, "order_by");
assert!(
reason.contains("must not be empty"),
"reason should contain 'must not be empty', got: {reason}"
);
}
other => panic!("expected Validation error, got: {other:?}"),
}
}
#[test]
fn build_order_by_sql_single_asc() {
let items = vec![OrderByItem {
field: "priority".to_string(),
direction: Direction::Asc,
}];
let sql = build_order_by_sql(&items);
assert_eq!(sql, "json_extract(data, '$.priority') ASC");
}
#[test]
fn build_order_by_sql_single_desc() {
let items = vec![OrderByItem {
field: "priority".to_string(),
direction: Direction::Desc,
}];
let sql = build_order_by_sql(&items);
assert_eq!(sql, "json_extract(data, '$.priority') DESC");
}
#[test]
fn build_order_by_sql_multi_key() {
let items = vec![
OrderByItem {
field: "priority".to_string(),
direction: Direction::Asc,
},
OrderByItem {
field: "due".to_string(),
direction: Direction::Asc,
},
];
let sql = build_order_by_sql(&items);
assert_eq!(
sql,
"json_extract(data, '$.priority') ASC, json_extract(data, '$.due') ASC"
);
}
#[test]
fn build_order_by_sql_multi_key_mixed_directions() {
let items = vec![
OrderByItem {
field: "priority".to_string(),
direction: Direction::Asc,
},
OrderByItem {
field: "created_at".to_string(),
direction: Direction::Desc,
},
];
let sql = build_order_by_sql(&items);
assert_eq!(
sql,
"json_extract(data, '$.priority') ASC, json_extract(data, '$.created_at') DESC"
);
}
#[test]
fn schema_for_order_by_succeeds() {
let _schema = schemars::schema_for!(Vec<OrderByItem>);
}
}