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,231 @@
|
|
|
1
|
+
"""Turning a projection into the statements a build appends, and nothing wider.
|
|
2
|
+
|
|
3
|
+
Reconciliation for one installation is two statements per table: delete the rows
|
|
4
|
+
this installation no longer projects, then merge the rows it does. Both are scoped
|
|
5
|
+
to one ``(repository, target_type)``, so the reach of a whole build's catalogue
|
|
6
|
+
work is bounded by construction rather than by care.
|
|
7
|
+
|
|
8
|
+
**The statements do not depend on reading the catalogue first.** The delete keeps
|
|
9
|
+
exactly the keys the projection claims and the merge is idempotent, so the pair is
|
|
10
|
+
correct against any prior state — including a state the planner could not see.
|
|
11
|
+
That is deliberate: a build that derived its deletes from an inventory would have
|
|
12
|
+
its deletion scope widened by a failed read, which is the failure mode
|
|
13
|
+
how-does-build-work §6 exists to prevent. Here a failed read cannot widen anything,
|
|
14
|
+
because nothing is derived from it.
|
|
15
|
+
|
|
16
|
+
Reading is still worth doing, for a different reason: a reviewer should be able to
|
|
17
|
+
see what a bundle will change before it runs (§3, §17). :func:`compare` produces
|
|
18
|
+
that summary — how many rows are new, changed, unchanged and removed — without any
|
|
19
|
+
statement depending on it.
|
|
20
|
+
|
|
21
|
+
**Ordering is the one strict invariant.** Dictionaries describe, Installation
|
|
22
|
+
records the binding, Registry certifies. Registry is written last, so a row in it
|
|
23
|
+
cannot outrun the work it attests to; the installer's barriers do the rest. Prune
|
|
24
|
+
runs the order backwards — uncertify first, so nothing is left certified while its
|
|
25
|
+
description is being removed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Iterable, Mapping, Sequence
|
|
32
|
+
|
|
33
|
+
from .projection import CatalogueProjection
|
|
34
|
+
from .render import (
|
|
35
|
+
InstallationScope,
|
|
36
|
+
Row,
|
|
37
|
+
render_delete_obsolete,
|
|
38
|
+
render_delete_scope,
|
|
39
|
+
render_merge,
|
|
40
|
+
)
|
|
41
|
+
from .tables import (
|
|
42
|
+
CATALOGUE_TABLES,
|
|
43
|
+
DICTIONARY_TABLES,
|
|
44
|
+
INSTALLATION,
|
|
45
|
+
REGISTRY,
|
|
46
|
+
CatalogueTable,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TableChanges:
|
|
51
|
+
"""What reconciling one table would do. Reporting only — see the module note."""
|
|
52
|
+
|
|
53
|
+
table: CatalogueTable
|
|
54
|
+
inserted: int = 0
|
|
55
|
+
updated: int = 0
|
|
56
|
+
unchanged: int = 0
|
|
57
|
+
deleted: int = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def touched(self) -> int:
|
|
61
|
+
return self.inserted + self.updated + self.deleted
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_noop(self) -> bool:
|
|
65
|
+
return self.touched == 0
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
return (
|
|
69
|
+
f"{self.table.name}: +{self.inserted} ~{self.updated} "
|
|
70
|
+
f"-{self.deleted} ={self.unchanged}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class TableReconciliation:
|
|
76
|
+
"""One table's scoped statements, in the order they must run."""
|
|
77
|
+
|
|
78
|
+
table: CatalogueTable
|
|
79
|
+
#: None only for Installation, whose key *is* the installation scope: there is
|
|
80
|
+
#: at most one such row, so there is never an obsolete one to remove and the
|
|
81
|
+
#: merge alone keeps it current.
|
|
82
|
+
delete: str | None
|
|
83
|
+
#: None when the projection has no rows for this table — there is nothing to
|
|
84
|
+
#: merge, and an empty statement is worse than no action.
|
|
85
|
+
merge: str | None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def statements(self) -> tuple[str, ...]:
|
|
89
|
+
return tuple(
|
|
90
|
+
statement for statement in (self.delete, self.merge) if statement is not None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class CatalogueReconciliation:
|
|
96
|
+
"""Every catalogue statement one build appends, grouped by when it may run.
|
|
97
|
+
|
|
98
|
+
The grouping is the contract: dictionaries may run in any order among
|
|
99
|
+
themselves, Installation follows them, and Registry follows everything. A
|
|
100
|
+
caller turns each group into its own barrier.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
scope: InstallationScope
|
|
104
|
+
dictionaries: tuple[TableReconciliation, ...]
|
|
105
|
+
installation: TableReconciliation
|
|
106
|
+
registry: TableReconciliation
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def groups(self) -> tuple[tuple[str, tuple[TableReconciliation, ...]], ...]:
|
|
110
|
+
return (
|
|
111
|
+
("reconcile catalogue dictionaries", self.dictionaries),
|
|
112
|
+
("record the installation", (self.installation,)),
|
|
113
|
+
("publish the registry", (self.registry,)),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def statements(self) -> tuple[str, ...]:
|
|
118
|
+
"""Every statement, in execution order. Registry's are last."""
|
|
119
|
+
|
|
120
|
+
return tuple(
|
|
121
|
+
statement
|
|
122
|
+
for _description, group in self.groups
|
|
123
|
+
for reconciliation in group
|
|
124
|
+
for statement in reconciliation.statements
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reconcile(projection: CatalogueProjection) -> CatalogueReconciliation:
|
|
129
|
+
"""The statements that make one installation's catalogue match its projection."""
|
|
130
|
+
|
|
131
|
+
scope = projection.scope
|
|
132
|
+
return CatalogueReconciliation(
|
|
133
|
+
scope=scope,
|
|
134
|
+
dictionaries=tuple(
|
|
135
|
+
_for_table(table, projection.for_table(table), scope)
|
|
136
|
+
for table in DICTIONARY_TABLES
|
|
137
|
+
),
|
|
138
|
+
installation=_for_table(
|
|
139
|
+
INSTALLATION, projection.for_table(INSTALLATION), scope
|
|
140
|
+
),
|
|
141
|
+
registry=_for_table(REGISTRY, projection.for_table(REGISTRY), scope),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _for_table(
|
|
146
|
+
table: CatalogueTable, rows: Sequence[Row], scope: InstallationScope
|
|
147
|
+
) -> TableReconciliation:
|
|
148
|
+
return TableReconciliation(
|
|
149
|
+
table=table,
|
|
150
|
+
delete=render_delete_obsolete(table, rows, scope=scope),
|
|
151
|
+
merge=render_merge(table, rows, scope=scope),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- what it would change ----------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def key_of(table: CatalogueTable, row: Row) -> tuple:
|
|
159
|
+
return tuple(row.get(name) for name in table.key)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _keyed(table: CatalogueTable, rows: Iterable[Row]) -> dict[tuple, Row]:
|
|
163
|
+
return {key_of(table, row): row for row in rows}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def compare(
|
|
167
|
+
table: CatalogueTable, desired: Iterable[Row], existing: Iterable[Row]
|
|
168
|
+
) -> TableChanges:
|
|
169
|
+
"""How one table's rows differ from what is there — for review, not for DML.
|
|
170
|
+
|
|
171
|
+
A row is *unchanged* when every non-key column matches, which is exactly the
|
|
172
|
+
condition the merge's ``MATCHED`` guard tests. So a reported no-op is a real
|
|
173
|
+
no-op: the statement will run and write nothing.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
wanted = _keyed(table, desired)
|
|
177
|
+
found = _keyed(table, existing)
|
|
178
|
+
inserted = updated = unchanged = 0
|
|
179
|
+
for key, row in wanted.items():
|
|
180
|
+
if key not in found:
|
|
181
|
+
inserted += 1
|
|
182
|
+
elif any(
|
|
183
|
+
row.get(name) != found[key].get(name) for name in table.comparison_columns
|
|
184
|
+
):
|
|
185
|
+
updated += 1
|
|
186
|
+
else:
|
|
187
|
+
unchanged += 1
|
|
188
|
+
return TableChanges(
|
|
189
|
+
table=table,
|
|
190
|
+
inserted=inserted,
|
|
191
|
+
updated=updated,
|
|
192
|
+
unchanged=unchanged,
|
|
193
|
+
deleted=sum(1 for key in found if key not in wanted),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def summarise(
|
|
198
|
+
projection: CatalogueProjection, existing: Mapping[str, Sequence[Row]]
|
|
199
|
+
) -> tuple[TableChanges, ...]:
|
|
200
|
+
"""What a build's catalogue work would change, table by table.
|
|
201
|
+
|
|
202
|
+
``existing`` is keyed by table name, as :func:`weaver.catalogue.reader.
|
|
203
|
+
read_installation` returns it.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
return tuple(
|
|
207
|
+
compare(table, projection.for_table(table), existing.get(table.name, ()))
|
|
208
|
+
for table in CATALOGUE_TABLES
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- the explicit prune scopes -----------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def prune_installation(scope: InstallationScope) -> tuple[str, ...]:
|
|
216
|
+
"""Remove one installation entirely, in dependency-safe order.
|
|
217
|
+
|
|
218
|
+
This is what decommissioning a target does. It is emphatically **not** what a
|
|
219
|
+
build does: a build that did not include a target type has no opinion about
|
|
220
|
+
it, which is a different thing from having removed it. Nothing in the build
|
|
221
|
+
path may reach this.
|
|
222
|
+
|
|
223
|
+
Registry goes first — uncertify before removing the descriptions, so no row is
|
|
224
|
+
ever left certified while what described it is gone.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
# Uncertify first, remove dependent dictionaries next, and remove the
|
|
228
|
+
# installation root last. Delta does not enforce foreign keys, so this is
|
|
229
|
+
# the explicit ordered equivalent of ON DELETE CASCADE.
|
|
230
|
+
ordered = (REGISTRY, *reversed(DICTIONARY_TABLES), INSTALLATION)
|
|
231
|
+
return tuple(render_delete_scope(table, scope=scope) for table in ordered)
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Rendering catalogue rows as deterministic, scoped Spark SQL.
|
|
2
|
+
|
|
3
|
+
Every statement this module produces is frozen into a build bundle at generation
|
|
4
|
+
time and executed unchanged (how-does-build-work §11, §12). Three properties follow,
|
|
5
|
+
and each is enforced here rather than trusted to a caller:
|
|
6
|
+
|
|
7
|
+
**Deterministic.** The same rows always render the same text, byte for byte. Rows
|
|
8
|
+
are sorted by their key before rendering, so a mapping's iteration order cannot
|
|
9
|
+
change a payload — and therefore cannot change a bundle's identity
|
|
10
|
+
(how-does-build-work §15).
|
|
11
|
+
|
|
12
|
+
**Scoped.** Every ``DELETE`` and every ``MERGE`` predicate names one
|
|
13
|
+
``repository`` and one ``target_type``. A Lakehouse build physically cannot touch
|
|
14
|
+
a Warehouse row, because the scope is not an argument a renderer might forget: it
|
|
15
|
+
is part of the row's identity and part of every statement's ``WHERE``.
|
|
16
|
+
|
|
17
|
+
**Explicit about values.** Every literal is cast to its declared column type, so
|
|
18
|
+
a null is a typed null and a row of all-nulls cannot silently change the source
|
|
19
|
+
frame's schema. Strings are escaped for Spark's default parser, where a backslash
|
|
20
|
+
escapes.
|
|
21
|
+
|
|
22
|
+
The one thing deliberately *not* frozen is the clock. ``current_timestamp()`` is
|
|
23
|
+
rendered as a call, not as a literal: a rendered time would make the same input
|
|
24
|
+
produce a different payload every run, which would destroy bundle identity for no
|
|
25
|
+
gain. The engine supplies the instant; the payload stays stable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Iterable, Mapping, Sequence
|
|
32
|
+
|
|
33
|
+
from ..declaration.metadata import AUDIT_LIVE_DELETE_DATETIME
|
|
34
|
+
from ..spark.tokens import EPOCH_TOKEN, object_token
|
|
35
|
+
from .tables import (
|
|
36
|
+
AUDIT_DELETE_COLUMN,
|
|
37
|
+
AUDIT_INSERT_COLUMN,
|
|
38
|
+
AUDIT_UPDATE_COLUMN,
|
|
39
|
+
BOOLEAN,
|
|
40
|
+
CATALOGUE_SCHEMA,
|
|
41
|
+
ITEM_SCOPE_COLUMNS,
|
|
42
|
+
SCOPE_ITEM_NAME,
|
|
43
|
+
SCOPE_ITEM_TYPE,
|
|
44
|
+
TIMESTAMP,
|
|
45
|
+
CatalogueTable,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: A row as projected: column name to value. Values are ``str``, ``bool`` or
|
|
49
|
+
#: ``None`` — nothing needing a renderer of its own.
|
|
50
|
+
Row = Mapping[str, object]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class InstallationScope:
|
|
55
|
+
"""The one installation a statement may touch.
|
|
56
|
+
|
|
57
|
+
Carried as a value rather than passed as two strings, so a renderer cannot be
|
|
58
|
+
called without it and a caller cannot supply half of it.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
item_type: str
|
|
62
|
+
item_name: str
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def columns(self) -> tuple[str, ...]:
|
|
66
|
+
return ITEM_SCOPE_COLUMNS
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def values(self) -> Mapping[str, str]:
|
|
70
|
+
return {
|
|
71
|
+
SCOPE_ITEM_TYPE: self.item_type,
|
|
72
|
+
SCOPE_ITEM_NAME: self.item_name,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def predicate(self) -> str:
|
|
77
|
+
return self.predicate_for()
|
|
78
|
+
|
|
79
|
+
def predicate_for(self, qualifier: str = "") -> str:
|
|
80
|
+
prefix = f"{qualifier}." if qualifier else ""
|
|
81
|
+
return " AND ".join(
|
|
82
|
+
f"{prefix}{identifier(column)} = {literal(value)}"
|
|
83
|
+
for column, value in self.values.items()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def owns(self, row: Row) -> bool:
|
|
87
|
+
return all(row.get(column) == value for column, value in self.values.items())
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return f"{self.item_type}/{self.item_name}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def identifier(name: str) -> str:
|
|
94
|
+
"""A back-tick quoted Spark identifier, safe for spaces and keywords."""
|
|
95
|
+
|
|
96
|
+
return "`" + name.replace("`", "``") + "`"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def qualified_name(table: CatalogueTable) -> str:
|
|
100
|
+
"""How a rendered statement names one catalogue table.
|
|
101
|
+
|
|
102
|
+
Not ``_.Registry``. The catalogue lives in the Weaver Lakehouse, and a build's
|
|
103
|
+
other statements are aimed at a destination Lakehouse — one session, two
|
|
104
|
+
places — so a name that resolved through the session's current catalogue
|
|
105
|
+
would put the record of the build wherever the session happened to be
|
|
106
|
+
pointed. The statement names the object; the batch names the Weaver
|
|
107
|
+
Lakehouse; the executor puts the two together (:mod:`weaver.spark.tokens`).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return object_token(CATALOGUE_SCHEMA, table.name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def literal(value: object) -> str:
|
|
114
|
+
"""One value as a Spark SQL literal.
|
|
115
|
+
|
|
116
|
+
Spark's default parser treats a backslash as an escape, so both it and the
|
|
117
|
+
quote are escaped. Booleans and nulls are rendered as themselves rather than
|
|
118
|
+
as strings that happen to read that way.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
if value is None:
|
|
122
|
+
return "NULL"
|
|
123
|
+
if isinstance(value, bool):
|
|
124
|
+
return "true" if value else "false"
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
127
|
+
return f"'{escaped}'"
|
|
128
|
+
if isinstance(value, (int, float)):
|
|
129
|
+
return repr(value)
|
|
130
|
+
raise TypeError(
|
|
131
|
+
f"catalogue values are strings, booleans or null, not {type(value).__name__}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def typed_literal(value: object, column_type: str) -> str:
|
|
136
|
+
"""One value, cast to its declared type.
|
|
137
|
+
|
|
138
|
+
The cast is not decoration. A ``MERGE`` source is a ``SELECT`` union whose
|
|
139
|
+
schema comes from its first branch, so an uncast null would type a column by
|
|
140
|
+
accident and a later branch could then fail to match the target.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
if column_type == BOOLEAN and value is not None and not isinstance(value, bool):
|
|
144
|
+
raise TypeError(f"expected a boolean for a {column_type} column, got {value!r}")
|
|
145
|
+
return f"CAST({literal(value)} AS {column_type.upper()})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def column_set(columns: Iterable[str]) -> str | None:
|
|
149
|
+
"""A comma-separated column set, declared order preserved.
|
|
150
|
+
|
|
151
|
+
Order is meaning here — a key on ``(Region, Country)`` is not the same key as
|
|
152
|
+
one on ``(Country, Region)`` — so this never sorts. An empty set is null
|
|
153
|
+
rather than an empty string, because "no key" and "a key of no columns" are
|
|
154
|
+
different claims.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
joined = ", ".join(columns)
|
|
158
|
+
return joined or None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- statements ---------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def sorted_rows(table: CatalogueTable, rows: Iterable[Row]) -> tuple[Row, ...]:
|
|
165
|
+
"""Rows in key order — the canonical order every statement renders in."""
|
|
166
|
+
|
|
167
|
+
def sort_key(row: Row) -> tuple[str, ...]:
|
|
168
|
+
return tuple(str(row.get(name) or "") for name in table.key)
|
|
169
|
+
|
|
170
|
+
return tuple(sorted(rows, key=sort_key))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_merge(
|
|
174
|
+
table: CatalogueTable, rows: Sequence[Row], *, scope: InstallationScope
|
|
175
|
+
) -> str | None:
|
|
176
|
+
"""A scoped ``MERGE`` that inserts new rows and updates changed ones.
|
|
177
|
+
|
|
178
|
+
Returns None when there is nothing to merge, so a caller emits no action
|
|
179
|
+
rather than an empty statement.
|
|
180
|
+
|
|
181
|
+
An unchanged row is a genuine no-op: the ``MATCHED`` branch is guarded by a
|
|
182
|
+
null-safe comparison of every non-key column, so it neither writes nor
|
|
183
|
+
advances ``row_update_datetime``. That is what makes a rebuild of unchanged
|
|
184
|
+
Weaver document leave the catalogue alone.
|
|
185
|
+
|
|
186
|
+
**A published column is set on insert and never on update.** That is not an
|
|
187
|
+
optimisation, it is what makes the value mean what it claims. Every object a
|
|
188
|
+
build actually rebuilds reaches this statement as an *insert*: it is either
|
|
189
|
+
new, or its Registry claim was deleted before any physical work began. So an
|
|
190
|
+
update can only be a row whose projection changed while the object itself was
|
|
191
|
+
left alone — a document that forbids rebuilding is the case that exists — and
|
|
192
|
+
dating such a row to this build would say it was rebuilt when it was not.
|
|
193
|
+
Leaving published columns out of ``UPDATE SET`` keeps the old value, which is
|
|
194
|
+
the true one.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
rows = sorted_rows(table, rows)
|
|
198
|
+
if not rows:
|
|
199
|
+
return None
|
|
200
|
+
_check_scope(table, rows, scope)
|
|
201
|
+
_check_unique_keys(table, rows)
|
|
202
|
+
|
|
203
|
+
columns = table.column_names
|
|
204
|
+
source = _source_relation(table, rows)
|
|
205
|
+
|
|
206
|
+
on = " AND ".join(
|
|
207
|
+
f"target.{identifier(name)} <=> source.{identifier(name)}" for name in table.key
|
|
208
|
+
)
|
|
209
|
+
# The target side is narrowed to this installation as well as matched on the
|
|
210
|
+
# key. The key already carries the scope, so this is belt and braces — and it
|
|
211
|
+
# is the belt that shows in a review.
|
|
212
|
+
scoped = scope.predicate_for("target")
|
|
213
|
+
|
|
214
|
+
comparison = table.comparison_columns
|
|
215
|
+
changed = " OR ".join(
|
|
216
|
+
f"NOT (target.{identifier(name)} <=> source.{identifier(name)})"
|
|
217
|
+
for name in comparison
|
|
218
|
+
)
|
|
219
|
+
updates = ", ".join(
|
|
220
|
+
[f"target.{identifier(name)} = source.{identifier(name)}" for name in comparison]
|
|
221
|
+
+ [f"target.{identifier(AUDIT_UPDATE_COLUMN)} = current_timestamp()"]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Named rather than positional: the audit columns are appended by the build in
|
|
225
|
+
# a fixed order, and pairing values to that order by position would put the
|
|
226
|
+
# sentinel in the wrong column the day the order changed.
|
|
227
|
+
supplied = {
|
|
228
|
+
AUDIT_INSERT_COLUMN: "current_timestamp()",
|
|
229
|
+
AUDIT_UPDATE_COLUMN: "current_timestamp()",
|
|
230
|
+
# A live row's delete datetime is a sentinel maximum, never null — all
|
|
231
|
+
# three audit columns are physically not null.
|
|
232
|
+
AUDIT_DELETE_COLUMN: (
|
|
233
|
+
f"CAST({literal(AUDIT_LIVE_DELETE_DATETIME)} AS {TIMESTAMP.upper()})"
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
# A published column appears here and in *no* other clause. Not in the source
|
|
237
|
+
# relation, because no projection knows it; not in the comparison, because a
|
|
238
|
+
# value that is new every build would make every row differ; and not in the
|
|
239
|
+
# UPDATE, which is the substantive decision — see the note above the
|
|
240
|
+
# statement.
|
|
241
|
+
supplied.update(
|
|
242
|
+
{
|
|
243
|
+
name: f"CAST('{EPOCH_TOKEN}' AS {TIMESTAMP.upper()})"
|
|
244
|
+
for name in table.published_column_names
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
insert_columns = ", ".join(
|
|
248
|
+
identifier(name) for name in table.physical_columns
|
|
249
|
+
)
|
|
250
|
+
insert_values = ", ".join(
|
|
251
|
+
supplied[name] if name in supplied else f"source.{identifier(name)}"
|
|
252
|
+
for name in table.physical_columns
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
f"MERGE INTO {qualified_name(table)} AS target\n"
|
|
257
|
+
f"USING (\n"
|
|
258
|
+
f" {source}\n"
|
|
259
|
+
f") AS source\n"
|
|
260
|
+
f" ON {scoped}\n"
|
|
261
|
+
f" AND {on}\n"
|
|
262
|
+
f"WHEN MATCHED AND ({changed}) THEN UPDATE SET {updates}\n"
|
|
263
|
+
f"WHEN NOT MATCHED THEN INSERT ({insert_columns}) VALUES ({insert_values})\n"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _source_relation(table: CatalogueTable, rows: Sequence[Row]) -> str:
|
|
268
|
+
"""The merge source: one ``VALUES`` relation, cast by an enclosing projection.
|
|
269
|
+
|
|
270
|
+
The obvious construction — one ``SELECT`` of cast literals per row, chained
|
|
271
|
+
with ``UNION ALL`` — does not scale, and the failure is nasty. Spark generates
|
|
272
|
+
Java for the plan, a method's bytecode may not exceed 64 KB, and a union of a
|
|
273
|
+
hundred projections exceeds it: the catalogue's own ``ColumnDictionary`` has a
|
|
274
|
+
row per column of every catalogue table, and that was enough to break the
|
|
275
|
+
bootstrap with ``Code grows beyond 64 KB``.
|
|
276
|
+
|
|
277
|
+
One ``VALUES`` relation is a single plan node however many rows it carries, so
|
|
278
|
+
the casts move outward into one projection over it. The values themselves are
|
|
279
|
+
bare literals: ``VALUES`` unifies a column's type across rows — all-null
|
|
280
|
+
becomes void — and the enclosing ``CAST`` settles it either way, which is what
|
|
281
|
+
keeps the source's schema exactly the target's.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
tuples = ",\n ".join(
|
|
285
|
+
"(" + ", ".join(literal(row.get(name)) for name in table.column_names) + ")"
|
|
286
|
+
for row in rows
|
|
287
|
+
)
|
|
288
|
+
# Positional names for the raw relation, so a column called `repository` in the
|
|
289
|
+
# values cannot be confused with the aliased output of the same name.
|
|
290
|
+
raw = [f"c{index}" for index, _name in enumerate(table.column_names)]
|
|
291
|
+
projected = ", ".join(
|
|
292
|
+
f"CAST({identifier(raw[index])} AS {table.column(name).type.upper()})"
|
|
293
|
+
f" AS {identifier(name)}"
|
|
294
|
+
for index, name in enumerate(table.column_names)
|
|
295
|
+
)
|
|
296
|
+
names = ", ".join(identifier(name) for name in raw)
|
|
297
|
+
return (
|
|
298
|
+
f"SELECT {projected}\n"
|
|
299
|
+
f" FROM VALUES\n"
|
|
300
|
+
f" {tuples}\n"
|
|
301
|
+
f" AS source_values({names})"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def render_delete_obsolete(
|
|
306
|
+
table: CatalogueTable, rows: Sequence[Row], *, scope: InstallationScope
|
|
307
|
+
) -> str | None:
|
|
308
|
+
"""A scoped ``DELETE`` of everything in this installation the rows do not claim.
|
|
309
|
+
|
|
310
|
+
An installation that now projects *no* rows for a table gets a plain scoped
|
|
311
|
+
delete: rendering nothing there would leave stale rows behind forever.
|
|
312
|
+
|
|
313
|
+
Returns None only for a table whose key is the installation scope itself —
|
|
314
|
+
:data:`~weaver.catalogue.tables.INSTALLATION`. There is at most one such row
|
|
315
|
+
per scope, so "the rows this projection does not claim" is empty by
|
|
316
|
+
construction and the merge alone keeps it current. Rendering a predicate over
|
|
317
|
+
no columns beyond the scope would delete the very row about to be merged.
|
|
318
|
+
|
|
319
|
+
The predicate is written as a disjunction of key equalities rather than a
|
|
320
|
+
tuple ``IN``: it renders identically on any engine, reads in a review, and
|
|
321
|
+
keeps the scope visible at the front of the statement.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
rows = sorted_rows(table, rows)
|
|
325
|
+
_check_scope(table, rows, scope)
|
|
326
|
+
if not rows:
|
|
327
|
+
return f"DELETE FROM {qualified_name(table)}\n WHERE {scope.predicate}\n"
|
|
328
|
+
|
|
329
|
+
# Only the key columns beyond the scope: the scope is already in the WHERE.
|
|
330
|
+
identity = tuple(name for name in table.key if name not in scope.columns)
|
|
331
|
+
if not identity:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
keep = "\n OR ".join(
|
|
335
|
+
"("
|
|
336
|
+
+ " AND ".join(
|
|
337
|
+
f"{identifier(name)} <=> {typed_literal(row.get(name), table.column(name).type)}"
|
|
338
|
+
for name in identity
|
|
339
|
+
)
|
|
340
|
+
+ ")"
|
|
341
|
+
for row in rows
|
|
342
|
+
)
|
|
343
|
+
return (
|
|
344
|
+
f"DELETE FROM {qualified_name(table)}\n"
|
|
345
|
+
f" WHERE {scope.predicate}\n"
|
|
346
|
+
f" AND NOT (\n"
|
|
347
|
+
f" {keep}\n"
|
|
348
|
+
f" )\n"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def render_delete_scope(table: CatalogueTable, *, scope: InstallationScope) -> str:
|
|
353
|
+
"""A scoped ``DELETE`` of one whole installation from one table.
|
|
354
|
+
|
|
355
|
+
This is installation pruning: it is what decommissioning a target does, and it
|
|
356
|
+
is never what an ordinary build does. A build that did not include a target
|
|
357
|
+
type has no opinion about it, which is a different thing from having removed
|
|
358
|
+
it.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
return f"DELETE FROM {qualified_name(table)}\n WHERE {scope.predicate}\n"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _check_unique_keys(table: CatalogueTable, rows: Sequence[Row]) -> None:
|
|
367
|
+
"""Refuse a merge whose source holds two rows with one key.
|
|
368
|
+
|
|
369
|
+
Delta fails a ``MERGE`` when several source rows match one target row, and it
|
|
370
|
+
fails at *install* time — long after the bundle was reviewed. Catching it here
|
|
371
|
+
turns a late, obscure runtime error into a generation error naming the table
|
|
372
|
+
and the key, and it means a duplicate can only be a projection fault rather
|
|
373
|
+
than a mystery.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
seen: dict[tuple, int] = {}
|
|
377
|
+
for row in rows:
|
|
378
|
+
key = tuple(row.get(name) for name in table.key)
|
|
379
|
+
seen[key] = seen.get(key, 0) + 1
|
|
380
|
+
duplicated = [key for key, count in seen.items() if count > 1]
|
|
381
|
+
if duplicated:
|
|
382
|
+
shown = "; ".join(
|
|
383
|
+
", ".join(str(part) for part in key) for key in duplicated[:3]
|
|
384
|
+
)
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"{table.qualified}: {len(duplicated)} duplicated key(s) in the projected "
|
|
387
|
+
f"rows ({shown}) — a merge source must hold one row per key"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _check_scope(
|
|
392
|
+
table: CatalogueTable, rows: Iterable[Row], scope: InstallationScope
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Refuse to render a statement over rows from another installation.
|
|
395
|
+
|
|
396
|
+
The guard exists because the consequence is invisible: a stray row would be
|
|
397
|
+
merged into the wrong installation's scope and read as truth. Cheap to check,
|
|
398
|
+
expensive to discover.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
stray = [row for row in rows if not scope.owns(row)]
|
|
402
|
+
if stray:
|
|
403
|
+
found = ", ".join(
|
|
404
|
+
"/".join(repr(row.get(column)) for column in scope.columns)
|
|
405
|
+
for row in stray[:3]
|
|
406
|
+
)
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"{table.qualified}: {len(stray)} row(s) do not belong to installation "
|
|
409
|
+
f"{scope} ({found}) — a statement may only touch one installation"
|
|
410
|
+
)
|