sql-code-graph 1.35.3__py3-none-any.whl → 1.36.1__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.35.3.dist-info → sql_code_graph-1.36.1.dist-info}/METADATA +35 -5
- {sql_code_graph-1.35.3.dist-info → sql_code_graph-1.36.1.dist-info}/RECORD +7 -7
- sqlcg/__init__.py +1 -1
- sqlcg/parsers/registry.py +7 -3
- sqlcg/parsers/snowflake_parser.py +114 -4
- {sql_code_graph-1.35.3.dist-info → sql_code_graph-1.36.1.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.35.3.dist-info → sql_code_graph-1.36.1.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.36.1
|
|
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
|
|
@@ -85,7 +85,8 @@ for that project.
|
|
|
85
85
|
|
|
86
86
|
1. **Initialize**: `sqlcg db init`
|
|
87
87
|
2. **Index**: `sqlcg index ./sql --dialect snowflake`
|
|
88
|
-
3. **Keep fresh**: `sqlcg git install-hooks` (
|
|
88
|
+
3. **Keep fresh**: `sqlcg git install-hooks` (auto-reindex on branch switch), or
|
|
89
|
+
`sqlcg watch ./sql` to re-index automatically as you edit files (both optional)
|
|
89
90
|
|
|
90
91
|
## Full setup (recommended)
|
|
91
92
|
|
|
@@ -303,7 +304,8 @@ sqlcg analyze empty-impact <table>... # downstream blast radius when named tabl
|
|
|
303
304
|
sqlcg analyze pr-impact --base <ref> # detect producers a PR dropped + their blast radius (code-regression detection)
|
|
304
305
|
sqlcg analyze upstream/downstream # trace lineage from the CLI
|
|
305
306
|
sqlcg find table/column/pattern # search the graph
|
|
306
|
-
sqlcg watch <path> # watch
|
|
307
|
+
sqlcg watch <path> # watch a directory and re-index on SQL file changes
|
|
308
|
+
sqlcg viz # generate a self-contained graph-explorer HTML
|
|
307
309
|
sqlcg db info # graph stats + freshness (indexed SHA vs HEAD)
|
|
308
310
|
sqlcg git install-hooks # install post-checkout + post-merge resync hooks
|
|
309
311
|
sqlcg gain # show usage metrics
|
|
@@ -316,7 +318,7 @@ sqlcg mcp restart # stop the server (client must respawn it
|
|
|
316
318
|
sqlcg version # show installed version
|
|
317
319
|
```
|
|
318
320
|
|
|
319
|
-
### Staying on the latest build
|
|
321
|
+
### Staying on the latest build
|
|
320
322
|
|
|
321
323
|
The installed package, the CLI, and the running MCP server all report the **same**
|
|
322
324
|
version (`sqlcg.__version__`). After upgrading, an editor may still be talking to an
|
|
@@ -325,7 +327,7 @@ server's `version` and a `stale_by_version` flag that is `true` when the live se
|
|
|
325
327
|
differs from the installed build. Re-running `sqlcg install` stops the stale server so
|
|
326
328
|
your editor respawns it on the new build, so you never debug against an outdated server.
|
|
327
329
|
|
|
328
|
-
### Reads while the server is running
|
|
330
|
+
### Reads while the server is running
|
|
329
331
|
|
|
330
332
|
DuckDB takes an exclusive lock on the database file, so while the MCP server is
|
|
331
333
|
live it holds that lock (other processes cannot open the file, even read-only).
|
|
@@ -336,6 +338,34 @@ running they open the database directly, exactly as before. If the server is
|
|
|
336
338
|
mid-reindex the read waits for it to finish rather than failing with
|
|
337
339
|
"Database is locked".
|
|
338
340
|
|
|
341
|
+
### Visualising the graph (`viz`)
|
|
342
|
+
|
|
343
|
+
`sqlcg viz` generates a **self-contained graph-explorer HTML** from the live
|
|
344
|
+
graph — every node, edge, the force-graph library, and the facet data are inlined
|
|
345
|
+
into a single file, so it opens by double-click in a browser with no server or
|
|
346
|
+
external resources.
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
sqlcg viz # writes table_graph.html in the current dir
|
|
350
|
+
sqlcg viz --out lineage.html # choose the output path
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
In the browser the filter composes schema ∩ kind ∩ tag (multi-select per facet)
|
|
354
|
+
plus a job dropdown; an edge renders only when both endpoints are visible. Table,
|
|
355
|
+
view, and temp nodes are on by default; CTE and derived nodes are off (toggleable).
|
|
356
|
+
|
|
357
|
+
Two optional CSV facets let you colour and slice the graph by your own
|
|
358
|
+
conventions — both are `pattern,label[,color]` with a required header, patterns
|
|
359
|
+
are case-insensitive `fnmatch` globs (`*`, `?`) matched against the qualified
|
|
360
|
+
table name:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
sqlcg viz --tags tags.csv # legend swatches: colour + filter the graph
|
|
364
|
+
sqlcg viz --jobs jobs.csv # populate the job dropdown (filter only)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Omit `--tags` to hide the tag legend; omit `--jobs` to leave the dropdown empty.
|
|
368
|
+
|
|
339
369
|
## Supported dialects
|
|
340
370
|
|
|
341
371
|
sqlcg is built on [sqlglot](https://github.com/tobymao/sqlglot), so other dialects
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
sqlcg/__init__.py,sha256=
|
|
1
|
+
sqlcg/__init__.py,sha256=1pTarvYKeyADzesOkchbID_Tn8DRtdOiq2qMb535jXQ,116
|
|
2
2
|
sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
|
|
3
3
|
sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
|
|
4
4
|
sqlcg/cli/coverage.py,sha256=Xm9ITzZDHv2mJ70Q5jCacVuhDStVrE3gq12_-Ypvtd8,43823
|
|
@@ -49,8 +49,8 @@ sqlcg/parsers/base.py,sha256=d5s5_LSv96jrww9vx52GujjrLHwpxy_UOhmIlWcKglw,106489
|
|
|
49
49
|
sqlcg/parsers/bigquery_parser.py,sha256=g0B6aIpMyxLMVQ3ohAAjzR4nEmMh-WGkFcYLMiKdLxs,3177
|
|
50
50
|
sqlcg/parsers/dynamic_name.py,sha256=q0QAa9iAcmRW4e_0G2b2j-xTbI3VR1-Wwa-nJRLtrQw,6836
|
|
51
51
|
sqlcg/parsers/postgres_parser.py,sha256=lYfUpQY6j4Qm7ndXBtXbgPoGzYqYddWt5YeFnWKdA6I,946
|
|
52
|
-
sqlcg/parsers/registry.py,sha256=
|
|
53
|
-
sqlcg/parsers/snowflake_parser.py,sha256=
|
|
52
|
+
sqlcg/parsers/registry.py,sha256=Ur-J8_CVvW05aYthGqN-LhEcIoBwcekubAGxQ8SerRw,1761
|
|
53
|
+
sqlcg/parsers/snowflake_parser.py,sha256=Egg2CkfnAxcr6_yB83uIx1YePKB1w61egP_NeyhzWo4,53059
|
|
54
54
|
sqlcg/parsers/tsql_parser.py,sha256=RRj1pACtAk2tLTDaFWRYF67a0IDvaf5A1YQXWIz0bpQ,956
|
|
55
55
|
sqlcg/server/__init__.py,sha256=n4wuNE7xyJIJxJZBtmtdccCMQfvTdF-IqIaZVbC4FC4,35
|
|
56
56
|
sqlcg/server/control.py,sha256=qUcztb_aDhL-_X_Nq4q6uGx17cUlbLnI6vUpoZsEjbo,4506
|
|
@@ -73,7 +73,7 @@ sqlcg/viz/render.py,sha256=BINkGbJbbb_iqhrkN795RaQsdg8nqCiJtsEFF1yo22Y,2737
|
|
|
73
73
|
sqlcg/viz/tags.py,sha256=6zRnGlHjuGmEeB6yN1uhzm8rqL7ZGoyL1Ki7jI5oM6A,5368
|
|
74
74
|
sqlcg/viz/assets/force-graph.min.js,sha256=jNdYdDdrYiUdUlElxRkolPBt30rstQk2q15Q32VVdzc,177272
|
|
75
75
|
sqlcg/viz/assets/template.html,sha256=9_j-mvo1ZxwgiJPDdVrNmca37dTrTjjYVd3977u-DxE,12294
|
|
76
|
-
sql_code_graph-1.
|
|
77
|
-
sql_code_graph-1.
|
|
78
|
-
sql_code_graph-1.
|
|
79
|
-
sql_code_graph-1.
|
|
76
|
+
sql_code_graph-1.36.1.dist-info/METADATA,sha256=NR-127QkvaJ20LcGYPpd2HRAhZPDXHAPUdjNc0kmp6M,19208
|
|
77
|
+
sql_code_graph-1.36.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
78
|
+
sql_code_graph-1.36.1.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
|
|
79
|
+
sql_code_graph-1.36.1.dist-info/RECORD,,
|
sqlcg/__init__.py
CHANGED
sqlcg/parsers/registry.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Parser registry and factory for dialect-specific SQL parsers."""
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
3
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from sqlcg.lineage.schema_resolver import SchemaResolver
|
|
@@ -9,6 +9,8 @@ if TYPE_CHECKING:
|
|
|
9
9
|
# Global registry of dialect -> parser class mapping
|
|
10
10
|
PARSERS: dict[str | None, type["SqlParser"]] = {}
|
|
11
11
|
|
|
12
|
+
_ParserT = TypeVar("_ParserT", bound="type[SqlParser]")
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
def register(dialect: str | None):
|
|
14
16
|
"""Decorator to register a parser class for a dialect.
|
|
@@ -17,10 +19,12 @@ def register(dialect: str | None):
|
|
|
17
19
|
dialect: SQL dialect identifier (None for ANSI, "snowflake", etc.)
|
|
18
20
|
|
|
19
21
|
Returns:
|
|
20
|
-
Decorator function
|
|
22
|
+
Decorator function. It returns the decorated class *unchanged* and with its
|
|
23
|
+
concrete type preserved, so dialect-specific static methods (e.g.
|
|
24
|
+
``SnowflakeParser.is_dynamic_name_sink``) remain accessible to type checkers.
|
|
21
25
|
"""
|
|
22
26
|
|
|
23
|
-
def decorator(cls:
|
|
27
|
+
def decorator(cls: _ParserT) -> _ParserT:
|
|
24
28
|
PARSERS[dialect] = cls
|
|
25
29
|
return cls
|
|
26
30
|
|
|
@@ -10,6 +10,7 @@ import sqlglot.expressions as exp
|
|
|
10
10
|
from sqlcg.lineage.schema_resolver import SchemaResolver
|
|
11
11
|
from sqlcg.parsers.ansi_parser import AnsiParser
|
|
12
12
|
from sqlcg.parsers.base import ParsedFile, ParseQuality
|
|
13
|
+
from sqlcg.parsers.dynamic_name import resolve_dynamic_name
|
|
13
14
|
from sqlcg.parsers.registry import register
|
|
14
15
|
from sqlcg.utils.logging import getLogger
|
|
15
16
|
|
|
@@ -356,12 +357,34 @@ class SnowflakeParser(AnsiParser):
|
|
|
356
357
|
# `EXECUTE IMMEDIATE (:var)` Command node below can resolve its inner literal.
|
|
357
358
|
# Only single-string-literal RHS values are captured (the statically-recoverable
|
|
358
359
|
# case); a concatenation / CONCAT / bind-var RHS is not a literal and is skipped.
|
|
360
|
+
#
|
|
361
|
+
# Generic-var-name resolution (PR-2): build a SECOND, additive map ``var_rhs``
|
|
362
|
+
# holding the full RHS *AST* (not just literal strings) for both ``var := <expr>``
|
|
363
|
+
# (exp.PropertyEQ) and ``SET var = <expr>`` (exp.Set) assignments. The fold core
|
|
364
|
+
# (``resolve_dynamic_name``) runs on these ASTs on demand from an IDENTIFIER($var)
|
|
365
|
+
# sink. ``var_literals`` stays intact and literal-only — the EXECUTE IMMEDIATE
|
|
366
|
+
# path below still consumes it, and must not be regressed by this addition.
|
|
367
|
+
# Last-write-wins in file source order (plan §var-pre-scan).
|
|
359
368
|
var_literals: dict[str, str] = {}
|
|
369
|
+
var_rhs: dict[str, exp.Expression] = {} # type: ignore[attr-defined]
|
|
360
370
|
for stmt in statements:
|
|
361
|
-
if isinstance(stmt, exp.PropertyEQ)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
371
|
+
if isinstance(stmt, exp.PropertyEQ):
|
|
372
|
+
if stmt.this is not None and stmt.this.name and stmt.expression is not None:
|
|
373
|
+
var_rhs[stmt.this.name.lower()] = stmt.expression
|
|
374
|
+
if isinstance(stmt.expression, exp.Literal) and stmt.expression.is_string:
|
|
375
|
+
var_literals[stmt.this.name.lower()] = str(stmt.expression.this)
|
|
376
|
+
elif isinstance(stmt, exp.Set):
|
|
377
|
+
for item in stmt.expressions:
|
|
378
|
+
if not isinstance(item, exp.SetItem):
|
|
379
|
+
continue
|
|
380
|
+
eq = item.this
|
|
381
|
+
if (
|
|
382
|
+
isinstance(eq, exp.EQ)
|
|
383
|
+
and eq.this is not None
|
|
384
|
+
and eq.this.name
|
|
385
|
+
and eq.expression is not None
|
|
386
|
+
):
|
|
387
|
+
var_rhs[eq.this.name.lower()] = eq.expression
|
|
365
388
|
|
|
366
389
|
for stmt in statements:
|
|
367
390
|
if stmt is None:
|
|
@@ -419,10 +442,20 @@ class SnowflakeParser(AnsiParser):
|
|
|
419
442
|
|
|
420
443
|
# PR-6 Phase 1: a `var := '<literal>'` assignment is a scripting-variable
|
|
421
444
|
# binding, not a query — suppress it (mirrors the USE-statement suppression).
|
|
445
|
+
# NOTE (PR-2): the `SET var = <expr>` form (exp.Set) is NOT suppressed — it
|
|
446
|
+
# flows through as an OTHER-kind statement (existing parse-classification
|
|
447
|
+
# contract, test_set_session_variable_not_parse_failed). Its RHS was already
|
|
448
|
+
# captured in var_rhs above for IDENTIFIER($var) resolution; suppressing it
|
|
449
|
+
# would regress that contract for no benefit.
|
|
422
450
|
if isinstance(stmt, exp.PropertyEQ):
|
|
423
451
|
result.append(None)
|
|
424
452
|
continue
|
|
425
453
|
|
|
454
|
+
# Generic-var-name resolution (PR-2): rewrite IDENTIFIER($var) table sinks
|
|
455
|
+
# to their resolved [db.]name BEFORE qualification, so a catalog-less resolved
|
|
456
|
+
# tail still inherits the active USE SCHEMA prefix from _qualify_bare_tables.
|
|
457
|
+
self._resolve_identifier_tables(stmt, var_rhs)
|
|
458
|
+
|
|
426
459
|
# Qualify bare table references in this statement if we have a schema context.
|
|
427
460
|
if current_schema:
|
|
428
461
|
self._qualify_bare_tables(stmt, current_schema)
|
|
@@ -431,6 +464,83 @@ class SnowflakeParser(AnsiParser):
|
|
|
431
464
|
|
|
432
465
|
return result
|
|
433
466
|
|
|
467
|
+
@staticmethod
|
|
468
|
+
def is_dynamic_name_sink(table: Any) -> Any:
|
|
469
|
+
"""Return the ``$var`` Parameter if ``table`` is ``IDENTIFIER($var)``, else None.
|
|
470
|
+
|
|
471
|
+
Snowflake's ``IDENTIFIER()`` is the dynamic-name sink. In a table position it
|
|
472
|
+
parses to an ``exp.Table`` whose ``.this`` is an ``exp.Anonymous`` named
|
|
473
|
+
``IDENTIFIER`` wrapping a single ``exp.Parameter`` (the session ``$var``).
|
|
474
|
+
|
|
475
|
+
The IDENTIFIER name is matched **case-insensitively** — sqlglot emits the
|
|
476
|
+
function name UPPERCASE (``IDENTIFIER``), so a literal lowercase compare would
|
|
477
|
+
match nothing and every dynamic-name test would silently pass as a no-op
|
|
478
|
+
(plan gate-correction (b)).
|
|
479
|
+
|
|
480
|
+
This is the SOLE dialect-coupled piece of generic var-name resolution. Non-DML
|
|
481
|
+
IDENTIFIER uses (``ALTER WAREHOUSE IDENTIFIER($w)``, ``CALL p($t)``) parse to
|
|
482
|
+
``exp.Command`` and never produce an ``exp.Table``, so they are auto-excluded —
|
|
483
|
+
no name blocklist is needed (plan §sink-predicate).
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
table: an ``exp.Table`` candidate sink.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
The wrapped ``exp.Parameter`` (the ``$var``) when ``table`` is
|
|
490
|
+
``IDENTIFIER($var)``; ``None`` otherwise.
|
|
491
|
+
"""
|
|
492
|
+
anon = getattr(table, "this", None)
|
|
493
|
+
if not isinstance(anon, exp.Anonymous):
|
|
494
|
+
return None
|
|
495
|
+
if (anon.name or "").lower() != "identifier":
|
|
496
|
+
return None
|
|
497
|
+
args = anon.expressions
|
|
498
|
+
if len(args) != 1:
|
|
499
|
+
return None
|
|
500
|
+
param = args[0]
|
|
501
|
+
if not isinstance(param, exp.Parameter):
|
|
502
|
+
return None
|
|
503
|
+
return param
|
|
504
|
+
|
|
505
|
+
def _resolve_identifier_tables(
|
|
506
|
+
self,
|
|
507
|
+
stmt: Any,
|
|
508
|
+
var_rhs: dict[str, exp.Expression], # type: ignore[attr-defined]
|
|
509
|
+
) -> None:
|
|
510
|
+
"""In-place: rewrite ``IDENTIFIER($var)`` table sinks to their resolved TableRef.
|
|
511
|
+
|
|
512
|
+
Demand-driven, once per statement (no qualify/expand/sg_lineage): walk this
|
|
513
|
+
statement's ``exp.Table`` sinks, and for each ``IDENTIFIER($var)`` sink look the
|
|
514
|
+
``$var`` RHS AST up in ``var_rhs`` and fold it with the dialect-agnostic core
|
|
515
|
+
(``resolve_dynamic_name``, chain_depth=1). On a resolved ``exp.Table`` replace the
|
|
516
|
+
sink in place, preserving the original alias. On an honest give-up (``None``) leave
|
|
517
|
+
the IDENTIFIER sink untouched (dropped, as today). See plan §wiring.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
stmt: sqlglot AST node to mutate in place.
|
|
521
|
+
var_rhs: lowercased var name -> its RHS AST (pre-scanned this file).
|
|
522
|
+
"""
|
|
523
|
+
if not isinstance(stmt, exp.Expression): # type: ignore[attr-defined]
|
|
524
|
+
return
|
|
525
|
+
for table in stmt.find_all(exp.Table):
|
|
526
|
+
param = self.is_dynamic_name_sink(table)
|
|
527
|
+
if param is None:
|
|
528
|
+
continue
|
|
529
|
+
var_name = param.name
|
|
530
|
+
if not var_name:
|
|
531
|
+
continue
|
|
532
|
+
rhs = var_rhs.get(var_name.lower())
|
|
533
|
+
if rhs is None:
|
|
534
|
+
continue
|
|
535
|
+
resolved = resolve_dynamic_name(rhs, var_rhs, chain_depth=1)
|
|
536
|
+
if resolved is None:
|
|
537
|
+
continue
|
|
538
|
+
# Preserve the original alias (FROM/INSERT alias survives the rewrite).
|
|
539
|
+
alias = table.args.get("alias")
|
|
540
|
+
if alias is not None:
|
|
541
|
+
resolved.set("alias", alias)
|
|
542
|
+
table.replace(resolved)
|
|
543
|
+
|
|
434
544
|
@staticmethod
|
|
435
545
|
def _qualify_bare_tables(stmt: Any, schema: str) -> None:
|
|
436
546
|
"""In-place: prefix all bare (no db/catalog) exp.Table refs with ``schema``.
|
|
File without changes
|
|
File without changes
|