Skip to content

Commit 192f41c

Browse files
committed
fix(db): #1464 (Phase B) composite-aware engine — shared_schema FK/UNIQUE parity
The #1431 migration engine's snapshot was lossy: FKs per-column ({col: table}), composite UNIQUE(tenant_id,id) flattened to a column set, indexes non-deduped. So a shared_schema intra-tenant ref produced a simple (fk)→Parent(id) FK + UNIQUE(id) + a bogus UNIQUE(tenant_id), and composite list indexes could double-emit (DuplicateTable at upgrade). schema_snapshot.project_schema now reads table-level ForeignKeyConstraints (cols→reftable+refcols) + UniqueConstraint column-tuples + a deduped index set (extracted to _table_fks/_table_uniques/_table_index_keys helpers, keeping CC<15); schema_diff op models carry composite columns (+ _coerce_fks/_coerce_uniques backward-compat for legacy embedded snapshots); schema_render emits the composite FK/UNIQUE (was hard-coded ["id"]). Cross-tenant referential integrity (tenant_id, fk)→Parent(tenant_id, id) is now enforced by the migration-built schema, not just create_all. Removed the #1464 xfail; baseline + new incremental-revision parity oracles GREEN on real PG (now composite-aware via an independent pg_catalog read). Adversarial review caught a CRITICAL FK-name mismatch (sa_schema fk_{entity}_{field} vs engine fk_{entity}_{partition_key}_{field}) → aligned sa_schema so both paths name composite FKs identically; + name-alignment regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e760dc4 commit 192f41c

16 files changed

Lines changed: 346 additions & 150 deletions

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,4 @@ Example: `examples/ops_dashboard` has working `bar_chart` (FK `group_by: system`
374374
- **KG re-seeding**: `ensure_seeded()` checks a version key; bump it in `seed.py` when TOML data changes.
375375

376376
---
377-
**Version**: 0.85.1 | **Python**: 3.12+ | **Status**: Production Ready
377+
**Version**: 0.85.2 | **Python**: 3.12+ | **Status**: Production Ready

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12-
## [0.85.1] - 2026-06-24
12+
## [0.85.2] - 2026-06-24
13+
14+
### Fixed
15+
- **#1464 (Phase B — engine fix) `db baseline` now reproduces shared_schema composite tenant-scoped constraints with full `create_all` parity.** The #1431 migration engine's snapshot model was lossy — FKs stored per-column (`{col: table}`, no referred columns), composite `UNIQUE(tenant_id, id)` flattened to a column set, indexes in a non-deduped list — so a shared_schema intra-tenant ref produced a *simple* `(fk) → Parent(id)` FK + `UNIQUE(id)` + a bogus, unusable `UNIQUE(tenant_id)` (one row per tenant), and composite "list" indexes could double-emit (`DuplicateTable` at upgrade). `schema_snapshot.project_schema` now reads table-level `ForeignKeyConstraint`s (constrained cols → referred table + referred cols) and `UniqueConstraint` column-tuples, and de-duplicates index keys; `schema_diff` op models (`AddForeignKey`/`DropForeignKey`/`AddUnique`/`DropUnique`) carry composite columns; `schema_render` emits the composite FK/UNIQUE (was hard-coded `["id"]`). Legacy embedded snapshots are upgraded in-place (`_coerce_fks`/`_coerce_uniques`) so an incremental `db revision` against a pre-#1464 baseline stays correct. **Cross-tenant referential integrity (`(tenant_id, fk) → Parent(tenant_id, id)`) is now structurally enforced by the migration-built schema, not just by `create_all`.** Verified on real Postgres (baseline + incremental-revision parity oracles, now composite-aware: they compare FK *structure* + UNIQUE column-tuples via an independent `pg_catalog` read). Adversarially reviewed: a CRITICAL constraint-name mismatch was caught and fixed — `sa_schema` named the composite FK `fk_{entity}_{field}` while the engine named it `fk_{entity}_{partition_key}_{field}`; both now agree, so the two provisioning paths produce byte-identical names (an engine drop/downgrade against a `create_all` DB would otherwise target a non-existent name).
16+
17+
### Agent Guidance
18+
- **The migration engine's schema snapshot is composite-aware.** `project_schema` FKs are `[(constrained_cols, ref_table, ref_cols), ...]` and uniques are `[(col, ...), ...]` (tuples, embedded via `pprint`). When adding constraint handling, go through table-level `ForeignKeyConstraint`/`UniqueConstraint`, not per-column `.foreign_keys`/`col.unique`. Composite constraint names join all columns (`fk_{table}_{c1}_{c2}`); keep `sa_schema` (create_all) and `schema_render` (engine) naming aligned or the parity oracle's intent (byte-identical schemas) is lost.
1319

1420
### Added
1521
- **#1464 (Phase A — reproducer) engine-baseline parity oracle now detects shared_schema composite-constraint divergence.** The `db baseline ≡ create_all` parity gate (`tests/integration/test_engine_baseline_parity_pg.py`) was blind to composite tenant-scoped constraints: it compared FKs by `column→table` (not structure) and omitted UNIQUE constraints entirely, both via the engine's own lossy snapshot. Added an independent `pg_catalog`-based introspection (`_pg_constraint_shapes`) that captures composite FK structure (`(constrained cols) → table(referred cols)`) and UNIQUE column-tuples, plus an inline `shared_schema_intra_fk` corpus fixture (Workspace tenant-root; `Project.owner → Member` intra-tenant ref). The fixture reproduces #1464 — the engine baseline emits a simple `(owner) → Member` FK + `UNIQUE(id)` + bogus `UNIQUE(tenant_id)` instead of the composite `(tenant_id, owner) → Member(tenant_id, id)` + `UNIQUE(tenant_id, id)` — and is marked `xfail(strict=True)` so CI stays green while the gap is tracked in-repo. The engine fix (Phase B) flips it to pass.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# DAZZLE Development Roadmap
22

33
**Last Updated**: 2026-06-16
4-
**Current Version**: v0.85.1
4+
**Current Version**: v0.85.2
55

66
For past releases, see [CHANGELOG.md](CHANGELOG.md).
77

homebrew/dazzle.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ class Dazzle < Formula
1010

1111
desc "DSL-first application framework with LLM-assisted development"
1212
homepage "https://github.com/manwithacat/dazzle"
13-
version "0.85.1"
13+
version "0.85.2"
1414
license "MIT"
1515

16-
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.85.1.tar.gz"
16+
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.85.2.tar.gz"
1717
sha256 "PLACEHOLDER_SOURCE_SHA256"
1818

1919
# pydantic-core requires Rust to build from source, so use pre-built wheels

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "dazzle-dsl"
7-
version = "0.85.1"
7+
version = "0.85.2"
88
description = "DAZZLE — declarative SaaS framework with built-in compliance (SOC 2, ISO 27001), provable RBAC, and graph features"
99
readme = "README.md"
1010
requires-python = ">=3.12"

src/dazzle/db/schema_diff.py

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ class AddTable:
2424

2525
table: str
2626
columns: dict[str, Any]
27-
fks: dict[str, str]
27+
# #1464: composite-aware. fks = [(cols, ref_table, ref_cols), ...];
28+
# uniques = [(col, ...), ...]. indexes = ["col[,col]", ...] (comma-joined).
29+
fks: list[Any]
2830
indexes: list[str]
29-
uniques: list[str]
31+
uniques: list[Any]
3032

3133

3234
@dataclass(frozen=True)
@@ -84,20 +86,22 @@ class AlterColumn:
8486

8587
@dataclass(frozen=True)
8688
class AddForeignKey:
87-
"""Add a foreign key constraint."""
89+
"""Add a (possibly composite) foreign key constraint (#1464)."""
8890

8991
table: str
90-
column: str
92+
columns: tuple[str, ...]
9193
ref_table: str
94+
ref_columns: tuple[str, ...]
9295

9396

9497
@dataclass(frozen=True)
9598
class DropForeignKey:
96-
"""Drop a foreign key constraint."""
99+
"""Drop a (possibly composite) foreign key constraint (#1464)."""
97100

98101
table: str
99-
column: str
102+
columns: tuple[str, ...]
100103
ref_table: str
104+
ref_columns: tuple[str, ...]
101105

102106

103107
@dataclass(frozen=True)
@@ -118,18 +122,18 @@ class DropIndex:
118122

119123
@dataclass(frozen=True)
120124
class AddUnique:
121-
"""Add a unique constraint on a column."""
125+
"""Add a (possibly composite) unique constraint (#1464)."""
122126

123127
table: str
124-
column: str
128+
columns: tuple[str, ...]
125129

126130

127131
@dataclass(frozen=True)
128132
class DropUnique:
129-
"""Drop a unique constraint on a column."""
133+
"""Drop a (possibly composite) unique constraint (#1464)."""
130134

131135
table: str
132-
column: str
136+
columns: tuple[str, ...]
133137

134138

135139
SchemaOp = (
@@ -155,8 +159,11 @@ class DropUnique:
155159
#: ColSnap keys: type (str), nullable (bool), default (str|None), pk (bool)
156160
ColSnap = dict[str, Any]
157161

158-
#: TableSnap keys: columns (dict[str, ColSnap]), fks (dict[str, str]),
159-
#: indexes (list[str]), uniques (list[str])
162+
#: TableSnap keys: columns (dict[str, ColSnap]),
163+
#: fks (list[(cols, ref_table, ref_cols)] — composite-aware, #1464; legacy
164+
#: dict[col, table] is accepted + upgraded by _coerce_fks),
165+
#: uniques (list[(col, ...)] — legacy list[str] upgraded by _coerce_uniques),
166+
#: indexes (list[str] — comma-joined columns)
160167
TableSnap = dict[str, Any]
161168

162169
#: Snapshot: table-name → TableSnap
@@ -299,6 +306,32 @@ def _diff_columns(
299306
return add_ops, alter_ops, drop_ops
300307

301308

309+
def _coerce_fks(snap: TableSnap) -> set[tuple[tuple[str, ...], str, tuple[str, ...]]]:
310+
"""Normalize a snapshot's ``fks`` to the composite set shape (#1464).
311+
312+
Accepts both the current composite shape (``[(cols, ref_table, ref_cols), ...]``)
313+
and the legacy per-column shape (``{col: ref_table}``) embedded in pre-#1464
314+
baselines — the latter upgrades to single-column FKs referencing the PK ``id``
315+
(what the old engine always assumed), so an incremental ``db revision`` against
316+
an old baseline doesn't see a spurious drop+re-add of every FK.
317+
"""
318+
raw = snap.get("fks", [])
319+
if isinstance(raw, dict): # legacy {col: ref_table}
320+
return {((col,), tbl, ("id",)) for col, tbl in raw.items()}
321+
return {(tuple(cols), tbl, tuple(refcols)) for cols, tbl, refcols in raw}
322+
323+
324+
def _coerce_uniques(snap: TableSnap) -> set[tuple[str, ...]]:
325+
"""Normalize a snapshot's ``uniques`` to a set of column tuples (#1464).
326+
327+
Accepts the current shape (``[(col, ...), ...]``) and the legacy flat shape
328+
(``["col", ...]`` — single-column names), upgrading each bare name to a
329+
one-column tuple.
330+
"""
331+
raw = snap.get("uniques", [])
332+
return {(c,) if isinstance(c, str) else tuple(c) for c in raw}
333+
334+
302335
def _diff_constraints(
303336
curr_tname: str,
304337
prev_snap: TableSnap,
@@ -313,12 +346,16 @@ def _diff_constraints(
313346
add_ops: list[SchemaOp] = []
314347
drop_ops: list[SchemaOp] = []
315348

316-
prev_fk_set = set(prev_snap.get("fks", {}).items())
317-
curr_fk_set = set(curr_snap.get("fks", {}).items())
318-
for col, ref in sorted(curr_fk_set - prev_fk_set):
319-
add_ops.append(AddForeignKey(table=curr_tname, column=col, ref_table=ref))
320-
for col, ref in sorted(prev_fk_set - curr_fk_set):
321-
drop_ops.append(DropForeignKey(table=curr_tname, column=col, ref_table=ref))
349+
prev_fk_set = _coerce_fks(prev_snap)
350+
curr_fk_set = _coerce_fks(curr_snap)
351+
for cols, ref, refcols in sorted(curr_fk_set - prev_fk_set):
352+
add_ops.append(
353+
AddForeignKey(table=curr_tname, columns=cols, ref_table=ref, ref_columns=refcols)
354+
)
355+
for cols, ref, refcols in sorted(prev_fk_set - curr_fk_set):
356+
drop_ops.append(
357+
DropForeignKey(table=curr_tname, columns=cols, ref_table=ref, ref_columns=refcols)
358+
)
322359

323360
prev_indexes = set(prev_snap.get("indexes", []))
324361
curr_indexes = set(curr_snap.get("indexes", []))
@@ -327,12 +364,12 @@ def _diff_constraints(
327364
for col in sorted(prev_indexes - curr_indexes):
328365
drop_ops.append(DropIndex(table=curr_tname, column=col))
329366

330-
prev_uniques = set(prev_snap.get("uniques", []))
331-
curr_uniques = set(curr_snap.get("uniques", []))
332-
for col in sorted(curr_uniques - prev_uniques):
333-
add_ops.append(AddUnique(table=curr_tname, column=col))
334-
for col in sorted(prev_uniques - curr_uniques):
335-
drop_ops.append(DropUnique(table=curr_tname, column=col))
367+
prev_uniques = _coerce_uniques(prev_snap)
368+
curr_uniques = _coerce_uniques(curr_snap)
369+
for cols in sorted(curr_uniques - prev_uniques):
370+
add_ops.append(AddUnique(table=curr_tname, columns=cols))
371+
for cols in sorted(prev_uniques - curr_uniques):
372+
drop_ops.append(DropUnique(table=curr_tname, columns=cols))
336373

337374
return add_ops, drop_ops
338375

@@ -398,21 +435,25 @@ def diff(
398435
for tname in sorted(curr_tables - prev_tables):
399436
if tname not in table_prev_name:
400437
tsnap = curr[tname]
438+
fk_specs = sorted(_coerce_fks(tsnap))
439+
unique_specs = sorted(_coerce_uniques(tsnap))
401440
add_tables.append(
402441
AddTable(
403442
table=tname,
404443
columns=dict(tsnap.get("columns", {})),
405-
fks=dict(tsnap.get("fks", {})),
444+
fks=fk_specs,
406445
indexes=list(tsnap.get("indexes", [])),
407-
uniques=list(tsnap.get("uniques", [])),
446+
uniques=unique_specs,
408447
)
409448
)
410-
for col, ref in sorted(tsnap.get("fks", {}).items()):
411-
add_table_constraints.append(AddForeignKey(table=tname, column=col, ref_table=ref))
449+
for cols, ref, refcols in fk_specs:
450+
add_table_constraints.append(
451+
AddForeignKey(table=tname, columns=cols, ref_table=ref, ref_columns=refcols)
452+
)
412453
for idx_cols in sorted(tsnap.get("indexes", [])):
413454
add_table_constraints.append(AddIndex(table=tname, column=idx_cols))
414-
for col in sorted(tsnap.get("uniques", [])):
415-
add_table_constraints.append(AddUnique(table=tname, column=col))
455+
for cols in unique_specs:
456+
add_table_constraints.append(AddUnique(table=tname, columns=cols))
416457

417458
# --- 4. Diff columns + constraints for common/renamed table pairs -------
418459
add_details: list[SchemaOp] = []

src/dazzle/db/schema_render.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ def _col_snap_to_sa_column(name: str, snap: ColSnap) -> sa.Column[Any]:
187187
# ---------------------------------------------------------------------------
188188

189189

190-
def _fk_name(table: str, column: str) -> str:
191-
return f"fk_{table}_{column}"
190+
def _fk_name(table: str, columns: tuple[str, ...]) -> str:
191+
return f"fk_{table}_{'_'.join(columns)}"
192192

193193

194194
def _idx_name(table: str, column: str) -> str:
@@ -202,8 +202,8 @@ def _idx_columns(column: str) -> list[str]:
202202
return column.split(",")
203203

204204

205-
def _uq_name(table: str, column: str) -> str:
206-
return f"uq_{table}_{column}"
205+
def _uq_name(table: str, columns: tuple[str, ...]) -> str:
206+
return f"uq_{table}_{'_'.join(columns)}"
207207

208208

209209
# ---------------------------------------------------------------------------
@@ -519,13 +519,13 @@ def _type_change_execute_op(
519519
def _render_add_fk(
520520
op: AddForeignKey,
521521
) -> tuple[aops.MigrateOperation, aops.MigrateOperation]:
522-
name = _fk_name(op.table, op.column)
522+
name = _fk_name(op.table, op.columns)
523523
create_op = aops.CreateForeignKeyOp(
524524
name,
525525
op.table,
526526
op.ref_table,
527-
[op.column],
528-
["id"],
527+
list(op.columns),
528+
list(op.ref_columns),
529529
)
530530
drop_op = aops.DropConstraintOp(name, op.table, type_="foreignkey")
531531
return create_op, drop_op
@@ -534,14 +534,14 @@ def _render_add_fk(
534534
def _render_drop_fk(
535535
op: DropForeignKey,
536536
) -> tuple[aops.MigrateOperation, aops.MigrateOperation]:
537-
name = _fk_name(op.table, op.column)
537+
name = _fk_name(op.table, op.columns)
538538
drop_op = aops.DropConstraintOp(name, op.table, type_="foreignkey")
539539
recreate_op = aops.CreateForeignKeyOp(
540540
name,
541541
op.table,
542542
op.ref_table,
543-
[op.column],
544-
["id"],
543+
list(op.columns),
544+
list(op.ref_columns),
545545
)
546546
return drop_op, recreate_op
547547

@@ -567,18 +567,18 @@ def _render_drop_index(
567567
def _render_add_unique(
568568
op: AddUnique,
569569
) -> tuple[aops.MigrateOperation, aops.MigrateOperation]:
570-
name = _uq_name(op.table, op.column)
571-
create_op = aops.CreateUniqueConstraintOp(name, op.table, [op.column])
570+
name = _uq_name(op.table, op.columns)
571+
create_op = aops.CreateUniqueConstraintOp(name, op.table, list(op.columns))
572572
drop_op = aops.DropConstraintOp(name, op.table, type_="unique")
573573
return create_op, drop_op
574574

575575

576576
def _render_drop_unique(
577577
op: DropUnique,
578578
) -> tuple[aops.MigrateOperation, aops.MigrateOperation]:
579-
name = _uq_name(op.table, op.column)
579+
name = _uq_name(op.table, op.columns)
580580
drop_op = aops.DropConstraintOp(name, op.table, type_="unique")
581-
recreate_op = aops.CreateUniqueConstraintOp(name, op.table, [op.column])
581+
recreate_op = aops.CreateUniqueConstraintOp(name, op.table, list(op.columns))
582582
return drop_op, recreate_op
583583

584584

0 commit comments

Comments
 (0)