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,440 @@
|
|
|
1
|
+
"""Consistency under the merged schema (design 6.8 step 4, invariant I1).
|
|
2
|
+
|
|
3
|
+
The checks are scoped to what the merge changed: the merged states of the elements changed on
|
|
4
|
+
either side, read together with the target head through an ``IndexView``. Violations become
|
|
5
|
+
cross-cutting Conflicts grouped per Kind or Relation and Attribute. Dangling Links of
|
|
6
|
+
``cascade`` Relations are dropped and recorded as automatic resolutions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from pydantic import JsonValue, ValidationError
|
|
15
|
+
|
|
16
|
+
from lorebase_core.canonical import ObjectType, canonical_bytes
|
|
17
|
+
from lorebase_core.merge.conflicts import ConflictKind, ResolutionChoice, Side
|
|
18
|
+
from lorebase_core.merge.data_merge import DataMerge, Origin
|
|
19
|
+
from lorebase_core.merge.objects import data_object_type, partition_element
|
|
20
|
+
from lorebase_core.merge.state import Context, Payload, Stage
|
|
21
|
+
from lorebase_core.model import (
|
|
22
|
+
Cardinality,
|
|
23
|
+
Link,
|
|
24
|
+
OnDelete,
|
|
25
|
+
Record,
|
|
26
|
+
Relation,
|
|
27
|
+
RelationBacking,
|
|
28
|
+
Schema,
|
|
29
|
+
)
|
|
30
|
+
from lorebase_core.tree import (
|
|
31
|
+
LINK_KEY_SEPARATOR,
|
|
32
|
+
kind_partition,
|
|
33
|
+
link_key,
|
|
34
|
+
relation_partition,
|
|
35
|
+
tree_get,
|
|
36
|
+
)
|
|
37
|
+
from lorebase_core.types import AttributeType
|
|
38
|
+
from lorebase_core.validate import IndexView, Issue, IssueCode, validate_link, validate_record
|
|
39
|
+
|
|
40
|
+
__all__ = ["MergedView", "check_consistency", "relaxed_cardinality"]
|
|
41
|
+
|
|
42
|
+
_ISSUE_KINDS: dict[IssueCode, ConflictKind] = {
|
|
43
|
+
IssueCode.VALUE_ATTRIBUTE_UNKNOWN: ConflictKind.ORPHANED_VALUE,
|
|
44
|
+
IssueCode.VALUE_TYPE: ConflictKind.TYPE_COERCION,
|
|
45
|
+
IssueCode.VALUE_NOT_CANONICAL: ConflictKind.TYPE_COERCION,
|
|
46
|
+
IssueCode.VALUE_REQUIRED: ConflictKind.MISSING_VALUE,
|
|
47
|
+
IssueCode.RECORD_KIND_UNKNOWN: ConflictKind.ORPHANED_DATA,
|
|
48
|
+
IssueCode.LINK_RELATION_UNKNOWN: ConflictKind.ORPHANED_DATA,
|
|
49
|
+
IssueCode.LINK_FOREIGN_KEY_BACKED: ConflictKind.ORPHANED_DATA,
|
|
50
|
+
IssueCode.RECORD_TRAIT_CONFORMANCE: ConflictKind.TRAIT_CONFORMANCE,
|
|
51
|
+
}
|
|
52
|
+
"""Issue codes of single-element validation and their Conflict kinds; others are constraints."""
|
|
53
|
+
|
|
54
|
+
_CARDINALITIES = {
|
|
55
|
+
(True, True): Cardinality.ONE_TO_ONE,
|
|
56
|
+
(True, False): Cardinality.MANY_TO_ONE,
|
|
57
|
+
(False, True): Cardinality.ONE_TO_MANY,
|
|
58
|
+
(False, False): Cardinality.MANY_TO_MANY,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def relaxed_cardinality(cardinality: Cardinality, side: str) -> Cardinality:
|
|
63
|
+
"""``cardinality`` without the "at most one Link" rule on ``side`` (``from`` or ``to``)."""
|
|
64
|
+
single_from = cardinality in (Cardinality.ONE_TO_ONE, Cardinality.MANY_TO_ONE)
|
|
65
|
+
single_to = cardinality in (Cardinality.ONE_TO_ONE, Cardinality.ONE_TO_MANY)
|
|
66
|
+
if side == "from":
|
|
67
|
+
single_from = False
|
|
68
|
+
else:
|
|
69
|
+
single_to = False
|
|
70
|
+
return _CARDINALITIES[(single_from, single_to)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MergedView:
|
|
74
|
+
"""Records and Links after the merge: the target head overlaid with the merge set."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, index: IndexView, merge: DataMerge) -> None:
|
|
77
|
+
self.index = index
|
|
78
|
+
self.merge = merge
|
|
79
|
+
self._from: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
|
|
80
|
+
self._to: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
|
|
81
|
+
for (partition, _), state in merge.present():
|
|
82
|
+
if data_object_type(partition) is ObjectType.LINK:
|
|
83
|
+
self.add(state)
|
|
84
|
+
|
|
85
|
+
def add(self, state: Payload) -> None:
|
|
86
|
+
relation, start, end = str(state["relation"]), str(state["from"]), str(state["to"])
|
|
87
|
+
self._from[relation][start].add(end)
|
|
88
|
+
self._to[relation][end].add(start)
|
|
89
|
+
|
|
90
|
+
def drop(self, partition: str, key: str) -> None:
|
|
91
|
+
state = self.merge.states.get((partition, key))
|
|
92
|
+
if state is not None:
|
|
93
|
+
relation, start, end = str(state["relation"]), str(state["from"]), str(state["to"])
|
|
94
|
+
self._from[relation][start].discard(end)
|
|
95
|
+
self._to[relation][end].discard(start)
|
|
96
|
+
self.merge.states[(partition, key)] = None
|
|
97
|
+
|
|
98
|
+
def record_exists(self, kind_id: str, key: str) -> bool:
|
|
99
|
+
location = (kind_partition(kind_id), key)
|
|
100
|
+
if location in self.merge.states:
|
|
101
|
+
return self.merge.states[location] is not None
|
|
102
|
+
return self.index.record_exists(kind_id, key)
|
|
103
|
+
|
|
104
|
+
def links(
|
|
105
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
106
|
+
) -> set[tuple[str, str]]:
|
|
107
|
+
"""``(from_key, to_key)`` of the Links with the given endpoint after the merge."""
|
|
108
|
+
partition = relation_partition(relation_id)
|
|
109
|
+
found = {
|
|
110
|
+
pair
|
|
111
|
+
for pair in self.index.links_of(relation_id, from_key=from_key, to_key=to_key)
|
|
112
|
+
if (partition, link_key(*pair)) not in self.merge.states
|
|
113
|
+
}
|
|
114
|
+
if from_key is not None:
|
|
115
|
+
found.update((from_key, end) for end in self._from[relation_id].get(from_key, ()))
|
|
116
|
+
if to_key is not None:
|
|
117
|
+
found.update((start, to_key) for start in self._to[relation_id].get(to_key, ()))
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_consistency(ctx: Context, schema: Schema, merge: DataMerge, index: IndexView) -> None:
|
|
122
|
+
"""Run the consistency rules of design 6.8 step 4 on the merge set."""
|
|
123
|
+
checker = _Checker(ctx, schema, merge, MergedView(index, merge))
|
|
124
|
+
checker.dangling_links()
|
|
125
|
+
checker.elements()
|
|
126
|
+
checker.references()
|
|
127
|
+
checker.uniqueness()
|
|
128
|
+
checker.cardinality()
|
|
129
|
+
checker.required_relations()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Checker:
|
|
133
|
+
def __init__(self, ctx: Context, schema: Schema, merge: DataMerge, view: MergedView) -> None:
|
|
134
|
+
self.ctx = ctx
|
|
135
|
+
self.schema = schema
|
|
136
|
+
self.merge = merge
|
|
137
|
+
self.view = view
|
|
138
|
+
|
|
139
|
+
def checked(self, location: tuple[str, str]) -> bool:
|
|
140
|
+
return location[0] not in self.ctx.blocked and location not in self.ctx.open_keys
|
|
141
|
+
|
|
142
|
+
def add(
|
|
143
|
+
self,
|
|
144
|
+
kind: ConflictKind,
|
|
145
|
+
partition: str,
|
|
146
|
+
attribute: str | None,
|
|
147
|
+
member: str,
|
|
148
|
+
detail: JsonValue,
|
|
149
|
+
message: str,
|
|
150
|
+
*,
|
|
151
|
+
values_from: str | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
element = partition_element(partition)
|
|
154
|
+
group = self.ctx.group(
|
|
155
|
+
kind,
|
|
156
|
+
partition,
|
|
157
|
+
None,
|
|
158
|
+
attribute,
|
|
159
|
+
stage=Stage.CHECK,
|
|
160
|
+
element=element,
|
|
161
|
+
message=message,
|
|
162
|
+
)
|
|
163
|
+
if values_from is not None and element is not None and not group.members:
|
|
164
|
+
group.values = self.ctx.property_values(element, values_from)
|
|
165
|
+
group.members.setdefault(member, detail)
|
|
166
|
+
|
|
167
|
+
# Dangling Links ----------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def dangling_links(self) -> None:
|
|
170
|
+
cascaded: dict[str, list[str]] = defaultdict(list)
|
|
171
|
+
candidates: list[tuple[Relation, str, str, str]] = []
|
|
172
|
+
for (partition, key), state in list(self.merge.present()):
|
|
173
|
+
if data_object_type(partition) is not ObjectType.LINK:
|
|
174
|
+
continue
|
|
175
|
+
relation = self.schema.relations.get(str(state["relation"]))
|
|
176
|
+
if relation is not None and relation.backing is RelationBacking.EXPLICIT:
|
|
177
|
+
candidates.append((relation, key, str(state["from"]), str(state["to"])))
|
|
178
|
+
for (partition, key), merged in sorted(self.merge.states.items()):
|
|
179
|
+
element = partition_element(partition)
|
|
180
|
+
ours = self.merge.ours.get((partition, key))
|
|
181
|
+
if merged is not None or ours is None or element is None:
|
|
182
|
+
continue
|
|
183
|
+
if data_object_type(partition) is ObjectType.LINK:
|
|
184
|
+
continue
|
|
185
|
+
for relation in self.schema.relations_of(element):
|
|
186
|
+
if relation.backing is not RelationBacking.EXPLICIT:
|
|
187
|
+
continue
|
|
188
|
+
pairs: set[tuple[str, str]] = set()
|
|
189
|
+
if relation.from_kind == element:
|
|
190
|
+
pairs.update(self.view.index.links_of(relation.id, from_key=key))
|
|
191
|
+
if relation.to_kind == element:
|
|
192
|
+
pairs.update(self.view.index.links_of(relation.id, to_key=key))
|
|
193
|
+
link_partition = relation_partition(relation.id)
|
|
194
|
+
for start, end in sorted(pairs):
|
|
195
|
+
location = (link_partition, link_key(start, end))
|
|
196
|
+
if location in self.merge.states:
|
|
197
|
+
continue
|
|
198
|
+
digest = tree_get(self.ctx.store, self.ctx.roots[Side.OURS], *location)
|
|
199
|
+
loaded = self.ctx.document(digest)
|
|
200
|
+
if loaded is None:
|
|
201
|
+
continue
|
|
202
|
+
self.merge.states[location] = loaded
|
|
203
|
+
self.merge.ours[location] = digest
|
|
204
|
+
self.merge.origins[location] = Origin()
|
|
205
|
+
self.view.add(loaded)
|
|
206
|
+
candidates.append((relation, location[1], start, end))
|
|
207
|
+
for relation, key, start, end in candidates:
|
|
208
|
+
partition = relation_partition(relation.id)
|
|
209
|
+
missing = [
|
|
210
|
+
[kind_id, endpoint]
|
|
211
|
+
for kind_id, endpoint in ((relation.from_kind, start), (relation.to_kind, end))
|
|
212
|
+
if not self.view.record_exists(kind_id, endpoint)
|
|
213
|
+
]
|
|
214
|
+
if not missing:
|
|
215
|
+
continue
|
|
216
|
+
if relation.on_delete is OnDelete.CASCADE and self.ctx.policy.cascade_dangling:
|
|
217
|
+
self.view.drop(partition, key)
|
|
218
|
+
cascaded[partition].append(key)
|
|
219
|
+
elif self.checked((partition, key)):
|
|
220
|
+
self.add(
|
|
221
|
+
ConflictKind.DANGLING_LINK,
|
|
222
|
+
partition,
|
|
223
|
+
None,
|
|
224
|
+
key,
|
|
225
|
+
{"missing": missing}, # type: ignore[dict-item]
|
|
226
|
+
f"Links of {relation.api_name!r} point at deleted Records",
|
|
227
|
+
values_from="on_delete",
|
|
228
|
+
)
|
|
229
|
+
for partition, keys in sorted(cascaded.items()):
|
|
230
|
+
element = partition_element(partition)
|
|
231
|
+
conflict = self.ctx.conflict(
|
|
232
|
+
ConflictKind.DANGLING_LINK,
|
|
233
|
+
partition,
|
|
234
|
+
None,
|
|
235
|
+
None,
|
|
236
|
+
self.ctx.property_values(element or "", "on_delete"),
|
|
237
|
+
self.ctx.element_hashes(element),
|
|
238
|
+
members=keys,
|
|
239
|
+
)
|
|
240
|
+
self.ctx.record_auto(conflict, ResolutionChoice.DROP, "policy:cascade_dangling")
|
|
241
|
+
|
|
242
|
+
# Single elements -----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def elements(self) -> None:
|
|
245
|
+
for location, state in list(self.merge.present()):
|
|
246
|
+
if not self.checked(location):
|
|
247
|
+
continue
|
|
248
|
+
partition, key = location
|
|
249
|
+
for issue in self.validate(location, state):
|
|
250
|
+
kind = _ISSUE_KINDS.get(issue.code, ConflictKind.CONSTRAINT)
|
|
251
|
+
detail: dict[str, JsonValue] = {"code": issue.code.value, "message": issue.message}
|
|
252
|
+
if kind is ConflictKind.TRAIT_CONFORMANCE:
|
|
253
|
+
detail["trait"] = issue.details.get("trait")
|
|
254
|
+
self.add(kind, partition, issue.attribute, key, detail, issue.message)
|
|
255
|
+
|
|
256
|
+
def validate(self, location: tuple[str, str], state: Payload) -> list[Issue]:
|
|
257
|
+
try:
|
|
258
|
+
if data_object_type(location[0]) is ObjectType.LINK:
|
|
259
|
+
return validate_link(self.schema, Link.model_validate(state))
|
|
260
|
+
return validate_record(self.schema, Record.model_validate(state))
|
|
261
|
+
except ValidationError as exc:
|
|
262
|
+
return [
|
|
263
|
+
Issue(
|
|
264
|
+
code=IssueCode.VALUE_TYPE,
|
|
265
|
+
message=f"invalid element: {exc.errors()[0]['msg']}",
|
|
266
|
+
key=location[1],
|
|
267
|
+
)
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
# Reference Attributes ---------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def references(self) -> None:
|
|
273
|
+
for location, state in list(self.merge.present()):
|
|
274
|
+
partition, key = location
|
|
275
|
+
element = partition_element(partition)
|
|
276
|
+
kind = self.schema.kinds.get(element or "")
|
|
277
|
+
if kind is None or not self.checked(location):
|
|
278
|
+
continue
|
|
279
|
+
values = state.get("values")
|
|
280
|
+
if not isinstance(values, dict):
|
|
281
|
+
continue
|
|
282
|
+
for attribute in kind.attributes.values():
|
|
283
|
+
spec = attribute.type.scalar
|
|
284
|
+
if spec.name is not AttributeType.REF or spec.kind_ref is None:
|
|
285
|
+
continue
|
|
286
|
+
target = spec.kind_ref
|
|
287
|
+
missing = [
|
|
288
|
+
[target, ref]
|
|
289
|
+
for ref in _keys_in(values.get(attribute.id))
|
|
290
|
+
if not self.view.record_exists(target, ref)
|
|
291
|
+
]
|
|
292
|
+
if missing:
|
|
293
|
+
self.add(
|
|
294
|
+
ConflictKind.DANGLING_LINK,
|
|
295
|
+
partition,
|
|
296
|
+
attribute.id,
|
|
297
|
+
key,
|
|
298
|
+
{"missing": missing}, # type: ignore[dict-item]
|
|
299
|
+
f"{attribute.api_name!r} references deleted Records",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Uniqueness ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def uniqueness(self) -> None:
|
|
305
|
+
for kind in self.schema.kinds.values():
|
|
306
|
+
unique = [a for a in kind.attributes.values() if a.unique]
|
|
307
|
+
partition = kind_partition(kind.id)
|
|
308
|
+
if not unique or partition in self.ctx.blocked:
|
|
309
|
+
continue
|
|
310
|
+
claims: dict[tuple[str, bytes], str] = {}
|
|
311
|
+
for location, state in self.merge.present(partition):
|
|
312
|
+
if location in self.ctx.open_keys:
|
|
313
|
+
continue
|
|
314
|
+
key = location[1]
|
|
315
|
+
values = state.get("values")
|
|
316
|
+
if not isinstance(values, dict):
|
|
317
|
+
continue
|
|
318
|
+
for attribute in unique:
|
|
319
|
+
value = values.get(attribute.id)
|
|
320
|
+
if value is None:
|
|
321
|
+
continue
|
|
322
|
+
claim = (attribute.id, canonical_bytes(value))
|
|
323
|
+
others = []
|
|
324
|
+
if claim in claims:
|
|
325
|
+
others.append(claims[claim])
|
|
326
|
+
else:
|
|
327
|
+
claims[claim] = key
|
|
328
|
+
owner = self.view.index.find_unique(kind.id, attribute.id, value)
|
|
329
|
+
if (
|
|
330
|
+
owner is not None
|
|
331
|
+
and owner != key
|
|
332
|
+
and (partition, owner) not in (self.merge.states)
|
|
333
|
+
):
|
|
334
|
+
others.append(owner)
|
|
335
|
+
for other in others:
|
|
336
|
+
message = f"{attribute.api_name!r} value is used by several Records"
|
|
337
|
+
for member, peer in ((key, other), (other, key)):
|
|
338
|
+
self.add(
|
|
339
|
+
ConflictKind.UNIQUENESS,
|
|
340
|
+
partition,
|
|
341
|
+
attribute.id,
|
|
342
|
+
member,
|
|
343
|
+
{"value": value, "other": peer},
|
|
344
|
+
message,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Cardinality ----------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def cardinality(self) -> None:
|
|
350
|
+
for relation in self.schema.relations.values():
|
|
351
|
+
partition = relation_partition(relation.id)
|
|
352
|
+
if relation.backing is not RelationBacking.EXPLICIT or partition in self.ctx.blocked:
|
|
353
|
+
continue
|
|
354
|
+
for side, single in (
|
|
355
|
+
("from", relation.single_per_from),
|
|
356
|
+
("to", relation.single_per_to),
|
|
357
|
+
):
|
|
358
|
+
if not single:
|
|
359
|
+
continue
|
|
360
|
+
endpoints = sorted(
|
|
361
|
+
{
|
|
362
|
+
str(state[side])
|
|
363
|
+
for location, state in self.merge.present(partition)
|
|
364
|
+
if location not in self.ctx.open_keys
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
for endpoint in endpoints:
|
|
368
|
+
pairs = (
|
|
369
|
+
self.view.links(relation.id, from_key=endpoint)
|
|
370
|
+
if side == "from"
|
|
371
|
+
else self.view.links(relation.id, to_key=endpoint)
|
|
372
|
+
)
|
|
373
|
+
if len(pairs) > 1:
|
|
374
|
+
self.add(
|
|
375
|
+
ConflictKind.CARDINALITY,
|
|
376
|
+
partition,
|
|
377
|
+
side,
|
|
378
|
+
endpoint,
|
|
379
|
+
{"links": [list(p) for p in sorted(pairs)]},
|
|
380
|
+
f"{relation.api_name!r} is {relation.cardinality}: competing Links",
|
|
381
|
+
values_from="cardinality",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Required relations -------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def required_relations(self) -> None:
|
|
387
|
+
for relation in self.schema.relations.values():
|
|
388
|
+
if not relation.required_from:
|
|
389
|
+
continue
|
|
390
|
+
partition = relation_partition(relation.id)
|
|
391
|
+
records = kind_partition(relation.from_kind)
|
|
392
|
+
if partition in self.ctx.blocked or records in self.ctx.blocked:
|
|
393
|
+
continue
|
|
394
|
+
missing: Iterable[str]
|
|
395
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
396
|
+
missing = self.missing_foreign_keys(relation, records)
|
|
397
|
+
else:
|
|
398
|
+
missing = self.missing_links(relation, partition, records)
|
|
399
|
+
for key in missing:
|
|
400
|
+
self.add(
|
|
401
|
+
ConflictKind.REQUIRED_RELATION,
|
|
402
|
+
partition,
|
|
403
|
+
None,
|
|
404
|
+
key,
|
|
405
|
+
None,
|
|
406
|
+
f"Records need at least one {relation.api_name!r} Link",
|
|
407
|
+
values_from="required_from",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def missing_links(self, relation: Relation, partition: str, records: str) -> list[str]:
|
|
411
|
+
candidates = {
|
|
412
|
+
location[1]
|
|
413
|
+
for location, _ in self.merge.present(records)
|
|
414
|
+
if location not in self.ctx.open_keys
|
|
415
|
+
}
|
|
416
|
+
for (link_partition, key), state in self.merge.states.items():
|
|
417
|
+
if link_partition == partition and state is None:
|
|
418
|
+
start = key.split(LINK_KEY_SEPARATOR, 1)[0]
|
|
419
|
+
if self.view.record_exists(relation.from_kind, start):
|
|
420
|
+
candidates.add(start)
|
|
421
|
+
return [k for k in sorted(candidates) if not self.view.links(relation.id, from_key=k)]
|
|
422
|
+
|
|
423
|
+
def missing_foreign_keys(self, relation: Relation, records: str) -> list[str]:
|
|
424
|
+
attribute = relation.foreign_key_attribute
|
|
425
|
+
missing = []
|
|
426
|
+
for location, state in self.merge.present(records):
|
|
427
|
+
values = state.get("values")
|
|
428
|
+
if attribute is None or location in self.ctx.open_keys or not isinstance(values, dict):
|
|
429
|
+
continue
|
|
430
|
+
if values.get(attribute) is None:
|
|
431
|
+
missing.append(location[1])
|
|
432
|
+
return missing
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _keys_in(value: JsonValue) -> list[str]:
|
|
436
|
+
if isinstance(value, str):
|
|
437
|
+
return [value]
|
|
438
|
+
if isinstance(value, list):
|
|
439
|
+
return [item for item in value if isinstance(item, str)]
|
|
440
|
+
return []
|