Files
rdbms-playground/tests/it/sql_create_table.rs
claude@clouddev1 e8fa859ab9 refactor(db): unwind vestigial worker source plumbing (ADR-0052 follow-up)
ADR-0052 moved success journaling out of the worker to the dispatch
layer, leaving the `source` that handlers threaded purely for the
worker's old history.log write dead. Remove it:

- drop `_source` from finalize_persistence and do_rebuild_from_text
- inline + delete the three read-only *_request wrappers
- drop the now-unused `source` param from the ~30 forwarding worker
  handlers (leaf + composite), compiler-guided
- remove the `source` field from the DescribeTable/QueryData/RunSelect
  requests and their DatabaseHandle methods (call sites updated)

The only worker `source` left is the snapshot/undo label
(snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical,
no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean.
2026-06-14 13:47:49 +00:00

1365 lines
49 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sub-phase 4a integration tests for advanced-mode SQL
//! `CREATE TABLE` (ADR-0035 §1/§4).
//!
//! Worker round-trip: a `Command::SqlCreateTable` executes
//! **structurally** through the existing `do_create_table` machinery,
//! so an advanced-mode-created table is a first-class playground
//! object (metadata + the ten-type vocabulary). Covers:
//! - Created tables appear in `list_tables` and `describe_table`
//! reports the playground `user_type` per column.
//! - A `serial` sole-PK autoincrements even in a multi-column table
//! (the §6.4 inline-`PRIMARY KEY` extension).
//! - `IF NOT EXISTS` on an existing table is a no-op (`Skipped`); the
//! plain form errors when the table exists (§4).
//! - One SQL `CREATE TABLE` is exactly one undo step (ADR-0006).
//!
//! Parsing (text → `Command::SqlCreateTable`) is covered by the
//! `builder_tests` in `src/dsl/grammar/sql_create_table.rs`; these
//! tests drive the worker directly, mirroring `tests/sql_insert.rs`.
use rdbms_playground::db::{CreateOutcome, Database};
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, SqlForeignKey, Type, Value};
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(undo: bool) -> (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, undo)
.expect("open db with persistence");
(project, db, dir)
}
#[test]
fn created_table_appears_with_playground_types() {
let (_p, db, _d) = open(false);
let r = rt();
let out = r
.block_on(db.sql_create_table(
"Widget".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("name", Type::Text),
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table Widget (id int primary key, name text)".to_string()),
))
.expect("create should succeed");
assert!(matches!(out, CreateOutcome::Created(_)));
let tables = r.block_on(db.list_tables()).expect("list");
assert!(tables.contains(&"Widget".to_string()));
let desc = r
.block_on(db.describe_table("Widget".to_string()))
.expect("describe");
let types: Vec<(String, Option<Type>)> = desc
.columns
.iter()
.map(|c| (c.name.clone(), c.user_type))
.collect();
assert_eq!(
types,
vec![
("id".to_string(), Some(Type::Int)),
("name".to_string(), Some(Type::Text)),
]
);
}
#[test]
fn integer_primary_key_is_plain_int() {
// ADR-0035 §3: INTEGER PRIMARY KEY maps to plain `int`, not
// `serial`. The structural object reports `int`.
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id integer primary key)".to_string()),
))
.expect("create");
let desc = r
.block_on(db.describe_table("T".to_string()))
.expect("describe");
assert_eq!(desc.columns[0].user_type, Some(Type::Int));
}
#[test]
fn serial_pk_autoincrements_in_multi_column_table() {
// §6.4: a `serial` sole-PK in a *multi-column* table must inline
// `PRIMARY KEY` so it keeps autoincrement (rowid-alias) semantics
// — the case simple mode never produces in one statement.
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("name", Type::Text),
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id serial primary key, name text)".to_string()),
))
.expect("create");
// Form B inserts (no column list): the serial id is auto-filled.
for name in ["a", "b"] {
r.block_on(db.insert(
"T".to_string(),
None,
vec![Value::Text(name.to_string())],
Some(format!("insert into T (name) values ('{name}')")),
))
.expect("insert");
}
let data = r
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let id_idx = data
.columns
.iter()
.position(|c| c == "id")
.expect("id column");
let mut ids: Vec<Option<String>> = data.rows.iter().map(|row| row[id_idx].clone()).collect();
ids.sort();
assert_eq!(
ids,
vec![Some("1".to_string()), Some("2".to_string())],
"serial PK autoincremented 1, 2"
);
}
#[test]
fn if_not_exists_is_a_noop_when_table_exists() {
let (_p, db, _d) = open(false);
let r = rt();
let specs = || vec![ColumnSpec::new("id", Type::Int)];
r.block_on(db.sql_create_table(
"T".to_string(),
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id int)".to_string()),
))
.expect("first create");
let out = r
.block_on(db.sql_create_table(
"T".to_string(),
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
true, // IF NOT EXISTS
Some("create table if not exists T (id int)".to_string()),
))
.expect("second create should succeed as a no-op");
assert!(
matches!(out, CreateOutcome::Skipped(_)),
"IF NOT EXISTS on an existing table is a no-op"
);
let tables = r.block_on(db.list_tables()).expect("list");
assert_eq!(tables.iter().filter(|t| t.as_str() == "T").count(), 1);
}
#[test]
fn table_without_primary_key_is_allowed() {
// Advanced mode allows a PK-less table (standard SQL; the
// "trust the user like SQL" posture, ADR-0035 §7) — unlike simple
// mode, which requires/defaults a PK. User-confirmed 2026-05-25.
let (_p, db, _d) = open(false);
let r = rt();
let out = r
.block_on(db.sql_create_table(
"Notes".to_string(),
vec![ColumnSpec::new("body", Type::Text)],
vec![], // no primary key
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table Notes (body text)".to_string()),
))
.expect("a PK-less table should create");
assert!(matches!(out, CreateOutcome::Created(_)));
// And it is usable: a row inserts and reads back.
r.block_on(db.insert(
"Notes".to_string(),
None,
vec![Value::Text("hello".to_string())],
Some("insert into Notes (body) values ('hello')".to_string()),
))
.expect("insert into PK-less table");
let data = r
.block_on(db.query_data("Notes".to_string(), None, None))
.expect("query");
assert_eq!(data.rows.len(), 1);
}
/// A column carrying a raw-SQL `CHECK` (ADR-0035 §4a.2).
fn col_check(name: &str, ty: Type, check_sql: &str) -> ColumnSpec {
let mut c = ColumnSpec::new(name, ty);
c.check_sql = Some(check_sql.to_string());
c
}
/// A column carrying a raw-SQL `DEFAULT` (ADR-0035 §4a.2).
fn col_default(name: &str, ty: Type, default_sql: &str) -> ColumnSpec {
let mut c = ColumnSpec::new(name, ty);
c.default_sql = Some(default_sql.to_string());
c
}
#[test]
fn check_constraint_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Serial), col_check("price", Type::Real, "price >= 0")],
vec!["id".to_string()],
vec![],
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id serial primary key, price real check (price >= 0))".to_string()),
))
.expect("create");
// A satisfying row inserts; a violating one is rejected by the CHECK.
r.block_on(db.insert(
"T".to_string(),
Some(vec!["price".to_string()]),
vec![Value::Number("10".to_string())],
Some("insert".to_string()),
))
.expect("price 10 satisfies the check");
let bad = r.block_on(db.insert(
"T".to_string(),
Some(vec!["price".to_string()]),
vec![Value::Number("-5".to_string())],
Some("insert".to_string()),
));
assert!(bad.is_err(), "CHECK (price >= 0) rejects -5");
}
#[test]
fn default_is_applied_when_column_omitted() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("label", Type::Text),
col_default("n", Type::Int, "7"),
],
vec!["id".to_string()],
vec![],
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id serial primary key, label text, n int default 7)".to_string()),
))
.expect("create");
// Insert only `label`; `id` auto-fills and `n` takes its default.
r.block_on(db.insert(
"T".to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text("x".to_string())],
Some("insert".to_string()),
))
.expect("insert");
let data = r
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
}
#[test]
fn composite_unique_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (a int, b int, unique (a, b))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("first (1,2)");
assert!(r.block_on(ins("1", "2")).is_err(), "UNIQUE(a,b) rejects duplicate (1,2)");
r.block_on(ins("1", "3")).expect("distinct (1,3) is allowed");
}
#[test]
fn check_default_and_composite_unique_survive_rebuild() {
// The part-D round-trip: CHECK (metadata), DEFAULT (PRAGMA), and
// composite UNIQUE (TableSchema + PRAGMA index_list origin 'u')
// must all be reconstructed from project.yaml on rebuild.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
col_check("price", Type::Real, "price >= 0"),
col_default("n", Type::Int, "7"),
],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
vec![], // no table CHECK
vec![], // no FK
false,
Some(
"create table T (a int, b int, price real check (price >= 0), \
n int default 7, unique (a, b))"
.to_string(),
),
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
let ins = |a: &str, b: &str, price: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string(), "price".to_string()]),
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(price.to_string()),
],
Some("insert".to_string()),
)
};
// CHECK survived: a negative price is rejected.
assert!(r.block_on(ins("1", "1", "-1")).is_err(), "CHECK survived rebuild");
// A valid row inserts; DEFAULT n=7 survived.
r.block_on(ins("1", "1", "5")).expect("valid row");
let data = r
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
// Composite UNIQUE survived: (1,1) again is rejected.
assert!(r.block_on(ins("1", "1", "5")).is_err(), "composite UNIQUE survived rebuild");
}
#[test]
fn table_level_check_is_enforced() {
// ADR-0035 §4a.3: a multi-column CHECK has no column to hang on and
// the engine reports no CHECKs, so it round-trips via a metadata
// table. Here we prove the engine actually enforces it.
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()], // table-level CHECK
vec![], // no FK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) satisfies a < b");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK (a < b) rejects (2,1)");
assert!(r.block_on(ins("3", "3")).is_err(), "CHECK (a < b) rejects (3,3)");
}
#[test]
fn multiple_table_level_checks_all_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
ColumnSpec::new("c", Type::Int),
],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string(), "b < c".to_string()],
vec![], // no FK
false,
Some("create table T (a int, b int, c int, check (a < b), check (b < c))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str, c: &str| {
db.insert(
"T".to_string(),
None,
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(c.to_string()),
],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2", "3")).expect("(1,2,3) satisfies both checks");
assert!(r.block_on(ins("2", "1", "3")).is_err(), "first CHECK (a < b) enforced");
assert!(r.block_on(ins("1", "3", "2")).is_err(), "second CHECK (b < c) enforced");
}
#[test]
fn dropping_a_table_clears_its_table_check_metadata() {
// The CHECK metadata table is keyed by (table_name, seq). If a drop
// left orphan rows behind, re-creating the same table with a CHECK
// would collide on that primary key and fail. A clean create→drop→
// create round-trip proves the drop path clears the metadata.
let (_p, db, _d) = open(false);
let r = rt();
let make = || {
db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
vec![], // no FK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
)
};
r.block_on(make()).expect("first create");
r.block_on(db.drop_table("T".to_string(), Some("drop table T".to_string())))
.expect("drop");
r.block_on(make()).expect("re-create must not collide on orphaned CHECK metadata");
// The re-created CHECK is enforced (and there is exactly one of it).
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) valid");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK enforced after re-create");
}
#[test]
fn table_level_check_survives_a_rebuild_triggering_column_add() {
// Cross-cutting probe (ADR-0013 rebuild primitive × 4a.3 metadata):
// adding a constrained column to a table that carries a table-level
// CHECK rebuilds the table via `schema_to_ddl`. The CHECK must
// survive both in the engine (enforced) AND in the metadata table
// (so a *later* rebuild_from_text still re-emits it) — otherwise the
// constraint is silently lost the next time the table is rebuilt.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
vec![], // no FK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
// A UNIQUE column forces the rebuild path (ADR-0029 §6).
let mut c = ColumnSpec::new("c", Type::Int);
c.unique = true;
r.block_on(db.add_column("T".to_string(), c, Some("add column T: c(int) unique".to_string())))
.expect("add column via rebuild");
let ins = |a: &str, b: &str, c: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(c.to_string()),
],
Some("insert".to_string()),
)
};
// Engine still enforces the CHECK right after the rebuild.
r.block_on(ins("1", "2", "10")).expect("(1,2) valid after column add");
assert!(r.block_on(ins("2", "1", "20")).is_err(), "CHECK survived the column-add rebuild");
// And the metadata survived too: a fresh rebuild from project.yaml
// re-emits the CHECK (it would be lost if the rebuild primitive had
// dropped the table_checks rows without repopulating them).
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
assert!(
r.block_on(ins("9", "8", "30")).is_err(),
"CHECK still present after a later rebuild_from_text — metadata was preserved"
);
}
#[test]
fn table_level_check_survives_rebuild() {
// The part-D proof for 4a.3: the engine reports no CHECK, so the
// constraint can only be reconstructed from the metadata table via
// project.yaml. After a rebuild it must still be enforced.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
vec![], // no FK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) still valid after rebuild");
assert!(
r.block_on(ins("5", "4")).is_err(),
"table-level CHECK survived rebuild via the metadata table"
);
}
#[test]
fn if_not_exists_noop_is_journalled() {
// A successful no-op is still a submission and belongs in the
// complete journal (ADR-0034) — like read-only `show table`, and
// unlike a *failed* duplicate-create (journalled `err`).
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id int)".to_string()),
))
.expect("first create");
let noop = "create table if not exists T (id int)";
let out = r
.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
true,
Some(noop.to_string()),
))
.expect("no-op");
assert!(matches!(out, CreateOutcome::Skipped(_)));
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]
fn plain_create_errors_when_table_exists() {
let (_p, db, _d) = open(false);
let r = rt();
let specs = || vec![ColumnSpec::new("id", Type::Int)];
r.block_on(db.sql_create_table(
"T".to_string(),
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id int)".to_string()),
))
.expect("first create");
let err = r.block_on(db.sql_create_table(
"T".to_string(),
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false, // no IF NOT EXISTS
Some("create table T (id int)".to_string()),
));
assert!(err.is_err(), "re-creating an existing table without IF NOT EXISTS errors");
}
#[test]
fn sql_create_table_is_one_undo_step() {
let (_p, db, _d) = open(true); // undo enabled
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id int)".to_string()),
))
.expect("create");
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
let undone = r.block_on(db.undo()).expect("undo call");
assert!(undone.is_some(), "the CREATE TABLE recorded one undo step");
assert!(
!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()),
"table is gone after a single undo"
);
}
/// Sorted `id` column values of table `T`.
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
let d = r
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let idx = d.columns.iter().position(|c| c == "id").expect("id column");
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
v.sort();
v
}
fn insert_row(db: &Database, r: &tokio::runtime::Runtime, name: &str) {
r.block_on(db.insert(
"T".to_string(),
None,
vec![Value::Text(name.to_string())],
Some(format!("insert into T (name) values ('{name}')")),
))
.expect("insert");
}
/// `serial` PK as the **first** column must keep autoincrement across a
/// rebuild: the structural create and the `schema_to_ddl` rebuild both
/// inline `PRIMARY KEY` on a first-column single PK, so the DDL is
/// identical and the sequence continues (id 3 after rebuild).
#[test]
fn serial_pk_first_column_autoincrements_after_rebuild() {
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("name", Type::Text),
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (id serial primary key, name text)".to_string()),
))
.expect("create");
insert_row(&db, &r, "a");
insert_row(&db, &r, "b");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
insert_row(&db, &r, "c");
assert_eq!(
ids(&db, &r),
vec![Some("1".to_string()), Some("2".to_string()), Some("3".to_string())]
);
}
/// `serial` PK as a **non-first** column must also keep autoincrement
/// across a rebuild. Here the rebuild emits a *table-level* PK (the PK
/// is not column 0), proving autoincrement does not rely on the
/// rowid-alias / inline-PK form — the insert path computes the next
/// value itself (ADR-0035 §6.4). Guards against silent round-trip loss.
#[test]
fn serial_pk_non_first_column_autoincrements_after_rebuild() {
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("name", Type::Text),
ColumnSpec::new("id", Type::Serial),
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
vec![], // no FK
false,
Some("create table T (name text, id serial primary key)".to_string()),
))
.expect("create");
insert_row(&db, &r, "a");
insert_row(&db, &r, "b");
assert_eq!(ids(&db, &r), vec![Some("1".to_string()), Some("2".to_string())]);
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
insert_row(&db, &r, "c");
assert_eq!(
ids(&db, &r),
vec![Some("1".to_string()), Some("2".to_string()), Some("3".to_string())],
"serial keeps autoincrement after a rebuild even as a non-first column"
);
}
#[test]
fn dropping_a_column_a_table_check_references_fails_cleanly() {
// Cross-cutting safety probe: a simple-mode `drop column` of a column
// that a table-level CHECK references rebuilds the table via
// `schema_to_ddl`, which re-emits `CHECK (a < b)` for a temp table
// that no longer has `a` — the engine rejects it. This must fail
// *cleanly* (the rebuild transaction rolls back), leaving the table
// fully intact, never half-migrated. Up-front detection (parsing the
// referenced columns out of the raw CHECK text so the refusal is
// deliberate) is 4e work; the friendly wording itself is H1. Today's
// clean engine-level rejection is the safe interim (user-confirmed).
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
vec![], // no FK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
let dropped = r.block_on(db.drop_column(
"T".to_string(),
"a".to_string(),
false,
Some("drop column T: a".to_string()),
));
assert!(dropped.is_err(), "dropping a column a CHECK references is rejected");
// The table is intact: both columns survive (rollback) ...
let desc = r
.block_on(db.describe_table("T".to_string()))
.expect("describe still works");
assert_eq!(
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
vec!["a".to_string(), "b".to_string()],
"the failed drop rolled back — no half-migrated table"
);
// ... and the CHECK is still enforced.
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string()]),
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) valid — table survived intact");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK still enforced after the failed drop");
}
// =================================================================
// Sub-phase 4b — foreign keys in CREATE TABLE (ADR-0035 §5).
//
// These drive the worker directly with `SqlForeignKey` specs; the
// grammar (text -> Command) is covered by `builder_tests`. An FK is
// the SQL spelling of an ADR-0013 named relationship, created in the
// same transaction as the table (one undo step).
// =================================================================
/// A simple FK spec with default (no action) referential actions.
fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> SqlForeignKey {
SqlForeignKey {
name: None,
child_columns: vec![child_column.to_string()],
parent_table: parent_table.to_string(),
parent_columns: parent_column.map(|c| vec![c.to_string()]),
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
inline: false,
}
}
/// Create `parent (id serial primary key, label text)` — the extra
/// column lets a row be inserted (a sole auto-fill serial has nothing
/// to bind). `id` auto-fills 1, 2, … on each label insert.
fn make_parent(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table(
"parent".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![], // no FK
false,
Some("create table parent (id serial primary key, label text)".to_string()),
))
.expect("create parent");
}
/// Insert a parent row (label only); its serial `id` auto-fills.
fn insert_parent_row(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.insert(
"parent".to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text("x".to_string())],
Some("insert".to_string()),
))
.expect("parent row");
}
#[test]
fn foreign_key_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
// A parent row, then a valid child referencing it.
insert_parent_row(&db, &r); // id=1
r.block_on(db.insert(
"child".to_string(),
Some(vec!["pid".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
))
.expect("child pid=1 references an existing parent");
// A child referencing a non-existent parent is rejected.
let bad = r.block_on(db.insert(
"child".to_string(),
Some(vec!["pid".to_string()]),
vec![Value::Number("999".to_string())],
Some("insert".to_string()),
));
assert!(bad.is_err(), "FK rejects pid=999 (no such parent)");
}
#[test]
fn foreign_key_creates_named_relationship_visible_in_describe() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
// The child has an outbound relationship; the parent an inbound one.
let child = r.block_on(db.describe_table("child".to_string())).expect("describe child");
assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
let rel = &child.outbound_relationships[0];
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
assert_eq!(rel.other_table, "parent");
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
}
#[test]
fn explicit_constraint_name_is_used() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
let mut spec = fk("pid", "parent", Some("id"));
spec.name = Some("child_to_parent".to_string());
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![spec],
false,
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
))
.expect("create child with named FK");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
}
#[test]
fn bare_references_resolves_to_parent_single_column_pk() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", None)], // bare REFERENCES parent
false,
Some("create table child (id serial primary key, pid int references parent)".to_string()),
))
.expect("create child with bare REFERENCES");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
}
#[test]
fn self_referencing_foreign_key_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"emp".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("mgr", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("mgr", "emp", Some("id"))], // self-reference
false,
Some("create table emp (id int primary key, mgr int references emp(id))".to_string()),
))
.expect("create self-referencing emp");
let ins = |id: &str, mgr: Option<&str>| {
let (cols, vals) = mgr.map_or_else(
|| (vec!["id".to_string()], vec![Value::Number(id.to_string())]),
|m| {
(
vec!["id".to_string(), "mgr".to_string()],
vec![Value::Number(id.to_string()), Value::Number(m.to_string())],
)
},
);
db.insert("emp".to_string(), Some(cols), vals, Some("insert".to_string()))
};
r.block_on(ins("1", None)).expect("root (mgr NULL)");
r.block_on(ins("2", Some("1"))).expect("emp 2 reports to 1");
assert!(r.block_on(ins("3", Some("99"))).is_err(), "self-FK rejects mgr=99");
}
#[test]
fn foreign_key_type_mismatch_is_rejected() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r); // parent.id is serial -> fk_target_type int
// child.pid declared text -> incompatible with int.
let res = r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid text references parent(id))".to_string()),
));
assert!(res.is_err(), "FK column type must match the parent's fk_target_type");
}
#[test]
fn foreign_key_to_non_pk_column_is_rejected() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"parent".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table parent (id serial primary key, label text)".to_string()),
))
.expect("create parent");
let res = r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("plabel", Type::Text)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("plabel", "parent", Some("label"))], // label is not a PK
false,
Some("create table child (id serial primary key, plabel text references parent(label))".to_string()),
));
assert!(res.is_err(), "FK must target a primary key");
}
#[test]
fn foreign_key_survives_rebuild() {
let (p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
insert_parent_row(&db, &r);
assert!(
r.block_on(db.insert(
"child".to_string(),
Some(vec!["pid".to_string()]),
vec![Value::Number("999".to_string())],
Some("insert".to_string()),
))
.is_err(),
"FK still enforced after rebuild"
);
}
#[test]
fn create_table_with_fk_is_one_undo_step() {
let (_p, db, _d) = open(true); // undo enabled
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
// One undo removes the child table AND its relationship row, so the
// parent (now un-referenced) can be described without a dangling rel.
r.block_on(db.undo()).expect("undo").expect("a step was undone");
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
}
#[test]
fn foreign_key_on_delete_cascade_takes_effect() {
// Proves the referential action reaches the engine DDL (not just
// the metadata): deleting a parent row cascades to the children.
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
let mut spec = fk("pid", "parent", Some("id"));
spec.on_delete = ReferentialAction::Cascade;
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![spec],
false,
Some(
"create table child (id serial primary key, pid int references parent(id) \
on delete cascade)"
.to_string(),
),
))
.expect("create child with ON DELETE CASCADE");
insert_parent_row(&db, &r); // id=1
r.block_on(db.insert(
"child".to_string(),
Some(vec!["pid".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
))
.expect("child referencing parent 1");
// Delete the parent row; the child should cascade away.
r.block_on(db.delete(
"parent".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
Some("delete".to_string()),
))
.expect("delete parent");
let child_rows = r
.block_on(db.query_data("child".to_string(), None, None))
.expect("query child");
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
}
#[test]
fn foreign_key_to_unknown_parent_is_rejected() {
let (_p, db, _d) = open(false);
let r = rt();
let res = r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "ghost", Some("id"))], // no such table
false,
Some("create table child (id serial primary key, pid int references ghost(id))".to_string()),
));
assert!(res.is_err(), "a FK to a non-existent parent table is rejected");
// And the failed create left nothing behind.
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
}
#[test]
fn composite_pk_bare_reference_is_rejected() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"cp".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec!["a".to_string(), "b".to_string()], // composite PK
vec![],
vec![],
vec![],
false,
Some("create table cp (a int, b int, primary key (a, b))".to_string()),
))
.expect("create composite-PK parent");
// A bare `REFERENCES cp` cannot disambiguate which PK column.
let res = r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("ref", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("ref", "cp", None)], // bare reference to a composite-PK parent
false,
Some("create table child (id serial primary key, ref int references cp)".to_string()),
));
assert!(res.is_err(), "bare REFERENCES to a composite-PK parent must be rejected");
}
#[test]
fn fk_survives_a_rebuild_triggering_column_add() {
// Cross-cutting (ADR-0013 rebuild primitive × 4b): adding a
// constrained column to a child that has an FK rebuilds the table
// via schema_to_ddl. The FK must survive in the engine AND the
// relationship metadata (so a later rebuild_from_text re-emits it).
let (p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create child with FK");
// A UNIQUE column forces the rebuild path.
let mut c = ColumnSpec::new("code", Type::Int);
c.unique = true;
r.block_on(db.add_column("child".to_string(), c, Some("add column child: code(int) unique".to_string())))
.expect("add column via rebuild");
// The relationship still exists after the rebuild.
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
// And the engine still enforces it (now and after a fresh rebuild).
insert_parent_row(&db, &r);
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
assert!(
r.block_on(db.insert(
"child".to_string(),
Some(vec!["pid".to_string()]),
vec![Value::Number("999".to_string())],
Some("insert".to_string()),
))
.is_err(),
"FK still enforced after the column-add rebuild and a later rebuild_from_text"
);
}
#[test]
fn fk_referential_actions_survive_rebuild() {
// The actions (not just the FK's existence) must round-trip through
// pragma -> REL_TABLE -> project.yaml -> rebuild.
let (p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
let mut spec = fk("pid", "parent", Some("id"));
spec.on_delete = ReferentialAction::Cascade;
spec.on_update = ReferentialAction::SetNull;
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![spec],
false,
Some(
"create table child (id serial primary key, pid int references parent(id) \
on delete cascade on update set null)"
.to_string(),
),
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
let rel = &child.outbound_relationships[0];
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
}
#[test]
fn dropping_the_child_clears_the_fk_relationship() {
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create");
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
.expect("drop child");
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
}
#[test]
fn dropping_a_referenced_parent_is_refused() {
// ADR-0013: a parent with inbound relationships can't be dropped.
let (_p, db, _d) = open(false);
let r = rt();
make_parent(&db, &r);
r.block_on(db.sql_create_table(
"child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("pid", "parent", Some("id"))],
false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
))
.expect("create");
assert!(
r.block_on(db.drop_table("parent".to_string(), Some("drop table parent".to_string()))).is_err(),
"a referenced parent can't be dropped while the child's FK exists"
);
}
#[test]
fn bare_self_reference_resolves_to_own_pk() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"emp".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("mgr", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![fk("mgr", "emp", None)], // bare AND self-referential
false,
Some("create table emp (id int primary key, mgr int references emp)".to_string()),
))
.expect("create self-referential emp with a bare reference");
let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe");
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
// Enforced: a non-existent manager is rejected.
r.block_on(db.insert(
"emp".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert".to_string()),
))
.expect("root row");
assert!(
r.block_on(db.insert(
"emp".to_string(),
Some(vec!["id".to_string(), "mgr".to_string()]),
vec![Value::Number("2".to_string()), Value::Number("99".to_string())],
Some("insert".to_string()),
))
.is_err(),
"bare self-ref FK is enforced"
);
}