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:
claude@clouddev1
2026-05-20 16:16:04 +00:00
parent c5cf03b152
commit 0c3847a5b9
3 changed files with 171 additions and 9 deletions
+112
View File
@@ -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();