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,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 []