Skip to content

Declare unique constraints - #139

Merged
jbylund merged 1 commit into
mainfrom
feat/declared-unique
Aug 17, 2026
Merged

jbylund merged 1 commit into
mainfrom
feat/declared-unique

Conversation

@jbylund

@jbylund jbylund commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes #128. Stacked on #138, which is stacked on #137 — this PR targets #138's branch, so the diff here is only the unique-constraint work.

Table takes unique, any number of keys spelled the way primary_key is:

Table("users", {"id": "integer", "email": "text", "tenant": "text"},
      primary_key="id", unique=["email", ("tenant", "email")])

Output is byte-identical to PostgreSQL 18 for the same declaration, ordering included — psql sorts the footer indisprimary DESC, relname:

Indexes:
    "u1_pkey" PRIMARY KEY, btree (id)
    "u1_a_b_key" UNIQUE CONSTRAINT, btree (a, b)
    "u1_email_key" UNIQUE CONSTRAINT, btree (email)
    "u1_oddName_key" UNIQUE CONSTRAINT, btree ("oddName")

Most of the diff is undoing a one-key-per-table assumption

#128 was supposed to be small, and the declaration half was. The catalog half was not, because #127 had baked in "at most one key per table" in two places:

  • Index and constraint OIDs came from the table's position (_FIRST_INDEX_OID + index), so a table with three unique constraints would have issued three rows sharing one OID.
  • The row builder was a single if table.primary_key: block.

Both are now driven by a running count over the rows actually emitted. Worth knowing for #129, which adds a third kind of constraint to the same loop.

Where the two kinds of key actually differ

They share one code path that branches in exactly three places, which is the useful summary:

primary key unique
contype / rendering p / PRIMARY KEY (...) u / UNIQUE (...)
indisprimary true false — psql reads this to pick the footer label
columns implicitly NOT NULL yes no

The third is the one substantive difference, and it has a test asserting \d u1 says not null exactly once.

Naming

Index names are relation names, so they are unique across the schema rather than per table, and generated the way Postgres generates them: t_pkey, or t_<columns>_key with the column names unquoted whatever they are — Postgres builds u1_oddName_key from "oddName" and quotes the whole name only when printing it.

Truncated to 63 bytes (NAMEDATALEN - 1), which is reachable with an ordinary long table name plus two columns, and a truncation that collides gets a counter as Postgres' own ChooseIndexName does. Identical keys are refused at declaration instead, since that is a copy-paste rather than a name clash.

Tests

18 new. test_declared.py for normalisation and the three refusals; test_pg_catalog.py running psql's footer query verbatim and asserting the full ordered row set, plus the truncation and OID-uniqueness cases; test_psql.py asserting all four rendered footer lines against the real binary.

Full suite: 946 passed, 32 xfailed (up from 928). ruff check and ruff format --check clean.

Not in scope

Foreign keys (#129) and declaring a constraint after construction (#130). Unique indexes that are not constraints — CREATE UNIQUE INDEX without ADD CONSTRAINT, which psql prints as UNIQUE, rather than UNIQUE CONSTRAINT, — have no declaration surface and nothing needs them.

@jbylund
jbylund force-pushed the feat/declared-primary-key branch from bc0c255 to e5ce3b9 Compare August 17, 2026 17:26
Table takes `unique`, any number of keys spelled the way primary_key is, each
normalised and validated the same way. Two identical keys are refused: they
would name two constraints the same thing and `\d` would render a duplicated
line. Postgres tolerates that and disambiguates with a counter, but here it is
almost certainly a copy-paste.

`\d` output is byte-identical to PostgreSQL 18 for the same declaration,
including the order -- psql sorts the footer `indisprimary DESC, relname`:

    Indexes:
        "u1_pkey" PRIMARY KEY, btree (id)
        "u1_a_b_key" UNIQUE CONSTRAINT, btree (a, b)
        "u1_email_key" UNIQUE CONSTRAINT, btree (email)
        "u1_oddName_key" UNIQUE CONSTRAINT, btree ("oddName")

Most of the diff is that #127 assumed one key per table. Index and constraint
OIDs came from the *table's* position, and the row builder was a single `if
table.primary_key:` block, so a table with three unique constraints would have
issued three rows with one OID. Both are now driven by a running count over the
rows actually emitted, and the two kinds of key share one code path that differs
in exactly three places: contype and the PRIMARY KEY/UNIQUE rendering, the
indisprimary flag psql reads to choose between the two footer labels, and
whether the columns are implicitly NOT NULL. Only a primary key's are, which is
the one substantive difference between them.

Index names are relation names, so they are unique across the schema rather than
per table, and generated the way Postgres generates them: `t_pkey`, or
`t_<columns>_key` with the column names unquoted whatever they are -- Postgres
builds `u1_oddName_key` from `"oddName"` and quotes the whole name when it prints
it. Truncated to 63 bytes, which is NAMEDATALEN - 1 and reachable with an
ordinary long table name plus two columns, and a truncation that collides gets a
counter as ChooseIndexName does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbylund
jbylund force-pushed the feat/declared-unique branch from 22b4781 to ec71a48 Compare August 17, 2026 17:34
@jbylund
jbylund changed the base branch from feat/declared-primary-key to main August 17, 2026 17:34
@jbylund jbylund closed this Aug 17, 2026
@jbylund jbylund reopened this Aug 17, 2026
@jbylund
jbylund merged commit adeaabd into main Aug 17, 2026
6 checks passed
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.

Declare unique constraints on a Table

1 participant