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.
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]")
@@ -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
- 5,
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
- from sqlcg.core.config import get_backend, get_db_path, get_dialect
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
- effective_to = to_sha or _get_head(path)
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] [dim]{from_sha[:8]}..{effective_to[:8]}[/dim]"
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
- from_sha,
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 _get_head(root: Path) -> str:
154
- """Return the current HEAD SHA for the git repo at root.
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
- Raises typer.Exit(1) if git is unavailable or root is not a git repo.
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", "HEAD"],
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 determine HEAD SHA in {root}: {result.stderr.strip()}[/red]"
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 determine HEAD SHA[/red]")
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
 
@@ -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
@@ -204,6 +204,8 @@ class GraphBackend(ABC):
204
204
  return "path"
205
205
  case NodeLabel.TABLE:
206
206
  return "qualified"
207
+ case NodeLabel.EXTERNAL_CONSUMER:
208
+ return "name"
207
209
  case _:
208
210
  return "id"
209
211
 
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
- RETURN src.id AS id, src.col_name AS col_name, src.table_qualified AS table_qualified, r.transform AS transform, r.confidence AS confidence
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 = "4"
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")