Files
rdbms-playground/tests/sql_alter_table.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

141 lines
5.3 KiB
Rust

//! 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"
);
}