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:
+193
-45
@@ -820,6 +820,37 @@ const COL_NAME: Node = Node::Hinted {
|
||||
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] = &[
|
||||
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<Command, ValidationError> {
|
||||
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<String> = 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<ColumnSpec> = Vec::new();
|
||||
let mut pending_name: Option<String> = 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::<Type>().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 <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
|
||||
// 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::<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
|
||||
.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<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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user