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:
+95
-21
@@ -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![],
|
||||
|
||||
Reference in New Issue
Block a user