Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/skeleton.py: 10%
403 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
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
12from .meta import BaseSkeleton, MetaSkel, KeyType, _UNDEFINED_KINDNAME
13from . import tasks
14from .utils import skeletonByKind
15from ..bones.base import (
16 Compute,
17 ComputeInterval,
18 ComputeMethod,
19 ReadFromClientException,
20 ReadFromClientError,
21 ReadFromClientErrorSeverity
22)
23from ..bones.relational import RelationalConsistency
24from ..bones.key import KeyBone
25from ..bones.date import DateBone
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 name = StringBone(
102 descr="Name",
103 visible=False,
104 compute=Compute(
105 fn=lambda skel: f"{skel["key"].kind}/{skel["key"].id_or_name}" if skel["key"] else None,
106 interval=ComputeInterval(ComputeMethod.OnWrite)
107 )
108 )
110 # The date (including time) when this entry has been created
111 creationdate = DateBone(
112 descr="created at",
113 readOnly=True,
114 visible=False,
115 indexed=True,
116 compute=Compute(
117 lambda: utils.utcNow().replace(microsecond=0),
118 interval=ComputeInterval(ComputeMethod.Once)
119 ),
120 )
122 # The last date (including time) when this entry has been updated
124 changedate = DateBone(
125 descr="updated at",
126 readOnly=True,
127 visible=False,
128 indexed=True,
129 compute=Compute(
130 lambda: utils.utcNow().replace(microsecond=0),
131 interval=ComputeInterval(ComputeMethod.OnWrite)
132 ),
133 )
135 viurCurrentSeoKeys = SeoKeyBone(
136 descr="SEO-Keys",
137 readOnly=True,
138 visible=False,
139 languages=conf.i18n.available_languages
140 )
142 def __repr__(self):
143 return "<skeleton %s with data=%r>" % (self.kindName, {k: self[k] for k in self.keys()})
145 def __str__(self):
146 return str({k: self[k] for k in self.keys()})
148 def __init__(self, *args, **kwargs):
149 super(Skeleton, self).__init__(*args, **kwargs)
150 assert self.kindName and self.kindName is not _UNDEFINED_KINDNAME, "You must set kindName on this skeleton!"
152 @classmethod
153 def all(cls, skel, **kwargs) -> db.Query:
154 """
155 Create a query with the current Skeletons kindName.
157 :returns: A db.Query object which allows for entity filtering and sorting.
158 """
159 return db.Query(skel.kindName, srcSkelClass=skel, **kwargs)
161 @classmethod
162 def fromClient(
163 cls,
164 skel: SkeletonInstance,
165 data: dict[str, list[str] | str],
166 *,
167 amend: bool = False,
168 ignore: t.Optional[t.Iterable[str]] = None,
169 ) -> bool:
170 """
171 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that
172 the values retrieved from *data* are checked against the bones and their validity checks.
174 Even if this function returns False, all bones are guaranteed to be in a valid state.
175 The ones which have been read correctly are set to their valid values;
176 Bones with invalid values are set back to a safe default (None in most cases).
177 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading
178 data with this function failed (through this might violates the assumed consistency-model).
180 :param skel: The skeleton instance to be filled.
181 :param data: Dictionary from which the data is read.
182 :param amend: Defines whether content of data may be incomplete to amend the skel,
183 which is useful for edit-actions.
184 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None.
186 :returns: True if all data was successfully read and complete. \
187 False otherwise (e.g. some required fields where missing or where invalid).
188 """
189 assert skel.renderPreparation is None, "Cannot modify values while rendering"
191 # Load data into this skeleton
192 complete = bool(data) and super().fromClient(skel, data, amend=amend, ignore=ignore)
194 if (
195 not data # in case data is empty
196 or (len(data) == 1 and "key" in data)
197 or (utils.parse.bool(data.get("nomissing")))
198 ):
199 skel.errors = []
201 # Check if all unique values are available
202 for boneName, boneInstance in skel.items():
203 if boneInstance.unique:
204 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName)
205 for lockValue in lockValues:
206 dbObj = db.get(db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue))
207 if dbObj and (not skel["key"] or dbObj["references"] != skel["key"].id_or_name):
208 # This value is taken (sadly, not by us)
209 complete = False
210 errorMsg = boneInstance.unique.message
211 skel.errors.append(
212 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, errorMsg, [boneName]))
214 # Check inter-Bone dependencies
215 for checkFunc in skel.interBoneValidations:
216 errors = checkFunc(skel)
217 if errors:
218 for error in errors:
219 if error.severity.value > 1:
220 complete = False
221 if conf.debug.skeleton_from_client:
222 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}")
224 skel.errors.extend(errors)
226 return complete
228 @classmethod
229 @deprecated(
230 version="3.7.0",
231 reason="Use skel.read() instead of skel.fromDB()",
232 )
233 def fromDB(cls, skel: SkeletonInstance, key: KeyType) -> bool:
234 """
235 Deprecated function, replaced by Skeleton.read().
236 """
237 return bool(cls.read(skel, key, _check_legacy=False))
239 @classmethod
240 def read(
241 cls,
242 skel: SkeletonInstance,
243 key: t.Optional[KeyType] = None,
244 *,
245 create: bool | dict | t.Callable[[SkeletonInstance], None] = False,
246 _check_legacy: bool = True
247 ) -> t.Optional[SkeletonInstance]:
248 """
249 Read Skeleton with *key* from the datastore into the Skeleton.
250 If not key is given, skel["key"] will be used.
252 Reads all available data of entity kind *kindName* and the key *key*
253 from the Datastore into the Skeleton structure's bones. Any previous
254 data of the bones will discard.
256 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`.
258 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
259 If not provided, skel["key"] will be used.
260 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
261 given key does not exist, it will be created.
263 :returns: None on error, or the given SkeletonInstance on success.
265 """
266 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first!
267 if _check_legacy and "fromDB" in cls.__dict__:
268 with warnings.catch_warnings():
269 warnings.simplefilter("ignore", DeprecationWarning)
270 return cls.fromDB(skel, key=key)
272 assert skel.renderPreparation is None, "Cannot modify values while rendering"
274 try:
275 db_key = db.key_helper(key or skel["key"], skel.kindName)
276 except (ValueError, NotImplementedError): # This key did not parse
277 return None
279 if db_res := db.get(db_key):
280 skel.setEntity(db_res)
281 return skel
282 elif create in (False, None):
283 return None
284 elif isinstance(create, dict):
285 if create and not skel.fromClient(create, amend=True):
286 raise ReadFromClientException(skel.errors)
287 elif callable(create):
288 create(skel)
289 elif create is not True:
290 raise ValueError("'create' must either be dict, a callable or True.")
292 return skel.write()
294 @classmethod
295 @deprecated(
296 version="3.7.0",
297 reason="Use skel.write() instead of skel.toDB()",
298 )
299 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key:
300 """
301 Deprecated function, replaced by Skeleton.write().
302 """
304 # TODO: Remove with ViUR4
305 if "clearUpdateTag" in kwargs:
306 msg = "clearUpdateTag was replaced by update_relations"
307 warnings.warn(msg, DeprecationWarning, stacklevel=3)
308 logging.warning(msg, stacklevel=3)
309 update_relations = not kwargs["clearUpdateTag"]
311 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False)
312 return skel["key"]
314 @classmethod
315 def write(
316 cls,
317 skel: SkeletonInstance,
318 key: t.Optional[KeyType] = None,
319 *,
320 update_relations: bool = True,
321 _check_legacy: bool = True,
322 ) -> SkeletonInstance:
323 """
324 Write current Skeleton to the datastore.
326 Stores the current data of this instance into the database.
327 If an *key* value is set to the object, this entity will ne updated;
328 Otherwise a new entity will be created.
330 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`.
332 :param key: Allows to specify a key that is set to the skeleton and used for writing.
333 :param update_relations: If False, this entity won't be marked dirty;
334 This avoids from being fetched by the background task updating relations.
336 :returns: The Skeleton.
337 """
338 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first!
339 if _check_legacy and "toDB" in cls.__dict__:
340 with warnings.catch_warnings():
341 warnings.simplefilter("ignore", DeprecationWarning)
342 return cls.toDB(skel, update_relations=update_relations)
344 assert skel.renderPreparation is None, "Cannot modify values while rendering"
346 def __txn_write(write_skel):
347 db_key = write_skel["key"]
348 skel = write_skel.skeletonCls()
350 blob_list = set()
351 change_list = []
352 old_copy = {}
353 # Load the current values from Datastore or create a new, empty db.Entity
354 if not db_key:
355 # We'll generate the key we'll be stored under early so we can use it for locks etc
356 db_key = db.allocate_ids(skel.kindName)[0]
357 skel.dbEntity = db.Entity(db_key)
358 is_add = True
359 else:
360 db_key = db.key_helper(db_key, skel.kindName)
361 if db_obj := db.get(db_key):
362 skel.dbEntity = db_obj
363 old_copy = {k: v for k, v in skel.dbEntity.items()}
364 is_add = False
365 else:
366 skel.dbEntity = db.Entity(db_key)
367 is_add = True
369 skel.dbEntity.setdefault("viur", {})
371 # Merge values and assemble unique properties
372 # Move accessed Values from srcSkel over to skel
373 skel.accessedValues = write_skel.accessedValues
375 write_skel["key"] = skel["key"] = db_key # Ensure key stays set
376 write_skel.dbEntity = skel.dbEntity # update write_skel's dbEntity
378 for bone_name, bone in skel.items():
379 if bone_name == "key": # Explicitly skip key on top-level - this had been set above
380 continue
382 # Allow bones to perform outstanding "magic" operations before saving to db
383 bone.performMagic(skel, bone_name, isAdd=is_add) # FIXME VIUR4: ANY MAGIC IN OUR CODE IS DEPRECATED!!!
385 if not (bone_name in skel.accessedValues or bone.compute) and bone_name not in skel.dbEntity:
386 _ = skel[bone_name] # Ensure the datastore is filled with the default value
388 if (
389 bone_name in skel.accessedValues or bone.compute # We can have a computed value on store
390 or bone_name not in skel.dbEntity # It has not been written and is not in the database
391 ):
392 # Serialize bone into entity
393 try:
394 bone.serialize(skel, bone_name, True)
395 except Exception as e:
396 logging.error(
397 f"Failed to serialize {bone_name=} ({bone=}): {skel.accessedValues[bone_name]=}"
398 )
399 raise e
401 # Obtain referenced blobs
402 blob_list.update(bone.getReferencedBlobs(skel, bone_name))
404 # Check if the value has actually changed
405 if skel.dbEntity.get(bone_name) != old_copy.get(bone_name):
406 change_list.append(bone_name)
408 # Lock hashes from bones that must have unique values
409 if bone.unique:
410 # Remember old hashes for bones that must have an unique value
411 old_unique_values = []
413 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]:
414 old_unique_values = skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"]
415 # Check if the property is unique
416 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name)
417 new_lock_kind = f"{skel.kindName}_{bone_name}_uniquePropertyIndex"
418 for new_lock_value in new_unique_values:
419 new_lock_key = db.Key(new_lock_kind, new_lock_value)
420 if lock_db_obj := db.get(new_lock_key):
422 # There's already a lock for that value, check if we hold it
423 if lock_db_obj["references"] != skel.dbEntity.key.id_or_name:
424 # This value has already been claimed, and not by us
425 # TODO: Use a custom exception class which is catchable with an try/except
426 raise ValueError(
427 f"The unique value {skel[bone_name]!r} of bone {bone_name!r} "
428 f"has been recently claimed (by {new_lock_key=}).")
429 else:
430 # This value is locked for the first time, create a new lock-object
431 lock_obj = db.Entity(new_lock_key)
432 lock_obj["references"] = skel.dbEntity.key.id_or_name
433 db.put(lock_obj)
434 if new_lock_value in old_unique_values:
435 old_unique_values.remove(new_lock_value)
436 skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] = new_unique_values
438 # Remove any lock-object we're holding for values that we don't have anymore
439 for old_unique_value in old_unique_values:
440 # Try to delete the old lock
442 old_lock_key = db.Key(f"{skel.kindName}_{bone_name}_uniquePropertyIndex", old_unique_value)
443 if old_lock_obj := db.get(old_lock_key):
444 if old_lock_obj["references"] != skel.dbEntity.key.id_or_name:
446 # We've been supposed to have that lock - but we don't.
447 # Don't remove that lock as it now belongs to a different entry
448 logging.critical("Detected Database corruption! A Value-Lock had been reassigned!")
449 else:
450 # It's our lock which we don't need anymore
451 db.delete(old_lock_key)
452 else:
453 logging.critical("Detected Database corruption! Could not delete stale lock-object!")
455 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4
456 skel.dbEntity.pop("viur_incomming_relational_locks", None)
458 # Ensure the SEO-Keys are up-to-date
459 last_requested_seo_keys = skel.dbEntity["viur"].get("viurLastRequestedSeoKeys") or {}
460 last_set_seo_keys = skel.dbEntity["viur"].get("viurCurrentSeoKeys") or {}
461 # Filter garbage serialized into this field by the SeoKeyBone
462 last_set_seo_keys = {k: v for k, v in last_set_seo_keys.items() if not k.startswith("_") and v}
464 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict):
465 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = {}
467 if current_seo_keys := skel.getCurrentSEOKeys():
468 # Convert to lower-case and remove certain characters
469 for lang, value in current_seo_keys.items():
470 current_seo_keys[lang] = value.lower().translate(Skeleton.__seo_key_trans).strip()
472 for language in (conf.i18n.available_languages or [conf.i18n.default_language]):
473 if current_seo_keys and language in current_seo_keys:
474 current_seo_key = current_seo_keys[language]
476 if current_seo_key != last_requested_seo_keys.get(language): # This one is new or has changed
477 new_seo_key = current_seo_keys[language]
479 for _ in range(0, 3):
480 entry_using_key = db.Query(skel.kindName).filter(
481 "viur.viurActiveSeoKeys =", new_seo_key).getEntry()
483 if entry_using_key and entry_using_key.key != skel.dbEntity.key:
484 # It's not unique; append a random string and try again
485 new_seo_key = f"{current_seo_keys[language]}-{utils.string.random(5).lower()}"
487 else:
488 # We found a new SeoKey
489 break
490 else:
491 raise ValueError("Could not generate an unique seo key in 3 attempts")
492 else:
493 new_seo_key = current_seo_key
494 last_set_seo_keys[language] = new_seo_key
496 else:
497 # We'll use the database-key instead
498 last_set_seo_keys[language] = str(skel.dbEntity.key.id_or_name)
500 # Store the current, active key for that language
501 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] = last_set_seo_keys[language]
503 skel.dbEntity["viur"].setdefault("viurActiveSeoKeys", [])
504 for language, seo_key in last_set_seo_keys.items():
505 if skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] not in \
506 skel.dbEntity["viur"]["viurActiveSeoKeys"]:
507 # Ensure the current, active seo key is in the list of all seo keys
508 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key)
509 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]:
510 # Ensure that key is also in there
511 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name))
512 # Trim to the last 200 used entries
513 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200]
514 # Store lastRequestedKeys so further updates can run more efficient
515 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys
517 # mark entity as "dirty" when update_relations is set, to zero otherwise.
518 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0
520 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity)
522 # Allow the database adapter to apply last minute changes to the object
523 for adapter in skel.database_adapters:
524 adapter.prewrite(skel, is_add, change_list)
526 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name
527 def fixDotNames(entity):
528 for k, v in list(entity.items()):
529 if isinstance(v, dict):
530 for k2, v2 in list(entity.items()):
531 if k2.startswith(f"{k}."):
532 del entity[k2]
533 backupKey = k2.replace(".", "__")
534 entity[backupKey] = v2
535 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey}
536 fixDotNames(v)
537 elif isinstance(v, list):
538 for x in v:
539 if isinstance(x, dict):
540 fixDotNames(x)
542 # FIXME: REMOVE IN VIUR4
543 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2
544 fixDotNames(skel.dbEntity)
546 # Write the core entry back
547 db.put(skel.dbEntity)
549 # Now write the blob-lock object
550 blob_list = skel.preProcessBlobLocks(blob_list)
551 if blob_list is None:
552 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?")
553 if None in blob_list:
554 msg = f"None is not valid in {blob_list=}"
555 logging.error(msg)
556 raise ValueError(msg)
558 if not is_add and (old_blob_lock_obj := db.get(db.Key("viur-blob-locks", db_key.id_or_name))):
559 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list
560 old_blob_lock_obj["active_blob_references"] = list(blob_list)
561 if old_blob_lock_obj["old_blob_references"] is None:
562 old_blob_lock_obj["old_blob_references"] = list(removed_blobs)
563 else:
564 old_blob_refs = set(old_blob_lock_obj["old_blob_references"])
565 old_blob_refs.update(removed_blobs) # Add removed blobs
566 old_blob_refs -= blob_list # Remove active blobs
567 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs)
569 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"])
570 old_blob_lock_obj["is_stale"] = False
571 db.put(old_blob_lock_obj)
572 else: # We need to create a new blob-lock-object
573 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name))
574 blob_lock_obj["active_blob_references"] = list(blob_list)
575 blob_lock_obj["old_blob_references"] = []
576 blob_lock_obj["has_old_blob_references"] = False
577 blob_lock_obj["is_stale"] = False
578 db.put(blob_lock_obj)
580 return skel.dbEntity.key, write_skel, change_list, is_add
582 # Parse provided key, if any, and set it to skel["key"]
583 if key:
584 skel["key"] = db.key_helper(key, skel.kindName)
586 if skel._cascade_deletion is True:
587 if skel["key"]:
588 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}")
589 skel.delete()
591 return skel
593 # Run transactional function
594 if db.is_in_transaction():
595 key, skel, change_list, is_add = __txn_write(skel)
596 else:
597 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel)
599 for bone_name, bone in skel.items():
600 bone.postSavedHandler(skel, bone_name, key)
602 skel.postSavedHandler(key, skel.dbEntity)
604 if update_relations and not is_add:
605 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually
606 tasks.update_relations(key, changed_bones=change_list, _countdown=10)
608 else: # Update all inbound relations, regardless of which bones they mirror
609 tasks.update_relations(key)
611 # Trigger the database adapter of the changes made to the entry
612 for adapter in skel.database_adapters:
613 adapter.write(skel, is_add, change_list)
615 return skel
617 @classmethod
618 def delete(cls, skel: SkeletonInstance, key: t.Optional[KeyType] = None) -> None:
619 """
620 Deletes the entity associated with the current Skeleton from the data store.
622 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used.
623 """
625 def __txn_delete(skel: SkeletonInstance, key: db.Key):
626 if not skel.read(key):
627 raise ValueError("This skeleton is not in the database (anymore?)!")
629 # Is there any relation to this Skeleton which prevents the deletion?
630 locked_relation = (
631 db.Query("viur-relations")
632 .filter("dest.__key__ =", key)
633 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value)
634 ).getEntry()
636 if locked_relation is not None:
637 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!")
639 # Ensure that any value lock objects remaining for this entry are being deleted
640 viur_data = skel.dbEntity.get("viur") or {}
642 for boneName, bone in skel.items():
643 bone.delete(skel, boneName)
644 if bone.unique:
645 flushList = []
646 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []:
647 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)
648 lockObj = db.get(lockKey)
649 if not lockObj:
650 logging.error(f"{lockKey=} missing!")
651 elif lockObj["references"] != key.id_or_name:
652 logging.error(
653 f"""{key!r} does not hold lock for {lockKey!r}""")
654 else:
655 flushList.append(lockObj)
656 if flushList:
657 db.delete(flushList)
659 # Delete the blob-key lock object
660 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name)
661 lockObj = db.get(lockObjectKey)
663 if lockObj is not None:
664 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None:
665 db.delete(lockObjectKey) # Nothing to do here
666 else:
667 if lockObj["old_blob_references"] is None:
668 # No old stale entries, move active_blob_references -> old_blob_references
669 lockObj["old_blob_references"] = lockObj["active_blob_references"]
670 elif lockObj["active_blob_references"] is not None:
671 # Append the current references to the list of old & stale references
672 lockObj["old_blob_references"] += lockObj["active_blob_references"]
673 lockObj["active_blob_references"] = [] # There are no active ones left
674 lockObj["is_stale"] = True
675 lockObj["has_old_blob_references"] = True
676 db.put(lockObj)
678 db.delete(key)
679 tasks.update_relations(key)
681 if key := (key or skel["key"]):
682 key = db.key_helper(key, skel.kindName)
683 else:
684 raise ValueError("This skeleton has no key!")
686 # Full skeleton is required to have all bones!
687 skel = skeletonByKind(skel.kindName)()
689 if db.is_in_transaction():
690 __txn_delete(skel, key)
691 else:
692 db.run_in_transaction(__txn_delete, skel, key)
694 for boneName, bone in skel.items():
695 bone.postDeletedHandler(skel, boneName, key)
697 skel.postDeletedHandler(key)
699 # Inform the custom DB Adapter
700 for adapter in skel.database_adapters:
701 adapter.delete(skel)
703 @classmethod
704 def patch(
705 cls,
706 skel: SkeletonInstance,
707 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {},
708 *,
709 key: t.Optional[db.Key | int | str] = None,
710 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None,
711 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None,
712 update_relations: bool = True,
713 ignore: t.Optional[t.Iterable[str]] = (),
714 retry: int = 0,
715 ) -> SkeletonInstance:
716 """
717 Performs an edit operation on a Skeleton within a transaction.
719 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the
720 given Skeleton and its underlying database entity.
722 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts,
723 a callable can also be given that can individually modify the Skeleton that is edited.
725 :param values: A dict of key-values to update on the entry, or a callable that is executed within
726 the transaction.
728 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the
729 given value, which can be used for counters.
730 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
731 If not provided, skel["key"] will be used.
732 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating.
733 If something fails within this check, an AssertionError is being raised.
734 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
735 given key does not exist.
736 :param update_relations: Trigger update relations task on success. Defaults to False.
737 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list,
738 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally)
739 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED!
741 If the function does not raise an Exception, all went well.
742 The function always returns the input Skeleton.
744 Raises:
745 ValueError: In case parameters where given wrong or incomplete.
746 AssertionError: In case an asserted check parameter did not match.
747 ReadFromClientException: In case a skel.fromClient() failed with a high severity.
748 """
750 # Transactional function
751 def __update_txn():
752 # Try to read the skeleton, create on demand
753 if not skel.read(key):
754 if create is None or create is False:
755 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.")
757 if not (key or skel["key"]) and create in (False, None):
758 return ValueError("No valid key provided")
760 if key or skel["key"]:
761 skel["key"] = db.key_helper(key or skel["key"], skel.kindName)
763 if isinstance(create, dict):
764 if create and not skel.fromClient(create, amend=True, ignore=ignore):
765 raise ReadFromClientException(skel.errors)
766 elif callable(create):
767 create(skel)
768 elif create is not True:
769 raise ValueError("'create' must either be dict or a callable.")
771 # Handle check
772 if isinstance(check, dict):
773 for bone, value in check.items():
774 if skel[bone] != value:
775 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}")
777 elif callable(check):
778 check(skel)
780 # Set values
781 if isinstance(values, dict):
782 if values and not skel.fromClient(values, amend=True, ignore=ignore):
783 raise ReadFromClientException(skel.errors)
785 # Special-feature: "+" and "-" prefix for simple calculations
786 # TODO: This can maybe integrated into skel.fromClient() later...
787 for name, value in values.items():
788 match name[0]:
789 case "+": # Increment by value?
790 skel[name[1:]] += value
791 case "-": # Decrement by value?
792 skel[name[1:]] -= value
794 elif callable(values):
795 values(skel)
797 else:
798 raise ValueError("'values' must either be dict or a callable.")
800 return skel.write(update_relations=update_relations)
802 if not db.is_in_transaction():
803 # Retry loop
804 while True:
805 try:
806 return db.run_in_transaction(__update_txn)
808 except RuntimeError as e:
809 retry -= 1
810 if retry < 0:
811 raise
813 logging.debug(f"{e}, retrying {retry} more times")
815 time.sleep(1)
816 else:
817 return __update_txn()
819 @classmethod
820 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks):
821 """
822 Can be overridden to modify the list of blobs referenced by this skeleton
823 """
824 return locks
826 @classmethod
827 def preProcessSerializedData(cls, skel: SkeletonInstance, entity):
828 """
829 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually
830 written to the data store.
831 """
832 return entity
834 @classmethod
835 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj):
836 """
837 Can be overridden to perform further actions after the entity has been written
838 to the data store.
839 """
840 pass
842 @classmethod
843 def postDeletedHandler(cls, skel: SkeletonInstance, key):
844 """
845 Can be overridden to perform further actions after the entity has been deleted
846 from the data store.
847 """
848 pass
850 @classmethod
851 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]:
852 """
853 Should be overridden to return a dictionary of language -> SEO-Friendly key
854 this entry should be reachable under. How theses names are derived are entirely up to the application.
855 If the name is already in use for this module, the server will automatically append some random string
856 to make it unique.
857 :return:
858 """
859 return