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