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