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.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Sub-phase 4e Tier-3 end-to-end tests for advanced-mode SQL
|
||||
//! `ALTER TABLE` add/drop/rename column (ADR-0035 §4e).
|
||||
//!
|
||||
//! These drive the **full advanced-mode pipeline** via `run_replay`: a
|
||||
//! literal `alter table …` line is parsed in Advanced mode, routed to
|
||||
//! `Command::SqlAlterTable`, decomposed by the runtime to the existing
|
||||
//! column executor, and persisted. They prove the decomposition for all
|
||||
//! three actions and the **raw-text DEFAULT/CHECK ADD COLUMN** path (the
|
||||
//! 4e executor extension). The drop/rename refusals (PK / FK / index /
|
||||
//! table-CHECK) live in the shared executors and are covered by
|
||||
//! `tests/column_op_guards.rs` — the SQL surface reaches the same code.
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::Value;
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project;
|
||||
use rdbms_playground::runtime::run_replay;
|
||||
|
||||
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 db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||
r.block_on(db.describe_table("T".to_string(), None))
|
||||
.expect("describe")
|
||||
.columns
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
// A script exercising all three actions through the full pipeline.
|
||||
// `v` is added (simple) so there is a non-PK column to rename/drop;
|
||||
// a row is inserted before the ADD so the DEFAULT backfill is
|
||||
// exercised by the rebuild.
|
||||
std::fs::write(
|
||||
project.path().join("alter.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
add column T: v (text)\n\
|
||||
insert into T (id, v) values (1, 'a')\n\
|
||||
alter table T add column qty int default 0 check (qty >= 0)\n\
|
||||
alter table T rename column v to label\n\
|
||||
alter table T add column note text\n\
|
||||
alter table T drop column note\n",
|
||||
)
|
||||
.expect("write script");
|
||||
|
||||
let events = r.block_on(run_replay(&db, project.path(), "alter.commands"));
|
||||
match events.last().expect("at least one event") {
|
||||
AppEvent::ReplayCompleted { count, .. } => {
|
||||
assert_eq!(*count, 7, "all seven lines replayed; events: {events:?}");
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
||||
}
|
||||
|
||||
// Final schema: id, label (renamed from v), qty; `note` added then
|
||||
// dropped.
|
||||
let cols = column_names(&db, &r);
|
||||
assert_eq!(cols, vec!["id".to_string(), "label".to_string(), "qty".to_string()]);
|
||||
|
||||
// The DEFAULT backfilled the pre-existing row to qty = 0.
|
||||
let rows = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 1);
|
||||
// qty is the third column; the rebuild backfilled the default.
|
||||
assert_eq!(rows[0][2].as_deref(), Some("0"), "DEFAULT 0 backfilled the existing row");
|
||||
|
||||
// The CHECK (qty >= 0) is enforced: a negative qty is refused.
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("2".to_string()), Value::Number("-1".to_string())],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_err(),
|
||||
"the raw-text CHECK (qty >= 0) added via ALTER is enforced"
|
||||
);
|
||||
// A non-negative qty is accepted.
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("3".to_string()), Value::Number("7".to_string())],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.expect("qty = 7 satisfies the CHECK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_alter_add_column_survives_rebuild() {
|
||||
// The column added via SQL ALTER (with a raw CHECK) round-trips
|
||||
// through the text artifacts and survives a rebuild.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("alter.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
alter table T add column qty int check (qty >= 0)\n",
|
||||
)
|
||||
.expect("write script");
|
||||
r.block_on(run_replay(&db, project.path(), "alter.commands"));
|
||||
assert!(column_names(&db, &r).contains(&"qty".to_string()));
|
||||
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
.expect("rebuild");
|
||||
// The CHECK survives the rebuild — a negative qty is still refused.
|
||||
assert!(column_names(&db, &r).contains(&"qty".to_string()));
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("-5".to_string())],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_err(),
|
||||
"the ALTER-added CHECK is intact after rebuild"
|
||||
);
|
||||
}
|
||||
@@ -229,6 +229,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
DropIndex { .. } => "DropIndex".into(),
|
||||
SqlDropIndex { .. } => "SqlDropIndex".into(),
|
||||
SqlCreateIndex { .. } => "SqlCreateIndex".into(),
|
||||
SqlAlterTable { .. } => "SqlAlterTable".into(),
|
||||
AddConstraint { .. } => "AddConstraint".into(),
|
||||
DropConstraint { .. } => "DropConstraint".into(),
|
||||
ShowTable { .. } => "ShowTable".into(),
|
||||
|
||||
Reference in New Issue
Block a user