diff --git a/Cargo.toml b/Cargo.toml index 1ae7358..c5688b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ futures-util = "0.3.32" gethostname = "1.1.0" rand = "0.10.1" ratatui = "0.30.0" -rusqlite = { version = "0.39.0", features = ["bundled"] } +rusqlite = { version = "0.39.0", features = ["bundled", "column_metadata"] } serde = { version = "1.0.228", features = ["derive"] } serde_yml = "0.0.12" sysinfo = { version = "0.39.0", default-features = false, features = ["system"] } diff --git a/src/db.rs b/src/db.rs index 3161467..6055c2b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5699,13 +5699,19 @@ fn do_run_select_request( } /// Execute a grammar-validated SQL `SELECT` and collect its -/// rows into a [`DataResult`] (ADR-0030 §6). +/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 + +/// Amendment 1). /// -/// All result columns are reported with `column_types = None` — -/// a SELECT result has no playground type unless we resolve -/// each output column back to its source column, which is a -/// Phase-2 (full `SELECT`) concern. The DSL data-table renderer -/// (ADR-0016) renders typeless columns with neutral alignment. +/// Per-column playground types are recovered from the engine's +/// column-origin metadata (`column_table_name` / +/// `column_origin_name`, surfaced by rusqlite's +/// `columns_with_metadata`). The Amendment-1 empirical probe +/// confirmed the metadata follows through non-recursive CTEs, +/// scalar subqueries, derived tables, set ops, and JOINs; only +/// computed projections and recursive-CTE result columns return +/// `None`. The renderer (ADR-0016) handles typed columns +/// (bool → true/false, etc.) and falls back to neutral +/// alignment for `None`. fn do_run_select(conn: &Connection, sql: &str) -> Result { debug!(sql = %sql, "run_select"); let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; @@ -5715,7 +5721,7 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result { .map(String::from) .collect(); let col_count = column_names.len(); - let column_types: Vec> = vec![None; col_count]; + let column_types = resolve_select_column_types(conn, &stmt); let rows_iter = stmt .query_map([], |row| { let mut cells: Vec = Vec::with_capacity(col_count); @@ -5731,7 +5737,8 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result { rows.push( cells .into_iter() - .map(|v| format_cell(v, None)) + .enumerate() + .map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten())) .collect(), ); } @@ -5743,6 +5750,49 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result { }) } +/// Resolve playground types for each result column of a +/// prepared SELECT statement (ADR-0032 §12 + Amendment 1). +/// +/// For each result column, query the engine's column-origin +/// metadata. If both `table_name` and `origin_name` are +/// populated (the result column traces back to a base-table +/// column), look up the playground type in +/// `__rdbms_playground_columns`. Otherwise the slot stays +/// `None` — Amendment 1 documents that recursive-CTE result +/// columns and computed projections are the only structural +/// classes that don't follow through. +fn resolve_select_column_types( + conn: &Connection, + stmt: &rusqlite::Statement, +) -> Vec> { + let metas = stmt.columns_with_metadata(); + if metas.is_empty() { + return Vec::new(); + } + // Prepare the lookup once; reuse across columns. + let mut lookup = match conn.prepare(&format!( + "SELECT user_type FROM {META_TABLE} \ + WHERE table_name = ?1 COLLATE NOCASE \ + AND column_name = ?2 COLLATE NOCASE" + )) { + Ok(s) => s, + Err(_) => return vec![None; metas.len()], + }; + metas + .iter() + .map(|m| { + let table = m.table_name()?; + let origin = m.origin_name()?; + lookup + .query_row(rusqlite::params![table, origin], |row| { + row.get::<_, String>(0) + }) + .ok() + .and_then(|kw| kw.parse::().ok()) + }) + .collect() +} + /// Build the parameterised `SELECT … FROM …` statement for a /// `show data` query (ADR-0026 §5–§6). Separated from /// `do_query_data` so the `explain` path runs `EXPLAIN QUERY diff --git a/tests/sql_select.rs b/tests/sql_select.rs index 6fd130e..86d739c 100644 --- a/tests/sql_select.rs +++ b/tests/sql_select.rs @@ -231,6 +231,118 @@ fn database_run_select_from_user_table_returns_inserted_rows() { assert_eq!(data.columns, vec!["Name".to_string()]); } +// ---- ADR-0032 §12 + Amendment 1: column-origin type recovery ---- + +#[test] +fn database_run_select_recovers_bool_column_type() { + // Lifts Phase-1 §4.5: `SELECT is_active FROM products` + // previously rendered the bool as `0` / `1`. With the + // engine's column-origin metadata wired through, the + // result carries `Some(Type::Bool)` and the renderer + // formats it as `true` / `false`. + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Products".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("Active", Type::Bool), + ], + vec!["id".to_string()], + None, + ) + .await + .expect("create table"); + db.insert( + "Products".to_string(), + None, + vec![Value::Bool(true)], + None, + ) + .await + .expect("insert row"); + db.insert( + "Products".to_string(), + None, + vec![Value::Bool(false)], + None, + ) + .await + .expect("insert row"); + }); + let data = rt + .block_on(db.run_select("select Active from Products".to_string(), None)) + .expect("SELECT runs"); + assert_eq!(data.rows.len(), 2); + assert_eq!(data.column_types, vec![Some(Type::Bool)]); + assert_eq!(data.rows[0][0].as_deref(), Some("true")); + assert_eq!(data.rows[1][0].as_deref(), Some("false")); +} + +#[test] +fn database_run_select_recovers_text_type_through_alias() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Users".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("Name", Type::Text), + ], + vec!["id".to_string()], + None, + ) + .await + .expect("create table"); + db.insert( + "Users".to_string(), + None, + vec![Value::Text("Ada".to_string())], + None, + ) + .await + .expect("insert"); + }); + // The `AS n` alias remaps the result column name; the + // origin metadata still points at `Users.Name`, so the + // playground type is recovered. + let data = rt + .block_on( + db.run_select("select Name as n from Users".to_string(), None), + ) + .expect("SELECT runs"); + assert_eq!(data.columns, vec!["n".to_string()]); + assert_eq!(data.column_types, vec![Some(Type::Text)]); +} + +#[test] +fn database_run_select_computed_expression_stays_typeless() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "T".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("Score", Type::Int), + ], + vec!["id".to_string()], + None, + ) + .await + .expect("create table"); + db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None) + .await + .expect("insert"); + }); + let data = rt + .block_on(db.run_select("select Score + 1 from T".to_string(), None)) + .expect("SELECT runs"); + assert_eq!(data.column_types, vec![None]); +} + #[test] fn database_run_select_appends_to_history_when_source_present() { let (project, db, _dir) = open_project_db();