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
+1
View File
@@ -2175,6 +2175,7 @@ mod tests {
primary_key: true,
unique: false,
default: None,
check: None,
}],
outbound_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,
/// or `None` (ADR-0029).
pub default: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form,
/// or `None` (ADR-0029).
pub check: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1050,6 +1053,7 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\
check_expr TEXT,\n\
PRIMARY KEY (table_name, column_name)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\
@@ -1612,6 +1616,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
// user_type is always populated for tables we
// created; the fallback is defensive.
user_type: c.user_type.unwrap_or(Type::Text),
@@ -1707,6 +1712,7 @@ fn read_table_snapshot(
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
let column_idents: Vec<String> = read
@@ -1816,6 +1822,63 @@ fn default_sql_literal(spec: &ColumnSpec) -> Result<Option<String>, DbError> {
Ok(Some(sql_literal(&bound_to_sqlite_value(&bound))))
}
/// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 /
/// §7) — the form stored in the `check_expr` metadata column
/// and emitted into column DDL. `compile_expr` produces
/// `?N`-parameterised SQL; `inline_params_for_display`
/// (ADR-0028) folds the literals back in, since DDL admits no
/// parameters.
fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let sql = compile_expr(expr, schema, &mut params);
inline_params_for_display(&sql, &params)
}
/// A minimal `ReadSchema` built from column specs — enough for
/// `compile_expr` to resolve column types when compiling a
/// `CHECK` at create-table time, before the table exists.
fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema {
ReadSchema {
columns: columns
.iter()
.map(|c| ReadColumn {
name: c.name.clone(),
sqlite_type: c.ty.sqlite_strict_type().to_string(),
notnull: c.not_null,
primary_key: primary_key.contains(&c.name),
unique: c.unique,
default_sql: None,
check: None,
user_type: Some(c.ty),
})
.collect(),
primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(),
}
}
/// Insert a column's row into the metadata table — the user
/// type, plus the compiled `CHECK` SQL when present
/// (ADR-0029 §7).
fn insert_column_metadata(
conn: &Connection,
table: &str,
column: &str,
user_type: Type,
check_sql: Option<&str>,
) -> Result<(), DbError> {
conn.execute(
&format!(
"INSERT INTO {META_TABLE} \
(table_name, column_name, user_type, check_expr) \
VALUES (?1, ?2, ?3, ?4);"
),
rusqlite::params![table, column, user_type.keyword(), check_sql],
)
.map_err(DbError::from_rusqlite)?;
Ok(())
}
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -1843,8 +1906,17 @@ fn do_create_table(
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name;
// Compile each column's CHECK once (ADR-0029 §4) — reused
// by the DDL clause and the metadata insert below. The
// minimal schema gives `compile_expr` the column types.
let check_schema = read_schema_for_specs(columns, primary_key);
let check_sqls: Vec<Option<String>> = columns
.iter()
.map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
.collect();
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for col in columns {
for (col, check_sql) in columns.iter().zip(&check_sqls) {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
@@ -1858,6 +1930,9 @@ fn do_create_table(
// redundant declarations (ADR-0029 §9) so a PK column
// never carries them here.
clause.push_str(&column_constraints_sql(col)?);
if let Some(cs) = check_sql {
clause.push_str(&format!(" CHECK ({cs})"));
}
column_clauses.push(clause);
}
@@ -1884,17 +1959,8 @@ fn do_create_table(
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
))
.map_err(DbError::from_rusqlite)?;
for col in columns {
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
.map_err(DbError::from_rusqlite)?;
}
for (col, check_sql) in columns.iter().zip(&check_sqls) {
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
}
let description = do_describe_table(conn, name)?;
let changes = Changes {
@@ -1992,13 +2058,25 @@ fn do_add_column(
ty = column.ty.keyword(),
)));
}
// A CHECK on an auto-generated column is supported at
// `create table` time; adding one to a `serial` /
// `shortid` column afterwards is not (the auto-fill
// rebuild path does not thread it).
if column.check.is_some() {
return Err(DbError::Unsupported(format!(
"a `check` constraint on the auto-generated column `{}` \
can only be set when the table is created.",
column.name,
)));
}
return do_add_auto_generated_column(conn, persistence, source, table, column);
}
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`,
// and a `NOT NULL` column added that way must carry a
// default — both route through the rebuild primitive
// instead (ADR-0029 §6).
if column.unique || (column.not_null && column.default.is_none()) {
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
// or `CHECK`, and a `NOT NULL` column added that way must
// carry a default — all route through the rebuild
// primitive instead (ADR-0029 §6).
if column.unique || column.check.is_some() || (column.not_null && column.default.is_none())
{
do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
} else {
do_add_plain_column(conn, persistence, source, table, column)
@@ -2087,6 +2165,7 @@ fn do_add_auto_generated_column(
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(ty),
});
@@ -2233,18 +2312,21 @@ fn do_add_constrained_column_via_rebuild(
primary_key: false,
unique: spec.unique,
default_sql: default_sql_literal(spec)?,
check: None,
user_type: Some(spec.ty),
});
// The CHECK is compiled against the post-add schema, so it
// may reference the new column itself.
let check_sql = spec
.check
.as_ref()
.map(|e| compile_check_sql(e, &new_schema));
if let Some(last) = new_schema.columns.last_mut() {
last.check.clone_from(&check_sql);
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, spec.name.as_str(), spec.ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
@@ -3400,6 +3482,11 @@ struct ReadColumn {
/// literal, echoed verbatim by `schema_to_ddl` so the
/// rebuild dance preserves it (ADR-0029).
default_sql: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form
/// (ADR-0029 §7), read from the `check_expr` metadata
/// column — `pragma_table_info` does not expose CHECK.
/// Echoed verbatim by `schema_to_ddl`.
check: Option<String>,
user_type: Option<Type>,
}
@@ -3416,7 +3503,8 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
// Columns + PK from pragma_table_info, joined with our user-type metadata.
let mut col_stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, pti.dflt_value \
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \
pti.dflt_value, m.check_expr \
FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \
@@ -3434,6 +3522,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
primary_key: row.get::<_, i64>(3)? != 0,
unique: false, // filled in below from pragma_index_list
default_sql: row.get(5)?,
check: row.get(6)?,
user_type,
})
})
@@ -3656,6 +3745,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
clause.push_str(" DEFAULT ");
clause.push_str(default_sql);
}
// ADR-0029 CHECK — echoed verbatim from the compiled
// SQL stored in the `check_expr` metadata column.
if let Some(check) = &col.check {
clause.push_str(" CHECK (");
clause.push_str(check);
clause.push(')');
}
clauses.push(clause);
}
@@ -3907,6 +4003,7 @@ fn do_add_relationship(
primary_key: false,
unique: false,
default_sql: None,
check: None,
user_type: Some(expected_child_type),
});
} else {
@@ -4281,6 +4378,7 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
primary_key: c.primary_key,
unique: c.unique,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
@@ -5478,6 +5576,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
primary_key: table.primary_key.contains(&c.name),
unique: c.unique,
default_sql: c.default.clone(),
check: c.check.clone(),
user_type: Some(c.user_type),
})
.collect();
@@ -8695,6 +8794,137 @@ mod tests {
);
}
// --- CHECK constraints (ADR-0029 §4) --------------------
/// Parse a `create table` DSL string into its db-call parts
/// — the way to get a real `Expr` into a `ColumnSpec.check`
/// without hand-building the AST.
fn parse_create(dsl: &str) -> (String, Vec<ColumnSpec>, Vec<String>) {
match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
Command::CreateTable {
name,
columns,
primary_key,
} => (name, columns, primary_key),
other => panic!("expected CreateTable, got {other:?}"),
}
}
/// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
let (_, columns, _) = parse_create(&format!(
"create table __probe with pk {name}({}) check ({check_dsl})",
ty.keyword(),
));
columns.into_iter().next().expect("one column")
}
#[tokio::test]
async fn create_table_check_constraint_is_enforced() {
let db = db();
let (n, c, pk) = parse_create(
"create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
);
db.create_table(n, c, pk, None).await.unwrap();
let insert_grade = |g: &str| {
db.insert(
"Grades".to_string(),
Some(vec!["grade".to_string()]),
vec![Value::Text(g.to_string())],
None,
)
};
assert!(insert_grade("A").await.is_ok(), "a value the check allows");
assert!(
insert_grade("Z").await.is_err(),
"a value the check forbids is refused",
);
}
#[tokio::test]
async fn describe_surfaces_the_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk age(int) check (age >= 0)");
db.create_table(n, c, pk, None).await.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
let check = age.check.as_deref().expect("age carries a CHECK");
assert!(
check.contains(">="),
"the compiled check SQL is surfaced: {check}",
);
}
#[tokio::test]
async fn add_column_check_constraint_is_enforced() {
let db = db();
people_table(&db).await;
db.add_column(
"People".to_string(),
col_c_check("score", Type::Int, "score >= 0"),
None,
)
.await
.expect("a CHECK column adds via the rebuild path");
let desc = db.describe_table("People".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some());
// An update that violates the check is refused.
let bad = db
.update(
"People".to_string(),
vec![("score".to_string(), Value::Number("-1".to_string()))],
parse_filter("update People set score=-1 where id = 1"),
None,
)
.await;
assert!(bad.is_err(), "an update violating the CHECK is refused");
}
#[tokio::test]
async fn rebuild_preserves_a_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk code(text) check (code like 'X%')");
db.create_table(n, c, pk, None).await.unwrap();
db.add_column("T".to_string(), col("note", Type::Int), None)
.await
.unwrap();
// A type change on `note` rebuilds the table; `code`'s
// CHECK must survive the round-trip through schema_to_ddl.
db.change_column_type(
"T".to_string(),
"note".to_string(),
Type::Decimal,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
assert!(
desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(),
"code keeps its CHECK across the rebuild",
);
}
#[tokio::test]
async fn add_serial_column_with_a_check_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c_check("seq", Type::Serial, "seq > 0"),
None,
)
.await;
assert!(
result.is_err(),
"a CHECK on an auto-generated column is a create-table-only feature",
);
}
#[tokio::test]
async fn update_with_all_rows_affects_everything() {
let db = db();
@@ -8966,6 +9196,7 @@ mod tests {
primary_key: true,
unique: false,
default_sql: None,
check: None,
user_type: Some(Type::Serial),
},
ReadColumn {
@@ -8975,6 +9206,7 @@ mod tests {
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(Type::Text),
},
],
+92 -10
View File
@@ -13,7 +13,7 @@
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector,
ChangeColumnMode, ColumnSpec, Command, Expr, IndexSelector, RelationshipSelector,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
@@ -27,7 +27,7 @@ use crate::dsl::grammar::{
/// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
// Building blocks
@@ -616,7 +616,8 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
message_key: "parse.error_wrapper",
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 {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -624,8 +625,7 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
not_null,
unique,
default,
// CHECK joins in a later ADR-0029 step.
check: None,
check,
})
}
Some("1") => build_add_relationship(path),
@@ -842,8 +842,20 @@ const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
];
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] =
&[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);
/// 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);
/// 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
/// single-column command's matched path (`add column`),
/// returning the `(not_null, unique, default)` triple. The
/// scan reacts only to the four constraint keywords, so
/// returning the `(not_null, unique, default, check)` tuple.
/// The scan reacts only to the constraint keywords, so
/// passing the whole path is safe. (`create table`'s
/// multi-column collection is inline in `build_create_table`.)
fn collect_column_constraints(
path: &MatchedPath,
) -> Result<(bool, bool, Option<Value>), ValidationError> {
) -> Result<(bool, bool, Option<Value>, Option<Expr>), ValidationError> {
let mut not_null = false;
let mut unique = false;
let mut default = None;
let mut check = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
@@ -929,10 +972,13 @@ fn collect_column_constraints(
})?;
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
@@ -1005,6 +1051,13 @@ fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
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:?}"),
}
}
#[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 {
parts.push(format!("DEFAULT {default}"));
}
if let Some(check) = &c.check {
parts.push(format!("CHECK ({check})"));
}
parts.join(", ")
}
@@ -521,6 +524,7 @@ mod tests {
primary_key: pk,
unique: false,
default: None,
check: None,
}
}
@@ -990,6 +994,7 @@ mod tests {
primary_key: false,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
+1
View File
@@ -376,6 +376,7 @@ mod tests {
unique: false,
not_null: false,
default: None,
check: None,
}
}
+5
View File
@@ -152,6 +152,10 @@ pub struct ColumnSchema {
/// form SQLite reports and `schema_to_ddl` echoes verbatim.
/// `None` when the column has no default.
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).
@@ -383,6 +387,7 @@ mod tests {
unique: false,
not_null: false,
default: None,
check: None,
}],
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(&yaml_string(default));
}
if let Some(check) = &col.check {
line.push_str(", check: ");
line.push_str(&yaml_string(check));
}
line.push_str(" }");
let _ = writeln!(out, "{line}");
}
@@ -238,6 +242,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
unique: c.unique,
not_null: c.not_null,
default: c.default,
check: c.check,
});
}
tables.push(TableSchema {
@@ -370,6 +375,9 @@ struct RawColumn {
/// `DEFAULT` SQL literal (ADR-0029); absent in older files.
#[serde(default)]
default: Option<String>,
/// `CHECK` SQL (ADR-0029); absent in older files.
#[serde(default)]
check: Option<String>,
}
#[derive(Deserialize)]
@@ -407,16 +415,16 @@ mod tests {
name: "Customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, 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, check: None },
],
},
TableSchema {
name: "Orders".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, 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, check: None },
],
},
],
@@ -484,6 +492,7 @@ mod tests {
unique: false,
not_null: false,
default: None,
check: None,
}],
}],
relationships: vec![],
@@ -523,6 +532,7 @@ mod tests {
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "title".to_string(),
@@ -530,6 +540,7 @@ mod tests {
unique: true,
not_null: true,
default: Some("'untitled'".to_string()),
check: None,
},
ColumnSchema {
name: "stock".to_string(),
@@ -537,6 +548,7 @@ mod tests {
unique: false,
not_null: false,
default: Some("0".to_string()),
check: Some("\"stock\" >= 0".to_string()),
},
],
}],
@@ -622,8 +634,8 @@ relationships:
name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![
ColumnSchema { name: "a".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 },
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, check: None },
],
}],
relationships: vec![],
+2
View File
@@ -1117,6 +1117,7 @@ mod tests {
primary_key: true,
unique: false,
default: None,
check: None,
},
ColumnDescription {
name: "Name".to_string(),
@@ -1126,6 +1127,7 @@ mod tests {
primary_key: false,
unique: false,
default: None,
check: None,
},
],
outbound_relationships: Vec::new(),