Files
rdbms-playground/tests/column_op_guards.rs
T
claude@clouddev1 bbc2e34b33 feat: ADR-0035 4e — ALTER TABLE add/drop/rename column
Advanced-only `alter` entry word; ALTER TABLE <T> ADD COLUMN <col> <type>
[constraints] | DROP COLUMN <col> | RENAME COLUMN <old> TO <new> ->
SqlAlterTable, runtime-decomposed to the existing column executors
(do_add_column / do_drop_column / do_rename_column) — one undo step each,
no new worker layer. The COLUMN keyword is required (reserves bare
RENAME TO for 4h, ADD CONSTRAINT for 4g).

- ADD COLUMN takes NOT NULL / UNIQUE / DEFAULT / CHECK (no PK / inline
  REFERENCES). do_add_column extended to consume the SQL raw-text
  default_sql / check_sql (sql_expr is validate-only, the 4a.2
  mechanism), reaching parity with CREATE TABLE's column constraints.
- Drop/rename column refuse a column any CHECK references — table-level
  AND column-level (incl. a column's own self-check on rename) — the
  4a.3 deferral, detected up-front by tokenizing the raw CHECK text
  (skipping string literals). In the shared executors, so it guards both
  the simple and SQL surfaces and fixes a latent rename-drift bug that
  desynced the stored CHECK text and broke rebuild.
- SQL DROP COLUMN refuses an index-covered column (no --cascade SQL
  spelling — matches SQLite + the simple default).
- The column executors and do_add_index gained an internal-__rdbms_*
  guard (refuse as "no such table"), closing a pre-existing exposure on
  both surfaces. (do_change_column_type / do_add_constraint /
  do_add_relationship are a tracked follow-up.)
- `alter` is advanced-only; AlterTableAction::AddColumn is boxed
  (clippy::large_enum_variant).

Docs: ADR-0035 status + §13 4e; ADR README; requirements.md Q1. Plan:
docs/plans/20260525-adr-0035-sql-ddl-4e.md.

Tests: 1854 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
2026-05-25 19:49:13 +00:00

213 lines
8.1 KiB
Rust

//! Executor-level guards on the shared column operations (ADR-0035 §4e).
//!
//! These guards live in `do_add_column` / `do_drop_column` /
//! `do_rename_column`, so they apply to BOTH the simple-mode DSL
//! commands (exercised here) and the advanced-mode SQL `ALTER TABLE`
//! (which reaches the same executors). Two guards:
//! 1. internal `__rdbms_*` tables are refused as "no such table";
//! 2. dropping/renaming a column a table-level CHECK references is
//! refused up-front (the 4a.3 deferral; it also fixes a latent
//! rename-drift bug that would break a later rebuild).
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
.expect("open db with persistence");
(project, db, dir)
}
/// `T (id int pk, a int, b int, c text)` with a table-level CHECK
/// `a < b`.
fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
ColumnSpec::new("c", Type::Text),
],
vec!["id".to_string()],
vec![],
vec!["a < b".to_string()],
vec![],
false,
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
))
.expect("create T with table CHECK");
}
#[test]
fn simple_column_ops_refuse_internal_tables() {
let (_p, db, _d) = open();
let r = rt();
let internal = "__rdbms_playground_columns".to_string();
assert!(
r.block_on(db.add_column(
internal.clone(),
ColumnSpec::new("x", Type::Int),
Some("add column".to_string())
))
.is_err(),
"add column on an internal table is refused"
);
assert!(
r.block_on(db.drop_column(internal.clone(), "table_name".to_string(), false, None))
.is_err(),
"drop column on an internal table is refused"
);
assert!(
r.block_on(db.rename_column(internal, "table_name".to_string(), "tn".to_string(), None))
.is_err(),
"rename column on an internal table is refused"
);
}
#[test]
fn drop_column_referenced_by_a_table_check_is_refused() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced by the CHECK `a < b` → refused (both surfaces;
// here via the simple `drop column`).
assert!(
r.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.is_err(),
"dropping a CHECK-referenced column is refused"
);
// `c` is not referenced → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
.expect("dropping an unreferenced column succeeds");
}
#[test]
fn rename_column_referenced_by_a_table_check_is_refused() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced → refused (without this guard, a native rename
// would silently drift the CHECK metadata and break rebuild).
assert!(
r.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None))
.is_err(),
"renaming a CHECK-referenced column is refused"
);
// `c` is not referenced → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None))
.expect("renaming an unreferenced column succeeds");
}
/// `T (id int pk, price int, discount int CHECK(discount < price),
/// qty int CHECK(qty >= 0))` — column-level CHECKs (ADR-0035 §4e).
fn make_t_with_column_checks(db: &Database, r: &tokio::runtime::Runtime) {
let mut discount = ColumnSpec::new("discount", Type::Int);
discount.check_sql = Some("discount < price".to_string());
let mut qty = ColumnSpec::new("qty", Type::Int);
qty.check_sql = Some("qty >= 0".to_string());
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("price", Type::Int),
discount,
qty,
],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (...)".to_string()),
))
.expect("create T with column CHECKs");
}
#[test]
fn rename_column_with_a_column_level_check_is_refused() {
// A native RENAME would leave the stored column-level CHECK text
// stale (drift → broken rebuild), so it is refused — including a
// column's own self-check.
let (_p, db, _d) = open();
let r = rt();
make_t_with_column_checks(&db, &r);
// `qty`'s own check `qty >= 0` references qty → refused.
assert!(
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
.is_err(),
"renaming a column with its own column-level CHECK is refused"
);
// `price` is referenced by `discount`'s check `discount < price`.
assert!(
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
.is_err(),
"renaming a column referenced by another column's CHECK is refused"
);
// `id` is referenced by no CHECK → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "id".to_string(), "pk".to_string(), None))
.expect("renaming an unreferenced column succeeds");
}
#[test]
fn drop_column_referenced_by_another_columns_check_is_refused_but_own_check_drops() {
let (p, db, _d) = open();
let r = rt();
make_t_with_column_checks(&db, &r);
// `price` is referenced by `discount`'s check → refused.
assert!(
r.block_on(db.drop_column("T".to_string(), "price".to_string(), false, None))
.is_err(),
"dropping a column another column's CHECK references is refused"
);
// `qty` has only its OWN check → it drops with the column.
r.block_on(db.drop_column("T".to_string(), "qty".to_string(), false, None))
.expect("dropping a column whose only CHECK is its own succeeds");
// Rebuild still works (the remaining `discount < price` CHECK's
// columns survive).
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild succeeds after dropping the self-checked column");
}
#[test]
fn rebuild_survives_after_dropping_an_unreferenced_column() {
// Guard is not over-broad: a table that carries a CHECK still
// rebuilds after an unrelated column is dropped (the CHECK's
// referenced columns remain).
let (p, db, _d) = open();
let r = rt();
make_t_with_check(&db, &r);
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
.expect("drop unreferenced column");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild succeeds — the CHECK still references existing columns");
// The CHECK is intact: it still enforces a < b.
assert!(
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
vec![
rdbms_playground::dsl::Value::Number("1".to_string()),
rdbms_playground::dsl::Value::Number("5".to_string()),
rdbms_playground::dsl::Value::Number("3".to_string()),
],
Some("insert".to_string()),
))
.is_err(),
"CHECK a < b still enforced after the rebuild (5 < 3 is false)"
);
}