fix: namespace-aware information_schema served from SQLite (#88) - #103
Merged
Conversation
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>
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #88.
Problem
information_schema.tablesreported pgsqlite's own materialized catalog relations as user tables inpublic:28 internal relations alongside the one real table, so Django
inspectdband SQLAlchemyautomapwould generate models forpg_constraintand friends.Real PostgreSQL returns these rows too — a stock cluster has ~180 — but under
table_schema = 'pg_catalog'/'information_schema'. Clients filter ontable_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:2059is inside the columns handler). The live path washandle_information_schema_tables_query, which ranwith no catalog filtering, emitting
"public"for every row. Migration v28 (#87) had already assigned these relations to namespace 11/13000 inpg_class— which is why\dtwas clean — but theinformation_schema_tablesview hardcoded'public' as table_schemaand the Rust handler bypassed both.The handlers were stale in the same way #87 found in
PgClassHandler. Measured before this change:SELECT table_name FROM information_schema.tables ORDER BY 1ORDER BYignoredSELECT count(*) FROM information_schema.tablesSELECT DISTINCT table_name FROM information_schema.columnsDISTINCTignoredWHEREsupport extended only to equality ontable_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 namedpg_myreportintopg_catalog— hiding it from ORM introspection, a regression on current behavior where it is at least visible. It also under-matches: eight migration-createdidx_*indexes match nopg_%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_relnamespaceUDF. Returns2200(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.rs—pg_column_info()plus four UDFs. The deleted handler resolvedcharacter_maximum_length/numeric_precision/numeric_scaleprivately; all three were NULL in the view, so deleting the handler without replacing them would have regressedVARCHAR(50)to no length andNUMERIC(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.information_schema_columnsis built directly onsqlite_master/pragma_table_info/__pgsqlite_schemarather than layering onpg_attribute, which keeps it off a view every ORM reads for column reflection.SchemaPrefixTranslatorrewrites both relations to their views; the stale handlers and their helpers are deleted.Type fidelity
Routing
columnsto the old view as-is would have regressed six of eight columns. Measured onCREATE TABLE fidelity (id SERIAL PRIMARY KEY, amount NUMERIC(10,2), uid UUID, doc JSONB, tags TEXT[], ts TIMESTAMPTZ, flag BOOLEAN, nick VARCHAR(50)):idSERIALtext✗integer✓integeramountNUMERIC(10,2)numeric✓text✗numericuidUUIDuuid✓text✗uuiddocJSONBjsonb✓text✗jsonbtagsTEXT[]text✗text✗ARRAYtsTIMESTAMPTZtext✗integer✗✗timestamp with time zoneflagBOOLEANboolean✓integer✗booleannickVARCHAR(50)character varying✓text✗character varyingThe old view leaking
integerforTIMESTAMPTZwould 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::replace, soSELECT 'information_schema.tables'came back mangled andWHERE 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 thepg_catalog.rewrites shipped in \dt reports "Did not find any tables" on a database that has tables #87.Information_Schema.TablesandINFORMATION_SCHEMA.tables— a spelling JDBC-derived tooling emits — hard-errored withno 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 thepg_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 witheq_ignore_ascii_caseinstead of building a lowercased copy.numeric_precision_radixalso now reports 2 for binary-precision types and 10 only fornumeric/decimal, matching_pg_numeric_precision_radix().Testing
1341 passed / 0 failed.
cargo checkandcargo clippywarning 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, andnumeric_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.rsandinformation_schema_comprehensive_test.rsare unmodified and still pass.The SQLAlchemy suite could not be run:
tests/python/run_sqlalchemy_tests.shaborts because Poetry 2.4.1 rejects thevirtualenvs.prefer-active-pythonkey it sets. Pre-existing (introduced in 2482469, #53), unrelated to these changes.Deliberately out of scope
pg_classkeeps v28'sLIKE 'pg\_%'heuristic andpg_attribute.atttypidis 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_myreportreportspublicininformation_schema.tablesbut is still misfiled underpg_cataloginpg_class.user_table_named_like_a_catalog_relation_is_visibleasserts 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