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;