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.
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 = 5,
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
- """Upsert a batch of ParsedFile objects in a single transaction.
427
+ """Accumulate rows for the batch and flush once (v1.1.1 batch-upsert path).
292
428
 
293
- On any per-file exception, the file is recorded as failed but the
294
- transaction continues for the remaining files in the batch. This
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
- if not batch:
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 = 5,
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 same pattern as index_repo._flush_batch
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
- with db.transaction():
731
- for pf_in_batch in batch:
732
- try:
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
- with db.transaction():
747
- for pf_in_batch in batch:
748
- try:
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 and its dependent views.
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
- for row in stale_views:
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 _upsert_parsed_file(
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
- ) -> dict:
873
- """Map ParsedFile graph nodes/edges using bulk upsert.
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 upsert
877
- db: GraphBackend instance
987
+ parsed: ParsedFile to build rows for
988
+ defined_table_registry: Optional cross-file DDL dedup registry
878
989
 
879
990
  Returns:
880
- Dict with keys: tables, edges, columns_defined, star_sources
991
+ FileRowSet with all row lists and per-file counts/quality key
881
992
  """
882
- counts = {"tables": 0, "edges": 0, "columns_defined": 0, "star_sources": 0}
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": "TABLE",
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
- table_rows.append(
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": "TABLE",
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
- column_rows.append(
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
- column_lineage_edges.append(
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": "TABLE",
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": "TABLE",
1194
+ "kind": "table",
1073
1195
  "defined_in_file": "",
1074
1196
  }
1075
1197
  )
1076
1198
 
1077
- # --- Phase B: deduplicate rows within each group ---
1078
- # Cypher MERGE is idempotent under deduplication, but we deduplicate in Python
1079
- # to reduce payload size
1080
- table_rows = list({r["qualified"]: r for r in table_rows}.values())
1081
- column_rows = list({r["id"]: r for r in column_rows}.values())
1082
- query_rows = list({r["id"]: r for r in query_rows}.values())
1083
-
1084
- # --- Phase C: flush in dependency order (nodes before their edges) ---
1085
- db.upsert_nodes_bulk(NodeLabel.FILE, file_rows)
1086
- db.upsert_nodes_bulk(NodeLabel.TABLE, table_rows)
1087
- db.upsert_nodes_bulk(NodeLabel.COLUMN, column_rows)
1088
- db.upsert_nodes_bulk(NodeLabel.QUERY, query_rows)
1089
-
1090
- db.upsert_edges_bulk(NodeLabel.TABLE, NodeLabel.FILE, RelType.DEFINED_IN, defined_in_edges)
1091
- db.upsert_edges_bulk(
1092
- NodeLabel.TABLE, NodeLabel.COLUMN, RelType.HAS_COLUMN, has_column_edges
1093
- )
1094
- db.upsert_edges_bulk(
1095
- NodeLabel.QUERY, NodeLabel.FILE, RelType.QUERY_DEFINED_IN, query_defined_in_edges
1096
- )
1097
- db.upsert_edges_bulk(
1098
- NodeLabel.QUERY, NodeLabel.TABLE, RelType.SELECTS_FROM, selects_from_edges
1099
- )
1100
- db.upsert_edges_bulk(
1101
- NodeLabel.COLUMN, NodeLabel.COLUMN, RelType.COLUMN_LINEAGE, column_lineage_edges
1102
- )
1103
- db.upsert_edges_bulk(
1104
- NodeLabel.QUERY, NodeLabel.TABLE, RelType.STAR_SOURCE, star_source_edges
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
- return counts
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 _reindex_view_definition(
1138
- self, view_name: str, db: GraphBackend, dialect: str | None
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
- Args:
1143
- view_name: Qualified view name
1144
- db: GraphBackend instance
1145
- dialect: SQL dialect
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
- query = (
1148
- f"MATCH (t:{NodeLabel.TABLE} {{qualified: $name}})"
1149
- f"-[:{RelType.DEFINED_IN}]->(f:{NodeLabel.FILE}) "
1150
- "RETURN f.path AS path"
1151
- )
1152
- result = db.run_read(query, {"name": view_name})
1153
- for row in result:
1154
- self.reindex_file(row["path"], db, dialect)
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
+ }