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,417 @@
|
|
|
1
|
+
"""T-SQL load generation — one independently runnable procedure per table.
|
|
2
|
+
|
|
3
|
+
What this produces is not the procedure. It is a script that *installs* the
|
|
4
|
+
procedure, and the difference is the whole design.
|
|
5
|
+
|
|
6
|
+
.. code-block:: text
|
|
7
|
+
|
|
8
|
+
payload an installer script, frozen at build
|
|
9
|
+
reads sys.columns of the target table
|
|
10
|
+
assembles the procedure text
|
|
11
|
+
exec sp_executesql
|
|
12
|
+
|
|
13
|
+
The column lists cannot be written at generation time. A Warehouse table may
|
|
14
|
+
infer its shape from its query, so the generator does not always know its
|
|
15
|
+
columns — and even when it does, the *physical* table is what the procedure must
|
|
16
|
+
name. Reading ``sys.columns`` at install settles both, and it excludes the
|
|
17
|
+
identity column for free: the engine generates that column, so ``is_identity =
|
|
18
|
+
0`` keeps it out of every insert list without this module having to know which
|
|
19
|
+
column it was. It is the same two-phase shape
|
|
20
|
+
:mod:`weaver.declaration.tsql_ddl` uses to build the table, for the same reason.
|
|
21
|
+
|
|
22
|
+
The installed procedure is independently runnable, which is the point of a
|
|
23
|
+
primitive::
|
|
24
|
+
|
|
25
|
+
exec [_].[Load Sales.Customer] @fault_tolerant = 1;
|
|
26
|
+
|
|
27
|
+
It reads no repository, consults no catalogue and calls nothing else Weaver
|
|
28
|
+
owns. It returns one row of :data:`weaver.runtime.load_result.RESULT_COLUMNS`.
|
|
29
|
+
|
|
30
|
+
**The intermediate tables are real.** ``Sales.Customer_Staging``,
|
|
31
|
+
``_Upsert`` and ``_Reject`` are ordinary tables in the object's own schema, as
|
|
32
|
+
they were in the reference implementation. Real tables are what make a failed
|
|
33
|
+
load inspectable: when rows are rejected the evidence is still there afterwards,
|
|
34
|
+
addressable by anyone with a query tool. They are dropped at the start of every
|
|
35
|
+
run and again at the end, but only when the run was clean — the whole point of
|
|
36
|
+
keeping a reject table is that a run which rejected rows leaves it behind.
|
|
37
|
+
|
|
38
|
+
**No history.** The reference carried a ``_Current``/``_History`` pair behind a
|
|
39
|
+
view. Weaver builds only the authored table, so a load updates it directly and a
|
|
40
|
+
row absent from a non-incremental source is deleted outright. The audit columns
|
|
41
|
+
still record insert and update times; the delete sentinel stays live because
|
|
42
|
+
there is nowhere for a deleted row to go.
|
|
43
|
+
|
|
44
|
+
Ported from ``weaver_runtime.dbrep.sql.etl``, with the history layout removed and
|
|
45
|
+
the ``fault_tolerant`` contract and structured result added.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from .metadata import (
|
|
51
|
+
AUDIT_COLUMNS,
|
|
52
|
+
AUDIT_LIVE_DELETE_DATETIME,
|
|
53
|
+
SesDocument,
|
|
54
|
+
)
|
|
55
|
+
from .sql_shaping import render_sql_template, split_trailing_query
|
|
56
|
+
from ..runtime.load_contract import (
|
|
57
|
+
REASON_BLANK_PK,
|
|
58
|
+
REASON_DUPLICATE_PK,
|
|
59
|
+
REJECTION_REASON,
|
|
60
|
+
LoadContract,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: The column a staged row's duplicate rank lands in. Named to be unmistakably
|
|
64
|
+
#: Weaver's: it sits beside the author's own columns in a real table, so a name
|
|
65
|
+
#: an author might have chosen would be a collision waiting to happen.
|
|
66
|
+
RANK_COLUMN = "__weaver_pk_row_number"
|
|
67
|
+
|
|
68
|
+
#: The suffixes of the three intermediate tables, in the object's own schema.
|
|
69
|
+
STAGING_SUFFIX = "_Staging"
|
|
70
|
+
UPSERT_SUFFIX = "_Upsert"
|
|
71
|
+
REJECT_SUFFIX = "_Reject"
|
|
72
|
+
|
|
73
|
+
#: What a run reports when it refused to start, and when it went ahead anyway.
|
|
74
|
+
#: Both say rejects occurred; they differ in what happened to the target, which
|
|
75
|
+
#: is the only thing ``fault_tolerant`` changes.
|
|
76
|
+
INTOLERANT_MESSAGE = (
|
|
77
|
+
"rows were rejected and fault_tolerant = 0, so the target was not modified"
|
|
78
|
+
)
|
|
79
|
+
TOLERATED_MESSAGE = "rows were rejected and excluded from the load"
|
|
80
|
+
|
|
81
|
+
#: Banners marking where the author's own code sits in the generated procedure.
|
|
82
|
+
#: A generated artefact is read by people — usually when something has gone
|
|
83
|
+
#: wrong — and the first question is always "which of this did I write?".
|
|
84
|
+
PREPROCESSING_BANNER = "/*-- Pre-processing --*/"
|
|
85
|
+
TRANSFORMATION_BANNER = "/*---- Data transformation ----*/"
|
|
86
|
+
END_TRANSFORMATION_BANNER = "/*---- End data transformation ----*/"
|
|
87
|
+
POSTPROCESSING_BANNER = "/*-- Post-processing --*/"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def generate_tsql_load_script(
|
|
91
|
+
document: SesDocument, body: str, *, procedure_name: str
|
|
92
|
+
) -> str:
|
|
93
|
+
"""The installer script for one Warehouse table's load procedure.
|
|
94
|
+
|
|
95
|
+
``body`` is the table's own query — the same text its build materialises to
|
|
96
|
+
settle its shape. A load runs it for real.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
contract = LoadContract.from_document(document)
|
|
100
|
+
names = _table_names(document, procedure_name)
|
|
101
|
+
staging_sql = _staging_sql(names, body, contract)
|
|
102
|
+
|
|
103
|
+
if contract.primary_key:
|
|
104
|
+
load_body = _primary_key_body(names, contract)
|
|
105
|
+
else:
|
|
106
|
+
load_body = _full_replace_body(names)
|
|
107
|
+
|
|
108
|
+
procedure = render_sql_template(
|
|
109
|
+
"load/load_procedure",
|
|
110
|
+
load_procedure=names["procedure"],
|
|
111
|
+
live_delete_datetime=AUDIT_LIVE_DELETE_DATETIME,
|
|
112
|
+
preprocessing_banner=_indent(PREPROCESSING_BANNER, 4),
|
|
113
|
+
postprocessing_banner=_indent(POSTPROCESSING_BANNER, 4),
|
|
114
|
+
start_artifact_cleanup=_indent(_cleanup(names, contract), 4),
|
|
115
|
+
staging_sql=_indent(staging_sql, 4),
|
|
116
|
+
staging_table=names["staging"],
|
|
117
|
+
target_table=names["target"],
|
|
118
|
+
load_body=_indent(load_body, 4),
|
|
119
|
+
end_artifact_cleanup=_indent(_end_cleanup(names, contract), 4),
|
|
120
|
+
).rstrip()
|
|
121
|
+
|
|
122
|
+
return render_sql_template(
|
|
123
|
+
"load/install_load_procedure",
|
|
124
|
+
column_metadata_sql=_column_metadata_sql(names, contract),
|
|
125
|
+
procedure_template_sql_literal=_sql_literal(procedure),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- the pieces of the procedure ---------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _staging_sql(names: dict, body: str, contract: LoadContract) -> str:
|
|
133
|
+
"""Materialise the object's query, ranking duplicate keys as it goes.
|
|
134
|
+
|
|
135
|
+
One pass, because the rank is what identifies a duplicate and computing it
|
|
136
|
+
later would mean reading the staged rows twice. With no key there is nothing
|
|
137
|
+
to rank and the query is staged as it stands.
|
|
138
|
+
|
|
139
|
+
Only the *last standalone query* is staged. A body may set a temporary view
|
|
140
|
+
up first and select from it, and wrapping the whole body in a subquery would
|
|
141
|
+
put a ``create`` inside a ``from`` — so the preamble runs as it was written,
|
|
142
|
+
and the query it leads to is what fills staging.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
preamble, query = split_trailing_query(body)
|
|
146
|
+
lead = f"{preamble};\n\n" if preamble else ""
|
|
147
|
+
if not contract.primary_key:
|
|
148
|
+
staged = f"create table {names['staging']} as\n{query};"
|
|
149
|
+
else:
|
|
150
|
+
partition = ", ".join(f"s.{_quote(column)}" for column in contract.primary_key)
|
|
151
|
+
staged = (
|
|
152
|
+
f"create table {names['staging']} as\n"
|
|
153
|
+
f"select\n"
|
|
154
|
+
f" s.*\n"
|
|
155
|
+
f" , row_number() over (partition by {partition} order by (select null)) "
|
|
156
|
+
f"as {_quote(RANK_COLUMN)}\n"
|
|
157
|
+
f"from (\n{_indent(query, 4)}\n) as s;"
|
|
158
|
+
)
|
|
159
|
+
return f"{TRANSFORMATION_BANNER}\n{lead}{staged}\n{END_TRANSFORMATION_BANNER}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _primary_key_body(names: dict, contract: LoadContract) -> str:
|
|
163
|
+
blank = _blank_key_predicate(contract.primary_key)
|
|
164
|
+
return render_sql_template(
|
|
165
|
+
"load/primary_key_body",
|
|
166
|
+
reject_table=names["reject"],
|
|
167
|
+
upsert_table=names["upsert"],
|
|
168
|
+
staging_table=names["staging"],
|
|
169
|
+
target_table=names["target"],
|
|
170
|
+
rank_column=RANK_COLUMN,
|
|
171
|
+
staging_blank_predicate=blank,
|
|
172
|
+
staging_target_join=_join("s", "t", contract.primary_key),
|
|
173
|
+
target_upsert_join=_join("c", "u", contract.primary_key),
|
|
174
|
+
target_missing_predicate=f"t.{_quote(contract.primary_key[0])} is null",
|
|
175
|
+
missing_reconciliation=_missing_reconciliation(names, contract),
|
|
176
|
+
prospective_deletes=_prospective_deletes(names, contract),
|
|
177
|
+
delete_threshold=contract.delete_threshold,
|
|
178
|
+
update_threshold=contract.update_threshold,
|
|
179
|
+
stability_rows=contract.stability_rows,
|
|
180
|
+
rejection_reason=REJECTION_REASON,
|
|
181
|
+
blank_reason=REASON_BLANK_PK,
|
|
182
|
+
duplicate_reason=REASON_DUPLICATE_PK,
|
|
183
|
+
intolerant_message=_escape_literal(INTOLERANT_MESSAGE),
|
|
184
|
+
tolerated_message=_escape_literal(TOLERATED_MESSAGE),
|
|
185
|
+
).rstrip()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _full_replace_body(names: dict) -> str:
|
|
189
|
+
return render_sql_template(
|
|
190
|
+
"load/full_replace_body",
|
|
191
|
+
target_table=names["target"],
|
|
192
|
+
staging_table=names["staging"],
|
|
193
|
+
).rstrip()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _prospective_deletes(names: dict, contract: LoadContract) -> str:
|
|
197
|
+
"""Count what the load is about to delete, before it deletes any of it.
|
|
198
|
+
|
|
199
|
+
A number obtained by deleting would be a report rather than a check, and the
|
|
200
|
+
whole point of the guard is to decide *not* to.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
if not contract.deletes_absent_rows:
|
|
204
|
+
return (
|
|
205
|
+
"-- Incremental: nothing is deleted, so there is nothing to count."
|
|
206
|
+
)
|
|
207
|
+
join = _join("s", "c", contract.primary_key)
|
|
208
|
+
return (
|
|
209
|
+
f"select @weaver_prospective_deletes = count(*)\n"
|
|
210
|
+
f"from {names['target']} as c\n"
|
|
211
|
+
f"where not exists (\n"
|
|
212
|
+
f" select 1 from {names['staging']} as s where {join}\n"
|
|
213
|
+
f");"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _missing_reconciliation(names: dict, contract: LoadContract) -> str:
|
|
218
|
+
"""Delete target rows the source stopped producing — a physical delete.
|
|
219
|
+
|
|
220
|
+
Only for a keyed, non-incremental load. An incremental source shows a window
|
|
221
|
+
rather than the whole truth, so absence from it is not a retirement and
|
|
222
|
+
deleting on it would destroy rows the source never claimed to describe.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if not contract.deletes_absent_rows:
|
|
226
|
+
return (
|
|
227
|
+
"-- Incremental: absence from the source is not a retirement, so\n"
|
|
228
|
+
"-- nothing is deleted."
|
|
229
|
+
)
|
|
230
|
+
join = _join("s", "c", contract.primary_key)
|
|
231
|
+
return (
|
|
232
|
+
f"delete c\n"
|
|
233
|
+
f"from {names['target']} as c\n"
|
|
234
|
+
f"where not exists (\n"
|
|
235
|
+
f" select 1 from {names['staging']} as s where {join}\n"
|
|
236
|
+
f");"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _cleanup(names: dict, contract: LoadContract) -> str:
|
|
241
|
+
"""Drop whatever a previous run left behind, newest dependency first.
|
|
242
|
+
|
|
243
|
+
Only the tables this procedure actually makes. An unkeyed load has no reject
|
|
244
|
+
or upsert table — with no key nothing can be matched, so there is nothing to
|
|
245
|
+
reject and no upsert set — and dropping them anyway would leave a reader
|
|
246
|
+
hunting for the statement that creates them.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
keys = ("reject", "upsert", "staging") if contract.primary_key else ("staging",)
|
|
250
|
+
return "\n".join(
|
|
251
|
+
f"if object_id({_sql_literal(names[key])}, N'U') is not null "
|
|
252
|
+
f"drop table {names[key]};"
|
|
253
|
+
for key in keys
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _end_cleanup(names: dict, contract: LoadContract) -> str:
|
|
258
|
+
"""Clear the intermediate tables, unless they are the evidence of a problem.
|
|
259
|
+
|
|
260
|
+
A run that rejected nothing leaves nothing to look at, so its artefacts go.
|
|
261
|
+
A run that rejected rows keeps all three: the reject table names what was
|
|
262
|
+
refused, and staging and upsert are what make the rejection explicable.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
cleanup = _cleanup(names, contract)
|
|
266
|
+
if not contract.primary_key:
|
|
267
|
+
return cleanup
|
|
268
|
+
return (
|
|
269
|
+
"if @weaver_rows_rejected = 0\n"
|
|
270
|
+
"begin\n"
|
|
271
|
+
f"{_indent(cleanup, 4)}\n"
|
|
272
|
+
"end;"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# --- the installer's column metadata -----------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _column_metadata_sql(names: dict, contract: LoadContract) -> str:
|
|
280
|
+
"""Read the target's loadable columns, and what an update sets.
|
|
281
|
+
|
|
282
|
+
The audit columns are excluded because the load supplies them itself, and
|
|
283
|
+
identity is excluded because the engine does.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
audit = ", ".join(_sql_literal(name) for name in AUDIT_COLUMNS)
|
|
287
|
+
source_column_filter = (
|
|
288
|
+
f"c.name not in ({audit})\n and c.is_identity = 0"
|
|
289
|
+
)
|
|
290
|
+
return render_sql_template(
|
|
291
|
+
"load/column_metadata",
|
|
292
|
+
target_table_literal=_sql_literal(names["target"]),
|
|
293
|
+
source_column_filter=source_column_filter,
|
|
294
|
+
update_select=_update_select(names, contract, source_column_filter),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _update_select(names: dict, contract: LoadContract, source_column_filter: str) -> str:
|
|
299
|
+
"""Build the UPDATE SET list: every loadable column except the key.
|
|
300
|
+
|
|
301
|
+
The key is excluded because it is what matched the rows — setting a column
|
|
302
|
+
to the value it was joined on is work that cannot change anything. The audit
|
|
303
|
+
columns are appended unconditionally, since an updated row's update time and
|
|
304
|
+
live sentinel are Weaver's to state.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
if not contract.primary_key:
|
|
308
|
+
return "set @weaver_update_set_columns = N'';"
|
|
309
|
+
key_values = ", ".join(
|
|
310
|
+
_sql_literal(column.lower()) for column in contract.primary_key
|
|
311
|
+
)
|
|
312
|
+
return (
|
|
313
|
+
";with update_columns as (\n"
|
|
314
|
+
" select\n"
|
|
315
|
+
" c.name\n"
|
|
316
|
+
" , c.column_id\n"
|
|
317
|
+
" , row_number() over (order by c.column_id) as row_ordinal\n"
|
|
318
|
+
" from sys.columns as c\n"
|
|
319
|
+
f" where c.[object_id] = object_id({_sql_literal(names['target'])})\n"
|
|
320
|
+
f" and {source_column_filter}\n"
|
|
321
|
+
f" and lower(c.name) not in ({key_values})\n"
|
|
322
|
+
")\n"
|
|
323
|
+
"select\n"
|
|
324
|
+
" @weaver_update_set_columns =\n"
|
|
325
|
+
" coalesce(\n"
|
|
326
|
+
" string_agg(\n"
|
|
327
|
+
" case\n"
|
|
328
|
+
" when row_ordinal = 1 then N'c.' + quotename(name) + N' = u.' + quotename(name)\n"
|
|
329
|
+
" else char(10) + N' , c.' + quotename(name) + N' = u.' + quotename(name)\n"
|
|
330
|
+
" end,\n"
|
|
331
|
+
" N''\n"
|
|
332
|
+
" ) within group (order by column_id)\n"
|
|
333
|
+
" + char(10) + N' , ',\n"
|
|
334
|
+
" N''\n"
|
|
335
|
+
" )\n"
|
|
336
|
+
" + N'c.[Row update datetime] = @weaver_load_datetime'\n"
|
|
337
|
+
" + char(10) + N' , c.[Row delete datetime] = @weaver_live_datetime'\n"
|
|
338
|
+
"from update_columns;"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --- names and text ----------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _table_names(document: SesDocument, procedure_name: str) -> dict:
|
|
346
|
+
"""The five names one load deals in, quoted once here and reused.
|
|
347
|
+
|
|
348
|
+
The intermediate tables sit in the object's own schema beside it, which is
|
|
349
|
+
the reference's arrangement: they belong to the object being loaded, not to
|
|
350
|
+
the generated ``_`` schema, which holds procedures.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
schema = document.object_id.schema
|
|
354
|
+
obj = document.object_id.object
|
|
355
|
+
qualified = f"{_quote(schema)}."
|
|
356
|
+
return {
|
|
357
|
+
"target": f"{qualified}{_quote(obj)}",
|
|
358
|
+
"staging": f"{qualified}{_quote(obj + STAGING_SUFFIX)}",
|
|
359
|
+
"upsert": f"{qualified}{_quote(obj + UPSERT_SUFFIX)}",
|
|
360
|
+
"reject": f"{qualified}{_quote(obj + REJECT_SUFFIX)}",
|
|
361
|
+
"procedure": procedure_name,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _join(left: str, right: str, columns: tuple[str, ...]) -> str:
|
|
366
|
+
return "\n and ".join(
|
|
367
|
+
f"{left}.{_quote(column)} = {right}.{_quote(column)}" for column in columns
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _blank_key_predicate(columns: tuple[str, ...]) -> str:
|
|
372
|
+
"""A key column that is null, empty or only spaces is not a key.
|
|
373
|
+
|
|
374
|
+
Blank is rejected alongside null deliberately: a key that is whitespace
|
|
375
|
+
matches nothing a human would call a match, and letting it through would
|
|
376
|
+
create a row nobody can find again.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
predicates = [
|
|
380
|
+
f"nullif(trim(cast(s.{_quote(column)} as varchar(max))), '') is null"
|
|
381
|
+
for column in columns
|
|
382
|
+
]
|
|
383
|
+
if len(predicates) == 1:
|
|
384
|
+
return predicates[0]
|
|
385
|
+
return "(" + "\n or ".join(predicates) + ")"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _quote(name: str) -> str:
|
|
389
|
+
return "[" + name.replace("]", "]]") + "]"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _sql_literal(text: str) -> str:
|
|
393
|
+
return "N'" + text.replace("'", "''") + "'"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _escape_literal(text: str) -> str:
|
|
397
|
+
"""Text going *inside* an already-quoted literal in the procedure template.
|
|
398
|
+
|
|
399
|
+
The procedure is itself embedded in a string literal by the installer, so a
|
|
400
|
+
quote here is doubled twice over. Escaping once at each layer is what keeps
|
|
401
|
+
the two levels straight.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
return text.replace("'", "''")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _indent(text: str, spaces: int) -> str:
|
|
408
|
+
pad = " " * spaces
|
|
409
|
+
return "\n".join(pad + line if line.strip() else line for line in text.splitlines())
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
__all__ = [
|
|
413
|
+
"INTOLERANT_MESSAGE",
|
|
414
|
+
"RANK_COLUMN",
|
|
415
|
+
"TOLERATED_MESSAGE",
|
|
416
|
+
"generate_tsql_load_script",
|
|
417
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
fallback_type: varchar(max)
|
|
2
|
+
preserve_nullability: true
|
|
3
|
+
default_varchar_length: 8000
|
|
4
|
+
default_varbinary_length: 8000
|
|
5
|
+
max_length_token: max
|
|
6
|
+
identity_type: bigint identity not null
|
|
7
|
+
|
|
8
|
+
mappings:
|
|
9
|
+
bigint:
|
|
10
|
+
target: bigint
|
|
11
|
+
binary:
|
|
12
|
+
target: varbinary
|
|
13
|
+
length: source
|
|
14
|
+
bit:
|
|
15
|
+
target: bit
|
|
16
|
+
char:
|
|
17
|
+
target: varchar
|
|
18
|
+
length: source
|
|
19
|
+
date:
|
|
20
|
+
target: date
|
|
21
|
+
datetime:
|
|
22
|
+
target: datetime2
|
|
23
|
+
scale: 6
|
|
24
|
+
datetime2:
|
|
25
|
+
target: datetime2
|
|
26
|
+
scale: min_source_6
|
|
27
|
+
datetimeoffset:
|
|
28
|
+
target: datetime2
|
|
29
|
+
scale: min_source_6
|
|
30
|
+
decimal:
|
|
31
|
+
target: decimal
|
|
32
|
+
precision: source
|
|
33
|
+
scale: source
|
|
34
|
+
float:
|
|
35
|
+
target: float
|
|
36
|
+
image:
|
|
37
|
+
target: varbinary
|
|
38
|
+
length: max
|
|
39
|
+
int:
|
|
40
|
+
target: int
|
|
41
|
+
money:
|
|
42
|
+
target: decimal
|
|
43
|
+
precision: 19
|
|
44
|
+
scale: 4
|
|
45
|
+
nchar:
|
|
46
|
+
target: varchar
|
|
47
|
+
length: source
|
|
48
|
+
ntext:
|
|
49
|
+
target: varchar
|
|
50
|
+
length: max
|
|
51
|
+
numeric:
|
|
52
|
+
target: decimal
|
|
53
|
+
precision: source
|
|
54
|
+
scale: source
|
|
55
|
+
nvarchar:
|
|
56
|
+
target: varchar
|
|
57
|
+
length: source
|
|
58
|
+
real:
|
|
59
|
+
target: real
|
|
60
|
+
rowversion:
|
|
61
|
+
target: varbinary
|
|
62
|
+
length: 8
|
|
63
|
+
smalldatetime:
|
|
64
|
+
target: datetime2
|
|
65
|
+
scale: 0
|
|
66
|
+
smallint:
|
|
67
|
+
target: smallint
|
|
68
|
+
smallmoney:
|
|
69
|
+
target: decimal
|
|
70
|
+
precision: 10
|
|
71
|
+
scale: 4
|
|
72
|
+
text:
|
|
73
|
+
target: varchar
|
|
74
|
+
length: max
|
|
75
|
+
time:
|
|
76
|
+
target: time
|
|
77
|
+
scale: min_source_6
|
|
78
|
+
timestamp:
|
|
79
|
+
target: varbinary
|
|
80
|
+
length: 8
|
|
81
|
+
tinyint:
|
|
82
|
+
target: smallint
|
|
83
|
+
uniqueidentifier:
|
|
84
|
+
target: uniqueidentifier
|
|
85
|
+
varbinary:
|
|
86
|
+
target: varbinary
|
|
87
|
+
length: source
|
|
88
|
+
varchar:
|
|
89
|
+
target: varchar
|
|
90
|
+
length: source
|
|
91
|
+
xml:
|
|
92
|
+
target: varchar
|
|
93
|
+
length: max
|