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
+52 -1
View File
@@ -132,6 +132,19 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
}
}
// Indexes section (ADR-0025), shown only when the table
// carries at least one user-created index.
if !desc.indexes.is_empty() {
out.push("Indexes:".to_string());
for index in &desc.indexes {
out.push(format!(
" {} ({})",
index.name,
index.columns.join(", "),
));
}
}
out
}
@@ -331,7 +344,7 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{ColumnDescription, RelationshipEnd};
use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
use crate::dsl::ReferentialAction;
use insta::assert_snapshot;
@@ -548,6 +561,7 @@ mod tests {
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
assert_snapshot!(render_structure(&desc).join("\n"));
}
@@ -566,6 +580,7 @@ mod tests {
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(
@@ -590,6 +605,7 @@ mod tests {
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// PK appears for id, NOT NULL for name, blank for nick.
@@ -597,6 +613,40 @@ mod tests {
assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}");
}
#[test]
fn render_structure_shows_indexes_section() {
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: vec![IndexInfo {
name: "idx_email".to_string(),
columns: vec!["Email".to_string()],
unique: false,
}],
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
}
#[test]
fn render_structure_omits_indexes_section_when_none() {
let desc = TableDescription {
name: "T".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(!out.contains("Indexes:"), "got:\n{out}");
}
#[test]
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
let mut desc = TableDescription {
@@ -610,6 +660,7 @@ mod tests {
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// The lowercase form of the SQLite type should appear.