mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-23 22:15:46 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
d68bf2819c
commit
35665a76ba
@@ -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
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user