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:
@@ -80,14 +80,24 @@ A column spec gains an optional, repeatable constraint suffix
|
|||||||
**after** the `(type)` group:
|
**after** the `(type)` group:
|
||||||
|
|
||||||
```
|
```
|
||||||
create table Users with pk
|
create table Books with pk isbn(text) check (length(isbn) = 13)
|
||||||
id(serial),
|
|
||||||
email(text) not null unique,
|
|
||||||
age(int) default 18 check (age >= 0)
|
|
||||||
|
|
||||||
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
|
- Standard SQL writes constraints after the data type
|
||||||
(`email TEXT NOT NULL UNIQUE`). The playground brackets the
|
(`email TEXT NOT NULL UNIQUE`). The playground brackets the
|
||||||
type as `email(text)` — a pre-existing convention this ADR
|
type as `email(text)` — a pre-existing convention this ADR
|
||||||
|
|||||||
@@ -38,3 +38,13 @@ entry names the ADR that drew the boundary.
|
|||||||
yet available.
|
yet available.
|
||||||
- **No `LIMIT … OFFSET`** — `limit` takes a row count
|
- **No `LIMIT … OFFSET`** — `limit` takes a row count
|
||||||
only.
|
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.
|
||||||
|
|||||||
@@ -1610,6 +1610,8 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
|||||||
.map(|c| ColumnSchema {
|
.map(|c| ColumnSchema {
|
||||||
name: c.name.clone(),
|
name: c.name.clone(),
|
||||||
unique: c.unique,
|
unique: c.unique,
|
||||||
|
not_null: c.notnull,
|
||||||
|
default: c.default_sql.clone(),
|
||||||
// user_type is always populated for tables we
|
// user_type is always populated for tables we
|
||||||
// created; the fallback is defensive.
|
// created; the fallback is defensive.
|
||||||
user_type: c.user_type.unwrap_or(Type::Text),
|
user_type: c.user_type.unwrap_or(Type::Text),
|
||||||
@@ -1703,6 +1705,8 @@ fn read_table_snapshot(
|
|||||||
name: c.name.clone(),
|
name: c.name.clone(),
|
||||||
user_type: c.user_type.unwrap_or(Type::Text),
|
user_type: c.user_type.unwrap_or(Type::Text),
|
||||||
unique: c.unique,
|
unique: c.unique,
|
||||||
|
not_null: c.notnull,
|
||||||
|
default: c.default_sql.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let column_idents: Vec<String> = read
|
let column_idents: Vec<String> = read
|
||||||
@@ -5361,10 +5365,10 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
|
|||||||
.map(|c| ReadColumn {
|
.map(|c| ReadColumn {
|
||||||
name: c.name.clone(),
|
name: c.name.clone(),
|
||||||
sqlite_type: c.user_type.sqlite_strict_type().to_string(),
|
sqlite_type: c.user_type.sqlite_strict_type().to_string(),
|
||||||
notnull: false,
|
notnull: c.not_null,
|
||||||
primary_key: table.primary_key.contains(&c.name),
|
primary_key: table.primary_key.contains(&c.name),
|
||||||
unique: c.unique,
|
unique: c.unique,
|
||||||
default_sql: None,
|
default_sql: c.default.clone(),
|
||||||
user_type: Some(c.user_type),
|
user_type: Some(c.user_type),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -375,7 +375,10 @@ fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, Valid
|
|||||||
/// Convert a `MatchedItem` whose kind is one of the `value_literal`
|
/// Convert a `MatchedItem` whose kind is one of the `value_literal`
|
||||||
/// variants (Word("null"|"true"|"false"), NumberLit, StringLit) to
|
/// variants (Word("null"|"true"|"false"), NumberLit, StringLit) to
|
||||||
/// a `Value`. Returns None for non-value items.
|
/// a `Value`. Returns None for non-value items.
|
||||||
fn item_to_value(item: &MatchedItem) -> Option<Value> {
|
///
|
||||||
|
/// `pub(crate)` so `grammar::ddl` can reuse it when collecting a
|
||||||
|
/// `default <literal>` column constraint (ADR-0029).
|
||||||
|
pub(crate) fn item_to_value(item: &MatchedItem) -> Option<Value> {
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
MatchedKind::Word("null") => Some(Value::Null),
|
MatchedKind::Word("null") => Some(Value::Null),
|
||||||
MatchedKind::Word("true") => Some(Value::Bool(true)),
|
MatchedKind::Word("true") => Some(Value::Bool(true)),
|
||||||
|
|||||||
+193
-45
@@ -820,6 +820,37 @@ const COL_NAME: Node = Node::Hinted {
|
|||||||
inner: &COL_NAME_IDENT,
|
inner: &COL_NAME_IDENT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ADR-0029 column-constraint suffix — `not null`, `unique`,
|
||||||
|
// `default <literal>`. (`check (<expr>)` 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] = &[
|
const COL_SPEC_NODES: &[Node] = &[
|
||||||
COL_NAME,
|
COL_NAME,
|
||||||
Node::Punct('('),
|
Node::Punct('('),
|
||||||
@@ -833,6 +864,7 @@ const COL_SPEC_NODES: &[Node] = &[
|
|||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
|
COLUMN_CONSTRAINT_SUFFIX,
|
||||||
];
|
];
|
||||||
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
|
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);
|
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<Command, ValidationError> {
|
fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||||
let name = require_ident(path, "table_name")?;
|
let name = require_ident(path, "table_name")?;
|
||||||
|
|
||||||
// Collect column specs by pairing alternating col_name /
|
// Walk the matched items, segmenting per column: a
|
||||||
// col_type ident matches. They always appear in declaration
|
// `col_name` ident stashes the name, the following
|
||||||
// order so a simple zip is correct.
|
// `col_type` ident finalises the spec, and the constraint
|
||||||
let names: Vec<String> = path
|
// tokens after it (ADR-0029 §2.1) attach to that spec.
|
||||||
.items
|
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||||||
.iter()
|
let mut pending_name: Option<String> = None;
|
||||||
.filter_map(|i| match &i.kind {
|
let mut items = path.items.iter().peekable();
|
||||||
MatchedKind::Ident { role: "col_name", .. } => Some(i.text.clone()),
|
while let Some(item) = items.next() {
|
||||||
_ => None,
|
match &item.kind {
|
||||||
})
|
MatchedKind::Ident { role: "col_name", .. } => {
|
||||||
.collect();
|
pending_name = Some(item.text.clone());
|
||||||
let types_raw: Vec<&str> = path
|
}
|
||||||
.items
|
MatchedKind::Ident { role: "col_type", .. } => {
|
||||||
.iter()
|
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
|
||||||
.filter_map(|i| match &i.kind {
|
message_key: "parse.error_wrapper",
|
||||||
MatchedKind::Ident { role: "col_type", .. } => Some(i.text.as_str()),
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
_ => None,
|
})?;
|
||||||
})
|
let col_name = pending_name.take().ok_or_else(|| ValidationError {
|
||||||
.collect();
|
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 <literal>` — 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
|
// No PK clause OR `with pk` alone (no specs): if `with` was
|
||||||
// matched, default to id:serial; otherwise reject with the
|
// matched, default to id(serial); otherwise reject with the
|
||||||
// "tables need at least one column" friendly wording.
|
// "tables need a primary key" friendly wording.
|
||||||
let saw_with = path
|
if columns.is_empty() {
|
||||||
.items
|
let saw_with = path
|
||||||
.iter()
|
.items
|
||||||
.any(|i| matches!(i.kind, MatchedKind::Word("with")));
|
.iter()
|
||||||
|
.any(|i| matches!(i.kind, MatchedKind::Word("with")));
|
||||||
let pk_specs: Vec<(String, Type)> = if names.is_empty() {
|
|
||||||
if saw_with {
|
if saw_with {
|
||||||
// `with pk` alone — default to id(serial).
|
columns.push(ColumnSpec::new("id", Type::Serial));
|
||||||
vec![("id".to_string(), Type::Serial)]
|
|
||||||
} else {
|
} else {
|
||||||
return Err(ValidationError {
|
return Err(ValidationError {
|
||||||
message_key: "parse.custom.create_table_needs_pk",
|
message_key: "parse.custom.create_table_needs_pk",
|
||||||
args: vec![],
|
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::<Type>().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
|
// Every `with pk` column is part of the primary key
|
||||||
.iter()
|
// (ADR-0029 §2.1). A PK column is already NOT NULL, and a
|
||||||
.map(|(n, t)| ColumnSpec::new(n.clone(), *t))
|
// single-column PK is already UNIQUE — declaring those
|
||||||
.collect();
|
// explicitly is a friendly error, not a silent no-op
|
||||||
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
|
// (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 {
|
Ok(Command::CreateTable {
|
||||||
name,
|
name,
|
||||||
@@ -930,3 +1012,69 @@ pub static CREATE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_create_table,
|
ast_builder: build_create_table,
|
||||||
help_id: Some("ddl.create"),
|
help_id: Some("ddl.create"),
|
||||||
usage_ids: &["parse.usage.create_table"],};
|
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<ColumnSpec> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
/// surface the generic "Type a value: number, 'text', …" prose
|
||||||
/// here rather than the misleading `null`/`true`/`false`
|
/// here rather than the misleading `null`/`true`/`false`
|
||||||
/// candidate trio.
|
/// candidate trio.
|
||||||
const FALLBACK_VALUE_LITERAL: Node = Node::Hinted {
|
/// The schemaless value-literal slot. `pub(crate)` so the
|
||||||
|
/// `default <literal>` 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"),
|
mode: HintMode::ProseOnly("hint.value_literal_slot"),
|
||||||
inner: &FALLBACK_VALUE_LITERAL_INNER,
|
inner: &FALLBACK_VALUE_LITERAL_INNER,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// by the DSL parser. See `parse.custom.*` in the catalog.
|
// by the DSL parser. See `parse.custom.*` in the catalog.
|
||||||
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
||||||
("parse.custom.change_column_flags_exclusive", &[]),
|
("parse.custom.change_column_flags_exclusive", &[]),
|
||||||
|
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
|
||||||
("parse.custom.create_table_needs_pk", &[]),
|
("parse.custom.create_table_needs_pk", &[]),
|
||||||
("parse.custom.expression_too_deep", &[]),
|
("parse.custom.expression_too_deep", &[]),
|
||||||
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
||||||
|
|||||||
@@ -398,6 +398,10 @@ parse:
|
|||||||
# classifies the input as mid-typing rather than
|
# classifies the input as mid-typing rather than
|
||||||
# dispatching a logically-empty Form C insert.
|
# 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."
|
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
|
# Caret pointer showing where in the input the parser
|
||||||
# failed. `{padding}` is the leading whitespace; the
|
# failed. `{padding}` is the leading whitespace; the
|
||||||
# template appends `^` so the rendered line places the
|
# template appends `^` so the rendered line places the
|
||||||
|
|||||||
@@ -370,7 +370,13 @@ mod tests {
|
|||||||
use crate::persistence::ColumnSchema;
|
use crate::persistence::ColumnSchema;
|
||||||
|
|
||||||
fn col(name: &str, ty: Type) -> 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]
|
#[test]
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ pub struct ColumnSchema {
|
|||||||
/// save/load cycle. Defaults to `false` when missing in
|
/// save/load cycle. Defaults to `false` when missing in
|
||||||
/// older project files.
|
/// older project files.
|
||||||
pub unique: bool,
|
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).
|
/// One index as recorded in `project.yaml` (ADR-0025).
|
||||||
@@ -374,6 +381,8 @@ mod tests {
|
|||||||
name: "Name".to_string(),
|
name: "Name".to_string(),
|
||||||
user_type: Type::Text,
|
user_type: Type::Text,
|
||||||
unique: false,
|
unique: false,
|
||||||
|
not_null: false,
|
||||||
|
default: None,
|
||||||
}],
|
}],
|
||||||
rows: vec![vec![CellValue::Text("Alice".to_string())]],
|
rows: vec![vec![CellValue::Text("Alice".to_string())]],
|
||||||
};
|
};
|
||||||
|
|||||||
+95
-21
@@ -92,22 +92,45 @@ fn write_table(out: &mut String, table: &TableSchema) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_column(out: &mut String, col: &ColumnSchema) {
|
/// Always render `s` as a double-quoted YAML string — used
|
||||||
if col.unique {
|
/// for a column's `default` SQL literal, which must round-trip
|
||||||
let _ = writeln!(
|
/// as a string even when it looks numeric (ADR-0029).
|
||||||
out,
|
fn yaml_string(s: &str) -> String {
|
||||||
" - {{ name: {}, type: {}, unique: true }}",
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
quote_if_needed(&col.name),
|
out.push('"');
|
||||||
col.user_type.keyword(),
|
for c in s.chars() {
|
||||||
);
|
match c {
|
||||||
} else {
|
'"' => out.push_str("\\\""),
|
||||||
let _ = writeln!(
|
'\\' => out.push_str("\\\\"),
|
||||||
out,
|
'\n' => out.push_str("\\n"),
|
||||||
" - {{ name: {}, type: {} }}",
|
_ => out.push(c),
|
||||||
quote_if_needed(&col.name),
|
}
|
||||||
col.user_type.keyword(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
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,
|
name: c.name,
|
||||||
user_type,
|
user_type,
|
||||||
unique: c.unique,
|
unique: c.unique,
|
||||||
|
not_null: c.not_null,
|
||||||
|
default: c.default,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tables.push(TableSchema {
|
tables.push(TableSchema {
|
||||||
@@ -339,6 +364,12 @@ struct RawColumn {
|
|||||||
/// field default to `false`.
|
/// field default to `false`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
unique: bool,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -376,16 +407,16 @@ mod tests {
|
|||||||
name: "Customers".to_string(),
|
name: "Customers".to_string(),
|
||||||
primary_key: vec!["id".to_string()],
|
primary_key: vec!["id".to_string()],
|
||||||
columns: vec![
|
columns: vec![
|
||||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, 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 },
|
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
TableSchema {
|
TableSchema {
|
||||||
name: "Orders".to_string(),
|
name: "Orders".to_string(),
|
||||||
primary_key: vec!["id".to_string()],
|
primary_key: vec!["id".to_string()],
|
||||||
columns: vec![
|
columns: vec![
|
||||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, 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 },
|
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(),
|
name: "yes".to_string(),
|
||||||
user_type: Type::Bool,
|
user_type: Type::Bool,
|
||||||
unique: false,
|
unique: false,
|
||||||
|
not_null: false,
|
||||||
|
default: None,
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
@@ -474,6 +507,47 @@ mod tests {
|
|||||||
assert_eq!(parsed, original);
|
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]
|
#[test]
|
||||||
fn parses_minimal_yaml_with_no_tables() {
|
fn parses_minimal_yaml_with_no_tables() {
|
||||||
let body = "\
|
let body = "\
|
||||||
@@ -548,8 +622,8 @@ relationships:
|
|||||||
name: "Items".to_string(),
|
name: "Items".to_string(),
|
||||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||||
columns: vec![
|
columns: vec![
|
||||||
ColumnSchema { name: "a".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 },
|
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
|
|||||||
Reference in New Issue
Block a user