From 12395a9a6ce74a528bc44057b30d0989784c36b2 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 14:41:29 +0000 Subject: [PATCH] =?UTF-8?q?create=20table:=20column=20constraints=20?= =?UTF-8?q?=E2=80=94=20NOT=20NULL=20/=20UNIQUE=20/=20DEFAULT=20grammar=20(?= =?UTF-8?q?ADR-0029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create table … with pk` now parses the column-constraint suffix; combined with the commit-1 db layer, a constrained table works end to end. - A shared constraint-suffix grammar fragment — `not null`, `unique`, `default ` — sits after each column's `(type)` group; `build_create_table` walks the matched path per column and folds the constraints into `ColumnSpec`. - §9 redundancy check: every `with pk` column is a primary-key column, so `not null` (any) and `unique` (single-column PK) are rejected with a friendly error (`parse.custom.constraint_redundant_on_pk`). - `project.yaml` round-trip: `ColumnSchema` gains `not_null` / `default`; the YAML reader/writer and `build_read_schema` carry them, so `rebuild` / `export` / `import` preserve constraints. - ADR-0029 §2.1's example corrected — `create table` columns are all PK columns, so its suffix is for `default` / `check`; `docs/simple-mode-limitations.md` records that non-PK columns at create time need advanced mode. CHECK is deferred to the next commit. 1184 tests pass (+7); clippy clean. --- docs/adr/0029-column-constraints.md | 20 ++- docs/simple-mode-limitations.md | 10 ++ src/db.rs | 8 +- src/dsl/grammar/data.rs | 5 +- src/dsl/grammar/ddl.rs | 238 ++++++++++++++++++++++------ src/dsl/grammar/shared.rs | 5 +- src/friendly/keys.rs | 1 + src/friendly/strings/en-US.yaml | 4 + src/persistence/csv_io.rs | 8 +- src/persistence/mod.rs | 9 ++ src/persistence/yaml.rs | 116 +++++++++++--- 11 files changed, 348 insertions(+), 76 deletions(-) diff --git a/docs/adr/0029-column-constraints.md b/docs/adr/0029-column-constraints.md index 7c2b979..05469d9 100644 --- a/docs/adr/0029-column-constraints.md +++ b/docs/adr/0029-column-constraints.md @@ -80,14 +80,24 @@ A column spec gains an optional, repeatable constraint suffix **after** the `(type)` group: ``` -create table Users with pk - id(serial), - email(text) not null unique, - age(int) default 18 check (age >= 0) +create table Books with pk isbn(text) check (length(isbn) = 13) -add column to Orders: note (text) default '' +add column to Books: title (text) not null +add column to Books: stock (int) default 0 check (stock >= 0) ``` +**Where each constraint is useful.** The simple-mode `create +table … with pk …` declares *only* primary-key columns — +every column in the `with pk` list is part of the primary key +(non-PK columns are added afterward with `add column`; see +`docs/simple-mode-limitations.md`). Since a PK column is +already `NOT NULL` and `UNIQUE`, §9 rejects those two as +redundant there — so on a `create table` column the suffix is +useful for `default` / `check`. `not null` / `unique` come +into their own on `add column` (non-PK columns) and on `add +constraint`. The suffix grammar is nonetheless shared +verbatim across all three surfaces; §9 does the rejecting. + - Standard SQL writes constraints after the data type (`email TEXT NOT NULL UNIQUE`). The playground brackets the type as `email(text)` — a pre-existing convention this ADR diff --git a/docs/simple-mode-limitations.md b/docs/simple-mode-limitations.md index 205eb3c..b7af0b2 100644 --- a/docs/simple-mode-limitations.md +++ b/docs/simple-mode-limitations.md @@ -38,3 +38,13 @@ entry names the ADR that drew the boundary. yet available. - **No `LIMIT … OFFSET`** — `limit` takes a row count only. + +## Table creation (ADR-0029) + +- **`create table` declares only primary-key columns.** + `create table T with pk …` makes every listed column part + of the primary key; there is no simple-mode syntax for a + non-PK column in the same statement. Non-PK columns are + 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. diff --git a/src/db.rs b/src/db.rs index 911a688..28a3b82 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1610,6 +1610,8 @@ fn read_schema_snapshot(conn: &Connection) -> Result { .map(|c| ColumnSchema { name: c.name.clone(), unique: c.unique, + not_null: c.notnull, + default: c.default_sql.clone(), // user_type is always populated for tables we // created; the fallback is defensive. user_type: c.user_type.unwrap_or(Type::Text), @@ -1703,6 +1705,8 @@ fn read_table_snapshot( name: c.name.clone(), user_type: c.user_type.unwrap_or(Type::Text), unique: c.unique, + not_null: c.notnull, + default: c.default_sql.clone(), }) .collect(); let column_idents: Vec = read @@ -5361,10 +5365,10 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) .map(|c| ReadColumn { name: c.name.clone(), sqlite_type: c.user_type.sqlite_strict_type().to_string(), - notnull: false, + notnull: c.not_null, primary_key: table.primary_key.contains(&c.name), unique: c.unique, - default_sql: None, + default_sql: c.default.clone(), user_type: Some(c.user_type), }) .collect(); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 3aae0da..2d8f22f 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -375,7 +375,10 @@ fn require_ident(path: &MatchedPath, role: &'static str) -> Result Option { +/// +/// `pub(crate)` so `grammar::ddl` can reuse it when collecting a +/// `default ` column constraint (ADR-0029). +pub(crate) fn item_to_value(item: &MatchedItem) -> Option { match &item.kind { MatchedKind::Word("null") => Some(Value::Null), MatchedKind::Word("true") => Some(Value::Bool(true)), diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index ca6f2f5..82b8c24 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -820,6 +820,37 @@ const COL_NAME: Node = Node::Hinted { inner: &COL_NAME_IDENT, }; +// ADR-0029 column-constraint suffix — `not null`, `unique`, +// `default `. (`check ()` joins in a later +// ADR-0029 step.) One shared fragment: `create table` uses it +// here; `add column` and `add constraint` reuse it later. +const NOT_NULL_NODES: &[Node] = &[ + Node::Word(Word::keyword("not")), + Node::Word(Word::keyword("null")), +]; +const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES); + +const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique")); + +const DEFAULT_CONSTRAINT_NODES: &[Node] = &[ + Node::Word(Word::keyword("default")), + super::shared::FALLBACK_VALUE_LITERAL, +]; +const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES); + +const COLUMN_CONSTRAINT_CHOICES: &[Node] = + &[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT]; +const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES); + +/// Zero-or-more constraints — the suffix after a column's +/// `(type)` group (ADR-0029 §2.1). `min: 0` so an +/// unconstrained column still matches. +const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated { + inner: &COLUMN_CONSTRAINT, + separator: None, + min: 0, +}; + const COL_SPEC_NODES: &[Node] = &[ COL_NAME, Node::Punct('('), @@ -833,6 +864,7 @@ const COL_SPEC_NODES: &[Node] = &[ writes_user_listed_column: false, }, Node::Punct(')'), + COLUMN_CONSTRAINT_SUFFIX, ]; const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES); @@ -858,64 +890,114 @@ const CREATE_TABLE_NODES: &[Node] = &[ ]; const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES); +/// The friendly error for declaring a constraint a +/// primary-key column already implies (ADR-0029 §9). +fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError { + ValidationError { + message_key: "parse.custom.constraint_redundant_on_pk", + args: vec![ + ("column", column.to_string()), + ("constraint", constraint.to_string()), + ], + } +} + fn build_create_table(path: &MatchedPath) -> Result { let name = require_ident(path, "table_name")?; - // Collect column specs by pairing alternating col_name / - // col_type ident matches. They always appear in declaration - // order so a simple zip is correct. - let names: Vec = path - .items - .iter() - .filter_map(|i| match &i.kind { - MatchedKind::Ident { role: "col_name", .. } => Some(i.text.clone()), - _ => None, - }) - .collect(); - let types_raw: Vec<&str> = path - .items - .iter() - .filter_map(|i| match &i.kind { - MatchedKind::Ident { role: "col_type", .. } => Some(i.text.as_str()), - _ => None, - }) - .collect(); + // Walk the matched items, segmenting per column: a + // `col_name` ident stashes the name, the following + // `col_type` ident finalises the spec, and the constraint + // tokens after it (ADR-0029 §2.1) attach to that spec. + let mut columns: Vec = Vec::new(); + let mut pending_name: Option = None; + let mut items = path.items.iter().peekable(); + while let Some(item) = items.next() { + match &item.kind { + MatchedKind::Ident { role: "col_name", .. } => { + pending_name = Some(item.text.clone()); + } + MatchedKind::Ident { role: "col_type", .. } => { + let ty = item.text.parse::().map_err(|_| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown type".to_string())], + })?; + let col_name = pending_name.take().ok_or_else(|| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "column type without a name".to_string())], + })?; + columns.push(ColumnSpec::new(col_name, ty)); + } + // `not null` — the grammar's `Seq` guarantees a + // `null` Word follows a matched `not` Word. + MatchedKind::Word("not") => { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("null")) + ) { + items.next(); + if let Some(last) = columns.last_mut() { + last.not_null = true; + } + } + } + MatchedKind::Word("unique") => { + if let Some(last) = columns.last_mut() { + last.unique = true; + } + } + // `default ` — the `Seq` guarantees a value + // item follows a matched `default` Word. + MatchedKind::Word("default") => { + let value = items + .next() + .and_then(crate::dsl::grammar::data::item_to_value) + .ok_or_else(|| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "default needs a value".to_string())], + })?; + if let Some(last) = columns.last_mut() { + last.default = Some(value); + } + } + _ => {} + } + } // No PK clause OR `with pk` alone (no specs): if `with` was - // matched, default to id:serial; otherwise reject with the - // "tables need at least one column" friendly wording. - let saw_with = path - .items - .iter() - .any(|i| matches!(i.kind, MatchedKind::Word("with"))); - - let pk_specs: Vec<(String, Type)> = if names.is_empty() { + // matched, default to id(serial); otherwise reject with the + // "tables need a primary key" friendly wording. + if columns.is_empty() { + let saw_with = path + .items + .iter() + .any(|i| matches!(i.kind, MatchedKind::Word("with"))); if saw_with { - // `with pk` alone — default to id(serial). - vec![("id".to_string(), Type::Serial)] + columns.push(ColumnSpec::new("id", Type::Serial)); } else { return Err(ValidationError { message_key: "parse.custom.create_table_needs_pk", args: vec![], }); } - } else { - let mut out = Vec::with_capacity(names.len()); - for (n, t_str) in names.iter().zip(types_raw.iter()) { - let ty = t_str.parse::().map_err(|_| ValidationError { - message_key: "parse.error_wrapper", - args: vec![("detail", "unknown type".to_string())], - })?; - out.push((n.clone(), ty)); - } - out - }; + } - let columns = pk_specs - .iter() - .map(|(n, t)| ColumnSpec::new(n.clone(), *t)) - .collect(); - let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect(); + // Every `with pk` column is part of the primary key + // (ADR-0029 §2.1). A PK column is already NOT NULL, and a + // single-column PK is already UNIQUE — declaring those + // explicitly is a friendly error, not a silent no-op + // (ADR-0029 §9). + let single_column_pk = columns.len() == 1; + for col in &columns { + if col.not_null { + return Err(redundant_pk_constraint(&col.name, "NOT NULL")); + } + if col.unique && single_column_pk { + return Err(redundant_pk_constraint(&col.name, "UNIQUE")); + } + } + + let primary_key = columns.iter().map(|c| c.name.clone()).collect(); Ok(Command::CreateTable { name, @@ -930,3 +1012,69 @@ pub static CREATE: CommandNode = CommandNode { ast_builder: build_create_table, help_id: Some("ddl.create"), usage_ids: &["parse.usage.create_table"],}; + +// ================================================================= +// Tests — `create table` column constraints (ADR-0029 §2.1, §9) +// ================================================================= + +#[cfg(test)] +mod constraint_tests { + use super::Command; + use crate::dsl::command::ColumnSpec; + use crate::dsl::parser::parse_command; + use crate::dsl::value::Value; + + /// Parse a `create table` and return its column specs. + fn create_columns(input: &str) -> Vec { + match parse_command(input).expect("create table should parse") { + Command::CreateTable { columns, .. } => columns, + other => panic!("expected CreateTable, got {other:?}"), + } + } + + #[test] + fn create_table_parses_a_text_default() { + // `grade` is the (single) PK column; `default` is + // allowed on a PK column (ADR-0029 §9). + let cols = create_columns("create table T with pk grade(text) default 'A'"); + assert_eq!(cols.len(), 1); + assert_eq!(cols[0].default, Some(Value::Text("A".to_string()))); + assert!(!cols[0].not_null && !cols[0].unique); + } + + #[test] + fn create_table_parses_a_numeric_default_on_a_compound_pk_member() { + let cols = create_columns("create table T with pk a(int), b(int) default 7"); + assert_eq!(cols.len(), 2); + assert_eq!(cols[1].default, Some(Value::Number("7".to_string()))); + } + + #[test] + fn not_null_on_a_pk_column_is_a_redundancy_error() { + // Every `create table` column is a primary-key column, + // so `not null` is always redundant there (ADR-0029 §9). + assert!(parse_command("create table T with pk id(serial) not null").is_err()); + } + + #[test] + fn unique_on_a_single_column_pk_is_a_redundancy_error() { + assert!(parse_command("create table T with pk code(text) unique").is_err()); + } + + #[test] + fn unique_on_a_compound_pk_member_is_allowed() { + // A compound PK does not make its members individually + // unique, so an explicit `unique` is meaningful there. + let cols = create_columns("create table T with pk a(int) unique, b(text)"); + assert_eq!(cols.len(), 2); + assert!(cols[0].unique, "`a` carries an explicit UNIQUE"); + assert!(!cols[1].unique); + } + + #[test] + fn an_unconstrained_create_table_still_parses() { + let cols = create_columns("create table T with pk id(serial), name(text)"); + assert_eq!(cols.len(), 2); + assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none())); + } +} diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs index 424aeec..fff71ad 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -354,7 +354,10 @@ const FALLBACK_VALUE_LITERAL_INNER: Node = Node::Choice(FALLBACK_VALUE_LITERAL_C /// surface the generic "Type a value: number, 'text', …" prose /// here rather than the misleading `null`/`true`/`false` /// candidate trio. -const FALLBACK_VALUE_LITERAL: Node = Node::Hinted { +/// The schemaless value-literal slot. `pub(crate)` so the +/// `default ` column constraint (ADR-0029) can reuse +/// it from `grammar::ddl`. +pub(crate) const FALLBACK_VALUE_LITERAL: Node = Node::Hinted { mode: HintMode::ProseOnly("hint.value_literal_slot"), inner: &FALLBACK_VALUE_LITERAL_INNER, }; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 9561e7b..d06b3fe 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -189,6 +189,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // by the DSL parser. See `parse.custom.*` in the catalog. ("parse.custom.bind_type_mismatch", &["found", "expected"]), ("parse.custom.change_column_flags_exclusive", &[]), + ("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]), ("parse.custom.create_table_needs_pk", &[]), ("parse.custom.expression_too_deep", &[]), ("parse.custom.insert_form_a_missing_values", &["columns"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 01caa6c..e111ec4 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -398,6 +398,10 @@ parse: # classifies the input as mid-typing rather than # dispatching a logically-empty Form C insert. insert_form_a_missing_values: "`insert into ...({columns})` looks like Form A — add `values (...)` to supply the matching values." + # ADR-0029 §9: a primary-key column is already NOT NULL, + # and a single-column primary key is already UNIQUE — + # declaring either explicitly is redundant. + constraint_redundant_on_pk: "`{column}` is a primary-key column, so it is already {constraint} — drop the redundant constraint." # Caret pointer showing where in the input the parser # failed. `{padding}` is the leading whitespace; the # template appends `^` so the rendered line places the diff --git a/src/persistence/csv_io.rs b/src/persistence/csv_io.rs index 3dd0de5..464cf6d 100644 --- a/src/persistence/csv_io.rs +++ b/src/persistence/csv_io.rs @@ -370,7 +370,13 @@ mod tests { use crate::persistence::ColumnSchema; fn col(name: &str, ty: Type) -> ColumnSchema { - ColumnSchema { name: name.to_string(), user_type: ty, unique: false } + ColumnSchema { + name: name.to_string(), + user_type: ty, + unique: false, + not_null: false, + default: None, + } } #[test] diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 8119e85..f4b54ce 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -145,6 +145,13 @@ pub struct ColumnSchema { /// save/load cycle. Defaults to `false` when missing in /// older project files. pub unique: bool, + /// `NOT NULL` constraint (ADR-0029). Defaults to `false` + /// when missing in older project files. + pub not_null: bool, + /// `DEFAULT` expression as a SQL literal (ADR-0029) — the + /// form SQLite reports and `schema_to_ddl` echoes verbatim. + /// `None` when the column has no default. + pub default: Option, } /// One index as recorded in `project.yaml` (ADR-0025). @@ -374,6 +381,8 @@ mod tests { name: "Name".to_string(), user_type: Type::Text, unique: false, + not_null: false, + default: None, }], rows: vec![vec![CellValue::Text("Alice".to_string())]], }; diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index 55db703..f321b76 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -92,22 +92,45 @@ fn write_table(out: &mut String, table: &TableSchema) { } } -fn write_column(out: &mut String, col: &ColumnSchema) { - if col.unique { - let _ = writeln!( - out, - " - {{ name: {}, type: {}, unique: true }}", - quote_if_needed(&col.name), - col.user_type.keyword(), - ); - } else { - let _ = writeln!( - out, - " - {{ name: {}, type: {} }}", - quote_if_needed(&col.name), - col.user_type.keyword(), - ); +/// Always render `s` as a double-quoted YAML string — used +/// for a column's `default` SQL literal, which must round-trip +/// as a string even when it looks numeric (ADR-0029). +fn yaml_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } } + out.push('"'); + out +} + +fn write_column(out: &mut String, col: &ColumnSchema) { + let mut line = format!( + " - {{ name: {}, type: {}", + quote_if_needed(&col.name), + col.user_type.keyword(), + ); + // ADR-0018 / ADR-0029 constraint flags — emitted only when + // set, so an unconstrained column stays a compact two-field + // entry and older readers stay forward-compatible. + if col.unique { + line.push_str(", unique: true"); + } + if col.not_null { + line.push_str(", not_null: true"); + } + if let Some(default) = &col.default { + line.push_str(", default: "); + line.push_str(&yaml_string(default)); + } + line.push_str(" }"); + let _ = writeln!(out, "{line}"); } fn write_relationship(out: &mut String, rel: &RelationshipSchema) { @@ -213,6 +236,8 @@ pub(crate) fn parse_schema(body: &str) -> Result { name: c.name, user_type, unique: c.unique, + not_null: c.not_null, + default: c.default, }); } tables.push(TableSchema { @@ -339,6 +364,12 @@ struct RawColumn { /// field default to `false`. #[serde(default)] unique: bool, + /// `NOT NULL` flag (ADR-0029); absent in older files. + #[serde(default)] + not_null: bool, + /// `DEFAULT` SQL literal (ADR-0029); absent in older files. + #[serde(default)] + default: Option, } #[derive(Deserialize)] @@ -376,16 +407,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 }, - ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false }, + 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 }, ], }, TableSchema { name: "Orders".to_string(), primary_key: vec!["id".to_string()], columns: vec![ - ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false }, - ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false }, + 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 }, ], }, ], @@ -451,6 +482,8 @@ mod tests { name: "yes".to_string(), user_type: Type::Bool, unique: false, + not_null: false, + default: None, }], }], relationships: vec![], @@ -474,6 +507,47 @@ mod tests { assert_eq!(parsed, original); } + #[test] + fn column_constraints_round_trip_through_yaml() { + // NOT NULL / UNIQUE / DEFAULT survive a serialize → + // parse cycle (ADR-0029 §7). + let snap = SchemaSnapshot { + created_at: "2026-05-19T00:00:00Z".to_string(), + tables: vec![TableSchema { + name: "Books".to_string(), + primary_key: vec!["isbn".to_string()], + columns: vec![ + ColumnSchema { + name: "isbn".to_string(), + user_type: Type::Text, + unique: false, + not_null: false, + default: None, + }, + ColumnSchema { + name: "title".to_string(), + user_type: Type::Text, + unique: true, + not_null: true, + default: Some("'untitled'".to_string()), + }, + ColumnSchema { + name: "stock".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: Some("0".to_string()), + }, + ], + }], + relationships: vec![], + indexes: vec![], + }; + let body = serialize_schema(&snap); + let parsed = parse_schema(&body).expect("parse schema"); + assert_eq!(parsed, snap, "constraints survive the yaml round-trip"); + } + #[test] fn parses_minimal_yaml_with_no_tables() { let body = "\ @@ -548,8 +622,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 }, - ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false }, + 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 }, ], }], relationships: vec![],