Skip to main content

improv_pg_model/
lib.rs

1//! Postgres-free core of the `pg_improv` extension.
2//!
3//! The extension shell reads rows over SPI, hands them to this crate as plain
4//! Rust values, and runs the SQL this crate returns. Everything decidable
5//! without a backend lives here:
6//!
7//! * identifier validation and quoting ([`validate_name`], [`quote_ident`]);
8//! * catalog rows → [`Model`] ([`build_model`]) and new derived measures
9//!   ([`define`]);
10//! * bound fact-table rows → input cells with stable item minting
11//!   ([`load_inputs`]), and unbound `improv.cell` rows ([`load_cells`]);
12//! * engine results → upsert/delete statements ([`result_rows`],
13//!   [`diff_statements`]).
14//!
15//! The catalog encoding is shared with `improv_storage_pg` (the GUI's store):
16//! `measure.formula` is `Target = expr` text (JSON `Formula` is also read),
17//! `measure.value_type` is `number | boolean | text | datetime | enum`, a
18//! measure's `categories` keep their stored order, and `cell.coord` is
19//! `{"<category id>": <item id>}`.
20#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
21#![cfg_attr(coverage_nightly, allow(unused_features))]
22
23pub mod live;
24pub mod pgoutput;
25
26use std::collections::btree_map::Entry;
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28
29use improv_core_model::parser::{self, Definition};
30use improv_core_model::{
31    CategoryId, Coordinate, Expr, Formula, ItemId, Measure, MeasureId, MeasureKind, Model, Name,
32    Value, ValueType,
33};
34use improv_data_source::ItemInterner;
35use improv_engine::{CellValue, CoordKey};
36
37mod delta;
38pub use delta::{
39    apply_edits, cell_changes, coord_json, dependents, measure_ids, plan, quote_literal, scopable,
40    scope_filter, scoped_rel_sql, slot_name, sync_trigger_name, touched_sql, value_json, Plan,
41    Scope,
42};
43
44/// Longest accepted model/category/measure name. Postgres truncates
45/// identifiers at 63 bytes and the result schema is `improv_<model>`.
46pub const MAX_NAME_LEN: usize = 56;
47
48/// Everything that can go wrong in the pure core.
49#[derive(Debug, Clone, PartialEq, thiserror::Error)]
50pub enum Error {
51    /// A model, category or measure name is not a plain identifier.
52    #[error("invalid {what} name {name:?}: must match ^[A-Za-z][A-Za-z0-9_]*$ (at most 56 chars, not \"value\")")]
53    InvalidName {
54        /// Which kind of name (`model`, `category`, `measure`).
55        what: &'static str,
56        /// The rejected name.
57        name: String,
58    },
59    /// A definition could not be parsed, typed, or is not supported in v1.
60    #[error("definition: {0}")]
61    Definition(String),
62    /// Two bound rows map to the same input cell.
63    #[error("duplicate coordinate for measure {measure}: {coord:?}")]
64    DuplicateCoordinate {
65        /// The measure's name.
66        measure: String,
67        /// The coordinate's item names, in dimension order.
68        coord: Vec<String>,
69    },
70    /// The bound input exceeds the `improv.max_cells` budget.
71    #[error("{cells} bound input cells exceed improv.max_cells = {max}")]
72    TooManyCells {
73        /// Input cells the model would hold.
74        cells: usize,
75        /// The configured budget.
76        max: usize,
77    },
78    /// The catalog is internally inconsistent.
79    #[error("catalog: {0}")]
80    Catalog(String),
81}
82
83/// Result alias for this crate.
84pub type Result<T> = std::result::Result<T, Error>;
85
86/// Accept `^[A-Za-z][A-Za-z0-9_]*$`, at most [`MAX_NAME_LEN`] chars, and not
87/// `value` (any case: it is the result tables' value column).
88pub fn validate_name(what: &'static str, name: &str) -> Result<()> {
89    let mut chars = name.chars();
90    let ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
91        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
92        && name.len() <= MAX_NAME_LEN
93        && !name.eq_ignore_ascii_case("value");
94    if ok {
95        Ok(())
96    } else {
97        Err(Error::InvalidName {
98            what,
99            name: name.to_string(),
100        })
101    }
102}
103
104/// Quote an identifier exactly like Postgres' `quote_ident` does when quoting
105/// is needed (always quoting is equivalent and simpler).
106pub fn quote_ident(s: &str) -> String {
107    format!("\"{}\"", s.replace('"', "\"\""))
108}
109
110/// The result schema of a model: `improv_<lower(model)>`.
111pub fn result_schema(model: &str) -> String {
112    format!("improv_{}", model.to_ascii_lowercase())
113}
114
115/// The quoted, schema-qualified result table of a derived measure.
116pub fn result_table(model: &str, measure: &str) -> String {
117    format!(
118        "{}.{}",
119        quote_ident(&result_schema(model)),
120        quote_ident(&measure.to_ascii_lowercase())
121    )
122}
123
124/// `improv.category` row.
125#[derive(Debug, Clone, PartialEq)]
126pub struct CategoryRow {
127    /// Category id (unique within the model).
128    pub id: u32,
129    /// Category name.
130    pub name: String,
131}
132
133/// `improv.item` row.
134#[derive(Debug, Clone, PartialEq)]
135pub struct ItemRow {
136    /// Item id (unique within the model, never reused).
137    pub id: u32,
138    /// Owning category id.
139    pub category_id: u32,
140    /// Item name (the bound column's value as text).
141    pub name: String,
142}
143
144/// `improv.measure` row.
145#[derive(Debug, Clone, PartialEq)]
146pub struct MeasureRow {
147    /// Measure id (unique within the model).
148    pub id: u32,
149    /// Measure name.
150    pub name: String,
151    /// `number | boolean | text | datetime | enum`.
152    pub value_type: String,
153    /// `input | derived`.
154    pub kind: String,
155    /// A derived measure's `Target = expr` text (or JSON `Formula`).
156    pub formula: Option<String>,
157    /// Free-text description.
158    pub description: Option<String>,
159    /// Dimension category ids, in stored order.
160    pub categories: Vec<u32>,
161}
162
163impl MeasureRow {
164    /// An input measure row of type `number`.
165    pub fn input(id: u32, name: &str, categories: Vec<u32>) -> Self {
166        MeasureRow {
167            id,
168            name: name.to_string(),
169            value_type: "number".into(),
170            kind: "input".into(),
171            formula: None,
172            description: None,
173            categories,
174        }
175    }
176
177    /// Whether this row is a derived measure.
178    pub fn is_derived(&self) -> bool {
179        self.kind == "derived"
180    }
181}
182
183/// The catalog spelling of a value type.
184pub fn value_type_name(vt: ValueType) -> &'static str {
185    match vt {
186        ValueType::Number => "number",
187        ValueType::Boolean => "boolean",
188        ValueType::Text => "text",
189        ValueType::DateTime => "datetime",
190        ValueType::Enum => "enum",
191    }
192}
193
194fn value_type_parse(s: &str) -> Result<ValueType> {
195    Ok(match s {
196        "number" => ValueType::Number,
197        "boolean" => ValueType::Boolean,
198        "text" => ValueType::Text,
199        "datetime" => ValueType::DateTime,
200        "enum" => ValueType::Enum,
201        other => return Err(Error::Catalog(format!("unknown value type {other:?}"))),
202    })
203}
204
205/// One model's catalog rows.
206#[derive(Debug, Clone, Default, PartialEq)]
207pub struct Catalog {
208    /// `improv.category` rows.
209    pub categories: Vec<CategoryRow>,
210    /// `improv.item` rows.
211    pub items: Vec<ItemRow>,
212    /// `improv.measure` rows.
213    pub measures: Vec<MeasureRow>,
214    /// `improv.lambda` rows.
215    pub lambdas: Vec<LambdaRow>,
216}
217
218/// `improv.lambda` row: a named LAMBDA.
219#[derive(Debug, Clone, PartialEq)]
220pub struct LambdaRow {
221    /// Lambda id (unique within the model; calls store it).
222    pub id: u32,
223    /// Its name, as in the definition.
224    pub name: String,
225    /// `Name = LAMBDA(…)` text (what `improv.define` writes) or a JSON
226    /// `NamedLambda` (what the GUI's store writes).
227    pub definition: String,
228}
229
230/// Parse a `Target = expr` definition, rejecting the `SQL(...)`/`CALL(...)`
231/// source forms (external code never runs inside Postgres in v1).
232fn parse_formula(model: &Model, text: &str) -> Result<(String, Formula)> {
233    match parser::parse_definition(model, text).map_err(|e| Error::Definition(e.to_string()))? {
234        Definition::Formula(ft) => Ok((ft.target.0, ft.formula)),
235        Definition::Lambda(lt) => Err(Error::Definition(format!(
236            "{} = LAMBDA(…) defines a named LAMBDA, not a measure",
237            lt.name.0
238        ))),
239        _ => Err(Error::Definition(
240            "SQL(...) and CALL(...) definitions are not supported inside Postgres".into(),
241        )),
242    }
243}
244
245/// Build a [`Model`] (structure only, no input cells) from catalog rows.
246/// Every measure and named LAMBDA is added before any formula is parsed, so
247/// a text formula may reference any measure, whatever its id (a
248/// redefinition may read a measure defined after it), and call any lambda.
249pub fn build_model(catalog: &Catalog) -> Result<Model> {
250    let mut model = Model::new();
251    for c in &catalog.categories {
252        model.add_category(CategoryId(c.id), c.name.clone());
253    }
254    for i in &catalog.items {
255        if !model.categories.contains_key(&CategoryId(i.category_id)) {
256            return Err(Error::Catalog(format!(
257                "item {} references unknown category {}",
258                i.id, i.category_id
259            )));
260        }
261        model.add_item(ItemId(i.id), CategoryId(i.category_id), i.name.clone());
262    }
263    let mut rows: Vec<&MeasureRow> = catalog.measures.iter().collect();
264    rows.sort_by_key(|r| r.id);
265    for r in &rows {
266        if !matches!(
267            (r.kind.as_str(), &r.formula),
268            ("input", _) | ("derived", Some(_))
269        ) {
270            return Err(Error::Catalog(format!(
271                "measure {} has kind {:?} and formula {:?}",
272                r.name, r.kind, r.formula
273            )));
274        }
275        // A derived measure is a placeholder until its formula is parsed.
276        model.add_measure(Measure {
277            id: MeasureId(r.id),
278            name: Name(r.name.clone()),
279            value_type: value_type_parse(&r.value_type)?,
280            categories: r.categories.iter().map(|&c| CategoryId(c)).collect(),
281            kind: MeasureKind::Input,
282            description: r.description.clone(),
283        });
284    }
285    let lambdas: Vec<(u32, String)> = catalog
286        .lambdas
287        .iter()
288        .map(|l| (l.id, l.definition.clone()))
289        .collect();
290    model.load_lambdas(&lambdas).map_err(Error::Catalog)?;
291    for l in &catalog.lambdas {
292        if !model.lambdas[&l.id].name.0.eq_ignore_ascii_case(&l.name) {
293            return Err(Error::Catalog(format!(
294                "lambda {} is named {} but defines {}",
295                l.id, l.name, model.lambdas[&l.id].name.0
296            )));
297        }
298    }
299    for r in rows {
300        let Some(text) = r.formula.as_deref().filter(|_| r.is_derived()) else {
301            continue;
302        };
303        let formula = match serde_json::from_str::<Formula>(text) {
304            Ok(f) => f,
305            Err(_) => {
306                let (target, formula) = parse_formula(&model, text)?;
307                if target != r.name {
308                    return Err(Error::Catalog(format!(
309                        "measure {} has a formula for {target}",
310                        r.name
311                    )));
312                }
313                formula
314            }
315        };
316        // Added above, as a placeholder.
317        model
318            .measures
319            .entry(MeasureId(r.id))
320            .and_modify(|m| m.kind = MeasureKind::Derived(formula));
321    }
322    Ok(model)
323}
324
325/// What [`define_measure`] found to do.
326#[derive(Debug, Clone, PartialEq)]
327pub enum Defined {
328    /// A new derived measure: insert the row and create its result table.
329    New(MeasureRow),
330    /// The measure `id` already has this formula: change nothing.
331    Unchanged(u32),
332    /// A new formula for an existing derived measure: update its row, then
333    /// recompute it and its dependents.
334    Replaced {
335        /// The updated row (id, name and description kept).
336        row: MeasureRow,
337        /// Its value type or dimensions changed: recreate its result table.
338        reshaped: bool,
339    },
340    /// A named LAMBDA ([`define_lambda`]): insert or update its row, then
341    /// recompute `callers` and their dependents.
342    Lambda {
343        /// The new or updated row.
344        row: LambdaRow,
345        /// The derived measures that call it, directly or through other
346        /// lambdas (none for a new lambda).
347        callers: Vec<MeasureId>,
348    },
349}
350
351/// Whether `text` is a `Name = LAMBDA(…)` definition.
352fn is_lambda(text: &str) -> bool {
353    text.split_once('=').is_some_and(|(_, rhs)| {
354        let r = rhs.trim_start();
355        r.get(..6).is_some_and(|h| h.eq_ignore_ascii_case("lambda"))
356            && r[6..].trim_start().starts_with('(')
357    })
358}
359
360/// A `Name = LAMBDA(…)` definition for `model`: `None` when `text` defines a
361/// measure, else a new named LAMBDA ([`Defined::Lambda`]), or with `replace`
362/// [`Defined::Unchanged`] when it has this definition already, else its
363/// redefinition, with the measures that call it to recompute. Its body may
364/// reduce over a category parameter (`LAMBDA(x, c, MAX(x OVER c))`). A name
365/// a measure has is an error, and so is a redefinition that would call a
366/// lambda calling it, or break or change the type or dimensions of a
367/// measure that calls it (or depends on one that does): each error names
368/// the dependent.
369pub fn define_lambda(model: &Model, text: &str, replace: bool) -> Result<Option<Defined>> {
370    if !is_lambda(text) {
371        return Ok(None);
372    }
373    let lt = match parser::parse_definition(model, text)
374        .map_err(|e| Error::Definition(e.to_string()))?
375    {
376        Definition::Lambda(lt) => lt,
377        _ => {
378            return Err(Error::Definition(format!(
379                "{text:?} is not Name = LAMBDA(…)"
380            )))
381        }
382    };
383    let name = lt.name.0.as_str();
384    if let Some(m) = model
385        .measures
386        .values()
387        .find(|m| m.name.0.eq_ignore_ascii_case(name))
388    {
389        return Err(Error::Definition(format!(
390            "{} is a measure; name the LAMBDA differently",
391            m.name.0
392        )));
393    }
394    let row = |id| LambdaRow {
395        id,
396        name: name.to_string(),
397        definition: text.trim().to_string(),
398    };
399    match model.lambda_by_name(name) {
400        None => Ok(Some(Defined::Lambda {
401            row: row(model.lambdas.keys().max().map_or(1, |m| m + 1)),
402            callers: Vec::new(),
403        })),
404        Some((_, old)) if !replace => Err(Error::Definition(format!(
405            "LAMBDA {} already exists (improv.define(model, definition, replace => true) \
406             redefines it)",
407            old.name.0
408        ))),
409        Some((id, old)) if old.params == lt.lambda.params && old.body == lt.lambda.body => {
410            Ok(Some(Defined::Unchanged(id)))
411        }
412        Some((id, old)) => {
413            let callers = replace_lambda(model, id, &old.name.0, lt.lambda)?;
414            Ok(Some(Defined::Lambda {
415                row: row(id),
416                callers,
417            }))
418        }
419    }
420}
421
422/// Check that lambda `id` (`name`) may become `new`, and return the derived
423/// measures that call it.
424fn replace_lambda(
425    model: &Model,
426    id: u32,
427    name: &str,
428    new: improv_core_model::NamedLambda,
429) -> Result<Vec<MeasureId>> {
430    let (lambdas, callers) = model.lambda_users(id);
431    let lambda_name = |i: &u32| model.lambdas[i].name.0.clone();
432    // `new` may not call a lambda that calls it (directly or not): a cycle.
433    for l in &lambdas {
434        if calls_named(&new.body, *l) {
435            return Err(Error::Definition(format!(
436                "{name} cannot call {}: {} calls {name}",
437                lambda_name(l),
438                lambda_name(l)
439            )));
440        }
441    }
442    let mut next = model.clone();
443    next.lambdas.insert(id, new);
444    for l in &lambdas {
445        next.expand_lambdas(&next.lambdas[l].body).map_err(|e| {
446            Error::Definition(format!(
447                "redefining {name} breaks LAMBDA {}, which calls it: {e}",
448                lambda_name(l)
449            ))
450        })?;
451    }
452    keep_shapes(&next, name, &dependents(&next, &callers))?;
453    Ok(callers)
454}
455
456/// Check that each derived measure of `ids`, which depend on `name`, still
457/// compiles in `next` to the type and dimensions it has (its result table).
458/// An input keeps its shape.
459fn keep_shapes(next: &Model, name: &str, ids: &[MeasureId]) -> Result<()> {
460    for d in ids {
461        let m = &next.measures[d];
462        let MeasureKind::Derived(f) = &m.kind else {
463            continue;
464        };
465        let shape =
466            improv_engine::compiler::derived_shape(next, *d, &m.name.0, f).map_err(|e| {
467                Error::Definition(format!(
468                    "redefining {name} breaks {}, which depends on it: {e}",
469                    m.name.0
470                ))
471            })?;
472        if shape != (m.value_type, m.categories.clone()) {
473            return Err(Error::Definition(format!(
474                "redefining {name} would change the type or dimensions of {}, which depends \
475                 on it: redefine or drop {} first",
476                m.name.0, m.name.0
477            )));
478        }
479    }
480    Ok(())
481}
482
483/// Whether `e` calls named LAMBDA `id` directly.
484fn calls_named(e: &Expr, id: u32) -> bool {
485    match e {
486        Expr::Call(f, a) => {
487            improv_core_model::functions::named_lambda_of(*f) == Some(id)
488                || a.iter().any(|x| calls_named(x, id))
489        }
490        Expr::UnaryOp(_, a) => calls_named(a, id),
491        Expr::BinaryOp(_, a, b) => calls_named(a, id) || calls_named(b, id),
492        Expr::Literal(_) | Expr::Ref(..) | Expr::Category(_) => false,
493    }
494}
495
496/// The named LAMBDA called `name` (any case) when nothing calls it, `None`
497/// when there is none: dropping a lambda a measure or another lambda still
498/// calls is an error naming them.
499pub fn drop_lambda(model: &Model, name: &str) -> Result<Option<u32>> {
500    let Some((id, l)) = model.lambda_by_name(name) else {
501        return Ok(None);
502    };
503    let (lambdas, measures) = model.lambda_users(id);
504    let mut users: Vec<&str> = measures
505        .iter()
506        .filter_map(|m| model.measures.get(m).map(|m| m.name.0.as_str()))
507        .chain(lambdas.iter().map(|i| model.lambdas[i].name.0.as_str()))
508        .collect();
509    users.sort_unstable();
510    if users.is_empty() {
511        return Ok(Some(id));
512    }
513    Err(Error::Definition(format!(
514        "cannot drop {}: {} still {} it (drop or redefine {} first)",
515        l.name.0,
516        users.join(", "),
517        if users.len() == 1 { "calls" } else { "call" },
518        if users.len() == 1 { "it" } else { "them" }
519    )))
520}
521
522/// Validate a `Target = expr` definition against `model` and return the
523/// catalog row for the new derived measure `id`. Its categories are the
524/// engine-inferred result dimensions (an `OVER` aggregation drops the
525/// aggregated category), which is exactly the result table's shape. An
526/// existing target is an error ([`define_measure`] can replace it).
527pub fn define(model: &Model, text: &str, id: u32) -> Result<MeasureRow> {
528    match target(model, text) {
529        (name, None) if model.lambda_by_name(name).is_some() => Err(Error::Definition(format!(
530            "{name} is a named LAMBDA; name the measure differently"
531        ))),
532        (_, None) => new_measure(model, text, id),
533        (_, Some(m)) => Err(Error::Definition(format!(
534            "measure {} already exists (improv.define(model, definition, replace => true) \
535             redefines it)",
536            m.name.0
537        ))),
538    }
539}
540
541/// The target name of definition `text`, and the measure of that name (any
542/// case) in `model`.
543fn target<'a>(model: &'a Model, text: &'a str) -> (&'a str, Option<&'a Measure>) {
544    let name = text.split('=').next().unwrap_or("").trim();
545    let m = model
546        .measures
547        .values()
548        .find(|m| m.name.0.eq_ignore_ascii_case(name));
549    (name, m)
550}
551
552/// [`define`], or with `replace` a redefinition of an existing derived
553/// measure: [`Defined::Unchanged`] when it parses to the formula it has, else
554/// [`Defined::Replaced`]. A replacement is refused when its formula would read
555/// a measure that depends on the target (a cycle), when it changes the
556/// target's value type while another measure reads it, and when it breaks or
557/// reshapes a dependent; each error names the dependent.
558pub fn define_measure(model: &Model, text: &str, id: u32, replace: bool) -> Result<Defined> {
559    match target(model, text) {
560        (_, Some(m)) if replace => replace_measure(model, text, m),
561        _ => define(model, text, id).map(Defined::New),
562    }
563}
564
565fn new_measure(model: &Model, text: &str, id: u32) -> Result<MeasureRow> {
566    // A new measure may refer to itself (a PREV recurrence): parse with its
567    // name provisionally known.
568    let name = text.split('=').next().unwrap_or("").trim();
569    let (target, formula) = parse_formula(&model.with_provisional(MeasureId(id), name), text)?;
570    validate_name("measure", &target)?;
571    if model
572        .measures
573        .values()
574        .any(|m| m.name.0.eq_ignore_ascii_case(&target))
575    {
576        return Err(Error::Definition(format!(
577            "measure {target} already exists"
578        )));
579    }
580    let (value_type, categories) =
581        improv_engine::compiler::derived_shape(model, MeasureId(id), &target, &formula)
582            .map_err(|e| Error::Definition(e.to_string()))?;
583    if value_type == ValueType::Enum {
584        return Err(no_enum_result(&target));
585    }
586    Ok(MeasureRow {
587        id,
588        name: target,
589        value_type: value_type_name(value_type).into(),
590        kind: "derived".into(),
591        formula: Some(text.trim().to_string()),
592        description: None,
593        categories: categories.iter().map(|c| c.0).collect(),
594    })
595}
596
597fn no_enum_result(name: &str) -> Error {
598    Error::Definition(format!(
599        "{name} is an enum; enum results are not supported yet"
600    ))
601}
602
603fn replace_measure(model: &Model, text: &str, m: &Measure) -> Result<Defined> {
604    let MeasureKind::Derived(old) = &m.kind else {
605        return Err(Error::Definition(format!(
606            "{} is an input measure; only a derived measure can be redefined",
607            m.name.0
608        )));
609    };
610    let (_, formula) = parse_formula(model, text)?;
611    if formula == *old {
612        return Ok(Defined::Unchanged(m.id.0));
613    }
614    let name_of = |id: &MeasureId| model.measures.get(id).map_or("?", |m| m.name.0.as_str());
615    let downstream: Vec<MeasureId> = dependents(model, &[m.id])
616        .into_iter()
617        .filter(|d| *d != m.id)
618        .collect();
619    if let Some(d) = formula
620        .referenced_measures()
621        .iter()
622        .find(|r| downstream.contains(r))
623    {
624        return Err(Error::Definition(format!(
625            "{} cannot read {}: {} depends on {}",
626            m.name.0,
627            name_of(d),
628            name_of(d),
629            m.name.0
630        )));
631    }
632    let (value_type, categories) =
633        improv_engine::compiler::derived_shape(model, m.id, &m.name.0, &formula)
634            .map_err(|e| Error::Definition(e.to_string()))?;
635    if value_type == ValueType::Enum {
636        return Err(no_enum_result(&m.name.0));
637    }
638    let readers = referencing(model, m.id);
639    if value_type != m.value_type && !readers.is_empty() {
640        return Err(Error::Definition(format!(
641            "redefining {} would change its type from {} to {}, but {} {} it: \
642             redefine or drop {} first",
643            m.name.0,
644            value_type_name(m.value_type),
645            value_type_name(value_type),
646            readers.join(", "),
647            if readers.len() == 1 { "reads" } else { "read" },
648            readers.join(", ")
649        )));
650    }
651    // Every dependent must keep its type and dimensions (its result table).
652    let mut next = model.clone();
653    next.add_measure(Measure {
654        kind: MeasureKind::Derived(formula),
655        value_type,
656        categories: categories.clone(),
657        ..m.clone()
658    });
659    keep_shapes(&next, &m.name.0, &downstream)?;
660    Ok(Defined::Replaced {
661        reshaped: value_type != m.value_type || categories != m.categories,
662        row: MeasureRow {
663            id: m.id.0,
664            name: m.name.0.clone(),
665            value_type: value_type_name(value_type).into(),
666            kind: "derived".into(),
667            formula: Some(text.trim().to_string()),
668            description: m.description.clone(),
669            categories: categories.iter().map(|c| c.0).collect(),
670        },
671    })
672}
673
674/// The other derived measures whose formula reads `id` directly, by name,
675/// sorted.
676pub fn referencing(model: &Model, id: MeasureId) -> Vec<String> {
677    let mut names: Vec<String> = model
678        .measures
679        .values()
680        .filter(|m| m.id != id)
681        .filter(
682            |m| matches!(&m.kind, MeasureKind::Derived(f) if f.referenced_measures().contains(&id)),
683        )
684        .map(|m| m.name.0.clone())
685        .collect();
686    names.sort();
687    names
688}
689
690/// The measure called `name` (any case), when no other measure reads it:
691/// dropping a measure a dependent still references is an error naming it.
692pub fn drop_target(model: &Model, name: &str) -> Result<MeasureId> {
693    let m = model
694        .measures
695        .values()
696        .find(|m| m.name.0.eq_ignore_ascii_case(name))
697        .ok_or_else(|| Error::Definition(format!("measure {name} does not exist")))?;
698    let readers = referencing(model, m.id);
699    if readers.is_empty() {
700        return Ok(m.id);
701    }
702    Err(Error::Definition(format!(
703        "cannot drop {}: {} still {} it (drop or redefine {} first)",
704        m.name.0,
705        readers.join(", "),
706        if readers.len() == 1 {
707            "references"
708        } else {
709            "reference"
710        },
711        if readers.len() == 1 { "it" } else { "them" }
712    )))
713}
714
715/// The rows of one bound input measure, as read from its fact table: one
716/// `Option<String>` per dimension (in the measure's category order) and the
717/// value as the measure's type reads it (`None` = SQL NULL).
718#[derive(Debug, Clone, Default, PartialEq)]
719pub struct BoundRows {
720    /// The input measure the rows feed.
721    pub measure_id: u32,
722    /// `(dimension values, value)` per fact row.
723    pub rows: Vec<(Vec<Option<String>>, Option<CellValue>)>,
724}
725
726/// What [`load_inputs`] did.
727#[derive(Debug, Clone, Default, PartialEq)]
728pub struct Loaded {
729    /// Items minted in memory for values the model had no item for. A
730    /// persisting caller mints through `improv.mint_items` first (see
731    /// [`unknown_items`]), so this is empty for it.
732    pub new_items: Vec<ItemRow>,
733    /// Input cells now in the model.
734    pub cells: usize,
735    /// Rows ignored because a dimension value was NULL.
736    pub null_dim_rows: usize,
737}
738
739/// A bound measure's id, name and categories.
740type Shaped = (MeasureId, String, Vec<CategoryId>);
741
742/// The distinct non-NULL dimension values per category.
743type Distinct<'a> = BTreeMap<CategoryId, BTreeSet<&'a str>>;
744
745/// Validate `bound` against `model`; per measure its shape, and the distinct
746/// non-NULL dimension values per category.
747fn shape<'a>(model: &Model, bound: &'a [BoundRows]) -> Result<(Vec<Shaped>, Distinct<'a>)> {
748    let mut shaped = Vec::with_capacity(bound.len());
749    let mut distinct = Distinct::new();
750    for b in bound {
751        let m = model
752            .measures
753            .get(&MeasureId(b.measure_id))
754            .ok_or_else(|| {
755                Error::Catalog(format!("binding for unknown measure {}", b.measure_id))
756            })?;
757        for (dims, _) in &b.rows {
758            if dims.len() != m.categories.len() {
759                return Err(Error::Catalog(format!(
760                    "{} has {} dimensions but a bound row has {}",
761                    m.name.0,
762                    m.categories.len(),
763                    dims.len()
764                )));
765            }
766            for (c, v) in m.categories.iter().zip(dims) {
767                if let Some(v) = v {
768                    distinct.entry(*c).or_default().insert(v);
769                }
770            }
771        }
772        shaped.push((
773            MeasureId(b.measure_id),
774            m.name.0.clone(),
775            m.categories.clone(),
776        ));
777    }
778    Ok((shaped, distinct))
779}
780
781/// The `(category, name)` pairs `bound` uses that `model` has no item for,
782/// sorted. A persisting caller mints them through `improv.mint_items` and
783/// adds the returned items before [`load_inputs`], so it mints nothing.
784pub fn unknown_items(model: &Model, bound: &[BoundRows]) -> Result<Vec<(CategoryId, String)>> {
785    let known: BTreeSet<(CategoryId, &str)> = model
786        .items
787        .values()
788        .map(|i| (i.category, i.name.0.as_str()))
789        .collect();
790    let (_, distinct) = shape(model, bound)?;
791    Ok(distinct
792        .into_iter()
793        .flat_map(|(c, vs)| vs.into_iter().map(move |v| (c, v)))
794        .filter(|k| !known.contains(k))
795        .map(|(c, v)| (c, v.to_string()))
796        .collect())
797}
798
799/// Turn bound fact rows into input cells. Existing items keep their ids; new
800/// distinct values are minted above the model's highest item id, in lexical
801/// order per category, so the same data always mints the same ids. A NULL
802/// value is no cell; a NULL dimension skips (and counts) the row; two rows on
803/// one coordinate are an error.
804pub fn load_inputs(model: &mut Model, bound: &[BoundRows]) -> Result<Loaded> {
805    let known: BTreeSet<ItemId> = model.items.keys().copied().collect();
806    let (shaped, distinct) = shape(model, bound)?;
807
808    let mut interner = ItemInterner::seeded_from(model, 1);
809    for (c, values) in &distinct {
810        for v in values {
811            interner.intern(*c, v);
812        }
813    }
814
815    let mut cells: BTreeMap<(MeasureId, Coordinate), Value> = BTreeMap::new();
816    let mut null_dim_rows = 0;
817    for (b, (mid, name, cats)) in bound.iter().zip(&shaped) {
818        for (dims, value) in &b.rows {
819            let Some(names) = dims
820                .iter()
821                .map(Option::as_deref)
822                .collect::<Option<Vec<_>>>()
823            else {
824                null_dim_rows += 1;
825                continue;
826            };
827            let Some(v) = value else { continue };
828            let v = to_value(v)
829                .ok_or_else(|| Error::Catalog(format!("{name}: {v} is out of range")))?;
830            let coord = Coordinate::from_pairs(
831                cats.iter()
832                    .zip(&names)
833                    .map(|(c, n)| (*c, interner.intern(*c, n))),
834            );
835            match cells.entry((*mid, coord)) {
836                Entry::Occupied(_) => {
837                    return Err(Error::DuplicateCoordinate {
838                        measure: name.clone(),
839                        coord: names.iter().map(|n| n.to_string()).collect(),
840                    })
841                }
842                Entry::Vacant(e) => {
843                    e.insert(v);
844                }
845            }
846        }
847    }
848
849    interner.register(model);
850    let mut new_items: Vec<ItemRow> = model
851        .items
852        .values()
853        .filter(|i| !known.contains(&i.id))
854        .map(|i| ItemRow {
855            id: i.id.0,
856            category_id: i.category.0,
857            name: i.name.0.clone(),
858        })
859        .collect();
860    new_items.sort_by_key(|i| i.id);
861    let n = cells.len();
862    for ((mid, coord), v) in cells {
863        model.set_input(mid, coord, v);
864    }
865    Ok(Loaded {
866        new_items,
867        cells: n,
868        null_dim_rows,
869    })
870}
871
872/// Add unbound input cells (`improv.cell` rows: measure id, `coord` JSON
873/// `{"<category id>": <item id>}`, `value` JSON scalar). Returns the count.
874pub fn load_cells(model: &mut Model, cells: &[(u32, String, String)]) -> Result<usize> {
875    use serde_json::Value as J;
876    for (mid, coord, value) in cells {
877        let bad = |what: &str| Error::Catalog(format!("improv.cell {mid} {coord}: {what}"));
878        let vt = model
879            .measures
880            .get(&MeasureId(*mid))
881            .ok_or_else(|| bad("unknown measure"))?
882            .value_type;
883        let dims: BTreeMap<CategoryId, ItemId> =
884            serde_json::from_str(coord).map_err(|e| bad(&e.to_string()))?;
885        let json: J = serde_json::from_str(value).map_err(|e| bad(&e.to_string()))?;
886        let v = json_value(vt, &json)
887            .ok_or_else(|| bad(&format!("{json} is not a {} value", value_type_name(vt))))?;
888        model.set_input(MeasureId(*mid), Coordinate { dims }, v);
889    }
890    Ok(cells.len())
891}
892
893/// A JSON scalar as a `vt` value: a number, an enum ordinal, a bool, a
894/// string, or a datetime as an RFC 3339 string. `None` when it is not one.
895pub(crate) fn json_value(vt: ValueType, json: &serde_json::Value) -> Option<Value> {
896    use serde_json::Value as J;
897    match (vt, json) {
898        (ValueType::Number, J::Number(n)) => n.as_f64().map(Value::Number),
899        (ValueType::Enum, J::Number(n)) => n
900            .as_u64()
901            .and_then(|n| u32::try_from(n).ok())
902            .map(Value::Enum),
903        (ValueType::Boolean, J::Bool(b)) => Some(Value::Boolean(*b)),
904        (ValueType::Text, J::String(s)) => Some(Value::Text(s.clone())),
905        (ValueType::DateTime, J::String(s)) => chrono::DateTime::parse_from_rfc3339(s)
906            .ok()
907            .map(|d| Value::DateTime(d.with_timezone(&chrono::Utc))),
908        _ => None,
909    }
910}
911
912/// Refuse a model whose input exceeds the `improv.max_cells` budget.
913pub fn check_cell_budget(cells: usize, max: usize) -> Result<()> {
914    if cells > max {
915        Err(Error::TooManyCells { cells, max })
916    } else {
917        Ok(())
918    }
919}
920
921/// A result table's contents: item names in dimension order → value.
922pub type ResultRows = BTreeMap<Vec<String>, CellValue>;
923
924/// An engine cell as a model value (`None`: an error, or a timestamp chrono
925/// cannot hold).
926fn to_value(v: &CellValue) -> Option<Value> {
927    Some(match v {
928        CellValue::Num(b) => Value::Number(f64::from_bits(*b)),
929        CellValue::Bool(b) => Value::Boolean(*b),
930        CellValue::Text(t) => Value::Text(t.clone()),
931        CellValue::Date(ms) => Value::DateTime(chrono::DateTime::from_timestamp_millis(*ms)?),
932        CellValue::Err(_) => return None,
933    })
934}
935
936/// Whether `v` is a value of type `vt`.
937fn is_a(vt: ValueType, v: &CellValue) -> bool {
938    matches!(
939        (vt, v),
940        (ValueType::Number, CellValue::Num(_))
941            | (ValueType::Boolean, CellValue::Bool(_))
942            | (ValueType::Text, CellValue::Text(_))
943            | (ValueType::DateTime, CellValue::Date(_))
944    )
945}
946
947fn no_enum() -> Error {
948    Error::Catalog("enum measures have no Postgres column type yet".into())
949}
950
951/// The value type of an input measure bound to a column of Postgres type
952/// `typname` (`pg_type.typname`; `shown` is its `format_type`, for errors),
953/// read through `agg`.
954pub fn bound_value_type(typname: &str, shown: &str, agg: Option<&str>) -> Result<ValueType> {
955    let vt = match typname {
956        "float4" | "float8" | "numeric" | "int2" | "int4" | "int8" => ValueType::Number,
957        "bool" => ValueType::Boolean,
958        "text" | "varchar" | "bpchar" => ValueType::Text,
959        "date" | "timestamp" | "timestamptz" => ValueType::DateTime,
960        _ => {
961            return Err(Error::Catalog(format!(
962                "cannot bind a measure to a column of type {shown}"
963            )))
964        }
965    };
966    match agg.map(agg_sql).transpose()? {
967        Some("count" | "count_distinct") => Ok(ValueType::Number),
968        Some(a @ ("sum" | "avg")) if vt != ValueType::Number => Err(Error::Catalog(format!(
969            "agg {a} needs a number column, not {shown}"
970        ))),
971        _ => Ok(vt),
972    }
973}
974
975/// A result table's `value` column type for `vt` (as `format_type` names it).
976pub fn sql_type(vt: ValueType) -> Result<&'static str> {
977    Ok(match vt {
978        ValueType::Number => "double precision",
979        ValueType::Boolean => "boolean",
980        ValueType::Text => "text",
981        ValueType::DateTime => "timestamp with time zone",
982        ValueType::Enum => return Err(no_enum()),
983    })
984}
985
986/// SQL reading expression `e` as a `vt` value: `float8`, `bool`, `text`, or
987/// a datetime as `int8` epoch milliseconds (the engine's resolution).
988fn read_expr(vt: ValueType, e: &str) -> Result<String> {
989    Ok(match vt {
990        ValueType::Number => format!("{e}::float8"),
991        ValueType::Boolean => format!("{e}::bool"),
992        ValueType::Text => format!("{e}::text"),
993        ValueType::DateTime => format!("(extract(epoch from {e}::timestamptz) * 1000)::int8"),
994        ValueType::Enum => return Err(no_enum()),
995    })
996}
997
998/// Refuse a result table whose `value` column (of type `found`, as
999/// `format_type` names it) does not hold `vt`.
1000pub fn check_value_column(table: &str, vt: ValueType, found: &str) -> Result<()> {
1001    if found == sql_type(vt)? {
1002        return Ok(());
1003    }
1004    Err(Error::Catalog(format!(
1005        "{table}.value is {found} but the measure is {}: redefine the measure \
1006         (improv.define(model, definition, replace => true)) to recreate its table",
1007        value_type_name(vt)
1008    )))
1009}
1010
1011/// Name the engine's cells for `measure` in its dimension order. Cells that
1012/// are not of the measure's type (errors) are left out.
1013pub fn result_rows(
1014    model: &Model,
1015    measure: MeasureId,
1016    cells: &HashMap<CoordKey, CellValue>,
1017) -> Result<ResultRows> {
1018    let m = model
1019        .measures
1020        .get(&measure)
1021        .ok_or_else(|| Error::Catalog(format!("unknown measure {}", measure.0)))?;
1022    let mut out = ResultRows::new();
1023    for (key, val) in cells {
1024        if !is_a(m.value_type, val) {
1025            continue;
1026        }
1027        let names = m
1028            .categories
1029            .iter()
1030            .map(|c| {
1031                key.iter()
1032                    .find(|(kc, _)| *kc == c.0)
1033                    .and_then(|(_, i)| model.items.get(&ItemId(*i)))
1034                    .map(|it| it.name.0.clone())
1035            })
1036            .collect::<Option<Vec<_>>>()
1037            .ok_or_else(|| {
1038                Error::Catalog(format!(
1039                    "engine cell {key:?} does not match {}'s dimensions",
1040                    m.name.0
1041                ))
1042            })?;
1043        out.insert(names, val.clone());
1044    }
1045    Ok(out)
1046}
1047
1048/// The lower-cased result-column names of a measure's dimensions.
1049pub fn dim_columns(model: &Model, measure: MeasureId) -> Result<Vec<String>> {
1050    let m = model
1051        .measures
1052        .get(&measure)
1053        .ok_or_else(|| Error::Catalog(format!("unknown measure {}", measure.0)))?;
1054    m.categories
1055        .iter()
1056        .map(|c| {
1057            model
1058                .categories
1059                .get(c)
1060                .map(|cat| cat.name.0.to_ascii_lowercase())
1061                .ok_or_else(|| Error::Catalog(format!("unknown category {}", c.0)))
1062        })
1063        .collect()
1064}
1065
1066/// `CREATE TABLE IF NOT EXISTS` for a derived measure's result table (a
1067/// measure defined through the GUI gets its table on the first recalc). A
1068/// dimensionless measure's table is just `value`, holding at most one row.
1069pub fn create_table_sql(table: &str, dim_cols: &[String], vt: ValueType) -> Result<String> {
1070    let vt = sql_type(vt)?;
1071    if dim_cols.is_empty() {
1072        return Ok(format!("CREATE TABLE IF NOT EXISTS {table} (value {vt})"));
1073    }
1074    let cols: Vec<String> = dim_cols
1075        .iter()
1076        .map(|c| format!("{} text NOT NULL", quote_ident(c)))
1077        .collect();
1078    Ok(format!(
1079        "CREATE TABLE IF NOT EXISTS {table} ({}, value {vt}, PRIMARY KEY ({}))",
1080        cols.join(", "),
1081        quoted_list(dim_cols)
1082    ))
1083}
1084
1085/// `SELECT` of a result table's current rows (dims…, value read as `vt`,
1086/// see [`select_bound_sql`]).
1087pub fn select_result_sql(table: &str, dim_cols: &[String], vt: ValueType) -> Result<String> {
1088    let mut cols: Vec<String> = dim_cols.iter().map(|c| quote_ident(c)).collect();
1089    cols.push(read_expr(vt, "value")?);
1090    Ok(format!("SELECT {} FROM {table}", cols.join(", ")))
1091}
1092
1093/// The canonical name of a `binding.agg` (`sum | avg | min | max | count |
1094/// count_distinct`); an error for anything else.
1095pub fn agg_sql(agg: &str) -> Result<&'static str> {
1096    Ok(match agg {
1097        "sum" => "sum",
1098        "avg" => "avg",
1099        "min" => "min",
1100        "max" => "max",
1101        "count" => "count",
1102        "count_distinct" => "count_distinct",
1103        other => {
1104            return Err(Error::Catalog(format!(
1105                "agg must be sum, avg, min, max, count or count_distinct, not {other:?}"
1106            )))
1107        }
1108    })
1109}
1110
1111/// The SQL aggregate call of `binding.agg` `agg` over the (quoted) value
1112/// expression `v`: `sum(v)`, …, `count(DISTINCT v)`.
1113pub fn agg_call(agg: &str, v: &str) -> Result<String> {
1114    Ok(match agg_sql(agg)? {
1115        "count_distinct" => format!("count(DISTINCT {v})"),
1116        a => format!("{a}({v})"),
1117    })
1118}
1119
1120/// `SELECT` of a binding's fact rows: dims as text…, then the value read as
1121/// `vt` (`float8`, `bool`, `text`, or a datetime as `int8` epoch
1122/// milliseconds). With `agg`, one row per coordinate, its rows' values
1123/// aggregated (a `bool`'s `min` and `max` as `bool_and` and `bool_or`: SQL
1124/// has no `max(bool)`). `rel` is a `regclass::text`, which Postgres has
1125/// already quoted.
1126pub fn select_bound_sql(
1127    rel: &str,
1128    dim_cols: &[String],
1129    value_col: &str,
1130    agg: Option<&str>,
1131    vt: ValueType,
1132) -> Result<String> {
1133    let dims: Vec<String> = dim_cols
1134        .iter()
1135        .map(|c| format!("{}::text", quote_ident(c)))
1136        .collect();
1137    let v = quote_ident(value_col);
1138    Ok(match agg {
1139        None => format!(
1140            "SELECT {}, {} FROM {rel}",
1141            dims.join(", "),
1142            read_expr(vt, &v)?
1143        ),
1144        Some(a) => {
1145            let by: Vec<String> = (1..=dims.len()).map(|i| i.to_string()).collect();
1146            let call = match (agg_sql(a)?, vt) {
1147                ("min", ValueType::Boolean) => format!("bool_and({v})"),
1148                ("max", ValueType::Boolean) => format!("bool_or({v})"),
1149                _ => agg_call(a, &v)?,
1150            };
1151            format!(
1152                "SELECT {}, {} FROM {rel} GROUP BY {}",
1153                dims.join(", "),
1154                read_expr(vt, &call)?,
1155                by.join(", ")
1156            )
1157        }
1158    })
1159}
1160
1161fn quoted_list(cols: &[String]) -> String {
1162    cols.iter()
1163        .map(|c| quote_ident(c))
1164        .collect::<Vec<_>>()
1165        .join(", ")
1166}
1167
1168/// One SQL statement with column-major array parameters: `$1..$n` are the
1169/// `text[]` dimension columns of [`Statement::dims`], then (upserts only)
1170/// [`Statement::values`].
1171#[derive(Debug, Clone, PartialEq)]
1172pub struct Statement {
1173    /// The SQL text.
1174    pub sql: String,
1175    /// One `text[]` parameter per dimension column.
1176    pub dims: Vec<Vec<String>>,
1177    /// The value parameter (upserts only).
1178    pub values: Option<Values>,
1179}
1180
1181/// An upsert's value parameter: `float8[]` for numbers, else `text[]` the
1182/// SQL casts to the column type.
1183#[derive(Debug, Clone, PartialEq)]
1184pub enum Values {
1185    /// A `float8[]`.
1186    Number(Vec<f64>),
1187    /// A `text[]` (booleans as `true`/`false`, datetimes as RFC 3339).
1188    Text(Vec<String>),
1189}
1190
1191/// `v` as the text Postgres reads back as a `vt` value.
1192fn sql_text(vt: ValueType, v: &CellValue) -> Option<String> {
1193    match (vt, v) {
1194        (ValueType::Boolean, CellValue::Bool(b)) => Some(b.to_string()),
1195        (ValueType::Text, CellValue::Text(t)) => Some(t.clone()),
1196        (ValueType::DateTime, CellValue::Date(ms)) => {
1197            chrono::DateTime::from_timestamp_millis(*ms).map(|d| d.to_rfc3339())
1198        }
1199        _ => None,
1200    }
1201}
1202
1203impl Statement {
1204    /// Result rows this statement changes.
1205    pub fn rows(&self) -> usize {
1206        match (self.dims.first(), &self.values) {
1207            (Some(d), _) => d.len(),
1208            (None, Some(Values::Number(v))) => v.len(),
1209            (None, Some(Values::Text(v))) => v.len(),
1210            // A dimensionless table's delete: its one row.
1211            (None, None) => 1,
1212        }
1213    }
1214}
1215
1216/// The statements that turn `old` (the table's rows) into `new` (the engine's
1217/// rows) of a `vt` measure: at most one batched upsert of new/changed rows
1218/// and one batched delete of vanished rows. Unchanged rows (bit-identical
1219/// value) are skipped; a value that is not a `vt` is an error. With no
1220/// dimension columns (a dimensionless measure's one-row table) the upsert
1221/// replaces the table's row and the delete empties it.
1222pub fn diff_statements(
1223    table: &str,
1224    dim_cols: &[String],
1225    vt: ValueType,
1226    old: &ResultRows,
1227    new: &ResultRows,
1228) -> Result<Vec<Statement>> {
1229    let n = dim_cols.len();
1230    let columns = |keys: &mut dyn Iterator<Item = &Vec<String>>| {
1231        let mut cols = vec![Vec::new(); n];
1232        for k in keys {
1233            for (col, v) in cols.iter_mut().zip(k) {
1234                col.push(v.clone());
1235            }
1236        }
1237        cols
1238    };
1239    let unnest = |extra: &str| {
1240        let params: Vec<String> = (1..=n).map(|i| format!("${i}::text[]")).collect();
1241        format!("unnest({}{extra})", params.join(", "))
1242    };
1243    let cols = quoted_list(dim_cols);
1244    let mut out = Vec::new();
1245
1246    let changed: Vec<(&Vec<String>, &CellValue)> = new
1247        .iter()
1248        .filter(|(k, v)| old.get(*k) != Some(*v))
1249        .collect();
1250    if !changed.is_empty() {
1251        let bad = |v: &CellValue| {
1252            Error::Catalog(format!("{table}: {v} is not a {}", value_type_name(vt)))
1253        };
1254        let (values, param) = match vt {
1255            ValueType::Number => (
1256                Values::Number(
1257                    changed
1258                        .iter()
1259                        .map(|(_, v)| v.as_num().ok_or_else(|| bad(v)))
1260                        .collect::<Result<_>>()?,
1261                ),
1262                "float8[]".to_string(),
1263            ),
1264            _ => (
1265                Values::Text(
1266                    changed
1267                        .iter()
1268                        .map(|(_, v)| sql_text(vt, v).ok_or_else(|| bad(v)))
1269                        .collect::<Result<_>>()?,
1270                ),
1271                format!("text[]::{}[]", sql_type(vt)?),
1272            ),
1273        };
1274        let sql = if n == 0 {
1275            format!(
1276                "WITH gone AS (DELETE FROM {table}) \
1277                 INSERT INTO {table} (value) SELECT * FROM unnest($1::{param})"
1278            )
1279        } else {
1280            format!(
1281                "INSERT INTO {table} ({cols}, value) SELECT * FROM {} \
1282                 ON CONFLICT ({cols}) DO UPDATE SET value = EXCLUDED.value",
1283                unnest(&format!(", ${}::{param}", n + 1))
1284            )
1285        };
1286        out.push(Statement {
1287            sql,
1288            dims: columns(&mut changed.iter().map(|(k, _)| *k)),
1289            values: Some(values),
1290        });
1291    }
1292
1293    let gone: Vec<&Vec<String>> = old.keys().filter(|k| !new.contains_key(*k)).collect();
1294    if !gone.is_empty() {
1295        let sql = if n == 0 {
1296            format!("DELETE FROM {table}")
1297        } else {
1298            format!(
1299                "DELETE FROM {table} WHERE ({cols}) IN (SELECT * FROM {})",
1300                unnest("")
1301            )
1302        };
1303        out.push(Statement {
1304            sql,
1305            dims: columns(&mut gone.into_iter()),
1306            values: None,
1307        });
1308    }
1309    Ok(out)
1310}
1311
1312#[cfg(test)]
1313#[cfg_attr(coverage_nightly, coverage(off))]
1314mod tests {
1315    use super::*;
1316
1317    fn s(v: &str) -> String {
1318        v.to_string()
1319    }
1320
1321    /// Time(1) × Product(2), inputs Price[Time,Product](1), Quantity[Time,Product](2).
1322    fn catalog() -> Catalog {
1323        Catalog {
1324            categories: vec![
1325                CategoryRow {
1326                    id: 1,
1327                    name: s("Time"),
1328                },
1329                CategoryRow {
1330                    id: 2,
1331                    name: s("Product"),
1332                },
1333            ],
1334            items: vec![],
1335            measures: vec![
1336                MeasureRow::input(1, "Price", vec![1, 2]),
1337                MeasureRow::input(2, "Quantity", vec![1, 2]),
1338            ],
1339            lambdas: vec![],
1340        }
1341    }
1342
1343    /// P3-3: named LAMBDAs stored as rows, defined (with a category
1344    /// parameter), called by measures, and dropped once nothing calls them.
1345    #[test]
1346    fn named_lambdas_in_the_catalog() {
1347        let mut cat = catalog();
1348        let model = build_model(&cat).unwrap();
1349        let text = "LOGNORM = LAMBDA(x, c, LN1P(x) / LN1P(MAX(x OVER c)))";
1350        let Ok(Some(Defined::Lambda { row, callers })) = define_lambda(&model, text, false) else {
1351            panic!()
1352        };
1353        assert_eq!((row.id, row.name.as_str(), callers), (1, "LOGNORM", vec![]));
1354        cat.lambdas.push(row);
1355        let model = build_model(&cat).unwrap();
1356        cat.measures
1357            .push(define(&model, "PriceN = LOGNORM(Price, Time)", 3).unwrap());
1358        cat.measures
1359            .push(define(&model, "Qn = LOGNORM(Quantity, Product) * 2", 4).unwrap());
1360        let mut model = build_model(&cat).unwrap();
1361        assert_eq!(
1362            model.measures[&MeasureId(3)].categories,
1363            vec![CategoryId(1), CategoryId(2)]
1364        );
1365        load_inputs(&mut model, &sales()).unwrap();
1366        let out = improv_engine::dataflow::evaluate(&model, &[MeasureId(3), MeasureId(4)]).unwrap();
1367        let rows = result_rows(&model, MeasureId(4), &out[&MeasureId(4)]).unwrap();
1368        // Q2: Widget 200, Gadget 100; the max over Product is 200.
1369        let want = 2.0 * 100f64.ln_1p() / 200f64.ln_1p();
1370        assert!(
1371            matches!(rows[&vec![s("Q2"), s("Gadget")]], CellValue::Num(v) if (f64::from_bits(v) - want).abs() < 1e-12),
1372            "{rows:?}"
1373        );
1374
1375        let def = |t: &str, replace| define_lambda(&model, t, replace);
1376        assert_eq!(def("Price = Quantity * 2", true), Ok(None));
1377        assert_eq!(def(text, true), Ok(Some(Defined::Unchanged(1))));
1378        assert_eq!(
1379            def("lognorm = LAMBDA( x,c, LN1P(x)/LN1P(MAX(x OVER c)) )", true),
1380            Ok(Some(Defined::Unchanged(1)))
1381        );
1382        let e = |r: Result<Option<Defined>>| r.unwrap_err().to_string();
1383        assert!(e(def(text, false)).contains("already exists"));
1384        let msg = e(def("quantity = LAMBDA(x, x)", false));
1385        assert!(msg.contains("Quantity is a measure"), "{msg}");
1386        assert!(e(def("X = LAMBDA(x, x + Price)", false)).contains("only its parameters"));
1387        assert!(matches!(
1388            def("Half = LAMBDA(x, x / 2)", false),
1389            Ok(Some(Defined::Lambda { row, .. })) if row.id == 2
1390        ));
1391        let err = define_measure(&model, "lognorm = Price * 2", 9, false).unwrap_err();
1392        assert!(err.to_string().contains("is a named LAMBDA"), "{err}");
1393
1394        // Redefining: the callers to recompute; refusals name the dependent.
1395        let twice = "LOGNORM = LAMBDA(x, c, 2 * LN1P(x) / LN1P(MAX(x OVER c)))";
1396        assert!(matches!(
1397            def(twice, true),
1398            Ok(Some(Defined::Lambda { row, callers }))
1399                if row.id == 1 && row.definition == twice
1400                    && callers == vec![MeasureId(3), MeasureId(4)]
1401        ));
1402        let msg = e(def("LOGNORM = LAMBDA(x, c, x > MAX(x OVER c))", true));
1403        assert!(
1404            msg.contains("change the type or dimensions of PriceN"),
1405            "{msg}"
1406        );
1407        let msg = e(def("LOGNORM = LAMBDA(x, LN1P(x))", true));
1408        assert!(
1409            msg.contains("breaks PriceN") && msg.contains("takes 1"),
1410            "{msg}"
1411        );
1412        let msg = e(def("LOGNORM = LAMBDA(x, c, MAX(x OVER c))", true));
1413        assert!(
1414            msg.contains("change the type or dimensions of PriceN"),
1415            "{msg}"
1416        );
1417        let mut chain = cat.clone();
1418        chain.lambdas.push(LambdaRow {
1419            id: 2,
1420            name: s("Outer"),
1421            definition: s("Outer = LAMBDA(v, LOGNORM(v, Time) + 1)"),
1422        });
1423        chain.lambdas.push(LambdaRow {
1424            id: 3,
1425            name: s("Again"),
1426            definition: s("Again = LAMBDA(v, Outer(v))"),
1427        });
1428        let chained = build_model(&chain).unwrap();
1429        let msg = define_lambda(&chained, "LOGNORM = LAMBDA(x, c, Outer(x))", true)
1430            .unwrap_err()
1431            .to_string();
1432        assert!(
1433            msg.contains("LOGNORM cannot call Outer: Outer calls LOGNORM"),
1434            "{msg}"
1435        );
1436        // Found anywhere in the body: under an operator, or an argument.
1437        for body in ["-Again(x)", "Again(x) + 1", "1 + Again(x)", "ABS(Again(x))"] {
1438            let t = format!("LOGNORM = LAMBDA(x, c, {body})");
1439            let msg = define_lambda(&chained, &t, true).unwrap_err().to_string();
1440            assert!(msg.contains("cannot call Again"), "{body}: {msg}");
1441        }
1442        let e = drop_lambda(&chained, "Outer").unwrap_err().to_string();
1443        assert_eq!(
1444            e,
1445            "definition: cannot drop Outer: Again still calls it (drop or redefine it first)"
1446        );
1447        let msg = define_lambda(&chained, "LOGNORM = LAMBDA(x, x)", true)
1448            .unwrap_err()
1449            .to_string();
1450        assert!(msg.contains("breaks LAMBDA Outer, which calls it"), "{msg}");
1451
1452        let e = drop_lambda(&model, "LogNorm").unwrap_err().to_string();
1453        assert!(
1454            e.contains("cannot drop LOGNORM: PriceN, Qn still call it"),
1455            "{e}"
1456        );
1457        assert_eq!(drop_lambda(&model, "PriceN"), Ok(None));
1458        cat.measures.truncate(2);
1459        assert_eq!(
1460            drop_lambda(&build_model(&cat).unwrap(), "lognorm"),
1461            Ok(Some(1))
1462        );
1463
1464        // A stored row naming another lambda than it defines is refused.
1465        cat.lambdas[0].name = s("Other");
1466        let e = build_model(&cat).unwrap_err().to_string();
1467        assert!(
1468            e.contains("lambda 1 is named Other but defines LOGNORM"),
1469            "{e}"
1470        );
1471        cat.lambdas[0].definition = s("LOGNORM = LAMBDA(x, Nope(x))");
1472        assert!(build_model(&cat)
1473            .unwrap_err()
1474            .to_string()
1475            .contains("lambda 1:"));
1476    }
1477
1478    fn row(t: &str, p: &str, v: f64) -> (Vec<Option<String>>, Option<CellValue>) {
1479        (vec![Some(s(t)), Some(s(p))], Some(CellValue::num(v)))
1480    }
1481
1482    fn n(v: f64) -> CellValue {
1483        CellValue::num(v)
1484    }
1485
1486    /// The canonical 4 quarters × 2 products fact table, price/qty columns.
1487    fn sales() -> Vec<BoundRows> {
1488        let mut price = BoundRows {
1489            measure_id: 1,
1490            rows: vec![],
1491        };
1492        let mut qty = BoundRows {
1493            measure_id: 2,
1494            rows: vec![],
1495        };
1496        for (q, t) in ["Q1", "Q2", "Q3", "Q4"].iter().enumerate() {
1497            let q = q as f64 + 1.0;
1498            price.rows.push(row(t, "Widget", 10.0));
1499            price.rows.push(row(t, "Gadget", 20.0));
1500            qty.rows.push(row(t, "Widget", 100.0 * q));
1501            qty.rows.push(row(t, "Gadget", 50.0 * q));
1502        }
1503        vec![price, qty]
1504    }
1505
1506    #[test]
1507    fn name_validation() {
1508        for ok in ["plan", "Revenue", "a1_b", "X"] {
1509            validate_name("model", ok).unwrap();
1510        }
1511        let long = "a".repeat(MAX_NAME_LEN + 1);
1512        for bad in [
1513            "",
1514            "1abc",
1515            "_a",
1516            "a-b",
1517            "a b",
1518            "a\"b",
1519            "Value",
1520            "réal",
1521            long.as_str(),
1522        ] {
1523            assert!(
1524                matches!(validate_name("model", bad), Err(Error::InvalidName { .. })),
1525                "{bad:?} accepted"
1526            );
1527        }
1528        validate_name("model", &"a".repeat(MAX_NAME_LEN)).unwrap();
1529    }
1530
1531    #[test]
1532    fn identifiers_are_quoted() {
1533        assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
1534        assert_eq!(result_schema("Plan"), "improv_plan");
1535        assert_eq!(
1536            result_table("Plan", "Revenue"),
1537            "\"improv_plan\".\"revenue\""
1538        );
1539        let cols = vec![s("time"), s("product")];
1540        assert_eq!(
1541            create_table_sql("t", &cols, ValueType::Number).unwrap(),
1542            "CREATE TABLE IF NOT EXISTS t (\"time\" text NOT NULL, \"product\" text NOT NULL, \
1543             value double precision, PRIMARY KEY (\"time\", \"product\"))"
1544        );
1545        assert!(create_table_sql("t", &cols, ValueType::Boolean)
1546            .unwrap()
1547            .contains("value boolean,"));
1548        assert!(create_table_sql("t", &cols, ValueType::Enum).is_err());
1549        assert_eq!(
1550            select_result_sql("t", &cols, ValueType::Number).unwrap(),
1551            "SELECT \"time\", \"product\", value::float8 FROM t"
1552        );
1553        assert_eq!(
1554            select_result_sql("t", &cols, ValueType::DateTime).unwrap(),
1555            "SELECT \"time\", \"product\", \
1556             (extract(epoch from value::timestamptz) * 1000)::int8 FROM t"
1557        );
1558        let num = ValueType::Number;
1559        assert_eq!(
1560            select_bound_sql("public.sales", &cols, "price", None, num).unwrap(),
1561            "SELECT \"time\"::text, \"product\"::text, \"price\"::float8 FROM public.sales"
1562        );
1563        assert_eq!(
1564            select_bound_sql("s", &cols, "price", Some("avg"), num).unwrap(),
1565            "SELECT \"time\"::text, \"product\"::text, avg(\"price\")::float8 FROM s GROUP BY 1, 2"
1566        );
1567        // Postgres has no max(bool) or min(bool): bool_or and bool_and are.
1568        let bool = ValueType::Boolean;
1569        assert_eq!(
1570            select_bound_sql("s", &cols, "ok", Some("max"), bool).unwrap(),
1571            "SELECT \"time\"::text, \"product\"::text, bool_or(\"ok\")::bool FROM s GROUP BY 1, 2"
1572        );
1573        assert_eq!(
1574            select_bound_sql("s", &cols, "ok", Some("min"), bool).unwrap(),
1575            "SELECT \"time\"::text, \"product\"::text, bool_and(\"ok\")::bool FROM s GROUP BY 1, 2"
1576        );
1577        assert_eq!(
1578            select_bound_sql("s", &cols, "ok", Some("count"), num).unwrap(),
1579            "SELECT \"time\"::text, \"product\"::text, count(\"ok\")::float8 FROM s GROUP BY 1, 2"
1580        );
1581        assert_eq!(
1582            select_bound_sql("s", &cols, "price", Some("min"), num).unwrap(),
1583            "SELECT \"time\"::text, \"product\"::text, min(\"price\")::float8 FROM s GROUP BY 1, 2"
1584        );
1585        assert!(select_bound_sql("s", &cols, "price", Some("median; --"), num).is_err());
1586        assert_eq!(agg_sql("count").unwrap(), "count");
1587        assert_eq!(
1588            select_bound_sql("s", &cols, "sha", Some("count_distinct"), num).unwrap(),
1589            "SELECT \"time\"::text, \"product\"::text, count(DISTINCT \"sha\")::float8 \
1590             FROM s GROUP BY 1, 2"
1591        );
1592    }
1593
1594    #[test]
1595    fn catalog_to_model_and_define() {
1596        let mut cat = catalog();
1597        let model = build_model(&cat).unwrap();
1598        assert_eq!(model.categories.len(), 2);
1599        assert!(matches!(
1600            model.measure_by_name("Price").unwrap().kind,
1601            MeasureKind::Input
1602        ));
1603
1604        let rev = define(&model, " Revenue = Price * Quantity ", 3).unwrap();
1605        assert_eq!(rev.formula.as_deref(), Some("Revenue = Price * Quantity"));
1606        assert_eq!(rev.categories, vec![1, 2]);
1607        cat.measures.push(rev);
1608        let model = build_model(&cat).unwrap();
1609        let total = define(&model, "Total = SUM(Revenue OVER Time)", 4).unwrap();
1610        assert_eq!(total.categories, vec![2], "OVER Time drops Time");
1611        cat.measures.push(total);
1612
1613        // Derived rows load after inputs regardless of row order.
1614        cat.measures.reverse();
1615        let model = build_model(&cat).unwrap();
1616        assert!(matches!(
1617            model.measure_by_name("Total").unwrap().kind,
1618            MeasureKind::Derived(_)
1619        ));
1620        assert_eq!(
1621            dim_columns(&model, MeasureId(3)).unwrap(),
1622            vec![s("time"), s("product")]
1623        );
1624    }
1625
1626    #[test]
1627    fn define_rejections() {
1628        let model = build_model(&catalog()).unwrap();
1629        let err = |t: &str| define(&model, t, 9).unwrap_err();
1630        assert!(
1631            matches!(err("Price = Quantity * 2"), Error::Definition(m) if m.contains("exists"))
1632        );
1633        assert!(matches!(err("Bad = Nope * 2"), Error::Definition(_)));
1634        // A dimensionless result (P3-2): a one-row table.
1635        assert!(define(&model, "Two = 2", 9).unwrap().categories.is_empty());
1636        assert!(
1637            matches!(err("X = SQL(\"select 1\")"), Error::Definition(m) if m.contains("not supported"))
1638        );
1639        assert!(
1640            matches!(err("X = CALL(f, Price)"), Error::Definition(m) if m.contains("not supported"))
1641        );
1642        assert!(matches!(
1643            err("value = Price * 2"),
1644            Error::InvalidName { .. }
1645        ));
1646        assert!(define(&model, "Big = Price > 2", 9).unwrap().value_type == "boolean");
1647        assert!(matches!(err("garbage"), Error::Definition(_)));
1648    }
1649
1650    /// P1-1: replace and drop decisions, each refusal naming the dependent.
1651    #[test]
1652    fn redefine_and_drop() {
1653        let mut cat = catalog();
1654        let model = build_model(&cat).unwrap();
1655        cat.measures
1656            .push(define(&model, "Revenue = Price * Quantity", 3).unwrap());
1657        let model = build_model(&cat).unwrap();
1658        cat.measures
1659            .push(define(&model, "Total = SUM(Revenue OVER Time)", 4).unwrap());
1660        let model = build_model(&cat).unwrap();
1661        let def = |t: &str| define_measure(&model, t, 9, true);
1662        let err = |t: &str| match def(t) {
1663            Err(Error::Definition(m)) => m,
1664            other => panic!("{t}: {other:?}"),
1665        };
1666        assert_eq!(
1667            def("Total = SUM(Revenue OVER Time)"),
1668            Ok(Defined::Unchanged(4))
1669        );
1670        assert_eq!(
1671            def("total=SUM( Revenue OVER Time )"),
1672            Ok(Defined::Unchanged(4))
1673        );
1674        let Ok(Defined::Replaced { row, reshaped }) = def("Revenue = Price * Quantity * 2") else {
1675            panic!()
1676        };
1677        assert_eq!((row.id, row.name.as_str(), reshaped), (3, "Revenue", false));
1678        assert_eq!(
1679            row.formula.as_deref(),
1680            Some("Revenue = Price * Quantity * 2")
1681        );
1682        let Ok(Defined::Replaced { reshaped, row }) = def("Total = SUM(Revenue OVER Product)")
1683        else {
1684            panic!()
1685        };
1686        assert!(reshaped, "Total moves from Product to Time");
1687        assert_eq!(row.categories, vec![1]);
1688        assert!(err("Revenue = Price > 2").contains("from number to boolean, but Total reads it"));
1689        // Nothing reads Total: it may change its type.
1690        let Ok(Defined::Replaced { row, reshaped }) = def("Total = SUM(Revenue OVER Time) > 1")
1691        else {
1692            panic!()
1693        };
1694        assert_eq!((row.value_type.as_str(), reshaped), ("boolean", true));
1695        assert!(err("Revenue = Total * 2").contains("Total depends on Revenue"));
1696        // Total still sums Revenue[Product] over Time, so it keeps its shape.
1697        assert!(matches!(
1698            def("Revenue = SUM(Price OVER Time)"),
1699            Ok(Defined::Replaced { reshaped: true, .. })
1700        ));
1701        let mut wider = cat.clone();
1702        wider
1703            .measures
1704            .push(define(&model, "Double = Revenue * 2", 5).unwrap());
1705        let wide = build_model(&wider).unwrap();
1706        let e = define_measure(&wide, "Revenue = SUM(Price OVER Time)", 9, true).unwrap_err();
1707        assert!(e.to_string().contains("dimensions of Double"), "{e}");
1708        assert!(err("Price = Quantity").contains("input measure"));
1709        assert!(matches!(def("New = Price"), Ok(Defined::New(r)) if r.id == 9));
1710        assert!(define_measure(&model, "Total = Revenue", 9, false)
1711            .is_err_and(|e| e.to_string().contains("replace => true")));
1712
1713        assert_eq!(referencing(&model, MeasureId(3)), vec![s("Total")]);
1714        assert_eq!(drop_target(&model, "total"), Ok(MeasureId(4)));
1715        let e = drop_target(&model, "Price").unwrap_err().to_string();
1716        assert!(
1717            e.contains("cannot drop Price: Revenue still references it"),
1718            "{e}"
1719        );
1720        assert!(drop_target(&model, "Nope").is_err());
1721
1722        // A formula may read a measure with a higher id (a redefinition did).
1723        cat.measures[2].formula = Some(s("Revenue = Total * 1"));
1724        cat.measures[3].formula = Some(s("Total = SUM(Price OVER Time)"));
1725        let model = build_model(&cat).unwrap();
1726        assert_eq!(
1727            model.measures[&MeasureId(3)].kind,
1728            MeasureKind::Derived(
1729                parser::parse_formula(&model, "Revenue = Total * 1")
1730                    .unwrap()
1731                    .formula
1732            )
1733        );
1734    }
1735
1736    #[test]
1737    fn bad_catalogs() {
1738        let mut cat = catalog();
1739        cat.items.push(ItemRow {
1740            id: 1,
1741            category_id: 7,
1742            name: s("x"),
1743        });
1744        assert!(matches!(build_model(&cat), Err(Error::Catalog(_))));
1745
1746        let mut cat = catalog();
1747        let mut bad = MeasureRow::input(3, "Revenue", vec![1, 2]);
1748        bad.kind = s("derived");
1749        bad.formula = Some(s("Other = Price * 2"));
1750        cat.measures.push(bad.clone());
1751        assert!(matches!(build_model(&cat), Err(Error::Catalog(_))));
1752        bad.formula = None;
1753        cat.measures[2] = bad.clone();
1754        assert!(matches!(build_model(&cat), Err(Error::Catalog(m)) if m.contains("kind")));
1755        bad.kind = s("input");
1756        bad.value_type = s("money");
1757        cat.measures[2] = bad;
1758        assert!(matches!(build_model(&cat), Err(Error::Catalog(m)) if m.contains("money")));
1759
1760        let mut cat = catalog();
1761        cat.measures[0].value_type = s("text");
1762        let mut model = build_model(&cat).unwrap();
1763        let errs = BoundRows {
1764            measure_id: 1,
1765            rows: vec![(vec![Some(s("Q1")), Some(s("W"))], Some(CellValue::Err(0)))],
1766        };
1767        assert!(matches!(
1768            load_inputs(&mut model, &[errs]),
1769            Err(Error::Catalog(m)) if m.contains("out of range")
1770        ));
1771
1772        let mut model = build_model(&catalog()).unwrap();
1773        let unknown = BoundRows {
1774            measure_id: 99,
1775            rows: vec![],
1776        };
1777        assert!(matches!(
1778            load_inputs(&mut model, &[unknown]),
1779            Err(Error::Catalog(_))
1780        ));
1781        let short = BoundRows {
1782            measure_id: 1,
1783            rows: vec![(vec![Some(s("Q1"))], Some(n(1.0)))],
1784        };
1785        assert!(matches!(
1786            load_inputs(&mut model, &[short]),
1787            Err(Error::Catalog(_))
1788        ));
1789        assert!(dim_columns(&model, MeasureId(99)).is_err());
1790        assert!(result_rows(&model, MeasureId(99), &HashMap::new()).is_err());
1791    }
1792
1793    #[test]
1794    fn catalog_contract_with_storage_pg() {
1795        // Stored category order is kept (not re-sorted); description and
1796        // value type are honoured; a JSON formula (what storage_pg wrote
1797        // before it switched to text) is read too.
1798        let mut cat = catalog();
1799        cat.measures[0].categories = vec![2, 1];
1800        cat.measures[0].description = Some(s("list price"));
1801        let model = build_model(&cat).unwrap();
1802        let price = model.measure_by_name("Price").unwrap();
1803        assert_eq!(price.categories, vec![CategoryId(2), CategoryId(1)]);
1804        assert_eq!(price.description.as_deref(), Some("list price"));
1805        assert_eq!(
1806            dim_columns(&model, MeasureId(1)).unwrap(),
1807            vec![s("product"), s("time")]
1808        );
1809
1810        let rev = define(&model, "Revenue = Price * Quantity", 3).unwrap();
1811        let mut with_text = cat.clone();
1812        with_text.measures.push(rev.clone());
1813        let parsed = build_model(&with_text).unwrap().measures[&MeasureId(3)]
1814            .kind
1815            .clone();
1816        let MeasureKind::Derived(f) = parsed else {
1817            panic!("derived")
1818        };
1819        let mut json = rev;
1820        json.formula = Some(serde_json::to_string(&f).unwrap());
1821        cat.measures.push(json);
1822        let model = build_model(&cat).unwrap();
1823        assert_eq!(model.measures[&MeasureId(3)].kind, MeasureKind::Derived(f));
1824        assert_eq!(value_type_name(ValueType::Enum), "enum");
1825        assert_eq!(value_type_name(ValueType::Text), "text");
1826    }
1827
1828    /// The definitions `define` and `define_lambda` refuse, each naming why.
1829    #[test]
1830    fn definition_refusals_name_their_cause() {
1831        let mut cat = catalog();
1832        let model = build_model(&cat).unwrap();
1833        let err = |r: Result<MeasureRow>| r.unwrap_err().to_string();
1834        let msg = err(define(&model, "Half = LAMBDA(x, x / 2)", 9));
1835        assert!(
1836            msg.contains("defines a named LAMBDA, not a measure"),
1837            "{msg}"
1838        );
1839        // A quoted target is found only once parsed.
1840        let msg = err(define(&model, "'Price' = 1", 9));
1841        assert_eq!(msg, "definition: measure Price already exists");
1842        let msg = err(define(&model, "X = Price + \"a\"", 9));
1843        assert!(msg.contains("type mismatch"), "{msg}");
1844        // Looks like a lambda (`… = LAMBDA(`), but its target is quoted text.
1845        let msg = define_lambda(&model, "'x = LAMBDA(' = 1", false)
1846            .unwrap_err()
1847            .to_string();
1848        assert!(msg.contains("is not Name = LAMBDA(…)"), "{msg}");
1849        // A derived measure may not have an enum result (yet).
1850        cat.measures[0].value_type = s("enum");
1851        cat.measures
1852            .push(define(&model, "D = Quantity * 2", 3).unwrap());
1853        let model = build_model(&cat).unwrap();
1854        let msg = err(define(&model, "E = Price", 9));
1855        assert!(msg.contains("E is an enum; enum results"), "{msg}");
1856        let redefine = |t: &str| define_measure(&model, t, 9, true).unwrap_err().to_string();
1857        assert!(redefine("D = Price").contains("D is an enum"));
1858        assert!(redefine("D = Quantity + \"a\"").contains("type mismatch"));
1859    }
1860
1861    #[test]
1862    fn inputs_keep_their_shape() {
1863        let model = build_model(&catalog()).unwrap();
1864        assert_eq!(
1865            keep_shapes(&model, "X", &[MeasureId(1), MeasureId(2)]),
1866            Ok(())
1867        );
1868    }
1869
1870    /// A redefinition is checked against every dependent, also one that
1871    /// depends on it only through another measure.
1872    #[test]
1873    fn redefinitions_keep_every_dependent_valid() {
1874        let mut cat = catalog();
1875        cat.measures.push(MeasureRow::input(5, "Cost", vec![1, 2]));
1876        let model = build_model(&cat).unwrap();
1877        cat.measures
1878            .push(define(&model, "D = Quantity * 2", 3).unwrap());
1879        let model = build_model(&cat).unwrap();
1880        cat.measures
1881            .push(define(&model, "Prev = PREV(D, Time)", 4).unwrap());
1882        let model = build_model(&cat).unwrap();
1883        let redefine = |t: &str| define_measure(&model, t, 9, true);
1884        let msg = redefine("D = SUM(Quantity OVER Time)")
1885            .unwrap_err()
1886            .to_string();
1887        assert!(
1888            msg.contains("redefining D breaks Prev, which depends on it")
1889                && msg.contains("does not range over Time"),
1890            "{msg}"
1891        );
1892        assert!(matches!(
1893            redefine("D = Cost * 2"),
1894            Ok(Defined::Replaced {
1895                reshaped: false,
1896                ..
1897            })
1898        ));
1899        // Two readers are named together.
1900        cat.measures.push(define(&model, "Neg = -D", 6).unwrap());
1901        let model = build_model(&cat).unwrap();
1902        let msg = define_measure(&model, "D = Quantity > 1", 9, true)
1903            .unwrap_err()
1904            .to_string();
1905        assert!(msg.contains("but Neg, Prev read it"), "{msg}");
1906        let msg = drop_target(&model, "d").unwrap_err().to_string();
1907        assert_eq!(
1908            msg,
1909            "definition: cannot drop D: Neg, Prev still reference it (drop or redefine them first)"
1910        );
1911    }
1912
1913    #[test]
1914    fn unbound_cells_from_improv_cell() {
1915        let mut cat = catalog();
1916        cat.items = vec![
1917            ItemRow {
1918                id: 1,
1919                category_id: 1,
1920                name: s("Q1"),
1921            },
1922            ItemRow {
1923                id: 2,
1924                category_id: 2,
1925                name: s("Widget"),
1926            },
1927        ];
1928        for (id, vt) in [(5, "boolean"), (6, "text"), (7, "enum"), (8, "datetime")] {
1929            let mut m = MeasureRow::input(id, &format!("M{id}"), vec![1, 2]);
1930            m.value_type = s(vt);
1931            cat.measures.push(m);
1932        }
1933        let mut model = build_model(&cat).unwrap();
1934        let c = s(r#"{"1": 1, "2": 2}"#);
1935        let cell = |m: u32, v: &str| (m, c.clone(), s(v));
1936        let n = load_cells(
1937            &mut model,
1938            &[
1939                cell(1, "4.5"),
1940                cell(5, "true"),
1941                cell(6, "\"x\""),
1942                cell(7, "3"),
1943            ],
1944        )
1945        .unwrap();
1946        assert_eq!(n, 4);
1947        let coord =
1948            Coordinate::from_pairs([(CategoryId(1), ItemId(1)), (CategoryId(2), ItemId(2))]);
1949        assert_eq!(
1950            model.inputs[&(MeasureId(1), coord.clone())],
1951            Value::Number(4.5)
1952        );
1953        assert_eq!(model.inputs[&(MeasureId(7), coord.clone())], Value::Enum(3));
1954        load_cells(&mut model, &[cell(8, "\"2026-01-01T00:00:00Z\"")]).unwrap();
1955        assert!(matches!(
1956            model.inputs[&(MeasureId(8), coord)],
1957            Value::DateTime(_)
1958        ));
1959
1960        for bad in [
1961            cell(9, "1"),
1962            cell(1, "\"x\""),
1963            cell(8, "\"yesterday\""),
1964            (1, s("[1]"), s("1")),
1965            (1, c.clone(), s("nope")),
1966        ] {
1967            assert!(matches!(
1968                load_cells(&mut model, &[bad]),
1969                Err(Error::Catalog(_))
1970            ));
1971        }
1972    }
1973
1974    #[test]
1975    fn dim_columns_unknown_category() {
1976        let mut cat = catalog();
1977        cat.measures[0].categories = vec![1, 5];
1978        let model = build_model(&cat).unwrap();
1979        assert!(matches!(
1980            dim_columns(&model, MeasureId(1)),
1981            Err(Error::Catalog(_))
1982        ));
1983    }
1984
1985    #[test]
1986    fn rows_to_cells_mint_stable_items() {
1987        let mut cat = catalog();
1988        let mut model = build_model(&cat).unwrap();
1989        let loaded = load_inputs(&mut model, &sales()).unwrap();
1990        assert_eq!(loaded.cells, 16);
1991        assert_eq!(loaded.null_dim_rows, 0);
1992        // Lexical per category, categories in id order: Q1..Q4, then Gadget, Widget.
1993        let names: Vec<(u32, &str)> = loaded
1994            .new_items
1995            .iter()
1996            .map(|i| (i.id, i.name.as_str()))
1997            .collect();
1998        assert_eq!(
1999            names,
2000            vec![
2001                (1, "Q1"),
2002                (2, "Q2"),
2003                (3, "Q3"),
2004                (4, "Q4"),
2005                (5, "Gadget"),
2006                (6, "Widget")
2007            ]
2008        );
2009
2010        // Persist, reload, load again plus a new product: old ids hold, the
2011        // new value mints above them, nothing old is re-minted.
2012        cat.items = loaded.new_items.clone();
2013        let mut model = build_model(&cat).unwrap();
2014        let mut more = sales();
2015        more[0].rows.push(row("Q1", "Doohickey", 5.0));
2016        assert_eq!(
2017            unknown_items(&model, &more).unwrap(),
2018            vec![(CategoryId(2), s("Doohickey"))]
2019        );
2020        let again = load_inputs(&mut model, &more).unwrap();
2021        assert_eq!(again.cells, 17);
2022        assert_eq!(
2023            again.new_items,
2024            vec![ItemRow {
2025                id: 7,
2026                category_id: 2,
2027                name: s("Doohickey")
2028            }]
2029        );
2030        let widget = model.items.values().find(|i| i.name.0 == "Widget").unwrap();
2031        assert_eq!(widget.id, ItemId(6));
2032    }
2033
2034    #[test]
2035    fn nulls_and_duplicates() {
2036        let mut model = build_model(&catalog()).unwrap();
2037        let b = BoundRows {
2038            measure_id: 1,
2039            rows: vec![
2040                (vec![None, Some(s("Widget"))], Some(n(1.0))),
2041                (vec![Some(s("Q1")), Some(s("Widget"))], None),
2042                row("Q2", "Widget", 3.0),
2043            ],
2044        };
2045        let loaded = load_inputs(&mut model, &[b]).unwrap();
2046        assert_eq!((loaded.cells, loaded.null_dim_rows), (1, 1));
2047
2048        let mut model = build_model(&catalog()).unwrap();
2049        let dup = BoundRows {
2050            measure_id: 1,
2051            rows: vec![row("Q1", "Widget", 1.0), row("Q1", "Widget", 2.0)],
2052        };
2053        assert_eq!(
2054            load_inputs(&mut model, &[dup]).unwrap_err(),
2055            Error::DuplicateCoordinate {
2056                measure: s("Price"),
2057                coord: vec![s("Q1"), s("Widget")]
2058            }
2059        );
2060    }
2061
2062    #[test]
2063    fn cell_budget() {
2064        check_cell_budget(5, 5).unwrap();
2065        assert_eq!(
2066            check_cell_budget(6, 5).unwrap_err().to_string(),
2067            "6 bound input cells exceed improv.max_cells = 5"
2068        );
2069    }
2070
2071    /// The full pure path: catalog → model → inputs → engine → named rows →
2072    /// statements, with the canonical oracle values.
2073    #[test]
2074    fn engine_results_to_statements() {
2075        let mut cat = catalog();
2076        let model = build_model(&cat).unwrap();
2077        cat.measures
2078            .push(define(&model, "Revenue = Price * Quantity", 3).unwrap());
2079        let model = build_model(&cat).unwrap();
2080        cat.measures
2081            .push(define(&model, "Total = SUM(Revenue OVER Time)", 4).unwrap());
2082        let mut model = build_model(&cat).unwrap();
2083        load_inputs(&mut model, &sales()).unwrap();
2084
2085        let out = improv_engine::dataflow::evaluate(&model, &[MeasureId(3), MeasureId(4)]).unwrap();
2086        let rev = result_rows(&model, MeasureId(3), &out[&MeasureId(3)]).unwrap();
2087        assert_eq!(rev.len(), 8);
2088        assert_eq!(rev[&vec![s("Q3"), s("Widget")]], n(3000.0));
2089        assert_eq!(rev[&vec![s("Q4"), s("Gadget")]], n(4000.0));
2090        let total = result_rows(&model, MeasureId(4), &out[&MeasureId(4)]).unwrap();
2091        // Widget: 10*(100+200+300+400) ; Gadget: 20*(50+100+150+200)
2092        assert_eq!(
2093            total,
2094            BTreeMap::from([
2095                (vec![s("Gadget")], n(10000.0)),
2096                (vec![s("Widget")], n(10000.0))
2097            ])
2098        );
2099
2100        let cols = dim_columns(&model, MeasureId(3)).unwrap();
2101        let table = result_table("plan", "Revenue");
2102        let num = ValueType::Number;
2103        let fresh = diff_statements(&table, &cols, num, &ResultRows::new(), &rev).unwrap();
2104        assert_eq!(fresh.len(), 1);
2105        assert_eq!(fresh[0].rows(), 8);
2106        assert!(fresh[0].sql.starts_with(
2107            "INSERT INTO \"improv_plan\".\"revenue\" (\"time\", \"product\", value) \
2108             SELECT * FROM unnest($1::text[], $2::text[], $3::float8[]) ON CONFLICT"
2109        ));
2110        assert!(diff_statements(&table, &cols, num, &rev, &rev)
2111            .unwrap()
2112            .is_empty());
2113    }
2114
2115    #[test]
2116    fn diff_upserts_changes_and_deletes_vanished() {
2117        let cols = vec![s("product")];
2118        let rows = |v: &[(&str, f64)]| -> ResultRows {
2119            v.iter().map(|(k, x)| (vec![s(k)], n(*x))).collect()
2120        };
2121        let old = rows(&[("a", 1.0), ("b", 2.0), ("c", 3.0)]);
2122        let new = rows(&[("a", 1.0), ("b", 2.5), ("d", 4.0)]);
2123        let st = diff_statements("t", &cols, ValueType::Number, &old, &new).unwrap();
2124        assert_eq!(st.len(), 2);
2125        assert_eq!(st[0].dims, vec![vec![s("b"), s("d")]]);
2126        assert_eq!(st[0].values, Some(Values::Number(vec![2.5, 4.0])));
2127        assert_eq!(
2128            st[1].sql,
2129            "DELETE FROM t WHERE (\"product\") IN (SELECT * FROM unnest($1::text[]))"
2130        );
2131        assert_eq!(st[1].dims, vec![vec![s("c")]]);
2132        assert_eq!(st[1].values, None);
2133        assert_eq!(st.iter().map(Statement::rows).sum::<usize>(), 3);
2134        // No dimension columns and no values: a dimensionless table's delete.
2135        assert_eq!(
2136            Statement {
2137                sql: s(""),
2138                dims: vec![],
2139                values: None
2140            }
2141            .rows(),
2142            1
2143        );
2144    }
2145
2146    /// P3-2: a measure with no categories gets a one-row table of just
2147    /// `value`; its diff replaces or deletes that row.
2148    #[test]
2149    fn dimensionless_results() {
2150        let mut cat = catalog();
2151        let model = build_model(&cat).unwrap();
2152        let row = define(&model, "MaxQ = MAX(Quantity OVER Time, Product)", 3).unwrap();
2153        assert!(row.categories.is_empty());
2154        cat.measures.push(row);
2155        let mut model = build_model(&cat).unwrap();
2156        load_inputs(&mut model, &sales()).unwrap();
2157        let out = improv_engine::dataflow::evaluate(&model, &[MeasureId(3)]).unwrap();
2158        let rows = result_rows(&model, MeasureId(3), &out[&MeasureId(3)]).unwrap();
2159        assert_eq!(rows, BTreeMap::from([(vec![], n(400.0))]));
2160        let cols = dim_columns(&model, MeasureId(3)).unwrap();
2161        assert!(cols.is_empty());
2162        assert_eq!(
2163            create_table_sql("t", &cols, ValueType::Number).unwrap(),
2164            "CREATE TABLE IF NOT EXISTS t (value double precision)"
2165        );
2166        assert_eq!(
2167            select_result_sql("t", &cols, ValueType::Number).unwrap(),
2168            "SELECT value::float8 FROM t"
2169        );
2170        let num = ValueType::Number;
2171        let none = ResultRows::new();
2172        let st = diff_statements("t", &cols, num, &none, &rows).unwrap();
2173        assert_eq!(st.len(), 1);
2174        assert_eq!(
2175            st[0].sql,
2176            "WITH gone AS (DELETE FROM t) INSERT INTO t (value) SELECT * FROM unnest($1::float8[])"
2177        );
2178        assert_eq!((st[0].rows(), st[0].dims.len()), (1, 0));
2179        assert!(diff_statements("t", &cols, num, &rows, &rows)
2180            .unwrap()
2181            .is_empty());
2182        let st = diff_statements("t", &cols, num, &rows, &none).unwrap();
2183        assert_eq!((st[0].sql.as_str(), st[0].rows()), ("DELETE FROM t", 1));
2184        let text = ValueType::Text;
2185        let one = BTreeMap::from([(vec![], CellValue::Text(s("x")))]);
2186        let st = diff_statements("t", &cols, text, &none, &one).unwrap();
2187        assert!(
2188            st[0].sql.ends_with("unnest($1::text[]::text[])"),
2189            "{}",
2190            st[0].sql
2191        );
2192        assert_eq!(st[0].rows(), 1);
2193    }
2194
2195    #[test]
2196    fn result_rows_skips_non_numeric_and_rejects_foreign_coords() {
2197        let mut cat = catalog();
2198        cat.items = vec![
2199            ItemRow {
2200                id: 1,
2201                category_id: 1,
2202                name: s("Q1"),
2203            },
2204            ItemRow {
2205                id: 2,
2206                category_id: 2,
2207                name: s("Widget"),
2208            },
2209        ];
2210        let model = build_model(&cat).unwrap();
2211        let cells = HashMap::from([
2212            (vec![(1, 1), (2, 2)], CellValue::num(7.0)),
2213            (vec![(1, 1), (2, 2), (3, 3)], CellValue::Text(s("x"))),
2214        ]);
2215        let rows = result_rows(&model, MeasureId(1), &cells).unwrap();
2216        assert_eq!(
2217            rows,
2218            ResultRows::from([(vec![s("Q1"), s("Widget")], n(7.0))])
2219        );
2220        let foreign = HashMap::from([(vec![(1, 1)], CellValue::num(1.0))]);
2221        assert!(result_rows(&model, MeasureId(1), &foreign).is_err());
2222    }
2223
2224    /// G5: column types map to value types; typed rows load, and typed
2225    /// results diff into `text[]` upserts cast to the column type.
2226    #[test]
2227    fn typed_bindings_and_results() {
2228        let vt = |t: &str, agg| bound_value_type(t, t, agg);
2229        for (t, want) in [
2230            ("numeric", ValueType::Number),
2231            ("int8", ValueType::Number),
2232            ("bool", ValueType::Boolean),
2233            ("varchar", ValueType::Text),
2234            ("bpchar", ValueType::Text),
2235            ("date", ValueType::DateTime),
2236            ("timestamptz", ValueType::DateTime),
2237        ] {
2238            assert_eq!(vt(t, None).unwrap(), want, "{t}");
2239        }
2240        assert_eq!(vt("text", Some("count")).unwrap(), ValueType::Number);
2241        assert_eq!(
2242            vt("text", Some("count_distinct")).unwrap(),
2243            ValueType::Number
2244        );
2245        assert_eq!(vt("bool", Some("max")).unwrap(), ValueType::Boolean);
2246        assert!(matches!(vt("jsonb", None), Err(Error::Catalog(m)) if m.contains("type jsonb")));
2247        assert!(matches!(vt("text", Some("sum")), Err(Error::Catalog(m)) if m.contains("number")));
2248        assert!(matches!(vt("bool", Some("avg")), Err(Error::Catalog(m)) if m.contains("agg avg")));
2249        assert_eq!(vt("int4", Some("sum")).unwrap(), ValueType::Number);
2250        assert_eq!(vt("numeric", Some("avg")).unwrap(), ValueType::Number);
2251        assert!(vt("int4", Some("median")).is_err());
2252        assert_eq!(sql_type(ValueType::Text).unwrap(), "text");
2253        assert!(sql_type(ValueType::Enum).is_err());
2254        let cols = vec![s("p")];
2255        assert_eq!(
2256            select_bound_sql("s", &cols, "v", None, ValueType::Text).unwrap(),
2257            "SELECT \"p\"::text, \"v\"::text FROM s"
2258        );
2259        assert!(select_bound_sql("s", &cols, "v", None, ValueType::Enum).is_err());
2260        check_value_column("t", ValueType::Boolean, "boolean").unwrap();
2261        assert!(matches!(
2262            check_value_column("t", ValueType::Boolean, "double precision"),
2263            Err(Error::Catalog(m)) if m.contains("replace => true")
2264        ));
2265
2266        let mut cat = catalog();
2267        cat.measures[0].value_type = s("text");
2268        cat.measures[1].value_type = s("datetime");
2269        let mut model = build_model(&cat).unwrap();
2270        let at = |v: CellValue| vec![(vec![Some(s("Q1")), Some(s("Widget"))], Some(v))];
2271        let bound = [
2272            BoundRows {
2273                measure_id: 1,
2274                rows: at(CellValue::Text(s("tools"))),
2275            },
2276            BoundRows {
2277                measure_id: 2,
2278                rows: at(CellValue::Date(86_400_000)),
2279            },
2280        ];
2281        load_inputs(&mut model, &bound).unwrap();
2282        let got: BTreeSet<String> = model.inputs.values().map(|v| format!("{v:?}")).collect();
2283        assert!(got.contains("Text(\"tools\")"), "{got:?}");
2284        assert!(
2285            got.iter().any(|v| v.starts_with("DateTime(1970-01-02")),
2286            "{got:?}"
2287        );
2288        let huge = [BoundRows {
2289            measure_id: 2,
2290            rows: at(CellValue::Date(i64::MAX)),
2291        }];
2292        assert!(load_inputs(&mut model, &huge).is_err());
2293        cat.measures[1].value_type = s("boolean");
2294        let mut model = build_model(&cat).unwrap();
2295        let flag = [BoundRows {
2296            measure_id: 2,
2297            rows: at(CellValue::Bool(true)),
2298        }];
2299        load_inputs(&mut model, &flag).unwrap();
2300        assert_eq!(
2301            model.inputs.values().collect::<Vec<_>>(),
2302            [&Value::Boolean(true)]
2303        );
2304
2305        let cols = vec![s("product")];
2306        let one = |v: CellValue| ResultRows::from([(vec![s("a")], v)]);
2307        let st = diff_statements(
2308            "t",
2309            &cols,
2310            ValueType::Boolean,
2311            &ResultRows::new(),
2312            &one(CellValue::Bool(true)),
2313        )
2314        .unwrap();
2315        assert!(st[0].sql.contains("$2::text[]::boolean[]"), "{}", st[0].sql);
2316        assert_eq!(st[0].values, Some(Values::Text(vec![s("true")])));
2317        let dt = ValueType::DateTime;
2318        let st =
2319            diff_statements("t", &cols, dt, &ResultRows::new(), &one(CellValue::Date(0))).unwrap();
2320        assert_eq!(
2321            st[0].values,
2322            Some(Values::Text(vec![s("1970-01-01T00:00:00+00:00")]))
2323        );
2324        let text = one(CellValue::Text(s("x")));
2325        assert!(diff_statements("t", &cols, ValueType::Number, &ResultRows::new(), &text).is_err());
2326        assert!(diff_statements("t", &cols, dt, &ResultRows::new(), &text).is_err());
2327    }
2328}