mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 00:19:33 +00:00
fix(sql): link a trigger to the function it executes
A trigger exists to run a routine, but the create_trigger branch only linked the trigger to its ON table and ignored the EXECUTE FUNCTION/PROCEDURE clause entirely. The routine a trigger fires — its whole reason to exist — was never represented in the graph. Register function/procedure node ids in a routine_nids map (mirroring table_nids) and, in create_trigger, read the object_reference after EXECUTE and emit an `executes` edge to that routine. Resolution is name-based against routines defined in the same file (a trigger's function is declared before it); when the routine is not in this file the edge is skipped rather than guessing a cross-file target or minting a bare-name stub that could never rewire onto a `name()` function node. Add regression tests for the resolved case and the fail-closed case.
This commit is contained in:
1 parent
35adf432b9
commit
ea39929459
2 files changed
+74
No files matched your search
@@ -328,6 +328,7 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = {file_nid}
|
||||
table_nids: dict[str, str] = {} # name → nid for reference resolution
|
||||
routine_nids: dict[str, str] = {} # function/procedure name → nid, for trigger links
|
||||
|
||||
def _read(n) -> str:
|
||||
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
|
||||
@@ -448,6 +449,7 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
_add_node(nid, f"{name}()", line)
|
||||
routine_nids[_norm_ident(name)] = nid
|
||||
_walk_from_refs(node, nid, line)
|
||||
|
||||
elif t == "create_procedure":
|
||||
@@ -455,6 +457,7 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
_add_node(nid, f"{name}()", line)
|
||||
routine_nids[_norm_ident(name)] = nid
|
||||
_walk_from_refs(node, nid, line)
|
||||
|
||||
elif t == "alter_table":
|
||||
@@ -487,8 +490,10 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
|
||||
elif t == "create_trigger":
|
||||
trig_name: str | None = None
|
||||
tbl_name: str | None = None
|
||||
exec_name: str | None = None
|
||||
after_trigger = False
|
||||
after_on = False
|
||||
after_execute = False
|
||||
for c in node.children:
|
||||
if c.type == "keyword_trigger":
|
||||
after_trigger = True
|
||||
@@ -501,12 +506,30 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
|
||||
after_on = True
|
||||
elif after_on and not tbl_name and c.type == "object_reference":
|
||||
tbl_name = _read(c)
|
||||
# The routine the trigger runs follows EXECUTE
|
||||
# FUNCTION/PROCEDURE (`... EXECUTE FUNCTION log_it()`). The ON
|
||||
# table is captured before EXECUTE appears, so this reads the
|
||||
# routine, not the table.
|
||||
elif c.type == "keyword_execute":
|
||||
after_execute = True
|
||||
elif after_execute and not exec_name and c.type == "object_reference":
|
||||
exec_name = _read(c)
|
||||
if trig_name:
|
||||
trig_nid = _make_id(stem, trig_name)
|
||||
_add_node(trig_nid, trig_name, line)
|
||||
if tbl_name:
|
||||
tbl_nid = table_nids.get(_norm_ident(tbl_name)) or _ref_stub(tbl_name)
|
||||
_add_edge(trig_nid, tbl_nid, "triggers", line)
|
||||
# Link the trigger to the function it executes. A trigger exists
|
||||
# to run that routine, yet the edge was never emitted. Resolve
|
||||
# name-based against routines defined in this file (a trigger's
|
||||
# function is declared before it); fail closed when the routine
|
||||
# is not in this file rather than guess a cross-file target or
|
||||
# mint a bare-name stub that cannot rewire onto a `name()` node.
|
||||
if exec_name:
|
||||
routine_nid = routine_nids.get(_norm_ident(exec_name))
|
||||
if routine_nid:
|
||||
_add_edge(trig_nid, routine_nid, "executes", line)
|
||||
|
||||
elif t == "create_index":
|
||||
# CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] <name>
|
||||
|
||||
@@ -859,6 +859,57 @@ def test_sql_clean_trigger_links_to_its_on_table(tmp_path):
|
||||
assert (by_label["audit_ins"]["id"], "triggers", by_label["users"]["id"]) in edges
|
||||
|
||||
|
||||
def test_sql_trigger_links_to_the_function_it_executes(tmp_path):
|
||||
"""A trigger exists to run a function, yet the EXECUTE FUNCTION/PROCEDURE
|
||||
target was never linked. Only the ON table got an edge, so the trigger's
|
||||
whole reason to exist — the routine it fires — was dropped from the graph.
|
||||
"""
|
||||
pytest.importorskip("tree_sitter_sql")
|
||||
p = tmp_path / "schema.sql"
|
||||
p.write_text(
|
||||
"CREATE TABLE users (id INT);\n"
|
||||
"CREATE FUNCTION log_it() RETURNS TRIGGER AS 'SELECT 1';\n"
|
||||
"CREATE TRIGGER audit_ins AFTER INSERT ON users\n"
|
||||
" FOR EACH ROW EXECUTE FUNCTION log_it();\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_sql(p)
|
||||
by_label = {n["label"]: n for n in r["nodes"]}
|
||||
assert "audit_ins" in by_label and "log_it()" in by_label
|
||||
edges = {(e["source"], e["relation"], e["target"]) for e in r["edges"]}
|
||||
# The trigger fires on its ON table ...
|
||||
assert (by_label["audit_ins"]["id"], "triggers", by_label["users"]["id"]) in edges
|
||||
# ... and executes the function it names.
|
||||
assert (by_label["audit_ins"]["id"], "executes", by_label["log_it()"]["id"]) in edges
|
||||
# Nothing dangles.
|
||||
node_ids = {n["id"] for n in r["nodes"]}
|
||||
assert all(e["source"] in node_ids and e["target"] in node_ids for e in r["edges"])
|
||||
|
||||
|
||||
def test_sql_trigger_executing_an_undefined_function_fails_closed(tmp_path):
|
||||
"""When the executed routine is not defined in this file, link nothing
|
||||
rather than mint a bare-name stub that could never rewire onto the real
|
||||
`name()` function node. The ON-table edge is unaffected.
|
||||
"""
|
||||
pytest.importorskip("tree_sitter_sql")
|
||||
p = tmp_path / "schema.sql"
|
||||
p.write_text(
|
||||
"CREATE TABLE users (id INT);\n"
|
||||
"CREATE TRIGGER audit_ins AFTER INSERT ON users EXECUTE FUNCTION elsewhere();\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_sql(p)
|
||||
by_label = {n["label"]: n for n in r["nodes"]}
|
||||
assert (by_label["audit_ins"]["id"], "triggers", by_label["users"]["id"]) in {
|
||||
(e["source"], e["relation"], e["target"]) for e in r["edges"]
|
||||
}
|
||||
# No executes edge, and the unresolved routine was not minted as a node.
|
||||
assert not any(e["relation"] == "executes" for e in r["edges"])
|
||||
assert "elsewhere()" not in by_label
|
||||
node_ids = {n["id"] for n in r["nodes"]}
|
||||
assert all(e["source"] in node_ids and e["target"] in node_ids for e in r["edges"])
|
||||
|
||||
|
||||
def test_sql_procedural_body_trigger_is_recovered(tmp_path):
|
||||
"""A trigger with a `FOR EACH ROW BEGIN ... END` body has no grammar parse,
|
||||
so the statement lands in ERROR recovery. TRIGGER was excluded from the
|
||||
|
||||
Reference in new issue
Block a user