fix: resolve table names case-insensitively across all executors

SQL identifiers are case-insensitive, so the engine resolves a table
named in any capitalization — but our metadata tables (keyed by
table_name / parent_table / child_table) and data/<table>.csv files use
case-sensitive TEXT '=', so an operation naming a table in a different
case than stored drifted: schema ops orphaned metadata rows, and a
wrong-case insert/update/delete silently skipped the CSV write, losing
the change on the next reload/rebuild. This contradicted ADR-0009's
stated rule (case-insensitive resolution, case-preserving display).

Add a canonical_table_name helper (resolve to the stored case via
COLLATE NOCASE, excluding sqlite_* and __rdbms_* tables) and apply it at
the entry of every table-naming executor — drop table, add/drop/rename
column, change column type, add/drop constraint, add relationship, add
index, rename table, insert/update/delete, and the advanced SQL DML —
so the live schema, the metadata, and the CSV stay in step regardless of
how the user capitalized the name. This also folds the internal-table
guard into the same lookup (executors that previously lacked it now
refuse __rdbms_*/sqlite_* as "no such table"). do_rename_table now
accepts a case-variant source too.

Column names remain matched case-sensitively (a wrong case is refused as
"no such column" — strict, but never drifting), per the scope agreed
with the user.

Tests: tests/case_insensitive_names.rs — wrong-case rename-column,
insert (survives a fresh rebuild — no data loss), add-column, drop-table,
rename-table, and add-relationship, all with fresh-rebuild round-trips.
Full suite 1909 passing / 0 failing / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 10:04:27 +00:00
parent f7e77a86f8
commit a95c8074f3
3 changed files with 357 additions and 38 deletions
+112 -37
View File
@@ -2889,6 +2889,51 @@ fn user_table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
Ok(count > 0)
}
/// An engine-neutral "no such table" error for `name`.
fn no_such_table(name: &str) -> DbError {
DbError::Sqlite {
message: format!("no such table: {name}"),
kind: SqliteErrorKind::NoSuchTable,
}
}
/// Resolve a user-supplied table name to its **stored (canonical) case**,
/// or `None` if no such user table exists.
///
/// SQL identifiers are case-insensitive, so a user may type a table name
/// in any capitalization and the engine resolves it. But our metadata
/// tables (keyed by `table_name` / `parent_table` / `child_table`) and the
/// `data/<table>.csv` files are keyed by the *stored* case, and TEXT `=`
/// is case-sensitive — so an executor that used the as-typed name would
/// drift the metadata/CSV out of step with the live schema. Every executor
/// that names a table canonicalizes first and then operates on the
/// canonical name, keeping the live schema, the metadata, and the CSV in
/// step regardless of how the user capitalised the name.
///
/// Internal `__rdbms_*` tables are excluded (treated as non-existent),
/// folding the [`reject_internal_table_name`] guard into the same lookup.
fn canonical_table_name(conn: &Connection, name: &str) -> Result<Option<String>, DbError> {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_schema \
WHERE type = 'table' AND name = ?1 COLLATE NOCASE \
AND name NOT LIKE 'sqlite_%' \
AND substr(name, 1, 8) != '__rdbms_'",
)
.map_err(DbError::from_rusqlite)?;
let mut rows = stmt.query([name]).map_err(DbError::from_rusqlite)?;
match rows.next().map_err(DbError::from_rusqlite)? {
Some(row) => Ok(Some(row.get::<_, String>(0).map_err(DbError::from_rusqlite)?)),
None => Ok(None),
}
}
/// Resolve a table name to its canonical stored case, erroring with a
/// "no such table" if it does not exist (the common executor entry guard).
fn require_canonical_table(conn: &Connection, name: &str) -> Result<String, DbError> {
canonical_table_name(conn, name)?.ok_or_else(|| no_such_table(name))
}
fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, DbError> {
use rusqlite::types::ValueRef;
let v = row.get_ref(idx).map_err(DbError::from_rusqlite)?;
@@ -3269,6 +3314,11 @@ fn do_drop_table(
source: Option<&str>,
name: &str,
) -> Result<(), DbError> {
// Canonicalize the user-typed name to its stored case (and refuse a
// non-existent / internal table), so the metadata DELETEs and the CSV
// removal target the right name regardless of capitalization.
let canonical_name = require_canonical_table(conn, name)?;
let name = canonical_name.as_str();
// Refuse the drop while any *other* table still has a
// relationship pointing at this one — dropping the parent
// would leave dangling FK constraints in the children. The
@@ -3343,7 +3393,8 @@ fn do_add_column(
table: &str,
column: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
if matches!(column.ty, Type::Serial | Type::ShortId) {
// ADR-0029 §6: a `serial` / `shortid` column auto-fills
// its own values, so a separate `default` is ambiguous.
@@ -3679,11 +3730,13 @@ fn do_add_constraint(
column: &str,
constraint: &Constraint,
) -> Result<TableDescription, DbError> {
// Refuse the internal `__rdbms_*` tables up-front (as "no such
// table"), like the sibling schema-mutation executors. Closes the
// simple `add constraint` exposure and the SQL `ALTER TABLE … ADD
// CONSTRAINT` decomposition target (ADR-0035 §4g).
reject_internal_table_name(table)?;
// Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling
// schema-mutation executors. Closes the simple `add constraint`
// exposure and the SQL `ALTER TABLE … ADD CONSTRAINT` decomposition
// target (ADR-0035 §4g).
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let (col_is_pk, col_user_type) = {
let col = old_schema
@@ -3819,6 +3872,8 @@ fn do_drop_constraint(
column: &str,
kind: ConstraintKind,
) -> Result<TableDescription, DbError> {
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let (col_is_pk, present) = {
let col = old_schema
@@ -4242,7 +4297,8 @@ fn do_drop_column(
column: &str,
cascade: bool,
) -> Result<DropColumnResult, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
let col_info = schema
.columns
@@ -4365,7 +4421,8 @@ fn do_rename_column(
old: &str,
new: &str,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
if !schema.columns.iter().any(|c| c.name == old) {
return Err(DbError::Sqlite {
@@ -4484,20 +4541,14 @@ fn do_rename_table(
old: &str,
new: &str,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(old)?;
reject_internal_table_name(new)?;
// Canonicalize the source to its stored case (and refuse a
// non-existent / internal source as "no such table") — so a
// case-variant source name still resolves to the real table and the
// metadata UPDATEs below match the stored case.
let canonical_old = require_canonical_table(conn, old)?;
let old = canonical_old.as_str();
// Existence + collision: `read_schema` does not error on a missing
// table (`pragma_table_info` returns no rows), so check explicitly —
// and pre-empt the engine's own collision error so the refusal stays
// engine-neutral (ADR-0035 §9).
let tables = do_list_tables(conn)?;
if !tables.iter().any(|t| t == old) {
return Err(DbError::Sqlite {
message: format!("no such table: {old}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
if old == new {
return Err(DbError::Unsupported(format!(
"rename: new name is identical to the existing one (`{old}`)."
@@ -4515,6 +4566,7 @@ fn do_rename_table(
treats them as the same table, so there is nothing to rename."
)));
}
let tables = do_list_tables(conn)?;
if tables.iter().any(|t| t.eq_ignore_ascii_case(new)) {
return Err(DbError::Unsupported(format!(
"table `{new}` already exists; pick a different name."
@@ -4677,11 +4729,13 @@ fn do_change_column_type(
ty: Type,
mode: ChangeColumnMode,
) -> Result<ChangeColumnTypeResult, DbError> {
// Refuse the internal `__rdbms_*` tables up-front (as "no such
// table"), like the sibling column executors. Closes the simple
// `change column` exposure and the SQL `ALTER COLUMN TYPE`
// decomposition target (ADR-0035 §4f); user-confirmed 2026-05-25.
reject_internal_table_name(table)?;
// Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling
// column executors. Closes the simple `change column` exposure and
// the SQL `ALTER COLUMN TYPE` decomposition target (ADR-0035 §4f);
// user-confirmed 2026-05-25.
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let col_info = old_schema
.columns
@@ -6555,12 +6609,16 @@ fn do_add_relationship(
on_update: ReferentialAction,
create_fk: bool,
) -> Result<TableDescription, DbError> {
// Refuse the internal `__rdbms_*` tables on either endpoint (as "no
// such table"), like the sibling schema-mutation executors. Closes
// the simple `add 1:n relationship` exposure and the SQL `ALTER
// Canonicalize both endpoints to their stored case (and refuse a
// non-existent / internal `__rdbms_*` table as "no such table"), like
// the sibling schema-mutation executors — so the relationship metadata
// stores the stored-case names and `describe` / rebuild match them.
// Closes the simple `add 1:n relationship` exposure and the SQL `ALTER
// TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g).
reject_internal_table_name(parent_table)?;
reject_internal_table_name(child_table)?;
let canonical_parent = require_canonical_table(conn, parent_table)?;
let parent_table = canonical_parent.as_str();
let canonical_child = require_canonical_table(conn, child_table)?;
let child_table = canonical_child.as_str();
// 1. Read parent schema; verify the referenced column is a PK.
let parent_schema = read_schema(conn, parent_table)?;
let parent_col = parent_schema
@@ -6775,7 +6833,8 @@ fn do_alter_add_table_check(
name: Option<&str>,
expr_sql: &str,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
if name.is_some() && !check_table_has_name_column(conn)? {
@@ -6879,7 +6938,8 @@ fn do_alter_add_unique(
table: &str,
columns: &[String],
) -> Result<TableDescription, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
for c in columns {
if !old_schema.columns.iter().any(|oc| &oc.name == c) {
@@ -6945,7 +7005,8 @@ fn do_drop_constraint_by_name(
table: &str,
name: &str,
) -> Result<Option<TableDescription>, DbError> {
reject_internal_table_name(table)?;
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
// 1. A named table-level CHECK on this table?
if check_table_has_name_column(conn)? {
@@ -7122,10 +7183,12 @@ fn do_add_index(
columns: &[String],
unique: bool,
) -> Result<TableDescription, DbError> {
// 0. Internal tables are not user tables (ADR-0025 / ADR-0035 §4d) —
// refused on both the simple `add index` and SQL `CREATE INDEX`
// surfaces, which both reach here.
reject_internal_table_name(table)?;
// 0. Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table) — both the simple `add index` and SQL
// `CREATE INDEX` surfaces reach here, and the auto-index name embeds
// the table name, so it must use the stored case.
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
// 1. Table must exist; gather its columns.
let schema = read_schema(conn, table)?;
// 2. Every indexed column must exist on the table.
@@ -7649,6 +7712,8 @@ fn do_insert(
user_columns: Option<&[String]>,
user_values: &[Value],
) -> Result<InsertResult, DbError> {
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Resolve which columns the user is providing values for.
@@ -7809,6 +7874,8 @@ fn do_update(
"UPDATE requires at least one assignment".to_string(),
));
}
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Capture rowids of matching rows up front so we can fetch
@@ -7901,6 +7968,8 @@ fn do_delete(
table: &str,
filter: &RowFilter,
) -> Result<DeleteResult, DbError> {
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Snapshot child-table row counts before the delete so we
@@ -8208,6 +8277,8 @@ fn do_sql_insert(
returning: bool,
) -> Result<InsertResult, DbError> {
debug!(sql = %sql, table = %target_table, returning, "sql_insert");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// The `shortid` auto-fill rewrite reconstructs only `INSERT …
// VALUES …` and would drop any trailing clause — `ON CONFLICT …`
// (3h) and/or `RETURNING …` (3g). `row_source` is the clean
@@ -8294,6 +8365,8 @@ fn do_sql_update(
returning: bool,
) -> Result<UpdateResult, DbError> {
debug!(sql = %sql, table = %target_table, returning, "sql_update");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
@@ -8365,6 +8438,8 @@ fn do_sql_delete(
returning: bool,
) -> Result<DeleteResult, DbError> {
debug!(sql = %sql, table = %target_table, returning, "sql_delete");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// Snapshot child-table row counts before the delete so cascade
// effects can be detected by diffing afterwards (Amendment 2;