Skip to main content

improv_duckdb_model/
calc.rs

1//! `improv_calc(formula [, inputs])`: one scalar Improv formula over named
2//! inputs, the engine's own per-cell operators ([`improv_engine::scalar`]),
3//! as `LANGUAGE improv` evaluates a function body.
4//!
5//! Inputs are a JSON object. Each key is an input measure; its value's JSON
6//! type gives the measure's type (number, boolean, text; a JSON `null` is a
7//! missing input, which makes a result that depends on it NULL). Compiled
8//! formulas are cached by formula text and input names *and types*, so a
9//! column of rows with the same shape compiles once.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14use improv_core_model::{parser, Expr, Measure, MeasureId, MeasureKind, Model, Name, ValueType};
15use improv_engine::compiler::{infer, CompileContext};
16use improv_engine::{scalar, CellValue};
17
18/// A formula compiled against its inputs' names and types.
19#[derive(Debug)]
20pub struct Compiled {
21    /// Input `i` (in key order) is `MeasureId(i + 1)`.
22    names: Vec<String>,
23    expr: Expr,
24}
25
26/// An input value as the formula reads it; `None` is a missing input.
27type Input = Option<CellValue>;
28
29/// The inputs of one call, by name, in key order.
30pub type Inputs = Vec<(String, Input)>;
31
32/// Parse a JSON object of inputs. A SQL NULL `inputs` argument is no inputs.
33/// Keys keep their order; a nested array or object is an error.
34pub fn parse_inputs(json: Option<&str>) -> Result<Inputs, String> {
35    use serde_json::Value as J;
36    let Some(text) = json.map(str::trim).filter(|t| !t.is_empty()) else {
37        return Ok(Vec::new());
38    };
39    let J::Object(map) = serde_json::from_str::<J>(text)
40        .map_err(|e| format!("improv_calc: inputs are not valid JSON: {e}"))?
41    else {
42        return Err("improv_calc: inputs must be a JSON object like {\"rate\": 0.05}".into());
43    };
44    map.into_iter()
45        .map(|(k, v)| {
46            let v = match v {
47                J::Null => None,
48                J::Bool(b) => Some(CellValue::Bool(b)),
49                J::Number(n) => Some(CellValue::num(n.as_f64().unwrap_or(f64::NAN))),
50                J::String(s) => Some(CellValue::Text(s)),
51                J::Array(_) | J::Object(_) => {
52                    return Err(format!(
53                        "improv_calc: input {k:?} is a JSON array or object; inputs are scalars"
54                    ))
55                }
56            };
57            Ok((k, v))
58        })
59        .collect()
60}
61
62/// The type an input is declared with: a missing one is a number (the
63/// commonest case; `IF(ISBLANK(x), …)` still works on it).
64fn input_type(v: &Input) -> ValueType {
65    match v {
66        Some(CellValue::Bool(_)) => ValueType::Boolean,
67        Some(CellValue::Text(_)) => ValueType::Text,
68        Some(CellValue::Date(_)) => ValueType::DateTime,
69        _ => ValueType::Number,
70    }
71}
72
73/// Compile `formula` with these inputs' names and types.
74pub fn compile(formula: &str, inputs: &Inputs) -> Result<Compiled, String> {
75    let mut model = Model::new();
76    for (i, (name, v)) in inputs.iter().enumerate() {
77        model.add_measure(Measure {
78            id: MeasureId(i as u32 + 1),
79            name: Name(name.clone()),
80            value_type: input_type(v),
81            categories: vec![],
82            kind: MeasureKind::Input,
83            description: None,
84        });
85    }
86    let text = formula.trim();
87    let text = text.strip_prefix('=').unwrap_or(text);
88    let expr = parser::parse_expr(&model, text)
89        .map_err(|e| format!("improv_calc: {e}"))?
90        .expr;
91    scalar::check(&expr).map_err(|e| format!("improv_calc: {e}"))?;
92    infer(&CompileContext::new(&model.measures), MeasureId(0), &expr)
93        .map_err(|e| format!("improv_calc: {e}"))?;
94    Ok(Compiled {
95        names: inputs.iter().map(|(n, _)| n.clone()).collect(),
96        expr,
97    })
98}
99
100impl Compiled {
101    /// Evaluate on `inputs` (the shape it was compiled for). `Ok(None)`:
102    /// the result depends on a missing input.
103    pub fn eval(&self, inputs: &Inputs) -> Result<Option<CellValue>, String> {
104        debug_assert_eq!(inputs.len(), self.names.len());
105        let env: HashMap<MeasureId, CellValue> = (1..)
106            .zip(inputs)
107            .filter_map(|(i, (_, v))| v.clone().map(|v| (MeasureId(i), v)))
108            .collect();
109        scalar::eval(&self.expr, &env).map_err(|e| format!("improv_calc: {e}"))
110    }
111}
112
113/// The cache key: formula text, then each input's name and type.
114type Key = (String, Vec<(String, &'static str)>);
115
116/// A bounded cache of compiled formulas, shared by every thread.
117#[derive(Debug)]
118pub struct Cache {
119    max: usize,
120    map: Mutex<HashMap<Key, Arc<Compiled>>>,
121}
122
123impl Default for Cache {
124    fn default() -> Self {
125        Cache::new(1024)
126    }
127}
128
129impl Cache {
130    /// A cache of at most `max` formulas.
131    pub fn new(max: usize) -> Self {
132        Cache {
133            max,
134            map: Mutex::new(HashMap::new()),
135        }
136    }
137
138    /// Formulas cached now.
139    pub fn len(&self) -> usize {
140        self.lock().len()
141    }
142
143    /// Whether nothing is cached.
144    pub fn is_empty(&self) -> bool {
145        self.len() == 0
146    }
147
148    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<Key, Arc<Compiled>>> {
149        self.map.lock().unwrap_or_else(|e| e.into_inner())
150    }
151
152    /// The compiled `formula` for inputs of this shape, compiling it on a
153    /// miss. A compile error is not cached.
154    pub fn get(&self, formula: &str, inputs: &Inputs) -> Result<Arc<Compiled>, String> {
155        let key: Key = (
156            formula.to_string(),
157            inputs
158                .iter()
159                .map(|(n, v)| (n.clone(), improv_pg_model::value_type_name(input_type(v))))
160                .collect(),
161        );
162        if let Some(c) = self.lock().get(&key) {
163            return Ok(c.clone());
164        }
165        // Compile outside the lock: other threads keep evaluating.
166        let c = Arc::new(compile(formula, inputs)?);
167        let mut map = self.lock();
168        // ponytail: clear-all when full, not LRU; a working set larger than
169        // `max` recompiles often. Make it LRU if that shows up in profiles.
170        if map.len() >= self.max {
171            map.clear();
172        }
173        map.insert(key, c.clone());
174        Ok(c)
175    }
176
177    /// `improv_calc_text`'s value: any type as text, an error as its code;
178    /// `None` is SQL NULL. A formula that does not compile is an `Err`.
179    pub fn calc(&self, formula: &str, inputs: Option<&str>) -> Result<Option<CellValue>, String> {
180        let inputs = parse_inputs(inputs)?;
181        self.get(formula, &inputs)?.eval(&inputs)
182    }
183}
184
185/// `improv_calc`'s value: a number, a boolean as 1 or 0, a date as its
186/// spreadsheet serial (days since 1899-12-30); text and errors are NULL.
187pub fn number(v: &CellValue) -> Option<f64> {
188    const MS_PER_DAY: f64 = 86_400_000.0;
189    const EPOCH_SERIAL: f64 = 25_569.0; // 1970-01-01
190    match v {
191        CellValue::Num(_) => v.as_num(),
192        CellValue::Bool(b) => Some(f64::from(u8::from(*b))),
193        CellValue::Date(ms) => Some(*ms as f64 / MS_PER_DAY + EPOCH_SERIAL),
194        CellValue::Text(_) | CellValue::Err(_) => None,
195    }
196}