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,620 @@
|
|
|
1
|
+
"""Reconciling a bound physical target against what an item declares.
|
|
2
|
+
|
|
3
|
+
Building says what must exist. Pruning says what must stop existing, and it is
|
|
4
|
+
the half that can destroy data, so it is deliberately narrow: only objects the
|
|
5
|
+
target already holds, only in schemas the bound item declares, and only after
|
|
6
|
+
the inventory has been frozen at plan time rather than re-read at install time.
|
|
7
|
+
|
|
8
|
+
The Delta side reads the Spark catalogue for the bound Lakehouse and the Files
|
|
9
|
+
area for its Folders. The Warehouse side reads the target's own SQL catalogue,
|
|
10
|
+
in the environment the build is running in.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Iterable, Mapping
|
|
17
|
+
|
|
18
|
+
from ..catalogue.tables import CATALOGUE_SCHEMA
|
|
19
|
+
from ..etl import LOAD_ROOT
|
|
20
|
+
from ..workspaces import BUILD_BUNDLES_AREA, CLI_AREA
|
|
21
|
+
from ..spark import SparkCatalogue, object_token, schema_token
|
|
22
|
+
from ..declaration.metadata import DELTA_TARGET, FOLDER_TARGET, SQL_TARGET, TABLE, VIEW
|
|
23
|
+
from ..declaration.model import PROCEDURE_SHAPE, WeaverDocumentId
|
|
24
|
+
from ..declaration.source import SourceDocument
|
|
25
|
+
from ..errors import BuildError
|
|
26
|
+
from ..store import Store
|
|
27
|
+
from ..targets import ItemRef
|
|
28
|
+
from .models import (
|
|
29
|
+
PRUNE_FOLDER,
|
|
30
|
+
PRUNE_SCHEMA,
|
|
31
|
+
PRUNE_TABLE,
|
|
32
|
+
PRUNE_VIEW,
|
|
33
|
+
BuildAction,
|
|
34
|
+
)
|
|
35
|
+
from .changes import (
|
|
36
|
+
FOLDER as FOLDER_KIND,
|
|
37
|
+
FOLDER_SCHEMA as FOLDER_SCHEMA_KIND,
|
|
38
|
+
SCHEMA as SCHEMA_KIND,
|
|
39
|
+
TABLE as TABLE_KIND,
|
|
40
|
+
VIEW as VIEW_KIND,
|
|
41
|
+
TargetChange,
|
|
42
|
+
removed,
|
|
43
|
+
)
|
|
44
|
+
from .payloads import sha256_hex
|
|
45
|
+
from .targets import BoundTarget
|
|
46
|
+
|
|
47
|
+
#: Files areas a prune never touches: they are Weaver's own, not an item's
|
|
48
|
+
#: materialised output.
|
|
49
|
+
_RESERVED_FILES_AREAS = frozenset({BUILD_BUNDLES_AREA, CLI_AREA})
|
|
50
|
+
|
|
51
|
+
#: *Delta* schemas a prune never touches. A schema-enabled Fabric Lakehouse has a
|
|
52
|
+
#: default ``dbo`` schema that cannot be dropped and that Weaver does not manage;
|
|
53
|
+
#: ``_`` holds Weaver's own catalogue, which no item owns. A build normally cannot
|
|
54
|
+
#: see `_` at all — it lives in the Weaver Lakehouse and prune is scoped to the
|
|
55
|
+
#: bound destination's own storage — but an item built *into* the Weaver Lakehouse
|
|
56
|
+
#: would, and a prune that dropped the catalogue would take the record of every
|
|
57
|
+
#: installation with it.
|
|
58
|
+
#:
|
|
59
|
+
#: The load layer's ``_`` is a different object wearing the same name: a *folder*
|
|
60
|
+
#: under Files, and a *Warehouse* schema. Neither is listed here, and neither
|
|
61
|
+
#: should be — both are generated, projected and pruned like any other managed
|
|
62
|
+
#: object, which is exactly how they go when an item stops declaring load code.
|
|
63
|
+
_RESERVED_SCHEMAS = frozenset({"dbo", CATALOGUE_SCHEMA})
|
|
64
|
+
|
|
65
|
+
#: Warehouse schemas that belong to the engine rather than to any item.
|
|
66
|
+
_RESERVED_SQL_SCHEMAS = frozenset(
|
|
67
|
+
{"dbo", "guest", "information_schema", "sys", "queryinsights", "_rsc"}
|
|
68
|
+
)
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class _Managed:
|
|
71
|
+
"""The keep-set the build diffs the target against, folded for comparison."""
|
|
72
|
+
|
|
73
|
+
schemas: frozenset[str]
|
|
74
|
+
folder_schemas: frozenset[str]
|
|
75
|
+
folders: frozenset[str]
|
|
76
|
+
tables: frozenset[str]
|
|
77
|
+
views: frozenset[str]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TargetInventory:
|
|
82
|
+
"""Transport-neutral physical state prepared before bundle generation.
|
|
83
|
+
|
|
84
|
+
``files`` and ``procedures`` are what a load layer installs, and they are
|
|
85
|
+
read like everything else here. A type the inventory cannot see would be
|
|
86
|
+
disproved by every reconciliation — the claim tested against nothing and
|
|
87
|
+
found missing — so an artefact would be rebuilt on every build, silently.
|
|
88
|
+
Observing them is what lets the ordinary machinery answer presence, physical
|
|
89
|
+
deletion and drift for load artefacts too.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
target_id: str
|
|
93
|
+
kind: str
|
|
94
|
+
target_name: str
|
|
95
|
+
schemas: tuple[str, ...] = ()
|
|
96
|
+
folder_schemas: tuple[str, ...] = ()
|
|
97
|
+
folders: tuple[str, ...] = ()
|
|
98
|
+
tables: tuple[str, ...] = ()
|
|
99
|
+
views: tuple[str, ...] = ()
|
|
100
|
+
#: Deployed load files, as ``<path beneath Files>/<filename>``.
|
|
101
|
+
files: tuple[str, ...] = ()
|
|
102
|
+
#: Generated load procedures, as ``<schema>.<name>``.
|
|
103
|
+
procedures: tuple[str, ...] = ()
|
|
104
|
+
|
|
105
|
+
def to_mapping(self) -> dict[str, object]:
|
|
106
|
+
"""A versioned JSON-safe representation for remote state handover."""
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"format_version": 1,
|
|
110
|
+
"target_id": self.target_id,
|
|
111
|
+
"kind": self.kind,
|
|
112
|
+
"target_name": self.target_name,
|
|
113
|
+
"schemas": list(self.schemas),
|
|
114
|
+
"folder_schemas": list(self.folder_schemas),
|
|
115
|
+
"folders": list(self.folders),
|
|
116
|
+
"tables": list(self.tables),
|
|
117
|
+
"views": list(self.views),
|
|
118
|
+
"files": list(self.files),
|
|
119
|
+
"procedures": list(self.procedures),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_mapping(cls, mapping) -> "TargetInventory":
|
|
124
|
+
"""Reconstruct an inventory returned by an in-Fabric state read."""
|
|
125
|
+
|
|
126
|
+
version = mapping.get("format_version")
|
|
127
|
+
if version != 1:
|
|
128
|
+
raise BuildError(
|
|
129
|
+
f"unsupported target inventory format_version {version!r}; expected 1"
|
|
130
|
+
)
|
|
131
|
+
return cls(
|
|
132
|
+
target_id=mapping["target_id"],
|
|
133
|
+
kind=mapping["kind"],
|
|
134
|
+
target_name=mapping["target_name"],
|
|
135
|
+
schemas=tuple(mapping.get("schemas", ())),
|
|
136
|
+
folder_schemas=tuple(mapping.get("folder_schemas", ())),
|
|
137
|
+
folders=tuple(mapping.get("folders", ())),
|
|
138
|
+
tables=tuple(mapping.get("tables", ())),
|
|
139
|
+
views=tuple(mapping.get("views", ())),
|
|
140
|
+
files=tuple(mapping.get("files", ())),
|
|
141
|
+
procedures=tuple(mapping.get("procedures", ())),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def update_using(self, plan) -> "TargetInventory":
|
|
145
|
+
"""This target as the plan intends to leave it.
|
|
146
|
+
|
|
147
|
+
The build's declared effect on this target, applied. What it gives is a
|
|
148
|
+
*prediction*, and the value of a prediction is that it can be wrong: an
|
|
149
|
+
estate built from a repository and read back should equal the same
|
|
150
|
+
repository's declared inventory, and if applying a build's own summary to
|
|
151
|
+
the state it was planned against does not reach that, the build does not
|
|
152
|
+
converge.
|
|
153
|
+
|
|
154
|
+
Reads the summary rather than inferring one from the actions, because an
|
|
155
|
+
inference would be a model of what executors do living where no executor
|
|
156
|
+
could correct it. The summary is held to the actions separately, by
|
|
157
|
+
bijection over action ids.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
from .changes import apply_to
|
|
161
|
+
|
|
162
|
+
return apply_to(self, plan.target_changes.get(self.target_id, ()))
|
|
163
|
+
|
|
164
|
+
def has_object(self, schema: str, name: str, object_type: str) -> bool:
|
|
165
|
+
"""Whether the target holds this object, asked of the right collection.
|
|
166
|
+
|
|
167
|
+
Branching on the type is not a convenience: falling through to ``tables``
|
|
168
|
+
for a type this did not know about would answer *no* for something that
|
|
169
|
+
is plainly there, and reconciliation reads a *no* as proof the claim is
|
|
170
|
+
stale.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if object_type == "file":
|
|
174
|
+
# A file is addressed by path, and its schema already *is* the path
|
|
175
|
+
# beneath Files — so the two halves join with a separator rather than
|
|
176
|
+
# the dot a two-part object name uses.
|
|
177
|
+
return _holds(self.files, f"{schema}/{name}")
|
|
178
|
+
if object_type == "stored_procedure":
|
|
179
|
+
return _holds(self.procedures, f"{schema}.{name}")
|
|
180
|
+
physical_schema = schema
|
|
181
|
+
values = self.views if object_type == "view" else self.tables
|
|
182
|
+
if object_type == "folder":
|
|
183
|
+
prefix = "Files/"
|
|
184
|
+
if schema.casefold().startswith(prefix.casefold()):
|
|
185
|
+
physical_schema = schema[len(prefix) :]
|
|
186
|
+
values = self.folders
|
|
187
|
+
return _holds(values, f"{physical_schema}.{name}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _holds(values: Iterable[str], qualified: str) -> bool:
|
|
191
|
+
return qualified.casefold() in {value.casefold() for value in values}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_lakehouse_inventory(
|
|
195
|
+
target: BoundTarget, *, resolver, store: Store, spark=None
|
|
196
|
+
) -> TargetInventory:
|
|
197
|
+
"""Read every Weaver-manageable object in one Lakehouse."""
|
|
198
|
+
|
|
199
|
+
lakehouse = ItemRef(target.item_id)
|
|
200
|
+
tables_root = resolver.tables_root(lakehouse)
|
|
201
|
+
files_root = resolver.files_root(lakehouse)
|
|
202
|
+
control_item = target.logical_item_name == "_weaver"
|
|
203
|
+
reserved_schemas = set(_RESERVED_SCHEMAS)
|
|
204
|
+
if control_item:
|
|
205
|
+
reserved_schemas.discard(CATALOGUE_SCHEMA)
|
|
206
|
+
schemas = tuple(
|
|
207
|
+
entry.name
|
|
208
|
+
for entry in _child_dirs(store, tables_root)
|
|
209
|
+
if (
|
|
210
|
+
entry.name.casefold() == CATALOGUE_SCHEMA.casefold()
|
|
211
|
+
if control_item
|
|
212
|
+
else entry.name.casefold() not in reserved_schemas
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
catalogue = _catalogue_for(resolver, lakehouse, spark)
|
|
216
|
+
if (
|
|
217
|
+
control_item
|
|
218
|
+
and catalogue is not None
|
|
219
|
+
and catalogue.schema_exists(CATALOGUE_SCHEMA)
|
|
220
|
+
and CATALOGUE_SCHEMA.casefold()
|
|
221
|
+
not in {schema.casefold() for schema in schemas}
|
|
222
|
+
):
|
|
223
|
+
# The empty catalogue schema is catalogue state, not storage state: until
|
|
224
|
+
# its first table exists there is no Tables/_ directory for the store to
|
|
225
|
+
# discover. The package-owned control item is the one safe exception to
|
|
226
|
+
# storage-only schema discovery because it is the attached Lakehouse.
|
|
227
|
+
schemas += (CATALOGUE_SCHEMA,)
|
|
228
|
+
tables = tuple(
|
|
229
|
+
f"{schema}.{entry.name}"
|
|
230
|
+
for schema in schemas
|
|
231
|
+
for entry in _child_dirs(store, tables_root / schema)
|
|
232
|
+
)
|
|
233
|
+
# The same narrowing the Delta side uses, and for the same reason. The
|
|
234
|
+
# control item's Files area holds Weaver's own working directories — the
|
|
235
|
+
# declaration, retained bundles, CLI handover — none of which is a Folder
|
|
236
|
+
# object; what it *does* declare is the task log, under the reserved schema.
|
|
237
|
+
# Excluding the whole area instead left that folder unobservable, so every
|
|
238
|
+
# build concluded it was absent and tried to create it again.
|
|
239
|
+
folder_schema_entries = tuple(
|
|
240
|
+
entry
|
|
241
|
+
for entry in _child_dirs(store, files_root)
|
|
242
|
+
if (
|
|
243
|
+
entry.name.casefold() == CATALOGUE_SCHEMA.casefold()
|
|
244
|
+
if control_item
|
|
245
|
+
else entry.name not in _RESERVED_FILES_AREAS
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
folders = tuple(
|
|
249
|
+
f"{entry.name}.{child.name}"
|
|
250
|
+
for entry in folder_schema_entries
|
|
251
|
+
for child in _child_dirs(store, entry.location)
|
|
252
|
+
)
|
|
253
|
+
views: tuple[str, ...] = ()
|
|
254
|
+
if catalogue is not None:
|
|
255
|
+
views = tuple(
|
|
256
|
+
f"{schema}.{view}"
|
|
257
|
+
for schema in schemas
|
|
258
|
+
for view in catalogue.views(schema)
|
|
259
|
+
)
|
|
260
|
+
files = () if control_item else _load_files(store, files_root)
|
|
261
|
+
return TargetInventory(
|
|
262
|
+
target_id=target.id,
|
|
263
|
+
kind=target.kind,
|
|
264
|
+
target_name=target.name,
|
|
265
|
+
schemas=tuple(sorted(schemas, key=str.casefold)),
|
|
266
|
+
folder_schemas=tuple(
|
|
267
|
+
sorted((entry.name for entry in folder_schema_entries), key=str.casefold)
|
|
268
|
+
),
|
|
269
|
+
folders=tuple(sorted(folders, key=str.casefold)),
|
|
270
|
+
tables=tuple(sorted(tables, key=str.casefold)),
|
|
271
|
+
views=tuple(sorted(views, key=str.casefold)),
|
|
272
|
+
files=files,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _load_files(store: Store, files_root) -> tuple[str, ...]:
|
|
277
|
+
"""Every deployed load file, as the path beneath ``Files`` that names it.
|
|
278
|
+
|
|
279
|
+
Scoped to the runtime tree rather than the whole Files area, and deliberately:
|
|
280
|
+
a Folder object's *contents* are data an item loaded, not objects Weaver
|
|
281
|
+
installed, so walking all of Files would inventory rows as though they were
|
|
282
|
+
artefacts. The load tree is the one place a build puts individual files it
|
|
283
|
+
claims one by one.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
root = files_root / LOAD_ROOT.split("/")[0]
|
|
287
|
+
if not store.exists(root) or not store.is_directory(root):
|
|
288
|
+
return ()
|
|
289
|
+
prefix = files_root.value.rstrip("/") + "/"
|
|
290
|
+
return tuple(
|
|
291
|
+
sorted(
|
|
292
|
+
(
|
|
293
|
+
entry.location.value[len(prefix) :]
|
|
294
|
+
for entry in store.list(root, recursive=True)
|
|
295
|
+
if not entry.is_directory
|
|
296
|
+
),
|
|
297
|
+
key=str.casefold,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def read_warehouse_inventory(target: BoundTarget, *, sql) -> TargetInventory:
|
|
303
|
+
"""Read every Weaver-manageable schema, table, view and procedure."""
|
|
304
|
+
|
|
305
|
+
rows = sql.query(
|
|
306
|
+
"""
|
|
307
|
+
select schema_name(objects.schema_id) as schema_name,
|
|
308
|
+
objects.name as object_name,
|
|
309
|
+
objects.type as object_type
|
|
310
|
+
from sys.objects as objects
|
|
311
|
+
where objects.is_ms_shipped = 0
|
|
312
|
+
and objects.type in (N'U', N'V', N'P')
|
|
313
|
+
order by schema_name(objects.schema_id), objects.name
|
|
314
|
+
"""
|
|
315
|
+
)
|
|
316
|
+
objects = [
|
|
317
|
+
(
|
|
318
|
+
str(row["schema_name"]),
|
|
319
|
+
str(row["object_name"]),
|
|
320
|
+
str(row["object_type"]).strip(),
|
|
321
|
+
)
|
|
322
|
+
for row in rows
|
|
323
|
+
if str(row["schema_name"]).casefold() not in _RESERVED_SQL_SCHEMAS
|
|
324
|
+
]
|
|
325
|
+
schema_rows = sql.query(
|
|
326
|
+
"""
|
|
327
|
+
select schemas.name as name
|
|
328
|
+
from sys.schemas as schemas
|
|
329
|
+
left join sys.database_principals as owners
|
|
330
|
+
on owners.principal_id = schemas.principal_id
|
|
331
|
+
where owners.is_fixed_role is null or owners.is_fixed_role = 0
|
|
332
|
+
"""
|
|
333
|
+
)
|
|
334
|
+
schemas = tuple(
|
|
335
|
+
sorted(
|
|
336
|
+
{
|
|
337
|
+
str(row["name"])
|
|
338
|
+
for row in schema_rows
|
|
339
|
+
if str(row["name"]).casefold() not in _RESERVED_SQL_SCHEMAS
|
|
340
|
+
},
|
|
341
|
+
key=str.casefold,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return TargetInventory(
|
|
345
|
+
target_id=target.id,
|
|
346
|
+
kind=target.kind,
|
|
347
|
+
target_name=target.name,
|
|
348
|
+
schemas=schemas,
|
|
349
|
+
tables=tuple(
|
|
350
|
+
sorted(
|
|
351
|
+
(f"{schema}.{name}" for schema, name, kind in objects if kind == "U"),
|
|
352
|
+
key=str.casefold,
|
|
353
|
+
)
|
|
354
|
+
),
|
|
355
|
+
views=tuple(
|
|
356
|
+
sorted(
|
|
357
|
+
(f"{schema}.{name}" for schema, name, kind in objects if kind == "V"),
|
|
358
|
+
key=str.casefold,
|
|
359
|
+
)
|
|
360
|
+
),
|
|
361
|
+
procedures=tuple(
|
|
362
|
+
sorted(
|
|
363
|
+
(f"{schema}.{name}" for schema, name, kind in objects if kind == "P"),
|
|
364
|
+
key=str.casefold,
|
|
365
|
+
)
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def render_inventory_prune(
|
|
371
|
+
target: BoundTarget,
|
|
372
|
+
inventory: TargetInventory,
|
|
373
|
+
managed: _Managed,
|
|
374
|
+
payloads: dict[str, bytes],
|
|
375
|
+
) -> tuple[tuple[BuildAction, ...], tuple[TargetChange, ...]]:
|
|
376
|
+
"""Purely render prune actions from one already-read inventory.
|
|
377
|
+
|
|
378
|
+
``payloads`` is filled with the frozen drops, keyed by bare filename: the
|
|
379
|
+
caller owns which sequence these actions land in and therefore which payload
|
|
380
|
+
directory they live under.
|
|
381
|
+
|
|
382
|
+
Returns the changes alongside, and this is the one place they are not merely
|
|
383
|
+
convenient. A prune action carries no ``resource_node_id`` — the object it
|
|
384
|
+
removes has no node in the repository, which is why it is being pruned — so
|
|
385
|
+
what a prune destroys is otherwise recorded nowhere a reader or a test can
|
|
386
|
+
reach without parsing SQL.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
actions: list[BuildAction] = []
|
|
390
|
+
changes: list[TargetChange] = []
|
|
391
|
+
if target.kind == "warehouse":
|
|
392
|
+
for qualified in inventory.views:
|
|
393
|
+
if qualified.casefold() not in managed.views:
|
|
394
|
+
schema, name = qualified.split(".", 1)
|
|
395
|
+
actions.append(
|
|
396
|
+
_drop_action(
|
|
397
|
+
target,
|
|
398
|
+
PRUNE_VIEW,
|
|
399
|
+
"view",
|
|
400
|
+
qualified,
|
|
401
|
+
f"drop view if exists {_tsql_ident(schema)}.{_tsql_ident(name)};",
|
|
402
|
+
payloads,
|
|
403
|
+
executor="tsql",
|
|
404
|
+
extension=".sql",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
changes.append(removed(VIEW_KIND, qualified, actions[-1].id))
|
|
408
|
+
for qualified in inventory.tables:
|
|
409
|
+
if qualified.casefold() not in managed.tables:
|
|
410
|
+
schema, name = qualified.split(".", 1)
|
|
411
|
+
actions.append(
|
|
412
|
+
_drop_action(
|
|
413
|
+
target,
|
|
414
|
+
PRUNE_TABLE,
|
|
415
|
+
"table",
|
|
416
|
+
qualified,
|
|
417
|
+
f"drop table if exists {_tsql_ident(schema)}.{_tsql_ident(name)};",
|
|
418
|
+
payloads,
|
|
419
|
+
executor="tsql",
|
|
420
|
+
extension=".sql",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
changes.append(removed(TABLE_KIND, qualified, actions[-1].id))
|
|
424
|
+
for schema in inventory.schemas:
|
|
425
|
+
if schema.casefold() not in managed.schemas:
|
|
426
|
+
actions.append(
|
|
427
|
+
_drop_action(
|
|
428
|
+
target,
|
|
429
|
+
PRUNE_SCHEMA,
|
|
430
|
+
"schema",
|
|
431
|
+
schema,
|
|
432
|
+
f"drop schema if exists {_tsql_ident(schema)};",
|
|
433
|
+
payloads,
|
|
434
|
+
executor="tsql",
|
|
435
|
+
extension=".sql",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
changes.append(removed(SCHEMA_KIND, schema, actions[-1].id))
|
|
439
|
+
else:
|
|
440
|
+
orphan_schemas = {
|
|
441
|
+
schema.casefold()
|
|
442
|
+
for schema in inventory.schemas
|
|
443
|
+
if schema.casefold() not in managed.schemas
|
|
444
|
+
}
|
|
445
|
+
for qualified in inventory.views:
|
|
446
|
+
schema, name = qualified.split(".", 1)
|
|
447
|
+
if (
|
|
448
|
+
schema.casefold() not in orphan_schemas
|
|
449
|
+
and qualified.casefold() not in managed.views
|
|
450
|
+
):
|
|
451
|
+
actions.append(
|
|
452
|
+
_drop_action(
|
|
453
|
+
target,
|
|
454
|
+
PRUNE_VIEW,
|
|
455
|
+
"view",
|
|
456
|
+
qualified,
|
|
457
|
+
f"DROP VIEW IF EXISTS {object_token(schema, name)}",
|
|
458
|
+
payloads,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
changes.append(removed(VIEW_KIND, qualified, actions[-1].id))
|
|
462
|
+
for qualified in inventory.tables:
|
|
463
|
+
schema, name = qualified.split(".", 1)
|
|
464
|
+
if (
|
|
465
|
+
schema.casefold() not in orphan_schemas
|
|
466
|
+
and qualified.casefold() not in managed.tables
|
|
467
|
+
):
|
|
468
|
+
actions.append(
|
|
469
|
+
_drop_action(
|
|
470
|
+
target,
|
|
471
|
+
PRUNE_TABLE,
|
|
472
|
+
"table",
|
|
473
|
+
qualified,
|
|
474
|
+
f"DROP TABLE IF EXISTS {object_token(schema, name)}",
|
|
475
|
+
payloads,
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
changes.append(removed(TABLE_KIND, qualified, actions[-1].id))
|
|
479
|
+
for schema in inventory.folder_schemas:
|
|
480
|
+
if schema.casefold() not in managed.folder_schemas:
|
|
481
|
+
actions.append(_prune_folder_action(target, f"folder:{schema}"))
|
|
482
|
+
changes.append(removed(FOLDER_SCHEMA_KIND, schema, actions[-1].id))
|
|
483
|
+
for qualified in inventory.folders:
|
|
484
|
+
schema, _name = qualified.split(".", 1)
|
|
485
|
+
if (
|
|
486
|
+
schema.casefold() in managed.folder_schemas
|
|
487
|
+
and qualified.casefold() not in managed.folders
|
|
488
|
+
):
|
|
489
|
+
actions.append(_prune_folder_action(target, f"folder:{qualified}"))
|
|
490
|
+
changes.append(removed(FOLDER_KIND, qualified, actions[-1].id))
|
|
491
|
+
for schema in inventory.schemas:
|
|
492
|
+
if schema.casefold() in orphan_schemas:
|
|
493
|
+
actions.append(
|
|
494
|
+
_drop_action(
|
|
495
|
+
target,
|
|
496
|
+
PRUNE_SCHEMA,
|
|
497
|
+
"schema",
|
|
498
|
+
schema,
|
|
499
|
+
f"DROP SCHEMA IF EXISTS {schema_token(schema)} CASCADE",
|
|
500
|
+
payloads,
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
changes.append(removed(SCHEMA_KIND, schema, actions[-1].id))
|
|
504
|
+
return tuple(actions), tuple(changes)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def managed_sets(
|
|
508
|
+
documents: Mapping[str, SourceDocument],
|
|
509
|
+
object_target_kind: str = DELTA_TARGET,
|
|
510
|
+
*,
|
|
511
|
+
alias_destinations: Iterable[WeaverDocumentId] = (),
|
|
512
|
+
load_identities: Iterable[WeaverDocumentId] = (),
|
|
513
|
+
) -> _Managed:
|
|
514
|
+
"""The keep-set for one physical side: Delta objects, or Warehouse ones.
|
|
515
|
+
|
|
516
|
+
``alias_destinations`` are the item's alias destinations. They belong in the
|
|
517
|
+
keep-set because they are desired state in this item exactly as a declared
|
|
518
|
+
document is — merely produced somewhere else — and a build that pruned the
|
|
519
|
+
shortcut or view it was about to create would be both destructive and
|
|
520
|
+
pointless. Which set an alias joins follows its physical form: a folder under
|
|
521
|
+
Files, a view in a Warehouse, a table directory in a Lakehouse.
|
|
522
|
+
|
|
523
|
+
``load_identities`` contribute the one namespace a document cannot: the ``_``
|
|
524
|
+
schema a Warehouse's generated load procedures live in. Nothing *declares* a
|
|
525
|
+
document there, so without this the schema would be an orphan and every build
|
|
526
|
+
would drop the schema it had just created. It is derived from the artefacts
|
|
527
|
+
rather than added unconditionally, which is what lets the schema go when the
|
|
528
|
+
last procedure does. On the Lakehouse side the runtime tree needs nothing
|
|
529
|
+
here — it is a declared folder, and is spared as one.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
tables = {d.qualified for d in documents.values() if d.target_kind == object_target_kind and d.kind == TABLE}
|
|
533
|
+
views = {d.qualified for d in documents.values() if d.target_kind == object_target_kind and d.kind == VIEW}
|
|
534
|
+
folders = {d.qualified for d in documents.values() if d.target_kind == FOLDER_TARGET}
|
|
535
|
+
for destination in alias_destinations:
|
|
536
|
+
qualified = destination.object_id.qualified
|
|
537
|
+
if destination.is_files:
|
|
538
|
+
folders.add(qualified)
|
|
539
|
+
elif object_target_kind == SQL_TARGET:
|
|
540
|
+
views.add(qualified)
|
|
541
|
+
else:
|
|
542
|
+
tables.add(qualified)
|
|
543
|
+
schemas = {name.split(".", 1)[0].lower() for name in tables | views}
|
|
544
|
+
schemas.update(
|
|
545
|
+
identity.object_id.schema.lower()
|
|
546
|
+
for identity in load_identities
|
|
547
|
+
if identity.shape == PROCEDURE_SHAPE
|
|
548
|
+
)
|
|
549
|
+
return _Managed(
|
|
550
|
+
schemas=frozenset(schemas),
|
|
551
|
+
folder_schemas=frozenset(name.split(".", 1)[0].lower() for name in folders),
|
|
552
|
+
folders=frozenset(name.lower() for name in folders),
|
|
553
|
+
tables=frozenset(name.lower() for name in tables),
|
|
554
|
+
views=frozenset(name.lower() for name in views),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _drop_action(
|
|
559
|
+
target,
|
|
560
|
+
kind,
|
|
561
|
+
slug,
|
|
562
|
+
name,
|
|
563
|
+
statement,
|
|
564
|
+
payloads,
|
|
565
|
+
*,
|
|
566
|
+
executor: str = "spark_sql",
|
|
567
|
+
extension: str = ".spark.sql",
|
|
568
|
+
) -> BuildAction:
|
|
569
|
+
content = (statement + "\n").encode("utf-8")
|
|
570
|
+
filename = f"{slug}-{name}{extension}"
|
|
571
|
+
payloads[filename] = content
|
|
572
|
+
return BuildAction(
|
|
573
|
+
id=f"prune-{slug}-{name}",
|
|
574
|
+
kind=kind,
|
|
575
|
+
resource_node_id=None,
|
|
576
|
+
executor=executor,
|
|
577
|
+
payload=filename,
|
|
578
|
+
payload_sha256=sha256_hex(content),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _prune_folder_action(target, resource: str) -> BuildAction:
|
|
583
|
+
return BuildAction(
|
|
584
|
+
id=f"prune-{resource}",
|
|
585
|
+
kind=PRUNE_FOLDER,
|
|
586
|
+
resource_node_id=resource,
|
|
587
|
+
executor="folder",
|
|
588
|
+
payload=None,
|
|
589
|
+
payload_sha256=None,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _child_dirs(store: Store, root) -> list:
|
|
594
|
+
if not store.exists(root) or not store.is_directory(root):
|
|
595
|
+
return []
|
|
596
|
+
return sorted(
|
|
597
|
+
(entry for entry in store.list(root) if entry.is_directory), key=lambda e: e.name
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _catalogue_for(resolver, lakehouse: ItemRef, spark) -> "SparkCatalogue | None":
|
|
602
|
+
"""Catalogue operations against the Lakehouse being reconciled.
|
|
603
|
+
|
|
604
|
+
None without a session — prune still reconciles tables, folders and schemas
|
|
605
|
+
from storage, and simply cannot see views, which is the documented cost of
|
|
606
|
+
generating without one.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
if spark is None:
|
|
610
|
+
return None
|
|
611
|
+
resolve = getattr(resolver, "spark_destination", None)
|
|
612
|
+
if resolve is None: # pragma: no cover - both shipped resolvers provide it
|
|
613
|
+
return None
|
|
614
|
+
return SparkCatalogue(spark, resolve(lakehouse))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _tsql_ident(name: str) -> str:
|
|
618
|
+
"""A bracket-quoted T-SQL identifier."""
|
|
619
|
+
|
|
620
|
+
return "[" + name.replace("]", "]]") + "]"
|