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,759 @@
1
+ """Spark SQL load generation — one runnable program per Spark SQL table.
2
+
3
+ The Warehouse counterpart is a stored procedure: it has parameters, variables
4
+ and ``if``. Spark SQL has none of those, so the same algorithm has to be written
5
+ without control flow, and that constraint shapes everything here.
6
+
7
+ Three consequences, each deliberate:
8
+
9
+ **The program is an ordered list of statements, not one statement.** Spark
10
+ executes one statement per ``spark.sql`` call, so the file is delimited and run
11
+ in order — the same shape the ``spark_sql_batch`` executor already installs
12
+ with. The last statement projects the result row.
13
+
14
+ **The counts are measured, not accumulated.** A procedure adds up ``@@rowcount``
15
+ as it goes; nothing here can hold a running total, so every count is a query
16
+ against the staged data taken *before* the writes, and materialised into a small
17
+ result table the final statement reads. Measuring first is also what makes the
18
+ counts describe the same instant as the decision they justify.
19
+
20
+ **Fault tolerance is a predicate, not a branch.** A commented literal reading
21
+ 0 is substituted with 1 to tolerate rejects, and the valid-rows view is gated on
22
+ it: with rejects present and no tolerance the view is empty, so the merge and
23
+ the delete run against nothing and the target is untouched. An ``if`` that
24
+ Spark does not have becomes a ``where`` that it does — and the statement list
25
+ stays the same length either way, which is what keeps the program readable as
26
+ one thing rather than two.
27
+
28
+ Object names are ``{{object:Schema.Object}}`` tokens for the same reason the
29
+ build payloads are: a bundle must be destination-free so the same repository
30
+ generates the same bytes everywhere. The installed *file* is addressed, because
31
+ by then the destination is known — see
32
+ :class:`weaver.build_bundle.executors.load_file.LoadFileExecutor`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from ..runtime.load_contract import LoadContract
38
+ from ..runtime.load_result import RESULT_COLUMNS
39
+ from ..spark.tokens import object_token
40
+ from .dependencies import rewrite_sql_references
41
+ from .sql_shaping import split_statements, split_trailing_query
42
+ from .metadata import (
43
+ AUDIT_COLUMNS,
44
+ AUDIT_LIVE_DELETE_DATETIME,
45
+ PYTHON,
46
+ SesDocument,
47
+ audit_column_name,
48
+ )
49
+
50
+ #: What separates one statement from the next in an installed program. A bare
51
+ #: ``;`` will not do: a statement may legitimately contain one inside a string
52
+ #: literal, and splitting on it would cut a program in half at the worst
53
+ #: possible moment. This marker is a SQL comment, so the file also stays valid
54
+ #: to paste into a notebook whole.
55
+ STATEMENT_DELIMITER = "-- weaver:statement"
56
+
57
+ #: The first line of every generated program. It is what the installer keys its
58
+ #: token expansion on: a generated load keeps its *authored* filename, so the
59
+ #: file cannot be recognised by its name — only by what it says it is.
60
+ GENERATED_LOAD_MARKER = "-- Weaver generated load"
61
+
62
+ #: What a bundle carries for a Spark SQL load: an *instruction*, not the
63
+ #: finished program. The columns a load writes are the built table's, and a
64
+ #: Spark SQL table may infer its schema at build — so the program cannot be
65
+ #: completed until the table exists. The installer reads the columns and renders
66
+ #: the file into place, which is exactly what the Warehouse installer does with
67
+ #: sys.columns rather than guessing at generation time.
68
+ GENERATED_LOAD_INSTRUCTION = "weaver:generated-load"
69
+
70
+ #: The one hole the installer does *not* fill. Tolerance of rejects is a run's
71
+ #: own choice rather than anything about where the object lives, so it is
72
+ #: answered by whoever runs the program.
73
+ #:
74
+ #: Deliberately not a ``{{...}}`` token: that namespace belongs to the
75
+ #: installer's destination resolution, which refuses any token it does not
76
+ #: itself resolve — correctly, since a name left unresolved must never reach the
77
+ #: engine. This is a comment wrapped around a literal instead, so an installed
78
+ #: program is valid SQL with nothing substituted at all, and what it does then
79
+ #: is refuse — the safe answer for anyone who ran the file without choosing.
80
+ FAULT_TOLERANT_MARKER = "/*weaver:fault_tolerant*/"
81
+ FAULT_TOLERANT_DEFAULT = f"{FAULT_TOLERANT_MARKER}0"
82
+
83
+ #: The second answer a run gives, in the same comment-wrapped form and for the
84
+ #: same reason: an installed program is valid SQL with nothing substituted, and
85
+ #: what it does then is enforce the declared thresholds.
86
+ IGNORE_THRESHOLD_MARKER = "/*weaver:ignore_stability_threshold*/"
87
+ IGNORE_THRESHOLD_DEFAULT = f"{IGNORE_THRESHOLD_MARKER}0"
88
+
89
+ #: The rank a duplicate key gets, and the suffixes of the intermediate relations.
90
+ RANK_COLUMN = "__weaver_pk_row_number"
91
+ STAGING_SUFFIX = "_Staging"
92
+ REJECT_SUFFIX = "_Reject"
93
+ UPSERT_SUFFIX = "_Upsert"
94
+ RESULT_SUFFIX = "_LoadResult"
95
+ DELETE_SUFFIX = "_Delete"
96
+
97
+ #: What marks a row of the upsert set as new rather than merely changed. The
98
+ #: same column the Warehouse procedure and the Python load use, so one query
99
+ #: reads a change set whichever engine produced it.
100
+ IS_NEW_COLUMN = "_Is new row"
101
+
102
+ #: Re-exported from the runtime so the generators and the Python loads write one
103
+ #: vocabulary. A reject table is read by people, and a Warehouse reject that said
104
+ #: "null primary key" beside a Delta one that said "blank_primary_key" would make
105
+ #: the same refusal look like two different problems.
106
+ from ..runtime.load_contract import ( # noqa: E402
107
+ REASON_BLANK_PK,
108
+ REASON_DUPLICATE_PK,
109
+ REJECTION_REASON,
110
+ )
111
+
112
+ #: Every table this program creates carries Delta column mapping, for the same
113
+ #: reason :func:`weaver.declaration.ddl._create_table_sql` does: a declared
114
+ #: column name may contain spaces, and Delta refuses those in a physical schema
115
+ #: unless mapping is on. Staging carries the author's own columns forward, so a
116
+ #: table created without it fails on exactly the declarations Weaver permits.
117
+ COLUMN_MAPPING = "TBLPROPERTIES ('delta.columnMapping.mode' = 'name')"
118
+
119
+ #: Banners marking where the author's own code sits in the generated program.
120
+ #: A generated artefact is read by people — usually when something has gone
121
+ #: wrong — and the first question is always "which of this did I write?".
122
+ PREPROCESSING_BANNER = "-- Pre-processing"
123
+ TRANSFORMATION_BANNER = "-- Data transformation (authored)"
124
+ POSTPROCESSING_BANNER = "-- Post-processing"
125
+
126
+ INTOLERANT_MESSAGE = (
127
+ "rows were rejected and fault_tolerant = 0, so the target was not modified"
128
+ )
129
+ TOLERATED_MESSAGE = "rows were rejected and excluded from the load"
130
+
131
+
132
+ def generate_spark_load_program(
133
+ document: SesDocument, body: str, *, columns: tuple[str, ...]
134
+ ) -> str:
135
+ """The runnable Spark SQL program that loads one table.
136
+
137
+ ``columns`` are the target's own business columns, read off the built table
138
+ rather than guessed from the declaration — the same two-phase shape
139
+ :mod:`weaver.declaration.tsql_load` uses, and for the same reason. A Spark
140
+ SQL table may infer its schema at build, so its columns are only knowable
141
+ once the table exists; and even when declared, the physical table is what
142
+ the program has to name.
143
+ """
144
+
145
+ contract = LoadContract.from_document(document)
146
+ names = _names(document)
147
+ addressed = _addressed(body.strip().rstrip(";"))
148
+
149
+ if contract.primary_key:
150
+ statements = _keyed_program(names, addressed, contract, columns)
151
+ else:
152
+ statements = _full_replace_program(names, addressed, contract, columns)
153
+
154
+ # The header must not quote the delimiter. It is a comment, but the splitter
155
+ # looks for the marker anywhere, so a header that spelled it out would be
156
+ # cut in half and its first line offered to Spark as a statement.
157
+ header = (
158
+ f"{GENERATED_LOAD_MARKER} for {document.qualified}.\n"
159
+ f"-- Statements run in order, separated by the marker below.\n"
160
+ f"-- Substitute {FAULT_TOLERANT_MARKER}0 with {FAULT_TOLERANT_MARKER}1 to load "
161
+ "valid rows despite rejects. Unsubstituted, it refuses.\n"
162
+ )
163
+ joined = f"\n\n{STATEMENT_DELIMITER}\n\n".join(
164
+ statement.strip() for statement in statements
165
+ )
166
+ # A delimiter after the header, so the header is a chunk of its own and the
167
+ # splitter drops it. Without one it rides along with the first statement and
168
+ # is re-sent to the engine on every run.
169
+ return f"{header}\n{STATEMENT_DELIMITER}\n\n{joined}\n"
170
+
171
+
172
+ def statements_of(program: str) -> tuple[str, ...]:
173
+ """Split an installed program back into the statements it is made of.
174
+
175
+ The inverse of the join above, and the only supported way to read one: a
176
+ caller that split on ``;`` would eventually cut through a string literal.
177
+ """
178
+
179
+ parts = []
180
+ for chunk in program.split(STATEMENT_DELIMITER):
181
+ statement = chunk.strip()
182
+ # A chunk of nothing but comments is the file's header, or the tail of a
183
+ # marker line — never something to hand to Spark, which would reject it
184
+ # as a syntax error at end of input.
185
+ if statement and not _is_all_comment(statement):
186
+ parts.append(statement)
187
+ return tuple(parts)
188
+
189
+
190
+ def _is_all_comment(statement: str) -> bool:
191
+ return all(
192
+ not line.strip() or line.lstrip().startswith("--")
193
+ for line in statement.splitlines()
194
+ )
195
+
196
+
197
+ # --- the two programs --------------------------------------------------------
198
+
199
+
200
+ def _keyed_program(names, body, contract, business) -> list[str]:
201
+ preamble, query = split_trailing_query(body)
202
+ audit = delta_audit_names()
203
+ blank = blank_key_predicate(contract.primary_key)
204
+ rejected = f"({blank} OR s.`{RANK_COLUMN}` > 1)"
205
+
206
+ statements = [
207
+ f"{PREPROCESSING_BANNER}\nDROP TABLE IF EXISTS {names['reject']}",
208
+ f"DROP TABLE IF EXISTS {names['upsert']}",
209
+ f"DROP TABLE IF EXISTS {names['staging']}",
210
+ f"DROP TABLE IF EXISTS {names['result']}",
211
+ # Only when this program creates one. Dropping a table it never makes
212
+ # would leave a reader looking for the statement that creates it.
213
+ *(
214
+ [f"DROP TABLE IF EXISTS {names['delete']}"]
215
+ if contract.deletes_absent_rows
216
+ else []
217
+ ),
218
+ # The authored preamble, as written. A body may set a temporary view up
219
+ # before selecting from it, and only the trailing query fills staging —
220
+ # wrapping the whole body in a subquery would put a CREATE inside a FROM.
221
+ *(f"{TRANSFORMATION_BANNER}\n{statement}" for statement in split_statements(preamble)),
222
+ # Staging is a real table, not a view: the source query must run once,
223
+ # and a view would re-run it for every count and again for the merge.
224
+ f"{TRANSFORMATION_BANNER}\n"
225
+ f"CREATE TABLE {names['staging']} USING delta {COLUMN_MAPPING} AS\n"
226
+ f"SELECT\n s.*\n"
227
+ f" , row_number() OVER (\n"
228
+ f" PARTITION BY {_columns('s', contract.primary_key)}\n"
229
+ f" ORDER BY (SELECT NULL)\n"
230
+ f" ) AS `{RANK_COLUMN}`\n"
231
+ f"FROM (\n{_indent(query, 4)}\n) AS s",
232
+ f"{POSTPROCESSING_BANNER}\n"
233
+ f"CREATE TABLE {names['reject']} USING delta {COLUMN_MAPPING} AS\n"
234
+ f"SELECT\n {_columns('s', business)}\n"
235
+ f" , CASE WHEN {blank} THEN '{REASON_BLANK_PK}'\n"
236
+ f" ELSE '{REASON_DUPLICATE_PK}' END AS `{REJECTION_REASON}`\n"
237
+ f"FROM {names['staging']} AS s\n"
238
+ f"WHERE {rejected}",
239
+ # The gate. With rejects present and no tolerance this view is empty, so
240
+ # every write below it touches nothing and the target is left exactly as
241
+ # it was — the branch a procedure would take, written as a predicate.
242
+ f"CREATE OR REPLACE TEMP VIEW {names['valid']} AS\n"
243
+ f"SELECT s.*\nFROM {names['staging']} AS s\n"
244
+ f"WHERE NOT {rejected}\n"
245
+ f" AND {_tolerated(names)}",
246
+ _upsert_table(names, contract, business),
247
+ # After the upsert set, which it counts, and before any write — so the
248
+ # counts and the changes they describe are one decision.
249
+ _result_table(names, contract, rejected),
250
+ # The stability gate, narrowing the upsert set the writes read. With a
251
+ # breach and no tolerance this is empty, so the merge below touches
252
+ # nothing and the target is left exactly as it was — the `if` a
253
+ # procedure would use, written as a `where`.
254
+ f"CREATE OR REPLACE TEMP VIEW {names['permitted']} AS\n"
255
+ f"SELECT u.*\nFROM {names['upsert']} AS u\n"
256
+ f"WHERE {_permitted(names)}",
257
+ _merge(names, contract, business, audit),
258
+ ]
259
+ if contract.deletes_absent_rows:
260
+ statements.extend(_delete_absent(names, contract))
261
+ statements.append(_guard(names))
262
+ statements.append(_final_select(names))
263
+ return statements
264
+
265
+
266
+ def _full_replace_program(names, body, contract, business) -> list[str]:
267
+ """No key, so no match, no update and nothing to reject.
268
+
269
+ The target's contents become the source's. There is no reject table at all
270
+ here — rejection is a statement about keys, and there are none.
271
+ """
272
+
273
+ audit = delta_audit_names()
274
+ columns = ", ".join(f"`{name}`" for name in business)
275
+ preamble, query = split_trailing_query(body)
276
+ return [
277
+ f"{PREPROCESSING_BANNER}\nDROP TABLE IF EXISTS {names['staging']}",
278
+ f"DROP TABLE IF EXISTS {names['result']}",
279
+ *(f"{TRANSFORMATION_BANNER}\n{statement}" for statement in split_statements(preamble)),
280
+ f"{TRANSFORMATION_BANNER}\n"
281
+ f"CREATE TABLE {names['staging']} USING delta {COLUMN_MAPPING} AS\n{query}",
282
+ f"{POSTPROCESSING_BANNER}\n"
283
+ f"CREATE TABLE {names['result']} USING delta {COLUMN_MAPPING} AS\n"
284
+ f"SELECT\n"
285
+ f" (SELECT count(*) FROM {names['staging']}) AS rows_read\n"
286
+ f" , (SELECT count(*) FROM {names['staging']}) AS rows_inserted\n"
287
+ f" , CAST(0 AS BIGINT) AS rows_updated\n"
288
+ f" , (SELECT count(*) FROM {names['target']}) AS rows_deleted\n"
289
+ f" , CAST(0 AS BIGINT) AS rows_rejected",
290
+ f"DELETE FROM {names['target']}",
291
+ f"INSERT INTO {names['target']} ({columns}, {_audit_list(audit)})\n"
292
+ f"SELECT {columns}, {_audit_values(audit)}\n"
293
+ f"FROM {names['staging']}",
294
+ _final_select(names),
295
+ ]
296
+
297
+
298
+ # --- statements --------------------------------------------------------------
299
+
300
+
301
+ def _result_table(names: dict, contract: LoadContract, rejected: str) -> str:
302
+ """Every count, measured against the staged rows before any write.
303
+
304
+ ``rows_inserted`` and ``rows_updated`` are what the merge *will* do, counted
305
+ from the same predicates the merge uses, and ``rows_deleted`` likewise. A
306
+ Delta merge does report its own metrics, but only through the table history,
307
+ which would make reading them a second round trip against state that a
308
+ concurrent write could have moved on.
309
+ """
310
+
311
+ valid = f"(SELECT * FROM {names['staging']} AS s WHERE NOT {rejected})"
312
+ join = key_join("v", "t", contract.primary_key)
313
+ tolerated = _tolerated(names)
314
+ deleted = (
315
+ f" , CASE WHEN {tolerated} THEN (\n"
316
+ f" SELECT count(*) FROM {names['target']} AS t\n"
317
+ f" WHERE NOT EXISTS (SELECT 1 FROM {valid} AS v WHERE {join})\n"
318
+ f" ) ELSE 0 END AS proposed_deleted\n"
319
+ if contract.deletes_absent_rows
320
+ else " , CAST(0 AS BIGINT) AS proposed_deleted\n"
321
+ )
322
+ return (
323
+ f"CREATE TABLE {names['result']} USING delta {COLUMN_MAPPING} AS\n"
324
+ f"SELECT *\n"
325
+ f" , {_outcome_columns()}\n"
326
+ f"FROM (\n"
327
+ f" SELECT *\n"
328
+ f" , {_threshold_predicate(names, contract)} AS within_thresholds\n"
329
+ f" FROM (\n"
330
+ f" SELECT\n"
331
+ f" (SELECT count(*) FROM {names['staging']}) AS rows_read\n"
332
+ f" , (SELECT count(*) FROM {names['target']}) AS target_rows\n"
333
+ f" , (SELECT count(*) FROM {names['upsert']} "
334
+ f"WHERE `{IS_NEW_COLUMN}` = 1) AS proposed_inserted\n"
335
+ f" , (SELECT count(*) FROM {names['upsert']} "
336
+ f"WHERE `{IS_NEW_COLUMN}` = 0) AS proposed_updated\n"
337
+ f"{deleted}"
338
+ f" , (SELECT count(*) FROM {names['reject']}) AS rows_rejected\n"
339
+ f" ) AS proposed\n"
340
+ f") AS decided"
341
+ )
342
+
343
+
344
+ def _outcome_columns() -> str:
345
+ """``succeeded`` and ``error_message``, derived where every reader sees them.
346
+
347
+ Both are known before a single row moves, so they are settled with the
348
+ counts rather than at the end — which is what lets the guard that raises and
349
+ the row that reports read one decision.
350
+ """
351
+
352
+ return (
353
+ f" (rows_rejected = 0 AND within_thresholds) AS succeeded\n"
354
+ f" , CASE\n"
355
+ f" WHEN NOT within_thresholds THEN\n"
356
+ f" concat('the proposed change is over this object''s stability "
357
+ f"thresholds: ',\n"
358
+ f" proposed_deleted, ' deletes and ', proposed_updated,\n"
359
+ f" ' updates against ', target_rows, ' rows; "
360
+ f"the target was not modified')\n"
361
+ f" WHEN rows_rejected = 0 THEN CAST(NULL AS STRING)\n"
362
+ f" WHEN {FAULT_TOLERANT_DEFAULT} = 0 THEN '{INTOLERANT_MESSAGE}'\n"
363
+ f" ELSE '{TOLERATED_MESSAGE}'\n"
364
+ f" END AS error_message"
365
+ )
366
+
367
+
368
+ def _guard(names: dict) -> str:
369
+ """Raise when the run failed and was not asked to tolerate it.
370
+
371
+ Native, because ``exec [_].[Load S.N]`` and ``.load()`` must fail the same
372
+ way — a primitive that returned a quiet row where its sibling raised would
373
+ make every caller special-case which one it was talking to.
374
+
375
+ Safe at the end: both failing cases empty the relations the writes read, so
376
+ nothing has been written by the time this runs.
377
+ """
378
+
379
+ return (
380
+ f"{POSTPROCESSING_BANNER}\n"
381
+ f"SELECT CASE\n"
382
+ f" WHEN succeeded THEN 'ok'\n"
383
+ f" WHEN {FAULT_TOLERANT_DEFAULT} = 1 THEN 'reported'\n"
384
+ f" ELSE raise_error(error_message)\n"
385
+ f" END AS guard\n"
386
+ f"FROM {names['result']}"
387
+ )
388
+
389
+
390
+ def _threshold_predicate(names: dict, contract: LoadContract) -> str:
391
+ """Whether the proposed change is within what the object allows.
392
+
393
+ Decided *once*, here, and recorded as a column — because three things need
394
+ the answer: the writes that must not happen, the delete set that must stay
395
+ empty, and the result that has to say so. Recomputing it in each would let
396
+ them disagree, and a load that reported one thing and did another is the
397
+ failure this whole guard exists to prevent.
398
+
399
+ An explicit ``CASE`` rather than an ``OR`` chain, because SQL does not
400
+ promise to short-circuit and the arithmetic divides by ``target_rows``. An
401
+ empty target has no proportion to be a percentage of, and a first load into
402
+ one is the case the guard must never stand in the way of.
403
+ """
404
+
405
+ return (
406
+ f"CASE\n"
407
+ f" WHEN {IGNORE_THRESHOLD_DEFAULT} = 1 THEN true\n"
408
+ f" WHEN target_rows = 0 THEN true\n"
409
+ f" WHEN target_rows < {contract.stability_rows} THEN true\n"
410
+ f" ELSE proposed_deleted * 100.0 / target_rows "
411
+ f"<= {contract.delete_threshold}\n"
412
+ f" AND proposed_updated * 100.0 / target_rows "
413
+ f"<= {contract.update_threshold}\n"
414
+ f" END"
415
+ )
416
+
417
+
418
+ def _upsert_table(names: dict, contract: LoadContract, business) -> str:
419
+ """What this load has decided to change, materialised before it changes it.
420
+
421
+ The Warehouse procedure and the Python load both build this table; the
422
+ program used to derive the same set inline, three times, in three subqueries.
423
+ Materialising it means the counts and the writes read one set rather than
424
+ re-deriving it, and it survives the run — so what Weaver decided is
425
+ inspectable afterwards, like what it staged and what it refused.
426
+
427
+ A matched row appears only when a comparison column differs. Including every
428
+ matched row would be simpler and wrong: it would rewrite the update
429
+ timestamp of rows nothing changed, so "when did this row last change" would
430
+ come to mean "when was this table last loaded".
431
+ """
432
+
433
+ join = key_join("s", "t", contract.primary_key)
434
+ changed = changed_predicate("s", "t", contract)
435
+ missing = f"t.`{contract.primary_key[0]}` IS NULL"
436
+ return (
437
+ f"CREATE TABLE {names['upsert']} USING delta {COLUMN_MAPPING} AS\n"
438
+ f"SELECT\n {_columns('s', business)}\n"
439
+ f" , CASE WHEN {missing} THEN 1 ELSE 0 END AS `{IS_NEW_COLUMN}`\n"
440
+ f"FROM {names['valid']} AS s\n"
441
+ f"LEFT JOIN {names['target']} AS t ON {join}\n"
442
+ f"WHERE {missing} OR ({changed})"
443
+ )
444
+
445
+
446
+ def _merge(names: dict, contract: LoadContract, business, audit) -> str:
447
+ """Insert the new rows and update the changed ones, from the upsert set.
448
+
449
+ One statement rather than two, because Delta has no ``UPDATE ... FROM`` and a
450
+ merge against a set whose rows are already classified applies exactly the
451
+ change that set recorded.
452
+ """
453
+
454
+ join = key_join("s", "t", contract.primary_key)
455
+ updates = ", ".join(
456
+ f"t.`{name}` = s.`{name}`"
457
+ for name in business
458
+ if name not in contract.primary_key
459
+ )
460
+ update_set = ", ".join(
461
+ part
462
+ for part in (
463
+ updates,
464
+ f"t.`{audit[1]}` = current_timestamp()",
465
+ f"t.`{audit[2]}` = {live_delete_literal()}",
466
+ )
467
+ if part
468
+ )
469
+ columns = ", ".join(f"`{name}`" for name in business)
470
+ values = ", ".join(f"s.`{name}`" for name in business)
471
+ return (
472
+ f"MERGE INTO {names['target']} AS t\n"
473
+ f"USING {names['permitted']} AS s\n"
474
+ f" ON {join}\n"
475
+ f"WHEN MATCHED AND s.`{IS_NEW_COLUMN}` = 0 THEN UPDATE SET {update_set}\n"
476
+ f"WHEN NOT MATCHED AND s.`{IS_NEW_COLUMN}` = 1 "
477
+ f"THEN INSERT ({columns}, {_audit_list(audit)})\n"
478
+ f" VALUES ({values}, {_audit_values(audit)})"
479
+ )
480
+
481
+
482
+ def _tolerated(names: dict) -> str:
483
+ """Whether this run is permitted to write: no rejects, or tolerance asked for.
484
+
485
+ One definition, used by the counts, the valid view and the delete key set.
486
+ They must agree — a count computed under one condition and a write performed
487
+ under another would report a load that did not happen.
488
+ """
489
+
490
+ return (
491
+ f"({FAULT_TOLERANT_DEFAULT} = 1 "
492
+ f"OR (SELECT count(*) FROM {names['reject']}) = 0)"
493
+ )
494
+
495
+
496
+ def _permitted(names: dict) -> str:
497
+ """The decision the result table already recorded.
498
+
499
+ Read rather than recomputed, so the writes, the delete set and the reported
500
+ result cannot disagree about whether this load was allowed to happen.
501
+ """
502
+
503
+ return f"(SELECT within_thresholds FROM {names['result']})"
504
+
505
+
506
+ def _delete_absent(names: dict, contract: LoadContract) -> list[str]:
507
+ """Remove target rows the source stopped producing, in two statements.
508
+
509
+ Delta refuses a subquery in ``DELETE``, and ``WHEN NOT MATCHED BY SOURCE``
510
+ would be worse than unavailable — it would be dangerous. The valid view is
511
+ empty whenever a run is refusing to write, and "not matched by source"
512
+ against an empty source matches *every* target row, so the one case that
513
+ must leave the target untouched would empty it instead.
514
+
515
+ Materialising the keys first removes the ``DELETE`` restriction: the
516
+ subquery lives in a ``CREATE TABLE AS``, where it is allowed.
517
+
518
+ The gate has to be repeated here, and this is the sharp edge. Every other
519
+ statement is made harmless by an empty valid view, but *this* one inverts
520
+ it: "in the target and not in valid" selects everything precisely when valid
521
+ is empty. So an intolerant run would delete the whole table — which is what
522
+ a test caught, and why the tolerance condition is stated on the key set
523
+ itself rather than inherited from the view.
524
+ """
525
+
526
+ join = key_join("v", "t", contract.primary_key)
527
+ keys = ", ".join(f"t.`{c}`" for c in contract.primary_key)
528
+ return [
529
+ f"CREATE TABLE {names['delete']} USING delta {COLUMN_MAPPING} AS\n"
530
+ f"SELECT {keys}\n"
531
+ f"FROM {names['target']} AS t\n"
532
+ f"WHERE NOT EXISTS (\n"
533
+ f" SELECT 1 FROM {names['valid']} AS v WHERE {join}\n"
534
+ f")\n"
535
+ f" AND {_tolerated(names)}\n"
536
+ f" AND {_permitted(names)}",
537
+ f"MERGE INTO {names['target']} AS t\n"
538
+ f"USING {names['delete']} AS d\n"
539
+ f" ON {key_join('d', 't', contract.primary_key)}\n"
540
+ f"WHEN MATCHED THEN DELETE",
541
+ ]
542
+
543
+
544
+ def _final_select(names: dict) -> str:
545
+ """The result row, in the shape every transport reports.
546
+
547
+ ``rows_deleted`` is reconciled from the target's own cardinality rather than
548
+ taken from the delete driver: the driver says what the load *intended*, and
549
+ this says what happened. The two differ whenever a key named for deletion
550
+ was not there to begin with.
551
+ """
552
+
553
+ inserted = "CASE WHEN within_thresholds THEN proposed_inserted ELSE 0 END"
554
+ return (
555
+ f"SELECT\n"
556
+ f" succeeded\n"
557
+ f" , rows_read\n"
558
+ f" , {inserted} AS rows_inserted\n"
559
+ f" , CASE WHEN within_thresholds THEN proposed_updated ELSE 0 END\n"
560
+ f" AS rows_updated\n"
561
+ f" , target_rows + {inserted} - (SELECT count(*) FROM {names['target']})\n"
562
+ f" AS rows_deleted\n"
563
+ f" , rows_rejected\n"
564
+ f" , error_message\n"
565
+ f"FROM {names['result']}"
566
+ )
567
+
568
+
569
+ # --- names and fragments -----------------------------------------------------
570
+
571
+
572
+ def _names(document: SesDocument) -> dict:
573
+ schema = document.object_id.schema
574
+ obj = document.object_id.object
575
+ return {
576
+ "target": object_token(schema, obj),
577
+ "staging": object_token(schema, obj + STAGING_SUFFIX),
578
+ "reject": object_token(schema, obj + REJECT_SUFFIX),
579
+ "upsert": object_token(schema, obj + UPSERT_SUFFIX),
580
+ "result": object_token(schema, obj + RESULT_SUFFIX),
581
+ "delete": object_token(schema, obj + DELETE_SUFFIX),
582
+ # A temp view is session-scoped and unqualified: it is the one relation
583
+ # here that is not a managed object, because it holds no rows of its own.
584
+ "valid": f"weaver_valid_{schema}__{obj}".replace(" ", "_"),
585
+ "permitted": f"weaver_permitted_{schema}__{obj}".replace(" ", "_"),
586
+ }
587
+
588
+
589
+ def delta_audit_names() -> tuple[str, str, str]:
590
+ return tuple(audit_column_name(logical, PYTHON) for logical in AUDIT_COLUMNS)
591
+
592
+
593
+ def _audit_list(audit) -> str:
594
+ return ", ".join(f"`{name}`" for name in audit)
595
+
596
+
597
+ def _audit_values(audit) -> str:
598
+ return f"current_timestamp(), current_timestamp(), {live_delete_literal()}"
599
+
600
+
601
+ def live_delete_literal() -> str:
602
+ return f"CAST('{AUDIT_LIVE_DELETE_DATETIME}' AS TIMESTAMP)"
603
+
604
+
605
+ def key_join(left: str, right: str, columns) -> str:
606
+ return " AND ".join(f"{left}.`{c}` = {right}.`{c}`" for c in columns)
607
+
608
+
609
+ def changed_predicate(left: str, right: str, contract: LoadContract) -> str:
610
+ """Whether a matched row differs, null-safely.
611
+
612
+ ``<=>`` rather than ``<>`` because a column going to or from null is a
613
+ change, and ``<>`` answers null to that question — so a row that lost a
614
+ value would silently never be updated.
615
+ """
616
+
617
+ comparison = [
618
+ column
619
+ for column in contract.comparison_columns
620
+ if column not in contract.primary_key
621
+ ]
622
+ if not comparison:
623
+ # Nothing to compare: every matched row is unchanged by definition, and
624
+ # saying so as `false` keeps the merge's shape identical either way.
625
+ return "false"
626
+ return " OR ".join(
627
+ f"NOT ({left}.`{c}` <=> {right}.`{c}`)" for c in comparison
628
+ )
629
+
630
+
631
+ def blank_key_predicate(columns, alias: str = "s") -> str:
632
+ """A key column that is null, empty or only spaces is not a key.
633
+
634
+ Blank is rejected alongside null deliberately: a key of whitespace matches
635
+ nothing a human would call a match, and letting it through would create a
636
+ row nobody can find again.
637
+
638
+ ``alias`` is empty when the predicate is applied to a frame rather than
639
+ inside a join, where there is no relation to qualify.
640
+ """
641
+
642
+ prefix = f"{alias}." if alias else ""
643
+ predicates = [
644
+ f"nullif(trim(CAST({prefix}`{c}` AS STRING)), '') IS NULL" for c in columns
645
+ ]
646
+ if len(predicates) == 1:
647
+ return predicates[0]
648
+ return "(" + " OR ".join(predicates) + ")"
649
+
650
+
651
+ def _columns(alias: str, columns) -> str:
652
+ return ", ".join(f"{alias}.`{c}`" for c in columns)
653
+
654
+
655
+ def _addressed(body: str) -> str:
656
+ """Name every managed reference in the query, as the build payloads do."""
657
+
658
+ def rewrite(reference):
659
+ object_id = reference.object_id
660
+ if object_id is None:
661
+ return None
662
+ return object_token(object_id.schema, object_id.object)
663
+
664
+ return rewrite_sql_references(body, rewrite)
665
+
666
+
667
+ def _indent(text: str, spaces: int) -> str:
668
+ pad = " " * spaces
669
+ return "\n".join(pad + line if line.strip() else line for line in text.splitlines())
670
+
671
+
672
+ __all__ = [
673
+ "COLUMN_MAPPING",
674
+ "GENERATED_LOAD_MARKER",
675
+ "FAULT_TOLERANT_DEFAULT",
676
+ "FAULT_TOLERANT_MARKER",
677
+ "REJECTION_REASON",
678
+ "blank_key_predicate",
679
+ "changed_predicate",
680
+ "delta_audit_names",
681
+ "key_join",
682
+ "live_delete_literal",
683
+ "INTOLERANT_MESSAGE",
684
+ "STATEMENT_DELIMITER",
685
+ "TOLERATED_MESSAGE",
686
+ "generate_spark_load_program",
687
+ "statements_of",
688
+ ]
689
+
690
+
691
+ # --- the two-phase install ----------------------------------------------------
692
+
693
+
694
+ def generate_spark_load_instruction(document: SesDocument, body: str) -> str:
695
+ """What the bundle carries: everything the installer needs but the columns.
696
+
697
+ The columns are the one thing generation cannot know. A Spark SQL table may
698
+ leave its schema to be inferred at build, and even a declared one is
699
+ materialised as the *physical* table the program must name. So the payload
700
+ is a deterministic instruction rather than finished SQL, exactly as the
701
+ ``spark_table`` build payload is and for the same reason.
702
+
703
+ Deriving the writable columns from ``Comparison columns`` — as this once did
704
+ — was wrong twice over: those are the columns whose *change* means an
705
+ update, not the table's shape, so a declaration that narrowed them dropped
706
+ every other column from staging, rejects, inserts and updates.
707
+ """
708
+
709
+ import json
710
+
711
+ contract = LoadContract.from_document(document)
712
+ payload = {
713
+ "weaver": GENERATED_LOAD_INSTRUCTION,
714
+ "object": object_token(document.object_id.schema, document.object_id.object),
715
+ "qualified": document.qualified,
716
+ "schema": document.object_id.schema,
717
+ "name": document.object_id.object,
718
+ "body": _addressed(body.strip().rstrip(";")),
719
+ "primary_key": list(contract.primary_key),
720
+ "comparison_columns": list(contract.comparison_columns),
721
+ "incremental": contract.incremental,
722
+ "delete_threshold": contract.delete_threshold,
723
+ "update_threshold": contract.update_threshold,
724
+ "stability_rows": contract.stability_rows,
725
+ }
726
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
727
+
728
+
729
+ def render_installed_program(instruction: dict, columns: tuple[str, ...]) -> str:
730
+ """Finish the program, now that the table can say what its columns are.
731
+
732
+ Reconstructs the contract from the frozen instruction rather than reopening
733
+ a repository — the installer holds no declaration, only what the bundle
734
+ carried (how-does-build-work §2).
735
+ """
736
+
737
+ from .metadata import SPARK_SQL, TABLE, ObjectId, SesDocument
738
+
739
+ document = SesDocument(
740
+ kind=TABLE,
741
+ language=SPARK_SQL,
742
+ # Description and lineage are the author's prose. The installer never
743
+ # reads them and the bundle rightly does not carry them, so they are
744
+ # placeheld to satisfy the model rather than invented.
745
+ description=None,
746
+ lineage=None,
747
+ object_id=ObjectId(
748
+ schema=instruction["schema"], object=instruction["name"]
749
+ ),
750
+ primary_key=tuple(instruction["primary_key"]),
751
+ declared_comparison_columns=tuple(instruction["comparison_columns"]),
752
+ is_incremental=instruction["incremental"],
753
+ delete_threshold=instruction["delete_threshold"],
754
+ update_threshold=instruction["update_threshold"],
755
+ stability_rows=instruction["stability_rows"],
756
+ )
757
+ return generate_spark_load_program(
758
+ document, instruction["body"], columns=tuple(columns)
759
+ )