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
+29 -29
View File
@@ -80,7 +80,7 @@ A column spec gains an optional, repeatable constraint suffix
**after** the `(type)` group: **after** the `(type)` group:
``` ```
create table Books with pk isbn(text) check (length(isbn) = 13) create table Books with pk isbn(text) check (isbn like '978%')
add column to Books: title (text) not null add column to Books: title (text) not null
add column to Books: stock (int) default 0 check (stock >= 0) add column to Books: stock (int) default 0 check (stock >= 0)
@@ -283,28 +283,31 @@ the data.
constraints must round-trip through `project.yaml` or they constraints must round-trip through `project.yaml` or they
vanish on `rebuild` / `export` / `import`. vanish on `rebuild` / `export` / `import`.
The `CHECK` expression is stored — everywhere — as the
**compiled SQL** form: the parsed `Expr` run through
`compile_expr` with literals inlined (§4). Identifiers are
double-quoted, exactly as ADR-0028's `explain` display SQL
already renders them, so the form is consistent with what the
learner already meets. (The original plan stored canonical
*DSL text*; that needs both an `Expr`→text renderer and a
text→`Expr` re-parser for the round-trip, while the rebuild
path needs the SQL form regardless — storing SQL once removes
both.)
- **`project.yaml`** — the `ColumnSchema` record gains - **`project.yaml`** — the `ColumnSchema` record gains
`not_null: bool`, `default: Option<Value>`, and `not_null: bool`, `default: Option<String>`, and
`check: Option<String>`. (`unique: bool` already exists, `check: Option<String>`. (`unique: bool` already exists,
from ADR-0018's `serial` / `shortid` contract.) The `check` from ADR-0018's `serial` / `shortid` contract.) `check`
expression is stored as its **canonical DSL text** — see the holds the compiled SQL.
`Expr` text renderer below.
- **Metadata table** — `NOT NULL`, `UNIQUE`, and `DEFAULT` are - **Metadata table** — `NOT NULL`, `UNIQUE`, and `DEFAULT` are
all recoverable from SQLite itself (`pragma_table_info`'s all recoverable from SQLite itself (`pragma_table_info`'s
`notnull` and `dflt_value`; `pragma_index_list` origin `u`), `notnull` and `dflt_value`; `pragma_index_list` origin `u`),
so they need no metadata row. `CHECK` is *not* exposed by so they need no metadata row. `CHECK` is *not* exposed by
any pragma — only by the raw `sqlite_master` SQL, which is any pragma. So `__rdbms_playground_columns` carries a
in engine syntax. So `__rdbms_playground_columns` carries a nullable `check_expr TEXT` column holding the compiled SQL,
nullable `check_expr TEXT` column holding the canonical DSL which `schema_to_ddl` and `describe` echo verbatim. It is
text, keeping `describe` independent of engine-syntax part of the internal table's `CREATE TABLE` definition —
parsing. It is part of the internal table's `CREATE TABLE` there are no existing databases to migrate.
definition — there are no existing databases to migrate.
- **`Expr` → DSL text renderer** — a new `render_expr` (small;
the `Expr` tree from ADR-0026 is shallow) produces canonical
DSL text for an expression. One renderer, three consumers:
`project.yaml` serialization, the `check_expr` metadata
column, and the structure view (§8). The check round-trips
text → `Expr` (re-parsed on load) → text.
### 8. Structure rendering ### 8. Structure rendering
@@ -315,17 +318,17 @@ Option<String>`, and `check: Option<String>`.
every constraint a column carries: every constraint a column carries:
``` ```
┌───────┬─────────────────────────────────────────┐ ┌───────┬─────────────────────────────────────────┐
│ Name │ Type │ Constraints │ │ Name │ Type │ Constraints │
├───────┼─────────────────────────────────────────┤ ├───────┼─────────────────────────────────────────┤
│ id │ serial │ PK │ │ id │ serial │ PK │
│ email │ text │ NOT NULL, UNIQUE │ │ email │ text │ NOT NULL, UNIQUE │
│ age │ int │ DEFAULT 18, CHECK age >= 0 │ age │ int │ DEFAULT 18, CHECK ("age" >= 0)
└───────┴─────────────────────────────────────────┘ └───────┴─────────────────────────────────────────┘
``` ```
The `CHECK` expression renders in DSL form (`render_expr`), The `CHECK` renders in its compiled-SQL form (§7) — the same
not engine SQL. double-quoted-identifier style as ADR-0028's `explain` SQL.
### 9. PK columns — redundant and impossible constraints ### 9. PK columns — redundant and impossible constraints
@@ -409,10 +412,7 @@ additions:
tables go through the ADR-0016 pretty-table renderer. tables go through the ADR-0016 pretty-table renderer.
- The internal metadata table `__rdbms_playground_columns` - The internal metadata table `__rdbms_playground_columns`
carries a new `check_expr` column — its first change since carries a new `check_expr` column — its first change since
ADR-0012. ADR-0012 — holding the compiled-SQL form of the `CHECK`.
- A new `Expr` → DSL-text renderer (`render_expr`) is added;
it is also reusable by any future feature that needs to
show an expression back to the user.
- `project.yaml`'s `ColumnSchema` grows three fields; the - `project.yaml`'s `ColumnSchema` grows three fields; the
format stays backward-compatible (the new keys default to format stays backward-compatible (the new keys default to
"absent" — `not_null: false`, no `default`, no `check`). "absent" — `not_null: false`, no `default`, no `check`).
@@ -433,8 +433,8 @@ A sensible build order, each step test-guarded:
honour it, including the §6 `add column` rules. The honour it, including the §6 `add column` rules. The
`Expr` → SQL compile for `CHECK`. `Expr` → SQL compile for `CHECK`.
3. **Storage round-trip.** `ColumnSchema` fields, the 3. **Storage round-trip.** `ColumnSchema` fields, the
`check_expr` metadata column, `render_expr`, and the `check_expr` metadata column, and the `project.yaml`
`project.yaml` read/write paths. read/write paths.
4. **`add constraint …` / `drop constraint …`.** The two 4. **`add constraint …` / `drop constraint …`.** The two
commands, the rebuild-table path, and the §5 dry-run with commands, the rebuild-table path, and the §5 dry-run with
its pretty-table refusals. its pretty-table refusals.
+5
View File
@@ -48,3 +48,8 @@ entry names the ADR that drew the boundary.
added afterward with `add column`. Creating a table with a added afterward with `add column`. Creating a table with a
mix of PK and non-PK columns in one statement needs mix of PK and non-PK columns in one statement needs
advanced-mode `CREATE TABLE` syntax. advanced-mode `CREATE TABLE` syntax.
- **`check (<expr>)` constraints reuse the WHERE-expression
grammar** (ADR-0026), so the same limits apply: no scalar
functions (`length(x)`), no arithmetic. A check is a boolean
combination of column-vs-literal comparisons, `LIKE`, `IN`,
`BETWEEN`, and `IS NULL`.
+1
View File
@@ -2175,6 +2175,7 @@ mod tests {
primary_key: true, primary_key: true,
unique: false, unique: false,
default: None, default: None,
check: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(), inbound_relationships: Vec::new(),
+258 -26
View File
@@ -136,6 +136,9 @@ pub struct ColumnDescription {
/// The column's `DEFAULT` expression as SQLite reports it, /// The column's `DEFAULT` expression as SQLite reports it,
/// or `None` (ADR-0029). /// or `None` (ADR-0029).
pub default: Option<String>, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -1050,6 +1053,7 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
table_name TEXT NOT NULL,\n\ table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\ column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\ user_type TEXT NOT NULL,\n\
check_expr TEXT,\n\
PRIMARY KEY (table_name, column_name)\n\ PRIMARY KEY (table_name, column_name)\n\
) STRICT;\n\ ) STRICT;\n\
CREATE TABLE IF NOT EXISTS {REL_TABLE} (\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, unique: c.unique,
not_null: c.notnull, not_null: c.notnull,
default: c.default_sql.clone(), default: c.default_sql.clone(),
check: c.check.clone(),
// user_type is always populated for tables we // user_type is always populated for tables we
// created; the fallback is defensive. // created; the fallback is defensive.
user_type: c.user_type.unwrap_or(Type::Text), user_type: c.user_type.unwrap_or(Type::Text),
@@ -1707,6 +1712,7 @@ fn read_table_snapshot(
unique: c.unique, unique: c.unique,
not_null: c.notnull, not_null: c.notnull,
default: c.default_sql.clone(), default: c.default_sql.clone(),
check: c.check.clone(),
}) })
.collect(); .collect();
let column_idents: Vec<String> = read 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)))) 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( fn do_create_table(
conn: &Connection, conn: &Connection,
persistence: Option<&Persistence>, persistence: Option<&Persistence>,
@@ -1843,8 +1906,17 @@ fn do_create_table(
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1 let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name; && 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()); 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!( let mut clause = format!(
"{ident} {sqlite_type}", "{ident} {sqlite_type}",
ident = quote_ident(&col.name), ident = quote_ident(&col.name),
@@ -1858,6 +1930,9 @@ fn do_create_table(
// redundant declarations (ADR-0029 §9) so a PK column // redundant declarations (ADR-0029 §9) so a PK column
// never carries them here. // never carries them here.
clause.push_str(&column_constraints_sql(col)?); clause.push_str(&column_constraints_sql(col)?);
if let Some(cs) = check_sql {
clause.push_str(&format!(" CHECK ({cs})"));
}
column_clauses.push(clause); column_clauses.push(clause);
} }
@@ -1884,17 +1959,8 @@ fn do_create_table(
.unchecked_transaction() .unchecked_transaction()
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
{ for (col, check_sql) in columns.iter().zip(&check_sqls) {
let mut stmt = tx insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
.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)?;
}
} }
let description = do_describe_table(conn, name)?; let description = do_describe_table(conn, name)?;
let changes = Changes { let changes = Changes {
@@ -1992,13 +2058,25 @@ fn do_add_column(
ty = column.ty.keyword(), 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); return do_add_auto_generated_column(conn, persistence, source, table, column);
} }
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`, // SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
// and a `NOT NULL` column added that way must carry a // or `CHECK`, and a `NOT NULL` column added that way must
// default — both route through the rebuild primitive // carry a default — all route through the rebuild
// instead (ADR-0029 §6). // primitive instead (ADR-0029 §6).
if column.unique || (column.not_null && column.default.is_none()) { 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) do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
} else { } else {
do_add_plain_column(conn, persistence, source, table, column) do_add_plain_column(conn, persistence, source, table, column)
@@ -2087,6 +2165,7 @@ fn do_add_auto_generated_column(
primary_key: false, primary_key: false,
unique: true, unique: true,
default_sql: None, default_sql: None,
check: None,
user_type: Some(ty), user_type: Some(ty),
}); });
@@ -2233,18 +2312,21 @@ fn do_add_constrained_column_via_rebuild(
primary_key: false, primary_key: false,
unique: spec.unique, unique: spec.unique,
default_sql: default_sql_literal(spec)?, default_sql: default_sql_literal(spec)?,
check: None,
user_type: Some(spec.ty), 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> { let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
tx.execute( insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
&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)?;
let changes = Changes { let changes = Changes {
schema_dirty: true, schema_dirty: true,
rewritten_tables: vec![table.to_string()], rewritten_tables: vec![table.to_string()],
@@ -3400,6 +3482,11 @@ struct ReadColumn {
/// literal, echoed verbatim by `schema_to_ddl` so the /// literal, echoed verbatim by `schema_to_ddl` so the
/// rebuild dance preserves it (ADR-0029). /// rebuild dance preserves it (ADR-0029).
default_sql: Option<String>, 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>, 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. // Columns + PK from pragma_table_info, joined with our user-type metadata.
let mut col_stmt = conn let mut col_stmt = conn
.prepare(&format!( .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 \ FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \ LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \ 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, primary_key: row.get::<_, i64>(3)? != 0,
unique: false, // filled in below from pragma_index_list unique: false, // filled in below from pragma_index_list
default_sql: row.get(5)?, default_sql: row.get(5)?,
check: row.get(6)?,
user_type, user_type,
}) })
}) })
@@ -3656,6 +3745,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
clause.push_str(" DEFAULT "); clause.push_str(" DEFAULT ");
clause.push_str(default_sql); 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); clauses.push(clause);
} }
@@ -3907,6 +4003,7 @@ fn do_add_relationship(
primary_key: false, primary_key: false,
unique: false, unique: false,
default_sql: None, default_sql: None,
check: None,
user_type: Some(expected_child_type), user_type: Some(expected_child_type),
}); });
} else { } else {
@@ -4281,6 +4378,7 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
primary_key: c.primary_key, primary_key: c.primary_key,
unique: c.unique, unique: c.unique,
default: c.default_sql.clone(), default: c.default_sql.clone(),
check: c.check.clone(),
}) })
.collect(); .collect();
@@ -5478,6 +5576,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
primary_key: table.primary_key.contains(&c.name), primary_key: table.primary_key.contains(&c.name),
unique: c.unique, unique: c.unique,
default_sql: c.default.clone(), default_sql: c.default.clone(),
check: c.check.clone(),
user_type: Some(c.user_type), user_type: Some(c.user_type),
}) })
.collect(); .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] #[tokio::test]
async fn update_with_all_rows_affects_everything() { async fn update_with_all_rows_affects_everything() {
let db = db(); let db = db();
@@ -8966,6 +9196,7 @@ mod tests {
primary_key: true, primary_key: true,
unique: false, unique: false,
default_sql: None, default_sql: None,
check: None,
user_type: Some(Type::Serial), user_type: Some(Type::Serial),
}, },
ReadColumn { ReadColumn {
@@ -8975,6 +9206,7 @@ mod tests {
primary_key: false, primary_key: false,
unique: true, unique: true,
default_sql: None, default_sql: None,
check: None,
user_type: Some(Type::Text), user_type: Some(Type::Text),
}, },
], ],
+92 -10
View File
@@ -13,7 +13,7 @@
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector, ChangeColumnMode, ColumnSpec, Command, Expr, IndexSelector, RelationshipSelector,
}; };
use crate::dsl::value::Value; use crate::dsl::value::Value;
use crate::dsl::grammar::{ use crate::dsl::grammar::{
@@ -27,7 +27,7 @@ use crate::dsl::grammar::{
/// candidates (ADR-0024 §HintMode-per-node). /// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name"); const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// ================================================================= // =================================================================
// Building blocks // Building blocks
@@ -616,7 +616,8 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], args: vec![("detail", "unknown type".to_string())],
})?; })?;
let (not_null, unique, default) = collect_column_constraints(path)?; let (not_null, unique, default, check) =
collect_column_constraints(path)?;
Ok(Command::AddColumn { Ok(Command::AddColumn {
table: require_ident(path, "table_name")?, table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?, column: require_ident(path, "column_name")?,
@@ -624,8 +625,7 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
not_null, not_null,
unique, unique,
default, default,
// CHECK joins in a later ADR-0029 step. check,
check: None,
}) })
} }
Some("1") => build_add_relationship(path), Some("1") => build_add_relationship(path),
@@ -842,8 +842,20 @@ const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
]; ];
const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES); const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES);
// `check ( <expr> )` — the expression is the ADR-0026 WHERE
// grammar, reached through `Subgrammar` (ADR-0029 §2.1). The
// parentheses match SQL's `CHECK (…)` and give the parser an
// unambiguous end for the expression.
const CHECK_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("check")),
Node::Punct('('),
Node::Subgrammar(&super::expr::OR_EXPR),
Node::Punct(')'),
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] = const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT]; &[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES); const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's /// Zero-or-more constraints — the suffix after a column's
@@ -894,18 +906,49 @@ const CREATE_TABLE_NODES: &[Node] = &[
]; ];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES); const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
/// Consume a `check` constraint's `( <expr> )` from `items`,
/// which must be positioned just after the `Word("check")`,
/// and build the ADR-0026 expression (ADR-0029 §2.1). The
/// grammar's `Seq` guarantees the surrounding `(` … `)`;
/// paren depth handles a parenthesised sub-expression inside.
fn consume_check_expr(
items: &mut std::iter::Peekable<std::slice::Iter<'_, MatchedItem>>,
) -> Result<Expr, ValidationError> {
items.next(); // the opening `(`
let mut depth = 1usize;
let mut expr_items: Vec<MatchedItem> = Vec::new();
for inner in items.by_ref() {
match &inner.kind {
MatchedKind::Punct('(') => {
depth += 1;
expr_items.push(inner.clone());
}
MatchedKind::Punct(')') => {
depth -= 1;
if depth == 0 {
break;
}
expr_items.push(inner.clone());
}
_ => expr_items.push(inner.clone()),
}
}
super::expr::build_expr(&expr_items)
}
/// Collect the ADR-0029 constraint suffix from a /// Collect the ADR-0029 constraint suffix from a
/// single-column command's matched path (`add column`), /// single-column command's matched path (`add column`),
/// returning the `(not_null, unique, default)` triple. The /// returning the `(not_null, unique, default, check)` tuple.
/// scan reacts only to the four constraint keywords, so /// The scan reacts only to the constraint keywords, so
/// passing the whole path is safe. (`create table`'s /// passing the whole path is safe. (`create table`'s
/// multi-column collection is inline in `build_create_table`.) /// multi-column collection is inline in `build_create_table`.)
fn collect_column_constraints( fn collect_column_constraints(
path: &MatchedPath, path: &MatchedPath,
) -> Result<(bool, bool, Option<Value>), ValidationError> { ) -> Result<(bool, bool, Option<Value>, Option<Expr>), ValidationError> {
let mut not_null = false; let mut not_null = false;
let mut unique = false; let mut unique = false;
let mut default = None; let mut default = None;
let mut check = None;
let mut items = path.items.iter().peekable(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { while let Some(item) = items.next() {
match &item.kind { match &item.kind {
@@ -929,10 +972,13 @@ fn collect_column_constraints(
})?; })?;
default = Some(value); default = Some(value);
} }
MatchedKind::Word("check") => {
check = Some(consume_check_expr(&mut items)?);
}
_ => {} _ => {}
} }
} }
Ok((not_null, unique, default)) Ok((not_null, unique, default, check))
} }
/// The friendly error for declaring a constraint a /// The friendly error for declaring a constraint a
@@ -1005,6 +1051,13 @@ fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
last.default = Some(value); last.default = Some(value);
} }
} }
// `check ( <expr> )` (ADR-0029 §2.1).
MatchedKind::Word("check") => {
let expr = consume_check_expr(&mut items)?;
if let Some(last) = columns.last_mut() {
last.check = Some(expr);
}
}
_ => {} _ => {}
} }
} }
@@ -1152,4 +1205,33 @@ mod constraint_tests {
other => panic!("expected AddColumn, got {other:?}"), other => panic!("expected AddColumn, got {other:?}"),
} }
} }
#[test]
fn create_table_parses_a_check_constraint() {
let cols = create_columns("create table T with pk age(int) check (age >= 0)");
assert_eq!(cols.len(), 1);
assert!(cols[0].check.is_some(), "the column carries a CHECK");
}
#[test]
fn add_column_parses_a_check_constraint() {
match parse_command("add column to T: age (int) check (age >= 0 and age < 150)")
.expect("parse")
{
Command::AddColumn { check, .. } => {
assert!(check.is_some(), "the column carries a CHECK");
}
other => panic!("expected AddColumn, got {other:?}"),
}
}
#[test]
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
assert!(cols[0].check.is_some());
}
} }
+5
View File
@@ -369,6 +369,9 @@ fn constraints_display(c: &ColumnDescription) -> String {
if let Some(default) = &c.default { if let Some(default) = &c.default {
parts.push(format!("DEFAULT {default}")); parts.push(format!("DEFAULT {default}"));
} }
if let Some(check) = &c.check {
parts.push(format!("CHECK ({check})"));
}
parts.join(", ") parts.join(", ")
} }
@@ -521,6 +524,7 @@ mod tests {
primary_key: pk, primary_key: pk,
unique: false, unique: false,
default: None, default: None,
check: None,
} }
} }
@@ -990,6 +994,7 @@ mod tests {
primary_key: false, primary_key: false,
unique: false, unique: false,
default: None, default: None,
check: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(), inbound_relationships: Vec::new(),
+1
View File
@@ -376,6 +376,7 @@ mod tests {
unique: false, unique: false,
not_null: false, not_null: false,
default: None, default: None,
check: None,
} }
} }
+5
View File
@@ -152,6 +152,10 @@ pub struct ColumnSchema {
/// form SQLite reports and `schema_to_ddl` echoes verbatim. /// form SQLite reports and `schema_to_ddl` echoes verbatim.
/// `None` when the column has no default. /// `None` when the column has no default.
pub default: Option<String>, pub default: Option<String>,
/// `CHECK` constraint in compiled-SQL form (ADR-0029 §7),
/// echoed verbatim into the rebuilt DDL. `None` when the
/// column has no check.
pub check: Option<String>,
} }
/// One index as recorded in `project.yaml` (ADR-0025). /// One index as recorded in `project.yaml` (ADR-0025).
@@ -383,6 +387,7 @@ mod tests {
unique: false, unique: false,
not_null: false, not_null: false,
default: None, default: None,
check: None,
}], }],
rows: vec![vec![CellValue::Text("Alice".to_string())]], rows: vec![vec![CellValue::Text("Alice".to_string())]],
}; };
+18 -6
View File
@@ -129,6 +129,10 @@ fn write_column(out: &mut String, col: &ColumnSchema) {
line.push_str(", default: "); line.push_str(", default: ");
line.push_str(&yaml_string(default)); line.push_str(&yaml_string(default));
} }
if let Some(check) = &col.check {
line.push_str(", check: ");
line.push_str(&yaml_string(check));
}
line.push_str(" }"); line.push_str(" }");
let _ = writeln!(out, "{line}"); let _ = writeln!(out, "{line}");
} }
@@ -238,6 +242,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
unique: c.unique, unique: c.unique,
not_null: c.not_null, not_null: c.not_null,
default: c.default, default: c.default,
check: c.check,
}); });
} }
tables.push(TableSchema { tables.push(TableSchema {
@@ -370,6 +375,9 @@ struct RawColumn {
/// `DEFAULT` SQL literal (ADR-0029); absent in older files. /// `DEFAULT` SQL literal (ADR-0029); absent in older files.
#[serde(default)] #[serde(default)]
default: Option<String>, default: Option<String>,
/// `CHECK` SQL (ADR-0029); absent in older files.
#[serde(default)]
check: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -407,16 +415,16 @@ mod tests {
name: "Customers".to_string(), name: "Customers".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None }, ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None }, ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
], ],
}, },
TableSchema { TableSchema {
name: "Orders".to_string(), name: "Orders".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None }, ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None }, ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
], ],
}, },
], ],
@@ -484,6 +492,7 @@ mod tests {
unique: false, unique: false,
not_null: false, not_null: false,
default: None, default: None,
check: None,
}], }],
}], }],
relationships: vec![], relationships: vec![],
@@ -523,6 +532,7 @@ mod tests {
unique: false, unique: false,
not_null: false, not_null: false,
default: None, default: None,
check: None,
}, },
ColumnSchema { ColumnSchema {
name: "title".to_string(), name: "title".to_string(),
@@ -530,6 +540,7 @@ mod tests {
unique: true, unique: true,
not_null: true, not_null: true,
default: Some("'untitled'".to_string()), default: Some("'untitled'".to_string()),
check: None,
}, },
ColumnSchema { ColumnSchema {
name: "stock".to_string(), name: "stock".to_string(),
@@ -537,6 +548,7 @@ mod tests {
unique: false, unique: false,
not_null: false, not_null: false,
default: Some("0".to_string()), default: Some("0".to_string()),
check: Some("\"stock\" >= 0".to_string()),
}, },
], ],
}], }],
@@ -622,8 +634,8 @@ relationships:
name: "Items".to_string(), name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()], primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None }, ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None }, ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
], ],
}], }],
relationships: vec![], relationships: vec![],
+2
View File
@@ -1117,6 +1117,7 @@ mod tests {
primary_key: true, primary_key: true,
unique: false, unique: false,
default: None, default: None,
check: None,
}, },
ColumnDescription { ColumnDescription {
name: "Name".to_string(), name: "Name".to_string(),
@@ -1126,6 +1127,7 @@ mod tests {
primary_key: false, primary_key: false,
unique: false, unique: false,
default: None, default: None,
check: None,
}, },
], ],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
+3
View File
@@ -252,6 +252,7 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
primary_key: *pk, primary_key: *pk,
unique: false, unique: false,
default: None, default: None,
check: None,
}) })
.collect(), .collect(),
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
@@ -419,6 +420,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
primary_key: true, primary_key: true,
unique: false, unique: false,
default: None, default: None,
check: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd { inbound_relationships: vec![RelationshipEnd {
@@ -470,6 +472,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
primary_key: true, primary_key: true,
unique: false, unique: false,
default: None, default: None,
check: None,
}], }],
outbound_relationships: Vec::new(), outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd { inbound_relationships: vec![RelationshipEnd {