diff --git a/CHANGELOG.md b/CHANGELOG.md index f797354ec..c7c70292b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.57 (unreleased) +- Fix: `CREATE INDEX` / `CREATE UNIQUE INDEX` statements now produce index nodes linked to their table by an `indexes` edge — the grammar parsed them, but the SQL walk never dispatched on `create_index`, so every index was silently dropped (#3467, thanks @lrafasouza). - Fix: an incremental rebuild no longer wipes cross-file project AST nodes — re-extracting one `.csproj`/`.sln` was dropping package/framework nodes of a *referenced* project (whose stub carried the referenced file's `source_file`); the AST-replacement set is now derived from the files actually extracted (#3411, thanks @hopstreax). - Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07). - Fix: a C# generic call site with explicit type arguments — `Get(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get` as the callee and failing to match (#3406, thanks @abhay-codes07). 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 walk(node) -> None: 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.