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