fix(pg_introspect): read FKs from pg_constraint so read-only roles get references edges (#1746)

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) <noreply@anthropic.com>
This commit is contained in:
rithyKabir
2026-07-10 11:12:28 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent d68bf2819c
commit 35665a76ba
3 changed files with 76 additions and 26 deletions
+2
View File
@@ -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.
+34 -25
View File
@@ -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:
+40 -1
View File
@@ -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."""