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:
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user