1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
26#![cfg_attr(coverage_nightly, allow(unused_features))]
27
28use improv_core_model::{
29 parse_definition, parse_expr, CategoryId, Coordinate, Definition, ExternalCall, ExternalFn,
30 Formula, ItemId, Measure, MeasureId, MeasureKind, Model, Name, Scenario, ScenarioId, SqlSource,
31 Value, ValueType,
32};
33use postgres::config::{Host, SslMode};
34use postgres::{Client, Config, GenericClient, IsolationLevel, NoTls};
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36
37#[cfg(test)]
38mod tests;
39
40pub const CATALOG_SQL: &str = r#"
46CREATE SCHEMA IF NOT EXISTS improv;
47CREATE TABLE IF NOT EXISTS improv.model (
48 id bigserial PRIMARY KEY,
49 name text NOT NULL UNIQUE,
50 sync_mode text NOT NULL DEFAULT 'logical'
51 CHECK (sync_mode IN ('logical', 'trigger', 'commit', 'manual')),
52 slot_name text,
53 created timestamptz NOT NULL DEFAULT now()
54);
55CREATE TABLE IF NOT EXISTS improv.category (
56 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
57 id bigint NOT NULL,
58 name text NOT NULL,
59 PRIMARY KEY (model_id, id)
60);
61CREATE TABLE IF NOT EXISTS improv.item (
62 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
63 id bigint NOT NULL,
64 category_id bigint NOT NULL,
65 name text NOT NULL,
66 PRIMARY KEY (model_id, id)
67);
68CREATE TABLE IF NOT EXISTS improv.measure (
69 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
70 id bigint NOT NULL,
71 name text NOT NULL,
72 value_type text NOT NULL,
73 kind text NOT NULL CHECK (kind IN ('input', 'derived')),
74 formula text,
75 description text,
76 categories bigint[] NOT NULL DEFAULT '{}',
77 PRIMARY KEY (model_id, id)
78);
79CREATE TABLE IF NOT EXISTS improv.binding (
80 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
81 measure_id bigint NOT NULL,
82 rel regclass NOT NULL,
83 value_col text NOT NULL,
84 dim_cols jsonb NOT NULL,
85 PRIMARY KEY (model_id, measure_id)
86);
87CREATE TABLE IF NOT EXISTS improv.cell (
88 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
89 measure_id bigint NOT NULL,
90 coord jsonb NOT NULL,
91 value jsonb NOT NULL,
92 PRIMARY KEY (model_id, measure_id, coord)
93);
94CREATE TABLE IF NOT EXISTS improv.view (
95 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
96 id bigint NOT NULL,
97 name text NOT NULL,
98 layout jsonb NOT NULL,
99 PRIMARY KEY (model_id, id)
100);
101CREATE TABLE IF NOT EXISTS improv.meta (
102 model_id bigint PRIMARY KEY REFERENCES improv.model(id) ON DELETE CASCADE,
103 blob jsonb NOT NULL
104);
105CREATE TABLE IF NOT EXISTS improv.model_version (
106 model_id bigint PRIMARY KEY REFERENCES improv.model(id) ON DELETE CASCADE,
107 tx bigint NOT NULL DEFAULT 0
108);
109CREATE TABLE IF NOT EXISTS improv.lambda (
110 model_id bigint NOT NULL REFERENCES improv.model(id) ON DELETE CASCADE,
111 id bigint NOT NULL,
112 name text NOT NULL,
113 definition text NOT NULL,
114 PRIMARY KEY (model_id, id)
115);
116-- Shared with the pg_improv extension (design §9.2): the same DDL in
117-- idempotent form (this runs against an existing database); the extension
118-- uses plain CREATE. Both pin mint_items' search_path.
119ALTER TABLE improv.binding ADD COLUMN IF NOT EXISTS agg text
120 CHECK (agg IN ('sum', 'avg', 'min', 'max', 'count', 'count_distinct'));
121CREATE UNIQUE INDEX IF NOT EXISTS item_name_uniq ON improv.item (model_id, category_id, name);
122CREATE OR REPLACE FUNCTION improv.mint_items(p_model bigint, p_cats bigint[], p_names text[])
123RETURNS TABLE (category_id bigint, name text, id bigint) LANGUAGE plpgsql
124SET search_path = pg_catalog, pg_temp AS $$
125BEGIN
126 PERFORM 1 FROM improv.model m WHERE m.id = p_model FOR UPDATE;
127 INSERT INTO improv.item (model_id, id, category_id, name)
128 SELECT p_model,
129 (SELECT coalesce(max(i.id), 0) FROM improv.item i WHERE i.model_id = p_model)
130 + row_number() OVER (ORDER BY s.c, s.n),
131 s.c, s.n
132 FROM (SELECT DISTINCT u.c, u.n FROM unnest(p_cats, p_names) AS u(c, n)
133 WHERE NOT EXISTS (SELECT 1 FROM improv.item i
134 WHERE i.model_id = p_model AND i.category_id = u.c AND i.name = u.n)) s
135 ON CONFLICT DO NOTHING;
136 RETURN QUERY
137 SELECT i.category_id, i.name, i.id FROM improv.item i
138 JOIN (SELECT DISTINCT u.c, u.n FROM unnest(p_cats, p_names) AS u(c, n)) w
139 ON i.model_id = p_model AND i.category_id = w.c AND i.name = w.n;
140END $$;
141"#;
142
143#[derive(Debug, thiserror::Error)]
144pub enum StoreError {
145 #[error("mentat error: {0}")]
148 Mentat(String),
149 #[error("serialization error: {0}")]
150 Serde(#[from] serde_json::Error),
151 #[error("data integrity: {0}")]
152 Integrity(String),
153 #[error("conflict: {0}")]
155 Conflict(String),
156 #[error("postgres error: {0}")]
157 Pg(String),
158}
159
160pub type Result<T> = std::result::Result<T, StoreError>;
161
162impl From<postgres::Error> for StoreError {
163 fn from(e: postgres::Error) -> Self {
164 StoreError::Pg(match e.as_db_error() {
165 Some(db) => db.message().to_string(),
166 None => e.to_string(),
167 })
168 }
169}
170
171const CONFLICT_MSG: &str =
172 "the model was changed by another writer since it was loaded; reload and retry";
173
174pub fn split_model_url(url: &str) -> Result<(String, String)> {
177 let (base, query) = url.split_once('?').unwrap_or((url, ""));
178 let mut model = None;
179 let mut rest = Vec::new();
180 for p in query.split('&').filter(|p| !p.is_empty()) {
181 match p.strip_prefix("model=") {
182 Some(m) => model = Some(m.to_string()),
183 None => rest.push(p),
184 }
185 }
186 let model = model.filter(|m| !m.is_empty()).ok_or_else(|| {
187 StoreError::Pg(
188 "a postgres model URL needs ?model=<name> (e.g. postgres://me@db/sales?model=plan)"
189 .into(),
190 )
191 })?;
192 let conn = if rest.is_empty() {
193 base.to_string()
194 } else {
195 format!("{base}?{}", rest.join("&"))
196 };
197 Ok((conn, model))
198}
199
200fn check_transport(config: &Config) -> Result<()> {
203 let no_tls = |why: &str| {
204 Err(StoreError::Pg(format!(
205 "TLS isn't built into this build of Improv; {why}"
206 )))
207 };
208 let mode = config.get_ssl_mode();
209 if mode == SslMode::Require {
210 return no_tls("sslmode=require cannot be honoured (use an SSH tunnel to a loopback port)");
211 }
212 let local = config.get_hosts().iter().all(|h| match h {
213 Host::Tcp(name) => {
214 name.eq_ignore_ascii_case("localhost")
215 || name
216 .parse::<std::net::IpAddr>()
217 .is_ok_and(|ip| ip.is_loopback())
218 }
219 #[cfg(unix)]
220 Host::Unix(_) => true,
221 }) && config.get_hostaddrs().iter().all(|ip| ip.is_loopback());
222 if !local && mode != SslMode::Disable {
223 return no_tls(
224 "refusing a non-loopback host without an explicit sslmode=disable \
225 (the password would cross the network in cleartext)",
226 );
227 }
228 Ok(())
229}
230
231fn ident(s: &str) -> String {
233 format!("\"{}\"", s.replace('"', "\"\""))
234}
235
236fn id32(v: i64) -> Result<u32> {
237 u32::try_from(v).map_err(|_| StoreError::Integrity(format!("id {v} is out of range")))
238}
239
240fn vt_name(vt: ValueType) -> &'static str {
241 match vt {
242 ValueType::Number => "number",
243 ValueType::Boolean => "boolean",
244 ValueType::Text => "text",
245 ValueType::DateTime => "datetime",
246 ValueType::Enum => "enum",
247 }
248}
249
250fn vt_parse(s: &str) -> Result<ValueType> {
251 Ok(match s {
252 "number" => ValueType::Number,
253 "boolean" => ValueType::Boolean,
254 "text" => ValueType::Text,
255 "datetime" => ValueType::DateTime,
256 "enum" => ValueType::Enum,
257 other => {
258 return Err(StoreError::Integrity(format!(
259 "unknown value type {other:?}"
260 )))
261 }
262 })
263}
264
265fn value_to_json(v: &Value) -> Result<serde_json::Value> {
268 Ok(match v {
269 Value::Number(n) if n.fract() == 0.0 && n.abs() < 9.0e15 => (*n as i64).into(),
271 Value::Number(n) => serde_json::json!(n),
272 Value::Boolean(b) => (*b).into(),
273 Value::Text(s) => s.as_str().into(),
274 Value::DateTime(d) => d.to_rfc3339().into(),
275 Value::Enum(e) => (*e).into(),
276 Value::Error(e) => {
277 return Err(StoreError::Integrity(format!(
278 "an error value cannot be stored as an input ({e:?})"
279 )))
280 }
281 })
282}
283
284fn json_to_value(vt: ValueType, j: &serde_json::Value) -> Result<Value> {
285 use serde_json::Value as J;
286 Ok(match (vt, j) {
287 (ValueType::Number, J::Number(n)) => Value::Number(n.as_f64().unwrap_or(f64::NAN)),
288 (ValueType::Enum, J::Number(n)) if n.as_u64().is_some_and(|n| n <= u32::MAX as u64) => {
289 Value::Enum(n.as_u64().unwrap_or_default() as u32)
290 }
291 (ValueType::Boolean, J::Bool(b)) => Value::Boolean(*b),
292 (ValueType::DateTime, J::String(s)) => match chrono::DateTime::parse_from_rfc3339(s) {
293 Ok(dt) => Value::DateTime(dt.with_timezone(&chrono::Utc)),
294 Err(_) => Value::Text(s.clone()),
295 },
296 (ValueType::Text, J::String(s)) => Value::Text(s.clone()),
297 (ValueType::Text, other) => Value::Text(other.to_string()),
298 _ => {
299 return Err(StoreError::Integrity(format!(
300 "{j} is not a {} value",
301 vt_name(vt)
302 )))
303 }
304 })
305}
306
307fn agg_call(agg: &str, v: &str, vt: ValueType) -> Result<String> {
311 match (agg, vt) {
312 ("min", ValueType::Boolean) => Ok(format!("bool_and({v})")),
313 ("max", ValueType::Boolean) => Ok(format!("bool_or({v})")),
314 ("sum" | "avg" | "min" | "max" | "count", _) => Ok(format!("{agg}({v})")),
315 ("count_distinct", _) => Ok(format!("count(DISTINCT {v})")),
316 _ => Err(StoreError::Integrity(format!(
317 "unknown binding agg {agg:?}"
318 ))),
319 }
320}
321
322fn value_type_of(model: &Model, m: MeasureId) -> Result<ValueType> {
323 model
324 .measures
325 .get(&m)
326 .map(|m| m.value_type)
327 .ok_or_else(|| StoreError::Integrity(format!("cell for unknown measure {}", m.0)))
328}
329
330fn parse_formula_text(
333 model: &Model,
334 text: &str,
335) -> std::result::Result<Formula, improv_core_model::ParseError> {
336 match parse_definition(model, text) {
337 Ok(Definition::Formula(ft)) => Ok(ft.formula),
338 _ => parse_expr(model, text),
339 }
340}
341
342#[derive(serde::Deserialize, Default)]
344struct Meta {
345 #[serde(default)]
346 external_fns: HashMap<String, ExternalFn>,
347 #[serde(default)]
348 external_calls: HashMap<MeasureId, ExternalCall>,
349 #[serde(default)]
350 scenarios: HashMap<ScenarioId, Scenario>,
351 #[serde(default)]
352 sql_sources: HashMap<MeasureId, SqlSource>,
353 #[serde(default)]
354 lambdas: std::collections::BTreeMap<u32, improv_core_model::NamedLambda>,
355}
356
357struct Binding {
359 measure: MeasureId,
360 table: String,
362 value_col: String,
363 dims: Vec<(CategoryId, String)>,
365 agg: Option<String>,
368}
369
370fn bindings(c: &mut impl GenericClient, model: &Model, mid: i64) -> Result<Vec<Binding>> {
371 let rows = c.query(
372 "SELECT b.measure_id, n.nspname::text, c.relname::text, b.value_col, b.dim_cols::text, \
373 b.agg \
374 FROM improv.binding b JOIN pg_class c ON c.oid = b.rel \
375 JOIN pg_namespace n ON n.oid = c.relnamespace \
376 WHERE b.model_id = $1 ORDER BY b.measure_id",
377 &[&mid],
378 )?;
379 rows.iter()
380 .map(|r| {
381 let cols: BTreeMap<String, String> = serde_json::from_str(r.get(4))?;
382 let dims = cols
383 .into_iter()
384 .map(|(cat, col)| {
385 model
386 .category_by_name(&cat)
387 .map(|c| (c.id, col))
388 .ok_or_else(|| {
389 StoreError::Integrity(format!("binding names unknown category {cat:?}"))
390 })
391 })
392 .collect::<Result<_>>()?;
393 Ok(Binding {
394 measure: MeasureId(id32(r.get(0))?),
395 table: format!("{}.{}", ident(r.get(1)), ident(r.get(2))),
396 value_col: r.get(3),
397 dims,
398 agg: r.get(5),
399 })
400 })
401 .collect()
402}
403
404fn version(c: &mut impl GenericClient, mid: i64, lock: bool) -> Result<i64> {
405 let sql = if lock {
406 "SELECT tx FROM improv.model_version WHERE model_id = $1 FOR UPDATE"
407 } else {
408 "SELECT tx FROM improv.model_version WHERE model_id = $1"
409 };
410 Ok(c.query_one(sql, &[&mid])?.get(0))
411}
412
413#[derive(Debug, Clone)]
417pub struct PgSnapshot {
418 bound: HashMap<(MeasureId, Coordinate), Value>,
420 cells: HashMap<(MeasureId, Coordinate), Value>,
422 ids: HashMap<&'static str, HashSet<i64>>,
425 lambdas: BTreeMap<u32, improv_core_model::NamedLambda>,
427 names: HashMap<ItemId, String>,
429}
430
431impl PgSnapshot {
432 fn new(model: &Model, cells: HashMap<(MeasureId, Coordinate), Value>) -> Self {
434 let bound = model
435 .inputs
436 .iter()
437 .filter(|(k, _)| !cells.contains_key(*k))
438 .map(|(k, v)| (k.clone(), v.clone()))
439 .collect();
440 let ids = HashMap::from([
441 (
442 "category",
443 model.categories.keys().map(|i| i64::from(i.0)).collect(),
444 ),
445 ("item", model.items.keys().map(|i| i64::from(i.0)).collect()),
446 (
447 "measure",
448 model.measures.keys().map(|i| i64::from(i.0)).collect(),
449 ),
450 ("view", model.views.keys().map(|i| i64::from(i.0)).collect()),
451 (
452 "lambda",
453 model.lambdas.keys().map(|i| i64::from(*i)).collect(),
454 ),
455 ]);
456 let names = model
457 .items
458 .values()
459 .map(|i| (i.id, i.name.0.clone()))
460 .collect();
461 PgSnapshot {
462 bound,
463 cells,
464 ids,
465 lambdas: model.lambdas.clone(),
466 names,
467 }
468 }
469}
470
471fn delete_dropped(
475 tx: &mut impl GenericClient,
476 mid: i64,
477 table: &str,
478 keep: &[i64],
479 loaded: Option<&HashSet<i64>>,
480) -> Result<()> {
481 let Some(loaded) = loaded else {
482 tx.execute(
483 &format!("DELETE FROM improv.{table} WHERE model_id = $1 AND id <> ALL($2)"),
484 &[&mid, &keep],
485 )?;
486 return Ok(());
487 };
488 let keep: HashSet<&i64> = keep.iter().collect();
489 let gone: Vec<i64> = loaded
490 .iter()
491 .filter(|i| !keep.contains(i))
492 .copied()
493 .collect();
494 if !gone.is_empty() {
495 tx.execute(
496 &format!("DELETE FROM improv.{table} WHERE model_id = $1 AND id = ANY($2)"),
497 &[&mid, &gone],
498 )?;
499 }
500 Ok(())
501}
502
503fn write_bound(
515 tx: &mut impl GenericClient,
516 model: &Model,
517 b: &Binding,
518 base: Option<&PgSnapshot>,
519) -> Result<()> {
520 let Some(measure) = model.measures.get(&b.measure) else {
522 return Ok(());
523 };
524 let snap = base.map(|s| &s.bound);
525 if b.agg.is_some() && snap.is_none() {
528 return Ok(());
529 }
530 let mut diff = Vec::new();
532 for (k, v) in &model.inputs {
533 if k.0 != b.measure {
534 continue;
535 }
536 let old = snap.and_then(|s| s.get(k));
537 if snap.is_none() || old != Some(v) {
538 diff.push((&k.1, Some(v), old));
539 }
540 }
541 for (k, v) in snap.into_iter().flatten() {
542 if k.0 == b.measure && !model.inputs.contains_key(k) {
543 diff.push((&k.1, None, Some(v)));
544 }
545 }
546 if diff.is_empty() {
547 return Ok(());
548 }
549 if b.agg.is_some() {
550 return Err(StoreError::Integrity(format!(
551 "aggregated measure {} is read-only",
552 measure.name.0
553 )));
554 }
555 let json = |v: Option<&Value>| v.map_or(Ok(serde_json::Value::Null), value_to_json);
556 let (mut xs, mut labels) = (Vec::new(), Vec::new());
557 for (coord, new, old) in &diff {
558 let mut n = serde_json::Map::new();
559 let mut names = Vec::new();
560 for (cat, col) in &b.dims {
561 let name = coord
562 .get(*cat)
563 .and_then(|i| {
564 let gone = || base.and_then(|s| s.names.get(&i)).map(String::as_str);
565 model.items.get(&i).map(|i| i.name.0.as_str()).or_else(gone)
566 })
567 .ok_or_else(|| {
568 StoreError::Integrity(format!(
569 "a cell of measure {} lacks a {} item",
570 b.measure.0, col
571 ))
572 })?;
573 n.insert(col.clone(), name.into());
574 names.push(name);
575 }
576 n.insert(b.value_col.clone(), json(*new)?);
577 let mut x = serde_json::Map::from_iter([("n".to_string(), n.into())]);
578 if base.is_some() {
579 let o = serde_json::Map::from_iter([(b.value_col.clone(), json(*old)?)]);
580 x.insert("o".into(), o.into());
581 }
582 xs.push(serde_json::Value::Object(x));
583 labels.push(names.join(", "));
584 }
585
586 let (t, v) = (&b.table, ident(&b.value_col));
587 let on = b
588 .dims
589 .iter()
590 .map(|(_, c)| format!("t.{0} = n.{0}", ident(c)))
591 .chain(std::iter::once("true".to_string()))
592 .collect::<Vec<_>>()
593 .join(" AND ");
594 let src = format!(
595 "jsonb_array_elements($1::text::jsonb) WITH ORDINALITY e(x, i), \
596 LATERAL jsonb_populate_record(NULL::{t}, e.x->'n') n"
597 );
598 let guard = match base {
599 Some(_) => {
600 format!("t.{v} IS NOT DISTINCT FROM (jsonb_populate_record(NULL::{t}, e.x->'o')).{v}")
601 }
602 None => format!("t.{v} IS DISTINCT FROM n.{v}"),
603 };
604 let mut hits = vec![0; diff.len()];
606 for r in tx.query(
607 &format!("UPDATE {t} t SET {v} = n.{v} FROM {src} WHERE {on} AND {guard} RETURNING e.i"),
608 &[&serde_json::to_string(&xs)?],
609 )? {
610 hits[r.get::<_, i64>(0) as usize - 1] += 1;
611 }
612
613 let added: Vec<usize> = (0..diff.len())
617 .filter(|&i| hits[i] == 0 && (base.is_none() || diff[i].2.is_none()))
618 .collect();
619 let mut taken = vec![false; diff.len()];
620 if !added.is_empty() {
621 let cols: Vec<String> = b
622 .dims
623 .iter()
624 .map(|(_, c)| ident(c))
625 .chain(std::iter::once(v.clone()))
626 .collect();
627 let ncols: Vec<String> = cols.iter().map(|c| format!("n.{c}")).collect();
628 let sub: Vec<&serde_json::Value> = added.iter().map(|&i| &xs[i]).collect();
629 for r in tx.query(
630 &format!(
631 "WITH ins AS (INSERT INTO {t} ({}) SELECT {} FROM {src} \
632 WHERE NOT EXISTS (SELECT 1 FROM {t} t WHERE {on})) \
633 SELECT e.i FROM {src} WHERE EXISTS (SELECT 1 FROM {t} t WHERE {on})",
634 cols.join(", "),
635 ncols.join(", ")
636 ),
637 &[&serde_json::to_string(&sub)?],
638 )? {
639 taken[added[r.get::<_, i64>(0) as usize - 1]] = true;
640 }
641 }
642 if base.is_none() {
643 return Ok(());
644 }
645 let lost = (0..diff.len())
646 .find(|&i| hits[i] > 1 || (hits[i] == 0 && (diff[i].2.is_some() || taken[i])));
647 match lost {
648 Some(i) => Err(StoreError::Conflict(format!(
649 "bound cell {}[{}] was changed in the database since it was loaded; \
650 reload and retry",
651 measure.name.0, labels[i]
652 ))),
653 None => Ok(()),
654 }
655}
656
657pub struct PgModelStore {
659 client: Client,
660 model_id: i64,
661 loaded_tx: Option<i64>,
664 loaded: Option<PgSnapshot>,
667}
668
669impl PgModelStore {
670 pub fn open(url: &str) -> Result<Self> {
673 let (conn, name) = split_model_url(url)?;
674 let config: Config = conn
675 .parse()
676 .map_err(|_| StoreError::Pg("invalid postgres connection string (redacted)".into()))?;
677 check_transport(&config)?;
678 let mut client = config.connect(NoTls).map_err(|e| {
680 StoreError::Pg(match e.as_db_error() {
681 Some(db) => format!("postgres connect failed: {}", db.message()),
682 None => "postgres connect failed (connection error)".into(),
683 })
684 })?;
685 const INSTALLED: &str =
688 "SELECT to_regprocedure('improv.mint_items(bigint, bigint[], text[])') IS NOT NULL \
689 AND to_regclass('improv.lambda') IS NOT NULL";
690 if !client.query_one(INSTALLED, &[])?.get::<_, bool>(0) {
691 client.execute("SELECT pg_advisory_lock(hashtext('improv.catalog'))", &[])?;
695 if !client.query_one(INSTALLED, &[])?.get::<_, bool>(0) {
696 client.batch_execute(&format!("BEGIN; {CATALOG_SQL} COMMIT;"))?;
697 }
698 client.execute("SELECT pg_advisory_unlock(hashtext('improv.catalog'))", &[])?;
699 }
700 let model_id: i64 = client
701 .query_one(
702 "WITH ins AS (INSERT INTO improv.model(name) VALUES ($1) \
703 ON CONFLICT (name) DO NOTHING RETURNING id) \
704 SELECT id FROM ins UNION ALL SELECT id FROM improv.model WHERE name = $1",
705 &[&name],
706 )?
707 .get(0);
708 client.execute(
709 "INSERT INTO improv.model_version(model_id) VALUES ($1) ON CONFLICT DO NOTHING",
710 &[&model_id],
711 )?;
712 Ok(PgModelStore {
713 client,
714 model_id,
715 loaded_tx: None,
716 loaded: None,
717 })
718 }
719
720 pub fn tx_high_water(&mut self) -> Result<i64> {
722 version(&mut self.client, self.model_id, false)
723 }
724
725 pub fn loaded_tx(&self) -> Option<i64> {
727 self.loaded_tx
728 }
729
730 pub fn set_loaded_tx(&mut self, tx: Option<i64>) {
733 self.loaded_tx = tx;
734 }
735
736 pub fn snapshot(&self) -> Option<&PgSnapshot> {
739 self.loaded.as_ref()
740 }
741
742 pub fn set_snapshot(&mut self, snapshot: Option<PgSnapshot>) {
745 self.loaded = snapshot;
746 }
747
748 pub fn reload_model(&mut self) -> Result<Model> {
750 self.load_model()
751 }
752
753 pub fn load_model(&mut self) -> Result<Model> {
756 self.load(None)
757 }
758
759 pub fn load_partial(&mut self, measure_ids: &[MeasureId]) -> Result<Model> {
764 self.load(Some(measure_ids))
765 }
766
767 fn load(&mut self, roots: Option<&[MeasureId]>) -> Result<Model> {
768 let mid = self.model_id;
769 let mut tx = self
770 .client
771 .build_transaction()
772 .isolation_level(IsolationLevel::RepeatableRead)
773 .read_only(true)
774 .start()?;
775 let tx_now = version(&mut tx, mid, false)?;
776 let mut model = Model::new();
777 for r in tx.query(
778 "SELECT id, name FROM improv.category WHERE model_id = $1 ORDER BY id",
779 &[&mid],
780 )? {
781 model.add_category(CategoryId(id32(r.get(0))?), r.get::<_, String>(1));
782 }
783 for r in tx.query(
784 "SELECT id, category_id, name FROM improv.item WHERE model_id = $1 ORDER BY id",
785 &[&mid],
786 )? {
787 let (id, cat) = (ItemId(id32(r.get(0))?), CategoryId(id32(r.get(1))?));
788 model.add_item(id, cat, r.get::<_, String>(2));
789 }
790 let mut dsl = Vec::new();
791 for r in tx.query(
792 "SELECT id, name, value_type, kind, formula, description, categories \
793 FROM improv.measure WHERE model_id = $1 ORDER BY id",
794 &[&mid],
795 )? {
796 let id = MeasureId(id32(r.get(0))?);
797 let kind = match r.get::<_, &str>(3) {
798 "derived" => {
799 let f: Option<String> = r.get(4);
800 let f = f.ok_or_else(|| {
801 StoreError::Integrity(format!("derived measure {} has no formula", id.0))
802 })?;
803 match serde_json::from_str(&f) {
804 Ok(f) => MeasureKind::Derived(f),
805 Err(_) => {
808 dsl.push((id, f));
809 MeasureKind::Input
810 }
811 }
812 }
813 _ => MeasureKind::Input,
814 };
815 let categories = r
816 .get::<_, Vec<i64>>(6)
817 .into_iter()
818 .map(|c| id32(c).map(CategoryId))
819 .collect::<Result<_>>()?;
820 model.add_measure(Measure {
821 id,
822 name: Name(r.get(1)),
823 value_type: vt_parse(r.get(2))?,
824 categories,
825 kind,
826 description: r.get(5),
827 });
828 }
829 if let Some(r) = tx.query_opt(
830 "SELECT blob::text FROM improv.meta WHERE model_id = $1",
831 &[&mid],
832 )? {
833 let meta: Meta = serde_json::from_str(r.get(0))?;
834 model.external_fns = meta.external_fns;
835 model.external_calls = meta.external_calls;
836 model.scenarios = meta.scenarios;
837 model.sql_sources = meta.sql_sources;
838 model.lambdas = meta.lambdas;
840 }
841 let lambdas = tx
842 .query(
843 "SELECT id, definition FROM improv.lambda WHERE model_id = $1 ORDER BY id",
844 &[&mid],
845 )?
846 .iter()
847 .map(|r| Ok((id32(r.get(0))?, r.get::<_, String>(1))))
848 .collect::<Result<Vec<_>>>()?;
849 let legacy = lambdas.is_empty() && !model.lambdas.is_empty();
852 if !lambdas.is_empty() {
853 model.lambdas.clear();
854 model
855 .load_lambdas(&lambdas)
856 .map_err(StoreError::Integrity)?;
857 }
858 for (id, text) in dsl {
860 let f = parse_formula_text(&model, &text).map_err(|e| {
861 StoreError::Integrity(format!("measure {}: bad formula {text:?}: {e}", id.0))
862 })?;
863 if let Some(m) = model.measures.get_mut(&id) {
864 m.kind = MeasureKind::Derived(f);
865 }
866 }
867 for r in tx.query(
868 "SELECT layout::text FROM improv.view WHERE model_id = $1",
869 &[&mid],
870 )? {
871 model.add_view(serde_json::from_str(r.get(0))?);
872 }
873
874 let keep: Option<HashSet<MeasureId>> = roots.map(|r| model.measure_dependency_closure(r));
876 let filter: Option<Vec<i64>> = keep
877 .as_ref()
878 .map(|k| k.iter().map(|m| i64::from(m.0)).collect());
879 for r in tx.query(
880 "SELECT measure_id, coord::text, value::text FROM improv.cell \
881 WHERE model_id = $1 AND ($2::int8[] IS NULL OR measure_id = ANY($2))",
882 &[&mid, &filter],
883 )? {
884 let m = MeasureId(id32(r.get(0))?);
885 let vt = value_type_of(&model, m)?;
886 let dims: BTreeMap<CategoryId, ItemId> = serde_json::from_str(r.get(1))?;
887 let v = json_to_value(vt, &serde_json::from_str(r.get(2))?)?;
888 model.set_input(m, Coordinate { dims }, v);
889 }
890 let cells = model.inputs.clone();
892
893 let mut names: HashMap<(CategoryId, String), ItemId> = model
898 .items
899 .values()
900 .map(|i| ((i.category, i.name.0.clone()), i.id))
901 .collect();
902 let bound = bindings(&mut tx, &model, mid)?;
903 let mut rows = Vec::new();
904 for (bi, b) in bound.iter().enumerate() {
905 if keep.as_ref().is_some_and(|k| !k.contains(&b.measure)) {
906 continue;
907 }
908 let vt = value_type_of(&model, b.measure)?;
909 let mut cols: Vec<String> = b
910 .dims
911 .iter()
912 .map(|(_, c)| format!("{}::text", ident(c)))
913 .collect();
914 let mut v = ident(&b.value_col);
915 if let Some(a) = b.agg.as_deref() {
916 v = agg_call(a, &v, vt)?;
917 }
918 if vt == ValueType::DateTime {
920 v = format!("({v})::timestamptz");
921 }
922 cols.push(format!("to_jsonb({v})::text"));
923 let sql = match b.agg {
924 None => format!("SELECT {} FROM {}", cols.join(", "), b.table),
925 Some(_) => {
926 let by: Vec<String> = (1..cols.len()).map(|i| i.to_string()).collect();
927 format!(
928 "SELECT {} FROM {} GROUP BY {}",
929 cols.join(", "),
930 b.table,
931 by.join(", ")
932 )
933 }
934 };
935 'row: for r in tx.query(&sql, &[])? {
936 let Some(v) = r.get::<_, Option<&str>>(b.dims.len()) else {
938 continue;
939 };
940 let mut dims = Vec::with_capacity(b.dims.len());
941 for (i, (cat, _)) in b.dims.iter().enumerate() {
942 let Some(name) = r.get::<_, Option<String>>(i) else {
943 continue 'row;
944 };
945 dims.push((*cat, name));
946 }
947 rows.push((bi, dims, json_to_value(vt, &serde_json::from_str(v)?)?));
948 }
949 }
950 tx.commit()?;
951 let new: BTreeSet<&(CategoryId, String)> = rows
952 .iter()
953 .flat_map(|(_, dims, _)| dims)
954 .filter(|k| !names.contains_key(*k))
955 .collect();
956 if !new.is_empty() {
957 let (cats, new): (Vec<i64>, Vec<&str>) = new
958 .into_iter()
959 .map(|(c, n)| (i64::from(c.0), n.as_str()))
960 .unzip();
961 for r in self.client.query(
962 "SELECT category_id, name, id FROM improv.mint_items($1, $2::int8[], $3::text[])",
963 &[&mid, &cats, &new],
964 )? {
965 let (cat, id) = (CategoryId(id32(r.get(0))?), ItemId(id32(r.get(2))?));
966 let name: String = r.get(1);
967 model.add_item(id, cat, name.clone());
968 names.insert((cat, name), id);
969 }
970 }
971 for (bi, dims, v) in rows {
972 let b = &bound[bi];
973 let mut coord = Coordinate::new();
974 for k in &dims {
975 let item = names.get(k).ok_or_else(|| {
976 StoreError::Integrity(format!("improv.mint_items returned no id for {:?}", k.1))
977 })?;
978 coord = coord.with(k.0, *item);
979 }
980 if model.inputs.insert((b.measure, coord), v).is_some() {
981 return Err(StoreError::Integrity(format!(
982 "{} has two rows for one coordinate of measure {}",
983 b.table, b.measure.0
984 )));
985 }
986 }
987 self.loaded_tx = Some(tx_now);
988 let mut snap = PgSnapshot::new(&model, cells);
989 if legacy {
990 snap.lambdas.clear();
991 snap.ids.insert("lambda", HashSet::new());
992 }
993 self.loaded = Some(snap);
994 Ok(model)
995 }
996
997 pub fn save_model(&mut self, model: &Model) -> Result<()> {
1012 let mid = self.model_id;
1013 let mut tx = self.client.transaction()?;
1014 let cur = version(&mut tx, mid, true)?;
1015 if self.loaded_tx.is_some_and(|l| l != cur) {
1016 return Err(StoreError::Conflict(CONFLICT_MSG.into()));
1017 }
1018
1019 let (ids, names): (Vec<i64>, Vec<&str>) = model
1020 .categories
1021 .values()
1022 .map(|c| (i64::from(c.id.0), c.name.0.as_str()))
1023 .unzip();
1024 tx.execute(
1025 "INSERT INTO improv.category(model_id, id, name) \
1026 SELECT $1, * FROM unnest($2::int8[], $3::text[]) \
1027 ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name",
1028 &[&mid, &ids, &names],
1029 )?;
1030 let cat_ids = ids;
1031
1032 let mut ids = Vec::new();
1033 let mut cats = Vec::new();
1034 let mut names = Vec::new();
1035 for i in model.items.values() {
1036 ids.push(i64::from(i.id.0));
1037 cats.push(i64::from(i.category.0));
1038 names.push(i.name.0.as_str());
1039 }
1040 tx.execute(
1041 "INSERT INTO improv.item(model_id, id, category_id, name) \
1042 SELECT $1, * FROM unnest($2::int8[], $3::int8[], $4::text[]) \
1043 ON CONFLICT (model_id, id) \
1044 DO UPDATE SET category_id = EXCLUDED.category_id, name = EXCLUDED.name",
1045 &[&mid, &ids, &cats, &names],
1046 )?;
1047 let item_ids = ids;
1048
1049 let upsert = tx.prepare(
1050 "INSERT INTO improv.measure \
1051 (model_id, id, name, value_type, kind, formula, description, categories) \
1052 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
1053 ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, \
1054 value_type = EXCLUDED.value_type, kind = EXCLUDED.kind, \
1055 formula = EXCLUDED.formula, description = EXCLUDED.description, \
1056 categories = EXCLUDED.categories",
1057 )?;
1058 let mut measure_ids = Vec::new();
1059 for m in model.measures.values() {
1060 let (kind, formula) = match &m.kind {
1061 MeasureKind::Input => ("input", None),
1062 MeasureKind::Derived(f) => ("derived", Some(serde_json::to_string(f)?)),
1063 };
1064 let cats: Vec<i64> = m.categories.iter().map(|c| i64::from(c.0)).collect();
1065 let id = i64::from(m.id.0);
1066 tx.execute(
1067 &upsert,
1068 &[
1069 &mid,
1070 &id,
1071 &m.name.0,
1072 &vt_name(m.value_type),
1073 &kind,
1074 &formula,
1075 &m.description,
1076 &cats,
1077 ],
1078 )?;
1079 measure_ids.push(id);
1080 }
1081 let base = self.loaded.as_ref();
1083 for (table, keep) in [
1084 ("measure", &measure_ids),
1085 ("item", &item_ids),
1086 ("category", &cat_ids),
1087 ] {
1088 delete_dropped(&mut tx, mid, table, keep, base.map(|s| &s.ids[table]))?;
1089 }
1090
1091 let bound = bindings(&mut tx, model, mid)?;
1093 for b in &bound {
1094 write_bound(&mut tx, model, b, base)?;
1095 }
1096
1097 let bound: HashSet<MeasureId> = bound.iter().map(|b| b.measure).collect();
1101 let cells: HashMap<(MeasureId, Coordinate), Value> = model
1102 .inputs
1103 .iter()
1104 .filter(|((m, _), _)| !bound.contains(m))
1105 .map(|(k, v)| (k.clone(), v.clone()))
1106 .collect();
1107 let (mut ms, mut coords, mut vals) = (Vec::new(), Vec::new(), Vec::new());
1108 for (k @ (m, coord), v) in &cells {
1109 if base.is_some_and(|s| s.cells.get(k) == Some(v)) {
1110 continue;
1111 }
1112 ms.push(i64::from(m.0));
1113 coords.push(serde_json::to_string(&coord.dims)?);
1114 vals.push(value_to_json(v)?.to_string());
1115 }
1116 if let Some(s) = base {
1117 let (mut gm, mut gk) = (Vec::new(), Vec::new());
1118 for (m, coord) in s.cells.keys().filter(|k| !cells.contains_key(*k)) {
1119 gm.push(i64::from(m.0));
1120 gk.push(serde_json::to_string(&coord.dims)?);
1121 }
1122 if !gm.is_empty() {
1123 tx.execute(
1124 "DELETE FROM improv.cell c USING unnest($2::int8[], $3::text[]) u(m, k) \
1125 WHERE c.model_id = $1 AND c.measure_id = u.m AND c.coord = u.k::jsonb",
1126 &[&mid, &gm, &gk],
1127 )?;
1128 }
1129 } else {
1130 tx.execute(
1131 "DELETE FROM improv.cell c WHERE c.model_id = $1 AND NOT EXISTS \
1132 (SELECT 1 FROM unnest($2::int8[], $3::text[]) u(m, k) \
1133 WHERE c.measure_id = u.m AND c.coord = u.k::jsonb)",
1134 &[&mid, &ms, &coords],
1135 )?;
1136 }
1137 if !ms.is_empty() {
1138 tx.execute(
1139 "INSERT INTO improv.cell(model_id, measure_id, coord, value) \
1140 SELECT $1, m, k::jsonb, v::jsonb FROM unnest($2::int8[], $3::text[], $4::text[]) u(m, k, v) \
1141 ON CONFLICT (model_id, measure_id, coord) DO UPDATE SET value = EXCLUDED.value \
1142 WHERE improv.cell.value IS DISTINCT FROM EXCLUDED.value",
1143 &[&mid, &ms, &coords, &vals],
1144 )?;
1145 }
1146
1147 let (mut ids, mut names, mut layouts) = (Vec::new(), Vec::new(), Vec::new());
1148 for v in model.views.values() {
1149 ids.push(i64::from(v.id.0));
1150 names.push(v.name.0.as_str());
1151 layouts.push(serde_json::to_string(v)?);
1152 }
1153 tx.execute(
1154 "INSERT INTO improv.view(model_id, id, name, layout) \
1155 SELECT $1, i, n, l::jsonb FROM unnest($2::int8[], $3::text[], $4::text[]) u(i, n, l) \
1156 ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, layout = EXCLUDED.layout",
1157 &[&mid, &ids, &names, &layouts],
1158 )?;
1159 delete_dropped(&mut tx, mid, "view", &ids, base.map(|s| &s.ids["view"]))?;
1160
1161 let stored = base.map(|s| &s.lambdas);
1165 let ids: Vec<i64> = model.lambdas.keys().map(|i| i64::from(*i)).collect();
1166 let (mut up, mut names, mut defs) = (Vec::new(), Vec::new(), Vec::new());
1167 for (id, l) in &model.lambdas {
1168 if stored.is_some_and(|s| s.get(id) == Some(l)) {
1169 continue;
1170 }
1171 up.push(i64::from(*id));
1172 names.push(l.name.0.as_str());
1173 defs.push(serde_json::to_string(l)?);
1174 }
1175 tx.execute(
1176 "INSERT INTO improv.lambda(model_id, id, name, definition) \
1177 SELECT $1, i, n, d FROM unnest($2::int8[], $3::text[], $4::text[]) u(i, n, d) \
1178 ON CONFLICT (model_id, id) DO UPDATE SET name = EXCLUDED.name, \
1179 definition = EXCLUDED.definition",
1180 &[&mid, &up, &names, &defs],
1181 )?;
1182 delete_dropped(&mut tx, mid, "lambda", &ids, base.map(|s| &s.ids["lambda"]))?;
1183
1184 let blob = serde_json::json!({
1185 "external_fns": model.external_fns,
1186 "external_calls": model.external_calls,
1187 "scenarios": model.scenarios,
1188 "sql_sources": model.sql_sources,
1189 })
1190 .to_string();
1191 tx.execute(
1192 "INSERT INTO improv.meta(model_id, blob) VALUES ($1, $2::text::jsonb) \
1193 ON CONFLICT (model_id) DO UPDATE SET blob = EXCLUDED.blob",
1194 &[&mid, &blob],
1195 )?;
1196
1197 let new_tx: i64 = tx
1198 .query_one(
1199 "UPDATE improv.model_version SET tx = tx + 1 WHERE model_id = $1 RETURNING tx",
1200 &[&mid],
1201 )?
1202 .get(0);
1203 tx.commit()?;
1204 self.loaded_tx = Some(new_tx);
1205 self.loaded = Some(PgSnapshot::new(model, cells));
1206 Ok(())
1207 }
1208}