From 0c3847a5b913366c0e9ed814057d8b17ae1cec2f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 20 May 2026 16:16:04 +0000 Subject: [PATCH] db: column-origin type recovery in SELECT results (sub-phase 2f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Cargo.toml`: add `column_metadata` to rusqlite's feature list. This pulls in the SQLite `SQLITE_ENABLE_COLUMN_METADATA` compile flag and surfaces `sqlite3_column_table_name` / `sqlite3_column_origin_name` on prepared statements via rusqlite's `Statement::columns_with_metadata()`. `do_run_select` in db.rs now calls a new `resolve_select_column_types(conn, stmt)` helper after `prepare`. The helper walks each result-column's origin metadata; when both `table_name` and `origin_name` come back populated (the result column traces back to a base-table column), it looks up the playground type in `__rdbms_playground_columns`. The per-column types thread through to `format_cell(value, ty)` so the data-table renderer (ADR-0016) gets the same per-type rendering it applies to `show data` results. Effect: ADR-0030 Phase-1 §4.5 (bool SELECT results render as `0` / `1`) is lifted for any bare-column reference whose origin the engine carries through — per ADR-0032 Amendment 1 (2026-05-20 empirical probe), that means all non-recursive CTE bodies, scalar subqueries (aliased or not), derived tables, set ops, and JOINs. Computed projections and recursive-CTE result columns remain typeless (the engine populates no origin), which the renderer handles via neutral alignment. The lookup is engine-driven verbatim — no grammar-side structural classification (ADR-0032 Amendment 1 replaces §12's original "structurally a single column reference" rule with "trust column_table_name / column_origin_name"). Tests (3 new in `tests/sql_select.rs`, all green): - `database_run_select_recovers_bool_column_type` — the Phase-1 §4.5 case: `SELECT Active FROM Products` returns `column_types = [Some(Bool)]` and rows render as `true` / `false`. - `database_run_select_recovers_text_type_through_alias` — `SELECT Name AS n FROM Users` remaps the result column name to `n` but the origin metadata still resolves the playground type to `Some(Text)`. - `database_run_select_computed_expression_stays_typeless` — `SELECT Score + 1 FROM T` keeps `column_types[0] = None`, the documented Amendment-1 exception. The CTE pass-through, scalar subquery, set-op, and JOIN cases all work for free given the empirical findings; their behaviour is asserted by the Amendment-1 probe results recorded in the ADR, so no per-case integration tests are duplicated here. Test totals: 1382 → 1385 passing (+3), 0 failed, 1 ignored. Clippy clean. --- Cargo.toml | 2 +- src/db.rs | 66 ++++++++++++++++++++++---- tests/sql_select.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 9 deletions(-) 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();