sql-code-graph 1.0.2__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/METADATA +13 -2
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/RECORD +29 -27
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +115 -17
- sqlcg/cli/commands/db.py +17 -0
- sqlcg/cli/commands/find.py +7 -0
- sqlcg/cli/commands/git.py +61 -11
- sqlcg/cli/commands/index.py +30 -2
- sqlcg/cli/commands/mcp.py +103 -0
- sqlcg/cli/commands/reindex.py +122 -12
- sqlcg/core/config.py +80 -0
- sqlcg/core/freshness.py +134 -0
- sqlcg/core/graph_db.py +2 -0
- sqlcg/core/queries.cypher +24 -6
- sqlcg/core/queries.py +4 -1
- sqlcg/core/schema.cypher +13 -1
- sqlcg/core/schema.py +5 -1
- sqlcg/indexer/indexer.py +376 -160
- sqlcg/indexer/walker.py +3 -0
- sqlcg/parsers/ansi_parser.py +56 -0
- sqlcg/parsers/base.py +6 -3
- sqlcg/parsers/snowflake_parser.py +46 -6
- sqlcg/server/control.py +144 -0
- sqlcg/server/models.py +68 -0
- sqlcg/server/server.py +283 -1
- sqlcg/server/skill.py +20 -4
- sqlcg/server/tools.py +203 -13
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/entry_points.txt +0 -0
sqlcg/indexer/indexer.py
CHANGED
|
@@ -4,10 +4,11 @@ import multiprocessing as mp
|
|
|
4
4
|
import queue
|
|
5
5
|
import time
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
10
|
+
from sqlcg.core.config import get_external_consumers, get_presentation_prefixes
|
|
9
11
|
from sqlcg.core.graph_db import GraphBackend
|
|
10
|
-
from sqlcg.core.queries import STALE_VIEWS_QUERY
|
|
11
12
|
from sqlcg.core.schema import NodeLabel, RelType
|
|
12
13
|
from sqlcg.indexer.error_classify import _classify_error, dominant_cause
|
|
13
14
|
from sqlcg.indexer.pool import HardKillPool
|
|
@@ -22,6 +23,141 @@ from sqlcg.utils.logging import getLogger
|
|
|
22
23
|
logger = getLogger(__name__)
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileRowSet:
|
|
28
|
+
"""Row dicts produced from a single ParsedFile (Phase A output).
|
|
29
|
+
|
|
30
|
+
Pure data container — no db access, no execute(). Used as the unit
|
|
31
|
+
passed from _build_file_rows to BatchRowBuffer.extend().
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
file_rows: list[dict] = field(default_factory=list)
|
|
35
|
+
table_rows: list[dict] = field(default_factory=list)
|
|
36
|
+
column_rows: list[dict] = field(default_factory=list)
|
|
37
|
+
query_rows: list[dict] = field(default_factory=list)
|
|
38
|
+
defined_in_edges: list[dict] = field(default_factory=list)
|
|
39
|
+
has_column_edges: list[dict] = field(default_factory=list)
|
|
40
|
+
query_defined_in_edges: list[dict] = field(default_factory=list)
|
|
41
|
+
selects_from_edges: list[dict] = field(default_factory=list)
|
|
42
|
+
column_lineage_edges: list[dict] = field(default_factory=list)
|
|
43
|
+
star_source_edges: list[dict] = field(default_factory=list)
|
|
44
|
+
counts: dict = field(
|
|
45
|
+
default_factory=lambda: {"tables": 0, "edges": 0, "columns_defined": 0, "star_sources": 0}
|
|
46
|
+
)
|
|
47
|
+
parse_quality_key: str = "full"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class BatchRowBuffer:
|
|
52
|
+
"""Accumulated row dicts across all files in a batch.
|
|
53
|
+
|
|
54
|
+
Used by _flush_row_batch to dedup and flush once per batch instead of
|
|
55
|
+
once per file — the v1.1.1 perf improvement (~13,400 → ~270 execute()).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
file_rows: list[dict] = field(default_factory=list)
|
|
59
|
+
table_rows: list[dict] = field(default_factory=list)
|
|
60
|
+
column_rows: list[dict] = field(default_factory=list)
|
|
61
|
+
query_rows: list[dict] = field(default_factory=list)
|
|
62
|
+
defined_in_edges: list[dict] = field(default_factory=list)
|
|
63
|
+
has_column_edges: list[dict] = field(default_factory=list)
|
|
64
|
+
query_defined_in_edges: list[dict] = field(default_factory=list)
|
|
65
|
+
selects_from_edges: list[dict] = field(default_factory=list)
|
|
66
|
+
column_lineage_edges: list[dict] = field(default_factory=list)
|
|
67
|
+
star_source_edges: list[dict] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def extend(self, rows: FileRowSet) -> None:
|
|
70
|
+
"""Accumulate one file's row sets into this buffer."""
|
|
71
|
+
self.file_rows.extend(rows.file_rows)
|
|
72
|
+
self.table_rows.extend(rows.table_rows)
|
|
73
|
+
self.column_rows.extend(rows.column_rows)
|
|
74
|
+
self.query_rows.extend(rows.query_rows)
|
|
75
|
+
self.defined_in_edges.extend(rows.defined_in_edges)
|
|
76
|
+
self.has_column_edges.extend(rows.has_column_edges)
|
|
77
|
+
self.query_defined_in_edges.extend(rows.query_defined_in_edges)
|
|
78
|
+
self.selects_from_edges.extend(rows.selects_from_edges)
|
|
79
|
+
self.column_lineage_edges.extend(rows.column_lineage_edges)
|
|
80
|
+
self.star_source_edges.extend(rows.star_source_edges)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_single(cls, rows: FileRowSet) -> "BatchRowBuffer":
|
|
84
|
+
"""Build a single-file batch buffer (used by the thin _upsert_parsed_file wrapper)."""
|
|
85
|
+
buf = cls()
|
|
86
|
+
buf.extend(rows)
|
|
87
|
+
return buf
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _flush_row_batch(db: GraphBackend, buf: BatchRowBuffer) -> None:
|
|
91
|
+
"""Dedup accumulated rows across the batch, then issue the 10 bulk calls ONCE.
|
|
92
|
+
|
|
93
|
+
This is the v1.1.1 batch-flush core: called once per batch (not once per file).
|
|
94
|
+
Dedup keys mirror the graph's MERGE cardinality:
|
|
95
|
+
- file_rows: path (primary key)
|
|
96
|
+
- table_rows: qualified (primary key); prefers row with non-empty
|
|
97
|
+
defined_in_file so DEFINED_IN provenance is preserved.
|
|
98
|
+
- column_rows: id (primary key)
|
|
99
|
+
- query_rows: id (primary key, globally unique path:index)
|
|
100
|
+
- edge rows: (src_key, dst_key) only — matches MERGE (src)-[r]->(dst)
|
|
101
|
+
cardinality; KuzuDB cannot hold two edges of the same
|
|
102
|
+
type between the same pair.
|
|
103
|
+
|
|
104
|
+
Performance invariant: calls each upsert_*_bulk at most ONCE per invocation;
|
|
105
|
+
never calls upsert_node / upsert_edge per row.
|
|
106
|
+
Guard: tests/unit/test_upsert_batch_invariant.py
|
|
107
|
+
"""
|
|
108
|
+
# --- Phase B: batch-scoped dedup ---
|
|
109
|
+
# For table_rows, prefer defined rows (non-empty defined_in_file) so provenance
|
|
110
|
+
# is not lost when a shared table is referenced by multiple files.
|
|
111
|
+
table_dedup: dict[str, dict] = {}
|
|
112
|
+
for r in buf.table_rows:
|
|
113
|
+
key = r["qualified"]
|
|
114
|
+
existing = table_dedup.get(key)
|
|
115
|
+
if existing is None or (not existing.get("defined_in_file") and r.get("defined_in_file")):
|
|
116
|
+
table_dedup[key] = r
|
|
117
|
+
table_rows = list(table_dedup.values())
|
|
118
|
+
|
|
119
|
+
column_rows = list({r["id"]: r for r in buf.column_rows}.values())
|
|
120
|
+
query_rows = list({r["id"]: r for r in buf.query_rows}.values())
|
|
121
|
+
file_rows = list({r["path"]: r for r in buf.file_rows}.values())
|
|
122
|
+
|
|
123
|
+
# Edge dedup by (src_key, dst_key) — matches MERGE (src)-[r]->(dst) cardinality.
|
|
124
|
+
defined_in_edges = list(
|
|
125
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.defined_in_edges}.values()
|
|
126
|
+
)
|
|
127
|
+
has_column_edges = list(
|
|
128
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.has_column_edges}.values()
|
|
129
|
+
)
|
|
130
|
+
query_defined_in_edges = list(
|
|
131
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.query_defined_in_edges}.values()
|
|
132
|
+
)
|
|
133
|
+
selects_from_edges = list(
|
|
134
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.selects_from_edges}.values()
|
|
135
|
+
)
|
|
136
|
+
column_lineage_edges = list(
|
|
137
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.column_lineage_edges}.values()
|
|
138
|
+
)
|
|
139
|
+
star_source_edges = list(
|
|
140
|
+
{(r["src_key"], r["dst_key"]): r for r in buf.star_source_edges}.values()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# --- Phase C: flush in dependency order (nodes before their edges) ---
|
|
144
|
+
db.upsert_nodes_bulk(NodeLabel.FILE, file_rows)
|
|
145
|
+
db.upsert_nodes_bulk(NodeLabel.TABLE, table_rows)
|
|
146
|
+
db.upsert_nodes_bulk(NodeLabel.COLUMN, column_rows)
|
|
147
|
+
db.upsert_nodes_bulk(NodeLabel.QUERY, query_rows)
|
|
148
|
+
|
|
149
|
+
db.upsert_edges_bulk(NodeLabel.TABLE, NodeLabel.FILE, RelType.DEFINED_IN, defined_in_edges)
|
|
150
|
+
db.upsert_edges_bulk(NodeLabel.TABLE, NodeLabel.COLUMN, RelType.HAS_COLUMN, has_column_edges)
|
|
151
|
+
db.upsert_edges_bulk(
|
|
152
|
+
NodeLabel.QUERY, NodeLabel.FILE, RelType.QUERY_DEFINED_IN, query_defined_in_edges
|
|
153
|
+
)
|
|
154
|
+
db.upsert_edges_bulk(NodeLabel.QUERY, NodeLabel.TABLE, RelType.SELECTS_FROM, selects_from_edges)
|
|
155
|
+
db.upsert_edges_bulk(
|
|
156
|
+
NodeLabel.COLUMN, NodeLabel.COLUMN, RelType.COLUMN_LINEAGE, column_lineage_edges
|
|
157
|
+
)
|
|
158
|
+
db.upsert_edges_bulk(NodeLabel.QUERY, NodeLabel.TABLE, RelType.STAR_SOURCE, star_source_edges)
|
|
159
|
+
|
|
160
|
+
|
|
25
161
|
def _subprocess_parse_worker(parser_cls, dialect, path, sql, q):
|
|
26
162
|
"""Parse a single file in a subprocess; queue the ParsedFile (or exception).
|
|
27
163
|
|
|
@@ -55,7 +191,7 @@ class Indexer:
|
|
|
55
191
|
dialect: str | None,
|
|
56
192
|
db: GraphBackend,
|
|
57
193
|
dbt_manifest: Path | None = None,
|
|
58
|
-
timeout_per_file: int =
|
|
194
|
+
timeout_per_file: int = 10,
|
|
59
195
|
use_git: bool = True,
|
|
60
196
|
progress_callback: Callable[[int, int], None] | None = None,
|
|
61
197
|
no_ddl: bool = False,
|
|
@@ -288,37 +424,15 @@ class Indexer:
|
|
|
288
424
|
}
|
|
289
425
|
|
|
290
426
|
def _flush_batch(batch: list[ParsedFile]) -> None:
|
|
291
|
-
"""
|
|
427
|
+
"""Accumulate rows for the batch and flush once (v1.1.1 batch-upsert path).
|
|
292
428
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
matches the legacy per-file behaviour where one bad file did not
|
|
296
|
-
abort the whole index run.
|
|
429
|
+
Per-file row-building errors are isolated inside _upsert_file_batch; good
|
|
430
|
+
files always flush together in a single transaction.
|
|
297
431
|
|
|
298
432
|
Note: sqlcg watch's reindex_file uses a separate code path with
|
|
299
433
|
its own short per-file transaction. PERF-BATCH only affects index_repo.
|
|
300
434
|
"""
|
|
301
|
-
|
|
302
|
-
return
|
|
303
|
-
with db.transaction():
|
|
304
|
-
for parsed_in_batch in batch:
|
|
305
|
-
try:
|
|
306
|
-
counts = self._upsert_parsed_file(
|
|
307
|
-
parsed_in_batch,
|
|
308
|
-
db,
|
|
309
|
-
defined_table_registry=defined_table_registry,
|
|
310
|
-
)
|
|
311
|
-
nonlocal_counts["tables"] += counts["tables"]
|
|
312
|
-
nonlocal_counts["edges"] += counts["edges"]
|
|
313
|
-
nonlocal_counts["star_sources"] += counts.get("star_sources", 0)
|
|
314
|
-
nonlocal_counts["columns_defined"] += counts.get("columns_defined", 0)
|
|
315
|
-
q_key = parsed_in_batch.parse_quality.value.lower()
|
|
316
|
-
nonlocal_counts["quality"][q_key] += 1
|
|
317
|
-
except Exception as exc:
|
|
318
|
-
logger.warning(
|
|
319
|
-
"Failed to upsert %s: %s — skipping", parsed_in_batch.path, exc
|
|
320
|
-
)
|
|
321
|
-
nonlocal_counts["quality"]["failed"] += 1
|
|
435
|
+
self._upsert_file_batch(batch, db, defined_table_registry, nonlocal_counts)
|
|
322
436
|
|
|
323
437
|
if profile:
|
|
324
438
|
_t_upsert_start = time.perf_counter()
|
|
@@ -345,6 +459,9 @@ class Indexer:
|
|
|
345
459
|
if profile:
|
|
346
460
|
_t_star_end = time.perf_counter()
|
|
347
461
|
|
|
462
|
+
# Post-ingestion: ingest declared external downstream consumers from .sqlcg.toml
|
|
463
|
+
ingest_result = self._ingest_external_consumers(db, path)
|
|
464
|
+
|
|
348
465
|
# Persist the HEAD SHA so `sqlcg reindex` (no SHAs) can compute the delta.
|
|
349
466
|
# Silent on failure — non-git repos still index cleanly.
|
|
350
467
|
try:
|
|
@@ -402,6 +519,9 @@ class Indexer:
|
|
|
402
519
|
"quality": nonlocal_counts["quality"],
|
|
403
520
|
"error_summary": error_summary,
|
|
404
521
|
"batch_size": batch_size,
|
|
522
|
+
"external_consumers": ingest_result["consumers"],
|
|
523
|
+
"external_consumer_edges": ingest_result["edges"],
|
|
524
|
+
"external_consumer_warnings": ingest_result["warnings"],
|
|
405
525
|
}
|
|
406
526
|
|
|
407
527
|
if profile:
|
|
@@ -434,7 +554,7 @@ class Indexer:
|
|
|
434
554
|
dialect: str | None,
|
|
435
555
|
*,
|
|
436
556
|
batch_size: int = 50,
|
|
437
|
-
timeout_per_file: int =
|
|
557
|
+
timeout_per_file: int = 10,
|
|
438
558
|
max_closure_depth: int = 3,
|
|
439
559
|
) -> dict:
|
|
440
560
|
"""Incrementally resync the graph after a git branch change or pull.
|
|
@@ -722,41 +842,28 @@ class Indexer:
|
|
|
722
842
|
for pf in closure_results:
|
|
723
843
|
db.delete_nodes_for_file(pf.path_str)
|
|
724
844
|
|
|
725
|
-
# Flush in batches using the
|
|
845
|
+
# Flush in batches using the shared _upsert_file_batch path (v1.1.1 batch-flush).
|
|
846
|
+
# resync_changed does not surface tables/edges/quality counts in its summary, so
|
|
847
|
+
# a temporary counts dict is passed and discarded after each batch.
|
|
848
|
+
_resync_counts: dict = {
|
|
849
|
+
"tables": 0,
|
|
850
|
+
"edges": 0,
|
|
851
|
+
"star_sources": 0,
|
|
852
|
+
"columns_defined": 0,
|
|
853
|
+
"quality": {"full": 0, "table_only": 0, "scripting_fallback": 0, "failed": 0},
|
|
854
|
+
}
|
|
726
855
|
batch: list[ParsedFile] = []
|
|
727
856
|
for pf in all_results:
|
|
728
857
|
batch.append(pf)
|
|
729
858
|
if len(batch) >= batch_size:
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
self._upsert_parsed_file(
|
|
734
|
-
pf_in_batch,
|
|
735
|
-
db,
|
|
736
|
-
defined_table_registry=defined_table_registry,
|
|
737
|
-
)
|
|
738
|
-
except Exception as exc:
|
|
739
|
-
logger.warning(
|
|
740
|
-
"resync_changed: upsert failed for %s: %s — skipping",
|
|
741
|
-
pf_in_batch.path,
|
|
742
|
-
exc,
|
|
743
|
-
)
|
|
859
|
+
self._upsert_file_batch(
|
|
860
|
+
batch, db, defined_table_registry, _resync_counts, "resync_changed: "
|
|
861
|
+
)
|
|
744
862
|
batch = []
|
|
745
863
|
if batch:
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
self._upsert_parsed_file(
|
|
750
|
-
pf_in_batch,
|
|
751
|
-
db,
|
|
752
|
-
defined_table_registry=defined_table_registry,
|
|
753
|
-
)
|
|
754
|
-
except Exception as exc:
|
|
755
|
-
logger.warning(
|
|
756
|
-
"resync_changed: upsert failed for %s: %s — skipping",
|
|
757
|
-
pf_in_batch.path,
|
|
758
|
-
exc,
|
|
759
|
-
)
|
|
864
|
+
self._upsert_file_batch(
|
|
865
|
+
batch, db, defined_table_registry, _resync_counts, "resync_changed: "
|
|
866
|
+
)
|
|
760
867
|
|
|
761
868
|
# ---- Step 8: Single star expansion (same as index_repo) ------------------
|
|
762
869
|
self._expand_star_sources(db)
|
|
@@ -767,15 +874,19 @@ class Indexer:
|
|
|
767
874
|
return summary
|
|
768
875
|
|
|
769
876
|
def reindex_file(self, file_path: str, db: GraphBackend, dialect: str | None) -> None:
|
|
770
|
-
"""Re-index a single file
|
|
877
|
+
"""Re-index a single file.
|
|
878
|
+
|
|
879
|
+
Re-indexes only the changed file. The stale-view cascade that was here
|
|
880
|
+
previously was a no-op (the dead query matched kind='VIEW' which is never
|
|
881
|
+
written). It has been removed in PR-3 (#33). Full re-index
|
|
882
|
+
(db reset && sqlcg index) is the migration path for VIEW dependency
|
|
883
|
+
tracking, deferred to v1.2.
|
|
771
884
|
|
|
772
885
|
Args:
|
|
773
886
|
file_path: Path to the file to re-index
|
|
774
887
|
db: GraphBackend instance
|
|
775
888
|
dialect: SQL dialect (None for ANSI)
|
|
776
889
|
"""
|
|
777
|
-
stale_views = db.run_read(STALE_VIEWS_QUERY, {"path": file_path})
|
|
778
|
-
|
|
779
890
|
with db.transaction():
|
|
780
891
|
db.delete_nodes_for_file(file_path)
|
|
781
892
|
schema_resolver = SchemaResolver(dialect=dialect)
|
|
@@ -785,11 +896,7 @@ class Indexer:
|
|
|
785
896
|
|
|
786
897
|
self._upsert_parsed_file(parsed, db)
|
|
787
898
|
|
|
788
|
-
|
|
789
|
-
self._reindex_view_definition(row["view_name"], db, dialect)
|
|
790
|
-
|
|
791
|
-
# Re-run star expansion after re-indexing and all stale views are processed
|
|
792
|
-
# Call this outside the transaction
|
|
899
|
+
# Re-run star expansion after re-indexing
|
|
793
900
|
self._expand_star_sources(db)
|
|
794
901
|
|
|
795
902
|
def _index_single_file(self, parser, path: Path, sql: str, timeout: int) -> ParsedFile:
|
|
@@ -864,40 +971,31 @@ class Indexer:
|
|
|
864
971
|
"""Canonical graph key for a table/column: lowercased full_id."""
|
|
865
972
|
return full_id.lower()
|
|
866
973
|
|
|
867
|
-
def
|
|
974
|
+
def _build_file_rows(
|
|
868
975
|
self,
|
|
869
976
|
parsed: ParsedFile,
|
|
870
|
-
db: GraphBackend,
|
|
871
977
|
defined_table_registry: dict[str, str] | None = None,
|
|
872
|
-
) ->
|
|
873
|
-
"""
|
|
978
|
+
) -> FileRowSet:
|
|
979
|
+
"""Build all row dicts for one ParsedFile — Phase A (pure, no db access).
|
|
980
|
+
|
|
981
|
+
Extracts the four node-row lists and six edge-row lists from a ParsedFile.
|
|
982
|
+
No execute(), no db argument. A build-time exception here is caught by the
|
|
983
|
+
per-file try/except in _upsert_file_batch so the bad file is isolated from
|
|
984
|
+
the rest of the batch.
|
|
874
985
|
|
|
875
986
|
Args:
|
|
876
|
-
parsed: ParsedFile to
|
|
877
|
-
|
|
987
|
+
parsed: ParsedFile to build rows for
|
|
988
|
+
defined_table_registry: Optional cross-file DDL dedup registry
|
|
878
989
|
|
|
879
990
|
Returns:
|
|
880
|
-
|
|
991
|
+
FileRowSet with all row lists and per-file counts/quality key
|
|
881
992
|
"""
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
# --- Phase A: collect rows from parsed model ---
|
|
885
|
-
|
|
886
|
-
file_rows: list[dict] = []
|
|
887
|
-
table_rows: list[dict] = [] # NodeLabel.TABLE
|
|
888
|
-
column_rows: list[dict] = [] # NodeLabel.COLUMN
|
|
889
|
-
query_rows: list[dict] = [] # NodeLabel.QUERY
|
|
890
|
-
|
|
891
|
-
defined_in_edges: list[dict] = [] # TABLE -> FILE
|
|
892
|
-
has_column_edges: list[dict] = [] # TABLE -> COLUMN
|
|
893
|
-
query_defined_in_edges: list[dict] = [] # QUERY -> FILE
|
|
894
|
-
selects_from_edges: list[dict] = [] # QUERY -> TABLE
|
|
895
|
-
column_lineage_edges: list[dict] = [] # COLUMN -> COLUMN
|
|
896
|
-
star_source_edges: list[dict] = [] # QUERY -> TABLE
|
|
993
|
+
rows = FileRowSet()
|
|
994
|
+
rows.parse_quality_key = parsed.parse_quality.value.lower()
|
|
897
995
|
|
|
898
996
|
# File node
|
|
899
997
|
cause, failed = dominant_cause(parsed.errors)
|
|
900
|
-
file_rows.append(
|
|
998
|
+
rows.file_rows.append(
|
|
901
999
|
{
|
|
902
1000
|
"path": parsed.path_str,
|
|
903
1001
|
"dialect": parsed.dialect or "",
|
|
@@ -924,18 +1022,18 @@ class Indexer:
|
|
|
924
1022
|
if hasattr(parsed, "errors"):
|
|
925
1023
|
parsed.errors.append(f"duplicate_ddl:{table.full_id}:already_in:{first_file}")
|
|
926
1024
|
|
|
927
|
-
table_rows.append(
|
|
1025
|
+
rows.table_rows.append(
|
|
928
1026
|
{
|
|
929
1027
|
"qualified": table.full_id,
|
|
930
1028
|
"name": table.name,
|
|
931
1029
|
"catalog": table.catalog or "",
|
|
932
1030
|
"db": table.db or "",
|
|
933
|
-
"kind": "
|
|
1031
|
+
"kind": "table",
|
|
934
1032
|
"defined_in_file": parsed.path_str,
|
|
935
1033
|
}
|
|
936
1034
|
)
|
|
937
|
-
defined_in_edges.append({"src_key": table.full_id, "dst_key": parsed.path_str})
|
|
938
|
-
counts["tables"] += 1
|
|
1035
|
+
rows.defined_in_edges.append({"src_key": table.full_id, "dst_key": parsed.path_str})
|
|
1036
|
+
rows.counts["tables"] += 1
|
|
939
1037
|
|
|
940
1038
|
# Build a map of target.full_id -> QueryNode for column lookup
|
|
941
1039
|
defined_by_query = {
|
|
@@ -955,7 +1053,7 @@ class Indexer:
|
|
|
955
1053
|
|
|
956
1054
|
for col_name in qnode.defined_columns:
|
|
957
1055
|
col_id = f"{table.full_id}.{col_name}"
|
|
958
|
-
column_rows.append(
|
|
1056
|
+
rows.column_rows.append(
|
|
959
1057
|
{
|
|
960
1058
|
"id": col_id,
|
|
961
1059
|
"col_name": col_name,
|
|
@@ -965,15 +1063,15 @@ class Indexer:
|
|
|
965
1063
|
"table_name": table.name,
|
|
966
1064
|
}
|
|
967
1065
|
)
|
|
968
|
-
has_column_edges.append(
|
|
1066
|
+
rows.has_column_edges.append(
|
|
969
1067
|
{"src_key": table.full_id, "dst_key": col_id, "source": "ddl"}
|
|
970
1068
|
)
|
|
971
|
-
counts["columns_defined"] += 1
|
|
1069
|
+
rows.counts["columns_defined"] += 1
|
|
972
1070
|
|
|
973
1071
|
# Query nodes and related edges
|
|
974
1072
|
for i, stmt in enumerate(parsed.statements):
|
|
975
1073
|
query_id = f"{parsed.path_str}:{i}"
|
|
976
|
-
query_rows.append(
|
|
1074
|
+
rows.query_rows.append(
|
|
977
1075
|
{
|
|
978
1076
|
"id": query_id,
|
|
979
1077
|
"file_path": parsed.path_str,
|
|
@@ -984,29 +1082,41 @@ class Indexer:
|
|
|
984
1082
|
"parse_failed": stmt.parse_failed,
|
|
985
1083
|
"confidence": stmt.confidence,
|
|
986
1084
|
"parsing_mode": stmt.parsing_mode,
|
|
1085
|
+
"start_line": stmt.start_line,
|
|
987
1086
|
}
|
|
988
1087
|
)
|
|
989
|
-
query_defined_in_edges.append({"src_key": query_id, "dst_key": parsed.path_str})
|
|
1088
|
+
rows.query_defined_in_edges.append({"src_key": query_id, "dst_key": parsed.path_str})
|
|
990
1089
|
|
|
991
|
-
# Source table edges
|
|
1090
|
+
# Source table edges — apply structural role from TableRef.role.
|
|
1091
|
+
# Real source tables have role="table"; CTE aliases have role="cte".
|
|
1092
|
+
# Exclude <output> synthetic sinks (fallback dst when stmt.target is None).
|
|
992
1093
|
for src_table in stmt.sources:
|
|
993
|
-
|
|
1094
|
+
if src_table.full_id == "<output>":
|
|
1095
|
+
continue
|
|
1096
|
+
rows.table_rows.append(
|
|
994
1097
|
{
|
|
995
1098
|
"qualified": src_table.full_id,
|
|
996
1099
|
"name": src_table.name,
|
|
997
1100
|
"catalog": src_table.catalog or "",
|
|
998
1101
|
"db": src_table.db or "",
|
|
999
|
-
"kind":
|
|
1102
|
+
"kind": src_table.role,
|
|
1000
1103
|
"defined_in_file": "",
|
|
1001
1104
|
}
|
|
1002
1105
|
)
|
|
1003
|
-
selects_from_edges.append({"src_key": query_id, "dst_key": src_table.full_id})
|
|
1106
|
+
rows.selects_from_edges.append({"src_key": query_id, "dst_key": src_table.full_id})
|
|
1004
1107
|
|
|
1005
|
-
# Column lineage edges
|
|
1108
|
+
# Column lineage edges.
|
|
1109
|
+
# CTE destination tables (role="cte") are not in stmt.sources so they are not
|
|
1110
|
+
# emitted by the source-table loop above. Collect them here from edge.dst so
|
|
1111
|
+
# that each CTE alias gets a SqlTable node with kind="cte" in the graph.
|
|
1112
|
+
# Also exclude <output> synthetic sinks from both table_rows and column_rows.
|
|
1006
1113
|
for edge in stmt.column_lineage:
|
|
1007
1114
|
src_id = edge.src.full_id
|
|
1008
1115
|
dst_id = edge.dst.full_id
|
|
1009
|
-
|
|
1116
|
+
# Exclude <output> sink — it is a synthetic fallback, not a real table
|
|
1117
|
+
if edge.dst.table.full_id == "<output>":
|
|
1118
|
+
continue
|
|
1119
|
+
rows.column_rows.append(
|
|
1010
1120
|
{
|
|
1011
1121
|
"id": src_id,
|
|
1012
1122
|
"col_name": edge.src.name,
|
|
@@ -1016,7 +1126,7 @@ class Indexer:
|
|
|
1016
1126
|
"table_name": edge.src.table.name,
|
|
1017
1127
|
}
|
|
1018
1128
|
)
|
|
1019
|
-
column_rows.append(
|
|
1129
|
+
rows.column_rows.append(
|
|
1020
1130
|
{
|
|
1021
1131
|
"id": dst_id,
|
|
1022
1132
|
"col_name": edge.dst.name,
|
|
@@ -1026,7 +1136,19 @@ class Indexer:
|
|
|
1026
1136
|
"table_name": edge.dst.table.name,
|
|
1027
1137
|
}
|
|
1028
1138
|
)
|
|
1029
|
-
|
|
1139
|
+
# Emit CTE destination table nodes so they appear as SqlTable with kind="cte"
|
|
1140
|
+
if edge.dst.table.role == "cte":
|
|
1141
|
+
rows.table_rows.append(
|
|
1142
|
+
{
|
|
1143
|
+
"qualified": edge.dst.table.full_id,
|
|
1144
|
+
"name": edge.dst.table.name,
|
|
1145
|
+
"catalog": edge.dst.table.catalog or "",
|
|
1146
|
+
"db": edge.dst.table.db or "",
|
|
1147
|
+
"kind": "cte",
|
|
1148
|
+
"defined_in_file": "",
|
|
1149
|
+
}
|
|
1150
|
+
)
|
|
1151
|
+
rows.column_lineage_edges.append(
|
|
1030
1152
|
{
|
|
1031
1153
|
"src_key": src_id,
|
|
1032
1154
|
"dst_key": dst_id,
|
|
@@ -1035,21 +1157,21 @@ class Indexer:
|
|
|
1035
1157
|
"query_id": query_id,
|
|
1036
1158
|
}
|
|
1037
1159
|
)
|
|
1038
|
-
counts["edges"] += 1
|
|
1160
|
+
rows.counts["edges"] += 1
|
|
1039
1161
|
|
|
1040
|
-
# STAR_SOURCE edges for graph-backend expansion
|
|
1162
|
+
# STAR_SOURCE edges for graph-backend expansion — real source tables, keep kind="table"
|
|
1041
1163
|
for star in stmt.star_sources:
|
|
1042
|
-
table_rows.append(
|
|
1164
|
+
rows.table_rows.append(
|
|
1043
1165
|
{
|
|
1044
1166
|
"qualified": star.source.full_id,
|
|
1045
1167
|
"name": star.source.name,
|
|
1046
1168
|
"catalog": star.source.catalog or "",
|
|
1047
1169
|
"db": star.source.db or "",
|
|
1048
|
-
"kind": "
|
|
1170
|
+
"kind": "table",
|
|
1049
1171
|
"defined_in_file": "",
|
|
1050
1172
|
}
|
|
1051
1173
|
)
|
|
1052
|
-
star_source_edges.append(
|
|
1174
|
+
rows.star_source_edges.append(
|
|
1053
1175
|
{
|
|
1054
1176
|
"src_key": query_id,
|
|
1055
1177
|
"dst_key": star.source.full_id,
|
|
@@ -1058,53 +1180,95 @@ class Indexer:
|
|
|
1058
1180
|
"confidence": 0.8,
|
|
1059
1181
|
}
|
|
1060
1182
|
)
|
|
1061
|
-
counts["star_sources"] += 1
|
|
1183
|
+
rows.counts["star_sources"] += 1
|
|
1062
1184
|
|
|
1063
1185
|
# Upsert target table node (if not already a defined_table)
|
|
1064
1186
|
# so that star expansion can create destination columns
|
|
1065
1187
|
if stmt.target and stmt.target.full_id not in defined_table_ids:
|
|
1066
|
-
table_rows.append(
|
|
1188
|
+
rows.table_rows.append(
|
|
1067
1189
|
{
|
|
1068
1190
|
"qualified": stmt.target.full_id,
|
|
1069
1191
|
"name": stmt.target.name,
|
|
1070
1192
|
"catalog": stmt.target.catalog or "",
|
|
1071
1193
|
"db": stmt.target.db or "",
|
|
1072
|
-
"kind": "
|
|
1194
|
+
"kind": "table",
|
|
1073
1195
|
"defined_in_file": "",
|
|
1074
1196
|
}
|
|
1075
1197
|
)
|
|
1076
1198
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1199
|
+
return rows
|
|
1200
|
+
|
|
1201
|
+
def _upsert_file_batch(
|
|
1202
|
+
self,
|
|
1203
|
+
batch: list[ParsedFile],
|
|
1204
|
+
db: GraphBackend,
|
|
1205
|
+
defined_table_registry: dict[str, str],
|
|
1206
|
+
nonlocal_counts: dict,
|
|
1207
|
+
warning_prefix: str = "",
|
|
1208
|
+
) -> None:
|
|
1209
|
+
"""Accumulate rows for all files in batch, then flush once in one transaction.
|
|
1210
|
+
|
|
1211
|
+
Per-file failure isolation: _build_file_rows (pure) runs per file inside its
|
|
1212
|
+
own try/except — a bad file is recorded as failed and excluded from the buffer;
|
|
1213
|
+
the remaining good files flush together. Flush-time backend errors (rare) fail
|
|
1214
|
+
the whole batch transaction, which is already the behaviour inside db.transaction().
|
|
1215
|
+
|
|
1216
|
+
Args:
|
|
1217
|
+
batch: List of ParsedFile objects to upsert
|
|
1218
|
+
db: GraphBackend instance
|
|
1219
|
+
defined_table_registry: Cross-file DDL dedup registry
|
|
1220
|
+
nonlocal_counts: Mutable summary dict updated in place (tables/edges/quality/…)
|
|
1221
|
+
warning_prefix: Optional prefix for warning log messages (e.g. "resync_changed: ")
|
|
1222
|
+
"""
|
|
1223
|
+
if not batch:
|
|
1224
|
+
return
|
|
1225
|
+
buf = BatchRowBuffer()
|
|
1226
|
+
for parsed_in_batch in batch:
|
|
1227
|
+
try:
|
|
1228
|
+
file_rows = self._build_file_rows(parsed_in_batch, defined_table_registry)
|
|
1229
|
+
except Exception as exc:
|
|
1230
|
+
logger.warning(
|
|
1231
|
+
"%sFailed to build rows for %s: %s — skipping",
|
|
1232
|
+
warning_prefix,
|
|
1233
|
+
parsed_in_batch.path,
|
|
1234
|
+
exc,
|
|
1235
|
+
)
|
|
1236
|
+
nonlocal_counts["quality"]["failed"] += 1
|
|
1237
|
+
continue
|
|
1238
|
+
buf.extend(file_rows)
|
|
1239
|
+
nonlocal_counts["tables"] += file_rows.counts["tables"]
|
|
1240
|
+
nonlocal_counts["edges"] += file_rows.counts["edges"]
|
|
1241
|
+
nonlocal_counts["star_sources"] += file_rows.counts.get("star_sources", 0)
|
|
1242
|
+
nonlocal_counts["columns_defined"] += file_rows.counts.get("columns_defined", 0)
|
|
1243
|
+
nonlocal_counts["quality"][file_rows.parse_quality_key] += 1
|
|
1244
|
+
with db.transaction():
|
|
1245
|
+
_flush_row_batch(db, buf)
|
|
1246
|
+
|
|
1247
|
+
def _upsert_parsed_file(
|
|
1248
|
+
self,
|
|
1249
|
+
parsed: ParsedFile,
|
|
1250
|
+
db: GraphBackend,
|
|
1251
|
+
defined_table_registry: dict[str, str] | None = None,
|
|
1252
|
+
) -> dict:
|
|
1253
|
+
"""Thin single-file wrapper used by reindex_file and sqlcg watch.
|
|
1254
|
+
|
|
1255
|
+
Builds one file's rows (pure) and flushes them immediately — semantically
|
|
1256
|
+
equivalent to the old per-file upsert, but now via the shared builder/flusher
|
|
1257
|
+
split. The caller (reindex_file at indexer.py:796) wraps this in its own
|
|
1258
|
+
db.transaction(); this method does NOT open a transaction.
|
|
1106
1259
|
|
|
1107
|
-
|
|
1260
|
+
Args:
|
|
1261
|
+
parsed: ParsedFile to upsert
|
|
1262
|
+
db: GraphBackend instance
|
|
1263
|
+
defined_table_registry: Optional cross-file DDL dedup registry
|
|
1264
|
+
|
|
1265
|
+
Returns:
|
|
1266
|
+
Dict with keys: tables, edges, columns_defined, star_sources
|
|
1267
|
+
"""
|
|
1268
|
+
file_rows = self._build_file_rows(parsed, defined_table_registry)
|
|
1269
|
+
buf = BatchRowBuffer.from_single(file_rows)
|
|
1270
|
+
_flush_row_batch(db, buf)
|
|
1271
|
+
return file_rows.counts
|
|
1108
1272
|
|
|
1109
1273
|
def _expand_star_sources(self, db: GraphBackend) -> int:
|
|
1110
1274
|
"""Run the post-ingestion star expansion query.
|
|
@@ -1134,21 +1298,73 @@ class Indexer:
|
|
|
1134
1298
|
# Return the number of new edges created
|
|
1135
1299
|
return max(0, after_count - before_count)
|
|
1136
1300
|
|
|
1137
|
-
def
|
|
1138
|
-
|
|
1139
|
-
) -> None:
|
|
1140
|
-
"""Re-index the file that defines a view.
|
|
1301
|
+
def _ingest_external_consumers(self, db: GraphBackend, path: Path) -> dict:
|
|
1302
|
+
"""Ingest declared external downstream consumers from .sqlcg.toml.
|
|
1141
1303
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1304
|
+
Runs ONCE per index_repo call, after _expand_star_sources and before
|
|
1305
|
+
set_indexed_sha. Uses upsert_nodes_bulk/upsert_edges_bulk exclusively
|
|
1306
|
+
(never upsert_node/upsert_edge per-row — CLAUDE.md perf invariant).
|
|
1307
|
+
|
|
1308
|
+
Returns:
|
|
1309
|
+
Dict with keys: consumers (int), edges (int), warnings (list[str])
|
|
1146
1310
|
"""
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
)
|
|
1152
|
-
|
|
1153
|
-
for
|
|
1154
|
-
|
|
1311
|
+
specs = get_external_consumers(path)
|
|
1312
|
+
if not specs:
|
|
1313
|
+
return {"consumers": 0, "edges": 0, "warnings": []}
|
|
1314
|
+
|
|
1315
|
+
presentation_prefixes = get_presentation_prefixes(path)
|
|
1316
|
+
|
|
1317
|
+
# Gather all target table qualifieds across all specs for a single existence check
|
|
1318
|
+
all_targets: list[str] = []
|
|
1319
|
+
for spec in specs:
|
|
1320
|
+
all_targets.extend(spec.consumes)
|
|
1321
|
+
|
|
1322
|
+
# Single UNWIND round-trip to check which targets exist as SqlTable nodes
|
|
1323
|
+
if all_targets:
|
|
1324
|
+
existing_rows = db.run_read(
|
|
1325
|
+
"UNWIND $names AS n MATCH (t:SqlTable {qualified: n}) RETURN n AS qualified",
|
|
1326
|
+
{"names": all_targets},
|
|
1327
|
+
)
|
|
1328
|
+
existing_tables: set[str] = {row["qualified"] for row in existing_rows}
|
|
1329
|
+
else:
|
|
1330
|
+
existing_tables = set()
|
|
1331
|
+
|
|
1332
|
+
warnings: list[str] = []
|
|
1333
|
+
consumer_rows: list[dict] = []
|
|
1334
|
+
edge_rows: list[dict] = []
|
|
1335
|
+
|
|
1336
|
+
for spec in specs:
|
|
1337
|
+
consumer_rows.append({"name": spec.name, "consumer_type": spec.consumer_type})
|
|
1338
|
+
for target in spec.consumes:
|
|
1339
|
+
if target not in existing_tables:
|
|
1340
|
+
warnings.append(
|
|
1341
|
+
f"Warning: external consumer '{spec.name}' "
|
|
1342
|
+
f"references unknown table '{target}'"
|
|
1343
|
+
)
|
|
1344
|
+
continue
|
|
1345
|
+
# Check if this target is presentation-facing
|
|
1346
|
+
if presentation_prefixes and not any(
|
|
1347
|
+
target.startswith(p) for p in presentation_prefixes
|
|
1348
|
+
):
|
|
1349
|
+
warnings.append(
|
|
1350
|
+
f"Warning: external consumer '{spec.name}' "
|
|
1351
|
+
f"references non-presentation table '{target}'"
|
|
1352
|
+
f" — edge created anyway (advisory)"
|
|
1353
|
+
)
|
|
1354
|
+
edge_rows.append({"src_key": target, "dst_key": spec.name})
|
|
1355
|
+
|
|
1356
|
+
# Bulk upsert nodes, then edges — never per-row
|
|
1357
|
+
db.upsert_nodes_bulk(NodeLabel.EXTERNAL_CONSUMER, consumer_rows)
|
|
1358
|
+
if edge_rows:
|
|
1359
|
+
db.upsert_edges_bulk(
|
|
1360
|
+
NodeLabel.TABLE,
|
|
1361
|
+
NodeLabel.EXTERNAL_CONSUMER,
|
|
1362
|
+
RelType.CONSUMED_BY,
|
|
1363
|
+
edge_rows,
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
return {
|
|
1367
|
+
"consumers": len(consumer_rows),
|
|
1368
|
+
"edges": len(edge_rows),
|
|
1369
|
+
"warnings": warnings,
|
|
1370
|
+
}
|