Skip to main content

improv_storage_sql/
lib.rs

1//! SQL connectivity (Phase 7): import external SQL query results into an Improv
2//! `Model`, and export a measure's cells back to a SQL table.
3//!
4//! Design invariants (see `.agent/steering/AGENT_DATABASE_CONNECTIVITY.md` §7):
5//!
6//! * **Storage separation.** External SQL is a data *source/sink*; the canonical
7//!   model lives in Mentat. Import produces ordinary categories/items/measures/
8//!   input cells — the engine gains no SQL-specific paths and stays deterministic.
9//! * **Security.** An import/refresh query must be a single `SELECT`/`WITH`
10//!   statement ([`check_read_only_sql`]), and every backend runs it read-only
11//!   (Postgres and DuckDB in a `READ ONLY` transaction, DuckDB with external
12//!   access off; SQLite only if the statement is read-only). Data values are
13//!   bound as parameters, never interpolated. Postgres has no TLS in this
14//!   build, so [`connect_postgres`] refuses a non-loopback host unless the
15//!   connection string says `sslmode=disable`.
16//!
17//! Testing Postgres: the live tests skip unless `IMPROV_TEST_PG` holds a
18//! libpq DSN to a scratch database they may create/drop tables in, e.g.
19//! `IMPROV_TEST_PG="host=127.0.0.1 user=improv password=improv
20//! dbname=improv_test" cargo test -p improv_storage_sql`.
21//!
22//! Backends sit behind a common [`Backend`] enum ([`backend`] module): SQLite
23//! (the reference driver, and what the embedded Mentat store already uses) and
24//! Postgres. Both produce a common typed [`backend::Table`], so the column→model
25//! mapping below is backend-agnostic. Connections are described out of band by
26//! [`conn::Connection`] (no plaintext secrets) and opened via [`conn::open`].
27#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
28// The attribute is only used inside #[cfg(test)] modules, so non-test
29// coverage builds would otherwise warn that the feature is unused.
30#![cfg_attr(coverage_nightly, allow(unused_features))]
31
32pub mod backend;
33pub mod conn;
34pub mod duck;
35pub mod pg;
36
37pub use backend::{Backend, Cell, SqlConn, Table};
38pub use conn::{ConnKind, Connection};
39pub use duck::connect_duckdb;
40pub use pg::connect_postgres;
41
42use backend::Cell as BackendCell;
43use improv_core_model::{CategoryId, Coordinate, MeasureId, Model, Value, ValueType};
44
45#[derive(Debug, thiserror::Error)]
46pub enum SqlError {
47    #[error("sql error: {0}")]
48    Sqlite(#[from] rusqlite::Error),
49    #[error("connect: {0}")]
50    Connect(String),
51    #[error("import: {0}")]
52    Import(String),
53    #[error("export: {0}")]
54    Export(String),
55}
56
57pub type Result<T> = std::result::Result<T, SqlError>;
58
59/// Reject anything that is not a single read statement.
60///
61/// A stored `SqlSource.query` is re-run on every refresh with the analyst's
62/// credentials, so it must not be able to write. After skipping leading
63/// whitespace and `--` / `/* */` comments, the text must start with the
64/// keyword `SELECT` or `WITH`, and a `;` may only be followed by whitespace.
65/// This is a cheap first line of defence (deliberately conservative: a `;`
66/// inside a string literal is rejected too). Each backend additionally runs
67/// the query read-only (see [`Backend::query`]), which is what catches a
68/// data-modifying CTE such as `WITH x AS (DELETE ...) SELECT ...`.
69pub fn check_read_only_sql(sql: &str) -> Result<()> {
70    let reject = |why: &str| Err(SqlError::Import(format!("refusing SQL query: {why}")));
71    let mut s = sql;
72    loop {
73        s = s.trim_start();
74        if let Some(rest) = s.strip_prefix("--") {
75            s = rest.find('\n').map_or("", |i| &rest[i + 1..]);
76        } else if let Some(rest) = s.strip_prefix("/*") {
77            match rest.find("*/") {
78                Some(i) => s = &rest[i + 2..],
79                None => return reject("unterminated comment"),
80            }
81        } else {
82            break;
83        }
84    }
85    let word: String = s
86        .chars()
87        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
88        .collect();
89    if !word.eq_ignore_ascii_case("select") && !word.eq_ignore_ascii_case("with") {
90        return reject("only a single SELECT or WITH statement is allowed");
91    }
92    if let Some(i) = s.find(';') {
93        if !s[i + 1..].trim().is_empty() {
94            return reject("multiple statements are not allowed");
95        }
96    }
97    Ok(())
98}
99
100/// How to map a query's result columns onto model elements.
101///
102/// Each `dimensions` entry names a result column that becomes a **category**
103/// (its distinct values become items); `value_column` names the column that
104/// becomes the imported measure's numeric value. A row therefore contributes
105/// one input cell: `measure[dim=val, …] = value_column`.
106pub struct ImportSpec {
107    /// SQL `SELECT` to run against the connection.
108    pub query: String,
109    /// Result-column name → the category it maps to (name + id).
110    pub dimensions: Vec<DimensionMapping>,
111    /// The result column holding the numeric measure value.
112    pub value_column: String,
113    /// The measure to create/populate for the value column.
114    pub measure_id: MeasureId,
115    pub measure_name: String,
116    /// Base id for minting item ids; items get sequential ids from here.
117    pub item_id_base: u32,
118    /// How the resulting measure should refresh (default `Manual`).
119    pub refresh_policy: improv_core_model::RefreshPolicy,
120}
121
122pub struct DimensionMapping {
123    pub column: String,
124    pub category_id: CategoryId,
125    pub category_name: String,
126}
127
128/// Import the result of `spec.query` into `model` as a new input measure over
129/// the mapped dimension categories. Distinct dimension-column values become
130/// items (interned by their string form). Returns the number of cells imported.
131///
132/// SQL data enters purely as input cells (a normal measure collection), so the
133/// deterministic engine core is untouched.
134pub fn import_query<C: SqlConn>(
135    mut conn: C,
136    model: &mut Model,
137    spec: &ImportSpec,
138) -> Result<usize> {
139    // Run the query once, up front, into a backend-agnostic table. Only a
140    // single read statement is accepted, and backends run it read-only.
141    check_read_only_sql(&spec.query)?;
142    let table = conn.query(&spec.query)?;
143
144    // Ensure the categories exist.
145    improv_data_source::ensure_categories(
146        model,
147        spec.dimensions
148            .iter()
149            .map(|d| (d.category_id, d.category_name.as_str())),
150    );
151    // Create the measure (input, over the mapped categories).
152    let cats: Vec<CategoryId> = spec.dimensions.iter().map(|d| d.category_id).collect();
153    improv_data_source::add_input_measure(
154        model,
155        spec.measure_id,
156        &spec.measure_name,
157        ValueType::Number,
158        cats,
159        Some(format!("imported from SQL: {}", truncate(&spec.query, 80))),
160    );
161
162    // Per-category interner: dimension value string -> ItemId. Seeded with the
163    // model's EXISTING items (by category + name) so re-import / refresh reuses
164    // items instead of minting duplicates with the same name.
165    let mut interner = improv_data_source::ItemInterner::seeded_from(model, spec.item_id_base);
166
167    // Resolve column indices up front; error clearly if a mapped column is absent.
168    let dim_idx: Vec<(usize, CategoryId)> = spec
169        .dimensions
170        .iter()
171        .map(|d| Ok((table.column_index(&d.column)?, d.category_id)))
172        .collect::<Result<_>>()?;
173    let val_idx = table.column_index(&spec.value_column)?;
174
175    let mut count = 0usize;
176    // Collect (coord, value) first so we can mutate the model after.
177    let mut cells: Vec<(Coordinate, f64)> = Vec::new();
178    for row in &table.rows {
179        let mut coord = Coordinate::new();
180        for (ci, cat) in &dim_idx {
181            let key = row[*ci].as_text();
182            let item = interner.intern(*cat, &key);
183            coord = coord.with(*cat, item);
184        }
185        let value: f64 = row[val_idx].as_number().map_err(|_| {
186            SqlError::Import(format!(
187                "value column '{}' is not numeric",
188                spec.value_column
189            ))
190        })?;
191        cells.push((coord, value));
192        count += 1;
193    }
194
195    // Register interned items on their categories, then set the input cells.
196    interner.register(model);
197    for (coord, value) in cells {
198        model.set_input(spec.measure_id, coord, Value::Number(value));
199    }
200
201    Ok(count)
202}
203
204/// Register an SQL-backed input measure (records its `SqlSource` on the model)
205/// and populate it by running the query once. The measure's categories must
206/// already exist or be creatable from the import; this is a convenience over
207/// `import_query` that also marks the measure as SQL-backed so it can be
208/// refreshed later.
209pub fn add_sql_measure<C: SqlConn>(conn: C, model: &mut Model, spec: &ImportSpec) -> Result<usize> {
210    let source = improv_core_model::SqlSource {
211        query: spec.query.clone(),
212        dimension_columns: spec.dimensions.iter().map(|d| d.column.clone()).collect(),
213        value_column: spec.value_column.clone(),
214        refresh_policy: spec.refresh_policy,
215    };
216    let n = import_query(conn, model, spec)?;
217    model.sql_sources.insert(spec.measure_id, source);
218    Ok(n)
219}
220
221/// Refresh an SQL-backed measure: re-run its stored query and replace its input
222/// cells with the fresh result. New dimension values become new items. The
223/// measure must have a `SqlSource` (added via `add_sql_measure`).
224///
225/// This is the `SQL("...")` live-query refresh (Phase 7): SQL data re-enters as
226/// ordinary input cells, so the engine recomputes dependents with no SQL path
227/// of its own. It is nondeterministic by nature (external data), hence gated on
228/// an explicit refresh rather than sitting on a pure evaluation path.
229pub fn refresh_sql_measure<C: SqlConn>(
230    conn: C,
231    model: &mut Model,
232    measure: MeasureId,
233) -> Result<usize> {
234    let source = model
235        .sql_sources
236        .get(&measure)
237        .cloned()
238        .ok_or_else(|| SqlError::Import(format!("{measure:?} is not an SQL-backed measure")))?;
239    let m = model
240        .measures
241        .get(&measure)
242        .ok_or_else(|| SqlError::Import(format!("no measure {measure:?}")))?;
243    if source.dimension_columns.len() != m.categories.len() {
244        return Err(SqlError::Import(format!(
245            "SQL source for {measure:?} maps {} columns but the measure has {} categories",
246            source.dimension_columns.len(),
247            m.categories.len()
248        )));
249    }
250    // Rebuild the import spec from the stored source + the measure's categories.
251    let dimensions: Vec<DimensionMapping> = source
252        .dimension_columns
253        .iter()
254        .zip(m.categories.iter())
255        .map(|(col, cat)| DimensionMapping {
256            column: col.clone(),
257            category_id: *cat,
258            category_name: model
259                .categories
260                .get(cat)
261                .map(|c| c.name.0.clone())
262                .unwrap_or_else(|| cat.0.to_string()),
263        })
264        .collect();
265    let spec = ImportSpec {
266        query: source.query.clone(),
267        dimensions,
268        value_column: source.value_column.clone(),
269        measure_id: measure,
270        measure_name: m.name.0.clone(),
271        // Continue item ids above the current max to avoid collisions.
272        item_id_base: model
273            .items
274            .keys()
275            .map(|i| i.0)
276            .max()
277            .map(|m| m + 1)
278            .unwrap_or(1_000_000),
279        refresh_policy: source.refresh_policy,
280    };
281    // Clear this measure's existing cells (refresh replaces them). Items from
282    // prior refreshes are left in place (harmless; may be re-referenced).
283    model.inputs.retain(|(mid, _), _| *mid != measure);
284    // import_query re-adds the measure; remove first so it isn't duplicated.
285    model.measures.remove(&measure);
286    let n = import_query(conn, model, &spec)?;
287    Ok(n)
288}
289
290/// Write a measure's input cells to a SQL table: one column per dimension
291/// category (item name), plus a value column. Creates the table if missing and
292/// inserts one row per cell. Values are bound as parameters (no interpolation).
293///
294/// `table` and column names are validated as plain identifiers to avoid
295/// injection through DDL (which cannot be parameterized).
296pub fn export_measure<C: SqlConn>(
297    mut conn: C,
298    model: &Model,
299    measure: MeasureId,
300    table: &str,
301    value_column: &str,
302) -> Result<usize> {
303    ident(table)?;
304    ident(value_column)?;
305    let m = model
306        .measures
307        .get(&measure)
308        .ok_or_else(|| SqlError::Export(format!("no measure {measure:?}")))?;
309
310    // Column per category, in the measure's declared order.
311    let dim_cols: Vec<(CategoryId, String)> = m
312        .categories
313        .iter()
314        .map(|c| {
315            let name = model
316                .categories
317                .get(c)
318                .map(|x| sanitize_ident(&x.name.0))
319                .unwrap_or_else(|| format!("cat{}", c.0));
320            (*c, name)
321        })
322        .collect();
323    for (_, name) in &dim_cols {
324        ident(name)?;
325    }
326
327    // CREATE TABLE (idempotent). Identifiers are validated; values are bound.
328    let cols_ddl = dim_cols
329        .iter()
330        .map(|(_, n)| format!("{n} TEXT"))
331        .chain(std::iter::once(format!(
332            "{value_column} {}",
333            conn.real_type()
334        )))
335        .collect::<Vec<_>>()
336        .join(", ");
337    conn.execute(
338        &format!("CREATE TABLE IF NOT EXISTS {table} ({cols_ddl})"),
339        &[],
340    )?;
341
342    let placeholders = (1..=dim_cols.len() + 1)
343        .map(|n| conn.placeholder(n))
344        .collect::<Vec<_>>()
345        .join(", ");
346    let col_list = dim_cols
347        .iter()
348        .map(|(_, n)| n.clone())
349        .chain(std::iter::once(value_column.to_string()))
350        .collect::<Vec<_>>()
351        .join(", ");
352    let insert = format!("INSERT INTO {table} ({col_list}) VALUES ({placeholders})");
353
354    let mut count = 0usize;
355    for ((mid, coord), val) in &model.inputs {
356        if *mid != measure {
357            continue;
358        }
359        let n = match val {
360            Value::Number(n) => *n,
361            _ => continue, // v1 exports numeric cells only
362        };
363        // Bind dimension item names then the value (all as parameters).
364        let mut params: Vec<BackendCell> = Vec::with_capacity(dim_cols.len() + 1);
365        for (cat, _) in &dim_cols {
366            let item_name = coord
367                .get(*cat)
368                .and_then(|i| model.items.get(&i))
369                .map(|it| it.name.0.clone())
370                .unwrap_or_default();
371            params.push(BackendCell::Text(item_name));
372        }
373        params.push(BackendCell::Float(n));
374        conn.execute(&insert, &params)?;
375        count += 1;
376    }
377    Ok(count)
378}
379
380// --- helpers ---
381
382/// Validate a SQL identifier (table/column) used in DDL/DML that can't be
383/// parameterized: ASCII alphanumeric + underscore, not starting with a digit.
384fn ident(s: &str) -> Result<()> {
385    let ok = !s.is_empty()
386        && s.chars()
387            .next()
388            .map(|c| c.is_ascii_alphabetic() || c == '_')
389            .unwrap_or(false)
390        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
391    if ok {
392        Ok(())
393    } else {
394        Err(SqlError::Export(format!("invalid SQL identifier: {s:?}")))
395    }
396}
397
398/// Turn an arbitrary category/measure name into a safe identifier.
399fn sanitize_ident(s: &str) -> String {
400    let mut out: String = s
401        .chars()
402        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
403        .collect();
404    if out
405        .chars()
406        .next()
407        .map(|c| c.is_ascii_digit())
408        .unwrap_or(true)
409    {
410        out.insert(0, '_');
411    }
412    out
413}
414
415fn truncate(s: &str, n: usize) -> String {
416    if s.len() <= n {
417        s.to_string()
418    } else {
419        format!("{}…", &s[..n])
420    }
421}
422
423#[cfg(test)]
424#[cfg_attr(coverage_nightly, coverage(off))]
425mod tests {
426    use super::*;
427
428    fn source_db() -> rusqlite::Connection {
429        let conn = rusqlite::Connection::open_in_memory().unwrap();
430        conn.execute_batch(
431            "CREATE TABLE sales (time TEXT, product TEXT, revenue REAL);
432             INSERT INTO sales VALUES ('2025','WidgetA', 1000.0);
433             INSERT INTO sales VALUES ('2025','WidgetB', 500.0);
434             INSERT INTO sales VALUES ('2026','WidgetA', 1200.0);",
435        )
436        .unwrap();
437        conn
438    }
439
440    fn revenue_spec() -> ImportSpec {
441        ImportSpec {
442            query: "SELECT time, product, revenue FROM sales".into(),
443            dimensions: vec![
444                DimensionMapping {
445                    column: "time".into(),
446                    category_id: CategoryId(1),
447                    category_name: "Time".into(),
448                },
449                DimensionMapping {
450                    column: "product".into(),
451                    category_id: CategoryId(2),
452                    category_name: "Product".into(),
453                },
454            ],
455            value_column: "revenue".into(),
456            measure_id: MeasureId(100),
457            measure_name: "Revenue".into(),
458            item_id_base: 1000,
459            refresh_policy: Default::default(),
460        }
461    }
462
463    #[test]
464    fn import_maps_columns_to_model() {
465        let mut conn = Backend::Sqlite(source_db());
466        let mut model = Model::new();
467        let n = import_query(&mut conn, &mut model, &revenue_spec()).unwrap();
468        assert_eq!(n, 3, "three rows imported");
469
470        // Categories + items created.
471        assert_eq!(model.category_by_name("Time").unwrap().items.len(), 2); // 2025, 2026
472        assert_eq!(model.category_by_name("Product").unwrap().items.len(), 2);
473
474        // Measure created as input over both categories.
475        let m = model.measure_by_name("Revenue").unwrap();
476        assert!(m.is_input());
477        assert_eq!(m.categories.len(), 2);
478        assert_eq!(model.inputs.len(), 3);
479
480        // A specific cell resolves: Revenue[2025, WidgetA] = 1000.
481        let time = model.category_by_name("Time").unwrap().id;
482        let product = model.category_by_name("Product").unwrap().id;
483        let item = |cat, name: &str| {
484            model
485                .items
486                .values()
487                .find(|i| i.category == cat && i.name.0 == name)
488                .unwrap()
489                .id
490        };
491        let coord = Coordinate::from_pairs([
492            (time, item(time, "2025")),
493            (product, item(product, "WidgetA")),
494        ]);
495        assert_eq!(
496            model.input(MeasureId(100), &coord),
497            Some(&Value::Number(1000.0))
498        );
499    }
500
501    #[test]
502    fn import_then_export_round_trips_values() {
503        let mut conn = Backend::Sqlite(source_db());
504        let mut model = Model::new();
505        import_query(&mut conn, &mut model, &revenue_spec()).unwrap();
506
507        let out = rusqlite::Connection::open_in_memory().unwrap();
508        let mut out_be = Backend::Sqlite(out);
509        let n = export_measure(
510            &mut out_be,
511            &model,
512            MeasureId(100),
513            "revenue_out",
514            "revenue",
515        )
516        .unwrap();
517        assert_eq!(n, 3);
518
519        // Read the exported values back; the multiset of revenues matches.
520        let Backend::Sqlite(out) = &out_be else {
521            unreachable!()
522        };
523        let mut stmt = out
524            .prepare("SELECT revenue FROM revenue_out ORDER BY revenue")
525            .unwrap();
526        let vals: Vec<f64> = stmt
527            .query_map([], |r| r.get::<_, f64>(0))
528            .unwrap()
529            .map(|r| r.unwrap())
530            .collect();
531        assert_eq!(vals, vec![500.0, 1000.0, 1200.0]);
532    }
533
534    #[test]
535    fn missing_column_errors() {
536        let mut conn = Backend::Sqlite(source_db());
537        let mut model = Model::new();
538        let mut spec = revenue_spec();
539        spec.value_column = "nope".into();
540        assert!(import_query(&mut conn, &mut model, &spec).is_err());
541    }
542
543    #[test]
544    fn export_rejects_bad_identifier() {
545        let model = {
546            let mut conn = Backend::Sqlite(source_db());
547            let mut m = Model::new();
548            import_query(&mut conn, &mut m, &revenue_spec()).unwrap();
549            m
550        };
551        let mut out = Backend::Sqlite(rusqlite::Connection::open_in_memory().unwrap());
552        // A table name with a quote must be rejected, not interpolated.
553        assert!(export_measure(&mut out, &model, MeasureId(100), "bad\"; DROP", "v").is_err());
554    }
555
556    #[test]
557    fn sql_measure_refresh_picks_up_new_data() {
558        let mut conn = Backend::Sqlite(source_db());
559        let mut model = Model::new();
560        // Register + populate an SQL-backed measure.
561        let n = add_sql_measure(&mut conn, &mut model, &revenue_spec()).unwrap();
562        assert_eq!(n, 3);
563        assert!(model.sql_sources.contains_key(&MeasureId(100)));
564
565        // The source data changes (a new row + a changed value).
566        let Backend::Sqlite(raw) = &conn else {
567            unreachable!()
568        };
569        raw.execute_batch(
570            "UPDATE sales SET revenue = 9999.0 WHERE time='2025' AND product='WidgetA';
571             INSERT INTO sales VALUES ('2027','WidgetA', 1.0);",
572        )
573        .unwrap();
574
575        // Refresh replaces this measure's cells with the fresh query result.
576        let n2 = refresh_sql_measure(&mut conn, &mut model, MeasureId(100)).unwrap();
577        assert_eq!(n2, 4, "now four rows");
578        assert_eq!(model.inputs.len(), 4);
579
580        // The changed cell reflects the new value.
581        let time = model.category_by_name("Time").unwrap().id;
582        let product = model.category_by_name("Product").unwrap().id;
583        let item = |cat, name: &str| {
584            model
585                .items
586                .values()
587                .find(|i| i.category == cat && i.name.0 == name)
588                .unwrap()
589                .id
590        };
591        let coord = Coordinate::from_pairs([
592            (time, item(time, "2025")),
593            (product, item(product, "WidgetA")),
594        ]);
595        assert_eq!(
596            model.input(MeasureId(100), &coord),
597            Some(&Value::Number(9999.0))
598        );
599    }
600
601    #[test]
602    fn refresh_requires_a_registered_source() {
603        let mut conn = Backend::Sqlite(source_db());
604        let mut model = Model::new();
605        // A plain import (no source registered) can't be refreshed.
606        import_query(&mut conn, &mut model, &revenue_spec()).unwrap();
607        assert!(refresh_sql_measure(&mut conn, &mut model, MeasureId(100)).is_err());
608    }
609
610    /// Backend abstraction: open a SQLite `Connection` descriptor and run the
611    /// SQLite import through the same enum, then export. Exercises `conn::open`.
612    #[test]
613    fn conn_open_sqlite_import_export() {
614        // Seed an in-memory DB and hand it to the abstraction directly.
615        let mut conn = Backend::Sqlite(source_db());
616        let mut model = Model::new();
617        let n = import_query(&mut conn, &mut model, &revenue_spec()).unwrap();
618        assert_eq!(n, 3);
619
620        // A file-backed SQLite descriptor round-trips through open().
621        let dir = std::env::temp_dir();
622        let path = dir.join(format!("improv_sql_test_{}.db", std::process::id()));
623        let desc = crate::conn::sqlite("s1", "scratch", path.to_str().unwrap());
624        let mut be = crate::conn::open(&desc).unwrap();
625        let out =
626            export_measure(&mut be, &model, MeasureId(100), "revenue_out", "revenue").unwrap();
627        assert_eq!(out, 3);
628        drop(be);
629        let _ = std::fs::remove_file(&path);
630    }
631
632    /// DuckDB round-trip: same shape as the SQLite test, through the DuckDB
633    /// backend. Seed an in-memory DuckDB, import a measure, then export it back
634    /// and read the values.
635    #[test]
636    fn duckdb_import_then_export_round_trips() {
637        let dconn = duckdb::Connection::open_in_memory().unwrap();
638        dconn
639            .execute_batch(
640                "CREATE TABLE sales (time VARCHAR, product VARCHAR, revenue DOUBLE);
641                 INSERT INTO sales VALUES ('2025','WidgetA', 1000.0);
642                 INSERT INTO sales VALUES ('2025','WidgetB', 500.0);
643                 INSERT INTO sales VALUES ('2026','WidgetA', 1200.0);",
644            )
645            .unwrap();
646        let mut conn = Backend::Duckdb(dconn);
647        let mut model = Model::new();
648        let n = import_query(&mut conn, &mut model, &revenue_spec()).unwrap();
649        assert_eq!(n, 3);
650
651        // Imported cells match: Revenue[2025, WidgetA] = 1000.
652        let time = model.category_by_name("Time").unwrap().id;
653        let product = model.category_by_name("Product").unwrap().id;
654        let item = |cat, name: &str| {
655            model
656                .items
657                .values()
658                .find(|i| i.category == cat && i.name.0 == name)
659                .unwrap()
660                .id
661        };
662        let coord = Coordinate::from_pairs([
663            (time, item(time, "2025")),
664            (product, item(product, "WidgetA")),
665        ]);
666        assert_eq!(
667            model.input(MeasureId(100), &coord),
668            Some(&Value::Number(1000.0))
669        );
670
671        // Export back into a fresh DuckDB and read the revenues.
672        let mut out_be = Backend::Duckdb(duckdb::Connection::open_in_memory().unwrap());
673        let out = export_measure(
674            &mut out_be,
675            &model,
676            MeasureId(100),
677            "revenue_out",
678            "revenue",
679        )
680        .unwrap();
681        assert_eq!(out, 3);
682        let Backend::Duckdb(out_conn) = &out_be else {
683            unreachable!()
684        };
685        let mut stmt = out_conn
686            .prepare("SELECT revenue FROM revenue_out ORDER BY revenue")
687            .unwrap();
688        let vals: Vec<f64> = stmt
689            .query_map([], |r| r.get::<_, f64>(0))
690            .unwrap()
691            .map(|r| r.unwrap())
692            .collect();
693        assert_eq!(vals, vec![500.0, 1000.0, 1200.0]);
694    }
695
696    #[test]
697    fn read_only_sql_accepts_single_reads() {
698        for ok in [
699            "SELECT 1",
700            "  select time, product FROM sales;  ",
701            "-- note\nSELECT 1",
702            "/* hi */ /* again */ WITH x AS (SELECT 1) SELECT * FROM x",
703            "with x as (select 1) select * from x;\n",
704        ] {
705            assert!(check_read_only_sql(ok).is_ok(), "{ok:?}");
706        }
707    }
708
709    #[test]
710    fn read_only_sql_rejects_writes_and_stacking() {
711        for bad in [
712            "",
713            "drop table sales",
714            "select 1; drop table x",
715            "SELECT 1;DELETE FROM sales",
716            "/* */ delete from sales",
717            "-- SELECT\nupdate sales set revenue = 0",
718            "/* unterminated SELECT",
719            "selectx 1",
720            "COPY sales TO PROGRAM 'id'",
721            "ATTACH 'x.db'",
722            "PRAGMA writable_schema = 1",
723        ] {
724            assert!(check_read_only_sql(bad).is_err(), "{bad:?}");
725        }
726    }
727
728    /// `WITH ... DELETE` passes the keyword check; the backend's read-only
729    /// execution is what stops it (SQLite: `sqlite3_stmt_readonly`).
730    #[test]
731    fn sqlite_refuses_cte_write() {
732        let conn = source_db();
733        let mut spec = revenue_spec();
734        spec.query = "WITH x AS (SELECT 1) DELETE FROM sales".into();
735        assert!(check_read_only_sql(&spec.query).is_ok());
736        let mut model = Model::new();
737        assert!(import_query(&conn, &mut model, &spec).is_err());
738        let n: i64 = conn
739            .query_row("SELECT COUNT(*) FROM sales", [], |r| r.get(0))
740            .unwrap();
741        assert_eq!(n, 3, "rows must survive");
742    }
743
744    /// DuckDB runs queries in a READ ONLY transaction and with external access
745    /// off, so neither a write nor a host-file read gets through.
746    #[test]
747    fn duckdb_query_is_read_only_and_sandboxed() {
748        let mut be = crate::connect_duckdb(":memory:").unwrap();
749        be.execute("CREATE TABLE t (v DOUBLE)", &[]).unwrap();
750        be.execute("INSERT INTO t VALUES (?)", &[Cell::Float(1.0)])
751            .unwrap();
752        // Bypass check_read_only_sql to prove the transaction layer holds.
753        assert!(be.query("DELETE FROM t").is_err());
754        assert!(be.query("INSERT INTO t VALUES (2)").is_err());
755        assert!(be.query("WITH x AS (SELECT 1) DELETE FROM t").is_err());
756        assert!(be.query("SELECT * FROM read_csv('/etc/passwd')").is_err());
757        let t = be.query("SELECT v FROM t").unwrap();
758        assert_eq!(t.rows.len(), 1, "the read still works, the writes did not");
759        // The handle remains usable for export after a refused query.
760        be.execute("INSERT INTO t VALUES (?)", &[Cell::Float(3.0)])
761            .unwrap();
762        assert_eq!(be.query("SELECT v FROM t").unwrap().rows.len(), 2);
763    }
764
765    /// Postgres query mapping: guarded by IMPROV_TEST_PG. Skips cleanly if unset
766    /// so CI without a database still passes.
767    #[test]
768    fn postgres_import_when_available() {
769        let Ok(uri) = std::env::var("IMPROV_TEST_PG") else {
770            println!("skipped: IMPROV_TEST_PG not set");
771            return;
772        };
773        let mut be = crate::connect_postgres(&uri).expect("connect IMPROV_TEST_PG");
774        // Build a disposable source table.
775        be.execute("DROP TABLE IF EXISTS improv_pg_sales", &[])
776            .unwrap();
777        be.execute(
778            "CREATE TABLE improv_pg_sales (t TEXT, p TEXT, revenue DOUBLE PRECISION)",
779            &[],
780        )
781        .unwrap();
782        for (t, p, r) in [
783            ("2025", "A", 10.0),
784            ("2025", "B", 20.0),
785            ("2026", "A", 30.0),
786        ] {
787            be.execute(
788                "INSERT INTO improv_pg_sales (t, p, revenue) VALUES ($1, $2, $3)",
789                &[Cell::Text(t.into()), Cell::Text(p.into()), Cell::Float(r)],
790            )
791            .unwrap();
792        }
793        let mut model = Model::new();
794        let spec = ImportSpec {
795            query: "SELECT t, p, revenue FROM improv_pg_sales".into(),
796            dimensions: vec![
797                DimensionMapping {
798                    column: "t".into(),
799                    category_id: CategoryId(1),
800                    category_name: "Time".into(),
801                },
802                DimensionMapping {
803                    column: "p".into(),
804                    category_id: CategoryId(2),
805                    category_name: "Product".into(),
806                },
807            ],
808            value_column: "revenue".into(),
809            measure_id: MeasureId(100),
810            measure_name: "Revenue".into(),
811            item_id_base: 1000,
812            refresh_policy: Default::default(),
813        };
814        let n = import_query(&mut be, &mut model, &spec).unwrap();
815        assert_eq!(n, 3);
816        assert_eq!(model.inputs.len(), 3);
817        be.execute("DROP TABLE improv_pg_sales", &[]).unwrap();
818    }
819
820    #[test]
821    fn identifier_validation_and_sanitizing() {
822        assert!(ident("ok_1").is_ok());
823        assert!(ident("_x").is_ok());
824        assert!(ident("").is_err());
825        assert!(ident("1abc").is_err());
826        assert!(ident("a-b").is_err());
827        assert_eq!(sanitize_ident("Product Line"), "Product_Line");
828        assert_eq!(sanitize_ident("2025"), "_2025");
829        assert_eq!(sanitize_ident(""), "_");
830        assert_eq!(truncate("abc", 5), "abc");
831        assert_eq!(truncate("abcdef", 3), "abc…");
832    }
833
834    #[test]
835    fn non_numeric_value_column_is_an_import_error() {
836        let raw = rusqlite::Connection::open_in_memory().unwrap();
837        raw.execute_batch("CREATE TABLE s (k TEXT, v TEXT); INSERT INTO s VALUES ('a','x');")
838            .unwrap();
839        let mut spec = revenue_spec();
840        spec.query = "SELECT k, v FROM s".into();
841        spec.dimensions.truncate(1);
842        spec.dimensions[0].column = "k".into();
843        spec.value_column = "v".into();
844        let err = import_query(&raw, &mut Model::new(), &spec).unwrap_err();
845        assert!(err.to_string().contains("is not numeric"), "{err}");
846    }
847
848    #[test]
849    fn long_query_is_truncated_in_the_measure_description() {
850        let mut spec = revenue_spec();
851        spec.query = format!(
852            "SELECT time, product, revenue FROM sales {}",
853            " ".repeat(100)
854        );
855        let mut model = Model::new();
856        import_query(&source_db(), &mut model, &spec).unwrap();
857        let desc = model.measures[&MeasureId(100)].description.clone().unwrap();
858        assert!(desc.ends_with('…'), "{desc}");
859    }
860
861    #[test]
862    fn refresh_rejects_inconsistent_sources() {
863        let mut model = Model::new();
864        add_sql_measure(&source_db(), &mut model, &revenue_spec()).unwrap();
865        // Source maps more columns than the measure has categories.
866        let mut bad = model.clone();
867        bad.sql_sources
868            .get_mut(&MeasureId(100))
869            .unwrap()
870            .dimension_columns
871            .push("extra".into());
872        let err = refresh_sql_measure(&source_db(), &mut bad, MeasureId(100)).unwrap_err();
873        assert!(err.to_string().contains("maps 3 columns"), "{err}");
874        // Source without its measure.
875        let mut orphan = model.clone();
876        orphan.measures.remove(&MeasureId(100));
877        let err = refresh_sql_measure(&source_db(), &mut orphan, MeasureId(100)).unwrap_err();
878        assert!(err.to_string().contains("no measure"), "{err}");
879        // A category missing from the model falls back to its numeric id.
880        model.categories.remove(&CategoryId(2));
881        assert_eq!(
882            refresh_sql_measure(&source_db(), &mut model, MeasureId(100)).unwrap(),
883            3
884        );
885        assert_eq!(model.categories[&CategoryId(2)].name.0, "2");
886    }
887
888    #[test]
889    fn export_skips_other_measures_and_non_numeric_cells() {
890        let mut model = Model::new();
891        import_query(&source_db(), &mut model, &revenue_spec()).unwrap();
892        // A second measure's cell and a non-numeric cell of ours are skipped.
893        let coord = model.inputs.keys().next().unwrap().1.clone();
894        model.set_input(MeasureId(7), coord.clone(), Value::Number(1.0));
895        model.set_input(MeasureId(100), coord, Value::Text("n/a".into()));
896        // A category name that needs sanitizing, and one absent from the model.
897        model.categories.get_mut(&CategoryId(1)).unwrap().name.0 = "Fiscal Year".into();
898        model.categories.remove(&CategoryId(2));
899        let out = rusqlite::Connection::open_in_memory().unwrap();
900        let n = export_measure(&out, &model, MeasureId(100), "o", "v").unwrap();
901        assert_eq!(n, 2);
902        let cols: Vec<String> = out
903            .prepare("SELECT * FROM o")
904            .unwrap()
905            .column_names()
906            .iter()
907            .map(|s| s.to_string())
908            .collect();
909        assert_eq!(cols, ["Fiscal_Year", "cat2", "v"]);
910        let err = export_measure(&out, &model, MeasureId(999), "o", "v").unwrap_err();
911        assert!(err.to_string().contains("no measure"), "{err}");
912        assert!(export_measure(&out, &model, MeasureId(100), "o", "1v").is_err());
913    }
914
915    /// Live Postgres export: `$n` placeholders and `DOUBLE PRECISION`.
916    #[test]
917    fn postgres_export_when_available() {
918        let Ok(uri) = std::env::var("IMPROV_TEST_PG") else {
919            println!("skipped: IMPROV_TEST_PG not set");
920            return;
921        };
922        let mut model = Model::new();
923        import_query(&source_db(), &mut model, &revenue_spec()).unwrap();
924        let mut be = crate::connect_postgres(&uri).expect("connect IMPROV_TEST_PG");
925        assert_eq!(
926            (be.placeholder(2), be.real_type()),
927            ("$2".into(), "DOUBLE PRECISION")
928        );
929        be.execute("DROP TABLE IF EXISTS improv_pg_export", &[])
930            .unwrap();
931        let n = export_measure(&mut be, &model, MeasureId(100), "improv_pg_export", "v").unwrap();
932        assert_eq!(n, 3);
933        let t = be
934            .query("SELECT sum(v) AS s FROM improv_pg_export")
935            .unwrap();
936        assert_eq!(t.rows, [[Cell::Float(2700.0)]]);
937        be.execute("DROP TABLE improv_pg_export", &[]).unwrap();
938    }
939}