Skip to main content

corium_db/
lib.rs

1//! Immutable database values: time views, covering-index access, naming,
2//! per-attribute statistics, and bootstrap metadata.
3//!
4//! A [`Db`] is a value: cheap to clone, never mutated in place. Time views
5//! ([`Db::as_of`], [`Db::since`], [`Db::history`]) wrap the same recorded
6//! datoms with a different fold policy — no copying of facts. The four
7//! covering indexes for a view are materialized lazily on first read and
8//! shared by every clone of that value.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::{Arc, OnceLock};
12
13use corium_core::{
14    AttrId, Cardinality, Datom, EntityId, IndexOrder, Keyword, KeywordInterner, Partition, Schema,
15    Unique, Value, encoding::Encodable,
16};
17
18/// The first user-assignable sequence number. Lower ids are reserved for bootstrap data.
19pub const FIRST_USER_ID: u64 = 1_000;
20
21/// Time-view selector for a database value (see `docs/design/time-model.md`).
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub enum DbView {
24    /// Live facts as of the basis transaction.
25    #[default]
26    Current,
27    /// Facts as they stood at basis `t` (inclusive).
28    AsOf(u64),
29    /// Only live facts added after `t` (exclusive).
30    Since(u64),
31    /// Every assertion and retraction ever recorded, except `:db/noHistory` attributes.
32    History,
33}
34
35/// Registry of `:db/ident` names for entities (attributes chiefly).
36#[derive(Clone, Debug, Default)]
37pub struct Idents {
38    by_keyword: BTreeMap<Keyword, EntityId>,
39    by_id: BTreeMap<EntityId, Keyword>,
40}
41
42impl Idents {
43    /// Registers an ident for an entity.
44    pub fn insert(&mut self, keyword: Keyword, id: EntityId) {
45        self.by_id.insert(id, keyword.clone());
46        self.by_keyword.insert(keyword, id);
47    }
48
49    /// Resolves a keyword to its entity id.
50    #[must_use]
51    pub fn entid(&self, keyword: &Keyword) -> Option<EntityId> {
52        self.by_keyword.get(keyword).copied()
53    }
54
55    /// Resolves an entity id back to its ident.
56    #[must_use]
57    pub fn ident(&self, id: EntityId) -> Option<&Keyword> {
58        self.by_id.get(&id)
59    }
60
61    /// Iterates all registered idents in keyword order.
62    pub fn iter(&self) -> impl Iterator<Item = (&Keyword, &EntityId)> {
63        self.by_keyword.iter()
64    }
65}
66
67/// Per-attribute statistics driving planner selectivity estimates.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69pub struct AttrStats {
70    /// Datoms carrying this attribute in the view.
71    pub count: usize,
72    /// Distinct values for this attribute.
73    pub distinct_values: usize,
74    /// Distinct entities carrying this attribute.
75    pub distinct_entities: usize,
76}
77
78/// Whole-view statistics for the query planner.
79#[derive(Clone, Debug, Default)]
80pub struct PlannerStats {
81    /// Statistics per attribute.
82    pub per_attr: BTreeMap<AttrId, AttrStats>,
83    /// Total datoms in the view.
84    pub total_datoms: usize,
85    /// Distinct entities in the view.
86    pub entity_count: usize,
87}
88
89impl PlannerStats {
90    /// Estimated datoms matched by a scan with the given bound components.
91    #[must_use]
92    pub fn estimate(&self, e_bound: bool, a: Option<AttrId>, v_bound: bool) -> usize {
93        let attr = a.and_then(|a| self.per_attr.get(&a));
94        match (e_bound, attr) {
95            // Bound entity: at most the entity's datoms; refine by attribute.
96            (true, Some(stats)) => (stats.count / stats.distinct_entities.max(1)).max(1),
97            (true, None) => (self.total_datoms / self.entity_count.max(1)).max(1),
98            (false, Some(stats)) if v_bound => (stats.count / stats.distinct_values.max(1)).max(1),
99            (false, Some(stats)) => stats.count.max(1),
100            // Unknown attribute constant: nothing will match.
101            (false, None) if a.is_some() => 1,
102            (false, None) => self.total_datoms.max(1),
103        }
104    }
105}
106
107type Index = BTreeMap<Vec<u8>, Datom>;
108
109const ORDERS: [IndexOrder; 4] = [
110    IndexOrder::Eavt,
111    IndexOrder::Aevt,
112    IndexOrder::Avet,
113    IndexOrder::Vaet,
114];
115
116const fn slot(order: IndexOrder) -> usize {
117    match order {
118        IndexOrder::Eavt => 0,
119        IndexOrder::Aevt => 1,
120        IndexOrder::Avet => 2,
121        IndexOrder::Vaet => 3,
122    }
123}
124
125/// Builds the encoded key prefix for a partial datom in one index order.
126///
127/// Components are consumed in the index's component order and encoding stops
128/// at the first missing component, so the result is a proper range prefix.
129#[must_use]
130pub fn key_prefix(
131    order: IndexOrder,
132    e: Option<EntityId>,
133    a: Option<AttrId>,
134    v: Option<&Value>,
135) -> Vec<u8> {
136    enum C {
137        E,
138        A,
139        V,
140    }
141    let components = match order {
142        IndexOrder::Eavt => [C::E, C::A, C::V],
143        IndexOrder::Aevt => [C::A, C::E, C::V],
144        IndexOrder::Avet => [C::A, C::V, C::E],
145        IndexOrder::Vaet => [C::V, C::A, C::E],
146    };
147    let mut out = Vec::new();
148    for component in components {
149        match component {
150            C::E => match e {
151                Some(e) => e.encode_into(&mut out),
152                None => break,
153            },
154            C::A => match a {
155                Some(a) => a.encode_into(&mut out),
156                None => break,
157            },
158            C::V => match v {
159                Some(v) => v.encode_into(&mut out),
160                None => break,
161            },
162        }
163    }
164    out
165}
166
167/// An immutable value of a database at one basis transaction and time view.
168#[derive(Clone, Debug, Default)]
169pub struct Db {
170    basis_t: u64,
171    schema: Schema,
172    recorded: Arc<Vec<Datom>>,
173    idents: Arc<Idents>,
174    interner: Arc<KeywordInterner>,
175    view: DbView,
176    indexes: Arc<OnceLock<[Index; 4]>>,
177    stats: Arc<OnceLock<PlannerStats>>,
178}
179
180impl Db {
181    /// Creates an empty database with the supplied schema.
182    #[must_use]
183    pub fn new(schema: Schema) -> Self {
184        Self {
185            schema,
186            ..Self::default()
187        }
188    }
189
190    /// Creates a current database value from a published EAVT snapshot.
191    ///
192    /// `datoms` must be the live facts at `basis_t`. Their original
193    /// transaction ids are retained, but transactions before `basis_t` that
194    /// no longer contribute a live fact are not reconstructed. This makes
195    /// the value suitable for current queries and for applying the log tail;
196    /// complete historical views still require replaying the full log.
197    #[must_use]
198    pub fn from_current_snapshot(
199        basis_t: u64,
200        schema: Schema,
201        idents: Idents,
202        interner: KeywordInterner,
203        datoms: Vec<Datom>,
204    ) -> Self {
205        Self {
206            basis_t,
207            schema,
208            recorded: Arc::new(datoms),
209            idents: Arc::new(idents),
210            interner: Arc::new(interner),
211            view: DbView::Current,
212            indexes: Arc::new(OnceLock::new()),
213            stats: Arc::new(OnceLock::new()),
214        }
215    }
216
217    /// Attaches ident and keyword naming registries, returning the named value.
218    #[must_use]
219    pub fn with_naming(mut self, idents: Idents, interner: KeywordInterner) -> Self {
220        self.idents = Arc::new(idents);
221        self.interner = Arc::new(interner);
222        self
223    }
224
225    /// Current transaction basis.
226    #[must_use]
227    pub const fn basis_t(&self) -> u64 {
228        self.basis_t
229    }
230
231    /// Schema at this basis.
232    #[must_use]
233    pub const fn schema(&self) -> &Schema {
234        &self.schema
235    }
236
237    /// Ident registry.
238    #[must_use]
239    pub fn idents(&self) -> &Idents {
240        &self.idents
241    }
242
243    /// Keyword interner used by keyword values in this database.
244    #[must_use]
245    pub fn interner(&self) -> &KeywordInterner {
246        &self.interner
247    }
248
249    /// The time view this value presents.
250    #[must_use]
251    pub const fn view(&self) -> DbView {
252        self.view
253    }
254
255    /// Every recorded assertion and retraction, in transaction order.
256    #[must_use]
257    pub fn recorded_datoms(&self) -> &[Datom] {
258        &self.recorded
259    }
260
261    /// Returns the as-of view at basis `t`: facts as they stood then.
262    #[must_use]
263    pub fn as_of(&self, t: u64) -> Self {
264        self.with_view(DbView::AsOf(t))
265    }
266
267    /// Returns the since view: only live facts added after `t`.
268    #[must_use]
269    pub fn since(&self, t: u64) -> Self {
270        self.with_view(DbView::Since(t))
271    }
272
273    /// Returns the history view: all assertions and retractions ever.
274    #[must_use]
275    pub fn history(&self) -> Self {
276        self.with_view(DbView::History)
277    }
278
279    fn with_view(&self, view: DbView) -> Self {
280        if view == self.view {
281            return self.clone();
282        }
283        Self {
284            view,
285            indexes: Arc::new(OnceLock::new()),
286            stats: Arc::new(OnceLock::new()),
287            ..self.clone()
288        }
289    }
290
291    /// Groups recorded datoms by transaction over the half-open range `[start, end)`.
292    #[must_use]
293    pub fn tx_range(&self, start: u64, end: Option<u64>) -> Vec<(u64, Vec<Datom>)> {
294        let mut by_t: BTreeMap<u64, Vec<Datom>> = BTreeMap::new();
295        for datom in self.recorded.iter() {
296            let t = datom.tx.sequence();
297            if t >= start && end.is_none_or(|end| t < end) {
298                by_t.entry(t).or_default().push(datom.clone());
299            }
300        }
301        by_t.into_iter().collect()
302    }
303
304    /// Returns this view's facts, deterministically ordered by EAVT.
305    #[must_use]
306    pub fn datoms(&self) -> Vec<Datom> {
307        self.datoms_at(IndexOrder::Eavt).cloned().collect()
308    }
309
310    /// Iterates this view's datoms in one index order.
311    ///
312    /// AVET covers only indexed/unique attributes and VAET only reference
313    /// values, mirroring Datomic's covering-index composition.
314    pub fn datoms_at(&self, order: IndexOrder) -> impl Iterator<Item = &Datom> {
315        self.indexes()[slot(order)].values()
316    }
317
318    /// Iterates this view's datoms for one attribute in AEVT order.
319    ///
320    /// Unlike AVET, AEVT covers every installed attribute. Callers can use
321    /// this as the fallback for attribute predicates that do not have AVET
322    /// coverage.
323    pub fn datoms_for_attribute(&self, a: AttrId) -> impl Iterator<Item = &Datom> {
324        let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
325        self.indexes()[slot(IndexOrder::Aevt)]
326            .range(prefix.clone()..)
327            .take_while(move |(key, _)| key.starts_with(&prefix))
328            .map(|(_, datom)| datom)
329    }
330
331    /// Iterates datoms whose key in `order` starts with `prefix`.
332    pub fn datoms_prefix<'a>(
333        &'a self,
334        order: IndexOrder,
335        prefix: &'a [u8],
336    ) -> impl Iterator<Item = &'a Datom> {
337        self.indexes()[slot(order)]
338            .range(prefix.to_vec()..)
339            .take_while(move |(key, _)| key.starts_with(prefix))
340            .map(|(_, datom)| datom)
341    }
342
343    /// Iterates datoms in `order` starting from the first key at or after `start`.
344    pub fn seek_datoms<'a>(
345        &'a self,
346        order: IndexOrder,
347        start: &[u8],
348    ) -> impl Iterator<Item = &'a Datom> {
349        self.indexes()[slot(order)]
350            .range(start.to_vec()..)
351            .map(|(_, datom)| datom)
352    }
353
354    /// Iterates the AVET index for `a` over the value range `[start, end)`.
355    ///
356    /// Only indexed/unique attributes appear in AVET.
357    pub fn index_range<'a>(
358        &'a self,
359        a: AttrId,
360        start: Option<&Value>,
361        end: Option<&'a Value>,
362    ) -> impl Iterator<Item = &'a Datom> {
363        let a_prefix = key_prefix(IndexOrder::Avet, None, Some(a), None);
364        let start_key = key_prefix(IndexOrder::Avet, None, Some(a), start);
365        self.indexes()[slot(IndexOrder::Avet)]
366            .range(start_key..)
367            .take_while(move |(key, _)| key.starts_with(&a_prefix))
368            .map(|(_, datom)| datom)
369            .take_while(move |datom| end.is_none_or(|end| datom.v < *end))
370    }
371
372    /// Current values for an entity/attribute pair.
373    #[must_use]
374    pub fn values(&self, e: EntityId, a: AttrId) -> Vec<Value> {
375        let prefix = key_prefix(IndexOrder::Eavt, Some(e), Some(a), None);
376        self.datoms_prefix(IndexOrder::Eavt, &prefix)
377            .map(|datom| datom.v.clone())
378            .collect()
379    }
380
381    /// Resolves a unique attribute/value pair to its entity.
382    #[must_use]
383    pub fn lookup(&self, a: AttrId, v: &Value) -> Option<EntityId> {
384        if avet_covered(&self.schema, a) {
385            let prefix = key_prefix(IndexOrder::Avet, None, Some(a), Some(v));
386            self.datoms_prefix(IndexOrder::Avet, &prefix)
387                .next()
388                .map(|datom| datom.e)
389        } else {
390            let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
391            self.datoms_prefix(IndexOrder::Aevt, &prefix)
392                .find(|datom| datom.v == *v)
393                .map(|datom| datom.e)
394        }
395    }
396
397    /// Applies a committed record, returning a new database value.
398    ///
399    /// Only meaningful for the current view; time views are read-only.
400    #[must_use]
401    pub fn with_transaction(&self, t: u64, datoms: &[Datom]) -> Self {
402        debug_assert!(
403            self.view == DbView::Current,
404            "with_transaction applies only to the current view"
405        );
406        let mut next = self.clone();
407        next.basis_t = t;
408        Arc::make_mut(&mut next.recorded).extend_from_slice(datoms);
409        next.indexes = Arc::new(OnceLock::new());
410        next.stats = Arc::new(OnceLock::new());
411        // Derive indexes incrementally when the parent already built them, so
412        // transaction pipelines don't refold the whole history per operation.
413        if let Some(parent) = self.indexes.get() {
414            let mut derived = parent.clone();
415            apply_current(&mut derived, datoms.iter(), &self.schema);
416            let _ = next.indexes.set(derived);
417        }
418        next
419    }
420
421    /// Computes basic statistics over this view's facts.
422    #[must_use]
423    pub fn stats(&self) -> DbStats {
424        let planner = self.planner_stats();
425        DbStats {
426            datoms: planner.total_datoms,
427            entities: planner.entity_count,
428            attributes: planner.per_attr.len(),
429        }
430    }
431
432    /// Planner statistics for this view, built lazily and cached.
433    #[must_use]
434    pub fn planner_stats(&self) -> &PlannerStats {
435        self.stats.get_or_init(|| {
436            let mut stats = PlannerStats::default();
437            let mut values: BTreeMap<AttrId, BTreeSet<&Value>> = BTreeMap::new();
438            let mut attr_entities: BTreeMap<AttrId, BTreeSet<EntityId>> = BTreeMap::new();
439            let mut entities: BTreeSet<EntityId> = BTreeSet::new();
440            for datom in self.datoms_at(IndexOrder::Eavt) {
441                stats.total_datoms += 1;
442                stats.per_attr.entry(datom.a).or_default().count += 1;
443                values.entry(datom.a).or_default().insert(&datom.v);
444                attr_entities.entry(datom.a).or_default().insert(datom.e);
445                entities.insert(datom.e);
446            }
447            for (a, entry) in &mut stats.per_attr {
448                entry.distinct_values = values.get(a).map_or(0, BTreeSet::len);
449                entry.distinct_entities = attr_entities.get(a).map_or(0, BTreeSet::len);
450            }
451            stats.entity_count = entities.len();
452            stats
453        })
454    }
455
456    fn indexes(&self) -> &[Index; 4] {
457        self.indexes.get_or_init(|| {
458            let mut indexes: [Index; 4] = Default::default();
459            match self.view {
460                DbView::History => {
461                    for datom in self.recorded.iter() {
462                        if self.schema.get(datom.a).is_some_and(|a| a.no_history) {
463                            continue;
464                        }
465                        insert_datom(&mut indexes, datom, &self.schema, true);
466                    }
467                }
468                DbView::Current | DbView::AsOf(_) | DbView::Since(_) => {
469                    let cutoff = match self.view {
470                        DbView::AsOf(t) => Some(t),
471                        _ => None,
472                    };
473                    let filtered = self
474                        .recorded
475                        .iter()
476                        .filter(|d| cutoff.is_none_or(|t| d.tx.sequence() <= t));
477                    apply_current(&mut indexes, filtered, &self.schema);
478                    if let DbView::Since(t) = self.view {
479                        for index in &mut indexes {
480                            index.retain(|_, datom| datom.tx.sequence() > t);
481                        }
482                    }
483                }
484            }
485            indexes
486        })
487    }
488}
489
490/// Folds assertions/retractions into current-view indexes.
491///
492/// Current views key entries by components only (no transaction suffix):
493/// at most one live datom exists per `(e, a, v)`, and retractions must
494/// erase the assertion regardless of which transactions produced them.
495fn apply_current<'a>(
496    indexes: &mut [Index; 4],
497    datoms: impl Iterator<Item = &'a Datom>,
498    schema: &Schema,
499) {
500    for datom in datoms {
501        if datom.added {
502            insert_datom(indexes, datom, schema, false);
503        } else {
504            for order in ORDERS {
505                if covered(schema, order, datom) {
506                    let key = key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v));
507                    indexes[slot(order)].remove(&key);
508                }
509            }
510        }
511    }
512}
513
514fn insert_datom(indexes: &mut [Index; 4], datom: &Datom, schema: &Schema, with_tx: bool) {
515    for order in ORDERS {
516        if covered(schema, order, datom) {
517            let key = if with_tx {
518                datom.key(order)
519            } else {
520                key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v))
521            };
522            indexes[slot(order)].insert(key, datom.clone());
523        }
524    }
525}
526
527fn covered(schema: &Schema, order: IndexOrder, datom: &Datom) -> bool {
528    match order {
529        IndexOrder::Eavt | IndexOrder::Aevt => true,
530        IndexOrder::Avet => avet_covered(schema, datom.a),
531        IndexOrder::Vaet => matches!(datom.v, Value::Ref(_)),
532    }
533}
534
535/// Whether the attribute participates in the AVET covering index.
536#[must_use]
537pub fn avet_covered(schema: &Schema, a: AttrId) -> bool {
538    schema
539        .get(a)
540        .is_some_and(|attr| attr.indexed || attr.unique.is_some())
541}
542
543/// Counts over one database view.
544#[derive(Clone, Copy, Debug, Eq, PartialEq)]
545pub struct DbStats {
546    /// Facts in the view.
547    pub datoms: usize,
548    /// Entities having at least one fact in the view.
549    pub entities: usize,
550    /// Attributes used by facts in the view.
551    pub attributes: usize,
552}
553
554/// Convenience constructor for schema attributes used during bootstrap/tests.
555#[must_use]
556pub const fn attribute(
557    id: u64,
558    value_type: corium_core::ValueType,
559    cardinality: Cardinality,
560    unique: Option<Unique>,
561) -> corium_core::Attribute {
562    corium_core::Attribute {
563        id: EntityId::new(Partition::Db as u32, id),
564        value_type,
565        cardinality,
566        unique,
567        is_component: false,
568        indexed: unique.is_some(),
569        no_history: false,
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use corium_core::ValueType;
577
578    fn schema() -> Schema {
579        let mut schema = Schema::default();
580        schema.insert(attribute(1, ValueType::Str, Cardinality::One, None));
581        schema.insert(attribute(
582            2,
583            ValueType::Long,
584            Cardinality::One,
585            Some(Unique::Identity),
586        ));
587        schema.insert(attribute(3, ValueType::Ref, Cardinality::Many, None));
588        schema
589    }
590
591    fn attr(id: u64) -> AttrId {
592        EntityId::new(Partition::Db as u32, id)
593    }
594
595    fn entity(id: u64) -> EntityId {
596        EntityId::new(Partition::User as u32, id)
597    }
598
599    fn tx_entity(t: u64) -> EntityId {
600        EntityId::new(Partition::Tx as u32, t)
601    }
602
603    fn datom(e: u64, a: u64, v: Value, t: u64, added: bool) -> Datom {
604        Datom {
605            e: entity(e),
606            a: attr(a),
607            v,
608            tx: tx_entity(t),
609            added,
610        }
611    }
612
613    fn sample() -> Db {
614        Db::new(schema())
615            .with_transaction(
616                1,
617                &[
618                    datom(1, 1, Value::Str("alice".into()), 1, true),
619                    datom(1, 2, Value::Long(7), 1, true),
620                ],
621            )
622            .with_transaction(
623                2,
624                &[
625                    datom(1, 1, Value::Str("alice".into()), 2, false),
626                    datom(1, 1, Value::Str("alicia".into()), 2, true),
627                    datom(2, 3, Value::Ref(entity(1)), 2, true),
628                ],
629            )
630    }
631
632    #[test]
633    fn current_view_folds_retractions() {
634        let db = sample();
635        assert_eq!(
636            db.values(entity(1), attr(1)),
637            vec![Value::Str("alicia".into())]
638        );
639        assert_eq!(db.stats().datoms, 3);
640    }
641
642    #[test]
643    fn attribute_scan_uses_complete_aevt_coverage() {
644        let db = sample();
645        let datoms = db.datoms_for_attribute(attr(1)).collect::<Vec<_>>();
646        assert_eq!(datoms.len(), 1);
647        assert_eq!(datoms[0].v, Value::Str("alicia".into()));
648        assert!(datoms.iter().all(|datom| datom.a == attr(1)));
649    }
650
651    #[test]
652    fn as_of_reconstructs_past_basis() {
653        let db = sample().as_of(1);
654        assert_eq!(
655            db.values(entity(1), attr(1)),
656            vec![Value::Str("alice".into())]
657        );
658        assert_eq!(db.stats().datoms, 2);
659    }
660
661    #[test]
662    fn since_excludes_older_live_facts() {
663        let db = sample().since(1);
664        // The long asserted at t=1 is invisible; the renamed string is visible.
665        assert_eq!(db.values(entity(1), attr(2)), Vec::<Value>::new());
666        assert_eq!(
667            db.values(entity(1), attr(1)),
668            vec![Value::Str("alicia".into())]
669        );
670    }
671
672    #[test]
673    fn history_exposes_assertions_and_retractions() {
674        let db = sample().history();
675        let names: Vec<_> = db
676            .datoms_prefix(
677                IndexOrder::Eavt,
678                &key_prefix(IndexOrder::Eavt, Some(entity(1)), Some(attr(1)), None),
679            )
680            .map(|d| (d.v.clone(), d.added))
681            .collect();
682        assert_eq!(names.len(), 3);
683        assert!(names.contains(&(Value::Str("alice".into()), false)));
684    }
685
686    #[test]
687    fn avet_only_covers_indexed_attributes() {
688        let db = sample();
689        assert_eq!(db.datoms_at(IndexOrder::Avet).count(), 1);
690        assert_eq!(db.datoms_at(IndexOrder::Vaet).count(), 1);
691    }
692
693    #[test]
694    fn index_range_scans_value_bounds() {
695        let db = Db::new(schema()).with_transaction(
696            1,
697            &[
698                datom(1, 2, Value::Long(1), 1, true),
699                datom(2, 2, Value::Long(5), 1, true),
700                datom(3, 2, Value::Long(9), 1, true),
701            ],
702        );
703        let hits: Vec<_> = db
704            .index_range(attr(2), Some(&Value::Long(2)), Some(&Value::Long(9)))
705            .map(|d| d.e)
706            .collect();
707        assert_eq!(hits, vec![entity(2)]);
708    }
709
710    #[test]
711    fn tx_range_groups_by_transaction() {
712        let ranged = sample().tx_range(2, None);
713        assert_eq!(ranged.len(), 1);
714        assert_eq!(ranged[0].0, 2);
715        assert_eq!(ranged[0].1.len(), 3);
716    }
717
718    #[test]
719    fn incremental_indexes_match_rebuilt_indexes() {
720        let base = Db::new(schema());
721        let tx1 = [datom(1, 1, Value::Str("a".into()), 1, true)];
722        let tx2 = [
723            datom(1, 1, Value::Str("a".into()), 2, false),
724            datom(1, 1, Value::Str("b".into()), 2, true),
725        ];
726        // Force the parent cache so with_transaction derives incrementally.
727        let warm = base.with_transaction(1, &tx1);
728        let _ = warm.datoms();
729        let incremental = warm.with_transaction(2, &tx2);
730        let cold = base.with_transaction(1, &tx1).with_transaction(2, &tx2);
731        assert_eq!(incremental.datoms(), cold.datoms());
732    }
733
734    #[test]
735    fn planner_stats_count_attributes() {
736        let stats_owner = sample();
737        let stats = stats_owner.planner_stats();
738        assert_eq!(stats.total_datoms, 3);
739        assert_eq!(stats.per_attr[&attr(1)].count, 1);
740        assert!(stats.estimate(false, Some(attr(1)), false) <= stats.total_datoms);
741    }
742}