create table: column constraints — NOT NULL / UNIQUE / DEFAULT grammar (ADR-0029)

`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 <literal>` — 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.
This commit is contained in:
claude@clouddev1
2026-05-19 14:41:29 +00:00
parent a60e879f20
commit 12395a9a6c
11 changed files with 348 additions and 76 deletions
+7 -1
View File
@@ -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]
+9
View File
@@ -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<String>,
}
/// 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())]],
};
+95 -21
View File
@@ -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<SchemaSnapshot, YamlError> {
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<String>,
}
#[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![],