From 35665a76ba26da0e1bfcab074fede19c94fc5c89 Mon Sep 17 00:00:00 2001 From: rithyKabir Date: Fri, 10 Jul 2026 11:12:28 +0100 Subject: [PATCH] fix(pg_introspect): read FKs from pg_constraint so read-only roles get references edges (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-introspection FK query joined information_schema.referential_ constraints, which Postgres only exposes for constraints where the current user has WRITE access to the referencing table. A read-only introspection role therefore got zero FK rows — while tables/views/routines still appeared (SELECT is enough for those views) — so the graph silently lost every `references` edge, contradicting the documented FK-mapping behavior. Switch to pg_catalog.pg_constraint (world-readable, not privilege-filtered), keyed by constraint oid rather than name — which also fixes a latent bug where same-named constraints on sibling tables could cross-match in the old name-based key_column_usage joins. Composite-FK column order is preserved with UNNEST(conkey/confkey) WITH ORDINALITY. Mock test asserts the query targets pg_constraint and not the privilege-filtered view, plus composite-FK ordering. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ graphify/pg_introspect.py | 59 +++++++++++++++++++++---------------- tests/test_pg_introspect.py | 41 +++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a397bb1eb..057e32f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.12 (unreleased) +- Fix: live PostgreSQL introspection (`--postgres`) now emits foreign-key `references` edges under a read-only role (#1746, thanks @rithyKabir). The FK query read `information_schema.referential_constraints`, which is privilege-filtered — a role with only SELECT sees zero FK rows while tables/views/routines still appear, so every `references` edge silently vanished. It now reads the world-readable `pg_catalog.pg_constraint` (keyed by oid, which also fixes same-named constraints on sibling tables cross-matching in the old name-based joins), preserving composite-FK column order via `UNNEST ... WITH ORDINALITY`. + - Fix: `json_config` no longer emits `imports`/`extends` edges to node IDs it never creates (#1764, thanks @oleksii-tumanov). `package.json` dependencies and `tsconfig.json` `extends`/`$ref` targets produced edges whose endpoint node was absent, so `build_from_json` silently dropped them (the "no matching node id" case is filtered out of real errors) — losing dependency/extends structure on two of the most common files in any JS/TS repo. The extractor now creates the referenced target as a `concept` node before adding the edge. - Fix: `graphify update` no longer deletes semantic hyperedges on every run (#1755, thanks @oleksii-tumanov). The AST-only rebuild treated every rebuilt corpus file as grounds to evict hyperedges anchored to it, but the AST pass never re-emits hyperedges, so doc-sourced hyperedges (exactly what semantic extraction produces) were permanently lost on the first `update` after a full build — even a no-op run. Hyperedge eviction is now scoped to genuinely deleted (or symlink-outside) sources, mirroring node/edge handling; replacement-by-id and dangling-member cleanup are unchanged. diff --git a/graphify/pg_introspect.py b/graphify/pg_introspect.py index 24567fa18..dc7b2bbf8 100644 --- a/graphify/pg_introspect.py +++ b/graphify/pg_introspect.py @@ -58,33 +58,42 @@ def introspect_postgres(dsn: str | None = None) -> dict: """) routines = cur.fetchall() - # 4. Query foreign keys — grouped by constraint to handle composites + # 4. Query foreign keys — grouped by constraint to handle composites. + # Read pg_catalog.pg_constraint, NOT information_schema.referential_ + # constraints: that view only shows constraints where the current + # user has WRITE access to the referencing table (owner or a + # privilege other than SELECT), so a read-only introspection role + # sees zero FK rows while tables/views/routines all appear — the + # graph then silently loses every 'references' edge (#1746). + # pg_constraint is not privilege-filtered. It also keys constraints + # by oid rather than by name (constraint names are only unique per + # table, so the old name-based key_column_usage joins could + # cross-match same-named constraints on sibling tables). cur.execute(""" SELECT - tc.constraint_name, - kcu1.table_schema, - kcu1.table_name, - ARRAY_AGG(kcu1.column_name ORDER BY kcu1.ordinal_position) AS columns, - kcu2.table_schema AS foreign_table_schema, - kcu2.table_name AS foreign_table_name, - ARRAY_AGG(kcu2.column_name ORDER BY kcu2.ordinal_position) AS foreign_columns - FROM - information_schema.table_constraints AS tc - JOIN information_schema.referential_constraints AS rc - ON tc.constraint_name = rc.constraint_name - AND tc.table_schema = rc.constraint_schema - JOIN information_schema.key_column_usage AS kcu1 - ON tc.constraint_name = kcu1.constraint_name - AND tc.table_schema = kcu1.table_schema - JOIN information_schema.key_column_usage AS kcu2 - ON rc.unique_constraint_name = kcu2.constraint_name - AND rc.unique_constraint_schema = kcu2.table_schema - AND kcu1.position_in_unique_constraint = kcu2.ordinal_position - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema NOT IN ('pg_catalog', 'information_schema') - GROUP BY tc.constraint_name, kcu1.table_schema, kcu1.table_name, - kcu2.table_schema, kcu2.table_name - ORDER BY kcu1.table_schema, kcu1.table_name; + con.conname AS constraint_name, + ns.nspname AS table_schema, + rel.relname AS table_name, + (SELECT ARRAY_AGG(att.attname ORDER BY k.ord) + FROM UNNEST(con.conkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_catalog.pg_attribute att + ON att.attrelid = con.conrelid AND att.attnum = k.attnum + ) AS columns, + fns.nspname AS foreign_table_schema, + frel.relname AS foreign_table_name, + (SELECT ARRAY_AGG(att.attname ORDER BY k.ord) + FROM UNNEST(con.confkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_catalog.pg_attribute att + ON att.attrelid = con.confrelid AND att.attnum = k.attnum + ) AS foreign_columns + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace + JOIN pg_catalog.pg_class frel ON frel.oid = con.confrelid + JOIN pg_catalog.pg_namespace fns ON fns.oid = frel.relnamespace + WHERE con.contype = 'f' + AND ns.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY ns.nspname, rel.relname, con.conname; """) fks = cur.fetchall() finally: diff --git a/tests/test_pg_introspect.py b/tests/test_pg_introspect.py index a133cdad3..919d7ba40 100644 --- a/tests/test_pg_introspect.py +++ b/tests/test_pg_introspect.py @@ -24,6 +24,8 @@ def _make_mock_psycopg(tables, views, routines, fks, ``connect_raises``, if set, is an exception *instance* raised by connect(). """ + executed_queries: list[str] = [] + class MockCursor: def __enter__(self): return self @@ -33,6 +35,7 @@ def _make_mock_psycopg(tables, views, routines, fks, def execute(self, query, params=None): self.query = query + executed_queries.append(query) def fetchall(self): q = self.query.strip().lower() @@ -42,7 +45,7 @@ def _make_mock_psycopg(tables, views, routines, fks, return views elif "information_schema.routines" in q: return routines - elif "information_schema.referential_constraints" in q: + elif "pg_constraint" in q: return fks return [] @@ -75,6 +78,7 @@ def _make_mock_psycopg(tables, views, routines, fks, "host": host, "dbname": dbname, } + mock_psycopg._executed_queries = executed_queries return mock_psycopg @@ -244,6 +248,41 @@ def test_pg_introspect_composite_fk(): ) +def test_pg_introspect_fk_query_avoids_privilege_filtered_view(): + """#1746: information_schema.referential_constraints only shows constraints + where the current user has WRITE access to the referencing table (owner or + a privilege other than SELECT). A read-only introspection role therefore + gets zero FK rows — while tables/views/routines all still appear, since + SELECT is enough for those views — and the graph silently loses every + 'references' edge. The FK query must read pg_catalog.pg_constraint, which + is not privilege-filtered.""" + mock_tables = [ + ("public", "users", "BASE TABLE"), + ("public", "orders", "BASE TABLE"), + ] + mock_fks = [ + ("fk_orders_user_id", "public", "orders", ["user_id"], "public", "users", ["id"]), + ] + + mock_psycopg = _make_mock_psycopg(mock_tables, [], [], mock_fks) + + with patch.dict("sys.modules", {"psycopg": mock_psycopg}): + res = introspect_postgres("postgresql://readonly:secret@myhost/mydb") + + constraint_queries = [ + q for q in mock_psycopg._executed_queries if "constraint" in q.lower() + ] + assert constraint_queries, "no FK query was executed" + assert all( + "referential_constraints" not in q.lower() for q in constraint_queries + ), "FK query must not read information_schema.referential_constraints (privilege-filtered, #1746)" + assert any("pg_constraint" in q.lower() for q in constraint_queries) + + # And the FK still becomes a references edge end-to-end + ref_edges = [e for e in res["edges"] if e["relation"] == "references"] + assert len(ref_edges) == 1 + + def test_pg_introspect_connection_error(): """A psycopg.OperationalError must be re-raised as ConnectionError with a sanitized message (no DSN/credentials) and no stack-trace noise."""