diff --git a/graphify/extractors/sql.py b/graphify/extractors/sql.py index 602f89b0b..d250956e0 100644 --- a/graphify/extractors/sql.py +++ b/graphify/extractors/sql.py @@ -486,6 +486,32 @@ 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", line) + elif t == "create_index": + # CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] + # ON (...). Unlike CREATE POLICY (#3401) the grammar + # parses this statement fine; the walk simply never dispatched on + # it, so every index was silently dropped (#3467). The name is the + # identifier (or a quoted literal) before ON; the table is the + # object_reference after it. An unnamed index (`CREATE INDEX ON + # t (c)`) has nothing to name a node after and is skipped. + index_name: str | None = None + index_table: str | None = None + after_on = False + for c in node.children: + if c.type == "keyword_on": + after_on = True + elif not after_on and index_name is None and c.type in ("identifier", "literal"): + index_name = _read(c).strip('"`') + elif after_on and index_table is None and c.type == "object_reference": + index_table = _read(c) + if index_name: + index_nid = _make_id(stem, index_name) + _add_node(index_nid, index_name, line) + if index_table: + index_tbl_nid = (table_nids.get(_norm_ident(index_table)) + or _ref_stub(index_table)) + _add_edge(index_nid, index_tbl_nid, "indexes", line) + # NOTE: there is deliberately NO recovery scan on individual ERROR # nodes. Any ERROR node anywhere makes root.has_error true, so the # whole-file masked scan below this walk already recovers everything diff --git a/tests/test_multilang.py b/tests/test_multilang.py index c7ef4605e..7ed9c6f98 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -531,6 +531,41 @@ def test_sql_no_dangling_edges(): for e in r["edges"]: assert e["source"] in node_ids, f"dangling source: {e['source']}" +def test_sql_create_index_emits_index_node_linked_to_its_table(tmp_path): + """#3467: CREATE [UNIQUE] INDEX parsed fine but the walk never dispatched + on create_index, so every index was silently dropped.""" + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "schema.sql" + p.write_text( + "CREATE TABLE public.profiles (id uuid PRIMARY KEY, owner uuid NOT NULL);\n" + "CREATE INDEX profiles_owner_idx ON public.profiles (owner);\n" + "CREATE UNIQUE INDEX IF NOT EXISTS profiles_id_uniq ON public.profiles (id);\n" + "CREATE INDEX CONCURRENTLY orders_customer_idx ON public.orders (customer_id);\n" + 'CREATE INDEX "quoted idx" ON public.profiles (owner);\n' + "CREATE INDEX ON public.profiles (owner);\n", + encoding="utf-8", + ) + r = extract_sql(p) + by_label = {n["label"]: n for n in r["nodes"]} + for name in ("profiles_owner_idx", "profiles_id_uniq", "orders_customer_idx", "quoted idx"): + assert name in by_label, name + assert by_label[name]["source_file"] == str(p) + edges = {(e["source"], e["relation"], e["target"]) for e in r["edges"]} + profiles = by_label["public.profiles"]["id"] + assert (by_label["profiles_owner_idx"]["id"], "indexes", profiles) in edges + assert (by_label["profiles_id_uniq"]["id"], "indexes", profiles) in edges + assert (by_label["quoted idx"]["id"], "indexes", profiles) in edges + # An index on a table defined in another file links to a sourceless stub, + # the same way a trigger does (#2324). + orders = by_label["public.orders"] + assert orders["source_file"] == "" + assert (by_label["orders_customer_idx"]["id"], "indexes", orders["id"]) in edges + # The unnamed index is skipped and 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"]) + assert sum(1 for e in r["edges"] if e["relation"] == "indexes") == 4 + + def test_sql_tsql_bracketed_procedure_is_recovered(tmp_path): """T-SQL CREATE PROCEDURE [Schema].[Name] ... AS BEGIN...END.