Coverage for / home / runner / work / viur-core / viur-core / viur / src / viur / core / skeleton / skeleton.py: 10%
411 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 14:23 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 14: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)
213 for lockValue in lockValues:
214 dbObj = db.get(db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue))
215 if dbObj and (not skel["key"] or dbObj["references"] != skel["key"].id_or_name):
216 # This value is taken (sadly, not by us)
217 complete = False
218 errorMsg = boneInstance.unique.message
219 skel.errors.append(
220 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, errorMsg, [boneName]))
222 # Check inter-Bone dependencies
223 for checkFunc in skel.interBoneValidations:
224 errors = checkFunc(skel)
225 if errors:
226 for error in errors:
227 if error.severity.value > 1:
228 complete = False
229 if conf.debug.skeleton_from_client:
230 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}")
232 skel.errors.extend(errors)
234 return complete
236 @classmethod
237 @deprecated(
238 version="3.7.0",
239 reason="Use skel.read() instead of skel.fromDB()",
240 )
241 def fromDB(cls, skel: SkeletonInstance, key: db.KeyType) -> bool:
242 """
243 Deprecated function, replaced by Skeleton.read().
244 """
245 return bool(cls.read(skel, key, _check_legacy=False))
247 @classmethod
248 def read(
249 cls,
250 skel: SkeletonInstance,
251 key: t.Optional[db.KeyType] = None,
252 *,
253 create: bool | dict | t.Callable[[SkeletonInstance], None] = False,
254 _check_legacy: bool = True
255 ) -> t.Optional[SkeletonInstance]:
256 """
257 Read Skeleton with *key* from the datastore into the Skeleton.
258 If not key is given, skel["key"] will be used.
260 Reads all available data of entity kind *kindName* and the key *key*
261 from the Datastore into the Skeleton structure's bones. Any previous
262 data of the bones will discard.
264 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`.
266 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
267 If not provided, skel["key"] will be used.
268 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
269 given key does not exist, it will be created.
271 :returns: None on error, or the given SkeletonInstance on success.
273 """
274 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first!
275 if _check_legacy and "fromDB" in cls.__dict__:
276 with warnings.catch_warnings():
277 warnings.simplefilter("ignore", DeprecationWarning)
278 return cls.fromDB(skel, key=key)
280 assert skel.renderPreparation is None, "Cannot modify values while rendering"
282 try:
283 db_key = db.key_helper(key or skel["key"], skel.kindName)
284 except (ValueError, NotImplementedError): # This key did not parse
285 return None
287 if db_res := db.get(db_key):
288 skel.setEntity(db_res)
289 return skel
290 elif create in (False, None):
291 return None
292 elif isinstance(create, dict):
293 if create and not skel.fromClient(create, amend=True, ignore=()):
294 raise ReadFromClientException(skel.errors)
295 elif callable(create):
296 create(skel)
297 elif create is not True:
298 raise ValueError("'create' must either be dict, a callable or True.")
300 skel["key"] = db_key
301 return skel.write()
303 @classmethod
304 @deprecated(
305 version="3.7.0",
306 reason="Use skel.write() instead of skel.toDB()",
307 )
308 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key:
309 """
310 Deprecated function, replaced by Skeleton.write().
311 """
313 # TODO: Remove with ViUR4
314 if "clearUpdateTag" in kwargs:
315 msg = "clearUpdateTag was replaced by update_relations"
316 warnings.warn(msg, DeprecationWarning, stacklevel=3)
317 logging.warning(msg, stacklevel=3)
318 update_relations = not kwargs["clearUpdateTag"]
320 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False)
321 return skel["key"]
323 @classmethod
324 def write(
325 cls,
326 skel: SkeletonInstance,
327 key: t.Optional[db.KeyType] = None,
328 *,
329 update_relations: bool = True,
330 _check_legacy: bool = True,
331 ) -> SkeletonInstance:
332 """
333 Write current Skeleton to the datastore.
335 Stores the current data of this instance into the database.
336 If an *key* value is set to the object, this entity will ne updated;
337 Otherwise a new entity will be created.
339 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`.
341 :param key: Allows to specify a key that is set to the skeleton and used for writing.
342 :param update_relations: If False, this entity won't be marked dirty;
343 This avoids from being fetched by the background task updating relations.
345 :returns: The Skeleton.
346 """
347 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first!
348 if _check_legacy and "toDB" in cls.__dict__:
349 with warnings.catch_warnings():
350 warnings.simplefilter("ignore", DeprecationWarning)
351 return cls.toDB(skel, update_relations=update_relations)
353 # FIXME: This check is incomplete as long it does nt check the entire tree!
354 assert skel.renderPreparation is None, "Cannot modify values while rendering"
356 def __txn_write(write_skel):
357 db_key = write_skel["key"]
358 skel = write_skel.skeletonCls()
360 blob_list = set()
361 change_list = []
362 old_copy = {}
363 # Load the current values from Datastore or create a new, empty db.Entity
364 if not db_key:
365 # We'll generate the key we'll be stored under early so we can use it for locks etc
366 db_key = db.allocate_ids(skel.kindName)[0]
367 skel.dbEntity = db.Entity(db_key)
368 is_add = True
369 else:
370 db_key = db.key_helper(db_key, skel.kindName)
371 if db_obj := db.get(db_key):
372 skel.dbEntity = db_obj
373 old_copy = {k: v for k, v in skel.dbEntity.items()}
374 is_add = False
375 else:
376 skel.dbEntity = db.Entity(db_key)
377 is_add = True
379 skel.dbEntity.setdefault("viur", {})
381 # Merge values and assemble unique properties
382 # Move accessed Values from srcSkel over to skel
383 skel.accessedValues = write_skel.accessedValues
385 write_skel["key"] = skel["key"] = db_key # Ensure key stays set
386 write_skel.dbEntity = skel.dbEntity # update write_skel's dbEntity
388 for bone_name, bone in skel.items():
389 if bone_name == "key": # Explicitly skip key on top-level - this had been set above
390 continue
392 # Allow bones to perform outstanding "magic" operations before saving to db
393 bone.performMagic(skel, bone_name, isAdd=is_add) # FIXME VIUR4: ANY MAGIC IN OUR CODE IS DEPRECATED!!!
395 if not (bone_name in skel.accessedValues or bone.compute) and bone_name not in skel.dbEntity:
396 _ = skel[bone_name] # Ensure the datastore is filled with the default value
398 if (
399 bone_name in skel.accessedValues or bone.compute # We can have a computed value on store
400 or bone_name not in skel.dbEntity # It has not been written and is not in the database
401 ):
402 # Serialize bone into entity
403 try:
404 bone.serialize(skel, bone_name, True)
405 except Exception as e:
406 logging.error(
407 f"Failed to serialize {bone_name=} ({bone=}): {skel.accessedValues[bone_name]=}"
408 )
409 raise e
411 # Obtain referenced blobs
412 blob_list.update(bone.getReferencedBlobs(skel, bone_name))
414 # Check if the value has actually changed
415 if skel.dbEntity.get(bone_name) != old_copy.get(bone_name):
416 change_list.append(bone_name)
418 # Lock hashes from bones that must have unique values
419 if bone.unique:
420 # Remember old hashes for bones that must have an unique value
421 old_unique_values = []
423 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]:
424 old_unique_values = skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"]
425 # Check if the property is unique
426 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name)
427 new_lock_kind = f"{skel.kindName}_{bone_name}_uniquePropertyIndex"
428 for new_lock_value in new_unique_values:
429 new_lock_key = db.Key(new_lock_kind, new_lock_value)
430 if lock_db_obj := db.get(new_lock_key):
432 # There's already a lock for that value, check if we hold it
433 if lock_db_obj["references"] != skel.dbEntity.key.id_or_name:
434 # This value has already been claimed, and not by us
435 # TODO: Use a custom exception class which is catchable with an try/except
436 raise ValueError(
437 f"The unique value {skel[bone_name]!r} of bone {bone_name!r} "
438 f"has been recently claimed (by {new_lock_key=}).")
439 else:
440 # This value is locked for the first time, create a new lock-object
441 lock_obj = db.Entity(new_lock_key)
442 lock_obj["references"] = skel.dbEntity.key.id_or_name
443 db.put(lock_obj)
444 if new_lock_value in old_unique_values:
445 old_unique_values.remove(new_lock_value)
446 skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] = new_unique_values
448 # Remove any lock-object we're holding for values that we don't have anymore
449 for old_unique_value in old_unique_values:
450 # Try to delete the old lock
452 old_lock_key = db.Key(f"{skel.kindName}_{bone_name}_uniquePropertyIndex", old_unique_value)
453 if old_lock_obj := db.get(old_lock_key):
454 if old_lock_obj["references"] != skel.dbEntity.key.id_or_name:
456 # We've been supposed to have that lock - but we don't.
457 # Don't remove that lock as it now belongs to a different entry
458 logging.critical("Detected Database corruption! A Value-Lock had been reassigned!")
459 else:
460 # It's our lock which we don't need anymore
461 db.delete(old_lock_key)
462 else:
463 logging.critical("Detected Database corruption! Could not delete stale lock-object!")
465 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4
466 skel.dbEntity.pop("viur_incomming_relational_locks", None)
468 # Ensure the SEO-Keys are up-to-date
469 last_requested_seo_keys = skel.dbEntity["viur"].get("viurLastRequestedSeoKeys") or {}
470 last_set_seo_keys = skel.dbEntity["viur"].get("viurCurrentSeoKeys") or {}
471 # Filter garbage serialized into this field by the SeoKeyBone
472 last_set_seo_keys = {k: v for k, v in last_set_seo_keys.items() if not k.startswith("_") and v}
474 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict):
475 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = {}
477 if current_seo_keys := skel.getCurrentSEOKeys():
478 # Convert to lower-case and remove certain characters
479 for lang, value in current_seo_keys.items():
480 current_seo_keys[lang] = value.lower().translate(Skeleton.__seo_key_trans).strip()
482 for language in (conf.i18n.available_languages or [conf.i18n.default_language]):
483 if current_seo_keys and language in current_seo_keys:
484 current_seo_key = current_seo_keys[language]
486 if current_seo_key != last_requested_seo_keys.get(language): # This one is new or has changed
487 new_seo_key = current_seo_keys[language]
489 for _ in range(0, 3):
490 entry_using_key = db.Query(skel.kindName).filter(
491 "viur.viurActiveSeoKeys =", new_seo_key).getEntry()
493 if entry_using_key and entry_using_key.key != skel.dbEntity.key:
494 # It's not unique; append a random string and try again
495 new_seo_key = f"{current_seo_keys[language]}-{utils.string.random(5).lower()}"
497 else:
498 # We found a new SeoKey
499 break
500 else:
501 raise ValueError("Could not generate an unique seo key in 3 attempts")
502 else:
503 new_seo_key = current_seo_key
504 last_set_seo_keys[language] = new_seo_key
506 else:
507 # We'll use the database-key instead
508 last_set_seo_keys[language] = str(skel.dbEntity.key.id_or_name)
510 # Store the current, active key for that language
511 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] = last_set_seo_keys[language]
513 skel.dbEntity["viur"].setdefault("viurActiveSeoKeys", [])
514 for language, seo_key in last_set_seo_keys.items():
515 if (
516 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language]
517 not in skel.dbEntity["viur"]["viurActiveSeoKeys"]
518 ):
519 # Ensure the current, active seo key is in the list of all seo keys
520 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key)
521 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]:
522 # Ensure that key is also in there
523 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name))
524 # Trim to the last 200 used entries
525 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200]
526 # Store lastRequestedKeys so further updates can run more efficient
527 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys
529 # mark entity as "dirty" when update_relations is set, to zero otherwise.
530 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0
532 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity)
534 # Allow the database adapter to apply last minute changes to the object
535 for adapter in skel.database_adapters:
536 adapter.prewrite(skel, is_add, change_list)
538 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name
539 def fixDotNames(entity):
540 for k, v in list(entity.items()):
541 if isinstance(v, dict):
542 for k2, v2 in list(entity.items()):
543 if k2.startswith(f"{k}."):
544 del entity[k2]
545 backupKey = k2.replace(".", "__")
546 entity[backupKey] = v2
547 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey}
548 fixDotNames(v)
549 elif isinstance(v, list):
550 for x in v:
551 if isinstance(x, dict):
552 fixDotNames(x)
554 # FIXME: REMOVE IN VIUR4
555 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2
556 fixDotNames(skel.dbEntity)
558 # Write the core entry back
559 db.put(skel.dbEntity)
561 # Now write the blob-lock object
562 blob_list = skel.preProcessBlobLocks(blob_list)
563 if blob_list is None:
564 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?")
565 if None in blob_list:
566 msg = f"None is not valid in {blob_list=}"
567 logging.error(msg)
568 raise ValueError(msg)
570 if not is_add and (old_blob_lock_obj := db.get(db.Key("viur-blob-locks", db_key.id_or_name))):
571 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list
572 old_blob_lock_obj["active_blob_references"] = list(blob_list)
573 if old_blob_lock_obj["old_blob_references"] is None:
574 old_blob_lock_obj["old_blob_references"] = list(removed_blobs)
575 else:
576 old_blob_refs = set(old_blob_lock_obj["old_blob_references"])
577 old_blob_refs.update(removed_blobs) # Add removed blobs
578 old_blob_refs -= blob_list # Remove active blobs
579 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs)
581 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"])
582 old_blob_lock_obj["is_stale"] = False
583 db.put(old_blob_lock_obj)
584 else: # We need to create a new blob-lock-object
585 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name))
586 blob_lock_obj["active_blob_references"] = list(blob_list)
587 blob_lock_obj["old_blob_references"] = []
588 blob_lock_obj["has_old_blob_references"] = False
589 blob_lock_obj["is_stale"] = False
590 db.put(blob_lock_obj)
592 return skel.dbEntity.key, write_skel, change_list, is_add
594 # Parse provided key, if any, and set it to skel["key"]
595 if key:
596 skel["key"] = db.key_helper(key, skel.kindName)
598 if skel._cascade_deletion is True:
599 if skel["key"]:
600 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}")
601 skel.delete()
603 return skel
605 # Run transactional function
606 if db.is_in_transaction():
607 key, skel, change_list, is_add = __txn_write(skel)
608 else:
609 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel)
611 for bone_name, bone in skel.items():
612 bone.postSavedHandler(skel, bone_name, key)
614 skel.postSavedHandler(key, skel.dbEntity)
616 if update_relations and not is_add:
617 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually
618 tasks.update_relations(key, changed_bones=change_list, _countdown=10)
620 else: # Update all inbound relations, regardless of which bones they mirror
621 tasks.update_relations(key)
623 # Trigger the database adapter of the changes made to the entry
624 for adapter in skel.database_adapters:
625 adapter.write(skel, is_add, change_list)
627 return skel
629 @classmethod
630 def delete(cls, skel: SkeletonInstance, key: t.Optional[db.KeyType] = None) -> None:
631 """
632 Deletes the entity associated with the current Skeleton from the data store.
634 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used.
635 """
637 def __txn_delete(skel: SkeletonInstance, key: db.Key):
638 if not skel.read(key):
639 raise ValueError("This skeleton is not in the database (anymore?)!")
641 # Is there any relation to this Skeleton which prevents the deletion?
642 locked_relation = (
643 db.Query("viur-relations")
644 .filter("dest.__key__ =", key)
645 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value)
646 ).getEntry()
648 if locked_relation is not None:
649 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!")
651 # Ensure that any value lock objects remaining for this entry are being deleted
652 viur_data = skel.dbEntity.get("viur") or {}
654 for boneName, bone in skel.items():
655 bone.delete(skel, boneName)
656 if bone.unique:
657 flushList = []
658 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []:
659 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)
660 lockObj = db.get(lockKey)
661 if not lockObj:
662 logging.error(f"{lockKey=} missing!")
663 elif lockObj["references"] != key.id_or_name:
664 logging.error(
665 f"""{key!r} does not hold lock for {lockKey!r}""")
666 else:
667 flushList.append(lockObj)
668 if flushList:
669 db.delete(flushList)
671 # Delete the blob-key lock object
672 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name)
673 lockObj = db.get(lockObjectKey)
675 if lockObj is not None:
676 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None:
677 db.delete(lockObjectKey) # Nothing to do here
678 else:
679 if lockObj["old_blob_references"] is None:
680 # No old stale entries, move active_blob_references -> old_blob_references
681 lockObj["old_blob_references"] = lockObj["active_blob_references"]
682 elif lockObj["active_blob_references"] is not None:
683 # Append the current references to the list of old & stale references
684 lockObj["old_blob_references"] += lockObj["active_blob_references"]
685 lockObj["active_blob_references"] = [] # There are no active ones left
686 lockObj["is_stale"] = True
687 lockObj["has_old_blob_references"] = True
688 db.put(lockObj)
690 db.delete(key)
691 tasks.update_relations(key)
693 if key := (key or skel["key"]):
694 key = db.key_helper(key, skel.kindName)
695 else:
696 raise ValueError("This skeleton has no key!")
698 # Full skeleton is required to have all bones!
699 skel = skeletonByKind(skel.kindName)()
701 if db.is_in_transaction():
702 __txn_delete(skel, key)
703 else:
704 db.run_in_transaction(__txn_delete, skel, key)
706 for boneName, bone in skel.items():
707 bone.postDeletedHandler(skel, boneName, key)
709 skel.postDeletedHandler(key)
711 # Inform the custom DB Adapter
712 for adapter in skel.database_adapters:
713 adapter.delete(skel)
715 @classmethod
716 def patch(
717 cls,
718 skel: SkeletonInstance,
719 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {},
720 *,
721 key: t.Optional[db.Key | int | str] = None,
722 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None,
723 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None,
724 update_relations: bool = True,
725 ignore: t.Optional[t.Iterable[str]] = (),
726 internal: bool = True,
727 retry: int = 0,
728 ) -> SkeletonInstance:
729 """
730 Performs an edit operation on a Skeleton within a transaction.
732 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the
733 given Skeleton and its underlying database entity.
735 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts,
736 a callable can also be given that can individually modify the Skeleton that is edited.
738 :param values: A dict of key-values to update on the entry, or a callable that is executed within
739 the transaction.
741 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the
742 given value, which can be used for counters.
743 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
744 If not provided, skel["key"] will be used.
745 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating.
746 If something fails within this check, an AssertionError is being raised.
747 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
748 given key does not exist.
749 :param update_relations: Trigger update relations task on success. Defaults to False.
750 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list,
751 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally)
752 :param internal: Internal patch does ignore any NotSet and Empty errors that may raise in skel.fromClient()
753 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED!
755 If the function does not raise an Exception, all went well.
756 The function always returns the input Skeleton.
758 Raises:
759 ValueError: In case parameters where given wrong or incomplete.
760 AssertionError: In case an asserted check parameter did not match.
761 ReadFromClientException: In case a skel.fromClient() failed with a high severity.
762 """
764 # Transactional function
765 def __update_txn():
766 # Try to read the skeleton, create on demand
767 if not skel.read(key):
768 if create is None or create is False:
769 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.")
771 if not (key or skel["key"]) and create in (False, None):
772 return ValueError("No valid key provided")
774 if key or skel["key"]:
775 skel["key"] = db.key_helper(key or skel["key"], skel.kindName)
777 if isinstance(create, dict):
778 if create and not skel.fromClient(create, amend=True, ignore=ignore):
779 raise ReadFromClientException(skel.errors)
780 elif callable(create):
781 create(skel)
782 elif create is not True:
783 raise ValueError("'create' must either be dict or a callable.")
785 # Handle check
786 if isinstance(check, dict):
787 for bone, value in check.items():
788 if skel[bone] != value:
789 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}")
791 elif callable(check):
792 check(skel)
794 # Set values
795 if isinstance(values, dict):
796 if values and not skel.fromClient(values, amend=True, ignore=ignore) and not internal:
797 raise ReadFromClientException(skel.errors)
799 # In case we're in internal-mode, only raise fatal errors.
800 if skel.errors and internal:
801 for error in skel.errors:
802 if error.severity in (
803 ReadFromClientErrorSeverity.Invalid,
804 ReadFromClientErrorSeverity.InvalidatesOther,
805 ):
806 raise ReadFromClientException(skel.errors)
808 # otherwise, ignore any reported errors
809 skel.errors.clear()
811 # Special-feature: "+" and "-" prefix for simple calculations
812 # TODO: This can maybe integrated into skel.fromClient() later...
813 for name, value in values.items():
814 match name[0]:
815 case "+": # Increment by value?
816 skel[name[1:]] += value
817 case "-": # Decrement by value?
818 skel[name[1:]] -= value
820 elif callable(values):
821 values(skel)
823 else:
824 raise ValueError("'values' must either be dict or a callable.")
826 return skel.write(update_relations=update_relations)
828 if not db.is_in_transaction():
829 # Retry loop
830 while True:
831 try:
832 return db.run_in_transaction(__update_txn)
834 except RuntimeError as e:
835 retry -= 1
836 if retry < 0:
837 raise
839 logging.debug(f"{e}, retrying {retry} more times")
841 time.sleep(1)
842 else:
843 return __update_txn()
845 @classmethod
846 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks):
847 """
848 Can be overridden to modify the list of blobs referenced by this skeleton
849 """
850 return locks
852 @classmethod
853 def preProcessSerializedData(cls, skel: SkeletonInstance, entity):
854 """
855 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually
856 written to the data store.
857 """
858 return entity
860 @classmethod
861 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj):
862 """
863 Can be overridden to perform further actions after the entity has been written
864 to the data store.
865 """
866 pass
868 @classmethod
869 def postDeletedHandler(cls, skel: SkeletonInstance, key):
870 """
871 Can be overridden to perform further actions after the entity has been deleted
872 from the data store.
873 """
874 pass
876 @classmethod
877 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]:
878 """
879 Should be overridden to return a dictionary of language -> SEO-Friendly key
880 this entry should be reachable under. How theses names are derived are entirely up to the application.
881 If the name is already in use for this module, the server will automatically append some random string
882 to make it unique.
883 :return:
884 """
885 return