sql-code-graph 0.3.0__py3-none-any.whl → 1.0.1__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.
Files changed (44) hide show
  1. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/METADATA +87 -9
  2. sql_code_graph-1.0.1.dist-info/RECORD +63 -0
  3. sqlcg/__init__.py +1 -1
  4. sqlcg/cli/commands/analyze.py +24 -0
  5. sqlcg/cli/commands/db.py +40 -7
  6. sqlcg/cli/commands/gain.py +5 -17
  7. sqlcg/cli/commands/git.py +71 -40
  8. sqlcg/cli/commands/index.py +151 -17
  9. sqlcg/cli/commands/install.py +147 -8
  10. sqlcg/cli/commands/mcp.py +12 -0
  11. sqlcg/cli/commands/reindex.py +170 -0
  12. sqlcg/cli/commands/uninstall.py +94 -39
  13. sqlcg/cli/commands/watch.py +14 -1
  14. sqlcg/cli/main.py +8 -0
  15. sqlcg/core/config.py +185 -2
  16. sqlcg/core/graph_db.py +65 -0
  17. sqlcg/core/kuzu_backend.py +177 -6
  18. sqlcg/core/neo4j_backend.py +38 -0
  19. sqlcg/core/queries.cypher +114 -0
  20. sqlcg/core/queries.py +44 -82
  21. sqlcg/core/schema.cypher +15 -3
  22. sqlcg/core/schema.py +2 -1
  23. sqlcg/indexer/error_classify.py +140 -0
  24. sqlcg/indexer/git_delta.py +121 -0
  25. sqlcg/indexer/indexer.py +951 -132
  26. sqlcg/indexer/pool.py +500 -0
  27. sqlcg/indexer/walker.py +1 -3
  28. sqlcg/indexer/watcher.py +68 -18
  29. sqlcg/lineage/aggregator.py +58 -2
  30. sqlcg/lineage/schema_resolver.py +26 -14
  31. sqlcg/parsers/ansi_parser.py +195 -26
  32. sqlcg/parsers/base.py +627 -58
  33. sqlcg/parsers/bigquery_parser.py +7 -2
  34. sqlcg/parsers/postgres_parser.py +7 -2
  35. sqlcg/parsers/registry.py +7 -2
  36. sqlcg/parsers/snowflake_parser.py +170 -8
  37. sqlcg/parsers/tsql_parser.py +7 -2
  38. sqlcg/server/models.py +297 -4
  39. sqlcg/server/noise_filter.py +167 -0
  40. sqlcg/server/skill.py +256 -0
  41. sqlcg/server/tools.py +934 -178
  42. sql_code_graph-0.3.0.dist-info/RECORD +0 -56
  43. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/WHEEL +0 -0
  44. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/entry_points.txt +0 -0
@@ -23,17 +23,64 @@ from sqlcg.utils.logging import getLogger
23
23
  logger = getLogger(__name__)
24
24
 
25
25
 
26
+ def _find_lock_holder(db_path: str) -> str:
27
+ """Return a human-readable PID string for the process holding the DB lock.
28
+
29
+ Uses lsof on Linux/macOS. Returns a descriptive fallback if lsof is
30
+ unavailable or returns no results.
31
+ """
32
+ import shutil
33
+ import subprocess
34
+
35
+ if not shutil.which("lsof"):
36
+ return "PID unknown (lsof not available)"
37
+ try:
38
+ result = subprocess.run(
39
+ ["lsof", "-t", db_path],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=3,
43
+ )
44
+ pids = result.stdout.strip().split()
45
+ if pids:
46
+ return f"PID {', '.join(pids)}"
47
+ except Exception:
48
+ pass
49
+ return "PID unknown"
50
+
51
+
26
52
  class KuzuBackend(GraphBackend):
27
53
  """KùzuDB implementation of the graph database backend."""
28
54
 
29
- def __init__(self, db_path: str):
55
+ def __init__(self, db_path: str, buffer_pool_size_mb: int = 0, read_only: bool = False):
30
56
  """Initialize KùzuDB backend.
31
57
 
32
58
  Args:
33
59
  db_path: Path to the KùzuDB database file (or ':memory:' for in-memory)
60
+ buffer_pool_size_mb: Buffer pool size in MB (0 = use KuzuDB default)
61
+ read_only: Open in read-only mode (allows concurrent indexing)
62
+
63
+ Raises:
64
+ RuntimeError: If the database is locked or cannot be opened.
34
65
  """
35
66
  self._db_path = db_path
36
- self._db = kuzu.database.Database(db_path)
67
+ try:
68
+ kwargs: dict = {"read_only": read_only}
69
+ if buffer_pool_size_mb > 0:
70
+ kwargs["buffer_pool_size"] = buffer_pool_size_mb * 1024 * 1024
71
+ self._db = kuzu.database.Database(db_path, **kwargs)
72
+ except RuntimeError as exc:
73
+ if "Could not set lock" in str(exc) or "lock" in str(exc).lower():
74
+ # Attempt to find the holding PID via lsof
75
+ pid_hint = _find_lock_holder(db_path)
76
+ pid_str = pid_hint.split()[-1] if pid_hint else "<PID>"
77
+ msg = (
78
+ f"Database is locked — another sqlcg process is running "
79
+ f"({pid_hint}). "
80
+ f"Wait for it to finish or kill it with: kill {pid_str}"
81
+ )
82
+ raise RuntimeError(msg) from exc
83
+ raise
37
84
  self._conn = kuzu.Connection(self._db)
38
85
  self._in_transaction = False
39
86
 
@@ -160,6 +207,93 @@ class KuzuBackend(GraphBackend):
160
207
  logger.error(f"upsert_edge failed: {src_label} -> {rel_type} -> {dst_label}: {e}")
161
208
  raise
162
209
 
210
+ def upsert_nodes_bulk(self, label: str, rows: list[dict[str, Any]]) -> None:
211
+ """Bulk-upsert nodes of one label in a single backend round-trip."""
212
+ if not rows:
213
+ return
214
+ # Validate all property keys across all rows (same guard as upsert_node)
215
+ for row in rows:
216
+ self._validate_props(row)
217
+
218
+ pk_field = self._pk_field(label)
219
+ # Determine the property key set from the first row; require homogeneity.
220
+ keys = list(rows[0].keys())
221
+ if pk_field not in keys:
222
+ raise ValueError(
223
+ f"upsert_nodes_bulk({label}): every row must include primary key '{pk_field}'"
224
+ )
225
+ for i, row in enumerate(rows[1:], 1):
226
+ if set(row.keys()) != set(keys):
227
+ raise ValueError(
228
+ f"upsert_nodes_bulk({label}): row {i} has property keys "
229
+ f"{sorted(row.keys())}, expected {sorted(keys)}"
230
+ )
231
+
232
+ set_keys = [k for k in keys if k != pk_field]
233
+ # UNWIND $rows AS row MERGE (n:Label {pk: row.pk}) SET n.k = row.k, ...
234
+ query = f"UNWIND $rows AS row MERGE (n:{label} {{{pk_field}: row.{pk_field}}})"
235
+ if set_keys:
236
+ set_parts = [f"n.{k} = row.{k}" for k in set_keys]
237
+ query += f" SET {', '.join(set_parts)}"
238
+
239
+ try:
240
+ self._conn.execute(query, {"rows": rows})
241
+ except Exception as e:
242
+ logger.error(f"upsert_nodes_bulk failed: {label} ({len(rows)} rows): {e}")
243
+ raise
244
+
245
+ def upsert_edges_bulk(
246
+ self,
247
+ src_label: str,
248
+ dst_label: str,
249
+ rel_type: str,
250
+ rows: list[dict[str, Any]],
251
+ ) -> None:
252
+ """Bulk-upsert edges of one (src_label, rel_type, dst_label) triple."""
253
+ if not rows:
254
+ return
255
+ for row in rows:
256
+ # src_key/dst_key are not graph properties; validate the remainder.
257
+ props = {k: v for k, v in row.items() if k not in ("src_key", "dst_key")}
258
+ self._validate_props(props)
259
+
260
+ src_pk = self._pk_field(src_label)
261
+ dst_pk = self._pk_field(dst_label)
262
+
263
+ keys = list(rows[0].keys())
264
+ for required in ("src_key", "dst_key"):
265
+ if required not in keys:
266
+ raise ValueError(
267
+ f"upsert_edges_bulk({src_label}->{rel_type}->{dst_label}): "
268
+ f"every row must include '{required}'"
269
+ )
270
+ for i, row in enumerate(rows[1:], 1):
271
+ if set(row.keys()) != set(keys):
272
+ raise ValueError(
273
+ f"upsert_edges_bulk: row {i} has property keys {sorted(row.keys())}, "
274
+ f"expected {sorted(keys)}"
275
+ )
276
+
277
+ prop_keys = [k for k in keys if k not in ("src_key", "dst_key")]
278
+ query = (
279
+ f"UNWIND $rows AS row "
280
+ f"MATCH (src:{src_label} {{{src_pk}: row.src_key}}) "
281
+ f"MATCH (dst:{dst_label} {{{dst_pk}: row.dst_key}}) "
282
+ f"MERGE (src)-[r:{rel_type}]->(dst)"
283
+ )
284
+ if prop_keys:
285
+ set_parts = [f"r.{k} = row.{k}" for k in prop_keys]
286
+ query += f" SET {', '.join(set_parts)}"
287
+
288
+ try:
289
+ self._conn.execute(query, {"rows": rows})
290
+ except Exception as e:
291
+ logger.error(
292
+ f"upsert_edges_bulk failed: {src_label}->{rel_type}->{dst_label} "
293
+ f"({len(rows)} rows): {e}"
294
+ )
295
+ raise
296
+
163
297
  def run_read(self, query: str, params: dict[str, Any]) -> list[dict[str, Any]]:
164
298
  """Execute a read-only query and return results."""
165
299
  try:
@@ -231,6 +365,38 @@ class KuzuBackend(GraphBackend):
231
365
  logger.warning(f"Failed to read schema version: {e}")
232
366
  return None
233
367
 
368
+ def set_indexed_sha(self, sha: str) -> None:
369
+ """Persist the git SHA of the last successful index.
370
+
371
+ Uses MERGE so the call is safe whether the SchemaVersion node already
372
+ exists or not. Mirrors the init_schema MERGE pattern.
373
+
374
+ Args:
375
+ sha: Git commit SHA string.
376
+ """
377
+ try:
378
+ self._conn.execute(
379
+ "MERGE (v:SchemaVersion {version: $version}) SET v.indexed_sha = $sha",
380
+ {"version": SCHEMA_VERSION, "sha": sha},
381
+ )
382
+ except Exception as e:
383
+ logger.warning(f"Failed to write indexed_sha: {e}")
384
+
385
+ def get_indexed_sha(self) -> str | None:
386
+ """Retrieve the git SHA of the last successful index.
387
+
388
+ Returns:
389
+ The stored SHA string, or None if never set.
390
+ """
391
+ try:
392
+ result = self.run_read(
393
+ "MATCH (v:SchemaVersion) RETURN v.indexed_sha AS sha LIMIT 1", {}
394
+ )
395
+ return result[0]["sha"] if result else None
396
+ except Exception as e:
397
+ logger.warning(f"Failed to read indexed_sha: {e}")
398
+ return None
399
+
234
400
  def close(self) -> None:
235
401
  """Close the database connection."""
236
402
  try:
@@ -261,11 +427,16 @@ class KuzuBackend(GraphBackend):
261
427
  yield self
262
428
  self._conn.execute("COMMIT")
263
429
  self._in_transaction = False
264
- except Exception:
430
+ except Exception as exc:
431
+ self._in_transaction = False
432
+ if "No active transaction" in str(exc):
433
+ # KuzuDB killed the transaction internally (e.g. KU_UNREACHABLE assertion
434
+ # during a bulk write). Individual failures were already logged; the batch
435
+ # is lost but the indexer can continue.
436
+ logger.warning("Transaction invalidated by KuzuDB — batch not committed")
437
+ return
265
438
  try:
266
439
  self._conn.execute("ROLLBACK")
267
- self._in_transaction = False
268
440
  except Exception as rollback_err:
269
- logger.error(f"Rollback failed: {rollback_err}")
270
- self._in_transaction = False # defensive reset
441
+ logger.debug("Rollback skipped: %s", rollback_err)
271
442
  raise
@@ -110,6 +110,30 @@ class Neo4jBackend(GraphBackend):
110
110
  logger.error(f"upsert_edge failed: {src_label} -> {rel_type} -> {dst_label}: {e}")
111
111
  raise
112
112
 
113
+ def upsert_nodes_bulk(self, label: str, rows: list[dict[str, Any]]) -> None:
114
+ """Bulk-upsert nodes of one label.
115
+
116
+ Neo4j adapter is not currently implemented. Use KuzuBackend instead.
117
+ """
118
+ raise NotImplementedError(
119
+ "Neo4j bulk upsert is not yet implemented. Use KuzuBackend instead."
120
+ )
121
+
122
+ def upsert_edges_bulk(
123
+ self,
124
+ src_label: str,
125
+ dst_label: str,
126
+ rel_type: str,
127
+ rows: list[dict[str, Any]],
128
+ ) -> None:
129
+ """Bulk-upsert edges of one (src_label, rel_type, dst_label) triple.
130
+
131
+ Neo4j adapter is not currently implemented. Use KuzuBackend instead.
132
+ """
133
+ raise NotImplementedError(
134
+ "Neo4j bulk upsert is not yet implemented. Use KuzuBackend instead."
135
+ )
136
+
113
137
  def run_read(self, query: str, params: dict[str, Any]) -> list[dict[str, Any]]:
114
138
  """Execute a read-only query and return results."""
115
139
  try:
@@ -160,6 +184,20 @@ class Neo4jBackend(GraphBackend):
160
184
  logger.warning(f"Failed to read schema version: {e}")
161
185
  return None
162
186
 
187
+ def set_indexed_sha(self, sha: str) -> None:
188
+ """Persist the git SHA of the last successful index (Neo4j stub).
189
+
190
+ Neo4j support for indexed_sha is not yet implemented.
191
+ """
192
+ raise NotImplementedError("set_indexed_sha is not yet implemented for Neo4jBackend")
193
+
194
+ def get_indexed_sha(self) -> str | None:
195
+ """Retrieve the git SHA of the last successful index (Neo4j stub).
196
+
197
+ Neo4j support for indexed_sha is not yet implemented.
198
+ """
199
+ raise NotImplementedError("get_indexed_sha is not yet implemented for Neo4jBackend")
200
+
163
201
  def close(self) -> None:
164
202
  """Close the database connection."""
165
203
  try:
@@ -0,0 +1,114 @@
1
+ -- DELETE_COLUMNS_FOR_FILE
2
+ MATCH (f:File {path: $path})<-[:DEFINED_IN]-(t:SqlTable)-[:HAS_COLUMN]->(c:SqlColumn)
3
+ DETACH DELETE c
4
+
5
+ -- DELETE_QUERIES_FOR_FILE
6
+ MATCH (f:File {path: $path})<-[:QUERY_DEFINED_IN]-(q:SqlQuery)
7
+ DETACH DELETE q
8
+
9
+ -- DELETE_TABLES_FOR_FILE
10
+ MATCH (f:File {path: $path})<-[:DEFINED_IN]-(t:SqlTable)
11
+ DETACH DELETE t
12
+
13
+ -- DELETE_FILE
14
+ MATCH (f:File {path: $path}) DETACH DELETE f
15
+
16
+ -- STALE_VIEWS
17
+ MATCH (f:File {path: $path})<-[:DEFINED_IN]-(t:SqlTable)
18
+ <-[:SELECTS_FROM]-(q:SqlQuery)-[:DECLARES]->(v:SqlTable {kind: 'VIEW'})
19
+ RETURN DISTINCT v.qualified AS view_name
20
+
21
+ -- INDEX_REPO_FILES
22
+ MATCH (f:File) WHERE f.path STARTS WITH $repo_prefix RETURN f.path AS path
23
+
24
+ -- TRACE_COLUMN_LINEAGE
25
+ MATCH (dst:SqlColumn {id: $id})<-[r:COLUMN_LINEAGE]-(src:SqlColumn)
26
+ RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified, r.transform AS transform, r.confidence AS confidence
27
+
28
+ -- FIND_TABLE_USAGES
29
+ MATCH (t:SqlTable {name: $name})<-[:SELECTS_FROM]-(q:SqlQuery)
30
+ -[:QUERY_DEFINED_IN]->(f:File)
31
+ RETURN f.path AS file, q.sql AS sql, q.kind AS kind
32
+
33
+ -- GET_DOWNSTREAM_DEPENDENCIES
34
+ MATCH (src:SqlColumn {id: $id})-[:COLUMN_LINEAGE]->(dst:SqlColumn)
35
+ RETURN dst.id AS id, dst.col_name AS col_name, dst.table_qualified AS table_qualified
36
+
37
+ -- GET_UPSTREAM_DEPENDENCIES
38
+ MATCH (dst:SqlColumn {id: $id})<-[:COLUMN_LINEAGE]-(src:SqlColumn)
39
+ RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified
40
+
41
+ -- SEARCH_SQL_PATTERN
42
+ MATCH (q:SqlQuery)-[:QUERY_DEFINED_IN]->(f:File)
43
+ WHERE contains(q.sql, $query)
44
+ RETURN f.path AS file, q.sql AS sql, q.kind AS kind
45
+ LIMIT $limit
46
+
47
+ -- LIST_DIALECTS_AND_REPOS
48
+ MATCH (r:Repo)<-[:BELONGS_TO]-(f:File)
49
+ RETURN r.path AS path, r.name AS name, collect(DISTINCT f.dialect) AS dialects
50
+
51
+ -- EXPAND_STAR_SOURCES
52
+ MATCH (q:SqlQuery)-[s:STAR_SOURCE]->(t:SqlTable)-[:HAS_COLUMN]->(c:SqlColumn)
53
+ WHERE q.target_table <> ''
54
+ MATCH (tgt:SqlTable {qualified: q.target_table})
55
+ MERGE (dst:SqlColumn {id: q.target_table + '.' + c.col_name})
56
+ ON CREATE SET dst.col_name = c.col_name,
57
+ dst.table_qualified = q.target_table,
58
+ dst.catalog = tgt.catalog,
59
+ dst.db = tgt.db,
60
+ dst.table_name = tgt.name
61
+ MERGE (tgt)-[:HAS_COLUMN]->(dst)
62
+ MERGE (c)-[r:COLUMN_LINEAGE]->(dst)
63
+ ON CREATE SET r.transform = 'STAR_EXPANSION',
64
+ r.confidence = 0.8,
65
+ r.query_id = q.id
66
+ RETURN count(r) AS edges_created
67
+
68
+ -- COUNT_STAR_SOURCES
69
+ MATCH ()-[r:STAR_SOURCE]->() RETURN count(r) AS n
70
+
71
+ -- COUNT_STAR_EXPANSIONS
72
+ MATCH ()-[r:COLUMN_LINEAGE {transform: 'STAR_EXPANSION'}]->() RETURN count(r) AS n
73
+
74
+ -- FIND_DEFINITION
75
+ MATCH (t:SqlTable {qualified: $table_qualified})-[:DEFINED_IN]->(f:File)
76
+ RETURN t.qualified AS table_qualified, t.kind AS kind, t.defined_in_file AS defined_in_file, f.path AS file_path
77
+
78
+ -- GET_TABLE_DEFINING_FILES
79
+ MATCH (t:SqlTable {qualified: $table_qualified})-[:DEFINED_IN]->(f:File)
80
+ RETURN f.path AS file_path, t.kind AS kind
81
+
82
+ -- GET_TABLE_DIRECT_UPSTREAMS
83
+ MATCH (q:SqlQuery {target_table: $table_qualified})-[:SELECTS_FROM]->(src:SqlTable)
84
+ WHERE src.qualified <> $table_qualified
85
+ OPTIONAL MATCH (q)-[:QUERY_DEFINED_IN]->(f:File)
86
+ RETURN DISTINCT src.qualified AS upstream_table, f.path AS in_file
87
+
88
+ -- GET_COLUMNS_FOR_TABLE
89
+ MATCH (t:SqlTable {qualified: $table_qualified})-[:HAS_COLUMN]->(c:SqlColumn)
90
+ RETURN c.id AS col_id, c.col_name AS col_name
91
+
92
+ -- GET_TABLES_DEFINED_IN_FILE
93
+ MATCH (f:File {path: $file_path})<-[:DEFINED_IN]-(t:SqlTable)
94
+ RETURN t.qualified AS table_qualified
95
+
96
+ -- ANALYZE_UNUSED_TABLES
97
+ MATCH (t:SqlTable)
98
+ WHERE NOT (t)<-[:SELECTS_FROM]-()
99
+ RETURN t.qualified AS table_qualified
100
+ ORDER BY t.qualified
101
+
102
+ -- HUB_RANKING
103
+ MATCH (t:SqlTable)<-[:SELECTS_FROM]-(q:SqlQuery)
104
+ WHERE q.target_table <> ''
105
+ WITH t.qualified AS table_qualified, q.target_table AS consumer_table
106
+ WHERE consumer_table <> table_qualified
107
+ RETURN table_qualified, count(DISTINCT consumer_table) AS downstream_dependents
108
+ ORDER BY downstream_dependents DESC, table_qualified
109
+ LIMIT $k
110
+
111
+ -- DEPENDENT_FILES_OF_TABLES
112
+ UNWIND $tables AS tbl
113
+ MATCH (t:SqlTable {qualified: tbl})<-[:SELECTS_FROM]-(q:SqlQuery)-[:QUERY_DEFINED_IN]->(f:File)
114
+ RETURN DISTINCT f.path AS path
sqlcg/core/queries.py CHANGED
@@ -1,82 +1,44 @@
1
- """Centralized Cypher query strings for graph operations."""
2
-
3
- from sqlcg.core.schema import NodeLabel, RelType
4
-
5
- # Scope is bounded by exact path match; APOC procedures are not required.
6
- # Delete Column nodes for tables defined in a file
7
- DELETE_COLUMNS_FOR_FILE = (
8
- f"MATCH (f:{NodeLabel.FILE} {{path: $path}})"
9
- f"<-[:{RelType.DEFINED_IN}]-(t:{NodeLabel.TABLE})"
10
- f"-[:{RelType.HAS_COLUMN}]->(c:{NodeLabel.COLUMN})"
11
- " DETACH DELETE c"
12
- )
13
-
14
- # Delete Query nodes and their edges
15
- DELETE_QUERIES_FOR_FILE = (
16
- f"MATCH (f:{NodeLabel.FILE} {{path: $path}})"
17
- f"<-[:{RelType.QUERY_DEFINED_IN}]-(q:{NodeLabel.QUERY})"
18
- " DETACH DELETE q"
19
- )
20
-
21
- # Delete Table nodes defined in a file
22
- DELETE_TABLES_FOR_FILE = (
23
- f"MATCH (f:{NodeLabel.FILE} {{path: $path}})"
24
- f"<-[:{RelType.DEFINED_IN}]-(t:{NodeLabel.TABLE})"
25
- " DETACH DELETE t"
26
- )
27
-
28
- # Delete the File node itself
29
- DELETE_FILE = f"MATCH (f:{NodeLabel.FILE} {{path: $path}}) DETACH DELETE f"
30
-
31
- # Find views that depend on tables defined in a file
32
- STALE_VIEWS_QUERY = (
33
- f"MATCH (f:{NodeLabel.FILE} {{path: $path}})"
34
- f"<-[:{RelType.DEFINED_IN}]-(t:{NodeLabel.TABLE})"
35
- f"<-[:{RelType.SELECTS_FROM}]-(q:{NodeLabel.QUERY})"
36
- f"-[:{RelType.DECLARES}]->(v:{NodeLabel.TABLE} {{kind: 'VIEW'}})"
37
- " RETURN DISTINCT v.qualified AS view_name"
38
- )
39
-
40
- # Get all files in a repo by path prefix
41
- INDEX_REPO_FILES_QUERY = (
42
- "MATCH (f:File) WHERE f.path STARTS WITH $repo_prefix RETURN f.path AS path"
43
- )
44
-
45
- # Trace upstream lineage of a column
46
- TRACE_COLUMN_LINEAGE_QUERY = (
47
- "MATCH (dst:SqlColumn {id: $id})<-[:COLUMN_LINEAGE]-(src:SqlColumn) "
48
- "RETURN src.id AS id, src.col_name AS col_name"
49
- )
50
-
51
- # Find table usages in queries
52
- FIND_TABLE_USAGES_QUERY = (
53
- "MATCH (t:SqlTable {name: $name})<-[:SELECTS_FROM]-(q:SqlQuery)"
54
- "-[:QUERY_DEFINED_IN]->(f:File) "
55
- "RETURN f.path AS file, q.sql AS sql, q.kind AS kind"
56
- )
57
-
58
- # Get downstream column dependencies
59
- GET_DOWNSTREAM_DEPENDENCIES_QUERY = (
60
- "MATCH (src:SqlColumn {id: $id})-[:COLUMN_LINEAGE]->(dst:SqlColumn) "
61
- "RETURN dst.id AS id, dst.col_name AS col_name"
62
- )
63
-
64
- # Get upstream column dependencies
65
- GET_UPSTREAM_DEPENDENCIES_QUERY = (
66
- "MATCH (dst:SqlColumn {id: $id})<-[:COLUMN_LINEAGE]-(src:SqlColumn) "
67
- "RETURN src.id AS id, src.col_name AS col_name"
68
- )
69
-
70
- # Search SQL patterns in indexed queries
71
- SEARCH_SQL_PATTERN_QUERY = (
72
- "MATCH (q:SqlQuery)-[:QUERY_DEFINED_IN]->(f:File) "
73
- "WHERE contains(q.sql, $query) "
74
- "RETURN f.path AS file, q.sql AS sql, q.kind AS kind "
75
- "LIMIT $limit"
76
- )
77
-
78
- # List dialects and repos
79
- LIST_DIALECTS_AND_REPOS_QUERY = (
80
- "MATCH (r:Repo)<-[:BELONGS_TO]-(f:File) "
81
- "RETURN r.path AS path, r.name AS name, collect(DISTINCT f.dialect) AS dialects"
82
- )
1
+ """Cypher query loader. All query strings live in queries.cypher."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ _CYPHER_FILE = Path(__file__).parent / "queries.cypher"
7
+
8
+
9
+ def _load() -> dict[str, str]:
10
+ """Load named Cypher blocks from queries.cypher.
11
+
12
+ Format: blocks are separated by lines matching "-- BLOCK_NAME" at the start.
13
+ Each block name becomes a key in the returned dict.
14
+ """
15
+ text = _CYPHER_FILE.read_text(encoding="utf-8")
16
+ blocks = re.split(r"^--\s+(\w+)\s*$", text, flags=re.MULTILINE)
17
+ return {blocks[i]: blocks[i + 1].strip() for i in range(1, len(blocks), 2)}
18
+
19
+
20
+ _Q = _load()
21
+
22
+ DELETE_COLUMNS_FOR_FILE = _Q["DELETE_COLUMNS_FOR_FILE"]
23
+ DELETE_QUERIES_FOR_FILE = _Q["DELETE_QUERIES_FOR_FILE"]
24
+ DELETE_TABLES_FOR_FILE = _Q["DELETE_TABLES_FOR_FILE"]
25
+ DELETE_FILE = _Q["DELETE_FILE"]
26
+ STALE_VIEWS_QUERY = _Q["STALE_VIEWS"]
27
+ INDEX_REPO_FILES_QUERY = _Q["INDEX_REPO_FILES"]
28
+ TRACE_COLUMN_LINEAGE_QUERY = _Q["TRACE_COLUMN_LINEAGE"]
29
+ FIND_TABLE_USAGES_QUERY = _Q["FIND_TABLE_USAGES"]
30
+ GET_DOWNSTREAM_DEPENDENCIES_QUERY = _Q["GET_DOWNSTREAM_DEPENDENCIES"]
31
+ GET_UPSTREAM_DEPENDENCIES_QUERY = _Q["GET_UPSTREAM_DEPENDENCIES"]
32
+ SEARCH_SQL_PATTERN_QUERY = _Q["SEARCH_SQL_PATTERN"]
33
+ LIST_DIALECTS_AND_REPOS_QUERY = _Q["LIST_DIALECTS_AND_REPOS"]
34
+ EXPAND_STAR_SOURCES_QUERY = _Q["EXPAND_STAR_SOURCES"]
35
+ COUNT_STAR_SOURCES_QUERY = _Q["COUNT_STAR_SOURCES"]
36
+ COUNT_STAR_EXPANSIONS_QUERY = _Q["COUNT_STAR_EXPANSIONS"]
37
+ FIND_DEFINITION_QUERY = _Q["FIND_DEFINITION"]
38
+ GET_TABLE_DEFINING_FILES_QUERY = _Q["GET_TABLE_DEFINING_FILES"]
39
+ GET_TABLE_DIRECT_UPSTREAMS_QUERY = _Q["GET_TABLE_DIRECT_UPSTREAMS"]
40
+ GET_COLUMNS_FOR_TABLE_QUERY = _Q["GET_COLUMNS_FOR_TABLE"]
41
+ GET_TABLES_DEFINED_IN_FILE_QUERY = _Q["GET_TABLES_DEFINED_IN_FILE"]
42
+ ANALYZE_UNUSED_TABLES_QUERY = _Q["ANALYZE_UNUSED_TABLES"]
43
+ HUB_RANKING_QUERY = _Q["HUB_RANKING"]
44
+ DEPENDENT_FILES_OF_TABLES_QUERY = _Q["DEPENDENT_FILES_OF_TABLES"]
sqlcg/core/schema.cypher CHANGED
@@ -9,7 +9,9 @@ CREATE NODE TABLE File (
9
9
  path STRING PRIMARY KEY,
10
10
  repo_path STRING,
11
11
  sha STRING,
12
- dialect STRING
12
+ dialect STRING,
13
+ parse_failed BOOLEAN,
14
+ parse_cause STRING
13
15
  );
14
16
 
15
17
  -- Table node: one per unique table reference
@@ -62,7 +64,8 @@ CREATE REL TABLE QUERY_DEFINED_IN (
62
64
 
63
65
  -- Table -> Column: table has this column
64
66
  CREATE REL TABLE HAS_COLUMN (
65
- FROM SqlTable TO SqlColumn
67
+ FROM SqlTable TO SqlColumn,
68
+ source STRING
66
69
  );
67
70
 
68
71
  -- Query -> Table: query selects from table
@@ -98,7 +101,16 @@ CREATE REL TABLE DECLARES (
98
101
  FROM SqlQuery TO SqlTable
99
102
  );
100
103
 
104
+ -- Query -> Table: query does SELECT * (or alias.*) from this table
105
+ CREATE REL TABLE STAR_SOURCE (
106
+ FROM SqlQuery TO SqlTable,
107
+ qualifier STRING,
108
+ target_table STRING,
109
+ confidence FLOAT
110
+ );
111
+
101
112
  -- Schema version tracking
102
113
  CREATE NODE TABLE SchemaVersion (
103
- version STRING PRIMARY KEY
114
+ version STRING PRIMARY KEY,
115
+ indexed_sha STRING
104
116
  );
sqlcg/core/schema.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from enum import StrEnum
4
4
  from importlib.resources import files
5
5
 
6
- SCHEMA_VERSION = "1"
6
+ SCHEMA_VERSION = "4"
7
7
 
8
8
 
9
9
  class NodeLabel(StrEnum):
@@ -26,6 +26,7 @@ class RelType(StrEnum):
26
26
  UPDATES = "UPDATES"
27
27
  COLUMN_LINEAGE = "COLUMN_LINEAGE"
28
28
  DECLARES = "DECLARES"
29
+ STAR_SOURCE = "STAR_SOURCE"
29
30
 
30
31
 
31
32
  # Backward-compatible aliases