Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/skeleton.py: 10%
412 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 12:23 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 12:23 +0000
1from __future__ import annotations # noqa: required for pre-defined annotations
3import logging
4import time
5import typing as t
6import warnings
8from deprecated.sphinx import deprecated
10from viur.core import conf, db, errors, utils
11from . import tasks
12from .meta import BaseSkeleton, MetaSkel, _UNDEFINED_KINDNAME
13from .utils import skeletonByKind
14from ..bones.base import (
15 Compute,
16 ComputeInterval,
17 ComputeMethod,
18 ReadFromClientError,
19 ReadFromClientErrorSeverity,
20 ReadFromClientException,
21)
22from ..bones.date import DateBone
23from ..bones.key import KeyBone
24from ..bones.raw import RawBone
25from ..bones.relational import RelationalConsistency
26from ..bones.string import StringBone
28if t.TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from .instance import SkeletonInstance
30 from .adapter import DatabaseAdapter
33class SeoKeyBone(StringBone):
34 """
35 Special kind of StringBone saving its contents as `viurCurrentSeoKeys` into the entity's `viur` dict.
36 """
38 def unserialize(self, skel: SkeletonInstance, name: str) -> bool:
39 try:
40 skel.accessedValues[name] = skel.dbEntity["viur"]["viurCurrentSeoKeys"]
41 except KeyError:
42 skel.accessedValues[name] = self.getDefaultValue(skel)
44 def serialize(self, skel: SkeletonInstance, name: str, parentIndexed: bool) -> bool:
45 # Serialize also to skel["viur"]["viurCurrentSeoKeys"], so we can use this bone in relations
46 if name in skel.accessedValues:
47 newVal = skel.accessedValues[name]
48 if not skel.dbEntity.get("viur"):
49 skel.dbEntity["viur"] = db.Entity()
50 res = db.Entity()
51 res["_viurLanguageWrapper_"] = True
52 for language in (self.languages or []):
53 if not self.indexed:
54 res.exclude_from_indexes.add(language)
55 res[language] = None
56 if language in newVal:
57 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
58 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = res
59 return True
62class Skeleton(BaseSkeleton, metaclass=MetaSkel):
63 kindName: str = _UNDEFINED_KINDNAME
64 """
65 Specifies the entity kind name this Skeleton is associated with.
66 Will be determined automatically when not explicitly set.
67 """
69 database_adapters: DatabaseAdapter | t.Iterable[DatabaseAdapter] | None = _UNDEFINED_KINDNAME
70 """
71 Custom database adapters.
72 Allows to hook special functionalities that during skeleton modifications.
73 """
75 subSkels = {} # List of pre-defined sub-skeletons of this type
77 interBoneValidations: list[
78 t.Callable[[Skeleton], list[ReadFromClientError]]] = [] # List of functions checking inter-bone dependencies
80 __seo_key_trans = str.maketrans(
81 {"<": "",
82 ">": "",
83 "\"": "",
84 "'": "",
85 "\n": "",
86 "\0": "",
87 "/": "",
88 "\\": "",
89 "?": "",
90 "&": "",
91 "#": ""
92 })
94 # The "key" bone stores the current database key of this skeleton.
95 # Warning: Assigning to this bones value now *will* set the key
96 # it gets stored in. Must be kept readOnly to avoid security-issues with add/edit.
97 key = KeyBone(
98 descr="Key"
99 )
101 shortkey = RawBone(
102 descr="Shortkey",
103 compute=Compute(lambda skel: skel["key"].id_or_name if skel["key"] else None),
104 readOnly=True,
105 visible=False,
106 searchable=True,
107 )
109 name = StringBone(
110 descr="Name",
111 visible=False,
112 compute=Compute(
113 fn=lambda skel: f"{skel["key"].kind}/{skel["key"].id_or_name}" if skel["key"] else None,
114 interval=ComputeInterval(ComputeMethod.OnWrite)
115 )
116 )
118 # The date (including time) when this entry has been created
119 creationdate = DateBone(
120 descr="created at",
121 readOnly=True,
122 visible=False,
123 indexed=True,
124 compute=Compute(
125 lambda: utils.utcNow().replace(microsecond=0),
126 interval=ComputeInterval(ComputeMethod.Once)
127 ),
128 )
130 # The last date (including time) when this entry has been updated
132 changedate = DateBone(
133 descr="updated at",
134 readOnly=True,
135 visible=False,
136 indexed=True,
137 compute=Compute(
138 lambda: utils.utcNow().replace(microsecond=0),
139 interval=ComputeInterval(ComputeMethod.OnWrite)
140 ),
141 )
143 viurCurrentSeoKeys = SeoKeyBone(
144 descr="SEO-Keys",
145 readOnly=True,
146 visible=False,
147 languages=conf.i18n.available_languages
148 )
150 def __repr__(self):
151 return "<skeleton %s with data=%r>" % (self.kindName, {k: self[k] for k in self.keys()})
153 def __str__(self):
154 return str({k: self[k] for k in self.keys()})
156 def __init__(self, *args, **kwargs):
157 super(Skeleton, self).__init__(*args, **kwargs)
158 assert self.kindName and self.kindName is not _UNDEFINED_KINDNAME, "You must set kindName on this skeleton!"
160 @classmethod
161 def all(cls, skel, **kwargs) -> db.Query:
162 """
163 Create a query with the current Skeletons kindName.
165 :returns: A db.Query object which allows for entity filtering and sorting.
166 """
167 return db.Query(skel.kindName, srcSkelClass=skel, **kwargs)
169 @classmethod
170 def fromClient(
171 cls,
172 skel: SkeletonInstance,
173 data: dict[str, list[str] | str],
174 *,
175 amend: bool = False,
176 ignore: t.Optional[t.Iterable[str]] = None,
177 ) -> bool:
178 """
179 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that
180 the values retrieved from *data* are checked against the bones and their validity checks.
182 Even if this function returns False, all bones are guaranteed to be in a valid state.
183 The ones which have been read correctly are set to their valid values;
184 Bones with invalid values are set back to a safe default (None in most cases).
185 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading
186 data with this function failed (through this might violates the assumed consistency-model).
188 :param skel: The skeleton instance to be filled.
189 :param data: Dictionary from which the data is read.
190 :param amend: Defines whether content of data may be incomplete to amend the skel,
191 which is useful for edit-actions.
192 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None.
194 :returns: True if all data was successfully read and complete. \
195 False otherwise (e.g. some required fields where missing or where invalid).
196 """
197 assert skel.renderPreparation is None, "Cannot modify values while rendering"
199 # Load data into this skeleton
200 complete = bool(data) and super().fromClient(skel, data, amend=amend, ignore=ignore)
202 if (
203 not data # in case data is empty
204 or (len(data) == 1 and "key" in data)
205 or (utils.parse.bool(data.get("nomissing")))
206 ):
207 skel.errors = []
209 # Check if all unique values are available
210 for boneName, boneInstance in skel.items():
211 if boneInstance.unique:
212 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName)
214 for lockValue in lockValues:
215 lock_key = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)
216 lock_entity = db.get(lock_key)
218 if lock_entity and (not skel["key"] or lock_entity["references"] != skel["key"].id_or_name):
219 logging.error(f"{boneName=} {lock_key=} already taken by {lock_entity["references"]!r}")
221 # This value is taken (sadly, not by us)
222 complete = False
223 skel.errors.append(
224 ReadFromClientError(
225 ReadFromClientErrorSeverity.Invalid,
226 boneInstance.unique.message,
227 [boneName]
228 )
229 )
231 # Check inter-Bone dependencies
232 for checkFunc in skel.interBoneValidations:
233 errors = checkFunc(skel)
234 if errors:
235 for error in errors:
236 if error.severity.value > 1:
237 complete = False
238 if conf.debug.skeleton_from_client:
239 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}")
241 skel.errors.extend(errors)
243 return complete
245 @classmethod
246 @deprecated(
247 version="3.7.0",
248 reason="Use skel.read() instead of skel.fromDB()",
249 )
250 def fromDB(cls, skel: SkeletonInstance, key: db.KeyType) -> bool:
251 """
252 Deprecated function, replaced by Skeleton.read().
253 """
254 return bool(cls.read(skel, key, _check_legacy=False))
256 @classmethod
257 def read(
258 cls,
259 skel: SkeletonInstance,
260 key: t.Optional[db.KeyType] = None,
261 *,
262 create: bool | dict | t.Callable[[SkeletonInstance], None] = False,
263 _check_legacy: bool = True
264 ) -> t.Optional[SkeletonInstance]:
265 """
266 Read Skeleton with *key* from the datastore into the Skeleton.
267 If not key is given, skel["key"] will be used.
269 Reads all available data of entity kind *kindName* and the key *key*
270 from the Datastore into the Skeleton structure's bones. Any previous
271 data of the bones will discard.
273 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`.
275 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
276 If not provided, skel["key"] will be used.
277 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
278 given key does not exist, it will be created.
280 :returns: None on error, or the given SkeletonInstance on success.
282 """
283 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first!
284 if _check_legacy and "fromDB" in cls.__dict__:
285 with warnings.catch_warnings():
286 warnings.simplefilter("ignore", DeprecationWarning)
287 return cls.fromDB(skel, key=key)
289 assert skel.renderPreparation is None, "Cannot modify values while rendering"
291 try:
292 db_key = db.key_helper(key or skel["key"], skel.kindName)
293 except (ValueError, NotImplementedError): # This key did not parse
294 return None
296 if db_res := db.get(db_key):
297 skel.setEntity(db_res)
298 return skel
299 elif create in (False, None):
300 return None
301 elif isinstance(create, dict):
302 if create and not skel.fromClient(create, amend=True, ignore=()):
303 raise ReadFromClientException(skel.errors)
304 elif callable(create):
305 create(skel)
306 elif create is not True:
307 raise ValueError("'create' must either be dict, a callable or True.")
309 skel["key"] = db_key
310 return skel.write()
312 @classmethod
313 @deprecated(
314 version="3.7.0",
315 reason="Use skel.write() instead of skel.toDB()",
316 )
317 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key:
318 """
319 Deprecated function, replaced by Skeleton.write().
320 """
322 # TODO: Remove with ViUR4
323 if "clearUpdateTag" in kwargs:
324 msg = "clearUpdateTag was replaced by update_relations"
325 warnings.warn(msg, DeprecationWarning, stacklevel=3)
326 logging.warning(msg, stacklevel=3)
327 update_relations = not kwargs["clearUpdateTag"]
329 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False)
330 return skel["key"]
332 @classmethod
333 def write(
334 cls,
335 skel: SkeletonInstance,
336 key: t.Optional[db.KeyType] = None,
337 *,
338 update_relations: bool = True,
339 _check_legacy: bool = True,
340 ) -> SkeletonInstance:
341 """
342 Write current Skeleton to the datastore.
344 Stores the current data of this instance into the database.
345 If an *key* value is set to the object, this entity will ne updated;
346 Otherwise a new entity will be created.
348 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`.
350 :param key: Allows to specify a key that is set to the skeleton and used for writing.
351 :param update_relations: If False, this entity won't be marked dirty;
352 This avoids from being fetched by the background task updating relations.
354 :returns: The Skeleton.
355 """
356 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first!
357 if _check_legacy and "toDB" in cls.__dict__:
358 with warnings.catch_warnings():
359 warnings.simplefilter("ignore", DeprecationWarning)
360 return cls.toDB(skel, update_relations=update_relations)
362 # FIXME: This check is incomplete as long it does nt check the entire tree!
363 assert skel.renderPreparation is None, "Cannot modify values while rendering"
365 def __txn_write(write_skel):
366 db_key = write_skel["key"]
367 skel = write_skel.skeletonCls()
369 blob_list = set()
370 change_list = []
371 old_copy = {}
372 # Load the current values from Datastore or create a new, empty db.Entity
373 if not db_key:
374 # We'll generate the key we'll be stored under early so we can use it for locks etc
375 db_key = db.allocate_ids(skel.kindName)[0]
376 skel.dbEntity = db.Entity(db_key)
377 is_add = True
378 else:
379 db_key = db.key_helper(db_key, skel.kindName)
380 if db_obj := db.get(db_key):
381 skel.dbEntity = db_obj
382 old_copy = {k: v for k, v in skel.dbEntity.items()}
383 is_add = False
384 else:
385 skel.dbEntity = db.Entity(db_key)
386 is_add = True
388 skel.dbEntity.setdefault("viur", {})
390 # Merge values and assemble unique properties
391 # Move accessed Values from srcSkel over to skel
392 skel.accessedValues = write_skel.accessedValues
394 write_skel["key"] = skel["key"] = db_key # Ensure key stays set
395 write_skel.dbEntity = skel.dbEntity # update write_skel's dbEntity
397 for bone_name, bone in skel.items():
398 if bone_name == "key": # Explicitly skip key on top-level - this had been set above
399 continue
401 # Allow bones to perform outstanding "magic" operations before saving to db
402 bone.performMagic(skel, bone_name, isAdd=is_add) # FIXME VIUR4: ANY MAGIC IN OUR CODE IS DEPRECATED!!!
404 if not (bone_name in skel.accessedValues or bone.compute) and bone_name not in skel.dbEntity:
405 _ = skel[bone_name] # Ensure the datastore is filled with the default value
407 if (
408 bone_name in skel.accessedValues or bone.compute # We can have a computed value on store
409 or bone_name not in skel.dbEntity # It has not been written and is not in the database
410 ):
411 # Serialize bone into entity
412 try:
413 bone.serialize(skel, bone_name, True)
414 except Exception as e:
415 logging.error(
416 f"Failed to serialize {bone_name=} ({bone=}): {skel.accessedValues[bone_name]=}"
417 )
418 raise e
420 # Obtain referenced blobs
421 blob_list.update(bone.getReferencedBlobs(skel, bone_name))
423 # Check if the value has actually changed
424 if skel.dbEntity.get(bone_name) != old_copy.get(bone_name):
425 change_list.append(bone_name)
427 # Lock hashes from bones that must have unique values
428 if bone.unique:
429 # Remember old hashes for bones that must have an unique value
430 old_unique_values = []
432 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]:
433 old_unique_values = skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"]
434 # Check if the property is unique
435 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name)
436 new_lock_kind = f"{skel.kindName}_{bone_name}_uniquePropertyIndex"
437 for new_lock_value in new_unique_values:
438 new_lock_key = db.Key(new_lock_kind, new_lock_value)
439 if lock_db_obj := db.get(new_lock_key):
441 # There's already a lock for that value, check if we hold it
442 if lock_db_obj["references"] != skel.dbEntity.key.id_or_name:
443 # This value has already been claimed, and not by us
444 # TODO: Use a custom exception class which is catchable with an try/except
445 raise ValueError(
446 f"The unique value {skel[bone_name]!r} of bone {bone_name!r} "
447 f"has been recently claimed (by {new_lock_key=}).")
448 else:
449 # This value is locked for the first time, create a new lock-object
450 lock_obj = db.Entity(new_lock_key)
451 lock_obj["references"] = skel.dbEntity.key.id_or_name
452 db.put(lock_obj)
453 if new_lock_value in old_unique_values:
454 old_unique_values.remove(new_lock_value)
455 skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] = new_unique_values
457 # Remove any lock-object we're holding for values that we don't have anymore
458 for old_unique_value in old_unique_values:
459 # Try to delete the old lock
461 old_lock_key = db.Key(f"{skel.kindName}_{bone_name}_uniquePropertyIndex", old_unique_value)
462 if old_lock_obj := db.get(old_lock_key):
463 if old_lock_obj["references"] != skel.dbEntity.key.id_or_name:
465 # We've been supposed to have that lock - but we don't.
466 # Don't remove that lock as it now belongs to a different entry
467 logging.critical("Detected Database corruption! A Value-Lock had been reassigned!")
468 else:
469 # It's our lock which we don't need anymore
470 db.delete(old_lock_key)
471 else:
472 logging.critical("Detected Database corruption! Could not delete stale lock-object!")
474 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4
475 skel.dbEntity.pop("viur_incomming_relational_locks", None)
477 # Ensure the SEO-Keys are up-to-date
478 last_requested_seo_keys = skel.dbEntity["viur"].get("viurLastRequestedSeoKeys") or {}
479 last_set_seo_keys = skel.dbEntity["viur"].get("viurCurrentSeoKeys") or {}
480 # Filter garbage serialized into this field by the SeoKeyBone
481 last_set_seo_keys = {k: v for k, v in last_set_seo_keys.items() if not k.startswith("_") and v}
483 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict):
484 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = {}
486 if current_seo_keys := skel.getCurrentSEOKeys():
487 # Convert to lower-case and remove certain characters
488 for lang, value in current_seo_keys.items():
489 current_seo_keys[lang] = value.lower().translate(Skeleton.__seo_key_trans).strip()
491 for language in (conf.i18n.available_languages or [conf.i18n.default_language]):
492 if current_seo_keys and language in current_seo_keys:
493 current_seo_key = current_seo_keys[language]
495 if current_seo_key != last_requested_seo_keys.get(language): # This one is new or has changed
496 new_seo_key = current_seo_keys[language]
498 for _ in range(0, 3):
499 entry_using_key = db.Query(skel.kindName).filter(
500 "viur.viurActiveSeoKeys =", new_seo_key).getEntry()
502 if entry_using_key and entry_using_key.key != skel.dbEntity.key:
503 # It's not unique; append a random string and try again
504 new_seo_key = f"{current_seo_keys[language]}-{utils.string.random(5).lower()}"
506 else:
507 # We found a new SeoKey
508 break
509 else:
510 raise ValueError("Could not generate an unique seo key in 3 attempts")
511 else:
512 new_seo_key = current_seo_key
513 last_set_seo_keys[language] = new_seo_key
515 else:
516 # We'll use the database-key instead
517 last_set_seo_keys[language] = str(skel.dbEntity.key.id_or_name)
519 # Store the current, active key for that language
520 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] = last_set_seo_keys[language]
522 skel.dbEntity["viur"].setdefault("viurActiveSeoKeys", [])
523 for language, seo_key in last_set_seo_keys.items():
524 if (
525 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language]
526 not in skel.dbEntity["viur"]["viurActiveSeoKeys"]
527 ):
528 # Ensure the current, active seo key is in the list of all seo keys
529 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key)
530 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]:
531 # Ensure that key is also in there
532 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name))
533 # Trim to the last 200 used entries
534 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200]
535 # Store lastRequestedKeys so further updates can run more efficient
536 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys
538 # mark entity as "dirty" when update_relations is set, to zero otherwise.
539 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0
541 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity)
543 # Allow the database adapter to apply last minute changes to the object
544 for adapter in skel.database_adapters:
545 adapter.prewrite(skel, is_add, change_list)
547 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name
548 def fixDotNames(entity):
549 for k, v in list(entity.items()):
550 if isinstance(v, dict):
551 for k2, v2 in list(entity.items()):
552 if k2.startswith(f"{k}."):
553 del entity[k2]
554 backupKey = k2.replace(".", "__")
555 entity[backupKey] = v2
556 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey}
557 fixDotNames(v)
558 elif isinstance(v, list):
559 for x in v:
560 if isinstance(x, dict):
561 fixDotNames(x)
563 # FIXME: REMOVE IN VIUR4
564 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2
565 fixDotNames(skel.dbEntity)
567 # Write the core entry back
568 db.put(skel.dbEntity)
570 # Now write the blob-lock object
571 blob_list = skel.preProcessBlobLocks(blob_list)
572 if blob_list is None:
573 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?")
574 if None in blob_list:
575 msg = f"None is not valid in {blob_list=}"
576 logging.error(msg)
577 raise ValueError(msg)
579 if not is_add and (old_blob_lock_obj := db.get(db.Key("viur-blob-locks", db_key.id_or_name))):
580 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list
581 old_blob_lock_obj["active_blob_references"] = list(blob_list)
582 if old_blob_lock_obj["old_blob_references"] is None:
583 old_blob_lock_obj["old_blob_references"] = list(removed_blobs)
584 else:
585 old_blob_refs = set(old_blob_lock_obj["old_blob_references"])
586 old_blob_refs.update(removed_blobs) # Add removed blobs
587 old_blob_refs -= blob_list # Remove active blobs
588 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs)
590 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"])
591 old_blob_lock_obj["is_stale"] = False
592 db.put(old_blob_lock_obj)
593 else: # We need to create a new blob-lock-object
594 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name))
595 blob_lock_obj["active_blob_references"] = list(blob_list)
596 blob_lock_obj["old_blob_references"] = []
597 blob_lock_obj["has_old_blob_references"] = False
598 blob_lock_obj["is_stale"] = False
599 db.put(blob_lock_obj)
601 return skel.dbEntity.key, write_skel, change_list, is_add
603 # Parse provided key, if any, and set it to skel["key"]
604 if key:
605 skel["key"] = db.key_helper(key, skel.kindName)
607 if skel._cascade_deletion is True:
608 if skel["key"]:
609 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}")
610 skel.delete()
612 return skel
614 # Run transactional function
615 if db.is_in_transaction():
616 key, skel, change_list, is_add = __txn_write(skel)
617 else:
618 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel)
620 for bone_name, bone in skel.items():
621 bone.postSavedHandler(skel, bone_name, key)
623 skel.postSavedHandler(key, skel.dbEntity)
625 if update_relations and not is_add:
626 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually
627 tasks.update_relations(key, changed_bones=change_list, _countdown=10)
629 else: # Update all inbound relations, regardless of which bones they mirror
630 tasks.update_relations(key)
632 # Trigger the database adapter of the changes made to the entry
633 for adapter in skel.database_adapters:
634 adapter.write(skel, is_add, change_list)
636 return skel
638 @classmethod
639 def delete(cls, skel: SkeletonInstance, key: t.Optional[db.KeyType] = None) -> None:
640 """
641 Deletes the entity associated with the current Skeleton from the data store.
643 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used.
644 """
646 def __txn_delete(skel: SkeletonInstance, key: db.Key):
647 if not skel.read(key):
648 raise ValueError("This skeleton is not in the database (anymore?)!")
650 # Is there any relation to this Skeleton which prevents the deletion?
651 locked_relation = (
652 db.Query("viur-relations")
653 .filter("dest.__key__ =", key)
654 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value)
655 ).getEntry()
657 if locked_relation is not None:
658 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!")
660 # Ensure that any value lock objects remaining for this entry are being deleted
661 viur_data = skel.dbEntity.get("viur") or {}
663 for boneName, bone in skel.items():
664 bone.delete(skel, boneName)
665 if bone.unique:
666 flushList = []
667 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []:
668 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)
669 lockObj = db.get(lockKey)
670 if not lockObj:
671 logging.error(f"{lockKey=} missing!")
672 elif lockObj["references"] != key.id_or_name:
673 logging.error(
674 f"""{key!r} does not hold lock for {lockKey!r}""")
675 else:
676 flushList.append(lockObj)
677 if flushList:
678 db.delete(flushList)
680 # Delete the blob-key lock object
681 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name)
682 lockObj = db.get(lockObjectKey)
684 if lockObj is not None:
685 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None:
686 db.delete(lockObjectKey) # Nothing to do here
687 else:
688 if lockObj["old_blob_references"] is None:
689 # No old stale entries, move active_blob_references -> old_blob_references
690 lockObj["old_blob_references"] = lockObj["active_blob_references"]
691 elif lockObj["active_blob_references"] is not None:
692 # Append the current references to the list of old & stale references
693 lockObj["old_blob_references"] += lockObj["active_blob_references"]
694 lockObj["active_blob_references"] = [] # There are no active ones left
695 lockObj["is_stale"] = True
696 lockObj["has_old_blob_references"] = True
697 db.put(lockObj)
699 db.delete(key)
700 tasks.update_relations(key)
702 if key := (key or skel["key"]):
703 key = db.key_helper(key, skel.kindName)
704 else:
705 raise ValueError("This skeleton has no key!")
707 # Full skeleton is required to have all bones!
708 skel = skeletonByKind(skel.kindName)()
710 if db.is_in_transaction():
711 __txn_delete(skel, key)
712 else:
713 db.run_in_transaction(__txn_delete, skel, key)
715 for boneName, bone in skel.items():
716 bone.postDeletedHandler(skel, boneName, key)
718 skel.postDeletedHandler(key)
720 # Inform the custom DB Adapter
721 for adapter in skel.database_adapters:
722 adapter.delete(skel)
724 @classmethod
725 def patch(
726 cls,
727 skel: SkeletonInstance,
728 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {},
729 *,
730 key: t.Optional[db.Key | int | str] = None,
731 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None,
732 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None,
733 update_relations: bool = True,
734 ignore: t.Optional[t.Iterable[str]] = (),
735 internal: bool = True,
736 retry: int = 0,
737 ) -> SkeletonInstance:
738 """
739 Performs an edit operation on a Skeleton within a transaction.
741 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the
742 given Skeleton and its underlying database entity.
744 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts,
745 a callable can also be given that can individually modify the Skeleton that is edited.
747 :param values: A dict of key-values to update on the entry, or a callable that is executed within
748 the transaction.
750 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the
751 given value, which can be used for counters.
752 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
753 If not provided, skel["key"] will be used.
754 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating.
755 If something fails within this check, an AssertionError is being raised.
756 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
757 given key does not exist.
758 :param update_relations: Trigger update relations task on success. Defaults to False.
759 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list,
760 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally)
761 :param internal: Internal patch does ignore any NotSet and Empty errors that may raise in skel.fromClient()
762 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED!
764 If the function does not raise an Exception, all went well.
765 The function always returns the input Skeleton.
767 Raises:
768 ValueError: In case parameters where given wrong or incomplete.
769 AssertionError: In case an asserted check parameter did not match.
770 ReadFromClientException: In case a skel.fromClient() failed with a high severity.
771 """
773 # Transactional function
774 def __update_txn():
775 # Try to read the skeleton, create on demand
776 if not skel.read(key):
777 if create is None or create is False:
778 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.")
780 if not (key or skel["key"]) and create in (False, None):
781 return ValueError("No valid key provided")
783 if key or skel["key"]:
784 skel["key"] = db.key_helper(key or skel["key"], skel.kindName)
786 if isinstance(create, dict):
787 if create and not skel.fromClient(create, amend=True, ignore=ignore):
788 raise ReadFromClientException(skel.errors)
789 elif callable(create):
790 create(skel)
791 elif create is not True:
792 raise ValueError("'create' must either be dict or a callable.")
794 # Handle check
795 if isinstance(check, dict):
796 for bone, value in check.items():
797 if skel[bone] != value:
798 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}")
800 elif callable(check):
801 check(skel)
803 # Set values
804 if isinstance(values, dict):
805 if values and not skel.fromClient(values, amend=True, ignore=ignore) and not internal:
806 raise ReadFromClientException(skel.errors)
808 # In case we're in internal-mode, only raise fatal errors.
809 if skel.errors and internal:
810 for error in skel.errors:
811 if error.severity in (
812 ReadFromClientErrorSeverity.Invalid,
813 ReadFromClientErrorSeverity.InvalidatesOther,
814 ):
815 raise ReadFromClientException(skel.errors)
817 # otherwise, ignore any reported errors
818 skel.errors.clear()
820 # Special-feature: "+" and "-" prefix for simple calculations
821 # TODO: This can maybe integrated into skel.fromClient() later...
822 for name, value in values.items():
823 match name[0]:
824 case "+": # Increment by value?
825 skel[name[1:]] += value
826 case "-": # Decrement by value?
827 skel[name[1:]] -= value
829 elif callable(values):
830 values(skel)
832 else:
833 raise ValueError("'values' must either be dict or a callable.")
835 return skel.write(update_relations=update_relations)
837 if not db.is_in_transaction():
838 # Retry loop
839 while True:
840 try:
841 return db.run_in_transaction(__update_txn)
843 except RuntimeError as e:
844 retry -= 1
845 if retry < 0:
846 raise
848 logging.debug(f"{e}, retrying {retry} more times")
850 time.sleep(1)
851 else:
852 return __update_txn()
854 @classmethod
855 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks):
856 """
857 Can be overridden to modify the list of blobs referenced by this skeleton
858 """
859 return locks
861 @classmethod
862 def preProcessSerializedData(cls, skel: SkeletonInstance, entity):
863 """
864 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually
865 written to the data store.
866 """
867 return entity
869 @classmethod
870 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj):
871 """
872 Can be overridden to perform further actions after the entity has been written
873 to the data store.
874 """
875 pass
877 @classmethod
878 def postDeletedHandler(cls, skel: SkeletonInstance, key):
879 """
880 Can be overridden to perform further actions after the entity has been deleted
881 from the data store.
882 """
883 pass
885 @classmethod
886 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]:
887 """
888 Should be overridden to return a dictionary of language -> SEO-Friendly key
889 this entry should be reachable under. How theses names are derived are entirely up to the application.
890 If the name is already in use for this module, the server will automatically append some random string
891 to make it unique.
892 :return:
893 """
894 return