feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX

Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON
<T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> ->
SqlDropIndex, both reusing the ADR-0025 executors (do_add_index /
do_drop_index), like 4c reused do_drop_table.

- CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1):
  ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced
  mode trusts the user like SQL does. Adds an additive IndexSchema.unique
  flag (project.yaml, serde-default, version stays 1); rebuild re-emits
  CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique).
  Simple-mode `add unique index` stays deferred.
- IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip
  (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome.
- Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE]
  prefix is a concrete-keyword Choice and the optional name an on-led-first
  selector (the drop-index selector precedent) — trap-safe.
- create/drop each gain a second advanced node; the existing all-candidates
  dispatch handles it (locked by parse tests).
- Unique indexes marked [unique] in the structure view and items panel.
- do_add_index refuses internal __rdbms_* tables as "no such table",
  closing a latent exposure on both the simple `add index` and the new
  SQL CREATE INDEX surfaces (ADR-0025 Amendment 1).

Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README;
requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md.

Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-25 18:41:02 +00:00
parent 44248fb8bb
commit 701217d29f
22 changed files with 1865 additions and 48 deletions
+27
View File
@@ -469,6 +469,24 @@ impl App {
));
Vec::new()
}
AppEvent::DslDropIndexSkipped { command } => {
// No-op (DROP INDEX IF EXISTS on an absent index,
// ADR-0035 §4d): just the skip note. `target_table()`
// returns the index name for `SqlDropIndex`.
self.note_system(crate::t!(
"ddl.drop_index_skipped_absent",
name = command.target_table()
));
Vec::new()
}
AppEvent::DslCreateIndexSkipped { command: _, name } => {
// No-op (CREATE INDEX IF NOT EXISTS on an existing index
// name, ADR-0035 §4d): the skip note carries the resolved
// index name (the unnamed form's auto-name isn't on the
// command). No structure shown.
self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name));
Vec::new()
}
AppEvent::DslDataSucceeded { command, data } => {
self.handle_dsl_query_success(&command, &data);
Vec::new()
@@ -1603,6 +1621,12 @@ impl App {
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
},
C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
// (it reuses `do_add_index`); route engine/validation errors
// through it with the parsed table.
C::SqlCreateIndex { table, .. } => {
(Operation::AddIndex, Some(table.as_str()), None)
}
C::AddConstraint { table, column, .. } => (
Operation::AddConstraint,
Some(table.as_str()),
@@ -1619,6 +1643,9 @@ impl App {
}
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
},
// The SQL `DROP INDEX` is name-only (the table is resolved by
// the executor), like the named DSL drop.
C::SqlDropIndex { .. } => (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),
+13 -3
View File
@@ -48,9 +48,19 @@ pub struct SchemaCache {
/// 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>>,
/// Per-table user indexes (ADR-0025). Keyed by table name; drives
/// the nested tables/indexes items panel (S2). Each entry carries
/// the index's uniqueness so the panel can mark a UNIQUE index
/// (ADR-0035 §4d).
pub table_indexes: std::collections::HashMap<String, Vec<IndexEntry>>,
}
/// One per-table index for the items panel (ADR-0025 / ADR-0035 §4d):
/// its name and whether it is a UNIQUE index.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
pub name: String,
pub unique: bool,
}
/// One column's user-facing type info, scoped to a table
+235 -9
View File
@@ -486,6 +486,28 @@ enum Request {
source: Option<String>,
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
},
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4d).
/// Executes through `do_drop_index`; `if_exists` turns an absent
/// index into a no-op (`DropIndexOutcome::Skipped`, no snapshot).
SqlDropIndex {
name: String,
if_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<DropIndexOutcome, DbError>>,
},
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
/// (ADR-0035 §4d). Executes through `do_add_index` (with `unique`);
/// `if_not_exists` turns an existing index name into a no-op
/// (`CreateIndexOutcome::Skipped`, no snapshot).
SqlCreateIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateIndexOutcome, DbError>>,
},
AddColumn {
table: String,
column: ColumnSpec,
@@ -894,6 +916,53 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
/// Returns whether the index was dropped (with the affected table's
/// structure) or skipped (the `IF EXISTS` no-op on an absent index).
pub async fn sql_drop_index(
&self,
name: String,
if_exists: bool,
source: Option<String>,
) -> Result<DropIndexOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlDropIndex {
name,
if_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
/// (ADR-0035 §4d). Returns whether the index was created (with the
/// affected table's structure) or skipped (the `IF NOT EXISTS` no-op
/// on an existing index name, carrying the resolved name).
pub async fn sql_create_index(
&self,
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateIndexOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_column(
&self,
table: String,
@@ -1950,6 +2019,10 @@ fn handle_request(
name.as_deref(),
&table,
&columns,
// Simple-mode `add index` is always non-unique
// (ADR-0025); `add unique index` stays deferred. The SQL
// `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d).
false,
));
}
Request::DropIndex {
@@ -1964,6 +2037,76 @@ fn handle_request(
&selector,
));
}
Request::SqlDropIndex {
name,
if_exists,
source,
reply,
} => {
// `IF EXISTS` on an absent index is a no-op: reply `Skipped`
// and take **no** snapshot (nothing to undo). The submitted
// line is still journalled (the 4c skip pattern, ADR-0034 /
// ADR-0035 §4). Existence uses the same user-index lookup as
// `do_drop_index` (`sql IS NOT NULL`).
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(DropIndexOutcome::Skipped)
})();
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_index(
conn,
persistence,
source.as_deref(),
&IndexSelector::Named { name: name.clone() },
)
.map(DropIndexOutcome::Dropped)
});
}
}
Request::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
source,
reply,
} => {
// `IF NOT EXISTS` short-circuits only a *name* collision into
// a no-op (reply `Skipped`, no snapshot, line journalled — the
// 4c skip pattern). The name uses the broad lookup (any index
// of that name), matching `do_add_index`'s collision guard.
// A *different*-named but redundant-column-set create still
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
})();
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_add_index(
conn,
persistence,
source.as_deref(),
name.as_deref(),
&table,
&columns,
unique,
)
.map(CreateIndexOutcome::Created)
});
}
}
Request::AddConstraint {
table,
column,
@@ -2415,6 +2558,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
name: idx.name,
table: name.clone(),
columns: idx.columns,
unique: idx.unique,
});
}
}
@@ -2699,6 +2843,34 @@ pub enum DropOutcome {
Skipped,
}
/// The result of an advanced-mode SQL `DROP INDEX` (ADR-0035 §4d).
///
/// Either the index was dropped — `Dropped` carries the affected
/// table's structure so the runtime auto-shows the now de-indexed table
/// (ADR-0014), unlike `DROP TABLE` whose table is gone — or `IF EXISTS`
/// matched no index and the statement was a no-op driving the "doesn't
/// exist — skipped" note (the index name comes from the command).
#[derive(Debug)]
pub enum DropIndexOutcome {
Dropped(TableDescription),
Skipped,
}
/// The result of an advanced-mode SQL `CREATE [UNIQUE] INDEX`
/// (ADR-0035 §4d).
///
/// Either the index was created — `Created` carries the affected table's
/// structure for the auto-show (ADR-0014) — or `IF NOT EXISTS` matched
/// an existing index name and the statement was a no-op. `Skipped`
/// carries the **resolved** index name (the auto-name is unknown to the
/// command for the unnamed form) to drive the "already exists — skipped"
/// note.
#[derive(Debug)]
pub enum CreateIndexOutcome {
Created(TableDescription),
Skipped(String),
}
#[allow(clippy::too_many_arguments)]
fn do_create_table(
conn: &Connection,
@@ -5859,6 +6031,38 @@ fn do_drop_relationship(
/// 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.
/// Resolve an index name: the user-given name, or the ADR-0025
/// auto-name `<table>_<col…>_idx`. Shared by `do_add_index` and the
/// `CREATE INDEX IF NOT EXISTS` skip pre-check (ADR-0035 §4d) so both
/// compute the same name.
fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> String {
name.map_or_else(
|| format!("{table}_{}_idx", columns.join("_")),
ToString::to_string,
)
}
/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
///
/// `user_only = true` counts only explicit `CREATE INDEX` objects
/// (`sql IS NOT NULL`), matching `do_drop_index`'s named lookup — used
/// by the `DROP INDEX IF EXISTS` skip. `user_only = false` counts any
/// index of that name (incl. the automatic PK / UNIQUE-constraint
/// indexes), matching `do_add_index`'s name-collision guard — used by
/// the `CREATE INDEX IF NOT EXISTS` skip.
fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result<bool, DbError> {
let sql = if user_only {
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;"
} else {
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1;"
};
let count: i64 = conn
.query_row(sql, [name], |row| row.get(0))
.map_err(DbError::from_rusqlite)?;
Ok(count > 0)
}
fn do_add_index(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -5866,7 +6070,21 @@ fn do_add_index(
name: Option<&str>,
table: &str,
columns: &[String],
unique: bool,
) -> Result<TableDescription, DbError> {
// 0. Internal `__rdbms_*` tables are not user tables (they are
// filtered from `list_tables` and never offered in completion), so
// indexing one is refused as "no such table" — the same opacity
// the rest of the app presents. Guards BOTH the simple `add index`
// and the SQL `CREATE INDEX` surfaces, since both reach here
// (ADR-0025 / ADR-0035 §4d; the grammar's `reject_internal_table`
// only covers the typed SQL family, not the simple node).
if table.to_ascii_lowercase().starts_with("__rdbms_") {
return Err(DbError::Sqlite {
message: format!("no such table: {table}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
// 1. Table must exist; gather its columns.
let schema = read_schema(conn, table)?;
// 2. Every indexed column must exist on the table.
@@ -5878,11 +6096,17 @@ fn do_add_index(
});
}
}
// 3. Refuse a redundant index over an identical column set.
// 3. Refuse a redundant index over an identical column set *of the
// same kind*. A plain and a unique index over the same columns are
// NOT redundant (the unique one enforces a constraint the plain one
// does not), so the guard keys on `(columns, unique)` (ADR-0035
// §4d). To hold both, the user must name them distinctly — the
// auto-name is identical, so the name guard (step 5) would
// otherwise collide.
let existing = read_table_indexes(conn, table)?;
if let Some(dup) = existing
.iter()
.find(|i| i.columns.as_slice() == columns)
.find(|i| i.columns.as_slice() == columns && i.unique == unique)
{
return Err(DbError::Unsupported(format!(
"the columns ({}) of `{table}` are already indexed by `{}`.",
@@ -5891,10 +6115,7 @@ fn do_add_index(
)));
}
// 4. Resolve the index name (auto-generate when omitted).
let resolved = name.map_or_else(
|| format!("{table}_{}_idx", columns.join("_")),
ToString::to_string,
);
let resolved = resolve_index_name(name, table, columns);
// 5. Refuse a name collision.
let name_taken: i64 = conn
.query_row(
@@ -5919,13 +6140,14 @@ fn do_add_index(
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let unique_kw = if unique { "UNIQUE " } else { "" };
let ddl = format!(
"CREATE INDEX {idx} ON {tbl} ({cols});",
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&resolved),
tbl = quote_ident(table),
cols = cols_csv,
);
debug!(ddl = %ddl, "add_index");
debug!(ddl = %ddl, unique, "add_index");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
@@ -7818,8 +8040,12 @@ fn do_rebuild_from_text(
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
// ADR-0035 §4d: a UNIQUE index round-trips its uniqueness, so
// re-emit `CREATE UNIQUE INDEX` — otherwise a rebuild would
// silently demote it to a plain index.
let unique_kw = if index.unique { "UNIQUE " } else { "" };
tx.execute_batch(&format!(
"CREATE INDEX {idx} ON {tbl} ({cols});",
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&index.name),
tbl = quote_ident(&index.table),
))
+30
View File
@@ -278,6 +278,29 @@ pub enum Command {
DropIndex {
selector: IndexSelector,
},
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4,
/// sub-phase 4d). Name-only (SQL has no positional column form — that
/// is the simple `drop index on T(…)`). Executes through the same
/// `do_drop_index` machinery as [`Self::DropIndex`]; `if_exists`
/// turns an absent index into a no-op-with-note rather than an error.
SqlDropIndex {
name: String,
if_exists: bool,
},
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>]
/// ON <table> (<col>, …)` (ADR-0035 §4d). Executes through the same
/// `do_add_index` machinery as [`Self::AddIndex`] (the columns/
/// auto-name reuse), plus the `unique` flag (simple mode has no
/// `add unique index` — that stays deferred per ADR-0025). `name` is
/// `None` for the unnamed form (auto-named at execution);
/// `if_not_exists` makes an existing index name a no-op-with-note.
SqlCreateIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
},
/// Add a column-level constraint to an existing column
/// (ADR-0029 §2.2). Applied through the rebuild-table
/// primitive after a §5 dry-run guards populated columns.
@@ -710,6 +733,8 @@ impl Command {
Self::DropRelationship { .. } => "drop relationship",
Self::AddIndex { .. } => "add index",
Self::DropIndex { .. } => "drop index",
Self::SqlDropIndex { .. } => "drop index",
Self::SqlCreateIndex { .. } => "create index",
Self::AddConstraint { .. } => "add constraint",
Self::DropConstraint { .. } => "drop constraint",
Self::ShowTable { .. } => "show table",
@@ -783,6 +808,11 @@ impl Command {
// sensible fallback for logging.
IndexSelector::Named { name } => name,
},
// The SQL drop is name-only; the index name identifies it
// until the executor resolves the table (mirrors the named
// `DropIndex` / `SqlDropTable` fallback).
Self::SqlDropIndex { name, .. } => name,
Self::SqlCreateIndex { table, .. } => table,
// Replay isn't tied to a single table; the path is
// the most identifying thing for log output.
Self::Replay { path } => path,
+299
View File
@@ -203,6 +203,21 @@ static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
];
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
// form (that stays the simple `drop index on …`, which falls back to
// the simple `drop` node). Leads on the concrete `index` keyword; the
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
// still parses and reaches the skip path.
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
SQL_DROP_IF_EXISTS_OPT,
INDEX_NAME_EXISTING,
Node::Optional(&Node::Punct(';')),
];
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);
// =================================================================
// drop_column — `drop column [from] [table] <T> : <col>`
// =================================================================
@@ -1726,6 +1741,106 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
usage_ids: &["parse.usage.sql_drop_table"],
};
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
/// flag (mirroring `build_sql_drop_table`).
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::SqlDropIndex {
name: require_ident(path, "index_name")?,
if_exists: path.contains_word("if"),
})
}
pub static SQL_DROP_INDEX: CommandNode = CommandNode {
entry: Word::keyword("drop"),
shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"),
usage_ids: &["parse.usage.sql_drop_index"],
};
// =================================================================
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
// node (alongside SQL_CREATE_TABLE).
// =================================================================
// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("exists")),
];
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));
// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
// instead greedily consume the `on` keyword (`consume_ident` does not
// reject keywords), breaking the unnamed form.
static SQL_CI_UNNAMED_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
static SQL_CI_NAMED_NODES: &[Node] = &[
INDEX_NAME_NEW,
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);
static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
SQL_CI_LEAD,
SQL_CI_IF_NOT_EXISTS_OPT,
SQL_CI_SELECTOR,
Node::Optional(&Node::Punct(';')),
];
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);
/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
/// extraction mirrors the simple `add index` builder.
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::SqlCreateIndex {
name: ident(path, "index_name").map(str::to_string),
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
unique: path.contains_word("unique"),
if_not_exists: path.contains_word("if"),
})
}
pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"),
usage_ids: &["parse.usage.sql_create_index"],
};
// =================================================================
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
// =================================================================
@@ -1994,3 +2109,187 @@ mod sql_drop_table_tests {
));
}
}
#[cfg(test)]
mod sql_drop_index_tests {
use crate::dsl::command::{Command, IndexSelector};
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
fn drop_index_fields(input: &str) -> (String, bool) {
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
Command::SqlDropIndex { name, if_exists } => (name, if_exists),
other => panic!("expected SqlDropIndex, got {other:?}"),
}
}
#[test]
fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
assert_eq!(name, "Orders_CustId_idx");
assert!(!if_exists);
}
#[test]
fn if_exists_sets_the_flag() {
let (name, if_exists) = drop_index_fields("drop index if exists ix");
assert_eq!(name, "ix");
assert!(if_exists);
// trailing semicolon tolerated
assert!(drop_index_fields("drop index if exists ix;").1);
}
#[test]
fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
// `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
// SQL_DROP_INDEX); the dispatcher must try both and pick the
// shape that matches (ADR-0035 §4d — the second-advanced-node
// case).
assert!(matches!(
parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
Command::SqlDropTable { .. }
));
assert!(matches!(
parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
Command::SqlDropIndex { .. }
));
}
#[test]
fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
// The SQL form is name-only; `drop index on T (cols)` is the
// simple positional form. The name-only SQL shape can't fully
// match it (trailing `(cols)`), so it falls back to the simple
// `drop` node's `DropIndex { Columns }` even in advanced mode.
match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
.expect("parses")
{
Command::DropIndex {
selector: IndexSelector::Columns { table, columns },
} => {
assert_eq!(table, "Orders");
assert_eq!(columns, vec!["CustId".to_string()]);
}
other => panic!("expected positional DropIndex, got {other:?}"),
}
}
#[test]
fn named_drop_index_in_simple_mode_is_the_dsl_command() {
// In simple mode the SQL node is gated; `drop index ix` is the
// simple `DropIndex { Named }`.
match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
Command::DropIndex {
selector: IndexSelector::Named { name },
} => assert_eq!(name, "ix"),
other => panic!("expected named DropIndex, got {other:?}"),
}
}
}
#[cfg(test)]
mod sql_create_index_tests {
use crate::dsl::command::Command;
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
struct Ci {
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
}
fn ci(input: &str) -> Ci {
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
Command::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
#[test]
fn named_create_index_parses() {
let c = ci("create index ix on Customers (email)");
assert_eq!(c.name.as_deref(), Some("ix"));
assert_eq!(c.table, "Customers");
assert_eq!(c.columns, vec!["email".to_string()]);
assert!(!c.unique);
assert!(!c.if_not_exists);
}
#[test]
fn unnamed_create_index_leaves_name_none() {
// The unnamed form: the optional name must NOT swallow `on`
// (the `DI_SELECTOR`-style on-led-first selector handles it).
let c = ci("create index on Customers (email)");
assert_eq!(c.name, None);
assert_eq!(c.table, "Customers");
assert_eq!(c.columns, vec!["email".to_string()]);
}
#[test]
fn unique_sets_the_flag() {
let c = ci("create unique index ux on Customers (email)");
assert!(c.unique);
assert_eq!(c.name.as_deref(), Some("ux"));
// unnamed unique form too
let c2 = ci("create unique index on Customers (email)");
assert!(c2.unique);
assert_eq!(c2.name, None);
}
#[test]
fn if_not_exists_sets_the_flag() {
let c = ci("create index if not exists ix on Customers (email)");
assert!(c.if_not_exists);
assert_eq!(c.name.as_deref(), Some("ix"));
// combined with unique + unnamed + trailing semicolon
let c2 = ci("create unique index if not exists on Customers (email);");
assert!(c2.unique && c2.if_not_exists);
assert_eq!(c2.name, None);
}
#[test]
fn multi_column_index_parses() {
let c = ci("create index on Orders (CustId, Date)");
assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
}
#[test]
fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
// `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
// SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
assert!(matches!(
parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
.expect("parses"),
Command::SqlCreateTable { .. }
));
assert!(matches!(
parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
Command::SqlCreateIndex { .. }
));
assert!(matches!(
parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
.expect("parses"),
Command::SqlCreateIndex { unique: true, .. }
));
}
#[test]
fn simple_create_table_dsl_still_parses_in_advanced_mode() {
// The `create table … with pk …` DSL form falls back to the
// simple node even with two advanced `create` nodes present.
assert!(matches!(
parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
.expect("parses"),
Command::CreateTable { .. }
));
}
}
+16 -8
View File
@@ -585,16 +585,24 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&data::SQL_UPDATE, CommandCategory::Advanced),
(&data::SQL_DELETE, CommandCategory::Advanced),
// Shared entry word `create` (ADR-0035 §2): the simple
// `ddl::CREATE` (above) and this advanced SQL node. The
// dispatcher tries SQL first in advanced mode and falls back to
// the `create table … with pk …` DSL node when the SQL shape
// does not match — the `insert` precedent.
// `ddl::CREATE` (above) and these advanced SQL nodes. The
// dispatcher tries the advanced candidates first in advanced mode
// and falls back to the `create table … with pk …` DSL node when no
// SQL shape matches — the `insert` precedent. 4d adds
// SQL_CREATE_INDEX, so `create` now has *two* advanced nodes;
// `decide` tries both (`create table …` → SQL_CREATE_TABLE,
// `create [unique] index …` → SQL_CREATE_INDEX).
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
// Shared `drop` entry word: `ddl::DROP` (simple) and this advanced
// SQL node. SQL-first in advanced mode; `drop table [if exists] T`
// matches here while `drop column`/`drop relationship`/`drop index`
// fall back to the simple `drop` node.
(&ddl::SQL_CREATE_INDEX, CommandCategory::Advanced),
// Shared `drop` entry word: `ddl::DROP` (simple) and these advanced
// SQL nodes. SQL-first in advanced mode; `drop table [if exists] T`
// → SQL_DROP_TABLE, `drop index [if exists] <name>` → SQL_DROP_INDEX
// (4d — `drop` now has *two* advanced nodes; the dispatcher's
// `decide` tries all advanced candidates). `drop column`/`drop
// relationship`/`drop index on T(…)` fall back to the simple `drop`
// node.
(&ddl::SQL_DROP_TABLE, CommandCategory::Advanced),
(&ddl::SQL_DROP_INDEX, CommandCategory::Advanced),
];
/// Whether `entry` names an advanced-mode-only command (ADR-0030
+14
View File
@@ -41,6 +41,20 @@ pub enum AppEvent {
DslDropSkipped {
command: Command,
},
/// A SQL `DROP INDEX IF EXISTS` matched no index — a no-op
/// (ADR-0035 §4d). Renders an index-specific "doesn't exist —
/// skipped" note; no structure to show.
DslDropIndexSkipped {
command: Command,
},
/// A SQL `CREATE INDEX IF NOT EXISTS` matched an existing index name
/// — a no-op (ADR-0035 §4d). `name` is the resolved index name (the
/// auto-name is not on the command). Renders "already exists —
/// skipped"; no structure to show.
DslCreateIndexSkipped {
command: Command,
name: String,
},
/// A `show data` query succeeded.
DslDataSucceeded { command: Command, data: DataResult },
/// An `explain …` command succeeded (ADR-0028). `plan`
+7
View File
@@ -173,9 +173,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.ddl.create", &[]),
("help.ddl.sql_create_table", &[]),
("help.ddl.sql_drop_table", &[]),
("help.ddl.sql_create_index", &[]),
("help.ddl.sql_drop_index", &[]),
// Advanced-mode SQL CREATE TABLE / DROP TABLE no-op notes (ADR-0035 §4).
("ddl.create_skipped_exists", &["name"]),
("ddl.drop_skipped_absent", &["name"]),
// Advanced-mode SQL CREATE INDEX / DROP INDEX no-op notes (ADR-0035 §4d).
("ddl.create_index_skipped_exists", &["name"]),
("ddl.drop_index_skipped_absent", &["name"]),
("help.ddl.drop", &[]),
("help.ddl.add", &[]),
("help.ddl.rename", &[]),
@@ -248,6 +253,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.create_table", &[]),
("parse.usage.sql_create_table", &[]),
("parse.usage.sql_drop_table", &[]),
("parse.usage.sql_create_index", &[]),
("parse.usage.sql_drop_index", &[]),
("parse.usage.delete", &[]),
("parse.usage.drop_column", &[]),
("parse.usage.drop_constraint", &[]),
+13
View File
@@ -265,6 +265,11 @@ help:
[, primary key (<col>, ...)]) — create a table (advanced SQL)
sql_drop_table: |-
drop table [if exists] <T> — remove a table (advanced SQL)
sql_create_index: |-
create [unique] index [if not exists] [<name>] on <T> (<col>, ...)
— create an index (advanced SQL)
sql_drop_index: |-
drop index [if exists] <name> — remove an index (advanced SQL)
drop: |-
drop table <T> — remove a table
drop column [from] [table] <T>: <col> [--cascade] — remove a column
@@ -382,6 +387,12 @@ ddl:
# `drop table if exists <T>` where the table is absent: a no-op that
# succeeds with this note instead of a "doesn't exist" error.
drop_skipped_absent: "table '{name}' doesn't exist — skipped (no changes made)"
# `create [unique] index if not exists <name> …` where the index name
# already exists: a no-op that succeeds with this note (ADR-0035 §4d).
create_index_skipped_exists: "index '{name}' already exists — skipped (no changes made)"
# `drop index if exists <name>` where the index is absent: a no-op that
# succeeds with this note instead of a "doesn't exist" error.
drop_index_skipped_absent: "index '{name}' doesn't exist — skipped (no changes made)"
parse:
# Wrapper around chumsky's structural error message. The
@@ -452,6 +463,8 @@ parse:
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key], ... [, primary key (<col>, ...)])"
sql_drop_table: "drop table [if exists] <Name>"
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
sql_drop_index: "drop index [if exists] <Name>"
drop_table: "drop table <Name>"
drop_column: "drop column [from] [table] <Table>: <Name>"
drop_relationship: |-
+29 -2
View File
@@ -137,12 +137,15 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
}
// Indexes section (ADR-0025), shown only when the table
// carries at least one user-created index.
// carries at least one user-created index. A UNIQUE index is
// marked `[unique]` so a learner can tell a uniqueness-enforcing
// index from a performance-only one (ADR-0035 §4d).
if !desc.indexes.is_empty() {
out.push("Indexes:".to_string());
for index in &desc.indexes {
let unique = if index.unique { " [unique]" } else { "" };
out.push(format!(
" {} ({})",
" {} ({}){unique}",
index.name,
index.columns.join(", "),
));
@@ -797,6 +800,30 @@ mod tests {
let out = render_structure(&desc).join("\n");
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
}
#[test]
fn render_structure_marks_a_unique_index() {
// ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
// can tell it from a performance-only index.
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: "uidx_email".to_string(),
columns: vec!["Email".to_string()],
unique: true,
}],
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
}
#[test]
+8
View File
@@ -186,6 +186,14 @@ pub struct IndexSchema {
pub table: String,
/// The indexed columns, in index order.
pub columns: Vec<String>,
/// Whether this is a `UNIQUE` index (ADR-0035 §4d — advanced-mode
/// `CREATE UNIQUE INDEX`). The engine reports it via
/// `pragma_index_list`'s `unique` column, so it is read back rather
/// than stored in any `__rdbms_*` table; it is carried here so it
/// round-trips through `project.yaml` and survives `rebuild`.
/// Defaults to `false` when missing in older project files (the YAML
/// field is optional on read); `version` stays `1`.
pub unique: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+96
View File
@@ -74,6 +74,12 @@ fn write_index(out: &mut String, index: &IndexSchema) {
out.push_str(&quote_if_needed(col));
}
let _ = writeln!(out, "]");
// Emit `unique` only when true (ADR-0035 §4d), matching the
// column-`unique` convention — keeps pre-unique-index project files
// byte-stable on a no-op round-trip.
if index.unique {
let _ = writeln!(out, " unique: true");
}
}
fn write_table(out: &mut String, table: &TableSchema) {
@@ -300,6 +306,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
name: i.name,
table: i.table,
columns: i.columns,
unique: i.unique,
})
.collect();
Ok(SchemaSnapshot {
@@ -434,6 +441,11 @@ struct RawIndex {
name: String,
table: String,
columns: Vec<String>,
/// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project
/// files written before unique indexes existed omit it and default
/// to `false`.
#[serde(default)]
unique: bool,
}
#[cfg(test)]
@@ -479,6 +491,7 @@ mod tests {
name: "Orders_CustId_idx".to_string(),
table: "Orders".to_string(),
columns: vec!["CustId".to_string()],
unique: false,
}],
}
}
@@ -556,6 +569,89 @@ mod tests {
assert_eq!(parsed, original);
}
#[test]
fn unique_index_round_trips_through_yaml() {
// ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize
// → parse cycle. A plain index emits no `unique` line; a unique
// index emits `unique: true`.
let snap = SchemaSnapshot {
created_at: "2026-05-25T00:00:00Z".to_string(),
tables: vec![TableSchema {
name: "Customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "Email".to_string(),
user_type: Type::Text,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}],
relationships: Vec::new(),
indexes: vec![
IndexSchema {
name: "Customers_Email_uidx".to_string(),
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
unique: true,
},
IndexSchema {
name: "Customers_id_idx".to_string(),
table: "Customers".to_string(),
columns: vec!["id".to_string()],
unique: false,
},
],
};
let body = serialize_schema(&snap);
// The unique index emits the flag; the plain one does not.
assert!(body.contains("unique: true"), "yaml:\n{body}");
assert_eq!(
body.matches("unique: true").count(),
1,
"only the unique index carries the flag:\n{body}"
);
let parsed = parse_schema(&body).expect("parse schema");
assert_eq!(parsed, snap);
}
#[test]
fn index_without_unique_field_defaults_to_false() {
// Older project files (written before unique indexes) omit the
// `unique` field; the `#[serde(default)]` makes it `false`.
let body = "\
version: 1
project:
created_at: 2026-05-25T00:00:00Z
tables:
- name: Customers
primary_key: [id]
columns:
- { name: id, type: serial }
relationships: []
indexes:
- name: Customers_id_idx
table: Customers
columns: [id]
";
let parsed = parse_schema(body).expect("parse schema");
assert_eq!(parsed.indexes.len(), 1);
assert!(!parsed.indexes[0].unique);
}
#[test]
fn column_constraints_round_trip_through_yaml() {
// NOT NULL / UNIQUE / DEFAULT survive a serialize →
+55 -8
View File
@@ -29,9 +29,9 @@ use crate::action::Action;
use crate::app::App;
use crate::cli::Args;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, CreateOutcome, DataResult, Database, DbError,
DeleteResult, DropColumnResult, DropOutcome, InsertResult, QueryPlan, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, CreateIndexOutcome, CreateOutcome, DataResult,
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
QueryPlan, TableDescription, UpdateResult,
};
use crate::dsl::{Command, ColumnSpec};
use crate::dsl::walker::Severity;
@@ -1029,11 +1029,18 @@ 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
// Per-table indexes for the items panel (S2, ADR-0025).
// Carry uniqueness so the panel can mark a UNIQUE index
// (ADR-0035 §4d). Captured before `desc.columns` is
// consumed below.
let index_names: Vec<String> =
desc.indexes.iter().map(|i| i.name.clone()).collect();
let index_entries: Vec<crate::completion::IndexEntry> = desc
.indexes
.iter()
.map(|i| crate::completion::IndexEntry {
name: i.name.clone(),
unique: i.unique,
})
.collect();
let cols: Vec<TableColumn> = desc
.columns
.into_iter()
@@ -1055,7 +1062,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
})
.collect();
cache.table_columns.insert(name.clone(), cols);
cache.table_indexes.insert(name, index_names);
cache.table_indexes.insert(name, index_entries);
}
}
cache
@@ -1261,6 +1268,15 @@ fn spawn_dsl_dispatch(
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
command: command.clone(),
},
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
command: command.clone(),
},
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
}
}
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
command: command.clone(),
data,
@@ -1662,6 +1678,15 @@ enum CommandOutcome {
/// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
/// renders the "doesn't exist — skipped" note from the command.
SchemaDropSkipped,
/// A SQL `DROP INDEX IF EXISTS` that matched no index — a no-op
/// (ADR-0035 §4d). The App renders the "doesn't exist — skipped"
/// note from the command's index name.
SchemaDropIndexSkipped,
/// A SQL `CREATE INDEX IF NOT EXISTS` that matched an existing index
/// name — a no-op (ADR-0035 §4d). Carries the resolved index name
/// (the auto-name is unknown to the command) for the "already exists
/// — skipped" note.
SchemaCreateIndexSkipped(String),
Query(DataResult),
QueryPlan(QueryPlan),
Insert(InsertResult),
@@ -2048,6 +2073,28 @@ async fn execute_command_typed(
.drop_index(selector, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::SqlDropIndex { name, if_exists } => database
.sql_drop_index(name, if_exists, src)
.await
.map(|outcome| match outcome {
// Auto-show the now de-indexed table (ADR-0014), unlike
// SQL DROP TABLE whose table is gone.
DropIndexOutcome::Dropped(d) => CommandOutcome::Schema(Some(d)),
DropIndexOutcome::Skipped => CommandOutcome::SchemaDropIndexSkipped,
}),
Command::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
} => database
.sql_create_index(name, table, columns, unique, if_not_exists, src)
.await
.map(|outcome| match outcome {
CreateIndexOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
CreateIndexOutcome::Skipped(n) => CommandOutcome::SchemaCreateIndexSkipped(n),
}),
Command::AddConstraint {
table,
column,
+12 -3
View File
@@ -514,8 +514,11 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
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 {
// Mark a UNIQUE index so the panel distinguishes it from
// a performance-only index (ADR-0035 §4d).
let unique = if index.unique { " [unique]" } else { "" };
lines.push(Line::from(Span::styled(
format!(" {index}"),
format!(" {}{unique}", index.name),
Style::default().fg(theme.muted),
)));
}
@@ -1280,17 +1283,23 @@ mod tests {
#[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.
// with its index names indented beneath it. A UNIQUE index is
// marked `[unique]` (ADR-0035 §4d).
use crate::completion::IndexEntry;
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()],
vec![
IndexEntry { name: "idx_email".to_string(), unique: false },
IndexEntry { name: "uidx_login".to_string(), unique: true },
],
);
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}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
}
}