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:
@@ -391,3 +391,70 @@ fn rebuild_preserves_created_at_from_yaml() {
|
||||
"yaml should preserve the edited created_at:\n{final_yaml}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Indexes round-trip through `project.yaml` and a full rebuild
|
||||
/// (ADR-0025): create an index, drop the `.db`, rebuild from
|
||||
/// text, confirm the index is back.
|
||||
#[test]
|
||||
fn rebuild_restores_indexes() {
|
||||
let data = tempdir();
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
|
||||
ColumnSpec { name: "Email".to_string(), ty: Type::Text },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
Some("create table Customers with pk id:serial".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_index(
|
||||
Some("idx_email".to_string()),
|
||||
"Customers".to_string(),
|
||||
vec!["Email".to_string()],
|
||||
Some("add index as idx_email on Customers (Email)".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
|
||||
// The index must be recorded in project.yaml — the `.db` is
|
||||
// a derived artifact and gets discarded next.
|
||||
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
|
||||
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
});
|
||||
|
||||
let desc = rt()
|
||||
.block_on(async { db.describe_table("Customers".to_string(), None).await })
|
||||
.expect("describe_table");
|
||||
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
|
||||
assert_eq!(desc.indexes[0].name, "idx_email");
|
||||
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Matrix coverage for `add index [as <name>] on <T> (<col>, …)`
|
||||
//! and `drop index (<name> | on <T> (<col>, …))` (ADR-0025).
|
||||
|
||||
use crate::typing_surface::*;
|
||||
use rdbms_playground::input_render::InputState;
|
||||
|
||||
#[test]
|
||||
fn after_add_offers_index_branch() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("add ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["index"]);
|
||||
crate::snap!("after_add_index_branch", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_index_after_on_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("add index on ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("add_index_after_on", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_index_open_paren_narrows_to_table_columns() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("add index on Orders (", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["OrderId", "CustId", "Total"]);
|
||||
assert_no_candidate_named(&a, &["id", "Name"]);
|
||||
crate::snap!("add_index_open_paren", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_add_index_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("add index on Orders (CustId)", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("AddIndex"));
|
||||
crate::snap!("add_index_complete", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_add_index_named_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("add index as ord_cust on Orders (CustId)", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("AddIndex"));
|
||||
crate::snap!("add_index_named_complete", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_drop_offers_index_branch() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("drop ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["index"]);
|
||||
crate::snap!("drop_index_branch", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_drop_index_by_name_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("drop index some_idx", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("DropIndex"));
|
||||
crate::snap!("drop_index_named_complete", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_drop_index_by_columns_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("drop index on Orders (CustId)", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("DropIndex"));
|
||||
crate::snap!("drop_index_columns_complete", a);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod create_table;
|
||||
pub mod drop_column;
|
||||
pub mod drop_relationship;
|
||||
pub mod add_relationship;
|
||||
pub mod index_ops;
|
||||
pub mod rename_change_column;
|
||||
pub mod app_commands;
|
||||
pub mod candidate_ordering;
|
||||
@@ -203,6 +204,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
||||
AddRelationship { .. } => "AddRelationship".into(),
|
||||
DropRelationship { .. } => "DropRelationship".into(),
|
||||
AddIndex { .. } => "AddIndex".into(),
|
||||
DropIndex { .. } => "DropIndex".into(),
|
||||
ShowTable { .. } => "ShowTable".into(),
|
||||
Insert { .. } => "Insert".into(),
|
||||
Update { .. } => "Update".into(),
|
||||
|
||||
+8
@@ -14,6 +14,10 @@ Assessment {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "1:n",
|
||||
kind: Keyword,
|
||||
@@ -34,6 +38,10 @@ Assessment {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "1:n",
|
||||
kind: Keyword,
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"add index on \" cursor=13"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "add index on ",
|
||||
cursor: 13,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
13,
|
||||
13,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"add index on Orders (\" cursor=21"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "add index on Orders (",
|
||||
cursor: 21,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "CustId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "OrderId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Total",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
21,
|
||||
21,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "CustId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "OrderId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Total",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"add \" cursor=4"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "add ",
|
||||
cursor: 4,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "1:n",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
4,
|
||||
4,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "1:n",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"drop \" cursor=5"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "drop ",
|
||||
cursor: 5,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "relationship",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
5,
|
||||
5,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "column",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "relationship",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "index",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"add index as ord_cust on Orders (CustId)\" cursor=40"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "add index as ord_cust on Orders (CustId)",
|
||||
cursor: 40,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"AddIndex",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"add index on Orders (CustId)\" cursor=28"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "add index on Orders (CustId)",
|
||||
cursor: 28,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"AddIndex",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"drop index on Orders (CustId)\" cursor=29"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "drop index on Orders (CustId)",
|
||||
cursor: 29,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"DropIndex",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/index_ops.rs
|
||||
description: "input=\"drop index some_idx\" cursor=19"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "drop index some_idx",
|
||||
cursor: 19,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"No such identifier: `some_idx`",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"DropIndex",
|
||||
),
|
||||
}
|
||||
@@ -257,6 +257,7 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
|
||||
.collect(),
|
||||
outbound_relationships: Vec::new(),
|
||||
inbound_relationships: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +423,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
indexes: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddRelationship {
|
||||
@@ -470,6 +472,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
indexes: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddColumn {
|
||||
|
||||
Reference in New Issue
Block a user