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