sql-code-graph 1.1.3__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.3
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=YGDRrWVIrONmQholAKWh6hSKxlPd2dLcM1AdHHdBhEA,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=hiKj0R1m5i4ZmwrXBlVT14xGy6rs9jmv_ZDCLVZj4Tg,14282
7
- sqlcg/cli/commands/db.py,sha256=5VpknLqYaimK6YA516w6iQVX6JmHcD52o6MuW5d088c,7462
8
- sqlcg/cli/commands/find.py,sha256=SsK6q4YRPknrz_lIQ4Gun6HRoAdoVRGClwAYdm_s2OU,3168
9
- sqlcg/cli/commands/gain.py,sha256=hz36QmuaXJXutI4vyNMDfcNsBeLTXa6EOw2bWe2AhTQ,8939
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,7 +16,7 @@ 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=LuB8HWPsIt1OsjOshTT1bJdXWXN01w76ABl9M-VB9DM,14777
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
@@ -30,17 +30,17 @@ 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=KyyowxiSNU3Gm4JE-mj8gVm6D80XERJPd-he59I2sIk,62018
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
43
+ sqlcg/parsers/ansi_parser.py,sha256=mGZvijMOMQ4i1BybpwU29a8jnIGViefhy9fxzkSpsRM,17193
44
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
@@ -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.3.dist-info/METADATA,sha256=Z_aRnsDOgZ_ngAHkIr3x2XpEjF-x6UMUQwcIkAMlGjo,13615
63
- sql_code_graph-1.1.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
64
- sql_code_graph-1.1.3.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
65
- sql_code_graph-1.1.3.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.3"
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,26 +19,36 @@ app = typer.Typer(help="Lineage analysis")
19
19
  console = Console()
20
20
 
21
21
 
22
- def _kind_filter(source_alias: str, include_intermediate: bool) -> str:
23
- """Build the Half-B (#38) kind filter for the lineage traversal query.
22
+ def _kind_filter(node_alias: str, *, include_intermediate: bool) -> str:
23
+ """Build the SqlTable kind-filter clause for upstream/downstream queries.
24
24
 
25
- When ``include_intermediate`` is False, the filter uses ``OPTIONAL MATCH`` plus
26
- ``t.kind IS NULL OR t.kind IN ['table', 'external']`` so a source whose SqlTable
27
- node is ABSENT (a CTE-body source on a graph indexed before the #39 fix, or not yet
28
- re-indexed) is KEPT rather than silently dropped. Reverting this to an inner
29
- ``MATCH (t:SqlTable {...}) ... WHERE t.kind IN [...]`` is the #38 regression:
30
- node-less physical sources vanish from results.
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).
31
32
 
32
- ``source_alias`` is ``src`` for upstream and ``dst`` for downstream it names both
33
- the node whose table is looked up and the variable carried through the WITH clause.
34
- This is the single production source of the filter string; the #40 recall guard
35
- imports it so reverting Half B here turns the guard red.
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.
36
46
  """
37
47
  if include_intermediate:
38
48
  return ""
39
49
  return (
40
- f"OPTIONAL MATCH (t:SqlTable {{qualified: {source_alias}.table_qualified}}) "
41
- f"WITH c, {source_alias}, t WHERE t.kind IS NULL OR t.kind IN ['table', 'external'] "
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'] "
42
52
  )
43
53
 
44
54
 
@@ -57,49 +67,46 @@ def upstream( # noqa: B008
57
67
  console.print("[red]Error: --depth must be between 1 and 100[/red]")
58
68
  raise typer.Exit(1)
59
69
 
60
- # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them.
61
- # Half B (#38): use OPTIONAL MATCH so a missing SqlTable node (e.g. CTE-body source not yet
62
- # re-indexed after #39 fix) is KEPT rather than silently dropped. WHERE t.kind IS NULL OR
63
- # t.kind IN [...] means: keep when node absent (NULL) OR when kind is a physical source.
64
- # CTE aliases (kind='cte') and derived tables (kind='derived') are filtered out.
65
- kind_filter = _kind_filter("src", include_intermediate)
66
-
67
- with get_backend(read_only=True) as backend:
68
- results = backend.run_read(
69
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
70
+ # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them
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},
82
+ )
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}})"
70
87
  f"<-[:{RelType.COLUMN_LINEAGE}*1..{depth}]-(src:{NodeLabel.COLUMN}) "
71
- f"{kind_filter}"
72
- f"OPTIONAL MATCH (src)-[direct:{RelType.COLUMN_LINEAGE}]->(c) "
73
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
74
- "RETURN src.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
75
- {"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},
76
94
  )
77
- if not results and len(ref.split(".")) >= 3:
78
- bare = _bare_ref(ref)
79
- fallback_results = backend.run_read(
80
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
81
- f"<-[:{RelType.COLUMN_LINEAGE}*1..{depth}]-(src:{NodeLabel.COLUMN}) "
82
- f"{kind_filter}"
83
- f"OPTIONAL MATCH (src)-[direct:{RelType.COLUMN_LINEAGE}]->(c) "
84
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
85
- "RETURN src.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
86
- {"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."
87
102
  )
88
- if fallback_results:
89
- console.print(
90
- f"[yellow]Hint:[/yellow] No results for '{ref}'. "
91
- f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
92
- "The INSERT target may have been indexed without a schema prefix. "
93
- "Multiple tables with the same unqualified name in different schemas "
94
- "would all match — re-index with an explicit schema for precise results."
95
- )
96
- results = fallback_results
97
- if not raw:
98
- from sqlcg.server.noise_filter import NoiseFilter
99
-
100
- nf = NoiseFilter.from_config() # repo_root=None → falls back to Path.cwd()
101
- results = _filter_column_results(results, nf)
102
- _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"])
103
110
 
104
111
 
105
112
  @app.command("downstream")
@@ -117,72 +124,70 @@ def downstream( # noqa: B008
117
124
  console.print("[red]Error: --depth must be between 1 and 100[/red]")
118
125
  raise typer.Exit(1)
119
126
 
120
- # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them.
121
- # Half B (#38): OPTIONAL MATCH keeps sources whose SqlTable node is absent (NULL) or is a
122
- # physical kind. WITH c, dst, t carries the three variables in scope at this interpolation
123
- # point; direct and q are bound later in the query.
124
- kind_filter = _kind_filter("dst", include_intermediate)
125
-
126
- with get_backend(read_only=True) as backend:
127
- results = backend.run_read(
128
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $ref}})"
127
+ # By default, filter out CTE/derived intermediate nodes; --include-intermediate restores them
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},
139
+ )
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}})"
129
144
  f"-[:{RelType.COLUMN_LINEAGE}*1..{depth}]->(dst:{NodeLabel.COLUMN}) "
130
- f"{kind_filter}"
131
- f"OPTIONAL MATCH (c)-[direct:{RelType.COLUMN_LINEAGE}]->(dst) "
132
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
133
- "RETURN dst.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
134
- {"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},
135
151
  )
136
- if not results and len(ref.split(".")) >= 3:
137
- bare = _bare_ref(ref)
138
- fallback_results = backend.run_read(
139
- f"MATCH (c:{NodeLabel.COLUMN} {{id: $bare}})"
140
- f"-[:{RelType.COLUMN_LINEAGE}*1..{depth}]->(dst:{NodeLabel.COLUMN}) "
141
- f"{kind_filter}"
142
- f"OPTIONAL MATCH (c)-[direct:{RelType.COLUMN_LINEAGE}]->(dst) "
143
- "OPTIONAL MATCH (q:SqlQuery {id: direct.query_id}) "
144
- "RETURN dst.id AS id, q.file_path AS file, q.start_line AS line LIMIT 100",
145
- {"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."
146
159
  )
147
- if fallback_results:
148
- console.print(
149
- f"[yellow]Hint:[/yellow] No results for '{ref}'. "
150
- f"Found {len(fallback_results)} edge(s) under bare name '{bare}'. "
151
- "The INSERT target may have been indexed without a schema prefix. "
152
- "Multiple tables with the same unqualified name in different schemas "
153
- "would all match — re-index with an explicit schema for precise results."
154
- )
155
- results = fallback_results
156
- if not raw:
157
- from sqlcg.server.noise_filter import NoiseFilter
158
-
159
- nf = NoiseFilter.from_config() # repo_root=None → falls back to Path.cwd()
160
- results = _filter_column_results(results, nf)
161
- _print_table(_add_file_line_col(results), ["id", "file:line"])
162
-
163
- # Append external consumer rows for terminal tables (scalar query, one per terminal).
164
- # Resolve terminal tables from the column results; fall back to the root column's table.
165
- terminal_tables: set[str] = set()
166
- for r in results:
167
- tbl = _col_id_to_table(r["id"])
168
- if tbl:
169
- terminal_tables.add(tbl)
170
- # Also check the root column's table (in case no downstream columns were found).
171
- root_parts = ref.rsplit(".", 1)
172
- if len(root_parts) == 2:
173
- terminal_tables.add(root_parts[0])
174
- consumer_rows: list[dict] = []
175
- for tbl in sorted(terminal_tables):
176
- rows_ec = backend.run_read(
177
- GET_TABLE_EXTERNAL_CONSUMERS_QUERY,
178
- {"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": ""}
179
188
  )
180
- for ec in rows_ec:
181
- consumer_rows.append(
182
- {"id": f"[external] {ec['name']} ({ec['consumer_type']})", "file:line": ""}
183
- )
184
- if consumer_rows:
185
- _print_table(consumer_rows, ["id", "file:line"])
189
+ if consumer_rows:
190
+ _print_table(consumer_rows, ["id", "file:line"])
186
191
 
187
192
 
188
193
  @app.command("impact")
@@ -191,19 +196,18 @@ def impact( # noqa: B008
191
196
  raw: bool = typer.Option(False, "--raw", help="Disable noise filtering on results"), # noqa: B008
192
197
  ) -> None:
193
198
  """Show all queries impacted by a table."""
194
- with get_backend(read_only=True) as backend:
195
- results = backend.run_read(
196
- f"MATCH (t:{NodeLabel.TABLE} {{qualified: $t}})"
197
- f"<-[:{RelType.SELECTS_FROM}]-(q:{NodeLabel.QUERY}) "
198
- "RETURN DISTINCT q.id AS id, q.kind AS kind, q.target_table AS target LIMIT 100",
199
- {"t": table},
200
- )
201
- if not raw:
202
- 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
203
207
 
204
- nf = NoiseFilter.from_config()
205
- results = [r for r in results if not nf.is_noise(r.get("target", ""))]
206
- _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"])
207
211
 
208
212
 
209
213
  @app.command("failures")
@@ -227,15 +231,14 @@ def failures(
227
231
  with 'sqlcg db reset && sqlcg index <path>' if the graph was built with
228
232
  an earlier version.
229
233
  """
230
- with get_backend(read_only=True) as backend:
231
- cypher = (
232
- f"MATCH (f:{NodeLabel.FILE}) WHERE f.parse_failed = true "
233
- "AND ($cause IS NULL OR f.parse_cause = $cause) "
234
- "RETURN f.path AS path, f.parse_cause AS cause "
235
- f"ORDER BY f.parse_cause LIMIT {limit}"
236
- )
237
- rows = backend.run_read(cypher, {"cause": cause})
238
- _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"])
239
242
 
240
243
 
241
244
  @app.command("unused")
@@ -244,18 +247,17 @@ def unused(
244
247
  raw: bool = typer.Option(False, "--raw", help="Disable noise filtering on results"), # noqa: B008
245
248
  ) -> None:
246
249
  """Find tables with no query references."""
247
- with get_backend(read_only=True) as backend:
248
- results = backend.run_read(
249
- f"MATCH (t:{NodeLabel.TABLE}) WHERE NOT (t)<-[:{RelType.SELECTS_FROM}]-() "
250
- "RETURN DISTINCT t.qualified AS qualified LIMIT 100",
251
- {},
252
- )
253
- if not raw:
254
- 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
255
257
 
256
- nf = NoiseFilter.from_config()
257
- results = [r for r in results if not nf.is_noise(r["qualified"])]
258
- _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"])
259
261
 
260
262
 
261
263
  def _bare_ref(ref: str) -> str: