Grammar: with-pk column specs use name(type), matching add column

`create table … with pk` parsed column types as `name:type`,
while `add column` uses `name(type)`. Unify on the parens
form so column-type syntax is consistent across the DSL:

    create table T with pk id(serial), name(text)

Only `COL_SPEC` changes (`:` → `( … )`); `build_create_table`
reads columns by role, so it is unaffected. The `:` that
separates table from column in `add column` / `drop column`
is unchanged. Sweeps the test suite, the typing-surface
matrix (two `after_colon` cells renamed to `after_paren`,
4 snapshots regenerated), the friendly catalog's usage
templates, ADR-0009's example, and requirements.md.

1039 passing / 0 failing / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-18 21:51:52 +00:00
parent 9aa7e2ede0
commit d9a98bbd49
20 changed files with 68 additions and 67 deletions
+1 -1
View File
@@ -519,7 +519,7 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id:serial\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
+1 -1
View File
@@ -6,7 +6,7 @@
//! (e.g. "table does not exist").
//!
//! The shape supports compound primary keys natively even though
//! only the dedicated `with pk a:int,b:int` grammar exposes them
//! only the dedicated `with pk a(int),b(int)` grammar exposes them
//! today. Future grammar extensions (inline column specs, `set
//! primary key`, junction-table convenience commands) emit into
//! the same shape.
+4 -3
View File
@@ -796,7 +796,7 @@ pub static CHANGE: CommandNode = CommandNode {
usage_ids: &["parse.usage.change_column"],};
// =================================================================
// create_table — `create table <Name> [with pk [<col>:<type>[, ...]]]`
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
// (Phase C)
// =================================================================
@@ -816,7 +816,7 @@ const COL_NAME: Node = Node::Hinted {
const COL_SPEC_NODES: &[Node] = &[
COL_NAME,
Node::Punct(':'),
Node::Punct('('),
Node::Ident {
source: IdentSource::Types,
role: "col_type",
@@ -826,6 +826,7 @@ const COL_SPEC_NODES: &[Node] = &[
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct(')'),
];
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
@@ -884,7 +885,7 @@ fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
let pk_specs: Vec<(String, Type)> = if names.is_empty() {
if saw_with {
// `with pk` alone — default to id:serial.
// `with pk` alone — default to id(serial).
vec![("id".to_string(), Type::Serial)]
} else {
return Err(ValidationError {
+7 -7
View File
@@ -393,7 +393,7 @@ mod tests {
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email:text"),
ok("create table Customers with pk email(text)"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
@@ -405,7 +405,7 @@ mod tests {
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id:int,product_id:int"),
ok("create table OrderLines with pk order_id(int),product_id(int)"),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![col("order_id", Type::Int), col("product_id", Type::Int),],
@@ -417,7 +417,7 @@ mod tests {
#[test]
fn create_table_pk_accepts_any_user_type() {
for ty in Type::all() {
let input = format!("create table T with pk col:{}", ty.keyword());
let input = format!("create table T with pk col({})", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
@@ -436,7 +436,7 @@ mod tests {
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id : serial"),
ok("create table T with pk id ( serial )"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
@@ -444,7 +444,7 @@ mod tests {
}
);
assert_eq!(
ok("create table T with pk a : int , b : int"),
ok("create table T with pk a ( int ) , b ( int )"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
@@ -456,7 +456,7 @@ mod tests {
#[test]
fn create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email:TEXT"),
ok("CREATE TABLE Customers WITH PK email(TEXT)"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
@@ -760,7 +760,7 @@ mod tests {
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id:varchar");
let e = err("create table T with pk id(varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
+5 -5
View File
@@ -1076,7 +1076,7 @@ mod tests {
#[test]
fn walker_parses_create_table_named_typed_pk() {
assert_eq!(
parse("create table Customers with pk email:text").unwrap(),
parse("create table Customers with pk email(text)").unwrap(),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
@@ -1088,7 +1088,7 @@ mod tests {
#[test]
fn walker_parses_create_table_compound_pk() {
assert_eq!(
parse("create table OrderLines with pk order_id:int,product_id:int").unwrap(),
parse("create table OrderLines with pk order_id(int),product_id(int)").unwrap(),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![col("order_id", Type::Int), col("product_id", Type::Int)],
@@ -1100,7 +1100,7 @@ mod tests {
#[test]
fn walker_create_table_pk_tolerates_whitespace_around_punct() {
assert_eq!(
parse("create table T with pk id : serial").unwrap(),
parse("create table T with pk id ( serial )").unwrap(),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
@@ -1108,7 +1108,7 @@ mod tests {
}
);
assert_eq!(
parse("create table T with pk a : int , b : int").unwrap(),
parse("create table T with pk a ( int ) , b ( int )").unwrap(),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
@@ -1134,7 +1134,7 @@ mod tests {
#[test]
fn walker_create_table_keywords_are_case_insensitive() {
assert_eq!(
parse("CREATE TABLE Customers WITH PK email:TEXT").unwrap(),
parse("CREATE TABLE Customers WITH PK email(TEXT)").unwrap(),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
+3 -3
View File
@@ -248,7 +248,7 @@ help:
messages [short|verbose] — show or switch error-message verbosity (verbose is the default)
ddl:
create: |-
create table <T> with pk [<col>:<type>, ...] — create a table
create table <T> with pk [<col>(<type>), ...] — create a table
drop: |-
drop table <T> — remove a table
drop column [from] [table] <T>: <col> [--cascade] — remove a column
@@ -371,7 +371,7 @@ parse:
custom:
replay_path_expected: "expected a path after `replay`"
create_table_needs_pk: |-
tables need at least one column. Add `with pk` for a default `id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. Use a comma-separated list for compound primary keys.
tables need at least one column. Add `with pk` for a default `id INTEGER PRIMARY KEY`, or `with pk <name>(<type>)` to choose. Use a comma-separated list for compound primary keys.
on_action_specified_twice: "`on {target}` specified twice"
change_column_flags_exclusive: "`--force-conversion` and `--dont-convert` are mutually exclusive — pick one."
unknown_type: "unknown type '{found}' (expected one of: {expected})"
@@ -410,7 +410,7 @@ parse:
# marks optional parts; angle-bracket `<...>` marks
# placeholders. ADR-0009's surface conventions apply.
usage:
create_table: "create table <Name> with pk [<col>:<type>[, ...]]"
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
drop_table: "drop table <Name>"
drop_column: "drop column [from] [table] <Table>: <Name>"
drop_relationship: |-
+3 -3
View File
@@ -3,7 +3,7 @@
//! Format: one record per line, three pipe-separated fields:
//!
//! ```text
//! 2026-05-07T14:30:12Z|ok|create table Customers with pk id:serial
//! 2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial)
//! ```
//!
//! Status is always `ok` in v1; failed commands are not
@@ -196,12 +196,12 @@ mod tests {
#[test]
fn record_format() {
let line = format_record(
"create table Customers with pk id:serial",
"create table Customers with pk id(serial)",
"2026-05-07T14:30:12Z".to_string(),
);
assert_eq!(
line,
"2026-05-07T14:30:12Z|ok|create table Customers with pk id:serial\n",
"2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial)\n",
);
}
+2 -2
View File
@@ -395,12 +395,12 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id:serial").unwrap();
p.append_history("create table Foo with pk id(serial)").unwrap();
p.append_history("insert into Foo (1)").unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].ends_with("|ok|create table Foo with pk id:serial"));
assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)"));
assert!(lines[1].ends_with("|ok|insert into Foo (1)"));
}
}