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:
+48
-1
@@ -46,10 +46,14 @@ pub enum Command {
|
||||
},
|
||||
/// Remove a column from a table. Refused if the column is
|
||||
/// part of the primary key or is involved in a declared
|
||||
/// relationship — drop the relationship first.
|
||||
/// relationship — drop the relationship first. Refused, too,
|
||||
/// when an index covers the column, unless `cascade` is set
|
||||
/// (the `--cascade` flag), in which case the covering
|
||||
/// indexes are dropped alongside the column (ADR-0025).
|
||||
DropColumn {
|
||||
table: String,
|
||||
column: String,
|
||||
cascade: bool,
|
||||
},
|
||||
/// Rename a column. SQLite handles cascading renames in
|
||||
/// FK references on other tables; the executor mirrors
|
||||
@@ -96,6 +100,19 @@ pub enum Command {
|
||||
DropRelationship {
|
||||
selector: RelationshipSelector,
|
||||
},
|
||||
/// Create an index on one or more columns of a table
|
||||
/// (ADR-0025). `name` is optional — when `None`, the
|
||||
/// executor auto-generates `<Table>_<col…>_idx`.
|
||||
AddIndex {
|
||||
name: Option<String>,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
},
|
||||
/// Drop an index by name, or by positional reference to its
|
||||
/// table and exact column set (ADR-0025).
|
||||
DropIndex {
|
||||
selector: IndexSelector,
|
||||
},
|
||||
/// Re-display a table's structure in the output. Doesn't
|
||||
/// change schema; useful when the user wants to look at a
|
||||
/// table they aren't currently DDL'ing on.
|
||||
@@ -253,6 +270,26 @@ impl std::fmt::Display for RelationshipSelector {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a `drop index` command identifies the index to remove
|
||||
/// (ADR-0025). Both forms are accepted; the executor resolves to
|
||||
/// a single index.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IndexSelector {
|
||||
Named { name: String },
|
||||
Columns { table: String, columns: Vec<String> },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IndexSelector {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Named { name } => write!(f, "{name}"),
|
||||
Self::Columns { table, columns } => {
|
||||
write!(f, "on {table} ({})", columns.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Short label for log output and result rendering.
|
||||
#[must_use]
|
||||
@@ -266,6 +303,8 @@ impl Command {
|
||||
Self::ChangeColumnType { .. } => "change column",
|
||||
Self::AddRelationship { .. } => "add relationship",
|
||||
Self::DropRelationship { .. } => "drop relationship",
|
||||
Self::AddIndex { .. } => "add index",
|
||||
Self::DropIndex { .. } => "drop index",
|
||||
Self::ShowTable { .. } => "show table",
|
||||
Self::Insert { .. } => "insert into",
|
||||
Self::Update { .. } => "update",
|
||||
@@ -318,6 +357,14 @@ impl Command {
|
||||
// is a sensible fallback for logging.
|
||||
RelationshipSelector::Named { name } => name,
|
||||
},
|
||||
Self::AddIndex { table, .. } => table,
|
||||
Self::DropIndex { selector } => match selector {
|
||||
IndexSelector::Columns { table, .. } => table,
|
||||
// A named drop doesn't name the table until the
|
||||
// executor resolves it; the index name is a
|
||||
// sensible fallback for logging.
|
||||
IndexSelector::Named { name } => name,
|
||||
},
|
||||
// Replay isn't tied to a single table; the path is
|
||||
// the most identifying thing for log output.
|
||||
Self::Replay { path } => path,
|
||||
|
||||
+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"),
|
||||
|
||||
@@ -67,6 +67,8 @@ pub enum IdentSource {
|
||||
Columns,
|
||||
/// Existing relationship name.
|
||||
Relationships,
|
||||
/// Existing index name.
|
||||
Indexes,
|
||||
/// Closed set from `Type::all()` — surfaced by the walker's
|
||||
/// content validator on column-type slots; not user-listable
|
||||
/// from the schema.
|
||||
@@ -82,7 +84,10 @@ impl IdentSource {
|
||||
/// entities rather than user invention or a closed set).
|
||||
#[must_use]
|
||||
pub const fn completes_from_schema(self) -> bool {
|
||||
matches!(self, Self::Tables | Self::Columns | Self::Relationships)
|
||||
matches!(
|
||||
self,
|
||||
Self::Tables | Self::Columns | Self::Relationships | Self::Indexes
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-facing label used in parse-error wording
|
||||
@@ -97,6 +102,7 @@ impl IdentSource {
|
||||
Self::Tables => "table name",
|
||||
Self::Columns => "column name",
|
||||
Self::Relationships => "relationship name",
|
||||
Self::Indexes => "index name",
|
||||
Self::Types => "type",
|
||||
}
|
||||
}
|
||||
@@ -113,6 +119,7 @@ impl IdentSource {
|
||||
"table name" => Some(Self::Tables),
|
||||
"column name" => Some(Self::Columns),
|
||||
"relationship name" => Some(Self::Relationships),
|
||||
"index name" => Some(Self::Indexes),
|
||||
"type" => Some(Self::Types),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ pub mod walker;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{
|
||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
|
||||
RelationshipSelector, RowFilter,
|
||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, IndexSelector, MessagesValue,
|
||||
ModeValue, RelationshipSelector, RowFilter,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
|
||||
+81
-1
@@ -235,6 +235,7 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
|
||||
IdentSource::Tables => "table name".to_string(),
|
||||
IdentSource::Columns => "column name".to_string(),
|
||||
IdentSource::Relationships => "relationship name".to_string(),
|
||||
IdentSource::Indexes => "index name".to_string(),
|
||||
IdentSource::Types => "type".to_string(),
|
||||
IdentSource::NewName | IdentSource::Free => "identifier".to_string(),
|
||||
},
|
||||
@@ -316,7 +317,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, ColumnSpec, RelationshipSelector, RowFilter,
|
||||
ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter,
|
||||
};
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::value::Value;
|
||||
@@ -471,6 +472,7 @@ mod tests {
|
||||
Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -482,6 +484,7 @@ mod tests {
|
||||
Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -489,6 +492,7 @@ mod tests {
|
||||
Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -496,6 +500,7 @@ mod tests {
|
||||
Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1156,6 +1161,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- add index / drop index (ADR-0025) ---
|
||||
|
||||
#[test]
|
||||
fn add_index_named() {
|
||||
assert_eq!(
|
||||
ok("add index as idx_email on Customers (Email)"),
|
||||
Command::AddIndex {
|
||||
name: Some("idx_email".to_string()),
|
||||
table: "Customers".to_string(),
|
||||
columns: vec!["Email".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_index_unnamed() {
|
||||
assert_eq!(
|
||||
ok("add index on Customers (Email)"),
|
||||
Command::AddIndex {
|
||||
name: None,
|
||||
table: "Customers".to_string(),
|
||||
columns: vec!["Email".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_index_composite_columns() {
|
||||
assert_eq!(
|
||||
ok("add index on Orders (CustId, Date)"),
|
||||
Command::AddIndex {
|
||||
name: None,
|
||||
table: "Orders".to_string(),
|
||||
columns: vec!["CustId".to_string(), "Date".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_by_name() {
|
||||
assert_eq!(
|
||||
ok("drop index idx_email"),
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Named {
|
||||
name: "idx_email".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_by_columns() {
|
||||
assert_eq!(
|
||||
ok("drop index on Customers (Email)"),
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Columns {
|
||||
table: "Customers".to_string(),
|
||||
columns: vec!["Email".to_string()],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_column_cascade_flag() {
|
||||
assert_eq!(
|
||||
ok("drop column Customers: Email --cascade"),
|
||||
Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_allows_underscores_and_digits_after_start() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -884,6 +884,7 @@ mod tests {
|
||||
let want = Command::DropColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Email".to_string(),
|
||||
cascade: false,
|
||||
};
|
||||
assert_eq!(parse("drop column Customers: Email").unwrap(), want);
|
||||
assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
|
||||
|
||||
Reference in New Issue
Block a user