mirror of
https://github.com/safishamsi/graphify.git
synced 2026-11-03 12:33:37 +00:00
Recover CREATE TABLE statements dropped into ERROR nodes by unsupported column defaults (fixes #4613)
(cherry picked from commit d496e9113e)
This commit is contained in:
1 parent
8a29ab62cb
commit
e69cc9d89e
2 files changed
+98
-1
No files matched your search
@@ -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}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user