Skip to main content

paginate_core/sort/
mod.rs

1//! Stable, multi-key in-memory sort with null placement.
2//!
3//! Faithful port of pypaginate's `sorting/`:
4//!
5//! * Each spec contributes a `(null_flag, value)` key; `reverse` (for DESC) is
6//!   applied to the whole key so null placement stays independent of direction.
7//! * Specs are applied in **reverse priority order** with a **stable** sort, so
8//!   the first spec wins ties — identical to Python's repeated `list.sort`.
9//! * A missing field or an explicit null is treated as null (the Python engine
10//!   catches the accessor error); incomparable values raise [`CoreError::Sort`].
11//!
12//! [`sort_indices`] returns a permutation so the binding can reorder the
13//! original host objects without cloning them through the core.
14
15use std::cmp::Ordering;
16
17use crate::accessor::{compile_path, resolve_opt};
18use crate::coerce;
19use crate::error::{CoreError, Result};
20use crate::value::Value;
21
22/// Sort direction.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum SortDirection {
26    /// Ascending order.
27    Asc,
28    /// Descending order.
29    Desc,
30}
31
32impl SortDirection {
33    /// Parse the wire token (`"asc"` / `"desc"`) shared by every binding.
34    ///
35    /// # Errors
36    /// [`CoreError::Sort`] for any other token.
37    pub fn from_token(token: &str) -> Result<Self> {
38        match token {
39            "asc" => Ok(Self::Asc),
40            "desc" => Ok(Self::Desc),
41            other => Err(CoreError::Sort {
42                message: format!("unknown sort direction: {other}"),
43            }),
44        }
45    }
46}
47
48/// Where nulls are placed relative to non-null values.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum NullsPosition {
52    /// Nulls sort before non-null values.
53    First,
54    /// Nulls sort after non-null values.
55    Last,
56}
57
58impl NullsPosition {
59    /// Parse the wire token (`"first"` / `"last"`) shared by every binding.
60    ///
61    /// # Errors
62    /// [`CoreError::Sort`] for any other token.
63    pub fn from_token(token: &str) -> Result<Self> {
64        match token {
65            "first" => Ok(Self::First),
66            "last" => Ok(Self::Last),
67            other => Err(CoreError::Sort {
68                message: format!("unknown nulls position: {other}"),
69            }),
70        }
71    }
72}
73
74/// A single sort key.
75#[derive(Debug, Clone)]
76pub struct SortSpec {
77    /// Dotted field path to sort by.
78    pub field: String,
79    /// Ascending or descending.
80    pub direction: SortDirection,
81    /// Null placement.
82    pub nulls: NullsPosition,
83}
84
85/// Return a permutation of `items` indices sorted by `specs` (first = highest
86/// priority). With no specs the original order is returned.
87///
88/// # Errors
89/// [`CoreError::Filter`] for a path segment starting with `_`, or
90/// [`CoreError::Sort`] when two field values are not order-comparable.
91pub fn sort_indices(items: &[Value], specs: &[SortSpec]) -> Result<Vec<usize>> {
92    sort_indices_of(items, (0..items.len()).collect(), specs)
93}
94
95/// Sort an existing index list (a subset/permutation of `items`) by `specs`,
96/// preserving its relative order for equal keys (stable). The pipeline uses this
97/// to sort a *filtered* subset without disturbing the rest.
98///
99/// # Errors
100/// See [`sort_indices`].
101pub fn sort_indices_of(
102    items: &[Value],
103    mut order: Vec<usize>,
104    specs: &[SortSpec],
105) -> Result<Vec<usize>> {
106    if specs.is_empty() {
107        return Ok(order);
108    }
109    // Apply specs in reverse so the first spec has highest priority under a
110    // stable sort — exactly how the Python engine layers `list.sort` calls.
111    for spec in specs.iter().rev() {
112        let path = compile_path(&spec.field)?;
113        let null_first = null_sorts_first(spec.direction, spec.nulls);
114        let reverse = spec.direction == SortDirection::Desc;
115        // Decorate: resolve each item's sort key ONCE, not on every comparison
116        // (a comparison sort does O(n log n) compares — re-resolving the field
117        // each time dominated the cost). `keys[i]` borrows item `i`'s value.
118        let keys: Vec<Option<&Value>> = items.iter().map(|it| resolve_opt(it, &path)).collect();
119        let mut failure: Option<CoreError> = None;
120        order.sort_by(|&a, &b| {
121            if failure.is_some() {
122                return Ordering::Equal;
123            }
124            match compare_keys(keys[a], keys[b], null_first) {
125                Ok(ordering) if reverse => ordering.reverse(),
126                Ok(ordering) => ordering,
127                Err(err) => {
128                    failure = Some(err);
129                    Ordering::Equal
130                }
131            }
132        });
133        if let Some(err) = failure {
134            return Err(err);
135        }
136    }
137    Ok(order)
138}
139
140/// Convenience wrapper around [`sort_indices`] that clones items into order.
141///
142/// # Errors
143/// See [`sort_indices`].
144pub fn apply(items: &[Value], specs: &[SortSpec]) -> Result<Vec<Value>> {
145    let order = sort_indices(items, specs)?;
146    Ok(order.into_iter().map(|i| items[i].clone()).collect())
147}
148
149fn null_sorts_first(direction: SortDirection, nulls: NullsPosition) -> bool {
150    (nulls == NullsPosition::First) != (direction == SortDirection::Desc)
151}
152
153/// Compare two precomputed keys as the tuple `(null_flag, value)`.
154fn compare_keys(av: Option<&Value>, bv: Option<&Value>, null_first: bool) -> Result<Ordering> {
155    let a_flag = null_first ^ av.is_none();
156    let b_flag = null_first ^ bv.is_none();
157    if a_flag != b_flag {
158        return Ok(a_flag.cmp(&b_flag));
159    }
160    match (av, bv) {
161        (Some(x), Some(y)) => coerce::compare(x, y).ok_or_else(|| CoreError::Sort {
162            message: "field values are not order-comparable".to_owned(),
163        }),
164        _ => Ok(Ordering::Equal),
165    }
166}
167
168#[cfg(test)]
169mod tests;