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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user