Skip to main content

paginate_core/columnar/
mod.rs

1//! Typed columns for a fast single-field filter and single-key sort path.
2//!
3//! The row engine resolves a `BTreeMap` lookup per item per field and dispatches
4//! on `Value`; for a tight `age >= 500` / `name == "x"` / `price < 9.99` query
5//! that overhead dominates. When a field holds the **same scalar type in every
6//! row**, we keep it as a dense typed `Vec` (`i64`/`f64`/`String`) and scan it
7//! directly — no map lookup, no `Value` dispatch.
8//!
9//! Correctness over cleverness. A column is built **only** if the field is that
10//! exact scalar in every row (no missing, null, mixed, or — for floats — `NaN`),
11//! and a fast path is taken **only** for operators whose typed comparison
12//! provably equals the row engine's [`coerce`] semantics:
13//!
14//! * **Int** — exact `i64` order and equality (matches `coerce::compare`'s exact
15//!   `(Int, Int)` arm and, after the matching fix, `coerce::eq`).
16//! * **Float** — `f64` order/equality after coercing the needle through
17//!   [`coerce::as_number`] (so `price > 5` works); a `NaN` needle or column is
18//!   never fast-pathed (it would error/short-circuit in the row engine).
19//! * **Str** — byte order (== Unicode code-point order for UTF-8, which is what
20//!   `coerce::compare` uses) and exact equality.
21//! * **Bool** — `false < true` (matches `coerce::compare`'s explicit
22//!   `(Bool, Bool)` arm) and equality (bool `==` equals `coerce::eq`'s 0/1
23//!   fold); only a `Bool` needle qualifies, so `active == 1` still falls back.
24//!
25//! A `Str` column also serves the substring operators (`contains`/`starts_with`/
26//! `ends_with`) via a direct scan — byte-identical to the row engine's `str`
27//! methods, but without the per-row accessor walk and boxed-predicate dispatch.
28//!
29//! Anything else — an unknown field, `like`/`ilike`/`regex`, a range, a value
30//! whose coercion can't be proven equal — returns `None`, and the caller falls
31//! back to the row engine. Silently correct beats cleverly wrong.
32//!
33//! [`coerce`]: crate::coerce
34
35mod build;
36mod filter;
37
38use std::cmp::Ordering;
39use std::collections::BTreeMap;
40
41use crate::filter::FilterOp;
42use crate::sort::SortDirection;
43use crate::value::Value;
44
45/// A dense, single-type column extracted from every row of a dataset.
46pub(crate) enum Column {
47    Int(Vec<i64>),
48    Float(Vec<f64>),
49    Str(Vec<String>),
50    Bool(Vec<bool>),
51}
52
53/// Dense typed columns keyed by field name (only fully-typed, NaN-free fields).
54pub struct Columns {
55    columns: BTreeMap<String, Column>,
56}
57
58impl Columns {
59    /// Build typed columns from `items`. A field qualifies only if it is the
60    /// same scalar variant (`Int`/`Float`/`Str`) in every row — floats must also
61    /// be non-`NaN`. The first row seeds the candidate type per field.
62    #[must_use]
63    pub fn build(items: &[Value]) -> Self {
64        let mut columns = BTreeMap::new();
65        let Some(Value::Map(first)) = items.first() else {
66            return Self { columns };
67        };
68        for (field, seed) in first {
69            if let Some(column) = build::build_column(items, field, seed) {
70                columns.insert(field.clone(), column);
71            }
72        }
73        Self { columns }
74    }
75
76    /// Fast filter for a single comparison/equality op on a typed column, or
77    /// `None` when this path does not apply (unknown field, unsupported
78    /// operator, or a value whose coercion can't be proven equal to the row
79    /// engine). Returns matching indices in ascending order — identical to
80    /// [`crate::filter::filter_indices`].
81    #[must_use]
82    pub fn filter(&self, field: &str, op: FilterOp, value: &Value) -> Option<Vec<usize>> {
83        match self.columns.get(field)? {
84            Column::Int(col) => filter::filter_int(col, op, value),
85            Column::Float(col) => filter::filter_float(col, op, value),
86            Column::Str(col) => filter::filter_str(col, op, value),
87            Column::Bool(col) => filter::filter_bool(col, op, value),
88        }
89    }
90
91    /// Sort an existing index list by one or more typed-column keys, preserving
92    /// relative order for equal keys (stable). Returns `None` unless **every**
93    /// key is a typed column. Keys are applied highest-priority first (matching
94    /// [`crate::sort::sort_indices`]); typed columns have no nulls, so null
95    /// placement is irrelevant.
96    /// `limit` keeps only the first `k` rows (a top-k partial sort) — the page
97    /// window — instead of fully ordering the whole subset; `None` sorts all.
98    #[must_use]
99    pub fn sort_subset(
100        &self,
101        order: &[usize],
102        keys: &[(&str, SortDirection)],
103        limit: Option<usize>,
104    ) -> Option<Vec<usize>> {
105        if keys.is_empty() {
106            return None;
107        }
108        // Resolve every key first; if any field is not a typed column, fall back
109        // (and avoid cloning `order`).
110        let resolved: Vec<(&Column, bool)> = keys
111            .iter()
112            .map(|(field, direction)| {
113                self.columns
114                    .get(*field)
115                    .map(|column| (column, *direction == SortDirection::Desc))
116            })
117            .collect::<Option<Vec<_>>>()?;
118        let mut order = order.to_vec();
119        // A single-key sort (the common case) gets a comparator specialised to the
120        // column type — no per-comparison `Column` enum match, which profiling
121        // showed dominates once the full sort became a top-k. Multi-key uses one
122        // total comparator (keys in priority order). Both break ties by ascending
123        // item index (stable, since `order` is ascending) and honour `limit`.
124        if let [(column, desc)] = resolved.as_slice() {
125            sort_one_key(&mut order, column, *desc, limit);
126        } else {
127            partial_sort(&mut order, limit, |&a, &b| {
128                for (column, desc) in &resolved {
129                    let ord = oriented(column.compare(a, b), *desc);
130                    if ord != Ordering::Equal {
131                        return ord;
132                    }
133                }
134                a.cmp(&b)
135            });
136        }
137        Some(order)
138    }
139}
140
141impl Column {
142    /// Compare two rows by this column's natural (ascending) order. A built float
143    /// column has no `NaN`, so `partial_cmp` is total.
144    fn compare(&self, a: usize, b: usize) -> Ordering {
145        match self {
146            Column::Int(col) => col[a].cmp(&col[b]),
147            Column::Float(col) => col[a].partial_cmp(&col[b]).unwrap_or(Ordering::Equal),
148            Column::Str(col) => col[a].cmp(&col[b]),
149            Column::Bool(col) => col[a].cmp(&col[b]),
150        }
151    }
152}
153
154/// Single-key sort with a comparator specialised to the column's element type
155/// (ties broken by ascending item index for stability). The `Ord` columns share
156/// one generic body — the compiler still monomorphises it per type, so each gets
157/// an enum-match-free comparator; `Float` is separate (`f64` is only `PartialOrd`).
158fn sort_one_key(order: &mut Vec<usize>, column: &Column, desc: bool, limit: Option<usize>) {
159    match column {
160        Column::Int(c) => sort_by_key(order, c, desc, limit),
161        Column::Str(c) => sort_by_key(order, c, desc, limit),
162        Column::Bool(c) => sort_by_key(order, c, desc, limit),
163        Column::Float(c) => partial_sort(order, limit, |&a, &b| {
164            oriented(c[a].partial_cmp(&c[b]).unwrap_or(Ordering::Equal), desc)
165                .then_with(|| a.cmp(&b))
166        }),
167    }
168}
169
170/// Top-k/full sort of `order` by one totally-ordered column, descending-aware,
171/// ties by ascending item index.
172fn sort_by_key<T: Ord>(order: &mut Vec<usize>, col: &[T], desc: bool, limit: Option<usize>) {
173    partial_sort(order, limit, |&a, &b| {
174        oriented(col[a].cmp(&col[b]), desc).then_with(|| a.cmp(&b))
175    });
176}
177
178/// Sort `order` by `cmp`, keeping only the first `k` rows when `limit` is `Some(k)`
179/// (a top-k partial sort for a bounded page); a `None`/large limit sorts all.
180fn partial_sort(
181    order: &mut Vec<usize>,
182    limit: Option<usize>,
183    mut cmp: impl FnMut(&usize, &usize) -> Ordering,
184) {
185    match limit {
186        Some(0) => order.clear(),
187        Some(k) if k < order.len() => {
188            order.select_nth_unstable_by(k - 1, &mut cmp);
189            order.truncate(k);
190            order.sort_unstable_by(&mut cmp);
191        }
192        _ => order.sort_unstable_by(&mut cmp),
193    }
194}
195
196/// Reverse an ordering for descending sorts.
197fn oriented(ordering: Ordering, desc: bool) -> Ordering {
198    if desc {
199        ordering.reverse()
200    } else {
201        ordering
202    }
203}
204
205#[cfg(test)]
206mod tests;