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,1405 @@
|
|
|
1
|
+
"""The Weaver document document contract.
|
|
2
|
+
|
|
3
|
+
This is the basic unit of work in Weaver: a Folder, a Delta table, or a
|
|
4
|
+
Warehouse table or view, declared as YAML at the top of its source file — a
|
|
5
|
+
Python module docstring, or the opening ``/* … */`` of a SQL file.
|
|
6
|
+
|
|
7
|
+
The contract is validated to exhaustion up front. Every key is known, every
|
|
8
|
+
column reference is checked against the declared schema where one exists, and
|
|
9
|
+
every contradiction is refused before anything physical happens. A mistyped
|
|
10
|
+
``Primary Key`` must not parse as "no primary key" and silently become a full
|
|
11
|
+
replacement at load time.
|
|
12
|
+
|
|
13
|
+
Where validation cannot happen here it is recorded rather than skipped: a SQL
|
|
14
|
+
object infers its shape from its query, so its column references are parsed but
|
|
15
|
+
resolved at build. :attr:`SesDocument.defers_column_validation` says so.
|
|
16
|
+
|
|
17
|
+
Nothing here imports the module it describes, reads a file, or resolves a
|
|
18
|
+
reference to another object. Reference resolution needs sibling documents and
|
|
19
|
+
belongs with the repository reader.
|
|
20
|
+
|
|
21
|
+
**Layout convention.** Separate each subsection with a blank line. This is not
|
|
22
|
+
enforced — YAML does not care — but the header is the contract a reader meets
|
|
23
|
+
first, and a wall of keys is a worse contract than a legible one::
|
|
24
|
+
|
|
25
|
+
Table ID: Sales.Order
|
|
26
|
+
|
|
27
|
+
Description: One row per confirmed customer order.
|
|
28
|
+
|
|
29
|
+
Lineage: $Sales.OrderExport
|
|
30
|
+
|
|
31
|
+
Primary key: Order id
|
|
32
|
+
|
|
33
|
+
Schema:
|
|
34
|
+
Order id: string
|
|
35
|
+
Order date: date
|
|
36
|
+
|
|
37
|
+
Revision notes:
|
|
38
|
+
- 2026-07-23 Added the amount column.
|
|
39
|
+
|
|
40
|
+
Fixtures and examples follow it so the convention is learned by reading.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import ast
|
|
46
|
+
import re
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from datetime import date, datetime
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
import yaml
|
|
52
|
+
|
|
53
|
+
from ..errors import MetadataError
|
|
54
|
+
|
|
55
|
+
FOLDER = "Folder"
|
|
56
|
+
TABLE = "Table"
|
|
57
|
+
VIEW = "View"
|
|
58
|
+
OBJECT_KINDS = frozenset({FOLDER, TABLE, VIEW})
|
|
59
|
+
|
|
60
|
+
PYTHON = "python"
|
|
61
|
+
SQL = "sql"
|
|
62
|
+
SPARK_SQL = "spark_sql"
|
|
63
|
+
LANGUAGES = frozenset({PYTHON, SQL, SPARK_SQL})
|
|
64
|
+
|
|
65
|
+
#: Languages whose objects materialise as Delta rather than in a Warehouse.
|
|
66
|
+
#: They declare their shape up front and use the underscored audit spelling.
|
|
67
|
+
DELTA_LANGUAGES = frozenset({PYTHON, SPARK_SQL})
|
|
68
|
+
|
|
69
|
+
# The three physical destinations. An object ID is unique *within* one of these,
|
|
70
|
+
# not across them: Sales.Order may exist as a folder, as a Delta table and as a
|
|
71
|
+
# Warehouse table at the same time, because those are three different places.
|
|
72
|
+
FOLDER_TARGET = "folder"
|
|
73
|
+
DELTA_TARGET = "delta"
|
|
74
|
+
SQL_TARGET = "sql"
|
|
75
|
+
TARGET_KINDS = (FOLDER_TARGET, DELTA_TARGET, SQL_TARGET)
|
|
76
|
+
|
|
77
|
+
# The two execution namespaces a two-part reference may bind in. A Lakehouse
|
|
78
|
+
# object (Folder or Delta) resolves its references inside the Lakehouse; a
|
|
79
|
+
# Warehouse object (SQL) inside the Warehouse. The two are bridged only by an
|
|
80
|
+
# explicit alias, never by inference.
|
|
81
|
+
LAKEHOUSE_NAMESPACE = "lakehouse"
|
|
82
|
+
WAREHOUSE_NAMESPACE = "warehouse"
|
|
83
|
+
NAMESPACES = (LAKEHOUSE_NAMESPACE, WAREHOUSE_NAMESPACE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def namespace_for_target(target_kind: str) -> str:
|
|
87
|
+
"""The namespace a native object of this target binds its references in."""
|
|
88
|
+
|
|
89
|
+
return WAREHOUSE_NAMESPACE if target_kind == SQL_TARGET else LAKEHOUSE_NAMESPACE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def target_kind_for(language: str, kind: str) -> str:
|
|
93
|
+
"""Where an object materialises, from its language and kind.
|
|
94
|
+
|
|
95
|
+
Routing is inferred, never configured — which is what removed the old
|
|
96
|
+
paired source-and-target build command.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
if kind == FOLDER:
|
|
100
|
+
return FOLDER_TARGET
|
|
101
|
+
if language in DELTA_LANGUAGES:
|
|
102
|
+
return DELTA_TARGET
|
|
103
|
+
return SQL_TARGET
|
|
104
|
+
|
|
105
|
+
_ID_KEYS = {"Folder ID": FOLDER, "Table ID": TABLE, "View ID": VIEW}
|
|
106
|
+
_PLACEHOLDERS = {"not declared", "n/a", "tbd", "todo"}
|
|
107
|
+
|
|
108
|
+
# Cross-engine aliases. A Lakehouse object publishes into the Warehouse with a
|
|
109
|
+
# Warehouse alias; a Warehouse object publishes into the Lakehouse with a
|
|
110
|
+
# Lakehouse alias. Eligibility is by target, not just kind, so both keys are
|
|
111
|
+
# accepted here and refused in _parse_aliases when they sit on the wrong object.
|
|
112
|
+
WAREHOUSE_ALIAS = "Warehouse alias"
|
|
113
|
+
LAKEHOUSE_ALIAS = "Lakehouse alias"
|
|
114
|
+
_ALIAS_KEYS = {WAREHOUSE_ALIAS, LAKEHOUSE_ALIAS}
|
|
115
|
+
|
|
116
|
+
#: Stability thresholds — the guard against a load that is *technically* correct
|
|
117
|
+
#: and obviously wrong. A source that broke overnight and returned a tenth of its
|
|
118
|
+
#: rows produces a load Weaver would otherwise carry out faithfully.
|
|
119
|
+
#:
|
|
120
|
+
#: The percentages are of the target's row count *before* the load, and the row
|
|
121
|
+
#: threshold is the size below which neither applies: on a small table a single
|
|
122
|
+
#: row is a large percentage, and tripping on that would teach everyone to turn
|
|
123
|
+
#: the guard off.
|
|
124
|
+
DELETE_THRESHOLD = "Delete percentage threshold"
|
|
125
|
+
UPDATE_THRESHOLD = "Update percentage threshold"
|
|
126
|
+
STABILITY_ROWS = "Stability row threshold"
|
|
127
|
+
|
|
128
|
+
#: Deliberately not zero. A load that has never been run against a populated
|
|
129
|
+
#: table has nothing to compare with, and a first load inserts everything — so
|
|
130
|
+
#: the defaults protect an established table without standing in the way of one
|
|
131
|
+
#: being established.
|
|
132
|
+
DEFAULT_DELETE_THRESHOLD = 5
|
|
133
|
+
DEFAULT_UPDATE_THRESHOLD = 20
|
|
134
|
+
DEFAULT_STABILITY_ROWS = 1_000_000
|
|
135
|
+
|
|
136
|
+
# Keys accepted per kind. Anything else is a typo and is refused by name.
|
|
137
|
+
_COMMON_KEYS = {
|
|
138
|
+
"Description",
|
|
139
|
+
"Lineage",
|
|
140
|
+
"Notes",
|
|
141
|
+
"Revision notes",
|
|
142
|
+
"Dependencies",
|
|
143
|
+
"Static",
|
|
144
|
+
"Prohibit rebuild",
|
|
145
|
+
WAREHOUSE_ALIAS,
|
|
146
|
+
LAKEHOUSE_ALIAS,
|
|
147
|
+
}
|
|
148
|
+
_KIND_KEYS = {
|
|
149
|
+
FOLDER: {"File key", "Incremental"},
|
|
150
|
+
TABLE: {
|
|
151
|
+
"Schema",
|
|
152
|
+
"Column notes",
|
|
153
|
+
"Primary key",
|
|
154
|
+
"Unique keys",
|
|
155
|
+
"Foreign keys",
|
|
156
|
+
"Not null",
|
|
157
|
+
"Identity",
|
|
158
|
+
"Comparison columns",
|
|
159
|
+
"Incremental",
|
|
160
|
+
DELETE_THRESHOLD,
|
|
161
|
+
UPDATE_THRESHOLD,
|
|
162
|
+
STABILITY_ROWS,
|
|
163
|
+
},
|
|
164
|
+
# A view's keys are logical: it stores no rows, so they describe the shape of
|
|
165
|
+
# the result rather than constraining storage. They are declared so the model
|
|
166
|
+
# is complete and can be checked for quality; nothing physical follows.
|
|
167
|
+
VIEW: {"Column notes", "Primary key", "Unique keys", "Foreign keys"},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Retired keys, refused with the migration rather than as "unknown".
|
|
171
|
+
_RETIRED_KEYS = {
|
|
172
|
+
"Auto delete": (
|
|
173
|
+
"Auto delete is no longer supported. Use Incremental with the inverse value:\n"
|
|
174
|
+
"Auto delete: false becomes Incremental: true.\n"
|
|
175
|
+
"Auto delete: true becomes Incremental: false."
|
|
176
|
+
),
|
|
177
|
+
"Load mode": (
|
|
178
|
+
"Load mode is no longer supported. Behaviour follows from Incremental and "
|
|
179
|
+
"Primary key."
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Multiple independent columns are a YAML list; a column *set* — one key or one
|
|
184
|
+
# comparison tuple — is comma-separated.
|
|
185
|
+
_LIST_KEYS = {"Not null"}
|
|
186
|
+
_SET_KEYS = {"Primary key", "Comparison columns"}
|
|
187
|
+
|
|
188
|
+
_REFERENCE = re.compile(r"^\$([^\[\]$]+?)(?:\[([^\[\]$]+)\])?$")
|
|
189
|
+
|
|
190
|
+
# A revision entry opens with a date. Which spelling is the developer's choice;
|
|
191
|
+
# holding to one spelling within a document is not, because a mixed list cannot
|
|
192
|
+
# be read in order at a glance. Day-first and month-first share a shape and are
|
|
193
|
+
# not told apart — Weaver checks the shape, not the reading.
|
|
194
|
+
_REVISION_DATE_SHAPES = (
|
|
195
|
+
("YYYY-MM-DD", re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})(?=\s|$)"), True),
|
|
196
|
+
("YYYY/MM/DD", re.compile(r"^(\d{4})/(\d{1,2})/(\d{1,2})(?=\s|$)"), True),
|
|
197
|
+
("DD/MM/YYYY", re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})(?=\s|$)"), False),
|
|
198
|
+
("DD-MM-YYYY", re.compile(r"^(\d{1,2})-(\d{1,2})-(\d{4})(?=\s|$)"), False),
|
|
199
|
+
("DD.MM.YYYY", re.compile(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?=\s|$)"), False),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- audit columns ---------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
#: Logical audit columns, materialised on every table but never authored.
|
|
206
|
+
#: Physical spelling follows the representation: a Warehouse keeps the spaced
|
|
207
|
+
#: form already used by the SQL backend, Delta uses lower snake case because
|
|
208
|
+
#: spaces in Spark column names need quoting everywhere they appear and Delta's
|
|
209
|
+
#: own convention is snake case throughout.
|
|
210
|
+
AUDIT_INSERT = "Row insert datetime"
|
|
211
|
+
AUDIT_UPDATE = "Row update datetime"
|
|
212
|
+
AUDIT_DELETE = "Row delete datetime"
|
|
213
|
+
AUDIT_COLUMNS = (AUDIT_INSERT, AUDIT_UPDATE, AUDIT_DELETE)
|
|
214
|
+
|
|
215
|
+
_AUDIT_TYPES = {PYTHON: "timestamp", SPARK_SQL: "timestamp", SQL: "datetime2(6)"}
|
|
216
|
+
|
|
217
|
+
#: The delete datetime of a row that is still live. All three audit columns are
|
|
218
|
+
#: physically not null — there is no valid null state for any of them — so a live
|
|
219
|
+
#: row carries a sentinel maximum rather than an absence. That makes "as at" a
|
|
220
|
+
#: single range predicate instead of a null check, which is why the SQL Server
|
|
221
|
+
#: original chose it.
|
|
222
|
+
#:
|
|
223
|
+
#: Load will populate these columns for ordinary rows and is not yet written;
|
|
224
|
+
#: this constant exists now because the catalogue's own DML must satisfy the
|
|
225
|
+
#: not-null constraint today. It is the row convention, not a catalogue detail,
|
|
226
|
+
#: which is why it lives here.
|
|
227
|
+
AUDIT_LIVE_DELETE_DATETIME = "9999-12-31 23:59:59.999999"
|
|
228
|
+
|
|
229
|
+
#: The identity column is a surrogate the *engine* generates: build declares it
|
|
230
|
+
#: ``bigint identity not null`` and the Warehouse assigns a value to every
|
|
231
|
+
#: inserted row. It is Weaver's column, so it is not part of the declared
|
|
232
|
+
#: business schema or a query's output, and a load never inserts into it.
|
|
233
|
+
IDENTITY_TYPE = "bigint"
|
|
234
|
+
|
|
235
|
+
#: Which representations can carry an identity column, and it is the Warehouse
|
|
236
|
+
#: alone. Native identity is what makes the column trustworthy — a value Weaver
|
|
237
|
+
#: computed would have to be unique across concurrent writers, which is the
|
|
238
|
+
#: guarantee an engine's identity exists to provide, and neither Delta 3.2 (this
|
|
239
|
+
#: repository's floor) nor Fabric's Spark runtime offers one to generate it with.
|
|
240
|
+
#: So a Delta table declares no identity at all rather than carrying a column
|
|
241
|
+
#: whose contents Weaver could not honestly promise.
|
|
242
|
+
IDENTITY_LANGUAGES = frozenset({SQL})
|
|
243
|
+
|
|
244
|
+
_IDENTITY_UNSUPPORTED = (
|
|
245
|
+
"Identity is supported for Warehouse tables only. A Delta table has no "
|
|
246
|
+
"engine-generated identity to sit behind the column — Spark and Delta offer "
|
|
247
|
+
"none Weaver can rely on — so remove the Identity header and use the "
|
|
248
|
+
"business key, or declare the object in a Warehouse item."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def audit_column_name(logical: str, language: str) -> str:
|
|
253
|
+
"""The physical spelling of one logical audit column for a representation.
|
|
254
|
+
|
|
255
|
+
Delta gets lower snake case (``row_insert_datetime``); a Warehouse keeps the
|
|
256
|
+
spaced form (``Row insert datetime``) the SQL backend has always used.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
if language in DELTA_LANGUAGES:
|
|
260
|
+
return logical.replace(" ", "_").lower()
|
|
261
|
+
return logical
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
#: Every spelling of an audit column an author might reach for, folded for
|
|
265
|
+
#: comparison. All are reserved, including the retired ``Row_insert_datetime``
|
|
266
|
+
#: form, so a declaration can never collide with the columns Weaver adds.
|
|
267
|
+
_RESERVED_AUDIT_NAMES = frozenset(
|
|
268
|
+
spelling.lower()
|
|
269
|
+
for logical in AUDIT_COLUMNS
|
|
270
|
+
for spelling in (logical, logical.replace(" ", "_"))
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _audit_columns(language: str) -> tuple["Column", ...]:
|
|
275
|
+
return tuple(
|
|
276
|
+
Column(
|
|
277
|
+
name=audit_column_name(logical, language),
|
|
278
|
+
type=_AUDIT_TYPES[language],
|
|
279
|
+
# Weaver populates all three on every loaded row — insert and update
|
|
280
|
+
# datetimes, and a sentinel maximum delete datetime for a live row —
|
|
281
|
+
# so none has a valid null state and all are physically not null.
|
|
282
|
+
not_null=True,
|
|
283
|
+
is_audit=True,
|
|
284
|
+
)
|
|
285
|
+
for logical in AUDIT_COLUMNS
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- values ----------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class ObjectId:
|
|
294
|
+
"""Levels two and one — ``Schema.Object`` within a repository."""
|
|
295
|
+
|
|
296
|
+
schema: str
|
|
297
|
+
object: str
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def qualified(self) -> str:
|
|
301
|
+
return f"{self.schema}.{self.object}"
|
|
302
|
+
|
|
303
|
+
def __str__(self) -> str:
|
|
304
|
+
return self.qualified
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass(frozen=True)
|
|
308
|
+
class Reference:
|
|
309
|
+
"""An item-relative or item-qualified exact-case metadata reference."""
|
|
310
|
+
|
|
311
|
+
schema: str
|
|
312
|
+
object: str
|
|
313
|
+
column: str | None = None
|
|
314
|
+
item_type: str | None = None
|
|
315
|
+
item_name: str | None = None
|
|
316
|
+
is_files: bool = False
|
|
317
|
+
|
|
318
|
+
def __post_init__(self) -> None:
|
|
319
|
+
if (self.item_type is None) != (self.item_name is None):
|
|
320
|
+
raise MetadataError("a qualified reference needs both item type and item name")
|
|
321
|
+
if self.item_type is not None and self.item_type not in ("Lakehouse", "Warehouse"):
|
|
322
|
+
raise MetadataError(
|
|
323
|
+
f"reference item type must be Lakehouse or Warehouse, got {self.item_type!r}"
|
|
324
|
+
)
|
|
325
|
+
if self.is_files and self.item_type == "Warehouse":
|
|
326
|
+
raise MetadataError("Files references may only name a Lakehouse item")
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def object_id(self) -> ObjectId:
|
|
330
|
+
return ObjectId(schema=self.schema, object=self.object)
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def target(self) -> str:
|
|
334
|
+
within = f"Files/{self.schema}.{self.object}" if self.is_files else self.object_id.qualified
|
|
335
|
+
if self.item_type is None:
|
|
336
|
+
return within
|
|
337
|
+
return f"{self.item_type}/{self.item_name}/{within}"
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def is_item_qualified(self) -> bool:
|
|
341
|
+
return self.item_type is not None
|
|
342
|
+
|
|
343
|
+
def __str__(self) -> str:
|
|
344
|
+
target = f"${self.target}"
|
|
345
|
+
return f"{target}[{self.column}]" if self.column else target
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True)
|
|
349
|
+
class MetadataText:
|
|
350
|
+
"""Either literal prose or exactly one reference — never a mix.
|
|
351
|
+
|
|
352
|
+
``See $Sales.Order`` is refused. Mixed content cannot be resolved
|
|
353
|
+
mechanically, and a contract that is only sometimes machine-readable is not
|
|
354
|
+
a contract. Write ``$$`` for a literal dollar sign.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
literal: str | None = None
|
|
358
|
+
reference: Reference | None = None
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def is_reference(self) -> bool:
|
|
362
|
+
return self.reference is not None
|
|
363
|
+
|
|
364
|
+
def __str__(self) -> str:
|
|
365
|
+
return str(self.reference) if self.reference else (self.literal or "")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass(frozen=True)
|
|
369
|
+
class Revision:
|
|
370
|
+
"""One dated entry in the object's revision history."""
|
|
371
|
+
|
|
372
|
+
date: str
|
|
373
|
+
note: str
|
|
374
|
+
|
|
375
|
+
def __str__(self) -> str:
|
|
376
|
+
return f"{self.date} {self.note}"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True)
|
|
380
|
+
class ForeignKey:
|
|
381
|
+
"""One declared relationship to a parent object.
|
|
382
|
+
|
|
383
|
+
Semantic rather than physical — closer to an ER diagram than to a database
|
|
384
|
+
constraint. Nothing is enforced by the engine and no index follows; the
|
|
385
|
+
declaration records that these columns mean the parent's columns.
|
|
386
|
+
|
|
387
|
+
Consequently a key has no name, two objects may be related several times
|
|
388
|
+
over, and an object may reference itself (a parent-child hierarchy in one
|
|
389
|
+
table). The parent is a two-part ``Schema.Object``: it is a logical name in
|
|
390
|
+
the same repository, not a physical one.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
columns: tuple[str, ...]
|
|
394
|
+
reference: ObjectId
|
|
395
|
+
reference_columns: tuple[str, ...]
|
|
396
|
+
logical_reference: Reference | None = None
|
|
397
|
+
|
|
398
|
+
def __str__(self) -> str:
|
|
399
|
+
child = ", ".join(self.columns)
|
|
400
|
+
parent = ", ".join(self.reference_columns)
|
|
401
|
+
target = (
|
|
402
|
+
self.logical_reference.target
|
|
403
|
+
if self.logical_reference is not None
|
|
404
|
+
else self.reference.qualified
|
|
405
|
+
)
|
|
406
|
+
return f"{child}: {target}[{parent}]"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass(frozen=True)
|
|
410
|
+
class Column:
|
|
411
|
+
"""One column of a table or view."""
|
|
412
|
+
|
|
413
|
+
name: str
|
|
414
|
+
type: str | None = None
|
|
415
|
+
note: MetadataText | None = None
|
|
416
|
+
not_null: bool = False
|
|
417
|
+
is_audit: bool = False
|
|
418
|
+
is_identity: bool = False
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# --- the document ----------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class WeaverDocument:
|
|
426
|
+
"""A fully validated Weaver document object declaration."""
|
|
427
|
+
|
|
428
|
+
kind: str
|
|
429
|
+
language: str
|
|
430
|
+
object_id: ObjectId
|
|
431
|
+
description: MetadataText
|
|
432
|
+
lineage: MetadataText
|
|
433
|
+
notes: str | None = None
|
|
434
|
+
dependencies: tuple[ObjectId, ...] = ()
|
|
435
|
+
#: True when the document wrote a ``Dependencies`` key at all, including an
|
|
436
|
+
#: empty list. An explicit none must suppress discovery the same way a
|
|
437
|
+
#: populated list replaces it — otherwise `Dependencies: []` would silently
|
|
438
|
+
#: mean "discover them for me".
|
|
439
|
+
declares_dependencies: bool = False
|
|
440
|
+
revision_notes: tuple[Revision, ...] = ()
|
|
441
|
+
revision_date_format: str | None = None
|
|
442
|
+
schema: tuple[Column, ...] = ()
|
|
443
|
+
primary_key: tuple[str, ...] = ()
|
|
444
|
+
unique_keys: tuple[tuple[str, ...], ...] = ()
|
|
445
|
+
foreign_keys: tuple[ForeignKey, ...] = ()
|
|
446
|
+
declared_not_null: tuple[str, ...] = ()
|
|
447
|
+
identity: str | None = None
|
|
448
|
+
declared_comparison_columns: tuple[str, ...] = ()
|
|
449
|
+
delete_threshold: int = DEFAULT_DELETE_THRESHOLD
|
|
450
|
+
update_threshold: int = DEFAULT_UPDATE_THRESHOLD
|
|
451
|
+
stability_rows: int = DEFAULT_STABILITY_ROWS
|
|
452
|
+
file_keys: tuple[str, ...] = ()
|
|
453
|
+
is_incremental: bool = False
|
|
454
|
+
prohibit_rebuild: bool = False
|
|
455
|
+
static: bool = False
|
|
456
|
+
warehouse_alias: ObjectId | None = None
|
|
457
|
+
lakehouse_alias: ObjectId | None = None
|
|
458
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def qualified(self) -> str:
|
|
462
|
+
return self.object_id.qualified
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def has_primary_key(self) -> bool:
|
|
466
|
+
return bool(self.primary_key)
|
|
467
|
+
|
|
468
|
+
@property
|
|
469
|
+
def has_declared_schema(self) -> bool:
|
|
470
|
+
return bool(self.schema)
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def defers_column_validation(self) -> bool:
|
|
474
|
+
"""True when column references cannot be checked until build.
|
|
475
|
+
|
|
476
|
+
A SQL object infers its shape from its query, so its `Primary key`,
|
|
477
|
+
`Not null`, `Identity`, `Comparison columns` and `Column notes` are
|
|
478
|
+
validated against the built table rather than here.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
return self.kind in (TABLE, VIEW) and not self.has_declared_schema
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def audit_columns(self) -> tuple[Column, ...]:
|
|
485
|
+
"""The architectural columns, spelled for this representation."""
|
|
486
|
+
|
|
487
|
+
return _audit_columns(self.language) if self.kind == TABLE else ()
|
|
488
|
+
|
|
489
|
+
@property
|
|
490
|
+
def identity_column(self) -> Column | None:
|
|
491
|
+
"""The engine-generated surrogate column, when Identity names one.
|
|
492
|
+
|
|
493
|
+
A not-null ``bigint`` the Warehouse generates: build declares it
|
|
494
|
+
``identity`` and every insert leaves it out so the engine assigns
|
|
495
|
+
it. It is Weaver's own column, so it stands outside the business schema
|
|
496
|
+
(declared or inferred); the primary key may name it when the surrogate is
|
|
497
|
+
the key. Only a Warehouse table has one — see :data:`IDENTITY_LANGUAGES`.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
if self.identity is None or self.kind != TABLE:
|
|
501
|
+
return None
|
|
502
|
+
return Column(
|
|
503
|
+
name=self.identity, type=IDENTITY_TYPE, not_null=True, is_identity=True
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def effective_schema(self) -> tuple[Column, ...]:
|
|
508
|
+
"""The full physical shape of a declared table: identity, business, audit.
|
|
509
|
+
|
|
510
|
+
``schema`` stays exactly what the author wrote; this is what gets
|
|
511
|
+
materialised. The Weaver-managed identity column leads, the audit columns
|
|
512
|
+
trail. Both forms are available because either can be the one you need.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
identity = (self.identity_column,) if self.identity_column else ()
|
|
516
|
+
return identity + self.schema + self.audit_columns
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def not_null(self) -> tuple[str, ...]:
|
|
520
|
+
"""Declared not-null columns plus the primary key, which always is."""
|
|
521
|
+
|
|
522
|
+
return self.primary_key + self.declared_not_null
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def comparison_columns(self) -> tuple[str, ...]:
|
|
526
|
+
"""Columns whose change drives an upsert.
|
|
527
|
+
|
|
528
|
+
Defaults to every declared non-key column. Naming a narrower set makes
|
|
529
|
+
the comparison cheaper when a watermark column already implies change.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
if self.declared_comparison_columns:
|
|
533
|
+
return self.declared_comparison_columns
|
|
534
|
+
return tuple(
|
|
535
|
+
column.name
|
|
536
|
+
for column in self.schema
|
|
537
|
+
if column.name not in self.primary_key and not column.is_audit
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# Transitional public spelling. R8 removes it after callers have migrated;
|
|
542
|
+
# keeping the alias here lets identity and discovery move independently.
|
|
543
|
+
SesDocument = WeaverDocument
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# --- extraction ------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class _UniqueKeyLoader(yaml.SafeLoader):
|
|
550
|
+
"""YAML loader that refuses duplicate mapping keys."""
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _no_duplicate_keys(loader, node, deep=False):
|
|
554
|
+
loader.flatten_mapping(node)
|
|
555
|
+
mapping: dict[Any, Any] = {}
|
|
556
|
+
for key_node, value_node in node.value:
|
|
557
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
558
|
+
if key in mapping:
|
|
559
|
+
raise MetadataError(f"duplicate metadata key: {key}")
|
|
560
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
561
|
+
return mapping
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
_UniqueKeyLoader.add_constructor(
|
|
565
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
|
566
|
+
_no_duplicate_keys,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def extract_python_metadata(source: str) -> str:
|
|
571
|
+
"""The metadata YAML from a Python object file's module docstring."""
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
module = ast.parse(source)
|
|
575
|
+
except SyntaxError as exc:
|
|
576
|
+
raise MetadataError(f"python object file is not parseable: {exc}") from exc
|
|
577
|
+
doc = ast.get_docstring(module, clean=True)
|
|
578
|
+
if doc is None or not doc.strip():
|
|
579
|
+
raise MetadataError("python object file must begin with a docstring metadata block")
|
|
580
|
+
return doc
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def extract_sql_metadata_and_body(source: str) -> tuple[str, str]:
|
|
584
|
+
"""Split a SQL object file into (metadata text, executable body)."""
|
|
585
|
+
|
|
586
|
+
match = re.match(r"\s*/\*(.*?)\*/(.*)\Z", source, flags=re.DOTALL)
|
|
587
|
+
if not match:
|
|
588
|
+
raise MetadataError("Weaver document SQL must begin with a /* ... */ metadata block")
|
|
589
|
+
return match.group(1).strip("\n"), match.group(2).lstrip()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def parse_python_document(source: str) -> SesDocument:
|
|
593
|
+
return parse_document(extract_python_metadata(source), language=PYTHON)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def parse_sql_document(source: str) -> tuple[SesDocument, str]:
|
|
597
|
+
text, body = extract_sql_metadata_and_body(source)
|
|
598
|
+
return parse_document(text, language=SQL), body
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# --- parsing ---------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def parse_document(text: str, *, language: str) -> SesDocument:
|
|
605
|
+
"""Parse and exhaustively validate one metadata block."""
|
|
606
|
+
|
|
607
|
+
if language not in LANGUAGES:
|
|
608
|
+
raise MetadataError(f"language must be one of {', '.join(sorted(LANGUAGES))}")
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
loaded = yaml.load(text, Loader=_UniqueKeyLoader)
|
|
612
|
+
except MetadataError:
|
|
613
|
+
raise
|
|
614
|
+
except yaml.YAMLError as exc:
|
|
615
|
+
raise MetadataError(f"invalid metadata YAML: {exc}") from exc
|
|
616
|
+
|
|
617
|
+
if not isinstance(loaded, dict):
|
|
618
|
+
raise MetadataError("metadata must be a YAML mapping")
|
|
619
|
+
|
|
620
|
+
for retired, message in _RETIRED_KEYS.items():
|
|
621
|
+
if retired in loaded:
|
|
622
|
+
raise MetadataError(message)
|
|
623
|
+
|
|
624
|
+
kind, object_id = _parse_id(loaded)
|
|
625
|
+
_reject_unknown_keys(loaded, kind)
|
|
626
|
+
|
|
627
|
+
# A Warehouse (T-SQL) table may declare Schema or omit it: with a declaration
|
|
628
|
+
# the declared types are authoritative; without one the table takes its shape
|
|
629
|
+
# from its query, inferred at build (see how-does-build-work §2).
|
|
630
|
+
|
|
631
|
+
declares_dependencies = "Dependencies" in loaded
|
|
632
|
+
dependencies = _parse_dependencies(loaded.get("Dependencies"), object_id)
|
|
633
|
+
if language == SPARK_SQL and not declares_dependencies:
|
|
634
|
+
raise MetadataError(
|
|
635
|
+
"a Spark SQL object must declare Dependencies. Its query may read by "
|
|
636
|
+
"path, which cannot be resolved back to a managed object, so the graph "
|
|
637
|
+
"is declared rather than discovered. Write `Dependencies: []` if it "
|
|
638
|
+
"genuinely depends on nothing."
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
description = _parse_text(loaded, "Description")
|
|
642
|
+
lineage = _parse_text(loaded, "Lineage")
|
|
643
|
+
notes = _parse_notes(loaded.get("Notes"))
|
|
644
|
+
revisions, revision_format = _parse_revision_notes(loaded.get("Revision notes"))
|
|
645
|
+
static = _parse_bool(loaded.get("Static"), "Static")
|
|
646
|
+
prohibit_rebuild = _parse_flag_with_default(
|
|
647
|
+
loaded, "Prohibit rebuild", default=kind == FOLDER
|
|
648
|
+
)
|
|
649
|
+
file_keys = _parse_file_keys(loaded.get("File key"), kind=kind)
|
|
650
|
+
|
|
651
|
+
if kind == VIEW and "Incremental" in loaded:
|
|
652
|
+
raise MetadataError("Incremental is not supported for View objects")
|
|
653
|
+
is_incremental = _parse_flag_with_default(loaded, "Incremental", default=kind == FOLDER)
|
|
654
|
+
|
|
655
|
+
declared_columns = _parse_schema(loaded.get("Schema"))
|
|
656
|
+
if kind == TABLE and language == PYTHON and not declared_columns:
|
|
657
|
+
raise MetadataError(
|
|
658
|
+
"a Python-backed Delta table must declare Schema — it has no query to "
|
|
659
|
+
"infer a shape from, is created before it is loaded, and the declared "
|
|
660
|
+
"shape is what lets every column guard run up front"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
primary_key = _parse_column_set(loaded.get("Primary key"), "Primary key")
|
|
664
|
+
unique_keys = _parse_unique_keys(loaded.get("Unique keys"), primary_key)
|
|
665
|
+
foreign_keys = _parse_foreign_keys(loaded.get("Foreign keys"), object_id)
|
|
666
|
+
declared_not_null = _parse_column_list(loaded.get("Not null"), "Not null")
|
|
667
|
+
delete_threshold = _parse_percentage(loaded, DELETE_THRESHOLD, DEFAULT_DELETE_THRESHOLD)
|
|
668
|
+
update_threshold = _parse_percentage(loaded, UPDATE_THRESHOLD, DEFAULT_UPDATE_THRESHOLD)
|
|
669
|
+
stability_rows = _parse_row_count(loaded, STABILITY_ROWS, DEFAULT_STABILITY_ROWS)
|
|
670
|
+
identity = _parse_identity(loaded.get("Identity"))
|
|
671
|
+
if identity is not None and language not in IDENTITY_LANGUAGES:
|
|
672
|
+
raise MetadataError(_IDENTITY_UNSUPPORTED)
|
|
673
|
+
comparison = _parse_column_set(loaded.get("Comparison columns"), "Comparison columns")
|
|
674
|
+
column_notes = _parse_column_notes(loaded.get("Column notes"))
|
|
675
|
+
|
|
676
|
+
_validate_columns(
|
|
677
|
+
kind=kind,
|
|
678
|
+
declared_columns=declared_columns,
|
|
679
|
+
primary_key=primary_key,
|
|
680
|
+
unique_keys=unique_keys,
|
|
681
|
+
foreign_keys=foreign_keys,
|
|
682
|
+
declared_not_null=declared_not_null,
|
|
683
|
+
identity=identity,
|
|
684
|
+
comparison=comparison,
|
|
685
|
+
notes=column_notes,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if kind == TABLE:
|
|
689
|
+
if is_incremental and not primary_key:
|
|
690
|
+
raise MetadataError("Incremental: true requires a Primary key")
|
|
691
|
+
if comparison and not primary_key:
|
|
692
|
+
raise MetadataError(
|
|
693
|
+
"Comparison columns require a Primary key — they drive upsert comparison, "
|
|
694
|
+
"which only happens when rows can be matched"
|
|
695
|
+
)
|
|
696
|
+
if static and is_incremental:
|
|
697
|
+
raise MetadataError(
|
|
698
|
+
"Static and Incremental: true contradict — a static object is loaded once, "
|
|
699
|
+
"so there is nothing to accumulate"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
schema = _apply_column_details(declared_columns, column_notes, primary_key, declared_not_null)
|
|
703
|
+
|
|
704
|
+
warehouse_alias, lakehouse_alias = _parse_aliases(loaded, language, kind, object_id)
|
|
705
|
+
|
|
706
|
+
return SesDocument(
|
|
707
|
+
kind=kind,
|
|
708
|
+
language=language,
|
|
709
|
+
object_id=object_id,
|
|
710
|
+
description=description,
|
|
711
|
+
lineage=lineage,
|
|
712
|
+
notes=notes,
|
|
713
|
+
dependencies=dependencies,
|
|
714
|
+
declares_dependencies=declares_dependencies,
|
|
715
|
+
revision_notes=revisions,
|
|
716
|
+
revision_date_format=revision_format,
|
|
717
|
+
schema=schema,
|
|
718
|
+
primary_key=primary_key,
|
|
719
|
+
unique_keys=unique_keys,
|
|
720
|
+
foreign_keys=foreign_keys,
|
|
721
|
+
declared_not_null=declared_not_null,
|
|
722
|
+
identity=identity,
|
|
723
|
+
declared_comparison_columns=comparison,
|
|
724
|
+
delete_threshold=delete_threshold,
|
|
725
|
+
update_threshold=update_threshold,
|
|
726
|
+
stability_rows=stability_rows,
|
|
727
|
+
file_keys=file_keys,
|
|
728
|
+
is_incremental=is_incremental,
|
|
729
|
+
prohibit_rebuild=prohibit_rebuild,
|
|
730
|
+
static=static,
|
|
731
|
+
warehouse_alias=warehouse_alias,
|
|
732
|
+
lakehouse_alias=lakehouse_alias,
|
|
733
|
+
raw=dict(loaded),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _parse_id(raw: dict[str, Any]) -> tuple[str, ObjectId]:
|
|
738
|
+
present = [key for key in _ID_KEYS if key in raw and raw[key] is not None]
|
|
739
|
+
if len(present) != 1:
|
|
740
|
+
raise MetadataError("metadata must include exactly one of Folder ID, Table ID, View ID")
|
|
741
|
+
key = present[0]
|
|
742
|
+
value = raw[key]
|
|
743
|
+
if not isinstance(value, str) or not value.strip():
|
|
744
|
+
raise MetadataError(f"{key} must be a non-empty Schema.Object string")
|
|
745
|
+
parts = [part.strip() for part in value.strip().split(".")]
|
|
746
|
+
if len(parts) != 2 or not all(parts):
|
|
747
|
+
raise MetadataError(f"{key} must be a two-part Schema.Object declaration, got {value!r}")
|
|
748
|
+
return _ID_KEYS[key], ObjectId(schema=parts[0], object=parts[1])
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _reject_unknown_keys(raw: dict[str, Any], kind: str) -> None:
|
|
752
|
+
allowed = _COMMON_KEYS | _KIND_KEYS[kind] | set(_ID_KEYS)
|
|
753
|
+
unknown = {str(key) for key in raw} - allowed
|
|
754
|
+
if unknown:
|
|
755
|
+
wrong_kind = {
|
|
756
|
+
key
|
|
757
|
+
for key in unknown
|
|
758
|
+
for other_kind, keys in _KIND_KEYS.items()
|
|
759
|
+
if key in keys and other_kind != kind
|
|
760
|
+
}
|
|
761
|
+
detail = ""
|
|
762
|
+
if wrong_kind:
|
|
763
|
+
detail = f" ({', '.join(sorted(wrong_kind))} belongs to another object kind)"
|
|
764
|
+
raise MetadataError(
|
|
765
|
+
f"unknown metadata key(s) for a {kind} object: "
|
|
766
|
+
+ ", ".join(sorted(unknown))
|
|
767
|
+
+ detail
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _parse_text(raw: dict[str, Any], key: str) -> MetadataText:
|
|
772
|
+
value = raw.get(key)
|
|
773
|
+
if not isinstance(value, str) or not value.strip():
|
|
774
|
+
raise MetadataError(f"{key} is required and must be non-empty text")
|
|
775
|
+
return _parse_text_value(value, key)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _parse_text_value(value: str, key: str) -> MetadataText:
|
|
779
|
+
stripped = value.strip()
|
|
780
|
+
if "$" in stripped.replace("$$", ""):
|
|
781
|
+
match = _REFERENCE.match(stripped)
|
|
782
|
+
if not match:
|
|
783
|
+
raise MetadataError(
|
|
784
|
+
f"{key} must be either prose or exactly one $Schema.Object reference, "
|
|
785
|
+
f"not a mix of both: {stripped!r}. Write $$ for a literal dollar sign."
|
|
786
|
+
)
|
|
787
|
+
target, column = match.groups()
|
|
788
|
+
return MetadataText(
|
|
789
|
+
reference=_parse_logical_reference(
|
|
790
|
+
target.strip(), column=column.strip() if column else None, key=key
|
|
791
|
+
)
|
|
792
|
+
)
|
|
793
|
+
literal = stripped.replace("$$", "$")
|
|
794
|
+
if literal.lower() in _PLACEHOLDERS:
|
|
795
|
+
raise MetadataError(f"{key} must not be a placeholder value ({literal!r})")
|
|
796
|
+
return MetadataText(literal=literal)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _parse_logical_reference(
|
|
800
|
+
target: str, *, column: str | None = None, key: str
|
|
801
|
+
) -> Reference:
|
|
802
|
+
"""Parse the shared short/canonical logical-reference grammar."""
|
|
803
|
+
|
|
804
|
+
parts = target.split("/")
|
|
805
|
+
item_type: str | None = None
|
|
806
|
+
item_name: str | None = None
|
|
807
|
+
is_files = False
|
|
808
|
+
object_text: str
|
|
809
|
+
if len(parts) == 1:
|
|
810
|
+
object_text = parts[0]
|
|
811
|
+
elif len(parts) == 2 and parts[0] == "Files":
|
|
812
|
+
is_files = True
|
|
813
|
+
object_text = parts[1]
|
|
814
|
+
elif len(parts) == 3:
|
|
815
|
+
item_type, item_name, object_text = parts
|
|
816
|
+
elif len(parts) == 4 and parts[2] == "Files":
|
|
817
|
+
item_type, item_name, _, object_text = parts
|
|
818
|
+
is_files = True
|
|
819
|
+
else:
|
|
820
|
+
raise MetadataError(
|
|
821
|
+
f"{key} reference must be Schema.Object, Files/Schema.Object or an "
|
|
822
|
+
f"item-qualified logical identity, got {target!r}"
|
|
823
|
+
)
|
|
824
|
+
if object_text.count(".") != 1:
|
|
825
|
+
raise MetadataError(f"{key} reference must end in Schema.Object, got {target!r}")
|
|
826
|
+
schema, object_name = object_text.split(".")
|
|
827
|
+
names = (schema, object_name, item_name) if item_name is not None else (schema, object_name)
|
|
828
|
+
if any(not name or name != name.strip() for name in names):
|
|
829
|
+
raise MetadataError(f"{key} reference contains an empty or padded logical name")
|
|
830
|
+
return Reference(
|
|
831
|
+
schema=schema,
|
|
832
|
+
object=object_name,
|
|
833
|
+
column=column,
|
|
834
|
+
item_type=item_type,
|
|
835
|
+
item_name=item_name,
|
|
836
|
+
is_files=is_files,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _parse_dependencies(value: Any, object_id: ObjectId) -> tuple[ObjectId, ...]:
|
|
841
|
+
"""Objects this one depends on, declared rather than discovered.
|
|
842
|
+
|
|
843
|
+
Additive: whatever discovery finds is added to these, never replaced by
|
|
844
|
+
them. A missing dependency is a wrong build order, which is silent data
|
|
845
|
+
corruption, so the declared set can only ever widen the graph.
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
if value is None:
|
|
849
|
+
return ()
|
|
850
|
+
if not isinstance(value, list):
|
|
851
|
+
raise MetadataError(
|
|
852
|
+
"Dependencies must be a YAML list of Schema.Object names:\n"
|
|
853
|
+
"Dependencies:\n - Sales.Customer"
|
|
854
|
+
)
|
|
855
|
+
if not value:
|
|
856
|
+
# `Dependencies: []` is a positive declaration of none, which is what the
|
|
857
|
+
# requirement below wants from a Spark SQL author: be explicit. A query
|
|
858
|
+
# built entirely from literals genuinely depends on nothing.
|
|
859
|
+
return ()
|
|
860
|
+
seen: list[ObjectId] = []
|
|
861
|
+
for entry in value:
|
|
862
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
863
|
+
raise MetadataError("Dependencies entries must be non-empty Schema.Object names")
|
|
864
|
+
parts = [part.strip() for part in entry.strip().split(".")]
|
|
865
|
+
if len(parts) != 2 or not all(parts):
|
|
866
|
+
raise MetadataError(
|
|
867
|
+
f"a Dependencies entry must be a two-part Schema.Object name, got {entry!r}"
|
|
868
|
+
)
|
|
869
|
+
dependency = ObjectId(schema=parts[0], object=parts[1])
|
|
870
|
+
if dependency == object_id:
|
|
871
|
+
raise MetadataError(f"{object_id.qualified} cannot depend on itself")
|
|
872
|
+
if dependency in seen:
|
|
873
|
+
raise MetadataError(f"Dependencies repeats {dependency.qualified}")
|
|
874
|
+
seen.append(dependency)
|
|
875
|
+
return tuple(seen)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _parse_aliases(
|
|
879
|
+
raw: dict[str, Any], language: str, kind: str, object_id: ObjectId
|
|
880
|
+
) -> tuple[ObjectId | None, ObjectId | None]:
|
|
881
|
+
"""The cross-engine aliases this object publishes, checked for eligibility.
|
|
882
|
+
|
|
883
|
+
A Lakehouse object (a Delta table or Spark view) may publish a
|
|
884
|
+
``Warehouse alias``; a Warehouse object (a SQL table or view) may publish a
|
|
885
|
+
``Lakehouse alias``. Neither belongs on a Folder, and neither belongs on the
|
|
886
|
+
opposite engine. The alias may name a different Schema.Object from the
|
|
887
|
+
native one — a Staging table can surface as Sales.Customer — so it is parsed
|
|
888
|
+
through the same two-part model rather than assumed equal.
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
target = target_kind_for(language, kind)
|
|
892
|
+
warehouse_alias = _parse_alias(raw.get(WAREHOUSE_ALIAS), WAREHOUSE_ALIAS)
|
|
893
|
+
lakehouse_alias = _parse_alias(raw.get(LAKEHOUSE_ALIAS), LAKEHOUSE_ALIAS)
|
|
894
|
+
|
|
895
|
+
if warehouse_alias is not None and target != DELTA_TARGET:
|
|
896
|
+
raise MetadataError(
|
|
897
|
+
f"{WAREHOUSE_ALIAS} publishes a Lakehouse object into the Warehouse, so it "
|
|
898
|
+
f"belongs on a Delta table or Spark view, not on {object_id.qualified} "
|
|
899
|
+
+ (
|
|
900
|
+
"(a Warehouse object uses Lakehouse alias)"
|
|
901
|
+
if target == SQL_TARGET
|
|
902
|
+
else "(a Folder is not published across engines)"
|
|
903
|
+
)
|
|
904
|
+
)
|
|
905
|
+
if lakehouse_alias is not None and target != SQL_TARGET:
|
|
906
|
+
raise MetadataError(
|
|
907
|
+
f"{LAKEHOUSE_ALIAS} publishes a Warehouse object into the Lakehouse, so it "
|
|
908
|
+
f"belongs on a SQL table or view, not on {object_id.qualified} "
|
|
909
|
+
+ (
|
|
910
|
+
"(a Lakehouse object uses Warehouse alias)"
|
|
911
|
+
if target == DELTA_TARGET
|
|
912
|
+
else "(a Folder is not published across engines)"
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
return warehouse_alias, lakehouse_alias
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _parse_alias(value: Any, key: str) -> ObjectId | None:
|
|
919
|
+
if value is None:
|
|
920
|
+
return None
|
|
921
|
+
if not isinstance(value, str) or not value.strip():
|
|
922
|
+
raise MetadataError(f"{key} must be a non-empty Schema.Object name")
|
|
923
|
+
parts = [part.strip() for part in value.strip().split(".")]
|
|
924
|
+
if len(parts) != 2 or not all(parts):
|
|
925
|
+
raise MetadataError(
|
|
926
|
+
f"{key} must be a two-part Schema.Object name, got {value!r}"
|
|
927
|
+
)
|
|
928
|
+
return ObjectId(schema=parts[0], object=parts[1])
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _parse_notes(value: Any) -> str | None:
|
|
932
|
+
"""Free-range commentary. Deliberately unpoliced.
|
|
933
|
+
|
|
934
|
+
No reference parsing and no placeholder check: this is where an author
|
|
935
|
+
writes whatever helps, including a dollar sign.
|
|
936
|
+
"""
|
|
937
|
+
|
|
938
|
+
if value is None:
|
|
939
|
+
return None
|
|
940
|
+
if not isinstance(value, str) or not value.strip():
|
|
941
|
+
raise MetadataError("Notes must be non-empty text when present")
|
|
942
|
+
return value.strip()
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _parse_revision_notes(value: Any) -> tuple[tuple[Revision, ...], str | None]:
|
|
946
|
+
if value is None:
|
|
947
|
+
return (), None
|
|
948
|
+
if not isinstance(value, list) or not value:
|
|
949
|
+
raise MetadataError(
|
|
950
|
+
"Revision notes must be a non-empty YAML list, each entry opening with a date:\n"
|
|
951
|
+
"Revision notes:\n - 2026-07-23 Added the amount column."
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
revisions: list[Revision] = []
|
|
955
|
+
shape: str | None = None
|
|
956
|
+
for entry in value:
|
|
957
|
+
if isinstance(entry, (date, datetime)):
|
|
958
|
+
# YAML resolves a bare `- 2026-07-23` to a date rather than text.
|
|
959
|
+
raise MetadataError(
|
|
960
|
+
f"Revision notes entry {entry} has a date but no note"
|
|
961
|
+
)
|
|
962
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
963
|
+
raise MetadataError("Revision notes entries must be non-empty text")
|
|
964
|
+
text = entry.strip()
|
|
965
|
+
matched = _match_revision_date(text)
|
|
966
|
+
if matched is None:
|
|
967
|
+
raise MetadataError(
|
|
968
|
+
f"a Revision notes entry must open with a date, got {text!r}. "
|
|
969
|
+
"Any consistent spelling is accepted, such as 2026-07-23 or 23/07/2026."
|
|
970
|
+
)
|
|
971
|
+
entry_shape, date_text = matched
|
|
972
|
+
if shape is None:
|
|
973
|
+
shape = entry_shape
|
|
974
|
+
elif entry_shape != shape:
|
|
975
|
+
raise MetadataError(
|
|
976
|
+
f"Revision notes mix date formats — {shape} was used first, "
|
|
977
|
+
f"then {entry_shape} in {text!r}. Use one spelling throughout an object."
|
|
978
|
+
)
|
|
979
|
+
note = text[len(date_text):].strip()
|
|
980
|
+
if not note:
|
|
981
|
+
raise MetadataError(f"Revision notes entry {text!r} has a date but no note")
|
|
982
|
+
revisions.append(Revision(date=date_text, note=note))
|
|
983
|
+
return tuple(revisions), shape
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _match_revision_date(text: str) -> tuple[str, str] | None:
|
|
987
|
+
for shape, pattern, year_first in _REVISION_DATE_SHAPES:
|
|
988
|
+
match = pattern.match(text)
|
|
989
|
+
if match is None:
|
|
990
|
+
continue
|
|
991
|
+
first, second, third = (int(part) for part in match.groups())
|
|
992
|
+
if year_first:
|
|
993
|
+
month, day = second, third
|
|
994
|
+
plausible = 1 <= month <= 12 and 1 <= day <= 31
|
|
995
|
+
else:
|
|
996
|
+
# Day-first and month-first are indistinguishable, so accept either
|
|
997
|
+
# reading rather than pretend to know which was meant.
|
|
998
|
+
plausible = (
|
|
999
|
+
1 <= first <= 31 and 1 <= second <= 31 and (first <= 12 or second <= 12)
|
|
1000
|
+
)
|
|
1001
|
+
if not plausible:
|
|
1002
|
+
raise MetadataError(f"Revision notes entry does not open with a real date: {text!r}")
|
|
1003
|
+
return shape, match.group(0)
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _parse_bool(value: Any, key: str) -> bool:
|
|
1008
|
+
if value is None:
|
|
1009
|
+
return False
|
|
1010
|
+
if isinstance(value, bool):
|
|
1011
|
+
return value
|
|
1012
|
+
raise MetadataError(f"{key} must be a boolean (true/false)")
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _parse_percentage(raw: dict[str, Any], key: str, default: int) -> int:
|
|
1016
|
+
"""A whole percentage between 0 and 100.
|
|
1017
|
+
|
|
1018
|
+
100 is permitted and means "never trip", which is a clearer way to disable
|
|
1019
|
+
one threshold than a separate flag would be.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
if key not in raw or raw[key] is None:
|
|
1023
|
+
return default
|
|
1024
|
+
value = raw[key]
|
|
1025
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1026
|
+
raise MetadataError(f"{key} must be a whole percentage, got {value!r}")
|
|
1027
|
+
if not 0 <= value <= 100:
|
|
1028
|
+
raise MetadataError(f"{key} must be between 0 and 100, got {value}")
|
|
1029
|
+
return value
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _parse_row_count(raw: dict[str, Any], key: str, default: int) -> int:
|
|
1033
|
+
if key not in raw or raw[key] is None:
|
|
1034
|
+
return default
|
|
1035
|
+
value = raw[key]
|
|
1036
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
1037
|
+
raise MetadataError(f"{key} must be a whole number of rows, got {value!r}")
|
|
1038
|
+
return value
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _parse_flag_with_default(raw: dict[str, Any], key: str, *, default: bool) -> bool:
|
|
1042
|
+
if key not in raw:
|
|
1043
|
+
return default
|
|
1044
|
+
return _parse_bool(raw[key], key)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _parse_column_set(value: Any, key: str) -> tuple[str, ...]:
|
|
1048
|
+
"""A column *set* is comma-separated: one key, one comparison tuple."""
|
|
1049
|
+
|
|
1050
|
+
if value is None:
|
|
1051
|
+
return ()
|
|
1052
|
+
if isinstance(value, list):
|
|
1053
|
+
raise MetadataError(
|
|
1054
|
+
f"{key} is a column set and must be comma-separated text, not a YAML list"
|
|
1055
|
+
)
|
|
1056
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
1057
|
+
raise MetadataError(f"{key} must be comma-separated text")
|
|
1058
|
+
columns = tuple(part.strip() for part in str(value).split(","))
|
|
1059
|
+
if any(not column for column in columns):
|
|
1060
|
+
raise MetadataError(f"{key} must not contain empty column names")
|
|
1061
|
+
if len(set(columns)) != len(columns):
|
|
1062
|
+
raise MetadataError(f"{key} must not repeat columns")
|
|
1063
|
+
return columns
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _parse_unique_keys(
|
|
1067
|
+
value: Any, primary_key: tuple[str, ...]
|
|
1068
|
+
) -> tuple[tuple[str, ...], ...]:
|
|
1069
|
+
"""Alternate keys — a YAML list, one comma-separated column *set* per entry.
|
|
1070
|
+
|
|
1071
|
+
The two levels are deliberate and match ``Primary key``: independent things
|
|
1072
|
+
are a list, and one key's columns are a comma-separated set whose order is
|
|
1073
|
+
preserved. A key has no name, because nothing physical is created from it::
|
|
1074
|
+
|
|
1075
|
+
Unique keys:
|
|
1076
|
+
- Order number
|
|
1077
|
+
- Customer id, Order date
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
if value is None:
|
|
1081
|
+
return ()
|
|
1082
|
+
if not isinstance(value, list) or not value:
|
|
1083
|
+
raise MetadataError(
|
|
1084
|
+
"Unique keys must be a non-empty YAML list, one comma-separated column "
|
|
1085
|
+
"set per key:\nUnique keys:\n - Order number\n - Customer id, Order date"
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
keys: list[tuple[str, ...]] = []
|
|
1089
|
+
for entry in value:
|
|
1090
|
+
if isinstance(entry, list):
|
|
1091
|
+
raise MetadataError(
|
|
1092
|
+
"each Unique keys entry is one key and must be comma-separated text, "
|
|
1093
|
+
"not a nested YAML list"
|
|
1094
|
+
)
|
|
1095
|
+
columns = _parse_column_set(entry, "Unique keys")
|
|
1096
|
+
if not columns:
|
|
1097
|
+
raise MetadataError("Unique keys entries must name at least one column")
|
|
1098
|
+
if columns in keys:
|
|
1099
|
+
raise MetadataError(
|
|
1100
|
+
"Unique keys repeats the key " + ", ".join(columns)
|
|
1101
|
+
)
|
|
1102
|
+
if primary_key and columns == primary_key:
|
|
1103
|
+
raise MetadataError(
|
|
1104
|
+
"a Unique keys entry repeats the Primary key (" + ", ".join(columns)
|
|
1105
|
+
+ ") — the primary key is already unique, so remove it from Unique keys"
|
|
1106
|
+
)
|
|
1107
|
+
keys.append(columns)
|
|
1108
|
+
return tuple(keys)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
#: A foreign key's parent: ``Schema.Object[Column, Column]``. Brackets are
|
|
1112
|
+
#: required — the parent columns are what make the relationship readable, and a
|
|
1113
|
+
#: bare parent name would leave them to be guessed.
|
|
1114
|
+
_FOREIGN_KEY_PARENT = re.compile(r"^([^\[\]]+)\[([^\[\]]+)\]$")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _parse_foreign_keys(value: Any, object_id: ObjectId) -> tuple[ForeignKey, ...]:
|
|
1118
|
+
"""Declared relationships to parent objects, as an ER model rather than DDL.
|
|
1119
|
+
|
|
1120
|
+
Each entry is a one-entry mapping from this object's column set to the
|
|
1121
|
+
parent's::
|
|
1122
|
+
|
|
1123
|
+
Foreign keys:
|
|
1124
|
+
- Customer id: Sales.Customer[Customer id]
|
|
1125
|
+
- Region, Country: Sales.Territory[Region, Country]
|
|
1126
|
+
- Parent order id: Sales.Order[Order id]
|
|
1127
|
+
|
|
1128
|
+
Several entries may name the same parent, and the parent may be this object
|
|
1129
|
+
itself — a hierarchy in one table is an ordinary shape.
|
|
1130
|
+
"""
|
|
1131
|
+
|
|
1132
|
+
if value is None:
|
|
1133
|
+
return ()
|
|
1134
|
+
if not isinstance(value, list) or not value:
|
|
1135
|
+
raise MetadataError(
|
|
1136
|
+
"Foreign keys must be a non-empty YAML list, one relationship per entry:\n"
|
|
1137
|
+
"Foreign keys:\n - Customer id: Sales.Customer[Customer id]"
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
keys: list[ForeignKey] = []
|
|
1141
|
+
for entry in value:
|
|
1142
|
+
if not isinstance(entry, dict) or len(entry) != 1:
|
|
1143
|
+
raise MetadataError(
|
|
1144
|
+
"each Foreign keys entry maps one column set to one parent:\n"
|
|
1145
|
+
" - Customer id: Sales.Customer[Customer id]"
|
|
1146
|
+
)
|
|
1147
|
+
raw_columns, raw_parent = next(iter(entry.items()))
|
|
1148
|
+
columns = _parse_column_set(raw_columns, "Foreign keys")
|
|
1149
|
+
if not columns:
|
|
1150
|
+
raise MetadataError("a Foreign keys entry must name at least one column")
|
|
1151
|
+
if not isinstance(raw_parent, str) or not raw_parent.strip():
|
|
1152
|
+
raise MetadataError(
|
|
1153
|
+
f"the Foreign keys entry for {', '.join(columns)} must name a parent "
|
|
1154
|
+
"as Schema.Object[Column, Column]"
|
|
1155
|
+
)
|
|
1156
|
+
match = _FOREIGN_KEY_PARENT.match(raw_parent.strip())
|
|
1157
|
+
if match is None:
|
|
1158
|
+
raise MetadataError(
|
|
1159
|
+
f"the Foreign keys parent for {', '.join(columns)} must be "
|
|
1160
|
+
f"Schema.Object[Column, Column], got {raw_parent.strip()!r}"
|
|
1161
|
+
)
|
|
1162
|
+
raw_target, raw_parent_columns = match.groups()
|
|
1163
|
+
try:
|
|
1164
|
+
logical_reference = _parse_logical_reference(
|
|
1165
|
+
raw_target.strip(), key="Foreign keys"
|
|
1166
|
+
)
|
|
1167
|
+
except MetadataError as exc:
|
|
1168
|
+
raise MetadataError(
|
|
1169
|
+
f"the Foreign keys parent for {', '.join(columns)} must be "
|
|
1170
|
+
"Schema.Object[Column, Column] or an item-qualified logical "
|
|
1171
|
+
f"identity, got {raw_parent.strip()!r}"
|
|
1172
|
+
) from exc
|
|
1173
|
+
parent_columns = _parse_column_set(raw_parent_columns, "Foreign keys parent")
|
|
1174
|
+
if len(parent_columns) != len(columns):
|
|
1175
|
+
raise MetadataError(
|
|
1176
|
+
f"the Foreign keys entry for {', '.join(columns)} references "
|
|
1177
|
+
f"{len(parent_columns)} parent column(s) — a relationship pairs its "
|
|
1178
|
+
"columns, so the two sets must be the same size"
|
|
1179
|
+
)
|
|
1180
|
+
key = ForeignKey(
|
|
1181
|
+
columns=columns,
|
|
1182
|
+
reference=logical_reference.object_id,
|
|
1183
|
+
reference_columns=parent_columns,
|
|
1184
|
+
logical_reference=(
|
|
1185
|
+
logical_reference
|
|
1186
|
+
if logical_reference.is_item_qualified or logical_reference.is_files
|
|
1187
|
+
else None
|
|
1188
|
+
),
|
|
1189
|
+
)
|
|
1190
|
+
if not key.reference.schema or not key.reference.object:
|
|
1191
|
+
raise MetadataError(
|
|
1192
|
+
f"the Foreign keys parent for {', '.join(columns)} must be a two-part "
|
|
1193
|
+
f"Schema.Object name, got {raw_parent.strip()!r}"
|
|
1194
|
+
)
|
|
1195
|
+
if key in keys:
|
|
1196
|
+
raise MetadataError(f"Foreign keys repeats the relationship {key}")
|
|
1197
|
+
keys.append(key)
|
|
1198
|
+
return tuple(keys)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _parse_column_list(value: Any, key: str) -> tuple[str, ...]:
|
|
1202
|
+
"""Independent columns are a YAML list."""
|
|
1203
|
+
|
|
1204
|
+
if value is None:
|
|
1205
|
+
return ()
|
|
1206
|
+
if not isinstance(value, list):
|
|
1207
|
+
raise MetadataError(
|
|
1208
|
+
f"{key} is a list of independent columns and must be a YAML list:\n"
|
|
1209
|
+
f"{key}:\n - Column one\n - Column two"
|
|
1210
|
+
)
|
|
1211
|
+
columns: list[str] = []
|
|
1212
|
+
for entry in value:
|
|
1213
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
1214
|
+
raise MetadataError(f"{key} entries must be non-empty column names")
|
|
1215
|
+
columns.append(entry.strip())
|
|
1216
|
+
if len(set(columns)) != len(columns):
|
|
1217
|
+
raise MetadataError(f"{key} must not repeat columns")
|
|
1218
|
+
return tuple(columns)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _parse_file_keys(value: Any, *, kind: str) -> tuple[str, ...]:
|
|
1222
|
+
"""The globs a Folder manages. Everything else in the folder is not ours."""
|
|
1223
|
+
|
|
1224
|
+
if kind != FOLDER:
|
|
1225
|
+
return ()
|
|
1226
|
+
if value is None:
|
|
1227
|
+
raise MetadataError(
|
|
1228
|
+
"a Folder must declare File key — it is the scope of what Weaver manages, "
|
|
1229
|
+
"and reconciliation deletes nothing outside it"
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
values = [value] if isinstance(value, str) else value
|
|
1233
|
+
if not isinstance(values, list) or not values:
|
|
1234
|
+
raise MetadataError("File key must be a non-empty string or list of strings")
|
|
1235
|
+
|
|
1236
|
+
patterns: list[str] = []
|
|
1237
|
+
for pattern in values:
|
|
1238
|
+
if not isinstance(pattern, str) or not pattern.strip():
|
|
1239
|
+
raise MetadataError("File key patterns must be non-empty strings")
|
|
1240
|
+
normalised = pattern.strip().replace("\\", "/")
|
|
1241
|
+
if normalised.startswith("/") or ".." in normalised.split("/"):
|
|
1242
|
+
raise MetadataError(
|
|
1243
|
+
"File key patterns must be relative and must not traverse with '..'"
|
|
1244
|
+
)
|
|
1245
|
+
patterns.append(normalised)
|
|
1246
|
+
return tuple(patterns)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _parse_identity(value: Any) -> str | None:
|
|
1250
|
+
if value is None:
|
|
1251
|
+
return None
|
|
1252
|
+
if isinstance(value, list):
|
|
1253
|
+
raise MetadataError("Identity must be a single column")
|
|
1254
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
1255
|
+
raise MetadataError("Identity must be a single column name")
|
|
1256
|
+
name = str(value).strip()
|
|
1257
|
+
if not name:
|
|
1258
|
+
raise MetadataError("Identity must be a non-empty column name")
|
|
1259
|
+
if "," in name:
|
|
1260
|
+
raise MetadataError("Identity must be a single column, not a list")
|
|
1261
|
+
return name
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _parse_schema(value: Any) -> tuple[Column, ...]:
|
|
1265
|
+
if value is None:
|
|
1266
|
+
return ()
|
|
1267
|
+
if not isinstance(value, dict) or not value:
|
|
1268
|
+
raise MetadataError("Schema must be a non-empty mapping of column to type")
|
|
1269
|
+
columns: list[Column] = []
|
|
1270
|
+
for name, column_type in value.items():
|
|
1271
|
+
if not isinstance(name, str) or not name.strip():
|
|
1272
|
+
raise MetadataError("Schema column names must be non-empty strings")
|
|
1273
|
+
if not isinstance(column_type, str) or not column_type.strip():
|
|
1274
|
+
raise MetadataError(
|
|
1275
|
+
f"Schema column {name!r} must declare a non-empty type"
|
|
1276
|
+
)
|
|
1277
|
+
columns.append(Column(name=name.strip(), type=column_type.strip()))
|
|
1278
|
+
return tuple(columns)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _parse_column_notes(value: Any) -> dict[str, MetadataText]:
|
|
1282
|
+
if value is None:
|
|
1283
|
+
return {}
|
|
1284
|
+
if not isinstance(value, dict) or not value:
|
|
1285
|
+
raise MetadataError("Column notes must be a non-empty mapping of column to description")
|
|
1286
|
+
notes: dict[str, MetadataText] = {}
|
|
1287
|
+
for name, note in value.items():
|
|
1288
|
+
if not isinstance(name, str) or not name.strip():
|
|
1289
|
+
raise MetadataError("Column notes column names must be non-empty strings")
|
|
1290
|
+
if not isinstance(note, str) or not note.strip():
|
|
1291
|
+
raise MetadataError(f"Column notes for {name!r} must be non-empty text")
|
|
1292
|
+
notes[name.strip()] = _parse_text_value(note, f"Column notes[{name.strip()}]")
|
|
1293
|
+
return notes
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _validate_columns(
|
|
1297
|
+
*,
|
|
1298
|
+
kind: str,
|
|
1299
|
+
declared_columns: tuple[Column, ...],
|
|
1300
|
+
primary_key: tuple[str, ...],
|
|
1301
|
+
unique_keys: tuple[tuple[str, ...], ...],
|
|
1302
|
+
foreign_keys: tuple[ForeignKey, ...],
|
|
1303
|
+
declared_not_null: tuple[str, ...],
|
|
1304
|
+
identity: str | None,
|
|
1305
|
+
comparison: tuple[str, ...],
|
|
1306
|
+
notes: dict[str, MetadataText],
|
|
1307
|
+
) -> None:
|
|
1308
|
+
"""Cross-field column guards, where a declared schema makes them possible."""
|
|
1309
|
+
|
|
1310
|
+
redundant = [column for column in declared_not_null if column in primary_key]
|
|
1311
|
+
if redundant:
|
|
1312
|
+
raise MetadataError(
|
|
1313
|
+
"primary key columns are already not null, so remove them from Not null: "
|
|
1314
|
+
+ ", ".join(redundant)
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
overlapping = [column for column in comparison if column in primary_key]
|
|
1318
|
+
if overlapping:
|
|
1319
|
+
raise MetadataError(
|
|
1320
|
+
"Comparison columns must not include primary key columns — a matched row "
|
|
1321
|
+
"has equal keys by definition: " + ", ".join(overlapping)
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
colliding = [
|
|
1325
|
+
column.name
|
|
1326
|
+
for column in declared_columns
|
|
1327
|
+
if column.name.lower() in _RESERVED_AUDIT_NAMES
|
|
1328
|
+
]
|
|
1329
|
+
if colliding:
|
|
1330
|
+
raise MetadataError(
|
|
1331
|
+
"these column names are reserved for Weaver's audit columns: "
|
|
1332
|
+
+ ", ".join(colliding)
|
|
1333
|
+
)
|
|
1334
|
+
# Identity is a Weaver-managed surrogate column, so it must not clash with the
|
|
1335
|
+
# audit columns it sits beside.
|
|
1336
|
+
if identity is not None and identity.lower() in _RESERVED_AUDIT_NAMES:
|
|
1337
|
+
raise MetadataError(
|
|
1338
|
+
f"Identity {identity} collides with a Weaver audit column name"
|
|
1339
|
+
)
|
|
1340
|
+
# The primary key must not *be* the identity column, and the reason is a load
|
|
1341
|
+
# one rather than a modelling preference. The engine assigns the identity on
|
|
1342
|
+
# insert, so a source never produces it; a load matching on it could never
|
|
1343
|
+
# find an existing row, and every run would insert duplicates. Caught here
|
|
1344
|
+
# because the alternative is an "Invalid column name" from the engine at
|
|
1345
|
+
# install, which says nothing about what the declaration got wrong.
|
|
1346
|
+
if identity is not None and identity in primary_key:
|
|
1347
|
+
raise MetadataError(
|
|
1348
|
+
f"Primary key names the Identity column {identity!r}. The engine "
|
|
1349
|
+
"assigns the identity on insert, so a load can never match on it — "
|
|
1350
|
+
"key on the business column that identifies a row across loads, and "
|
|
1351
|
+
"let the identity be the surrogate beside it."
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
if not declared_columns:
|
|
1355
|
+
# A SQL object takes its shape from its query; checked at build instead.
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
# The identity column is Weaver's, not the author's, so it must not be
|
|
1359
|
+
# declared in Schema — but the primary key may name it when the surrogate is
|
|
1360
|
+
# the key, so it counts as a known column for the reference checks.
|
|
1361
|
+
if identity is not None and identity in {column.name for column in declared_columns}:
|
|
1362
|
+
raise MetadataError(
|
|
1363
|
+
f"Identity {identity} names a declared column; the identity column is "
|
|
1364
|
+
"Weaver-managed and must not appear in Schema"
|
|
1365
|
+
)
|
|
1366
|
+
known = {column.name for column in declared_columns}
|
|
1367
|
+
if identity is not None:
|
|
1368
|
+
known = known | {identity}
|
|
1369
|
+
unique_columns = tuple(
|
|
1370
|
+
column for unique_key in unique_keys for column in unique_key
|
|
1371
|
+
)
|
|
1372
|
+
foreign_key_columns = tuple(
|
|
1373
|
+
column for foreign_key in foreign_keys for column in foreign_key.columns
|
|
1374
|
+
)
|
|
1375
|
+
for key, columns in (
|
|
1376
|
+
("Primary key", primary_key),
|
|
1377
|
+
("Unique keys", unique_columns),
|
|
1378
|
+
("Foreign keys", foreign_key_columns),
|
|
1379
|
+
("Not null", declared_not_null),
|
|
1380
|
+
("Comparison columns", comparison),
|
|
1381
|
+
("Column notes", tuple(notes)),
|
|
1382
|
+
):
|
|
1383
|
+
missing = [column for column in columns if column not in known]
|
|
1384
|
+
if missing:
|
|
1385
|
+
raise MetadataError(
|
|
1386
|
+
f"{key} names column(s) that are not in Schema: " + ", ".join(missing)
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _apply_column_details(
|
|
1391
|
+
declared: tuple[Column, ...],
|
|
1392
|
+
notes: dict[str, MetadataText],
|
|
1393
|
+
primary_key: tuple[str, ...],
|
|
1394
|
+
declared_not_null: tuple[str, ...],
|
|
1395
|
+
) -> tuple[Column, ...]:
|
|
1396
|
+
not_null = set(primary_key) | set(declared_not_null)
|
|
1397
|
+
return tuple(
|
|
1398
|
+
Column(
|
|
1399
|
+
name=column.name,
|
|
1400
|
+
type=column.type,
|
|
1401
|
+
note=notes.get(column.name),
|
|
1402
|
+
not_null=column.name in not_null,
|
|
1403
|
+
)
|
|
1404
|
+
for column in declared
|
|
1405
|
+
)
|