1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
28#![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
59pub 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
100pub struct ImportSpec {
107 pub query: String,
109 pub dimensions: Vec<DimensionMapping>,
111 pub value_column: String,
113 pub measure_id: MeasureId,
115 pub measure_name: String,
116 pub item_id_base: u32,
118 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
128pub fn import_query<C: SqlConn>(
135 mut conn: C,
136 model: &mut Model,
137 spec: &ImportSpec,
138) -> Result<usize> {
139 check_read_only_sql(&spec.query)?;
142 let table = conn.query(&spec.query)?;
143
144 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 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 let mut interner = improv_data_source::ItemInterner::seeded_from(model, spec.item_id_base);
166
167 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 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 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
204pub 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
221pub 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 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 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 model.inputs.retain(|(mid, _), _| *mid != measure);
284 model.measures.remove(&measure);
286 let n = import_query(conn, model, &spec)?;
287 Ok(n)
288}
289
290pub 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 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 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, };
363 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, ¶ms)?;
375 count += 1;
376 }
377 Ok(count)
378}
379
380fn 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
398fn 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 assert_eq!(model.category_by_name("Time").unwrap().items.len(), 2); assert_eq!(model.category_by_name("Product").unwrap().items.len(), 2);
473
474 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 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 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 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 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 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 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 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 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 #[test]
613 fn conn_open_sqlite_import_export() {
614 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 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 #[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 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 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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 #[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}