weaverstack 0.1.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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Static dependency extraction — the names a source file refers to.
|
|
2
|
+
|
|
3
|
+
Extraction only. Nothing here decides whether a name resolves: a two-part name
|
|
4
|
+
may be an object in this repository, a shortcut declared elsewhere, or a typo,
|
|
5
|
+
and telling those apart needs the external-dependency configuration supplied at
|
|
6
|
+
build. This module's whole job is to report, accurately, what the file says.
|
|
7
|
+
|
|
8
|
+
**Python** declares a dependency by importing the other object's module. The
|
|
9
|
+
marker is structural — one ``__`` in an absolute import name::
|
|
10
|
+
|
|
11
|
+
from Sales__Order import Sales__Order -> Sales.Order
|
|
12
|
+
from weaver import Table -> not a reference
|
|
13
|
+
from ._helpers.dates import parse -> not a reference
|
|
14
|
+
|
|
15
|
+
**SQL** declares them by relation position — after ``from``, ``join``,
|
|
16
|
+
``apply`` or ``using``. Names are returned with their delimiters removed and
|
|
17
|
+
their part count intact:
|
|
18
|
+
|
|
19
|
+
=================================== ==========================================
|
|
20
|
+
``Schema.Object`` two parts — Weaver's namespace
|
|
21
|
+
``Catalogue.Schema.Object`` three parts — a physical thing, named by
|
|
22
|
+
the author
|
|
23
|
+
``Server.Catalogue.Schema.Object`` four parts — likewise
|
|
24
|
+
=================================== ==========================================
|
|
25
|
+
|
|
26
|
+
Single-part names are never relations. A CTE, a temp view, a temp table and a
|
|
27
|
+
table alias are all single-part, so requiring two parts excludes every one of
|
|
28
|
+
them without tracking scope.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
from .metadata import ObjectId
|
|
37
|
+
|
|
38
|
+
_TOKENS = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tokens():
|
|
42
|
+
global _TOKENS
|
|
43
|
+
if _TOKENS is None:
|
|
44
|
+
from sqlparse import tokens as _t
|
|
45
|
+
|
|
46
|
+
_TOKENS = _t
|
|
47
|
+
return _TOKENS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RelationReference:
|
|
52
|
+
"""One name a source file refers to, with its parts as written."""
|
|
53
|
+
|
|
54
|
+
parts: tuple[str, ...]
|
|
55
|
+
#: True when the name is immediately followed by ``(`` — a table-valued
|
|
56
|
+
#: function call, not a managed object. ``cross apply Sales.SplitLines(…)``
|
|
57
|
+
#: reads like a two-part relation but resolves to a function, so strict
|
|
58
|
+
#: two-part validation exempts it the way it exempts CTEs and temp tables.
|
|
59
|
+
call: bool = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def object_id(self) -> ObjectId | None:
|
|
63
|
+
"""The two-part identity, or None for a call or a qualified name.
|
|
64
|
+
|
|
65
|
+
A function call is not a repository object, so it yields no object
|
|
66
|
+
identity even though it has two parts.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if len(self.parts) != 2 or self.call:
|
|
70
|
+
return None
|
|
71
|
+
return ObjectId(schema=self.parts[0], object=self.parts[1])
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_qualified(self) -> bool:
|
|
75
|
+
"""True when the author named a physical target rather than an object."""
|
|
76
|
+
|
|
77
|
+
return len(self.parts) > 2
|
|
78
|
+
|
|
79
|
+
def __str__(self) -> str:
|
|
80
|
+
return ".".join(self.parts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class PythonImport:
|
|
85
|
+
"""One Python import with its relative level preserved for item resolution."""
|
|
86
|
+
|
|
87
|
+
module: str | None
|
|
88
|
+
level: int = 0
|
|
89
|
+
names: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
prefix = "." * self.level
|
|
93
|
+
return prefix + (self.module or "")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- Python -----------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def extract_python_references(imported_modules: tuple[str, ...]) -> tuple[RelationReference, ...]:
|
|
100
|
+
"""Object references among a module's absolute imports.
|
|
101
|
+
|
|
102
|
+
Structural: exactly one ``__``, with both sides present and neither
|
|
103
|
+
beginning with an underscore. ``weaver`` has no ``__`` and is not a
|
|
104
|
+
reference; a helper reached as ``_helpers.dates`` contributes its package
|
|
105
|
+
name, which likewise is not one.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
references: list[RelationReference] = []
|
|
109
|
+
seen: set[tuple[str, ...]] = set()
|
|
110
|
+
for name in imported_modules:
|
|
111
|
+
if name.startswith("_"):
|
|
112
|
+
continue
|
|
113
|
+
parts = name.split("__")
|
|
114
|
+
if len(parts) != 2:
|
|
115
|
+
continue
|
|
116
|
+
if not all(part and not part.startswith("_") for part in parts):
|
|
117
|
+
continue
|
|
118
|
+
key = tuple(parts)
|
|
119
|
+
if key not in seen:
|
|
120
|
+
seen.add(key)
|
|
121
|
+
references.append(RelationReference(parts=key))
|
|
122
|
+
return tuple(references)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- SQL --------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_FROM_BOUNDARY_KEYWORDS = {
|
|
128
|
+
"FOR",
|
|
129
|
+
"GO",
|
|
130
|
+
"GROUP",
|
|
131
|
+
"HAVING",
|
|
132
|
+
"OPTION",
|
|
133
|
+
"ORDER",
|
|
134
|
+
"UNION",
|
|
135
|
+
"EXCEPT",
|
|
136
|
+
"INTERSECT",
|
|
137
|
+
"WHERE",
|
|
138
|
+
"LATERAL",
|
|
139
|
+
"PIVOT",
|
|
140
|
+
"UNPIVOT",
|
|
141
|
+
"WINDOW",
|
|
142
|
+
"QUALIFY",
|
|
143
|
+
"CLUSTER",
|
|
144
|
+
"DISTRIBUTE",
|
|
145
|
+
"SORT",
|
|
146
|
+
"LIMIT",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#: ``trim(chars from value)`` is not a relation position.
|
|
150
|
+
_FROM_FUNCTIONS = {"TRIM", "SUBSTRING", "EXTRACT", "OVERLAY", "POSITION"}
|
|
151
|
+
|
|
152
|
+
_STATEMENT_START_KEYWORDS = {
|
|
153
|
+
"ALTER",
|
|
154
|
+
"CREATE",
|
|
155
|
+
"DELETE",
|
|
156
|
+
"DROP",
|
|
157
|
+
"INSERT",
|
|
158
|
+
"MERGE",
|
|
159
|
+
"SELECT",
|
|
160
|
+
"SET",
|
|
161
|
+
"TRUNCATE",
|
|
162
|
+
"UPDATE",
|
|
163
|
+
"USE",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#: Spark reads a path as ``delta.`abfss://…```. The prefix is a format, not a
|
|
167
|
+
#: schema, so the pair is not an object reference.
|
|
168
|
+
_PATH_FORMATS = {"delta", "parquet", "csv", "json", "orc", "avro", "text", "binaryfile"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class _FlatToken:
|
|
173
|
+
value: str
|
|
174
|
+
normalized: str
|
|
175
|
+
ttype: object
|
|
176
|
+
start: int
|
|
177
|
+
depth: int
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class LocatedReference:
|
|
182
|
+
"""One relation reference, and where in the text it was written.
|
|
183
|
+
|
|
184
|
+
Extraction reports what a file says; a *span* additionally lets a caller
|
|
185
|
+
rewrite it. Build needs that: a two-part name in a view body resolves through
|
|
186
|
+
whatever catalogue the session is currently pointed at, so the planner
|
|
187
|
+
replaces each managed reference with a name that says which Lakehouse it
|
|
188
|
+
means (see :mod:`weaver.spark.tokens`).
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
reference: RelationReference
|
|
192
|
+
start: int
|
|
193
|
+
end: int
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def extract_sql_references(sql_text: str) -> tuple[RelationReference, ...]:
|
|
197
|
+
"""Ordered, de-duplicated relation references from a SQL body."""
|
|
198
|
+
|
|
199
|
+
references: list[RelationReference] = []
|
|
200
|
+
seen: set[tuple[str, ...]] = set()
|
|
201
|
+
for located in locate_sql_references(sql_text):
|
|
202
|
+
if located.reference.parts in seen:
|
|
203
|
+
continue
|
|
204
|
+
seen.add(located.reference.parts)
|
|
205
|
+
references.append(located.reference)
|
|
206
|
+
return tuple(references)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def rewrite_sql_references(sql_text: str, rewrite) -> str:
|
|
210
|
+
"""One body with each relation reference the caller claims replaced.
|
|
211
|
+
|
|
212
|
+
``rewrite`` receives a :class:`RelationReference` and returns the text to put
|
|
213
|
+
in its place, or None to leave it exactly as written. Everything else in the
|
|
214
|
+
body — whitespace, comments, casing, the author's own delimiters — is
|
|
215
|
+
untouched, because a build must not quietly reformat a query it is going to
|
|
216
|
+
freeze and execute.
|
|
217
|
+
|
|
218
|
+
Replacements are applied last-first so an earlier span's offsets stay valid.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
replacements = []
|
|
222
|
+
for located in locate_sql_references(sql_text):
|
|
223
|
+
replacement = rewrite(located.reference)
|
|
224
|
+
if replacement is not None:
|
|
225
|
+
replacements.append((located.start, located.end, replacement))
|
|
226
|
+
|
|
227
|
+
for start, end, replacement in sorted(replacements, reverse=True):
|
|
228
|
+
sql_text = sql_text[:start] + replacement + sql_text[end:]
|
|
229
|
+
return sql_text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def locate_sql_references(sql_text: str) -> tuple[LocatedReference, ...]:
|
|
233
|
+
"""Every relation reference in a SQL body, in order, with its span.
|
|
234
|
+
|
|
235
|
+
Not de-duplicated: a name written three times is three places to rewrite.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
from sqlparse.exceptions import SQLParseError
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
tokens = _flatten(sql_text)
|
|
242
|
+
except (SQLParseError, RecursionError):
|
|
243
|
+
return _fallback(sql_text)
|
|
244
|
+
|
|
245
|
+
references: list[LocatedReference] = []
|
|
246
|
+
seen: set[int] = set()
|
|
247
|
+
|
|
248
|
+
for index, token in enumerate(tokens):
|
|
249
|
+
if not _is_keyword(token):
|
|
250
|
+
continue
|
|
251
|
+
head = _keyword_head(token)
|
|
252
|
+
words = set(token.normalized.split())
|
|
253
|
+
if head == "FROM":
|
|
254
|
+
if _enclosing_function(tokens, index) in _FROM_FUNCTIONS:
|
|
255
|
+
continue
|
|
256
|
+
for reference in _from_relations(sql_text, tokens, index):
|
|
257
|
+
_add(references, seen, reference)
|
|
258
|
+
elif head in {"APPLY", "USING"} or "JOIN" in words or "APPLY" in words:
|
|
259
|
+
following = _next_significant(tokens, index + 1)
|
|
260
|
+
if following is not None:
|
|
261
|
+
reference = _relation_at(sql_text, tokens[following].start)
|
|
262
|
+
if reference is not None:
|
|
263
|
+
_add(references, seen, reference)
|
|
264
|
+
elif head in {"MERGE", "INSERT", "UPDATE", "DELETE"}:
|
|
265
|
+
# A DML target is a relation too. Weaver does not restrict what an
|
|
266
|
+
# author writes; it only has to read it accurately.
|
|
267
|
+
reference = _dml_target(sql_text, tokens, index)
|
|
268
|
+
if reference is not None:
|
|
269
|
+
_add(references, seen, reference)
|
|
270
|
+
elif head in {"CROSS", "OUTER"}:
|
|
271
|
+
# sqlparse keywords `cross` but not `apply`, so `cross apply Schema.Fn(…)`
|
|
272
|
+
# arrives as two tokens and the relation sits after the second.
|
|
273
|
+
following = _next_significant(tokens, index + 1)
|
|
274
|
+
if following is not None and tokens[following].value.lower() == "apply":
|
|
275
|
+
after = _next_significant(tokens, following + 1)
|
|
276
|
+
if after is not None:
|
|
277
|
+
reference = _relation_at(sql_text, tokens[after].start)
|
|
278
|
+
if reference is not None:
|
|
279
|
+
_add(references, seen, reference)
|
|
280
|
+
|
|
281
|
+
return tuple(references)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _dml_target(
|
|
285
|
+
sql_text: str, tokens: list[_FlatToken], index: int
|
|
286
|
+
) -> LocatedReference | None:
|
|
287
|
+
"""The relation a DML statement writes to.
|
|
288
|
+
|
|
289
|
+
``insert into``, ``merge into`` and ``delete from`` may arrive as one
|
|
290
|
+
keyword token or two, depending on the dialect and on sqlparse, so an
|
|
291
|
+
intervening ``into``/``from`` is skipped when present.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
following = _next_significant(tokens, index + 1)
|
|
295
|
+
if following is None:
|
|
296
|
+
return None
|
|
297
|
+
if tokens[following].normalized.strip() in {"INTO", "FROM"}:
|
|
298
|
+
following = _next_significant(tokens, following + 1)
|
|
299
|
+
if following is None:
|
|
300
|
+
return None
|
|
301
|
+
return _relation_at(sql_text, tokens[following].start)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _fallback(sql_text: str) -> tuple[LocatedReference, ...]:
|
|
305
|
+
"""Scanner for bodies sqlparse cannot tokenise."""
|
|
306
|
+
|
|
307
|
+
references: list[LocatedReference] = []
|
|
308
|
+
seen: set[int] = set()
|
|
309
|
+
keyword = re.compile(r"\b(from|join|apply|using)\b", flags=re.IGNORECASE)
|
|
310
|
+
for match in keyword.finditer(sql_text):
|
|
311
|
+
located = _relation_at(sql_text, match.end())
|
|
312
|
+
if located is not None:
|
|
313
|
+
_add(references, seen, located)
|
|
314
|
+
return tuple(references)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _add(
|
|
318
|
+
references: list[LocatedReference],
|
|
319
|
+
seen: set[int],
|
|
320
|
+
located: LocatedReference,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Record one occurrence, by position.
|
|
323
|
+
|
|
324
|
+
Position, not name: extraction de-duplicates by name afterwards, but a
|
|
325
|
+
rewrite needs every place the name was written — and two rules can reach the
|
|
326
|
+
same place, which is the one duplicate to drop here.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
if located.start in seen:
|
|
330
|
+
return
|
|
331
|
+
seen.add(located.start)
|
|
332
|
+
references.append(located)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _from_relations(
|
|
336
|
+
sql_text: str, tokens: list[_FlatToken], from_index: int
|
|
337
|
+
) -> list[LocatedReference]:
|
|
338
|
+
"""Every relation in one ``from`` list, including comma-separated ones."""
|
|
339
|
+
|
|
340
|
+
depth = tokens[from_index].depth
|
|
341
|
+
first = _next_significant(tokens, from_index + 1)
|
|
342
|
+
if first is None:
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
relations: list[LocatedReference] = []
|
|
346
|
+
reference = _relation_at(sql_text, tokens[first].start)
|
|
347
|
+
if reference is not None:
|
|
348
|
+
relations.append(reference)
|
|
349
|
+
|
|
350
|
+
for index in range(first + 1, len(tokens)):
|
|
351
|
+
token = tokens[index]
|
|
352
|
+
if token.depth < depth:
|
|
353
|
+
break
|
|
354
|
+
if token.depth != depth:
|
|
355
|
+
continue
|
|
356
|
+
if _is_from_boundary(token):
|
|
357
|
+
break
|
|
358
|
+
if token.value != ",":
|
|
359
|
+
continue
|
|
360
|
+
following = _next_significant(tokens, index + 1)
|
|
361
|
+
if following is None or tokens[following].depth != depth:
|
|
362
|
+
continue
|
|
363
|
+
reference = _relation_at(sql_text, tokens[following].start)
|
|
364
|
+
if reference is not None:
|
|
365
|
+
relations.append(reference)
|
|
366
|
+
|
|
367
|
+
return relations
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_from_boundary(token: _FlatToken) -> bool:
|
|
371
|
+
if token.value == ";":
|
|
372
|
+
return True
|
|
373
|
+
if not _is_keyword(token):
|
|
374
|
+
return False
|
|
375
|
+
head = _keyword_head(token)
|
|
376
|
+
if head in _FROM_BOUNDARY_KEYWORDS:
|
|
377
|
+
return True
|
|
378
|
+
return head in _STATEMENT_START_KEYWORDS and head != "SELECT"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _relation_at(sql_text: str, start: int) -> LocatedReference | None:
|
|
382
|
+
"""A relation reference at ``start``, tagged if it is a function call."""
|
|
383
|
+
|
|
384
|
+
parsed = _parse_name(sql_text, start)
|
|
385
|
+
if parsed is None:
|
|
386
|
+
return None
|
|
387
|
+
parts, begin, position = parsed
|
|
388
|
+
# A ``(`` directly abutting the name is a call, ``Sales.SplitLines(…)``.
|
|
389
|
+
# A table hint is ``… with (nolock)`` — a keyword and a space intervene —
|
|
390
|
+
# so requiring the paren to abut avoids mistaking a hinted table for one.
|
|
391
|
+
call = position < len(sql_text) and sql_text[position] == "("
|
|
392
|
+
return LocatedReference(
|
|
393
|
+
reference=RelationReference(parts=parts, call=call), start=begin, end=position
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _parse_name(sql_text: str, start: int) -> tuple[tuple[str, ...], int, int] | None:
|
|
398
|
+
"""The parts of a relation name, where it begins, and where it ends.
|
|
399
|
+
|
|
400
|
+
The end offset is where the name stops — before any trailing whitespace — so
|
|
401
|
+
a caller can tell an abutting ``(`` (a function call) from a spaced one. The
|
|
402
|
+
begin offset is what makes the name replaceable.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
position = _skip_space(sql_text, start)
|
|
406
|
+
begin = position
|
|
407
|
+
parts: list[str] = []
|
|
408
|
+
end = position
|
|
409
|
+
|
|
410
|
+
while position < len(sql_text):
|
|
411
|
+
parsed = _parse_identifier_part(sql_text, position)
|
|
412
|
+
if parsed is None:
|
|
413
|
+
break
|
|
414
|
+
part, position = parsed
|
|
415
|
+
parts.append(part)
|
|
416
|
+
end = position
|
|
417
|
+
after_space = _skip_space(sql_text, position)
|
|
418
|
+
if after_space >= len(sql_text) or sql_text[after_space] != ".":
|
|
419
|
+
break
|
|
420
|
+
position = _skip_space(sql_text, after_space + 1)
|
|
421
|
+
if len(parts) >= 4:
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
if len(parts) < 2 or len(parts) > 4:
|
|
425
|
+
return None
|
|
426
|
+
if any(not part or part.startswith(("#", "@")) for part in parts):
|
|
427
|
+
return None
|
|
428
|
+
if len(parts) == 2 and parts[0].lower() in _PATH_FORMATS:
|
|
429
|
+
# delta.`abfss://…` — a format and a path, not schema and object.
|
|
430
|
+
return None
|
|
431
|
+
return tuple(parts), begin, end
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_identifier_part(sql_text: str, start: int) -> tuple[str, int] | None:
|
|
435
|
+
if start >= len(sql_text):
|
|
436
|
+
return None
|
|
437
|
+
character = sql_text[start]
|
|
438
|
+
if character == "[":
|
|
439
|
+
return _parse_delimited(sql_text, start, "]")
|
|
440
|
+
if character == '"':
|
|
441
|
+
return _parse_delimited(sql_text, start, '"')
|
|
442
|
+
if character == "`":
|
|
443
|
+
return _parse_delimited(sql_text, start, "`")
|
|
444
|
+
match = re.match(r"[A-Za-z_@#][A-Za-z0-9_@$#]*", sql_text[start:])
|
|
445
|
+
if not match:
|
|
446
|
+
return None
|
|
447
|
+
return match.group(0), start + match.end()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _parse_delimited(sql_text: str, start: int, closer: str) -> tuple[str, int] | None:
|
|
451
|
+
position = start + 1
|
|
452
|
+
characters: list[str] = []
|
|
453
|
+
while position < len(sql_text):
|
|
454
|
+
character = sql_text[position]
|
|
455
|
+
if character == closer:
|
|
456
|
+
if position + 1 < len(sql_text) and sql_text[position + 1] == closer:
|
|
457
|
+
characters.append(closer)
|
|
458
|
+
position += 2
|
|
459
|
+
continue
|
|
460
|
+
return "".join(characters), position + 1
|
|
461
|
+
characters.append(character)
|
|
462
|
+
position += 1
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _skip_space(sql_text: str, start: int) -> int:
|
|
467
|
+
position = start
|
|
468
|
+
while position < len(sql_text) and sql_text[position] in " \t\r\n":
|
|
469
|
+
position += 1
|
|
470
|
+
return position
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _flatten(sql_text: str) -> list[_FlatToken]:
|
|
474
|
+
import sqlparse
|
|
475
|
+
|
|
476
|
+
flat: list[_FlatToken] = []
|
|
477
|
+
offset = 0
|
|
478
|
+
depth = 0
|
|
479
|
+
for statement in sqlparse.parse(sql_text):
|
|
480
|
+
for token in statement.flatten():
|
|
481
|
+
value = token.value
|
|
482
|
+
token_depth = depth
|
|
483
|
+
if value == ")":
|
|
484
|
+
depth = max(0, depth - 1)
|
|
485
|
+
token_depth = depth
|
|
486
|
+
flat.append(
|
|
487
|
+
_FlatToken(
|
|
488
|
+
value=value,
|
|
489
|
+
normalized=token.normalized.upper(),
|
|
490
|
+
ttype=token.ttype,
|
|
491
|
+
start=offset,
|
|
492
|
+
depth=token_depth,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
offset += len(value)
|
|
496
|
+
if value == "(":
|
|
497
|
+
depth += 1
|
|
498
|
+
return flat
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _next_significant(tokens: list[_FlatToken], index: int) -> int | None:
|
|
502
|
+
for candidate in range(index, len(tokens)):
|
|
503
|
+
if not _is_trivia(tokens[candidate]):
|
|
504
|
+
return candidate
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _previous_significant(tokens: list[_FlatToken], index: int) -> int | None:
|
|
509
|
+
for candidate in range(index - 1, -1, -1):
|
|
510
|
+
if not _is_trivia(tokens[candidate]):
|
|
511
|
+
return candidate
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _enclosing_function(tokens: list[_FlatToken], index: int) -> str | None:
|
|
516
|
+
"""The function keyword of the parenthesis enclosing ``index``, if any."""
|
|
517
|
+
|
|
518
|
+
depth = 0
|
|
519
|
+
for candidate in range(index - 1, -1, -1):
|
|
520
|
+
value = tokens[candidate].value
|
|
521
|
+
if value == ")":
|
|
522
|
+
depth += 1
|
|
523
|
+
elif value == "(":
|
|
524
|
+
if depth == 0:
|
|
525
|
+
previous = _previous_significant(tokens, candidate)
|
|
526
|
+
if previous is None:
|
|
527
|
+
return None
|
|
528
|
+
return _keyword_head(tokens[previous])
|
|
529
|
+
depth -= 1
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _is_trivia(token: _FlatToken) -> bool:
|
|
534
|
+
tokens = _tokens()
|
|
535
|
+
return token.ttype in tokens.Whitespace or token.ttype in tokens.Comment
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _is_keyword(token: _FlatToken) -> bool:
|
|
539
|
+
return token.ttype in _tokens().Keyword
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _keyword_head(token: _FlatToken) -> str:
|
|
543
|
+
parts = token.normalized.split(maxsplit=1)
|
|
544
|
+
return parts[0] if parts else ""
|