Indexes: add index / drop index, persistence, display (ADR-0025)
Implement ADR-0025 — indexes as a DSL DDL feature. - Grammar: `add index [as <name>] on <T> (<cols>)`, `drop index <name>` / `drop index on <T> (<cols>)`, plus a `--cascade` flag on `drop column`. - db.rs: index operations over the engine's native index catalog (no metadata table). The rebuild-table primitive now captures and recreates indexes, so `change column` and the relationship operations no longer silently drop them. - `drop column` refuses an indexed column unless `--cascade`, which drops the covering indexes and reports each. - Persistence: additive `indexes:` list in `project.yaml` (version unchanged); round-trips through rebuild/export/import. - Display: an `Indexes:` section in the structure view and a nested tables/indexes items panel (S2). Reconciles requirements.md (C3 index portion, S2 satisfied) and CLAUDE.md. 1038 tests passing (+31), clippy clean.
This commit is contained in:
+140
-6
@@ -12,7 +12,9 @@
|
||||
//! `parent_table` vs `child_table` for the endpoints clause).
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{ChangeColumnMode, ColumnSpec, Command, RelationshipSelector};
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector,
|
||||
};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
|
||||
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
||||
@@ -109,6 +111,40 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
||||
inner: &RELATIONSHIP_NAME_NEW_IDENT,
|
||||
};
|
||||
|
||||
const INDEX_NAME_EXISTING: Node = Node::Ident {
|
||||
source: IdentSource::Indexes,
|
||||
role: "index_name",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
};
|
||||
|
||||
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "index_name",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
};
|
||||
const INDEX_NAME_NEW: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
inner: &INDEX_NAME_NEW_IDENT,
|
||||
};
|
||||
|
||||
// The column list shared by `add index` / `drop index`: one or
|
||||
// more existing column names, comma-separated, inside parens.
|
||||
// `COLUMN_NAME` narrows to the `on <Table>` table's columns
|
||||
// because that ident carries `writes_table: true`.
|
||||
const INDEX_COLUMN_LIST: Node = Node::Repeated {
|
||||
inner: &COLUMN_NAME,
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
|
||||
// `[to]` and `[table]` connectives.
|
||||
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
|
||||
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
|
||||
@@ -129,6 +165,11 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||||
// drop_column — `drop column [from] [table] <T> : <col>`
|
||||
// =================================================================
|
||||
|
||||
// `--cascade` (ADR-0025): opt-in to dropping any index that
|
||||
// covers the column alongside the column itself. Without it, a
|
||||
// covered column is refused with a friendly error.
|
||||
const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade"));
|
||||
|
||||
const DROP_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("column")),
|
||||
FROM_OPT,
|
||||
@@ -136,6 +177,7 @@ const DROP_COLUMN_NODES: &[Node] = &[
|
||||
TABLE_NAME_EXISTING,
|
||||
Node::Punct(':'),
|
||||
COLUMN_NAME,
|
||||
DROP_COLUMN_CASCADE_OPT,
|
||||
];
|
||||
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
|
||||
|
||||
@@ -213,10 +255,34 @@ const DROP_RELATIONSHIP_NODES: &[Node] = &[
|
||||
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
|
||||
|
||||
// =================================================================
|
||||
// drop entry — `drop (table|column|relationship) ...`
|
||||
// drop_index — `drop index (<name> | on <T> (<col>, …))`
|
||||
// =================================================================
|
||||
|
||||
const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE];
|
||||
const DI_POSITIONAL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("on")),
|
||||
TABLE_NAME_EXISTING,
|
||||
Node::Punct('('),
|
||||
INDEX_COLUMN_LIST,
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
|
||||
|
||||
// Positional form first — it opens with the `on` keyword, so a
|
||||
// bare index name can't be mistaken for it (mirrors DR_SELECTOR).
|
||||
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
|
||||
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
|
||||
|
||||
const DROP_INDEX_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("index")),
|
||||
DI_SELECTOR,
|
||||
];
|
||||
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
|
||||
|
||||
// =================================================================
|
||||
// drop entry — `drop (table|column|relationship|index) ...`
|
||||
// =================================================================
|
||||
|
||||
const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX];
|
||||
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
@@ -316,10 +382,31 @@ const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
||||
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
|
||||
|
||||
// =================================================================
|
||||
// add entry — `add (column|1:n relationship) …`
|
||||
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
||||
// =================================================================
|
||||
|
||||
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP];
|
||||
const AI_AS_NAME_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("as")),
|
||||
INDEX_NAME_NEW,
|
||||
];
|
||||
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
|
||||
|
||||
const ADD_INDEX_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("index")),
|
||||
AI_AS_NAME_OPT,
|
||||
Node::Word(Word::keyword("on")),
|
||||
TABLE_NAME_EXISTING,
|
||||
Node::Punct('('),
|
||||
INDEX_COLUMN_LIST,
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);
|
||||
|
||||
// =================================================================
|
||||
// add entry — `add (column|1:n relationship|index) …`
|
||||
// =================================================================
|
||||
|
||||
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX];
|
||||
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
@@ -402,6 +489,18 @@ fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, Valid
|
||||
})
|
||||
}
|
||||
|
||||
/// Every ident whose role matches, in matched (left-to-right)
|
||||
/// order. Used by the column-list commands.
|
||||
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
|
||||
path.items
|
||||
.iter()
|
||||
.filter_map(|i| match &i.kind {
|
||||
MatchedKind::Ident { role: r } if *r == role => Some(i.text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_action(words: &[&'static str]) -> ReferentialAction {
|
||||
// `set null`, `no action`, `cascade`, `restrict`.
|
||||
if words.contains(&"set") && words.contains(&"null") {
|
||||
@@ -435,7 +534,32 @@ fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Some("column") => Ok(Command::DropColumn {
|
||||
table: require_ident(path, "table_name")?,
|
||||
column: require_ident(path, "column_name")?,
|
||||
cascade: path
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))),
|
||||
}),
|
||||
Some("index") => {
|
||||
// Positional form has `on` as the third Word.
|
||||
let has_on = path
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(&i.kind, MatchedKind::Word("on")));
|
||||
if has_on {
|
||||
Ok(Command::DropIndex {
|
||||
selector: IndexSelector::Columns {
|
||||
table: require_ident(path, "table_name")?,
|
||||
columns: collect_idents(path, "column_name"),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
Ok(Command::DropIndex {
|
||||
selector: IndexSelector::Named {
|
||||
name: require_ident(path, "index_name")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Some("relationship") => {
|
||||
// Endpoints form has `from` as the third Word.
|
||||
let has_from = path
|
||||
@@ -495,6 +619,11 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
})
|
||||
}
|
||||
Some("1") => build_add_relationship(path),
|
||||
Some("index") => Ok(Command::AddIndex {
|
||||
name: ident(path, "index_name").map(str::to_string),
|
||||
table: require_ident(path, "table_name")?,
|
||||
columns: collect_idents(path, "column_name"),
|
||||
}),
|
||||
_ => Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown add subcommand".to_string())],
|
||||
@@ -638,6 +767,7 @@ pub static DROP: CommandNode = CommandNode {
|
||||
"parse.usage.drop_table",
|
||||
"parse.usage.drop_column",
|
||||
"parse.usage.drop_relationship",
|
||||
"parse.usage.drop_index",
|
||||
],};
|
||||
|
||||
pub static ADD: CommandNode = CommandNode {
|
||||
@@ -645,7 +775,11 @@ pub static ADD: CommandNode = CommandNode {
|
||||
shape: ADD_SHAPE,
|
||||
ast_builder: build_add,
|
||||
help_id: Some("ddl.add"),
|
||||
usage_ids: &["parse.usage.add_column", "parse.usage.add_relationship"],};
|
||||
usage_ids: &[
|
||||
"parse.usage.add_column",
|
||||
"parse.usage.add_relationship",
|
||||
"parse.usage.add_index",
|
||||
],};
|
||||
|
||||
pub static RENAME: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rename"),
|
||||
|
||||
Reference in New Issue
Block a user