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:
rajatnagda45 committed 2026-10-05 09:01:14 +05:30
1 parent 35adf432b9
commit ea39929459
2 files changed
+74

No files matched your search

+23
View File
@@ -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>
+51
View File
@@ -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