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,497 @@
|
|
|
1
|
+
"""Loading a Python-defined Delta table — the mechanics behind ``Table.load()``.
|
|
2
|
+
|
|
3
|
+
The authored class proposes rows; this owns everything that happens to them.
|
|
4
|
+
``read()`` returns ``(staging, deletes)`` and never touches the target, which is
|
|
5
|
+
the invariant the whole runtime rests on: an object that wrote to its own table
|
|
6
|
+
would make Weaver's accounting a guess.
|
|
7
|
+
|
|
8
|
+
**The first value is staging, not an upsert set.** It has not been validated,
|
|
9
|
+
nothing has been rejected from it, and no row in it has yet been classified as
|
|
10
|
+
new or changed. It goes through the same phases the Warehouse procedure runs::
|
|
11
|
+
|
|
12
|
+
staging
|
|
13
|
+
→ validate keys
|
|
14
|
+
→ reject invalid rows
|
|
15
|
+
→ valid staging
|
|
16
|
+
→ compare against target
|
|
17
|
+
→ derive the upsert set
|
|
18
|
+
→ insert new rows
|
|
19
|
+
→ update changed rows
|
|
20
|
+
→ apply explicit deletes, separately, by key
|
|
21
|
+
→ delete absent rows, for non-incremental loads only
|
|
22
|
+
|
|
23
|
+
**The intermediate tables are real**, as they are in the Warehouse and in the
|
|
24
|
+
generated Spark SQL program: ``<Schema>.<Object>_Staging``, ``_Upsert`` and
|
|
25
|
+
``_Reject``. That is what makes a load inspectable — what the source produced,
|
|
26
|
+
what was refused, and what Weaver decided to change are all still there
|
|
27
|
+
afterwards, and a run that failed can be understood without re-running the
|
|
28
|
+
authored code that produced it. Temporary views cannot do that: they vanish with
|
|
29
|
+
the session that made them, which is exactly when someone wants to look.
|
|
30
|
+
|
|
31
|
+
**``Incremental`` chooses the delete driver, and there is only ever one.** An
|
|
32
|
+
incremental source is a window on the truth, so absence proves nothing and the
|
|
33
|
+
object must *state* what went — ``read()[1]``. A non-incremental source is the
|
|
34
|
+
whole truth, so absence is the statement and an explicit list would be a second,
|
|
35
|
+
quieter answer to a question already answered; a non-incremental table that
|
|
36
|
+
returns one is refused rather than silently ignored, as a non-incremental folder
|
|
37
|
+
already is.
|
|
38
|
+
|
|
39
|
+
One driver, used for both the stability count and the deletion itself. Adding
|
|
40
|
+
two paths together would let the guard protect against a number the load never
|
|
41
|
+
intended to delete.
|
|
42
|
+
|
|
43
|
+
Two departures from the reference remain, both forced. It is written in SQL
|
|
44
|
+
rather than the DataFrame API, because ``tests/test_core_boundary.py`` forbids
|
|
45
|
+
importing ``pyspark`` or ``delta`` anywhere in ``weaver``. And ``fault_tolerant``
|
|
46
|
+
is Weaver's own addition: an intolerant run returns before any target mutation.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from ..declaration.spark_load import (
|
|
52
|
+
COLUMN_MAPPING,
|
|
53
|
+
blank_key_predicate,
|
|
54
|
+
changed_predicate,
|
|
55
|
+
delta_audit_names,
|
|
56
|
+
key_join,
|
|
57
|
+
live_delete_literal,
|
|
58
|
+
)
|
|
59
|
+
from ..errors import LoadError
|
|
60
|
+
from .load_contract import (
|
|
61
|
+
REASON_BLANK_PK,
|
|
62
|
+
REASON_DUPLICATE_PK,
|
|
63
|
+
REJECTION_REASON,
|
|
64
|
+
LoadContract,
|
|
65
|
+
)
|
|
66
|
+
from .load_result import LoadResult
|
|
67
|
+
|
|
68
|
+
#: The suffixes of the three artefacts, matching the Warehouse and the generated
|
|
69
|
+
#: Spark program so one vocabulary describes a load whichever engine ran it.
|
|
70
|
+
STAGING_SUFFIX = "_Staging"
|
|
71
|
+
UPSERT_SUFFIX = "_Upsert"
|
|
72
|
+
REJECT_SUFFIX = "_Reject"
|
|
73
|
+
|
|
74
|
+
#: The keys a load will remove, settled before it removes any. Working rather
|
|
75
|
+
#: than evidence — unlike the reject table, it says nothing a failed run needs
|
|
76
|
+
#: explaining — so it is cleared with the rest.
|
|
77
|
+
DELETE_SUFFIX = "_Delete"
|
|
78
|
+
|
|
79
|
+
#: What ranks duplicate keys, and what marks a row of the upsert set as new.
|
|
80
|
+
#: Both are Weaver's, and both sit in a table beside the author's own columns.
|
|
81
|
+
RANK_COLUMN = "__weaver_pk_row_number"
|
|
82
|
+
IS_NEW_COLUMN = "_Is new row"
|
|
83
|
+
|
|
84
|
+
INTOLERANT_MESSAGE = (
|
|
85
|
+
"rows were rejected and fault_tolerant = 0, so the target was not modified"
|
|
86
|
+
)
|
|
87
|
+
TOLERATED_MESSAGE = "rows were rejected and excluded from the load"
|
|
88
|
+
|
|
89
|
+
#: What a run reports when the change is larger than the object allows. Governed
|
|
90
|
+
#: by ``fault_tolerant`` exactly as row rejection is: refused outright at 0, gone
|
|
91
|
+
#: ahead with but still reported as a failure at 1.
|
|
92
|
+
#: A breach never mutates. ``fault_tolerant`` decides only how the refusal is
|
|
93
|
+
#: surfaced — raised, or returned as a failed result — because a change this
|
|
94
|
+
#: large being *tolerated* is what ``ignore_stability_threshold`` is for.
|
|
95
|
+
BREACH_MESSAGE = "{reason}; the target was not modified"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_table(
|
|
99
|
+
spark,
|
|
100
|
+
*,
|
|
101
|
+
contract: LoadContract,
|
|
102
|
+
lakehouse,
|
|
103
|
+
staging_frame,
|
|
104
|
+
deletes=None,
|
|
105
|
+
fault_tolerant: bool = False,
|
|
106
|
+
ignore_stability_threshold: bool = False,
|
|
107
|
+
) -> LoadResult:
|
|
108
|
+
"""Load one Delta table from the rows its object staged.
|
|
109
|
+
|
|
110
|
+
The destination is resolved by the caller, never inferred here: a load that
|
|
111
|
+
guessed it from the attached Lakehouse would write to the control plane.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
schema, name = contract.object_id.schema, contract.object_id.object
|
|
115
|
+
names = {
|
|
116
|
+
"target": lakehouse.qualify(schema, name),
|
|
117
|
+
"staging": lakehouse.qualify(schema, name + STAGING_SUFFIX),
|
|
118
|
+
"upsert": lakehouse.qualify(schema, name + UPSERT_SUFFIX),
|
|
119
|
+
"reject": lakehouse.qualify(schema, name + REJECT_SUFFIX),
|
|
120
|
+
"delete": lakehouse.qualify(schema, name + DELETE_SUFFIX),
|
|
121
|
+
}
|
|
122
|
+
columns = _business_columns(spark, names["target"])
|
|
123
|
+
_require_columns(staging_frame, contract, columns)
|
|
124
|
+
deletes = _delete_driver(contract, deletes)
|
|
125
|
+
|
|
126
|
+
# Every run starts from nothing a previous run left. Otherwise a clean load
|
|
127
|
+
# leaves the last run's reject table standing, and it reads as evidence about
|
|
128
|
+
# the run that just succeeded.
|
|
129
|
+
_clear(spark, names)
|
|
130
|
+
|
|
131
|
+
_materialise_staging(spark, names, staging_frame, contract, columns)
|
|
132
|
+
rows_read = _count(spark, names["staging"])
|
|
133
|
+
|
|
134
|
+
if contract.replaces_wholesale:
|
|
135
|
+
return _full_replace(spark, names, columns, rows_read)
|
|
136
|
+
|
|
137
|
+
rows_rejected = _reject_invalid_keys(spark, names, contract, columns)
|
|
138
|
+
if rows_rejected and not fault_tolerant:
|
|
139
|
+
# Nothing has been written, so refusing is a decision not to start
|
|
140
|
+
# rather than an unwind — and the reject table is the evidence.
|
|
141
|
+
raise LoadError(
|
|
142
|
+
f"{contract.qualified}: {INTOLERANT_MESSAGE}",
|
|
143
|
+
result=LoadResult.failure(
|
|
144
|
+
INTOLERANT_MESSAGE, rows_read=rows_read, rows_rejected=rows_rejected
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
_derive_upserts(spark, names, contract, columns)
|
|
149
|
+
_derive_deletes(spark, names, contract, deletes)
|
|
150
|
+
|
|
151
|
+
# Everything the load is about to do, counted before it does any of it —
|
|
152
|
+
# which is the whole reason the change set is a table.
|
|
153
|
+
target_before = _count(spark, names["target"])
|
|
154
|
+
breach = None
|
|
155
|
+
if not ignore_stability_threshold:
|
|
156
|
+
breach = contract.breaches(
|
|
157
|
+
target_rows=target_before,
|
|
158
|
+
deleting=_count(spark, names["delete"]),
|
|
159
|
+
updating=_count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 0"),
|
|
160
|
+
)
|
|
161
|
+
if breach:
|
|
162
|
+
# A breach never writes. Tolerating one would be tolerating exactly the
|
|
163
|
+
# change the threshold was declared to prevent, so what fault_tolerant
|
|
164
|
+
# decides here is only whether the refusal is raised or returned.
|
|
165
|
+
refused = LoadResult.failure(
|
|
166
|
+
BREACH_MESSAGE.format(reason=breach),
|
|
167
|
+
rows_read=rows_read,
|
|
168
|
+
rows_rejected=rows_rejected,
|
|
169
|
+
)
|
|
170
|
+
if not fault_tolerant:
|
|
171
|
+
raise LoadError(f"{contract.qualified}: {breach}", result=refused)
|
|
172
|
+
return refused
|
|
173
|
+
|
|
174
|
+
inserted, updated = _apply_upserts(spark, names, contract, columns)
|
|
175
|
+
_apply_deletes(spark, names, contract)
|
|
176
|
+
|
|
177
|
+
# What the target actually lost, from its own cardinality. The delete driver
|
|
178
|
+
# says what the load *intended*; this says what happened, and the two differ
|
|
179
|
+
# whenever a key named for deletion was not there to begin with.
|
|
180
|
+
deleted = target_before + inserted - _count(spark, names["target"])
|
|
181
|
+
|
|
182
|
+
result = LoadResult(
|
|
183
|
+
succeeded=True,
|
|
184
|
+
rows_read=rows_read,
|
|
185
|
+
rows_inserted=inserted,
|
|
186
|
+
rows_updated=updated,
|
|
187
|
+
rows_deleted=deleted,
|
|
188
|
+
rows_rejected=rows_rejected,
|
|
189
|
+
)
|
|
190
|
+
if rows_rejected:
|
|
191
|
+
# The artefacts stay: a run that refused rows is one someone will want
|
|
192
|
+
# to look at, and the reject table alone does not explain itself.
|
|
193
|
+
return result.rejected(f"{rows_rejected} {TOLERATED_MESSAGE}")
|
|
194
|
+
_clear(spark, names)
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- phases ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _materialise_staging(spark, names, frame, contract: LoadContract, columns) -> None:
|
|
202
|
+
"""Put what ``read()`` produced into a real table, ranked for duplicates.
|
|
203
|
+
|
|
204
|
+
Materialised before anything else happens, and that ordering is the point:
|
|
205
|
+
the authored query runs exactly once, and a source that fails does so before
|
|
206
|
+
the target has been touched.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
view = _register(spark, frame, names["target"], "staged")
|
|
210
|
+
named = ", ".join(f"s.`{c}`" for c in columns)
|
|
211
|
+
if not contract.primary_key:
|
|
212
|
+
selected = named
|
|
213
|
+
else:
|
|
214
|
+
partition = ", ".join(f"s.`{c}`" for c in contract.primary_key)
|
|
215
|
+
selected = (
|
|
216
|
+
f"{named}, row_number() OVER ("
|
|
217
|
+
f" PARTITION BY {partition} ORDER BY (SELECT NULL)) AS `{RANK_COLUMN}`"
|
|
218
|
+
)
|
|
219
|
+
spark.sql(
|
|
220
|
+
f"CREATE TABLE {names['staging']} USING delta {COLUMN_MAPPING} AS "
|
|
221
|
+
f"SELECT {selected} FROM {view} AS s"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reject_invalid_keys(spark, names, contract: LoadContract, columns) -> int:
|
|
226
|
+
"""Move rows Weaver will not load into the reject table, with the reason.
|
|
227
|
+
|
|
228
|
+
A count alone says something went wrong and nothing about what, so the rows
|
|
229
|
+
are kept. They are then removed from staging, which from here on is the
|
|
230
|
+
*valid* staging every later phase reads.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
blank = blank_key_predicate(contract.primary_key, alias="s")
|
|
234
|
+
rejected = f"({blank} OR s.`{RANK_COLUMN}` > 1)"
|
|
235
|
+
named = ", ".join(f"s.`{c}`" for c in columns)
|
|
236
|
+
spark.sql(
|
|
237
|
+
f"CREATE TABLE {names['reject']} USING delta {COLUMN_MAPPING} AS\n"
|
|
238
|
+
f"SELECT {named},\n"
|
|
239
|
+
f" CASE WHEN {blank} THEN '{REASON_BLANK_PK}'\n"
|
|
240
|
+
f" ELSE '{REASON_DUPLICATE_PK}' END AS `{REJECTION_REASON}`\n"
|
|
241
|
+
f"FROM {names['staging']} AS s WHERE {rejected}"
|
|
242
|
+
)
|
|
243
|
+
count = _count(spark, names["reject"])
|
|
244
|
+
if count:
|
|
245
|
+
unqualified = rejected.replace("s.`", "`")
|
|
246
|
+
spark.sql(f"DELETE FROM {names['staging']} WHERE {unqualified}")
|
|
247
|
+
else:
|
|
248
|
+
# Nothing was refused, so there is no evidence to keep and an empty table
|
|
249
|
+
# standing next to the object would only invite the wrong conclusion.
|
|
250
|
+
spark.sql(f"DROP TABLE IF EXISTS {names['reject']}")
|
|
251
|
+
return count
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _derive_upserts(spark, names, contract: LoadContract, columns) -> None:
|
|
255
|
+
"""Record what this load has decided to change, before it changes anything.
|
|
256
|
+
|
|
257
|
+
A table rather than a subquery, so what Weaver decided is inspectable
|
|
258
|
+
afterwards — and so the stability check can read the size of the change
|
|
259
|
+
before a single row has moved.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
join = key_join("s", "t", contract.primary_key)
|
|
263
|
+
changed = changed_predicate("s", "t", contract)
|
|
264
|
+
missing = f"t.`{contract.primary_key[0]}` IS NULL"
|
|
265
|
+
named = ", ".join(f"s.`{c}`" for c in columns)
|
|
266
|
+
spark.sql(
|
|
267
|
+
f"CREATE TABLE {names['upsert']} USING delta {COLUMN_MAPPING} AS\n"
|
|
268
|
+
f"SELECT {named},\n"
|
|
269
|
+
f" CASE WHEN {missing} THEN 1 ELSE 0 END AS `{IS_NEW_COLUMN}`\n"
|
|
270
|
+
f"FROM {names['staging']} AS s\n"
|
|
271
|
+
f"LEFT JOIN {names['target']} AS t ON {join}\n"
|
|
272
|
+
f"WHERE {missing} OR ({changed})"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _apply_upserts(spark, names, contract: LoadContract, columns) -> tuple[int, int]:
|
|
277
|
+
"""Insert the new rows, then update the changed ones.
|
|
278
|
+
|
|
279
|
+
Two statements over the one materialised set, as the Warehouse does, so both
|
|
280
|
+
counts describe exactly the rows the writes touched.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
audit = delta_audit_names()
|
|
284
|
+
insert_columns = ", ".join(f"`{c}`" for c in columns)
|
|
285
|
+
audit_columns = ", ".join(f"`{a}`" for a in audit)
|
|
286
|
+
inserted = _count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 1")
|
|
287
|
+
if inserted:
|
|
288
|
+
spark.sql(
|
|
289
|
+
f"INSERT INTO {names['target']} ({insert_columns}, {audit_columns})\n"
|
|
290
|
+
f"SELECT {insert_columns}, current_timestamp(), current_timestamp(), "
|
|
291
|
+
f"{live_delete_literal()}\n"
|
|
292
|
+
f"FROM {names['upsert']} WHERE `{IS_NEW_COLUMN}` = 1"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
updated = _count(spark, names["upsert"], where=f"`{IS_NEW_COLUMN}` = 0")
|
|
296
|
+
if updated:
|
|
297
|
+
sets = [
|
|
298
|
+
f"t.`{c}` = u.`{c}`" for c in columns if c not in contract.primary_key
|
|
299
|
+
] + [
|
|
300
|
+
f"t.`{audit[1]}` = current_timestamp()",
|
|
301
|
+
f"t.`{audit[2]}` = {live_delete_literal()}",
|
|
302
|
+
]
|
|
303
|
+
# A merge rather than an UPDATE ... FROM, which Delta does not have. The
|
|
304
|
+
# rows were already chosen when the upsert set was built, so this only
|
|
305
|
+
# applies the change it recorded.
|
|
306
|
+
spark.sql(
|
|
307
|
+
f"MERGE INTO {names['target']} AS t\n"
|
|
308
|
+
f"USING (SELECT * FROM {names['upsert']} WHERE `{IS_NEW_COLUMN}` = 0) AS u\n"
|
|
309
|
+
f" ON {key_join('u', 't', contract.primary_key)}\n"
|
|
310
|
+
f"WHEN MATCHED THEN UPDATE SET {', '.join(sets)}"
|
|
311
|
+
)
|
|
312
|
+
return inserted, updated
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _delete_driver(contract: LoadContract, deletes):
|
|
316
|
+
"""Which delete claim this object makes, refusing the one it may not.
|
|
317
|
+
|
|
318
|
+
``Incremental`` decides, and it decides exclusively. A non-incremental table
|
|
319
|
+
that also returns explicit deletes is stating twice, in two ways, and the
|
|
320
|
+
second statement would be applied on top of a reconciliation that already
|
|
321
|
+
accounted for it — so it is refused rather than ignored, exactly as a
|
|
322
|
+
non-incremental folder's explicit deletes are.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
if contract.incremental:
|
|
326
|
+
return deletes
|
|
327
|
+
if deletes is not None and bool(deletes.take(1)):
|
|
328
|
+
raise LoadError(
|
|
329
|
+
f"{contract.qualified}: a non-incremental table cannot name explicit "
|
|
330
|
+
"deletes — the source is the whole truth, so a row's absence from it "
|
|
331
|
+
"is what retires it. Return an empty frame, or declare "
|
|
332
|
+
"Incremental: true."
|
|
333
|
+
)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _derive_deletes(spark, names, contract: LoadContract, deletes) -> None:
|
|
338
|
+
"""Materialise the keys this load will remove, before it removes any.
|
|
339
|
+
|
|
340
|
+
One relation from one driver. A number obtained by deleting would be a
|
|
341
|
+
report rather than a check, and the guard's whole purpose is to decide *not*
|
|
342
|
+
to — so the keys are settled first and the same set is then both counted and
|
|
343
|
+
applied.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
keys = ", ".join(f"`{c}`" for c in contract.primary_key)
|
|
347
|
+
spark.sql(f"DROP TABLE IF EXISTS {names['delete']}")
|
|
348
|
+
if contract.incremental:
|
|
349
|
+
source = (
|
|
350
|
+
f"SELECT DISTINCT {keys} FROM "
|
|
351
|
+
f"{_register(spark, deletes, names['target'], 'delete_keys')}"
|
|
352
|
+
if deletes is not None
|
|
353
|
+
else f"SELECT {keys} FROM {names['target']} WHERE false"
|
|
354
|
+
)
|
|
355
|
+
# Only keys the target actually holds: a delete for a row that was never
|
|
356
|
+
# there is not a deletion, and counting it would make the guard protect
|
|
357
|
+
# against work the load was never going to do.
|
|
358
|
+
join = key_join("d", "t", contract.primary_key)
|
|
359
|
+
spark.sql(
|
|
360
|
+
f"CREATE TABLE {names['delete']} USING delta {COLUMN_MAPPING} AS\n"
|
|
361
|
+
f"SELECT {', '.join(f't.`{c}`' for c in contract.primary_key)}\n"
|
|
362
|
+
f"FROM {names['target']} AS t JOIN ({source}) AS d ON {join}"
|
|
363
|
+
)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
join = key_join("s", "t", contract.primary_key)
|
|
367
|
+
spark.sql(
|
|
368
|
+
f"CREATE TABLE {names['delete']} USING delta {COLUMN_MAPPING} AS\n"
|
|
369
|
+
f"SELECT {', '.join(f't.`{c}`' for c in contract.primary_key)}\n"
|
|
370
|
+
f"FROM {names['target']} AS t\n"
|
|
371
|
+
f"WHERE NOT EXISTS "
|
|
372
|
+
f"(SELECT 1 FROM {names['staging']} AS s WHERE {join})"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _apply_deletes(spark, names, contract) -> None:
|
|
377
|
+
"""Remove exactly the keys the driver settled on."""
|
|
378
|
+
|
|
379
|
+
join = key_join("d", "t", contract.primary_key)
|
|
380
|
+
spark.sql(
|
|
381
|
+
f"MERGE INTO {names['target']} AS t USING {names['delete']} AS d "
|
|
382
|
+
f"ON {join} WHEN MATCHED THEN DELETE"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _full_replace(spark, names, columns, rows_read: int) -> LoadResult:
|
|
387
|
+
"""No key, so no row can be matched: the target's contents become these.
|
|
388
|
+
|
|
389
|
+
Staging is materialised first and the target emptied only afterwards, which
|
|
390
|
+
is the whole reason staging is a table. Clearing the target and *then*
|
|
391
|
+
evaluating the authored source would leave nothing behind if the source
|
|
392
|
+
failed.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
audit = delta_audit_names()
|
|
396
|
+
named = ", ".join(f"`{c}`" for c in columns)
|
|
397
|
+
audit_columns = ", ".join(f"`{a}`" for a in audit)
|
|
398
|
+
rows_deleted = _count(spark, names["target"])
|
|
399
|
+
spark.sql(f"DELETE FROM {names['target']}")
|
|
400
|
+
spark.sql(
|
|
401
|
+
f"INSERT INTO {names['target']} ({named}, {audit_columns})\n"
|
|
402
|
+
f"SELECT {named}, current_timestamp(), current_timestamp(), "
|
|
403
|
+
f"{live_delete_literal()} FROM {names['staging']}"
|
|
404
|
+
)
|
|
405
|
+
_clear(spark, names)
|
|
406
|
+
return LoadResult(
|
|
407
|
+
succeeded=True,
|
|
408
|
+
rows_read=rows_read,
|
|
409
|
+
rows_inserted=rows_read,
|
|
410
|
+
rows_deleted=rows_deleted,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --- helpers -----------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _clear(spark, names) -> None:
|
|
418
|
+
"""Drop the three artefacts, newest dependency first."""
|
|
419
|
+
|
|
420
|
+
for key in ("upsert", "reject", "delete", "staging"):
|
|
421
|
+
spark.sql(f"DROP TABLE IF EXISTS {names[key]}")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _count(spark, relation: str, *, where: str | None = None) -> int:
|
|
425
|
+
"""How many rows. Unfiltered, Delta answers this from its transaction log.
|
|
426
|
+
|
|
427
|
+
Which is why the target's own count is affordable: it is the ``sys.partitions``
|
|
428
|
+
equivalent rather than a scan. A filtered count does read, so the filtered
|
|
429
|
+
ones here are over the upsert set, never over the target.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
clause = f" WHERE {where}" if where else ""
|
|
433
|
+
return int(
|
|
434
|
+
spark.sql(f"SELECT count(*) AS n FROM {relation}{clause}").collect()[0]["n"]
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _business_columns(spark, target: str) -> tuple[str, ...]:
|
|
439
|
+
"""The target's own columns, less the audit ones the load supplies itself.
|
|
440
|
+
|
|
441
|
+
Read from the table rather than from the declaration, for the reason the
|
|
442
|
+
Warehouse installer reads sys.columns: the physical table is what is being
|
|
443
|
+
written to, and a declaration that had drifted from it would produce a
|
|
444
|
+
statement naming a column that is not there.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
audit = set(delta_audit_names())
|
|
448
|
+
return tuple(
|
|
449
|
+
field.name
|
|
450
|
+
for field in spark.table(target).schema.fields
|
|
451
|
+
if field.name not in audit
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _require_columns(frame, contract: LoadContract, columns) -> None:
|
|
456
|
+
"""Every column the target needs must be present, by exact name.
|
|
457
|
+
|
|
458
|
+
Checked before anything is written, and by name rather than by position: a
|
|
459
|
+
frame whose columns happen to line up today would silently load the wrong
|
|
460
|
+
values the day an author reorders a select.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
produced = set(frame.columns)
|
|
464
|
+
missing = [name for name in columns if name not in produced]
|
|
465
|
+
if missing:
|
|
466
|
+
raise LoadError(
|
|
467
|
+
f"{contract.qualified}: read() did not produce "
|
|
468
|
+
f"{', '.join(repr(name) for name in missing)} — the staged frame "
|
|
469
|
+
"must carry every column the table declares, by exact name"
|
|
470
|
+
)
|
|
471
|
+
key_missing = [name for name in contract.primary_key if name not in produced]
|
|
472
|
+
if key_missing:
|
|
473
|
+
raise LoadError(
|
|
474
|
+
f"{contract.qualified}: the primary key columns "
|
|
475
|
+
f"{', '.join(repr(name) for name in key_missing)} are not in the "
|
|
476
|
+
"staged frame, so no row can be matched"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _register(spark, frame, target: str, role: str) -> str:
|
|
481
|
+
"""A temporary view over one frame, so SQL can name it.
|
|
482
|
+
|
|
483
|
+
The one legitimate use of a temp view here: it names an in-flight
|
|
484
|
+
``DataFrame`` for a single statement. It is never where a phase's result
|
|
485
|
+
lives — those are tables, because they have to outlive the session.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
name = "weaver_" + role + "_" + _clean(target)
|
|
489
|
+
frame.createOrReplaceTempView(name)
|
|
490
|
+
return name
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _clean(name: str) -> str:
|
|
494
|
+
return name.replace(".", "_").replace("`", "").replace(" ", "_").replace("-", "_")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
__all__ = ["INTOLERANT_MESSAGE", "TOLERATED_MESSAGE", "load_table"]
|
weaver/spark/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Addressing and operating a *named* Spark destination.
|
|
2
|
+
|
|
3
|
+
Spark execution can no longer assume one destination. Even the simplest install
|
|
4
|
+
involves two — the Weaver Lakehouse holding the catalogue, and the Lakehouse
|
|
5
|
+
being built — reached from one session, so "where" has to be said rather than
|
|
6
|
+
inherited from whatever the session is attached to.
|
|
7
|
+
|
|
8
|
+
Three pieces, in the order they are used:
|
|
9
|
+
|
|
10
|
+
:mod:`~weaver.spark.destination`
|
|
11
|
+
what a Lakehouse is *called* here — Fabric's four-part name, or the local
|
|
12
|
+
proxy's folded database name.
|
|
13
|
+
:mod:`~weaver.spark.tokens`
|
|
14
|
+
how a frozen payload names an object without naming a destination, so a
|
|
15
|
+
bundle stays comparable between environments.
|
|
16
|
+
:mod:`~weaver.spark.catalogue`
|
|
17
|
+
the operations — create, execute, discover, exists — each against one named
|
|
18
|
+
destination.
|
|
19
|
+
|
|
20
|
+
Nothing here imports PySpark. A session is passed in and used through ``sql``
|
|
21
|
+
and ``catalog``, so the core stays importable without a JVM.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .catalogue import SparkCatalogue, drop_local_destination_catalogue
|
|
27
|
+
from .destination import (
|
|
28
|
+
LOCAL_SEPARATOR,
|
|
29
|
+
SparkDestination,
|
|
30
|
+
fabric_destination,
|
|
31
|
+
identifier,
|
|
32
|
+
local_destination,
|
|
33
|
+
)
|
|
34
|
+
from .tokens import expand, object_token, schema_token
|
|
35
|
+
from .session import local_delta_session
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"LOCAL_SEPARATOR",
|
|
39
|
+
"SparkCatalogue",
|
|
40
|
+
"SparkDestination",
|
|
41
|
+
"drop_local_destination_catalogue",
|
|
42
|
+
"expand",
|
|
43
|
+
"fabric_destination",
|
|
44
|
+
"identifier",
|
|
45
|
+
"local_destination",
|
|
46
|
+
"local_delta_session",
|
|
47
|
+
"object_token",
|
|
48
|
+
"schema_token",
|
|
49
|
+
]
|