Skip to content

fix: namespace-aware information_schema served from SQLite (#88) - #103

Merged
erans merged 11 commits into
mainfrom
worktree-fix+88-information-schema-namespace
Aug 12, 2026
Merged

fix: namespace-aware information_schema served from SQLite (#88)#103
erans merged 11 commits into
mainfrom
worktree-fix+88-information-schema-namespace

Conversation

@erans

@erans erans commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Fixes #88.

Problem

information_schema.tables reported pgsqlite's own materialized catalog relations as user tables in public:

public|pg_am
public|pg_class
public|information_schema_tables
...
public|customers

28 internal relations alongside the one real table, so Django inspectdb and SQLAlchemy automap would generate models for pg_constraint and friends.

Real PostgreSQL returns these rows too — a stock cluster has ~180 — but under table_schema = 'pg_catalog' / 'information_schema'. Clients filter on table_schema; that filtering is the mechanism that hides them, not omission. So the fix reports the correct schema rather than hiding the rows.

Root cause

Not the line the issue cited (query_interceptor.rs:2059 is inside the columns handler). The live path was handle_information_schema_tables_query, which ran

db.query("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') \
          AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__pgsqlite_%'")

with no catalog filtering, emitting "public" for every row. Migration v28 (#87) had already assigned these relations to namespace 11/13000 in pg_class — which is why \dt was clean — but the information_schema_tables view hardcoded 'public' as table_schema and the Rust handler bypassed both.

The handlers were stale in the same way #87 found in PgClassHandler. Measured before this change:

Query Result
SELECT table_name FROM information_schema.tables ORDER BY 1 29 rows, unsortedORDER BY ignored
SELECT count(*) FROM information_schema.tables 0 rows — aggregates unsupported
SELECT DISTINCT table_name FROM information_schema.columns 60 rows, DISTINCT ignored

WHERE support extended only to equality on table_name; every other predicate was silently dropped rather than erroring. Any fix that kept the handler would inherit all of it.

Approach

Follows the #87 precedent: make the SQLite views the single source of truth, delete the Rust handlers, and identify internal relations by exact name rather than by prefix.

Prefix matching is not sufficient in either direction. LIKE 'pg\_%' would misfile a legitimate user table named pg_myreport into pg_catalog — hiding it from ORM introspection, a regression on current behavior where it is at least visible. It also under-matches: eight migration-created idx_* indexes match no pg_% or __pgsqlite_% filter at all.

  • src/catalog/internal_relations.rs — const registry of the 36 relations pgsqlite's migrations create, each tagged with its namespace OID, exposed as the __pgsqlite_relnamespace UDF. Returns 2200 (public) for anything unlisted — the safe direction, since the failure mode is showing an internal relation rather than hiding a user's.
  • src/catalog/column_type_info.rspg_column_info() plus four UDFs. The deleted handler resolved character_maximum_length / numeric_precision / numeric_scale privately; all three were NULL in the view, so deleting the handler without replacing them would have regressed VARCHAR(50) to no length and NUMERIC(10,2) to no precision. Derived from the type string rather than an OID, since an OID cannot carry the (50) or (10,2) modifier.
  • Migration v29 — rebuilds both views. information_schema_columns is built directly on sqlite_master / pragma_table_info / __pgsqlite_schema rather than layering on pg_attribute, which keeps it off a view every ORM reads for column reflection.
  • RoutingSchemaPrefixTranslator rewrites both relations to their views; the stale handlers and their helpers are deleted.

Type fidelity

Routing columns to the old view as-is would have regressed six of eight columns. Measured on CREATE TABLE fidelity (id SERIAL PRIMARY KEY, amount NUMERIC(10,2), uid UUID, doc JSONB, tags TEXT[], ts TIMESTAMPTZ, flag BOOLEAN, nick VARCHAR(50)):

column declared old handler old view now
id SERIAL text integer integer
amount NUMERIC(10,2) numeric text numeric
uid UUID uuid text uuid
doc JSONB jsonb text jsonb
tags TEXT[] text text ARRAY
ts TIMESTAMPTZ text integer ✗✗ timestamp with time zone
flag BOOLEAN boolean integer boolean
nick VARCHAR(50) character varying text character varying

The old view leaking integer for TIMESTAMPTZ would have exposed pgsqlite's INTERNAL datetime storage on a client-visible surface.

Also fixed here

Two defects found by review while routing the queries through the translator:

  • String literals were being corrupted. The prefix rewrite used blind String::replace, so SELECT 'information_schema.tables' came back mangled and WHERE msg = 'see pg_catalog.pg_class' silently stopped matching stored rows — breaking any table that stores SQL text (audit logs, migration history, docs tables). This predates the branch; it already affected the pg_catalog. rewrites shipped in \dt reports "Did not find any tables" on a database that has tables #87.
  • Mixed-case qualifiers. The interceptor's gate is case-insensitive but the rewrites were not, so Information_Schema.Tables and INFORMATION_SCHEMA.tables — a spelling JDBC-derived tooling emits — hard-errored with no such table.

Both are closed by replace_outside_literals: case-insensitive, single left-to-right scan, skips '...' literals (with the doubled-quote escape) and "..." quoted identifiers. It is applied to the pg_catalog. rewrites too. Verified linear (2.69 MB query → 189 ms) and fuzzed against multi-byte UTF-8 at every character position; the byte/char-boundary hazard is designed out by comparing in place with eq_ignore_ascii_case instead of building a lowercased copy.

numeric_precision_radix also now reports 2 for binary-precision types and 10 only for numeric/decimal, matching _pg_numeric_precision_radix().

Testing

1341 passed / 0 failed. cargo check and cargo clippy warning counts unchanged from baseline (8 / 46).

New coverage in tests/information_schema_namespace_test.rs (13 tests): the #88 regression itself, the four handler defects above, the eight-column type-fidelity table, the four recovered columns, mixed-case routing, literal preservation, and numeric_precision_radix.

The drift guard (internal_relation_list_matches_migrated_database) compares the Rust registry against what a migrated-to-head database actually contains, in both directions, so a future migration that adds a catalog relation without updating the list fails loudly instead of silently leaking it. Proven non-vacuous during review by removing one entry and confirming the expected failure.

information_schema_test.rs and information_schema_comprehensive_test.rs are unmodified and still pass.

The SQLAlchemy suite could not be run: tests/python/run_sqlalchemy_tests.sh aborts because Poetry 2.4.1 rejects the virtualenvs.prefer-active-python key it sets. Pre-existing (introduced in 2482469, #53), unrelated to these changes.

Deliberately out of scope

pg_class keeps v28's LIKE 'pg\_%' heuristic and pg_attribute.atttypid is still derived from the SQLite declared type — both tracked in #102. Splitting them keeps this change off the path of \dt, \di, and every ORM introspection query one week after #87 landed there; the registry introduced here makes that follow-up a one-expression swap.

The consequence is a known divergence: a user table named pg_myreport reports public in information_schema.tables but is still misfiled under pg_catalog in pg_class. user_table_named_like_a_catalog_relation_is_visible asserts both, deliberately, so the gap is documented in a test rather than in a comment. That assertion flips when #102 lands.

🤖 Generated with Claude Code

erans and others added 11 commits August 11, 2026 13:51
information_schema.tables reports pgsqlite's 28 materialized catalog relations
as user tables in public. Root cause is the Rust handler at
query_interceptor.rs:1844, which filters only sqlite_%/__pgsqlite_% and
hardcodes 'public' as table_schema -- discarding the namespacing v28 already
built into pg_class.

Design follows the #87 precedent: make the SQLite views authoritative, delete
the handlers, route via SchemaPrefixTranslator. Adds an exact-name internal
relation registry to replace v28's LIKE 'pg\_%' heuristic, which misfiles a
user table named pg_myreport and misses the 8 idx_* indexes now leaking into
\di.

Also records that the columns half needs pg_attribute to resolve atttypid from
__pgsqlite_schema first -- measured, the view is wrong on 6 of 8 rich types and
leaks INTEGER datetime storage for TIMESTAMPTZ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pg_class keeps v28's LIKE 'pg\_%' heuristic for now, so this change stays off
the path of \dt, \di and every ORM introspection query one week after #87
landed there.

Consequence: the information_schema views resolve table_schema through
__pgsqlite_relnamespace(relname) directly rather than pg_class.relnamespace.
Inheriting the heuristic would have regressed a user table named pg_myreport
from "visible but mislabeled public" to "filed under pg_catalog and skipped by
ORMs". Test 4 now asserts the resulting divergence so it is documented rather
than discovered; that assertion flips in #102.

The registry and UDF stay in this change, which reduces #102 to one expression
swap against a UDF already proven in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 TDD tasks: exact-name relation registry + UDF, pg_column_info + type UDFs,
migration v29, route tables, route columns, drift guard.

Both view bodies were prototyped against a migrated database with SQL
stand-ins for the UDFs before writing the plan -- the lateral
pragma_table_info join, the pg_namespace partition (22/6/2) and is_nullable
for SERIAL PRIMARY KEY are confirmed, not assumed.

Spec corrected on one point: Migration::down is never executed anywhere in
src/migration/, so the planned up-then-down round-trip test is not
achievable. v29 still ships a down for convention; the plan says not to
claim it was verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prefix matching fails both ways: 'pg\_%' claims a user table named
pg_myreport, and nothing catches the 8 idx_* indexes migrations create on
__pgsqlite_* tables. Exact list of all 36, exposed to SQL as
__pgsqlite_relnamespace(name) for the catalog views to use.
Moves map_sqlite_type_to_pg_column_info out of the handler about to be
deleted, so the view can keep populating character_maximum_length,
numeric_precision and numeric_scale. Fixes the rows measurement showed
wrong: SERIAL, TIMESTAMPTZ, TIME/TIMETZ and array types all reported text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#88)

table_schema now comes from __pgsqlite_relnamespace rather than a hardcoded
'public'. columns reads sqlite_master/pragma_table_info/__pgsqlite_schema
directly instead of pg_attribute, which keeps v29 off a view every ORM reads
and recovers column_default, is_nullable for INTEGER PRIMARY KEY,
character_maximum_length and numeric precision/scale.

Views are not routed to yet -- the Rust handlers still intercept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…88)

Closes the headline defect: internal relations now report pg_catalog and
information_schema for table_schema instead of appearing as user tables in
public.

Also fixes three defects the handler carried: ORDER BY was ignored,
aggregates returned zero rows, and any predicate other than equality on
table_name was silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#88)

Types are now resolved from __pgsqlite_schema through pg_column_info, so
SERIAL reports integer and TIMESTAMPTZ reports timestamp with time zone
instead of text. character_maximum_length, numeric precision/scale,
column_default and is_nullable for INTEGER PRIMARY KEY are preserved from
the deleted handler rather than regressing to NULL.

Routing to the information_schema_columns view was already established by
Task 4's SchemaPrefixTranslator rewrite, so this commit is dead-code
removal: the query_interceptor.rs dispatch arm, the
handle_information_schema_columns_query_with_session handler, and
map_sqlite_type_to_pg_column_info (superseded by
catalog::column_type_info::pg_column_info). Also removed two logically
unreachable duplicate call sites in session/db_handler.rs (guarded by
!contains("information_schema") while checking
contains("information_schema.columns")), and the unused
extract_table_name_filters helper, which had no live callers and was the
source of the one warning this branch's Task 4 introduced.

Adds three tests to information_schema_namespace_test.rs covering type
fidelity, column modifiers/defaults, and internal-relation filtering.
A future migration that adds a catalog relation without updating
internal_relations.rs would silently leak it into information_schema.tables
as a user table. Fail in CI instead.

SQLAlchemy suite (tests/python/run_sqlalchemy_tests.sh) not run: poetry 2.4.1
in this environment rejects `poetry config virtualenvs.prefer-active-python`,
a config key removed from Poetry 2.x, so the script's environment setup step
fails before any tests run. Not a regression from this change; full Rust
suite (1332 passed) and manual psql reproduction of the issue both pass.

Closes #88.
SchemaPrefixTranslator::translate_query rewrote schema qualifiers with a
blind String::replace, which had two consequences.

Matches inside SQL string literals were rewritten, so a query storing or
comparing SQL text got corrupted values and silently wrong results:

  SELECT 'information_schema.tables'  -> 'information_schema_tables'
  WHERE msg = 'see pg_catalog.pg_class ...'  -> never matches

And the rewrites were case-sensitive while the catalog interceptor's gate
is case-insensitive, so a mixed-case qualifier entered the catalog path
and then fell through untranslated:

  FROM Information_Schema.Tables  -> ERROR: no such table

Both are fixed by replace_outside_literals: a single left-to-right scan
that matches case-insensitively and skips single-quoted literals (honoring
the doubled-quote escape) and double-quoted identifiers. Applied to every
rewrite pair in the function, pg_catalog included -- the literal defect
predates this branch there. The separate all-uppercase replace calls are
now redundant and removed.

Also amend v29's information_schema_columns view: numeric_precision_radix
switches on the column's data type rather than emitting 10 for every
non-NULL precision, so integer reports 2 and numeric reports 10, matching
PostgreSQL's _pg_numeric_precision_radix. v29 is unshipped, so it is
amended in place; the view keeps its 44 columns and their order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…input

The scanner does not recognize -- or /* */, so an apostrophe inside a
comment would leave it treating the rest of the query as one string
literal and silently skip every real qualifier after it. strip_sql_comments
runs ahead of the translator on both entry paths and is itself
literal-aware, which is what makes that unreachable today.

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.

information_schema.tables lists pgsqlite's materialized pg_* and information_schema_* relations

1 participant