Skip to main content

improv_pg_model/
delta.rs

1//! Trigger-mode planning (L1): what an input change must recompute, and
2//! in-memory what-if edits.
3//!
4//! [`plan`] is the decision: recompute the changed inputs' [`dependents`],
5//! restricted to the touched coordinates ([`Scope`]) when every measure they
6//! read is element-wise (no aggregation, BY/OVER/EXCEPT projection or
7//! external call), else in full.
8
9use 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
15/// Postgres's identifier limit (`NAMEDATALEN - 1`).
16const MAX_IDENT: usize = 63;
17
18/// The derived measures to recompute when `roots` change: the derived roots
19/// themselves and every derived measure that transitively reads a root,
20/// sorted by id.
21// ponytail: fixpoint over all measures, O(measures × depth); build a reverse
22// index if models reach thousands of measures.
23pub 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/// The coordinates a statement touched, over one relation's dimension
44/// categories.
45#[derive(Debug, Clone, Default, PartialEq)]
46pub struct Scope {
47    /// The touched categories.
48    pub cats: Vec<CategoryId>,
49    /// Touched coordinates: one item per [`Scope::cats`] entry.
50    pub keys: BTreeSet<Vec<ItemId>>,
51}
52
53impl Scope {
54    /// From transition-table rows (item names in `cats` order). A row with a
55    /// NULL or never-minted value is skipped: no cell can have it.
56    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    /// For a measure over `cats`: `(scope index, measure position)` of each
79    /// touched category it has, and the touched keys projected onto them.
80    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    /// Drop the input cells no in-scope output cell reads: those whose
96    /// projection onto the touched categories was not touched. Valid only
97    /// for an element-wise [`Plan`].
98    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    /// Keep only `measure`'s result rows in scope.
119    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/// What a change to some input measures must recompute.
147#[derive(Debug, Clone, PartialEq)]
148pub struct Plan {
149    /// Derived measures to recompute, sorted.
150    pub targets: Vec<MeasureId>,
151    /// `Some`: only cells in this scope can change, so inputs and results
152    /// may be restricted to it. `None`: recompute the targets in full.
153    pub scope: Option<Scope>,
154}
155
156/// Plan the recompute after a statement changed `changed` input measures at
157/// `touched` coordinates: their [`dependents`], restricted to `touched` when
158/// every measure those read is element-wise (an output cell then depends
159/// only on its inputs' cells at its own coordinate, projected or
160/// broadcast), else in full. Nothing touched, nothing to do.
161pub 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
174/// Whether a change to `changed` can only change cells at the touched
175/// coordinates: every measure their [`dependents`] read is element-wise.
176/// Then the inputs outside the touched coordinates' projections need not
177/// even be loaded ([`scope_filter`]).
178pub 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
185/// For a binding whose measure ranges over `cats`, read from dimension
186/// columns `cols` (same order), restricted to the touched rows `rows`
187/// (dimension values in `scope_cats` order, as [`Scope::from_rows`] takes
188/// them): the columns of the touched categories it has, and per column the
189/// touched values, row-aligned, each tuple once and none with a NULL. `None`
190/// when it has none of the touched categories, so every row is in scope.
191pub 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
218/// Relation `rel` (already quoted) restricted to the rows whose `cols`, as
219/// text, are one of the tuples passed as `$1::text[]`, `$2::text[]`, … (one
220/// array per column, row-aligned): a subquery to read a binding through
221/// (`select_bound_sql`'s `rel`) so an aggregated binding loads only the
222/// touched coordinates' groups.
223pub 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
236/// An input, or a formula of operators and scalar functions over plain
237/// references to measures whose categories are all `id`'s own.
238fn 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        // Scalar and lazy (IF-family) calls read only their own coordinate;
266        // the dimensional ones (PREV, POSITION) read other items.
267        // A named LAMBDA reads only its arguments: element-wise iff its
268        // expansion is.
269        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
281/// Measure ids by name (case-insensitive), in order.
282pub 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
299/// Apply what-if `edits` to `model`'s input cells in memory and return the
300/// edited measures. `edits` is a JSON array of `{"measure": name, "coord":
301/// {category: item}, "value": v | null}`: `v` a number, a bool, a string, or
302/// a datetime as an RFC 3339 string, by the measure's type (measure and
303/// category names case-insensitive; `null` removes the cell).
304pub 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
369/// The cells of `new` that differ from `old` (`Some`), and the cells of
370/// `old` that `new` lacks (`None`), in key order.
371pub 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
389/// A result key (item names in `measure`'s category order) as
390/// `{"<category>": "<item>"}`.
391pub 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
407/// A result value as JSON: a number (`"NaN"`, `"Infinity"` or `"-Infinity"`
408/// when not finite, as `float8` prints them), a bool, a string, or a
409/// datetime as an RFC 3339 string. An error is `null`.
410pub 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            // Whole numbers print as float8 does (`7`, not serde's `7.0`).
417            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
438/// The sync trigger for `op` (`ins` / `upd` / `del`):
439/// `improv_<lower(model)>_sync_<op>`, which must fit an identifier.
440pub 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
451/// The logical replication slot (and publication) syncing `model`:
452/// `improv_<lower(model)>`, within Postgres' 63-byte name limit.
453pub 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
464/// A SQL string literal.
465pub fn quote_literal(s: &str) -> String {
466    format!("'{}'", s.replace('\'', "''"))
467}
468
469/// `SELECT` of the distinct dimension values (as text) in a statement's
470/// transition tables, given the names of those present; `None` when there
471/// is nothing to read.
472pub 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    /// Price, Quantity [Time, Product] and Cost [Product] over
526    /// {Q1, Q2} × {Widget, Gadget} (items 1..4), every input cell set, plus
527    /// `defs` as measures 10, 11, ...
528    fn model(defs: &[&str]) -> Model {
529        model_with(&[], defs)
530    }
531
532    /// [`model`] with named LAMBDAs `lambdas` (ids 1, 2, ...) defined first.
533    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        // Cost feeds Margin (broadcast, larger operand left) and Overhead.
617        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        // Price also feeds Total, an aggregation: recompute in full.
621        let p = plan(&m, &[PRICE], s.clone());
622        assert_eq!(p.targets, ids(&[10, 11, 12]));
623        assert_eq!(p.scope, None);
624        // A derived measure reading Total is not element-wise either.
625        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        // Scalar functions are element-wise.
632        let m3 = model(&["Spread = ABS(Price - Cost)", "Neg = -Spread"]);
633        assert_eq!(plan(&m3, &[PRICE], s.clone()).scope, Some(s.clone()));
634        // Nothing touched: nothing to do.
635        let p = plan(&m, &[PRICE], Scope::default());
636        assert!(p.targets.is_empty());
637        // A projected reference (BY/OVER/EXCEPT) or an external call is not
638        // element-wise, wherever it sits in the expression.
639        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        // A named LAMBDA is element-wise when its body is.
677        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        // One it cannot expand (gone from the model) is not.
688        let mut gone = m.clone();
689        gone.lambdas.clear();
690        assert!(!scopable(&gone, &[MeasureId(10)]));
691        // An aggregation on either side of an operator is not.
692        let m = model(&["Left = SUM(Price OVER Time) + Cost"]);
693        assert!(!scopable(&m, &[COST]));
694        // Nor is a measure the model does not have, or a reference to one.
695        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        // A binding over both: the distinct non-NULL tuples, row-aligned.
711        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        // Over Product only, its column under its own name.
719        assert_eq!(
720            scope_filter(&[T, P], &rows, &[P], &cols(&["item"])),
721            Some((cols(&["item"]), vec![cols(&["Gadget", "Widget"])]))
722        );
723        // Over neither: every row.
724        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        // A typed input takes a value of its type.
823        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}