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:
@@ -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)]
|
||||
|
||||
@@ -74,6 +74,12 @@ fn write_index(out: &mut String, index: &IndexSchema) {
|
||||
out.push_str("e_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 →
|
||||
|
||||
Reference in New Issue
Block a user