From 871d898678c5d845cd8b9de54d7ef9839ec02c11 Mon Sep 17 00:00:00 2001 From: Pavel Hegler Date: Tue, 1 Sep 2026 19:02:08 +0200 Subject: [PATCH 1/2] migrate/supabase: translate multi-column UNIQUE(a,b) instead of silently dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_table_constraint only handled single-column UNIQUE; a table-level UNIQUE(a, b) fell through and vanished, so the migrated app permitted duplicate rows the source forbade — violating the migrator's never-silently-drop promise (docs/ai/19-migrate-supabase.md:7). Capture multi-column groups into PgTable.composite_uniques and emit them as the entity's composite `unique` (issue #115), mapping source columns to the entity's field / belongs_to fk-column names (the shape JC0559 accepts). If a group's column dropped out (e.g. an unmappable-type column), raise a blocking gap for that constraint instead of dropping it — the promise holds either way. Covers both the inline CREATE TABLE constraint and the pg_dump ALTER TABLE ... ADD CONSTRAINT ... UNIQUE(a,b) form (both route through apply_table_constraint). --- .../jerrycan/src/platform/migrate/entities.rs | 99 ++++++++++++++++++- .../jerrycan/src/platform/migrate/pgmodel.rs | 30 +++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/crates/jerrycan/src/platform/migrate/entities.rs b/crates/jerrycan/src/platform/migrate/entities.rs index 7ba120a8..77d4efb0 100644 --- a/crates/jerrycan/src/platform/migrate/entities.rs +++ b/crates/jerrycan/src/platform/migrate/entities.rs @@ -229,6 +229,41 @@ fn build_one( return None; } + // Table-level composite `UNIQUE(a, b, …)` (#115): translate each group whose + // columns all survive as a declared field or a belongs_to fk column — the same + // shape JC0559 accepts, so the round-trip is validation-clean. A column that + // dropped out (e.g. an unmappable-type column) can't be indexed, so raise a + // gap for that constraint rather than silently dropping it (never guess). + let mut unique = Vec::new(); + for group in &table.composite_uniques { + let missing: Vec<&str> = group + .iter() + .filter(|col| { + !fields.iter().any(|f| &f.name == *col) + && !belongs_to.iter().any(|b| &b.fk_column() == *col) + }) + .map(String::as_str) + .collect(); + if missing.is_empty() { + unique.push(group.clone()); + } else { + gaps.push(GapItem { + kind: GapKind::UnmappedType, + source: format!("{key} unique({})", group.join(", ")), + location: format!("schema.sql:{}", table.line), + reason: format!( + "composite UNIQUE column(s) `{}` did not survive translation (dropped/unmapped), so the constraint can't be reproduced as a composite `unique` index", + missing.join(", ") + ), + original: format!("unique ({})", group.join(", ")), + suggested: + "map the missing column(s) to a field, then add the group to the entity's `unique`, or enforce the invariant in a handler" + .into(), + severity: Severity::Blocking, + }); + } + } + // Preserve the source table name losslessly: pin `table` only when the // default (snake_case + pluralization) would NOT reproduce it, so a clean // name stays override-free while an irregular one round-trips exactly. @@ -239,7 +274,7 @@ fn build_one( table, belongs_to, public_read: false, - unique: vec![], + unique, fields, }) } @@ -345,6 +380,68 @@ create table public.order_items ( assert!(!item.fields.iter().any(|f| f.name == "location")); } + #[test] + fn composite_unique_survives_translation_and_an_unrepresentable_one_gaps() { + // WHY: docs/ai/19 promises the migrator never silently drops what it can't + // translate. A multi-column UNIQUE(a, b) IS translatable — jerrycan carries + // it as the entity's composite `unique` (#115). It must survive; a group over + // a dropped (unmappable-type) column must gap, not vanish. + let schema = r#" +create table public.memberships ( + id uuid primary key, + org_id uuid not null, + user_id uuid not null, + unique (org_id, user_id) +); +create table public.pins ( + id uuid primary key, + label text not null, + spot point, + unique (label, spot) +); +"#; + let db = PgDatabase::fold(&parse::split_and_parse(schema)); + let out = build_entities(&db); + + let membership = out + .entities + .iter() + .find(|(_, e)| e.name == "Membership") + .map(|(_, e)| e) + .unwrap(); + assert_eq!( + membership.unique, + vec![vec!["org_id".to_string(), "user_id".to_string()]], + "UNIQUE(org_id, user_id) must survive as the entity's composite `unique`" + ); + // Each column names a declared field (the shape JC0559 accepts), so the + // group round-trips to a buildable `CREATE UNIQUE INDEX`. + for col in &membership.unique[0] { + assert!(membership.fields.iter().any(|f| &f.name == col)); + } + + // `spot point` is an unmappable type → dropped field + its composite unique + // over it can't be built, so the constraint gaps rather than silently drops. + let pin = out + .entities + .iter() + .find(|(_, e)| e.name == "Pin") + .map(|(_, e)| e) + .unwrap(); + assert!( + pin.unique.is_empty(), + "a group over a dropped column must not be emitted" + ); + assert!( + out.gaps.iter().any(|g| g.kind + == crate::platform::migrate::gaps::GapKind::UnmappedType + && g.source.contains("unique(label, spot)") + && g.reason.contains("spot")), + "the unrepresentable composite unique must raise a gap: {:?}", + out.gaps + ); + } + #[test] fn naming_helpers_are_deterministic() { assert_eq!(entity_name("order_items"), "OrderItem"); diff --git a/crates/jerrycan/src/platform/migrate/pgmodel.rs b/crates/jerrycan/src/platform/migrate/pgmodel.rs index 17a5489e..f4872f65 100644 --- a/crates/jerrycan/src/platform/migrate/pgmodel.rs +++ b/crates/jerrycan/src/platform/migrate/pgmodel.rs @@ -34,6 +34,11 @@ pub struct PgTable { pub columns: Vec, pub pk: Vec, pub fks: Vec, + /// Table-level multi-column `UNIQUE(a, b, …)` constraints (≥2 columns). + /// Single-column uniqueness stays on the column (`PgColumn.unique`); these + /// carry the composite invariant a lone column cannot, emitted as the + /// entity's composite `unique` (issue #115). + pub composite_uniques: Vec>, pub rls_enabled: bool, pub line: usize, } @@ -504,11 +509,26 @@ fn apply_table_constraint(table: &mut PgTable, constraint: &TableConstraint) { } } TableConstraint::Unique(u) => { - if u.columns.len() == 1 - && let Some(name) = ident_of_expr(&u.columns[0].column.expr) - && let Some(col) = table.columns.iter_mut().find(|col| col.name == name) - { - col.unique = true; + if u.columns.len() == 1 { + if let Some(name) = ident_of_expr(&u.columns[0].column.expr) + && let Some(col) = table.columns.iter_mut().find(|col| col.name == name) + { + col.unique = true; + } + } else { + // Multi-column `UNIQUE(a, b, …)`: a composite invariant no single + // column carries — captured for the entity's composite `unique` + // (#115), never silently dropped. A UNIQUE constraint's columns are + // always plain identifiers, so all resolve; capture only a fully + // resolved group. + let cols: Vec = u + .columns + .iter() + .filter_map(|c| ident_of_expr(&c.column.expr)) + .collect(); + if cols.len() == u.columns.len() && cols.len() >= 2 { + table.composite_uniques.push(cols); + } } } TableConstraint::ForeignKey(fk) => { From 7f1b1675d6d40d755e20eedbc8feb810379288bf Mon Sep 17 00:00:00 2001 From: Pavel Hegler Date: Tue, 1 Sep 2026 22:24:40 +0200 Subject: [PATCH 2/2] migrate/supabase: capture multi-column CREATE UNIQUE INDEX into composite unique; gap partial/expression unique indexes (M1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CreateIndex arm only handled single-column indexes, so a multi-column `CREATE UNIQUE INDEX idx ON t (a, b)` — a separate parse node from the table-level UNIQUE(a, b) fixed in the parent commit, and the form pg_dump commonly emits — fell through and vanished. Same silent data-integrity loss: the migrated app permitted duplicate rows the source forbade. Resolve the columns and push the group into PgTable.composite_uniques so the existing entities.rs emit translates it (and gaps it if a column didn't survive). Non-unique multi-column indexes are lookup hints, not constraints, and stay ignored. Also stop guessing on a unique index that cannot be reproduced as a plain composite `unique`: an expression index (no column to hang `unique` on) or a partial `WHERE` index. The partial case was worse than a drop — a single-column partial unique index set `unique` unconditionally, inventing a constraint the source doesn't have, so rows legal in Supabase would 409 in the migrated app. Both now raise a blocking gap naming the index and keep the plain lookup index, per the never-silently-drop / never-guess promise (docs/ai/19-migrate-supabase.md:7). --- .../jerrycan/src/platform/migrate/entities.rs | 77 +++++++++++++++++++ .../jerrycan/src/platform/migrate/pgmodel.rs | 52 +++++++++++-- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/crates/jerrycan/src/platform/migrate/entities.rs b/crates/jerrycan/src/platform/migrate/entities.rs index 77d4efb0..26fef6e3 100644 --- a/crates/jerrycan/src/platform/migrate/entities.rs +++ b/crates/jerrycan/src/platform/migrate/entities.rs @@ -264,6 +264,25 @@ fn build_one( } } + // A `CREATE UNIQUE INDEX` the translator can't reproduce as a plain composite + // `unique` (expression columns, or a partial `WHERE` predicate) still enforced + // uniqueness in the source: gap it rather than drop it (never guess). + for idx in &table.unrepresentable_uniques { + gaps.push(GapItem { + kind: GapKind::UnmappedType, + source: format!("{key} unique index {}", idx.name), + location: format!("schema.sql:{}", idx.line), + reason: + "an expression or partial (`WHERE`) UNIQUE INDEX constrains rows that a plain composite `unique` cannot express, so it can't be reproduced automatically" + .into(), + original: idx.sql.clone(), + suggested: + "enforce the invariant in a handler, or keep the raw index in a hand-written migration" + .into(), + severity: Severity::Blocking, + }); + } + // Preserve the source table name losslessly: pin `table` only when the // default (snake_case + pluralization) would NOT reproduce it, so a clean // name stays override-free while an irregular one round-trips exactly. @@ -442,6 +461,64 @@ create table public.pins ( ); } + #[test] + fn multi_column_unique_index_survives_and_a_partial_one_gaps() { + // WHY: pg_dump commonly emits uniqueness as `CREATE UNIQUE INDEX` — a + // different parse node than a table-level `UNIQUE(a, b)`, so it needs its + // own translation or the constraint vanishes (docs/ai/19: never silently + // drop). A partial (`WHERE`) unique must gap rather than be dropped OR + // promoted to an unconditional `unique`, which would reject rows the + // source allows. A non-unique index is a lookup hint, not a constraint. + fn find<'a>(out: &'a BuildResult, name: &str) -> &'a Entity { + out.entities + .iter() + .find(|(_, e)| e.name == name) + .map(|(_, e)| e) + .unwrap() + } + let schema = r#" +create table public.memberships (id uuid primary key, org_id uuid not null, user_id uuid not null); +create unique index memberships_org_user on public.memberships (org_id, user_id); +create table public.profiles (id uuid primary key, username text not null, deleted_at timestamptz); +create unique index profiles_username_live on public.profiles (username) where deleted_at is null; +create table public.notes (id uuid primary key, title text not null, body text not null); +create index notes_title_body on public.notes (title, body); +"#; + let db = PgDatabase::fold(&parse::split_and_parse(schema)); + let out = build_entities(&db); + + assert_eq!( + find(&out, "Membership").unique, + vec![vec!["org_id".to_string(), "user_id".to_string()]], + "a multi-column CREATE UNIQUE INDEX must survive as the composite `unique`" + ); + + let username = find(&out, "Profile") + .fields + .iter() + .find(|f| f.name == "username") + .unwrap(); + assert!( + !username.unique, + "a partial unique index constrains a subset of rows — an unconditional `unique` would over-constrain" + ); + assert!(username.index, "it is still a real lookup index"); + assert!( + out.gaps + .iter() + .any(|g| g.source.contains("profiles_username_live") + && g.severity == Severity::Blocking), + "the partial unique index must gap, not vanish: {:?}", + out.gaps + ); + + // A non-unique composite index carries no constraint: unchanged, no gap. + let note = find(&out, "Note"); + assert!(note.unique.is_empty()); + assert!(!note.fields.iter().any(|f| f.index)); + assert!(!out.gaps.iter().any(|g| g.source.contains("notes"))); + } + #[test] fn naming_helpers_are_deterministic() { assert_eq!(entity_name("order_items"), "OrderItem"); diff --git a/crates/jerrycan/src/platform/migrate/pgmodel.rs b/crates/jerrycan/src/platform/migrate/pgmodel.rs index f4872f65..bb96b0c5 100644 --- a/crates/jerrycan/src/platform/migrate/pgmodel.rs +++ b/crates/jerrycan/src/platform/migrate/pgmodel.rs @@ -39,6 +39,12 @@ pub struct PgTable { /// carry the composite invariant a lone column cannot, emitted as the /// entity's composite `unique` (issue #115). pub composite_uniques: Vec>, + /// `CREATE UNIQUE INDEX` statements whose uniqueness cannot be reproduced as + /// a jerrycan `unique` — an expression index (no column to hang it on) or a + /// partial `WHERE` index (constrains a subset of rows, so an unconditional + /// `unique` would reject rows the source allows). Carried so the translator + /// gaps them instead of dropping a constraint the source enforced. + pub unrepresentable_uniques: Vec, pub rls_enabled: bool, pub line: usize, } @@ -245,14 +251,44 @@ impl PgDatabase { } Statement::CreateIndex(ci) => { let table = object_name(&ci.table_name); - if ci.columns.len() == 1 - && let Some(col) = ident_of_expr(&ci.columns[0].column.expr) - && let Some(t) = self.tables.get_mut(&table) - && let Some(column) = t.columns.iter_mut().find(|c| c.name == col) - { - column.indexed = true; - if ci.unique { - column.unique = true; + let cols: Vec = ci + .columns + .iter() + .filter_map(|c| ident_of_expr(&c.column.expr)) + .collect(); + // A UNIQUE index is a constraint, not a lookup hint: it must be + // translated or gapped, never dropped. Reproducible as jerrycan + // uniqueness only when every column is a plain identifier (an + // expression index has no column to hang `unique` on) and the + // index is unconditional (a partial `WHERE` index constrains a + // subset of rows, so an unconditional `unique` would reject rows + // the source allows — over-constraining is guessing too). + let unique_reproducible = + ci.unique && cols.len() == ci.columns.len() && ci.predicate.is_none(); + if let Some(t) = self.tables.get_mut(&table) { + if ci.unique && !unique_reproducible { + t.unrepresentable_uniques.push(PgRawObject { + name: ci + .name + .as_ref() + .map_or_else(|| "(unnamed)".to_string(), object_name), + sql: sql.to_string(), + line, + }); + } + if cols.len() == 1 + && let Some(column) = t.columns.iter_mut().find(|c| c.name == cols[0]) + { + column.indexed = true; + if unique_reproducible { + column.unique = true; + } + } else if unique_reproducible && cols.len() > 1 { + // Multi-column `CREATE UNIQUE INDEX ON t (a, b)`: the same + // composite invariant a table-level `UNIQUE(a, b)` carries + // (#115), just a different parse node. pg_dump commonly + // emits uniques in this form. + t.composite_uniques.push(cols); } } }