Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get<int>` as the callee and failing to match (#3406, thanks @abhay-codes07).
Expand Down
26 changes: 26 additions & 0 deletions graphify/extractors/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] <name>
# ON <table> (...). 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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down