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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. 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