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,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
|
+
)
|