sql-code-graph 1.1.0__py3-none-any.whl → 1.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-code-graph
3
- Version: 1.1.0
3
+ Version: 1.2.2
4
4
  Summary: SQL code graph analyzer and lineage tracer
5
5
  Project-URL: Homepage, https://github.com/Warhorze/sql-code-graph
6
6
  Project-URL: Repository, https://github.com/Warhorze/sql-code-graph
@@ -267,6 +267,16 @@ sqlcg mcp restart # stop the server (client must respawn it
267
267
  sqlcg version # show installed version
268
268
  ```
269
269
 
270
+ ### Reads while the server is running (v1.2.0)
271
+
272
+ KuzuDB allows a single writer, so while the MCP server is live it holds the
273
+ database lock. CLI **read** commands (`find`, `analyze`, `db info`, `list-repos`,
274
+ `gain`) automatically route their query through the running server over its
275
+ control socket and return rows as usual — no flag, no config. When no server is
276
+ running they open the database directly, exactly as before. If the server is
277
+ mid-reindex the read waits for it to finish rather than failing with
278
+ "Database is locked".
279
+
270
280
  ## Supported dialects
271
281
 
272
282
  sqlcg is built on [sqlglot](https://github.com/tobymao/sqlglot), so other dialects
@@ -1,12 +1,12 @@
1
- sqlcg/__init__.py,sha256=CWoJX8Awg5Tf6p2E5lT66EFE8kd-Aru8aujKizglgdo,115
1
+ sqlcg/__init__.py,sha256=6jzbRxs0WG3Yq2KYIDG9bagxRZvhbhp6agviO4_nXwM,115
2
2
  sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
3
3
  sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
4
4
  sqlcg/cli/main.py,sha256=WmdTjsOlz1ozi2Y3Aq4ezR_FCRl-Lc1YOKw3_d48dlY,1650
5
5
  sqlcg/cli/commands/__init__.py,sha256=oSHtr6VD-jNubOjuCQyZj2tBppjMEpQDh-IGQ8of9eA,30
6
- sqlcg/cli/commands/analyze.py,sha256=qtvM_TeqYzaLClZksM_o5hAdksZ9sqLM9HGDtLDrXwY,12646
7
- sqlcg/cli/commands/db.py,sha256=Q3VEdNJzhrs26KtskI5j9B3C0vBTZe4VN2sZXZG_6BY,7434
8
- sqlcg/cli/commands/find.py,sha256=5MbGavA-QS75zwm35dYK-0H1bJ1Zd_gJHgQ_lXnpMDU,3126
9
- sqlcg/cli/commands/gain.py,sha256=bOvia7CVla_fESrDEdftYze8Mm0xDio3SpCzIyoXg7A,8925
6
+ sqlcg/cli/commands/analyze.py,sha256=xr3RHmO4eFTP4VKZn4DAx3BJzSi60_DIZmdE-QLfsHI,13601
7
+ sqlcg/cli/commands/db.py,sha256=iUPnSxkSjt2EZzj_zmXiIyM39NTTCpazD4Y8Q9iPZEc,7725
8
+ sqlcg/cli/commands/find.py,sha256=zTYN9goILalYq4R9x6lIR6MmNcydDbR17UXkx1gPRsI,2913
9
+ sqlcg/cli/commands/gain.py,sha256=Kws76u1na2XxmbWN_YWrPaYHYmYBLC6DDDf7xqnltqc,9126
10
10
  sqlcg/cli/commands/git.py,sha256=yMgWOuoTCTBr2P1QgmghRi5ikmUYHuxDUVyBDYerErw,5728
11
11
  sqlcg/cli/commands/index.py,sha256=xMnxKDiUt5LH_3lKAotoRctL4VSOvcw7Gq--idLPtm0,11091
12
12
  sqlcg/cli/commands/install.py,sha256=KNABvrLbamPyYnmnVdCaM_MNezbDc-pr6IkignCWI8k,9186
@@ -16,32 +16,32 @@ sqlcg/cli/commands/report.py,sha256=JU0qjyMxwOukE7bN3XvvIzOI7zMg_Gsnvk_8F6pKNpA,
16
16
  sqlcg/cli/commands/uninstall.py,sha256=IYwQaqnMmmzW0Nlls40wD-L3tVkMgKIMRXUkcXPMUc4,9398
17
17
  sqlcg/cli/commands/watch.py,sha256=7N6c-QuvxAEGHzDZ0C3CU2BkHSraZW9YtgoFnz7SaQo,2373
18
18
  sqlcg/core/__init__.py,sha256=uNsJCrCMVWVT80sHPtI_f39BYqIf5N0i6LSq8x8HsyI,283
19
- sqlcg/core/config.py,sha256=8QtFNRnrzLK1Zw93AKX37h6bSASDLv-42FzDQ7zxTtI,13079
19
+ sqlcg/core/config.py,sha256=qNR-yXkfYfS8Y8WX4Qo6Zkq8PPP_ZiTrvX0DLmEZkGY,14821
20
20
  sqlcg/core/freshness.py,sha256=gRb8pRPw5SdIUxAYkMXIJ00DTdQ6CegRZPAvWnv0rU0,4575
21
21
  sqlcg/core/graph_db.py,sha256=Aa85wPFg26H-Ud9SrZyxCHH-99iitAI5S3X9T_62Yyw,7957
22
22
  sqlcg/core/jobs.py,sha256=Je-fCdSKRgiSsv1W8SgNAlp36a7t7-pJZ-qKPbka9OE,3298
23
- sqlcg/core/kuzu_backend.py,sha256=ziHt-AB9sEZY7qB8whseWFicbTfOZaNOxcNVKhjii5Y,16587
23
+ sqlcg/core/kuzu_backend.py,sha256=3kL8bGEQm70fuxYUdt1p7fsY12lCLQ07x01NYg6FOGA,16821
24
24
  sqlcg/core/neo4j_backend.py,sha256=AM1TncP9GBGph-rSHwalZPmGUV2kFILzaJP-PSB0UYw,8437
25
- sqlcg/core/queries.cypher,sha256=91Pb10-ekSi0812wuHJTdXcMY4sT53_5o-oHhfSP_DQ,4967
26
- sqlcg/core/queries.py,sha256=JLgV4MIgP7KVIQ0xpGj3_-MBhBfY_9XPoCdcI2mO-TM,2148
25
+ sqlcg/core/queries.cypher,sha256=cvPOVe5GUOzJN4bxUvDxNI--xIIP8gm42TR-gUnea4U,4685
26
+ sqlcg/core/queries.py,sha256=gkl4bhkZM8FsvbSA-IaK17sRFcO3hB5YlVCemkCXgWM,2064
27
27
  sqlcg/core/schema.cypher,sha256=rK5QMhSrzZhuj73NeNXGX6oM-rPPPvxFjex0fEyUvkQ,2859
28
28
  sqlcg/core/schema.py,sha256=JO5rkspYKjL9AEl5mt0VIJKn-IPOH3kJV_fVmAMuFCI,1467
29
29
  sqlcg/indexer/__init__.py,sha256=Wh20Unz2OHs1oIyWLrpurPAasF0BET2g4iXtNk7mh2U,56
30
30
  sqlcg/indexer/dbt_adapter.py,sha256=EB5x1WU5Z9d-I97ADDj88S_hG1C4z4nbrv8JUCzXfy8,686
31
31
  sqlcg/indexer/error_classify.py,sha256=MYjPVprwT-ARPjBCyCzu2F9DSrZfnTVtVIoBgm8s4H8,5329
32
32
  sqlcg/indexer/git_delta.py,sha256=P-QM4vnVURT2KLiE6u3cQynRUF-mTH13cbB4I20YHPQ,4468
33
- sqlcg/indexer/indexer.py,sha256=DYdUr59hRKCjJTRiQUWOC72JUQ9TgBrH0W4UOYNwqx8,60913
33
+ sqlcg/indexer/indexer.py,sha256=7TCgBLl3ml3mF8Z2q4YHZJ6HdxSuLH-rPJTibnUJUe4,66658
34
34
  sqlcg/indexer/pool.py,sha256=BTYx-pBe6zwUG89MHh0X7nzGNVlsHN-GjovYKanVI1s,18553
35
35
  sqlcg/indexer/walker.py,sha256=umNaqDbuerr75VYG1TEOv0ATsbI40O3SIw35f7XJcDE,1931
36
36
  sqlcg/indexer/watcher.py,sha256=mJQq1LASRLKKwhz0WhCUWPLLqyPR2_-FD_8efYU6gE8,8442
37
37
  sqlcg/lineage/__init__.py,sha256=Da1DlYwtK13WHv_RnHjAtNkHTOuFbhxqCjT1Le7DsWM,46
38
- sqlcg/lineage/aggregator.py,sha256=G1xsTjf981EVSgN1yIHcC_ecDvcTcSPvEp6Kb2HPXkY,4943
38
+ sqlcg/lineage/aggregator.py,sha256=LVyNcmvLBHWbh8SrDsJJBKd7sLg3-2NhEWwEndG7Jbc,4144
39
39
  sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8AaWcc,7320
40
40
  sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
41
41
  sqlcg/metrics/store.py,sha256=BaMf7QYTmYMlX_Jzi1GNU8R2sMVkWdn07f-ZSndtcNk,8879
42
42
  sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
43
- sqlcg/parsers/ansi_parser.py,sha256=tu1MWWaSYmpefKjgk2PPyGStIFjV47Z_1WjyBh5Zi2c,17180
44
- sqlcg/parsers/base.py,sha256=uL0W22zpbIz_9eq-i-4LSlonxy2J1yChuISMLSYgvRU,49345
43
+ sqlcg/parsers/ansi_parser.py,sha256=mGZvijMOMQ4i1BybpwU29a8jnIGViefhy9fxzkSpsRM,17193
44
+ sqlcg/parsers/base.py,sha256=IiOkVsm6jz9-48RqDCXiW-UXAraNxQ4pKXvSA7aolnA,49907
45
45
  sqlcg/parsers/bigquery_parser.py,sha256=mOnWTfXB_Dp4JwFE1PVYOB6CDPf5nYE0Dea8kJCl9uQ,2827
46
46
  sqlcg/parsers/postgres_parser.py,sha256=lYfUpQY6j4Qm7ndXBtXbgPoGzYqYddWt5YeFnWKdA6I,946
47
47
  sqlcg/parsers/registry.py,sha256=LXy1F6rqQI6VdxpRvZg_tNpoEucW3mXZHYBMlMONbX4,1496
@@ -52,14 +52,15 @@ sqlcg/server/control.py,sha256=v-r21npODiHlHnJHuo_6KWrKclQKq_E1QyrzIWjqgtY,4508
52
52
  sqlcg/server/exceptions.py,sha256=EONw34icOByCTpppSQrvQBW6asc4hfqaGDCAFjv96II,469
53
53
  sqlcg/server/models.py,sha256=l7ORy6sbtzBW1y3qVaeLwEukbyAgBkz9S5VIm2q4b24,19378
54
54
  sqlcg/server/noise_filter.py,sha256=idSBGgdKWWccJdpOo9qgbM2350Oew-2l5W6Yc9GYQqY,6337
55
- sqlcg/server/server.py,sha256=gzeO5WbSNfGxgIKte01uy0VjO1_basI2ChSuAwr0dBc,14844
55
+ sqlcg/server/read_client.py,sha256=ncoJK7UckGhWtN9bv1CgViNMNtac96zBUE7RPYQ8_WI,7783
56
+ sqlcg/server/server.py,sha256=QBb7N4lc5o7KBh1Ik6MAiOvBV8SW81ahtkM1ZOVuyXE,18983
56
57
  sqlcg/server/skill.py,sha256=GE8eeimk6yiGGJ74erGypqYAviur5peSR6_2a4QQWVM,12828
57
58
  sqlcg/server/tools.py,sha256=JvijDC0h5uHjZyZUIZq9sztNG3W5sr-Yy5rHwOVuJec,66642
58
59
  sqlcg/utils/__init__.py,sha256=--iqt5ThTXmT8Wz7da8hs3n0zDfYPl8P-z5OgRJ_77E,154
59
60
  sqlcg/utils/hashing.py,sha256=H25-sYfxHKb3_IERFnHyAIYNiXN470Oqo5sJT_D3YOA,438
60
61
  sqlcg/utils/ignore.py,sha256=wJjwa0mjnQ_xJExOUxk25y00g065XmmzJapqV3ifD5o,1151
61
62
  sqlcg/utils/logging.py,sha256=u0fCmYsLj9o81vawm3xZTHaw68GQYVm7JxG-gP81u8A,840
62
- sql_code_graph-1.1.0.dist-info/METADATA,sha256=blW1eYNjfy6P61747uUtc22qm5MDETMcVYImXPa762g,13615
63
- sql_code_graph-1.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
64
- sql_code_graph-1.1.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
65
- sql_code_graph-1.1.0.dist-info/RECORD,,
63
+ sql_code_graph-1.2.2.dist-info/METADATA,sha256=e4llIRyH4QYoHbIznQIpZ_wreqsuscvDzuLBVQBv33M,14148
64
+ sql_code_graph-1.2.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
65
+ sql_code_graph-1.2.2.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
66
+ sql_code_graph-1.2.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
sqlcg/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """SQL Code Graph - SQL lineage and dependency analysis tool."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.2"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -8,9 +8,9 @@ import typer
8
8
  from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
- from sqlcg.core.config import get_backend
12
11
  from sqlcg.core.queries import GET_TABLE_EXTERNAL_CONSUMERS_QUERY
13
12
  from sqlcg.core.schema import NodeLabel, RelType
13
+ from sqlcg.server.read_client import run_read_routed
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from sqlcg.server.noise_filter import NoiseFilter
@@ -19,6 +19,39 @@ app = typer.Typer(help="Lineage analysis")
19
19
  console = Console()
20
20
 
21
21
 
22
+ def _kind_filter(node_alias: str, *, include_intermediate: bool) -> str:
23
+ """Build the SqlTable kind-filter clause for upstream/downstream queries.
24
+
25
+ Uses an OPTIONAL MATCH + explicit ``WITH … WHERE t.kind IS NULL OR …`` form
26
+ (Half B of the #38/#39 fix) so that a node-less physical source — one whose
27
+ SqlTable node is absent because it was only seen inside a CTE body before
28
+ re-index — is KEPT, not dropped. CTE/derived intermediates carry
29
+ ``kind='cte'`` or ``kind='derived'`` and are excluded by the
30
+ ``kind IN [...]`` guard; ``IS NULL`` matches the absent-node case (t = NULL
31
+ → t.kind = NULL → IS NULL is TRUE).
32
+
33
+ The ``WITH {node_alias}, t WHERE`` clause is required: an OPTIONAL MATCH
34
+ WHERE clause in KuzuDB applies to the match attempt and does not filter the
35
+ surrounding row. The subsequent WITH … WHERE is the actual row filter.
36
+
37
+ Args:
38
+ node_alias: The Cypher alias for the column node whose table is filtered
39
+ (``"src"`` for upstream, ``"dst"`` for downstream).
40
+ include_intermediate: When True, return an empty string (no filtering);
41
+ all intermediates including CTE nodes are kept.
42
+
43
+ Returns:
44
+ A Cypher fragment string (with trailing space) to embed directly in the
45
+ query, or an empty string when include_intermediate is True.
46
+ """
47
+ if include_intermediate:
48
+ return ""
49
+ return (
50
+ f"OPTIONAL MATCH (t:SqlTable {{qualified: {node_alias}.table_qualified}}) "
51
+ f"WITH {node_alias}, t WHERE t.kind IS NULL OR t.kind IN ['table', 'external'] "
52
+ )
53
+
54
+
22
55
  @app.command("upstream")
23
56
  def upstream( # noqa: B008
24
57
  ref: str = typer.Argument(..., help="Column reference"), # noqa: B008
@@ -35,49 +68,45 @@ def upstream( # noqa: B008
35
68
  raise typer.Exit(1)
36
69
 
37
70
  # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them
38
- kind_filter = (
39
- ""
40
- if include_intermediate
41
- else "MATCH (t:SqlTable {qualified: src.table_qualified}) "
42
- "WHERE t.kind IN ['table', 'external'] "
71
+ kf = _kind_filter("src", include_intermediate=include_intermediate)
72
+
73
+ results = run_read_routed(
74
+ f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
75
+ f"<-[:{RelType.COLUMN_LINEAGE}*1..{depth}]-(src:{NodeLabel.COLUMN}) "
76
+ f"{kf}"
77
+ f"OPTIONAL MATCH (src)-[srcedge:{RelType.COLUMN_LINEAGE}]->() "
78
+ "OPTIONAL MATCH (q:SqlQuery {id: srcedge.query_id}) "
79
+ "WITH src, min(q.start_line) AS line, min(q.file_path) AS file "
80
+ "RETURN src.id AS id, file AS file, line AS line LIMIT 100",
81
+ {"ref": ref},
43
82
  )
44
-
45
- with get_backend() as backend:
46
- results = backend.run_read(
47
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
83
+ if not results and len(ref.split(".")) >= 3:
84
+ bare = _bare_ref(ref)
85
+ fallback_results = run_read_routed(
86
+ f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
48
87
  f"<-[:{RelType.COLUMN_LINEAGE}*1..{depth}]-(src:{NodeLabel.COLUMN}) "
49
- f"{kind_filter}"
50
- f"OPTIONAL MATCH (src)-[direct:{RelType.COLUMN_LINEAGE}]->(c) "
51
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
52
- "RETURN src.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
53
- {"ref": ref},
88
+ f"{kf}"
89
+ f"OPTIONAL MATCH (src)-[srcedge:{RelType.COLUMN_LINEAGE}]->() "
90
+ "OPTIONAL MATCH (q:SqlQuery {id: srcedge.query_id}) "
91
+ "WITH src, min(q.start_line) AS line, min(q.file_path) AS file "
92
+ "RETURN src.id AS id, file AS file, line AS line LIMIT 100",
93
+ {"bare": bare},
54
94
  )
55
- if not results and len(ref.split(".")) >= 3:
56
- bare = _bare_ref(ref)
57
- fallback_results = backend.run_read(
58
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
59
- f"<-[:{RelType.COLUMN_LINEAGE}*1..{depth}]-(src:{NodeLabel.COLUMN}) "
60
- f"{kind_filter}"
61
- f"OPTIONAL MATCH (src)-[direct:{RelType.COLUMN_LINEAGE}]->(c) "
62
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
63
- "RETURN src.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
64
- {"bare": bare},
95
+ if fallback_results:
96
+ console.print(
97
+ f"[yellow]Hint:[/yellow] No results for '{ref}'. "
98
+ f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
99
+ "The INSERT target may have been indexed without a schema prefix. "
100
+ "Multiple tables with the same unqualified name in different schemas "
101
+ "would all match — re-index with an explicit schema for precise results."
65
102
  )
66
- if fallback_results:
67
- console.print(
68
- f"[yellow]Hint:[/yellow] No results for '{ref}'. "
69
- f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
70
- "The INSERT target may have been indexed without a schema prefix. "
71
- "Multiple tables with the same unqualified name in different schemas "
72
- "would all match — re-index with an explicit schema for precise results."
73
- )
74
- results = fallback_results
75
- if not raw:
76
- from sqlcg.server.noise_filter import NoiseFilter
77
-
78
- nf = NoiseFilter.from_config() # repo_root=None → falls back to Path.cwd()
79
- results = _filter_column_results(results, nf)
80
- _print_table(_add_file_line_col(results), ["id", "file:line"])
103
+ results = fallback_results
104
+ if not raw:
105
+ from sqlcg.server.noise_filter import NoiseFilter
106
+
107
+ nf = NoiseFilter.from_config() # repo_root=None falls back to Path.cwd()
108
+ results = _filter_column_results(results, nf)
109
+ _print_table(_add_file_line_col(results), ["id", "file:line"])
81
110
 
82
111
 
83
112
  @app.command("downstream")
@@ -96,73 +125,69 @@ def downstream( # noqa: B008
96
125
  raise typer.Exit(1)
97
126
 
98
127
  # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them
99
- kind_filter = (
100
- ""
101
- if include_intermediate
102
- else "MATCH (t:SqlTable {qualified: dst.table_qualified}) "
103
- "WHERE t.kind IN ['table', 'external'] "
128
+ kf = _kind_filter("dst", include_intermediate=include_intermediate)
129
+
130
+ results = run_read_routed(
131
+ f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
132
+ f"-[:{RelType.COLUMN_LINEAGE}*1..{depth}]->(dst:{NodeLabel.COLUMN}) "
133
+ f"{kf}"
134
+ f"OPTIONAL MATCH ()-[dstedge:{RelType.COLUMN_LINEAGE}]->(dst) "
135
+ "OPTIONAL MATCH (q:SqlQuery {id: dstedge.query_id}) "
136
+ "WITH dst, min(q.start_line) AS line, min(q.file_path) AS file "
137
+ "RETURN dst.id AS id, file AS file, line AS line LIMIT 100",
138
+ {"ref": ref},
104
139
  )
105
-
106
- with get_backend() as backend:
107
- results = backend.run_read(
108
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
140
+ if not results and len(ref.split(".")) >= 3:
141
+ bare = _bare_ref(ref)
142
+ fallback_results = run_read_routed(
143
+ f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
109
144
  f"-[:{RelType.COLUMN_LINEAGE}*1..{depth}]->(dst:{NodeLabel.COLUMN}) "
110
- f"{kind_filter}"
111
- f"OPTIONAL MATCH (c)-[direct:{RelType.COLUMN_LINEAGE}]->(dst) "
112
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
113
- "RETURN dst.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
114
- {"ref": ref},
145
+ f"{kf}"
146
+ f"OPTIONAL MATCH ()-[dstedge:{RelType.COLUMN_LINEAGE}]->(dst) "
147
+ "OPTIONAL MATCH (q:SqlQuery {id: dstedge.query_id}) "
148
+ "WITH dst, min(q.start_line) AS line, min(q.file_path) AS file "
149
+ "RETURN dst.id AS id, file AS file, line AS line LIMIT 100",
150
+ {"bare": bare},
115
151
  )
116
- if not results and len(ref.split(".")) >= 3:
117
- bare = _bare_ref(ref)
118
- fallback_results = backend.run_read(
119
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
120
- f"-[:{RelType.COLUMN_LINEAGE}*1..{depth}]->(dst:{NodeLabel.COLUMN}) "
121
- f"{kind_filter}"
122
- f"OPTIONAL MATCH (c)-[direct:{RelType.COLUMN_LINEAGE}]->(dst) "
123
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
124
- "RETURN dst.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
125
- {"bare": bare},
152
+ if fallback_results:
153
+ console.print(
154
+ f"[yellow]Hint:[/yellow] No results for '{ref}'. "
155
+ f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
156
+ "The INSERT target may have been indexed without a schema prefix. "
157
+ "Multiple tables with the same unqualified name in different schemas "
158
+ "would all match — re-index with an explicit schema for precise results."
126
159
  )
127
- if fallback_results:
128
- console.print(
129
- f"[yellow]Hint:[/yellow] No results for '{ref}'. "
130
- f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
131
- "The INSERT target may have been indexed without a schema prefix. "
132
- "Multiple tables with the same unqualified name in different schemas "
133
- "would all match — re-index with an explicit schema for precise results."
134
- )
135
- results = fallback_results
136
- if not raw:
137
- from sqlcg.server.noise_filter import NoiseFilter
138
-
139
- nf = NoiseFilter.from_config() # repo_root=None → falls back to Path.cwd()
140
- results = _filter_column_results(results, nf)
141
- _print_table(_add_file_line_col(results), ["id", "file:line"])
142
-
143
- # Append external consumer rows for terminal tables (scalar query, one per terminal).
144
- # Resolve terminal tables from the column results; fall back to the root column's table.
145
- terminal_tables: set[str] = set()
146
- for r in results:
147
- tbl = _col_id_to_table(r["id"])
148
- if tbl:
149
- terminal_tables.add(tbl)
150
- # Also check the root column's table (in case no downstream columns were found).
151
- root_parts = ref.rsplit(".", 1)
152
- if len(root_parts) == 2:
153
- terminal_tables.add(root_parts[0])
154
- consumer_rows: list[dict] = []
155
- for tbl in sorted(terminal_tables):
156
- rows_ec = backend.run_read(
157
- GET_TABLE_EXTERNAL_CONSUMERS_QUERY,
158
- {"table_qualified": tbl},
160
+ results = fallback_results
161
+ if not raw:
162
+ from sqlcg.server.noise_filter import NoiseFilter
163
+
164
+ nf = NoiseFilter.from_config() # repo_root=None falls back to Path.cwd()
165
+ results = _filter_column_results(results, nf)
166
+ _print_table(_add_file_line_col(results), ["id", "file:line"])
167
+
168
+ # Append external consumer rows for terminal tables (scalar query, one per terminal).
169
+ # Resolve terminal tables from the column results; fall back to the root column's table.
170
+ terminal_tables: set[str] = set()
171
+ for r in results:
172
+ tbl = _col_id_to_table(r["id"])
173
+ if tbl:
174
+ terminal_tables.add(tbl)
175
+ # Also check the root column's table (in case no downstream columns were found).
176
+ root_parts = ref.rsplit(".", 1)
177
+ if len(root_parts) == 2:
178
+ terminal_tables.add(root_parts[0])
179
+ consumer_rows: list[dict] = []
180
+ for tbl in sorted(terminal_tables):
181
+ rows_ec = run_read_routed(
182
+ GET_TABLE_EXTERNAL_CONSUMERS_QUERY,
183
+ {"table_qualified": tbl},
184
+ )
185
+ for ec in rows_ec:
186
+ consumer_rows.append(
187
+ {"id": f"[external] {ec['name']} ({ec['consumer_type']})", "file:line": ""}
159
188
  )
160
- for ec in rows_ec:
161
- consumer_rows.append(
162
- {"id": f"[external] {ec['name']} ({ec['consumer_type']})", "file:line": ""}
163
- )
164
- if consumer_rows:
165
- _print_table(consumer_rows, ["id", "file:line"])
189
+ if consumer_rows:
190
+ _print_table(consumer_rows, ["id", "file:line"])
166
191
 
167
192
 
168
193
  @app.command("impact")
@@ -171,19 +196,18 @@ def impact( # noqa: B008
171
196
  raw: bool = typer.Option(False, "--raw", help="Disable noise filtering on results"), # noqa: B008
172
197
  ) -> None:
173
198
  """Show all queries impacted by a table."""
174
- with get_backend() as backend:
175
- results = backend.run_read(
176
- f"MATCH (t:{NodeLabel.TABLE} {{qualified: $t}})"
177
- f"<-[:{RelType.SELECTS_FROM}]-(q:{NodeLabel.QUERY}) "
178
- "RETURN DISTINCT q.id AS id, q.kind AS kind, q.target_table AS target LIMIT 100",
179
- {"t": table},
180
- )
181
- if not raw:
182
- from sqlcg.server.noise_filter import NoiseFilter
199
+ results = run_read_routed(
200
+ f"MATCH (t:{NodeLabel.TABLE} {{qualified: $t}})"
201
+ f"<-[:{RelType.SELECTS_FROM}]-(q:{NodeLabel.QUERY}) "
202
+ "RETURN DISTINCT q.id AS id, q.kind AS kind, q.target_table AS target LIMIT 100",
203
+ {"t": table},
204
+ )
205
+ if not raw:
206
+ from sqlcg.server.noise_filter import NoiseFilter
183
207
 
184
- nf = NoiseFilter.from_config()
185
- results = [r for r in results if not nf.is_noise(r.get("target", ""))]
186
- _print_table(results, ["id", "kind"])
208
+ nf = NoiseFilter.from_config()
209
+ results = [r for r in results if not nf.is_noise(r.get("target", ""))]
210
+ _print_table(results, ["id", "kind"])
187
211
 
188
212
 
189
213
  @app.command("failures")
@@ -207,15 +231,14 @@ def failures(
207
231
  with 'sqlcg db reset && sqlcg index <path>' if the graph was built with
208
232
  an earlier version.
209
233
  """
210
- with get_backend() as backend:
211
- cypher = (
212
- f"MATCH (f:{NodeLabel.FILE}) WHERE f.parse_failed = true "
213
- "AND ($cause IS NULL OR f.parse_cause = $cause) "
214
- "RETURN f.path AS path, f.parse_cause AS cause "
215
- f"ORDER BY f.parse_cause LIMIT {limit}"
216
- )
217
- rows = backend.run_read(cypher, {"cause": cause})
218
- _print_table(rows, ["path", "cause"])
234
+ cypher = (
235
+ f"MATCH (f:{NodeLabel.FILE}) WHERE f.parse_failed = true "
236
+ "AND ($cause IS NULL OR f.parse_cause = $cause) "
237
+ "RETURN f.path AS path, f.parse_cause AS cause "
238
+ f"ORDER BY f.parse_cause LIMIT {limit}"
239
+ )
240
+ rows = run_read_routed(cypher, {"cause": cause})
241
+ _print_table(rows, ["path", "cause"])
219
242
 
220
243
 
221
244
  @app.command("unused")
@@ -224,18 +247,17 @@ def unused(
224
247
  raw: bool = typer.Option(False, "--raw", help="Disable noise filtering on results"), # noqa: B008
225
248
  ) -> None:
226
249
  """Find tables with no query references."""
227
- with get_backend() as backend:
228
- results = backend.run_read(
229
- f"MATCH (t:{NodeLabel.TABLE}) WHERE NOT (t)<-[:{RelType.SELECTS_FROM}]-() "
230
- "RETURN DISTINCT t.qualified AS qualified LIMIT 100",
231
- {},
232
- )
233
- if not raw:
234
- from sqlcg.server.noise_filter import NoiseFilter
250
+ results = run_read_routed(
251
+ f"MATCH (t:{NodeLabel.TABLE}) WHERE NOT (t)<-[:{RelType.SELECTS_FROM}]-() "
252
+ "RETURN DISTINCT t.qualified AS qualified LIMIT 100",
253
+ {},
254
+ )
255
+ if not raw:
256
+ from sqlcg.server.noise_filter import NoiseFilter
235
257
 
236
- nf = NoiseFilter.from_config()
237
- results = [r for r in results if not nf.is_noise(r["qualified"])]
238
- _print_table(results, ["qualified"])
258
+ nf = NoiseFilter.from_config()
259
+ results = [r for r in results if not nf.is_noise(r["qualified"])]
260
+ _print_table(results, ["qualified"])
239
261
 
240
262
 
241
263
  def _bare_ref(ref: str) -> str: