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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user