sql-code-graph 1.0.2__py3-none-any.whl → 1.1.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.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/METADATA +13 -2
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/RECORD +29 -27
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +115 -17
- sqlcg/cli/commands/db.py +17 -0
- sqlcg/cli/commands/find.py +7 -0
- sqlcg/cli/commands/git.py +61 -11
- sqlcg/cli/commands/index.py +30 -2
- sqlcg/cli/commands/mcp.py +103 -0
- sqlcg/cli/commands/reindex.py +122 -12
- sqlcg/core/config.py +80 -0
- sqlcg/core/freshness.py +134 -0
- sqlcg/core/graph_db.py +2 -0
- sqlcg/core/queries.cypher +24 -6
- sqlcg/core/queries.py +4 -1
- sqlcg/core/schema.cypher +13 -1
- sqlcg/core/schema.py +5 -1
- sqlcg/indexer/indexer.py +376 -160
- sqlcg/indexer/walker.py +3 -0
- sqlcg/parsers/ansi_parser.py +56 -0
- sqlcg/parsers/base.py +6 -3
- sqlcg/parsers/snowflake_parser.py +46 -6
- sqlcg/server/control.py +144 -0
- sqlcg/server/models.py +68 -0
- sqlcg/server/server.py +283 -1
- sqlcg/server/skill.py +20 -4
- sqlcg/server/tools.py +203 -13
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/entry_points.txt +0 -0
sqlcg/cli/commands/mcp.py
CHANGED
|
@@ -71,3 +71,106 @@ def mcp_best_practices() -> None:
|
|
|
71
71
|
from sqlcg.server.skill import render_body
|
|
72
72
|
|
|
73
73
|
typer.echo(render_body())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("status")
|
|
77
|
+
def mcp_status() -> None:
|
|
78
|
+
"""Print server status JSON (connects to control socket).
|
|
79
|
+
|
|
80
|
+
Returns JSON with fields: running, pid, db_path, indexed_sha, head_sha,
|
|
81
|
+
stale_by_commits, connected_clients, uptime when a server is live.
|
|
82
|
+
|
|
83
|
+
When no server is found: {"running": false}.
|
|
84
|
+
When the PID file exists with a live process but the socket is unavailable:
|
|
85
|
+
{"running": true, "degraded": "socket unavailable", ...}.
|
|
86
|
+
|
|
87
|
+
R3 (stale socket): if the socket file exists but the server is not
|
|
88
|
+
responding (ConnectionRefusedError / FileNotFoundError), falls through
|
|
89
|
+
to the PID-file probe — never hangs or errors on a dead socket.
|
|
90
|
+
"""
|
|
91
|
+
import socket as _socket
|
|
92
|
+
|
|
93
|
+
from sqlcg.server.control import is_pid_alive, read_pid, sock_path
|
|
94
|
+
|
|
95
|
+
sp = sock_path()
|
|
96
|
+
try:
|
|
97
|
+
with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
|
|
98
|
+
s.settimeout(2)
|
|
99
|
+
s.connect(str(sp))
|
|
100
|
+
s.sendall(json.dumps({"op": "status"}).encode() + b"\n")
|
|
101
|
+
data = s.recv(4096)
|
|
102
|
+
console.print_json(data.decode())
|
|
103
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
104
|
+
# Socket unavailable — probe via PID file (R3: stale-socket fall-through)
|
|
105
|
+
rec = read_pid()
|
|
106
|
+
if rec and is_pid_alive(rec["pid"]):
|
|
107
|
+
console.print_json(
|
|
108
|
+
json.dumps(
|
|
109
|
+
{
|
|
110
|
+
"running": True,
|
|
111
|
+
"degraded": "socket unavailable",
|
|
112
|
+
"pid": rec["pid"],
|
|
113
|
+
"db_path": rec["db_path"],
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
console.print_json(json.dumps({"running": False}))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("stop")
|
|
122
|
+
def mcp_stop() -> None:
|
|
123
|
+
"""Stop the running MCP server gracefully.
|
|
124
|
+
|
|
125
|
+
Sends a ``stop`` op via the control socket; waits up to 5 s for the
|
|
126
|
+
socket file to disappear (confirming clean exit). Falls back to SIGTERM
|
|
127
|
+
on the PID-file PID if the socket is unavailable.
|
|
128
|
+
|
|
129
|
+
R3 (stale socket): ``ConnectionRefusedError`` / ``FileNotFoundError`` are
|
|
130
|
+
caught — never hangs on a dead socket.
|
|
131
|
+
"""
|
|
132
|
+
import socket as _socket
|
|
133
|
+
import time
|
|
134
|
+
|
|
135
|
+
from sqlcg.server.control import is_pid_alive, read_pid, sock_path
|
|
136
|
+
|
|
137
|
+
sp = sock_path()
|
|
138
|
+
try:
|
|
139
|
+
with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
|
|
140
|
+
s.settimeout(2)
|
|
141
|
+
s.connect(str(sp))
|
|
142
|
+
s.sendall(json.dumps({"op": "stop"}).encode() + b"\n")
|
|
143
|
+
s.recv(128)
|
|
144
|
+
# Wait up to 5 s for the socket file to disappear (confirms clean exit)
|
|
145
|
+
for _ in range(10):
|
|
146
|
+
if not sp.exists():
|
|
147
|
+
break
|
|
148
|
+
time.sleep(0.5)
|
|
149
|
+
console.print("[green]Server stopped.[/green]")
|
|
150
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
151
|
+
# Socket unavailable — fall back to SIGTERM via PID file
|
|
152
|
+
import signal
|
|
153
|
+
|
|
154
|
+
rec = read_pid()
|
|
155
|
+
if rec and is_pid_alive(rec["pid"]):
|
|
156
|
+
os.kill(rec["pid"], signal.SIGTERM)
|
|
157
|
+
console.print(f"[yellow]Socket unavailable — sent SIGTERM to PID {rec['pid']}[/yellow]")
|
|
158
|
+
else:
|
|
159
|
+
console.print("[yellow]No server found to stop.[/yellow]")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("restart")
|
|
163
|
+
def mcp_restart() -> None:
|
|
164
|
+
"""Stop the server. The client (editor) must respawn.
|
|
165
|
+
|
|
166
|
+
v1.1 cannot re-parent an editor-spawned stdio process. This command
|
|
167
|
+
stops the current server and prints guidance for the user to restart
|
|
168
|
+
the MCP server via their editor's MCP configuration.
|
|
169
|
+
|
|
170
|
+
True auto-restart (re-parenting stdio) is deferred to v1.2.
|
|
171
|
+
"""
|
|
172
|
+
mcp_stop()
|
|
173
|
+
console.print(
|
|
174
|
+
"[yellow]Server stopped. Please restart via your editor's MCP configuration.[/yellow]"
|
|
175
|
+
)
|
|
176
|
+
console.print("[dim]True auto-restart (re-parenting stdio) is deferred to v1.2.[/dim]")
|
sqlcg/cli/commands/reindex.py
CHANGED
|
@@ -18,6 +18,12 @@ from rich.console import Console
|
|
|
18
18
|
|
|
19
19
|
console = Console()
|
|
20
20
|
|
|
21
|
+
# Client-side socket timeout for the --notify control-socket path.
|
|
22
|
+
# A real DWH server-side resync_changed measured ~89 s (41 changed files + closure);
|
|
23
|
+
# 300 s covers that with headroom while keeping the wait bounded on a wedged server.
|
|
24
|
+
# This is a CLI transport bound, NOT a KuzuConfig/indexer constant.
|
|
25
|
+
_NOTIFY_SOCKET_TIMEOUT_S = 300
|
|
26
|
+
|
|
21
27
|
|
|
22
28
|
def reindex_cmd( # noqa: B008
|
|
23
29
|
path: Path = typer.Argument(..., help="Repository root directory to resync"), # noqa: B008
|
|
@@ -39,10 +45,18 @@ def reindex_cmd( # noqa: B008
|
|
|
39
45
|
help="Files per KuzuDB transaction (same default as index command)",
|
|
40
46
|
),
|
|
41
47
|
timeout_per_file: int = typer.Option( # noqa: B008
|
|
42
|
-
|
|
48
|
+
10,
|
|
43
49
|
"--timeout-per-file",
|
|
44
50
|
help="Per-file parse timeout in seconds",
|
|
45
51
|
),
|
|
52
|
+
notify: bool = typer.Option( # noqa: B008
|
|
53
|
+
False,
|
|
54
|
+
"--notify",
|
|
55
|
+
help=(
|
|
56
|
+
"If a server is live on this DB, route the reindex through the server "
|
|
57
|
+
"(avoids lock contention). Falls back to direct write if no server is found."
|
|
58
|
+
),
|
|
59
|
+
),
|
|
46
60
|
) -> None:
|
|
47
61
|
"""Incrementally resync the graph after a git branch change or pull.
|
|
48
62
|
|
|
@@ -56,17 +70,99 @@ def reindex_cmd( # noqa: B008
|
|
|
56
70
|
Exits with an error if the database schema version does not match the current
|
|
57
71
|
build — run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-init.
|
|
58
72
|
"""
|
|
59
|
-
|
|
73
|
+
import json
|
|
74
|
+
import socket as _socket
|
|
75
|
+
|
|
76
|
+
from sqlcg.core.config import config_file_present, get_backend, get_db_path, get_dialect
|
|
60
77
|
from sqlcg.core.schema import SCHEMA_VERSION
|
|
61
78
|
from sqlcg.indexer.indexer import Indexer
|
|
79
|
+
from sqlcg.server.control import sock_path
|
|
62
80
|
|
|
63
81
|
# Resolve to absolute path so ignore-spec and git delta receive an absolute root
|
|
64
82
|
path = path.resolve()
|
|
65
83
|
|
|
84
|
+
# --notify: if a server is live, route reindex through the socket (R3 fallback)
|
|
85
|
+
if notify:
|
|
86
|
+
sp = sock_path()
|
|
87
|
+
try:
|
|
88
|
+
with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
|
|
89
|
+
s.settimeout(_NOTIFY_SOCKET_TIMEOUT_S)
|
|
90
|
+
s.connect(str(sp))
|
|
91
|
+
# Resolve SHAs before sending — standalone mode reads from DB via socket
|
|
92
|
+
effective_from = from_sha
|
|
93
|
+
if effective_from is None:
|
|
94
|
+
# Standalone mode: we cannot read stored SHA here without opening the
|
|
95
|
+
# DB (which would conflict with the running server). If no --from is
|
|
96
|
+
# given with --notify, we send from="stored" as a sentinel and fall
|
|
97
|
+
# back to direct write; the caller should pass --from explicitly.
|
|
98
|
+
raise OSError( # noqa: TRY301
|
|
99
|
+
"--notify without --from requires direct DB access; falling through"
|
|
100
|
+
)
|
|
101
|
+
# Resolve symbolic refs (HEAD, branch names) to concrete 40-char SHAs
|
|
102
|
+
# before sending — prevents literal "HEAD" from being stored in the graph.
|
|
103
|
+
effective_from = _resolve_ref(path, effective_from)
|
|
104
|
+
effective_to = _resolve_ref(path, to_sha) if to_sha else _get_head(path)
|
|
105
|
+
payload = {
|
|
106
|
+
"op": "reindex",
|
|
107
|
+
"root": str(path),
|
|
108
|
+
"from": effective_from,
|
|
109
|
+
"to": effective_to,
|
|
110
|
+
"dialect": dialect,
|
|
111
|
+
}
|
|
112
|
+
s.sendall(json.dumps(payload).encode() + b"\n")
|
|
113
|
+
data = s.recv(65536)
|
|
114
|
+
result = json.loads(data)
|
|
115
|
+
if "error" in result:
|
|
116
|
+
console.print(f"[red]Server reindex error: {result['error']}[/red]")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
if not quiet:
|
|
119
|
+
srv_summary = result.get("summary", {})
|
|
120
|
+
console.print(
|
|
121
|
+
f"[green]Resynced via server[/green] "
|
|
122
|
+
f"+{srv_summary.get('added', 0)} added, "
|
|
123
|
+
f"~{srv_summary.get('modified', 0)} modified, "
|
|
124
|
+
f"-{srv_summary.get('deleted', 0)} deleted"
|
|
125
|
+
)
|
|
126
|
+
raise typer.Exit(0)
|
|
127
|
+
except TimeoutError:
|
|
128
|
+
# Bug 1 fix: server is alive and working (accepted the connection, holds the
|
|
129
|
+
# lock, will finish and persist). Do NOT fall through to the direct-write
|
|
130
|
+
# path — that would hit the held lock and produce a false "Database is locked"
|
|
131
|
+
# error. Exit 0 so the git hook stays non-fatal; the server will complete.
|
|
132
|
+
# (socket.timeout is an alias of TimeoutError, a subclass of OSError — this
|
|
133
|
+
# clause must be listed before the broad OSError clause below.)
|
|
134
|
+
import sys
|
|
135
|
+
|
|
136
|
+
print(
|
|
137
|
+
f"Server is still applying the reindex (timed out waiting after "
|
|
138
|
+
f"{_NOTIFY_SOCKET_TIMEOUT_S}s); the graph will update when it finishes "
|
|
139
|
+
f"— check 'sqlcg mcp status'.",
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
raise typer.Exit(0) from None
|
|
143
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
144
|
+
# R3: no live server (stale socket, socket absent, fallback condition) —
|
|
145
|
+
# fall through to the existing direct-write path unchanged.
|
|
146
|
+
# NOTE: socket.timeout / TimeoutError is an OSError subclass, so the
|
|
147
|
+
# dedicated timeout clause above must be listed first (already is).
|
|
148
|
+
pass
|
|
149
|
+
except typer.Exit:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
console.print(f"[red]--notify routing failed: {exc}[/red]")
|
|
153
|
+
raise typer.Exit(1) from exc
|
|
154
|
+
|
|
66
155
|
# Resolve dialect
|
|
67
156
|
if dialect == "auto":
|
|
68
157
|
dialect = get_dialect(path)
|
|
69
158
|
|
|
159
|
+
if not quiet and not config_file_present(path):
|
|
160
|
+
console.print(
|
|
161
|
+
f"[yellow]No .sqlcg.toml found at {path}/.sqlcg.toml — "
|
|
162
|
+
"using defaults (snowflake dialect, no aliases/prefixes). "
|
|
163
|
+
"Create .sqlcg.toml in the index directory to customise.[/yellow]"
|
|
164
|
+
)
|
|
165
|
+
|
|
70
166
|
db_path = get_db_path()
|
|
71
167
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
168
|
|
|
@@ -88,15 +184,17 @@ def reindex_cmd( # noqa: B008
|
|
|
88
184
|
|
|
89
185
|
# ---- Determine mode -------------------------------------------------------
|
|
90
186
|
if from_sha is not None:
|
|
91
|
-
# Explicit-SHA mode
|
|
92
|
-
|
|
187
|
+
# Explicit-SHA mode — resolve symbolic refs to concrete SHAs before storing
|
|
188
|
+
effective_from = _resolve_ref(path, from_sha)
|
|
189
|
+
effective_to = _resolve_ref(path, to_sha) if to_sha else _get_head(path)
|
|
93
190
|
if not quiet:
|
|
94
191
|
console.print(
|
|
95
|
-
f"Resyncing [cyan]{path}[/cyan]
|
|
192
|
+
f"Resyncing [cyan]{path}[/cyan] "
|
|
193
|
+
f"[dim]{effective_from[:8]}..{effective_to[:8]}[/dim]"
|
|
96
194
|
)
|
|
97
195
|
summary = indexer.resync_changed(
|
|
98
196
|
path,
|
|
99
|
-
|
|
197
|
+
effective_from,
|
|
100
198
|
effective_to,
|
|
101
199
|
backend,
|
|
102
200
|
dialect,
|
|
@@ -150,24 +248,36 @@ def reindex_cmd( # noqa: B008
|
|
|
150
248
|
)
|
|
151
249
|
|
|
152
250
|
|
|
153
|
-
def
|
|
154
|
-
"""
|
|
251
|
+
def _resolve_ref(root: Path, ref: str) -> str:
|
|
252
|
+
"""Resolve a git ref (HEAD, branch, tag, or concrete SHA) to a 40-char SHA.
|
|
155
253
|
|
|
156
|
-
|
|
254
|
+
A concrete SHA resolves to itself (idempotent), so callers may pass either a
|
|
255
|
+
symbolic ref or a SHA without branching.
|
|
256
|
+
|
|
257
|
+
Raises typer.Exit(1) if git is unavailable or the ref cannot be resolved.
|
|
157
258
|
"""
|
|
158
259
|
try:
|
|
159
260
|
result = subprocess.run(
|
|
160
|
-
["git", "rev-parse",
|
|
261
|
+
["git", "rev-parse", ref],
|
|
161
262
|
cwd=str(root),
|
|
162
263
|
capture_output=True,
|
|
163
264
|
text=True,
|
|
164
265
|
)
|
|
165
266
|
if result.returncode != 0:
|
|
166
267
|
console.print(
|
|
167
|
-
f"[red]Could not
|
|
268
|
+
f"[red]Could not resolve ref '{ref}' in {root}: {result.stderr.strip()}[/red]"
|
|
168
269
|
)
|
|
169
270
|
raise typer.Exit(1)
|
|
170
271
|
return result.stdout.strip()
|
|
171
272
|
except FileNotFoundError:
|
|
172
|
-
console.print("[red]git is not available — cannot
|
|
273
|
+
console.print("[red]git is not available — cannot resolve ref[/red]")
|
|
173
274
|
raise typer.Exit(1) from None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_head(root: Path) -> str:
|
|
278
|
+
"""Return the current HEAD SHA for the git repo at root.
|
|
279
|
+
|
|
280
|
+
Delegates to _resolve_ref so there is one git-rev-parse code path.
|
|
281
|
+
Raises typer.Exit(1) if git is unavailable or root is not a git repo.
|
|
282
|
+
"""
|
|
283
|
+
return _resolve_ref(root, "HEAD")
|
sqlcg/core/config.py
CHANGED
|
@@ -71,6 +71,21 @@ def get_db_path() -> Path:
|
|
|
71
71
|
return KuzuConfig.from_env().db_path
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def config_file_present(path: Path) -> bool:
|
|
75
|
+
"""Return True when a .sqlcg.toml file exists at the given directory.
|
|
76
|
+
|
|
77
|
+
Single source of truth for the config filename so callers never hard-code
|
|
78
|
+
".sqlcg.toml" independently.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Directory to check for .sqlcg.toml
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if path/.sqlcg.toml exists, False otherwise.
|
|
85
|
+
"""
|
|
86
|
+
return (Path(path) / ".sqlcg.toml").exists()
|
|
87
|
+
|
|
88
|
+
|
|
74
89
|
def get_dialect(path: Path) -> str:
|
|
75
90
|
"""Get the SQL dialect from .sqlcg.toml or fall back to snowflake.
|
|
76
91
|
|
|
@@ -266,6 +281,71 @@ def get_presentation_prefixes(path: Path) -> list[str]:
|
|
|
266
281
|
return []
|
|
267
282
|
|
|
268
283
|
|
|
284
|
+
class ExternalConsumerSpec(BaseModel):
|
|
285
|
+
"""Specification for a single external downstream consumer declared in .sqlcg.toml."""
|
|
286
|
+
|
|
287
|
+
name: str
|
|
288
|
+
consumer_type: str
|
|
289
|
+
consumes: list[str]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_external_consumers(path: Path) -> list[ExternalConsumerSpec]:
|
|
293
|
+
"""Get external downstream consumer declarations from .sqlcg.toml.
|
|
294
|
+
|
|
295
|
+
Reads [[sqlcg.external_consumers]] array-of-tables from .sqlcg.toml. Each
|
|
296
|
+
table must have ``name`` and ``consumes`` (non-empty list). Rows without a
|
|
297
|
+
``name`` or with an empty ``consumes`` list are silently skipped. The
|
|
298
|
+
``kind`` field is stored as ``consumer_type`` (lowercased). **Defaults to an
|
|
299
|
+
empty list** when the section is absent — when unset, the ingestion pass is a
|
|
300
|
+
no-op (correct generic behaviour for any user). No hardcoded fallback::
|
|
301
|
+
|
|
302
|
+
[[sqlcg.external_consumers]]
|
|
303
|
+
name = "Tableau: Sales Dashboard"
|
|
304
|
+
kind = "tableau"
|
|
305
|
+
consumes = ["ia_sales.fct_orders"]
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
path: Root directory to search for .sqlcg.toml
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
List of ExternalConsumerSpec objects. Defaults to an empty list.
|
|
312
|
+
"""
|
|
313
|
+
config_file = Path(path) / ".sqlcg.toml"
|
|
314
|
+
if config_file.exists():
|
|
315
|
+
try:
|
|
316
|
+
with open(config_file, "rb") as f:
|
|
317
|
+
config = tomllib.load(f)
|
|
318
|
+
raw = config.get("sqlcg", {}).get("external_consumers", [])
|
|
319
|
+
if not isinstance(raw, list):
|
|
320
|
+
return []
|
|
321
|
+
specs: list[ExternalConsumerSpec] = []
|
|
322
|
+
for entry in raw:
|
|
323
|
+
if not isinstance(entry, dict):
|
|
324
|
+
continue
|
|
325
|
+
name = entry.get("name", "")
|
|
326
|
+
if not name or not isinstance(name, str):
|
|
327
|
+
continue
|
|
328
|
+
consumes_raw = entry.get("consumes", [])
|
|
329
|
+
if not isinstance(consumes_raw, list) or not consumes_raw:
|
|
330
|
+
continue
|
|
331
|
+
consumes = [c.lower() for c in consumes_raw if isinstance(c, str)]
|
|
332
|
+
if not consumes:
|
|
333
|
+
continue
|
|
334
|
+
kind = entry.get("kind", "")
|
|
335
|
+
consumer_type = kind.lower() if isinstance(kind, str) else ""
|
|
336
|
+
specs.append(
|
|
337
|
+
ExternalConsumerSpec(
|
|
338
|
+
name=name,
|
|
339
|
+
consumer_type=consumer_type,
|
|
340
|
+
consumes=consumes,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return specs
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
|
|
269
349
|
def get_backend() -> "GraphBackend":
|
|
270
350
|
"""Get a graph backend instance respecting the SQLCG_BACKEND env var.
|
|
271
351
|
|
sqlcg/core/freshness.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Shared freshness helper — computes indexed-vs-HEAD delta for any repo root.
|
|
2
|
+
|
|
3
|
+
This module is deliberately free of backend imports so it can be unit-tested
|
|
4
|
+
with only a real git repo and no KuzuDB instance. Callers obtain the
|
|
5
|
+
``indexed_sha`` from the backend and pass it in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Freshness:
|
|
17
|
+
"""Snapshot of how fresh the indexed graph is relative to the current HEAD."""
|
|
18
|
+
|
|
19
|
+
indexed_sha: str | None
|
|
20
|
+
"""Git SHA recorded at the last successful index run (from SchemaVersion)."""
|
|
21
|
+
|
|
22
|
+
head_sha: str | None
|
|
23
|
+
"""Current HEAD SHA of the repo at *root*. None when root is not a git repo."""
|
|
24
|
+
|
|
25
|
+
stale_by_commits: int | None
|
|
26
|
+
"""How many commits HEAD is ahead of *indexed_sha*.
|
|
27
|
+
|
|
28
|
+
- ``0`` → graph is up to date.
|
|
29
|
+
- ``N>0`` → graph is N commits behind HEAD.
|
|
30
|
+
- ``None`` → distance is unknown (indexed SHA not in history, shallow clone,
|
|
31
|
+
or git unavailable).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
dirty: bool
|
|
35
|
+
"""True when the working tree contains uncommitted changes (whole-tree check)."""
|
|
36
|
+
|
|
37
|
+
branch: str | None
|
|
38
|
+
"""Current branch name (``git rev-parse --abbrev-ref HEAD``), or None."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Internal git helper
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
47
|
+
"""Run a git command; return stdout stripped, or None on any failure.
|
|
48
|
+
|
|
49
|
+
Never raises — on any subprocess error or non-zero exit code, returns None.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
r = subprocess.run(
|
|
53
|
+
["git", *args],
|
|
54
|
+
cwd=str(root),
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
)
|
|
58
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Public API
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute_freshness(root: Path, indexed_sha: str | None) -> Freshness:
|
|
69
|
+
"""Compute freshness relative to the git repo at *root*.
|
|
70
|
+
|
|
71
|
+
Returns a :class:`Freshness` with all-None/False fields when *root* is not
|
|
72
|
+
a git repo or git is unavailable — **never raises**.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
root: Filesystem path used as the ``cwd`` for git commands. This is
|
|
76
|
+
typically the ``r.path`` value read from the ``Repo`` graph node.
|
|
77
|
+
indexed_sha: The SHA recorded by ``KuzuBackend.get_indexed_sha()``.
|
|
78
|
+
May be ``None`` when the graph was never indexed, or a sentinel
|
|
79
|
+
like ``"<head>+dirty"`` written by ``index --include-working-tree``.
|
|
80
|
+
"""
|
|
81
|
+
head = _git(root, "rev-parse", "HEAD")
|
|
82
|
+
branch = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
83
|
+
dirty_out = _git(root, "status", "--porcelain")
|
|
84
|
+
dirty = bool(dirty_out)
|
|
85
|
+
|
|
86
|
+
stale: int | None = None
|
|
87
|
+
if head and indexed_sha:
|
|
88
|
+
if indexed_sha == head:
|
|
89
|
+
stale = 0
|
|
90
|
+
else:
|
|
91
|
+
raw = _git(root, "rev-list", "--count", f"{indexed_sha}..{head}")
|
|
92
|
+
if raw is not None:
|
|
93
|
+
try:
|
|
94
|
+
stale = int(raw)
|
|
95
|
+
except ValueError:
|
|
96
|
+
stale = None
|
|
97
|
+
# If indexed_sha is unknown (rebased/shallow), raw is None → stale stays None
|
|
98
|
+
|
|
99
|
+
return Freshness(
|
|
100
|
+
indexed_sha=indexed_sha,
|
|
101
|
+
head_sha=head,
|
|
102
|
+
stale_by_commits=stale,
|
|
103
|
+
dirty=dirty,
|
|
104
|
+
branch=branch,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_freshness_line(f: Freshness) -> str:
|
|
109
|
+
"""Return a one-line human summary of graph freshness.
|
|
110
|
+
|
|
111
|
+
Examples::
|
|
112
|
+
|
|
113
|
+
"indexed at abc12345 (up to date)"
|
|
114
|
+
"indexed at abc12345 (2 commit(s) behind HEAD)"
|
|
115
|
+
"indexed at abc12345 (2 commit(s) behind HEAD, working tree dirty)"
|
|
116
|
+
"indexed at abc12345 (commit distance unknown (SHA not in history))"
|
|
117
|
+
"freshness: not available (graph was never indexed from a git repo)"
|
|
118
|
+
"""
|
|
119
|
+
if f.indexed_sha is None:
|
|
120
|
+
return "freshness: not available (graph was never indexed from a git repo)"
|
|
121
|
+
|
|
122
|
+
parts: list[str] = []
|
|
123
|
+
if f.stale_by_commits is None:
|
|
124
|
+
parts.append("commit distance unknown (SHA not in history)")
|
|
125
|
+
elif f.stale_by_commits == 0:
|
|
126
|
+
parts.append("up to date")
|
|
127
|
+
else:
|
|
128
|
+
parts.append(f"{f.stale_by_commits} commit(s) behind HEAD")
|
|
129
|
+
if f.dirty:
|
|
130
|
+
parts.append("working tree dirty")
|
|
131
|
+
|
|
132
|
+
summary = ", ".join(parts)
|
|
133
|
+
sha8 = f.indexed_sha[:8]
|
|
134
|
+
return f"indexed at {sha8} ({summary})"
|
sqlcg/core/graph_db.py
CHANGED
sqlcg/core/queries.cypher
CHANGED
|
@@ -13,17 +13,17 @@ DETACH DELETE t
|
|
|
13
13
|
-- DELETE_FILE
|
|
14
14
|
MATCH (f:File {path: $path}) DETACH DELETE f
|
|
15
15
|
|
|
16
|
-
-- STALE_VIEWS
|
|
17
|
-
MATCH (f:File {path: $path})<-[:DEFINED_IN]-(t:SqlTable)
|
|
18
|
-
<-[:SELECTS_FROM]-(q:SqlQuery)-[:DECLARES]->(v:SqlTable {kind: 'VIEW'})
|
|
19
|
-
RETURN DISTINCT v.qualified AS view_name
|
|
20
|
-
|
|
21
16
|
-- INDEX_REPO_FILES
|
|
22
17
|
MATCH (f:File) WHERE f.path STARTS WITH $repo_prefix RETURN f.path AS path
|
|
23
18
|
|
|
24
19
|
-- TRACE_COLUMN_LINEAGE
|
|
25
20
|
MATCH (dst:SqlColumn {id: $id})<-[r:COLUMN_LINEAGE]-(src:SqlColumn)
|
|
26
|
-
|
|
21
|
+
OPTIONAL MATCH (q:SqlQuery {id: r.query_id})
|
|
22
|
+
OPTIONAL MATCH (t:SqlTable {qualified: src.table_qualified})
|
|
23
|
+
RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified,
|
|
24
|
+
r.transform AS transform, r.confidence AS confidence,
|
|
25
|
+
q.file_path AS file, q.start_line AS line, q.sql AS expression,
|
|
26
|
+
t.kind AS table_kind
|
|
27
27
|
|
|
28
28
|
-- FIND_TABLE_USAGES
|
|
29
29
|
MATCH (t:SqlTable {name: $name})<-[:SELECTS_FROM]-(q:SqlQuery)
|
|
@@ -38,6 +38,12 @@ RETURN dst.id AS id, dst.col_name AS col_name, dst.table_qualified AS table_qual
|
|
|
38
38
|
MATCH (dst:SqlColumn {id: $id})<-[:COLUMN_LINEAGE]-(src:SqlColumn)
|
|
39
39
|
RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified
|
|
40
40
|
|
|
41
|
+
-- GET_UPSTREAM_DEPENDENCIES_FILTERED
|
|
42
|
+
MATCH (dst:SqlColumn {id: $id})<-[:COLUMN_LINEAGE]-(src:SqlColumn)
|
|
43
|
+
MATCH (t:SqlTable {qualified: src.table_qualified})
|
|
44
|
+
WHERE t.kind IN ['table', 'external']
|
|
45
|
+
RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified
|
|
46
|
+
|
|
41
47
|
-- SEARCH_SQL_PATTERN
|
|
42
48
|
MATCH (q:SqlQuery)-[:QUERY_DEFINED_IN]->(f:File)
|
|
43
49
|
WHERE contains(q.sql, $query)
|
|
@@ -112,3 +118,15 @@ LIMIT $k
|
|
|
112
118
|
UNWIND $tables AS tbl
|
|
113
119
|
MATCH (t:SqlTable {qualified: tbl})<-[:SELECTS_FROM]-(q:SqlQuery)-[:QUERY_DEFINED_IN]->(f:File)
|
|
114
120
|
RETURN DISTINCT f.path AS path
|
|
121
|
+
|
|
122
|
+
-- GET_TABLE_EXTERNAL_CONSUMERS
|
|
123
|
+
MATCH (t:SqlTable {qualified: $table_qualified})-[:CONSUMED_BY]->(e:ExternalConsumer)
|
|
124
|
+
RETURN e.name AS name, e.consumer_type AS consumer_type
|
|
125
|
+
|
|
126
|
+
-- GET_TABLES_EXTERNAL_CONSUMERS_BATCH
|
|
127
|
+
UNWIND $table_qualifieds AS tq
|
|
128
|
+
MATCH (t:SqlTable {qualified: tq})-[:CONSUMED_BY]->(e:ExternalConsumer)
|
|
129
|
+
RETURN tq AS table_qualified, e.name AS name, e.consumer_type AS consumer_type
|
|
130
|
+
|
|
131
|
+
-- COUNT_EXTERNAL_CONSUMERS
|
|
132
|
+
MATCH ()-[r:CONSUMED_BY]->() RETURN count(r) AS n
|
sqlcg/core/queries.py
CHANGED
|
@@ -23,12 +23,12 @@ DELETE_COLUMNS_FOR_FILE = _Q["DELETE_COLUMNS_FOR_FILE"]
|
|
|
23
23
|
DELETE_QUERIES_FOR_FILE = _Q["DELETE_QUERIES_FOR_FILE"]
|
|
24
24
|
DELETE_TABLES_FOR_FILE = _Q["DELETE_TABLES_FOR_FILE"]
|
|
25
25
|
DELETE_FILE = _Q["DELETE_FILE"]
|
|
26
|
-
STALE_VIEWS_QUERY = _Q["STALE_VIEWS"]
|
|
27
26
|
INDEX_REPO_FILES_QUERY = _Q["INDEX_REPO_FILES"]
|
|
28
27
|
TRACE_COLUMN_LINEAGE_QUERY = _Q["TRACE_COLUMN_LINEAGE"]
|
|
29
28
|
FIND_TABLE_USAGES_QUERY = _Q["FIND_TABLE_USAGES"]
|
|
30
29
|
GET_DOWNSTREAM_DEPENDENCIES_QUERY = _Q["GET_DOWNSTREAM_DEPENDENCIES"]
|
|
31
30
|
GET_UPSTREAM_DEPENDENCIES_QUERY = _Q["GET_UPSTREAM_DEPENDENCIES"]
|
|
31
|
+
GET_UPSTREAM_DEPENDENCIES_FILTERED_QUERY = _Q["GET_UPSTREAM_DEPENDENCIES_FILTERED"]
|
|
32
32
|
SEARCH_SQL_PATTERN_QUERY = _Q["SEARCH_SQL_PATTERN"]
|
|
33
33
|
LIST_DIALECTS_AND_REPOS_QUERY = _Q["LIST_DIALECTS_AND_REPOS"]
|
|
34
34
|
EXPAND_STAR_SOURCES_QUERY = _Q["EXPAND_STAR_SOURCES"]
|
|
@@ -42,3 +42,6 @@ GET_TABLES_DEFINED_IN_FILE_QUERY = _Q["GET_TABLES_DEFINED_IN_FILE"]
|
|
|
42
42
|
ANALYZE_UNUSED_TABLES_QUERY = _Q["ANALYZE_UNUSED_TABLES"]
|
|
43
43
|
HUB_RANKING_QUERY = _Q["HUB_RANKING"]
|
|
44
44
|
DEPENDENT_FILES_OF_TABLES_QUERY = _Q["DEPENDENT_FILES_OF_TABLES"]
|
|
45
|
+
GET_TABLE_EXTERNAL_CONSUMERS_QUERY = _Q["GET_TABLE_EXTERNAL_CONSUMERS"]
|
|
46
|
+
GET_TABLES_EXTERNAL_CONSUMERS_BATCH_QUERY = _Q["GET_TABLES_EXTERNAL_CONSUMERS_BATCH"]
|
|
47
|
+
COUNT_EXTERNAL_CONSUMERS_QUERY = _Q["COUNT_EXTERNAL_CONSUMERS"]
|
sqlcg/core/schema.cypher
CHANGED
|
@@ -44,7 +44,8 @@ CREATE NODE TABLE SqlQuery (
|
|
|
44
44
|
target_table STRING,
|
|
45
45
|
parse_failed BOOLEAN,
|
|
46
46
|
confidence FLOAT,
|
|
47
|
-
parsing_mode STRING
|
|
47
|
+
parsing_mode STRING,
|
|
48
|
+
start_line INT64
|
|
48
49
|
);
|
|
49
50
|
|
|
50
51
|
-- File -> Repo: file belongs to this repository
|
|
@@ -114,3 +115,14 @@ CREATE NODE TABLE SchemaVersion (
|
|
|
114
115
|
version STRING PRIMARY KEY,
|
|
115
116
|
indexed_sha STRING
|
|
116
117
|
);
|
|
118
|
+
|
|
119
|
+
-- External consumer node: one per declared downstream consumer in .sqlcg.toml
|
|
120
|
+
CREATE NODE TABLE ExternalConsumer (
|
|
121
|
+
name STRING PRIMARY KEY,
|
|
122
|
+
consumer_type STRING
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
-- Table -> ExternalConsumer: this table is consumed by an external system
|
|
126
|
+
CREATE REL TABLE CONSUMED_BY (
|
|
127
|
+
FROM SqlTable TO ExternalConsumer
|
|
128
|
+
);
|
sqlcg/core/schema.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from enum import StrEnum
|
|
4
4
|
from importlib.resources import files
|
|
5
5
|
|
|
6
|
-
SCHEMA_VERSION = "
|
|
6
|
+
SCHEMA_VERSION = "6"
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class NodeLabel(StrEnum):
|
|
@@ -13,6 +13,7 @@ class NodeLabel(StrEnum):
|
|
|
13
13
|
COLUMN = "SqlColumn"
|
|
14
14
|
QUERY = "SqlQuery"
|
|
15
15
|
SCHEMA_VERSION = "SchemaVersion"
|
|
16
|
+
EXTERNAL_CONSUMER = "ExternalConsumer"
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class RelType(StrEnum):
|
|
@@ -27,6 +28,7 @@ class RelType(StrEnum):
|
|
|
27
28
|
COLUMN_LINEAGE = "COLUMN_LINEAGE"
|
|
28
29
|
DECLARES = "DECLARES"
|
|
29
30
|
STAR_SOURCE = "STAR_SOURCE"
|
|
31
|
+
CONSUMED_BY = "CONSUMED_BY"
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
# Backward-compatible aliases
|
|
@@ -36,6 +38,7 @@ NODE_TABLE = NodeLabel.TABLE
|
|
|
36
38
|
NODE_COLUMN = NodeLabel.COLUMN
|
|
37
39
|
NODE_QUERY = NodeLabel.QUERY
|
|
38
40
|
NODE_SCHEMA_VERSION = NodeLabel.SCHEMA_VERSION
|
|
41
|
+
NODE_EXTERNAL_CONSUMER = NodeLabel.EXTERNAL_CONSUMER
|
|
39
42
|
|
|
40
43
|
REL_DEFINED_IN = RelType.DEFINED_IN
|
|
41
44
|
REL_HAS_COLUMN = RelType.HAS_COLUMN
|
|
@@ -45,5 +48,6 @@ REL_DELETES_FROM = RelType.DELETES_FROM
|
|
|
45
48
|
REL_UPDATES = RelType.UPDATES
|
|
46
49
|
REL_COLUMN_LINEAGE = RelType.COLUMN_LINEAGE
|
|
47
50
|
REL_DECLARES = RelType.DECLARES
|
|
51
|
+
REL_CONSUMED_BY = RelType.CONSUMED_BY
|
|
48
52
|
|
|
49
53
|
SCHEMA_DDL: str = files("sqlcg.core").joinpath("schema.cypher").read_text(encoding="utf-8")
|