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:
@@ -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, ¶ms)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user