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:
claude@clouddev1
2026-05-16 00:15:55 +00:00
parent 41043d686b
commit 0dc159fd7e
35 changed files with 2155 additions and 73 deletions
+48 -1
View File
@@ -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
View File
@@ -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"),
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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!(
+1
View File
@@ -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);