Skip to main content

hayson_engine/query/
planner.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::io;
4
5use serde_json::Value;
6
7use crate::errors::HaysonCipherError;
8use crate::registry::composite_index::{RegistryIndex, RegistryIndexKind};
9use crate::registry::exact_index::value_key;
10use crate::registry::fulltext_index::SearchMode;
11use crate::registry::sorted_index::{RangeBounds, SortValue};
12use crate::registry::table_registry::TableRegistry;
13use crate::schema::{HaysonRecord, UNPARTITIONED};
14use crate::storage::record::parse_entry;
15use crate::storage::{LogFile, LogSet, Slot};
16
17use super::predicates::{all_leaves, leaves_of, same_value, test_node, ComparisonOp, Predicate, PredicateNode};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum QueryStrategy {
21    Primary,
22    Exact,
23    Sorted,
24    Fulltext,
25    Union,
26    Scan,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PlanBranch {
31    pub strategy: QueryStrategy,
32    pub field: Option<String>,
33    pub candidates: usize,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct AccessPath {
38    pub strategy: QueryStrategy,
39    pub field: Option<String>,
40    pub index: Option<String>,
41    pub keys: Vec<String>,
42    pub branches: Option<Vec<PlanBranch>>,
43    /// Relevance of every key a `spec.search` scored, kept alongside the candidates because the
44    /// ranking it drives is applied after the predicate filter, not while planning.
45    pub scores: Option<HashMap<String, f64>>,
46    /// Set only when the query ran through a unit of work's overlay — see [`PlanOverlay`].
47    pub overlay: Option<PlanOverlay>,
48}
49
50/// How the staged candidates of a [`PlanOverlay`] were picked.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum OverlayStrategy {
53    /// The overlay's own exact index answered a top-level equality.
54    Exact,
55    /// The staged records were filtered by the search text, through the rescored corpus.
56    Fulltext,
57    /// No overlay index could narrow the predicate, so every staged record was checked.
58    Scan,
59}
60
61/// What a unit of work's staged writes did to a query plan.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct PlanOverlay {
64    /// Staged records that joined the candidate set.
65    pub staged: usize,
66    /// Committed candidates the overlay took back out — re-staged in this unit, or staged deleted.
67    pub suppressed: usize,
68    /// The BM25 statistics behind the candidates were recomputed over the overlaid corpus.
69    pub rescored: bool,
70    pub strategy: OverlayStrategy,
71    /// Records the unit holds for this table, whether or not they could match.
72    pub size: usize,
73}
74
75/// Candidate keys an overlay's own index can prove for one field/value pair.
76pub enum OverlayCandidates {
77    /// The overlay indexes nothing on that field, so it cannot narrow and every staged record stands.
78    NotIndexed,
79    /// The overlay indexes the field; these are the staged keys holding the value, possibly none.
80    Narrowed(Vec<String>),
81}
82
83/// The unit-of-work overlay a query is planned through — the `txn/` side of the extension point
84/// `RecordSource` names. Kept as a trait here so `query/` stays below `txn/` in the layering.
85pub trait QueryOverlay {
86    /// Every key the unit stages a value for, in the state the unit will leave it.
87    fn records(&self) -> &HashMap<String, HaysonRecord>;
88    /// Every key the unit stages a delete for.
89    fn deleted(&self) -> &HashSet<String>;
90    fn candidates_for(&self, field: &str, encoded: &str) -> OverlayCandidates;
91}
92
93#[derive(Debug, Clone, PartialEq)]
94pub struct SearchSpec {
95    pub text: String,
96    pub mode: SearchMode,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum SortDirection {
101    Asc,
102    Desc,
103}
104
105/// The single sort key of a query. Single, not a list, because the TS `QueryBuilder.orderBy`
106/// it mirrors keeps one key and overwrites it on every call.
107#[derive(Debug, Clone, PartialEq)]
108pub struct OrderBy {
109    pub field: String,
110    pub direction: SortDirection,
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub struct QuerySpec {
115    pub root: PredicateNode,
116    pub search: Option<SearchSpec>,
117    pub order_by: Option<OrderBy>,
118    pub limit: Option<usize>,
119    pub offset: Option<usize>,
120    /// The caller wants `matched` and the plan, not the page: `QueryResult::keys` comes back empty
121    /// and no page is ordered, so a binding has nothing to load records for. What the TS
122    /// `QueryBuilder.count()`/`.explain()` ask for.
123    pub count_only: bool,
124}
125
126impl QuerySpec {
127    pub fn new(root: PredicateNode) -> Self {
128        QuerySpec {
129            root,
130            search: None,
131            order_by: None,
132            limit: None,
133            offset: None,
134            count_only: false,
135        }
136    }
137
138    pub fn with_search(mut self, text: impl Into<String>, mode: SearchMode) -> Self {
139        self.search = Some(SearchSpec {
140            text: text.into(),
141            mode,
142        });
143        self
144    }
145
146    pub fn with_order_by(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
147        self.order_by = Some(OrderBy {
148            field: field.into(),
149            direction,
150        });
151        self
152    }
153
154    pub fn with_limit(mut self, limit: usize) -> Self {
155        self.limit = Some(limit);
156        self
157    }
158
159    pub fn with_offset(mut self, offset: usize) -> Self {
160        self.offset = Some(offset);
161        self
162    }
163
164    pub fn counting(mut self, count_only: bool) -> Self {
165        self.count_only = count_only;
166        self
167    }
168
169    /// The same spec with the page window dropped — what an aggregate runs, since `limit`/`offset`
170    /// name a window over the answer, not over the rows an aggregate folds. `count_only` is dropped
171    /// with it: an aggregate folds the matched keys one record at a time, so it needs them listed.
172    pub fn unpaged(&self) -> Self {
173        QuerySpec {
174            limit: None,
175            offset: None,
176            count_only: false,
177            ..self.clone()
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183struct KeySet {
184    strategy: QueryStrategy,
185    field: Option<String>,
186    index: Option<String>,
187    keys: Vec<String>,
188    branches: Option<Vec<PlanBranch>>,
189}
190
191impl KeySet {
192    fn into_path(self, scores: Option<HashMap<String, f64>>) -> AccessPath {
193        AccessPath {
194            strategy: self.strategy,
195            field: self.field,
196            index: self.index,
197            keys: self.keys,
198            branches: self.branches,
199            scores,
200            overlay: None,
201        }
202    }
203}
204
205fn cheaper(best: Option<KeySet>, candidate: Option<KeySet>) -> Option<KeySet> {
206    match candidate {
207        None => best,
208        Some(candidate) => match best {
209            None => Some(candidate),
210            Some(best) => {
211                if candidate.keys.len() < best.keys.len() {
212                    Some(candidate)
213                } else {
214                    Some(best)
215                }
216            }
217        },
218    }
219}
220
221fn is_primitive(value: &Value) -> bool {
222    matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_))
223}
224
225fn is_primitive_equality(predicate: &Predicate) -> bool {
226    predicate.op == ComparisonOp::Eq && is_primitive(&predicate.value)
227}
228
229fn equality_on<'a>(leaves: &[&'a Predicate], field: &str) -> Option<&'a Predicate> {
230    let usable: Vec<&'a Predicate> = leaves
231        .iter()
232        .copied()
233        .filter(|leaf| {
234            leaf.field == field
235                && (leaf.op == ComparisonOp::Eq || leaf.op == ComparisonOp::Contains)
236                && leaf.value != Value::Null
237        })
238        .collect();
239    usable
240        .iter()
241        .find(|leaf| leaf.op == ComparisonOp::Eq)
242        .copied()
243        .or_else(|| usable.first().copied())
244}
245
246struct ExactMatch {
247    keys: Vec<String>,
248    width: usize,
249}
250
251/// Candidates in the order their records first entered the table. The registries hold them in hash
252/// sets, which have no order at all, so a query without `orderBy` would answer in a different order
253/// every run — where the TS engine, whose registries are insertion-ordered `Map`s and `Set`s,
254/// answers in arrival order. Arrival, not last write: overwriting a record does not move it to the
255/// back of the page, because `Map.set` on a key already present keeps that key where it was.
256fn in_write_order(registry: &TableRegistry, mut keys: Vec<String>) -> Vec<String> {
257    keys.sort_by(|left, right| {
258        let earlier = registry.arrival_of(left).unwrap_or(0);
259        let later = registry.arrival_of(right).unwrap_or(0);
260        earlier.cmp(&later).then_with(|| left.cmp(right))
261    });
262    keys
263}
264
265/// Whether the query repeats every `where` clause scoping `index` — an index covering only part of
266/// the table may only answer a query that provably falls inside that part.
267fn repeats_predicate(registry: &TableRegistry, index: &RegistryIndex, leaves: &[&Predicate]) -> bool {
268    let Some(clauses) = registry.where_of(index) else {
269        return true;
270    };
271    clauses.iter().all(|clause| {
272        leaves
273            .iter()
274            .any(|leaf| leaf.field == clause.field && leaf.op == clause.op && same_value(&leaf.value, &clause.value))
275    })
276}
277
278// NOTE: 0017 narrowed — an empty bucket proves absence only where nothing kept a record out of it.
279fn exact_match(registry: &TableRegistry, index: &RegistryIndex, leaves: &[&Predicate]) -> Option<ExactMatch> {
280    if index.kind != RegistryIndexKind::Exact && index.kind != RegistryIndexKind::Sorted {
281        return None;
282    }
283    let mut values: Vec<Value> = Vec::new();
284    let mut proves = registry.where_of(index).is_none();
285    for field in &index.fields {
286        match equality_on(leaves, field) {
287            Some(predicate) => {
288                values.push(predicate.value.clone());
289                if !is_primitive_equality(predicate) {
290                    proves = false;
291                }
292            }
293            None => break,
294        }
295    }
296    if values.is_empty() {
297        return None;
298    }
299    let width = values.len();
300    match registry.candidates_of(index, &values) {
301        Some(bucket) => Some(ExactMatch {
302            keys: in_write_order(registry, bucket.iter().cloned().collect()),
303            width,
304        }),
305        None => proves.then(|| ExactMatch {
306            keys: Vec::new(),
307            width,
308        }),
309    }
310}
311
312#[derive(Default)]
313struct RawBounds {
314    gt: Option<Value>,
315    gte: Option<Value>,
316    lt: Option<Value>,
317    lte: Option<Value>,
318}
319
320fn bounds_of(leaves: &[&Predicate], field: &str) -> Option<RawBounds> {
321    let mut bounds = RawBounds::default();
322    let mut found = false;
323    for predicate in leaves {
324        if predicate.field != field {
325            continue;
326        }
327        if !matches!(predicate.value, Value::Number(_) | Value::String(_)) {
328            continue;
329        }
330        match predicate.op {
331            ComparisonOp::Gt => {
332                bounds.gt = Some(predicate.value.clone());
333                found = true;
334            }
335            ComparisonOp::Gte => {
336                bounds.gte = Some(predicate.value.clone());
337                found = true;
338            }
339            ComparisonOp::Lt => {
340                bounds.lt = Some(predicate.value.clone());
341                found = true;
342            }
343            ComparisonOp::Lte => {
344                bounds.lte = Some(predicate.value.clone());
345                found = true;
346            }
347            _ => {}
348        }
349    }
350    found.then_some(bounds)
351}
352
353fn with_prefix(prefix: &[SortValue], bounds: &RawBounds) -> RangeBounds {
354    let vector_of = |edge: &Option<Value>| -> Option<Vec<SortValue>> {
355        let value = edge.as_ref()?;
356        let sortable = SortValue::from_json(value)?;
357        let mut vector = prefix.to_vec();
358        vector.push(sortable);
359        Some(vector)
360    };
361    RangeBounds {
362        gt: vector_of(&bounds.gt),
363        gte: vector_of(&bounds.gte),
364        lt: vector_of(&bounds.lt),
365        lte: vector_of(&bounds.lte),
366    }
367}
368
369fn sorted_keys(registry: &TableRegistry, index: &RegistryIndex, leaves: &[&Predicate]) -> Option<Vec<String>> {
370    if index.kind != RegistryIndexKind::Sorted {
371        return None;
372    }
373    let mut prefix: Vec<SortValue> = Vec::new();
374    for field in &index.fields {
375        let predicate = leaves
376            .iter()
377            .find(|leaf| leaf.field == *field && is_primitive_equality(leaf));
378        match predicate {
379            Some(predicate) => prefix.push(SortValue::from_json(&predicate.value)?),
380            None => break,
381        }
382    }
383    let open = index.fields.get(prefix.len())?;
384    let bounds = bounds_of(leaves, open)?;
385    let extended = with_prefix(&prefix, &bounds);
386    registry.range_of(index, &extended)
387}
388
389fn key_set_of(registry: &TableRegistry, index: &RegistryIndex, strategy: QueryStrategy, keys: Vec<String>) -> KeySet {
390    let named = index.fields.len() > 1 || registry.where_of(index).is_some();
391    KeySet {
392        strategy,
393        field: (!named).then(|| index.fields[0].clone()),
394        index: named.then(|| index.name.clone()),
395        keys,
396        branches: None,
397    }
398}
399
400fn primary_keys_of(leaves: &[&Predicate], primary_key: &str) -> Option<Vec<String>> {
401    for predicate in leaves {
402        if predicate.field != primary_key {
403            continue;
404        }
405        match (predicate.op, &predicate.value) {
406            (ComparisonOp::Eq, Value::String(s)) => return Some(vec![s.clone()]),
407            (ComparisonOp::Eq, Value::Number(n)) => return Some(vec![n.to_string()]),
408            (ComparisonOp::In, Value::Array(items)) => {
409                return Some(items.iter().map(value_as_key).collect());
410            }
411            _ => {}
412        }
413    }
414    None
415}
416
417fn value_as_key(value: &Value) -> String {
418    match value {
419        Value::String(s) => s.clone(),
420        other => other.to_string(),
421    }
422}
423
424fn primary_path(registry: &TableRegistry, leaves: &[&Predicate]) -> Option<KeySet> {
425    let wanted = primary_keys_of(leaves, &registry.schema.primary_key)?;
426    let keys: Vec<String> = wanted
427        .into_iter()
428        .filter(|key| registry.slot_of(key).is_some())
429        .collect();
430    Some(KeySet {
431        strategy: QueryStrategy::Primary,
432        field: Some(registry.schema.primary_key.clone()),
433        index: None,
434        keys,
435        branches: None,
436    })
437}
438
439fn fulltext_path(scores: &HashMap<String, f64>) -> KeySet {
440    // NOTE: scores live in a HashMap, so the candidates are ranked here or their order is random.
441    let mut keys: Vec<String> = scores.keys().cloned().collect();
442    keys.sort_by(|a, b| rank(scores, a, b));
443    KeySet {
444        strategy: QueryStrategy::Fulltext,
445        field: None,
446        index: None,
447        keys,
448        branches: None,
449    }
450}
451
452fn rank(scores: &HashMap<String, f64>, a: &str, b: &str) -> std::cmp::Ordering {
453    let left = scores.get(a).copied().unwrap_or(0.0);
454    let right = scores.get(b).copied().unwrap_or(0.0);
455    right
456        .partial_cmp(&left)
457        .unwrap_or(std::cmp::Ordering::Equal)
458        .then_with(|| a.cmp(b))
459}
460
461fn indexed_path(
462    registry: &TableRegistry,
463    nodes: &[PredicateNode],
464    scores: Option<&HashMap<String, f64>>,
465) -> Option<KeySet> {
466    let leaves = leaves_of(nodes);
467    let mut best: Option<KeySet> = None;
468    let mut widest = 0usize;
469
470    let usable: Vec<&RegistryIndex> = registry
471        .indexes()
472        .iter()
473        .filter(|index| repeats_predicate(registry, index, &leaves))
474        .collect();
475
476    for index in &usable {
477        let Some(matched) = exact_match(registry, index, &leaves) else {
478            continue;
479        };
480        let tied = best.as_ref().is_some_and(|b| matched.keys.len() == b.keys.len());
481        let cheaper_than_best = best.as_ref().is_none_or(|b| matched.keys.len() < b.keys.len());
482        if tied {
483            if matched.width > widest {
484                widest = matched.width;
485                best = Some(key_set_of(registry, index, QueryStrategy::Exact, matched.keys));
486            }
487        } else if cheaper_than_best {
488            widest = matched.width;
489            best = Some(key_set_of(registry, index, QueryStrategy::Exact, matched.keys));
490        }
491    }
492
493    if let Some(scores) = scores {
494        best = cheaper(best, Some(fulltext_path(scores)));
495    }
496
497    for index in &usable {
498        let Some(keys) = sorted_keys(registry, index, &leaves) else {
499            continue;
500        };
501        best = cheaper(best, Some(key_set_of(registry, index, QueryStrategy::Sorted, keys)));
502    }
503
504    // NOTE: nested groups never see `scores` — a top-level search only narrows the top level.
505    for node in nodes {
506        if matches!(node, PredicateNode::Group { .. }) {
507            best = cheaper(best, plan(registry, node, None));
508        }
509    }
510
511    best
512}
513
514fn and_path(
515    registry: &TableRegistry,
516    nodes: &[PredicateNode],
517    scores: Option<&HashMap<String, f64>>,
518) -> Option<KeySet> {
519    let leaves = leaves_of(nodes);
520    cheaper(primary_path(registry, &leaves), indexed_path(registry, nodes, scores))
521}
522
523fn branches_of(path: &KeySet) -> Vec<PlanBranch> {
524    path.branches.clone().unwrap_or_else(|| {
525        vec![PlanBranch {
526            strategy: path.strategy,
527            field: path.field.clone(),
528            candidates: path.keys.len(),
529        }]
530    })
531}
532
533// NOTE: a missing branch (no index, no primary hit) degrades the whole bracket, not just one arm.
534fn or_path(registry: &TableRegistry, nodes: &[PredicateNode]) -> Option<KeySet> {
535    if nodes.is_empty() {
536        return None;
537    }
538    let mut seen = HashSet::new();
539    let mut keys = Vec::new();
540    let mut branches = Vec::new();
541    for node in nodes {
542        let path = plan(registry, node, None)?;
543        for key in &path.keys {
544            if seen.insert(key.clone()) {
545                keys.push(key.clone());
546            }
547        }
548        branches.extend(branches_of(&path));
549    }
550    Some(KeySet {
551        strategy: QueryStrategy::Union,
552        field: None,
553        index: None,
554        keys,
555        branches: Some(branches),
556    })
557}
558
559fn plan(registry: &TableRegistry, node: &PredicateNode, scores: Option<&HashMap<String, f64>>) -> Option<KeySet> {
560    match node {
561        PredicateNode::Leaf(_) => and_path(registry, std::slice::from_ref(node), scores),
562        PredicateNode::Group {
563            combine: super::predicates::Combine::And,
564            nodes,
565        } => and_path(registry, nodes, scores),
566        PredicateNode::Group {
567            combine: super::predicates::Combine::Or,
568            nodes,
569        } => or_path(registry, nodes),
570    }
571}
572
573fn committed_path(registry: &TableRegistry, spec: &QuerySpec, scores: Option<&HashMap<String, f64>>) -> KeySet {
574    plan(registry, &spec.root, scores).unwrap_or_else(|| KeySet {
575        strategy: QueryStrategy::Scan,
576        field: None,
577        index: None,
578        keys: in_write_order(registry, registry.keys().cloned().collect()),
579        branches: None,
580    })
581}
582
583pub fn plan_access(registry: &TableRegistry, spec: &QuerySpec) -> AccessPath {
584    let scores = spec
585        .search
586        .as_ref()
587        .filter(|_| registry.has_fulltext())
588        .map(|search| registry.search(&search.text, search.mode));
589    committed_path(registry, spec, scores.as_ref()).into_path(scores)
590}
591
592/// The top-level conjunctive leaves of `spec` — the only predicates an overlay may narrow on, since
593/// an `or` arm proves nothing about the answer as a whole.
594fn top_level_leaves(spec: &QuerySpec) -> Vec<&Predicate> {
595    match &spec.root {
596        PredicateNode::Leaf(leaf) => vec![leaf],
597        PredicateNode::Group {
598            combine: super::predicates::Combine::And,
599            nodes,
600        } => nodes
601            .iter()
602            .filter_map(|node| match node {
603                PredicateNode::Leaf(leaf) => Some(leaf),
604                PredicateNode::Group { .. } => None,
605            })
606            .collect(),
607        PredicateNode::Group { .. } => Vec::new(),
608    }
609}
610
611/// The narrowest set of staged keys the overlay's own exact index can prove for `spec`, or `None`
612/// when no top-level equality names a field the overlay indexes.
613fn indexed_staged(overlay: &dyn QueryOverlay, spec: &QuerySpec) -> Option<Vec<String>> {
614    let mut best: Option<Vec<String>> = None;
615    for leaf in top_level_leaves(spec) {
616        if !is_primitive_equality(leaf) {
617            continue;
618        }
619        let OverlayCandidates::Narrowed(keys) = overlay.candidates_for(&leaf.field, &value_key(&leaf.value)) else {
620            continue;
621        };
622        if best.as_ref().is_none_or(|found| keys.len() < found.len()) {
623            best = Some(keys);
624        }
625    }
626    best
627}
628
629fn staged_candidates(
630    path: &KeySet,
631    overlay: &dyn QueryOverlay,
632    spec: &QuerySpec,
633    scores: Option<&HashMap<String, f64>>,
634) -> (Vec<String>, OverlayStrategy) {
635    // NOTE: only the fulltext path filters candidates by the query text, so only it may narrow here.
636    if path.strategy == QueryStrategy::Fulltext {
637        if let Some(scores) = scores {
638            let mut keys: Vec<String> = overlay
639                .records()
640                .keys()
641                .filter(|key| scores.contains_key(*key))
642                .cloned()
643                .collect();
644            keys.sort_by(|a, b| rank(scores, a, b));
645            return (keys, OverlayStrategy::Fulltext);
646        }
647    }
648    match indexed_staged(overlay, spec) {
649        Some(keys) => (keys, OverlayStrategy::Exact),
650        None => (overlay.records().keys().cloned().collect(), OverlayStrategy::Scan),
651    }
652}
653
654/// Plans `spec` as the unit of work will leave the table: the committed candidates the overlay has
655/// not taken over, plus the staged records that can still match, and a [`PlanOverlay`] saying how
656/// the two halves met. `scores`, when present, were computed over the overlaid corpus, so a staged
657/// record joins the ranking rather than merely the answer.
658pub fn plan_access_overlaid(
659    registry: &TableRegistry,
660    spec: &QuerySpec,
661    scores: Option<HashMap<String, f64>>,
662    overlay: &dyn QueryOverlay,
663) -> AccessPath {
664    let path = committed_path(registry, spec, scores.as_ref());
665    let kept: Vec<String> = path
666        .keys
667        .iter()
668        .filter(|key| !overlay.records().contains_key(*key) && !overlay.deleted().contains(*key))
669        .cloned()
670        .collect();
671    let suppressed = path.keys.len() - kept.len();
672    let (staged, strategy) = staged_candidates(&path, overlay, spec, scores.as_ref());
673    let summary = PlanOverlay {
674        staged: staged.len(),
675        suppressed,
676        rescored: scores.is_some(),
677        strategy,
678        size: overlay.records().len(),
679    };
680    let mut keys = kept;
681    keys.extend(staged);
682    AccessPath {
683        keys,
684        overlay: Some(summary),
685        ..path.into_path(scores)
686    }
687}
688
689/// Orders the matched `entries` — each a key paired with the value of the query's sort field, or
690/// `Value::Null` when the query names none — and then cuts out the page `offset`/`limit` ask for.
691///
692/// The order is the one the TS `executeQuery` fixes: an explicit `orderBy` wins; failing that a
693/// fulltext query ranks by score, ties broken on the key; failing that the access path's own order
694/// stands. Slicing happens last, over the whole ordered result, never before it.
695pub fn order_and_page(
696    entries: Vec<(String, Value)>,
697    spec: &QuerySpec,
698    scores: Option<&HashMap<String, f64>>,
699) -> Vec<String> {
700    let mut entries = entries;
701    match (&spec.order_by, scores) {
702        (Some(order), _) => {
703            let descending = order.direction == SortDirection::Desc;
704            entries.sort_by(|(_, left), (_, right)| {
705                let ordering = super::predicates::compare_values(left, right);
706                if descending {
707                    ordering.reverse()
708                } else {
709                    ordering
710                }
711            });
712        }
713        (None, Some(scores)) => entries.sort_by(|(left, _), (right, _)| rank(scores, left, right)),
714        (None, None) => {}
715    }
716    page_of(entries.into_iter().map(|(key, _)| key).collect(), spec)
717}
718
719/// The `offset`/`limit` window of `keys`. An offset past the end is an empty page, not an error,
720/// and a `limit` of zero selects nothing — both mirror the TS `Array.slice` this replaces.
721pub fn page_of(keys: Vec<String>, spec: &QuerySpec) -> Vec<String> {
722    if spec.offset.is_none() && spec.limit.is_none() {
723        return keys;
724    }
725    let from = spec.offset.unwrap_or(0).min(keys.len());
726    let to = match spec.limit {
727        Some(limit) => from.saturating_add(limit).min(keys.len()),
728        None => keys.len(),
729    };
730    keys[from..to].to_vec()
731}
732
733/// What a `RecordSource` can fail with. Its own type rather than `io::Error` so a failed decrypt
734/// keeps the whole `HaysonCipherError` — key id included — on the way out of the read path;
735/// `TableError` cannot be used here because it lives above `query/` in the layering.
736#[derive(Debug)]
737pub enum RecordSourceError {
738    /// Boxed for the same reason as `TableError::Cipher` — only a `cypher` field ever builds one.
739    Cipher(Box<HaysonCipherError>),
740    Io(io::Error),
741}
742
743impl fmt::Display for RecordSourceError {
744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745        match self {
746            RecordSourceError::Cipher(error) => error.fmt(f),
747            RecordSourceError::Io(error) => error.fmt(f),
748        }
749    }
750}
751
752impl std::error::Error for RecordSourceError {}
753
754impl From<io::Error> for RecordSourceError {
755    fn from(value: io::Error) -> Self {
756        RecordSourceError::Io(value)
757    }
758}
759
760impl From<HaysonCipherError> for RecordSourceError {
761    fn from(value: HaysonCipherError) -> Self {
762        RecordSourceError::Cipher(Box::new(value))
763    }
764}
765
766/// Extension point for the future `txn/` overlay: today records come straight from the committed
767/// log, but an overlay-aware source can be swapped in here once the unit-of-work layer exists.
768pub trait RecordSource {
769    fn load(&mut self, key: &str) -> Result<Option<HaysonRecord>, RecordSourceError>;
770}
771
772/// Decrypts `cypher` fields the moment a record is parsed off the log — see
773/// [`LogRecordSource::with_open`].
774pub type OpenRecordFn<'a> = dyn Fn(&str, HaysonRecord) -> Result<HaysonRecord, RecordSourceError> + 'a;
775
776#[allow(dead_code)]
777pub struct LogRecordSource<'a> {
778    registry: &'a TableRegistry,
779    log: &'a LogFile,
780    /// Applied to a record right after it is parsed off the log — the `cypher`-field decryption
781    /// hook a table with a keyring attaches via [`LogRecordSource::with_open`]. `None` for a table
782    /// that declares no `cypher` field, which needs no key at all to read.
783    open: Option<&'a OpenRecordFn<'a>>,
784}
785
786#[allow(dead_code)]
787impl<'a> LogRecordSource<'a> {
788    pub fn new(registry: &'a TableRegistry, log: &'a LogFile) -> Self {
789        LogRecordSource {
790            registry,
791            log,
792            open: None,
793        }
794    }
795
796    /// Same as [`LogRecordSource::new`], but every record `load` returns is first passed through
797    /// `open` (keyed by the record's own key) — used to decrypt `cypher` fields before a record
798    /// leaves the log-read boundary, so `get`/`query`/`patch` all hydrate through the same hook.
799    pub fn with_open(registry: &'a TableRegistry, log: &'a LogFile, open: &'a OpenRecordFn<'a>) -> Self {
800        LogRecordSource {
801            registry,
802            log,
803            open: Some(open),
804        }
805    }
806}
807
808impl RecordSource for LogRecordSource<'_> {
809    fn load(&mut self, key: &str) -> Result<Option<HaysonRecord>, RecordSourceError> {
810        let Some(entry) = self.registry.slot_of(key) else {
811            return Ok(None);
812        };
813        let slot = Slot {
814            offset: entry.offset,
815            length: entry.length,
816        };
817        let line = self.log.read_slice(&slot)?;
818        let path = self.log.path().to_string_lossy().into_owned();
819        let parsed =
820            parse_entry(&line, &path, entry.offset).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
821        match (parsed.data, self.open) {
822            (Some(record), Some(open)) => Ok(Some(open(key, record)?)),
823            (data, _) => Ok(data),
824        }
825    }
826}
827
828/// [`LogRecordSource`] for a table held as a [`LogSet`]: the registry says which partition a key
829/// lives in, and the record is read out of that partition's own file. It merges nothing itself —
830/// the planner hands it one key at a time, and the registry it plans against already spans every
831/// partition of the table.
832pub struct LogSetRecordSource<'a> {
833    registry: &'a TableRegistry,
834    logs: &'a LogSet,
835    open: Option<&'a OpenRecordFn<'a>>,
836}
837
838impl<'a> LogSetRecordSource<'a> {
839    pub fn new(registry: &'a TableRegistry, logs: &'a LogSet) -> Self {
840        LogSetRecordSource {
841            registry,
842            logs,
843            open: None,
844        }
845    }
846
847    /// Same as [`LogSetRecordSource::new`] with the `cypher`-field decryption hook of
848    /// [`LogRecordSource::with_open`].
849    pub fn with_open(registry: &'a TableRegistry, logs: &'a LogSet, open: &'a OpenRecordFn<'a>) -> Self {
850        LogSetRecordSource {
851            registry,
852            logs,
853            open: Some(open),
854        }
855    }
856}
857
858impl RecordSource for LogSetRecordSource<'_> {
859    fn load(&mut self, key: &str) -> Result<Option<HaysonRecord>, RecordSourceError> {
860        let Some(entry) = self.registry.slot_of(key) else {
861            return Ok(None);
862        };
863        let part = self.registry.part_of(key).unwrap_or(UNPARTITIONED);
864        let slot = Slot {
865            offset: entry.offset,
866            length: entry.length,
867        };
868        let line = self.logs.read_slice(part, &slot)?;
869        let path = self.logs.path_of(part).to_string_lossy().into_owned();
870        let parsed =
871            parse_entry(&line, &path, entry.offset).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
872        match (parsed.data, self.open) {
873            (Some(record), Some(open)) => Ok(Some(open(key, record)?)),
874            (data, _) => Ok(data),
875        }
876    }
877}
878
879#[derive(Debug, Clone, PartialEq, Eq)]
880pub struct QueryResult {
881    /// The page the query asked for: matched, ordered, then cut down by `offset`/`limit`. Empty for
882    /// a `count_only` spec, which asked for no page at all.
883    pub keys: Vec<String>,
884    /// How many records matched before `offset`/`limit` cut the page out of them.
885    pub matched: usize,
886    pub plan: AccessPathSummary,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct AccessPathSummary {
891    pub strategy: QueryStrategy,
892    pub field: Option<String>,
893    pub index: Option<String>,
894    pub candidates: usize,
895    /// The answer came out in relevance order: a `search` scored the candidates and no explicit
896    /// `order_by` overrode that order.
897    pub ranked: bool,
898    /// Every field the query touches lives in the registry's projection, so the predicate was
899    /// tested against cached values and no record was read off the log.
900    pub filtered_on_projection: bool,
901    pub reads: usize,
902    pub branches: Option<Vec<PlanBranch>>,
903    /// Set only for a query run through a unit of work — see [`PlanOverlay`].
904    pub overlay: Option<PlanOverlay>,
905}
906
907/// Every field a query reads to answer itself: the fields of every predicate leaf, nested groups
908/// included, plus the sort field. Mirrors the `fieldsInPlay` set of the TS `executeQuery`.
909fn fields_in_play(spec: &QuerySpec) -> Vec<&str> {
910    let mut fields: Vec<&str> = all_leaves(std::slice::from_ref(&spec.root))
911        .iter()
912        .map(|leaf| leaf.field.as_str())
913        .collect();
914    if let Some(order) = &spec.order_by {
915        fields.push(order.field.as_str());
916    }
917    fields
918}
919
920/// Whether a `spec.search` still admits `key`. A committed candidate is admitted by the candidate
921/// set itself only when the plan took the fulltext path; a staged one is never, since an index on
922/// the overlay may have picked it. Answered off the scores, which a unit of work computes over the
923/// overlaid corpus, so the staged record is in them exactly when the search matches it.
924fn scored_in(access: &AccessPath, spec: &QuerySpec, key: &str) -> bool {
925    if spec.search.is_none() {
926        return true;
927    }
928    access.scores.as_ref().is_some_and(|scores| scores.contains_key(key))
929}
930
931fn matched_entry(
932    record: &HaysonRecord,
933    key: &str,
934    spec: &QuerySpec,
935    sort_field: Option<&str>,
936) -> Option<(String, Value)> {
937    if !test_node(record, &spec.root) {
938        return None;
939    }
940    let sort_value = sort_field.map_or(Value::Null, |field| record.get(field).cloned().unwrap_or(Value::Null));
941    Some((key.to_string(), sort_value))
942}
943
944/// Runs `spec` against `registry`, narrowing candidates through `plan_access` and then testing the
945/// rest of the predicate tree against records pulled one at a time from `source`. A candidate set
946/// of zero never touches `source` — that is the load-bearing behaviour of ADR-dsam/0017.
947///
948/// A query whose every field is projected never touches `source` either: the predicate is tested
949/// against the registry's in-memory projections and `reads` stays at zero, the same shortcut the
950/// TS `executeQuery` takes.
951pub fn execute_query<S: RecordSource + ?Sized>(
952    registry: &TableRegistry,
953    source: &mut S,
954    spec: &QuerySpec,
955) -> Result<QueryResult, RecordSourceError> {
956    let access = plan_access(registry, spec);
957    execute_access(registry, source, spec, access, None)
958}
959
960/// [`execute_query`] over an access path the caller already planned, with the records of `overlay`
961/// answered from the overlay itself instead of the log — the `txn/` half of the extension point
962/// [`RecordSource`] names. A staged record costs no read and is never tested on a registry
963/// projection, because the registry has not been told about it yet.
964pub fn execute_access<S: RecordSource + ?Sized>(
965    registry: &TableRegistry,
966    source: &mut S,
967    spec: &QuerySpec,
968    access: AccessPath,
969    overlay: Option<&dyn QueryOverlay>,
970) -> Result<QueryResult, RecordSourceError> {
971    let sort_field = spec.order_by.as_ref().map(|order| order.field.as_str());
972    let on_projection = fields_in_play(spec)
973        .into_iter()
974        .all(|field| registry.is_projected(field));
975    let mut matched: Vec<(String, Value)> = Vec::new();
976    let mut reads = 0usize;
977    for key in &access.keys {
978        let staged = overlay.and_then(|overlay| overlay.records().get(key));
979        let entry = match staged {
980            // NOTE: only the fulltext path search-filters its candidates, so a staged key re-checks here.
981            Some(_) if !scored_in(&access, spec, key) => None,
982            Some(record) => matched_entry(record, key, spec, sort_field),
983            None if on_projection => registry
984                .projection_of(key)
985                .and_then(|projection| matched_entry(projection, key, spec, sort_field)),
986            None => match source.load(key)? {
987                Some(record) => {
988                    reads += 1;
989                    matched_entry(&record, key, spec, sort_field)
990                }
991                None => None,
992            },
993        };
994        if let Some(entry) = entry {
995            matched.push(entry);
996        }
997    }
998    let total = matched.len();
999    let keys = if spec.count_only {
1000        Vec::new()
1001    } else {
1002        order_and_page(matched, spec, access.scores.as_ref())
1003    };
1004    Ok(QueryResult {
1005        keys,
1006        matched: total,
1007        plan: AccessPathSummary {
1008            strategy: access.strategy,
1009            field: access.field,
1010            index: access.index,
1011            candidates: access.keys.len(),
1012            ranked: access.scores.is_some() && spec.order_by.is_none(),
1013            filtered_on_projection: on_projection,
1014            reads,
1015            branches: access.branches,
1016            overlay: access.overlay,
1017        },
1018    })
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use serde_json::json;
1024
1025    use super::*;
1026    use crate::query::builder::QueryBuilder;
1027    use crate::schema::parse_schema;
1028    use crate::storage::record::{serialize_entry, LogMeta, LogOp};
1029
1030    struct CountingSource<'a> {
1031        inner: LogRecordSource<'a>,
1032        reads: usize,
1033    }
1034
1035    impl RecordSource for CountingSource<'_> {
1036        fn load(&mut self, key: &str) -> Result<Option<HaysonRecord>, RecordSourceError> {
1037            self.reads += 1;
1038            self.inner.load(key)
1039        }
1040    }
1041
1042    fn users_schema() -> crate::schema::TableSchema {
1043        parse_schema(&json!({
1044            "schemaVersion": 1,
1045            "table": "users",
1046            "primaryKey": "id",
1047            "fields": {
1048                "id": { "type": "string" },
1049                "name": { "type": "string" },
1050                "age": { "type": "int", "index": "sorted" },
1051                "role": { "type": "string", "index": "exact" },
1052                "tags": { "type": "string[]", "index": "exact" },
1053            },
1054        }))
1055        .unwrap()
1056    }
1057
1058    fn record(pairs: &[(&str, Value)]) -> HaysonRecord {
1059        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
1060    }
1061
1062    fn ania() -> HaysonRecord {
1063        record(&[
1064            ("id", json!("user_1")),
1065            ("name", json!("Ania")),
1066            ("age", json!(31)),
1067            ("role", json!("admin")),
1068            ("tags", json!(["backend", "node"])),
1069        ])
1070    }
1071
1072    fn piotr() -> HaysonRecord {
1073        record(&[
1074            ("id", json!("user_2")),
1075            ("name", json!("Piotr")),
1076            ("age", json!(24)),
1077            ("role", json!("user")),
1078            ("tags", json!(["frontend"])),
1079        ])
1080    }
1081
1082    fn maria() -> HaysonRecord {
1083        record(&[
1084            ("id", json!("user_3")),
1085            ("name", json!("Maria")),
1086            ("age", json!(41)),
1087            ("role", json!("user")),
1088            ("tags", json!(["backend", "go"])),
1089        ])
1090    }
1091
1092    struct Fixture {
1093        _dir: tempfile::TempDir,
1094        registry: TableRegistry,
1095        log: LogFile,
1096    }
1097
1098    fn fixture_with_schema(schema: crate::schema::TableSchema, records: &[HaysonRecord]) -> Fixture {
1099        fixture_of(TableRegistry::new(schema), records)
1100    }
1101
1102    fn fixture_of(registry: TableRegistry, records: &[HaysonRecord]) -> Fixture {
1103        let dir = tempfile::tempdir().unwrap();
1104        let path = dir.path().join("users.jsonl");
1105        let mut log = LogFile::open(&path, false).unwrap();
1106        let mut registry = registry;
1107
1108        let mut seq = 0u64;
1109        for entry in records {
1110            seq += 1;
1111            let key = entry.get("id").and_then(Value::as_str).unwrap().to_string();
1112            let line = serialize_entry(
1113                &LogMeta {
1114                    key: key.clone(),
1115                    op: LogOp::Put,
1116                    seq,
1117                    ts: seq as i64,
1118                },
1119                Some(entry),
1120            );
1121            let slots = log.append(&[line]).unwrap();
1122            registry.apply_put(&key, entry, slots[0], seq);
1123        }
1124        Fixture {
1125            _dir: dir,
1126            registry,
1127            log,
1128        }
1129    }
1130
1131    fn fixture(records: &[HaysonRecord]) -> Fixture {
1132        fixture_with_schema(users_schema(), records)
1133    }
1134
1135    fn fulltext_users_schema() -> crate::schema::TableSchema {
1136        parse_schema(&json!({
1137            "schemaVersion": 1,
1138            "table": "users",
1139            "primaryKey": "id",
1140            "fields": {
1141                "id": { "type": "string" },
1142                "name": { "type": "string" },
1143                "bio": { "type": "string", "fulltext": true },
1144                "age": { "type": "int", "index": "sorted" },
1145                "role": { "type": "string", "index": "exact" },
1146            },
1147        }))
1148        .unwrap()
1149    }
1150
1151    fn ania_bio() -> HaysonRecord {
1152        record(&[
1153            ("id", json!("user_1")),
1154            ("name", json!("Ania")),
1155            ("bio", json!("Backend developer, Łódź")),
1156            ("age", json!(31)),
1157            ("role", json!("admin")),
1158        ])
1159    }
1160
1161    fn piotr_bio() -> HaysonRecord {
1162        record(&[
1163            ("id", json!("user_2")),
1164            ("name", json!("Piotr")),
1165            ("bio", json!("Frontend developer from Warsaw")),
1166            ("age", json!(24)),
1167            ("role", json!("user")),
1168        ])
1169    }
1170
1171    fn maria_bio() -> HaysonRecord {
1172        record(&[
1173            ("id", json!("user_3")),
1174            ("name", json!("Maria")),
1175            ("bio", json!("Backend developer, Kraków")),
1176            ("age", json!(41)),
1177            ("role", json!("user")),
1178        ])
1179    }
1180
1181    fn fulltext_fixture() -> Fixture {
1182        fixture_with_schema(fulltext_users_schema(), &[ania_bio(), piotr_bio(), maria_bio()])
1183    }
1184
1185    fn overwrite(fixture: &mut Fixture, entry: &HaysonRecord, seq: u64) {
1186        let key = entry.get("id").and_then(Value::as_str).unwrap().to_string();
1187        let line = serialize_entry(
1188            &LogMeta {
1189                key: key.clone(),
1190                op: LogOp::Put,
1191                seq,
1192                ts: seq as i64,
1193            },
1194            Some(entry),
1195        );
1196        let slots = fixture.log.append(&[line]).unwrap();
1197        fixture.registry.apply_put(&key, entry, slots[0], seq);
1198    }
1199
1200    fn run(fixture: &Fixture, spec: &QuerySpec) -> (QueryResult, usize) {
1201        let mut source = CountingSource {
1202            inner: LogRecordSource::new(&fixture.registry, &fixture.log),
1203            reads: 0,
1204        };
1205        let result = execute_query(&fixture.registry, &mut source, spec).unwrap();
1206        (result, source.reads)
1207    }
1208
1209    #[test]
1210    fn uses_the_exact_index_for_a_single_predicate() {
1211        let fixture = fixture(&[ania(), piotr(), maria()]);
1212        let spec = QueryBuilder::new()
1213            .r#where("role", ComparisonOp::Eq, json!("user"))
1214            .spec();
1215
1216        let (result, reads) = run(&fixture, &spec);
1217        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1218        assert_eq!(result.plan.field, Some("role".to_string()));
1219        assert_eq!(result.plan.candidates, 2);
1220        assert_eq!(reads, 0, "`role` is projected, so the predicate never leaves memory");
1221        let mut ids = result.keys.clone();
1222        ids.sort();
1223        assert_eq!(ids, vec!["user_2", "user_3"]);
1224    }
1225
1226    #[test]
1227    fn narrows_through_the_index_then_filters_the_rest_in_memory() {
1228        let fixture = fixture(&[ania(), piotr(), maria()]);
1229        let spec = QueryBuilder::new()
1230            .r#where("role", ComparisonOp::Eq, json!("user"))
1231            .r#where("age", ComparisonOp::Gt, json!(30))
1232            .spec();
1233
1234        let (result, reads) = run(&fixture, &spec);
1235        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1236        assert_eq!(result.plan.candidates, 2);
1237        assert_eq!(
1238            reads, 0,
1239            "`role` and `age` are both projected — the second filter runs on projections too"
1240        );
1241        assert_eq!(result.keys, vec!["user_3".to_string()]);
1242    }
1243
1244    #[test]
1245    fn picks_the_more_selective_index_when_more_than_one_applies() {
1246        let fixture = fixture(&[ania(), piotr(), maria()]);
1247        let spec = QueryBuilder::new()
1248            .r#where("role", ComparisonOp::Eq, json!("admin"))
1249            .r#where("age", ComparisonOp::Gt, json!(10))
1250            .spec();
1251
1252        let (result, _) = run(&fixture, &spec);
1253        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1254        assert_eq!(result.plan.field, Some("role".to_string()));
1255        assert_eq!(result.plan.candidates, 1);
1256    }
1257
1258    #[test]
1259    fn unions_the_branches_of_an_or_bracket() {
1260        let fixture = fixture(&[ania(), piotr(), maria()]);
1261        let spec = QueryBuilder::new()
1262            .where_any(|group| {
1263                group.r#where("role", ComparisonOp::Eq, json!("admin")).r#where(
1264                    "tags",
1265                    ComparisonOp::Contains,
1266                    json!("go"),
1267                );
1268            })
1269            .spec();
1270
1271        let (result, _) = run(&fixture, &spec);
1272        assert_eq!(result.plan.strategy, QueryStrategy::Union);
1273        assert_eq!(result.plan.candidates, 2);
1274        assert_eq!(
1275            result.plan.branches,
1276            Some(vec![
1277                PlanBranch {
1278                    strategy: QueryStrategy::Exact,
1279                    field: Some("role".to_string()),
1280                    candidates: 1
1281                },
1282                PlanBranch {
1283                    strategy: QueryStrategy::Exact,
1284                    field: Some("tags".to_string()),
1285                    candidates: 1
1286                },
1287            ])
1288        );
1289        let mut ids = result.keys.clone();
1290        ids.sort();
1291        assert_eq!(ids, vec!["user_1", "user_3"]);
1292    }
1293
1294    #[test]
1295    fn nests_an_and_bracket_inside_an_or_bracket() {
1296        let fixture = fixture(&[ania(), piotr(), maria()]);
1297        let spec = QueryBuilder::new()
1298            .where_any(|group| {
1299                group
1300                    .r#where("role", ComparisonOp::Eq, json!("admin"))
1301                    .where_all(|inner| {
1302                        inner.r#where("role", ComparisonOp::Eq, json!("user")).r#where(
1303                            "age",
1304                            ComparisonOp::Gt,
1305                            json!(35),
1306                        );
1307                    });
1308            })
1309            .spec();
1310
1311        let (result, _) = run(&fixture, &spec);
1312        assert_eq!(result.plan.strategy, QueryStrategy::Union);
1313        assert_eq!(
1314            result.plan.branches,
1315            Some(vec![
1316                PlanBranch {
1317                    strategy: QueryStrategy::Exact,
1318                    field: Some("role".to_string()),
1319                    candidates: 1
1320                },
1321                PlanBranch {
1322                    strategy: QueryStrategy::Sorted,
1323                    field: Some("age".to_string()),
1324                    candidates: 1
1325                },
1326            ])
1327        );
1328        let mut ids = result.keys.clone();
1329        ids.sort();
1330        assert_eq!(ids, vec!["user_1", "user_3"]);
1331    }
1332
1333    #[test]
1334    fn degrades_the_whole_bracket_to_a_scan_when_one_branch_has_no_index() {
1335        let fixture = fixture(&[ania(), piotr(), maria()]);
1336        let spec = QueryBuilder::new()
1337            .where_any(|group| {
1338                group.r#where("role", ComparisonOp::Eq, json!("admin")).r#where(
1339                    "name",
1340                    ComparisonOp::StartsWith,
1341                    json!("Piotr"),
1342                );
1343            })
1344            .spec();
1345
1346        let (result, _) = run(&fixture, &spec);
1347        assert_eq!(result.plan.strategy, QueryStrategy::Scan);
1348        assert_eq!(result.plan.candidates, 3);
1349        assert_eq!(result.plan.branches, None);
1350        let mut ids = result.keys.clone();
1351        ids.sort();
1352        assert_eq!(ids, vec!["user_1", "user_2"]);
1353    }
1354
1355    /// ADR-dsam/0017 — an equality miss on an indexed field is a proven-empty result, not a
1356    /// license to fall back to a full scan. Verified two ways: the plan's candidate count is
1357    /// zero, AND the record source (backed by the real log file) is never asked to load anything.
1358    #[test]
1359    fn an_empty_index_bucket_never_falls_back_to_a_scan() {
1360        let fixture = fixture(&[ania(), piotr(), maria()]);
1361        let spec = QueryBuilder::new()
1362            .r#where("role", ComparisonOp::Eq, json!("ghost"))
1363            .spec();
1364
1365        let (result, reads) = run(&fixture, &spec);
1366        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1367        assert_eq!(result.plan.field, Some("role".to_string()));
1368        assert_eq!(result.plan.candidates, 0);
1369        assert_eq!(
1370            reads, 0,
1371            "the log must not be read when the index already proved an empty result"
1372        );
1373        assert!(result.keys.is_empty());
1374    }
1375
1376    #[test]
1377    fn falls_back_to_a_scan_when_no_registry_can_help() {
1378        let fixture = fixture(&[ania(), piotr(), maria()]);
1379        let spec = QueryBuilder::new()
1380            .r#where("name", ComparisonOp::StartsWith, json!("A"))
1381            .spec();
1382
1383        let (result, reads) = run(&fixture, &spec);
1384        assert_eq!(result.plan.strategy, QueryStrategy::Scan);
1385        assert_eq!(result.plan.candidates, 3);
1386        assert_eq!(reads, 3);
1387        assert_eq!(result.keys, vec!["user_1".to_string()]);
1388    }
1389
1390    #[test]
1391    fn reaches_the_primary_registry_from_a_top_level_equality() {
1392        let fixture = fixture(&[ania(), piotr(), maria()]);
1393        let spec = QueryBuilder::new()
1394            .r#where("id", ComparisonOp::Eq, json!("user_2"))
1395            .spec();
1396
1397        let (result, _) = run(&fixture, &spec);
1398        assert_eq!(result.plan.strategy, QueryStrategy::Primary);
1399        assert_eq!(result.plan.candidates, 1);
1400        assert_eq!(result.keys, vec!["user_2".to_string()]);
1401    }
1402
1403    #[test]
1404    fn uses_the_fulltext_index_for_a_search_query() {
1405        let fixture = fulltext_fixture();
1406        let spec = QueryBuilder::new().search("backend developer", SearchMode::All).spec();
1407
1408        let (result, _) = run(&fixture, &spec);
1409        assert_eq!(result.plan.strategy, QueryStrategy::Fulltext);
1410        assert_eq!(result.plan.field, None);
1411        assert_eq!(result.plan.candidates, 2);
1412        let mut ids = result.keys.clone();
1413        ids.sort();
1414        assert_eq!(ids, vec!["user_1", "user_3"]);
1415    }
1416
1417    // NOTE: search sits outside the predicate tree, so only the winning strategy's own
1418    // candidates ever get compared to the search term -- pairing it with a non-indexed field
1419    // keeps fulltext as the sole access path and lets the other leaf narrow in memory.
1420    #[test]
1421    fn narrows_a_fulltext_search_with_an_in_memory_predicate() {
1422        let fixture = fulltext_fixture();
1423        let spec = QueryBuilder::new()
1424            .search("backend developer", SearchMode::All)
1425            .r#where("name", ComparisonOp::StartsWith, json!("A"))
1426            .spec();
1427
1428        let (result, _) = run(&fixture, &spec);
1429        assert_eq!(result.plan.strategy, QueryStrategy::Fulltext);
1430        assert_eq!(result.plan.candidates, 2);
1431        assert_eq!(result.keys, vec!["user_1".to_string()]);
1432    }
1433
1434    #[test]
1435    fn matches_a_direct_registry_search_call() {
1436        let fixture = fulltext_fixture();
1437        let spec = QueryBuilder::new().search("backend developer", SearchMode::All).spec();
1438
1439        let (result, _) = run(&fixture, &spec);
1440        let mut via_planner = result.keys.clone();
1441        via_planner.sort();
1442
1443        let mut direct: Vec<String> = fixture
1444            .registry
1445            .search("backend developer", SearchMode::All)
1446            .into_keys()
1447            .collect();
1448        direct.sort();
1449
1450        assert_eq!(via_planner, direct);
1451    }
1452
1453    #[test]
1454    fn orders_the_result_by_a_field_ascending_and_descending() {
1455        let fixture = fixture(&[ania(), piotr(), maria()]);
1456        let ascending = QueryBuilder::new().order_by("age", SortDirection::Asc).spec();
1457        let descending = QueryBuilder::new().order_by("age", SortDirection::Desc).spec();
1458
1459        let (up, _) = run(&fixture, &ascending);
1460        let (down, _) = run(&fixture, &descending);
1461
1462        assert_eq!(up.keys, vec!["user_2", "user_1", "user_3"], "24, 31, 41");
1463        assert_eq!(down.keys, vec!["user_3", "user_1", "user_2"], "41, 31, 24");
1464    }
1465
1466    /// The order of the three steps is the whole point: were the page cut before the predicate ran,
1467    /// the two oldest of every record (`user_1`, `user_3`) would answer instead of the two oldest
1468    /// of those the predicate kept.
1469    #[test]
1470    fn a_page_is_cut_out_of_the_ordered_result_only_after_the_predicate_narrowed_it() {
1471        let fixture = fixture(&[ania(), piotr(), maria()]);
1472        let spec = QueryBuilder::new()
1473            .r#where("age", ComparisonOp::Gt, json!(24))
1474            .order_by("age", SortDirection::Asc)
1475            .limit(1)
1476            .spec();
1477
1478        let (result, _) = run(&fixture, &spec);
1479
1480        assert_eq!(result.keys, vec!["user_1"], "31 is the youngest of those older than 24");
1481        assert_eq!(result.matched, 2);
1482    }
1483
1484    #[test]
1485    fn an_offset_and_a_limit_together_select_a_middle_window_of_the_order() {
1486        let fixture = fixture(&[ania(), piotr(), maria()]);
1487        let spec = QueryBuilder::new()
1488            .order_by("age", SortDirection::Asc)
1489            .offset(1)
1490            .limit(1)
1491            .spec();
1492
1493        let (result, _) = run(&fixture, &spec);
1494
1495        assert_eq!(result.keys, vec!["user_1"]);
1496        assert_eq!(
1497            result.matched, 3,
1498            "the page is one key wide, the match count counts all three"
1499        );
1500    }
1501
1502    #[test]
1503    fn an_offset_past_the_end_and_a_limit_of_zero_both_answer_an_empty_page() {
1504        let fixture = fixture(&[ania(), piotr(), maria()]);
1505        let past_the_end = QueryBuilder::new()
1506            .order_by("age", SortDirection::Asc)
1507            .offset(99)
1508            .spec();
1509        let nothing = QueryBuilder::new().order_by("age", SortDirection::Asc).limit(0).spec();
1510
1511        let (skipped, _) = run(&fixture, &past_the_end);
1512        let (none, _) = run(&fixture, &nothing);
1513
1514        assert!(skipped.keys.is_empty());
1515        assert_eq!(skipped.matched, 3);
1516        assert!(none.keys.is_empty());
1517        assert_eq!(none.matched, 3);
1518    }
1519
1520    #[test]
1521    fn a_page_window_over_an_empty_result_stays_empty() {
1522        let fixture = fixture(&[ania(), piotr(), maria()]);
1523        let spec = QueryBuilder::new()
1524            .r#where("role", ComparisonOp::Eq, json!("ghost"))
1525            .order_by("age", SortDirection::Asc)
1526            .offset(2)
1527            .limit(5)
1528            .spec();
1529
1530        let (result, _) = run(&fixture, &spec);
1531
1532        assert!(result.keys.is_empty());
1533        assert_eq!(result.matched, 0);
1534    }
1535
1536    // NOTE: only one sort key exists (as in TS), so a tie is left alone — no second key decides it.
1537    #[test]
1538    fn a_tie_on_the_sort_field_keeps_every_matched_key_exactly_once() {
1539        let mut twin = piotr();
1540        twin.insert("id".to_string(), json!("user_4"));
1541        twin.insert("age".to_string(), json!(41));
1542        let fixture = fixture(&[ania(), piotr(), maria(), twin]);
1543        let spec = QueryBuilder::new().order_by("age", SortDirection::Asc).spec();
1544
1545        let (result, _) = run(&fixture, &spec);
1546
1547        assert_eq!(result.keys.len(), 4);
1548        assert_eq!(
1549            &result.keys[..2],
1550            ["user_2".to_string(), "user_1".to_string()],
1551            "24 then 31"
1552        );
1553        let mut tied = result.keys[2..].to_vec();
1554        tied.sort();
1555        assert_eq!(
1556            tied,
1557            vec!["user_3", "user_4"],
1558            "both 41s are present, in whichever order"
1559        );
1560    }
1561
1562    #[test]
1563    fn a_fulltext_search_ranks_by_score_before_the_page_is_cut() {
1564        let fixture = fulltext_fixture();
1565        let ranked = QueryBuilder::new().search("backend developer", SearchMode::All).spec();
1566        let first = QueryBuilder::new()
1567            .search("backend developer", SearchMode::All)
1568            .limit(1)
1569            .spec();
1570
1571        let (all, _) = run(&fixture, &ranked);
1572        let (page, _) = run(&fixture, &first);
1573
1574        assert_eq!(
1575            page.keys,
1576            all.keys[..1],
1577            "the page is the head of the ranking, not an arbitrary hit"
1578        );
1579        assert_eq!(page.matched, 2);
1580    }
1581
1582    #[test]
1583    fn an_explicit_order_replaces_the_fulltext_ranking() {
1584        let fixture = fulltext_fixture();
1585        let spec = QueryBuilder::new()
1586            .search("backend developer", SearchMode::All)
1587            .order_by("age", SortDirection::Desc)
1588            .limit(1)
1589            .spec();
1590
1591        let (result, _) = run(&fixture, &spec);
1592
1593        assert_eq!(
1594            result.keys,
1595            vec!["user_3"],
1596            "Maria is 41 — the ranking would have led with user_1"
1597        );
1598    }
1599
1600    #[test]
1601    fn an_aggregate_folds_every_match_and_not_just_the_page() {
1602        use crate::query::aggregate::execute_aggregate;
1603
1604        let fixture = fixture(&[ania(), piotr(), maria()]);
1605        let (spec, aggregate) = QueryBuilder::new().count().limit(1).build_aggregate();
1606
1607        let mut source = LogRecordSource::new(&fixture.registry, &fixture.log);
1608        let folded = execute_aggregate(&fixture.registry, &mut source, &spec, &aggregate).unwrap();
1609
1610        assert_eq!(folded.count, 3);
1611    }
1612
1613    fn named_users_schema() -> crate::schema::TableSchema {
1614        parse_schema(&json!({
1615            "schemaVersion": 1,
1616            "table": "users",
1617            "primaryKey": "id",
1618            "fields": {
1619                "id": { "type": "string" },
1620                "name": { "type": "string", "index": "exact" },
1621                "age": { "type": "int", "index": "sorted" },
1622                "role": { "type": "string", "index": "exact" },
1623                "tags": { "type": "string[]", "index": "exact" },
1624            },
1625        }))
1626        .unwrap()
1627    }
1628
1629    fn ania_kowalska() -> HaysonRecord {
1630        record(&[
1631            ("id", json!("user_1")),
1632            ("name", json!("Ania Kowalska")),
1633            ("age", json!(31)),
1634            ("role", json!("admin")),
1635            ("tags", json!(["backend", "node"])),
1636        ])
1637    }
1638
1639    /// An `exact` index answers "which keys hold exactly this value" and nothing else, so a
1640    /// substring test against a field carrying one has to leave it alone — routing `contains`
1641    /// through it looked up "Kowals" as a whole name, missed, and called the miss a proven absence.
1642    #[test]
1643    fn a_contains_predicate_on_a_string_field_with_an_exact_index_falls_back_to_a_scan() {
1644        let fixture = fixture_with_schema(named_users_schema(), &[ania_kowalska(), piotr(), maria()]);
1645        let spec = QueryBuilder::new()
1646            .r#where("name", ComparisonOp::Contains, json!("Kowals"))
1647            .spec();
1648
1649        let (result, _) = run(&fixture, &spec);
1650
1651        assert_eq!(result.plan.strategy, QueryStrategy::Scan);
1652        assert_eq!(result.keys, vec!["user_1".to_string()]);
1653    }
1654
1655    /// The counterpart of the test above: membership in an array field IS what the exact index
1656    /// holds, one bucket per element, so that `contains` must keep its index.
1657    #[test]
1658    fn a_contains_predicate_on_an_array_field_keeps_using_the_exact_index() {
1659        let fixture = fixture(&[ania(), piotr(), maria()]);
1660        let spec = QueryBuilder::new()
1661            .r#where("tags", ComparisonOp::Contains, json!("go"))
1662            .spec();
1663
1664        let (result, _) = run(&fixture, &spec);
1665
1666        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1667        assert_eq!(result.plan.field, Some("tags".to_string()));
1668        assert_eq!(result.plan.candidates, 1);
1669        assert_eq!(result.keys, vec!["user_3".to_string()]);
1670    }
1671
1672    /// An array field is indexed per element, so the whole array is a value the index cannot hold —
1673    /// its silence about one says nothing, and the query has to be answered by a scan.
1674    #[test]
1675    fn an_equality_against_an_array_value_falls_back_to_a_scan() {
1676        let fixture = fixture(&[ania(), piotr(), maria()]);
1677        let spec = QueryBuilder::new()
1678            .r#where("tags", ComparisonOp::Eq, json!(["backend", "node"]))
1679            .spec();
1680
1681        let (result, _) = run(&fixture, &spec);
1682
1683        assert_eq!(result.plan.strategy, QueryStrategy::Scan);
1684        assert_eq!(result.keys, vec!["user_1".to_string()]);
1685    }
1686
1687    fn admins_only_index() -> Vec<crate::catalog::ExpandedIndex> {
1688        vec![crate::catalog::ExpandedIndex {
1689            name: "users_admin_name".to_string(),
1690            fields: vec!["name".to_string()],
1691            kind: crate::catalog::CatalogIndexKind::Exact,
1692            unique: false,
1693            where_: Some(vec![crate::catalog::CatalogPredicate {
1694                field: "role".to_string(),
1695                op: "=".to_string(),
1696                value: json!("admin"),
1697            }]),
1698            expire_after_seconds: None,
1699        }]
1700    }
1701
1702    fn partial_fixture() -> Fixture {
1703        let schema = parse_schema(&json!({
1704            "schemaVersion": 1,
1705            "table": "users",
1706            "primaryKey": "id",
1707            "fields": {
1708                "id": { "type": "string" },
1709                "name": { "type": "string" },
1710                "age": { "type": "int" },
1711                "role": { "type": "string" },
1712                "tags": { "type": "string[]" },
1713            },
1714        }))
1715        .unwrap();
1716        let registry = TableRegistry::with_declared_indexes(schema, &admins_only_index());
1717        fixture_of(registry, &[ania(), piotr(), maria()])
1718    }
1719
1720    /// ADR-dsam/0017 narrowed: a `where`-scoped index holds part of the table, so its empty bucket
1721    /// may mean "the record was never offered to me" rather than "no such record exists".
1722    #[test]
1723    fn a_where_scoped_index_never_answers_a_query_that_omits_its_predicate() {
1724        let fixture = partial_fixture();
1725        let spec = QueryBuilder::new()
1726            .r#where("name", ComparisonOp::Eq, json!("Piotr"))
1727            .spec();
1728
1729        let (result, _) = run(&fixture, &spec);
1730
1731        assert_eq!(
1732            result.plan.strategy,
1733            QueryStrategy::Scan,
1734            "Piotr is not an admin, so the index never saw him"
1735        );
1736        assert_eq!(result.plan.candidates, 3);
1737        assert_eq!(result.keys, vec!["user_2".to_string()]);
1738    }
1739
1740    #[test]
1741    fn a_where_scoped_index_answers_a_query_that_repeats_its_predicate() {
1742        let fixture = partial_fixture();
1743        let spec = QueryBuilder::new()
1744            .r#where("name", ComparisonOp::Eq, json!("Ania"))
1745            .r#where("role", ComparisonOp::Eq, json!("admin"))
1746            .spec();
1747
1748        let (result, _) = run(&fixture, &spec);
1749
1750        assert_eq!(result.plan.strategy, QueryStrategy::Exact);
1751        assert_eq!(result.plan.index, Some("users_admin_name".to_string()));
1752        assert_eq!(
1753            result.plan.field, None,
1754            "a scoped index is named by the index, not by its one field"
1755        );
1756        assert_eq!(result.plan.candidates, 1);
1757        assert_eq!(result.keys, vec!["user_1".to_string()]);
1758    }
1759
1760    /// A query without `orderBy` answers in the order the records were written — the registries
1761    /// hold their candidates in hash sets, which would otherwise reorder the answer run to run.
1762    #[test]
1763    fn candidates_of_an_index_and_of_a_scan_alike_come_out_in_write_order() {
1764        let fixture = fixture(&[maria(), ania(), piotr()]);
1765        let indexed = QueryBuilder::new()
1766            .r#where("role", ComparisonOp::Eq, json!("user"))
1767            .spec();
1768        let scanned = QueryBuilder::new()
1769            .r#where("name", ComparisonOp::StartsWith, json!(""))
1770            .spec();
1771
1772        let (by_index, _) = run(&fixture, &indexed);
1773        let (by_scan, _) = run(&fixture, &scanned);
1774
1775        assert_eq!(
1776            by_index.keys,
1777            vec!["user_3".to_string(), "user_2".to_string()],
1778            "Maria was written first"
1779        );
1780        assert_eq!(
1781            by_scan.keys,
1782            vec!["user_3".to_string(), "user_1".to_string(), "user_2".to_string()],
1783            "the scan follows the log, not the hash of the key"
1784        );
1785    }
1786
1787    /// NOTE-8bmm: overwriting a record must not shuffle it to the back of an unordered page --
1788    /// the TS engine's `Map`-backed primary registry keeps an existing key exactly where it was.
1789    #[test]
1790    fn an_overwritten_record_keeps_its_place_on_an_unordered_page() {
1791        let mut fixture = fixture(&[ania(), piotr(), maria()]);
1792        let mut updated = ania();
1793        updated.insert("age".to_string(), json!(32));
1794        overwrite(&mut fixture, &updated, 4);
1795        let scanned = QueryBuilder::new()
1796            .r#where("name", ComparisonOp::StartsWith, json!(""))
1797            .spec();
1798
1799        let (result, _) = run(&fixture, &scanned);
1800
1801        assert_eq!(
1802            result.keys,
1803            vec!["user_1".to_string(), "user_2".to_string(), "user_3".to_string()],
1804            "the update rewrote Ania in place, so she stays first -- not last behind Maria"
1805        );
1806    }
1807
1808    #[test]
1809    fn a_record_written_after_an_overwrite_still_lands_at_the_end_of_the_page() {
1810        let mut fixture = fixture(&[ania(), piotr()]);
1811        let mut updated = ania();
1812        updated.insert("age".to_string(), json!(32));
1813        overwrite(&mut fixture, &updated, 3);
1814        overwrite(&mut fixture, &maria(), 4);
1815        let scanned = QueryBuilder::new()
1816            .r#where("name", ComparisonOp::StartsWith, json!(""))
1817            .spec();
1818
1819        let (result, _) = run(&fixture, &scanned);
1820
1821        assert_eq!(
1822            result.keys,
1823            vec!["user_1".to_string(), "user_2".to_string(), "user_3".to_string()],
1824            "Maria is genuinely new, so she goes last however recently Ania was rewritten"
1825        );
1826    }
1827
1828    #[test]
1829    fn an_empty_fulltext_result_never_falls_back_to_a_scan() {
1830        let fixture = fulltext_fixture();
1831        let spec = QueryBuilder::new().search("zzz_nowhere", SearchMode::All).spec();
1832
1833        let (result, reads) = run(&fixture, &spec);
1834        assert_eq!(result.plan.strategy, QueryStrategy::Fulltext);
1835        assert_eq!(result.plan.candidates, 0);
1836        assert_eq!(
1837            reads, 0,
1838            "the log must not be read when the search already proved an empty result"
1839        );
1840        assert!(result.keys.is_empty());
1841    }
1842
1843    #[test]
1844    fn a_search_left_to_its_own_order_reports_a_ranked_plan() {
1845        let fixture = fulltext_fixture();
1846        let spec = QueryBuilder::new().search("backend developer", SearchMode::All).spec();
1847
1848        let (result, _) = run(&fixture, &spec);
1849
1850        assert!(result.plan.ranked, "the order came out of the relevance scores");
1851    }
1852
1853    #[test]
1854    fn an_explicit_order_beside_a_search_reports_an_unranked_plan() {
1855        let fixture = fulltext_fixture();
1856        let spec = QueryBuilder::new()
1857            .search("backend developer", SearchMode::All)
1858            .order_by("age", SortDirection::Desc)
1859            .spec();
1860
1861        let (result, _) = run(&fixture, &spec);
1862
1863        assert!(
1864            !result.plan.ranked,
1865            "the scores were computed but the explicit order overrode them"
1866        );
1867    }
1868
1869    #[test]
1870    fn a_query_without_a_search_reports_an_unranked_plan() {
1871        let fixture = fixture(&[ania(), piotr(), maria()]);
1872        let spec = QueryBuilder::new()
1873            .r#where("role", ComparisonOp::Eq, json!("user"))
1874            .spec();
1875
1876        let (result, _) = run(&fixture, &spec);
1877
1878        assert!(!result.plan.ranked, "no search ran, so there is nothing to rank by");
1879    }
1880
1881    #[test]
1882    fn a_query_whose_every_field_is_projected_answers_without_reading_a_record() {
1883        let fixture = fixture(&[ania(), piotr(), maria()]);
1884        let spec = QueryBuilder::new()
1885            .r#where("role", ComparisonOp::Eq, json!("user"))
1886            .r#where("age", ComparisonOp::Gt, json!(30))
1887            .spec();
1888
1889        let (result, reads) = run(&fixture, &spec);
1890
1891        assert!(result.plan.filtered_on_projection);
1892        assert_eq!(result.plan.reads, 0);
1893        assert_eq!(reads, 0);
1894        assert_eq!(result.keys, vec!["user_3".to_string()]);
1895    }
1896
1897    #[test]
1898    fn a_query_touching_a_field_outside_the_projection_reads_every_candidate() {
1899        let fixture = fixture(&[ania(), piotr(), maria()]);
1900        let spec = QueryBuilder::new()
1901            .r#where("name", ComparisonOp::Eq, json!("Maria"))
1902            .spec();
1903
1904        let (result, reads) = run(&fixture, &spec);
1905
1906        assert!(
1907            !result.plan.filtered_on_projection,
1908            "`name` carries no index, so it is not projected"
1909        );
1910        assert_eq!(result.plan.reads, 3);
1911        assert_eq!(reads, 3);
1912        assert_eq!(result.keys, vec!["user_3".to_string()]);
1913    }
1914
1915    #[test]
1916    fn a_sort_on_a_field_outside_the_projection_takes_the_query_off_the_projections() {
1917        let fixture = fixture(&[ania(), piotr(), maria()]);
1918        let spec = QueryBuilder::new()
1919            .r#where("role", ComparisonOp::Eq, json!("user"))
1920            .order_by("name", SortDirection::Asc)
1921            .spec();
1922
1923        let (result, _) = run(&fixture, &spec);
1924
1925        assert!(
1926            !result.plan.filtered_on_projection,
1927            "the sort field counts as a field in play"
1928        );
1929        assert_eq!(result.plan.reads, 2);
1930        assert_eq!(
1931            result.keys,
1932            vec!["user_3".to_string(), "user_2".to_string()],
1933            "Maria before Piotr"
1934        );
1935    }
1936
1937    #[test]
1938    fn a_count_only_query_answers_with_the_match_count_and_no_page() {
1939        let fixture = fixture(&[ania(), piotr(), maria()]);
1940        let paged = QueryBuilder::new()
1941            .r#where("role", ComparisonOp::Eq, json!("user"))
1942            .spec();
1943        let counting = paged.clone().counting(true);
1944
1945        let (page, _) = run(&fixture, &paged);
1946        let (count, _) = run(&fixture, &counting);
1947
1948        assert_eq!(
1949            page.keys.len(),
1950            2,
1951            "the same query hands back a page when it is not counting"
1952        );
1953        assert_eq!(count.matched, page.matched);
1954        assert!(
1955            count.keys.is_empty(),
1956            "a count-only query names no page for a caller to load"
1957        );
1958        assert_eq!(
1959            count.plan, page.plan,
1960            "counting changes what is returned, not how it was planned"
1961        );
1962    }
1963
1964    #[test]
1965    fn a_count_only_query_counts_every_match_even_where_a_page_would_be_cut() {
1966        let fixture = fixture(&[ania(), piotr(), maria()]);
1967        let spec = QueryBuilder::new()
1968            .r#where("role", ComparisonOp::Eq, json!("user"))
1969            .spec()
1970            .with_limit(1);
1971
1972        let (count, _) = run(&fixture, &spec.clone().counting(true));
1973
1974        assert_eq!(
1975            count.matched, 2,
1976            "`matched` counts before `limit` cuts, counting or not"
1977        );
1978        assert!(count.keys.is_empty());
1979    }
1980
1981    #[test]
1982    fn an_aggregate_keeps_its_keys_when_the_spec_it_unpages_was_counting() {
1983        let spec = QueryBuilder::new()
1984            .r#where("role", ComparisonOp::Eq, json!("user"))
1985            .spec()
1986            .counting(true);
1987
1988        // NOTE: an aggregate folds record by record over `keys`, so it can never run count-only.
1989        assert!(!spec.unpaged().count_only);
1990    }
1991}