diff --git a/docs/adr/0029-column-constraints.md b/docs/adr/0029-column-constraints.md index 05469d9..1814c1a 100644 --- a/docs/adr/0029-column-constraints.md +++ b/docs/adr/0029-column-constraints.md @@ -80,7 +80,7 @@ A column spec gains an optional, repeatable constraint suffix **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: stock (int) default 0 check (stock >= 0) @@ -283,28 +283,31 @@ the data. constraints must round-trip through `project.yaml` or they 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 - `not_null: bool`, `default: Option`, and + `not_null: bool`, `default: Option`, and `check: Option`. (`unique: bool` already exists, - from ADR-0018's `serial` / `shortid` contract.) The `check` - expression is stored as its **canonical DSL text** — see the - `Expr` text renderer below. + from ADR-0018's `serial` / `shortid` contract.) `check` + holds the compiled SQL. - **Metadata table** — `NOT NULL`, `UNIQUE`, and `DEFAULT` are all recoverable from SQLite itself (`pragma_table_info`'s `notnull` and `dflt_value`; `pragma_index_list` origin `u`), so they need no metadata row. `CHECK` is *not* exposed by - any pragma — only by the raw `sqlite_master` SQL, which is - in engine syntax. So `__rdbms_playground_columns` carries a - nullable `check_expr TEXT` column holding the canonical DSL - text, keeping `describe` independent of engine-syntax - parsing. It is part of the internal table's `CREATE TABLE` - 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. + any pragma. So `__rdbms_playground_columns` carries a + nullable `check_expr TEXT` column holding the compiled SQL, + which `schema_to_ddl` and `describe` echo verbatim. It is + part of the internal table's `CREATE TABLE` definition — + there are no existing databases to migrate. ### 8. Structure rendering @@ -315,17 +318,17 @@ Option`, and `check: Option`. every constraint a column carries: ``` -┌───────┬──────┬───────────────────────────────────┐ -│ Name │ Type │ Constraints │ -├───────┼──────┼───────────────────────────────────┤ +┌───────┬────────┬─────────────────────────────────┐ +│ Name │ Type │ Constraints │ +├───────┼────────┼─────────────────────────────────┤ │ id │ serial │ PK │ -│ email │ text │ NOT NULL, UNIQUE │ -│ age │ int │ DEFAULT 18, CHECK age >= 0 │ -└───────┴──────┴───────────────────────────────────┘ +│ email │ text │ NOT NULL, UNIQUE │ +│ age │ int │ DEFAULT 18, CHECK ("age" >= 0) │ +└───────┴────────┴─────────────────────────────────┘ ``` -The `CHECK` expression renders in DSL form (`render_expr`), -not engine SQL. +The `CHECK` renders in its compiled-SQL form (§7) — the same +double-quoted-identifier style as ADR-0028's `explain` SQL. ### 9. PK columns — redundant and impossible constraints @@ -409,10 +412,7 @@ additions: tables go through the ADR-0016 pretty-table renderer. - The internal metadata table `__rdbms_playground_columns` carries a new `check_expr` column — its first change since - ADR-0012. -- 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. + ADR-0012 — holding the compiled-SQL form of the `CHECK`. - `project.yaml`'s `ColumnSchema` grows three fields; the format stays backward-compatible (the new keys default to "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 `Expr` → SQL compile for `CHECK`. 3. **Storage round-trip.** `ColumnSchema` fields, the - `check_expr` metadata column, `render_expr`, and the - `project.yaml` read/write paths. + `check_expr` metadata column, and the `project.yaml` + read/write paths. 4. **`add constraint …` / `drop constraint …`.** The two commands, the rebuild-table path, and the §5 dry-run with its pretty-table refusals. diff --git a/docs/simple-mode-limitations.md b/docs/simple-mode-limitations.md index b7af0b2..331b848 100644 --- a/docs/simple-mode-limitations.md +++ b/docs/simple-mode-limitations.md @@ -48,3 +48,8 @@ entry names the ADR that drew the boundary. added afterward with `add column`. Creating a table with a mix of PK and non-PK columns in one statement needs advanced-mode `CREATE TABLE` syntax. +- **`check ()` 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`. diff --git a/src/app.rs b/src/app.rs index c74a4da..93a51d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2175,6 +2175,7 @@ mod tests { primary_key: true, unique: false, default: None, + check: None, }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), diff --git a/src/db.rs b/src/db.rs index 6435fd3..fa3d478 100644 --- a/src/db.rs +++ b/src/db.rs @@ -136,6 +136,9 @@ pub struct ColumnDescription { /// The column's `DEFAULT` expression as SQLite reports it, /// or `None` (ADR-0029). pub default: Option, + /// The column's `CHECK` constraint in compiled-SQL form, + /// or `None` (ADR-0029). + pub check: Option, } #[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 { 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 = read @@ -1816,6 +1822,63 @@ fn default_sql_literal(spec: &ColumnSpec) -> Result, 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 = Vec::new(); + let sql = compile_expr(expr, schema, &mut params); + inline_params_for_display(&sql, ¶ms) +} + +/// A minimal `ReadSchema` built from column specs — enough for +/// `compile_expr` to resolve column types when compiling a +/// `CHECK` at create-table time, before the table exists. +fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema { + ReadSchema { + columns: columns + .iter() + .map(|c| ReadColumn { + name: c.name.clone(), + sqlite_type: c.ty.sqlite_strict_type().to_string(), + notnull: c.not_null, + primary_key: primary_key.contains(&c.name), + unique: c.unique, + default_sql: None, + check: None, + user_type: Some(c.ty), + }) + .collect(), + primary_key: primary_key.to_vec(), + foreign_keys: Vec::new(), + } +} + +/// Insert a column's row into the metadata table — the user +/// type, plus the compiled `CHECK` SQL when present +/// (ADR-0029 §7). +fn insert_column_metadata( + conn: &Connection, + table: &str, + column: &str, + user_type: Type, + check_sql: Option<&str>, +) -> Result<(), DbError> { + conn.execute( + &format!( + "INSERT INTO {META_TABLE} \ + (table_name, column_name, user_type, check_expr) \ + VALUES (?1, ?2, ?3, ?4);" + ), + rusqlite::params![table, column, user_type.keyword(), check_sql], + ) + .map_err(DbError::from_rusqlite)?; + Ok(()) +} + fn do_create_table( conn: &Connection, persistence: Option<&Persistence>, @@ -1843,8 +1906,17 @@ fn do_create_table( let single_inline_pk = primary_key.len() == 1 && columns.len() == 1 && primary_key[0] == columns[0].name; + // Compile each column's CHECK once (ADR-0029 §4) — reused + // by the DDL clause and the metadata insert below. The + // minimal schema gives `compile_expr` the column types. + let check_schema = read_schema_for_specs(columns, primary_key); + let check_sqls: Vec> = columns + .iter() + .map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema))) + .collect(); + let mut column_clauses: Vec = 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, + /// 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, user_type: Option, } @@ -3416,7 +3503,8 @@ fn read_schema(conn: &Connection, table: &str) -> Result { // 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 { 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 (String, Vec, Vec) { + 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), }, ], diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 3075b64..4bb12c8 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -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 { 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 { 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 ( )` — 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 `( )` 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>, +) -> Result { + items.next(); // the opening `(` + let mut depth = 1usize; + let mut expr_items: Vec = 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), ValidationError> { +) -> Result<(bool, bool, Option, Option), 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 { last.default = Some(value); } } + // `check ( )` (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()); + } } diff --git a/src/output_render.rs b/src/output_render.rs index 7df0334..4f0b82d 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -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(), diff --git a/src/persistence/csv_io.rs b/src/persistence/csv_io.rs index 464cf6d..e116da5 100644 --- a/src/persistence/csv_io.rs +++ b/src/persistence/csv_io.rs @@ -376,6 +376,7 @@ mod tests { unique: false, not_null: false, default: None, + check: None, } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index f4b54ce..fda6bbe 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -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, + /// `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, } /// 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())]], }; diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index f321b76..f9a8d12 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -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 { 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, + /// `CHECK` SQL (ADR-0029); absent in older files. + #[serde(default)] + check: Option, } #[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![], diff --git a/src/ui.rs b/src/ui.rs index 8818c6e..774aede 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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(), diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 2d9ad56..954dcfb 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -252,6 +252,7 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription { primary_key: *pk, unique: false, default: None, + check: None, }) .collect(), outbound_relationships: Vec::new(), @@ -419,6 +420,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { primary_key: true, unique: false, default: None, + check: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd { @@ -470,6 +472,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { primary_key: true, unique: false, default: None, + check: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd {