db: column-origin type recovery in SELECT results (sub-phase 2f)
`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.
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ futures-util = "0.3.32"
|
|||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
ratatui = "0.30.0"
|
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 = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_yml = "0.0.12"
|
serde_yml = "0.0.12"
|
||||||
sysinfo = { version = "0.39.0", default-features = false, features = ["system"] }
|
sysinfo = { version = "0.39.0", default-features = false, features = ["system"] }
|
||||||
|
|||||||
@@ -5699,13 +5699,19 @@ fn do_run_select_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a grammar-validated SQL `SELECT` and collect its
|
/// 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` —
|
/// Per-column playground types are recovered from the engine's
|
||||||
/// a SELECT result has no playground type unless we resolve
|
/// column-origin metadata (`column_table_name` /
|
||||||
/// each output column back to its source column, which is a
|
/// `column_origin_name`, surfaced by rusqlite's
|
||||||
/// Phase-2 (full `SELECT`) concern. The DSL data-table renderer
|
/// `columns_with_metadata`). The Amendment-1 empirical probe
|
||||||
/// (ADR-0016) renders typeless columns with neutral alignment.
|
/// 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<DataResult, DbError> {
|
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
||||||
debug!(sql = %sql, "run_select");
|
debug!(sql = %sql, "run_select");
|
||||||
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
|
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
|
||||||
@@ -5715,7 +5721,7 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
|||||||
.map(String::from)
|
.map(String::from)
|
||||||
.collect();
|
.collect();
|
||||||
let col_count = column_names.len();
|
let col_count = column_names.len();
|
||||||
let column_types: Vec<Option<Type>> = vec![None; col_count];
|
let column_types = resolve_select_column_types(conn, &stmt);
|
||||||
let rows_iter = stmt
|
let rows_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
|
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
|
||||||
@@ -5731,7 +5737,8 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
|||||||
rows.push(
|
rows.push(
|
||||||
cells
|
cells
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| format_cell(v, None))
|
.enumerate()
|
||||||
|
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5743,6 +5750,49 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Option<Type>> {
|
||||||
|
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::<Type>().ok())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the parameterised `SELECT … FROM …` statement for a
|
/// Build the parameterised `SELECT … FROM …` statement for a
|
||||||
/// `show data` query (ADR-0026 §5–§6). Separated from
|
/// `show data` query (ADR-0026 §5–§6). Separated from
|
||||||
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
|
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
|
||||||
|
|||||||
@@ -231,6 +231,118 @@ fn database_run_select_from_user_table_returns_inserted_rows() {
|
|||||||
assert_eq!(data.columns, vec!["Name".to_string()]);
|
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]
|
#[test]
|
||||||
fn database_run_select_appends_to_history_when_source_present() {
|
fn database_run_select_appends_to_history_when_source_present() {
|
||||||
let (project, db, _dir) = open_project_db();
|
let (project, db, _dir) = open_project_db();
|
||||||
|
|||||||
Reference in New Issue
Block a user