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.
- {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/METADATA +87 -9
- sql_code_graph-1.0.1.dist-info/RECORD +63 -0
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +24 -0
- sqlcg/cli/commands/db.py +40 -7
- sqlcg/cli/commands/gain.py +5 -17
- sqlcg/cli/commands/git.py +71 -40
- sqlcg/cli/commands/index.py +151 -17
- sqlcg/cli/commands/install.py +147 -8
- sqlcg/cli/commands/mcp.py +12 -0
- sqlcg/cli/commands/reindex.py +170 -0
- sqlcg/cli/commands/uninstall.py +94 -39
- sqlcg/cli/commands/watch.py +14 -1
- sqlcg/cli/main.py +8 -0
- sqlcg/core/config.py +185 -2
- sqlcg/core/graph_db.py +65 -0
- sqlcg/core/kuzu_backend.py +177 -6
- sqlcg/core/neo4j_backend.py +38 -0
- sqlcg/core/queries.cypher +114 -0
- sqlcg/core/queries.py +44 -82
- sqlcg/core/schema.cypher +15 -3
- sqlcg/core/schema.py +2 -1
- sqlcg/indexer/error_classify.py +140 -0
- sqlcg/indexer/git_delta.py +121 -0
- sqlcg/indexer/indexer.py +951 -132
- sqlcg/indexer/pool.py +500 -0
- sqlcg/indexer/walker.py +1 -3
- sqlcg/indexer/watcher.py +68 -18
- sqlcg/lineage/aggregator.py +58 -2
- sqlcg/lineage/schema_resolver.py +26 -14
- sqlcg/parsers/ansi_parser.py +195 -26
- sqlcg/parsers/base.py +627 -58
- sqlcg/parsers/bigquery_parser.py +7 -2
- sqlcg/parsers/postgres_parser.py +7 -2
- sqlcg/parsers/registry.py +7 -2
- sqlcg/parsers/snowflake_parser.py +170 -8
- sqlcg/parsers/tsql_parser.py +7 -2
- sqlcg/server/models.py +297 -4
- sqlcg/server/noise_filter.py +167 -0
- sqlcg/server/skill.py +256 -0
- sqlcg/server/tools.py +934 -178
- sql_code_graph-0.3.0.dist-info/RECORD +0 -56
- {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/WHEEL +0 -0
- {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.1.dist-info}/entry_points.txt +0 -0
sqlcg/core/kuzu_backend.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
270
|
-
self._in_transaction = False # defensive reset
|
|
441
|
+
logger.debug("Rollback skipped: %s", rollback_err)
|
|
271
442
|
raise
|
sqlcg/core/neo4j_backend.py
CHANGED
|
@@ -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
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 = "
|
|
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
|