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:
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, ¶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(
|
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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ mod tests {
|
|||||||
unique: false,
|
unique: false,
|
||||||
not_null: false,
|
not_null: false,
|
||||||
default: None,
|
default: None,
|
||||||
|
check: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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![],
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user