sql-code-graph 1.35.2__py3-none-any.whl → 1.36.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.35.2.dist-info → sql_code_graph-1.36.0.dist-info}/METADATA +1 -1
- {sql_code_graph-1.35.2.dist-info → sql_code_graph-1.36.0.dist-info}/RECORD +9 -9
- sqlcg/__init__.py +1 -1
- sqlcg/indexer/indexer.py +61 -9
- sqlcg/parsers/bigquery_parser.py +5 -1
- sqlcg/parsers/registry.py +7 -3
- sqlcg/parsers/snowflake_parser.py +114 -4
- {sql_code_graph-1.35.2.dist-info → sql_code_graph-1.36.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.35.2.dist-info → sql_code_graph-1.36.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.36.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,4 +1,4 @@
|
|
|
1
|
-
sqlcg/__init__.py,sha256=
|
|
1
|
+
sqlcg/__init__.py,sha256=XPECmPOL0Clmm8vi1OTnN9oS6T6x-udm0Qb_r_tQoEA,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
|
|
@@ -34,7 +34,7 @@ sqlcg/indexer/__init__.py,sha256=Wh20Unz2OHs1oIyWLrpurPAasF0BET2g4iXtNk7mh2U,56
|
|
|
34
34
|
sqlcg/indexer/dbt_adapter.py,sha256=EB5x1WU5Z9d-I97ADDj88S_hG1C4z4nbrv8JUCzXfy8,686
|
|
35
35
|
sqlcg/indexer/error_classify.py,sha256=-sp8cRmuOBHu_CxnCtaXf34YxHFYwIFNjIrn4LaEv6M,7142
|
|
36
36
|
sqlcg/indexer/git_delta.py,sha256=zYdH5q-jV7w_ne8Oxdywsy0N3rwUjpd5RjEDurlrMSA,5026
|
|
37
|
-
sqlcg/indexer/indexer.py,sha256=
|
|
37
|
+
sqlcg/indexer/indexer.py,sha256=LNc5pI3_WwIMT2iTnCu9VB1Kk21T1qAmN7fupyiQu2s,105315
|
|
38
38
|
sqlcg/indexer/pool.py,sha256=iMmCQtpDRKBTQBep2_EUq9THcsE18Zgk0hdaFB_CwiA,19006
|
|
39
39
|
sqlcg/indexer/walker.py,sha256=Cft6JiJtdBFy0HR6L9pJdr5Fg0eRR3XBW1OMtM2apto,1947
|
|
40
40
|
sqlcg/indexer/watcher.py,sha256=mJQq1LASRLKKwhz0WhCUWPLLqyPR2_-FD_8efYU6gE8,8442
|
|
@@ -46,11 +46,11 @@ sqlcg/metrics/store.py,sha256=KuDtxvyAgug9_KtiSCpvgKM2VZM7VSaI3D11uMLjJJk,10604
|
|
|
46
46
|
sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
|
|
47
47
|
sqlcg/parsers/ansi_parser.py,sha256=RX6eVj7gt1qmsHNJLAF_a4jyW3RCI5W2oF4rd53cKNg,39336
|
|
48
48
|
sqlcg/parsers/base.py,sha256=d5s5_LSv96jrww9vx52GujjrLHwpxy_UOhmIlWcKglw,106489
|
|
49
|
-
sqlcg/parsers/bigquery_parser.py,sha256=
|
|
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.0.dist-info/METADATA,sha256=qmHQutd2-IfpHk1ETAPDISdRiprgHJSe8DvFQokUOa8,17791
|
|
77
|
+
sql_code_graph-1.36.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
78
|
+
sql_code_graph-1.36.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
|
|
79
|
+
sql_code_graph-1.36.0.dist-info/RECORD,,
|
sqlcg/__init__.py
CHANGED
sqlcg/indexer/indexer.py
CHANGED
|
@@ -388,7 +388,7 @@ def _flush_row_batch(
|
|
|
388
388
|
)
|
|
389
389
|
|
|
390
390
|
|
|
391
|
-
def _subprocess_parse_worker(parser_cls, dialect, path, sql, q):
|
|
391
|
+
def _subprocess_parse_worker(parser_cls, dialect, path, sql, q, rel_path=None):
|
|
392
392
|
"""Parse a single file in a subprocess; queue the ParsedFile (or exception).
|
|
393
393
|
|
|
394
394
|
parser_cls must be the *class* (pickleable), not an instance. The worker
|
|
@@ -398,10 +398,16 @@ def _subprocess_parse_worker(parser_cls, dialect, path, sql, q):
|
|
|
398
398
|
T-09-04: Parser constructors require a SchemaResolver. The subprocess gets a
|
|
399
399
|
fresh empty resolver; column resolution runs in infer-only mode, the same as
|
|
400
400
|
small-repo mode.
|
|
401
|
+
|
|
402
|
+
#171: rel_path is the repo-relative posix path used for CTE/temp namespace
|
|
403
|
+
keying. It MUST be forwarded so the incremental path produces the same keys
|
|
404
|
+
as index_repo (which threads rel_path through its task dict); without it the
|
|
405
|
+
namespace falls back to the absolute OS path, creating duplicate CTE/temp
|
|
406
|
+
nodes after an incremental reindex.
|
|
401
407
|
"""
|
|
402
408
|
try:
|
|
403
409
|
parser = parser_cls(SchemaResolver(dialect=str(dialect) if dialect else None))
|
|
404
|
-
out = parser.parse_file(path, sql)
|
|
410
|
+
out = parser.parse_file(path, sql, rel_path=rel_path)
|
|
405
411
|
q.put(out)
|
|
406
412
|
except BaseException as exc:
|
|
407
413
|
# Send the exception back; parent will re-raise.
|
|
@@ -1129,6 +1135,22 @@ class Indexer:
|
|
|
1129
1135
|
schema_resolver = SchemaResolver(dialect=dialect)
|
|
1130
1136
|
parser = get_parser(dialect, schema_resolver)
|
|
1131
1137
|
|
|
1138
|
+
# #170/#171: load schema_aliases and compute repo-relative posix paths the
|
|
1139
|
+
# SAME way index_repo does, so the incremental path applies the same alias
|
|
1140
|
+
# normalisation (#170) and CTE/temp namespace keys (#171). Without these,
|
|
1141
|
+
# a branch-switch resync produced phantom *_tmp.* nodes (aliases unapplied)
|
|
1142
|
+
# and duplicate CTE/temp nodes (absolute-path keys).
|
|
1143
|
+
from sqlcg.core.config import get_schema_aliases
|
|
1144
|
+
|
|
1145
|
+
schema_aliases = get_schema_aliases(root)
|
|
1146
|
+
root_resolved = Path(root).resolve()
|
|
1147
|
+
|
|
1148
|
+
def _rel_posix(fp: Path) -> str:
|
|
1149
|
+
try:
|
|
1150
|
+
return fp.resolve().relative_to(root_resolved).as_posix()
|
|
1151
|
+
except ValueError:
|
|
1152
|
+
return fp.as_posix()
|
|
1153
|
+
|
|
1132
1154
|
pass1_results: list[ParsedFile] = []
|
|
1133
1155
|
for file_path in reparse_set:
|
|
1134
1156
|
try:
|
|
@@ -1141,7 +1163,9 @@ class Indexer:
|
|
|
1141
1163
|
pass1_results.append(placeholder)
|
|
1142
1164
|
continue
|
|
1143
1165
|
try:
|
|
1144
|
-
parsed = self._index_single_file(
|
|
1166
|
+
parsed = self._index_single_file(
|
|
1167
|
+
parser, file_path, sql, timeout_per_file, rel_path=_rel_posix(file_path)
|
|
1168
|
+
)
|
|
1145
1169
|
except Exception as exc:
|
|
1146
1170
|
logger.warning("resync_changed: parse failed %s: %s", file_path, exc)
|
|
1147
1171
|
parsed = ParsedFile(path=file_path, dialect=dialect)
|
|
@@ -1287,7 +1311,11 @@ class Indexer:
|
|
|
1287
1311
|
def_path = Path(definer_fp)
|
|
1288
1312
|
def_sql = def_path.read_text(encoding="utf-8")
|
|
1289
1313
|
def_parsed = self._index_single_file(
|
|
1290
|
-
parser,
|
|
1314
|
+
parser,
|
|
1315
|
+
def_path,
|
|
1316
|
+
def_sql,
|
|
1317
|
+
timeout_per_file,
|
|
1318
|
+
rel_path=_rel_posix(def_path),
|
|
1291
1319
|
)
|
|
1292
1320
|
# Harvest only — register for cross_file_sources but do NOT upsert
|
|
1293
1321
|
aggregator.register_pass1(def_parsed)
|
|
@@ -1315,7 +1343,7 @@ class Indexer:
|
|
|
1315
1343
|
)
|
|
1316
1344
|
continue
|
|
1317
1345
|
try:
|
|
1318
|
-
cl_parsed = parser.parse_file(cl_path, cl_sql)
|
|
1346
|
+
cl_parsed = parser.parse_file(cl_path, cl_sql, rel_path=_rel_posix(cl_path))
|
|
1319
1347
|
except Exception as exc:
|
|
1320
1348
|
logger.warning("resync_changed: parse failed for closure file %s: %s", cl_path, exc)
|
|
1321
1349
|
cl_parsed = ParsedFile(path=cl_path, dialect=dialect)
|
|
@@ -1325,7 +1353,19 @@ class Indexer:
|
|
|
1325
1353
|
# ---- Step 7: Batched bulk upsert (same _flush_batch path as index_repo) ----
|
|
1326
1354
|
all_results = pass1_results + closure_results
|
|
1327
1355
|
|
|
1328
|
-
#
|
|
1356
|
+
# #170: key-normalisation choke point — apply schema_aliases + empty-identity
|
|
1357
|
+
# guard to EVERY parse result BEFORE the defined_table_registry is built and
|
|
1358
|
+
# before _upsert_file_batch. index_repo (line ~797) and reindex_file
|
|
1359
|
+
# (line ~1421) both do this; resync_changed previously did not, so a
|
|
1360
|
+
# branch-switch incremental reindex left staging-alias schemas (e.g. ba_tmp)
|
|
1361
|
+
# un-normalised, producing phantom *_tmp.* nodes that a from-scratch index
|
|
1362
|
+
# never creates. O(edges) per file, once per resync — outside the hot loop.
|
|
1363
|
+
from sqlcg.parsers.base import normalize_keys as _normalize_keys
|
|
1364
|
+
|
|
1365
|
+
for pf in all_results:
|
|
1366
|
+
_normalize_keys(pf, schema_aliases)
|
|
1367
|
+
|
|
1368
|
+
# Build a registry for duplicate DDL detection (post-normalisation full_ids)
|
|
1329
1369
|
defined_table_registry: dict[str, str] = {}
|
|
1330
1370
|
for pf in all_results:
|
|
1331
1371
|
for table in pf.defined_tables:
|
|
@@ -1432,7 +1472,14 @@ class Indexer:
|
|
|
1432
1472
|
# join-column edges until the next full index (plan-review BLOCKER).
|
|
1433
1473
|
self._resolve_join_columns(db)
|
|
1434
1474
|
|
|
1435
|
-
def _index_single_file(
|
|
1475
|
+
def _index_single_file(
|
|
1476
|
+
self,
|
|
1477
|
+
parser,
|
|
1478
|
+
path: Path,
|
|
1479
|
+
sql: str,
|
|
1480
|
+
timeout: int,
|
|
1481
|
+
rel_path: str | None = None,
|
|
1482
|
+
) -> ParsedFile:
|
|
1436
1483
|
"""Parse one file, with optional timeout via subprocess isolation.
|
|
1437
1484
|
|
|
1438
1485
|
T-09-04: Subprocess isolation via multiprocessing.Process + spawn context.
|
|
@@ -1446,12 +1493,17 @@ class Indexer:
|
|
|
1446
1493
|
path: Path to the file
|
|
1447
1494
|
sql: SQL text
|
|
1448
1495
|
timeout: Timeout in seconds (0 = no timeout)
|
|
1496
|
+
rel_path: Repo-relative posix path for CTE/temp namespace keying
|
|
1497
|
+
(#171). Threaded through to parse_file (both the in-process and
|
|
1498
|
+
subprocess branch) so the incremental path produces the same
|
|
1499
|
+
namespace keys as index_repo. Falls back to str(path) inside the
|
|
1500
|
+
parser when None.
|
|
1449
1501
|
|
|
1450
1502
|
Returns:
|
|
1451
1503
|
ParsedFile with parse_failed flag set if timeout occurs
|
|
1452
1504
|
"""
|
|
1453
1505
|
if timeout <= 0:
|
|
1454
|
-
return parser.parse_file(path, sql)
|
|
1506
|
+
return parser.parse_file(path, sql, rel_path=rel_path)
|
|
1455
1507
|
|
|
1456
1508
|
ctx = mp.get_context("spawn") # avoid fork-inherit pitfalls (KuzuDB connection FD etc.)
|
|
1457
1509
|
# Unbounded queue: the child writes one large ParsedFile (192–552 KB pickled).
|
|
@@ -1462,7 +1514,7 @@ class Indexer:
|
|
|
1462
1514
|
q: mp.Queue = ctx.Queue()
|
|
1463
1515
|
proc = ctx.Process(
|
|
1464
1516
|
target=_subprocess_parse_worker,
|
|
1465
|
-
args=(parser.__class__, parser.DIALECT, path, sql, q),
|
|
1517
|
+
args=(parser.__class__, parser.DIALECT, path, sql, q, rel_path),
|
|
1466
1518
|
daemon=True,
|
|
1467
1519
|
)
|
|
1468
1520
|
proc.start()
|
sqlcg/parsers/bigquery_parser.py
CHANGED
|
@@ -35,12 +35,16 @@ class BigQueryParser(AnsiParser):
|
|
|
35
35
|
"""
|
|
36
36
|
super().__init__(schema_resolver, schema_aliases=schema_aliases)
|
|
37
37
|
|
|
38
|
-
def parse_file(self, path: Path, sql: str) -> ParsedFile:
|
|
38
|
+
def parse_file(self, path: Path, sql: str, rel_path: str | None = None) -> ParsedFile:
|
|
39
39
|
"""Parse BigQuery SQL file with scripting block detection.
|
|
40
40
|
|
|
41
41
|
Args:
|
|
42
42
|
path: Path to the source file
|
|
43
43
|
sql: SQL text to parse
|
|
44
|
+
rel_path: Repo-relative posix path for CTE/temp namespace keying.
|
|
45
|
+
Accepted for signature parity with the other parsers (the pool and
|
|
46
|
+
resync paths always pass it); scripting-fallback BigQuery files do
|
|
47
|
+
not register CTE/temp nodes, so it is currently unused here.
|
|
44
48
|
|
|
45
49
|
Returns:
|
|
46
50
|
ParsedFile with parsed statements and metadata
|
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
|