Skip to content

Operate in exactly one schema, and filter every catalog query to it - #142

Open
roed-math wants to merge 4 commits into
roed314:mainfrom
roed-math:rc3-schema-contract
Open

Operate in exactly one schema, and filter every catalog query to it#142
roed-math wants to merge 4 commits into
roed314:mainfrom
roed-math:rc3-schema-contract

Conversation

@roed-math

Copy link
Copy Markdown

Third of the rc3 stabilization PRs: Track C.

Stacked on #140 and #141; the diff contains their commits too.

The problem

Unqualified DDL and DML followed whatever search_path happened to be, and
catalog inspection was a mixture of hard-coded 'public' and no filter at all.
I classified every catalog query in the package: 30-odd sites, of which 5
hard-coded public, 2 were correctly scoped, and the rest had no schema
predicate whatsoever.

With same_name in two schemas that gives answers assembled from both:
_column_types unions their columns or raises "Type mismatch"; _index_exists
and _constraint_exists report an object that lives in the other schema;
_all_tablenames lists the name twice; table_sizes reports public no matter
which schema the session is actually using.

The contract

PostgresDatabase(..., schema="public"). Default unchanged, so nothing moves
for existing deployments. Validated as an identifier once in the constructor
(new validate_schema_name), and pinned in search_path from
_configure_session — which runs for the first connection and every
replacement
, so a reconnect cannot come back pointing elsewhere.

It is deliberately not part of _connect_kwargs: psycopg.connect has no
such parameter, and putting it there would pass it to the driver.

The audit

Every catalog query now binds the selected schema as a value:
_table_exists, _all_tablenames, _index_exists, _list_indexes,
_relation_exists, _constraint_exists, _list_constraints, _column_types,
_relation_columns, refresh_tables' column discovery, the read-only and
knowls capability probes, _grantees, the legacy-extras check, table_sizes,
the tablespace map, _check_tmp_leftovers' two probes, the metadata bootstrap's
existing-table set, _approx_most_common, and dbdiff's column reader.

_schema_relations and _approx_most_common previously asked the server with
current_schema(). They bind the same value as everything else now, so there is
one notion of which schema this is rather than two that happen to agree.

One query keeps its own schema on purpose: the userdb.users grant probe is
about a different schema by design, not about this database's.

Tests

New tests/test_schema_contract.py, 15 cases, 12 of which fail on rc2:
public.same_name and psycodict_other.same_name with different columns, then
each lookup checked for confinement; the search path after connect and after
reset_connection(); schema absent from the driver options; an invalid schema
name refused. Two show the confinement from the other side — a database pointed
at the other schema is refused for having no metadata tables there rather than
quietly using public's, and with create=True it sees its own same_name
columns and none of public's search tables.

Full suite: 1326 passed, 36 skipped. Ruff and the -W docs build clean.

🤖 Generated with Claude Code

…random edge cases

Four independent defects found reviewing rc2, each with regression tests that
fail on rc2 and pass here.

max_id/min_id formatted their table argument into the statement as text.  A
name needing quotes was a syntax error and a name carrying its own statement
ran it; both compose an Identifier now.  While there, the empty sentinel is
documented: max_id returns -1, and 0 is a real id, which is what random() got
wrong below.

_approx_most_common read reltuples from a hard-coded public.nf_fields but read
frequencies from the owning table, so every table other than nf_fields got its
own frequencies scaled by an unrelated row count.  The row count now comes from
the table the statistics are about, looked up by name in the current schema,
and the column type goes through column_type_sql rather than being concatenated.

update_from_file's logging default was a shared dictionary literal that the
method wrote logid and aborted into.  Consecutive default calls saw the
previous call's values and a caller's dictionary came back modified.  An AST
scan of the package confirms this was the only mutable default anywhere that is
actually mutated, so nothing else needed changing.

random() raised IndexError from random.choice([]) when pick_first found no
values, reported a table whose only row has id 0 as empty, and discarded rows
whose projection was falsy -- a table of zeros exhausted maxtries and raised
"Random selection failed!".  random_sample returned None for an unrecognized
mode, which reads like an empty result, and reseeded the global random module
when asked for a repeatable sample.
@read-the-docs-community

read-the-docs-community Bot commented Aug 4, 2026

Copy link
Copy Markdown

@roed-math
roed-math force-pushed the rc3-schema-contract branch from 394a1ac to 72dde8c Compare August 4, 2026 22:59
roed314 added 2 commits August 4, 2026 19:03
Write paths called _break_stats, but read paths queried the cache tables
regardless, so a count cached before a restat=False write kept being served
afterwards.  Reproduced against PostgreSQL 18: a query counted at 67, every
matching row then changed so that none satisfy it, and quick_count still
answered 67.

Every lookup that would serve a cached answer now goes through one predicate,
_may_use_cache, and reports a miss while stats_valid is false -- quick_count,
quick_count_distinct, _quick_statistic, _has_stats, _has_numstats and
null_counts, which between them are what make count, max, min, sum,
column_counts and numstats recompute rather than return a stored value.

Three things are deliberately outside the rule, and the predicate's docstring
says why: the empty-query total, which is maintained on every write and stays
exact; a suffixed table, whose caches are its own and which stats_valid says
nothing about; and the _status/status/extra_counts inventory, which reports
what the cache contains rather than answering a question about the data --
refresh_stats uses it to discover what to recompute, so gating it would make an
invalid table forget what statistics it is supposed to have.

The flag had no way back to true.  _restore_stats is the counterpart of
_break_stats, called at the end of refresh_stats inside the same transaction
that rebuilt the caches, so a refresh that fails part-way leaves the table
marked invalid rather than claiming a cache it does not have.  A suffixed
refresh does not touch the live flag.

Separately, bulk paths now run PostgreSQL's own ANALYZE, which is a different
thing from psycodict's statistics: a bulk-loaded relation has none until
autovacuum reaches it and the planner costs it as though it were tiny.  The
_tmp copies are analyzed before the swap and outside its transaction, since
the catalog entry follows the relation through a rename -- one call in
_swap_in_tmp covers reload, rewrite, non-inplace update_from_file and staged
commits, which all funnel through it.  copy_from analyzes the live table.

The existing statistics fixtures insert rows, which invalidates, and then
assume a usable cache; they now say so with _restore_stats, which is true of
them (nothing is cached yet and the total is maintained by the insert) and is
the state a freshly loaded table is in.
Unqualified DDL and DML went wherever search_path happened to point, while
catalog inspection was a mixture of hard-coded 'public' and no filter at all.
With a relation of the same name in two schemas the answers came from both:
_column_types unioned their columns (or raised "Type mismatch"), an index or
constraint in the other schema counted as present, _all_tablenames listed the
name twice, and table_sizes reported only public whatever the session was
using.

PostgresDatabase now takes schema="public", validates it as an identifier once
in the constructor, and pins search_path to it in _configure_session -- which
runs for the first connection and for every replacement, so a reconnect cannot
come back pointing somewhere else.  It is deliberately not part of
_connect_kwargs: psycopg.connect has no such parameter, and passing it there
would reach the driver.

Every catalog query is then filtered to that schema, binding it as a value
rather than interpolating it: _table_exists, _all_tablenames, _index_exists,
_list_indexes, _relation_exists, _constraint_exists, _list_constraints,
_column_types, _relation_columns, refresh_tables' column discovery, the
read-only and knowls capability probes, _grantees, the legacy-extras check,
table_sizes, tablespaces, _check_tmp_leftovers' two probes, the metadata
bootstrap's existing-table set, _approx_most_common and dbdiff's column
reader.  _schema_relations and _approx_most_common previously asked the server
with current_schema(); they now bind the same value as everything else, so
there is one notion of which schema this is.

The userdb.users grant probe keeps its own schema: that one is deliberately
about a different schema, not about this database's.
…settings to it

Review of roed314#142 found the "exactly one schema" contract still open in three
places.

search_path was set from the schema's raw text.  But a search_path is not an
identifier: it is a comma-separated list, in which unquoted names are
case-folded and $user is a substitution.  A schema named "a, b" therefore
selected schema "a" while every catalog query kept binding the literal name --
so with create=True the metadata was bootstrapped into a schema the object did
not think it was using.  Verified on PG18: as text that name selects
psycodict_path_a; composed as one Identifier it selects the schema asked for,
and a name with spaces or mixed case is refused by the server outright rather
than silently reinterpreted.  pg_temp is now named explicitly *after* the
schema, since left unnamed it is searched *first* and a temporary meta_tables
shadowed the real one (also verified).  pg_catalog stays unnamed and so stays
ahead of both.

PostgreSQL ignores a search_path entry naming a schema that is missing or that
the role has no USAGE on, and with pg_temp present the session then resolves
unqualified names -- including CREATE TABLE -- in the temporary schema, so
current_schema() is checked afterwards and a mismatch raises.  One name cannot
be expressed at all: PostgreSQL unquotes each element before looking for the
$user token, so "$user" is replaced by the role name and never reaches a schema
of that name; validate_schema_name refuses it, with the reason.  Any other
spelling, $USER included, is an ordinary identifier.

_get_locks() collected locks from every non-system schema while _table_locked()
matched on relation name alone, so a lock on other_schema.same_name was
reported as a lock on this schema's same_name -- and _check_locks() consults
that before writes, reloads, create_table_like and column changes, refusing an
operation nothing was blocking.  Another session's temporary table of the same
name did it too.  Now filtered to the schema in SQL, which subsumes the old
pg_toast and pg_catalog exclusions.

_clone_storage_settings() was the one relation-specific catalog query still
resolving a bare name through regclass.  It now joins pg_class to pg_namespace
and binds the schema, keeping the pre-14 branch that omits attcompression, so
the helper is correct on its own rather than by way of the session's state.

Tests: five in test_schema_contract.py for the path (the comma-containing
schema and its two decoys, a schema needing quotes, the $user refusal, a
missing schema, the temporary-relation shadow), one in test_locks.py, one in
test_table_like_size.py.  All seven fail on the previous head.  Full suite
1332 passed, 36 skipped; ruff and the -W docs build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

roed-math commented Aug 5, 2026

Copy link
Copy Markdown
Author

Review addressed in 8cd9bd4. All three findings, plus the documentation items.

1 (P1) search_path is now one quoted identifier, with pg_temp after it

_configure_session composes the schema in as an Identifier instead of binding it as GUC text:

conn.execute(SQL("SET search_path TO {schema}, pg_temp").format(schema=Identifier(self.schema)))

The review's reasoning checked out on PG18, and worse than described in one respect. With the schema psycodict_path_a, psycodict_path_b:

written to search_path as current_schema()
text (previous head) psycodict_path_a
one Identifier psycodict_path_a, psycodict_path_b

So create=True really did bootstrap the metadata into the first component while the object reported the literal name. A name with spaces or mixed case is a cleaner failure as text (InvalidParameterValue: List syntax is invalid), but the comma case is silent.

$user cannot be expressed at all, quoting included. PostgreSQL calls SplitIdentifierString before comparing against the $user token, so "$user" is unquoted first and substituted with the role name; a schema genuinely called $user is unreachable through search_path by any spelling. Verified: with schemas $user, $USER and postgres all present, SET search_path TO "$user" selects postgres, while "$USER" and "$User" select themselves. validate_schema_name now refuses the exact spelling $user with that reason, rather than letting it fail confusingly at connect time. Its docstring says so, and still says the name is quoted wherever psycodict emits it, which is now true of search_path too.

pg_temp is named last, and the temp-shadow test confirms both directions: 'meta_tables'::regclass resolves to the persistent OID with pg_temp named, and to the temporary table without it.

current_schema() is checked right after, raising ValueError on a mismatch. This matters more than "clear error instead of silence": with pg_temp in the path and the selected schema missing or USAGE-less, PostgreSQL falls through to pg_temp_NN, so an unqualified CREATE TABLE would have produced a temporary table.

2 (P2) Lock discovery is scoped to the schema

_get_locks() filters with t.schemaname = %s bound to self._db.schema; the pg_toast/pg_catalog exclusions go away, subsumed by the equality. show_locks()'s docstring and the DataManagement.md bullet now say the result is this database's schema.

3 (P2) _clone_storage_settings carries its own schema predicate

Replaced the %s::regclass lookup with the pg_class/pg_namespace joins and [self.schema, tname], keeping the pre-14 branch that omits attcompression.

Documentation

schema added to the class docstring's INPUT (existence, USAGE, CREATE) and to the attribute list; DataManagement.md states the effective pg_catalog / schema / pg_temp order and the existence and privilege requirements; the CHANGELOG entry for this change is extended.

Tests

Seven new, all failing on a0ab659:

  • test_schema_contract.py: comma-containing schema with its two decoys (asserting the metadata landed in the selected schema and the decoys stayed empty, across reset_connection()), a schema needing quotes, the $user refusal, a missing schema, and the temporary-relation shadow.
  • test_locks.py::test_locks_in_another_schema_are_not_this_schema_s: ACCESS EXCLUSIVE on other.<name> from a second connection; the lock is confirmed present in the catalog, _table_locked returns [], and _check_locks("insert_many") goes ahead. The existing positive cases are untouched.
  • test_table_like_size.py::test_column_storage_settings_are_read_from_the_selected_schema: puts a same-named decoy ahead of the schema in the session's search_path, so it fails on the old code even with finding 1 fixed, which is the point of the helper being independently correct.

Validation

1332 passed, 36 skipped, 1 xfailed (was 1325), twice in a row; ruff check . clean; python -m sphinx -W --keep-going clean. Local runs are against PostgreSQL 18.3 / Python 3.13 / psycopg 3.3.4; the matrix will cover 13-18 and 3.9-3.14.

One note on the local suite: tests/test_notifications.py is timing-sensitive and fails intermittently on a loaded machine, at the same rate on a0ab659 as here, so it is unrelated to this change.

@roed314

roed314 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

GPT signed off.

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