lorebase-core 0.2.3__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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Validation of a batch of data changes against a Schema and existing data (invariant I1).
|
|
2
|
+
|
|
3
|
+
Existing data is read through an ``IndexView`` the caller provides (for example the
|
|
4
|
+
materialized head of a branch). The batch is evaluated as a whole: a Record created in the
|
|
5
|
+
batch can be the endpoint of a Link created in the same batch, and a unique value released by
|
|
6
|
+
one Record can be taken by another.
|
|
7
|
+
|
|
8
|
+
Deleting a Record handles its Links by the Relation's ``on_delete``: ``cascade`` yields Link
|
|
9
|
+
deletions as derived changes, ``restrict`` yields a violation. ``ref`` attributes pointing at
|
|
10
|
+
a deleted Record always restrict the deletion.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter, defaultdict
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from pydantic import JsonValue
|
|
21
|
+
|
|
22
|
+
from lorebase_core.model.attribute import Attribute
|
|
23
|
+
from lorebase_core.model.kind import Kind
|
|
24
|
+
from lorebase_core.model.record import Link, LinkIdentity, Record, RecordIdentity
|
|
25
|
+
from lorebase_core.model.relation import OnDelete, Relation, RelationBacking
|
|
26
|
+
from lorebase_core.model.schema import Schema
|
|
27
|
+
from lorebase_core.types import AttributeType, CanonicalValue
|
|
28
|
+
from lorebase_core.validate.changes import (
|
|
29
|
+
DataChange,
|
|
30
|
+
LinkDelete,
|
|
31
|
+
LinkUpsert,
|
|
32
|
+
RecordDelete,
|
|
33
|
+
RecordUpsert,
|
|
34
|
+
)
|
|
35
|
+
from lorebase_core.validate.data_rules import validate_link, validate_record
|
|
36
|
+
from lorebase_core.validate.issues import Issue, IssueCode, PathPart
|
|
37
|
+
|
|
38
|
+
#: Number of example Links or Records listed in grouped issues.
|
|
39
|
+
_SAMPLE_SIZE = 10
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IndexView(Protocol):
|
|
43
|
+
"""Read access to the existing data a batch is validated against."""
|
|
44
|
+
|
|
45
|
+
def record_exists(self, kind_id: str, key: str) -> bool:
|
|
46
|
+
"""Whether the Record exists."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def find_unique(self, kind_id: str, attribute_id: str, value: CanonicalValue) -> str | None:
|
|
50
|
+
"""Key of the Record of ``kind_id`` whose ``attribute_id`` equals ``value``, if any."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def keys_referencing(self, kind_id: str, attribute_id: str, key: str) -> Iterable[str]:
|
|
54
|
+
"""Keys of Records of ``kind_id`` whose ``ref`` attribute holds ``key`` (or contains it)."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def link_exists(self, relation_id: str, from_key: str, to_key: str) -> bool:
|
|
58
|
+
"""Whether the Link exists."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def count_links(
|
|
62
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
63
|
+
) -> int:
|
|
64
|
+
"""Number of Links of ``relation_id`` with the given endpoint(s)."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def links_of(
|
|
68
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
69
|
+
) -> Iterable[tuple[str, str]]:
|
|
70
|
+
"""``(from_key, to_key)`` of the Links of ``relation_id`` with the given endpoint(s)."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ChangesetReport:
|
|
76
|
+
"""Outcome of a batch validation: issues plus changes derived from the batch."""
|
|
77
|
+
|
|
78
|
+
issues: list[Issue] = field(default_factory=list)
|
|
79
|
+
#: Link deletions caused by ``on_delete = cascade``.
|
|
80
|
+
derived_changes: list[DataChange] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Batch:
|
|
85
|
+
schema: Schema
|
|
86
|
+
index: IndexView
|
|
87
|
+
report: ChangesetReport = field(default_factory=ChangesetReport)
|
|
88
|
+
#: Final state of each Record and Link touched by the batch (``None`` when deleted).
|
|
89
|
+
records: dict[RecordIdentity, Record | None] = field(default_factory=dict)
|
|
90
|
+
links: dict[LinkIdentity, Link | None] = field(default_factory=dict)
|
|
91
|
+
record_paths: dict[RecordIdentity, tuple[PathPart, ...]] = field(default_factory=dict)
|
|
92
|
+
link_paths: dict[LinkIdentity, tuple[PathPart, ...]] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
def add(self, issue: Issue) -> None:
|
|
95
|
+
self.report.issues.append(issue)
|
|
96
|
+
|
|
97
|
+
# State -------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def record_exists(self, kind_id: str, key: str) -> bool:
|
|
100
|
+
identity = RecordIdentity(kind_id, key)
|
|
101
|
+
if identity in self.records:
|
|
102
|
+
return self.records[identity] is not None
|
|
103
|
+
return self.index.record_exists(kind_id, key)
|
|
104
|
+
|
|
105
|
+
def link_exists_before(self, identity: LinkIdentity) -> bool:
|
|
106
|
+
return self.index.link_exists(*identity)
|
|
107
|
+
|
|
108
|
+
# Steps ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def collect(self, changes: Sequence[DataChange]) -> None:
|
|
111
|
+
seen: set[RecordIdentity | LinkIdentity] = set()
|
|
112
|
+
for index, change in enumerate(changes):
|
|
113
|
+
path: tuple[PathPart, ...] = ("changes", index)
|
|
114
|
+
identity = change.identity
|
|
115
|
+
if identity in seen:
|
|
116
|
+
self.add(
|
|
117
|
+
Issue(
|
|
118
|
+
code=IssueCode.CHANGESET_DUPLICATE_CHANGE,
|
|
119
|
+
path=path,
|
|
120
|
+
message=f"{identity} is changed more than once in the change set",
|
|
121
|
+
details={"identity": list(identity)},
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
continue
|
|
125
|
+
seen.add(identity)
|
|
126
|
+
if isinstance(change, RecordUpsert):
|
|
127
|
+
self.report.issues.extend(validate_record(self.schema, change.record, path=path))
|
|
128
|
+
self.records[change.identity] = change.record
|
|
129
|
+
self.record_paths[change.identity] = path
|
|
130
|
+
elif isinstance(change, RecordDelete):
|
|
131
|
+
self.collect_record_delete(change, path)
|
|
132
|
+
elif isinstance(change, LinkUpsert):
|
|
133
|
+
self.report.issues.extend(validate_link(self.schema, change.link, path=path))
|
|
134
|
+
self.links[change.identity] = change.link
|
|
135
|
+
self.link_paths[change.identity] = path
|
|
136
|
+
else:
|
|
137
|
+
self.collect_link_delete(change, path)
|
|
138
|
+
|
|
139
|
+
def collect_record_delete(self, change: RecordDelete, path: tuple[PathPart, ...]) -> None:
|
|
140
|
+
if change.kind not in self.schema.kinds:
|
|
141
|
+
self.add(
|
|
142
|
+
Issue(
|
|
143
|
+
code=IssueCode.RECORD_KIND_UNKNOWN,
|
|
144
|
+
path=(*path, "kind"),
|
|
145
|
+
message=f"unknown Kind {change.kind}",
|
|
146
|
+
kind=change.kind,
|
|
147
|
+
key=change.key,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
if not self.index.record_exists(change.kind, change.key):
|
|
152
|
+
self.add(
|
|
153
|
+
Issue(
|
|
154
|
+
code=IssueCode.RECORD_NOT_FOUND,
|
|
155
|
+
path=path,
|
|
156
|
+
message=f"Record {change.key!r} does not exist",
|
|
157
|
+
kind=change.kind,
|
|
158
|
+
key=change.key,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
self.records[change.identity] = None
|
|
163
|
+
self.record_paths[change.identity] = path
|
|
164
|
+
|
|
165
|
+
def collect_link_delete(self, change: LinkDelete, path: tuple[PathPart, ...]) -> None:
|
|
166
|
+
if change.relation not in self.schema.relations:
|
|
167
|
+
self.add(
|
|
168
|
+
Issue(
|
|
169
|
+
code=IssueCode.LINK_RELATION_UNKNOWN,
|
|
170
|
+
path=(*path, "relation"),
|
|
171
|
+
message=f"unknown Relation {change.relation}",
|
|
172
|
+
relation=change.relation,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
if not self.index.link_exists(*change.identity):
|
|
177
|
+
self.add(
|
|
178
|
+
Issue(
|
|
179
|
+
code=IssueCode.LINK_NOT_FOUND,
|
|
180
|
+
path=path,
|
|
181
|
+
message=f"Link {change.from_key!r} -> {change.to_key!r} does not exist",
|
|
182
|
+
relation=change.relation,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
self.links[change.identity] = None
|
|
187
|
+
self.link_paths[change.identity] = path
|
|
188
|
+
|
|
189
|
+
def apply_deletes(self) -> None:
|
|
190
|
+
deleted = [identity for identity, record in self.records.items() if record is None]
|
|
191
|
+
for identity in deleted:
|
|
192
|
+
path = self.record_paths[identity]
|
|
193
|
+
for relation in self.schema.relations_of(identity.kind):
|
|
194
|
+
if relation.backing is RelationBacking.EXPLICIT:
|
|
195
|
+
self.delete_links_of(relation, identity, path)
|
|
196
|
+
self.check_references_to(identity, path)
|
|
197
|
+
|
|
198
|
+
def delete_links_of(
|
|
199
|
+
self, relation: Relation, identity: RecordIdentity, path: tuple[PathPart, ...]
|
|
200
|
+
) -> None:
|
|
201
|
+
existing: set[tuple[str, str]] = set()
|
|
202
|
+
if relation.from_kind == identity.kind:
|
|
203
|
+
existing.update(self.index.links_of(relation.id, from_key=identity.key))
|
|
204
|
+
if relation.to_kind == identity.kind:
|
|
205
|
+
existing.update(self.index.links_of(relation.id, to_key=identity.key))
|
|
206
|
+
# Links the batch also writes are checked as Link changes (endpoint missing).
|
|
207
|
+
remaining = sorted(
|
|
208
|
+
pair for pair in existing if LinkIdentity(relation.id, *pair) not in self.links
|
|
209
|
+
)
|
|
210
|
+
if not remaining:
|
|
211
|
+
return
|
|
212
|
+
if relation.on_delete is OnDelete.CASCADE:
|
|
213
|
+
for from_key, to_key in remaining:
|
|
214
|
+
link_identity = LinkIdentity(relation.id, from_key, to_key)
|
|
215
|
+
self.links[link_identity] = None
|
|
216
|
+
self.link_paths[link_identity] = path
|
|
217
|
+
self.report.derived_changes.append(
|
|
218
|
+
LinkDelete(relation=relation.id, from_key=from_key, to_key=to_key)
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
self.add(
|
|
222
|
+
Issue(
|
|
223
|
+
code=IssueCode.RECORD_DELETE_RESTRICTED,
|
|
224
|
+
path=path,
|
|
225
|
+
message=(
|
|
226
|
+
f"{len(remaining)} Link(s) of {relation.api_name!r} restrict deleting "
|
|
227
|
+
f"Record {identity.key!r}"
|
|
228
|
+
),
|
|
229
|
+
kind=identity.kind,
|
|
230
|
+
relation=relation.id,
|
|
231
|
+
key=identity.key,
|
|
232
|
+
details={
|
|
233
|
+
"count": len(remaining),
|
|
234
|
+
"links": [list(pair) for pair in remaining[:_SAMPLE_SIZE]],
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def reference_attributes(self, target_kind: str) -> list[tuple[Kind, Attribute]]:
|
|
240
|
+
return [
|
|
241
|
+
(kind, attribute)
|
|
242
|
+
for kind in self.schema.kinds.values()
|
|
243
|
+
for attribute in kind.attributes.values()
|
|
244
|
+
if attribute.type.scalar.name is AttributeType.REF
|
|
245
|
+
and attribute.type.scalar.kind_ref == target_kind
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
def check_references_to(self, identity: RecordIdentity, path: tuple[PathPart, ...]) -> None:
|
|
249
|
+
for kind, attribute in self.reference_attributes(identity.kind):
|
|
250
|
+
referencing = set(self.index.keys_referencing(kind.id, attribute.id, identity.key))
|
|
251
|
+
for record_identity, record in self.records.items():
|
|
252
|
+
if record_identity.kind == kind.id and record is not None:
|
|
253
|
+
if _holds_key(record.values.get(attribute.id), identity.key):
|
|
254
|
+
referencing.add(record_identity.key)
|
|
255
|
+
else:
|
|
256
|
+
referencing.discard(record_identity.key)
|
|
257
|
+
referencing = {
|
|
258
|
+
key
|
|
259
|
+
for key in referencing
|
|
260
|
+
if RecordIdentity(kind.id, key) not in self.records
|
|
261
|
+
or self.records[RecordIdentity(kind.id, key)] is not None
|
|
262
|
+
}
|
|
263
|
+
if referencing:
|
|
264
|
+
keys = sorted(referencing)
|
|
265
|
+
self.add(
|
|
266
|
+
Issue(
|
|
267
|
+
code=IssueCode.RECORD_DELETE_RESTRICTED,
|
|
268
|
+
path=path,
|
|
269
|
+
message=(
|
|
270
|
+
f"{len(keys)} {kind.api_name} Record(s) reference {identity.key!r} "
|
|
271
|
+
f"through {attribute.api_name!r}"
|
|
272
|
+
),
|
|
273
|
+
kind=identity.kind,
|
|
274
|
+
key=identity.key,
|
|
275
|
+
attribute=attribute.id,
|
|
276
|
+
details={"count": len(keys), "keys": list(keys[:_SAMPLE_SIZE])},
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def check_link_endpoints(self) -> None:
|
|
281
|
+
for identity, link in self.links.items():
|
|
282
|
+
if link is None:
|
|
283
|
+
continue
|
|
284
|
+
relation = self.schema.relations.get(identity.relation)
|
|
285
|
+
if relation is None or relation.backing is RelationBacking.FOREIGN_KEY:
|
|
286
|
+
continue
|
|
287
|
+
for end, kind_id, key in (
|
|
288
|
+
("from", relation.from_kind, identity.from_key),
|
|
289
|
+
("to", relation.to_kind, identity.to_key),
|
|
290
|
+
):
|
|
291
|
+
if not self.record_exists(kind_id, key):
|
|
292
|
+
self.add(
|
|
293
|
+
Issue(
|
|
294
|
+
code=IssueCode.LINK_ENDPOINT_MISSING,
|
|
295
|
+
path=(*self.link_paths[identity], end),
|
|
296
|
+
message=f"{end} Record {key!r} of {relation.api_name!r} does not exist",
|
|
297
|
+
kind=kind_id,
|
|
298
|
+
relation=relation.id,
|
|
299
|
+
key=key,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def check_reference_values(self) -> None:
|
|
304
|
+
for identity, record in self.records.items():
|
|
305
|
+
kind = self.schema.kinds.get(identity.kind)
|
|
306
|
+
if record is None or kind is None:
|
|
307
|
+
continue
|
|
308
|
+
for attribute_id, value in record.values.items():
|
|
309
|
+
attribute = kind.attributes.get(attribute_id)
|
|
310
|
+
if attribute is None or attribute.type.scalar.name is not AttributeType.REF:
|
|
311
|
+
continue
|
|
312
|
+
target = attribute.type.scalar.kind_ref
|
|
313
|
+
assert target is not None
|
|
314
|
+
for key in _keys_in(value):
|
|
315
|
+
if not self.record_exists(target, key):
|
|
316
|
+
self.add(
|
|
317
|
+
Issue(
|
|
318
|
+
code=IssueCode.VALUE_REFERENCE_MISSING,
|
|
319
|
+
path=(*self.record_paths[identity], "values", attribute_id),
|
|
320
|
+
message=f"{attribute.api_name!r} references missing Record {key!r}",
|
|
321
|
+
kind=identity.kind,
|
|
322
|
+
key=identity.key,
|
|
323
|
+
attribute=attribute_id,
|
|
324
|
+
details={"target_kind": target, "target_key": key},
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def check_uniqueness(self) -> None:
|
|
329
|
+
claimed: dict[tuple[str, str, str], str] = {}
|
|
330
|
+
for identity, record in sorted(self.records.items()):
|
|
331
|
+
kind = self.schema.kinds.get(identity.kind)
|
|
332
|
+
if record is None or kind is None:
|
|
333
|
+
continue
|
|
334
|
+
for attribute in kind.attributes.values():
|
|
335
|
+
value = record.values.get(attribute.id)
|
|
336
|
+
if not attribute.unique or value is None:
|
|
337
|
+
continue
|
|
338
|
+
owner = self.unique_owner(identity, attribute, value, claimed)
|
|
339
|
+
if owner is None:
|
|
340
|
+
continue
|
|
341
|
+
# For a sensitive Attribute the owner is not named: a writer without the
|
|
342
|
+
# clearance would learn which Record holds a value it guessed.
|
|
343
|
+
sensitive = attribute.is_sensitive
|
|
344
|
+
self.add(
|
|
345
|
+
Issue(
|
|
346
|
+
code=IssueCode.VALUE_UNIQUE,
|
|
347
|
+
path=(*self.record_paths[identity], "values", attribute.id),
|
|
348
|
+
message=(
|
|
349
|
+
f"{attribute.api_name!r} value is already used by another Record"
|
|
350
|
+
if sensitive
|
|
351
|
+
else f"{attribute.api_name!r} value {value!r} is used by {owner!r}"
|
|
352
|
+
),
|
|
353
|
+
kind=identity.kind,
|
|
354
|
+
key=identity.key,
|
|
355
|
+
attribute=attribute.id,
|
|
356
|
+
details={} if sensitive else {"other_key": owner},
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def unique_owner(
|
|
361
|
+
self,
|
|
362
|
+
identity: RecordIdentity,
|
|
363
|
+
attribute: Attribute,
|
|
364
|
+
value: JsonValue,
|
|
365
|
+
claimed: dict[tuple[str, str, str], str],
|
|
366
|
+
) -> str | None:
|
|
367
|
+
"""Another Record holding ``value`` after the batch, if any."""
|
|
368
|
+
claim = (identity.kind, attribute.id, repr(value))
|
|
369
|
+
if claim in claimed:
|
|
370
|
+
return claimed[claim]
|
|
371
|
+
claimed[claim] = identity.key
|
|
372
|
+
owner = self.index.find_unique(identity.kind, attribute.id, value)
|
|
373
|
+
if owner is None or owner == identity.key:
|
|
374
|
+
return None
|
|
375
|
+
if RecordIdentity(identity.kind, owner) in self.records:
|
|
376
|
+
# The existing owner is changed by the batch: if it keeps the value, its own
|
|
377
|
+
# claim collides with this one; otherwise the value is released.
|
|
378
|
+
return None
|
|
379
|
+
return owner
|
|
380
|
+
|
|
381
|
+
def link_changes(self, relation_id: str) -> tuple[list[LinkIdentity], list[LinkIdentity]]:
|
|
382
|
+
"""New Links and removed existing Links of a Relation in the batch."""
|
|
383
|
+
added: list[LinkIdentity] = []
|
|
384
|
+
removed: list[LinkIdentity] = []
|
|
385
|
+
for identity, link in self.links.items():
|
|
386
|
+
if identity.relation != relation_id:
|
|
387
|
+
continue
|
|
388
|
+
exists = self.link_exists_before(identity)
|
|
389
|
+
if link is not None and not exists:
|
|
390
|
+
added.append(identity)
|
|
391
|
+
elif link is None and exists:
|
|
392
|
+
removed.append(identity)
|
|
393
|
+
return added, removed
|
|
394
|
+
|
|
395
|
+
def final_link_count(
|
|
396
|
+
self,
|
|
397
|
+
relation: Relation,
|
|
398
|
+
added: Counter[str],
|
|
399
|
+
removed: Counter[str],
|
|
400
|
+
*,
|
|
401
|
+
from_key: str | None = None,
|
|
402
|
+
to_key: str | None = None,
|
|
403
|
+
) -> int:
|
|
404
|
+
"""Links of `relation` from `from_key` (or to `to_key`) once the batch applies; `added`
|
|
405
|
+
and `removed` count the batch's new and removed Links per key of that side."""
|
|
406
|
+
key = from_key if from_key is not None else to_key
|
|
407
|
+
assert key is not None
|
|
408
|
+
existing = self.index.count_links(relation.id, from_key=from_key, to_key=to_key)
|
|
409
|
+
return existing - removed[key] + added[key]
|
|
410
|
+
|
|
411
|
+
def check_cardinality(self) -> None:
|
|
412
|
+
for relation in self.schema.relations.values():
|
|
413
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
414
|
+
continue
|
|
415
|
+
if not (relation.single_per_from or relation.single_per_to):
|
|
416
|
+
continue
|
|
417
|
+
added, removed = self.link_changes(relation.id)
|
|
418
|
+
sides = []
|
|
419
|
+
if relation.single_per_from:
|
|
420
|
+
sides.append("from")
|
|
421
|
+
if relation.single_per_to:
|
|
422
|
+
sides.append("to")
|
|
423
|
+
for side in sides:
|
|
424
|
+
groups: dict[str, list[LinkIdentity]] = defaultdict(list)
|
|
425
|
+
for identity in added:
|
|
426
|
+
groups[identity.from_key if side == "from" else identity.to_key].append(
|
|
427
|
+
identity
|
|
428
|
+
)
|
|
429
|
+
removed_by = Counter(
|
|
430
|
+
identity.from_key if side == "from" else identity.to_key for identity in removed
|
|
431
|
+
)
|
|
432
|
+
added_by = Counter({key: len(competing) for key, competing in groups.items()})
|
|
433
|
+
for key, competing in sorted(groups.items()):
|
|
434
|
+
count = self.final_link_count(
|
|
435
|
+
relation,
|
|
436
|
+
added_by,
|
|
437
|
+
removed_by,
|
|
438
|
+
from_key=key if side == "from" else None,
|
|
439
|
+
to_key=key if side == "to" else None,
|
|
440
|
+
)
|
|
441
|
+
if count <= 1:
|
|
442
|
+
continue
|
|
443
|
+
self.add(
|
|
444
|
+
Issue(
|
|
445
|
+
code=IssueCode.LINK_CARDINALITY,
|
|
446
|
+
path=self.link_paths[competing[0]],
|
|
447
|
+
message=(
|
|
448
|
+
f"{relation.api_name!r} is {relation.cardinality}: {side} Record "
|
|
449
|
+
f"{key!r} would have {count} Links"
|
|
450
|
+
),
|
|
451
|
+
relation=relation.id,
|
|
452
|
+
key=key,
|
|
453
|
+
details={
|
|
454
|
+
"side": side,
|
|
455
|
+
"count": count,
|
|
456
|
+
"links": [[i.from_key, i.to_key] for i in competing[:_SAMPLE_SIZE]],
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def check_required_relations(self) -> None:
|
|
462
|
+
for relation in self.schema.relations.values():
|
|
463
|
+
if not relation.required_from:
|
|
464
|
+
continue
|
|
465
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
466
|
+
self.check_required_foreign_key(relation)
|
|
467
|
+
continue
|
|
468
|
+
added, removed = self.link_changes(relation.id)
|
|
469
|
+
candidates: set[str] = {
|
|
470
|
+
identity.key
|
|
471
|
+
for identity, record in self.records.items()
|
|
472
|
+
if identity.kind == relation.from_kind
|
|
473
|
+
and record is not None
|
|
474
|
+
and not self.index.record_exists(identity.kind, identity.key)
|
|
475
|
+
}
|
|
476
|
+
candidates.update(
|
|
477
|
+
identity.from_key
|
|
478
|
+
for identity in removed
|
|
479
|
+
if self.record_exists(relation.from_kind, identity.from_key)
|
|
480
|
+
)
|
|
481
|
+
added_by = Counter(identity.from_key for identity in added)
|
|
482
|
+
removed_by = Counter(identity.from_key for identity in removed)
|
|
483
|
+
for key in sorted(candidates):
|
|
484
|
+
if self.final_link_count(relation, added_by, removed_by, from_key=key) > 0:
|
|
485
|
+
continue
|
|
486
|
+
identity = RecordIdentity(relation.from_kind, key)
|
|
487
|
+
path = self.record_paths.get(identity) or next(
|
|
488
|
+
self.link_paths[i] for i in removed if i.from_key == key
|
|
489
|
+
)
|
|
490
|
+
self.add(
|
|
491
|
+
Issue(
|
|
492
|
+
code=IssueCode.RECORD_REQUIRED_RELATION,
|
|
493
|
+
path=path,
|
|
494
|
+
message=f"Record {key!r} needs at least one {relation.api_name!r} Link",
|
|
495
|
+
kind=relation.from_kind,
|
|
496
|
+
relation=relation.id,
|
|
497
|
+
key=key,
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def check_required_foreign_key(self, relation: Relation) -> None:
|
|
502
|
+
attribute_id = relation.foreign_key_attribute
|
|
503
|
+
for identity, record in self.records.items():
|
|
504
|
+
if identity.kind != relation.from_kind or record is None or attribute_id is None:
|
|
505
|
+
continue
|
|
506
|
+
if record.values.get(attribute_id) is None:
|
|
507
|
+
self.add(
|
|
508
|
+
Issue(
|
|
509
|
+
code=IssueCode.RECORD_REQUIRED_RELATION,
|
|
510
|
+
path=(*self.record_paths[identity], "values", attribute_id),
|
|
511
|
+
message=f"Record {identity.key!r} needs a {relation.api_name!r} reference",
|
|
512
|
+
kind=identity.kind,
|
|
513
|
+
relation=relation.id,
|
|
514
|
+
key=identity.key,
|
|
515
|
+
attribute=attribute_id,
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _keys_in(value: JsonValue) -> list[str]:
|
|
521
|
+
if isinstance(value, str):
|
|
522
|
+
return [value]
|
|
523
|
+
if isinstance(value, list):
|
|
524
|
+
return [item for item in value if isinstance(item, str)]
|
|
525
|
+
return []
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _holds_key(value: JsonValue, key: str) -> bool:
|
|
529
|
+
return key in _keys_in(value)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def evaluate_changeset(
|
|
533
|
+
schema: Schema, index_view: IndexView, changes: Sequence[DataChange]
|
|
534
|
+
) -> ChangesetReport:
|
|
535
|
+
"""Validate a batch and compute its derived changes (cascaded Link deletions)."""
|
|
536
|
+
batch = _Batch(schema, index_view)
|
|
537
|
+
batch.collect(changes)
|
|
538
|
+
batch.apply_deletes()
|
|
539
|
+
batch.check_link_endpoints()
|
|
540
|
+
batch.check_reference_values()
|
|
541
|
+
batch.check_uniqueness()
|
|
542
|
+
batch.check_cardinality()
|
|
543
|
+
batch.check_required_relations()
|
|
544
|
+
return batch.report
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def validate_changeset(
|
|
548
|
+
schema: Schema, index_view: IndexView, changes: Sequence[DataChange]
|
|
549
|
+
) -> list[Issue]:
|
|
550
|
+
"""Issues of a batch of changes; see ``evaluate_changeset`` for derived changes."""
|
|
551
|
+
return evaluate_changeset(schema, index_view, changes).issues
|