Skip to content

Annotate PostgreSQL unique and exclusion constraints - #364

Merged
drwl merged 3 commits into
drwl:mainfrom
kamipo:support-unique-and-exclusion-constraints
Jul 23, 2026
Merged

Annotate PostgreSQL unique and exclusion constraints#364
drwl merged 3 commits into
drwl:mainfrom
kamipo:support-unique-and-exclusion-constraints

Conversation

@kamipo

@kamipo kamipo commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

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 Indexes section.

Motivation

In our application we lean on DEFERRABLE INITIALLY DEFERRED unique and exclusion constraints so we can swap values across rows within a transaction (e.g. reordering position on a list without hitting a mid-transaction uniqueness violation). This is a first-class PostgreSQL feature exposed by Rails 7.1+ as add_unique_constraint ..., deferrable: :deferred / add_exclusion_constraint ..., deferrable: :deferred, but none of it ever made it into annotaterb's output — the deferrable flag 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 a deferrable unique constraint appeared as a plain UNIQUE index with no hint of its actual semantics.

Changes

  1. Two new annotation sections, opt-in via config or CLI flags:

    • show_unique_constraints / --show-unique-constraints
    • show_exclusion_constraints / --show-exclusion-constraints

    Both mirror the existing check-constraint annotation pattern (default, markdown, yard, rdoc formats). The output includes column list / expression, USING method, WHERE predicate, and DEFERRABLE INITIALLY IMMEDIATE|DEFERRED.

  2. Filter constraint-backed indexes out of the Indexes section, matching ActiveRecord::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

#
# Indexes
#
#  index_reservations_on_room_id  (room_id)
#
# Unique Constraints
#
#  unique_position  (position) DEFERRABLE INITIALLY DEFERRED
#
# Exclusion Constraints
#
#  no_overlap       (room_id WITH =, during WITH &&) USING gist WHERE (canceled = false) DEFERRABLE INITIALLY DEFERRED

Compatibility

  • PostgreSQL-only feature; the accessors (connection.unique_constraints, connection.exclusion_constraints, supports_*_constraints?) exist in Rails 7.1+.
  • Guarded with 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.
  • Both show_unique_constraints and show_exclusion_constraints default to false, 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 from Indexes — this is the desired behavior since it removes double reporting, and the info is preserved (and enriched) once the new sections are enabled.

kamipo added 2 commits July 19, 2026 19:03
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.
@drwl

drwl commented Jul 21, 2026

Copy link
Copy Markdown
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 drwl left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@drwl
drwl merged commit a504deb into drwl:main Jul 23, 2026
32 checks passed
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>
@kamipo
kamipo deleted the support-unique-and-exclusion-constraints branch July 23, 2026 09:33
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.

2 participants