1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
11#![cfg_attr(coverage_nightly, allow(unused_features))]
14
15use improv_core_model::{
16 Category, CategoryId, Coordinate, Formula, Item, ItemId, Measure, MeasureId, MeasureKind,
17 Model, Name, Value, ValueType,
18};
19use mentat::{InProgress, Store, TypedValue};
20use std::collections::HashMap;
21
22mod convert;
23mod schema;
24
25#[derive(Debug, thiserror::Error)]
26pub enum StoreError {
27 #[error("mentat error: {0}")]
28 Mentat(String),
29 #[error("serialization error: {0}")]
30 Serde(#[from] serde_json::Error),
31 #[error("data integrity: {0}")]
32 Integrity(String),
33 #[error("conflict: {0}")]
36 Conflict(String),
37}
38
39const TX_HIGH_WATER_SQL: &str = "SELECT coalesce((SELECT tx FROM timelined_transactions \
48 WHERE timeline = 0 AND a <> 3 ORDER BY rowid DESC LIMIT 1), 0)";
49
50const CONFLICT_MSG: &str =
51 "the store was changed by another writer since it was loaded; reload and retry";
52
53fn high_water(ip: &InProgress) -> Result<i64> {
54 ip.transaction
55 .query_row(TX_HIGH_WATER_SQL, [], |r| r.get(0))
56 .map_err(|e| StoreError::Mentat(e.to_string()))
57}
58
59impl From<mentat::errors::MentatError> for StoreError {
60 fn from(e: mentat::errors::MentatError) -> Self {
61 StoreError::Mentat(e.to_string())
62 }
63}
64
65pub type Result<T> = std::result::Result<T, StoreError>;
66
67const MAX_DATOMS_PER_TRANSACT: usize = 2000;
84
85const MAX_DATOMS_PER_TRANSACT_QUEUE: usize = 32766 / 6;
107
108const MAX_CATEGORIES_PER_MEASURE: usize = MAX_DATOMS_PER_TRANSACT_QUEUE - 1;
112
113fn transact_group(
126 ip: &mut InProgress<'_, '_>,
127 datoms_per_entity: usize,
128 entities: impl IntoIterator<Item = String>,
129) -> Result<()> {
130 let parts: Vec<String> = entities.into_iter().collect();
131 let per_chunk = (MAX_DATOMS_PER_TRANSACT / datoms_per_entity.max(1)).max(1);
138 for batch in parts.chunks(per_chunk) {
139 ip.transact(format!("[{}]", batch.join("\n")))?;
140 }
141 Ok(())
142}
143
144const VALUE_NUMBER: &str = "value-number";
145const VALUE_BOOLEAN: &str = "value-boolean";
146const VALUE_TEXT: &str = "value-text";
147
148fn value_attr(vt: ValueType) -> &'static str {
151 match vt {
152 ValueType::Number | ValueType::Enum => VALUE_NUMBER,
153 ValueType::Boolean => VALUE_BOOLEAN,
154 ValueType::Text | ValueType::DateTime => VALUE_TEXT,
155 }
156}
157
158pub struct ModelStore {
161 store: Store,
162 loaded_tx: Option<i64>,
165 loaded_scope: Option<std::collections::HashSet<MeasureId>>,
170 #[cfg(test)]
175 cell_rows_returned: usize,
176}
177
178impl ModelStore {
179 pub fn open(path: &str) -> Result<Self> {
181 let mut store = Store::open(path)?;
182 store.transact(schema::SCHEMA_EDN)?;
183 Ok(ModelStore {
184 store,
185 loaded_tx: None,
186 loaded_scope: None,
187 #[cfg(test)]
188 cell_rows_returned: 0,
189 })
190 }
191
192 pub fn save_model(&mut self, model: &Model) -> Result<()> {
231 let mut ip = self.store.begin_transaction()?;
233 if let Some(loaded) = self.loaded_tx {
234 if high_water(&ip)? != loaded {
235 return Err(StoreError::Conflict(CONFLICT_MSG.into()));
236 }
237 }
238
239 transact_group(
243 &mut ip,
244 2,
245 model.categories.values().map(convert::category_edn),
246 )?;
247 transact_group(&mut ip, 3, model.items.values().map(convert::item_edn))?;
249
250 let mut measures = Vec::new();
251 for m in model.measures.values() {
252 measures.push(convert::measure_edn(m, model.sql_sources.get(&m.id))?);
253 }
254 let widest_categories = model
273 .measures
274 .values()
275 .map(|m| m.categories.len())
276 .max()
277 .unwrap_or(0);
278 if widest_categories > MAX_CATEGORIES_PER_MEASURE {
279 let worst = model
280 .measures
281 .values()
282 .max_by_key(|m| m.categories.len())
283 .expect("non-empty: widest_categories > 0");
284 return Err(StoreError::Integrity(format!(
285 "measure {} (\"{}\") has {} categories; a measure is a single \
286 indivisible entity and the store aborts on a transact carrying \
287 {} or more datoms for one attribute, so at most {} categories \
288 per measure can be saved",
289 worst.id.0,
290 worst.name.0,
291 widest_categories,
292 MAX_DATOMS_PER_TRANSACT_QUEUE,
293 MAX_CATEGORIES_PER_MEASURE,
294 )));
295 }
296 transact_group(&mut ip, 7 + widest_categories, measures)?;
300
301 let mut stored = stored_cells(&ip)?;
307 let mut cells = Vec::new();
308 let mut retracts = Vec::new();
309 for ((mid, coord), val) in model.inputs.iter() {
310 let coord_json = serde_json::to_string(coord)?;
311 let key = convert::cell_key(*mid, &coord_json);
312 let (attr, v) = convert::cell_value_edn(val)?;
313 if let Some(old) = stored.remove(&key) {
314 let mut unchanged = false;
315 for (a, ov) in &old.values {
316 if *a == attr {
317 unchanged = *ov == v;
319 } else {
320 retracts.push(format!("[:db/retract {} :cell/{a} {ov}]", old.e));
321 }
322 }
323 if unchanged {
324 continue;
325 }
326 }
327 cells.push(convert::cell_edn(*mid, &coord_json, &key, attr, &v));
328 }
329 for (key, old) in stored {
330 if let Some(scope) = &self.loaded_scope {
332 let mid = key.split_once("::").and_then(|(m, _)| m.parse().ok());
333 if !mid.is_some_and(|m| scope.contains(&MeasureId(m))) {
334 continue;
335 }
336 }
337 let e = old.e;
338 retracts.push(format!(
339 "[:db/retract {e} :cell/key {}]",
340 convert::edn_str_pub(&key)
341 ));
342 retracts.push(format!("[:db/retract {e} :cell/measure {}]", old.measure));
343 retracts.push(format!(
344 "[:db/retract {e} :cell/coord {}]",
345 convert::edn_str_pub(&old.coord)
346 ));
347 for (a, ov) in &old.values {
348 retracts.push(format!("[:db/retract {e} :cell/{a} {ov}]"));
349 }
350 }
351 transact_group(&mut ip, 1, retracts)?;
353 transact_group(&mut ip, 4, cells)?;
355
356 let mut views = Vec::new();
357 for v in model.views.values() {
358 let json = serde_json::to_string(v)?;
359 views.push(format!(
360 "{{:view/id {} :view/json {}}}",
361 v.id.0,
362 convert::edn_str_pub(&json)
363 ));
364 }
365 transact_group(&mut ip, 2, views)?;
367
368 if !model.external_fns.is_empty()
372 || !model.external_calls.is_empty()
373 || !model.scenarios.is_empty()
374 || !model.lambdas.is_empty()
375 {
376 let fns_json = serde_json::to_string(&model.external_fns)?;
377 let calls_json = serde_json::to_string(&model.external_calls)?;
378 let scen_json = serde_json::to_string(&model.scenarios)?;
379 let lambdas_json = serde_json::to_string(&model.lambdas)?;
380 let edn = format!(
381 "[{{:meta/singleton 1 :meta/external-fns {} :meta/external-calls {} :meta/scenarios {} :meta/lambdas {}}}]",
382 convert::edn_str_pub(&fns_json),
383 convert::edn_str_pub(&calls_json),
384 convert::edn_str_pub(&scen_json),
385 convert::edn_str_pub(&lambdas_json),
386 );
387 ip.transact(edn)?;
388 }
389
390 let new_tx = high_water(&ip)?;
393 ip.commit()?;
394 self.loaded_tx = Some(new_tx);
395 Ok(())
396 }
397
398 pub fn tx_high_water(&mut self) -> Result<i64> {
401 high_water(&self.store.begin_read()?.in_progress)
402 }
403
404 pub fn loaded_tx(&self) -> Option<i64> {
408 self.loaded_tx
409 }
410
411 pub fn set_loaded_tx(&mut self, tx: Option<i64>) {
414 self.loaded_tx = tx;
415 }
416
417 pub fn reload_model(&mut self) -> Result<Model> {
420 self.load_model()
421 }
422
423 pub fn load_model(&mut self) -> Result<Model> {
425 let tx = self.tx_high_water()?;
426 let mut model = Model::new();
427 self.load_categories(&mut model)?;
428 self.load_items(&mut model)?;
429 self.load_measures(&mut model)?;
430 self.load_cells(&mut model, None)?;
431 self.load_views(&mut model)?;
432 self.load_meta(&mut model)?;
433 self.loaded_tx = Some(tx);
434 self.loaded_scope = None;
435 Ok(model)
436 }
437
438 pub fn load_partial(&mut self, measure_ids: &[MeasureId]) -> Result<Model> {
469 let tx = self.tx_high_water()?;
470 let mut model = Model::new();
471 self.load_categories(&mut model)?;
472 self.load_items(&mut model)?;
473 self.load_measures(&mut model)?;
474 self.load_views(&mut model)?;
475 self.load_meta(&mut model)?; let closure = model.measure_dependency_closure(measure_ids);
477 self.load_cells(&mut model, Some(&closure))?;
478 self.loaded_tx = Some(tx);
479 self.loaded_scope = Some(closure);
480 Ok(model)
481 }
482
483 fn load_categories(&mut self, model: &mut Model) -> Result<()> {
486 let q = "[:find ?id ?name :where [?e :category/id ?id] [?e :category/name ?name]]";
487 for row in self.rel(q)? {
488 let id = CategoryId(convert::as_u32(&row[0])?);
489 let name = convert::as_string(&row[1])?;
490 model.categories.insert(
491 id,
492 Category {
493 id,
494 name: Name(name),
495 items: Vec::new(),
496 },
497 );
498 }
499 Ok(())
500 }
501
502 fn load_items(&mut self, model: &mut Model) -> Result<()> {
503 let q = "[:find ?id ?cat ?name :where \
504 [?e :item/id ?id] \
505 [?e :item/category ?c] [?c :category/id ?cat] \
506 [?e :item/name ?name]]";
507 for row in self.rel(q)? {
508 let id = ItemId(convert::as_u32(&row[0])?);
509 let category = CategoryId(convert::as_u32(&row[1])?);
510 let name = convert::as_string(&row[2])?;
511 model.items.insert(
512 id,
513 Item {
514 id,
515 category,
516 name: Name(name),
517 },
518 );
519 if let Some(c) = model.categories.get_mut(&category) {
520 c.items.push(id);
521 }
522 }
523 Ok(())
524 }
525
526 fn load_measures(&mut self, model: &mut Model) -> Result<()> {
527 let q = "[:find ?id ?name ?vt ?kind :where \
529 [?e :measure/id ?id] \
530 [?e :measure/name ?name] \
531 [?e :measure/value-type ?vt] \
532 [?e :measure/kind ?kind]]";
533 for row in self.rel(q)? {
534 let id = MeasureId(convert::as_u32(&row[0])?);
535 let name = convert::as_string(&row[1])?;
536 let value_type = convert::value_type_from_kw(&convert::as_string(&row[2])?)?;
537 let kind_kw = convert::as_string(&row[3])?;
538
539 let kind = match kind_kw.as_str() {
540 "input" => MeasureKind::Input,
541 "derived" => {
542 let formula_json = self
543 .scalar_string(&format!(
544 "[:find ?f . :where [?e :measure/id {}] [?e :measure/formula ?f]]",
545 id.0
546 ))?
547 .ok_or_else(|| {
548 StoreError::Integrity(format!(
549 "derived measure {} has no formula",
550 id.0
551 ))
552 })?;
553 let f: Formula = serde_json::from_str(&formula_json)?;
554 MeasureKind::Derived(f)
555 }
556 other => {
557 return Err(StoreError::Integrity(format!(
558 "unknown measure kind {other}"
559 )))
560 }
561 };
562
563 let description = self.scalar_string(&format!(
564 "[:find ?d . :where [?e :measure/id {}] [?e :measure/description ?d]]",
565 id.0
566 ))?;
567
568 let categories = self.load_measure_categories(id)?;
569
570 if let Some(json) = self.scalar_string(&format!(
572 "[:find ?s . :where [?e :measure/id {}] [?e :measure/sql-source ?s]]",
573 id.0
574 ))? {
575 let src: improv_core_model::SqlSource = serde_json::from_str(&json)?;
576 model.sql_sources.insert(id, src);
577 }
578
579 model.measures.insert(
580 id,
581 Measure {
582 id,
583 name: Name(name),
584 value_type,
585 categories,
586 kind,
587 description,
588 },
589 );
590 }
591 Ok(())
592 }
593
594 fn load_measure_categories(&mut self, measure: MeasureId) -> Result<Vec<CategoryId>> {
595 let q = format!(
596 "[:find ?cat :where \
597 [?e :measure/id {}] [?e :measure/categories ?c] [?c :category/id ?cat]]",
598 measure.0
599 );
600 let mut cats = Vec::new();
601 for row in self.rel(&q)? {
602 cats.push(CategoryId(convert::as_u32(&row[0])?));
603 }
604 let order: Vec<u32> = match self.scalar_string(&format!(
609 "[:find ?o . :where [?e :measure/id {}] [?e :measure/category-order ?o]]",
610 measure.0
611 ))? {
612 Some(json) => serde_json::from_str(&json)?,
613 None => Vec::new(),
614 };
615 cats.sort_by_key(|c| {
616 (
617 order.iter().position(|&o| o == c.0).unwrap_or(usize::MAX),
618 c.0,
619 )
620 });
621 Ok(cats)
622 }
623
624 fn load_cells(
625 &mut self,
626 model: &mut Model,
627 filter: Option<&std::collections::HashSet<MeasureId>>,
628 ) -> Result<()> {
629 let ground = match filter {
649 None => String::new(),
650 Some(keep) if keep.is_empty() => {
651 #[cfg(test)]
654 {
655 self.cell_rows_returned = 0;
656 }
657 return Ok(());
658 }
659 Some(keep) => {
660 let mut ids: Vec<u32> = keep.iter().map(|m| m.0).collect();
662 ids.sort_unstable();
663 let ids: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
664 format!("[(ground [{}]) [?mid ...]] ", ids.join(" "))
665 }
666 };
667 #[cfg(test)]
668 {
669 self.cell_rows_returned = 0;
670 }
671 for attr in [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT] {
672 let q = format!(
673 "[:find ?mid ?coord ?v :where {ground}\
674 [?m :measure/id ?mid] [?e :cell/measure ?m] \
675 [?e :cell/coord ?coord] [?e :cell/{attr} ?v]]"
676 );
677 let rows = self.rel(&q)?;
678 #[cfg(test)]
679 {
680 self.cell_rows_returned += rows.len();
681 }
682 for row in rows {
683 let mid = MeasureId(convert::as_u32(&row[0])?);
684 let vt = model
685 .measures
686 .get(&mid)
687 .map(|m| m.value_type)
688 .ok_or_else(|| {
689 StoreError::Integrity(format!("cell for unknown measure {mid:?}"))
690 })?;
691 let coord: Coordinate = serde_json::from_str(&convert::as_string(&row[1])?)?;
692 let value = match (attr, vt) {
693 (VALUE_NUMBER, ValueType::Enum) => {
694 Value::Enum(convert::as_f64(&row[2])? as u32)
695 }
696 (VALUE_NUMBER, _) => Value::Number(convert::as_f64(&row[2])?),
697 (VALUE_BOOLEAN, _) => Value::Boolean(convert::as_bool(&row[2])?),
698 (_, ValueType::DateTime) => {
699 let text = convert::as_string(&row[2])?;
700 match chrono::DateTime::parse_from_rfc3339(&text) {
701 Ok(dt) => Value::DateTime(dt.with_timezone(&chrono::Utc)),
702 Err(_) => Value::Text(text),
703 }
704 }
705 _ => Value::Text(convert::as_string(&row[2])?),
706 };
707 let key = (mid, coord);
711 if attr == value_attr(vt) || !model.inputs.contains_key(&key) {
712 model.inputs.insert(key, value);
713 }
714 }
715 }
716 Ok(())
717 }
718
719 fn load_views(&mut self, model: &mut Model) -> Result<()> {
720 let q = "[:find ?json :where [?e :view/id _] [?e :view/json ?json]]";
721 for row in self.rel(q)? {
722 let json = convert::as_string(&row[0])?;
723 let v: improv_core_model::View = serde_json::from_str(&json)?;
724 model.views.insert(v.id, v);
725 }
726 Ok(())
727 }
728
729 fn load_meta(&mut self, model: &mut Model) -> Result<()> {
730 if let Some(json) = self.scalar_string(
731 "[:find ?j . :where [?e :meta/singleton 1] [?e :meta/external-fns ?j]]",
732 )? {
733 model.external_fns = serde_json::from_str(&json)?;
734 }
735 if let Some(json) = self.scalar_string(
736 "[:find ?j . :where [?e :meta/singleton 1] [?e :meta/external-calls ?j]]",
737 )? {
738 model.external_calls = serde_json::from_str(&json)?;
739 }
740 if let Some(json) = self
741 .scalar_string("[:find ?j . :where [?e :meta/singleton 1] [?e :meta/scenarios ?j]]")?
742 {
743 model.scenarios = serde_json::from_str(&json)?;
744 }
745 if let Some(json) =
746 self.scalar_string("[:find ?j . :where [?e :meta/singleton 1] [?e :meta/lambdas ?j]]")?
747 {
748 model.lambdas = serde_json::from_str(&json)?;
749 }
750 Ok(())
751 }
752
753 fn scalar(&self, query: &str) -> Result<Option<TypedValue>> {
754 use mentat::Queryable;
755 let out = self.store.q_once(query, None)?;
756 let s = out
757 .results
758 .into_scalar()
759 .map_err(|e| StoreError::Mentat(e.to_string()))?;
760 Ok(s.and_then(|b| b.into_scalar()))
761 }
762
763 fn scalar_string(&self, query: &str) -> Result<Option<String>> {
764 match self.scalar(query)? {
765 Some(tv) => Ok(Some(convert::as_string(&tv)?)),
766 None => Ok(None),
767 }
768 }
769
770 fn rel(&self, query: &str) -> Result<Vec<Vec<TypedValue>>> {
771 rel_on(&self.store, query)
772 }
773}
774
775struct StoredCell {
777 e: i64,
778 measure: i64,
780 coord: String,
781 values: Vec<(&'static str, String)>,
783}
784
785fn stored_cells(q: &impl mentat::Queryable) -> Result<HashMap<String, StoredCell>> {
787 let mut out = HashMap::new();
788 let rows = rel_on(
789 q,
790 "[:find ?e ?key ?m ?coord :where \
791 [?e :cell/key ?key] [?e :cell/measure ?m] [?e :cell/coord ?coord]]",
792 )?;
793 for row in rows {
794 out.insert(
795 convert::as_string(&row[1])?,
796 StoredCell {
797 e: convert::as_entid(&row[0])?,
798 measure: convert::as_entid(&row[2])?,
799 coord: convert::as_string(&row[3])?,
800 values: Vec::new(),
801 },
802 );
803 }
804 for attr in [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT] {
805 let q_attr = format!("[:find ?key ?v :where [?e :cell/key ?key] [?e :cell/{attr} ?v]]");
806 for row in rel_on(q, &q_attr)? {
807 if let Some(c) = out.get_mut(&convert::as_string(&row[0])?) {
808 c.values.push((attr, convert::stored_value_edn(&row[1])?));
809 }
810 }
811 }
812 Ok(out)
813}
814
815fn rel_on(q: &impl mentat::Queryable, query: &str) -> Result<Vec<Vec<TypedValue>>> {
816 let out = q.q_once(query, None)?;
817 let rel = out
818 .results
819 .into_rel()
820 .map_err(|e| StoreError::Mentat(e.to_string()))?;
821 Ok(rel
822 .into_iter()
823 .map(|row| {
824 row.into_iter()
825 .map(|b| b.into_scalar().expect("scalar binding"))
826 .collect()
827 })
828 .collect())
829}
830
831#[allow(unused_imports)]
834use HashMap as _HashMap;
835
836#[cfg(test)]
837#[cfg_attr(coverage_nightly, coverage(off))]
838mod tests {
839 use super::*;
840 use improv_core_model::{BinaryOp, DimensionSpec, Expr, ItemId};
841
842 fn sample_model() -> Model {
843 let mut m = Model::new();
844 let (time, product) = (CategoryId(1), CategoryId(2));
845 m.add_category(time, "Time");
846 m.add_category(product, "Product");
847 m.add_item(ItemId(10), time, "2025");
848 m.add_item(ItemId(20), product, "Widget A");
849
850 m.add_measure(Measure {
851 id: MeasureId(100),
852 name: Name("Price".into()),
853 value_type: ValueType::Number,
854 categories: vec![product],
855 kind: MeasureKind::Input,
856 description: Some("Unit price".into()),
857 });
858 m.add_measure(Measure {
859 id: MeasureId(102),
860 name: Name("Revenue".into()),
861 value_type: ValueType::Number,
862 categories: vec![time, product],
863 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
864 BinaryOp::Mul,
865 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
866 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
867 ))),
868 description: None,
869 });
870 m.set_input(
871 MeasureId(100),
872 Coordinate::from_pairs([(product, ItemId(20))]),
873 Value::Number(10.0),
874 );
875 m.add_view(improv_core_model::View {
876 id: improv_core_model::ViewId(1),
877 name: Name("Prices by product".into()),
878 measure: MeasureId(100),
879 axis_order: vec![CategoryId(2)],
880 n_rows: 1,
881 n_cols: 1,
882 page_items: vec![],
883 filters: vec![improv_core_model::Filter {
884 category: CategoryId(2),
885 items: vec![ItemId(20)],
886 }],
887 rect: Default::default(),
888 placements: vec![],
889 });
890
891 m.lambdas.insert(
893 3,
894 improv_core_model::NamedLambda {
895 name: "Half".into(),
896 params: vec!["x".into()],
897 body: Expr::BinaryOp(
898 BinaryOp::Div,
899 Box::new(Expr::Ref(
900 improv_core_model::functions::lambda_param(0),
901 Default::default(),
902 )),
903 Box::new(Expr::Literal(Value::Number(2.0))),
904 ),
905 },
906 );
907 m.external_fns.insert(
912 "double".into(),
913 improv_core_model::ExternalFn {
914 name: "double".into(),
915 language: improv_core_model::Language::Pure,
916 body: "return args[0] * 2".into(),
917 arg_types: vec![ValueType::Number],
918 return_type: ValueType::Number,
919 pure: true,
920 },
921 );
922 m.external_calls.insert(
923 MeasureId(102),
924 improv_core_model::ExternalCall {
925 func: "double".into(),
926 arg_measures: vec![MeasureId(100)],
927 refresh_policy: improv_core_model::RefreshPolicy::Manual,
928 },
929 );
930 m.add_scenario(improv_core_model::Scenario {
931 id: improv_core_model::ScenarioId(1),
932 name: Name("High price".into()),
933 overrides: [(
934 (
935 MeasureId(100),
936 Coordinate::from_pairs([(product, ItemId(20))]),
937 ),
938 Value::Number(99.0),
939 )]
940 .into_iter()
941 .collect(),
942 });
943 m
944 }
945
946 #[test]
947 fn save_then_load_round_trips() {
948 let mut store = ModelStore::open("").expect("open in-memory");
949 let original = sample_model();
950 store.save_model(&original).expect("save");
951 let loaded = store.load_model().expect("load");
952
953 assert_eq!(loaded.categories.len(), 2);
954 assert_eq!(loaded.items.len(), 2);
955 assert_eq!(loaded.measures.len(), 2);
956 assert_eq!(loaded.inputs.len(), 1);
957
958 let rev = loaded.measure_by_name("Revenue").expect("revenue");
960 assert!(rev.is_derived());
961 assert_eq!(rev.categories.len(), 2);
962
963 let coord = Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
965 assert_eq!(
966 loaded.input(MeasureId(100), &coord),
967 Some(&Value::Number(10.0))
968 );
969
970 let v = loaded.view_by_name("Prices by product").expect("view");
972 assert_eq!(v.measure, MeasureId(100));
973 assert_eq!(v.axis_order, vec![CategoryId(2)]);
974 assert!(v.allows(CategoryId(2), ItemId(20)));
975 assert!(!v.allows(CategoryId(2), ItemId(21)));
976
977 assert_eq!(loaded.external_fns.len(), 1);
982 assert_eq!(loaded.lambdas, original.lambdas);
983 assert_eq!(
984 loaded.external_fns["double"],
985 original.external_fns["double"]
986 );
987 assert_eq!(loaded.external_calls.len(), 1);
988 assert_eq!(
989 loaded.external_calls[&MeasureId(102)],
990 original.external_calls[&MeasureId(102)]
991 );
992 assert_eq!(loaded.scenarios.len(), 1);
993 let scenario = loaded.scenario_by_name("High price").expect("scenario");
994 assert_eq!(
995 scenario.overrides.get(&(MeasureId(100), coord.clone())),
996 Some(&Value::Number(99.0))
997 );
998
999 assert_eq!(sort_vecs(loaded), sort_vecs(original));
1005 }
1006
1007 fn unordered_categories_model() -> Model {
1010 let mut m = Model::new();
1011 for (id, name) in [(1, "Year"), (2, "Region"), (3, "Product")] {
1012 m.add_category(CategoryId(id), name);
1013 }
1014 m.add_measure(Measure {
1015 id: MeasureId(400),
1016 name: Name("Sales".into()),
1017 value_type: ValueType::Number,
1018 categories: vec![CategoryId(3), CategoryId(1), CategoryId(2)],
1019 kind: MeasureKind::Input,
1020 description: None,
1021 });
1022 m
1023 }
1024
1025 #[test]
1029 fn measure_category_order_round_trips() {
1030 let want = vec![CategoryId(3), CategoryId(1), CategoryId(2)];
1031 let mut store = ModelStore::open("").expect("open in-memory");
1032 store
1033 .save_model(&unordered_categories_model())
1034 .expect("save");
1035 let loaded = store.load_model().expect("load");
1036 assert_eq!(loaded.measures[&MeasureId(400)].categories, want);
1037 let partial = store.load_partial(&[MeasureId(400)]).expect("partial");
1038 assert_eq!(partial.measures[&MeasureId(400)].categories, want);
1039
1040 let mut m = loaded;
1042 m.measures
1043 .get_mut(&MeasureId(400))
1044 .unwrap()
1045 .categories
1046 .reverse();
1047 store.save_model(&m).expect("re-save");
1048 assert_eq!(
1049 store.load_model().expect("re-load").measures[&MeasureId(400)].categories,
1050 vec![CategoryId(2), CategoryId(1), CategoryId(3)]
1051 );
1052 }
1053
1054 #[test]
1058 fn store_without_category_order_still_loads() {
1059 let dir = std::env::temp_dir().join(format!("improv-catorder-{}", std::process::id()));
1060 let _ = std::fs::remove_dir_all(&dir);
1061 std::fs::create_dir_all(&dir).unwrap();
1062 let path = dir.join("old.db");
1063 let path = path.to_str().unwrap();
1064
1065 let start = schema::SCHEMA_EDN
1066 .find(" ;; The order of :measure/categories")
1067 .expect("category-order block");
1068 let tail = ":db.cardinality/one}";
1069 let end = start + schema::SCHEMA_EDN[start..].find(tail).unwrap() + tail.len();
1070 let old_schema = format!(
1071 "{}{}",
1072 &schema::SCHEMA_EDN[..start],
1073 &schema::SCHEMA_EDN[end..]
1074 );
1075 assert!(!old_schema.contains("category-order"));
1076 {
1077 let mut old = Store::open(path).expect("open old");
1078 old.transact(&old_schema).expect("old schema");
1079 old.transact(
1080 r#"[{:category/id 1 :category/name "Year"}
1081 {:category/id 2 :category/name "Region"}
1082 {:category/id 3 :category/name "Product"}]"#,
1083 )
1084 .expect("old categories");
1085 old.transact(
1086 r#"[{:measure/id 400 :measure/name "Sales"
1087 :measure/value-type "number" :measure/kind "input"
1088 :measure/categories [(lookup-ref :category/id 3)
1089 (lookup-ref :category/id 1)
1090 (lookup-ref :category/id 2)]}]"#,
1091 )
1092 .expect("old measure");
1093 }
1094
1095 drop(ModelStore::open(path).expect("open old store with new schema"));
1097 let mut store = ModelStore::open(path).expect("re-open");
1098 let loaded = store.load_model().expect("load old store");
1099 assert_eq!(
1100 loaded.measures[&MeasureId(400)].categories,
1101 vec![CategoryId(1), CategoryId(2), CategoryId(3)]
1102 );
1103 let mut m = loaded;
1105 m.measures.get_mut(&MeasureId(400)).unwrap().categories =
1106 vec![CategoryId(3), CategoryId(1), CategoryId(2)];
1107 store.save_model(&m).expect("upgrade save");
1108 assert_eq!(
1109 store.load_model().expect("load").measures[&MeasureId(400)].categories,
1110 vec![CategoryId(3), CategoryId(1), CategoryId(2)]
1111 );
1112 drop(store);
1113 let _ = std::fs::remove_dir_all(&dir);
1114 }
1115
1116 #[test]
1123 fn legacy_flat_view_blob_loads_from_the_store() {
1124 let mut store = ModelStore::open("").expect("open in-memory");
1125 let mut m = sample_model();
1127 m.views.clear();
1128 store.save_model(&m).expect("save");
1129
1130 let legacy = r#"{"id":9,"name":"Legacy layout","measure":100,"axis_order":[2,1],"n_rows":1,"n_cols":1,"page_items":[],"filters":[{"category":2,"items":[20]}]}"#;
1133 store
1134 .store
1135 .transact(&format!(
1136 "[{{:view/id 9 :view/json {}}}]",
1137 convert::edn_str_pub(legacy)
1138 ))
1139 .expect("transact legacy view blob");
1140
1141 let loaded = store.load_model().expect("load");
1142 let v = loaded.view_by_name("Legacy layout").expect("legacy view");
1143 assert_eq!(v.id, improv_core_model::ViewId(9));
1144 assert_eq!(v.measure, MeasureId(100));
1145 assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
1146 assert_eq!((v.n_rows, v.n_cols), (1, 1));
1147 assert!(v.page_items.is_empty());
1148 assert!(v.allows(CategoryId(2), ItemId(20)));
1150 assert!(!v.allows(CategoryId(2), ItemId(21)));
1151 assert!(v.placements.is_empty());
1153 let ms = v.matrices();
1154 assert_eq!(ms.len(), 1);
1155 assert_eq!(ms[0].measure, MeasureId(100));
1156 assert_eq!(ms[0].filters, v.filters);
1157 assert_eq!(ms[0].rect, improv_core_model::CanvasRect::default());
1158
1159 store.save_model(&loaded).expect("re-save");
1162 let again = store.load_model().expect("re-load");
1163 assert_eq!(again.views, loaded.views);
1164 }
1165
1166 #[test]
1176 fn multi_placement_view_round_trips_through_the_store() {
1177 use improv_core_model::{CanvasRect, MatrixPlacement, View, ViewId};
1178
1179 let mut store = ModelStore::open("").expect("open in-memory");
1180 let mut m = sample_model();
1181 m.views.clear();
1182
1183 let mut primary = MatrixPlacement::new(MeasureId(100));
1184 primary.axis_order = vec![CategoryId(2)];
1185 primary.rect = CanvasRect {
1186 x: 12.5,
1187 y: 24.0,
1188 w: 400.0,
1189 h: 180.0,
1190 };
1191 primary.filters = vec![improv_core_model::Filter {
1192 category: CategoryId(2),
1193 items: vec![ItemId(20)],
1194 }];
1195
1196 let mut second = MatrixPlacement::new(MeasureId(102));
1197 second.axis_order = vec![CategoryId(1), CategoryId(2)];
1198 second.n_rows = 2;
1199 second.n_cols = 0;
1200 second.page_items = vec![(CategoryId(1), ItemId(10))];
1201 second.rect = CanvasRect {
1202 x: 440.0,
1203 y: 24.0,
1204 w: 320.25,
1205 h: 240.0,
1206 };
1207
1208 let extras: Vec<MatrixPlacement> = std::iter::once(second.clone())
1209 .chain((0u8..23).map(|i| {
1210 let mut p = MatrixPlacement::new(MeasureId(100));
1211 p.rect = CanvasRect {
1212 x: f32::from(i) * 10.0,
1213 y: 600.0,
1214 w: 100.0,
1215 h: 80.0,
1216 };
1217 p
1218 }))
1219 .collect();
1220 let view = View::from_matrices(
1221 ViewId(5),
1222 Name("Canvas".into()),
1223 primary.clone(),
1224 extras.clone(),
1225 );
1226 m.add_view(view.clone());
1227
1228 store.save_model(&m).expect("save multi-placement view");
1229 let loaded = store.load_model().expect("load");
1230 let v = loaded.view_by_name("Canvas").expect("canvas view");
1231
1232 assert_eq!(v, &view, "whole view survived the store round trip");
1233 assert_eq!(v.matrices().len(), 1 + extras.len());
1234 assert_eq!(v.rect, primary.rect);
1236 assert_eq!(v.placements[0].rect, second.rect);
1237 assert_eq!(v.placements[0].rect.w, 320.25);
1238 assert_eq!((v.placements[0].n_rows, v.placements[0].n_cols), (2, 0));
1240 assert_eq!(v.placements[0].page_items, second.page_items);
1241 assert!(v.allows(CategoryId(2), ItemId(20)));
1242 assert!(!v.allows(CategoryId(2), ItemId(21)));
1243 assert!(
1244 v.placements[0].allows(CategoryId(2), ItemId(21)),
1245 "matrix 2 is unfiltered -- filters are per matrix"
1246 );
1247 }
1248
1249 #[test]
1279 fn save_partway_failure_does_not_leave_a_partial_write() {
1280 let mut store = ModelStore::open("").expect("open in-memory");
1281 let m1 = sample_model();
1282 store.save_model(&m1).expect("save m1");
1283
1284 let mut m2 = m1.clone();
1291 let extra_cat = CategoryId(3);
1292 m2.add_category(extra_cat, "Region");
1293 m2.add_item(ItemId(30), extra_cat, "EMEA");
1294 m2.measures.get_mut(&MeasureId(100)).unwrap().name = Name("Price (changed)".into());
1295 m2.set_input(
1296 MeasureId(100),
1297 Coordinate::from_pairs([(CategoryId(2), ItemId(20))]),
1298 Value::Number(f64::NAN),
1299 );
1300 m2.add_view(improv_core_model::View {
1301 id: improv_core_model::ViewId(2),
1302 name: Name("Should never persist".into()),
1303 measure: MeasureId(100),
1304 axis_order: vec![],
1305 n_rows: 1,
1306 n_cols: 1,
1307 page_items: vec![],
1308 filters: vec![],
1309 rect: Default::default(),
1310 placements: vec![],
1311 });
1312 m2.add_scenario(improv_core_model::Scenario {
1313 id: improv_core_model::ScenarioId(2),
1314 name: Name("Should also never persist".into()),
1315 overrides: HashMap::new(),
1316 });
1317
1318 let err = store
1319 .save_model(&m2)
1320 .expect_err("NaN cell must fail to transact");
1321 assert!(
1322 matches!(err, StoreError::Mentat(_)),
1323 "expected a Mentat transact error, got {err:?}"
1324 );
1325
1326 let after = store.load_model().expect("load after failed save");
1329 assert_eq!(sort_vecs(after), sort_vecs(m1));
1330 }
1331
1332 fn temp_store_path(tag: &str) -> String {
1336 let p = std::env::temp_dir().join(format!("improv-lock-{tag}-{}.db", std::process::id()));
1337 let _ = std::fs::remove_file(&p);
1338 p.to_str().expect("utf-8 temp path").to_owned()
1339 }
1340
1341 #[test]
1344 fn stale_save_is_refused_and_keeps_the_other_writers_cell() {
1345 let path = temp_store_path("stale");
1346 ModelStore::open(&path)
1347 .and_then(|mut s| s.save_model(&sample_model()))
1348 .expect("seed");
1349 let mut a = ModelStore::open(&path).expect("open a");
1350 let mut b = ModelStore::open(&path).expect("open b");
1351 let model_a = a.load_model().expect("a loads");
1352 let mut model_b = b.load_model().expect("b loads");
1353 let b_coord = Coordinate::from_pairs([(CategoryId(2), ItemId(21))]);
1354 model_b.add_item(ItemId(21), CategoryId(2), "Widget B");
1355 model_b.set_input(MeasureId(100), b_coord.clone(), Value::Number(7.0));
1356 b.save_model(&model_b).expect("b saves");
1357
1358 let err = a.save_model(&model_a).expect_err("a's save is stale");
1359 assert!(matches!(err, StoreError::Conflict(_)), "got {err:?}");
1360
1361 let after = ModelStore::open(&path)
1362 .and_then(|mut s| s.load_model())
1363 .expect("reopen");
1364 assert_eq!(
1365 after.input(MeasureId(100), &b_coord),
1366 Some(&Value::Number(7.0))
1367 );
1368 let _ = std::fs::remove_file(&path);
1369 }
1370
1371 #[test]
1375 fn own_saves_and_bare_opens_do_not_conflict() {
1376 let path = temp_store_path("own");
1377 ModelStore::open(&path)
1378 .and_then(|mut s| s.save_model(&sample_model()))
1379 .expect("seed");
1380 let mut a = ModelStore::open(&path).expect("open a");
1381 let mut m = a.load_model().expect("load");
1382 a.save_model(&m).expect("first save");
1383 let _reader = ModelStore::open(&path).expect("another process opens");
1384 m.set_input(
1385 MeasureId(100),
1386 Coordinate::from_pairs([(CategoryId(2), ItemId(20))]),
1387 Value::Number(11.0),
1388 );
1389 a.save_model(&m).expect("second save");
1390 let _ = std::fs::remove_file(&path);
1391 }
1392
1393 #[test]
1394 fn fresh_store_saves_without_a_prior_load() {
1395 let path = temp_store_path("fresh");
1396 let mut s = ModelStore::open(&path).expect("open");
1397 assert_eq!(s.loaded_tx(), None);
1398 s.save_model(&sample_model()).expect("unconditional save");
1399 assert_eq!(s.loaded_tx(), Some(s.tx_high_water().expect("high water")));
1400 let _ = std::fs::remove_file(&path);
1401 }
1402
1403 fn sort_vecs(mut m: Model) -> Model {
1404 for c in m.categories.values_mut() {
1405 c.items.sort();
1406 }
1407 for measure in m.measures.values_mut() {
1408 measure.categories.sort();
1409 }
1410 m
1411 }
1412
1413 #[test]
1414 fn load_partial_loads_only_the_dependency_closures_cells() {
1415 let mut store = ModelStore::open("").expect("open in-memory");
1419 let mut m = Model::new();
1420 let (time, product) = (CategoryId(1), CategoryId(2));
1421 m.add_category(time, "Time");
1422 m.add_category(product, "Product");
1423 m.add_item(ItemId(10), time, "2025");
1424 m.add_item(ItemId(20), product, "WidgetA");
1425
1426 m.add_measure(Measure {
1427 id: MeasureId(100),
1428 name: Name("Price".into()),
1429 value_type: ValueType::Number,
1430 categories: vec![product],
1431 kind: MeasureKind::Input,
1432 description: None,
1433 });
1434 m.add_measure(Measure {
1435 id: MeasureId(101),
1436 name: Name("Quantity".into()),
1437 value_type: ValueType::Number,
1438 categories: vec![time, product],
1439 kind: MeasureKind::Input,
1440 description: None,
1441 });
1442 m.add_measure(Measure {
1443 id: MeasureId(102),
1444 name: Name("Revenue".into()),
1445 value_type: ValueType::Number,
1446 categories: vec![time, product],
1447 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1448 BinaryOp::Mul,
1449 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
1450 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
1451 ))),
1452 description: None,
1453 });
1454 m.add_measure(Measure {
1455 id: MeasureId(200),
1456 name: Name("Unrelated".into()),
1457 value_type: ValueType::Number,
1458 categories: vec![],
1459 kind: MeasureKind::Input,
1460 description: None,
1461 });
1462
1463 let price_coord = Coordinate::from_pairs([(product, ItemId(20))]);
1464 let qty_coord = Coordinate::from_pairs([(time, ItemId(10)), (product, ItemId(20))]);
1465 m.set_input(MeasureId(100), price_coord.clone(), Value::Number(10.0));
1466 m.set_input(MeasureId(101), qty_coord.clone(), Value::Number(5.0));
1467 m.set_input(MeasureId(200), Coordinate::new(), Value::Number(999.0));
1468
1469 store.save_model(&m).expect("save");
1470
1471 let partial = store.load_partial(&[MeasureId(102)]).expect("load_partial");
1476 assert!(
1477 partial.measures.contains_key(&MeasureId(200)),
1478 "measure shape still loads"
1479 );
1480 assert_eq!(
1481 partial.input(MeasureId(100), &price_coord),
1482 Some(&Value::Number(10.0))
1483 );
1484 assert_eq!(
1485 partial.input(MeasureId(101), &qty_coord),
1486 Some(&Value::Number(5.0))
1487 );
1488 assert_eq!(
1489 partial.input(MeasureId(200), &Coordinate::new()),
1490 None,
1491 "Unrelated's cell must NOT be loaded -- it's outside Revenue's closure"
1492 );
1493
1494 let full = store.load_model().expect("load_model");
1497 let out_full =
1498 improv_engine::dataflow::evaluate(&full, &[MeasureId(102)]).expect("eval full");
1499 let out_partial =
1500 improv_engine::dataflow::evaluate(&partial, &[MeasureId(102)]).expect("eval partial");
1501 assert_eq!(out_full, out_partial);
1502
1503 let mut partial = store
1506 .load_partial(&[MeasureId(102)])
1507 .expect("reload partial");
1508 partial.set_input(MeasureId(100), price_coord.clone(), Value::Number(11.0));
1509 partial.inputs.remove(&(MeasureId(101), qty_coord.clone()));
1510 store.save_model(&partial).expect("save partial");
1511 let after = store.load_model().expect("load after");
1512 assert_eq!(
1513 after.input(MeasureId(200), &Coordinate::new()),
1514 Some(&Value::Number(999.0)),
1515 "a cell outside the closure survives a partial save"
1516 );
1517 assert_eq!(
1518 after.input(MeasureId(100), &price_coord),
1519 Some(&Value::Number(11.0))
1520 );
1521 assert_eq!(after.input(MeasureId(101), &qty_coord), None);
1522 let mut full = after;
1524 full.inputs.remove(&(MeasureId(200), Coordinate::new()));
1525 store.save_model(&full).expect("save full");
1526 let last = store.load_model().expect("load last");
1527 assert_eq!(last.input(MeasureId(200), &Coordinate::new()), None);
1528 }
1529
1530 #[test]
1541 fn load_partial_enumeration_is_bounded_by_the_closure() {
1542 let mut store = ModelStore::open("").expect("open in-memory");
1543 let mut m = Model::new();
1544 let cat = CategoryId(1);
1545 m.add_category(cat, "Thing");
1546 const BIG: u32 = 400;
1547 for i in 0..BIG {
1548 m.add_item(ItemId(1000 + i), cat, format!("item{i}"));
1549 }
1550
1551 m.add_measure(Measure {
1553 id: MeasureId(1),
1554 name: Name("Small".into()),
1555 value_type: ValueType::Number,
1556 categories: vec![cat],
1557 kind: MeasureKind::Input,
1558 description: None,
1559 });
1560 m.add_measure(Measure {
1561 id: MeasureId(2),
1562 name: Name("Derived".into()),
1563 value_type: ValueType::Number,
1564 categories: vec![cat],
1565 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1566 BinaryOp::Mul,
1567 Box::new(Expr::Ref(MeasureId(1), DimensionSpec::default())),
1568 Box::new(Expr::Ref(MeasureId(1), DimensionSpec::default())),
1569 ))),
1570 description: None,
1571 });
1572 m.add_measure(Measure {
1574 id: MeasureId(3),
1575 name: Name("Huge".into()),
1576 value_type: ValueType::Number,
1577 categories: vec![cat],
1578 kind: MeasureKind::Input,
1579 description: None,
1580 });
1581
1582 let small_coord = Coordinate::from_pairs([(cat, ItemId(1000))]);
1583 m.set_input(MeasureId(1), small_coord.clone(), Value::Number(3.0));
1584 m.set_input(MeasureId(2), small_coord.clone(), Value::Number(9.0));
1585 for i in 0..BIG {
1586 m.set_input(
1587 MeasureId(3),
1588 Coordinate::from_pairs([(cat, ItemId(1000 + i))]),
1589 Value::Number(f64::from(i)),
1590 );
1591 }
1592 store.save_model(&m).expect("save");
1593
1594 let partial = store.load_partial(&[MeasureId(2)]).expect("load_partial");
1595 assert_eq!(
1598 store.cell_rows_returned,
1599 2,
1600 "load_partial must not enumerate out-of-closure rows (store holds {} cells)",
1601 2 + BIG
1602 );
1603 assert_eq!(partial.inputs.len(), 2);
1604 assert_eq!(
1605 partial.input(MeasureId(1), &small_coord),
1606 Some(&Value::Number(3.0))
1607 );
1608
1609 let none = store.load_partial(&[]).expect("load_partial(&[])");
1612 assert!(none.inputs.is_empty());
1613 assert_eq!(store.cell_rows_returned, 0);
1614
1615 let full = store.load_model().expect("load_model");
1617 assert_eq!(store.cell_rows_returned as u32, 2 + BIG);
1618 assert_eq!(full.inputs.len() as u32, 2 + BIG);
1619 assert_eq!(
1620 full.input(
1621 MeasureId(3),
1622 &Coordinate::from_pairs([(cat, ItemId(1000 + BIG - 1))])
1623 ),
1624 Some(&Value::Number(f64::from(BIG - 1)))
1625 );
1626 }
1627
1628 #[test]
1629 fn load_partial_with_no_dependencies_loads_only_its_own_cells() {
1630 let mut store = ModelStore::open("").expect("open in-memory");
1633 let mut m = Model::new();
1634 m.add_measure(Measure {
1635 id: MeasureId(1),
1636 name: Name("A".into()),
1637 value_type: ValueType::Number,
1638 categories: vec![],
1639 kind: MeasureKind::Input,
1640 description: None,
1641 });
1642 m.add_measure(Measure {
1643 id: MeasureId(2),
1644 name: Name("B".into()),
1645 value_type: ValueType::Number,
1646 categories: vec![],
1647 kind: MeasureKind::Input,
1648 description: None,
1649 });
1650 m.set_input(MeasureId(1), Coordinate::new(), Value::Number(1.0));
1651 m.set_input(MeasureId(2), Coordinate::new(), Value::Number(2.0));
1652 store.save_model(&m).expect("save");
1653
1654 let partial = store.load_partial(&[MeasureId(1)]).expect("load_partial");
1655 assert_eq!(
1656 partial.input(MeasureId(1), &Coordinate::new()),
1657 Some(&Value::Number(1.0))
1658 );
1659 assert_eq!(partial.input(MeasureId(2), &Coordinate::new()), None);
1660 }
1661
1662 const WIDE_CAT: CategoryId = CategoryId(1);
1663
1664 fn wide_model(n_cells: u32) -> Model {
1668 let mut m = Model::new();
1669 m.add_category(WIDE_CAT, "Thing");
1670 m.add_measure(Measure {
1671 id: MeasureId(1),
1672 name: Name("Amount".into()),
1673 value_type: ValueType::Number,
1674 categories: vec![WIDE_CAT],
1675 kind: MeasureKind::Input,
1676 description: None,
1677 });
1678 for i in 0..n_cells {
1679 m.add_item(ItemId(1000 + i), WIDE_CAT, format!("item{i}"));
1680 m.set_input(
1681 MeasureId(1),
1682 Coordinate::from_pairs([(WIDE_CAT, ItemId(1000 + i))]),
1683 Value::Number(f64::from(i) * 1.5),
1684 );
1685 }
1686 m
1687 }
1688
1689 fn assert_wide_round_trip(n_cells: u32) {
1690 let mut store = ModelStore::open("").expect("open in-memory");
1691 store.save_model(&wide_model(n_cells)).expect("save large");
1692
1693 let loaded = store.load_model().expect("load large");
1694 assert_eq!(loaded.items.len() as u32, n_cells);
1695 assert_eq!(loaded.inputs.len() as u32, n_cells);
1696 for i in [0, n_cells / 2, n_cells - 1] {
1698 assert_eq!(
1699 loaded.input(
1700 MeasureId(1),
1701 &Coordinate::from_pairs([(WIDE_CAT, ItemId(1000 + i))])
1702 ),
1703 Some(&Value::Number(f64::from(i) * 1.5)),
1704 "cell {i} of {n_cells}"
1705 );
1706 }
1707 }
1708
1709 #[test]
1718 fn save_and_load_model_past_mentats_transact_limit() {
1719 assert_wide_round_trip(6_000);
1720 }
1721
1722 #[test]
1725 fn save_and_load_model_spanning_many_chunks() {
1726 assert_wide_round_trip(12_000);
1727 }
1728
1729 #[test]
1733 fn a_failure_in_a_later_chunk_rolls_back_earlier_chunks() {
1734 let mut store = ModelStore::open("").expect("open in-memory");
1735 let mut m = wide_model(6_000);
1736 m.set_input(
1742 MeasureId(1),
1743 Coordinate::from_pairs([(WIDE_CAT, ItemId(1000))]),
1744 Value::Number(f64::NAN),
1745 );
1746 let err = store.save_model(&m).expect_err("NaN cell must fail");
1747 assert!(matches!(err, StoreError::Mentat(_)), "got {err:?}");
1748
1749 let after = store.load_model().expect("load after failed save");
1753 assert!(
1754 after.inputs.is_empty(),
1755 "{} cells leaked",
1756 after.inputs.len()
1757 );
1758 assert!(after.items.is_empty(), "{} items leaked", after.items.len());
1759 assert!(after.categories.is_empty());
1760 }
1761
1762 fn measure_with_n_categories_from(n: usize, first_id: u32) -> Model {
1764 let mut m = Model::new();
1765 let mut cats = Vec::with_capacity(n);
1766 for i in 0..n {
1767 let c = CategoryId(first_id + i as u32);
1768 m.add_category(c, format!("cat{i}"));
1769 cats.push(c);
1770 }
1771 m.add_measure(Measure {
1772 id: MeasureId(7),
1773 name: Name("Very Wide".into()),
1774 value_type: ValueType::Number,
1775 categories: cats,
1776 kind: MeasureKind::Input,
1777 description: None,
1778 });
1779 m
1780 }
1781
1782 fn measure_with_n_categories(n: usize) -> Model {
1783 measure_with_n_categories_from(n, 1)
1784 }
1785
1786 #[test]
1796 fn measure_past_the_single_entity_ceiling_errors_instead_of_aborting() {
1797 let mut store = ModelStore::open("").expect("open in-memory");
1798 let m = measure_with_n_categories(MAX_CATEGORIES_PER_MEASURE + 1);
1799 let err = store
1800 .save_model(&m)
1801 .expect_err("over-wide measure must fail");
1802
1803 let msg = err.to_string();
1804 assert!(matches!(err, StoreError::Integrity(_)), "got {err:?}");
1805 assert!(msg.contains("measure 7"), "{msg}");
1807 assert!(msg.contains("Very Wide"), "{msg}");
1808 assert!(msg.contains("5461"), "{msg}");
1809 assert!(
1810 msg.contains(&MAX_CATEGORIES_PER_MEASURE.to_string()),
1811 "{msg}"
1812 );
1813
1814 assert!(store.load_model().expect("load").measures.is_empty());
1817 }
1818
1819 #[test]
1822 fn measure_at_the_single_entity_ceiling_round_trips() {
1823 assert_eq!(MAX_CATEGORIES_PER_MEASURE, 5460);
1824 let mut store = ModelStore::open("").expect("open in-memory");
1825 let m = measure_with_n_categories(MAX_CATEGORIES_PER_MEASURE);
1826 store.save_model(&m).expect("5460 categories must save");
1827
1828 let loaded = store.load_model().expect("load");
1829 assert_eq!(
1830 loaded.measures[&MeasureId(7)].categories.len(),
1831 MAX_CATEGORIES_PER_MEASURE
1832 );
1833 }
1834
1835 #[test]
1845 fn churned_resave_reports_error_not_abort() {
1846 let mut store = ModelStore::open("").expect("open in-memory");
1847 store
1848 .save_model(&measure_with_n_categories_from(3_000, 1))
1849 .expect("first save");
1850 store
1851 .save_model(&measure_with_n_categories_from(3_000, 10_000))
1852 .expect("churned re-save (both halves under the guard)");
1853
1854 let loaded = store.load_model().expect("load");
1857 assert_eq!(loaded.measures[&MeasureId(7)].categories.len(), 6_000);
1858
1859 let err = store
1861 .save_model(&loaded)
1862 .expect_err("6000-ref re-save must fail");
1863 assert!(matches!(err, StoreError::Integrity(_)), "got {err:?}");
1864
1865 store
1868 .save_model(&measure_with_n_categories_from(3_000, 1))
1869 .expect("narrow save over a wide on-disk entity");
1870 }
1871 #[test]
1877 fn control_characters_survive_a_round_trip() {
1878 let mut store = ModelStore::open("").expect("open in-memory");
1879 let mut m = Model::new();
1880 let cat = CategoryId(1);
1881 m.add_category(cat, "Line\nBreak");
1882 m.add_item(ItemId(10), cat, "Tab\tItem");
1883 m.add_measure(Measure {
1884 id: MeasureId(1),
1885 name: Name("Multi\nLine".into()),
1886 value_type: ValueType::Text,
1887 categories: vec![cat],
1888 kind: MeasureKind::Input,
1889 description: Some("first\nsecond\tthird\rfourth".into()),
1890 });
1891 m.set_input(
1892 MeasureId(1),
1893 Coordinate::from_pairs([(cat, ItemId(10))]),
1894 Value::Text("cell\nwith\ttabs".into()),
1895 );
1896 store.save_model(&m).expect("save");
1897 let back = store.load_model().expect("load");
1898
1899 assert_eq!(back.categories[&cat].name.0, "Line\nBreak");
1900 assert_eq!(back.items[&ItemId(10)].name.0, "Tab\tItem");
1901 let got = &back.measures[&MeasureId(1)];
1902 assert_eq!(got.name.0, "Multi\nLine");
1903 assert_eq!(
1904 got.description.as_deref(),
1905 Some("first\nsecond\tthird\rfourth")
1906 );
1907 assert_eq!(
1908 back.input(MeasureId(1), &Coordinate::from_pairs([(cat, ItemId(10))])),
1909 Some(&Value::Text("cell\nwith\ttabs".into()))
1910 );
1911 }
1912
1913 fn mixed_type_model() -> Model {
1917 let mut m = Model::new();
1918 let cat = CategoryId(1);
1919 m.add_category(cat, "Thing");
1920 m.add_item(ItemId(10), cat, "a");
1921 m.add_item(ItemId(11), cat, "b");
1922 let dt = |s: &str| {
1923 Value::DateTime(
1924 chrono::DateTime::parse_from_rfc3339(s)
1925 .unwrap()
1926 .with_timezone(&chrono::Utc),
1927 )
1928 };
1929 let cols = [
1930 (ValueType::Number, Value::Number(1.5), Value::Number(-2.0)),
1931 (
1932 ValueType::Text,
1933 Value::Text("hi".into()),
1934 Value::Text(String::new()),
1935 ),
1936 (
1937 ValueType::Boolean,
1938 Value::Boolean(true),
1939 Value::Boolean(false),
1940 ),
1941 (
1942 ValueType::DateTime,
1943 dt("2026-01-02T03:04:05Z"),
1944 dt("1999-12-31T23:59:59.5Z"),
1945 ),
1946 (ValueType::Enum, Value::Enum(3), Value::Enum(0)),
1947 ];
1948 for (i, (vt, a, b)) in cols.into_iter().enumerate() {
1949 let mid = MeasureId(100 + i as u32);
1950 m.add_measure(Measure {
1951 id: mid,
1952 name: Name(format!("M{i}")),
1953 value_type: vt,
1954 categories: vec![cat],
1955 kind: MeasureKind::Input,
1956 description: None,
1957 });
1958 m.set_input(mid, Coordinate::from_pairs([(cat, ItemId(10))]), a);
1959 m.set_input(mid, Coordinate::from_pairs([(cat, ItemId(11))]), b);
1960 }
1961 m
1962 }
1963
1964 #[test]
1965 fn load_model_returns_identical_cells_for_every_value_type() {
1966 let mut store = ModelStore::open("").expect("open in-memory");
1967 let m = mixed_type_model();
1968 store.save_model(&m).expect("save");
1969 let loaded = store.load_model().expect("load");
1970 assert_eq!(loaded.inputs, m.inputs);
1971 let partial = store.load_partial(&[MeasureId(103)]).expect("partial");
1972 assert_eq!(partial.inputs.len(), 2);
1973 assert_eq!(
1974 partial.input(
1975 MeasureId(103),
1976 &Coordinate::from_pairs([(CategoryId(1), ItemId(10))])
1977 ),
1978 m.input(
1979 MeasureId(103),
1980 &Coordinate::from_pairs([(CategoryId(1), ItemId(10))])
1981 )
1982 );
1983 }
1984
1985 #[test]
1989 #[ignore = "stress: 100k cells; run with --ignored"]
1990 fn load_100k_cells_is_fast() {
1991 let mut store = ModelStore::open("").expect("open in-memory");
1992 store.save_model(&wide_model(100_000)).expect("save");
1993 let t = std::time::Instant::now();
1994 let loaded = store.load_model().expect("load");
1995 let took = t.elapsed();
1996 assert_eq!(loaded.inputs.len(), 100_000);
1997 let limit = if cfg!(debug_assertions) { 60 } else { 5 };
1999 eprintln!("100k-cell load_model: {took:?}");
2000 assert!(took.as_secs() < limit, "100k-cell load took {took:?}");
2001 }
2002
2003 fn cell_datom_counts(store: &ModelStore) -> (usize, usize) {
2006 let values = [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT]
2007 .iter()
2008 .map(|a| {
2009 store
2010 .rel(&format!("[:find ?e ?v :where [?e :cell/{a} ?v]]"))
2011 .unwrap()
2012 .len()
2013 })
2014 .sum();
2015 let entities = store
2016 .rel("[:find ?e :where [?e :cell/key _]]")
2017 .unwrap()
2018 .len();
2019 (values, entities)
2020 }
2021
2022 #[test]
2026 fn resave_keeps_cell_datoms_flat_and_retracts_stale_ones() {
2027 let mut store = ModelStore::open("").expect("open in-memory");
2028 let mut m = mixed_type_model();
2029 let n = m.inputs.len();
2030 for _ in 0..3 {
2031 store.save_model(&m).expect("save");
2032 assert_eq!(cell_datom_counts(&store), (n, n));
2033 }
2034
2035 let a = Coordinate::from_pairs([(CategoryId(1), ItemId(10))]);
2037 m.set_input(MeasureId(100), a.clone(), Value::Number(42.0));
2038 store.save_model(&m).expect("save changed");
2039 assert_eq!(cell_datom_counts(&store), (n, n));
2040 assert_eq!(store.load_model().unwrap().inputs, m.inputs);
2041
2042 m.inputs.remove(&(MeasureId(101), a.clone()));
2044 store.save_model(&m).expect("save cleared");
2045 assert_eq!(cell_datom_counts(&store), (n - 1, n - 1));
2046 assert_eq!(store.load_model().unwrap().inputs, m.inputs);
2047
2048 let mut loaded = store.load_model().unwrap();
2051 loaded.measures.get_mut(&MeasureId(100)).unwrap().value_type = ValueType::Text;
2052 loaded.set_input(MeasureId(100), a.clone(), Value::Text("now text".into()));
2053 let b = Coordinate::from_pairs([(CategoryId(1), ItemId(11))]);
2054 loaded.set_input(MeasureId(100), b, Value::Text("also".into()));
2055 store.save_model(&loaded).expect("save retyped");
2056 assert_eq!(cell_datom_counts(&store), (n - 1, n - 1));
2057 assert_eq!(store.load_model().unwrap().inputs, loaded.inputs);
2058 }
2059
2060 #[test]
2065 fn sql_source_call_blob_and_retyped_cell_round_trip() {
2066 let mut store = ModelStore::open("").expect("open in-memory");
2067 let product = CategoryId(2);
2068 let mut m = Model::new();
2069 m.add_category(product, "Product");
2070 m.add_item(ItemId(20), product, "Widget A");
2071 m.add_measure(Measure {
2072 id: MeasureId(100),
2073 name: Name("Price".into()),
2074 value_type: ValueType::Number,
2075 categories: vec![product],
2076 kind: MeasureKind::Input,
2077 description: None,
2078 });
2079 m.sql_sources.insert(
2080 MeasureId(100),
2081 improv_core_model::SqlSource {
2082 query: "SELECT p, v FROM t".into(),
2083 dimension_columns: vec!["p".into()],
2084 value_column: "v".into(),
2085 refresh_policy: Default::default(),
2086 },
2087 );
2088 m.external_calls.insert(
2089 MeasureId(100),
2090 improv_core_model::ExternalCall {
2091 func: "f".into(),
2092 arg_measures: vec![],
2093 refresh_policy: Default::default(),
2094 },
2095 );
2096 let coord = Coordinate::from_pairs([(product, ItemId(20))]);
2097 m.set_input(MeasureId(100), coord.clone(), Value::Number(1.0));
2098 store.save_model(&m).expect("save");
2099 m.set_input(MeasureId(100), coord.clone(), Value::Text("n/a".into()));
2100 store.save_model(&m).expect("re-save");
2101
2102 let loaded = store.reload_model().expect("reload");
2103 assert_eq!(loaded.sql_sources, m.sql_sources);
2104 assert_eq!(loaded.external_calls, m.external_calls);
2105 assert_eq!(loaded.inputs, m.inputs);
2106
2107 let partial = store.load_partial(&[]).expect("partial");
2108 assert!(partial.inputs.is_empty());
2109 assert_eq!(partial.measures.len(), 1);
2110 }
2111
2112 #[test]
2116 fn scenarios_only_meta_round_trips_and_infinite_cell_is_refused() {
2117 let mut store = ModelStore::open("").expect("open in-memory");
2118 let mut m = Model::new();
2119 m.add_category(CategoryId(1), "Time");
2120 m.add_item(ItemId(10), CategoryId(1), "2025");
2121 m.add_measure(Measure {
2122 id: MeasureId(1),
2123 name: Name("X".into()),
2124 value_type: ValueType::Number,
2125 categories: vec![CategoryId(1)],
2126 kind: MeasureKind::Input,
2127 description: None,
2128 });
2129 let coord = Coordinate::from_pairs([(CategoryId(1), ItemId(10))]);
2130 m.set_input(MeasureId(1), coord.clone(), Value::Number(2.5));
2131 m.add_scenario(improv_core_model::Scenario {
2132 id: improv_core_model::ScenarioId(1),
2133 name: Name("Up".into()),
2134 overrides: [((MeasureId(1), coord.clone()), Value::Number(2.0))]
2135 .into_iter()
2136 .collect(),
2137 });
2138 store.save_model(&m).expect("save");
2139 let loaded = store.load_model().expect("load");
2140 assert_eq!(loaded.scenarios, m.scenarios);
2141 assert_eq!(loaded.inputs, m.inputs);
2142
2143 let mut inf = m.clone();
2144 inf.set_input(MeasureId(1), coord, Value::Number(f64::INFINITY));
2145 let err = store.save_model(&inf).expect_err("inf has no EDN form");
2146 assert!(matches!(err, StoreError::Mentat(_)), "got {err:?}");
2147 assert_eq!(store.load_model().expect("reload").inputs, m.inputs);
2148 }
2149}