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.
This commit is contained in:
@@ -466,6 +466,7 @@ enum Request {
|
||||
columns: Vec<ColumnSpec>,
|
||||
primary_key: Vec<String>,
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
check_constraints: Vec<String>,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
|
||||
@@ -829,12 +830,14 @@ impl Database {
|
||||
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
|
||||
/// structurally; returns whether the table was created or skipped
|
||||
/// (the `IF NOT EXISTS` no-op, ADR-0035 §4).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sql_create_table(
|
||||
&self,
|
||||
name: String,
|
||||
columns: Vec<ColumnSpec>,
|
||||
primary_key: Vec<String>,
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
check_constraints: Vec<String>,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
) -> Result<CreateOutcome, DbError> {
|
||||
@@ -844,6 +847,7 @@ impl Database {
|
||||
columns,
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
@@ -1411,6 +1415,12 @@ const REL_TABLE: &str = "__rdbms_playground_relationships";
|
||||
/// `created_at`. Created on first connect and only ever
|
||||
/// written by us; the user never touches it directly.
|
||||
const META_PROJECT_TABLE: &str = "__rdbms_playground_meta";
|
||||
/// Table-level `CHECK (<expr>)` constraints (ADR-0035 §4a.3). The
|
||||
/// engine exposes no PRAGMA for CHECK constraints, so — unlike UNIQUE /
|
||||
/// PK / FK, which are read back from PRAGMA — a table-level CHECK has no
|
||||
/// engine-readable home and this table is its source of truth. One row
|
||||
/// per CHECK, ordered by `seq` (declaration order).
|
||||
const CHECK_TABLE: &str = "__rdbms_playground_table_checks";
|
||||
|
||||
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(&format!(
|
||||
@@ -1432,6 +1442,12 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
on_update TEXT NOT NULL,\n\
|
||||
PRIMARY KEY (child_table, child_column)\n\
|
||||
) STRICT;\n\
|
||||
CREATE TABLE IF NOT EXISTS {CHECK_TABLE} (\n\
|
||||
table_name TEXT NOT NULL,\n\
|
||||
seq INTEGER NOT NULL,\n\
|
||||
check_expr TEXT NOT NULL,\n\
|
||||
PRIMARY KEY (table_name, seq)\n\
|
||||
) STRICT;\n\
|
||||
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
|
||||
key TEXT NOT NULL PRIMARY KEY,\n\
|
||||
value TEXT NOT NULL\n\
|
||||
@@ -1691,6 +1707,7 @@ fn handle_request(
|
||||
&columns,
|
||||
&primary_key,
|
||||
&[],
|
||||
&[],
|
||||
));
|
||||
}
|
||||
Request::SqlCreateTable {
|
||||
@@ -1698,6 +1715,7 @@ fn handle_request(
|
||||
columns,
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
@@ -1726,6 +1744,7 @@ fn handle_request(
|
||||
&columns,
|
||||
&primary_key,
|
||||
&unique_constraints,
|
||||
&check_constraints,
|
||||
)
|
||||
.map(CreateOutcome::Created)
|
||||
});
|
||||
@@ -2324,6 +2343,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
||||
primary_key: read.primary_key.clone(),
|
||||
columns,
|
||||
unique_constraints: read.unique_constraints.clone(),
|
||||
check_constraints: read.check_constraints.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2569,6 +2589,7 @@ fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> Read
|
||||
primary_key: primary_key.to_vec(),
|
||||
foreign_keys: Vec::new(),
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2606,6 +2627,7 @@ pub enum CreateOutcome {
|
||||
Skipped(TableDescription),
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn do_create_table(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
@@ -2614,6 +2636,7 @@ fn do_create_table(
|
||||
columns: &[ColumnSpec],
|
||||
primary_key: &[String],
|
||||
unique_constraints: &[Vec<String>],
|
||||
check_constraints: &[String],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
@@ -2702,6 +2725,17 @@ fn do_create_table(
|
||||
ddl.push(')');
|
||||
}
|
||||
|
||||
// Table-level CHECK constraints (ADR-0035 §4a.3), emitted verbatim
|
||||
// from the captured raw SQL text. Must stay identical to the
|
||||
// `schema_to_ddl` rebuild path (the §6.1 two-generators rule).
|
||||
// The engine has no PRAGMA to report these back, so they are also
|
||||
// recorded in `CHECK_TABLE` (below) as their source of truth.
|
||||
for expr in check_constraints {
|
||||
ddl.push_str(", CHECK (");
|
||||
ddl.push_str(expr);
|
||||
ddl.push(')');
|
||||
}
|
||||
|
||||
ddl.push_str(") STRICT;");
|
||||
debug!(ddl = %ddl, "create_table");
|
||||
|
||||
@@ -2715,6 +2749,18 @@ fn do_create_table(
|
||||
for (col, check_sql) in columns.iter().zip(&check_sqls) {
|
||||
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
|
||||
}
|
||||
// Record table-level CHECKs in their metadata table (the engine
|
||||
// reports no CHECK constraints, ADR-0035 §4a.3). `seq` preserves
|
||||
// declaration order so read-back / rebuild re-emit them in order.
|
||||
for (seq, expr) in check_constraints.iter().enumerate() {
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr) VALUES (?1, ?2, ?3);"
|
||||
),
|
||||
rusqlite::params![name, seq as i64, expr],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
}
|
||||
let description = do_describe_table(conn, name)?;
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
@@ -2764,6 +2810,12 @@ fn do_drop_table(
|
||||
[name],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
// Table-level CHECK metadata goes with the table (ADR-0035 §4a.3).
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1;"),
|
||||
[name],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
rewritten_tables: Vec::new(),
|
||||
@@ -4722,6 +4774,11 @@ struct ReadSchema {
|
||||
/// read from the UNIQUE-constraint indexes (`origin = 'u'`).
|
||||
/// Single-column UNIQUE rides on `ReadColumn::unique` instead.
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
/// Table-level CHECK constraints as raw SQL text, in declaration
|
||||
/// order (ADR-0035 §4a.3). The engine reports no CHECK constraints,
|
||||
/// so these are read from `__rdbms_playground_table_checks` rather
|
||||
/// than PRAGMA, and echoed verbatim by `schema_to_ddl` on rebuild.
|
||||
check_constraints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -4842,14 +4899,40 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
|
||||
foreign_keys.push(row.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
|
||||
// Table-level CHECK constraints (ADR-0035 §4a.3) come from their
|
||||
// metadata table, not PRAGMA — the engine reports no CHECKs.
|
||||
let check_constraints = read_table_checks(conn, table)?;
|
||||
|
||||
Ok(ReadSchema {
|
||||
columns,
|
||||
primary_key,
|
||||
foreign_keys,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3) from
|
||||
/// `CHECK_TABLE`, in declaration order (`seq`). The engine exposes no
|
||||
/// PRAGMA for CHECK constraints, so this metadata table is their only
|
||||
/// source of truth.
|
||||
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbError> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT check_expr FROM {CHECK_TABLE} \
|
||||
WHERE table_name = ?1 ORDER BY seq;"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let rows = stmt
|
||||
.query_map([table], |row| row.get::<_, String>(0))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the user-created indexes on `table` (ADR-0025).
|
||||
///
|
||||
/// `pragma_index_list` reports every index; we keep only those
|
||||
@@ -5042,6 +5125,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
|
||||
clauses.push(format!("UNIQUE ({})", idents.join(", ")));
|
||||
}
|
||||
|
||||
// Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
|
||||
// from the raw SQL stored in the metadata table, emitted identically
|
||||
// to `do_create_table` (the §6.1 two-generators rule).
|
||||
for expr in &schema.check_constraints {
|
||||
clauses.push(format!("CHECK ({expr})"));
|
||||
}
|
||||
|
||||
for fk in &schema.foreign_keys {
|
||||
clauses.push(format!(
|
||||
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
|
||||
@@ -7504,6 +7594,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
|
||||
primary_key: table.primary_key.clone(),
|
||||
foreign_keys,
|
||||
unique_constraints: table.unique_constraints.clone(),
|
||||
check_constraints: table.check_constraints.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11206,6 +11297,7 @@ mod tests {
|
||||
primary_key: vec!["id".to_string()],
|
||||
foreign_keys: vec![],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
};
|
||||
let ddl = schema_to_ddl("T", &schema);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user