Annotate PostgreSQL unique and exclusion constraints - #364
Merged
Conversation
PostgreSQL supports both UNIQUE and EXCLUDE table constraints as first-class objects (distinct from unique indexes). Rails 7.1+ exposes them via `connection.unique_constraints` / `exclusion_constraints`, but annotaterb was ignoring them entirely. Add two new annotation sections, opt-in via `--show-unique-constraints` and `--show-exclusion-constraints` (and the equivalent config keys), mirroring the existing check-constraint annotation pattern. The output includes column list / expression, USING method, WHERE predicate, and DEFERRABLE INITIALLY IMMEDIATE|DEFERRED where applicable. Both features are PostgreSQL-only and require Rails 7.1+; guard with `respond_to?(:supports_*_constraints?)` so other adapters and older Rails versions render nothing rather than raising.
PostgreSQL's UNIQUE and EXCLUDE constraints are implemented as indexes with the same name as the constraint. `connection.indexes(table)` returns them alongside plain indexes, so annotaterb was listing them under both the Indexes section and (after the previous commit) the new Unique Constraints / Exclusion Constraints sections. Filter them out in IndexAnnotation::AnnotationBuilder by rejecting indexes whose name matches any constraint, mirroring ActiveRecord::SchemaDumper#indexes_in_create. The filter is guarded by `respond_to?(:supports_*_constraints?)` so it's a no-op on adapters without those APIs.
Rails added deferrable support for unique and exclusion constraints in 7.1 (rails/rails@cbc7b59, rails/rails@d849ee0), and from the start their extraction used `extract_constraint_deferrable`, which returns `false`, `:immediate`, or `:deferred` — never `true`. The `== true` guard was copied from the foreign-key path (where Rails 7.0's older `extract_foreign_key_deferrable` does return `true`), but it never fires for these two constraint types.
Owner
|
Thanks for submitting this PR. It's a decent size changed so I will need to take time to sit down and review it. The write up is helpful in understanding the problem. I will plan to do it tomorrow. |
drwl
approved these changes
Jul 23, 2026
drwl
left a comment
Owner
There was a problem hiding this comment.
Looks good to me. I think we'll want to keep an eye out if any people report regressions and then we can direct them to this pr.
OdenTakashi
pushed a commit
that referenced
this pull request
Jul 23, 2026
## Summary Emit `DEFERRABLE INITIALLY IMMEDIATE|DEFERRED` on foreign key annotations, alongside the existing `ON DELETE` / `ON UPDATE` metadata. Related to #364. ## Motivation Rails exposes deferrable foreign keys via `ForeignKeyDefinition#deferrable`, but annotaterb was silently dropping the flag — a foreign key created with `add_foreign_key ..., deferrable: :deferred` was indistinguishable from a plain one in the annotation. ## Compatibility Rails 7.0's `extract_foreign_key_deferrable` returns `true` for the initially-immediate case (7.1+ normalized this to `:immediate`); mapped `true` → `IMMEDIATE` for stable output. Guarded with `respond_to?(:deferrable)` for older Rails / other adapters. Co-authored-by: Andrew W. Lee <git@drewlee.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds annotation support for two PostgreSQL constraint types that annotaterb has been overlooking: unique constraints and exclusion constraints. Also fixes a related leak where the indexes backing those constraints were showing up in the
Indexessection.Motivation
In our application we lean on
DEFERRABLE INITIALLY DEFERREDunique and exclusion constraints so we can swap values across rows within a transaction (e.g. reorderingpositionon a list without hitting a mid-transaction uniqueness violation). This is a first-class PostgreSQL feature exposed by Rails 7.1+ asadd_unique_constraint ..., deferrable: :deferred/add_exclusion_constraint ..., deferrable: :deferred, but none of it ever made it into annotaterb's output — thedeferrableflag was dropped, and unique/exclusion constraints didn't have dedicated sections at all.Worse, the underlying indexes were still enumerated: since PostgreSQL implements unique/exclusion constraints as indexes with the same name,
connection.indexes(table)returns them, and annotaterb was rendering them as ordinary index rows. So adeferrableunique constraint appeared as a plainUNIQUEindex with no hint of its actual semantics.Changes
Two new annotation sections, opt-in via config or CLI flags:
show_unique_constraints/--show-unique-constraintsshow_exclusion_constraints/--show-exclusion-constraintsBoth mirror the existing check-constraint annotation pattern (default, markdown, yard, rdoc formats). The output includes column list / expression,
USINGmethod,WHEREpredicate, andDEFERRABLE INITIALLY IMMEDIATE|DEFERRED.Filter constraint-backed indexes out of the
Indexessection, matchingActiveRecord::SchemaDumper#indexes_in_create: indexes whose name matches a unique or exclusion constraint name are excluded. Without this the same object would show up twice once you enable the new sections.Example output
Compatibility
connection.unique_constraints,connection.exclusion_constraints,supports_*_constraints?) exist in Rails 7.1+.respond_to?(:supports_*_constraints?), so on other adapters or older Rails versions the new sections silently do nothing and the index filter is a no-op — no risk of raising in projects that don't opt in.show_unique_constraintsandshow_exclusion_constraintsdefault tofalse, so no existing annotations change unless the user opts in. The index-filtering change does apply unconditionally (matching Rails' schema dumper), so users of unique/exclusion constraints on Rails 7.1+ will see those rows disappear fromIndexes— this is the desired behavior since it removes double reporting, and the info is preserved (and enriched) once the new sections are enabled.