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
+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,