Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/relational.py: 6%
581 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 07:59 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-27 07:59 +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 typing as t
9import warnings
10from itertools import chain
11from time import time
13from viur.core import db, utils
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"]
53class RelationalBone(BaseBone):
54 """
55 The base class for all relational bones in the ViUR framework.
56 RelationalBone is used to create and manage relationships between database entities. This class provides
57 basic functionality and attributes that can be extended by other specialized relational bone classes,
58 such as N1Relation, N2NRelation, and Hierarchy.
59 This implementation prioritizes read efficiency and is suitable for situations where data is read more
60 frequently than written. However, it comes with increased write operations when writing an entity to the
61 database. The additional write operations depend on the type of relationship: multiple=True RelationalBones
62 or 1:N relations.
64 The implementation does not instantly update relational information when a skeleton is updated; instead,
65 it triggers a deferred task to update references. This may result in outdated data until the task is completed.
67 Note: Filtering a list by relational properties uses the outdated data.
69 Example:
70 - Entity A references Entity B.
71 - Both have a property "name."
72 - Entity B is updated (its name changes).
73 - Entity A's RelationalBone values still show Entity B's old name.
75 It is not recommended for cases where data is read less frequently than written, as there is no
76 write-efficient method available yet.
78 :param kind: KindName of the referenced property.
79 :param module: Name of the module which should be used to select entities of kind "kind". If not set,
80 the value of "kind" will be used (the kindName must match the moduleName)
81 :param refKeys: A list of properties to include from the referenced property. These properties will be
82 available in the template without having to fetch the referenced property. Filtering is also only possible
83 by properties named here!
84 :param parentKeys: A list of properties from the current skeleton to include. If mixing filtering by
85 relational properties and properties of the class itself, these must be named here.
86 :param multiple: If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation).
87 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this
88 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use
89 a ```class MultipleConstraints``` instead.
91 :param format:
92 Hint for the frontend how to display such an relation. This is now a python expression
93 evaluated by safeeval on the client side. The following values will be passed to the expression:
95 - value
96 The value to display. This will be always a dict (= a single value) - even if the relation is
97 multiple (in which case the expression is evaluated once per referenced entity)
99 - structure
100 The structure of the skeleton this bone is part of as a dictionary as it's transferred to the
101 fronted by the admin/vi-render.
103 - language
104 The current language used by the frontend in ISO2 code (eg. "de"). This will be always set, even if
105 the project did not enable the multi-language feature.
107 :param updateLevel:
108 Indicates how ViUR should keep the values copied from the referenced entity into our
109 entity up to date. If this bone is indexed, it's recommended to leave this set to
110 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results.
112 :param RelationalUpdateLevel.Always:
114 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this
115 entity also (after a small delay, as these updates happen deferred)
117 :param RelationalUpdateLevel.OnRebuildSearchIndex:
119 update refKeys only on rebuildSearchIndex. If the referenced entity changes, this entity will
120 remain unchanged (this RelationalBone will still have the old values), but it can be updated
121 by either by editing this entity or running a rebuildSearchIndex over our kind.
123 :param RelationalUpdateLevel.OnValueAssignment:
125 update only if explicitly set. A rebuildSearchIndex will not trigger an update, this bone has to be
126 explicitly modified (in an edit) to have it's values updated
128 :param consistency:
129 Can be used to implement SQL-like constrains on this relation. Possible values are:
130 - RelationalConsistency.Ignore
131 If the referenced entity gets deleted, this bone will not change. It will still reflect the old
132 values. This will be even be preserved over edits, however if that referenced value is once
133 deleted by the user (assigning a different value to this bone or removing that value of the list
134 of relations if we are multiple) there's no way of restoring it
136 - RelationalConsistency.PreventDeletion
137 Will prevent deleting the referenced entity as long as it's selected in this bone (calling
138 skel.delete() on the referenced entity will raise errors.Locked). It's still (technically)
139 possible to remove the underlying datastore entity using db.Delete manually, but this *must not*
140 be used on a skeleton object as it will leave a whole bunch of references in a stale state.
142 - RelationalConsistency.SetNull
143 Will set this bone to None (or remove the relation from the list in
144 case we are multiple) when the referenced entity is deleted.
146 - RelationalConsistency.CascadeDeletion:
147 (Dangerous!) Will delete this entity when the referenced entity is deleted. Warning: Unlike
148 relational updates this will cascade. If Entity A references B with CascadeDeletion set, and
149 B references C also with CascadeDeletion; if C gets deleted, both B and A will be deleted as well.
151 """
152 type = "relational"
153 kind = None
155 def __init__(
156 self,
157 *,
158 consistency: RelationalConsistency = RelationalConsistency.Ignore,
159 format: str = "$(dest.name)",
160 kind: str = None,
161 module: t.Optional[str] = None,
162 parentKeys: t.Optional[t.Iterable[str]] = {"name"},
163 refKeys: t.Optional[t.Iterable[str]] = {"name"},
164 updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always,
165 using: t.Optional["RelSkel"] = None,
166 **kwargs
167 ):
168 """
169 Initialize a new RelationalBone.
171 :param kind:
172 KindName of the referenced property.
173 :param module:
174 Name of the module which should be used to select entities of kind "type". If not set,
175 the value of "type" will be used (the kindName must match the moduleName)
176 :param refKeys:
177 An iterable of properties to include from the referenced property. These properties will be
178 available in the template without having to fetch the referenced property. Filtering is also only
179 possible by properties named here!
180 :param parentKeys:
181 An iterable of properties from the current skeleton to include. If mixing filtering by
182 relational properties and properties of the class itself, these must be named here.
183 :param multiple:
184 If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation).
185 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this
186 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use
187 a :class:MultipleConstraints instead.
189 :param format: Hint for the frontend how to display such an relation. This is now a python expression
190 evaluated by safeeval on the client side. The following values will be passed to the expression
192 :param value:
193 The value to display. This will be always a dict (= a single value) - even if the
194 relation is multiple (in which case the expression is evaluated once per referenced entity)
195 :param structure:
196 The structure of the skeleton this bone is part of as a dictionary as it's
197 transferred to the fronted by the admin/vi-render.
198 :param language:
199 The current language used by the frontend in ISO2 code (eg. "de"). This will be
200 always set, even if the project did not enable the multi-language feature.
202 :param updateLevel:
203 Indicates how ViUR should keep the values copied from the referenced entity into our
204 entity up to date. If this bone is indexed, it's recommended to leave this set to
205 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results.
207 :param RelationalUpdateLevel.Always:
208 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this
209 entity also (after a small delay, as these updates happen deferred)
210 :param RelationalUpdateLevel.OnRebuildSearchIndex:
211 update refKeys only on rebuildSearchIndex. If the
212 referenced entity changes, this entity will remain unchanged
213 (this RelationalBone will still have the old values), but it can be updated
214 by either by editing this entity or running a rebuildSearchIndex over our kind.
215 :param RelationalUpdateLevel.OnValueAssignment:
216 update only if explicitly set. A rebuildSearchIndex will not trigger
217 an update, this bone has to be explicitly modified (in an edit) to have it's values updated
219 :param consistency:
220 Can be used to implement SQL-like constrains on this relation.
222 :param RelationalConsistency.Ignore:
223 If the referenced entity gets deleted, this bone will not change. It
224 will still reflect the old values. This will be even be preserved over edits, however if that
225 referenced value is once deleted by the user (assigning a different value to this bone or
226 removing that value of the list of relations if we are multiple) there's no way of restoring it
228 :param RelationalConsistency.PreventDeletion:
229 Will prevent deleting the referenced entity as long as it's
230 selected in this bone (calling skel.delete() on the referenced entity will raise errors.Locked).
231 It's still (technically) possible to remove the underlying datastore entity using db.Delete
232 manually, but this *must not* be used on a skeleton object as it will leave a whole bunch of
233 references in a stale state.
235 :param RelationalConsistency.SetNull:
236 Will set this bone to None (or remove the relation from the list in
237 case we are multiple) when the referenced entity is deleted.
239 :param RelationalConsistency.CascadeDeletion:
240 (Dangerous!) Will delete this entity when the referenced entity
241 is deleted. Warning: Unlike relational updates this will cascade. If Entity A references B with
242 CascadeDeletion set, and B references C also with CascadeDeletion; if C gets deleted, both B and
243 A will be deleted as well.
244 """
245 super().__init__(**kwargs)
246 self.format = format
248 if kind:
249 self.kind = kind
251 if module:
252 self.module = module
253 elif self.kind:
254 self.module = self.kind
256 if self.kind is None or self.module is None:
257 raise NotImplementedError("'kind' and 'module' of RelationalBone must not be None")
259 # Referenced keys
260 self.refKeys = {"key"}
261 if refKeys:
262 self.refKeys |= set(refKeys)
264 # Parent keys
265 self.parentKeys = {"key"}
266 if parentKeys:
267 self.parentKeys |= set(parentKeys)
269 self.using = using
271 # FIXME: Remove in VIUR4!!
272 if isinstance(updateLevel, int):
273 msg = f"parameter updateLevel={updateLevel} in RelationalBone is deprecated. " \
274 f"Please use the RelationalUpdateLevel enum instead"
275 logging.warning(msg, stacklevel=3)
276 warnings.warn(msg, DeprecationWarning, stacklevel=3)
278 assert 0 <= updateLevel < 3
279 for n in RelationalUpdateLevel:
280 if updateLevel == n.value:
281 updateLevel = n
283 self.updateLevel = updateLevel
284 self.consistency = consistency
286 if getSystemInitialized():
287 from viur.core.skeleton import RefSkel, SkeletonInstance
288 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys)
289 self._skeletonInstanceClassRef = SkeletonInstance
290 self._ref_keys = set(self._refSkelCache.__boneMap__.keys())
292 def setSystemInitialized(self):
293 """
294 Set the system initialized for the current class and cache the RefSkel and SkeletonInstance.
296 This method calls the superclass's setSystemInitialized method and initializes the RefSkel
297 and SkeletonInstance classes. The RefSkel is created from the current kind and refKeys,
298 while the SkeletonInstance class is stored as a reference.
300 :rtype: None
301 """
302 super().setSystemInitialized()
303 from viur.core.skeleton import RefSkel, SkeletonInstance
305 try:
306 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys)
307 except AssertionError:
308 raise NotImplementedError(
309 f"Skeleton {self.skel_cls!r} {self.__class__.__name__} {self.name!r}: Kind {self.kind!r} unknown"
310 )
312 self._skeletonInstanceClassRef = SkeletonInstance
313 self._ref_keys = set(self._refSkelCache.__boneMap__.keys())
315 def _getSkels(self):
316 """
317 Retrieve the reference skeleton and the 'using' skeleton for the current RelationalBone instance.
319 This method returns a tuple containing the reference skeleton (RefSkel) and the 'using' skeleton
320 (UsingSkel) associated with the current RelationalBone instance. The 'using' skeleton is only
321 retrieved if the 'using' attribute is defined.
323 :return: A tuple containing the reference skeleton and the 'using' skeleton.
324 :rtype: tuple
325 """
326 refSkel = self._refSkelCache()
327 usingSkel = self.using() if self.using else None
328 return refSkel, usingSkel
330 def singleValueUnserialize(self, val):
331 """
332 Restore a value, including the Rel- and Using-Skeleton, from the serialized data read from the datastore.
334 This method takes a serialized value from the datastore, deserializes it, and returns the corresponding
335 value with restored RelSkel and Using-Skel. It also handles ViUR 2 compatibility by handling string values.
337 :param val: A JSON-encoded datastore property.
338 :type val: str or dict
339 :return: The deserialized value with restored RelSkel and Using-Skel.
340 :rtype: dict
342 :raises AssertionError: If the deserialized value is not a dictionary.
343 """
345 def fixFromDictToEntry(inDict):
346 """
347 Convert a dictionary to an entry with properly restored keys and values.
349 :param dict inDict: The input dictionary to convert.
350 : return: The resulting entry.
351 :rtype: dict
352 """
353 if not isinstance(inDict, dict):
354 return None
355 res = {}
356 if "dest" in inDict:
357 res["dest"] = db.Entity()
358 for k, v in inDict["dest"].items():
359 res["dest"][k] = v
360 if "key" in res["dest"]:
361 res["dest"].key = utils.normalizeKey(db.Key.from_legacy_urlsafe(res["dest"]["key"]))
362 if "rel" in inDict and inDict["rel"]:
363 res["rel"] = db.Entity()
364 for k, v in inDict["rel"].items():
365 res["rel"][k] = v
366 else:
367 res["rel"] = None
368 return res
370 if isinstance(val, str): # ViUR2 compatibility
371 try:
372 value = json.loads(val)
373 if isinstance(value, list):
374 value = [fixFromDictToEntry(x) for x in value]
375 elif isinstance(value, dict):
376 value = fixFromDictToEntry(value)
377 else:
378 value = None
379 except ValueError:
380 value = None
381 else:
382 value = val
383 if not value:
384 return None
385 elif isinstance(value, list) and value:
386 value = value[0]
387 assert isinstance(value, dict), f"Read something from the datastore thats not a dict: {type(value)}"
388 if "dest" not in value:
389 return None
390 relSkel, usingSkel = self._getSkels()
391 relSkel.unserialize(value["dest"])
392 if self.using is not None:
393 usingSkel.unserialize(value["rel"] or db.Entity())
394 usingData = usingSkel
395 else:
396 usingData = None
397 return {"dest": relSkel, "rel": usingData}
399 def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
400 """
401 Serialize the RelationalBone for the given skeleton, updating relational locks as necessary.
403 This method serializes the RelationalBone values for a given skeleton and stores the serialized
404 values in the skeleton's dbEntity. It also updates the relational locks, adding new locks and
405 removing old ones as needed.
407 :param SkeletonInstance skel: The skeleton instance containing the values to be serialized.
408 :param str name: The name of the bone to be serialized.
409 :param bool parentIndexed: A flag indicating whether the parent bone is indexed.
410 :return: True if the serialization is successful, False otherwise.
411 :rtype: bool
413 :raises AssertionError: If a programming error is detected.
414 """
416 def serialize_dest_rel(in_value: dict | None = None) -> (dict | None, dict | None):
417 if not in_value:
418 return None, None
419 if dest_val := in_value.get("dest"):
420 ref_data_serialized = dest_val.serialize(parentIndexed=indexed)
421 else:
422 ref_data_serialized = None
423 if rel_data := in_value.get("rel"):
424 using_data_serialized = rel_data.serialize(parentIndexed=indexed)
425 else:
426 using_data_serialized = None
428 return using_data_serialized, ref_data_serialized
431 super().serialize(skel, name, parentIndexed)
433 # Clean old properties from entry (prevent name collision)
434 for key in tuple(skel.dbEntity.keys()):
435 if key.startswith(f"{name}."):
436 del skel.dbEntity[key]
438 indexed = self.indexed and parentIndexed
440 if not (new_vals := skel.accessedValues.get(name)):
441 return False
442 elif self.languages:
443 res = {"_viurLanguageWrapper_": True}
444 for language in self.languages:
445 if language in new_vals:
446 if self.multiple:
447 res[language] = []
448 for val in new_vals[language]:
449 using_data, ref_data = serialize_dest_rel(val)
450 res[language].append({"rel": using_data, "dest": ref_data})
451 else:
452 if (val := new_vals[language]) and val["dest"]:
453 using_data, ref_data = serialize_dest_rel(val)
454 res[language] = {"rel": using_data, "dest": ref_data}
455 elif self.multiple:
456 res = []
457 for val in new_vals:
458 using_data, ref_data = serialize_dest_rel(val)
459 res.append({"rel": using_data, "dest": ref_data})
460 else:
461 using_data, ref_data = serialize_dest_rel(new_vals)
462 res = {"rel": using_data, "dest": ref_data}
463 skel.dbEntity[name] = res
465 # Ensure our indexed flag is up2date
466 if indexed and name in skel.dbEntity.exclude_from_indexes:
467 skel.dbEntity.exclude_from_indexes.discard(name)
468 elif not indexed and name not in skel.dbEntity.exclude_from_indexes:
469 skel.dbEntity.exclude_from_indexes.add(name)
471 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4
472 skel.dbEntity.pop(f"{name}_outgoingRelationalLocks", None)
474 return True
476 def _get_single_destinct_hash(self, value):
477 parts = [value["dest"]["key"]]
479 if self.using:
480 for name, bone in self.using.__boneMap__.items():
481 parts.append(bone._get_destinct_hash(value["rel"][name]))
483 return tuple(parts)
485 def postSavedHandler(self, skel, boneName, key) -> None:
486 """
487 Handle relational updates after a skeleton is saved.
489 This method updates, removes, or adds relations between the saved skeleton and the referenced entities.
490 It also takes care of updating the relational properties and consistency levels.
492 :param skel: The saved skeleton instance.
493 :param boneName: The name of the relational bone.
494 :param key: The key of the saved skeleton instance.
495 """
496 if key is None: # RelSkel container (e.g. RecordBone) has no key, it's covered by it's parent
497 return
498 if not skel[boneName]:
499 values = []
500 elif self.multiple and self.languages:
501 values = chain(*skel[boneName].values())
502 elif self.languages:
503 values = list(skel[boneName].values())
504 elif self.multiple:
505 values = skel[boneName]
506 else:
507 values = [skel[boneName]]
508 values = [x for x in values if x is not None]
509 parentValues = db.Entity()
510 srcEntity = skel.dbEntity
511 parentValues.key = srcEntity.key
512 for boneKey in (self.parentKeys or []):
513 if boneKey == "key": # this is a relcit from viur2, as the key is encoded in the embedded entity
514 continue
515 parentValues[boneKey] = srcEntity.get(boneKey)
516 dbVals = db.Query("viur-relations")
517 dbVals.filter("viur_src_kind =", skel.kindName)
518 dbVals.filter("viur_dest_kind =", self.kind)
519 dbVals.filter("viur_src_property =", boneName)
520 dbVals.filter("src.__key__ =", key)
521 for dbObj in dbVals.iter():
522 try:
523 if not dbObj["dest"].key in [x["dest"]["key"] for x in values]: # Relation has been removed
524 db.Delete(dbObj.key)
525 continue
526 except: # This entry is corrupt
527 db.Delete(dbObj.key)
528 else: # Relation: Updated
529 data = [x for x in values if x["dest"]["key"] == dbObj["dest"].key][0]
530 # Write our (updated) values in
531 refSkel = data["dest"]
532 dbObj["dest"] = refSkel.serialize(parentIndexed=True)
533 dbObj["src"] = parentValues
534 if self.using is not None:
535 usingSkel = data["rel"]
536 dbObj["rel"] = usingSkel.serialize(parentIndexed=True)
537 dbObj["viur_delayed_update_tag"] = time()
538 dbObj["viur_relational_updateLevel"] = self.updateLevel.value
539 dbObj["viur_relational_consistency"] = self.consistency.value
540 dbObj["viur_foreign_keys"] = list(self.refKeys)
541 dbObj["viurTags"] = srcEntity.get("viurTags") # Copy tags over so we can still use our searchengine
542 db.Put(dbObj)
543 values.remove(data)
544 # Add any new Relation
545 for val in values:
546 dbObj = db.Entity(db.Key("viur-relations", parent=key))
547 refSkel = val["dest"]
548 dbObj["dest"] = refSkel.serialize(parentIndexed=True)
549 dbObj["src"] = parentValues
550 if self.using is not None:
551 usingSkel = val["rel"]
552 dbObj["rel"] = usingSkel.serialize(parentIndexed=True)
553 dbObj["viur_delayed_update_tag"] = time()
554 dbObj["viur_src_kind"] = skel.kindName # The kind of the entry referencing
555 dbObj["viur_src_property"] = boneName # The key of the bone referencing
556 dbObj["viur_dest_kind"] = self.kind
557 dbObj["viur_relational_updateLevel"] = self.updateLevel.value
558 dbObj["viur_relational_consistency"] = self.consistency.value
559 dbObj["viur_foreign_keys"] = list(self._ref_keys)
560 db.Put(dbObj)
562 def postDeletedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key) -> None:
563 """
564 Handle relational updates after a skeleton is deleted.
566 This method deletes all relations associated with the deleted skeleton and the referenced entities
567 for the given relational bone.
569 :param skel: The deleted SkeletonInstance.
570 :param boneName: The name of the RelationalBone in the Skeleton.
571 :param key: The key of the deleted Entity.
572 """
573 query = db.Query("viur-relations")
574 query.filter("viur_src_kind =", skel.kindName)
575 query.filter("viur_dest_kind =", self.kind)
576 query.filter("viur_src_property =", boneName)
577 query.filter("src.__key__ =", key)
578 db.Delete([entity for entity in query.run()])
580 def isInvalid(self, key) -> None:
581 """
582 Check if the given key is invalid for this relational bone.
584 This method always returns None, as the actual validation of the key
585 is performed in other methods of the RelationalBone class.
587 :param key: The key to be checked for validity.
588 :return: None, as the actual validation is performed elsewhere.
589 """
590 return None
592 def parseSubfieldsFromClient(self):
593 """
594 Determine if the RelationalBone should parse subfields from the client.
596 This method returns True if the `using` attribute is not None, indicating
597 that this RelationalBone has a using-skeleton, and its subfields should
598 be parsed. Otherwise, it returns False.
600 :return: True if the using-skeleton is not None and subfields should be parsed, False otherwise.
601 :rtype: bool
602 """
603 return self.using is not None
605 def singleValueFromClient(self, value, skel, bone_name, client_data):
606 oldValues = skel[bone_name]
608 def restoreSkels(key, usingData, index=None):
609 refSkel, usingSkel = self._getSkels()
610 isEntryFromBackup = False # If the referenced entry has been deleted, restore information from backup
611 entry = None
612 dbKey = None
613 errors = []
614 try:
615 dbKey = db.keyHelper(key, self.kind)
616 entry = db.Get(dbKey)
617 assert entry
618 except: # Invalid key or something like that
619 logging.info(f"Invalid reference key >{key}< detected on bone '{bone_name}'")
620 if isinstance(oldValues, dict):
621 if oldValues["dest"]["key"] == dbKey:
622 entry = oldValues["dest"]
623 isEntryFromBackup = True
624 elif isinstance(oldValues, list):
625 for dbVal in oldValues:
626 if dbVal["dest"]["key"] == dbKey:
627 entry = dbVal["dest"]
628 isEntryFromBackup = True
629 if isEntryFromBackup:
630 refSkel = entry
631 elif entry:
632 refSkel.dbEntity = entry
633 for k in refSkel.keys():
634 # Unserialize all bones from refKeys, then drop dbEntity - otherwise all properties will be copied
635 _ = refSkel[k]
636 refSkel.dbEntity = None
637 else:
638 if index:
639 errors.append(
640 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid value submitted",
641 [str(index)]))
642 else:
643 errors.append(
644 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid value submitted"))
645 return None, None, errors # We could not parse this
646 if usingSkel:
647 if not usingSkel.fromClient(usingData):
648 usingSkel.errors.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Incomplete data"))
649 if index:
650 for error in usingSkel.errors:
651 error.fieldPath.insert(0, str(index))
652 errors.extend(usingSkel.errors)
653 return refSkel, usingSkel, errors
655 if self.using and isinstance(value, dict):
656 usingData = value
657 destKey = usingData["key"]
658 del usingData["key"]
659 else:
660 destKey = value
661 usingData = None
663 destKey = str(destKey)
665 refSkel, usingSkel, errors = restoreSkels(destKey, usingData)
666 if refSkel:
667 resVal = {"dest": refSkel, "rel": usingSkel}
668 err = self.isInvalid(resVal)
669 if err:
670 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
671 return resVal, errors
672 else:
673 return self.getEmptyValue(), errors
675 def _rewriteQuery(self, name, skel, dbFilter, rawFilter):
676 """
677 Rewrites a datastore query to operate on "viur-relations" instead of the original kind.
679 This method is needed to perform relational queries on n:m relations. It takes the original datastore query
680 and rewrites it to target the "viur-relations" kind. It also adjusts filters and sort orders accordingly.
682 :param str name: The name of the bone.
683 :param SkeletonInstance skel: The skeleton instance the bone is a part of.
684 :param viur.core.db.Query dbFilter: The original datastore query to be rewritten.
685 :param dict rawFilter: The raw filter applied to the original datastore query.
687 :return: A tuple containing the name, skeleton, rewritten query, and raw filter.
688 :rtype: Tuple[str, 'viur.core.skeleton.SkeletonInstance', 'viur.core.db.Query', dict]
690 :raises NotImplementedError: If the original query contains multiple filters with "IN" or "!=" operators.
691 :raises RuntimeError: If the filtering is invalid, e.g., using multiple key filters or querying
692 properties not in parentKeys.
693 """
694 origQueries = dbFilter.queries
695 if isinstance(origQueries, list):
696 raise NotImplementedError(
697 "Doing a relational Query with multiple=True and \"IN or !=\"-filters is currently unsupported!")
698 dbFilter.queries = db.QueryDefinition("viur-relations", {
699 "viur_src_kind =": skel.kindName,
700 "viur_dest_kind =": self.kind,
701 "viur_src_property =": name
703 }, orders=[], startCursor=origQueries.startCursor, endCursor=origQueries.endCursor)
704 for k, v in origQueries.filters.items(): # Merge old filters in
705 # Ensure that all non-relational-filters are in parentKeys
706 if k == db.KEY_SPECIAL_PROPERTY:
707 # We must process the key-property separately as its meaning changes as we change the datastore kind were querying
708 if isinstance(v, list) or isinstance(v, tuple):
709 logging.warning(f"Invalid filtering! Doing an relational Query on {name} with multiple key= "
710 f"filters is unsupported!")
711 raise RuntimeError()
712 if not isinstance(v, db.Key):
713 v = db.Key(v)
714 dbFilter.ancestor(v)
715 continue
716 boneName = k.split(".")[0].split(" ")[0]
717 if boneName not in self.parentKeys and boneName != "__key__":
718 logging.warning(f"Invalid filtering! {boneName} is not in parentKeys of RelationalBone {name}!")
719 raise RuntimeError()
720 dbFilter.filter(f"src.{k}", v)
721 orderList = []
722 for k, d in origQueries.orders: # Merge old sort orders in
723 if k == db.KEY_SPECIAL_PROPERTY:
724 orderList.append((f"{k}", d))
725 elif not k in self.parentKeys:
726 logging.warning(f"Invalid filtering! {k} is not in parentKeys of RelationalBone {name}!")
727 raise RuntimeError()
728 else:
729 orderList.append((f"src.{k}", d))
730 if orderList:
731 dbFilter.order(*orderList)
732 return name, skel, dbFilter, rawFilter
734 def buildDBFilter(
735 self,
736 name: str,
737 skel: "SkeletonInstance",
738 dbFilter: db.Query,
739 rawFilter: dict,
740 prefix: t.Optional[str] = None
741 ) -> db.Query:
742 """
743 Builds a datastore query by modifying the given filter based on the RelationalBone's properties.
745 This method takes a datastore query and modifies it according to the relational bone properties.
746 It also merges any related filters based on the 'refKeys' and 'using' attributes of the bone.
748 :param str name: The name of the bone.
749 :param SkeletonInstance skel: The skeleton instance the bone is a part of.
750 :param db.Query dbFilter: The original datastore query to be modified.
751 :param dict rawFilter: The raw filter applied to the original datastore query.
752 :param str prefix: Optional prefix to be applied to filter keys.
754 :return: The modified datastore query.
755 :rtype: db.Query
757 :raises RuntimeError: If the filtering is invalid, e.g., querying properties not in 'refKeys'
758 or not a bone in 'using'.
759 """
760 relSkel, _usingSkelCache = self._getSkels()
761 origQueries = dbFilter.queries
763 if origQueries is None: # This query is unsatisfiable
764 return dbFilter
766 myKeys = [x for x in rawFilter.keys() if x.startswith(f"{name}.")]
767 if len(myKeys) > 0: # We filter by some properties
768 if dbFilter.getKind() != "viur-relations" and self.multiple:
769 name, skel, dbFilter, rawFilter = self._rewriteQuery(name, skel, dbFilter, rawFilter)
771 # Merge the relational filters in
772 for myKey in myKeys:
773 value = rawFilter[myKey]
775 try:
776 unused, _type, key = myKey.split(".", 2)
777 assert _type in ["dest", "rel"]
778 except:
779 if self.using is None:
780 # This will be a "dest" query
781 _type = "dest"
782 try:
783 unused, key = myKey.split(".", 1)
784 except:
785 continue
786 else:
787 continue
789 # just use the first part of "key" to check against our refSkel / relSkel (strip any leading .something and $something)
790 checkKey = key
791 if "." in checkKey:
792 checkKey = checkKey.split(".")[0]
794 if "$" in checkKey:
795 checkKey = checkKey.split("$")[0]
797 if _type == "dest":
799 # Ensure that the relational-filter is in refKeys
800 if checkKey not in self._ref_keys:
801 logging.warning(f"Invalid filtering! {key} is not in refKeys of RelationalBone {name}!")
802 raise RuntimeError()
804 # Iterate our relSkel and let these bones write their filters in
805 for bname, bone in relSkel.items():
806 if checkKey == bname:
807 newFilter = {key: value}
808 if self.multiple:
809 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "dest.")
810 else:
811 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter,
812 prefix=(prefix or "") + name + ".dest.")
814 elif _type == "rel":
816 # Ensure that the relational-filter is in refKeys
817 if self.using is None or checkKey not in self.using():
818 logging.warning(f"Invalid filtering! {key} is not a bone in 'using' of {name}")
819 raise RuntimeError()
821 # Iterate our usingSkel and let these bones write their filters in
822 for bname, bone in self.using().items():
823 if key.startswith(bname):
824 newFilter = {key: value}
825 if self.multiple:
826 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "rel.")
827 else:
828 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter,
829 prefix=(prefix or "") + name + ".rel.")
831 if self.multiple:
832 dbFilter.setFilterHook(lambda s, filter, value: self.filterHook(name, s, filter, value))
833 dbFilter.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings))
835 elif name in rawFilter and isinstance(rawFilter[name], str) and rawFilter[name].lower() == "none":
836 dbFilter = dbFilter.filter(f"{name} =", None)
838 return dbFilter
840 def buildDBSort(
841 self,
842 name: str,
843 skel: "SkeletonInstance",
844 query: db.Query,
845 params: dict,
846 postfix: str = "",
847 ) -> t.Optional[db.Query]:
848 """
849 Builds a datastore query by modifying the given filter based on the RelationalBone's properties for sorting.
851 This method takes a datastore query and modifies its sorting behavior according to the relational bone
852 properties. It also checks if the sorting is valid based on the 'refKeys' and 'using' attributes of the bone.
854 :param name: The name of the bone.
855 :param skel: The skeleton instance the bone is a part of.
856 :param query: The original datastore query to be modified.
857 :param params: The raw filter applied to the original datastore query.
859 :return: The modified datastore query with updated sorting behavior.
860 :rtype: t.Optional[db.Query]
862 :raises RuntimeError: If the sorting is invalid, e.g., using properties not in 'refKeys'
863 or not a bone in 'using'.
864 """
865 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name):
866 if self.multiple and query.getKind() != "viur-relations":
867 # This query has not been rewritten (yet)
868 name, skel, query, params = self._rewriteQuery(name, skel, query, params)
870 try:
871 _, _type, param = orderby.split(".")
872 except ValueError as e:
873 logging.exception(f"Invalid layout of {orderby=}: {e}")
874 return query
875 if _type not in ("dest", "rel"):
876 logging.error("Invalid type {_type}")
877 return query
879 # Ensure that the relational-filter is in refKeys
880 if _type == "dest" and param not in self._ref_keys:
881 raise RuntimeError(f"Invalid filtering! {param!r} is not in refKeys of RelationalBone {name!r}!")
882 elif _type == "rel" and (self.using is None or param not in self.using()):
883 raise RuntimeError(f"Invalid filtering! {param!r} is not a bone in 'using' of RelationalBone {name!r}")
885 if self.multiple:
886 path = f"{_type}.{param}"
887 else:
888 path = f"{name}.{_type}.{param}"
890 order = utils.parse.sortorder(params.get("orderdir"))
891 query = query.order((path, order))
893 if self.multiple:
894 query.setFilterHook(lambda s, query, value: self.filterHook(name, s, query, value))
895 query.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings))
897 return query
899 def filterHook(self, name, query, param, value): # FIXME
900 """
901 Hook installed by buildDbFilter that rewrites filters added to the query to match the layout of the
902 viur-relations index and performs sanity checks on the query.
904 This method rewrites and validates filters added to a datastore query after the `buildDbFilter` method
905 has been executed. It ensures that the filters are compatible with the structure of the viur-relations
906 index and checks if the query is possible.
908 :param str name: The name of the bone.
909 :param db.Query query: The datastore query to be modified.
910 :param str param: The filter parameter to be checked and potentially modified.
911 :param value: The value associated with the filter parameter.
913 :return: A tuple containing the modified filter parameter and its associated value, or None if
914 the filter parameter is a key special property.
915 :rtype: Tuple[str, Any] or None
917 :raises RuntimeError: If the filtering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'.
918 """
919 if param.startswith("src.") or param.startswith("dest.") or param.startswith("viur_"):
920 # This filter is already valid in our relation
921 return param, value
922 if param.startswith(f"{name}."):
923 # We add a constrain filtering by properties of the referenced entity
924 refKey = param.replace(f"{name}.", "")
925 if " " in refKey: # Strip >, < or = params
926 refKey = refKey[:refKey.find(" ")]
927 if refKey not in self._ref_keys:
928 logging.warning(f"Invalid filtering! {refKey} is not in refKeys of RelationalBone {name}!")
929 raise RuntimeError()
930 if self.multiple:
931 return param.replace(f"{name}.", "dest."), value
932 else:
933 return param, value
934 else:
935 # We filter by a property of this entity
936 if not self.multiple:
937 # Not relational, not multiple - nothing to do here
938 return param, value
939 # Prepend "src."
940 srcKey = param
941 if " " in srcKey:
942 srcKey = srcKey[: srcKey.find(" ")] # Cut <, >, and =
943 if srcKey == db.KEY_SPECIAL_PROPERTY: # Rewrite key= filter as its meaning has changed
944 if isinstance(value, list) or isinstance(value, tuple):
945 logging.warning(f"Invalid filtering! Doing an relational Query on {name} "
946 f"with multiple key= filters is unsupported!")
947 raise RuntimeError()
948 if not isinstance(value, db.Key):
949 value = db.Key(value)
950 query.ancestor(value)
951 return None
952 if srcKey not in self.parentKeys:
953 logging.warning(f"Invalid filtering! {srcKey} is not in parentKeys of RelationalBone {name}!")
954 raise RuntimeError()
955 return f"src.{param}", value
957 def orderHook(self, name: str, query: db.Query, orderings): # FIXME
958 """
959 Hook installed by buildDbFilter that rewrites orderings added to the query to match the layout of the
960 viur-relations index and performs sanity checks on the query.
962 This method rewrites and validates orderings added to a datastore query after the `buildDbFilter` method
963 has been executed. It ensures that the orderings are compatible with the structure of the viur-relations
964 index and checks if the query is possible.
966 :param name: The name of the bone.
967 :param query: The datastore query to be modified.
968 :param orderings: A list or tuple of orderings to be checked and potentially modified.
969 :type orderings: List[Union[str, Tuple[str, db.SortOrder]]] or Tuple[Union[str, Tuple[str, db.SortOrder]]]
971 :return: A list of modified orderings that are compatible with the viur-relations index.
972 :rtype: List[Union[str, Tuple[str, db.SortOrder]]]
974 :raises RuntimeError: If the ordering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'.
975 """
976 res = []
977 if not isinstance(orderings, list) and not isinstance(orderings, tuple):
978 orderings = [orderings]
979 for order in orderings:
980 if isinstance(order, tuple):
981 orderKey = order[0]
982 else:
983 orderKey = order
984 if orderKey.startswith("dest.") or orderKey.startswith("rel.") or orderKey.startswith("src."):
985 # This is already valid for our relational index
986 res.append(order)
987 continue
988 if orderKey.startswith(f"{name}."):
989 k = orderKey.replace(f"{name}.", "")
990 if k not in self._ref_keys:
991 logging.warning(f"Invalid ordering! {k} is not in refKeys of RelationalBone {name}!")
992 raise RuntimeError()
993 if not self.multiple:
994 res.append(order)
995 else:
996 if isinstance(order, tuple):
997 res.append((f"dest.{k}", order[1]))
998 else:
999 res.append(f"dest.{k}")
1000 else:
1001 if not self.multiple:
1002 # Nothing to do here
1003 res.append(order)
1004 continue
1005 else:
1006 if orderKey not in self.parentKeys:
1007 logging.warning(
1008 f"Invalid ordering! {orderKey} is not in parentKeys of RelationalBone {name}!")
1009 raise RuntimeError()
1010 if isinstance(order, tuple):
1011 res.append((f"src.{orderKey}", order[1]))
1012 else:
1013 res.append(f"src.{orderKey}")
1014 return res
1016 def refresh(self, skel: "SkeletonInstance", name: str) -> None:
1017 """
1018 Refreshes all values that might be cached from other entities in the provided skeleton.
1020 This method updates the cached values for relational bones in the provided skeleton, which
1021 correspond to other entities. It fetches the updated values for the relational bone's
1022 reference keys and replaces the cached values in the skeleton with the fetched values.
1024 :param SkeletonInstance skel: The skeleton containing the bone to be refreshed.
1025 :param str boneName: The name of the bone to be refreshed.
1026 """
1027 if not skel[name] or self.updateLevel == RelationalUpdateLevel.OnValueAssignment:
1028 return
1030 for _, _, value in self.iter_bone_value(skel, name):
1031 if value and value["dest"]:
1032 try:
1033 target_skel = value["dest"].read()
1034 except ValueError:
1035 logging.error(
1036 f"{name}: The key {value['dest']['key']!r} ({value['dest'].get('name')!r}) seems to be gone"
1037 )
1038 continue
1040 for key in self.refKeys:
1041 value["dest"][key] = target_skel[key]
1043 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
1044 """
1045 Retrieves the search tags for the given RelationalBone in the provided skeleton.
1047 This method iterates over the values of the relational bone and gathers search tags from the
1048 reference and using skeletons. It combines all the tags into a set to avoid duplicates.
1050 :param skel: The skeleton containing the bone for which search tags are to be retrieved.
1051 :param name: The name of the bone for which search tags are to be retrieved.
1053 :return: A set of search tags for the specified relational bone.
1054 """
1055 result = set()
1057 def get_values(skel_, values_cache):
1058 for key, bone in skel_.items():
1059 if not bone.searchable:
1060 continue
1061 for tag in bone.getSearchTags(values_cache, key):
1062 result.add(tag)
1064 ref_skel_cache, using_skel_cache = self._getSkels()
1065 for idx, lang, value in self.iter_bone_value(skel, name):
1066 if value is None:
1067 continue
1068 if value["dest"]:
1069 get_values(ref_skel_cache, value["dest"])
1070 if value["rel"]:
1071 get_values(using_skel_cache, value["rel"])
1073 return result
1075 def createRelSkelFromKey(self, key: db.Key, rel: dict | None = None) -> RelDict | None:
1076 if rel_skel := self.relskels_from_keys([(key, rel)]):
1077 return rel_skel[0]
1078 return None
1080 def relskels_from_keys(self, key_rel_list: list[tuple[db.Key, dict | None]]) -> list[RelDict]:
1081 """
1082 Creates a list of RelSkel instances valid for this bone from the given database key.
1084 This method retrieves the entity corresponding to the provided key from the database, unserializes it
1085 into a reference skeleton, and returns a dictionary containing the reference skeleton and optional
1086 relation data.
1088 :param key_rel_list: List of tuples with the first value in the tuple is the
1089 key and the second is and RelSkel or None
1091 :return: A dictionary containing a reference skeleton and optional relation data.
1092 """
1094 if not all(db_objs := db.Get([db.keyHelper(value[0], self.kind) for value in key_rel_list])):
1095 return [] # return emtpy data when not all data is found
1096 res_rel_skels = []
1097 for (key, rel), db_obj in zip(key_rel_list, db_objs):
1098 dest_skel = self._refSkelCache()
1099 dest_skel.unserialize(db_obj)
1100 for bone_name in dest_skel:
1101 # Unserialize all bones from refKeys, then drop dbEntity - otherwise all properties will be copied
1102 _ = dest_skel[bone_name]
1103 dest_skel.dbEntity = None
1104 res_rel_skels.append(
1105 {
1106 "dest": dest_skel,
1107 "rel": rel or None
1108 }
1109 )
1110 return res_rel_skels
1112 def setBoneValue(
1113 self,
1114 skel: "SkeletonInstance",
1115 boneName: str,
1116 value: t.Any,
1117 append: bool,
1118 language: None | str = None
1119 ) -> bool:
1120 """
1121 Sets the value of the specified bone in the given skeleton. Sanity checks are performed to ensure the
1122 value is valid. If the value is invalid, no modifications are made.
1124 :param skel: Dictionary with the current values from the skeleton we belong to.
1125 :param boneName: The name of the bone to be modified.
1126 :param value: The value to be assigned. The type depends on the bone type.
1127 :param append: If true, the given value is appended to the values of the bone instead of replacing it.
1128 Only supported on bones with multiple=True.
1129 :param language: Set/append for a specific language (optional). Required if the bone
1130 supports languages.
1132 :return: True if the operation succeeded, False otherwise.
1133 """
1134 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported"
1135 assert not append or self.multiple, "Can't append - bone is not multiple"
1137 def tuple_check(in_value: tuple | None = None) -> bool:
1138 """
1139 Return False if the given value is a tuple with a length of two.
1140 In addition, the first field in the tuple must be a str,int or db.key.
1141 Furthermore, the second field must be a skeletonInstanceClassRef.
1142 """
1143 return not (isinstance(in_value, tuple) and len(in_value) == 2
1144 and isinstance(in_value[0], (str, int, db.Key))
1145 and isinstance(in_value[1], self._skeletonInstanceClassRef))
1147 if not self.multiple and not self.using:
1148 if not isinstance(value, (str, int, db.Key)):
1149 raise ValueError(f"You must supply exactly one Database-Key str or int to {boneName}")
1150 parsed_value = (value, None)
1151 elif not self.multiple and self.using:
1152 if tuple_check(value):
1153 raise ValueError(f"You must supply a tuple of (Database-Key, relSkel) to {boneName}")
1154 parsed_value = value
1155 elif self.multiple and not self.using:
1156 if not isinstance(value, (str, int, db.Key)) and not (isinstance(value, list)) \
1157 and all([isinstance(val, (str, int, db.Key)) for val in value]):
1158 raise ValueError(f"You must supply a Database-Key or a list hereof to {boneName}")
1159 if isinstance(value, list):
1160 parsed_value = [(key, None) for key in value]
1161 else:
1162 parsed_value = [(value, None)]
1163 else: # which means (self.multiple and self.using)
1164 if tuple_check(value) and not (isinstance(value, list) and all(tuple_check(val) for val in value)):
1165 raise ValueError(f"You must supply (db.Key, RelSkel) or a list hereof to {boneName}")
1166 if isinstance(value, list):
1167 parsed_value = value
1168 else:
1169 parsed_value = [value]
1171 if boneName not in skel:
1172 skel[boneName] = {}
1173 if language:
1174 skel[boneName].setdefault(language, [])
1176 if self.multiple:
1177 rel_list = self.relskels_from_keys(parsed_value)
1178 if append:
1179 if language:
1180 skel[boneName][language].extend(rel_list)
1181 else:
1182 if not isinstance(skel[boneName], list):
1183 skel[boneName] = []
1184 skel[boneName].extend(rel_list)
1185 else:
1186 if language:
1187 skel[boneName][language] = rel_list
1188 else:
1189 skel[boneName] = rel_list
1190 else:
1191 if not (rel := self.createRelSkelFromKey(parsed_value[0], parsed_value[1])):
1192 return False
1193 if language:
1194 skel[boneName][language] = rel
1195 else:
1196 skel[boneName] = rel
1197 return True
1199 def getReferencedBlobs(self, skel: "SkeletonInstance", name: str) -> set[str]:
1200 """
1201 Retrieves the set of referenced blobs from the specified bone in the given skeleton instance.
1203 :param SkeletonInstance skel: The skeleton instance to extract the referenced blobs from.
1204 :param str name: The name of the bone to retrieve the referenced blobs from.
1206 :return: A set containing the unique blob keys referenced by the specified bone.
1207 :rtype: Set[str]
1208 """
1209 result = set()
1210 for idx, lang, value in self.iter_bone_value(skel, name):
1211 if value is None:
1212 continue
1213 for key, bone_ in value["dest"].items():
1214 result.update(bone_.getReferencedBlobs(value["dest"], key))
1215 if value["rel"]:
1216 for key, bone_ in value["rel"].items():
1217 result.update(bone_.getReferencedBlobs(value["rel"], key))
1218 return result
1220 def getUniquePropertyIndexValues(self, valuesCache: dict, name: str) -> list[str]:
1221 """
1222 Generates unique property index values for the RelationalBone based on the referenced keys.
1223 Can be overridden if different behavior is required (e.g., examining values from `prop:usingSkel`).
1225 :param dict valuesCache: The cache containing the current values of the bone.
1226 :param str name: The name of the bone for which to generate unique property index values.
1228 :return: A list containing the unique property index values for the specified bone.
1229 :rtype: List[str]
1230 """
1231 value = valuesCache.get(name)
1232 if not value: # We don't have a value to lock
1233 return []
1234 if isinstance(value, dict):
1235 return self._hashValueForUniquePropertyIndex(value["dest"]["key"])
1236 elif isinstance(value, list):
1237 return self._hashValueForUniquePropertyIndex([x["dest"]["key"] for x in value])
1239 def structure(self) -> dict:
1240 return super().structure() | {
1241 "type": f"{self.type}.{self.kind}",
1242 "module": self.module,
1243 "format": self.format,
1244 "using": self.using().structure() if self.using else None,
1245 "relskel": self._refSkelCache().structure(),
1246 }