Skip to content

Declare a primary key, and let psql print it - #138

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

jbylund merged 1 commit into
mainfrom
feat/declared-primary-key

Conversation

@jbylund

@jbylund jbylund commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes #127. Stacked on #137 (feat/declared-table-and-schema) — review that first; this PR targets its branch, so the diff here is only the primary-key work.

Table takes primary_key, one column or a tuple of them:

Table("commits", {"sha": "text", "author": "text"}, primary_key="sha")
Table("commit_files", {"sha": "text", "path": "text"}, primary_key=("sha", "path"))

Normalised to a tuple so nothing downstream branches on the spelling, and validated at construction against the table's own columns — a key naming a column the table does not have is knowable there, so it raises on the line that wrote it. A tuple rather than a set because a composite key's order is part of it: it is what conkey records and what psql prints.

What \d prints now

              Table "public.commit_files"
   Column   |  Type   | Collation | Nullable | Default
------------+---------+-----------+----------+---------
 sha        | text    |           | not null |
 path       | text    |           | not null |
 insertions | integer |           |          |
 deletions  | integer |           |          |
Indexes:
    "commit_files_pkey" PRIMARY KEY, btree (sha, path)

Byte-for-byte what PostgreSQL 18 prints for the same declaration — I diffed them against a real server rather than reconstructing the format.

Four things that are each invisible on their own

This is most of the work, and none of it is guessable from the outside:

  • pg_class.relhasindex gates the entire footer. Left False, every row below is built and never asked for.
  • The index needs its own pg_class row, relkind = 'i'. psql reads the index's name as c2.relname off a second pg_class in the same query, so without it the join drops every index it was told about.
  • pg_get_indexdef cannot be the constant "" the other catalog functions are. psql echoes everything after " USING " straight into the footer, so that substring is where btree (sha) comes from. It is rewritten into a reference to a column carrying the rendered text — exactly as format_type already was — and pg_get_constraintdef gets the same treatment, which Declare foreign keys between Tables, resolved by name #129 will need.
  • pg_index and pg_constraint leave _ALWAYS_EMPTY_TABLES. Safe because psql reads both in a JOIN, not in one of the correlated subqueries that substitution exists for; I checked every query \d sends.

Details worth a look in review

not null is written in two places on purpose. attnotnull and information_schema.columns.is_nullable answer one question and a client reads whichever it reads, so they must not disagree. There's a test asserting both.

Index and constraint OIDs come from their own ranges, not the continuation of the table numbering — declaring a key must not renumber a table, since table OIDs are positional and a client may have introspected them already. test_declaring_a_primary_key_does_not_renumber_the_tables pins that.

PostgreSQL 17 also records key columns as contype='n' rows in pg_constraint. pg_mimic reports server_version 16, so it deliberately does not.

Identifiers are quoted only when they have to be, which is what pg_get_constraintdef does — btree (sha) and btree ("userId"). Reserved words are not consulted; the comment says why.

Tests

20 new, at three levels:

  • test_declared.py — normalisation, key order, and the two refusals.
  • test_pg_catalog.py — psql's index-footer query verbatim, against one-column, composite and needs-quoting keys, plus a table with no key answering no rows. Also the OID-stability and conkey assertions.
  • test_psql.py — the real psql binary, asserting the rendered footer string, that a keyless table has no Indexes: section, that \di lists the index, and that \dt does not — an index is a relation, so the pg_class row it needs must not leak into the table list.

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

Also here

examples/git_sql.py declares the four keys that are actually sound for git data: commits.sha, files.path, branches.name and commit_files (sha, path). Verified live.

Not in scope

Unique constraints (#128), foreign keys (#129), declaring a constraint after construction (#130). pg_get_constraintdef is made row-dependent here but no non-primary constraint exists to render yet.

Table takes primary_key, one column or a tuple of them, normalised to a tuple so
nothing downstream branches on the spelling and validated at construction
against the table's own columns -- a key naming a column the table does not have
is knowable there and should raise on the line that wrote it. A tuple rather
than a set because a composite key's order is part of it: it is what conkey
records and what psql prints.

`\d commits` now ends with

    Indexes:
        "commits_pkey" PRIMARY KEY, btree (sha)

which is byte-for-byte what PostgreSQL 18 prints for the same declaration.
Getting there needed four things that are each invisible on their own:

- pg_class.relhasindex, which psql gates the entire footer on. Left False, the
  rows below are built and never asked for.
- a pg_class row for the *index*, relkind 'i'. psql reads the index's name as
  c2.relname off a second pg_class in the same query, so without it the join
  drops every index it was told about.
- pg_get_indexdef, which cannot be the constant "" the other catalog functions
  are: psql echoes everything after " USING " straight into the footer, so that
  substring is where `btree (sha)` comes from. It is rewritten into a reference
  to a column carrying the rendered text, exactly as format_type already is, and
  pg_get_constraintdef gets the same treatment for #129's benefit.
- pg_index and pg_constraint leaving catalog_rewrite's _ALWAYS_EMPTY_TABLES.
  Safe because psql reads both in a JOIN rather than in one of the correlated
  subqueries that substitution exists for.

A key's columns are reported not null through both attnotnull and
information_schema.columns.is_nullable, which had to be done in both places --
they answer one question and a client reads whichever it reads. PostgreSQL 17
also records them as contype='n' rows in pg_constraint; pg_mimic reports
server_version 16, so it does not.

Index and constraint OIDs come from their own ranges rather than continuing the
table numbering, so declaring a key cannot renumber a table -- table OIDs are
positional and a client may have introspected them already. There is a test for
that specifically.

Identifiers in a rendered definition are quoted only when they have to be, which
is what pg_get_constraintdef does: btree (sha) and btree ("userId").

examples/git_sql.py declares the four keys that are actually sound for git data:
commits.sha, files.path, branches.name and commit_files (sha, path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbylund
jbylund force-pushed the feat/declared-primary-key branch from bc0c255 to e5ce3b9 Compare August 17, 2026 17:26
@jbylund
jbylund changed the base branch from feat/declared-table-and-schema to main August 17, 2026 17:26
@jbylund jbylund closed this Aug 17, 2026
@jbylund jbylund reopened this Aug 17, 2026
@jbylund
jbylund merged commit c5faded into main Aug 17, 2026
6 checks passed
jbylund added a commit that referenced this pull request Aug 17, 2026
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:

```python
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.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 a primary key on a Table, and render it in \d

1 participant