diff --git a/graphify/extractors/sql.py b/graphify/extractors/sql.py index 171baad80..e0c2f6527 100644 --- a/graphify/extractors/sql.py +++ b/graphify/extractors/sql.py @@ -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] diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 0abe9e284..a6fb1b64a 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -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