contextbase-shared-plugins 0.0.0a1__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.
- contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
- shared_plugins/__init__.py +8 -0
- shared_plugins/automation.py +11 -0
- shared_plugins/base_platform.py +105 -0
- shared_plugins/bindings.py +260 -0
- shared_plugins/dlt.py +89 -0
- shared_plugins/env.py +104 -0
- shared_plugins/exceptions.py +10 -0
- shared_plugins/google_client/__init__.py +1 -0
- shared_plugins/google_client/auth.py +108 -0
- shared_plugins/google_client/batch_retry.py +308 -0
- shared_plugins/google_client/http_errors.py +27 -0
- shared_plugins/machine_token.py +419 -0
- shared_plugins/microsoft_dataverse/__init__.py +27 -0
- shared_plugins/microsoft_dataverse/annotations.py +61 -0
- shared_plugins/microsoft_dataverse/auth.py +26 -0
- shared_plugins/microsoft_dataverse/binding_config.py +35 -0
- shared_plugins/microsoft_dataverse/client.py +468 -0
- shared_plugins/microsoft_dataverse/ctx.py +21 -0
- shared_plugins/microsoft_dataverse/identifiers.py +62 -0
- shared_plugins/microsoft_dataverse/ingress.py +53 -0
- shared_plugins/microsoft_dataverse/metadata.py +106 -0
- shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
- shared_plugins/microsoft_dataverse/source.py +299 -0
- shared_plugins/microsoft_dataverse/tables.py +34 -0
- shared_plugins/microsoft_dataverse/translators.py +133 -0
- shared_plugins/microsoft_dataverse/types.py +355 -0
- shared_plugins/microsoft_graph.py +250 -0
- shared_plugins/models.py +91 -0
- shared_plugins/naming.py +83 -0
- shared_plugins/pg_column_comments.py +59 -0
- shared_plugins/provider_token.py +238 -0
- shared_plugins/pyairbyte.py +485 -0
- shared_plugins/resources.py +179 -0
- shared_plugins/scratch.py +127 -0
- shared_plugins/sentry.py +117 -0
- shared_plugins/sqlalchemy_types.py +225 -0
- shared_plugins/sqlite.py +123 -0
- shared_plugins/values.py +117 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Dlt source factory for any Dataverse-backed plugin.
|
|
2
|
+
|
|
3
|
+
Plugin asset bodies call `build_dataverse_dlt_source(plugin_id, job, ...)`
|
|
4
|
+
to produce a `@dlt.source`-wrapped tuple of resources. One resource per
|
|
5
|
+
active table (delta or snapshot mode). The plugin's component.py is
|
|
6
|
+
agnostic to OData/Pydantic/runtime-schema concerns -- those all live here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator, Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import dlt
|
|
17
|
+
from shared_plugins.bindings import ResolvedBindingModels, iter_active_model_rows
|
|
18
|
+
from shared_plugins.naming import dlt_resource_name, dlt_source_name
|
|
19
|
+
from shared_plugins.resources import ctx_dlt_resource
|
|
20
|
+
|
|
21
|
+
from .client import DataverseClient
|
|
22
|
+
from .ctx import DataverseRowBase
|
|
23
|
+
from .runtime_schema import RuntimeDataverseTableSchema, build_runtime_table_schemas
|
|
24
|
+
from .tables import DataverseSyncMode, DataverseTableSpec
|
|
25
|
+
from .translators import dataverse_record_to_row
|
|
26
|
+
|
|
27
|
+
LOGGER = logging.getLogger(__name__)
|
|
28
|
+
# State key for the per-table change-tracking cursor. The stored value is a
|
|
29
|
+
# Dataverse change-tracking link: a mid-enumeration @odata.nextLink ($skiptoken)
|
|
30
|
+
# while an initial drain is still in flight, or a terminal @odata.deltaLink
|
|
31
|
+
# ($deltatoken) once it completes. The literal key stays "delta_link" so
|
|
32
|
+
# bindings already carrying a deltatoken under that key keep resuming instead of
|
|
33
|
+
# re-draining from scratch.
|
|
34
|
+
CHANGE_TRACKING_CURSOR_KEY = "delta_link"
|
|
35
|
+
# Upper bound on rows drained from a single table per pipeline run. dlt buffers
|
|
36
|
+
# each run's extract+normalize+load, and the memory peak is during normalize,
|
|
37
|
+
# driven by *wide* rows (e.g. emails with full HTML bodies) more than by row
|
|
38
|
+
# count. An unbounded initial drain OOM-killed an 8 GB host at ~6 GB; a 30k cap
|
|
39
|
+
# did NOT help, because the byte-heavy tables hold only a few thousand rows (well
|
|
40
|
+
# under 30k) so it never chunked them. cap=2000 ran comfortably under budget.
|
|
41
|
+
# A later raise to 5000 (to backfill activityparties in fewer runs) let the
|
|
42
|
+
# content tables emails (~5.5k) and crff1_emaillogses (~5.1k) drain in roughly
|
|
43
|
+
# one run each and OOM-killed a fresh 8 GB deployment on every cycle: the step
|
|
44
|
+
# peaked at ~5.8 GB in normalize, the kill landed before the load committed, so
|
|
45
|
+
# the $skiptoken never persisted and each */15 run re-pulled the same rows and
|
|
46
|
+
# re-died (codecreators V-6, 2026-07-09). 2000 is the empirically proven value
|
|
47
|
+
# for 8 GB hosts — do NOT raise it to speed up backfills; the cap only sets how
|
|
48
|
+
# many runs a drain takes, never what data ultimately lands. When the cap is
|
|
49
|
+
# reached mid-enumeration the resource persists the change-tracking $skiptoken
|
|
50
|
+
# and returns; the next run resumes from it and the `merge` disposition upserts
|
|
51
|
+
# the chunks together, so a table backfills across several scheduled runs before
|
|
52
|
+
# settling into incremental ($deltatoken) syncs.
|
|
53
|
+
# Operator-level tuning knob, hence a module constant rather than a BindingConfig
|
|
54
|
+
# field; size it against real-host memory, not row count.
|
|
55
|
+
MAX_DRAIN_ROWS_PER_RUN = 2000
|
|
56
|
+
SNAPSHOT_WRITE_DISPOSITION = {
|
|
57
|
+
"disposition": "merge",
|
|
58
|
+
"strategy": "delete-insert",
|
|
59
|
+
}
|
|
60
|
+
SNAPSHOT_MERGE_KEY = ("_ctx_binding_id",)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_dataverse_dlt_source(
|
|
64
|
+
*,
|
|
65
|
+
plugin_id: str,
|
|
66
|
+
job: str,
|
|
67
|
+
binding_id: str,
|
|
68
|
+
client: DataverseClient,
|
|
69
|
+
binding_models: ResolvedBindingModels,
|
|
70
|
+
specs: tuple[DataverseTableSpec, ...],
|
|
71
|
+
) -> Any:
|
|
72
|
+
active_specs = tuple(
|
|
73
|
+
spec for spec in specs if spec.resource_name in binding_models.active
|
|
74
|
+
)
|
|
75
|
+
runtime_schemas: Mapping[str, RuntimeDataverseTableSchema] = (
|
|
76
|
+
build_runtime_table_schemas(client=client, specs=active_specs)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@dlt.source(name=dlt_source_name(plugin_id, job))
|
|
80
|
+
def _source() -> tuple[Any, ...]:
|
|
81
|
+
resources: list[Any] = []
|
|
82
|
+
for spec in active_specs:
|
|
83
|
+
schema = runtime_schemas[spec.entity_set]
|
|
84
|
+
if spec.sync_mode is DataverseSyncMode.DELTA:
|
|
85
|
+
resources.append(
|
|
86
|
+
_build_delta_resource(
|
|
87
|
+
binding_id=binding_id,
|
|
88
|
+
client=client,
|
|
89
|
+
schema=schema,
|
|
90
|
+
binding_models=binding_models,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
resources.append(
|
|
95
|
+
_build_snapshot_resource(
|
|
96
|
+
binding_id=binding_id,
|
|
97
|
+
client=client,
|
|
98
|
+
schema=schema,
|
|
99
|
+
binding_models=binding_models,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not resources:
|
|
104
|
+
LOGGER.info(
|
|
105
|
+
"%s.%s.empty_source binding_id=%s active_models=%s",
|
|
106
|
+
plugin_id,
|
|
107
|
+
job,
|
|
108
|
+
binding_id,
|
|
109
|
+
tuple(binding_models.active),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return tuple(resources)
|
|
113
|
+
|
|
114
|
+
return _source()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_delta_resource(
|
|
118
|
+
*,
|
|
119
|
+
binding_id: str,
|
|
120
|
+
client: DataverseClient,
|
|
121
|
+
schema: RuntimeDataverseTableSchema,
|
|
122
|
+
binding_models: ResolvedBindingModels,
|
|
123
|
+
) -> Any:
|
|
124
|
+
spec = schema.spec
|
|
125
|
+
|
|
126
|
+
@ctx_dlt_resource(
|
|
127
|
+
name=dlt_resource_name(spec.resource_name),
|
|
128
|
+
write_disposition="merge",
|
|
129
|
+
primary_key=("_ctx_binding_id", spec.primary_key),
|
|
130
|
+
columns=schema.dlt_columns,
|
|
131
|
+
)
|
|
132
|
+
def delta_resource() -> Iterator[DataverseRowBase]:
|
|
133
|
+
state = dlt.current.resource_state()
|
|
134
|
+
initial_cursor = state.get(CHANGE_TRACKING_CURSOR_KEY)
|
|
135
|
+
if initial_cursor is not None and not isinstance(initial_cursor, str):
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"Stored Dataverse change-tracking cursor for {spec.entity_set} "
|
|
138
|
+
"is not a string."
|
|
139
|
+
)
|
|
140
|
+
next_cursor: dict[str, str] = {}
|
|
141
|
+
rows = _iter_delta_rows(
|
|
142
|
+
binding_id=binding_id,
|
|
143
|
+
client=client,
|
|
144
|
+
schema=schema,
|
|
145
|
+
initial_cursor=initial_cursor,
|
|
146
|
+
next_cursor=next_cursor,
|
|
147
|
+
)
|
|
148
|
+
yield from iter_active_model_rows(
|
|
149
|
+
model_name=spec.resource_name,
|
|
150
|
+
rows=rows,
|
|
151
|
+
binding_models=binding_models,
|
|
152
|
+
)
|
|
153
|
+
state[CHANGE_TRACKING_CURSOR_KEY] = next_cursor[CHANGE_TRACKING_CURSOR_KEY]
|
|
154
|
+
|
|
155
|
+
return delta_resource
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_snapshot_resource(
|
|
159
|
+
*,
|
|
160
|
+
binding_id: str,
|
|
161
|
+
client: DataverseClient,
|
|
162
|
+
schema: RuntimeDataverseTableSchema,
|
|
163
|
+
binding_models: ResolvedBindingModels,
|
|
164
|
+
) -> Any:
|
|
165
|
+
spec = schema.spec
|
|
166
|
+
|
|
167
|
+
@ctx_dlt_resource(
|
|
168
|
+
name=dlt_resource_name(spec.resource_name),
|
|
169
|
+
write_disposition=SNAPSHOT_WRITE_DISPOSITION,
|
|
170
|
+
merge_key=SNAPSHOT_MERGE_KEY,
|
|
171
|
+
primary_key=("_ctx_binding_id", spec.primary_key),
|
|
172
|
+
columns=schema.dlt_columns,
|
|
173
|
+
)
|
|
174
|
+
def snapshot_resource() -> Iterator[DataverseRowBase]:
|
|
175
|
+
rows = _iter_snapshot_rows(
|
|
176
|
+
binding_id=binding_id,
|
|
177
|
+
client=client,
|
|
178
|
+
schema=schema,
|
|
179
|
+
)
|
|
180
|
+
yield from iter_active_model_rows(
|
|
181
|
+
model_name=spec.resource_name,
|
|
182
|
+
rows=rows,
|
|
183
|
+
binding_models=binding_models,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return snapshot_resource
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _iter_delta_rows(
|
|
190
|
+
*,
|
|
191
|
+
binding_id: str,
|
|
192
|
+
client: DataverseClient,
|
|
193
|
+
schema: RuntimeDataverseTableSchema,
|
|
194
|
+
initial_cursor: str | None,
|
|
195
|
+
next_cursor: dict[str, str],
|
|
196
|
+
) -> Iterator[DataverseRowBase]:
|
|
197
|
+
spec = schema.spec
|
|
198
|
+
# An initial drain may already be partway through (resuming from a stored
|
|
199
|
+
# $skiptoken); only a stored $deltatoken means we are in steady-state
|
|
200
|
+
# incremental sync. `iter_change_tracking_pages` resumes from either link.
|
|
201
|
+
incremental = initial_cursor is not None and "$deltatoken" in initial_cursor
|
|
202
|
+
row_count = 0
|
|
203
|
+
page_count = 0
|
|
204
|
+
saved_cursor: str | None = None
|
|
205
|
+
t0 = time.monotonic()
|
|
206
|
+
|
|
207
|
+
LOGGER.info(
|
|
208
|
+
"dataverse.delta.start entity_set=%s mode=%s",
|
|
209
|
+
spec.entity_set,
|
|
210
|
+
"incremental" if incremental else "initial",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
for page in client.iter_change_tracking_pages(
|
|
214
|
+
spec,
|
|
215
|
+
delta_link=initial_cursor,
|
|
216
|
+
select=schema.select_columns,
|
|
217
|
+
):
|
|
218
|
+
page_count += 1
|
|
219
|
+
for record in page.value:
|
|
220
|
+
raw_payload = record.model_dump(by_alias=True, exclude_none=True)
|
|
221
|
+
row_count += 1
|
|
222
|
+
yield dataverse_record_to_row(
|
|
223
|
+
binding_id=binding_id,
|
|
224
|
+
schema=schema,
|
|
225
|
+
record=record,
|
|
226
|
+
raw_payload=raw_payload,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if page.delta_link is not None:
|
|
230
|
+
# Enumeration complete: the deltaLink is the incremental cursor for
|
|
231
|
+
# the next run.
|
|
232
|
+
saved_cursor = page.delta_link
|
|
233
|
+
break
|
|
234
|
+
if row_count >= MAX_DRAIN_ROWS_PER_RUN:
|
|
235
|
+
# Per-run cap reached while the initial drain is still in flight.
|
|
236
|
+
# Persist this page's nextLink ($skiptoken) so the next run resumes
|
|
237
|
+
# here; merge upserts accumulate the chunks across runs.
|
|
238
|
+
if page.next_link is None:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"Dataverse change-tracking page for {spec.entity_set} had "
|
|
241
|
+
"neither a nextLink to resume from nor a deltaLink."
|
|
242
|
+
)
|
|
243
|
+
saved_cursor = page.next_link
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if saved_cursor is None:
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
f"Dataverse change-tracking enumeration for {spec.entity_set} ended "
|
|
249
|
+
"without a deltaLink or a resumable nextLink."
|
|
250
|
+
)
|
|
251
|
+
next_cursor[CHANGE_TRACKING_CURSOR_KEY] = saved_cursor
|
|
252
|
+
|
|
253
|
+
LOGGER.info(
|
|
254
|
+
"dataverse.delta.done entity_set=%s mode=%s pages=%d rows=%d "
|
|
255
|
+
"more_pending=%s elapsed=%.1fs",
|
|
256
|
+
spec.entity_set,
|
|
257
|
+
"incremental" if incremental else "initial",
|
|
258
|
+
page_count,
|
|
259
|
+
row_count,
|
|
260
|
+
"$skiptoken" in saved_cursor,
|
|
261
|
+
time.monotonic() - t0,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _iter_snapshot_rows(
|
|
266
|
+
*,
|
|
267
|
+
binding_id: str,
|
|
268
|
+
client: DataverseClient,
|
|
269
|
+
schema: RuntimeDataverseTableSchema,
|
|
270
|
+
) -> Iterator[DataverseRowBase]:
|
|
271
|
+
spec = schema.spec
|
|
272
|
+
row_count = 0
|
|
273
|
+
page_count = 0
|
|
274
|
+
t0 = time.monotonic()
|
|
275
|
+
|
|
276
|
+
LOGGER.info("dataverse.snapshot.start entity_set=%s", spec.entity_set)
|
|
277
|
+
|
|
278
|
+
for page in client.iter_snapshot_pages(
|
|
279
|
+
spec,
|
|
280
|
+
select=schema.select_columns,
|
|
281
|
+
):
|
|
282
|
+
page_count += 1
|
|
283
|
+
for record in page.value:
|
|
284
|
+
raw_payload = record.model_dump(by_alias=True, exclude_none=True)
|
|
285
|
+
row_count += 1
|
|
286
|
+
yield dataverse_record_to_row(
|
|
287
|
+
binding_id=binding_id,
|
|
288
|
+
schema=schema,
|
|
289
|
+
record=record,
|
|
290
|
+
raw_payload=raw_payload,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
LOGGER.info(
|
|
294
|
+
"dataverse.snapshot.done entity_set=%s pages=%d rows=%d elapsed=%.1fs",
|
|
295
|
+
spec.entity_set,
|
|
296
|
+
page_count,
|
|
297
|
+
row_count,
|
|
298
|
+
time.monotonic() - t0,
|
|
299
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DataverseSyncMode(str, Enum):
|
|
8
|
+
DELTA = "delta"
|
|
9
|
+
SNAPSHOT = "snapshot"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class DataverseTableSpec:
|
|
14
|
+
"""Identifies one Dataverse entity set and how it should be synced.
|
|
15
|
+
|
|
16
|
+
Fields:
|
|
17
|
+
- entity_set: the OData entity-set name (e.g. "msdyn_projects").
|
|
18
|
+
- primary_key: the Dataverse logical name of the table's PK column
|
|
19
|
+
(e.g. "msdyn_projectid"). Used as the dlt primary_key trailing
|
|
20
|
+
element after _ctx_binding_id. Plugin authors look up this value
|
|
21
|
+
from Dataverse EntityDefinitions.PrimaryIdAttribute and hard-code
|
|
22
|
+
it in the spec — keeps the spec list self-documenting.
|
|
23
|
+
- sync_mode: DELTA (Prefer: odata.track-changes) or SNAPSHOT (full pull).
|
|
24
|
+
- resource_name: the dlt resource name. Conventionally equal to
|
|
25
|
+
entity_set, but kept distinct so plugins can rename if needed.
|
|
26
|
+
|
|
27
|
+
Note: $select is NOT on the spec. Selected columns are computed at
|
|
28
|
+
warmup from per-tenant attribute metadata (see runtime_schema.py).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
entity_set: str
|
|
32
|
+
primary_key: str
|
|
33
|
+
sync_mode: DataverseSyncMode
|
|
34
|
+
resource_name: str
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Per-record translation: Dataverse OData payload → per-table CtxModel instance.
|
|
2
|
+
|
|
3
|
+
The translator walks the raw OData payload, splits annotation keys
|
|
4
|
+
(`<col>@<annotation>`) per the registry policy, and constructs an instance
|
|
5
|
+
of the per-table runtime CtxModel subclass. Returns a CtxModel — NOT a dict
|
|
6
|
+
— because @ctx_dlt_resource validates emitted rows are CtxModel instances.
|
|
7
|
+
|
|
8
|
+
Unknown annotations raise PluginConfigurationError. Dropped annotations are
|
|
9
|
+
silently omitted (they're triaged and intentional). Kept annotations land
|
|
10
|
+
on the companion column suffix.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from shared_plugins.exceptions import PluginConfigurationError
|
|
18
|
+
|
|
19
|
+
from .annotations import (
|
|
20
|
+
DROPPED_ODATA_ANNOTATIONS,
|
|
21
|
+
DROPPED_RESPONSE_FIELDS,
|
|
22
|
+
ODATA_ANNOTATION_COLUMN_SUFFIXES,
|
|
23
|
+
)
|
|
24
|
+
from .ctx import DataverseRowBase
|
|
25
|
+
from .ingress import DataverseRecordIngress, is_deleted_record
|
|
26
|
+
from .runtime_schema import RuntimeDataverseTableSchema
|
|
27
|
+
|
|
28
|
+
# Keys that DataverseRecordIngress emits via model_dump(by_alias=True): the
|
|
29
|
+
# @odata.* aliases plus the non-aliased tombstone fields (`id`, `reason`).
|
|
30
|
+
# The column-flatten step skips these so only column data (extras under
|
|
31
|
+
# DataverseRecordIngress's extra="allow") reaches the per-table row model.
|
|
32
|
+
# Derived from the ingress model so new envelope fields are picked up
|
|
33
|
+
# automatically without touching the flatten filter.
|
|
34
|
+
_DATAVERSE_ENVELOPE_KEYS: frozenset[str] = frozenset(
|
|
35
|
+
(field.alias or name) for name, field in DataverseRecordIngress.model_fields.items()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dataverse_record_to_row(
|
|
40
|
+
*,
|
|
41
|
+
binding_id: str,
|
|
42
|
+
schema: RuntimeDataverseTableSchema,
|
|
43
|
+
record: DataverseRecordIngress,
|
|
44
|
+
raw_payload: dict[str, Any],
|
|
45
|
+
) -> DataverseRowBase:
|
|
46
|
+
"""Convert one Dataverse record into a per-table CtxModel instance.
|
|
47
|
+
|
|
48
|
+
`raw_payload` is the same OData record dict that produced `record` — the
|
|
49
|
+
strict envelope view (`record`) carries only the validated envelope
|
|
50
|
+
fields, while the full raw view (`raw_payload`) keeps every key from the
|
|
51
|
+
source response, including annotation keys (`<col>@<ann>`) that the
|
|
52
|
+
strict envelope does not preserve.
|
|
53
|
+
|
|
54
|
+
Tombstones (delta-stream delete entries) arrive without column data and
|
|
55
|
+
without the entity's PK column; the deleted row's identifier is on
|
|
56
|
+
`record.id`. This translator injects it as the PK column so dlt's
|
|
57
|
+
`hard_delete` hint on `is_deleted` (see build_dlt_column_hints in
|
|
58
|
+
types.py) issues a DELETE for the matching destination row during merge.
|
|
59
|
+
"""
|
|
60
|
+
deleted = is_deleted_record(record, schema.spec, payload=raw_payload)
|
|
61
|
+
ctx_source_updated_at = raw_payload.get("modifiedon")
|
|
62
|
+
|
|
63
|
+
base_values: dict[str, Any] = {
|
|
64
|
+
"_ctx_binding_id": binding_id,
|
|
65
|
+
"_ctx_source_updated_at": ctx_source_updated_at,
|
|
66
|
+
"etag": record.odata_etag,
|
|
67
|
+
"is_deleted": deleted,
|
|
68
|
+
"delete_reason": record.reason,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
column_values = _flatten_columns_and_annotations(
|
|
72
|
+
raw_payload=raw_payload,
|
|
73
|
+
schema=schema,
|
|
74
|
+
)
|
|
75
|
+
base_values.update(column_values)
|
|
76
|
+
|
|
77
|
+
if deleted:
|
|
78
|
+
if not isinstance(record.id, str):
|
|
79
|
+
raise PluginConfigurationError(
|
|
80
|
+
f"Dataverse tombstone for entity_set "
|
|
81
|
+
f"{schema.spec.entity_set!r} has reason={record.reason!r} "
|
|
82
|
+
"but no 'id' field on the envelope; cannot identify the "
|
|
83
|
+
"deleted row."
|
|
84
|
+
)
|
|
85
|
+
base_values[schema.spec.primary_key] = record.id
|
|
86
|
+
|
|
87
|
+
return schema.record_model.model_validate(base_values)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _flatten_columns_and_annotations(
|
|
91
|
+
*,
|
|
92
|
+
raw_payload: dict[str, Any],
|
|
93
|
+
schema: RuntimeDataverseTableSchema,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Walk raw payload keys, returning a {column_name: value} dict.
|
|
96
|
+
|
|
97
|
+
- Envelope keys (declared on DataverseRecordIngress: @odata.* aliases
|
|
98
|
+
plus `id` and `reason`) are skipped — those are read from `record`
|
|
99
|
+
directly by the caller.
|
|
100
|
+
- Plain keys in DROPPED_RESPONSE_FIELDS (unsolicited, unmodeled fields
|
|
101
|
+
Dataverse volunteers) are dropped per the triaged registry.
|
|
102
|
+
- Other plain keys land under their logical name.
|
|
103
|
+
- Annotation keys (`<col>@<annotation>`) land under
|
|
104
|
+
`<col><suffix>` per ODATA_ANNOTATION_COLUMN_SUFFIXES, or are dropped
|
|
105
|
+
per DROPPED_ODATA_ANNOTATIONS, or raise on unknown.
|
|
106
|
+
"""
|
|
107
|
+
out: dict[str, Any] = {}
|
|
108
|
+
for key, value in raw_payload.items():
|
|
109
|
+
if key in _DATAVERSE_ENVELOPE_KEYS:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if "@" not in key:
|
|
113
|
+
if key in DROPPED_RESPONSE_FIELDS:
|
|
114
|
+
continue
|
|
115
|
+
out[key] = value
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
base, _, annotation_token = key.partition("@")
|
|
119
|
+
annotation = f"@{annotation_token}"
|
|
120
|
+
if annotation in DROPPED_ODATA_ANNOTATIONS:
|
|
121
|
+
continue
|
|
122
|
+
suffix = ODATA_ANNOTATION_COLUMN_SUFFIXES.get(annotation)
|
|
123
|
+
if suffix is None:
|
|
124
|
+
raise PluginConfigurationError(
|
|
125
|
+
f"Encountered unknown OData annotation {annotation!r} on "
|
|
126
|
+
f"column {base!r} in entity_set {schema.spec.entity_set!r}. "
|
|
127
|
+
"Triage by adding it to ODATA_ANNOTATION_COLUMN_SUFFIXES "
|
|
128
|
+
"(keep) or DROPPED_ODATA_ANNOTATIONS (drop) in "
|
|
129
|
+
"shared_plugins.microsoft_dataverse.annotations."
|
|
130
|
+
)
|
|
131
|
+
out[f"{base}{suffix}"] = value
|
|
132
|
+
|
|
133
|
+
return out
|