feat: ADR-0035 4g — ALTER TABLE add/drop constraint + add FK

ALTER TABLE <T> ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)
and DROP CONSTRAINT <name>. ADD = table-CHECK + composite UNIQUE + FK
(ADD PRIMARY KEY and a named UNIQUE refused — composite UNIQUE is
anonymous in our model). Each ADD reuses a low-level path with a dry-run
guard (table-CHECK/UNIQUE rebuild; FK -> add_relationship, bare
REFERENCES -> parent single PK). DROP CONSTRAINT resolves the name to a
named table-CHECK then a child-side FK, else refuses. One undo step each.

Named table-CHECKs round-trip: a nullable `name` column on
__rdbms_playground_table_checks (rebuild-only arrival; a named add on a
pre-4g project is refused with a "rebuild first" hint) plus a project.yaml
check_constraints {expr, name} extension (bare-string form still reads).
The internal-__rdbms_* guard was folded into do_add_constraint /
do_add_relationship, completing that guard class.

Grammar: the action Choice keeps one branch per verb (add/drop/rename/
alter) with an inner Choice fanning out on the distinct second keyword,
since the walker's Choice does not backtrack between same-led branches.

Tests: 7 Tier-1 parse + 2 yaml round-trip + 1 internal-guard + 9 Tier-3
e2e. Help/usage refreshed; ADR-0035 §13 4g + README + requirements.md in
lockstep.
This commit is contained in:
claude@clouddev1
2026-05-25 22:07:50 +00:00
parent 5b76315d1e
commit 6ff97f6e20
16 changed files with 1747 additions and 84 deletions
+76 -2
View File
@@ -10,7 +10,8 @@
//! rename-drift bug that would break a later rebuild).
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, Type};
use rdbms_playground::dsl::command::Constraint;
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, ReferentialAction, Type};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
@@ -89,7 +90,7 @@ fn simple_column_ops_refuse_internal_tables() {
// happen to hit.
let err = r
.block_on(db.change_column_type(
internal,
internal.clone(),
"table_name".to_string(),
Type::Int,
ChangeColumnMode::Default,
@@ -100,6 +101,79 @@ fn simple_column_ops_refuse_internal_tables() {
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
);
// `add constraint` (the simple surface; also the SQL `ALTER TABLE …
// ADD CONSTRAINT` decomposition target — ADR-0035 §4g) is refused:
// the guard lives in `do_add_constraint`.
let err = r
.block_on(db.add_constraint(
internal,
"table_name".to_string(),
Constraint::NotNull,
None,
))
.expect_err("add constraint on an internal table is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
);
}
#[test]
fn add_relationship_refuses_internal_tables() {
// The guard lives in `do_add_relationship` (ADR-0035 §4g) and covers
// both the parent and the child endpoint — so the simple `add 1:n
// relationship` and the SQL `ALTER TABLE … ADD FOREIGN KEY` (which
// reaches the same executor) cannot touch an internal table.
let (_p, db, _d) = open();
let r = rt();
let internal = "__rdbms_playground_relationships".to_string();
// Internal *parent* — refused up-front.
let err = r
.block_on(db.add_relationship(
None,
internal.clone(),
"name".to_string(),
"C".to_string(),
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal parent is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal parent), got: {err:?}"
);
// Internal *child* — also refused (a real parent exists).
r.block_on(db.sql_create_table(
"P".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table P (id int primary key)".to_string()),
))
.expect("create P");
let err = r
.block_on(db.add_relationship(
None,
"P".to_string(),
"id".to_string(),
internal,
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal child is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal child), got: {err:?}"
);
}
#[test]
+238
View File
@@ -358,3 +358,241 @@ fn e2e_alter_column_type_is_one_undo_step() {
);
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type");
}
// --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) -----------
/// True if inserting `(id, qty)` into table `T` succeeds.
fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64) -> bool {
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number(id.to_string()), Value::Number(qty.to_string())],
Some("insert".to_string()),
))
.is_ok()
}
#[test]
fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
// ADD a named table-CHECK; it is enforced; it round-trips through a
// rebuild *with its name* — proven by DROP CONSTRAINT <name> still
// resolving after the rebuild (the name reached project.yaml and back).
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("c.commands"),
"create table T with pk id(int)\n\
add column T: qty (int)\n\
alter table T add constraint qty_positive check (qty >= 0)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "c.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 3),
"events: {events:?}"
);
// Enforced: qty = -1 refused, qty = 5 accepted.
assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1");
assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK");
// Rebuild from text, then DROP CONSTRAINT by name must still work →
// the name survived the round-trip.
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild");
assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild");
std::fs::write(
project.path().join("drop.commands"),
"alter table T drop constraint qty_positive\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "drop.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"DROP CONSTRAINT resolved the name after rebuild; events: {events:?}"
);
// After the drop the CHECK no longer applies: qty = -1 is accepted.
assert!(insert_t_qty_ok(&db, &r, 4, -1), "the CHECK was dropped");
}
#[test]
fn e2e_add_check_with_violating_data_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
add column T: qty (int)\n\
insert into T (id, qty) values (1, -5)\n\
alter table T add check (qty >= 0)\n",
),
"adding a CHECK that existing rows violate is refused"
);
}
#[test]
fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("u.commands"),
"create table T with pk id(int)\n\
add column T: a (int)\n\
add column T: b (int)\n\
insert into T (id, a, b) values (1, 1, 2)\n\
alter table T add unique (a, b)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
let dup_ok = |id: i64, a: i64, b: i64| {
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
vec![
Value::Number(id.to_string()),
Value::Number(a.to_string()),
Value::Number(b.to_string()),
],
Some("insert".to_string()),
))
.is_ok()
};
assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)");
assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted");
// Survives rebuild (the unique_constraints yaml path).
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild");
assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild");
}
#[test]
fn e2e_add_unique_with_duplicate_data_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
add column T: a (int)\n\
insert into T (id, a) values (1, 7)\n\
insert into T (id, a) values (2, 7)\n\
alter table T add unique (a)\n",
),
"adding a UNIQUE that existing rows violate is refused"
);
}
#[test]
fn e2e_add_foreign_key_creates_an_enforced_relationship() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("fk.commands"),
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
insert into P (id) values (1)\n\
alter table C add constraint c_to_p foreign key (pid) references P(id)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
// FK enforced: a child row referencing a missing parent is rejected;
// one referencing the existing parent (id = 1) is accepted.
let insert_c = |cid: i64, pid: i64| {
r.block_on(db.insert(
"C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())],
Some("insert".to_string()),
))
};
assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted");
assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected");
}
#[test]
fn e2e_drop_constraint_removes_a_named_foreign_key() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("fk.commands"),
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
alter table C add constraint c_to_p foreign key (pid) references P(id)\n\
alter table C drop constraint c_to_p\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
// The FK is gone: a child referencing a missing parent now succeeds.
assert!(
r.block_on(db.insert(
"C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("999".to_string())],
Some("insert".to_string()),
))
.is_ok(),
"after DROP CONSTRAINT the FK no longer applies"
);
}
#[test]
fn e2e_constraint_name_collision_is_refused() {
// A named CHECK cannot reuse a relationship (FK) name on the same
// table — both are `DROP CONSTRAINT <name>` targets, so a collision
// would make the drop ambiguous.
assert!(
replay_is_refused(
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
alter table C add constraint dup foreign key (pid) references P(id)\n\
alter table C add constraint dup check (cid > 0)\n",
),
"a CHECK reusing an existing FK name on the table is refused"
);
}
#[test]
fn e2e_drop_unknown_constraint_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
alter table T drop constraint nope\n",
),
"dropping a non-existent constraint is refused"
);
}
#[test]
fn e2e_add_constraint_is_one_undo_step() {
// ADD CONSTRAINT CHECK is one rebuild = one undo step; driven through
// the full SQL pipeline, then undone in one.
let (project, db, _d) = open_with_undo();
let r = rt();
std::fs::write(
project.path().join("c.commands"),
"create table T with pk id(int)\n\
add column T: qty (int)\n\
insert into T (id, qty) values (1, 5)\n\
alter table T add constraint qty_positive check (qty >= 0)\n",
)
.expect("write");
r.block_on(run_replay(&db, project.path(), "c.commands"));
assert!(!insert_t_qty_ok(&db, &r, 2, -1), "the CHECK is enforced");
assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"the ADD CONSTRAINT was one undo step"
);
// After undo the CHECK is gone: qty = -1 is accepted.
assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK");
}