1#![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;
34pub use improv_core_model::ValueType;
36pub use improv_engine::CellValue;
38use improv_engine::CoordKey;
39use improv_pg_model as pm;
40
41#[derive(Debug, Clone, PartialEq, thiserror::Error)]
43pub enum Error {
44 #[error("improv: model line {line}: {message}")]
46 Line {
47 line: usize,
49 message: String,
51 },
52 #[error("improv: {0}")]
54 Model(String),
55}
56
57pub 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#[derive(Debug, Clone, PartialEq)]
69pub struct CategorySpec {
70 pub name: String,
72 pub column: String,
74 pub line: usize,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct InputSpec {
81 pub name: String,
83 pub column: String,
85 pub agg: Option<String>,
88 pub line: usize,
90}
91
92#[derive(Debug, Clone, PartialEq)]
94pub struct DerivedSpec {
95 pub text: String,
97 pub line: usize,
99}
100
101#[derive(Debug, Clone, Default, PartialEq)]
103pub struct Spec {
104 pub categories: Vec<CategorySpec>,
106 pub inputs: Vec<InputSpec>,
108 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
116const RESERVED: [&str; 3] = ["measure", "value", "value_text"];
118
119pub 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
196fn 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#[derive(Debug, Clone, PartialEq)]
225pub struct Column {
226 pub name: String,
228 pub ty: String,
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub struct Bound {
235 pub dims: Vec<String>,
237 pub inputs: Vec<(String, ValueType)>,
239}
240
241fn 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
254pub 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
291pub 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
308pub fn describe_sql(source: &str) -> String {
310 format!("DESCRIBE SELECT * FROM {}", source_sql(source))
311}
312
313pub 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
323pub 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
338pub type FactRow = (Vec<Option<String>>, Option<CellValue>);
340
341#[derive(Debug, Clone, PartialEq)]
343pub struct Row {
344 pub measure: String,
346 pub dims: Vec<Option<String>>,
349 pub value: Option<f64>,
351 pub text: String,
354}
355
356pub 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 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#[derive(Debug, Clone, PartialEq)]
461pub struct FunctionRow {
462 pub name: &'static str,
464 pub category: &'static str,
466 pub signature: String,
468 pub returns: &'static str,
470 pub description: &'static str,
472 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
513pub 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;