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
+15
View File
@@ -120,6 +120,11 @@ pub struct SchemaSnapshot {
pub created_at: String,
pub tables: Vec<TableSchema>,
pub relationships: Vec<RelationshipSchema>,
/// Indexes across all tables (ADR-0025). Carried as a flat
/// list mirroring `relationships`; each entry names its
/// table. Empty for project files written before indexes
/// existed — the YAML field is optional on read.
pub indexes: Vec<IndexSchema>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -142,6 +147,15 @@ pub struct ColumnSchema {
pub unique: bool,
}
/// One index as recorded in `project.yaml` (ADR-0025).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexSchema {
pub name: String,
pub table: String,
/// The indexed columns, in index order.
pub columns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipSchema {
pub name: String,
@@ -342,6 +356,7 @@ mod tests {
created_at: "2026-05-07T14:30:12Z".to_string(),
tables: vec![],
relationships: vec![],
indexes: vec![],
};
p.write_schema(&schema).unwrap();
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
+59 -1
View File
@@ -23,7 +23,7 @@ use serde::Deserialize;
use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type;
use super::{ColumnSchema, RelationshipSchema, SchemaSnapshot, TableSchema};
use super::{ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableSchema};
/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
#[must_use]
@@ -51,9 +51,31 @@ pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
}
}
if schema.indexes.is_empty() {
let _ = writeln!(out, "indexes: []");
} else {
let _ = writeln!(out, "indexes:");
for index in &schema.indexes {
write_index(&mut out, index);
}
}
out
}
fn write_index(out: &mut String, index: &IndexSchema) {
let _ = writeln!(out, " - name: {}", quote_if_needed(&index.name));
let _ = writeln!(out, " table: {}", quote_if_needed(&index.table));
write!(out, " columns: [").unwrap();
for (i, col) in index.columns.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(&quote_if_needed(col));
}
let _ = writeln!(out, "]");
}
fn write_table(out: &mut String, table: &TableSchema) {
let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name));
write!(out, " primary_key: [").unwrap();
@@ -215,10 +237,20 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
on_update,
});
}
let indexes: Vec<IndexSchema> = raw
.indexes
.into_iter()
.map(|i| IndexSchema {
name: i.name,
table: i.table,
columns: i.columns,
})
.collect();
Ok(SchemaSnapshot {
created_at: raw.project.created_at,
tables,
relationships,
indexes,
})
}
@@ -279,6 +311,10 @@ struct RawProject {
tables: Vec<RawTable>,
#[serde(default)]
relationships: Vec<RawRelationship>,
/// Optional: project files written before ADR-0025 carry no
/// `indexes:` field and default to an empty list.
#[serde(default)]
indexes: Vec<RawIndex>,
}
#[derive(Deserialize)]
@@ -320,6 +356,13 @@ struct RawEndpoint {
column: String,
}
#[derive(Deserialize)]
struct RawIndex {
name: String,
table: String,
columns: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -355,6 +398,11 @@ mod tests {
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: vec![IndexSchema {
name: "Orders_CustId_idx".to_string(),
table: "Orders".to_string(),
columns: vec!["CustId".to_string()],
}],
}
}
@@ -374,6 +422,9 @@ mod tests {
assert!(body.contains("child: { table: Orders, column: CustId }"));
assert!(body.contains("on_delete: cascade"));
assert!(body.contains("on_update: no_action"));
assert!(body.contains("- name: Orders_CustId_idx"));
assert!(body.contains("table: Orders"));
assert!(body.contains("columns: [CustId]"));
}
#[test]
@@ -382,9 +433,11 @@ mod tests {
created_at: "2026-05-07T14:30:12Z".to_string(),
tables: vec![],
relationships: vec![],
indexes: vec![],
});
assert!(body.contains("tables: []"));
assert!(body.contains("relationships: []"));
assert!(body.contains("indexes: []"));
}
#[test]
@@ -401,6 +454,7 @@ mod tests {
}],
}],
relationships: vec![],
indexes: vec![],
});
assert!(body.contains("- name: \"true\""));
assert!(body.contains("{ name: \"yes\", type: bool }"));
@@ -432,6 +486,9 @@ relationships: []
let parsed = parse_schema(body).expect("parse minimal");
assert_eq!(parsed.tables.len(), 0);
assert_eq!(parsed.relationships.len(), 0);
// A project file with no `indexes:` field (written
// before ADR-0025) parses with an empty index list.
assert_eq!(parsed.indexes.len(), 0);
assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z");
}
@@ -496,6 +553,7 @@ relationships:
],
}],
relationships: vec![],
indexes: vec![],
});
assert!(body.contains("primary_key: [a, b]"));
}