constraints: CHECK — check (<expr>) at create table & add column (ADR-0029)

The fourth constraint. `check ( <expr> )` reuses the ADR-0026
WHERE-expression grammar via `Subgrammar`, so a check is
written in the same language as a `where` filter.

- Grammar: a `CHECK_CONSTRAINT` arm joins the shared
  constraint-suffix Choice; `consume_check_expr` extracts the
  parenthesised expression (paren-depth aware) into
  `ColumnSpec.check` / `Command::AddColumn.check`.
- Storage: the parsed `Expr` is compiled once to inline SQL
  (`compile_check_sql` — `compile_expr` + ADR-0028's
  param-inliner) and stored in that form everywhere — a new
  `check_expr` column in `__rdbms_playground_columns`,
  `project.yaml`'s `ColumnSchema.check`, and the column DDL
  emitted by `do_create_table` / `schema_to_ddl`.
- `add column … check` routes through the rebuild primitive
  (SQLite's `ALTER … ADD COLUMN` cannot carry it); a CHECK on
  a serial/shortid column is create-table-only and refused at
  add-column with a friendly message.
- `describe` surfaces the CHECK. ADR-0029 §7/§8 updated to the
  SQL-form decision — double-quoted identifiers, consistent
  with ADR-0028's `explain` display SQL.

1201 tests pass (+8); clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 16:42:18 +00:00
parent 58d8958822
commit 942222bfc9
11 changed files with 421 additions and 73 deletions
+258 -26
View File
@@ -136,6 +136,9 @@ pub struct ColumnDescription {
/// The column's `DEFAULT` expression as SQLite reports it,
/// or `None` (ADR-0029).
pub default: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form,
/// or `None` (ADR-0029).
pub check: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1050,6 +1053,7 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\
check_expr TEXT,\n\
PRIMARY KEY (table_name, column_name)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\
@@ -1612,6 +1616,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
// user_type is always populated for tables we
// created; the fallback is defensive.
user_type: c.user_type.unwrap_or(Type::Text),
@@ -1707,6 +1712,7 @@ fn read_table_snapshot(
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
let column_idents: Vec<String> = read
@@ -1816,6 +1822,63 @@ fn default_sql_literal(spec: &ColumnSpec) -> Result<Option<String>, DbError> {
Ok(Some(sql_literal(&bound_to_sqlite_value(&bound))))
}
/// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 /
/// §7) — the form stored in the `check_expr` metadata column
/// and emitted into column DDL. `compile_expr` produces
/// `?N`-parameterised SQL; `inline_params_for_display`
/// (ADR-0028) folds the literals back in, since DDL admits no
/// parameters.
fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let sql = compile_expr(expr, schema, &mut params);
inline_params_for_display(&sql, &params)
}
/// A minimal `ReadSchema` built from column specs — enough for
/// `compile_expr` to resolve column types when compiling a
/// `CHECK` at create-table time, before the table exists.
fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema {
ReadSchema {
columns: columns
.iter()
.map(|c| ReadColumn {
name: c.name.clone(),
sqlite_type: c.ty.sqlite_strict_type().to_string(),
notnull: c.not_null,
primary_key: primary_key.contains(&c.name),
unique: c.unique,
default_sql: None,
check: None,
user_type: Some(c.ty),
})
.collect(),
primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(),
}
}
/// Insert a column's row into the metadata table — the user
/// type, plus the compiled `CHECK` SQL when present
/// (ADR-0029 §7).
fn insert_column_metadata(
conn: &Connection,
table: &str,
column: &str,
user_type: Type,
check_sql: Option<&str>,
) -> Result<(), DbError> {
conn.execute(
&format!(
"INSERT INTO {META_TABLE} \
(table_name, column_name, user_type, check_expr) \
VALUES (?1, ?2, ?3, ?4);"
),
rusqlite::params![table, column, user_type.keyword(), check_sql],
)
.map_err(DbError::from_rusqlite)?;
Ok(())
}
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -1843,8 +1906,17 @@ fn do_create_table(
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name;
// Compile each column's CHECK once (ADR-0029 §4) — reused
// by the DDL clause and the metadata insert below. The
// minimal schema gives `compile_expr` the column types.
let check_schema = read_schema_for_specs(columns, primary_key);
let check_sqls: Vec<Option<String>> = columns
.iter()
.map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
.collect();
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for col in columns {
for (col, check_sql) in columns.iter().zip(&check_sqls) {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
@@ -1858,6 +1930,9 @@ fn do_create_table(
// redundant declarations (ADR-0029 §9) so a PK column
// never carries them here.
clause.push_str(&column_constraints_sql(col)?);
if let Some(cs) = check_sql {
clause.push_str(&format!(" CHECK ({cs})"));
}
column_clauses.push(clause);
}
@@ -1884,17 +1959,8 @@ fn do_create_table(
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
))
.map_err(DbError::from_rusqlite)?;
for col in columns {
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
.map_err(DbError::from_rusqlite)?;
}
for (col, check_sql) in columns.iter().zip(&check_sqls) {
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
}
let description = do_describe_table(conn, name)?;
let changes = Changes {
@@ -1992,13 +2058,25 @@ fn do_add_column(
ty = column.ty.keyword(),
)));
}
// A CHECK on an auto-generated column is supported at
// `create table` time; adding one to a `serial` /
// `shortid` column afterwards is not (the auto-fill
// rebuild path does not thread it).
if column.check.is_some() {
return Err(DbError::Unsupported(format!(
"a `check` constraint on the auto-generated column `{}` \
can only be set when the table is created.",
column.name,
)));
}
return do_add_auto_generated_column(conn, persistence, source, table, column);
}
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`,
// and a `NOT NULL` column added that way must carry a
// default — both route through the rebuild primitive
// instead (ADR-0029 §6).
if column.unique || (column.not_null && column.default.is_none()) {
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
// or `CHECK`, and a `NOT NULL` column added that way must
// carry a default — all route through the rebuild
// primitive instead (ADR-0029 §6).
if column.unique || column.check.is_some() || (column.not_null && column.default.is_none())
{
do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
} else {
do_add_plain_column(conn, persistence, source, table, column)
@@ -2087,6 +2165,7 @@ fn do_add_auto_generated_column(
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(ty),
});
@@ -2233,18 +2312,21 @@ fn do_add_constrained_column_via_rebuild(
primary_key: false,
unique: spec.unique,
default_sql: default_sql_literal(spec)?,
check: None,
user_type: Some(spec.ty),
});
// The CHECK is compiled against the post-add schema, so it
// may reference the new column itself.
let check_sql = spec
.check
.as_ref()
.map(|e| compile_check_sql(e, &new_schema));
if let Some(last) = new_schema.columns.last_mut() {
last.check.clone_from(&check_sql);
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, spec.name.as_str(), spec.ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
@@ -3400,6 +3482,11 @@ struct ReadColumn {
/// literal, echoed verbatim by `schema_to_ddl` so the
/// rebuild dance preserves it (ADR-0029).
default_sql: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form
/// (ADR-0029 §7), read from the `check_expr` metadata
/// column — `pragma_table_info` does not expose CHECK.
/// Echoed verbatim by `schema_to_ddl`.
check: Option<String>,
user_type: Option<Type>,
}
@@ -3416,7 +3503,8 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
// Columns + PK from pragma_table_info, joined with our user-type metadata.
let mut col_stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, pti.dflt_value \
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \
pti.dflt_value, m.check_expr \
FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \
@@ -3434,6 +3522,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
primary_key: row.get::<_, i64>(3)? != 0,
unique: false, // filled in below from pragma_index_list
default_sql: row.get(5)?,
check: row.get(6)?,
user_type,
})
})
@@ -3656,6 +3745,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
clause.push_str(" DEFAULT ");
clause.push_str(default_sql);
}
// ADR-0029 CHECK — echoed verbatim from the compiled
// SQL stored in the `check_expr` metadata column.
if let Some(check) = &col.check {
clause.push_str(" CHECK (");
clause.push_str(check);
clause.push(')');
}
clauses.push(clause);
}
@@ -3907,6 +4003,7 @@ fn do_add_relationship(
primary_key: false,
unique: false,
default_sql: None,
check: None,
user_type: Some(expected_child_type),
});
} else {
@@ -4281,6 +4378,7 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
primary_key: c.primary_key,
unique: c.unique,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
@@ -5478,6 +5576,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
primary_key: table.primary_key.contains(&c.name),
unique: c.unique,
default_sql: c.default.clone(),
check: c.check.clone(),
user_type: Some(c.user_type),
})
.collect();
@@ -8695,6 +8794,137 @@ mod tests {
);
}
// --- CHECK constraints (ADR-0029 §4) --------------------
/// Parse a `create table` DSL string into its db-call parts
/// — the way to get a real `Expr` into a `ColumnSpec.check`
/// without hand-building the AST.
fn parse_create(dsl: &str) -> (String, Vec<ColumnSpec>, Vec<String>) {
match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
Command::CreateTable {
name,
columns,
primary_key,
} => (name, columns, primary_key),
other => panic!("expected CreateTable, got {other:?}"),
}
}
/// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
let (_, columns, _) = parse_create(&format!(
"create table __probe with pk {name}({}) check ({check_dsl})",
ty.keyword(),
));
columns.into_iter().next().expect("one column")
}
#[tokio::test]
async fn create_table_check_constraint_is_enforced() {
let db = db();
let (n, c, pk) = parse_create(
"create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
);
db.create_table(n, c, pk, None).await.unwrap();
let insert_grade = |g: &str| {
db.insert(
"Grades".to_string(),
Some(vec!["grade".to_string()]),
vec![Value::Text(g.to_string())],
None,
)
};
assert!(insert_grade("A").await.is_ok(), "a value the check allows");
assert!(
insert_grade("Z").await.is_err(),
"a value the check forbids is refused",
);
}
#[tokio::test]
async fn describe_surfaces_the_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk age(int) check (age >= 0)");
db.create_table(n, c, pk, None).await.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
let check = age.check.as_deref().expect("age carries a CHECK");
assert!(
check.contains(">="),
"the compiled check SQL is surfaced: {check}",
);
}
#[tokio::test]
async fn add_column_check_constraint_is_enforced() {
let db = db();
people_table(&db).await;
db.add_column(
"People".to_string(),
col_c_check("score", Type::Int, "score >= 0"),
None,
)
.await
.expect("a CHECK column adds via the rebuild path");
let desc = db.describe_table("People".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some());
// An update that violates the check is refused.
let bad = db
.update(
"People".to_string(),
vec![("score".to_string(), Value::Number("-1".to_string()))],
parse_filter("update People set score=-1 where id = 1"),
None,
)
.await;
assert!(bad.is_err(), "an update violating the CHECK is refused");
}
#[tokio::test]
async fn rebuild_preserves_a_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk code(text) check (code like 'X%')");
db.create_table(n, c, pk, None).await.unwrap();
db.add_column("T".to_string(), col("note", Type::Int), None)
.await
.unwrap();
// A type change on `note` rebuilds the table; `code`'s
// CHECK must survive the round-trip through schema_to_ddl.
db.change_column_type(
"T".to_string(),
"note".to_string(),
Type::Decimal,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
assert!(
desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(),
"code keeps its CHECK across the rebuild",
);
}
#[tokio::test]
async fn add_serial_column_with_a_check_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c_check("seq", Type::Serial, "seq > 0"),
None,
)
.await;
assert!(
result.is_err(),
"a CHECK on an auto-generated column is a create-table-only feature",
);
}
#[tokio::test]
async fn update_with_all_rows_affects_everything() {
let db = db();
@@ -8966,6 +9196,7 @@ mod tests {
primary_key: true,
unique: false,
default_sql: None,
check: None,
user_type: Some(Type::Serial),
},
ReadColumn {
@@ -8975,6 +9206,7 @@ mod tests {
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(Type::Text),
},
],