use crate::CqrsError;
use serde::{Deserialize, Serialize};
#[cfg(feature = "utoipa")]
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
pub struct Sorter {
pub field: String,
pub direction: SortDirection,
}
impl Sorter {
pub fn validated_field(&self) -> Result<&str, CqrsError> {
let field = self.field.as_str();
let valid = !field.is_empty()
&& field.split('.').all(|segment| {
let mut chars = segment.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
});
if valid {
Ok(field)
} else {
Err(CqrsError::validation(format!(
"sort field {field:?} is not a valid field name: expected `.`-separated \
segments of [A-Za-z_][A-Za-z0-9_]*"
)))
}
}
}
#[cfg(any(feature = "postgres", feature = "surrealdb"))]
pub(crate) fn order_by_clause(
sort: Option<Vec<Sorter>>,
mapper: &impl rest_sql::FieldMapper,
) -> Result<String, CqrsError> {
let sorters = match sort {
Some(s) if !s.is_empty() => s,
_ => return Ok(String::new()),
};
let parts: Vec<String> = sorters
.iter()
.map(|s| {
let field = mapper.map(s.validated_field()?);
let dir = match s.direction {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
Ok(format!("{} {}", field, dir))
})
.collect::<Result<_, CqrsError>>()?;
Ok(format!(" ORDER BY {}", parts.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
fn sorter(field: &str) -> Sorter {
Sorter {
field: field.to_string(),
direction: SortDirection::Asc,
}
}
#[test]
fn plain_identifiers_are_accepted() {
for field in ["id", "created_at", "_private", "a", "f1", "A_1"] {
assert_eq!(
sorter(field).validated_field().unwrap(),
field,
"{field} is a plain identifier"
);
}
}
#[test]
fn dotted_paths_are_accepted() {
for field in ["muscle.primary", "a.b.c", "_a._b"] {
assert_eq!(sorter(field).validated_field().unwrap(), field);
}
}
#[test]
fn the_measured_injection_payload_is_rejected() {
let hostile = "1 UNION ALL SELECT data FROM secrets--";
let err = sorter(hostile).validated_field().unwrap_err();
assert!(
err.message.contains(hostile),
"the error must name the offending field, got: {}",
err.message
);
}
#[test]
fn anything_that_is_not_an_identifier_is_rejected() {
let hostile = [
"", " ", "id DESC", "id--", "\"id\"", "id;DROP TABLE t", "count(*)", "1", "1id", "-id", "id.", ".id", "a..b", "data->>'x'", "id\nDESC", "café", ];
for field in hostile {
let Err(err) = sorter(field).validated_field() else {
panic!("{field:?} must be rejected")
};
assert!(
err.message.contains(&format!("{field:?}")),
"the error must name {field:?}, got: {}",
err.message
);
}
}
#[test]
fn the_error_is_a_client_error_not_a_server_error() {
let err = sorter("id DESC").validated_field().unwrap_err();
assert_eq!(err.status, 400, "a bad sort field is the caller's mistake");
}
#[cfg(any(feature = "postgres", feature = "surrealdb"))]
mod order_by_clause_tests {
use super::{sorter, *};
use rest_sql::IdentityMapper;
#[test]
fn a_valid_sort_compiles_to_the_expected_clause() {
assert_eq!(
order_by_clause(Some(vec![sorter("id")]), &IdentityMapper).unwrap(),
" ORDER BY id ASC"
);
assert_eq!(
order_by_clause(
Some(vec![
sorter("created_at"),
Sorter {
field: "title".into(),
direction: SortDirection::Desc,
},
]),
&IdentityMapper
)
.unwrap(),
" ORDER BY created_at ASC, title DESC"
);
}
#[test]
fn no_sort_compiles_to_no_clause() {
assert_eq!(order_by_clause(None, &IdentityMapper).unwrap(), "");
assert_eq!(order_by_clause(Some(vec![]), &IdentityMapper).unwrap(), "");
}
#[test]
fn a_hostile_sort_field_never_reaches_the_clause() {
let hostile = "1 UNION ALL SELECT data FROM secrets--";
let err = order_by_clause(Some(vec![sorter(hostile)]), &IdentityMapper).unwrap_err();
assert_eq!(err.code, "GENERIC_VALIDATION_FAILED");
assert!(err.message.contains(hostile));
}
}
}