Skip to main content

improv_duckdb_model/
lib.rs

1//! DuckDB-free core of the `improv` DuckDB extension (`crates/duckdb_improv`).
2//!
3//! The extension reads rows through DuckDB and hands them here as plain Rust
4//! values, the same split as `improv_pg_model` / `pg_improv`:
5//!
6//! * [`calc`]: `improv_calc` / `improv_calc_text`, a scalar formula over
7//!   named JSON inputs (compiled once, cached);
8//! * [`function_rows`]: `improv_functions`, the built-in function registry;
9//! * [`parse_spec`], [`bind`], the `*_sql` builders and [`run`]:
10//!   `improv(source, model)`, a small model spec over a table or query, the
11//!   SQL that reads it, and the engine's cells as result rows.
12//!
13//! The model spec has one item per line (blank lines and `#` / `--`
14//! comments are skipped):
15//!
16//! ```text
17//! category <Name> = <column>
18//! input    <Name> = <column> [sum|avg|min|max|count]
19//! <Name> = <formula>
20//! ```
21//!
22//! Every input ranges over every category. A category's items are its
23//! column's distinct non-NULL values in the column's own order (numbers
24//! numerically, dates chronologically), so `PREV` and `NEXT` follow it.
25#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
26#![cfg_attr(coverage_nightly, allow(unused_features))]
27
28pub mod calc;
29
30use std::collections::HashMap;
31
32use improv_core_model::functions::{self, Category, FnDef, Ret};
33use improv_core_model::MeasureId;
34/// Re-exported so the extension shell needs no direct engine dependency.
35pub use improv_core_model::ValueType;
36/// Re-exported so the extension shell needs no direct engine dependency.
37pub use improv_engine::CellValue;
38use improv_engine::CoordKey;
39use improv_pg_model as pm;
40
41/// Everything that can go wrong building or running a model.
42#[derive(Debug, Clone, PartialEq, thiserror::Error)]
43pub enum Error {
44    /// A problem with one line of the model spec.
45    #[error("improv: model line {line}: {message}")]
46    Line {
47        /// 1-based line number in the spec.
48        line: usize,
49        /// What is wrong with it.
50        message: String,
51    },
52    /// A problem with the model as a whole, or the engine's.
53    #[error("improv: {0}")]
54    Model(String),
55}
56
57/// Result alias for this crate.
58pub type Result<T> = std::result::Result<T, Error>;
59
60fn at(line: usize, message: impl ToString) -> Error {
61    Error::Line {
62        line,
63        message: message.to_string(),
64    }
65}
66
67/// A `category <Name> = <column>` line.
68#[derive(Debug, Clone, PartialEq)]
69pub struct CategorySpec {
70    /// The category's name (also its result column).
71    pub name: String,
72    /// The source column its items come from.
73    pub column: String,
74    /// Its line in the spec.
75    pub line: usize,
76}
77
78/// An `input <Name> = <column> [agg]` line.
79#[derive(Debug, Clone, PartialEq)]
80pub struct InputSpec {
81    /// The input measure's name.
82    pub name: String,
83    /// The source column its values come from.
84    pub column: String,
85    /// `sum | avg | min | max | count`: rows on one coordinate are combined
86    /// with it (without one, two such rows are an error).
87    pub agg: Option<String>,
88    /// Its line in the spec.
89    pub line: usize,
90}
91
92/// A `<Name> = <formula>` line.
93#[derive(Debug, Clone, PartialEq)]
94pub struct DerivedSpec {
95    /// The whole definition, `Name = formula`.
96    pub text: String,
97    /// Its line in the spec.
98    pub line: usize,
99}
100
101/// A parsed model spec.
102#[derive(Debug, Clone, Default, PartialEq)]
103pub struct Spec {
104    /// Categories in declaration order (the result's column order).
105    pub categories: Vec<CategorySpec>,
106    /// Input measures in declaration order.
107    pub inputs: Vec<InputSpec>,
108    /// Derived measures in declaration order (each may use the inputs, the
109    /// derived measures before it, and itself through `PREV`).
110    pub derived: Vec<DerivedSpec>,
111}
112
113const USAGE: &str =
114    "expected `category Name = column`, `input Name = column [sum|avg|min|max|count]` or `Name = formula`";
115
116/// Result columns besides the categories; a category may not take one.
117const RESERVED: [&str; 3] = ["measure", "value", "value_text"];
118
119/// Parse a model spec. Errors name the offending line.
120pub fn parse_spec(text: &str) -> Result<Spec> {
121    let mut spec = Spec::default();
122    let mut seen: HashMap<String, usize> = HashMap::new();
123    for (i, raw) in text.lines().enumerate() {
124        let line = i + 1;
125        let l = raw.trim();
126        if l.is_empty() || l.starts_with('#') || l.starts_with("--") {
127            continue;
128        }
129        let (word, rest) = l.split_once(char::is_whitespace).unwrap_or((l, ""));
130        let rest = rest.trim_start();
131        let keyword = word.to_ascii_lowercase();
132        let declares = matches!(keyword.as_str(), "category" | "input") && !rest.starts_with('=');
133        let (name, rhs) = if declares { rest } else { l }
134            .split_once('=')
135            .map(|(n, r)| (n.trim(), r.trim()))
136            .ok_or_else(|| at(line, USAGE))?;
137        let what = match (declares, keyword.as_str()) {
138            (true, "category") => "category",
139            _ => "measure",
140        };
141        pm::validate_name(what, name).map_err(|e| at(line, e))?;
142        if what == "category" && RESERVED.iter().any(|r| name.eq_ignore_ascii_case(r)) {
143            return Err(at(
144                line,
145                format!("{name} is a result column; name the category something else"),
146            ));
147        }
148        if let Some(first) = seen.insert(name.to_ascii_lowercase(), line) {
149            return Err(at(
150                line,
151                format!("{name} is already declared on line {first}"),
152            ));
153        }
154        if !declares {
155            spec.derived.push(DerivedSpec {
156                text: l.to_string(),
157                line,
158            });
159            continue;
160        }
161        let (column, tail) = parse_column(rhs).map_err(|m| at(line, m))?;
162        let name = name.to_string();
163        if what == "category" {
164            if !tail.is_empty() {
165                return Err(at(line, format!("unexpected {tail:?} after the column")));
166            }
167            spec.categories.push(CategorySpec { name, column, line });
168            continue;
169        }
170        let agg = match tail.split_whitespace().collect::<Vec<_>>()[..] {
171            [] => None,
172            [a] => Some(pm::agg_sql(&a.to_ascii_lowercase()).map_err(|e| at(line, e))?),
173            _ => return Err(at(line, format!("unexpected {tail:?} after the column"))),
174        };
175        spec.inputs.push(InputSpec {
176            name,
177            column,
178            agg: agg.map(str::to_string),
179            line,
180        });
181    }
182    if spec.categories.is_empty() {
183        return Err(Error::Model(
184            "the model declares no category (add a line like `category Time = year`)".into(),
185        ));
186    }
187    if spec.derived.is_empty() {
188        return Err(Error::Model(
189            "the model defines no derived measure (add a line like `Revenue = Price * Quantity`)"
190                .into(),
191        ));
192    }
193    Ok(spec)
194}
195
196/// A column name (bare, or `"double quoted"` with `""` for a quote) and the
197/// rest of the line.
198fn parse_column(s: &str) -> std::result::Result<(String, &str), String> {
199    if let Some(body) = s.strip_prefix('"') {
200        let mut out = String::new();
201        let mut chars = body.char_indices();
202        while let Some((i, c)) = chars.next() {
203            if c != '"' {
204                out.push(c);
205            } else if body[i + 1..].starts_with('"') {
206                out.push('"');
207                chars.next();
208            } else if out.is_empty() {
209                break;
210            } else {
211                return Ok((out, body[i + 1..].trim()));
212            }
213        }
214        return Err("unterminated or empty quoted column name".into());
215    }
216    match s.split_once(char::is_whitespace) {
217        _ if s.is_empty() => Err("expected a column name after `=`".into()),
218        Some((c, rest)) => Ok((c.to_string(), rest.trim())),
219        None => Ok((s.to_string(), "")),
220    }
221}
222
223/// A source column as `DESCRIBE` reports it.
224#[derive(Debug, Clone, PartialEq)]
225pub struct Column {
226    /// Its name.
227    pub name: String,
228    /// Its DuckDB type (`INTEGER`, `DECIMAL(10,2)`, `VARCHAR`, …).
229    pub ty: String,
230}
231
232/// The spec bound to the source's columns.
233#[derive(Debug, Clone, PartialEq)]
234pub struct Bound {
235    /// The source column of each category, in declaration order.
236    pub dims: Vec<String>,
237    /// The source column and value type of each input, in declaration order.
238    pub inputs: Vec<(String, ValueType)>,
239}
240
241/// The PostgreSQL spelling [`pm::bound_value_type`] understands for a DuckDB
242/// column type; others pass through and are refused there.
243fn pg_type(ty: &str) -> &str {
244    let base = ty.split('(').next().unwrap_or(ty).trim();
245    match base {
246        "TINYINT" | "SMALLINT" | "INTEGER" | "BIGINT" | "HUGEINT" | "UTINYINT" | "USMALLINT"
247        | "UINTEGER" | "UBIGINT" | "UHUGEINT" | "FLOAT" | "DOUBLE" | "DECIMAL" => "float8",
248        "BOOLEAN" => "bool",
249        "VARCHAR" => "text",
250        _ => ty,
251    }
252}
253
254/// Resolve the spec's columns against the source (DuckDB names are
255/// case-insensitive) and type its inputs: numbers, booleans and text; `sum`
256/// and `avg` need a number, `count` is a number.
257pub fn bind(spec: &Spec, columns: &[Column]) -> Result<Bound> {
258    let find = |line: usize, want: &str| {
259        columns
260            .iter()
261            .find(|c| c.name.eq_ignore_ascii_case(want))
262            .ok_or_else(|| {
263                let have: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
264                at(
265                    line,
266                    format!(
267                        "the source has no column {want:?} (it has: {})",
268                        have.join(", ")
269                    ),
270                )
271            })
272    };
273    let dims = spec
274        .categories
275        .iter()
276        .map(|c| find(c.line, &c.column).map(|col| col.name.clone()))
277        .collect::<Result<_>>()?;
278    let inputs = spec
279        .inputs
280        .iter()
281        .map(|i| {
282            let col = find(i.line, &i.column)?;
283            let vt = pm::bound_value_type(pg_type(&col.ty), &col.ty, i.agg.as_deref())
284                .map_err(|e| at(i.line, format!("{}: {e}", i.name)))?;
285            Ok((col.name.clone(), vt))
286        })
287        .collect::<Result<_>>()?;
288    Ok(Bound { dims, inputs })
289}
290
291/// The source in a `FROM`: a query (`SELECT`, `WITH`, `FROM`, `VALUES`, or
292/// anything parenthesized) as a subquery, else a table or view name as
293/// written. A trailing `;` is dropped.
294pub fn source_sql(source: &str) -> String {
295    let s = source.trim().trim_end_matches(';').trim_end();
296    let first = s
297        .split(|c: char| !c.is_ascii_alphabetic())
298        .next()
299        .unwrap_or("")
300        .to_ascii_lowercase();
301    if s.starts_with('(') || matches!(first.as_str(), "select" | "with" | "from" | "values") {
302        format!("({s}) AS improv_source")
303    } else {
304        s.to_string()
305    }
306}
307
308/// The SQL listing the source's columns and types.
309pub fn describe_sql(source: &str) -> String {
310    format!("DESCRIBE SELECT * FROM {}", source_sql(source))
311}
312
313/// The SQL listing a category's items: its column's distinct non-NULL values
314/// as text, in the column's own order.
315pub fn items_sql(source: &str, column: &str) -> String {
316    let c = pm::quote_ident(column);
317    format!(
318        "SELECT CAST({c} AS VARCHAR) FROM {} WHERE {c} IS NOT NULL GROUP BY {c} ORDER BY {c}",
319        source_sql(source)
320    )
321}
322
323/// The SQL reading input `i`'s rows: every category column as text, then the
324/// value as a `DOUBLE`, `BOOLEAN` or `VARCHAR`, aggregated per coordinate
325/// when the input has an aggregation.
326pub fn input_sql(source: &str, spec: &Spec, bound: &Bound, i: usize) -> Result<String> {
327    let (col, vt) = &bound.inputs[i];
328    pm::select_bound_sql(
329        &source_sql(source),
330        &bound.dims,
331        col,
332        spec.inputs[i].agg.as_deref(),
333        *vt,
334    )
335    .map_err(|e| at(spec.inputs[i].line, e))
336}
337
338/// One fact row: the category values (in declaration order) and the value.
339pub type FactRow = (Vec<Option<String>>, Option<CellValue>);
340
341/// One result row of `improv(source, model)`.
342#[derive(Debug, Clone, PartialEq)]
343pub struct Row {
344    /// The derived measure.
345    pub measure: String,
346    /// One item per category (declaration order); `None` where the measure
347    /// does not range over that category.
348    pub dims: Vec<Option<String>>,
349    /// The value when it is a number.
350    pub value: Option<f64>,
351    /// The value as text: numbers to 15 significant digits, errors as their
352    /// code (`#DIV/0!`).
353    pub text: String,
354}
355
356/// Build the model from the spec, its categories' items (from
357/// [`items_sql`], per category) and its inputs' rows (from [`input_sql`],
358/// per input), evaluate every derived measure, and return one row per
359/// derived cell: measures in declaration order, each one's cells in item
360/// order.
361pub fn run(
362    spec: &Spec,
363    bound: &Bound,
364    items: &[Vec<String>],
365    facts: Vec<Vec<FactRow>>,
366) -> Result<Vec<Row>> {
367    let mut catalog = pm::Catalog::default();
368    let mut next_item = 0;
369    for (c, (cat, names)) in (1..).zip(spec.categories.iter().zip(items)) {
370        catalog.categories.push(pm::CategoryRow {
371            id: c,
372            name: cat.name.clone(),
373        });
374        for name in names {
375            next_item += 1;
376            catalog.items.push(pm::ItemRow {
377                id: next_item,
378                category_id: c,
379                name: name.clone(),
380            });
381        }
382    }
383    let cats: Vec<u32> = (1..=spec.categories.len() as u32).collect();
384    for (m, (input, (_, vt))) in (1..).zip(spec.inputs.iter().zip(&bound.inputs)) {
385        let mut row = pm::MeasureRow::input(m, &input.name, cats.clone());
386        row.value_type = pm::value_type_name(*vt).into();
387        catalog.measures.push(row);
388    }
389    let model_err = |e: pm::Error| Error::Model(e.to_string());
390    let mut model = pm::build_model(&catalog).map_err(model_err)?;
391    let mut targets = Vec::with_capacity(spec.derived.len());
392    for (m, d) in (spec.inputs.len() as u32 + 1..).zip(&spec.derived) {
393        let row = pm::define(&model, &d.text, m).map_err(|e| at(d.line, e))?;
394        if row.categories.is_empty() {
395            return Err(at(
396                d.line,
397                format!(
398                    "{} has no dimensions; dimensionless results are not supported",
399                    row.name
400                ),
401            ));
402        }
403        catalog.measures.push(row);
404        model = pm::build_model(&catalog).map_err(|e| at(d.line, e))?;
405        targets.push(MeasureId(m));
406    }
407    let bound_rows: Vec<pm::BoundRows> = (1..)
408        .zip(facts)
409        .map(|(measure_id, rows)| pm::BoundRows { measure_id, rows })
410        .collect();
411    pm::load_inputs(&mut model, &bound_rows).map_err(|e| match e {
412        pm::Error::DuplicateCoordinate { measure, coord } => {
413            let line = spec
414                .inputs
415                .iter()
416                .find(|i| i.name == measure)
417                .map_or(0, |i| i.line);
418            at(
419                line,
420                format!(
421                    "{measure}: two source rows have the coordinate ({}); add an aggregation \
422                     (sum, avg, min, max, count or count_distinct) to the input line",
423                    coord.join(", ")
424                ),
425            )
426        }
427        other => model_err(other),
428    })?;
429    let out = improv_engine::dataflow::evaluate(&model, &targets)
430        .map_err(|e| Error::Model(e.to_string()))?;
431    let empty = HashMap::new();
432    let mut rows = Vec::new();
433    for t in targets {
434        let name = &model.measures[&t].name.0;
435        let mut cells: Vec<(&CoordKey, &CellValue)> =
436            out.get(&t).unwrap_or(&empty).iter().collect();
437        // Items are numbered in each category's order, so ids sort by it.
438        cells.sort_by_key(|(k, _)| cats.iter().map(|c| item_of(k, *c)).collect::<Vec<_>>());
439        for (key, v) in cells {
440            let dims = cats
441                .iter()
442                .map(|c| item_of(key, *c).map(|i| catalog.items[i as usize - 1].name.clone()))
443                .collect();
444            rows.push(Row {
445                measure: name.clone(),
446                dims,
447                value: calc::number(v),
448                text: v.to_string(),
449            });
450        }
451    }
452    Ok(rows)
453}
454
455fn item_of(key: &CoordKey, cat: u32) -> Option<u32> {
456    key.iter().find(|(c, _)| *c == cat).map(|(_, i)| *i)
457}
458
459/// One row of `improv_functions()`.
460#[derive(Debug, Clone, PartialEq)]
461pub struct FunctionRow {
462    /// Canonical (Excel) name.
463    pub name: &'static str,
464    /// Reference-page category: `math`, `financial`, `text`, ….
465    pub category: &'static str,
466    /// `PMT(rate, nper, pv, [fv], [type])`.
467    pub signature: String,
468    /// `number | boolean | text | date | any`.
469    pub returns: &'static str,
470    /// One-line description.
471    pub description: &'static str,
472    /// Other spellings, comma-separated; `None` when there are none.
473    pub aliases: Option<String>,
474}
475
476fn category_name(c: Category) -> &'static str {
477    match c {
478        Category::Aggregation => "aggregation",
479        Category::Logical => "logical",
480        Category::Information => "information",
481        Category::Math => "math",
482        Category::Rounding => "rounding",
483        Category::Text => "text",
484        Category::DateTime => "datetime",
485        Category::Statistical => "statistical",
486        Category::Financial => "financial",
487        Category::Dimensional => "dimensional",
488        Category::Engineering => "engineering",
489    }
490}
491
492fn returns(r: Ret) -> &'static str {
493    match r {
494        Ret::Fixed(ValueType::Number) => "number",
495        Ret::Fixed(ValueType::Boolean) => "boolean",
496        Ret::Fixed(ValueType::Text) => "text",
497        Ret::Fixed(ValueType::DateTime) => "date",
498        Ret::Fixed(ValueType::Enum) | Ret::Unify | Ret::Arg(_) => "any",
499    }
500}
501
502fn function_row(d: &FnDef) -> FunctionRow {
503    FunctionRow {
504        name: d.name,
505        category: category_name(d.category),
506        signature: d.hint(),
507        returns: returns(d.ret),
508        description: d.doc,
509        aliases: (!d.aliases.is_empty()).then(|| d.aliases.join(", ")),
510    }
511}
512
513/// The built-in functions in registry order; with `query`, those
514/// [`functions::search`] finds, best match first.
515pub fn function_rows(query: Option<&str>) -> Vec<FunctionRow> {
516    match query {
517        None => functions::all().map(function_row).collect(),
518        Some(q) => functions::search(q).into_iter().map(function_row).collect(),
519    }
520}
521
522#[cfg(test)]
523#[cfg_attr(coverage_nightly, coverage(off))]
524mod tests;