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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-code-graph
3
- Version: 1.35.3
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` (optional)
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 for file changes
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 (v1.5.0)
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 (v1.2.0)
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=iuiB1QBl9EVtoW0aNt89z6gr_yDaZ5RV7phaFO8zX0Y,116
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=LXy1F6rqQI6VdxpRvZg_tNpoEucW3mXZHYBMlMONbX4,1496
53
- sqlcg/parsers/snowflake_parser.py,sha256=cv7bzBm6Wmwa8uY41Y59ebfFjnP1Gk0Sjp2KN_QBGD8,47542
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.35.3.dist-info/METADATA,sha256=bR0GUYuujbDEYNj4602aE5Olejev4X6hp7KYlaezZjg,17791
77
- sql_code_graph-1.35.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
78
- sql_code_graph-1.35.3.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
79
- sql_code_graph-1.35.3.dist-info/RECORD,,
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
@@ -1,5 +1,5 @@
1
1
  """SQL Code Graph - SQL lineage and dependency analysis tool."""
2
2
 
3
- __version__ = "1.35.3"
3
+ __version__ = "1.36.1"
4
4
 
5
5
  __all__ = ["__version__"]
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: type["SqlParser"]) -> type["SqlParser"]:
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) and isinstance(stmt.expression, exp.Literal):
362
- lit = stmt.expression
363
- if lit.is_string and stmt.this is not None and stmt.this.name:
364
- var_literals[stmt.this.name.lower()] = str(lit.this)
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``.