Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/relational.py: 7%
547 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +0000
1"""
2This module contains the RelationalBone to create and manage relationships between skeletons
3and enums to parameterize it.
4"""
5import enum
6import json
7import logging
8import time
9import typing as t
10import warnings
11from itertools import chain
13from viur.core import db, utils, i18n
14from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity, getSystemInitialized
16if t.TYPE_CHECKING: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true
17 from viur.core.skeleton import SkeletonInstance, RelSkel
20class RelationalConsistency(enum.IntEnum):
21 """
22 An enumeration representing the different consistency strategies for handling stale relations in
23 the RelationalBone class.
24 """
25 Ignore = 1
26 """Ignore stale relations, which represents the old behavior."""
27 PreventDeletion = 2
28 """Lock the target object so that it cannot be deleted."""
29 SetNull = 3
30 """Drop the relation if the target object is deleted."""
31 CascadeDeletion = 4
32 """
33 .. warning:: Delete this object also if the referenced entry is deleted (Dangerous!)
34 """
37class RelationalUpdateLevel(enum.Enum):
38 """
39 An enumeration representing the different update levels for the RelationalBone class.
40 """
41 Always = 0
42 """Always update the relational information, regardless of the context."""
43 OnRebuildSearchIndex = 1
44 """Update the relational information only when rebuilding the search index."""
45 OnValueAssignment = 2
46 """Update the relational information only when a new value is assigned to the bone."""
49class RelDict(t.TypedDict):
50 dest: "SkeletonInstance"
51 rel: t.Optional["RelSkel"]
54class RelationalBone(BaseBone):
55 """
56 The base class for all relational bones in the ViUR framework.
57 RelationalBone is used to create and manage relationships between database entities. This class provides
58 basic functionality and attributes that can be extended by other specialized relational bone classes,
59 such as N1Relation, N2NRelation, and Hierarchy.
60 This implementation prioritizes read efficiency and is suitable for situations where data is read more
61 frequently than written. However, it comes with increased write operations when writing an entity to the
62 database. The additional write operations depend on the type of relationship: multiple=True RelationalBones
63 or 1:N relations.
65 The implementation does not instantly update relational information when a skeleton is updated; instead,
66 it triggers a deferred task to update references. This may result in outdated data until the task is completed.
68 Note: Filtering a list by relational properties uses the outdated data.
70 Example:
71 - Entity A references Entity B.
72 - Both have a property "name."
73 - Entity B is updated (its name changes).
74 - Entity A's RelationalBone values still show Entity B's old name.
76 It is not recommended for cases where data is read less frequently than written, as there is no
77 write-efficient method available yet.
79 :param kind: KindName of the referenced property.
80 :param module: Name of the module which should be used to select entities of kind "kind". If not set,
81 the value of "kind" will be used (the kindName must match the moduleName)
82 :param refKeys: A list of properties to include from the referenced property. These properties will be
83 available in the template without having to fetch the referenced property. Filtering is also only possible
84 by properties named here!
85 :param parentKeys: A list of properties from the current skeleton to include. If mixing filtering by
86 relational properties and properties of the class itself, these must be named here.
87 :param multiple: If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation).
88 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this
89 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use
90 a ```class MultipleConstraints``` instead.
92 :param format:
93 Hint for the frontend how to display such an relation. This is now a python expression
94 evaluated by safeeval on the client side. The following values will be passed to the expression:
96 - value
97 The value to display. This will be always a dict (= a single value) - even if the relation is
98 multiple (in which case the expression is evaluated once per referenced entity)
100 - structure
101 The structure of the skeleton this bone is part of as a dictionary as it's transferred to the
102 fronted by the admin/vi-render.
104 - language
105 The current language used by the frontend in ISO2 code (eg. "de"). This will be always set, even if
106 the project did not enable the multi-language feature.
108 :param updateLevel:
109 Indicates how ViUR should keep the values copied from the referenced entity into our
110 entity up to date. If this bone is indexed, it's recommended to leave this set to
111 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results.
113 :param RelationalUpdateLevel.Always:
115 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this
116 entity also (after a small delay, as these updates happen deferred)
118 :param RelationalUpdateLevel.OnRebuildSearchIndex:
120 update refKeys only on rebuildSearchIndex. If the referenced entity changes, this entity will
121 remain unchanged (this RelationalBone will still have the old values), but it can be updated
122 by either by editing this entity or running a rebuildSearchIndex over our kind.
124 :param RelationalUpdateLevel.OnValueAssignment:
126 update only if explicitly set. A rebuildSearchIndex will not trigger an update, this bone has to be
127 explicitly modified (in an edit) to have it's values updated
129 :param consistency:
130 Can be used to implement SQL-like constrains on this relation. Possible values are:
131 - RelationalConsistency.Ignore
132 If the referenced entity gets deleted, this bone will not change. It will still reflect the old
133 values. This will be even be preserved over edits, however if that referenced value is once
134 deleted by the user (assigning a different value to this bone or removing that value of the list
135 of relations if we are multiple) there's no way of restoring it
137 - RelationalConsistency.PreventDeletion
138 Will prevent deleting the referenced entity as long as it's selected in this bone (calling
139 skel.delete() on the referenced entity will raise errors.Locked). It's still (technically)
140 possible to remove the underlying datastore entity using db.delete manually, but this *must not*
141 be used on a skeleton object as it will leave a whole bunch of references in a stale state.
143 - RelationalConsistency.SetNull
144 Will set this bone to None (or remove the relation from the list in
145 case we are multiple) when the referenced entity is deleted.
147 - RelationalConsistency.CascadeDeletion:
148 (Dangerous!) Will delete this entity when the referenced entity is deleted. Warning: Unlike
149 relational updates this will cascade. If Entity A references B with CascadeDeletion set, and
150 B references C also with CascadeDeletion; if C gets deleted, both B and A will be deleted as well.
152 """
153 type = "relational"
154 kind = None
156 def __init__(
157 self,
158 *,
159 consistency: RelationalConsistency = RelationalConsistency.Ignore,
160 format: str = "$(dest.name)",
161 kind: str = None,
162 module: t.Optional[str] = None,
163 parentKeys: t.Optional[t.Iterable[str]] = {"name"},
164 refKeys: t.Optional[t.Iterable[str]] = {"name"},
165 updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always,
166 using: t.Optional["RelSkel"] = None,
167 **kwargs
168 ):
169 """
170 Initialize a new RelationalBone.
172 :param kind:
173 KindName of the referenced property.
174 :param module:
175 Name of the module which should be used to select entities of kind "type". If not set,
176 the value of "type" will be used (the kindName must match the moduleName)
177 :param refKeys:
178 An iterable of properties to include from the referenced property. These properties will be
179 available in the template without having to fetch the referenced property. Filtering is also only
180 possible by properties named here!
181 :param parentKeys:
182 An iterable of properties from the current skeleton to include. If mixing filtering by
183 relational properties and properties of the class itself, these must be named here.
184 :param multiple:
185 If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation).
186 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this
187 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use
188 a :class:MultipleConstraints instead.
190 :param format: Hint for the frontend how to display such an relation. This is now a python expression
191 evaluated by safeeval on the client side. The following values will be passed to the expression
193 :param value:
194 The value to display. This will be always a dict (= a single value) - even if the
195 relation is multiple (in which case the expression is evaluated once per referenced entity)
196 :param structure:
197 The structure of the skeleton this bone is part of as a dictionary as it's
198 transferred to the fronted by the admin/vi-render.
199 :param language:
200 The current language used by the frontend in ISO2 code (eg. "de"). This will be
201 always set, even if the project did not enable the multi-language feature.
203 :param updateLevel:
204 Indicates how ViUR should keep the values copied from the referenced entity into our
205 entity up to date. If this bone is indexed, it's recommended to leave this set to
206 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results.
208 :param RelationalUpdateLevel.Always:
209 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this
210 entity also (after a small delay, as these updates happen deferred)
211 :param RelationalUpdateLevel.OnRebuildSearchIndex:
212 update refKeys only on rebuildSearchIndex. If the
213 referenced entity changes, this entity will remain unchanged
214 (this RelationalBone will still have the old values), but it can be updated
215 by either by editing this entity or running a rebuildSearchIndex over our kind.
216 :param RelationalUpdateLevel.OnValueAssignment:
217 update only if explicitly set. A rebuildSearchIndex will not trigger
218 an update, this bone has to be explicitly modified (in an edit) to have it's values updated
220 :param consistency:
221 Can be used to implement SQL-like constrains on this relation.
223 :param RelationalConsistency.Ignore:
224 If the referenced entity gets deleted, this bone will not change. It
225 will still reflect the old values. This will be even be preserved over edits, however if that
226 referenced value is once deleted by the user (assigning a different value to this bone or
227 removing that value of the list of relations if we are multiple) there's no way of restoring it
229 :param RelationalConsistency.PreventDeletion:
230 Will prevent deleting the referenced entity as long as it's
231 selected in this bone (calling skel.delete() on the referenced entity will raise errors.Locked).
232 It's still (technically) possible to remove the underlying datastore entity using db.delete
233 manually, but this *must not* be used on a skeleton object as it will leave a whole bunch of
234 references in a stale state.
236 :param RelationalConsistency.SetNull:
237 Will set this bone to None (or remove the relation from the list in
238 case we are multiple) when the referenced entity is deleted.
240 :param RelationalConsistency.CascadeDeletion:
241 (Dangerous!) Will delete this entity when the referenced entity
242 is deleted. Warning: Unlike relational updates this will cascade. If Entity A references B with
243 CascadeDeletion set, and B references C also with CascadeDeletion; if C gets deleted, both B and
244 A will be deleted as well.
245 """
246 super().__init__(**kwargs)
247 self.format = format
249 if kind:
250 self.kind = kind
252 if module:
253 self.module = module
254 elif self.kind:
255 self.module = self.kind
257 if self.kind is None or self.module is None:
258 raise NotImplementedError("'kind' and 'module' of RelationalBone must not be None")
260 # Referenced keys
261 self.refKeys = {"key"}
262 if refKeys:
263 self.refKeys |= set(refKeys)
265 # Parent keys
266 self.parentKeys = {"key"}
267 if parentKeys:
268 self.parentKeys |= set(parentKeys)
270 self.using = using
272 # FIXME: Remove in VIUR4!!
273 if isinstance(updateLevel, int):
274 msg = f"parameter updateLevel={updateLevel} in RelationalBone is deprecated. " \
275 f"Please use the RelationalUpdateLevel enum instead"
276 logging.warning(msg, stacklevel=3)
277 warnings.warn(msg, DeprecationWarning, stacklevel=3)
279 assert 0 <= updateLevel < 3
280 for n in RelationalUpdateLevel:
281 if updateLevel == n.value:
282 updateLevel = n
284 self.updateLevel = updateLevel
285 self.consistency = consistency
287 if getSystemInitialized():
288 from viur.core.skeleton import RefSkel, SkeletonInstance
289 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys)
290 self._skeletonInstanceClassRef = SkeletonInstance
291 self._ref_keys = set(self._refSkelCache.__boneMap__.keys())
293 def setSystemInitialized(self):
294 """
295 Set the system initialized for the current class and cache the RefSkel and SkeletonInstance.
297 This method calls the superclass's setSystemInitialized method and initializes the RefSkel
298 and SkeletonInstance classes. The RefSkel is created from the current kind and refKeys,
299 while the SkeletonInstance class is stored as a reference.
301 :rtype: None
302 """
303 super().setSystemInitialized()
304 from viur.core.skeleton import RefSkel, SkeletonInstance
306 try:
307 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys)
308 except AssertionError:
309 raise NotImplementedError(
310 f"Skeleton {self.skel_cls!r} {self.__class__.__name__} {self.name!r}: Kind {self.kind!r} unknown"
311 )
313 self._skeletonInstanceClassRef = SkeletonInstance
314 self._ref_keys = set(self._refSkelCache.__boneMap__.keys())
316 def _getSkels(self):
317 """
318 Retrieve the reference skeleton and the 'using' skeleton for the current RelationalBone instance.
320 This method returns a tuple containing the reference skeleton (RefSkel) and the 'using' skeleton
321 (UsingSkel) associated with the current RelationalBone instance. The 'using' skeleton is only
322 retrieved if the 'using' attribute is defined.
324 :return: A tuple containing the reference skeleton and the 'using' skeleton.
325 :rtype: tuple
326 """
327 refSkel = self._refSkelCache()
328 usingSkel = self.using() if self.using else None
329 return refSkel, usingSkel
331 def singleValueUnserialize(self, val):
332 """
333 Restore a value, including the Rel- and Using-Skeleton, from the serialized data read from the datastore.
335 This method takes a serialized value from the datastore, deserializes it, and returns the corresponding
336 value with restored RelSkel and Using-Skel. It also handles ViUR 2 compatibility by handling string values.
338 :param val: A JSON-encoded datastore property.
339 :type val: str or dict
340 :return: The deserialized value with restored RelSkel and Using-Skel.
341 :rtype: dict
343 :raises AssertionError: If the deserialized value is not a dictionary.
344 """
346 def fixFromDictToEntry(inDict):
347 """
348 Convert a dictionary to an entry with properly restored keys and values.
350 :param dict inDict: The input dictionary to convert.
351 : return: The resulting entry.
352 :rtype: dict
353 """
354 if not isinstance(inDict, dict):
355 return None
356 res = {}
357 if "dest" in inDict:
358 res["dest"] = db.Entity()
359 for k, v in inDict["dest"].items():
360 res["dest"][k] = v
361 if "key" in res["dest"]:
362 res["dest"].key = db.normalize_key(res["dest"]["key"])
363 if "rel" in inDict and inDict["rel"]:
364 res["rel"] = db.Entity()
365 for k, v in inDict["rel"].items():
366 res["rel"][k] = v
367 else:
368 res["rel"] = None
369 return res
371 if isinstance(val, str): # ViUR2 compatibility
372 try:
373 value = json.loads(val)
374 if isinstance(value, list):
375 value = [fixFromDictToEntry(x) for x in value]
376 elif isinstance(value, dict):
377 value = fixFromDictToEntry(value)
378 else:
379 value = None
380 except ValueError:
381 value = None
382 else:
383 value = val
384 if not value:
385 return None
386 elif isinstance(value, list) and value:
387 value = value[0]
388 assert isinstance(value, dict), f"Read something from the datastore thats not a dict: {type(value)}"
389 if "dest" not in value:
390 return None
391 relSkel, usingSkel = self._getSkels()
392 relSkel.unserialize(value["dest"])
393 if self.using is not None:
394 usingSkel.unserialize(value["rel"] or db.Entity())
395 usingData = usingSkel
396 else:
397 usingData = None
398 return {"dest": relSkel, "rel": usingData}
400 def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
401 """
402 Serialize the RelationalBone for the given skeleton, updating relational locks as necessary.
404 This method serializes the RelationalBone values for a given skeleton and stores the serialized
405 values in the skeleton's dbEntity. It also updates the relational locks, adding new locks and
406 removing old ones as needed.
408 :param SkeletonInstance skel: The skeleton instance containing the values to be serialized.
409 :param str name: The name of the bone to be serialized.
410 :param bool parentIndexed: A flag indicating whether the parent bone is indexed.
411 :return: True if the serialization is successful, False otherwise.
412 :rtype: bool
414 :raises AssertionError: If a programming error is detected.
415 """
417 def serialize_dest_rel(in_value: dict | None = None) -> (dict | None, dict | None):
418 if not in_value:
419 return None, None
420 if dest_val := in_value.get("dest"):
421 ref_data_serialized = dest_val.serialize(parentIndexed=indexed)
422 else:
423 ref_data_serialized = None
424 if rel_data := in_value.get("rel"):
425 using_data_serialized = rel_data.serialize(parentIndexed=indexed)
426 else:
427 using_data_serialized = None
429 return using_data_serialized, ref_data_serialized
432 super().serialize(skel, name, parentIndexed)
434 # Clean old properties from entry (prevent name collision)
435 for key in tuple(skel.dbEntity.keys()):
436 if key.startswith(f"{name}."):
437 del skel.dbEntity[key]
439 indexed = self.indexed and parentIndexed
441 if not (new_vals := skel.accessedValues.get(name)):
442 return False
444 # TODO: The good old leier... modernize this.
445 if self.languages:
446 res = {"_viurLanguageWrapper_": True}
447 for language in self.languages:
448 if language in new_vals:
449 if self.multiple:
450 res[language] = []
451 for val in new_vals[language]:
452 if val:
453 using_data, ref_data = serialize_dest_rel(val)
454 res[language].append({"rel": using_data, "dest": ref_data})
455 else:
456 if (val := new_vals[language]) and val["dest"]:
457 using_data, ref_data = serialize_dest_rel(val)
458 res[language] = {"rel": using_data, "dest": ref_data}
459 elif self.multiple:
460 res = []
461 for val in new_vals:
462 if val:
463 using_data, ref_data = serialize_dest_rel(val)
464 res.append({"rel": using_data, "dest": ref_data})
465 elif new_vals:
466 using_data, ref_data = serialize_dest_rel(new_vals)
467 res = {"rel": using_data, "dest": ref_data}
469 skel.dbEntity[name] = res
471 # Ensure our indexed flag is up2date
472 if indexed and name in skel.dbEntity.exclude_from_indexes:
473 skel.dbEntity.exclude_from_indexes.discard(name)
474 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
475 skel.dbEntity.exclude_from_indexes.add(name)
477 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4
478 skel.dbEntity.pop(f"{name}_outgoingRelationalLocks", None)
480 return True
482 def _get_single_destinct_hash(self, value):
483 parts = [value["dest"]["key"]]
485 if self.using:
486 for name, bone in self.using.__boneMap__.items():
487 parts.append(bone._get_destinct_hash(value["rel"][name]))
489 return tuple(parts)
491 def postSavedHandler(self, skel, boneName, key) -> None:
492 """
493 Handle relational updates after a skeleton is saved.
495 This method updates, removes, or adds relations between the saved skeleton and the referenced entities.
496 It also takes care of updating the relational properties and consistency levels.
498 :param skel: The saved skeleton instance.
499 :param boneName: The name of the relational bone.
500 :param key: The key of the saved skeleton instance.
501 """
502 viur_src_kind = key.kind
503 viur_src_property = boneName
505 # Hack for RelationalBones in containers (like RecordBones)
506 if "." in boneName:
507 _, boneName = boneName.rsplit(".", 1) # bone name to fummel out of the skeleton (again...)
509 if not skel[boneName]:
510 values = []
511 elif self.multiple and self.languages:
512 values = chain(*skel[boneName].values())
513 elif self.languages:
514 values = list(skel[boneName].values())
515 elif self.multiple:
516 values = skel[boneName]
517 else:
518 values = [skel[boneName]]
520 # Keep a set of all referenced keys
521 values = [value for value in values if value]
522 values_keys = {value["dest"]["key"] for value in values}
524 # Referenced parent values
525 src_values = db.Entity(key)
526 if skel.dbEntity:
527 src_values |= {bone: skel.dbEntity.get(bone) for bone in self.parentKeys or ()}
529 # Now is now, nana nananaaaaaaa...
530 now = time.time()
532 # Helper fcuntion to
533 def __update_relation(entity: db.Entity, data: dict):
534 ref_skel = data["dest"]
535 rel_skel = data["rel"]
537 entity["dest"] = ref_skel.serialize(parentIndexed=True)
538 entity["rel"] = rel_skel.serialize(parentIndexed=True) if rel_skel else None
539 entity["src"] = src_values
541 entity["viur_src_kind"] = viur_src_kind
542 entity["viur_src_property"] = viur_src_property
543 entity["viur_dest_kind"] = self.kind
544 entity["viur_delayed_update_tag"] = now
545 entity["viur_relational_updateLevel"] = self.updateLevel.value
546 entity["viur_relational_consistency"] = self.consistency.value
547 entity["viur_foreign_keys"] = list(self.refKeys)
548 entity["viurTags"] = skel.dbEntity.get("viurTags") if skel.dbEntity else None
550 db.put(entity)
552 # Query and update existing entries pointing to this bone
553 query = db.Query("viur-relations") \
554 .filter("viur_src_kind =", viur_src_kind) \
555 .filter("viur_dest_kind =", self.kind) \
556 .filter("viur_src_property =", viur_src_property) \
557 .filter("src.__key__ =", key)
559 for entity in query.iter():
560 try:
561 if entity["dest"].key not in values_keys: # Relation has been removed
562 db.delete(entity.key)
563 continue
565 except KeyError: # This entry is corrupt
566 db.delete(entity.key)
568 else: # Relation: Updated
569 # Find the newest item matching this key (this has to been done this way)...
570 value = [value for value in values if value["dest"]["key"] == entity["dest"].key][0]
571 # ... and remove it from the list of values
572 values.remove(value)
573 values_keys.remove(value["dest"]["key"])
575 # Update existing database entry
576 __update_relation(entity, value)
578 # Add new database entries for the remaining values
579 for value in values:
580 __update_relation(db.Entity(db.Key("viur-relations", parent=key)), value)
582 def postDeletedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key) -> None:
583 """
584 Handle relational updates after a skeleton is deleted.
586 This method deletes all relations associated with the deleted skeleton and the referenced entities
587 for the given relational bone.
589 :param skel: The deleted SkeletonInstance.
590 :param boneName: The name of the RelationalBone in the Skeleton.
591 :param key: The key of the deleted Entity.
592 """
593 query = db.Query("viur-relations") \
594 .filter("viur_src_kind =", key.kind) \
595 .filter("viur_dest_kind =", self.kind) \
596 .filter("viur_src_property =", boneName) \
597 .filter("src.__key__ =", key)
599 db.delete([entity for entity in query.run()])
601 def isInvalid(self, key) -> None:
602 """
603 Check if the given key is invalid for this relational bone.
605 This method always returns None, as the actual validation of the key
606 is performed in other methods of the RelationalBone class.
608 :param key: The key to be checked for validity.
609 :return: None, as the actual validation is performed elsewhere.
610 """
611 return None
613 def parseSubfieldsFromClient(self):
614 """
615 Determine if the RelationalBone should parse subfields from the client.
617 This method returns True if the `using` attribute is not None, indicating
618 that this RelationalBone has a using-skeleton, and its subfields should
619 be parsed. Otherwise, it returns False.
621 :return: True if the using-skeleton is not None and subfields should be parsed, False otherwise.
622 :rtype: bool
623 """
624 return self.using is not None
626 def singleValueFromClient(self, value, skel, bone_name, client_data):
627 errors = []
629 if isinstance(value, dict):
630 dest_key = value.pop("key", None)
631 else:
632 dest_key = value
633 value = {}
635 if self.using:
636 rel = self.using()
637 if not rel.fromClient(value):
638 errors.append(
639 ReadFromClientError(
640 ReadFromClientErrorSeverity.Invalid,
641 i18n.translate("core.bones.error.incomplete", "Incomplete data"),
642 )
643 )
645 errors.extend(rel.errors)
646 else:
647 rel = None
649 # FIXME VIUR4: createRelSkelFromKey doesn't accept an instance of a RelSkel...
650 if ret := self.createRelSkelFromKey(dest_key, None): # ...therefore we need to first give None...
651 ret["rel"] = rel # ...and then assign it manually.
653 if err := self.isInvalid(ret):
654 ret = self.getEmptyValue()
655 errors.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err))
657 return ret, errors
659 elif self.consistency == RelationalConsistency.Ignore:
660 # when RelationalConsistency.Ignore is on, keep existing relations, even when they where deleted
661 for _, _, value in self.iter_bone_value(skel, bone_name):
662 if str(value["dest"]["key"]) == str(dest_key):
663 value["rel"] = rel
664 return value, errors
666 errors.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid))
667 return self.getEmptyValue(), errors
669 def _rewriteQuery(self, name, skel, dbFilter, rawFilter):
670 """
671 Rewrites a datastore query to operate on "viur-relations" instead of the original kind.
673 This method is needed to perform relational queries on n:m relations. It takes the original datastore query
674 and rewrites it to target the "viur-relations" kind. It also adjusts filters and sort orders accordingly.
676 :param str name: The name of the bone.
677 :param SkeletonInstance skel: The skeleton instance the bone is a part of.
678 :param viur.core.db.Query dbFilter: The original datastore query to be rewritten.
679 :param dict rawFilter: The raw filter applied to the original datastore query.
681 :return: A tuple containing the name, skeleton, rewritten query, and raw filter.
682 :rtype: Tuple[str, 'viur.core.skeleton.SkeletonInstance', 'viur.core.db.Query', dict]
684 :raises NotImplementedError: If the original query contains multiple filters with "IN" or "!=" operators.
685 :raises RuntimeError: If the filtering is invalid, e.g., using multiple key filters or querying
686 properties not in parentKeys.
687 """
688 origQueries = dbFilter.queries
689 if isinstance(origQueries, list):
690 raise NotImplementedError(
691 "Doing a relational Query with multiple=True and \"IN or !=\"-filters is currently unsupported!")
692 dbFilter.queries = db.QueryDefinition("viur-relations", {
693 "viur_src_kind =": skel.kindName,
694 "viur_dest_kind =": self.kind,
695 "viur_src_property =": name
697 }, orders=[], startCursor=origQueries.startCursor, endCursor=origQueries.endCursor)
698 for k, v in origQueries.filters.items(): # Merge old filters in
699 # Ensure that all non-relational-filters are in parentKeys
700 if k == db.KEY_SPECIAL_PROPERTY:
701 # We must process the key-property separately as its meaning changes as we change the datastore kind were querying
702 if isinstance(v, list) or isinstance(v, tuple):
703 logging.warning(f"Invalid filtering! Doing an relational Query on {name} with multiple key= "
704 f"filters is unsupported!")
705 raise RuntimeError()
706 if not isinstance(v, db.Key):
707 v = db.Key(v)
708 dbFilter.ancestor(v)
709 continue
710 boneName = k.split(".")[0].split(" ")[0]
711 if boneName not in self.parentKeys and boneName != "__key__":
712 logging.warning(f"Invalid filtering! {boneName} is not in parentKeys of RelationalBone {name}!")
713 raise RuntimeError()
714 dbFilter.filter(f"src.{k}", v)
715 orderList = []
716 for k, d in origQueries.orders: # Merge old sort orders in
717 if k == db.KEY_SPECIAL_PROPERTY:
718 orderList.append((f"{k}", d))
719 elif not k in self.parentKeys:
720 logging.warning(f"Invalid filtering! {k} is not in parentKeys of RelationalBone {name}!")
721 raise RuntimeError()
722 else:
723 orderList.append((f"src.{k}", d))
724 if orderList:
725 dbFilter.order(*orderList)
726 return name, skel, dbFilter, rawFilter
728 def buildDBFilter(
729 self,
730 name: str,
731 skel: "SkeletonInstance",
732 dbFilter: db.Query,
733 rawFilter: dict,
734 prefix: t.Optional[str] = None
735 ) -> db.Query:
736 """
737 Builds a datastore query by modifying the given filter based on the RelationalBone's properties.
739 This method takes a datastore query and modifies it according to the relational bone properties.
740 It also merges any related filters based on the 'refKeys' and 'using' attributes of the bone.
742 :param str name: The name of the bone.
743 :param SkeletonInstance skel: The skeleton instance the bone is a part of.
744 :param db.Query dbFilter: The original datastore query to be modified.
745 :param dict rawFilter: The raw filter applied to the original datastore query.
746 :param str prefix: Optional prefix to be applied to filter keys.
748 :return: The modified datastore query.
749 :rtype: db.Query
751 :raises RuntimeError: If the filtering is invalid, e.g., querying properties not in 'refKeys'
752 or not a bone in 'using'.
753 """
754 relSkel, _usingSkelCache = self._getSkels()
755 origQueries = dbFilter.queries
757 if origQueries is None: # This query is unsatisfiable
758 return dbFilter
760 myKeys = [x for x in rawFilter.keys() if x.startswith(f"{name}.")]
761 if len(myKeys) > 0: # We filter by some properties
762 if dbFilter.getKind() != "viur-relations" and self.multiple:
763 name, skel, dbFilter, rawFilter = self._rewriteQuery(name, skel, dbFilter, rawFilter)
765 # Merge the relational filters in
766 for myKey in myKeys:
767 value = rawFilter[myKey]
769 try:
770 unused, _type, key = myKey.split(".", 2)
771 assert _type in ["dest", "rel"]
772 except:
773 if self.using is None:
774 # This will be a "dest" query
775 _type = "dest"
776 try:
777 unused, key = myKey.split(".", 1)
778 except:
779 continue
780 else:
781 continue
783 # just use the first part of "key" to check against our refSkel / relSkel (strip any leading .something and $something)
784 checkKey = key
785 if "." in checkKey:
786 checkKey = checkKey.split(".")[0]
788 if "$" in checkKey:
789 checkKey = checkKey.split("$")[0]
791 if _type == "dest":
793 # Ensure that the relational-filter is in refKeys
794 if checkKey not in self._ref_keys:
795 logging.warning(f"Invalid filtering! {key} is not in refKeys of RelationalBone {name}!")
796 raise RuntimeError()
798 # Iterate our relSkel and let these bones write their filters in
799 for bname, bone in relSkel.items():
800 if checkKey == bname:
801 newFilter = {key: value}
802 if self.multiple:
803 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "dest.")
804 else:
805 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter,
806 prefix=(prefix or "") + name + ".dest.")
808 elif _type == "rel":
810 # Ensure that the relational-filter is in refKeys
811 if self.using is None or checkKey not in self.using():
812 logging.warning(f"Invalid filtering! {key} is not a bone in 'using' of {name}")
813 raise RuntimeError()
815 # Iterate our usingSkel and let these bones write their filters in
816 for bname, bone in self.using().items():
817 if key.startswith(bname):
818 newFilter = {key: value}
819 if self.multiple:
820 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "rel.")
821 else:
822 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter,
823 prefix=(prefix or "") + name + ".rel.")
825 if self.multiple:
826 dbFilter.setFilterHook(lambda s, filter, value: self.filterHook(name, s, filter, value))
827 dbFilter.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings))
829 elif name in rawFilter and isinstance(rawFilter[name], str) and rawFilter[name].lower() == "none":
830 dbFilter = dbFilter.filter(f"{name} =", None)
832 return dbFilter
834 def buildDBSort(
835 self,
836 name: str,
837 skel: "SkeletonInstance",
838 query: db.Query,
839 params: dict,
840 postfix: str = "",
841 ) -> t.Optional[db.Query]:
842 """
843 Builds a datastore query by modifying the given filter based on the RelationalBone's properties for sorting.
845 This method takes a datastore query and modifies its sorting behavior according to the relational bone
846 properties. It also checks if the sorting is valid based on the 'refKeys' and 'using' attributes of the bone.
848 :param name: The name of the bone.
849 :param skel: The skeleton instance the bone is a part of.
850 :param query: The original datastore query to be modified.
851 :param params: The raw filter applied to the original datastore query.
853 :return: The modified datastore query with updated sorting behavior.
854 :rtype: t.Optional[db.Query]
856 :raises RuntimeError: If the sorting is invalid, e.g., using properties not in 'refKeys'
857 or not a bone in 'using'.
858 """
859 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
860 if self.multiple and query.getKind() != "viur-relations":
861 # This query has not been rewritten (yet)
862 name, skel, query, params = self._rewriteQuery(name, skel, query, params)
864 try:
865 _, _type, param = orderby.split(".")
866 except ValueError as e:
867 logging.exception(f"Invalid layout of {orderby=}: {e}")
868 return query
869 if _type not in ("dest", "rel"):
870 logging.error("Invalid type {_type}")
871 return query
873 # Ensure that the relational-filter is in refKeys
874 if _type == "dest" and param not in self._ref_keys:
875 raise RuntimeError(f"Invalid filtering! {param!r} is not in refKeys of RelationalBone {name!r}!")
876 elif _type == "rel" and (self.using is None or param not in self.using()):
877 raise RuntimeError(f"Invalid filtering! {param!r} is not a bone in 'using' of RelationalBone {name!r}")
879 if self.multiple:
880 path = f"{_type}.{param}"
881 else:
882 path = f"{name}.{_type}.{param}"
884 order = utils.parse.sortorder(params.get("orderdir"))
885 query = query.order((path, order))
887 if self.multiple:
888 query.setFilterHook(lambda s, query, value: self.filterHook(name, s, query, value))
889 query.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings))
891 return query
893 def filterHook(self, name, query, param, value): # FIXME
894 """
895 Hook installed by buildDbFilter that rewrites filters added to the query to match the layout of the
896 viur-relations index and performs sanity checks on the query.
898 This method rewrites and validates filters added to a datastore query after the `buildDbFilter` method
899 has been executed. It ensures that the filters are compatible with the structure of the viur-relations
900 index and checks if the query is possible.
902 :param str name: The name of the bone.
903 :param db.Query query: The datastore query to be modified.
904 :param str param: The filter parameter to be checked and potentially modified.
905 :param value: The value associated with the filter parameter.
907 :return: A tuple containing the modified filter parameter and its associated value, or None if
908 the filter parameter is a key special property.
909 :rtype: Tuple[str, Any] or None
911 :raises RuntimeError: If the filtering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'.
912 """
913 if param.startswith("src.") or param.startswith("dest.") or param.startswith("viur_"):
914 # This filter is already valid in our relation
915 return param, value
916 if param.startswith(f"{name}."):
917 # We add a constrain filtering by properties of the referenced entity
918 refKey = param.replace(f"{name}.", "")
919 if " " in refKey: # Strip >, < or = params
920 refKey = refKey[:refKey.find(" ")]
921 if refKey not in self._ref_keys:
922 logging.warning(f"Invalid filtering! {refKey} is not in refKeys of RelationalBone {name}!")
923 raise RuntimeError()
924 if self.multiple:
925 return param.replace(f"{name}.", "dest."), value
926 else:
927 return param, value
928 else:
929 # We filter by a property of this entity
930 if not self.multiple:
931 # Not relational, not multiple - nothing to do here
932 return param, value
933 # Prepend "src."
934 srcKey = param
935 if " " in srcKey:
936 srcKey = srcKey[: srcKey.find(" ")] # Cut <, >, and =
937 if srcKey == db.KEY_SPECIAL_PROPERTY: # Rewrite key= filter as its meaning has changed
938 if isinstance(value, list) or isinstance(value, tuple):
939 logging.warning(f"Invalid filtering! Doing an relational Query on {name} "
940 f"with multiple key= filters is unsupported!")
941 raise RuntimeError()
942 if not isinstance(value, db.Key):
943 value = db.Key(value)
944 query.ancestor(value)
945 return None
946 if srcKey not in self.parentKeys:
947 logging.warning(f"Invalid filtering! {srcKey} is not in parentKeys of RelationalBone {name}!")
948 raise RuntimeError()
949 return f"src.{param}", value
951 def orderHook(self, name: str, query: db.Query, orderings): # FIXME
952 """
953 Hook installed by buildDbFilter that rewrites orderings added to the query to match the layout of the
954 viur-relations index and performs sanity checks on the query.
956 This method rewrites and validates orderings added to a datastore query after the `buildDbFilter` method
957 has been executed. It ensures that the orderings are compatible with the structure of the viur-relations
958 index and checks if the query is possible.
960 :param name: The name of the bone.
961 :param query: The datastore query to be modified.
962 :param orderings: A list or tuple of orderings to be checked and potentially modified.
963 :type orderings: List[Union[str, Tuple[str, db.SortOrder]]] or Tuple[Union[str, Tuple[str, db.SortOrder]]]
965 :return: A list of modified orderings that are compatible with the viur-relations index.
966 :rtype: List[Union[str, Tuple[str, db.SortOrder]]]
968 :raises RuntimeError: If the ordering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'.
969 """
970 res = []
971 if not isinstance(orderings, list) and not isinstance(orderings, tuple):
972 orderings = [orderings]
973 for order in orderings:
974 if isinstance(order, tuple):
975 orderKey = order[0]
976 else:
977 orderKey = order
978 if orderKey.startswith("dest.") or orderKey.startswith("rel.") or orderKey.startswith("src."):
979 # This is already valid for our relational index
980 res.append(order)
981 continue
982 if orderKey.startswith(f"{name}."):
983 k = orderKey.replace(f"{name}.", "")
984 if k not in self._ref_keys:
985 logging.warning(f"Invalid ordering! {k} is not in refKeys of RelationalBone {name}!")
986 raise RuntimeError()
987 if not self.multiple:
988 res.append(order)
989 else:
990 if isinstance(order, tuple):
991 res.append((f"dest.{k}", order[1]))
992 else:
993 res.append(f"dest.{k}")
994 else:
995 if not self.multiple:
996 # Nothing to do here
997 res.append(order)
998 continue
999 else:
1000 if orderKey not in self.parentKeys:
1001 logging.warning(
1002 f"Invalid ordering! {orderKey} is not in parentKeys of RelationalBone {name}!")
1003 raise RuntimeError()
1004 if isinstance(order, tuple):
1005 res.append((f"src.{orderKey}", order[1]))
1006 else:
1007 res.append(f"src.{orderKey}")
1008 return res
1010 def refresh(self, skel: "SkeletonInstance", name: str) -> None:
1011 """
1012 Refreshes all values that might be cached from other entities in the provided skeleton.
1014 This method updates the cached values for relational bones in the provided skeleton, which
1015 correspond to other entities. It fetches the updated values for the relational bone's
1016 reference keys and replaces the cached values in the skeleton with the fetched values.
1018 :param SkeletonInstance skel: The skeleton containing the bone to be refreshed.
1019 :param str boneName: The name of the bone to be refreshed.
1020 """
1021 if not skel[name] or self.updateLevel == RelationalUpdateLevel.OnValueAssignment:
1022 return
1024 for _, _, value in self.iter_bone_value(skel, name):
1025 if value and value["dest"]:
1026 try:
1027 target_skel = value["dest"].read()
1028 except ValueError:
1030 # Handle removed reference according to the RelationalConsistency settings
1031 match self.consistency:
1032 case RelationalConsistency.CascadeDeletion:
1033 logging.info(
1034 f"{name}: "
1035 f"Cascade deleting {skel["key"]!r} ({skel["name"]!r}) "
1036 f"due removal of relation {value["dest"]["key"]!r} ({value["dest"]["name"]!r})"
1037 )
1038 skel._cascade_deletion = True
1039 break
1041 case RelationalConsistency.SetNull:
1042 logging.info(
1043 f"{name}: "
1044 f"Emptying relation {skel["key"]!r} ({skel["name"]!r}) "
1045 f"due removal of {value["dest"]["key"]!r} ({value["dest"]["name"]!r})"
1046 )
1047 value.clear()
1049 case _:
1050 logging.info(
1051 f"{name}: "
1052 f"Relation from {skel["key"]!r} ({skel["name"]!r}) "
1053 f"refers to deleted {value["dest"]["key"]!r} ({value["dest"]["name"]!r}), skipping"
1054 )
1056 continue
1058 # Copy over the refKey values
1059 for key in self.refKeys:
1060 value["dest"][key] = target_skel[key]
1062 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
1063 """
1064 Retrieves the search tags for the given RelationalBone in the provided skeleton.
1066 This method iterates over the values of the relational bone and gathers search tags from the
1067 reference and using skeletons. It combines all the tags into a set to avoid duplicates.
1069 :param skel: The skeleton containing the bone for which search tags are to be retrieved.
1070 :param name: The name of the bone for which search tags are to be retrieved.
1072 :return: A set of search tags for the specified relational bone.
1073 """
1074 result = set()
1076 def get_values(skel_, values_cache):
1077 for key, bone in skel_.items():
1078 if not bone.searchable:
1079 continue
1080 for tag in bone.getSearchTags(values_cache, key):
1081 result.add(tag)
1083 ref_skel_cache, using_skel_cache = self._getSkels()
1084 for idx, lang, value in self.iter_bone_value(skel, name):
1085 if value is None:
1086 continue
1087 if value["dest"]:
1088 get_values(ref_skel_cache, value["dest"])
1089 if value["rel"]:
1090 get_values(using_skel_cache, value["rel"])
1092 return result
1094 def createRelSkelFromKey(self, key: db.Key, rel: dict | None = None) -> RelDict | None:
1095 if rel_skel := self.relskels_from_keys([(key, rel)]):
1096 return rel_skel[0]
1097 return None
1099 def relskels_from_keys(self, key_rel_list: list[tuple[db.Key, dict | None]]) -> list[RelDict]:
1100 """
1101 Creates a list of RelSkel instances valid for this bone from the given database key.
1103 This method retrieves the entity corresponding to the provided key from the database, unserializes it
1104 into a reference skeleton, and returns a dictionary containing the reference skeleton and optional
1105 relation data.
1107 :param key_rel_list: List of tuples with the first value in the tuple is the
1108 key and the second is and RelSkel or None
1110 :return: A dictionary containing a reference skeleton and optional relation data.
1111 """
1113 if not all(db_objs := db.get([db.key_helper(value[0], self.kind, adjust_kind=True) for value in key_rel_list])):
1114 return [] # return emtpy data when not all data is found
1116 res_rel_skels = []
1118 for (key, rel), db_obj in zip(key_rel_list, db_objs):
1119 dest_skel = self._refSkelCache()
1120 dest_skel.unserialize(db_obj)
1121 for bone_name in dest_skel:
1122 # Unserialize all bones from refKeys, then drop dbEntity - otherwise all properties will be copied
1123 _ = dest_skel[bone_name]
1124 dest_skel.dbEntity = None
1125 res_rel_skels.append(
1126 {
1127 "dest": dest_skel,
1128 "rel": rel or None
1129 }
1130 )
1132 return res_rel_skels
1134 def setBoneValue(
1135 self,
1136 skel: "SkeletonInstance",
1137 boneName: str,
1138 value: t.Any,
1139 append: bool,
1140 language: None | str = None
1141 ) -> bool:
1142 """
1143 Sets the value of the specified bone in the given skeleton. Sanity checks are performed to ensure the
1144 value is valid. If the value is invalid, no modifications are made.
1146 :param skel: Dictionary with the current values from the skeleton we belong to.
1147 :param boneName: The name of the bone to be modified.
1148 :param value: The value to be assigned. The type depends on the bone type.
1149 :param append: If true, the given value is appended to the values of the bone instead of replacing it.
1150 Only supported on bones with multiple=True.
1151 :param language: Set/append for a specific language (optional). Required if the bone
1152 supports languages.
1154 :return: True if the operation succeeded, False otherwise.
1155 """
1156 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported"
1157 assert not append or self.multiple, "Can't append - bone is not multiple"
1159 def tuple_check(in_value: tuple | None = None) -> bool:
1160 """
1161 Return True if the given value is a tuple with a length of two.
1162 In addition, the first field in the tuple must be a str,int or db.key.
1163 Furthermore, the second field must be a skeletonInstanceClassRef.
1164 """
1165 return (isinstance(in_value, tuple) and len(in_value) == 2
1166 and isinstance(in_value[0], (str, int, db.Key))
1167 and isinstance(in_value[1], self._skeletonInstanceClassRef))
1169 if not self.multiple and not self.using:
1170 if not isinstance(value, (str, int, db.Key)):
1171 raise ValueError(f"You must supply exactly one Database-Key str or int to {boneName}")
1172 parsed_value = (value, None)
1173 elif not self.multiple and self.using:
1174 if not tuple_check(value):
1175 raise ValueError(f"You must supply a tuple of (Database-Key, relSkel) to {boneName}")
1176 parsed_value = value
1177 elif self.multiple and not self.using:
1178 if not isinstance(value, (str, int, db.Key)) and not (isinstance(value, list)) \
1179 and all([isinstance(val, (str, int, db.Key)) for val in value]):
1180 raise ValueError(f"You must supply a Database-Key or a list hereof to {boneName}")
1181 if isinstance(value, list):
1182 parsed_value = [(key, None) for key in value]
1183 else:
1184 parsed_value = [(value, None)]
1185 else: # which means (self.multiple and self.using)
1186 if not tuple_check(value) and (not isinstance(value, list) or not all(tuple_check(val) for val in value)):
1187 raise ValueError(f"You must supply (db.Key, RelSkel) or a list hereof to {boneName}")
1188 if isinstance(value, list):
1189 parsed_value = value
1190 else:
1191 parsed_value = [value]
1193 if boneName not in skel:
1194 skel[boneName] = {}
1195 if language:
1196 skel[boneName].setdefault(language, [])
1198 if self.multiple:
1199 rel_list = self.relskels_from_keys(parsed_value)
1200 if append:
1201 if language:
1202 skel[boneName][language].extend(rel_list)
1203 else:
1204 if not isinstance(skel[boneName], list):
1205 skel[boneName] = []
1206 skel[boneName].extend(rel_list)
1207 else:
1208 if language:
1209 skel[boneName][language] = rel_list
1210 else:
1211 skel[boneName] = rel_list
1212 else:
1213 if not (rel := self.createRelSkelFromKey(parsed_value[0], parsed_value[1])):
1214 return False
1215 if language:
1216 skel[boneName][language] = rel
1217 else:
1218 skel[boneName] = rel
1219 return True
1221 def getReferencedBlobs(self, skel: "SkeletonInstance", name: str) -> set[str]:
1222 """
1223 Retrieves the set of referenced blobs from the specified bone in the given skeleton instance.
1225 :param SkeletonInstance skel: The skeleton instance to extract the referenced blobs from.
1226 :param str name: The name of the bone to retrieve the referenced blobs from.
1228 :return: A set containing the unique blob keys referenced by the specified bone.
1229 :rtype: Set[str]
1230 """
1231 result = set()
1233 for idx, lang, value in self.iter_bone_value(skel, name):
1234 if not value:
1235 continue
1237 for key, bone in value["dest"].items():
1238 result.update(bone.getReferencedBlobs(value["dest"], key))
1240 if value["rel"]:
1241 for key, bone in value["rel"].items():
1242 result.update(bone.getReferencedBlobs(value["rel"], key))
1244 return result
1246 def getUniquePropertyIndexValues(self, valuesCache: dict, name: str) -> list[str]:
1247 """
1248 Generates unique property index values for the RelationalBone based on the referenced keys.
1249 Can be overridden if different behavior is required (e.g., examining values from `prop:usingSkel`).
1251 :param dict valuesCache: The cache containing the current values of the bone.
1252 :param str name: The name of the bone for which to generate unique property index values.
1254 :return: A list containing the unique property index values for the specified bone.
1255 :rtype: List[str]
1256 """
1257 value = valuesCache.get(name)
1258 if not value: # We don't have a value to lock
1259 return []
1260 if isinstance(value, dict):
1261 return self._hashValueForUniquePropertyIndex(value["dest"]["key"])
1262 elif isinstance(value, list):
1263 return self._hashValueForUniquePropertyIndex([entry["dest"]["key"] for entry in value if entry])
1265 def structure(self) -> dict:
1266 return super().structure() | {
1267 "type": f"{self.type}.{self.kind}",
1268 "module": self.module,
1269 "format": self.format,
1270 "using": self.using().structure() if self.using else None,
1271 "relskel": self._refSkelCache().structure(),
1272 }
1274 def _atomic_dump(self, value: dict[str, "SkeletonInstance"]) -> dict | None:
1275 if isinstance(value, dict):
1276 return {
1277 "dest": value["dest"].dump(),
1278 "rel": value["rel"].dump() if value["rel"] else None,
1279 }