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
+35 -3
View File
@@ -14,7 +14,7 @@ use tracing::{trace, warn};
use crate::action::Action;
use crate::db::{
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
InsertResult, TableDescription, UpdateResult,
DropColumnResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent;
@@ -341,6 +341,10 @@ impl App {
self.handle_dsl_add_column_success(&command, result);
Vec::new()
}
AppEvent::DslDropColumnSucceeded { command, result } => {
self.handle_dsl_drop_column_success(&command, result);
Vec::new()
}
AppEvent::DslFailed {
command,
error,
@@ -1146,6 +1150,26 @@ impl App {
self.current_table = Some(result.description);
}
fn handle_dsl_drop_column_success(
&mut self,
command: &Command,
result: DropColumnResult,
) {
self.note_ok_summary(command);
// ADR-0025: when `--cascade` removed covering indexes,
// name each one so the learner sees the side effect.
for index in &result.dropped_indexes {
self.note_system(crate::t!(
"ok.index_dropped_with_column",
index = index,
));
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
}
self.current_table = Some(result.description);
}
fn handle_dsl_change_column_success(
&mut self,
command: &Command,
@@ -1250,7 +1274,7 @@ impl App {
command: &Command,
facts: crate::friendly::FailureContext,
) -> crate::friendly::TranslateContext {
use crate::dsl::{Command as C, RelationshipSelector};
use crate::dsl::{Command as C, IndexSelector, RelationshipSelector};
use crate::friendly::{Operation, TranslateContext};
let (operation, fallback_table, fallback_column) = match command {
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
@@ -1260,7 +1284,7 @@ impl App {
Some(table.as_str()),
Some(column.as_str()),
),
C::DropColumn { table, column } => (
C::DropColumn { table, column, .. } => (
Operation::DropColumn,
Some(table.as_str()),
Some(column.as_str()),
@@ -1292,6 +1316,13 @@ impl App {
),
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
},
C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
C::DropIndex { selector } => match selector {
IndexSelector::Columns { table, .. } => {
(Operation::DropIndex, Some(table.as_str()), None)
}
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
},
C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None),
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
@@ -2023,6 +2054,7 @@ mod tests {
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
}
}
+30 -11
View File
@@ -40,11 +40,15 @@ pub struct SchemaCache {
pub tables: Vec<String>,
pub columns: Vec<String>,
pub relationships: Vec<String>,
pub indexes: Vec<String>,
/// Per-table column metadata with user-facing types
/// (ADR-0024 §Phase D). Keyed by table name; lookup is
/// case-insensitive in `columns_for_table` so the walker
/// can resolve `Customers` regardless of how it was typed.
pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
/// Per-table user index names (ADR-0025). Keyed by table
/// name; drives the nested tables/indexes items panel (S2).
pub table_indexes: std::collections::HashMap<String, Vec<String>>,
}
/// One column's user-facing type info, scoped to a table
@@ -65,6 +69,7 @@ impl SchemaCache {
IdentSource::Tables => &self.tables,
IdentSource::Columns => &self.columns,
IdentSource::Relationships => &self.relationships,
IdentSource::Indexes => &self.indexes,
IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[],
}
}
@@ -816,16 +821,23 @@ mod tests {
}
#[test]
fn multi_candidate_position_offers_column_and_one_to_n() {
fn multi_candidate_position_offers_add_subcommands() {
// After `add ` the parser expects `column` (for
// `add column ...`) and `1` (the opener for
// `add column ...`), `index` (for `add index ...`,
// ADR-0025), and `1` (the opener for
// `add 1:n relationship ...`). The completion engine
// surfaces both: `column` straight from the keyword
// expected-set, and `1:n` as a composite literal
// candidate so the user can Tab through to the
// relationship form without knowing the surface syntax.
// sections keyword candidates (`column`, `index`)
// ahead of the `1:n` composite literal, so the literal
// sorts last even though `add 1:n` is declared second.
let cs = cands("add ", 4);
assert_eq!(cs, vec!["column".to_string(), "1:n".to_string()]);
assert_eq!(
cs,
vec![
"column".to_string(),
"index".to_string(),
"1:n".to_string(),
]
);
}
#[test]
@@ -1039,7 +1051,10 @@ mod tests {
}
#[test]
fn drop_offers_three_alternatives_alphabetised() {
fn drop_offers_all_four_subcommands() {
// `drop` branches: column / relationship / table / index
// (ADR-0025). Candidates follow grammar declaration
// order, so `index` — added last — appears last.
let cs = cands("drop ", 5);
assert_eq!(
cs,
@@ -1047,6 +1062,7 @@ mod tests {
"column".to_string(),
"relationship".to_string(),
"table".to_string(),
"index".to_string(),
],
);
}
@@ -1593,13 +1609,16 @@ mod tests {
c.sort_by(|a, b| a.text.cmp(&b.text));
c
}
// `add ` exposes `column` and `1:n` — alphabetic ranker
// flips them.
// `add ` exposes `column`, `1:n` and `index` — the
// alphabetic ranker reorders them.
let cache = SchemaCache::default();
let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker)
.expect("some completion");
let texts: Vec<String> = comp.candidates.into_iter().map(|c| c.text).collect();
assert_eq!(texts, vec!["1:n".to_string(), "column".to_string()]);
assert_eq!(
texts,
vec!["1:n".to_string(), "column".to_string(), "index".to_string()]
);
}
#[test]
+760 -13
View File
@@ -31,7 +31,7 @@ use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ChangeColumnMode, RelationshipSelector, RowFilter};
use crate::dsl::command::{ChangeColumnMode, IndexSelector, RelationshipSelector, RowFilter};
use crate::dsl::ColumnSpec;
use crate::dsl::shortid;
use crate::dsl::types::Type;
@@ -39,8 +39,8 @@ use crate::dsl::value::{Bound, Value, ValueError};
use crate::output_render::{Alignment, render_diagnostic_table};
use crate::type_change;
use crate::persistence::{
CellValue, ColumnSchema, Persistence, PersistenceError, RelationshipSchema, SchemaSnapshot,
TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
};
use crate::project::{DATA_DIR, PROJECT_YAML};
@@ -64,6 +64,24 @@ pub struct TableDescription {
/// Relationships where *this* table is the parent (some
/// other table's column references one of ours).
pub inbound_relationships: Vec<RelationshipEnd>,
/// User-created indexes on this table (ADR-0025).
pub indexes: Vec<IndexInfo>,
}
/// One user-created index on a table (ADR-0025).
///
/// Read live from the engine's native catalog
/// (`pragma_index_list` / `pragma_index_info`); the playground
/// keeps no separate index metadata table. Only indexes with
/// origin `c` (a `CREATE INDEX` statement) are surfaced — the
/// automatic indexes backing primary keys and UNIQUE
/// constraints are not user indexes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexInfo {
pub name: String,
/// Indexed columns, in index order.
pub columns: Vec<String>,
pub unique: bool,
}
/// One end of a relationship as seen from the table being
@@ -220,6 +238,18 @@ pub struct AddColumnResult {
pub client_side_notes: Vec<String>,
}
/// Outcome of a successful `drop column …` (ADR-0025).
///
/// `dropped_indexes` names any index removed by `--cascade`
/// because it covered the dropped column. Empty in the common
/// case (no covering index, or none to cascade); the runtime
/// renders one note line per entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropColumnResult {
pub description: TableDescription,
pub dropped_indexes: Vec<String>,
}
/// Outcome of a successful `change column …` (ADR-0017 §6).
///
/// `description` is the post-rebuild table structure (used for
@@ -397,8 +427,9 @@ enum Request {
DropColumn {
table: String,
column: String,
cascade: bool,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
reply: oneshot::Sender<Result<DropColumnResult, DbError>>,
},
RenameColumn {
table: String,
@@ -440,6 +471,20 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
},
/// Create an index on a table (ADR-0025).
AddIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Drop an index by name or by table + column set (ADR-0025).
DropIndex {
selector: IndexSelector,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
Insert {
table: String,
columns: Option<Vec<String>>,
@@ -607,12 +652,48 @@ impl Database {
&self,
table: String,
column: String,
cascade: bool,
source: Option<String>,
) -> Result<TableDescription, DbError> {
) -> Result<DropColumnResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropColumn {
table,
column,
cascade,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_index(
&self,
name: Option<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddIndex {
name,
table,
columns,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_index(
&self,
selector: IndexSelector,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropIndex {
selector,
source,
reply,
})
@@ -1021,6 +1102,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
Request::DropColumn {
table,
column,
cascade,
source,
reply,
} => {
@@ -1030,6 +1112,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
source.as_deref(),
&table,
&column,
cascade,
));
}
Request::RenameColumn {
@@ -1119,6 +1202,34 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&selector,
));
}
Request::AddIndex {
name,
table,
columns,
source,
reply,
} => {
let _ = reply.send(do_add_index(
conn,
persistence,
source.as_deref(),
name.as_deref(),
&table,
&columns,
));
}
Request::DropIndex {
selector,
source,
reply,
} => {
let _ = reply.send(do_drop_index(
conn,
persistence,
source.as_deref(),
&selector,
));
}
Request::Insert {
table,
columns,
@@ -1255,6 +1366,27 @@ fn do_list_names_for(
}
Ok(out)
}
IdentSource::Indexes => {
// User indexes only: a `CREATE INDEX` statement
// leaves a non-null `sql`, whereas the automatic
// indexes backing PKs / UNIQUE constraints have a
// null `sql`.
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_master \
WHERE type = 'index' AND sql IS NOT NULL \
ORDER BY name;",
)
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
IdentSource::NewName | IdentSource::Types | IdentSource::Free => Ok(Vec::new()),
}
}
@@ -1426,11 +1558,22 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
}
let relationships = read_all_relationships(conn)?;
let mut indexes: Vec<IndexSchema> = Vec::new();
for name in &table_names {
for idx in read_table_indexes(conn, name)? {
indexes.push(IndexSchema {
name: idx.name,
table: name.clone(),
columns: idx.columns,
});
}
}
let created_at = read_project_created_at(conn)?;
Ok(SchemaSnapshot {
created_at,
tables,
relationships,
indexes,
})
}
@@ -1977,7 +2120,8 @@ fn do_drop_column(
source: Option<&str>,
table: &str,
column: &str,
) -> Result<TableDescription, DbError> {
cascade: bool,
) -> Result<DropColumnResult, DbError> {
let schema = read_schema(conn, table)?;
let col_info = schema
.columns
@@ -2011,9 +2155,39 @@ fn do_drop_column(
)));
}
// Indexes covering this column (ADR-0025). Without
// `--cascade` a covered column is refused; with it, the
// covering indexes are dropped alongside the column.
let covering: Vec<IndexInfo> = read_table_indexes(conn, table)?
.into_iter()
.filter(|i| i.columns.iter().any(|c| c == column))
.collect();
if !covering.is_empty() && !cascade {
let names = covering
.iter()
.map(|i| format!("`{}`", i.name))
.collect::<Vec<_>>()
.join(", ");
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` while an index covers \
it ({names}); drop the index first, or pass `--cascade` \
to drop the covering indexes too."
)));
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// Drop covering indexes first — the engine refuses
// DROP COLUMN on an indexed column otherwise. `covering`
// is empty unless `--cascade` was given (the refusal above).
for index in &covering {
tx.execute_batch(&format!(
"DROP INDEX {ident};",
ident = quote_ident(&index.name)
))
.map_err(DbError::from_rusqlite)?;
}
let ddl = format!(
"ALTER TABLE {tbl} DROP COLUMN {col};",
tbl = quote_ident(table),
@@ -2036,7 +2210,10 @@ fn do_drop_column(
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
Ok(DropColumnResult {
description,
dropped_indexes: covering.into_iter().map(|i| i.name).collect(),
})
}
/// Rename a column.
@@ -3104,6 +3281,68 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
})
}
/// Read the user-created indexes on `table` (ADR-0025).
///
/// `pragma_index_list` reports every index; we keep only those
/// with origin `c` (a `CREATE INDEX` statement) and skip partial
/// indexes — the playground never creates partial indexes, and
/// surfacing the automatic PK / UNIQUE indexes as user indexes
/// would be misleading. Results are ordered by index name for
/// stable rendering.
fn read_table_indexes(conn: &Connection, table: &str) -> Result<Vec<IndexInfo>, DbError> {
let mut list_stmt = conn
.prepare(
"SELECT name, \"unique\", origin, partial \
FROM pragma_index_list(?1) \
ORDER BY name;",
)
.map_err(DbError::from_rusqlite)?;
let metas = list_stmt
.query_map([table], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? != 0,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)? != 0,
))
})
.map_err(DbError::from_rusqlite)?;
let mut keep: Vec<(String, bool)> = Vec::new();
for meta in metas {
let (name, unique, origin, partial) = meta.map_err(DbError::from_rusqlite)?;
if origin == "c" && !partial {
keep.push((name, unique));
}
}
let mut out = Vec::with_capacity(keep.len());
for (name, unique) in keep {
let columns = read_index_columns(conn, &name)?;
out.push(IndexInfo {
name,
columns,
unique,
});
}
Ok(out)
}
/// The indexed columns of `index`, in index order.
fn read_index_columns(conn: &Connection, index: &str) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(
"SELECT name FROM pragma_index_info(?1) ORDER BY seqno;",
)
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([index], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
fn parse_action_from_sqlite(s: &str) -> ReferentialAction {
// SQLite stores the action keywords in upper-case form
// ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT").
@@ -3270,6 +3509,13 @@ where
copy_data(&tx, &temp_name, table)?;
// Capture the table's user indexes before the drop —
// `DROP TABLE` discards them (ADR-0025). They are
// recreated verbatim after the rename: every caller of
// this primitive preserves the column set, so the index
// column references stay valid.
let captured_indexes = read_table_indexes(&tx, table)?;
tx.execute_batch(&format!(
"DROP TABLE {ident};",
ident = quote_ident(table)
@@ -3282,6 +3528,22 @@ where
))
.map_err(DbError::from_rusqlite)?;
for index in &captured_indexes {
let cols = index
.columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let unique_kw = if index.unique { "UNIQUE " } else { "" };
tx.execute_batch(&format!(
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&index.name),
tbl = quote_ident(table),
))
.map_err(DbError::from_rusqlite)?;
}
metadata_updates(&tx)?;
// Verify referential integrity before committing. Any
@@ -3597,6 +3859,167 @@ fn do_drop_relationship(
Ok(Some(do_describe_table(conn, &parent_table)?))
}
/// Create an index on `table` over `columns` (ADR-0025).
///
/// Refuses a redundant index on an already-indexed column set
/// and a name collision. The index name is auto-generated as
/// `<table>_<col…>_idx` when not supplied.
fn do_add_index(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: Option<&str>,
table: &str,
columns: &[String],
) -> Result<TableDescription, DbError> {
// 1. Table must exist; gather its columns.
let schema = read_schema(conn, table)?;
// 2. Every indexed column must exist on the table.
for col in columns {
if !schema.columns.iter().any(|c| &c.name == col) {
return Err(DbError::Sqlite {
message: format!("no such column: {table}.{col}"),
kind: SqliteErrorKind::NoSuchColumn,
});
}
}
// 3. Refuse a redundant index over an identical column set.
let existing = read_table_indexes(conn, table)?;
if let Some(dup) = existing
.iter()
.find(|i| i.columns.as_slice() == columns)
{
return Err(DbError::Unsupported(format!(
"the columns ({}) of `{table}` are already indexed by `{}`.",
columns.join(", "),
dup.name,
)));
}
// 4. Resolve the index name (auto-generate when omitted).
let resolved = name.map_or_else(
|| format!("{table}_{}_idx", columns.join("_")),
ToString::to_string,
);
// 5. Refuse a name collision.
let name_taken: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = ?1;",
[&resolved],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if name_taken > 0 {
return Err(DbError::Unsupported(format!(
"an index named `{resolved}` already exists. \
Pick a different name or drop the existing one first."
)));
}
// 6. Create the index and persist.
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let cols_csv = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let ddl = format!(
"CREATE INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&resolved),
tbl = quote_ident(table),
cols = cols_csv,
);
debug!(ddl = %ddl, "add_index");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
schema_dirty: true,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Drop an index identified by name or by table + column set
/// (ADR-0025). Returns the affected table's description.
fn do_drop_index(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
selector: &IndexSelector,
) -> Result<TableDescription, DbError> {
let (index_name, table_name) = match selector {
IndexSelector::Named { name } => {
let lookup = conn.query_row(
"SELECT tbl_name FROM sqlite_master \
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;",
[name],
|row| row.get::<_, String>(0),
);
match lookup {
Ok(table) => (name.clone(), table),
Err(rusqlite::Error::QueryReturnedNoRows) => {
return Err(DbError::Sqlite {
message: format!("no such index: {name}"),
kind: SqliteErrorKind::Other,
});
}
Err(e) => return Err(DbError::from_rusqlite(e)),
}
}
IndexSelector::Columns { table, columns } => {
// Surface a missing table as such, not as "no index".
read_schema(conn, table)?;
let matches: Vec<IndexInfo> = read_table_indexes(conn, table)?
.into_iter()
.filter(|i| i.columns.as_slice() == columns.as_slice())
.collect();
match matches.as_slice() {
[] => {
return Err(DbError::Sqlite {
message: format!(
"no index on {table} ({}) exists",
columns.join(", ")
),
kind: SqliteErrorKind::Other,
});
}
[one] => (one.name.clone(), table.clone()),
many => {
let names = many
.iter()
.map(|i| format!("`{}`", i.name))
.collect::<Vec<_>>()
.join(", ");
return Err(DbError::Unsupported(format!(
"more than one index on {table} ({}) matches \
({names}); drop it by name instead.",
columns.join(", ")
)));
}
}
}
};
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&format!(
"DROP INDEX {ident};",
ident = quote_ident(&index_name)
))
.map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, &table_name)?;
let changes = Changes {
schema_dirty: true,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Read-only wrapper around `do_describe_table` that runs an
/// auxiliary `history.log` append for user-issued
/// `show table` commands.
@@ -3659,12 +4082,14 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
let outbound_relationships = read_relationships_outbound(conn, name)?;
let inbound_relationships = read_relationships_inbound(conn, name)?;
let indexes = read_table_indexes(conn, name)?;
Ok(TableDescription {
name: name.to_string(),
columns,
outbound_relationships,
inbound_relationships,
indexes,
})
}
@@ -4396,6 +4821,25 @@ fn do_rebuild_from_text(
load_table_csv(&tx, table, &csv_path)?;
}
// 5b. Recreate indexes (ADR-0025). Done after the data
// load — the result is identical either way, and
// this keeps the structural steps (tables, FKs,
// data) ahead of the derived index objects.
for index in &snapshot.indexes {
let cols = index
.columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
tx.execute_batch(&format!(
"CREATE INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&index.name),
tbl = quote_ident(&index.table),
))
.map_err(DbError::from_rusqlite)?;
}
// 6. Verify FK consistency before committing.
{
let mut check = tx
@@ -5062,11 +5506,16 @@ mod tests {
.await
.unwrap();
let desc = db
.drop_column("T".to_string(), "Score".to_string(), None)
let result = db
.drop_column("T".to_string(), "Score".to_string(), false, None)
.await
.unwrap();
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
let names: Vec<_> = result
.description
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, vec!["id"]);
// Row data still accessible (id was preserved); the
@@ -5081,7 +5530,7 @@ mod tests {
let db = db();
make_id_table(&db, "T").await;
let err = db
.drop_column("T".to_string(), "id".to_string(), None)
.drop_column("T".to_string(), "id".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
@@ -5118,7 +5567,7 @@ mod tests {
.unwrap();
// Try to drop the FK column on the child side.
let err = db
.drop_column("Orders".to_string(), "cust_id".to_string(), None)
.drop_column("Orders".to_string(), "cust_id".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
@@ -5130,7 +5579,7 @@ mod tests {
let db = db();
make_id_table(&db, "T").await;
let err = db
.drop_column("T".to_string(), "Ghost".to_string(), None)
.drop_column("T".to_string(), "Ghost".to_string(), false, None)
.await
.unwrap_err();
match err {
@@ -5139,6 +5588,304 @@ mod tests {
}
}
// --- indexes (ADR-0025) -----------------------------------
/// A `serial`-PK table with one extra text `Email` column —
/// something indexable.
async fn make_indexable_table(db: &Database, name: &str) {
make_id_table(db, name).await;
db.add_column(name.to_string(), "Email".to_string(), Type::Text, None)
.await
.expect("add Email column");
}
#[tokio::test]
async fn add_index_appears_in_description() {
let db = db();
make_indexable_table(&db, "Customers").await;
let desc = db
.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("add index");
assert_eq!(desc.indexes.len(), 1);
assert_eq!(desc.indexes[0].name, "idx_email");
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
}
#[tokio::test]
async fn add_index_auto_generates_name() {
let db = db();
make_indexable_table(&db, "Customers").await;
let desc = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("add index");
assert_eq!(desc.indexes[0].name, "Customers_Email_idx");
}
#[tokio::test]
async fn add_index_composite_auto_name_joins_columns() {
let db = db();
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), "CustId".to_string(), Type::Int, None)
.await
.unwrap();
db.add_column("Orders".to_string(), "Day".to_string(), Type::Date, None)
.await
.unwrap();
let desc = db
.add_index(
None,
"Orders".to_string(),
vec!["CustId".to_string(), "Day".to_string()],
None,
)
.await
.expect("add index");
assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx");
assert_eq!(
desc.indexes[0].columns,
vec!["CustId".to_string(), "Day".to_string()]
);
}
#[tokio::test]
async fn add_index_rejects_duplicate_name() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_column("Customers".to_string(), "Nick".to_string(), Type::Text, None)
.await
.unwrap();
db.add_index(
Some("idx".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.add_index(
Some("idx".to_string()),
"Customers".to_string(),
vec!["Nick".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn add_index_rejects_redundant_column_set() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("a".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.add_index(
Some("b".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn add_index_rejects_missing_column() {
let db = db();
make_indexable_table(&db, "Customers").await;
let err = db
.add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None)
.await
.unwrap_err();
assert!(
matches!(
err,
DbError::Sqlite {
kind: SqliteErrorKind::NoSuchColumn,
..
}
),
"got {err:?}"
);
}
#[tokio::test]
async fn add_index_rejects_missing_table() {
let db = db();
let err = db
.add_index(None, "Ghost".to_string(), vec!["x".to_string()], None)
.await
.unwrap_err();
assert!(
matches!(
err,
DbError::Sqlite {
kind: SqliteErrorKind::NoSuchTable,
..
}
),
"got {err:?}"
);
}
#[tokio::test]
async fn drop_index_by_name_removes_it() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let desc = db
.drop_index(
IndexSelector::Named {
name: "idx_email".to_string(),
},
None,
)
.await
.expect("drop index");
assert!(desc.indexes.is_empty());
}
#[tokio::test]
async fn drop_index_by_columns_removes_it() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.unwrap();
let desc = db
.drop_index(
IndexSelector::Columns {
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
},
None,
)
.await
.expect("drop index");
assert!(desc.indexes.is_empty());
}
#[tokio::test]
async fn drop_index_unknown_name_errors() {
let db = db();
make_indexable_table(&db, "Customers").await;
let err = db
.drop_index(
IndexSelector::Named {
name: "nope".to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}");
}
#[tokio::test]
async fn drop_column_refuses_indexed_column_without_cascade() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.drop_column("Customers".to_string(), "Email".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
assert!(format!("{err}").contains("idx_email"), "got {err}");
}
#[tokio::test]
async fn drop_column_cascade_drops_covering_index() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let result = db
.drop_column("Customers".to_string(), "Email".to_string(), true, None)
.await
.expect("drop column --cascade");
assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]);
assert!(result.description.indexes.is_empty());
assert!(
result
.description
.columns
.iter()
.all(|c| c.name != "Email"),
);
}
#[tokio::test]
async fn rebuild_table_preserves_indexes() {
// `change column` rebuilds the table; an index on an
// unrelated column must survive the rebuild (ADR-0025).
let db = db();
make_indexable_table(&db, "T").await;
db.add_column("T".to_string(), "Score".to_string(), Type::Int, None)
.await
.unwrap();
db.add_index(
Some("idx_email".to_string()),
"T".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let result = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Real,
ChangeColumnMode::Default,
None,
)
.await
.expect("change column type");
assert_eq!(result.description.indexes.len(), 1);
assert_eq!(result.description.indexes[0].name, "idx_email");
assert_eq!(
result.description.indexes[0].columns,
vec!["Email".to_string()]
);
}
#[tokio::test]
async fn rename_column_updates_schema_and_metadata() {
let db = db();
+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);
+8 -1
View File
@@ -9,7 +9,7 @@ use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
InsertResult, TableDescription, UpdateResult,
DropColumnResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -56,6 +56,13 @@ pub enum AppEvent {
command: Command,
result: AddColumnResult,
},
/// A `drop column …` succeeded. `result` carries the
/// post-drop description plus the names of any indexes
/// removed by `--cascade` (ADR-0025).
DslDropColumnSucceeded {
command: Command,
result: DropColumnResult,
},
/// A DSL command failed. `error` is the structured
/// payload, `facts` is the runtime-built schema-resolved
/// enrichment (parent tables, attempted values,
+3
View File
@@ -198,11 +198,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// code, not the catalog, because spacing is alignment-
// sensitive in the multi-entry case.
("parse.usage.add_column", &[]),
("parse.usage.add_index", &[]),
("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]),
("parse.usage.delete", &[]),
("parse.usage.drop_column", &[]),
("parse.usage.drop_index", &[]),
("parse.usage.drop_relationship", &[]),
("parse.usage.drop_table", &[]),
("parse.usage.insert", &[]),
@@ -394,6 +396,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "column", "src_ty", "target_ty", "total"],
),
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
("ok.index_dropped_with_column", &["index"]),
("ok.rows_deleted", &["count"]),
("ok.rows_inserted", &["count"]),
("ok.rows_updated", &["count"]),
+12 -1
View File
@@ -251,13 +251,17 @@ help:
create table <T> with pk [<col>:<type>, ...] — create a table
drop: |-
drop table <T> — remove a table
drop column [from] [table] <T>: <col> — remove a column
drop column [from] [table] <T>: <col> [--cascade] — remove a column
(--cascade also drops any index that covers the column)
drop relationship <name> — remove a relationship
drop index <name> — remove an index
drop index on <T> (<col>, ...) — remove an index by its columns
add: |-
add column [to] [table] <T>: <col> (<type>) — add a column
(for serial/shortid on a non-empty table: existing rows auto-filled)
add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk] — declare a relationship
add index [as <name>] on <T> (<col>, ...) — create an index
rename: |-
rename column [in] [table] <T>: <old> to <new> — rename a column
change: |-
@@ -412,12 +416,16 @@ parse:
drop_relationship: |-
drop relationship <Name>
drop relationship from <Parent>.<col> to <Child>.<col>
drop_index: |-
drop index <Name>
drop index on <Table> (<col>[, ...])
add_column: "add column [to] [table] <Table>: <Name> (<Type>)"
add_relationship: |-
add 1:n relationship [as <Name>]
from <Parent>.<col> to <Child>.<col>
[on delete <action>] [on update <action>]
[--create-fk]
add_index: "add index [as <Name>] on <Table> (<col>[, ...])"
rename_column: "rename column [in] [table] <Table>: <Old> to <New>"
change_column: |-
change column [in] [table] <Table>: <Name> (<Type>)
@@ -700,6 +708,9 @@ ok:
rows_inserted: " {count} row(s) inserted"
rows_updated: " {count} row(s) updated"
rows_deleted: " {count} row(s) deleted"
# Shown beneath a `drop column --cascade` summary, once per
# index removed because it covered the dropped column.
index_dropped_with_column: " also dropped index `{index}` (it covered the column)"
# ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ------------
client_side:
+4
View File
@@ -65,6 +65,8 @@ pub enum Operation {
ChangeColumnType,
AddRelationship,
DropRelationship,
AddIndex,
DropIndex,
Query,
Rebuild,
Replay,
@@ -92,6 +94,8 @@ impl Operation {
Self::ChangeColumnType => "change column",
Self::AddRelationship => "add relationship",
Self::DropRelationship => "drop relationship",
Self::AddIndex => "add index",
Self::DropIndex => "drop index",
Self::Query => "query",
Self::Rebuild => "rebuild",
Self::Replay => "replay",
+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.
+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]"));
}
+35 -5
View File
@@ -30,7 +30,7 @@ use crate::app::App;
use crate::cli::Args;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult,
InsertResult, TableDescription, UpdateResult,
DropColumnResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::Command;
use crate::event::AppEvent;
@@ -863,6 +863,9 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
if let Ok(rels) = database.list_names_for(IdentSource::Relationships).await {
cache.relationships = rels;
}
if let Ok(indexes) = database.list_names_for(IdentSource::Indexes).await {
cache.indexes = indexes;
}
// Phase D (ADR-0024 §Phase D): per-table column metadata
// with user-facing types. The walker's
// `DynamicSubgrammar(column_value_list)` reads this to
@@ -872,6 +875,11 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
// walker falls back to the schemaless value-literal list.
for name in cache.tables.clone() {
if let Ok(desc) = database.describe_table(name.clone(), None).await {
// Per-table index names for the items panel (S2,
// ADR-0025). Captured before `desc.columns` is
// consumed below.
let index_names: Vec<String> =
desc.indexes.iter().map(|i| i.name.clone()).collect();
let cols: Vec<TableColumn> = desc
.columns
.into_iter()
@@ -882,7 +890,8 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
})
})
.collect();
cache.table_columns.insert(name, cols);
cache.table_columns.insert(name.clone(), cols);
cache.table_indexes.insert(name, index_names);
}
}
cache
@@ -1039,6 +1048,10 @@ fn spawn_dsl_dispatch(
command: command.clone(),
result,
},
Ok(CommandOutcome::DropColumn(result)) => AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
},
Err(DbError::PersistenceFatal {
operation,
path,
@@ -1367,6 +1380,7 @@ enum CommandOutcome {
Delete(DeleteResult),
ChangeColumn(ChangeColumnTypeResult),
AddColumn(AddColumnResult),
DropColumn(DropColumnResult),
}
/// Spawn a task that reads a script file and dispatches each
@@ -1576,10 +1590,14 @@ async fn execute_command_typed(
.add_column(table, column, ty, src)
.await
.map(CommandOutcome::AddColumn),
Command::DropColumn { table, column } => database
.drop_column(table, column, src)
Command::DropColumn {
table,
column,
cascade,
} => database
.drop_column(table, column, cascade, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
.map(CommandOutcome::DropColumn),
Command::RenameColumn { table, old, new } => database
.rename_column(table, old, new, src)
.await
@@ -1620,6 +1638,18 @@ async fn execute_command_typed(
.drop_relationship(selector, src)
.await
.map(CommandOutcome::Schema),
Command::AddIndex {
name,
table,
columns,
} => database
.add_index(name, table, columns, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropIndex { selector } => database
.drop_index(selector, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::ShowTable { name } => database
.describe_table(name, src)
.await
+39 -14
View File
@@ -420,20 +420,27 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
let lines: Vec<Line<'_>> = app
.tables
.iter()
.map(|name| {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
Line::from(Span::styled(name.as_str(), style))
})
.collect();
// Nested tables / per-table indexes (S2, ADR-0025): each
// table line, with its index names indented beneath it.
let mut lines: Vec<Line<'_>> = Vec::new();
for name in &app.tables {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
lines.push(Line::from(Span::styled(name.as_str(), style)));
if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
for index in indexes {
lines.push(Line::from(Span::styled(
format!(" {index}"),
Style::default().fg(theme.muted),
)));
}
}
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
@@ -1013,6 +1020,7 @@ mod tests {
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
@@ -1041,4 +1049,21 @@ mod tests {
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
#[test]
fn items_panel_nests_indexes_under_their_table() {
// S2 (ADR-0025): the items panel renders each table
// with its index names indented beneath it.
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.schema_cache.table_indexes.insert(
"Customers".to_string(),
vec!["idx_email".to_string()],
);
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 24);
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
}
}