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,591 @@
|
|
|
1
|
+
"""Offset-exact T-SQL text transforms for shape-only query materialisation.
|
|
2
|
+
|
|
3
|
+
A Warehouse table's inferred build runs its query in *shape-only* form: every
|
|
4
|
+
``SELECT`` is guarded to return its columns and no rows, and the final result is
|
|
5
|
+
diverted into a temp table whose metadata the generated script then reads. Those
|
|
6
|
+
two rewrites — :func:`insert_where_one_eq_zero` and :func:`insert_select_into` —
|
|
7
|
+
work over a flattened, offset-carrying token stream rather than by string
|
|
8
|
+
munging, so nested queries, CTEs, set operations and existing ``WHERE`` clauses
|
|
9
|
+
are handled correctly.
|
|
10
|
+
|
|
11
|
+
Ported from the proven ``weaver_runtime.dbrep.sql.wrangle`` reference
|
|
12
|
+
implementation; only the dependency-finding and CTAS helpers (which weaverstack
|
|
13
|
+
covers elsewhere) were dropped. :func:`render_sql_template` fills the T-SQL DDL
|
|
14
|
+
templates in ``ses/templates``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import re
|
|
22
|
+
from string import Template
|
|
23
|
+
|
|
24
|
+
import sqlparse
|
|
25
|
+
from sqlparse import tokens as T
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SQL_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_BOUNDARY_KEYWORDS = {
|
|
32
|
+
"GO",
|
|
33
|
+
"GROUP",
|
|
34
|
+
"HAVING",
|
|
35
|
+
"ORDER",
|
|
36
|
+
"UNION",
|
|
37
|
+
"EXCEPT",
|
|
38
|
+
"INTERSECT",
|
|
39
|
+
"OPTION",
|
|
40
|
+
"FOR",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_STATEMENT_START_KEYWORDS = {
|
|
44
|
+
"ALTER",
|
|
45
|
+
"CREATE",
|
|
46
|
+
"DECLARE",
|
|
47
|
+
"DELETE",
|
|
48
|
+
"DROP",
|
|
49
|
+
"EXEC",
|
|
50
|
+
"EXECUTE",
|
|
51
|
+
"IF",
|
|
52
|
+
"INSERT",
|
|
53
|
+
"MERGE",
|
|
54
|
+
"PRINT",
|
|
55
|
+
"RAISERROR",
|
|
56
|
+
"RETURN",
|
|
57
|
+
"SELECT",
|
|
58
|
+
"SET",
|
|
59
|
+
"THROW",
|
|
60
|
+
"TRUNCATE",
|
|
61
|
+
"UPDATE",
|
|
62
|
+
"USE",
|
|
63
|
+
"WAITFOR",
|
|
64
|
+
"WHILE",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _FlatToken:
|
|
70
|
+
value: str
|
|
71
|
+
normalized: str
|
|
72
|
+
ttype: object
|
|
73
|
+
start: int
|
|
74
|
+
end: int
|
|
75
|
+
depth: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class _Replacement:
|
|
80
|
+
start: int
|
|
81
|
+
end: int
|
|
82
|
+
text: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class _QuerySpan:
|
|
87
|
+
start: int
|
|
88
|
+
end: int
|
|
89
|
+
select_index: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def insert_where_one_eq_zero(sql_text: str) -> str:
|
|
93
|
+
"""Insert ``WHERE 1=0`` into every SELECT in a T-SQL string.
|
|
94
|
+
|
|
95
|
+
If a SELECT already has a WHERE clause, its existing condition is wrapped in
|
|
96
|
+
parentheses and combined with ``AND 1=0``.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
replacements = _collect_replacements(sql_text)
|
|
100
|
+
if not replacements:
|
|
101
|
+
return sql_text
|
|
102
|
+
|
|
103
|
+
result = sql_text
|
|
104
|
+
for replacement in sorted(replacements, key=lambda item: item.start, reverse=True):
|
|
105
|
+
result = (
|
|
106
|
+
result[: replacement.start] + replacement.text + result[replacement.end :]
|
|
107
|
+
)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def insert_select_into(sql_text: str, table_name: str) -> str:
|
|
112
|
+
"""Insert ``INTO <table_name>`` into the last standalone SELECT query."""
|
|
113
|
+
|
|
114
|
+
query_span = _find_last_standalone_query(sql_text)
|
|
115
|
+
if query_span is None:
|
|
116
|
+
return sql_text
|
|
117
|
+
|
|
118
|
+
tokens = _flatten_with_offsets(sql_text)
|
|
119
|
+
insert_at = _find_select_into_insert_position(tokens, query_span)
|
|
120
|
+
insert_text = _select_into_text(sql_text, insert_at, table_name)
|
|
121
|
+
return f"{sql_text[:insert_at]}{insert_text}{sql_text[insert_at:]}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_sql_template(template_name: str) -> str:
|
|
125
|
+
"""Fetch a SQL template from ``source/sql_templates``."""
|
|
126
|
+
|
|
127
|
+
template_path = _sql_template_path(template_name)
|
|
128
|
+
return template_path.read_text(encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_sql_template(template_name: str, **values: object) -> str:
|
|
132
|
+
"""Fetch and populate a SQL template with ``string.Template`` values."""
|
|
133
|
+
|
|
134
|
+
template = Template(get_sql_template(template_name))
|
|
135
|
+
return template.substitute({key: str(value) for key, value in values.items()})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sql_template_path(template_name: str) -> Path:
|
|
139
|
+
normalised_name = template_name if template_name.endswith(".sql") else f"{template_name}.sql"
|
|
140
|
+
candidate = (SQL_TEMPLATE_DIR / normalised_name).resolve()
|
|
141
|
+
template_root = SQL_TEMPLATE_DIR.resolve()
|
|
142
|
+
if template_root not in candidate.parents:
|
|
143
|
+
raise ValueError("template_name must stay within the SQL template directory")
|
|
144
|
+
if not candidate.is_file():
|
|
145
|
+
raise FileNotFoundError(f"SQL template not found: {template_name}")
|
|
146
|
+
return candidate
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _collect_replacements(sql_text: str) -> list[_Replacement]:
|
|
150
|
+
tokens = _flatten_with_offsets(sql_text)
|
|
151
|
+
replacements: list[_Replacement] = []
|
|
152
|
+
covered_ranges: list[tuple[int, int]] = []
|
|
153
|
+
|
|
154
|
+
for index, token in enumerate(tokens):
|
|
155
|
+
if not _is_select(token):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if _is_covered(token.start, covered_ranges):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
replacement = _replacement_for_select(sql_text, tokens, index)
|
|
162
|
+
if replacement is None:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
replacements = [
|
|
166
|
+
item
|
|
167
|
+
for item in replacements
|
|
168
|
+
if not (replacement.start <= item.start and item.end <= replacement.end)
|
|
169
|
+
]
|
|
170
|
+
if replacement.start != replacement.end:
|
|
171
|
+
covered_ranges.append((replacement.start, replacement.end))
|
|
172
|
+
replacements.append(replacement)
|
|
173
|
+
|
|
174
|
+
return replacements
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_last_standalone_query(sql_text: str) -> _QuerySpan | None:
|
|
178
|
+
tokens = _flatten_with_offsets(sql_text)
|
|
179
|
+
spans: list[_QuerySpan] = []
|
|
180
|
+
|
|
181
|
+
for index, token in enumerate(tokens):
|
|
182
|
+
if token.depth != 0:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if _is_select(token) and _is_standalone_select_start(tokens, index):
|
|
186
|
+
spans.append(_QuerySpan(token.start, _find_query_end(tokens, index), index))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if _keyword_head(token) == "WITH" and _is_statement_boundary_before(
|
|
190
|
+
tokens, index
|
|
191
|
+
):
|
|
192
|
+
select_index = _find_cte_body_select(tokens, index)
|
|
193
|
+
if select_index is not None:
|
|
194
|
+
spans.append(
|
|
195
|
+
_QuerySpan(
|
|
196
|
+
token.start, _find_query_end(tokens, select_index), select_index
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if not spans:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
return max(spans, key=lambda item: item.start)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _find_select_into_insert_position(
|
|
207
|
+
tokens: list[_FlatToken], query_span: _QuerySpan
|
|
208
|
+
) -> int:
|
|
209
|
+
select_token = tokens[query_span.select_index]
|
|
210
|
+
|
|
211
|
+
for index in range(query_span.select_index + 1, len(tokens)):
|
|
212
|
+
token = tokens[index]
|
|
213
|
+
if token.start >= query_span.end:
|
|
214
|
+
break
|
|
215
|
+
if token.depth != select_token.depth:
|
|
216
|
+
continue
|
|
217
|
+
if _keyword_head(token) == "FROM":
|
|
218
|
+
return _end_before_trivia(tokens, query_span.select_index + 1, index)
|
|
219
|
+
if _is_select_into_boundary(token):
|
|
220
|
+
return _end_before_trivia(tokens, query_span.select_index + 1, index)
|
|
221
|
+
|
|
222
|
+
end_index = _token_index_at_or_after(tokens, query_span.end)
|
|
223
|
+
return _end_before_trivia(tokens, query_span.select_index + 1, end_index)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _select_into_text(sql_text: str, insert_at: int, table_name: str) -> str:
|
|
227
|
+
if insert_at > 0 and sql_text[insert_at - 1] == "\n":
|
|
228
|
+
return f"into {table_name}\n"
|
|
229
|
+
next_non_space = insert_at
|
|
230
|
+
while next_non_space < len(sql_text) and sql_text[next_non_space] in " \t\r\n":
|
|
231
|
+
if sql_text[next_non_space] == "\n":
|
|
232
|
+
return f"\ninto {table_name}"
|
|
233
|
+
next_non_space += 1
|
|
234
|
+
return f" into {table_name}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_select_into_boundary(token: _FlatToken) -> bool:
|
|
238
|
+
keyword = _keyword_head(token)
|
|
239
|
+
return token.value == ";" or keyword in {
|
|
240
|
+
"WHERE",
|
|
241
|
+
"GROUP",
|
|
242
|
+
"HAVING",
|
|
243
|
+
"ORDER",
|
|
244
|
+
"UNION",
|
|
245
|
+
"EXCEPT",
|
|
246
|
+
"INTERSECT",
|
|
247
|
+
"OPTION",
|
|
248
|
+
"FOR",
|
|
249
|
+
"GO",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _token_index_at_or_after(tokens: list[_FlatToken], position: int) -> int:
|
|
254
|
+
for index, token in enumerate(tokens):
|
|
255
|
+
if token.start >= position:
|
|
256
|
+
return index
|
|
257
|
+
return len(tokens)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_cte_body_select(tokens: list[_FlatToken], with_index: int) -> int | None:
|
|
261
|
+
for index in range(with_index + 1, len(tokens)):
|
|
262
|
+
token = tokens[index]
|
|
263
|
+
if token.depth != 0:
|
|
264
|
+
continue
|
|
265
|
+
if _is_select(token):
|
|
266
|
+
return index
|
|
267
|
+
if token.value == ";" or _keyword_head(token) == "GO":
|
|
268
|
+
return None
|
|
269
|
+
if _is_statement_starter(token) and _keyword_head(token) not in {"WITH", "SELECT"}:
|
|
270
|
+
return None
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _find_query_end(tokens: list[_FlatToken], select_index: int) -> int:
|
|
275
|
+
select_token = tokens[select_index]
|
|
276
|
+
|
|
277
|
+
for index in range(select_index + 1, len(tokens)):
|
|
278
|
+
token = tokens[index]
|
|
279
|
+
if token.depth < select_token.depth:
|
|
280
|
+
return token.start
|
|
281
|
+
if token.depth != select_token.depth:
|
|
282
|
+
continue
|
|
283
|
+
if token.value == ";" or _keyword_head(token) == "GO":
|
|
284
|
+
return token.end if token.value == ";" else _end_before_trivia(tokens, select_index, index)
|
|
285
|
+
if _is_statement_starter(token) and not _is_set_operator_select(tokens, index):
|
|
286
|
+
return _end_before_trivia(tokens, select_index, index)
|
|
287
|
+
|
|
288
|
+
return _end_before_trivia(tokens, select_index, len(tokens))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_standalone_select_start(tokens: list[_FlatToken], index: int) -> bool:
|
|
292
|
+
if _is_set_operator_select(tokens, index):
|
|
293
|
+
return False
|
|
294
|
+
if _has_top_level_with_since_boundary(tokens, index):
|
|
295
|
+
return False
|
|
296
|
+
if _is_statement_boundary_before(tokens, index):
|
|
297
|
+
return True
|
|
298
|
+
if not _starts_new_line(tokens, index):
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
starter = _last_statement_starter_since_boundary(tokens, index)
|
|
302
|
+
if starter is None:
|
|
303
|
+
return True
|
|
304
|
+
if _keyword_head(tokens[starter]) == "INSERT":
|
|
305
|
+
return _has_top_level_select_between(tokens, starter + 1, index)
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_set_operator_select(tokens: list[_FlatToken], index: int) -> bool:
|
|
310
|
+
previous = _previous_significant_index(tokens, index)
|
|
311
|
+
if previous is None:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
previous_keyword = _keyword_head(tokens[previous])
|
|
315
|
+
if previous_keyword in {"UNION", "EXCEPT", "INTERSECT"}:
|
|
316
|
+
return True
|
|
317
|
+
if previous_keyword == "ALL":
|
|
318
|
+
before_all = _previous_significant_index(tokens, previous)
|
|
319
|
+
return before_all is not None and _keyword_head(tokens[before_all]) in {
|
|
320
|
+
"UNION",
|
|
321
|
+
"EXCEPT",
|
|
322
|
+
"INTERSECT",
|
|
323
|
+
}
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _is_statement_boundary_before(tokens: list[_FlatToken], index: int) -> bool:
|
|
328
|
+
previous = _previous_significant_index(tokens, index)
|
|
329
|
+
if previous is None:
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
previous_token = tokens[previous]
|
|
333
|
+
return previous_token.value == ";" or _keyword_head(previous_token) == "GO"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _starts_new_line(tokens: list[_FlatToken], index: int) -> bool:
|
|
337
|
+
for previous in range(index - 1, -1, -1):
|
|
338
|
+
token = tokens[previous]
|
|
339
|
+
if token.depth != tokens[index].depth:
|
|
340
|
+
continue
|
|
341
|
+
if "\n" in token.value:
|
|
342
|
+
return True
|
|
343
|
+
if not _is_trivia(token):
|
|
344
|
+
return False
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _last_statement_starter_since_boundary(
|
|
349
|
+
tokens: list[_FlatToken], index: int
|
|
350
|
+
) -> int | None:
|
|
351
|
+
for previous in range(index - 1, -1, -1):
|
|
352
|
+
token = tokens[previous]
|
|
353
|
+
if token.depth != tokens[index].depth or _is_trivia(token):
|
|
354
|
+
continue
|
|
355
|
+
if token.value == ";" or _keyword_head(token) == "GO":
|
|
356
|
+
return None
|
|
357
|
+
if _is_statement_starter(token):
|
|
358
|
+
return previous
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _has_top_level_select_between(
|
|
363
|
+
tokens: list[_FlatToken], start: int, end: int
|
|
364
|
+
) -> bool:
|
|
365
|
+
return any(token.depth == 0 and _is_select(token) for token in tokens[start:end])
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _has_top_level_with_since_boundary(tokens: list[_FlatToken], index: int) -> bool:
|
|
369
|
+
for previous in range(index - 1, -1, -1):
|
|
370
|
+
token = tokens[previous]
|
|
371
|
+
if token.depth != tokens[index].depth or _is_trivia(token):
|
|
372
|
+
continue
|
|
373
|
+
if token.value == ";" or _keyword_head(token) == "GO":
|
|
374
|
+
return False
|
|
375
|
+
if _keyword_head(token) == "WITH":
|
|
376
|
+
return True
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _replacement_for_select(
|
|
381
|
+
sql_text: str, tokens: list[_FlatToken], select_index: int
|
|
382
|
+
) -> _Replacement | None:
|
|
383
|
+
select_token = tokens[select_index]
|
|
384
|
+
scope_end_index = _find_scope_end(tokens, select_index)
|
|
385
|
+
where_index = _find_where(tokens, select_index + 1, scope_end_index, select_token.depth)
|
|
386
|
+
|
|
387
|
+
if where_index is None:
|
|
388
|
+
insert_at = _find_insert_position(
|
|
389
|
+
tokens, select_index + 1, scope_end_index, select_token.depth
|
|
390
|
+
)
|
|
391
|
+
return _Replacement(insert_at, insert_at, " where 1=0")
|
|
392
|
+
|
|
393
|
+
condition_start = tokens[where_index].end
|
|
394
|
+
condition_end = _find_condition_end(
|
|
395
|
+
tokens, where_index + 1, scope_end_index, select_token.depth
|
|
396
|
+
)
|
|
397
|
+
condition = sql_text[condition_start:condition_end].strip()
|
|
398
|
+
transformed_condition = insert_where_one_eq_zero(condition) if condition else condition
|
|
399
|
+
return _Replacement(
|
|
400
|
+
condition_start,
|
|
401
|
+
condition_end,
|
|
402
|
+
f" ({transformed_condition}) and 1=0",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _flatten_with_offsets(sql_text: str) -> list[_FlatToken]:
|
|
407
|
+
flat: list[_FlatToken] = []
|
|
408
|
+
offset = 0
|
|
409
|
+
depth = 0
|
|
410
|
+
|
|
411
|
+
for statement in sqlparse.parse(sql_text):
|
|
412
|
+
for token in statement.flatten():
|
|
413
|
+
value = token.value
|
|
414
|
+
token_depth = depth
|
|
415
|
+
if value == ")":
|
|
416
|
+
depth = max(0, depth - 1)
|
|
417
|
+
token_depth = depth
|
|
418
|
+
|
|
419
|
+
flat.append(
|
|
420
|
+
_FlatToken(
|
|
421
|
+
value=value,
|
|
422
|
+
normalized=token.normalized.upper(),
|
|
423
|
+
ttype=token.ttype,
|
|
424
|
+
start=offset,
|
|
425
|
+
end=offset + len(value),
|
|
426
|
+
depth=token_depth,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
offset += len(value)
|
|
430
|
+
|
|
431
|
+
if value == "(":
|
|
432
|
+
depth += 1
|
|
433
|
+
|
|
434
|
+
return flat
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _find_scope_end(tokens: list[_FlatToken], select_index: int) -> int:
|
|
438
|
+
select_token = tokens[select_index]
|
|
439
|
+
|
|
440
|
+
for index in range(select_index + 1, len(tokens)):
|
|
441
|
+
token = tokens[index]
|
|
442
|
+
if token.depth < select_token.depth:
|
|
443
|
+
return index
|
|
444
|
+
if token.depth == select_token.depth and _is_scope_terminator(token):
|
|
445
|
+
return index
|
|
446
|
+
|
|
447
|
+
return len(tokens)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _find_where(
|
|
451
|
+
tokens: list[_FlatToken], start: int, end: int, depth: int
|
|
452
|
+
) -> int | None:
|
|
453
|
+
for index in range(start, end):
|
|
454
|
+
token = tokens[index]
|
|
455
|
+
if token.depth == depth and token.normalized == "WHERE":
|
|
456
|
+
return index
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _find_insert_position(
|
|
461
|
+
tokens: list[_FlatToken], start: int, end: int, depth: int
|
|
462
|
+
) -> int:
|
|
463
|
+
for index in range(start, end):
|
|
464
|
+
token = tokens[index]
|
|
465
|
+
if token.depth == depth and _is_boundary(token):
|
|
466
|
+
return _end_before_trivia(tokens, start, index)
|
|
467
|
+
|
|
468
|
+
if end < len(tokens):
|
|
469
|
+
return _end_before_trivia(tokens, start, end)
|
|
470
|
+
if tokens:
|
|
471
|
+
return _end_before_trivia(tokens, start, len(tokens))
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _find_condition_end(
|
|
476
|
+
tokens: list[_FlatToken], start: int, end: int, depth: int
|
|
477
|
+
) -> int:
|
|
478
|
+
for index in range(start, end):
|
|
479
|
+
token = tokens[index]
|
|
480
|
+
if token.depth == depth and _is_boundary(token):
|
|
481
|
+
return _end_before_trivia(tokens, start, index)
|
|
482
|
+
|
|
483
|
+
if end < len(tokens):
|
|
484
|
+
return _end_before_trivia(tokens, start, end)
|
|
485
|
+
if tokens:
|
|
486
|
+
return _end_before_trivia(tokens, start, len(tokens))
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _end_before_trivia(tokens: list[_FlatToken], start: int, end: int) -> int:
|
|
491
|
+
index = end - 1
|
|
492
|
+
while index >= start and tokens[index].ttype in T.Whitespace:
|
|
493
|
+
index -= 1
|
|
494
|
+
if index >= start:
|
|
495
|
+
return tokens[index].end
|
|
496
|
+
return tokens[start].start if start < len(tokens) else 0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_select(token: _FlatToken) -> bool:
|
|
500
|
+
return token.ttype is T.DML and token.normalized == "SELECT"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _is_boundary(token: _FlatToken) -> bool:
|
|
504
|
+
return token.value == ";" or _keyword_head(token) in _BOUNDARY_KEYWORDS
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _is_scope_terminator(token: _FlatToken) -> bool:
|
|
508
|
+
if token.value == ";":
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
keyword = _keyword_head(token)
|
|
512
|
+
return keyword in {"GO", "UNION", "EXCEPT", "INTERSECT"} or (
|
|
513
|
+
_is_statement_starter(token) and keyword != "SELECT"
|
|
514
|
+
) or _is_select(token)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _is_statement_starter(token: _FlatToken) -> bool:
|
|
518
|
+
return _keyword_head(token) in _STATEMENT_START_KEYWORDS
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _keyword_head(token: _FlatToken) -> str:
|
|
522
|
+
parts = token.normalized.split(maxsplit=1)
|
|
523
|
+
return parts[0] if parts else ""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _previous_significant_index(
|
|
527
|
+
tokens: list[_FlatToken], index: int, depth: int = 0
|
|
528
|
+
) -> int | None:
|
|
529
|
+
for previous in range(index - 1, -1, -1):
|
|
530
|
+
token = tokens[previous]
|
|
531
|
+
if token.depth != depth or _is_trivia(token):
|
|
532
|
+
continue
|
|
533
|
+
return previous
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
def _is_trivia(token: _FlatToken) -> bool:
|
|
537
|
+
return token.ttype in T.Whitespace or token.ttype in T.Comment
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _is_covered(position: int, ranges: list[tuple[int, int]]) -> bool:
|
|
541
|
+
return any(start <= position < end for start, end in ranges)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def split_trailing_query(sql_text: str) -> tuple[str, str]:
|
|
545
|
+
"""Split a body into everything before its final query, and that query.
|
|
546
|
+
|
|
547
|
+
A Weaver object's body is not always one statement. An author may set a
|
|
548
|
+
temporary view up first and select from it, and that shape is supported —
|
|
549
|
+
the repository's own fixtures use it. So a load cannot wrap the whole body
|
|
550
|
+
in a subquery to stage it: only the *last standalone query* produces the
|
|
551
|
+
rows, and whatever precedes it has to run first, on its own.
|
|
552
|
+
|
|
553
|
+
This is the same span the T-SQL build uses to place its ``INTO``
|
|
554
|
+
(:func:`insert_select_into`), reused rather than re-derived so build and
|
|
555
|
+
load agree about which part of a body is the query.
|
|
556
|
+
|
|
557
|
+
Returns ``(preamble, query)``. ``preamble`` is empty for the ordinary
|
|
558
|
+
single-statement body.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
span = _find_last_standalone_query(sql_text)
|
|
562
|
+
if span is None:
|
|
563
|
+
return "", sql_text.strip()
|
|
564
|
+
preamble = sql_text[: span.start].strip().rstrip(";").strip()
|
|
565
|
+
query = sql_text[span.start : span.end].strip().rstrip(";").strip()
|
|
566
|
+
return preamble, query
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def split_statements(sql_text: str) -> tuple[str, ...]:
|
|
570
|
+
"""One body's top-level statements, split on semicolons that separate them.
|
|
571
|
+
|
|
572
|
+
Depth-aware, so a semicolon inside parentheses or a string literal does not
|
|
573
|
+
cut a statement in half — which is exactly why a caller must not do this
|
|
574
|
+
with ``str.split(';')``.
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
text = sql_text.strip()
|
|
578
|
+
if not text:
|
|
579
|
+
return ()
|
|
580
|
+
statements: list[str] = []
|
|
581
|
+
start = 0
|
|
582
|
+
for token in _flatten_with_offsets(text):
|
|
583
|
+
if token.depth == 0 and token.value.strip() == ";":
|
|
584
|
+
piece = text[start : token.start].strip()
|
|
585
|
+
if piece:
|
|
586
|
+
statements.append(piece)
|
|
587
|
+
start = token.end
|
|
588
|
+
tail = text[start:].strip()
|
|
589
|
+
if tail:
|
|
590
|
+
statements.append(tail)
|
|
591
|
+
return tuple(statements)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
declare @weaver_column_error nvarchar(2048);
|
|
2
|
+
|
|
3
|
+
if not exists (
|
|
4
|
+
select 1
|
|
5
|
+
from tempdb.sys.columns as c
|
|
6
|
+
where c.[object_id] = object_id($temp_object_literal)
|
|
7
|
+
)
|
|
8
|
+
begin
|
|
9
|
+
throw 51001, 'weaver found no temp table columns to create.', 1;
|
|
10
|
+
end;
|
|
11
|
+
|
|
12
|
+
$metadata_validation_sql
|
|
13
|
+
|
|
14
|
+
;with described as (
|
|
15
|
+
select
|
|
16
|
+
coalesce(nullif(c.name, ''), concat('Column', c.column_id)) as column_name
|
|
17
|
+
from tempdb.sys.columns as c
|
|
18
|
+
where c.[object_id] = object_id($temp_object_literal)
|
|
19
|
+
),
|
|
20
|
+
declared_columns as (
|
|
21
|
+
$declared_columns_cte
|
|
22
|
+
)
|
|
23
|
+
select top (1)
|
|
24
|
+
@weaver_column_error = mismatch.message
|
|
25
|
+
from (
|
|
26
|
+
-- A declared column the query does not return, under the same case.
|
|
27
|
+
select
|
|
28
|
+
1 as ordinal,
|
|
29
|
+
dc.column_name,
|
|
30
|
+
concat(N'declared column ', dc.column_name, N' is not returned by the query') as message
|
|
31
|
+
from declared_columns as dc
|
|
32
|
+
where not exists (
|
|
33
|
+
select 1
|
|
34
|
+
from described as d
|
|
35
|
+
where d.column_name = dc.column_name collate Latin1_General_BIN2
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
union all
|
|
39
|
+
|
|
40
|
+
-- A query column not present in the declared schema, under the same case.
|
|
41
|
+
select
|
|
42
|
+
2 as ordinal,
|
|
43
|
+
d.column_name,
|
|
44
|
+
concat(N'query column ', d.column_name, N' is not in the declared schema') as message
|
|
45
|
+
from described as d
|
|
46
|
+
where not exists (
|
|
47
|
+
select 1
|
|
48
|
+
from declared_columns as dc
|
|
49
|
+
where dc.column_name = d.column_name collate Latin1_General_BIN2
|
|
50
|
+
)
|
|
51
|
+
) as mismatch
|
|
52
|
+
order by
|
|
53
|
+
mismatch.ordinal
|
|
54
|
+
, mismatch.column_name;
|
|
55
|
+
|
|
56
|
+
if @weaver_column_error is not null
|
|
57
|
+
begin
|
|
58
|
+
throw 51005, @weaver_column_error, 1;
|
|
59
|
+
end;
|
|
60
|
+
|
|
61
|
+
create table $target_table (
|
|
62
|
+
$declared_column_definitions
|
|
63
|
+
);
|
|
64
|
+
$pk_alter_sql
|