diff --git a/graphify/extractors/sql.py b/graphify/extractors/sql.py index c9dac2eae..614d5fba0 100644 --- a/graphify/extractors/sql.py +++ b/graphify/extractors/sql.py @@ -59,6 +59,22 @@ _TRIGGER_RECOVERY_RX = re.compile( re.IGNORECASE | re.DOTALL, ) +# CREATE TABLE recovery, sibling to the routine/trigger patterns above. A +# column default the grammar cannot model — MySQL/MariaDB +# `DEFAULT current_timestamp()` / `ON UPDATE current_timestamp()`, whose +# parentheses tree-sitter-sql rejects (upstream DerekStride/tree-sitter-sql) +# — puts the whole statement in an ERROR node, so walk() never dispatches on +# it and the table vanished, leaving only the sourceless stub that other +# tables' REFERENCES minted for it (#4613). The name atom is _TRIGGER_IDENT +# (the same delimited-identifier shape); the label is the bare name, with no +# `()` suffix — a table is not callable either. +_TABLE_RECOVERY_RX = re.compile( + r"\bCREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?TABLE\s+" + r"(?:IF\s+NOT\s+EXISTS\s+)?" + r"(" + _TRIGGER_IDENT + r")", + re.IGNORECASE, +) + # _mask_sql_comments is a linear character scanner, not a regex: the four # span kinds interact in ways a single pattern cannot express safely — # comment-opener parity inside strings, nested block comments, and @@ -813,6 +829,8 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: for child in node.children: _walk_from_refs(child, caller_nid, line, cte_names) + src_text = source.decode("utf-8", errors="replace") + # Pre-pass: register every table/view DEFINED in this file before walking, # so forward references (a FK to a table created later in the same file) # still resolve to the real sourced node instead of falling back to a stub. @@ -826,6 +844,21 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: _collect_defined_names(root) + # A CREATE TABLE the grammar failed on (#4613) never reaches the pre-pass + # — its statement sits inside an ERROR node — so references to it would + # mint sourceless stubs and the REFERENCES fallback below would skip it. + # When the parse failed, pre-register the recovered table names from the + # masked source so resolution sees the real id; the node itself is + # emitted in the has_error fallback, alongside the recovered routines + # and triggers. + if root.has_error: + _masked, _ident_spans = _scan_sql(src_text) + for m in _TABLE_RECOVERY_RX.finditer(_masked): + if any(s <= m.start() < e for s, e in _ident_spans): + continue + table_nids.setdefault( + _norm_ident(m.group(1)), _make_id(stem, m.group(1))) + # Secondary bare-name aliases: a reference written without a schema # (`REFERENCES users`) should resolve to a schema-qualified definition # (`public.users`) when that is unambiguous. Never shadow an explicit @@ -861,7 +894,6 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: # (e.g. Firebird COMPUTED BY columns push constraints out of the tree entirely). # Snapshot after tree walk so we don't re-emit edges already captured above. emitted = {(e["source"], e["target"]) for e in edges if e["relation"] == "references"} - src_text = source.decode("utf-8", errors="replace") for m in re.finditer(r"CREATE\s+TABLE\s+([\w$]+)\s*\(", src_text, re.IGNORECASE): tbl_name = m.group(1) tbl_nid = table_nids.get(_norm_ident(tbl_name)) @@ -946,4 +978,14 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: tbl_nid = table_nids.get(_norm_ident(tbl_name)) or _ref_stub(tbl_name) _add_edge(trig_nid, tbl_nid, "triggers", trig_line) + # Tables the grammar dropped into an ERROR node (#4613), pre-registered + # above so references already resolved to this id. _add_node dedupes, + # so a table that parsed cleanly keeps its walk-emitted node. + for m in _TABLE_RECOVERY_RX.finditer(masked_src): + if any(s <= m.start() < e for s, e in ident_spans): + continue + tbl_name = m.group(1) + tbl_line = src_text[: m.start()].count("\n") + 1 + _add_node(_make_id(stem, tbl_name), tbl_name, tbl_line) + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 0ec6eaaf2..7ad76b793 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -1046,6 +1046,61 @@ def test_sql_procedural_body_trigger_is_recovered(tmp_path): assert all(e["source"] in node_ids and e["target"] in node_ids for e in r["edges"]) +def test_sql_mysql_current_timestamp_parens_table_is_recovered(tmp_path): + """MySQL/MariaDB `DEFAULT current_timestamp()` / `ON UPDATE + current_timestamp()`: tree-sitter-sql rejects the parentheses, so the + whole CREATE TABLE lands in an ERROR node and the table was dropped — + the only trace left was the sourceless stub minted by other tables' + REFERENCES to it (#4613). + """ + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "repro.sql" + p.write_text( + "CREATE TABLE foo (\n" + " created_at datetime NOT NULL DEFAULT current_timestamp(),\n" + " updated_at datetime DEFAULT NULL ON UPDATE current_timestamp(),\n" + " KEY idx_x (created_by)\n" + ");\n" + "\n" + "CREATE TABLE after_table (\n" + " id int NOT NULL,\n" + " foo_id int REFERENCES foo (id)\n" + ");\n", + encoding="utf-8", + ) + r = extract_sql(p) + by_label = {n["label"]: n for n in r["nodes"]} + assert sorted(by_label) == ["after_table", "foo", "repro.sql"], sorted(by_label) + # The recovered table is a real sourced node, not the sourceless stub a + # REFERENCES would otherwise mint for it. + assert by_label["foo"]["source_file"] == str(p) + # ... so the FK from after_table resolves onto it. + edges = {(e["source"], e["relation"], e["target"]) for e in r["edges"]} + assert (by_label["after_table"]["id"], "references", by_label["foo"]["id"]) in edges + 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_commented_out_create_table_is_not_recovered(tmp_path): + """The table recovery runs over the comment-masked source: a commented-out + CREATE TABLE in an error-bearing file must not fabricate a node. + """ + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "schema.sql" + p.write_text( + "-- CREATE TABLE ghost (\n" + "-- id int\n" + "-- );\n" + "CREATE TABLE foo (\n" + " created_at datetime NOT NULL DEFAULT current_timestamp()\n" + ");\n", + encoding="utf-8", + ) + r = extract_sql(p) + labels = sorted(n["label"] for n in r["nodes"]) + assert labels == ["foo", "schema.sql"], labels + + def test_sql_tsql_bracketed_procedure_is_recovered(tmp_path): """T-SQL CREATE PROCEDURE [Schema].[Name] ... AS BEGIN...END.