1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
10
11use improv_core_model::{CategoryId, Coordinate, Expr, ItemId, MeasureId, MeasureKind, Model};
12
13use crate::{quote_ident, Error, Result, ResultRows};
14
15const MAX_IDENT: usize = 63;
17
18pub fn dependents(model: &Model, roots: &[MeasureId]) -> Vec<MeasureId> {
24 let mut hit: BTreeSet<MeasureId> = roots.iter().copied().collect();
25 loop {
26 let before = hit.len();
27 for m in model.measures.values() {
28 if let MeasureKind::Derived(f) = &m.kind {
29 if !hit.contains(&m.id) && f.referenced_measures().iter().any(|r| hit.contains(r)) {
30 hit.insert(m.id);
31 }
32 }
33 }
34 if hit.len() == before {
35 break;
36 }
37 }
38 hit.into_iter()
39 .filter(|id| model.measures.get(id).is_some_and(|m| !m.is_input()))
40 .collect()
41}
42
43#[derive(Debug, Clone, Default, PartialEq)]
46pub struct Scope {
47 pub cats: Vec<CategoryId>,
49 pub keys: BTreeSet<Vec<ItemId>>,
51}
52
53impl Scope {
54 pub fn from_rows(model: &Model, cats: &[CategoryId], rows: &[Vec<Option<String>>]) -> Scope {
57 let index: HashMap<(CategoryId, &str), ItemId> = model
58 .items
59 .iter()
60 .map(|(id, i)| ((i.category, i.name.0.as_str()), *id))
61 .collect();
62 let keys = rows
63 .iter()
64 .filter(|r| r.len() == cats.len())
65 .filter_map(|r| {
66 cats.iter()
67 .zip(r)
68 .map(|(c, v)| index.get(&(*c, v.as_deref()?)).copied())
69 .collect()
70 })
71 .collect();
72 Scope {
73 cats: cats.to_vec(),
74 keys,
75 }
76 }
77
78 fn project(&self, cats: &[CategoryId]) -> (Vec<(usize, usize)>, HashSet<Vec<ItemId>>) {
81 let shared: Vec<(usize, usize)> = self
82 .cats
83 .iter()
84 .enumerate()
85 .filter_map(|(i, c)| cats.iter().position(|m| m == c).map(|p| (i, p)))
86 .collect();
87 let keys = self
88 .keys
89 .iter()
90 .filter_map(|k| shared.iter().map(|&(i, _)| k.get(i).copied()).collect())
91 .collect();
92 (shared, keys)
93 }
94
95 pub fn restrict_inputs(&self, model: &mut Model) {
99 let projections: HashMap<MeasureId, (Vec<CategoryId>, HashSet<Vec<ItemId>>)> = model
100 .measures
101 .values()
102 .map(|m| {
103 let (shared, keys) = self.project(&m.categories);
104 let cats = shared.iter().map(|&(i, _)| self.cats[i]).collect();
105 (m.id, (cats, keys))
106 })
107 .collect();
108 model.inputs.retain(|(mid, coord), _| {
109 projections.get(mid).is_none_or(|(cats, keys)| {
110 cats.iter()
111 .map(|c| coord.dims.get(c).copied())
112 .collect::<Option<Vec<_>>>()
113 .is_some_and(|k| keys.contains(&k))
114 })
115 });
116 }
117
118 pub fn retain_rows(
120 &self,
121 model: &Model,
122 measure: MeasureId,
123 rows: &mut ResultRows,
124 ) -> Result<()> {
125 let m = model
126 .measures
127 .get(&measure)
128 .ok_or_else(|| Error::Catalog(format!("unknown measure {}", measure.0)))?;
129 let (shared, keys) = self.project(&m.categories);
130 let name = |id: &ItemId| model.items.get(id).map(|i| i.name.0.as_str());
131 let names: HashSet<Vec<&str>> = keys
132 .iter()
133 .filter_map(|k| k.iter().map(name).collect())
134 .collect();
135 rows.retain(|key, _| {
136 shared
137 .iter()
138 .map(|&(_, p)| key.get(p).map(String::as_str))
139 .collect::<Option<Vec<_>>>()
140 .is_some_and(|k| names.contains(&k))
141 });
142 Ok(())
143 }
144}
145
146#[derive(Debug, Clone, PartialEq)]
148pub struct Plan {
149 pub targets: Vec<MeasureId>,
151 pub scope: Option<Scope>,
154}
155
156pub fn plan(model: &Model, changed: &[MeasureId], touched: Scope) -> Plan {
162 if touched.keys.is_empty() {
163 return Plan {
164 targets: Vec::new(),
165 scope: None,
166 };
167 }
168 Plan {
169 targets: dependents(model, changed),
170 scope: scopable(model, changed).then_some(touched),
171 }
172}
173
174pub fn scopable(model: &Model, changed: &[MeasureId]) -> bool {
179 model
180 .measure_dependency_closure(&dependents(model, changed))
181 .iter()
182 .all(|id| elementwise(model, *id))
183}
184
185pub fn scope_filter(
192 scope_cats: &[CategoryId],
193 rows: &[Vec<Option<String>>],
194 cats: &[CategoryId],
195 cols: &[String],
196) -> Option<(Vec<String>, Vec<Vec<String>>)> {
197 let shared: Vec<(usize, &String)> = scope_cats
198 .iter()
199 .enumerate()
200 .filter_map(|(i, c)| Some((i, cols.get(cats.iter().position(|m| m == c)?)?)))
201 .collect();
202 if shared.is_empty() {
203 return None;
204 }
205 let tuples: BTreeSet<Vec<&str>> = rows
206 .iter()
207 .filter_map(|r| shared.iter().map(|(i, _)| r.get(*i)?.as_deref()).collect())
208 .collect();
209 let mut values = vec![Vec::with_capacity(tuples.len()); shared.len()];
210 for t in &tuples {
211 for (v, x) in values.iter_mut().zip(t) {
212 v.push(x.to_string());
213 }
214 }
215 Some((shared.into_iter().map(|(_, c)| c.clone()).collect(), values))
216}
217
218pub fn scoped_rel_sql(rel: &str, cols: &[String]) -> String {
224 let lhs: Vec<String> = cols
225 .iter()
226 .map(|c| format!("{}::text", quote_ident(c)))
227 .collect();
228 let arrays: Vec<String> = (1..=cols.len()).map(|i| format!("${i}::text[]")).collect();
229 format!(
230 "(SELECT * FROM {rel} WHERE ({}) IN (SELECT * FROM unnest({}))) improv_scope",
231 lhs.join(", "),
232 arrays.join(", ")
233 )
234}
235
236fn elementwise(model: &Model, id: MeasureId) -> bool {
239 if model.external_calls.contains_key(&id) {
240 return false;
241 }
242 match model.measures.get(&id).map(|m| (&m.kind, &m.categories)) {
243 Some((MeasureKind::Input, _)) => true,
244 Some((MeasureKind::Derived(f), cats)) => expr_elementwise(model, cats, &f.expr),
245 None => false,
246 }
247}
248
249fn expr_elementwise(model: &Model, cats: &[CategoryId], e: &Expr) -> bool {
250 match e {
251 Expr::Literal(_) | Expr::Category(_) => true,
252 Expr::Ref(r, spec) => {
253 spec.by.is_empty()
254 && spec.over.is_empty()
255 && spec.except.is_empty()
256 && model
257 .measures
258 .get(r)
259 .is_some_and(|m| m.categories.iter().all(|c| cats.contains(c)))
260 }
261 Expr::UnaryOp(_, a) => expr_elementwise(model, cats, a),
262 Expr::BinaryOp(_, a, b) => {
263 expr_elementwise(model, cats, a) && expr_elementwise(model, cats, b)
264 }
265 Expr::Call(f, _) if improv_core_model::functions::named_lambda_of(*f).is_some() => model
270 .expand_lambdas(e)
271 .is_ok_and(|x| expr_elementwise(model, cats, &x)),
272 Expr::Call(f, args) => {
273 improv_core_model::functions::by_id(*f).is_some_and(|d| {
274 !d.kind.reduces()
275 && d.category != improv_core_model::functions::Category::Dimensional
276 }) && args.iter().all(|a| expr_elementwise(model, cats, a))
277 }
278 }
279}
280
281pub fn measure_ids(model: &Model, names: &[String]) -> Result<Vec<MeasureId>> {
283 names
284 .iter()
285 .map(|n| {
286 find_measure(model, n).ok_or_else(|| Error::Catalog(format!("unknown measure {n:?}")))
287 })
288 .collect()
289}
290
291fn find_measure(model: &Model, name: &str) -> Option<MeasureId> {
292 model
293 .measures
294 .values()
295 .find(|m| m.name.0.eq_ignore_ascii_case(name))
296 .map(|m| m.id)
297}
298
299pub fn apply_edits(model: &mut Model, edits: &str) -> Result<Vec<MeasureId>> {
305 use serde_json::Value as J;
306 let bad = |what: String| Error::Catalog(format!("what_if edit: {what}"));
307 let json: J = serde_json::from_str(edits).map_err(|e| bad(e.to_string()))?;
308 let list = json
309 .as_array()
310 .ok_or_else(|| bad("edits must be a JSON array".into()))?;
311 let mut changed = BTreeSet::new();
312 for e in list {
313 let name = e
314 .get("measure")
315 .and_then(J::as_str)
316 .ok_or_else(|| bad(format!("{e} has no \"measure\" name")))?;
317 let id =
318 find_measure(model, name).ok_or_else(|| bad(format!("unknown measure {name:?}")))?;
319 let m = &model.measures[&id];
320 if !m.is_input() {
321 return Err(bad(format!("{name} is not an input measure")));
322 }
323 let vt = m.value_type;
324 let want: BTreeSet<CategoryId> = m.categories.iter().copied().collect();
325 let coord = e
326 .get("coord")
327 .and_then(J::as_object)
328 .ok_or_else(|| bad(format!("{e} has no \"coord\" object")))?;
329 let mut dims = BTreeMap::new();
330 for (cat_name, item) in coord {
331 let (cat, _) = model
332 .categories
333 .iter()
334 .find(|(_, c)| c.name.0.eq_ignore_ascii_case(cat_name))
335 .ok_or_else(|| bad(format!("unknown category {cat_name:?}")))?;
336 let item = item
337 .as_str()
338 .ok_or_else(|| bad(format!("{item} is not an item name")))?;
339 let (it, _) = model
340 .items
341 .iter()
342 .find(|(_, i)| i.category == *cat && i.name.0 == item)
343 .ok_or_else(|| bad(format!("{cat_name} has no item {item:?}")))?;
344 dims.insert(*cat, *it);
345 }
346 if !dims.keys().eq(want.iter()) {
347 return Err(bad(format!("coord must name exactly {name}'s categories")));
348 }
349 let coord = Coordinate { dims };
350 match e.get("value") {
351 Some(J::Null) => {
352 model.inputs.remove(&(id, coord));
353 }
354 v => {
355 let v = v.and_then(|v| crate::json_value(vt, v)).ok_or_else(|| {
356 bad(format!(
357 "{e} needs a {} or null \"value\"",
358 crate::value_type_name(vt)
359 ))
360 })?;
361 model.set_input(id, coord, v);
362 }
363 }
364 changed.insert(id);
365 }
366 Ok(changed.into_iter().collect())
367}
368
369pub fn cell_changes(
372 old: &ResultRows,
373 new: &ResultRows,
374) -> Vec<(Vec<String>, Option<improv_engine::CellValue>)> {
375 let mut out: Vec<_> = new
376 .iter()
377 .filter(|(k, v)| old.get(*k) != Some(*v))
378 .map(|(k, v)| (k.clone(), Some(v.clone())))
379 .collect();
380 out.extend(
381 old.keys()
382 .filter(|k| !new.contains_key(*k))
383 .map(|k| (k.clone(), None)),
384 );
385 out.sort_by(|a, b| a.0.cmp(&b.0));
386 out
387}
388
389pub fn coord_json(model: &Model, measure: MeasureId, key: &[String]) -> Result<serde_json::Value> {
392 let m = model
393 .measures
394 .get(&measure)
395 .ok_or_else(|| Error::Catalog(format!("unknown measure {}", measure.0)))?;
396 let mut obj = serde_json::Map::new();
397 for (c, item) in m.categories.iter().zip(key) {
398 let cat = model
399 .categories
400 .get(c)
401 .ok_or_else(|| Error::Catalog(format!("unknown category {}", c.0)))?;
402 obj.insert(cat.name.0.clone(), item.clone().into());
403 }
404 Ok(obj.into())
405}
406
407pub fn value_json(v: &improv_engine::CellValue) -> serde_json::Value {
411 use improv_engine::CellValue as C;
412 use serde_json::Value as J;
413 match v {
414 C::Num(_) => {
415 let n = v.as_num().unwrap_or(f64::NAN);
416 if n.fract() == 0.0 && n.abs() < 2f64.powi(53) {
418 return J::from(n as i64);
419 }
420 serde_json::Number::from_f64(n).map_or_else(
421 || {
422 J::from(match n {
423 f64::INFINITY => "Infinity",
424 f64::NEG_INFINITY => "-Infinity",
425 _ => "NaN",
426 })
427 },
428 J::Number,
429 )
430 }
431 C::Bool(b) => J::Bool(*b),
432 C::Text(t) => J::from(t.as_str()),
433 C::Date(_) => J::from(v.to_string()),
434 C::Err(_) => J::Null,
435 }
436}
437
438pub fn sync_trigger_name(model: &str, op: &str) -> Result<String> {
441 let name = format!("improv_{}_sync_{op}", model.to_ascii_lowercase());
442 if name.len() > MAX_IDENT {
443 return Err(Error::Catalog(format!(
444 "model name {model:?} is too long for trigger mode (at most {} bytes)",
445 MAX_IDENT + model.len() - name.len()
446 )));
447 }
448 Ok(name)
449}
450
451pub fn slot_name(model: &str) -> Result<String> {
454 let name = format!("improv_{}", model.to_ascii_lowercase());
455 if name.len() > MAX_IDENT {
456 return Err(Error::Catalog(format!(
457 "model name {model:?} is too long for logical mode (at most {} bytes)",
458 MAX_IDENT + model.len() - name.len()
459 )));
460 }
461 Ok(name)
462}
463
464pub fn quote_literal(s: &str) -> String {
466 format!("'{}'", s.replace('\'', "''"))
467}
468
469pub fn touched_sql(old: Option<&str>, new: Option<&str>, cols: &[String]) -> Option<String> {
473 if cols.is_empty() {
474 return None;
475 }
476 let dims: Vec<String> = cols
477 .iter()
478 .map(|c| format!("{}::text", quote_ident(c)))
479 .collect();
480 let parts: Vec<String> = [old, new]
481 .into_iter()
482 .flatten()
483 .map(|t| format!("SELECT {} FROM {}", dims.join(", "), quote_ident(t)))
484 .collect();
485 (!parts.is_empty()).then(|| parts.join(" UNION "))
486}
487
488#[cfg(test)]
489#[cfg_attr(coverage_nightly, coverage(off))]
490mod tests {
491 use super::*;
492 use crate::{build_model, define, result_rows, Catalog, CategoryRow, ItemRow, MeasureRow};
493 use improv_core_model::{Value, ValueType};
494 use improv_engine::dataflow::evaluate;
495 use improv_engine::CellValue;
496
497 const PRICE: MeasureId = MeasureId(1);
498 const QTY: MeasureId = MeasureId(2);
499 const COST: MeasureId = MeasureId(3);
500 const T: CategoryId = CategoryId(1);
501 const P: CategoryId = CategoryId(2);
502 const ALL: [&str; 4] = [
503 "Revenue = Price * Quantity",
504 "Total = SUM(Revenue OVER Time)",
505 "Margin = Price - Cost",
506 "Overhead = Cost * 2",
507 ];
508
509 fn input(id: u32, name: &str, cats: &[u32]) -> MeasureRow {
510 MeasureRow {
511 id,
512 name: name.into(),
513 value_type: "number".into(),
514 kind: "input".into(),
515 formula: None,
516 description: None,
517 categories: cats.to_vec(),
518 }
519 }
520
521 fn coord(pairs: &[(u32, u32)]) -> Coordinate {
522 Coordinate::from_pairs(pairs.iter().map(|&(c, i)| (CategoryId(c), ItemId(i))))
523 }
524
525 fn model(defs: &[&str]) -> Model {
529 model_with(&[], defs)
530 }
531
532 fn model_with(lambdas: &[&str], defs: &[&str]) -> Model {
534 let mut cat = Catalog::default();
535 cat.categories.extend([
536 CategoryRow {
537 id: 1,
538 name: "Time".into(),
539 },
540 CategoryRow {
541 id: 2,
542 name: "Product".into(),
543 },
544 ]);
545 cat.items.extend(
546 [
547 (1, 1, "Q1"),
548 (2, 1, "Q2"),
549 (3, 2, "Widget"),
550 (4, 2, "Gadget"),
551 ]
552 .map(|(id, category_id, n)| ItemRow {
553 id,
554 category_id,
555 name: n.into(),
556 }),
557 );
558 cat.measures.extend([
559 input(1, "Price", &[1, 2]),
560 input(2, "Quantity", &[1, 2]),
561 input(3, "Cost", &[2]),
562 ]);
563 for (id, l) in (1..).zip(lambdas) {
564 cat.lambdas.push(crate::LambdaRow {
565 id,
566 name: l.split('=').next().unwrap().trim().into(),
567 definition: l.to_string(),
568 });
569 }
570 for (id, d) in (10..).zip(defs) {
571 let row = define(&build_model(&cat).unwrap(), d, id).unwrap();
572 cat.measures.push(row);
573 }
574 let mut m = build_model(&cat).unwrap();
575 for p in [3, 4] {
576 m.set_input(COST, coord(&[(2, p)]), Value::Number(f64::from(p)));
577 for t in [1, 2] {
578 let c = coord(&[(1, t), (2, p)]);
579 m.set_input(PRICE, c.clone(), Value::Number(f64::from(10 * p)));
580 m.set_input(QTY, c, Value::Number(f64::from(100 * t + p)));
581 }
582 }
583 m
584 }
585
586 fn ids(v: &[u32]) -> Vec<MeasureId> {
587 v.iter().map(|&i| MeasureId(i)).collect()
588 }
589
590 fn touched(m: &Model, cats: &[CategoryId], rows: &[&[Option<&str>]]) -> Scope {
591 let rows: Vec<Vec<Option<String>>> = rows
592 .iter()
593 .map(|r| r.iter().map(|v| v.map(String::from)).collect())
594 .collect();
595 Scope::from_rows(m, cats, &rows)
596 }
597
598 #[test]
599 fn dependents_follow_formulas_transitively() {
600 let m = model(&ALL);
601 assert_eq!(dependents(&m, &[PRICE]), ids(&[10, 11, 12]));
602 assert_eq!(dependents(&m, &[QTY]), ids(&[10, 11]));
603 assert_eq!(dependents(&m, &[COST]), ids(&[12, 13]));
604 assert_eq!(
605 dependents(&m, &[MeasureId(10)]),
606 ids(&[10, 11]),
607 "a derived root too"
608 );
609 assert!(dependents(&m, &[]).is_empty());
610 }
611
612 #[test]
613 fn plan_restricts_only_element_wise_chains() {
614 let m = model(&ALL);
615 let s = touched(&m, &[T, P], &[&[Some("Q1"), Some("Widget")]]);
616 let p = plan(&m, &[COST], s.clone());
618 assert_eq!(p.targets, ids(&[12, 13]));
619 assert_eq!(p.scope.as_ref(), Some(&s));
620 let p = plan(&m, &[PRICE], s.clone());
622 assert_eq!(p.targets, ids(&[10, 11, 12]));
623 assert_eq!(p.scope, None);
624 let m2 = model(&[
626 "Revenue = Price * Quantity",
627 "Total = SUM(Revenue OVER Time)",
628 "Share = Total * 2",
629 ]);
630 assert_eq!(plan(&m2, &[MeasureId(11)], s.clone()).scope, None);
631 let m3 = model(&["Spread = ABS(Price - Cost)", "Neg = -Spread"]);
633 assert_eq!(plan(&m3, &[PRICE], s.clone()).scope, Some(s.clone()));
634 let p = plan(&m, &[PRICE], Scope::default());
636 assert!(p.targets.is_empty());
637 let m4 = model(&["Spread = Price - Cost", "Neg = -Spread"]);
640 for spec in [
641 improv_core_model::DimensionSpec {
642 by: vec![T],
643 ..Default::default()
644 },
645 improv_core_model::DimensionSpec {
646 over: vec![T],
647 ..Default::default()
648 },
649 improv_core_model::DimensionSpec {
650 except: vec![T],
651 ..Default::default()
652 },
653 ] {
654 let mut m = m4.clone();
655 let MeasureKind::Derived(f) = &mut m.measures.get_mut(&MeasureId(10)).unwrap().kind
656 else {
657 unreachable!()
658 };
659 f.expr = Expr::BinaryOp(
660 improv_core_model::BinaryOp::Sub,
661 Box::new(Expr::Ref(PRICE, Default::default())),
662 Box::new(Expr::Ref(COST, spec)),
663 );
664 assert_eq!(plan(&m, &[PRICE], s.clone()).scope, None);
665 }
666 let mut m = m4.clone();
667 m.external_calls.insert(
668 MeasureId(11),
669 improv_core_model::ExternalCall {
670 func: "f".into(),
671 arg_measures: vec![MeasureId(10)],
672 refresh_policy: Default::default(),
673 },
674 );
675 assert_eq!(plan(&m, &[PRICE], s.clone()).scope, None);
676 let lambdas = [
678 "Twice = LAMBDA(x, x * 2)",
679 "Top = LAMBDA(x, c, MAX(x OVER c))",
680 ];
681 let m = model_with(
682 &lambdas,
683 &["D = Twice(Price)", "Share = Price / Top(Price, Time)"],
684 );
685 assert!(scopable(&m, &[MeasureId(10)]));
686 assert!(!scopable(&m, &[MeasureId(11)]));
687 let mut gone = m.clone();
689 gone.lambdas.clear();
690 assert!(!scopable(&gone, &[MeasureId(10)]));
691 let m = model(&["Left = SUM(Price OVER Time) + Cost"]);
693 assert!(!scopable(&m, &[COST]));
694 let mut m = model(&["Spread = Price - Cost"]);
696 m.measures.remove(&COST);
697 assert!(!elementwise(&m, COST));
698 assert!(!elementwise(&m, MeasureId(10)));
699 }
700
701 #[test]
702 fn scope_filters_restrict_bindings_to_the_touched_tuples() {
703 let rows = vec![
704 vec![Some("Q1".to_string()), Some("Widget".to_string())],
705 vec![Some("Q1".into()), Some("Widget".into())],
706 vec![Some("Q2".into()), None],
707 vec![Some("Q2".into()), Some("Gadget".into())],
708 ];
709 let cols = |c: &[&str]| c.iter().map(|s| s.to_string()).collect::<Vec<_>>();
710 assert_eq!(
712 scope_filter(&[T, P], &rows, &[T, P], &cols(&["quarter", "product"])),
713 Some((
714 cols(&["quarter", "product"]),
715 vec![cols(&["Q1", "Q2"]), cols(&["Widget", "Gadget"])]
716 ))
717 );
718 assert_eq!(
720 scope_filter(&[T, P], &rows, &[P], &cols(&["item"])),
721 Some((cols(&["item"]), vec![cols(&["Gadget", "Widget"])]))
722 );
723 assert_eq!(scope_filter(&[T], &rows, &[P], &cols(&["product"])), None);
725 assert_eq!(
726 scoped_rel_sql("public.sales", &cols(&["quarter", "product"])),
727 "(SELECT * FROM public.sales WHERE (\"quarter\"::text, \"product\"::text) \
728 IN (SELECT * FROM unnest($1::text[], $2::text[]))) improv_scope"
729 );
730 }
731
732 #[test]
733 fn scope_skips_null_unknown_and_short_rows() {
734 let m = model(&[]);
735 let s = touched(
736 &m,
737 &[T, P],
738 &[
739 &[Some("Q1"), Some("Widget")],
740 &[None, Some("Gadget")],
741 &[Some("Q9"), Some("Widget")],
742 &[Some("Q1")],
743 ],
744 );
745 assert_eq!(s.keys, BTreeSet::from([vec![ItemId(1), ItemId(3)]]));
746 }
747
748 #[test]
749 fn retain_rows_projects_the_scope() {
750 let m = model(&[]);
751 let all: ResultRows = [
752 ("Q1", "Widget"),
753 ("Q1", "Gadget"),
754 ("Q2", "Widget"),
755 ("Q2", "Gadget"),
756 ]
757 .into_iter()
758 .map(|(t, p)| (vec![t.to_string(), p.to_string()], CellValue::num(1.0)))
759 .collect();
760 let kept = |s: Scope| {
761 let mut rows = all.clone();
762 s.retain_rows(&m, PRICE, &mut rows).unwrap();
763 rows.keys().map(|k| k.join("/")).collect::<Vec<_>>()
764 };
765 assert_eq!(
766 kept(touched(&m, &[P], &[&[Some("Widget")]])),
767 ["Q1/Widget", "Q2/Widget"]
768 );
769 assert_eq!(
770 kept(touched(&m, &[T, P], &[&[Some("Q2"), Some("Gadget")]])),
771 ["Q2/Gadget"]
772 );
773 let mut rows = all.clone();
774 assert!(Scope::default()
775 .retain_rows(&m, MeasureId(99), &mut rows)
776 .is_err());
777 }
778
779 #[test]
780 fn restricted_inputs_compute_the_in_scope_cells() {
781 let full_model = model(&[
782 "Revenue = Price * Quantity",
783 "Margin = Price - Cost",
784 "Net = Revenue - Cost",
785 ]);
786 let targets = ids(&[10, 11, 12]);
787 let full = evaluate(&full_model, &targets).unwrap();
788 for s in [
789 touched(&full_model, &[T, P], &[&[Some("Q2"), Some("Gadget")]]),
790 touched(&full_model, &[P], &[&[Some("Widget")]]),
791 ] {
792 let mut m = full_model.clone();
793 s.restrict_inputs(&mut m);
794 assert!(m.inputs.len() < full_model.inputs.len());
795 let part = evaluate(&m, &targets).unwrap();
796 for t in &targets {
797 let mut want = result_rows(&full_model, *t, &full[t]).unwrap();
798 s.retain_rows(&full_model, *t, &mut want).unwrap();
799 let mut got = result_rows(&m, *t, &part[t]).unwrap();
800 s.retain_rows(&m, *t, &mut got).unwrap();
801 assert!(!want.is_empty());
802 assert_eq!(got, want, "measure {t:?}");
803 }
804 }
805 }
806
807 #[test]
808 fn edits_set_and_clear_input_cells() {
809 let mut m = model(&["Revenue = Price * Quantity"]);
810 let changed = apply_edits(
811 &mut m,
812 r#"[{"measure": "price", "coord": {"Time": "Q1", "product": "Widget"}, "value": 11},
813 {"measure": "Cost", "coord": {"Product": "Gadget"}, "value": null}]"#,
814 )
815 .unwrap();
816 assert_eq!(changed, vec![PRICE, COST]);
817 assert_eq!(
818 m.input(PRICE, &coord(&[(1, 1), (2, 3)])),
819 Some(&Value::Number(11.0))
820 );
821 assert_eq!(m.input(COST, &coord(&[(2, 4)])), None);
822 let at = |vt, json: &str| {
824 let mut m = m.clone();
825 m.measures.get_mut(&COST).unwrap().value_type = vt;
826 let edit = format!(
827 r#"[{{"measure": "Cost", "coord": {{"Product": "Widget"}}, "value": {json}}}]"#
828 );
829 apply_edits(&mut m, &edit).map(|_| m.input(COST, &coord(&[(2, 3)])).cloned())
830 };
831 assert_eq!(
832 at(ValueType::Text, r#""x""#).unwrap(),
833 Some(Value::Text("x".into()))
834 );
835 assert_eq!(
836 at(ValueType::Boolean, "true").unwrap(),
837 Some(Value::Boolean(true))
838 );
839 let day = chrono::DateTime::from_timestamp_millis(86_400_000).unwrap();
840 assert_eq!(
841 at(ValueType::DateTime, r#""1970-01-02T01:00:00+01:00""#).unwrap(),
842 Some(Value::DateTime(day))
843 );
844 let e = at(ValueType::Boolean, "1").unwrap_err().to_string();
845 assert!(e.contains("needs a boolean or null"), "{e}");
846 for (edits, err) in [
847 ("{", "what_if edit"),
848 ("{}", "array"),
849 (r#"[{"coord": {}}]"#, "\"measure\""),
850 (r#"[{"measure": "Nope"}]"#, "unknown measure"),
851 (r#"[{"measure": "Revenue"}]"#, "not an input"),
852 (r#"[{"measure": "Cost", "value": 1}]"#, "\"coord\""),
853 (
854 r#"[{"measure": "Cost", "coord": {"Shape": "Round"}}]"#,
855 "unknown category",
856 ),
857 (
858 r#"[{"measure": "Cost", "coord": {"Product": 3}}]"#,
859 "not an item name",
860 ),
861 (
862 r#"[{"measure": "Cost", "coord": {"Product": "Nope"}}]"#,
863 "no item",
864 ),
865 (
866 r#"[{"measure": "Cost", "coord": {"Time": "Q1"}}]"#,
867 "exactly",
868 ),
869 (
870 r#"[{"measure": "Cost", "coord": {"Product": "Widget"}, "value": "x"}]"#,
871 "value",
872 ),
873 (
874 r#"[{"measure": "Cost", "coord": {"Product": "Widget"}}]"#,
875 "value",
876 ),
877 ] {
878 let e = apply_edits(&mut m.clone(), edits).unwrap_err().to_string();
879 assert!(e.contains(err), "{edits}: {e}");
880 }
881 }
882
883 #[test]
884 fn changes_and_coord_json() {
885 let rows = |v: &[(&str, f64)]| -> ResultRows {
886 v.iter()
887 .map(|(k, x)| (vec![k.to_string()], CellValue::num(*x)))
888 .collect()
889 };
890 let old = rows(&[("a", 1.0), ("b", 2.0), ("c", 3.0)]);
891 let new = rows(&[("a", 1.0), ("b", 5.0), ("d", 4.0)]);
892 assert_eq!(
893 cell_changes(&old, &new),
894 vec![
895 (vec!["b".to_string()], Some(CellValue::num(5.0))),
896 (vec!["c".to_string()], None),
897 (vec!["d".to_string()], Some(CellValue::num(4.0))),
898 ]
899 );
900 let m = model(&[]);
901 assert_eq!(
902 coord_json(&m, PRICE, &["Q1".into(), "Widget".into()]).unwrap(),
903 serde_json::json!({"Time": "Q1", "Product": "Widget"})
904 );
905 assert!(coord_json(&m, MeasureId(99), &[]).is_err());
906 let mut no_time = m.clone();
907 no_time.categories.remove(&T);
908 let e = coord_json(&no_time, PRICE, &["Q1".into(), "Widget".into()]).unwrap_err();
909 assert_eq!(e.to_string(), "catalog: unknown category 1");
910 }
911
912 #[test]
913 fn values_as_json() {
914 use improv_engine::CellValue as C;
915 use serde_json::json;
916 for (v, want) in [
917 (C::num(1.5), json!(1.5)),
918 (C::num(7.0), json!(7)),
919 (C::num(1e300), json!(1e300)),
920 (C::num(f64::NAN), json!("NaN")),
921 (C::num(f64::INFINITY), json!("Infinity")),
922 (C::num(f64::NEG_INFINITY), json!("-Infinity")),
923 (C::Bool(true), json!(true)),
924 (C::Text("a".into()), json!("a")),
925 (C::Date(86_400_000), json!("1970-01-02T00:00:00+00:00")),
926 (C::Err(0), json!(null)),
927 ] {
928 assert_eq!(value_json(&v), want, "{v:?}");
929 }
930 }
931
932 #[test]
933 fn names_and_sql() {
934 assert_eq!(
935 sync_trigger_name("Plan", "upd").unwrap(),
936 "improv_plan_sync_upd"
937 );
938 assert!(sync_trigger_name(&"m".repeat(47), "ins").is_ok());
939 let e = sync_trigger_name(&"m".repeat(48), "ins").unwrap_err();
940 assert!(e.to_string().contains("at most 47 bytes"), "{e}");
941 assert_eq!(quote_literal("it's"), "'it''s'");
942 let cols = ["quarter".to_string(), "pro\"duct".to_string()];
943 assert_eq!(
944 touched_sql(Some("improv_old"), Some("improv_new"), &cols).unwrap(),
945 "SELECT \"quarter\"::text, \"pro\"\"duct\"::text FROM \"improv_old\" UNION \
946 SELECT \"quarter\"::text, \"pro\"\"duct\"::text FROM \"improv_new\""
947 );
948 assert_eq!(
949 touched_sql(None, Some("n"), &cols[..1]).unwrap(),
950 "SELECT \"quarter\"::text FROM \"n\""
951 );
952 assert_eq!(touched_sql(None, None, &cols), None);
953 assert_eq!(touched_sql(Some("o"), None, &[]), None);
954 let m = model(&[]);
955 assert_eq!(
956 measure_ids(&m, &["COST".into(), "price".into()]).unwrap(),
957 vec![COST, PRICE]
958 );
959 assert!(measure_ids(&m, &["nope".into()]).is_err());
960 }
961
962 #[test]
963 fn slot_name_is_lowercased_and_bounded() {
964 assert_eq!(slot_name("Plan").unwrap(), "improv_plan");
965 assert!(slot_name(&"x".repeat(MAX_IDENT)).is_err());
966 }
967}