Files
rdbms-playground/tests/sql_create_table.rs
T
claude@clouddev1 60111f69d5 feat: ADR-0035 4a.3 — table-level / multi-column CHECK
Add table-level CHECK (e.g. `CREATE TABLE t (a int, b int, CHECK (a < b))`)
to advanced-mode SQL CREATE TABLE. Since SQLite exposes no PRAGMA for CHECK
constraints, a table-level CHECK cannot be read back from the engine and
becomes the source of truth in a new internal metadata table
`__rdbms_playground_table_checks (table_name, seq, check_expr)`.

- Grammar: new TABLE_CHECK element in ELEMENT_CHOICES.
- Builder: distinguishes a table-level CHECK from a column-level one by
  element position (no column-def open in the element), using depth-aware
  boundary tracking so a length-arg comma (`numeric(10,2)`) or a
  table-PRIMARY KEY's inner comma is not mistaken for an element separator.
- Worker: do_create_table emits the CHECK clauses and writes the metadata
  rows in its transaction; schema_to_ddl emits them identically on rebuild;
  read_schema / read_schema_snapshot read them from the metadata table;
  do_drop_table clears them.
- Persistence: TableSchema.check_constraints round-trips through project.yaml
  (#[serde(default)], optional on read), mirroring unique_constraints.
- Composite UNIQUE deliberately stays PRAGMA-detected (engine-reportable,
  unlike CHECK) — user-confirmed.

DA/runda round added cross-cutting tests and a forward-looking doc fix:
- table CHECK survives a rebuild triggered by `add column`, and a later
  rebuild_from_text (the ADR-0013 rebuild primitive uses a raw DROP, so the
  metadata rows keyed on the final name are preserved);
- dropping a column a table CHECK references fails cleanly (rollback, table
  intact); detection is 4e, friendly wording is H1;
- dropping a table clears its CHECK metadata (no orphan rows on re-create);
- amended ADR §6 so 4h's RENAME also updates the new metadata table.

20 Tier-3 + 9 grammar/builder + 2 YAML tests. Docs: ADR-0035 Status/§13/§6,
README index, requirements.md Q1. Help/usage skeleton + describe display of
table-level constraints deferred to 4i (symmetric with 4a.2).

Tests: 1769 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-25 14:06:52 +00:00

800 lines
28 KiB
Rust
Raw 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, 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
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(), None))
.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
false,
Some("create table T (id integer primary key)".to_string()),
))
.expect("create");
let desc = r
.block_on(db.describe_table("T".to_string(), None))
.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
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, 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
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
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
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, 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
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
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, 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
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
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, 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
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()],
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()],
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()],
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()],
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
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
true,
Some(noop.to_string()),
))
.expect("no-op");
assert!(matches!(out, CreateOutcome::Skipped(_)));
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}");
}
#[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
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
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
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, 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
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
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()],
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(), None))
.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");
}