Skip to content

feat(#19): composite uniqueness via Models.UniqueConstraint (add-only) - #160

Merged
PingoLee merged 1 commit into
mainfrom
fix/19-unique-together
Jul 15, 2026
Merged

feat(#19): composite uniqueness via Models.UniqueConstraint (add-only)#160
PingoLee merged 1 commit into
mainfrom
fix/19-unique-together

Conversation

@PingoLee

Copy link
Copy Markdown
Owner

Summary

Implements Django-style composite/multi-column uniqueness (unique_together) for regular models — issue #19. Single-column unique=true was already supported; this adds uniqueness spanning two or more columns.

Spelled as named Models.UniqueConstraint objects (the Django 2.2+ / SQLAlchemy form) declared via a model-level constraints= kwarg:

Constructor_engine = Models.Model("constructor_engines",
  id = Models.IDField(),
  constructorid = Models.ForeignKey(Constructor, pk_field="constructorid", on_delete="CASCADE"),
  year = Models.IntegerField(),
  engine_manufacturer = Models.CharField(max_length=50),
  constraints = [
    Models.UniqueConstraint(fields=("constructorid", "year"), name="uniq_constructor_year"),
  ],
)

At migration time each constraint becomes a CREATE UNIQUE INDEX — byte-identical on PostgreSQL and SQLite:

CREATE UNIQUE INDEX IF NOT EXISTS "uniq_constructor_year"
  ON "constructor_engines" ("constructorid", "year");

Design

  • Named-constraint API (not the legacy tuple) — custom index names now, room for conditional/partial uniqueness later. Fits the pre-publish "get the API right" ethos.
  • Reuses the proven ManyToManyField pipelinemodel.cache[…] → planner → Dialect.create_unique_index — instead of new machinery. No new SQL rendering.
  • Add-only (this PR): a constraint is materialized when its table is first created (same lifecycle as the automatic M2M join-table index), so it never pollutes later migration plans. Diffing add/drop/change on an already-migrated table is deferred — it needs composite-unique introspection that does not exist yet on either backend (a tracked follow-up).

This PR references #19 but does not fully close it — the migration-diff requirement (#3 in the issue) is the deferred follow-up.

What changed

  • src/Models.jlUniqueConstraint type + validation (fields must exist and be concrete columns, not ManyToManyField; no duplicate or blank names); constraints= on both the named Model("t"; …) and the idiomatic no-positional-name Model(; …) forms; round-trip emission in Model_to_str.
  • src/migrations/planner.jl_add_unique_constraints (sibling of _add_many_to_many_auto_constraints), called from _add_new_table; resolves field names to physical columns via model_column (honors db_column); guards against colliding index names.
  • src/migrations/importers.jl — the Django importer maps Meta.unique_together to UniqueConstraint (resolving the FK _id suffix), leniently warning and skipping a malformed declaration rather than aborting the import.
  • Docsmodels.md (with the 63-char identifier-limit note), import_django.md, fields.md, UPGRADING.md.

Testing

  • Unit (test/unit/test_unique_constraints.jl, DB-free): construction/validation, planner rendering on both backends, Model_to_str round-trip, importer mapping + leniency. Full unit suite 2619/2619.
  • Integration (new Phase 17 in test_migration_bootstrap.jl, isolated temp DB): declare a UniqueConstraint → migrate → assert the composite index exists → assert a duplicate insert is rejected and a different pair is accepted. Runs on both backends.
    • PostgreSQL (db_2): 1656 pass, exit 0 — log shows UniqueViolation: duplicate key value violates unique constraint "uniqtest_season_round_uniq".
    • SQLite (db_sl): 1619 pass + 1 known broken, exit 0 — duplicate rejected with UNIQUE constraint failed: uniqtest.season, uniqtest.round.
  • Docs build clean.

Follow-up (deferred)

Full add/drop/change diff across migrations (Django's AlterUniqueTogether): needs a new composite-unique introspection query returning (index_name, columns, is_unique) for both PostgreSQL and SQLite (today PG discards composite-unique column sets and the SQLite diff doesn't read indexes at all), plus a table-level diff pass and the SQLite table-rebuild interaction.

🤖 Generated with Claude Code

Add Django-style multi-column uniqueness (unique_together), spelled as named
UniqueConstraint objects declared via the model-level `constraints=` kwarg on
Model(...). Each constraint is materialized as a CREATE UNIQUE INDEX at table
creation (idempotent, byte-identical on PostgreSQL and SQLite), generalizing the
existing ManyToManyField auto-index pipeline: model.cache -> planner ->
Dialect.create_unique_index.

- Models: UniqueConstraint type + validation (fields must exist and be concrete
  columns, not M2M; no duplicate or blank names); constraints= on both the named
  and the idiomatic no-positional-name Model forms; round-trip via Model_to_str.
- Planner: _add_unique_constraints emits the index in _add_new_table and guards
  against colliding index names.
- Django importer: maps Meta.unique_together (resolving the FK _id suffix),
  leniently warning and skipping a malformed declaration instead of aborting.
- Tests: DB-free unit coverage (construction/validation, planner rendering on
  both backends, Model_to_str round-trip, importer) plus an isolated integration
  phase proving the index rejects a duplicate on PostgreSQL and SQLite.
- Docs: models.md (incl. the 63-char identifier-limit note), import_django.md,
  fields.md, UPGRADING.md.

Add-only: a constraint is created with its table (same lifecycle as the M2M
auto-index); diffing add/drop/change on an already-migrated table is deferred
(needs composite-unique introspection on both backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PingoLee
PingoLee merged commit d8f7d47 into main Jul 15, 2026
7 checks passed
@PingoLee
PingoLee deleted the fix/19-unique-together branch July 15, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant