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.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. 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