sql-code-graph 1.4.1__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql_code_graph-1.4.1.dist-info → sql_code_graph-1.5.0.dist-info}/METADATA +1 -1
- {sql_code_graph-1.4.1.dist-info → sql_code_graph-1.5.0.dist-info}/RECORD +14 -14
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/install.py +56 -43
- sqlcg/cli/commands/mcp.py +58 -10
- sqlcg/cli/main.py +34 -3
- sqlcg/core/queries.py +4 -0
- sqlcg/core/queries.sql +60 -0
- sqlcg/parsers/ansi_parser.py +7 -2
- sqlcg/server/models.py +9 -0
- sqlcg/server/server.py +9 -0
- sqlcg/server/tools.py +180 -28
- {sql_code_graph-1.4.1.dist-info → sql_code_graph-1.5.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.4.1.dist-info → sql_code_graph-1.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-code-graph
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
sqlcg/__init__.py,sha256=
|
|
1
|
+
sqlcg/__init__.py,sha256=umkxJTfZmp0-L1q_ge27nl61dPsJtPJKnGORGDpbJKY,115
|
|
2
2
|
sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
|
|
3
3
|
sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
|
|
4
|
-
sqlcg/cli/main.py,sha256=
|
|
4
|
+
sqlcg/cli/main.py,sha256=oiZ0e_blUCnlmmXNSCLYnifleDv6ixm9dF7GEDGe0dQ,2429
|
|
5
5
|
sqlcg/cli/commands/__init__.py,sha256=oSHtr6VD-jNubOjuCQyZj2tBppjMEpQDh-IGQ8of9eA,30
|
|
6
6
|
sqlcg/cli/commands/analyze.py,sha256=_aC5ML3w7YdLi7DL3TFS9OiCEIipuNZxWR6S4peTcn4,12154
|
|
7
7
|
sqlcg/cli/commands/db.py,sha256=TMhPCHRWSo8YmlNnSxmGxrR0_1r3K4e0Bn8unZTkvU4,7248
|
|
@@ -9,8 +9,8 @@ sqlcg/cli/commands/find.py,sha256=p5Vyyx-VBk8YDWYQN16UhECh7PIeMaEyCPEcUFcRFlM,25
|
|
|
9
9
|
sqlcg/cli/commands/gain.py,sha256=SJU1c51a7MgNbZItqQnaBfOWGnV5xpXQctbew5Dr9BE,9062
|
|
10
10
|
sqlcg/cli/commands/git.py,sha256=9a8T2FVxcAHq1H6Cslaq34t10w9fBGf4T2reiLk33t8,6135
|
|
11
11
|
sqlcg/cli/commands/index.py,sha256=RPEpT9aXmK0rixRuHgMcmAn9FLR4JtIbZ1LM4RLkMvY,17050
|
|
12
|
-
sqlcg/cli/commands/install.py,sha256=
|
|
13
|
-
sqlcg/cli/commands/mcp.py,sha256=
|
|
12
|
+
sqlcg/cli/commands/install.py,sha256=VdfZnC41WE3Lx6CcGSd2Xyi-XNNOLP1YQrnXmqo7x3c,10023
|
|
13
|
+
sqlcg/cli/commands/mcp.py,sha256=CA24kRfBltanKuSMkRVRf42GW8cTgG8YQrMruxB3vSQ,11592
|
|
14
14
|
sqlcg/cli/commands/reindex.py,sha256=FqXxvQ0UrxwDS0q2V3gzYgarN3NPh9muaIZ3rgvTmHs,13810
|
|
15
15
|
sqlcg/cli/commands/report.py,sha256=JU0qjyMxwOukE7bN3XvvIzOI7zMg_Gsnvk_8F6pKNpA,4915
|
|
16
16
|
sqlcg/cli/commands/uninstall.py,sha256=WrA1FnINxnd6mmE4-_QBK0aHBnibstJeAT8swnQKG4M,8962
|
|
@@ -22,8 +22,8 @@ sqlcg/core/freshness.py,sha256=F9jWn2cbFs60jA9ta8KrT6MghD0mzI7SqqKs_Op9AeU,4577
|
|
|
22
22
|
sqlcg/core/graph_db.py,sha256=eLYdmiqPWrZHtKLFcKgD2aiWZHFU3wrwz6Y0A3d9NcE,8633
|
|
23
23
|
sqlcg/core/jobs.py,sha256=Je-fCdSKRgiSsv1W8SgNAlp36a7t7-pJZ-qKPbka9OE,3298
|
|
24
24
|
sqlcg/core/queries.cypher,sha256=cvPOVe5GUOzJN4bxUvDxNI--xIIP8gm42TR-gUnea4U,4685
|
|
25
|
-
sqlcg/core/queries.py,sha256=
|
|
26
|
-
sqlcg/core/queries.sql,sha256=
|
|
25
|
+
sqlcg/core/queries.py,sha256=DeGrpsh-N8CczapKW5Iw_CPBMOxtWZfusMOhpN4Qn_c,3319
|
|
26
|
+
sqlcg/core/queries.sql,sha256=yRITKvjnWY0e1Sa8me0yFe_FkkIBsk3BQn2l0iqOJ7Q,10768
|
|
27
27
|
sqlcg/core/schema.cypher,sha256=rK5QMhSrzZhuj73NeNXGX6oM-rPPPvxFjex0fEyUvkQ,2859
|
|
28
28
|
sqlcg/core/schema.py,sha256=7fKf314ueIV7-tIkQQUS4O6H-OhlFKFVFFvGFqw_5Xk,1476
|
|
29
29
|
sqlcg/indexer/__init__.py,sha256=Wh20Unz2OHs1oIyWLrpurPAasF0BET2g4iXtNk7mh2U,56
|
|
@@ -40,7 +40,7 @@ sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8Aa
|
|
|
40
40
|
sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
|
|
41
41
|
sqlcg/metrics/store.py,sha256=KuDtxvyAgug9_KtiSCpvgKM2VZM7VSaI3D11uMLjJJk,10604
|
|
42
42
|
sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
|
|
43
|
-
sqlcg/parsers/ansi_parser.py,sha256=
|
|
43
|
+
sqlcg/parsers/ansi_parser.py,sha256=UNousZcTgHmgDzbvqF77hPf8RmTqwz4oo1rk9OMGWz4,17570
|
|
44
44
|
sqlcg/parsers/base.py,sha256=N6uqQWHO2lpzTIWgPqzewAXmij0ikZdNmNVJkIQ8Mr0,54889
|
|
45
45
|
sqlcg/parsers/bigquery_parser.py,sha256=mOnWTfXB_Dp4JwFE1PVYOB6CDPf5nYE0Dea8kJCl9uQ,2827
|
|
46
46
|
sqlcg/parsers/postgres_parser.py,sha256=lYfUpQY6j4Qm7ndXBtXbgPoGzYqYddWt5YeFnWKdA6I,946
|
|
@@ -50,18 +50,18 @@ sqlcg/parsers/tsql_parser.py,sha256=RRj1pACtAk2tLTDaFWRYF67a0IDvaf5A1YQXWIz0bpQ,
|
|
|
50
50
|
sqlcg/server/__init__.py,sha256=n4wuNE7xyJIJxJZBtmtdccCMQfvTdF-IqIaZVbC4FC4,35
|
|
51
51
|
sqlcg/server/control.py,sha256=qUcztb_aDhL-_X_Nq4q6uGx17cUlbLnI6vUpoZsEjbo,4506
|
|
52
52
|
sqlcg/server/exceptions.py,sha256=EONw34icOByCTpppSQrvQBW6asc4hfqaGDCAFjv96II,469
|
|
53
|
-
sqlcg/server/models.py,sha256=
|
|
53
|
+
sqlcg/server/models.py,sha256=4c4ula195cHEqZycO6ZaSNohSLsodB-Du1FP94JKSdc,19810
|
|
54
54
|
sqlcg/server/noise_filter.py,sha256=Ats2FFzmzFKqvQPWvlUzK8mY9pzlUhL4m1s8P_HNnvI,6335
|
|
55
55
|
sqlcg/server/read_client.py,sha256=4v1OOl12HCHp6J14HrcWQApSixvJ0wwE8UIIZ0pzLyw,7808
|
|
56
|
-
sqlcg/server/server.py,sha256=
|
|
56
|
+
sqlcg/server/server.py,sha256=1RiUmjWLgtN636kRQO6-4rH8amC-m9BKsIMO0aDRqes,24838
|
|
57
57
|
sqlcg/server/skill.py,sha256=EDvmEgl-LQwCxZ9Lca4lt8zCtkk2cCgh9GLYlGgDA64,12828
|
|
58
|
-
sqlcg/server/tools.py,sha256=
|
|
58
|
+
sqlcg/server/tools.py,sha256=SyxPbnFmsR1w2Hi2ENmD_mynCdI9JHhTgv1byXFIKoY,77191
|
|
59
59
|
sqlcg/server/writer.py,sha256=HH8pW6k1c2pbY4b6g2N87Tx4gNCezYqTLNTlAmsHg2Y,18522
|
|
60
60
|
sqlcg/utils/__init__.py,sha256=--iqt5ThTXmT8Wz7da8hs3n0zDfYPl8P-z5OgRJ_77E,154
|
|
61
61
|
sqlcg/utils/hashing.py,sha256=H25-sYfxHKb3_IERFnHyAIYNiXN470Oqo5sJT_D3YOA,438
|
|
62
62
|
sqlcg/utils/ignore.py,sha256=wJjwa0mjnQ_xJExOUxk25y00g065XmmzJapqV3ifD5o,1151
|
|
63
63
|
sqlcg/utils/logging.py,sha256=u0fCmYsLj9o81vawm3xZTHaw68GQYVm7JxG-gP81u8A,840
|
|
64
|
-
sql_code_graph-1.
|
|
65
|
-
sql_code_graph-1.
|
|
66
|
-
sql_code_graph-1.
|
|
67
|
-
sql_code_graph-1.
|
|
64
|
+
sql_code_graph-1.5.0.dist-info/METADATA,sha256=LWYY4WQliA5p-YOMnci4GcEGjKkJGy8sRBWPj8a30n8,14085
|
|
65
|
+
sql_code_graph-1.5.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
66
|
+
sql_code_graph-1.5.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
|
|
67
|
+
sql_code_graph-1.5.0.dist-info/RECORD,,
|
sqlcg/__init__.py
CHANGED
sqlcg/cli/commands/install.py
CHANGED
|
@@ -20,6 +20,8 @@ from pathlib import Path
|
|
|
20
20
|
import typer
|
|
21
21
|
from rich.console import Console
|
|
22
22
|
|
|
23
|
+
from sqlcg.cli.commands.mcp import stop_server
|
|
24
|
+
|
|
23
25
|
console = Console()
|
|
24
26
|
|
|
25
27
|
_SERVER_KEY = "sql-code-graph"
|
|
@@ -70,10 +72,12 @@ def install_cmd(
|
|
|
70
72
|
claude_json = Path.home() / ".claude.json"
|
|
71
73
|
console.print("[dim]--dry-run: would write to ~/.claude.json:[/dim]")
|
|
72
74
|
_preview_claude_json(claude_json, entry)
|
|
75
|
+
console.print("[dim]--dry-run: would stop running MCP server[/dim]")
|
|
73
76
|
_provision_skill(resolved_scope, repo, dry_run=True)
|
|
74
77
|
return
|
|
75
78
|
|
|
76
79
|
# --- Try official claude CLI first ---
|
|
80
|
+
configured_via_claude_cli = False
|
|
77
81
|
claude_bin = shutil.which("claude")
|
|
78
82
|
if claude_bin:
|
|
79
83
|
proc = subprocess.run(
|
|
@@ -84,56 +88,65 @@ def install_cmd(
|
|
|
84
88
|
if proc.returncode == 0:
|
|
85
89
|
console.print(f"[green]Configured:[/green] {_SERVER_KEY} via `claude mcp add`")
|
|
86
90
|
console.print("\nRestart Claude Code to pick up the new MCP server.")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
console.print(
|
|
91
|
-
f"[yellow]Warning:[/yellow] `claude mcp add` returned rc={proc.returncode}; "
|
|
92
|
-
"falling back to ~/.claude.json write."
|
|
93
|
-
)
|
|
94
|
-
if proc.stderr:
|
|
95
|
-
console.print(f"[dim]{proc.stderr.strip()}[/dim]")
|
|
96
|
-
|
|
97
|
-
# --- Fallback: write ~/.claude.json directly ---
|
|
98
|
-
claude_json = Path.home() / ".claude.json"
|
|
99
|
-
if claude_json.exists():
|
|
100
|
-
try:
|
|
101
|
-
data: dict = json.loads(claude_json.read_text())
|
|
102
|
-
except (json.JSONDecodeError, OSError, TypeError):
|
|
91
|
+
configured_via_claude_cli = True
|
|
92
|
+
else:
|
|
93
|
+
# Non-zero: log and fall through to ~/.claude.json fallback
|
|
103
94
|
console.print(
|
|
104
|
-
f"[yellow]Warning:[/yellow]
|
|
105
|
-
"
|
|
95
|
+
f"[yellow]Warning:[/yellow] `claude mcp add` returned rc={proc.returncode}; "
|
|
96
|
+
"falling back to ~/.claude.json write."
|
|
106
97
|
)
|
|
98
|
+
if proc.stderr:
|
|
99
|
+
console.print(f"[dim]{proc.stderr.strip()}[/dim]")
|
|
100
|
+
|
|
101
|
+
if not configured_via_claude_cli:
|
|
102
|
+
# --- Fallback: write ~/.claude.json directly ---
|
|
103
|
+
claude_json = Path.home() / ".claude.json"
|
|
104
|
+
if claude_json.exists():
|
|
105
|
+
try:
|
|
106
|
+
data: dict = json.loads(claude_json.read_text())
|
|
107
|
+
except (json.JSONDecodeError, OSError, TypeError):
|
|
108
|
+
console.print(
|
|
109
|
+
f"[yellow]Warning:[/yellow] {claude_json} contains invalid JSON — "
|
|
110
|
+
"mcpServers key will be added"
|
|
111
|
+
)
|
|
112
|
+
data = {}
|
|
113
|
+
else:
|
|
107
114
|
data = {}
|
|
108
|
-
else:
|
|
109
|
-
data = {}
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
existing = data.get("mcpServers", {}).get("user", {}).get(_SERVER_KEY)
|
|
117
|
+
if existing == entry:
|
|
118
|
+
console.print(f"[green]Already configured:[/green] {_SERVER_KEY} (in ~/.claude.json)")
|
|
119
|
+
else:
|
|
120
|
+
data.setdefault("mcpServers", {}).setdefault("user", {})[_SERVER_KEY] = entry
|
|
116
121
|
|
|
117
|
-
|
|
122
|
+
try:
|
|
123
|
+
tmp = claude_json.with_suffix(".tmp")
|
|
124
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
125
|
+
os.replace(tmp, claude_json)
|
|
126
|
+
except (OSError, TypeError, AttributeError):
|
|
127
|
+
pass # Ignore file I/O errors in testing
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
console.print(f"[dim]Written to {claude_json}[/dim]")
|
|
129
|
-
|
|
130
|
-
if entry.get("command") == "uvx":
|
|
131
|
-
console.print(
|
|
132
|
-
"[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
|
|
133
|
-
"Subsequent restarts use cache (~1s)."
|
|
134
|
-
)
|
|
129
|
+
cmd_str = " ".join(cmd_parts)
|
|
130
|
+
console.print(f"[green]Configured:[/green] {_SERVER_KEY} → {cmd_str}")
|
|
131
|
+
console.print(f"[dim]Written to {claude_json}[/dim]")
|
|
132
|
+
|
|
133
|
+
if entry.get("command") == "uvx":
|
|
134
|
+
console.print(
|
|
135
|
+
"[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
|
|
136
|
+
"Subsequent restarts use cache (~1s)."
|
|
137
|
+
)
|
|
135
138
|
|
|
136
|
-
|
|
139
|
+
console.print("\nRestart Claude Code to pick up the new MCP server.")
|
|
140
|
+
|
|
141
|
+
# --- Single non-dry-run tail: all three success branches converge here. ---
|
|
142
|
+
# The config may not have changed (e.g. "Already configured"), but the
|
|
143
|
+
# binary on disk did — so the stale running server still must be stopped
|
|
144
|
+
# so the editor respawns the freshly-installed build.
|
|
145
|
+
stopped = stop_server()
|
|
146
|
+
if stopped:
|
|
147
|
+
console.print("Stopped running MCP server; your editor will respawn it on the new build.")
|
|
148
|
+
else:
|
|
149
|
+
console.print("No running MCP server to refresh.")
|
|
137
150
|
_provision_skill(resolved_scope, repo, dry_run=False)
|
|
138
151
|
|
|
139
152
|
|
sqlcg/cli/commands/mcp.py
CHANGED
|
@@ -77,9 +77,19 @@ def mcp_best_practices() -> None:
|
|
|
77
77
|
def mcp_status() -> None:
|
|
78
78
|
"""Print server status JSON (connects to control socket).
|
|
79
79
|
|
|
80
|
-
Returns JSON with fields: running, pid, db_path, indexed_sha,
|
|
81
|
-
stale_by_commits, connected_clients, uptime, writer_queue
|
|
82
|
-
is live.
|
|
80
|
+
Returns JSON with fields: running, version, pid, db_path, indexed_sha,
|
|
81
|
+
head_sha, stale_by_commits, connected_clients, uptime, writer_queue,
|
|
82
|
+
stale_by_version when a server is live.
|
|
83
|
+
|
|
84
|
+
`version` is the *running* server's reported sqlcg.__version__ (read live
|
|
85
|
+
over the control socket — the build the editor is currently talking to).
|
|
86
|
+
`stale_by_version` compares it against the *installed* sqlcg.__version__
|
|
87
|
+
(the CLI process you just invoked): true when they differ, false when they
|
|
88
|
+
match, null when the running version cannot be determined (degraded mode).
|
|
89
|
+
When stale, a warning is printed telling you to restart the MCP server via
|
|
90
|
+
your editor or run `sqlcg install` (which stops the stale server for you).
|
|
91
|
+
This is distinct from `stale_by_commits` (graph-vs-repo freshness, not
|
|
92
|
+
package-vs-binary drift).
|
|
83
93
|
|
|
84
94
|
The status response is length-prefixed framed (v1.3.0, B3) so large
|
|
85
95
|
writer_queue payloads are received in full — the client uses the
|
|
@@ -87,7 +97,8 @@ def mcp_status() -> None:
|
|
|
87
97
|
|
|
88
98
|
When no server is found: {"running": false}.
|
|
89
99
|
When the PID file exists with a live process but the socket is unavailable:
|
|
90
|
-
{"running": true, "degraded": "socket unavailable", ...}
|
|
100
|
+
{"running": true, "degraded": "socket unavailable", "stale_by_version": null, ...}
|
|
101
|
+
(the running version cannot be read without a live socket).
|
|
91
102
|
|
|
92
103
|
R3 (stale socket): if the socket file exists but the server is not
|
|
93
104
|
responding (ConnectionRefusedError / FileNotFoundError), falls through
|
|
@@ -119,9 +130,25 @@ def mcp_status() -> None:
|
|
|
119
130
|
|
|
120
131
|
status = json.loads(data.decode())
|
|
121
132
|
|
|
133
|
+
# Drift detection: compare the *running* server's reported version (read
|
|
134
|
+
# over the socket, from the live process) against the *installed*
|
|
135
|
+
# sqlcg.__version__ (the version of the CLI process you just invoked).
|
|
136
|
+
from sqlcg import __version__
|
|
137
|
+
|
|
138
|
+
running_version = status.get("version")
|
|
139
|
+
stale = running_version is not None and running_version != __version__
|
|
140
|
+
status = {**status, "version": running_version, "stale_by_version": stale}
|
|
141
|
+
|
|
122
142
|
# Pretty-print the base fields.
|
|
123
143
|
console.print_json(json.dumps({k: v for k, v in status.items() if k != "writer_queue"}))
|
|
124
144
|
|
|
145
|
+
if stale:
|
|
146
|
+
console.print(
|
|
147
|
+
f"[yellow]Warning:[/yellow] running MCP server is v{running_version}, "
|
|
148
|
+
f"installed sqlcg is v{__version__} — restart the MCP server via your "
|
|
149
|
+
"editor (or run `sqlcg install`, which stops the stale server for you)."
|
|
150
|
+
)
|
|
151
|
+
|
|
125
152
|
# Render the writer_queue block separately for readability.
|
|
126
153
|
wq = status.get("writer_queue")
|
|
127
154
|
if wq:
|
|
@@ -178,6 +205,10 @@ def mcp_status() -> None:
|
|
|
178
205
|
"degraded": "socket unavailable",
|
|
179
206
|
"pid": rec["pid"],
|
|
180
207
|
"db_path": rec["db_path"],
|
|
208
|
+
# Cannot read the running version without a live socket —
|
|
209
|
+
# omit `version` and report drift as unknown (None → JSON
|
|
210
|
+
# null), never guess.
|
|
211
|
+
"stale_by_version": None,
|
|
181
212
|
}
|
|
182
213
|
)
|
|
183
214
|
)
|
|
@@ -185,16 +216,20 @@ def mcp_status() -> None:
|
|
|
185
216
|
console.print_json(json.dumps({"running": False}))
|
|
186
217
|
|
|
187
218
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
"""Stop the running MCP server gracefully.
|
|
219
|
+
def stop_server() -> bool:
|
|
220
|
+
"""Stop a running MCP server gracefully (best-effort). Returns True if stopped.
|
|
191
221
|
|
|
192
222
|
Sends a ``stop`` op via the control socket; waits up to 5 s for the
|
|
193
223
|
socket file to disappear (confirming clean exit). Falls back to SIGTERM
|
|
194
224
|
on the PID-file PID if the socket is unavailable.
|
|
195
225
|
|
|
196
226
|
R3 (stale socket): ``ConnectionRefusedError`` / ``FileNotFoundError`` are
|
|
197
|
-
caught — never hangs on a dead socket
|
|
227
|
+
caught — never hangs on a dead socket; a socket error here must not abort
|
|
228
|
+
the caller (``mcp_stop`` and ``install_cmd`` both rely on this).
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if a running server was found and stopped (via socket or SIGTERM
|
|
232
|
+
fallback); False if no running server was found.
|
|
198
233
|
"""
|
|
199
234
|
import socket as _socket
|
|
200
235
|
import time
|
|
@@ -214,6 +249,7 @@ def mcp_stop() -> None:
|
|
|
214
249
|
break
|
|
215
250
|
time.sleep(0.5)
|
|
216
251
|
console.print("[green]Server stopped.[/green]")
|
|
252
|
+
return True
|
|
217
253
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
218
254
|
# Socket unavailable — fall back to SIGTERM via PID file
|
|
219
255
|
import signal
|
|
@@ -222,8 +258,20 @@ def mcp_stop() -> None:
|
|
|
222
258
|
if rec and is_pid_alive(rec["pid"]):
|
|
223
259
|
os.kill(rec["pid"], signal.SIGTERM)
|
|
224
260
|
console.print(f"[yellow]Socket unavailable — sent SIGTERM to PID {rec['pid']}[/yellow]")
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("stop")
|
|
266
|
+
def mcp_stop() -> None:
|
|
267
|
+
"""Stop the running MCP server gracefully.
|
|
268
|
+
|
|
269
|
+
Delegates to ``stop_server()`` (shared with ``sqlcg install``) and prints
|
|
270
|
+
a final message based on whether a server was found.
|
|
271
|
+
"""
|
|
272
|
+
stopped = stop_server()
|
|
273
|
+
if not stopped:
|
|
274
|
+
console.print("[yellow]No server found to stop.[/yellow]")
|
|
227
275
|
|
|
228
276
|
|
|
229
277
|
@app.command("restart")
|
sqlcg/cli/main.py
CHANGED
|
@@ -34,8 +34,41 @@ USING THE MCP TOOLS:
|
|
|
34
34
|
Note: Binary is `sqlcg`; PyPI package is `sql-code-graph`.
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
def _version_string() -> str:
|
|
39
|
+
from sqlcg import __version__
|
|
40
|
+
|
|
41
|
+
return f"sqlcg version {__version__}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_callback(value: bool) -> None:
|
|
45
|
+
if value:
|
|
46
|
+
typer.echo(_version_string())
|
|
47
|
+
raise typer.Exit()
|
|
48
|
+
|
|
49
|
+
|
|
37
50
|
app = typer.Typer(name="sqlcg", help=help_text)
|
|
38
51
|
|
|
52
|
+
|
|
53
|
+
@app.callback(invoke_without_command=True)
|
|
54
|
+
def _root(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
version: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--version",
|
|
59
|
+
help="Show version and exit.",
|
|
60
|
+
is_eager=True,
|
|
61
|
+
callback=_version_callback,
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""SQL code graph analyzer."""
|
|
65
|
+
# The eager --version callback exits before we get here when --version is
|
|
66
|
+
# passed. Otherwise: if no subcommand was given, show help (matching the
|
|
67
|
+
# pre-callback behaviour) instead of silently exiting 0.
|
|
68
|
+
if ctx.invoked_subcommand is None:
|
|
69
|
+
typer.echo(ctx.get_help())
|
|
70
|
+
|
|
71
|
+
|
|
39
72
|
# Register subcommand groups
|
|
40
73
|
app.add_typer(db.app, name="db")
|
|
41
74
|
app.add_typer(find.app, name="find")
|
|
@@ -56,9 +89,7 @@ app.command("uninstall")(uninstall.uninstall_cmd)
|
|
|
56
89
|
@app.command()
|
|
57
90
|
def version() -> None:
|
|
58
91
|
"""Show version."""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
typer.echo(f"sqlcg version {__version__}")
|
|
92
|
+
typer.echo(_version_string())
|
|
62
93
|
|
|
63
94
|
|
|
64
95
|
def main() -> None:
|
sqlcg/core/queries.py
CHANGED
|
@@ -54,9 +54,13 @@ COUNT_STAR_SOURCES_QUERY = _Q["COUNT_STAR_SOURCES"]
|
|
|
54
54
|
COUNT_STAR_EXPANSIONS_QUERY = _Q["COUNT_STAR_EXPANSIONS"]
|
|
55
55
|
FIND_DEFINITION_QUERY = _Q["FIND_DEFINITION"]
|
|
56
56
|
GET_TABLE_DEFINING_FILES_QUERY = _Q["GET_TABLE_DEFINING_FILES"]
|
|
57
|
+
GET_PRODUCER_FILES_FOR_TABLE_QUERY = _Q["GET_PRODUCER_FILES_FOR_TABLE"]
|
|
57
58
|
GET_TABLE_DIRECT_UPSTREAMS_QUERY = _Q["GET_TABLE_DIRECT_UPSTREAMS"]
|
|
58
59
|
GET_COLUMNS_FOR_TABLE_QUERY = _Q["GET_COLUMNS_FOR_TABLE"]
|
|
59
60
|
GET_TABLES_DEFINED_IN_FILE_QUERY = _Q["GET_TABLES_DEFINED_IN_FILE"]
|
|
61
|
+
GET_TARGET_TABLES_FOR_FILE_QUERY = _Q["GET_TARGET_TABLES_FOR_FILE"]
|
|
62
|
+
GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY = _Q["GET_TABLE_ADJACENCY_FOR_COLUMNS"]
|
|
63
|
+
GET_TABLE_KINDS_BATCH_QUERY = _Q["GET_TABLE_KINDS_BATCH"]
|
|
60
64
|
ANALYZE_UNUSED_TABLES_QUERY = _Q["ANALYZE_UNUSED_TABLES"]
|
|
61
65
|
HUB_RANKING_QUERY = _Q["HUB_RANKING"]
|
|
62
66
|
DEPENDENT_FILES_OF_TABLES_QUERY = _Q["DEPENDENT_FILES_OF_TABLES"]
|
sqlcg/core/queries.sql
CHANGED
|
@@ -172,6 +172,19 @@ JOIN "DEFINED_IN" di ON di.src_key = t.qualified
|
|
|
172
172
|
JOIN "File" f ON f.path = di.dst_key
|
|
173
173
|
WHERE t.qualified = ?
|
|
174
174
|
|
|
175
|
+
-- GET_PRODUCER_FILES_FOR_TABLE
|
|
176
|
+
-- ETL INSERT...SELECT producers populate a table without a DEFINED_IN edge
|
|
177
|
+
-- (that edge is DDL-only). Resolve SqlQuery.target_table -> QUERY_DEFINED_IN -> File
|
|
178
|
+
-- so get_definition / get_change_scope can also surface "populated here" producer
|
|
179
|
+
-- files, not just "defined here" DDL files (mirror of GET_TARGET_TABLES_FOR_FILE,
|
|
180
|
+
-- the reverse-direction lookup). table_qualified is stored lowercase.
|
|
181
|
+
-- params: [table_qualified]
|
|
182
|
+
SELECT DISTINCT f.path AS file_path
|
|
183
|
+
FROM "SqlQuery" q
|
|
184
|
+
JOIN "QUERY_DEFINED_IN" qdi ON qdi.src_key = q.id
|
|
185
|
+
JOIN "File" f ON f.path = qdi.dst_key
|
|
186
|
+
WHERE q.target_table = ?
|
|
187
|
+
|
|
175
188
|
-- GET_TABLE_DIRECT_UPSTREAMS
|
|
176
189
|
-- params: [table_qualified, table_qualified]
|
|
177
190
|
SELECT DISTINCT src.qualified AS upstream_table, f.path AS in_file
|
|
@@ -198,6 +211,53 @@ FROM "SqlTable" t
|
|
|
198
211
|
JOIN "DEFINED_IN" di ON di.src_key = t.qualified
|
|
199
212
|
WHERE di.dst_key = ?
|
|
200
213
|
|
|
214
|
+
-- GET_TARGET_TABLES_FOR_FILE
|
|
215
|
+
-- ETL INSERT...SELECT producers populate a table without a DEFINED_IN edge
|
|
216
|
+
-- (that edge is DDL-only). Resolve query->file QUERY_DEFINED_IN -> SqlQuery.target_table
|
|
217
|
+
-- so diff_impact can also see "populated here" producers, not just "defined here" DDL.
|
|
218
|
+
-- params: [file_path]
|
|
219
|
+
SELECT DISTINCT q.target_table AS table_qualified
|
|
220
|
+
FROM "SqlQuery" q
|
|
221
|
+
JOIN "QUERY_DEFINED_IN" qdi ON qdi.src_key = q.id
|
|
222
|
+
WHERE qdi.dst_key = ?
|
|
223
|
+
AND q.target_table <> ''
|
|
224
|
+
|
|
225
|
+
-- GET_TABLE_ADJACENCY_FOR_COLUMNS
|
|
226
|
+
-- Aggregate table-level producer->consumer adjacency derived from COLUMN_LINEAGE,
|
|
227
|
+
-- restricted to a closure's column-id set (Option A — issue #38 backfill fix).
|
|
228
|
+
-- Replaces the N x GET_TABLE_DIRECT_UPSTREAMS loop with a single query: CTE-wrapped
|
|
229
|
+
-- INSERT...SELECT statements emit no SELECTS_FROM adjacency at all (the real source
|
|
230
|
+
-- table is nested in the CTE child scope and never surfaces into the statement's
|
|
231
|
+
-- top-level sources). The raw COLUMN_LINEAGE topology bridges producer -> consumer
|
|
232
|
+
-- via a TWO-HOP path through the synthetic cte/derived node (producer -> cte ->
|
|
233
|
+
-- consumer, not a parallel direct edge) — this query returns ALL rolled-up table
|
|
234
|
+
-- pairs (including synthetic endpoints); the caller contracts synthetic-node hops
|
|
235
|
+
-- into direct real-table adjacency in one pass over this small edge set (still
|
|
236
|
+
-- ONCE per closure, never per-table/per-column). Also returns each endpoint's
|
|
237
|
+
-- SqlTable.kind so the caller can identify synthetic nodes without a second query.
|
|
238
|
+
-- params: [col_ids, col_ids]
|
|
239
|
+
SELECT DISTINCT
|
|
240
|
+
src.table_qualified AS upstream_table,
|
|
241
|
+
ut.kind AS upstream_kind,
|
|
242
|
+
dst.table_qualified AS downstream_table,
|
|
243
|
+
dt.kind AS downstream_kind
|
|
244
|
+
FROM "COLUMN_LINEAGE" cl
|
|
245
|
+
JOIN "SqlColumn" src ON src.id = cl.src_key
|
|
246
|
+
JOIN "SqlColumn" dst ON dst.id = cl.dst_key
|
|
247
|
+
LEFT JOIN "SqlTable" ut ON ut.qualified = src.table_qualified
|
|
248
|
+
LEFT JOIN "SqlTable" dt ON dt.qualified = dst.table_qualified
|
|
249
|
+
WHERE cl.src_key = ANY(?)
|
|
250
|
+
AND cl.dst_key = ANY(?)
|
|
251
|
+
AND src.table_qualified <> dst.table_qualified
|
|
252
|
+
|
|
253
|
+
-- GET_TABLE_KINDS_BATCH
|
|
254
|
+
-- Batch lookup of SqlTable.kind for a set of qualified names — the authoritative
|
|
255
|
+
-- marker for synthetic cte/derived nodes (issue #38 synthetic-node-leak fix).
|
|
256
|
+
-- params: [table_qualifieds]
|
|
257
|
+
SELECT t.qualified AS table_qualified, t.kind AS kind
|
|
258
|
+
FROM "SqlTable" t
|
|
259
|
+
WHERE t.qualified = ANY(?)
|
|
260
|
+
|
|
201
261
|
-- ANALYZE_UNUSED_TABLES
|
|
202
262
|
-- params: []
|
|
203
263
|
SELECT t.qualified AS table_qualified
|
sqlcg/parsers/ansi_parser.py
CHANGED
|
@@ -404,14 +404,19 @@ class AnsiParser(SqlParser):
|
|
|
404
404
|
create_stmt: CREATE expression (must be a TABLE create)
|
|
405
405
|
|
|
406
406
|
Returns:
|
|
407
|
-
List of column names in source order (as they appear in the SQL)
|
|
407
|
+
List of column names in source order (as they appear in the SQL),
|
|
408
|
+
normalized to lowercase with surrounding double-quotes stripped —
|
|
409
|
+
identical to ColumnRef.__post_init__ — so DDL columns share node
|
|
410
|
+
identity with lowercase ETL lineage columns (issue #38 Phase 5).
|
|
408
411
|
"""
|
|
409
412
|
columns: list[str] = []
|
|
410
413
|
# Find all ColumnDef nodes in the CREATE statement
|
|
411
414
|
for col_def in create_stmt.find_all(exp.ColumnDef):
|
|
412
415
|
col_name = col_def.this.name if col_def.this else None
|
|
413
416
|
if col_name:
|
|
414
|
-
|
|
417
|
+
if len(col_name) >= 2 and col_name[0] == '"' and col_name[-1] == '"':
|
|
418
|
+
col_name = col_name[1:-1]
|
|
419
|
+
columns.append(col_name.lower())
|
|
415
420
|
return columns
|
|
416
421
|
|
|
417
422
|
@staticmethod
|
sqlcg/server/models.py
CHANGED
|
@@ -190,6 +190,9 @@ class DialectRepoResult(BaseModel):
|
|
|
190
190
|
class DbInfoResult(BaseModel):
|
|
191
191
|
"""Result of db_info tool — graph health and parse quality diagnostics."""
|
|
192
192
|
|
|
193
|
+
sqlcg_version: str = Field(
|
|
194
|
+
..., description="Installed sqlcg package version (sqlcg.__version__)"
|
|
195
|
+
)
|
|
193
196
|
schema_version: str = Field(..., description="Graph schema version")
|
|
194
197
|
node_counts: dict[str, int] = Field(
|
|
195
198
|
default_factory=dict,
|
|
@@ -242,6 +245,12 @@ class DefinitionResult(BaseModel):
|
|
|
242
245
|
duplicate_ddl: bool = Field(
|
|
243
246
|
False, description="True when the same table is defined in more than one file."
|
|
244
247
|
)
|
|
248
|
+
producer_files: list[str] = Field(
|
|
249
|
+
default_factory=list,
|
|
250
|
+
description="Files whose INSERT...SELECT populates this table (via "
|
|
251
|
+
"QUERY_DEFINED_IN -> SqlQuery.target_table). Often the more useful answer "
|
|
252
|
+
"to 'where do I change this table's logic?' than the DDL file alone.",
|
|
253
|
+
)
|
|
245
254
|
noise_excluded: list[str] = Field(
|
|
246
255
|
default_factory=list,
|
|
247
256
|
description="Definition file paths that were flagged as backup/noise.",
|
sqlcg/server/server.py
CHANGED
|
@@ -27,6 +27,7 @@ _real_stdout_buffer = os.fdopen(os.dup(1), "wb", buffering=0)
|
|
|
27
27
|
from dotenv import load_dotenv # noqa: E402
|
|
28
28
|
from mcp.server import FastMCP # noqa: E402
|
|
29
29
|
|
|
30
|
+
from sqlcg import __version__ # noqa: E402
|
|
30
31
|
from sqlcg.utils.logging import getLogger # noqa: E402
|
|
31
32
|
|
|
32
33
|
logger = getLogger(__name__)
|
|
@@ -35,6 +36,13 @@ logger = getLogger(__name__)
|
|
|
35
36
|
# This is safe because _real_stdout_buffer has already captured fd 1 above.
|
|
36
37
|
mcp = FastMCP("SQL Code Graph")
|
|
37
38
|
|
|
39
|
+
# FastMCP.__init__ does not accept a `version` kwarg (verified signature); the
|
|
40
|
+
# underlying mcp.server.lowlevel.Server.__init__(self, name, version=None, ...)
|
|
41
|
+
# does. Set it post-construction so the `initialize` handshake reports
|
|
42
|
+
# serverInfo.version == sqlcg.__version__ instead of None. Deliberate, pinned
|
|
43
|
+
# private-attr write — see tests/unit/test_version_parity.py.
|
|
44
|
+
mcp._mcp_server.version = __version__
|
|
45
|
+
|
|
38
46
|
|
|
39
47
|
def _configure_mcp_logging() -> None:
|
|
40
48
|
"""Redirect sys.stdout to sys.stderr and configure logging to stderr.
|
|
@@ -219,6 +227,7 @@ async def _control_socket_task(
|
|
|
219
227
|
|
|
220
228
|
resp: dict = {
|
|
221
229
|
"running": True,
|
|
230
|
+
"version": __version__,
|
|
222
231
|
"pid": os.getpid(),
|
|
223
232
|
"db_path": str(db_path or _get_db_path()),
|
|
224
233
|
"indexed_sha": indexed_sha,
|
sqlcg/server/tools.py
CHANGED
|
@@ -21,10 +21,14 @@ from sqlcg.core.queries import (
|
|
|
21
21
|
FIND_TABLE_USAGES_QUERY,
|
|
22
22
|
GET_COLUMNS_FOR_TABLE_QUERY,
|
|
23
23
|
GET_DOWNSTREAM_DEPENDENCIES_QUERY,
|
|
24
|
+
GET_PRODUCER_FILES_FOR_TABLE_QUERY,
|
|
25
|
+
GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY,
|
|
24
26
|
GET_TABLE_DEFINING_FILES_QUERY,
|
|
25
27
|
GET_TABLE_DIRECT_UPSTREAMS_QUERY,
|
|
28
|
+
GET_TABLE_KINDS_BATCH_QUERY,
|
|
26
29
|
GET_TABLES_DEFINED_IN_FILE_QUERY,
|
|
27
30
|
GET_TABLES_EXTERNAL_CONSUMERS_BATCH_QUERY,
|
|
31
|
+
GET_TARGET_TABLES_FOR_FILE_QUERY,
|
|
28
32
|
GET_UPSTREAM_DEPENDENCIES_QUERY,
|
|
29
33
|
HUB_RANKING_QUERY,
|
|
30
34
|
INDEX_REPO_FILES_QUERY,
|
|
@@ -396,6 +400,51 @@ def _rollup_to_tables(col_ids: list[str]) -> list[str]:
|
|
|
396
400
|
return tables
|
|
397
401
|
|
|
398
402
|
|
|
403
|
+
# Synthetic SqlTable.kind values that must never surface as rebuildable tables —
|
|
404
|
+
# the authoritative marker per indexer.py (kind="cte" / kind="derived"); see
|
|
405
|
+
# _exclude_synthetic_tables. Keying on kind (not the alias string) avoids dropping
|
|
406
|
+
# a real table that happens to share a name with a CTE alias.
|
|
407
|
+
_SYNTHETIC_TABLE_KINDS = frozenset({"cte", "derived"})
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _exclude_synthetic_tables(db: GraphBackend, tables: list[str]) -> tuple[list[str], list[str]]:
|
|
411
|
+
"""Split *tables* into (real, synthetic) using the authoritative SqlTable.kind.
|
|
412
|
+
|
|
413
|
+
Synthetic CTE/derived nodes (``kind in {"cte", "derived"}``) are emitted into
|
|
414
|
+
the graph for lineage-tracing purposes but must never surface as rebuildable
|
|
415
|
+
tables in get_change_scope / get_backfill_order / diff_impact / scope_change
|
|
416
|
+
(issue #38 synthetic-node leak). Exclusion is keyed on ``kind`` — the
|
|
417
|
+
authoritative marker set in indexer.py — never on the alias string, so a real
|
|
418
|
+
table that happens to share a name with a CTE alias is never dropped.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
db: Graph backend.
|
|
422
|
+
tables: Candidate table_qualified strings (already noise-filtered).
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
(real_tables, synthetic_excluded) — both order-preserving, deduplicated.
|
|
426
|
+
"""
|
|
427
|
+
if not tables:
|
|
428
|
+
return [], []
|
|
429
|
+
|
|
430
|
+
rows = db.run_read(GET_TABLE_KINDS_BATCH_QUERY, {"table_qualifieds": tables})
|
|
431
|
+
synthetic_ids = {
|
|
432
|
+
row["table_qualified"] for row in rows if row["kind"] in _SYNTHETIC_TABLE_KINDS
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if not synthetic_ids:
|
|
436
|
+
return tables, []
|
|
437
|
+
|
|
438
|
+
real: list[str] = []
|
|
439
|
+
synthetic: list[str] = []
|
|
440
|
+
for t in tables:
|
|
441
|
+
if t in synthetic_ids:
|
|
442
|
+
synthetic.append(t)
|
|
443
|
+
else:
|
|
444
|
+
real.append(t)
|
|
445
|
+
return real, synthetic
|
|
446
|
+
|
|
447
|
+
|
|
399
448
|
def _dedup_preserve_order(items: list[str]) -> list[str]:
|
|
400
449
|
"""Deduplicate a list while preserving first-seen order."""
|
|
401
450
|
out: list[str] = []
|
|
@@ -421,30 +470,84 @@ def _risk_label(affected_table_count: int) -> str:
|
|
|
421
470
|
return "high"
|
|
422
471
|
|
|
423
472
|
|
|
424
|
-
def _kahn_topological_sort(
|
|
473
|
+
def _kahn_topological_sort(
|
|
474
|
+
affected_tables: list[str], db: GraphBackend, closure_col_ids: list[str]
|
|
475
|
+
) -> tuple[list[str], bool]:
|
|
425
476
|
"""Order affected tables so producers come before consumers (rebuild order).
|
|
426
477
|
|
|
427
|
-
Builds
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
478
|
+
Builds adjacency from the **COLUMN_LINEAGE closure** (Option A — issue #38),
|
|
479
|
+
not from ``SELECTS_FROM``: CTE-wrapped ``INSERT ... WITH cte AS (...) SELECT
|
|
480
|
+
... FROM cte`` statements emit no ``SELECTS_FROM`` adjacency at all (the real
|
|
481
|
+
source table is nested in the CTE's child scope and never surfaces into the
|
|
482
|
+
statement's top-level ``sources``), which previously left every table at
|
|
483
|
+
indegree 0 and degraded ordering to alphabetical.
|
|
484
|
+
|
|
485
|
+
``COLUMN_LINEAGE`` *does* carry the producer -> consumer connectivity, but —
|
|
486
|
+
contrary to an earlier (unreproduced) assumption of a parallel direct edge —
|
|
487
|
+
it bridges producer and consumer via a **two-hop path through the synthetic
|
|
488
|
+
cte/derived node** (``producer -> cte -> consumer``), not a parallel direct
|
|
489
|
+
edge bypassing it. Rolling the raw closure up to table level therefore yields
|
|
490
|
+
edges *to and from* synthetic nodes, not real producer -> consumer adjacency
|
|
491
|
+
directly. This function **contracts** those synthetic-node hops in one pass
|
|
492
|
+
over the (small) rolled-up edge set — ``real -> synthetic -> ... -> real``
|
|
493
|
+
collapses to ``real -> real`` — yielding the true causal adjacency. Still
|
|
494
|
+
**one** aggregate query + one contraction pass per closure (never
|
|
495
|
+
per-table/per-column; replaces the old N x GET_TABLE_DIRECT_UPSTREAMS loop).
|
|
496
|
+
|
|
497
|
+
Runs Kahn's algorithm over the contracted adjacency. Returns (ordered_tables,
|
|
498
|
+
had_cycle). On a cycle the acyclic prefix is emitted in topological order
|
|
499
|
+
and the remaining cyclic tables are appended in input order — no exception
|
|
500
|
+
is raised.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
affected_tables: Noise-filtered, synthetic-excluded table set to order.
|
|
504
|
+
db: Graph backend.
|
|
505
|
+
closure_col_ids: Full column-id set of the closure (start columns plus
|
|
506
|
+
their transitive downstream columns) — both endpoints of every
|
|
507
|
+
producer -> consumer hop (including synthetic bridges) must be
|
|
508
|
+
present here for the adjacency query to find the connecting path.
|
|
433
509
|
"""
|
|
434
510
|
table_set = set(affected_tables)
|
|
435
511
|
successors: dict[str, set[str]] = {t: set() for t in affected_tables}
|
|
436
512
|
indegree: dict[str, int] = {t: 0 for t in affected_tables}
|
|
437
513
|
|
|
438
|
-
|
|
514
|
+
if closure_col_ids:
|
|
439
515
|
rows = db.run_read(
|
|
440
|
-
|
|
441
|
-
{"
|
|
516
|
+
GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY,
|
|
517
|
+
{"col_ids": closure_col_ids, "col_ids2": closure_col_ids},
|
|
442
518
|
)
|
|
519
|
+
# Raw rolled-up adjacency, including synthetic (cte/derived) endpoints.
|
|
520
|
+
raw_successors: dict[str, set[str]] = {}
|
|
521
|
+
synthetic_nodes: set[str] = set()
|
|
443
522
|
for row in rows:
|
|
444
|
-
src = row["upstream_table"]
|
|
445
|
-
if src
|
|
446
|
-
|
|
447
|
-
|
|
523
|
+
src, dst = row["upstream_table"], row["downstream_table"]
|
|
524
|
+
if src == dst:
|
|
525
|
+
continue
|
|
526
|
+
raw_successors.setdefault(src, set()).add(dst)
|
|
527
|
+
if row["upstream_kind"] in _SYNTHETIC_TABLE_KINDS:
|
|
528
|
+
synthetic_nodes.add(src)
|
|
529
|
+
if row["downstream_kind"] in _SYNTHETIC_TABLE_KINDS:
|
|
530
|
+
synthetic_nodes.add(dst)
|
|
531
|
+
|
|
532
|
+
# Contract synthetic-node hops: from each real producer, BFS through
|
|
533
|
+
# any chain of synthetic nodes to find the real consumers it reaches —
|
|
534
|
+
# `real -> synthetic [-> synthetic ...] -> real` collapses to `real -> real`.
|
|
535
|
+
for src in table_set:
|
|
536
|
+
seen_chain: set[str] = set()
|
|
537
|
+
frontier = deque(raw_successors.get(src, ()))
|
|
538
|
+
while frontier:
|
|
539
|
+
node = frontier.popleft()
|
|
540
|
+
if node in seen_chain:
|
|
541
|
+
continue
|
|
542
|
+
seen_chain.add(node)
|
|
543
|
+
if node in table_set:
|
|
544
|
+
if node != src and node not in successors[src]:
|
|
545
|
+
successors[src].add(node)
|
|
546
|
+
indegree[node] += 1
|
|
547
|
+
elif node in synthetic_nodes:
|
|
548
|
+
frontier.extend(raw_successors.get(node, ()))
|
|
549
|
+
# Real nodes outside table_set (e.g. noise-filtered) are dead ends —
|
|
550
|
+
# do not traverse through them; only synthetic bridges are contracted.
|
|
448
551
|
|
|
449
552
|
# Sort the zero-indegree frontier for deterministic output.
|
|
450
553
|
ready: deque[str] = deque(sorted(t for t in affected_tables if indegree[t] == 0))
|
|
@@ -842,7 +945,10 @@ def find_definition(table_qualified: str) -> DefinitionResult:
|
|
|
842
945
|
with _open_backend() as db:
|
|
843
946
|
_assert_indexed(db)
|
|
844
947
|
|
|
845
|
-
|
|
948
|
+
target = table_qualified.lower()
|
|
949
|
+
rows = db.run_read(FIND_DEFINITION_QUERY, {"table_qualified": target})
|
|
950
|
+
producer_rows = db.run_read(GET_PRODUCER_FILES_FOR_TABLE_QUERY, {"table_qualified": target})
|
|
951
|
+
producer_files = _dedup_preserve_order([r["file_path"] for r in producer_rows])
|
|
846
952
|
noise_filter = NoiseFilter.from_config()
|
|
847
953
|
|
|
848
954
|
duplicate_ddl = len(rows) > 1
|
|
@@ -874,6 +980,7 @@ def find_definition(table_qualified: str) -> DefinitionResult:
|
|
|
874
980
|
table_qualified=table_qualified,
|
|
875
981
|
definitions=definitions,
|
|
876
982
|
duplicate_ddl=duplicate_ddl,
|
|
983
|
+
producer_files=producer_files,
|
|
877
984
|
noise_excluded=noise_excluded,
|
|
878
985
|
hint=hint,
|
|
879
986
|
)
|
|
@@ -905,7 +1012,10 @@ def get_change_scope(table_qualified: str) -> ChangeScopeResult:
|
|
|
905
1012
|
noise_filter = NoiseFilter.from_config()
|
|
906
1013
|
|
|
907
1014
|
def_rows = db.run_read(GET_TABLE_DEFINING_FILES_QUERY, {"table_qualified": target})
|
|
908
|
-
|
|
1015
|
+
producer_rows = db.run_read(GET_PRODUCER_FILES_FOR_TABLE_QUERY, {"table_qualified": target})
|
|
1016
|
+
defining_files = _dedup_preserve_order(
|
|
1017
|
+
[r["file_path"] for r in def_rows] + [r["file_path"] for r in producer_rows]
|
|
1018
|
+
)
|
|
909
1019
|
|
|
910
1020
|
up_rows = db.run_read(
|
|
911
1021
|
GET_TABLE_DIRECT_UPSTREAMS_QUERY,
|
|
@@ -921,7 +1031,13 @@ def get_change_scope(table_qualified: str) -> ChangeScopeResult:
|
|
|
921
1031
|
affected_cols, _depth, truncated = _affected_columns_closure(db, start_cols, max_depth=None)
|
|
922
1032
|
|
|
923
1033
|
affected_tables_all = [t for t in _rollup_to_tables(affected_cols) if t != target]
|
|
924
|
-
|
|
1034
|
+
affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
|
|
1035
|
+
affected_tables_all
|
|
1036
|
+
)
|
|
1037
|
+
affected_tables, synthetic_excluded = _exclude_synthetic_tables(
|
|
1038
|
+
db, affected_tables_noise_filtered
|
|
1039
|
+
)
|
|
1040
|
+
noise_excluded = [*noise_excluded, *synthetic_excluded]
|
|
925
1041
|
kept_tables = set(affected_tables)
|
|
926
1042
|
affected_columns = [c for c in affected_cols if c.rsplit(".", 1)[0] in kept_tables]
|
|
927
1043
|
|
|
@@ -985,11 +1101,21 @@ def get_backfill_order(table_qualified: str) -> BackfillOrderResult:
|
|
|
985
1101
|
affected_cols, _depth, truncated = _affected_columns_closure(db, start_cols, max_depth=None)
|
|
986
1102
|
|
|
987
1103
|
affected_tables_all = [t for t in _rollup_to_tables(affected_cols) if t != target]
|
|
988
|
-
|
|
1104
|
+
affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
|
|
1105
|
+
affected_tables_all
|
|
1106
|
+
)
|
|
1107
|
+
affected_tables, synthetic_excluded = _exclude_synthetic_tables(
|
|
1108
|
+
db, affected_tables_noise_filtered
|
|
1109
|
+
)
|
|
1110
|
+
noise_excluded = [*noise_excluded, *synthetic_excluded]
|
|
989
1111
|
kept_tables = set(affected_tables)
|
|
990
1112
|
affected_columns = [c for c in affected_cols if c.rsplit(".", 1)[0] in kept_tables]
|
|
991
1113
|
|
|
992
|
-
|
|
1114
|
+
# Option A (issue #38): adjacency is derived from the same COLUMN_LINEAGE
|
|
1115
|
+
# closure as membership — pass the full id set (start + affected) so the
|
|
1116
|
+
# direct producer->consumer edge endpoints are both present.
|
|
1117
|
+
closure_col_ids = _dedup_preserve_order([*start_cols, *affected_cols])
|
|
1118
|
+
order, had_cycle = _kahn_topological_sort(affected_tables, db, closure_col_ids)
|
|
993
1119
|
|
|
994
1120
|
hint = None
|
|
995
1121
|
if had_cycle:
|
|
@@ -1044,19 +1170,26 @@ def diff_impact(changed_files: list[str]) -> DiffImpactResult:
|
|
|
1044
1170
|
fp = Path(file_path)
|
|
1045
1171
|
if not fp.is_absolute():
|
|
1046
1172
|
fp = root / fp
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
seen_changed
|
|
1052
|
-
|
|
1173
|
+
for query in (GET_TABLES_DEFINED_IN_FILE_QUERY, GET_TARGET_TABLES_FOR_FILE_QUERY):
|
|
1174
|
+
rows = db.run_read(query, {"file_path": str(fp)})
|
|
1175
|
+
for row in rows:
|
|
1176
|
+
tq = row["table_qualified"]
|
|
1177
|
+
if tq and tq not in seen_changed:
|
|
1178
|
+
seen_changed.add(tq)
|
|
1179
|
+
changed_tables.append(tq)
|
|
1053
1180
|
|
|
1054
1181
|
all_affected_cols: list[str] = []
|
|
1055
1182
|
affected_seen: set[str] = set()
|
|
1183
|
+
all_start_cols: list[str] = []
|
|
1184
|
+
start_seen: set[str] = set()
|
|
1056
1185
|
truncated = False
|
|
1057
1186
|
for tq in changed_tables:
|
|
1058
1187
|
col_rows = db.run_read(GET_COLUMNS_FOR_TABLE_QUERY, {"table_qualified": tq})
|
|
1059
1188
|
start_cols = [r["col_id"] for r in col_rows]
|
|
1189
|
+
for col in start_cols:
|
|
1190
|
+
if col not in start_seen:
|
|
1191
|
+
start_seen.add(col)
|
|
1192
|
+
all_start_cols.append(col)
|
|
1060
1193
|
cols, _depth, tr = _affected_columns_closure(db, start_cols, max_depth=None)
|
|
1061
1194
|
truncated = truncated or tr
|
|
1062
1195
|
for col in cols:
|
|
@@ -1067,9 +1200,18 @@ def diff_impact(changed_files: list[str]) -> DiffImpactResult:
|
|
|
1067
1200
|
affected_tables_all = [
|
|
1068
1201
|
t for t in _rollup_to_tables(all_affected_cols) if t not in seen_changed
|
|
1069
1202
|
]
|
|
1070
|
-
|
|
1203
|
+
affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
|
|
1204
|
+
affected_tables_all
|
|
1205
|
+
)
|
|
1206
|
+
affected_tables, synthetic_excluded = _exclude_synthetic_tables(
|
|
1207
|
+
db, affected_tables_noise_filtered
|
|
1208
|
+
)
|
|
1209
|
+
noise_excluded = [*noise_excluded, *synthetic_excluded]
|
|
1071
1210
|
presentation_facing = [t for t in affected_tables if any(t.startswith(p) for p in prefixes)]
|
|
1072
|
-
|
|
1211
|
+
# Option A (issue #38): same closure-derived adjacency as get_backfill_order —
|
|
1212
|
+
# union of every changed table's start columns plus the unioned affected set.
|
|
1213
|
+
closure_col_ids = _dedup_preserve_order([*all_start_cols, *all_affected_cols])
|
|
1214
|
+
order, had_cycle = _kahn_topological_sort(affected_tables, db, closure_col_ids)
|
|
1073
1215
|
|
|
1074
1216
|
# Resolve external consumers for the blast radius in a single BATCH query (not per-table).
|
|
1075
1217
|
external_consumers: list[str] = []
|
|
@@ -1530,13 +1672,22 @@ def db_info() -> DbInfoResult:
|
|
|
1530
1672
|
those files. Table-level lineage is still available.
|
|
1531
1673
|
- `warnings` list — empty means the graph is healthy.
|
|
1532
1674
|
|
|
1675
|
+
`sqlcg_version` is the installed *package* build (sqlcg.__version__) — use
|
|
1676
|
+
it to self-check you are talking to the build you expect (the MCP protocol
|
|
1677
|
+
`serverInfo.version` is not reliably visible to the agent persona). This is
|
|
1678
|
+
a different axis from `schema_version`, which tracks the graph schema, not
|
|
1679
|
+
the package: do not conflate the two.
|
|
1680
|
+
|
|
1533
1681
|
Parse quality legend (parsing_mode per SqlQuery node):
|
|
1534
1682
|
sqlglot — standard path; column lineage available if extracted
|
|
1535
1683
|
scripting_block — tokenizer fallback; column lineage unavailable
|
|
1536
1684
|
|
|
1537
1685
|
Returns:
|
|
1538
|
-
DbInfoResult with schema
|
|
1686
|
+
DbInfoResult with sqlcg/schema versions, node counts, parse quality,
|
|
1687
|
+
and warnings
|
|
1539
1688
|
"""
|
|
1689
|
+
from sqlcg import __version__
|
|
1690
|
+
|
|
1540
1691
|
db = _get_backend()
|
|
1541
1692
|
|
|
1542
1693
|
schema_version = db.get_schema_version() or "unknown"
|
|
@@ -1603,6 +1754,7 @@ def db_info() -> DbInfoResult:
|
|
|
1603
1754
|
pass
|
|
1604
1755
|
|
|
1605
1756
|
return DbInfoResult(
|
|
1757
|
+
sqlcg_version=__version__,
|
|
1606
1758
|
schema_version=schema_version,
|
|
1607
1759
|
node_counts=node_counts,
|
|
1608
1760
|
column_lineage_edges=column_lineage_edges,
|
|
File without changes
|
|
File without changes
|