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,522 @@
|
|
|
1
|
+
"""Data merge (design 6.8 step 3): Records and Links under the merged schema.
|
|
2
|
+
|
|
3
|
+
Each side's version of a changed element is first lifted into the merged schema: values of
|
|
4
|
+
Attributes the merged schema no longer has are dropped (orphaned values when that side wrote
|
|
5
|
+
them) and values of Attributes whose type changed are cast (type coercion Conflicts when the
|
|
6
|
+
cast fails). The lifted versions then merge Attribute by Attribute against the lifted base.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from pydantic import JsonValue
|
|
16
|
+
|
|
17
|
+
from lorebase_core.canonical import ObjectType
|
|
18
|
+
from lorebase_core.merge.conflicts import ConflictKind, ResolutionChoice, ResolutionError, Side
|
|
19
|
+
from lorebase_core.merge.objects import data_object_type, partition_element
|
|
20
|
+
from lorebase_core.merge.schema_merge import SchemaMerge, pick
|
|
21
|
+
from lorebase_core.merge.state import (
|
|
22
|
+
MISSING,
|
|
23
|
+
SIDES,
|
|
24
|
+
Context,
|
|
25
|
+
Payload,
|
|
26
|
+
Stage,
|
|
27
|
+
Value,
|
|
28
|
+
as_json,
|
|
29
|
+
same,
|
|
30
|
+
)
|
|
31
|
+
from lorebase_core.model import Attribute, Kind, Relation, Schema
|
|
32
|
+
from lorebase_core.tree import PARTITION_SCHEMA, DiffEntry, relation_partition, tree_get
|
|
33
|
+
from lorebase_core.types import CastError, CastRule, cast, cast_rule
|
|
34
|
+
|
|
35
|
+
__all__ = ["DataMerge", "Origin", "SideChanges", "merge_data", "side_changes"]
|
|
36
|
+
|
|
37
|
+
#: Changed data elements of one side against the base: ``(partition, key) -> (base, side)``.
|
|
38
|
+
SideChanges = dict[tuple[str, str], tuple[bytes | None, bytes | None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Origin:
|
|
43
|
+
"""Where the merged version of an element comes from, for provenance."""
|
|
44
|
+
|
|
45
|
+
whole: Side | None = None
|
|
46
|
+
attributes: dict[str, Side] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class DataMerge:
|
|
51
|
+
"""Merged states of every data element changed on either side."""
|
|
52
|
+
|
|
53
|
+
#: ``(partition, key) -> payload``, ``None`` when the element is deleted.
|
|
54
|
+
states: dict[tuple[str, str], Payload | None] = field(default_factory=dict)
|
|
55
|
+
#: Ours' object hash of each element in ``states``.
|
|
56
|
+
ours: dict[tuple[str, str], bytes | None] = field(default_factory=dict)
|
|
57
|
+
origins: dict[tuple[str, str], Origin] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def present(self, partition: str | None = None) -> Iterable[tuple[tuple[str, str], Payload]]:
|
|
60
|
+
"""Elements present after the merge, optionally of one partition, in key order."""
|
|
61
|
+
for location in sorted(self.states):
|
|
62
|
+
state = self.states[location]
|
|
63
|
+
if state is not None and (partition is None or location[0] == partition):
|
|
64
|
+
yield location, state
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def side_changes(entries: Iterable[DiffEntry]) -> SideChanges:
|
|
68
|
+
"""Data changes of a side from its diff against the base (the ``schema`` is left out)."""
|
|
69
|
+
return {
|
|
70
|
+
(e.partition, e.key): (e.hash_a, e.hash_b)
|
|
71
|
+
for e in entries
|
|
72
|
+
if e.partition != PARTITION_SCHEMA
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge_data(ctx: Context, schema: SchemaMerge, changes: Mapping[Side, SideChanges]) -> DataMerge:
|
|
77
|
+
"""Merge every data element changed on either side."""
|
|
78
|
+
result = DataMerge()
|
|
79
|
+
by_partition: dict[str, dict[str, bytes | None]] = defaultdict(dict)
|
|
80
|
+
for side in SIDES:
|
|
81
|
+
for (partition, key), (base_hash, _) in changes[side].items():
|
|
82
|
+
by_partition[partition][key] = base_hash
|
|
83
|
+
for partition in sorted(by_partition):
|
|
84
|
+
merger = _PartitionMerge(ctx, schema, changes, partition, result)
|
|
85
|
+
merger.run(by_partition[partition])
|
|
86
|
+
_apply_directives(ctx, result)
|
|
87
|
+
if ctx.policy.apply_defaults and not ctx.virtual:
|
|
88
|
+
_apply_defaults(ctx, schema, result)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _PartitionMerge:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
ctx: Context,
|
|
96
|
+
schema: SchemaMerge,
|
|
97
|
+
changes: Mapping[Side, SideChanges],
|
|
98
|
+
partition: str,
|
|
99
|
+
result: DataMerge,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.ctx = ctx
|
|
102
|
+
self.schema = schema
|
|
103
|
+
self.changes = changes
|
|
104
|
+
self.partition = partition
|
|
105
|
+
self.result = result
|
|
106
|
+
element = partition_element(partition)
|
|
107
|
+
self.element_id = element
|
|
108
|
+
self.is_link = element is not None and partition == relation_partition(element)
|
|
109
|
+
self.merged = self._element(schema.schema, element)
|
|
110
|
+
self.sides: dict[Side, Kind | Relation | None] = {
|
|
111
|
+
side: self._element(ctx.schemas[side], element)
|
|
112
|
+
for side in (Side.BASE, Side.OURS, Side.THEIRS)
|
|
113
|
+
}
|
|
114
|
+
merged_hash = None if element is None else schema.hashes.get(element)
|
|
115
|
+
self.lift_needed = {
|
|
116
|
+
side: element is not None
|
|
117
|
+
and (
|
|
118
|
+
ctx.schema_hashes[side].get(element) != merged_hash
|
|
119
|
+
or any(
|
|
120
|
+
s is side and p == partition for s, p, _ in ctx.directives.ignored_attributes
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
for side in (Side.BASE, Side.OURS, Side.THEIRS)
|
|
124
|
+
}
|
|
125
|
+
self.failures: set[tuple[Side, str]] = set()
|
|
126
|
+
self.bases: Mapping[str, bytes | None] = {}
|
|
127
|
+
|
|
128
|
+
def _element(self, schema: Schema | None, element: str | None) -> Kind | Relation | None:
|
|
129
|
+
if element is None or schema is None:
|
|
130
|
+
return None
|
|
131
|
+
if self.is_link:
|
|
132
|
+
return schema.relations.get(element)
|
|
133
|
+
return schema.kinds.get(element)
|
|
134
|
+
|
|
135
|
+
# Hashes ---------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def side_hash(self, side: Side, key: str, base: bytes | None) -> bytes | None:
|
|
138
|
+
if (side, self.partition) in self.ctx.directives.ignored_partitions:
|
|
139
|
+
return base
|
|
140
|
+
change = self.changes[side].get((self.partition, key))
|
|
141
|
+
return base if change is None else change[1]
|
|
142
|
+
|
|
143
|
+
# Entry point ----------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def run(self, keys: Mapping[str, bytes | None]) -> None:
|
|
146
|
+
self.bases = keys
|
|
147
|
+
if self.element_id is None:
|
|
148
|
+
for key, base in sorted(keys.items()):
|
|
149
|
+
self.store(key, self.ctx.document(self.side_hash(Side.THEIRS, key, base)))
|
|
150
|
+
return
|
|
151
|
+
if self.merged is None:
|
|
152
|
+
self.orphans(keys)
|
|
153
|
+
return
|
|
154
|
+
for key, base in sorted(keys.items()):
|
|
155
|
+
self.merge_key(key, base)
|
|
156
|
+
|
|
157
|
+
def store(self, key: str, state: Payload | None, origin: Origin | None = None) -> None:
|
|
158
|
+
location = (self.partition, key)
|
|
159
|
+
ours = self.changes[Side.OURS].get(location)
|
|
160
|
+
self.result.states[location] = state
|
|
161
|
+
self.result.ours[location] = self.bases[key] if ours is None else ours[1]
|
|
162
|
+
self.result.origins[location] = origin or Origin()
|
|
163
|
+
|
|
164
|
+
# Orphaned data --------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def orphans(self, keys: Mapping[str, bytes | None]) -> None:
|
|
167
|
+
"""The Kind or Relation is gone from the merged schema: its data is dropped."""
|
|
168
|
+
assert self.element_id is not None
|
|
169
|
+
for side in SIDES:
|
|
170
|
+
if self.sides[side] is None:
|
|
171
|
+
continue
|
|
172
|
+
members = [key for key, base in keys.items() if self.side_hash(side, key, base) != base]
|
|
173
|
+
if members and self.partition not in self.ctx.blocked:
|
|
174
|
+
group = self.ctx.group(
|
|
175
|
+
ConflictKind.ORPHANED_DATA,
|
|
176
|
+
self.partition,
|
|
177
|
+
None,
|
|
178
|
+
None,
|
|
179
|
+
stage=Stage.DECISION,
|
|
180
|
+
element=self.element_id,
|
|
181
|
+
data_side=side,
|
|
182
|
+
message=(
|
|
183
|
+
f"{self.element_id} was removed on one side; {len(members)} element(s) "
|
|
184
|
+
f"of it changed on {side.value}"
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
group.members.update(dict.fromkeys(members))
|
|
188
|
+
for key in sorted(keys):
|
|
189
|
+
self.store(key, None)
|
|
190
|
+
|
|
191
|
+
# Elements -------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def merge_key(self, key: str, base_hash: bytes | None) -> None:
|
|
194
|
+
ours_hash = self.side_hash(Side.OURS, key, base_hash)
|
|
195
|
+
theirs_hash = self.side_hash(Side.THEIRS, key, base_hash)
|
|
196
|
+
changed_ours = ours_hash != base_hash
|
|
197
|
+
changed_theirs = theirs_hash != base_hash
|
|
198
|
+
if ours_hash == theirs_hash and not self.lift_needed[Side.OURS]:
|
|
199
|
+
self.store(key, self.ctx.document(ours_hash))
|
|
200
|
+
return
|
|
201
|
+
if changed_theirs and not changed_ours and not self.lift_needed[Side.THEIRS]:
|
|
202
|
+
self.store(key, self.ctx.document(theirs_hash), Origin(whole=Side.THEIRS))
|
|
203
|
+
return
|
|
204
|
+
if changed_ours and not changed_theirs and not self.lift_needed[Side.OURS]:
|
|
205
|
+
self.store(key, self.ctx.document(ours_hash), Origin(whole=Side.OURS))
|
|
206
|
+
return
|
|
207
|
+
hashes = (base_hash, ours_hash, theirs_hash)
|
|
208
|
+
payloads = [self.ctx.document(h) for h in hashes]
|
|
209
|
+
base_values = _values(payloads[0])
|
|
210
|
+
lifted_base = self.lift(Side.BASE, key, base_values, base_values)
|
|
211
|
+
lifted: dict[Side, dict[str, JsonValue] | None] = {}
|
|
212
|
+
for side, payload in zip(SIDES, payloads[1:], strict=True):
|
|
213
|
+
lifted[side] = (
|
|
214
|
+
None if payload is None else self.lift(side, key, _values(payload), base_values)
|
|
215
|
+
)
|
|
216
|
+
for failed_side, attribute in self.failures:
|
|
217
|
+
values = lifted[side]
|
|
218
|
+
if failed_side is side and values is not None:
|
|
219
|
+
values.pop(attribute, None)
|
|
220
|
+
if attribute in lifted_base:
|
|
221
|
+
values[attribute] = lifted_base[attribute]
|
|
222
|
+
self.failures.clear()
|
|
223
|
+
template = next((p for p in (payloads[1], payloads[2], payloads[0]) if p is not None), None)
|
|
224
|
+
if template is None:
|
|
225
|
+
self.store(key, None)
|
|
226
|
+
return
|
|
227
|
+
if changed_ours and changed_theirs:
|
|
228
|
+
if payloads[1] is None and payloads[2] is None:
|
|
229
|
+
self.store(key, None)
|
|
230
|
+
elif payloads[0] is not None and (payloads[1] is None or payloads[2] is None):
|
|
231
|
+
self.delete_modify(key, hashes, template, lifted_base, lifted)
|
|
232
|
+
else:
|
|
233
|
+
values, origin = self.merge_values(
|
|
234
|
+
key, hashes, lifted_base, lifted[Side.OURS] or {}, lifted[Side.THEIRS] or {}
|
|
235
|
+
)
|
|
236
|
+
self.store(key, _with_values(template, values), origin)
|
|
237
|
+
return
|
|
238
|
+
side = Side.THEIRS if changed_theirs else Side.OURS
|
|
239
|
+
values_of_side = lifted[side]
|
|
240
|
+
state = None if values_of_side is None else _with_values(template, values_of_side)
|
|
241
|
+
self.store(key, state, Origin(whole=side) if changed_theirs or changed_ours else None)
|
|
242
|
+
|
|
243
|
+
def lift(
|
|
244
|
+
self, side: Side, key: str, values: dict[str, JsonValue], base: dict[str, JsonValue]
|
|
245
|
+
) -> dict[str, JsonValue]:
|
|
246
|
+
"""Values of one side expressed in the merged schema."""
|
|
247
|
+
merged = self.merged
|
|
248
|
+
assert merged is not None
|
|
249
|
+
side_element = self.sides[side]
|
|
250
|
+
side_attributes = merged.attributes if side_element is None else side_element.attributes
|
|
251
|
+
values = self.unignored(side, values, base)
|
|
252
|
+
lifted: dict[str, JsonValue] = {}
|
|
253
|
+
for attribute_id, value in values.items():
|
|
254
|
+
wrote = side is not Side.BASE and not same(value, base.get(attribute_id, MISSING))
|
|
255
|
+
target = merged.attributes.get(attribute_id)
|
|
256
|
+
if target is None:
|
|
257
|
+
if wrote and attribute_id in side_attributes:
|
|
258
|
+
self.problem(ConflictKind.ORPHANED_VALUE, side, attribute_id, key, value)
|
|
259
|
+
continue
|
|
260
|
+
source = side_attributes.get(attribute_id)
|
|
261
|
+
if source is None or source.type == target.type:
|
|
262
|
+
lifted[attribute_id] = value
|
|
263
|
+
continue
|
|
264
|
+
converted = self.convert(source, target, value)
|
|
265
|
+
if converted is MISSING:
|
|
266
|
+
if wrote:
|
|
267
|
+
self.problem(ConflictKind.TYPE_COERCION, side, attribute_id, key, value)
|
|
268
|
+
self.failures.add((side, attribute_id))
|
|
269
|
+
continue
|
|
270
|
+
lifted[attribute_id] = as_json(converted)
|
|
271
|
+
return lifted
|
|
272
|
+
|
|
273
|
+
def unignored(
|
|
274
|
+
self, side: Side, values: dict[str, JsonValue], base: dict[str, JsonValue]
|
|
275
|
+
) -> dict[str, JsonValue]:
|
|
276
|
+
"""``values`` with base values for the Attributes whose ``side`` changes are ignored."""
|
|
277
|
+
result = values
|
|
278
|
+
for ignored_side, partition, attribute_id in self.ctx.directives.ignored_attributes:
|
|
279
|
+
if ignored_side is not side or partition != self.partition:
|
|
280
|
+
continue
|
|
281
|
+
result = dict(result)
|
|
282
|
+
if attribute_id in base:
|
|
283
|
+
result[attribute_id] = base[attribute_id]
|
|
284
|
+
else:
|
|
285
|
+
result.pop(attribute_id, None)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
def convert(self, source: Attribute, target: Attribute, value: JsonValue) -> Value:
|
|
289
|
+
rule = cast_rule(source.type, target.type)
|
|
290
|
+
allowed = rule is CastRule.SAFE or (
|
|
291
|
+
rule is CastRule.CHECKED and self.ctx.policy.allow_checked_casts
|
|
292
|
+
)
|
|
293
|
+
if not allowed:
|
|
294
|
+
return MISSING
|
|
295
|
+
try:
|
|
296
|
+
return cast(source.type, target.type, value)
|
|
297
|
+
except CastError:
|
|
298
|
+
return MISSING
|
|
299
|
+
|
|
300
|
+
def problem(
|
|
301
|
+
self, kind: ConflictKind, side: Side, attribute_id: str, key: str, value: JsonValue
|
|
302
|
+
) -> None:
|
|
303
|
+
if self.partition in self.ctx.blocked:
|
|
304
|
+
return
|
|
305
|
+
assert self.element_id is not None
|
|
306
|
+
label = "removed" if kind is ConflictKind.ORPHANED_VALUE else "retyped"
|
|
307
|
+
group = self.ctx.group(
|
|
308
|
+
kind,
|
|
309
|
+
self.partition,
|
|
310
|
+
None,
|
|
311
|
+
attribute_id,
|
|
312
|
+
stage=Stage.DECISION,
|
|
313
|
+
element=self.element_id,
|
|
314
|
+
data_side=side,
|
|
315
|
+
message=f"{attribute_id} was {label} on one side and written on {side.value}",
|
|
316
|
+
)
|
|
317
|
+
group.members[key] = value
|
|
318
|
+
|
|
319
|
+
def delete_modify(
|
|
320
|
+
self,
|
|
321
|
+
key: str,
|
|
322
|
+
hashes: tuple[bytes | None, bytes | None, bytes | None],
|
|
323
|
+
template: Payload,
|
|
324
|
+
base: dict[str, JsonValue],
|
|
325
|
+
lifted: Mapping[Side, dict[str, JsonValue] | None],
|
|
326
|
+
) -> None:
|
|
327
|
+
versions: list[Value] = [
|
|
328
|
+
base,
|
|
329
|
+
*(MISSING if lifted[s] is None else lifted[s] for s in SIDES),
|
|
330
|
+
]
|
|
331
|
+
conflict = self.ctx.conflict(
|
|
332
|
+
ConflictKind.DELETE_MODIFY,
|
|
333
|
+
self.partition,
|
|
334
|
+
key,
|
|
335
|
+
None,
|
|
336
|
+
(base, lifted[Side.OURS], lifted[Side.THEIRS]),
|
|
337
|
+
hashes,
|
|
338
|
+
message=f"{key} was deleted on one side and modified on the other",
|
|
339
|
+
)
|
|
340
|
+
resolution = (
|
|
341
|
+
None
|
|
342
|
+
if self.partition in self.ctx.frozen
|
|
343
|
+
else self.ctx.decide(conflict, self.element_id, None)
|
|
344
|
+
)
|
|
345
|
+
if resolution is None:
|
|
346
|
+
if self.partition not in self.ctx.frozen:
|
|
347
|
+
self.ctx.report(conflict)
|
|
348
|
+
self.ctx.open_keys.add((self.partition, key))
|
|
349
|
+
self.store(key, _with_values(template, base))
|
|
350
|
+
return
|
|
351
|
+
chosen = pick(resolution, versions[0], versions[1], versions[2])
|
|
352
|
+
if chosen is MISSING or chosen is None:
|
|
353
|
+
self.store(key, None, _origin_of(resolution.choice))
|
|
354
|
+
return
|
|
355
|
+
if not isinstance(chosen, dict):
|
|
356
|
+
raise ResolutionError(f"custom value of {conflict.fingerprint} must be a value map")
|
|
357
|
+
self.store(key, _with_values(template, chosen), _origin_of(resolution.choice))
|
|
358
|
+
|
|
359
|
+
def merge_values(
|
|
360
|
+
self,
|
|
361
|
+
key: str,
|
|
362
|
+
hashes: tuple[bytes | None, bytes | None, bytes | None],
|
|
363
|
+
base: dict[str, JsonValue],
|
|
364
|
+
ours: dict[str, JsonValue],
|
|
365
|
+
theirs: dict[str, JsonValue],
|
|
366
|
+
) -> tuple[dict[str, JsonValue], Origin]:
|
|
367
|
+
"""Attribute-level three-way merge; arrays compare as whole values."""
|
|
368
|
+
kind = ConflictKind.ADD_ADD if hashes[0] is None else ConflictKind.ATTRIBUTE_VALUE
|
|
369
|
+
merged: dict[str, JsonValue] = {}
|
|
370
|
+
origin = Origin()
|
|
371
|
+
for attribute_id in sorted(base.keys() | ours.keys() | theirs.keys()):
|
|
372
|
+
b = base.get(attribute_id, MISSING)
|
|
373
|
+
o = ours.get(attribute_id, MISSING)
|
|
374
|
+
t = theirs.get(attribute_id, MISSING)
|
|
375
|
+
value: Value
|
|
376
|
+
if same(o, t):
|
|
377
|
+
value = o
|
|
378
|
+
elif same(o, b):
|
|
379
|
+
value = t
|
|
380
|
+
origin.attributes[attribute_id] = Side.THEIRS
|
|
381
|
+
elif same(t, b):
|
|
382
|
+
value = o
|
|
383
|
+
origin.attributes[attribute_id] = Side.OURS
|
|
384
|
+
else:
|
|
385
|
+
value = self.value_conflict(kind, key, attribute_id, hashes, (b, o, t), origin)
|
|
386
|
+
if value is not MISSING and value is not None:
|
|
387
|
+
merged[attribute_id] = value
|
|
388
|
+
return merged, origin
|
|
389
|
+
|
|
390
|
+
def value_conflict(
|
|
391
|
+
self,
|
|
392
|
+
kind: ConflictKind,
|
|
393
|
+
key: str,
|
|
394
|
+
attribute_id: str,
|
|
395
|
+
hashes: tuple[bytes | None, bytes | None, bytes | None],
|
|
396
|
+
values: tuple[Value, Value, Value],
|
|
397
|
+
origin: Origin,
|
|
398
|
+
) -> Value:
|
|
399
|
+
b, o, t = values
|
|
400
|
+
conflict = self.ctx.conflict(
|
|
401
|
+
kind,
|
|
402
|
+
self.partition,
|
|
403
|
+
key,
|
|
404
|
+
attribute_id,
|
|
405
|
+
(as_json(b), as_json(o), as_json(t)),
|
|
406
|
+
hashes,
|
|
407
|
+
message=f"{attribute_id} of {key} changed differently on both sides",
|
|
408
|
+
)
|
|
409
|
+
if self.partition in self.ctx.frozen:
|
|
410
|
+
self.ctx.open_keys.add((self.partition, key))
|
|
411
|
+
return b
|
|
412
|
+
target = None if self.merged is None else self.merged.attributes.get(attribute_id)
|
|
413
|
+
resolution = self.ctx.decide(
|
|
414
|
+
conflict,
|
|
415
|
+
self.element_id,
|
|
416
|
+
attribute_id,
|
|
417
|
+
attribute_authority=None if target is None else target.authority,
|
|
418
|
+
)
|
|
419
|
+
if resolution is None:
|
|
420
|
+
self.ctx.report(conflict)
|
|
421
|
+
self.ctx.open_keys.add((self.partition, key))
|
|
422
|
+
return b
|
|
423
|
+
side = _origin_of(resolution.choice).whole
|
|
424
|
+
if side is not None:
|
|
425
|
+
origin.attributes[attribute_id] = side
|
|
426
|
+
return pick(resolution, b, o, t)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _origin_of(choice: ResolutionChoice) -> Origin:
|
|
430
|
+
if choice is ResolutionChoice.THEIRS:
|
|
431
|
+
return Origin(whole=Side.THEIRS)
|
|
432
|
+
if choice is ResolutionChoice.OURS:
|
|
433
|
+
return Origin(whole=Side.OURS)
|
|
434
|
+
return Origin()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _values(payload: Payload | None) -> dict[str, JsonValue]:
|
|
438
|
+
if payload is None:
|
|
439
|
+
return {}
|
|
440
|
+
values = payload.get("values")
|
|
441
|
+
return dict(values) if isinstance(values, dict) else {}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _with_values(template: Payload, values: Mapping[str, JsonValue]) -> Payload:
|
|
445
|
+
return {**template, "values": {k: v for k, v in values.items() if v is not None}}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _apply_directives(ctx: Context, result: DataMerge) -> None:
|
|
449
|
+
"""Data overrides from resolutions: whole states, then single values."""
|
|
450
|
+
directives = ctx.directives
|
|
451
|
+
for location, state in sorted(directives.states.items(), key=lambda i: i[0]):
|
|
452
|
+
_ensure(ctx, result, location)
|
|
453
|
+
result.states[location] = state
|
|
454
|
+
result.origins[location] = Origin()
|
|
455
|
+
for (partition, key, attribute_id), value in sorted(
|
|
456
|
+
directives.values.items(), key=lambda i: i[0]
|
|
457
|
+
):
|
|
458
|
+
location = (partition, key)
|
|
459
|
+
_ensure(ctx, result, location)
|
|
460
|
+
state = result.states[location]
|
|
461
|
+
if state is None:
|
|
462
|
+
continue
|
|
463
|
+
values = _values(state)
|
|
464
|
+
if value is MISSING or value is None:
|
|
465
|
+
values.pop(attribute_id, None)
|
|
466
|
+
else:
|
|
467
|
+
values[attribute_id] = value
|
|
468
|
+
result.states[location] = _with_values(state, values)
|
|
469
|
+
result.origins[location].attributes.pop(attribute_id, None)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _ensure(ctx: Context, result: DataMerge, location: tuple[str, str]) -> None:
|
|
473
|
+
"""Bring an element not changed on either side into the merge set (ours' version)."""
|
|
474
|
+
if location in result.states:
|
|
475
|
+
return
|
|
476
|
+
ours_root = ctx.roots[Side.OURS]
|
|
477
|
+
digest = tree_get(ctx.store, ours_root, location[0], location[1])
|
|
478
|
+
result.states[location] = ctx.document(digest)
|
|
479
|
+
result.ours[location] = digest
|
|
480
|
+
result.origins[location] = Origin()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _apply_defaults(ctx: Context, schema: SchemaMerge, result: DataMerge) -> None:
|
|
484
|
+
"""Missing values of required Attributes take their default (recorded as resolutions)."""
|
|
485
|
+
applied: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
486
|
+
for (partition, key), state in list(result.present()):
|
|
487
|
+
element = partition_element(partition)
|
|
488
|
+
if element is None or (partition, key) in ctx.open_keys:
|
|
489
|
+
continue
|
|
490
|
+
owner: Kind | Relation | None
|
|
491
|
+
if data_object_type(partition) is ObjectType.LINK:
|
|
492
|
+
owner = schema.schema.relations.get(element)
|
|
493
|
+
else:
|
|
494
|
+
owner = schema.schema.kinds.get(element)
|
|
495
|
+
if owner is None:
|
|
496
|
+
continue
|
|
497
|
+
values = _values(state)
|
|
498
|
+
changed = False
|
|
499
|
+
for attribute in owner.attributes.values():
|
|
500
|
+
if (
|
|
501
|
+
attribute.required
|
|
502
|
+
and not attribute.is_derived
|
|
503
|
+
and attribute.default is not None
|
|
504
|
+
and values.get(attribute.id) is None
|
|
505
|
+
):
|
|
506
|
+
values[attribute.id] = attribute.default
|
|
507
|
+
applied[(partition, attribute.id)].append(key)
|
|
508
|
+
changed = True
|
|
509
|
+
if changed:
|
|
510
|
+
result.states[(partition, key)] = _with_values(state, values)
|
|
511
|
+
for (partition, attribute_id), keys in sorted(applied.items()):
|
|
512
|
+
element = partition_element(partition)
|
|
513
|
+
conflict = ctx.conflict(
|
|
514
|
+
ConflictKind.MISSING_VALUE,
|
|
515
|
+
partition,
|
|
516
|
+
None,
|
|
517
|
+
attribute_id,
|
|
518
|
+
ctx.element_values(element, attribute_id),
|
|
519
|
+
ctx.element_hashes(element),
|
|
520
|
+
members=keys,
|
|
521
|
+
)
|
|
522
|
+
ctx.record_auto(conflict, ResolutionChoice.CUSTOM, "policy:default")
|