smart-data-engine-sdk 0.1.0.dev0__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.
- sde/__init__.py +226 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/engines/__init__.py +0 -0
- sde/engines/clickhouse.py +689 -0
- sde/engines/orderbook.py +454 -0
- sde/engines/postgres.py +672 -0
- sde/entity.py +170 -0
- sde/errors.py +88 -0
- sde/explain.py +300 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/infer.py +461 -0
- sde/internal.py +90 -0
- sde/layout.py +660 -0
- sde/logging.py +132 -0
- sde/migration.py +820 -0
- sde/model.py +482 -0
- sde/placement.py +818 -0
- sde/py.typed +0 -0
- sde/routing.py +85 -0
- sde/schema.py +370 -0
- sde/session.py +507 -0
- sde/shapes.py +153 -0
- sde/telemetry.py +736 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +318 -0
- sde/types.py +228 -0
- sde/watermark.py +222 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/METADATA +152 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/RECORD +35 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/NOTICE +13 -0
sde/model.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Turning declarations into a canonical model, and the model into a version.
|
|
2
|
+
|
|
3
|
+
The canonical IR is the artefact every library has to agree on byte for byte. Its shape is described
|
|
4
|
+
in ``docs/format-contract.md`` and pinned by ``conformance/vectors/model/*``. Two rules from the
|
|
5
|
+
contract show up all over this file and are worth naming before you read it:
|
|
6
|
+
|
|
7
|
+
* Arrays are sorted wherever order carries no meaning - entities by name, fields by name, relations
|
|
8
|
+
by ``(from, name, to)``. Nothing may depend on the order the client happened to declare things in,
|
|
9
|
+
because that order is not part of their model.
|
|
10
|
+
* Where order *does* carry meaning, it is recorded as an explicit ``position`` inside each element
|
|
11
|
+
rather than as a position in the array. Composite keys are the case that matters: ``(tenant, id)``
|
|
12
|
+
and ``(id, tenant)`` are different keys, and a reader must not have to know that array order is
|
|
13
|
+
significant here but not three lines above.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from typing import Any, get_args, get_origin, get_type_hints
|
|
21
|
+
|
|
22
|
+
from .canonical import canonical_bytes, digest16
|
|
23
|
+
from .entity import EntityDecl, Ref, registry
|
|
24
|
+
from .errors import DeclarationError
|
|
25
|
+
from .logging import log
|
|
26
|
+
from .types import resolve_type
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CONTRACT",
|
|
30
|
+
"EntitySpec",
|
|
31
|
+
"FieldSpec",
|
|
32
|
+
"LogicalModel",
|
|
33
|
+
"RelationSpec",
|
|
34
|
+
"assemble",
|
|
35
|
+
"build_model",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
CONTRACT = 1
|
|
39
|
+
"""The **IR's** format version, and the one the conformance vectors are pinned to.
|
|
40
|
+
|
|
41
|
+
Separate from :data:`sde.placement.MAP_CONTRACT` since the placement map gained a key the IR did
|
|
42
|
+
not, and that separation is a correction rather than a convenience. One counter for two artefacts
|
|
43
|
+
means every artefact's version moves when any one of them changes - and because ``contract`` is
|
|
44
|
+
*inside* the IR, and ``model_version`` is a digest of the IR, bumping it for a change to the map
|
|
45
|
+
format would give every client a new model version, invalidate every issued map, and re-bless every
|
|
46
|
+
model vector. For a key in a different document.
|
|
47
|
+
|
|
48
|
+
The evidence that the split is right is the hand-written vector: ``model/001-single-entity`` was
|
|
49
|
+
typed out from the format contract to prove the document is implementable, and its digest is pinned
|
|
50
|
+
in CI. Adding ``also_write`` to the map does not move it, because nothing about the IR changed.
|
|
51
|
+
"""
|
|
52
|
+
"""Format contract version. Bumped when the IR shape or the encoding rules change."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class FieldSpec:
|
|
57
|
+
name: str
|
|
58
|
+
type: str
|
|
59
|
+
nullable: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class RelationSpec:
|
|
64
|
+
name: str
|
|
65
|
+
source: str
|
|
66
|
+
target: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class EntitySpec:
|
|
71
|
+
name: str
|
|
72
|
+
fields: tuple[FieldSpec, ...]
|
|
73
|
+
key: tuple[str, ...]
|
|
74
|
+
pii: tuple[str, ...]
|
|
75
|
+
residency: str | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class LogicalModel:
|
|
80
|
+
entities: tuple[EntitySpec, ...]
|
|
81
|
+
relations: tuple[RelationSpec, ...]
|
|
82
|
+
atomic: tuple[tuple[str, ...], ...]
|
|
83
|
+
cost_ceiling: Mapping[str, str] | None
|
|
84
|
+
ir: Mapping[str, Any]
|
|
85
|
+
version: str
|
|
86
|
+
|
|
87
|
+
def entity(self, name: str) -> EntitySpec:
|
|
88
|
+
for spec in self.entities:
|
|
89
|
+
if spec.name == name:
|
|
90
|
+
return spec
|
|
91
|
+
raise KeyError(name)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ir_bytes(self) -> bytes:
|
|
95
|
+
return canonical_bytes(self.ir)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_hints(decl: EntityDecl) -> dict[str, Any]:
|
|
99
|
+
try:
|
|
100
|
+
return get_type_hints(decl.cls, localns=dict(decl.localns), include_extras=True)
|
|
101
|
+
except NameError as exc:
|
|
102
|
+
raise DeclarationError(
|
|
103
|
+
f"{decl.name} refers to a name that is not defined yet ({exc}). Entities may reference "
|
|
104
|
+
"each other in any order, but every name has to exist by the time build_model() runs."
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split_fields(
|
|
109
|
+
decl: EntityDecl, hints: Mapping[str, Any], known: set[str]
|
|
110
|
+
) -> tuple[list[FieldSpec], list[RelationSpec]]:
|
|
111
|
+
fields: list[FieldSpec] = []
|
|
112
|
+
relations: list[RelationSpec] = []
|
|
113
|
+
for name, annotation in hints.items():
|
|
114
|
+
if name.startswith("_"):
|
|
115
|
+
continue
|
|
116
|
+
if get_origin(annotation) is Ref:
|
|
117
|
+
args = get_args(annotation)
|
|
118
|
+
if len(args) != 1 or not isinstance(args[0], type):
|
|
119
|
+
raise DeclarationError(
|
|
120
|
+
f"{decl.name}.{name} is a Ref without a single entity target"
|
|
121
|
+
)
|
|
122
|
+
target = args[0].__name__
|
|
123
|
+
if target not in known:
|
|
124
|
+
raise DeclarationError(
|
|
125
|
+
f"{decl.name}.{name} points at {target}, which is not a declared entity. Add "
|
|
126
|
+
"@sde.entity to it, or include it in the model you are building."
|
|
127
|
+
)
|
|
128
|
+
relations.append(RelationSpec(name=name, source=decl.name, target=target))
|
|
129
|
+
continue
|
|
130
|
+
neutral, nullable = resolve_type(annotation, field=name, entity=decl.name)
|
|
131
|
+
fields.append(FieldSpec(name=name, type=neutral, nullable=nullable))
|
|
132
|
+
return fields, relations
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_key(decl: EntityDecl, fields: list[FieldSpec]) -> tuple[str, ...]:
|
|
136
|
+
names = {f.name for f in fields}
|
|
137
|
+
if decl.key is not None:
|
|
138
|
+
missing = [k for k in decl.key if k not in names]
|
|
139
|
+
if missing:
|
|
140
|
+
raise DeclarationError(
|
|
141
|
+
f"{decl.name}.Meta.key names {missing}, which are not fields of {decl.name}. A "
|
|
142
|
+
"relation cannot be part of a key: the key has to be storable in the entity itself."
|
|
143
|
+
)
|
|
144
|
+
if not decl.key:
|
|
145
|
+
raise DeclarationError(f"{decl.name}.Meta.key is empty")
|
|
146
|
+
return decl.key
|
|
147
|
+
if "id" in names:
|
|
148
|
+
return ("id",)
|
|
149
|
+
raise DeclarationError(
|
|
150
|
+
f"{decl.name} has no 'id' field and no Meta.key. Every entity needs a key: without one "
|
|
151
|
+
"there "
|
|
152
|
+
"is no way to address a row, no way to migrate it and no way to verify a migration moved "
|
|
153
|
+
"it."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _normalise_atomic(
|
|
158
|
+
decls: tuple[EntityDecl, ...], known: set[str]
|
|
159
|
+
) -> tuple[tuple[str, ...], ...]:
|
|
160
|
+
"""Turn pairwise ``atomic_with`` declarations into merged, sorted groups.
|
|
161
|
+
|
|
162
|
+
``atomic_with`` is symmetric even when written on one side only: if A must change atomically
|
|
163
|
+
with B then B must change atomically with A, so a client declaring it once is enough and
|
|
164
|
+
declaring it twice must not produce two different groups. Merging is transitive for the same
|
|
165
|
+
reason - if A is atomic with B and B with C, all three have to commit together, and nothing else
|
|
166
|
+
would be implementable on a single engine's transaction.
|
|
167
|
+
"""
|
|
168
|
+
parent: dict[str, str] = {d.name: d.name for d in decls}
|
|
169
|
+
|
|
170
|
+
def find(x: str) -> str:
|
|
171
|
+
while parent[x] != x:
|
|
172
|
+
parent[x] = parent[parent[x]]
|
|
173
|
+
x = parent[x]
|
|
174
|
+
return x
|
|
175
|
+
|
|
176
|
+
def union(a: str, b: str) -> None:
|
|
177
|
+
ra, rb = find(a), find(b)
|
|
178
|
+
if ra != rb:
|
|
179
|
+
parent[max(ra, rb)] = min(ra, rb)
|
|
180
|
+
|
|
181
|
+
touched: set[str] = set()
|
|
182
|
+
for decl in decls:
|
|
183
|
+
for other in decl.atomic_with:
|
|
184
|
+
if other not in known:
|
|
185
|
+
raise DeclarationError(
|
|
186
|
+
f"{decl.name}.Meta.atomic_with names {other!r}, which is not a declared entity"
|
|
187
|
+
)
|
|
188
|
+
if other == decl.name:
|
|
189
|
+
raise DeclarationError(
|
|
190
|
+
f"{decl.name}.Meta.atomic_with names itself, which says nothing"
|
|
191
|
+
)
|
|
192
|
+
union(decl.name, other)
|
|
193
|
+
touched.add(decl.name)
|
|
194
|
+
touched.add(other)
|
|
195
|
+
|
|
196
|
+
groups: dict[str, list[str]] = {}
|
|
197
|
+
for name in sorted(touched):
|
|
198
|
+
groups.setdefault(find(name), []).append(name)
|
|
199
|
+
return tuple(sorted(tuple(sorted(members)) for members in groups.values()))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_model(
|
|
203
|
+
*entities: type,
|
|
204
|
+
cost_ceiling: Mapping[str, str] | None = None,
|
|
205
|
+
) -> LogicalModel:
|
|
206
|
+
"""Resolve declarations into a :class:`LogicalModel`.
|
|
207
|
+
|
|
208
|
+
With no arguments, everything decorated with :func:`~sde.entity.entity` so far is used. Passing
|
|
209
|
+
entities explicitly is what tests do, because a global registry and test isolation do not mix.
|
|
210
|
+
|
|
211
|
+
``cost_ceiling`` is ``{"amount": "500.00", "currency": "EUR"}`` - the amount is a *string* on
|
|
212
|
+
purpose. It is money, so it is a decimal, and a decimal in the IR would have to be a float,
|
|
213
|
+
which the canonical encoding refuses for good reason.
|
|
214
|
+
"""
|
|
215
|
+
decls: tuple[EntityDecl, ...]
|
|
216
|
+
if entities:
|
|
217
|
+
collected: list[EntityDecl] = []
|
|
218
|
+
for cls in entities:
|
|
219
|
+
decl = getattr(cls, "__sde_decl__", None)
|
|
220
|
+
if decl is None:
|
|
221
|
+
raise DeclarationError(
|
|
222
|
+
f"{cls.__name__} is not an entity. Decorate it with @sde.entity."
|
|
223
|
+
)
|
|
224
|
+
collected.append(decl)
|
|
225
|
+
decls = tuple(collected)
|
|
226
|
+
else:
|
|
227
|
+
decls = registry()
|
|
228
|
+
|
|
229
|
+
if not decls:
|
|
230
|
+
raise DeclarationError("no entities declared, so there is no model to build")
|
|
231
|
+
|
|
232
|
+
known = {d.name for d in decls}
|
|
233
|
+
|
|
234
|
+
specs: list[EntitySpec] = []
|
|
235
|
+
relations: list[RelationSpec] = []
|
|
236
|
+
for decl in decls:
|
|
237
|
+
hints = _resolve_hints(decl)
|
|
238
|
+
fields, rels = _split_fields(decl, hints, known)
|
|
239
|
+
# "no fields" and "pii names something that is not a field" used to be checked here and
|
|
240
|
+
# not on the neutral-JSON path. They are in `assemble` now, which both paths go through: a
|
|
241
|
+
# guarantee that holds at one of two front doors holds for one of two callers.
|
|
242
|
+
specs.append(
|
|
243
|
+
EntitySpec(
|
|
244
|
+
name=decl.name,
|
|
245
|
+
fields=tuple(sorted(fields, key=lambda f: f.name)),
|
|
246
|
+
# Not resolved for an entity with no fields: `assemble` refuses that first (§8a),
|
|
247
|
+
# and resolving here would refuse a relation-only entity for having no key - which
|
|
248
|
+
# sends the reader looking for a Meta.key when the problem is that nothing is
|
|
249
|
+
# stored.
|
|
250
|
+
key=_resolve_key(decl, fields) if fields else (),
|
|
251
|
+
pii=tuple(sorted(decl.pii)),
|
|
252
|
+
residency=decl.residency,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
relations.extend(rels)
|
|
256
|
+
|
|
257
|
+
atomic = _normalise_atomic(decls, known)
|
|
258
|
+
|
|
259
|
+
if cost_ceiling is not None:
|
|
260
|
+
missing = {"amount", "currency"} - set(cost_ceiling)
|
|
261
|
+
if missing:
|
|
262
|
+
raise DeclarationError(
|
|
263
|
+
f"cost_ceiling is missing {sorted(missing)}; it is "
|
|
264
|
+
'{"amount": "500.00", "currency": "EUR"} with the amount as a string'
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return assemble(
|
|
268
|
+
entities=tuple(specs),
|
|
269
|
+
relations=tuple(relations),
|
|
270
|
+
atomic=atomic,
|
|
271
|
+
cost_ceiling=cost_ceiling,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def neutral_declaration(model: LogicalModel) -> dict[str, Any]:
|
|
276
|
+
"""The model as the neutral form of format-contract §4a.
|
|
277
|
+
|
|
278
|
+
The inverse of :func:`sde.testing.loader.model_from_neutral`, and it exists because the control
|
|
279
|
+
plane takes a client's model as *that* document while a client declares it in whichever way
|
|
280
|
+
their language is comfortable with. Without this, a client using the decorator API has to write
|
|
281
|
+
their model a second time by hand for `declare`, and the two copies then drift.
|
|
282
|
+
|
|
283
|
+
**The IR is not this document.** They look close enough to be confused - our own tests declared
|
|
284
|
+
``model.ir`` and got away with it because nothing read the stored file back - and they differ
|
|
285
|
+
exactly where it matters: the IR records key order as ``[{"field": "id", "position": 0}]``
|
|
286
|
+
because array order is not load-bearing anywhere else in it, and the neutral form states a key
|
|
287
|
+
as ``["id"]`` because that is what a person writes. Feeding the IR to the loader is refused, and
|
|
288
|
+
``test_neutral_declaration.py`` pins that as well as the round trip.
|
|
289
|
+
"""
|
|
290
|
+
entities: list[dict[str, Any]] = []
|
|
291
|
+
for spec in model.entities:
|
|
292
|
+
entity: dict[str, Any] = {
|
|
293
|
+
"name": spec.name,
|
|
294
|
+
"fields": [
|
|
295
|
+
{"name": f.name, "type": f.type, **({"nullable": True} if f.nullable else {})}
|
|
296
|
+
for f in spec.fields
|
|
297
|
+
],
|
|
298
|
+
"key": list(spec.key),
|
|
299
|
+
}
|
|
300
|
+
if spec.pii:
|
|
301
|
+
entity["pii"] = list(spec.pii)
|
|
302
|
+
if spec.residency is not None:
|
|
303
|
+
entity["residency"] = spec.residency
|
|
304
|
+
entities.append(entity)
|
|
305
|
+
|
|
306
|
+
document: dict[str, Any] = {"entities": entities}
|
|
307
|
+
if model.relations:
|
|
308
|
+
document["relations"] = [
|
|
309
|
+
{"name": r.name, "from": r.source, "to": r.target} for r in model.relations
|
|
310
|
+
]
|
|
311
|
+
if model.atomic:
|
|
312
|
+
document["atomic"] = [list(group) for group in model.atomic]
|
|
313
|
+
if model.cost_ceiling is not None:
|
|
314
|
+
document["cost_ceiling"] = dict(model.cost_ceiling)
|
|
315
|
+
return document
|
|
316
|
+
|
|
317
|
+
def _refuse_a_declaration_that_is_not_a_model(
|
|
318
|
+
entities: tuple[EntitySpec, ...],
|
|
319
|
+
relations: tuple[RelationSpec, ...],
|
|
320
|
+
) -> None:
|
|
321
|
+
"""The seven refusals of format-contract §4a, in the order that section writes them.
|
|
322
|
+
|
|
323
|
+
They live here rather than at each front door because there are two front doors - the
|
|
324
|
+
decorator path and the neutral-JSON path the conformance vectors use - and until this function
|
|
325
|
+
existed each of them enforced a different subset. The vectors therefore ran a *weaker*
|
|
326
|
+
validator than any application does, which is the suite's own stated failure mode: a vector
|
|
327
|
+
that passes without reaching the code it describes takes the place of one that would have.
|
|
328
|
+
|
|
329
|
+
Every one of these was measured against a third implementation written from the contract alone.
|
|
330
|
+
Three of them were accepted by both of our libraries and refused by that one, and one - an empty
|
|
331
|
+
``key`` list - produced two different ``model_version`` values here, because Python spelled the
|
|
332
|
+
invented default ``or`` and TypeScript spelled it ``??``.
|
|
333
|
+
"""
|
|
334
|
+
if not entities:
|
|
335
|
+
raise DeclarationError("no entities declared, so there is no model to build")
|
|
336
|
+
|
|
337
|
+
seen: dict[str, EntitySpec] = {}
|
|
338
|
+
for spec in entities:
|
|
339
|
+
if not spec.fields:
|
|
340
|
+
raise DeclarationError(
|
|
341
|
+
f"{spec.name} has no fields. An entity that stores nothing cannot be placed, so "
|
|
342
|
+
"there is nothing for a map to say about it."
|
|
343
|
+
)
|
|
344
|
+
if spec.name in seen:
|
|
345
|
+
raise DeclarationError(
|
|
346
|
+
f"two entities are called {spec.name!r}. Entity names reach the canonical IR and "
|
|
347
|
+
"the colocation graph, so a duplicate makes 'which entity is this' unanswerable in "
|
|
348
|
+
"the document whose job is to answer it."
|
|
349
|
+
)
|
|
350
|
+
seen[spec.name] = spec
|
|
351
|
+
|
|
352
|
+
relations_of: dict[str, set[str]] = {}
|
|
353
|
+
for rel in relations:
|
|
354
|
+
for side in (rel.source, rel.target):
|
|
355
|
+
if side not in seen:
|
|
356
|
+
raise DeclarationError(
|
|
357
|
+
f"relation {rel.name!r} names unknown entity {side!r}"
|
|
358
|
+
)
|
|
359
|
+
named = relations_of.setdefault(rel.source, set())
|
|
360
|
+
if rel.name in named:
|
|
361
|
+
raise DeclarationError(
|
|
362
|
+
f"{rel.source}.{rel.name} is declared twice. Two relations of one name on one "
|
|
363
|
+
"entity are two edges the colocation graph cannot tell apart."
|
|
364
|
+
)
|
|
365
|
+
named.add(rel.name)
|
|
366
|
+
|
|
367
|
+
for spec in entities:
|
|
368
|
+
fields = [f.name for f in spec.fields]
|
|
369
|
+
duplicates = sorted({name for name in fields if fields.count(name) > 1})
|
|
370
|
+
if duplicates:
|
|
371
|
+
raise DeclarationError(
|
|
372
|
+
f"{spec.name} declares the fields {duplicates} more than once. The layout would "
|
|
373
|
+
"have two columns of one name, and the refusal would arrive from the client's "
|
|
374
|
+
"engine at CREATE TABLE."
|
|
375
|
+
)
|
|
376
|
+
if not spec.key:
|
|
377
|
+
raise DeclarationError(
|
|
378
|
+
f"{spec.name} declares no key. A key is what makes a row addressable, migratable "
|
|
379
|
+
"and verifiable - a backfill compares rows by it - so an entity without one is a "
|
|
380
|
+
"group that cannot be moved, and that is worth knowing when the model is declared "
|
|
381
|
+
"rather than in the middle of a migration. No key is invented for you."
|
|
382
|
+
)
|
|
383
|
+
missing = [k for k in spec.key if k not in set(fields)]
|
|
384
|
+
if missing:
|
|
385
|
+
raise DeclarationError(
|
|
386
|
+
f"{spec.name}: key names {missing}, which are not fields of {spec.name}"
|
|
387
|
+
)
|
|
388
|
+
repeated = sorted({k for k in spec.key if list(spec.key).count(k) > 1})
|
|
389
|
+
if repeated:
|
|
390
|
+
raise DeclarationError(
|
|
391
|
+
f"{spec.name}: key names {repeated} more than once, which is a composite key with "
|
|
392
|
+
"one column in two positions."
|
|
393
|
+
)
|
|
394
|
+
bad_pii = [pii for pii in spec.pii if pii not in set(fields)]
|
|
395
|
+
if bad_pii:
|
|
396
|
+
raise DeclarationError(
|
|
397
|
+
f"{spec.name}: pii names {bad_pii}, which are not fields of {spec.name}. A pii "
|
|
398
|
+
"entry that is not a field silently protects nothing, and the exclusion of "
|
|
399
|
+
"personal data from a derived copy is meant to be readable rather than trusted."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def assemble(
|
|
404
|
+
*,
|
|
405
|
+
entities: tuple[EntitySpec, ...],
|
|
406
|
+
relations: tuple[RelationSpec, ...],
|
|
407
|
+
atomic: tuple[tuple[str, ...], ...],
|
|
408
|
+
cost_ceiling: Mapping[str, str] | None,
|
|
409
|
+
) -> LogicalModel:
|
|
410
|
+
"""Build the IR and the version from already-resolved specs.
|
|
411
|
+
|
|
412
|
+
Both entry points come through here: the decorator path in :func:`build_model`, and the neutral
|
|
413
|
+
JSON path in :mod:`sde.testing.loader` that the conformance vectors use. That is not tidiness -
|
|
414
|
+
if the vectors exercised a second implementation of this encoding, they would be verifying
|
|
415
|
+
something no application ever runs, which is the most expensive kind of green test.
|
|
416
|
+
"""
|
|
417
|
+
_refuse_a_declaration_that_is_not_a_model(entities, relations)
|
|
418
|
+
|
|
419
|
+
specs = tuple(sorted(entities, key=lambda s: s.name))
|
|
420
|
+
rels = tuple(sorted(relations, key=lambda r: (r.source, r.name, r.target)))
|
|
421
|
+
|
|
422
|
+
# Fields and pii are sorted **here**, by the name that ends up in the IR - not left in whatever
|
|
423
|
+
# order the caller supplied. Both callers below happen to supply them sorted already, so for
|
|
424
|
+
# years this was a no-op and the ordering rule lived in a convention rather than in the code.
|
|
425
|
+
# Hashing broke the convention: it rebuilds an entity with digests for names while keeping the
|
|
426
|
+
# original sequence, so the IR came out ordered by the *real* field names. Two consequences, one
|
|
427
|
+
# of them a leak: the hashed IR encoded the alphabetical order of names it is supposed to hide,
|
|
428
|
+
# and no library that had never seen those names could reproduce the bytes. TypeScript sorted,
|
|
429
|
+
# Python did not, and the hashing vector is what made the disagreement visible.
|
|
430
|
+
def ordered(spec: EntitySpec) -> EntitySpec:
|
|
431
|
+
return replace(
|
|
432
|
+
spec,
|
|
433
|
+
fields=tuple(sorted(spec.fields, key=lambda f: f.name)),
|
|
434
|
+
# `key` is deliberately not sorted: its order is positional and carries meaning, which
|
|
435
|
+
# is why the IR writes an explicit index for it.
|
|
436
|
+
pii=tuple(sorted(spec.pii)),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
specs = tuple(ordered(spec) for spec in specs)
|
|
440
|
+
|
|
441
|
+
ir: dict[str, Any] = {
|
|
442
|
+
"contract": CONTRACT,
|
|
443
|
+
"entities": [
|
|
444
|
+
{
|
|
445
|
+
"name": s.name,
|
|
446
|
+
"fields": [
|
|
447
|
+
{"name": f.name, "type": f.type, "nullable": f.nullable} for f in s.fields
|
|
448
|
+
],
|
|
449
|
+
# Explicit position: array order is not load-bearing anywhere else in the IR, so it
|
|
450
|
+
# must not be here either.
|
|
451
|
+
"key": [{"field": k, "position": i} for i, k in enumerate(s.key)],
|
|
452
|
+
"pii": list(s.pii),
|
|
453
|
+
"residency": s.residency,
|
|
454
|
+
}
|
|
455
|
+
for s in specs
|
|
456
|
+
],
|
|
457
|
+
"relations": [
|
|
458
|
+
{"name": r.name, "from": r.source, "to": r.target} for r in rels
|
|
459
|
+
],
|
|
460
|
+
"atomic": [list(group) for group in atomic],
|
|
461
|
+
"cost_ceiling": dict(cost_ceiling) if cost_ceiling is not None else None,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
model = LogicalModel(
|
|
465
|
+
entities=specs,
|
|
466
|
+
relations=rels,
|
|
467
|
+
atomic=atomic,
|
|
468
|
+
cost_ceiling=dict(cost_ceiling) if cost_ceiling is not None else None,
|
|
469
|
+
ir=ir,
|
|
470
|
+
version=digest16(ir),
|
|
471
|
+
)
|
|
472
|
+
# Once per process. `model_version` is the identifier every other artefact is keyed on - a map
|
|
473
|
+
# naming a different one is refused, and that refusal is the most common thing anybody will
|
|
474
|
+
# ever ask us about - so the version this process computed has to be readable from the client's
|
|
475
|
+
# own log rather than reconstructed from their source.
|
|
476
|
+
log(
|
|
477
|
+
"sde.model.built",
|
|
478
|
+
model_version=model.version,
|
|
479
|
+
entities=len(specs),
|
|
480
|
+
groups=len(atomic),
|
|
481
|
+
)
|
|
482
|
+
return model
|