ADR-0024 Phase C: create table with column-list value literals

Migrate `create table <Name> [with pk [<col>:<type>[, ...]]]`
to the walker. Exercises Repeated{separator: Some(Punct(','))}
for the first time — the with-pk column-spec list.

Walker behaviour changes:
- Optional now backtracks on partial-match failure (Incomplete
  or Failed-Mismatch from a Seq mid-shape). Path / per-byte
  state rolls back to before the partial attempt; the inner's
  expected-set propagates as `skipped` so callers see "what
  would have completed it". Matches chumsky's `or_not`
  semantics. ValidationFailed (content errors) does NOT
  backtrack — the user means to fix those.
- Bridge: ValidationFailed errors now classify as
  `at_eof = true`, mirroring the chumsky-side custom-error
  convention. This is what lets `create table Customers`
  classify as IncompleteAtEof rather than DefiniteErrorAt
  (the user can still continue typing `with pk …`).

Grammar:
- src/dsl/grammar/ddl.rs gains CREATE: shape is
  Seq(Word("table"), Ident{NewName,table_name}, Optional(WITH_PK))
  where WITH_PK = Seq(Word("with"), Word("pk"),
  Optional(Repeated{COL_SPEC, separator: Punct(','), min:1})).
  AST builder enforces `with pk needs at least one column`
  with the existing parse.custom.create_table_needs_pk catalog
  wording; `with pk` alone defaults to id:serial.

Tests:
- 6 new walker-specific tests for create_table: with-pk
  default, named typed PK, compound PK, whitespace tolerance
  around `:` and `,`, bare-create-table-errors-with-with-pk-
  hint, case-insensitive keywords.
- Total: 825 passed, 0 failed, 1 ignored (was 819 / 1).
- cargo clippy --all-targets -- -D warnings clean.
This commit is contained in:
claude@clouddev1
2026-05-15 07:12:22 +00:00
parent 7e79ca865a
commit 6bb688251b
5 changed files with 277 additions and 29 deletions
+124 -8
View File
@@ -12,11 +12,12 @@
//! `parent_table` vs `child_table` for the endpoints clause).
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ChangeColumnMode, Command, RelationshipSelector};
use crate::dsl::command::{ChangeColumnMode, ColumnSpec, Command, RelationshipSelector};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT},
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
// =================================================================
@@ -579,9 +580,124 @@ pub static CHANGE: CommandNode = CommandNode {
hint_mode: None,
};
// `TABLE_NAME_NEW` is currently unused (Phase C will bring
// it back when `create table` migrates). Keeping the
// declaration here keeps the per-source-of-truth convention
// consistent.
#[allow(dead_code)]
const _UNUSED: Node = TABLE_NAME_NEW;
// =================================================================
// create_table — `create table <Name> [with pk [<col>:<type>[, ...]]]`
// (Phase C)
// =================================================================
const COL_SPEC_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::NewName,
role: "col_name",
validator: None,
highlight_override: None,
},
Node::Punct(':'),
Node::Ident {
source: IdentSource::Types,
role: "col_type",
validator: Some(TYPE_VALIDATOR),
highlight_override: None,
},
];
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
const SPEC_LIST: Node = Node::Repeated {
inner: &COL_SPEC,
separator: Some(&Node::Punct(',')),
min: 1,
};
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
const WITH_PK_NODES: &[Node] = &[
Node::Word(Word::keyword("with")),
Node::Word(Word::keyword("pk")),
SPEC_LIST_OPT,
];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
const CREATE_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_NEW,
WITH_PK_OPT,
];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
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();
// 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() {
if saw_with {
// `with pk` alone — default to id:serial.
vec![("id".to_string(), 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 {
name: n.clone(),
ty: *t,
})
.collect();
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
}
pub static CREATE: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: CREATE_TABLE,
ast_builder: build_create_table,
help_id: Some("ddl.create"),
usage_id: Some("parse.usage.create_table"),
hint_mode: None,
};