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