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