feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX

Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON
<T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> ->
SqlDropIndex, both reusing the ADR-0025 executors (do_add_index /
do_drop_index), like 4c reused do_drop_table.

- CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1):
  ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced
  mode trusts the user like SQL does. Adds an additive IndexSchema.unique
  flag (project.yaml, serde-default, version stays 1); rebuild re-emits
  CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique).
  Simple-mode `add unique index` stays deferred.
- IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip
  (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome.
- Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE]
  prefix is a concrete-keyword Choice and the optional name an on-led-first
  selector (the drop-index selector precedent) — trap-safe.
- create/drop each gain a second advanced node; the existing all-candidates
  dispatch handles it (locked by parse tests).
- Unique indexes marked [unique] in the structure view and items panel.
- do_add_index refuses internal __rdbms_* tables as "no such table",
  closing a latent exposure on both the simple `add index` and the new
  SQL CREATE INDEX surfaces (ADR-0025 Amendment 1).

Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README;
requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md.

Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-25 18:41:02 +00:00
parent 44248fb8bb
commit 701217d29f
22 changed files with 1865 additions and 48 deletions
+299
View File
@@ -203,6 +203,21 @@ static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
];
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
// form (that stays the simple `drop index on …`, which falls back to
// the simple `drop` node). Leads on the concrete `index` keyword; the
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
// still parses and reaches the skip path.
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
SQL_DROP_IF_EXISTS_OPT,
INDEX_NAME_EXISTING,
Node::Optional(&Node::Punct(';')),
];
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);
// =================================================================
// drop_column — `drop column [from] [table] <T> : <col>`
// =================================================================
@@ -1726,6 +1741,106 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
usage_ids: &["parse.usage.sql_drop_table"],
};
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
/// flag (mirroring `build_sql_drop_table`).
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::SqlDropIndex {
name: require_ident(path, "index_name")?,
if_exists: path.contains_word("if"),
})
}
pub static SQL_DROP_INDEX: CommandNode = CommandNode {
entry: Word::keyword("drop"),
shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"),
usage_ids: &["parse.usage.sql_drop_index"],
};
// =================================================================
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
// node (alongside SQL_CREATE_TABLE).
// =================================================================
// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("exists")),
];
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));
// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
// instead greedily consume the `on` keyword (`consume_ident` does not
// reject keywords), breaking the unnamed form.
static SQL_CI_UNNAMED_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
static SQL_CI_NAMED_NODES: &[Node] = &[
INDEX_NAME_NEW,
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);
static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
SQL_CI_LEAD,
SQL_CI_IF_NOT_EXISTS_OPT,
SQL_CI_SELECTOR,
Node::Optional(&Node::Punct(';')),
];
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);
/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
/// extraction mirrors the simple `add index` builder.
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::SqlCreateIndex {
name: ident(path, "index_name").map(str::to_string),
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
unique: path.contains_word("unique"),
if_not_exists: path.contains_word("if"),
})
}
pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"),
usage_ids: &["parse.usage.sql_create_index"],
};
// =================================================================
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
// =================================================================
@@ -1994,3 +2109,187 @@ mod sql_drop_table_tests {
));
}
}
#[cfg(test)]
mod sql_drop_index_tests {
use crate::dsl::command::{Command, IndexSelector};
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
fn drop_index_fields(input: &str) -> (String, bool) {
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
Command::SqlDropIndex { name, if_exists } => (name, if_exists),
other => panic!("expected SqlDropIndex, got {other:?}"),
}
}
#[test]
fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
assert_eq!(name, "Orders_CustId_idx");
assert!(!if_exists);
}
#[test]
fn if_exists_sets_the_flag() {
let (name, if_exists) = drop_index_fields("drop index if exists ix");
assert_eq!(name, "ix");
assert!(if_exists);
// trailing semicolon tolerated
assert!(drop_index_fields("drop index if exists ix;").1);
}
#[test]
fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
// `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
// SQL_DROP_INDEX); the dispatcher must try both and pick the
// shape that matches (ADR-0035 §4d — the second-advanced-node
// case).
assert!(matches!(
parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
Command::SqlDropTable { .. }
));
assert!(matches!(
parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
Command::SqlDropIndex { .. }
));
}
#[test]
fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
// The SQL form is name-only; `drop index on T (cols)` is the
// simple positional form. The name-only SQL shape can't fully
// match it (trailing `(cols)`), so it falls back to the simple
// `drop` node's `DropIndex { Columns }` even in advanced mode.
match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
.expect("parses")
{
Command::DropIndex {
selector: IndexSelector::Columns { table, columns },
} => {
assert_eq!(table, "Orders");
assert_eq!(columns, vec!["CustId".to_string()]);
}
other => panic!("expected positional DropIndex, got {other:?}"),
}
}
#[test]
fn named_drop_index_in_simple_mode_is_the_dsl_command() {
// In simple mode the SQL node is gated; `drop index ix` is the
// simple `DropIndex { Named }`.
match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
Command::DropIndex {
selector: IndexSelector::Named { name },
} => assert_eq!(name, "ix"),
other => panic!("expected named DropIndex, got {other:?}"),
}
}
}
#[cfg(test)]
mod sql_create_index_tests {
use crate::dsl::command::Command;
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
struct Ci {
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
}
fn ci(input: &str) -> Ci {
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
Command::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
#[test]
fn named_create_index_parses() {
let c = ci("create index ix on Customers (email)");
assert_eq!(c.name.as_deref(), Some("ix"));
assert_eq!(c.table, "Customers");
assert_eq!(c.columns, vec!["email".to_string()]);
assert!(!c.unique);
assert!(!c.if_not_exists);
}
#[test]
fn unnamed_create_index_leaves_name_none() {
// The unnamed form: the optional name must NOT swallow `on`
// (the `DI_SELECTOR`-style on-led-first selector handles it).
let c = ci("create index on Customers (email)");
assert_eq!(c.name, None);
assert_eq!(c.table, "Customers");
assert_eq!(c.columns, vec!["email".to_string()]);
}
#[test]
fn unique_sets_the_flag() {
let c = ci("create unique index ux on Customers (email)");
assert!(c.unique);
assert_eq!(c.name.as_deref(), Some("ux"));
// unnamed unique form too
let c2 = ci("create unique index on Customers (email)");
assert!(c2.unique);
assert_eq!(c2.name, None);
}
#[test]
fn if_not_exists_sets_the_flag() {
let c = ci("create index if not exists ix on Customers (email)");
assert!(c.if_not_exists);
assert_eq!(c.name.as_deref(), Some("ix"));
// combined with unique + unnamed + trailing semicolon
let c2 = ci("create unique index if not exists on Customers (email);");
assert!(c2.unique && c2.if_not_exists);
assert_eq!(c2.name, None);
}
#[test]
fn multi_column_index_parses() {
let c = ci("create index on Orders (CustId, Date)");
assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
}
#[test]
fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
// `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
// SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
assert!(matches!(
parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
.expect("parses"),
Command::SqlCreateTable { .. }
));
assert!(matches!(
parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
Command::SqlCreateIndex { .. }
));
assert!(matches!(
parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
.expect("parses"),
Command::SqlCreateIndex { unique: true, .. }
));
}
#[test]
fn simple_create_table_dsl_still_parses_in_advanced_mode() {
// The `create table … with pk …` DSL form falls back to the
// simple node even with two advanced `create` nodes present.
assert!(matches!(
parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
.expect("parses"),
Command::CreateTable { .. }
));
}
}