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.0, created at 2025-12-11 20:18 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 20:18 +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.raw import RawBone
24from ..bones.relational import RelationalConsistency
25from ..bones.key import KeyBone
26from ..bones.date import DateBone
27from ..bones.string import StringBone
29if t.TYPE_CHECKING: 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true
30 from .instance import SkeletonInstance
31 from .adapter import DatabaseAdapter
34class SeoKeyBone(StringBone):
35 """
36 Special kind of StringBone saving its contents as `viurCurrentSeoKeys` into the entity's `viur` dict.
37 """
39 def unserialize(self, skel: SkeletonInstance, name: str) -> bool:
40 try:
41 skel.accessedValues[name] = skel.dbEntity["viur"]["viurCurrentSeoKeys"]
42 except KeyError:
43 skel.accessedValues[name] = self.getDefaultValue(skel)
45 def serialize(self, skel: SkeletonInstance, name: str, parentIndexed: bool) -> bool:
46 # Serialize also to skel["viur"]["viurCurrentSeoKeys"], so we can use this bone in relations
47 if name in skel.accessedValues:
48 newVal = skel.accessedValues[name]
49 if not skel.dbEntity.get("viur"):
50 skel.dbEntity["viur"] = db.Entity()
51 res = db.Entity()
52 res["_viurLanguageWrapper_"] = True
53 for language in (self.languages or []):
54 if not self.indexed:
55 res.exclude_from_indexes.add(language)
56 res[language] = None
57 if language in newVal:
58 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed)
59 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = res
60 return True
63class Skeleton(BaseSkeleton, metaclass=MetaSkel):
64 kindName: str = _UNDEFINED_KINDNAME
65 """
66 Specifies the entity kind name this Skeleton is associated with.
67 Will be determined automatically when not explicitly set.
68 """
70 database_adapters: DatabaseAdapter | t.Iterable[DatabaseAdapter] | None = _UNDEFINED_KINDNAME
71 """
72 Custom database adapters.
73 Allows to hook special functionalities that during skeleton modifications.
74 """
76 subSkels = {} # List of pre-defined sub-skeletons of this type
78 interBoneValidations: list[
79 t.Callable[[Skeleton], list[ReadFromClientError]]] = [] # List of functions checking inter-bone dependencies
81 __seo_key_trans = str.maketrans(
82 {"<": "",
83 ">": "",
84 "\"": "",
85 "'": "",
86 "\n": "",
87 "\0": "",
88 "/": "",
89 "\\": "",
90 "?": "",
91 "&": "",
92 "#": ""
93 })
95 # The "key" bone stores the current database key of this skeleton.
96 # Warning: Assigning to this bones value now *will* set the key
97 # it gets stored in. Must be kept readOnly to avoid security-issues with add/edit.
98 key = KeyBone(
99 descr="Key"
100 )
102 shortkey = RawBone(
103 descr="Shortkey",
104 compute=Compute(lambda skel: skel["key"].id_or_name if skel["key"] else None),
105 readOnly=True,
106 visible=False,
107 searchable=True,
108 )
110 name = StringBone(
111 descr="Name",
112 visible=False,
113 compute=Compute(
114 fn=lambda skel: f"{skel["key"].kind}/{skel["key"].id_or_name}" if skel["key"] else None,
115 interval=ComputeInterval(ComputeMethod.OnWrite)
116 )
117 )
119 # The date (including time) when this entry has been created
120 creationdate = DateBone(
121 descr="created at",
122 readOnly=True,
123 visible=False,
124 indexed=True,
125 compute=Compute(
126 lambda: utils.utcNow().replace(microsecond=0),
127 interval=ComputeInterval(ComputeMethod.Once)
128 ),
129 )
131 # The last date (including time) when this entry has been updated
133 changedate = DateBone(
134 descr="updated at",
135 readOnly=True,
136 visible=False,
137 indexed=True,
138 compute=Compute(
139 lambda: utils.utcNow().replace(microsecond=0),
140 interval=ComputeInterval(ComputeMethod.OnWrite)
141 ),
142 )
144 viurCurrentSeoKeys = SeoKeyBone(
145 descr="SEO-Keys",
146 readOnly=True,
147 visible=False,
148 languages=conf.i18n.available_languages
149 )
151 def __repr__(self):
152 return "<skeleton %s with data=%r>" % (self.kindName, {k: self[k] for k in self.keys()})
154 def __str__(self):
155 return str({k: self[k] for k in self.keys()})
157 def __init__(self, *args, **kwargs):
158 super(Skeleton, self).__init__(*args, **kwargs)
159 assert self.kindName and self.kindName is not _UNDEFINED_KINDNAME, "You must set kindName on this skeleton!"
161 @classmethod
162 def all(cls, skel, **kwargs) -> db.Query:
163 """
164 Create a query with the current Skeletons kindName.
166 :returns: A db.Query object which allows for entity filtering and sorting.
167 """
168 return db.Query(skel.kindName, srcSkelClass=skel, **kwargs)
170 @classmethod
171 def fromClient(
172 cls,
173 skel: SkeletonInstance,
174 data: dict[str, list[str] | str],
175 *,
176 amend: bool = False,
177 ignore: t.Optional[t.Iterable[str]] = None,
178 ) -> bool:
179 """
180 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that
181 the values retrieved from *data* are checked against the bones and their validity checks.
183 Even if this function returns False, all bones are guaranteed to be in a valid state.
184 The ones which have been read correctly are set to their valid values;
185 Bones with invalid values are set back to a safe default (None in most cases).
186 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading
187 data with this function failed (through this might violates the assumed consistency-model).
189 :param skel: The skeleton instance to be filled.
190 :param data: Dictionary from which the data is read.
191 :param amend: Defines whether content of data may be incomplete to amend the skel,
192 which is useful for edit-actions.
193 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None.
195 :returns: True if all data was successfully read and complete. \
196 False otherwise (e.g. some required fields where missing or where invalid).
197 """
198 assert skel.renderPreparation is None, "Cannot modify values while rendering"
200 # Load data into this skeleton
201 complete = bool(data) and super().fromClient(skel, data, amend=amend, ignore=ignore)
203 if (
204 not data # in case data is empty
205 or (len(data) == 1 and "key" in data)
206 or (utils.parse.bool(data.get("nomissing")))
207 ):
208 skel.errors = []
210 # Check if all unique values are available
211 for boneName, boneInstance in skel.items():
212 if boneInstance.unique:
213 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName)
214 for lockValue in lockValues:
215 dbObj = db.get(db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue))
216 if dbObj and (not skel["key"] or dbObj["references"] != skel["key"].id_or_name):
217 # This value is taken (sadly, not by us)
218 complete = False
219 errorMsg = boneInstance.unique.message
220 skel.errors.append(
221 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, errorMsg, [boneName]))
223 # Check inter-Bone dependencies
224 for checkFunc in skel.interBoneValidations:
225 errors = checkFunc(skel)
226 if errors:
227 for error in errors:
228 if error.severity.value > 1:
229 complete = False
230 if conf.debug.skeleton_from_client:
231 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}")
233 skel.errors.extend(errors)
235 return complete
237 @classmethod
238 @deprecated(
239 version="3.7.0",
240 reason="Use skel.read() instead of skel.fromDB()",
241 )
242 def fromDB(cls, skel: SkeletonInstance, key: KeyType) -> bool:
243 """
244 Deprecated function, replaced by Skeleton.read().
245 """
246 return bool(cls.read(skel, key, _check_legacy=False))
248 @classmethod
249 def read(
250 cls,
251 skel: SkeletonInstance,
252 key: t.Optional[KeyType] = None,
253 *,
254 create: bool | dict | t.Callable[[SkeletonInstance], None] = False,
255 _check_legacy: bool = True
256 ) -> t.Optional[SkeletonInstance]:
257 """
258 Read Skeleton with *key* from the datastore into the Skeleton.
259 If not key is given, skel["key"] will be used.
261 Reads all available data of entity kind *kindName* and the key *key*
262 from the Datastore into the Skeleton structure's bones. Any previous
263 data of the bones will discard.
265 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`.
267 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
268 If not provided, skel["key"] will be used.
269 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
270 given key does not exist, it will be created.
272 :returns: None on error, or the given SkeletonInstance on success.
274 """
275 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first!
276 if _check_legacy and "fromDB" in cls.__dict__:
277 with warnings.catch_warnings():
278 warnings.simplefilter("ignore", DeprecationWarning)
279 return cls.fromDB(skel, key=key)
281 assert skel.renderPreparation is None, "Cannot modify values while rendering"
283 try:
284 db_key = db.key_helper(key or skel["key"], skel.kindName)
285 except (ValueError, NotImplementedError): # This key did not parse
286 return None
288 if db_res := db.get(db_key):
289 skel.setEntity(db_res)
290 return skel
291 elif create in (False, None):
292 return None
293 elif isinstance(create, dict):
294 if create and not skel.fromClient(create, amend=True, ignore=()):
295 raise ReadFromClientException(skel.errors)
296 elif callable(create):
297 create(skel)
298 elif create is not True:
299 raise ValueError("'create' must either be dict, a callable or True.")
301 skel["key"] = db_key
302 return skel.write()
304 @classmethod
305 @deprecated(
306 version="3.7.0",
307 reason="Use skel.write() instead of skel.toDB()",
308 )
309 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key:
310 """
311 Deprecated function, replaced by Skeleton.write().
312 """
314 # TODO: Remove with ViUR4
315 if "clearUpdateTag" in kwargs:
316 msg = "clearUpdateTag was replaced by update_relations"
317 warnings.warn(msg, DeprecationWarning, stacklevel=3)
318 logging.warning(msg, stacklevel=3)
319 update_relations = not kwargs["clearUpdateTag"]
321 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False)
322 return skel["key"]
324 @classmethod
325 def write(
326 cls,
327 skel: SkeletonInstance,
328 key: t.Optional[KeyType] = None,
329 *,
330 update_relations: bool = True,
331 _check_legacy: bool = True,
332 ) -> SkeletonInstance:
333 """
334 Write current Skeleton to the datastore.
336 Stores the current data of this instance into the database.
337 If an *key* value is set to the object, this entity will ne updated;
338 Otherwise a new entity will be created.
340 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`.
342 :param key: Allows to specify a key that is set to the skeleton and used for writing.
343 :param update_relations: If False, this entity won't be marked dirty;
344 This avoids from being fetched by the background task updating relations.
346 :returns: The Skeleton.
347 """
348 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first!
349 if _check_legacy and "toDB" in cls.__dict__:
350 with warnings.catch_warnings():
351 warnings.simplefilter("ignore", DeprecationWarning)
352 return cls.toDB(skel, update_relations=update_relations)
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 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] not in \
516 skel.dbEntity["viur"]["viurActiveSeoKeys"]:
517 # Ensure the current, active seo key is in the list of all seo keys
518 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key)
519 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]:
520 # Ensure that key is also in there
521 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name))
522 # Trim to the last 200 used entries
523 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200]
524 # Store lastRequestedKeys so further updates can run more efficient
525 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys
527 # mark entity as "dirty" when update_relations is set, to zero otherwise.
528 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0
530 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity)
532 # Allow the database adapter to apply last minute changes to the object
533 for adapter in skel.database_adapters:
534 adapter.prewrite(skel, is_add, change_list)
536 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name
537 def fixDotNames(entity):
538 for k, v in list(entity.items()):
539 if isinstance(v, dict):
540 for k2, v2 in list(entity.items()):
541 if k2.startswith(f"{k}."):
542 del entity[k2]
543 backupKey = k2.replace(".", "__")
544 entity[backupKey] = v2
545 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey}
546 fixDotNames(v)
547 elif isinstance(v, list):
548 for x in v:
549 if isinstance(x, dict):
550 fixDotNames(x)
552 # FIXME: REMOVE IN VIUR4
553 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2
554 fixDotNames(skel.dbEntity)
556 # Write the core entry back
557 db.put(skel.dbEntity)
559 # Now write the blob-lock object
560 blob_list = skel.preProcessBlobLocks(blob_list)
561 if blob_list is None:
562 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?")
563 if None in blob_list:
564 msg = f"None is not valid in {blob_list=}"
565 logging.error(msg)
566 raise ValueError(msg)
568 if not is_add and (old_blob_lock_obj := db.get(db.Key("viur-blob-locks", db_key.id_or_name))):
569 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list
570 old_blob_lock_obj["active_blob_references"] = list(blob_list)
571 if old_blob_lock_obj["old_blob_references"] is None:
572 old_blob_lock_obj["old_blob_references"] = list(removed_blobs)
573 else:
574 old_blob_refs = set(old_blob_lock_obj["old_blob_references"])
575 old_blob_refs.update(removed_blobs) # Add removed blobs
576 old_blob_refs -= blob_list # Remove active blobs
577 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs)
579 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"])
580 old_blob_lock_obj["is_stale"] = False
581 db.put(old_blob_lock_obj)
582 else: # We need to create a new blob-lock-object
583 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name))
584 blob_lock_obj["active_blob_references"] = list(blob_list)
585 blob_lock_obj["old_blob_references"] = []
586 blob_lock_obj["has_old_blob_references"] = False
587 blob_lock_obj["is_stale"] = False
588 db.put(blob_lock_obj)
590 return skel.dbEntity.key, write_skel, change_list, is_add
592 # Parse provided key, if any, and set it to skel["key"]
593 if key:
594 skel["key"] = db.key_helper(key, skel.kindName)
596 if skel._cascade_deletion is True:
597 if skel["key"]:
598 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}")
599 skel.delete()
601 return skel
603 # Run transactional function
604 if db.is_in_transaction():
605 key, skel, change_list, is_add = __txn_write(skel)
606 else:
607 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel)
609 for bone_name, bone in skel.items():
610 bone.postSavedHandler(skel, bone_name, key)
612 skel.postSavedHandler(key, skel.dbEntity)
614 if update_relations and not is_add:
615 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually
616 tasks.update_relations(key, changed_bones=change_list, _countdown=10)
618 else: # Update all inbound relations, regardless of which bones they mirror
619 tasks.update_relations(key)
621 # Trigger the database adapter of the changes made to the entry
622 for adapter in skel.database_adapters:
623 adapter.write(skel, is_add, change_list)
625 return skel
627 @classmethod
628 def delete(cls, skel: SkeletonInstance, key: t.Optional[KeyType] = None) -> None:
629 """
630 Deletes the entity associated with the current Skeleton from the data store.
632 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used.
633 """
635 def __txn_delete(skel: SkeletonInstance, key: db.Key):
636 if not skel.read(key):
637 raise ValueError("This skeleton is not in the database (anymore?)!")
639 # Is there any relation to this Skeleton which prevents the deletion?
640 locked_relation = (
641 db.Query("viur-relations")
642 .filter("dest.__key__ =", key)
643 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value)
644 ).getEntry()
646 if locked_relation is not None:
647 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!")
649 # Ensure that any value lock objects remaining for this entry are being deleted
650 viur_data = skel.dbEntity.get("viur") or {}
652 for boneName, bone in skel.items():
653 bone.delete(skel, boneName)
654 if bone.unique:
655 flushList = []
656 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []:
657 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)
658 lockObj = db.get(lockKey)
659 if not lockObj:
660 logging.error(f"{lockKey=} missing!")
661 elif lockObj["references"] != key.id_or_name:
662 logging.error(
663 f"""{key!r} does not hold lock for {lockKey!r}""")
664 else:
665 flushList.append(lockObj)
666 if flushList:
667 db.delete(flushList)
669 # Delete the blob-key lock object
670 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name)
671 lockObj = db.get(lockObjectKey)
673 if lockObj is not None:
674 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None:
675 db.delete(lockObjectKey) # Nothing to do here
676 else:
677 if lockObj["old_blob_references"] is None:
678 # No old stale entries, move active_blob_references -> old_blob_references
679 lockObj["old_blob_references"] = lockObj["active_blob_references"]
680 elif lockObj["active_blob_references"] is not None:
681 # Append the current references to the list of old & stale references
682 lockObj["old_blob_references"] += lockObj["active_blob_references"]
683 lockObj["active_blob_references"] = [] # There are no active ones left
684 lockObj["is_stale"] = True
685 lockObj["has_old_blob_references"] = True
686 db.put(lockObj)
688 db.delete(key)
689 tasks.update_relations(key)
691 if key := (key or skel["key"]):
692 key = db.key_helper(key, skel.kindName)
693 else:
694 raise ValueError("This skeleton has no key!")
696 # Full skeleton is required to have all bones!
697 skel = skeletonByKind(skel.kindName)()
699 if db.is_in_transaction():
700 __txn_delete(skel, key)
701 else:
702 db.run_in_transaction(__txn_delete, skel, key)
704 for boneName, bone in skel.items():
705 bone.postDeletedHandler(skel, boneName, key)
707 skel.postDeletedHandler(key)
709 # Inform the custom DB Adapter
710 for adapter in skel.database_adapters:
711 adapter.delete(skel)
713 @classmethod
714 def patch(
715 cls,
716 skel: SkeletonInstance,
717 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {},
718 *,
719 key: t.Optional[db.Key | int | str] = None,
720 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None,
721 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None,
722 update_relations: bool = True,
723 ignore: t.Optional[t.Iterable[str]] = (),
724 internal: bool = True,
725 retry: int = 0,
726 ) -> SkeletonInstance:
727 """
728 Performs an edit operation on a Skeleton within a transaction.
730 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the
731 given Skeleton and its underlying database entity.
733 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts,
734 a callable can also be given that can individually modify the Skeleton that is edited.
736 :param values: A dict of key-values to update on the entry, or a callable that is executed within
737 the transaction.
739 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the
740 given value, which can be used for counters.
741 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
742 If not provided, skel["key"] will be used.
743 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating.
744 If something fails within this check, an AssertionError is being raised.
745 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
746 given key does not exist.
747 :param update_relations: Trigger update relations task on success. Defaults to False.
748 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list,
749 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally)
750 :param internal: Internal patch does ignore any NotSet and Empty errors that may raise in skel.fromClient()
751 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED!
753 If the function does not raise an Exception, all went well.
754 The function always returns the input Skeleton.
756 Raises:
757 ValueError: In case parameters where given wrong or incomplete.
758 AssertionError: In case an asserted check parameter did not match.
759 ReadFromClientException: In case a skel.fromClient() failed with a high severity.
760 """
762 # Transactional function
763 def __update_txn():
764 # Try to read the skeleton, create on demand
765 if not skel.read(key):
766 if create is None or create is False:
767 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.")
769 if not (key or skel["key"]) and create in (False, None):
770 return ValueError("No valid key provided")
772 if key or skel["key"]:
773 skel["key"] = db.key_helper(key or skel["key"], skel.kindName)
775 if isinstance(create, dict):
776 if create and not skel.fromClient(create, amend=True, ignore=ignore):
777 raise ReadFromClientException(skel.errors)
778 elif callable(create):
779 create(skel)
780 elif create is not True:
781 raise ValueError("'create' must either be dict or a callable.")
783 # Handle check
784 if isinstance(check, dict):
785 for bone, value in check.items():
786 if skel[bone] != value:
787 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}")
789 elif callable(check):
790 check(skel)
792 # Set values
793 if isinstance(values, dict):
794 if values and not skel.fromClient(values, amend=True, ignore=ignore) and not internal:
795 raise ReadFromClientException(skel.errors)
797 # In case we're in internal-mode, only raise fatal errors.
798 if skel.errors and internal:
799 for error in skel.errors:
800 if error.severity in (
801 ReadFromClientErrorSeverity.Invalid,
802 ReadFromClientErrorSeverity.InvalidatesOther,
803 ):
804 raise ReadFromClientException(skel.errors)
806 # otherwise, ignore any reported errors
807 skel.errors.clear()
809 # Special-feature: "+" and "-" prefix for simple calculations
810 # TODO: This can maybe integrated into skel.fromClient() later...
811 for name, value in values.items():
812 match name[0]:
813 case "+": # Increment by value?
814 skel[name[1:]] += value
815 case "-": # Decrement by value?
816 skel[name[1:]] -= value
818 elif callable(values):
819 values(skel)
821 else:
822 raise ValueError("'values' must either be dict or a callable.")
824 return skel.write(update_relations=update_relations)
826 if not db.is_in_transaction():
827 # Retry loop
828 while True:
829 try:
830 return db.run_in_transaction(__update_txn)
832 except RuntimeError as e:
833 retry -= 1
834 if retry < 0:
835 raise
837 logging.debug(f"{e}, retrying {retry} more times")
839 time.sleep(1)
840 else:
841 return __update_txn()
843 @classmethod
844 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks):
845 """
846 Can be overridden to modify the list of blobs referenced by this skeleton
847 """
848 return locks
850 @classmethod
851 def preProcessSerializedData(cls, skel: SkeletonInstance, entity):
852 """
853 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually
854 written to the data store.
855 """
856 return entity
858 @classmethod
859 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj):
860 """
861 Can be overridden to perform further actions after the entity has been written
862 to the data store.
863 """
864 pass
866 @classmethod
867 def postDeletedHandler(cls, skel: SkeletonInstance, key):
868 """
869 Can be overridden to perform further actions after the entity has been deleted
870 from the data store.
871 """
872 pass
874 @classmethod
875 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]:
876 """
877 Should be overridden to return a dictionary of language -> SEO-Friendly key
878 this entry should be reachable under. How theses names are derived are entirely up to the application.
879 If the name is already in use for this module, the server will automatically append some random string
880 to make it unique.
881 :return:
882 """
883 return