Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/skeleton.py: 10%

403 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1from __future__ import annotations # noqa: required for pre-defined annotations 

2 

3import logging 

4import time 

5import typing as t 

6import warnings 

7 

8from deprecated.sphinx import deprecated 

9 

10from viur.core import conf, db, errors, utils 

11 

12from .meta import BaseSkeleton, MetaSkel, KeyType, _UNDEFINED_KINDNAME 

13from . import tasks 

14from .utils import skeletonByKind 

15from ..bones.base import ( 

16 Compute, 

17 ComputeInterval, 

18 ComputeMethod, 

19 ReadFromClientException, 

20 ReadFromClientError, 

21 ReadFromClientErrorSeverity 

22) 

23from ..bones.relational import RelationalConsistency 

24from ..bones.key import KeyBone 

25from ..bones.date import DateBone 

26from ..bones.string import StringBone 

27 

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 

31 

32 

33class SeoKeyBone(StringBone): 

34 """ 

35 Special kind of StringBone saving its contents as `viurCurrentSeoKeys` into the entity's `viur` dict. 

36 """ 

37 

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) 

43 

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 

60 

61 

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 """ 

68 

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 """ 

74 

75 subSkels = {} # List of pre-defined sub-skeletons of this type 

76 

77 interBoneValidations: list[ 

78 t.Callable[[Skeleton], list[ReadFromClientError]]] = [] # List of functions checking inter-bone dependencies 

79 

80 __seo_key_trans = str.maketrans( 

81 {"<": "", 

82 ">": "", 

83 "\"": "", 

84 "'": "", 

85 "\n": "", 

86 "\0": "", 

87 "/": "", 

88 "\\": "", 

89 "?": "", 

90 "&": "", 

91 "#": "" 

92 }) 

93 

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 ) 

100 

101 name = StringBone( 

102 descr="Name", 

103 visible=False, 

104 compute=Compute( 

105 fn=lambda skel: f"{skel["key"].kind}/{skel["key"].id_or_name}" if skel["key"] else None, 

106 interval=ComputeInterval(ComputeMethod.OnWrite) 

107 ) 

108 ) 

109 

110 # The date (including time) when this entry has been created 

111 creationdate = DateBone( 

112 descr="created at", 

113 readOnly=True, 

114 visible=False, 

115 indexed=True, 

116 compute=Compute( 

117 lambda: utils.utcNow().replace(microsecond=0), 

118 interval=ComputeInterval(ComputeMethod.Once) 

119 ), 

120 ) 

121 

122 # The last date (including time) when this entry has been updated 

123 

124 changedate = DateBone( 

125 descr="updated at", 

126 readOnly=True, 

127 visible=False, 

128 indexed=True, 

129 compute=Compute( 

130 lambda: utils.utcNow().replace(microsecond=0), 

131 interval=ComputeInterval(ComputeMethod.OnWrite) 

132 ), 

133 ) 

134 

135 viurCurrentSeoKeys = SeoKeyBone( 

136 descr="SEO-Keys", 

137 readOnly=True, 

138 visible=False, 

139 languages=conf.i18n.available_languages 

140 ) 

141 

142 def __repr__(self): 

143 return "<skeleton %s with data=%r>" % (self.kindName, {k: self[k] for k in self.keys()}) 

144 

145 def __str__(self): 

146 return str({k: self[k] for k in self.keys()}) 

147 

148 def __init__(self, *args, **kwargs): 

149 super(Skeleton, self).__init__(*args, **kwargs) 

150 assert self.kindName and self.kindName is not _UNDEFINED_KINDNAME, "You must set kindName on this skeleton!" 

151 

152 @classmethod 

153 def all(cls, skel, **kwargs) -> db.Query: 

154 """ 

155 Create a query with the current Skeletons kindName. 

156 

157 :returns: A db.Query object which allows for entity filtering and sorting. 

158 """ 

159 return db.Query(skel.kindName, srcSkelClass=skel, **kwargs) 

160 

161 @classmethod 

162 def fromClient( 

163 cls, 

164 skel: SkeletonInstance, 

165 data: dict[str, list[str] | str], 

166 *, 

167 amend: bool = False, 

168 ignore: t.Optional[t.Iterable[str]] = None, 

169 ) -> bool: 

170 """ 

171 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that 

172 the values retrieved from *data* are checked against the bones and their validity checks. 

173 

174 Even if this function returns False, all bones are guaranteed to be in a valid state. 

175 The ones which have been read correctly are set to their valid values; 

176 Bones with invalid values are set back to a safe default (None in most cases). 

177 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading 

178 data with this function failed (through this might violates the assumed consistency-model). 

179 

180 :param skel: The skeleton instance to be filled. 

181 :param data: Dictionary from which the data is read. 

182 :param amend: Defines whether content of data may be incomplete to amend the skel, 

183 which is useful for edit-actions. 

184 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None. 

185 

186 :returns: True if all data was successfully read and complete. \ 

187 False otherwise (e.g. some required fields where missing or where invalid). 

188 """ 

189 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

190 

191 # Load data into this skeleton 

192 complete = bool(data) and super().fromClient(skel, data, amend=amend, ignore=ignore) 

193 

194 if ( 

195 not data # in case data is empty 

196 or (len(data) == 1 and "key" in data) 

197 or (utils.parse.bool(data.get("nomissing"))) 

198 ): 

199 skel.errors = [] 

200 

201 # Check if all unique values are available 

202 for boneName, boneInstance in skel.items(): 

203 if boneInstance.unique: 

204 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

205 for lockValue in lockValues: 

206 dbObj = db.get(db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue)) 

207 if dbObj and (not skel["key"] or dbObj["references"] != skel["key"].id_or_name): 

208 # This value is taken (sadly, not by us) 

209 complete = False 

210 errorMsg = boneInstance.unique.message 

211 skel.errors.append( 

212 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, errorMsg, [boneName])) 

213 

214 # Check inter-Bone dependencies 

215 for checkFunc in skel.interBoneValidations: 

216 errors = checkFunc(skel) 

217 if errors: 

218 for error in errors: 

219 if error.severity.value > 1: 

220 complete = False 

221 if conf.debug.skeleton_from_client: 

222 logging.debug(f"{cls.kindName}: {error.fieldPath}: {error.errorMessage!r}") 

223 

224 skel.errors.extend(errors) 

225 

226 return complete 

227 

228 @classmethod 

229 @deprecated( 

230 version="3.7.0", 

231 reason="Use skel.read() instead of skel.fromDB()", 

232 ) 

233 def fromDB(cls, skel: SkeletonInstance, key: KeyType) -> bool: 

234 """ 

235 Deprecated function, replaced by Skeleton.read(). 

236 """ 

237 return bool(cls.read(skel, key, _check_legacy=False)) 

238 

239 @classmethod 

240 def read( 

241 cls, 

242 skel: SkeletonInstance, 

243 key: t.Optional[KeyType] = None, 

244 *, 

245 create: bool | dict | t.Callable[[SkeletonInstance], None] = False, 

246 _check_legacy: bool = True 

247 ) -> t.Optional[SkeletonInstance]: 

248 """ 

249 Read Skeleton with *key* from the datastore into the Skeleton. 

250 If not key is given, skel["key"] will be used. 

251 

252 Reads all available data of entity kind *kindName* and the key *key* 

253 from the Datastore into the Skeleton structure's bones. Any previous 

254 data of the bones will discard. 

255 

256 To store a Skeleton object to the Datastore, see :func:`~viur.core.skeleton.Skeleton.write`. 

257 

258 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched. 

259 If not provided, skel["key"] will be used. 

260 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the 

261 given key does not exist, it will be created. 

262 

263 :returns: None on error, or the given SkeletonInstance on success. 

264 

265 """ 

266 # FIXME VIUR4: Stay backward compatible, call sub-classed fromDB if available first! 

267 if _check_legacy and "fromDB" in cls.__dict__: 

268 with warnings.catch_warnings(): 

269 warnings.simplefilter("ignore", DeprecationWarning) 

270 return cls.fromDB(skel, key=key) 

271 

272 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

273 

274 try: 

275 db_key = db.key_helper(key or skel["key"], skel.kindName) 

276 except (ValueError, NotImplementedError): # This key did not parse 

277 return None 

278 

279 if db_res := db.get(db_key): 

280 skel.setEntity(db_res) 

281 return skel 

282 elif create in (False, None): 

283 return None 

284 elif isinstance(create, dict): 

285 if create and not skel.fromClient(create, amend=True): 

286 raise ReadFromClientException(skel.errors) 

287 elif callable(create): 

288 create(skel) 

289 elif create is not True: 

290 raise ValueError("'create' must either be dict, a callable or True.") 

291 

292 return skel.write() 

293 

294 @classmethod 

295 @deprecated( 

296 version="3.7.0", 

297 reason="Use skel.write() instead of skel.toDB()", 

298 ) 

299 def toDB(cls, skel: SkeletonInstance, update_relations: bool = True, **kwargs) -> db.Key: 

300 """ 

301 Deprecated function, replaced by Skeleton.write(). 

302 """ 

303 

304 # TODO: Remove with ViUR4 

305 if "clearUpdateTag" in kwargs: 

306 msg = "clearUpdateTag was replaced by update_relations" 

307 warnings.warn(msg, DeprecationWarning, stacklevel=3) 

308 logging.warning(msg, stacklevel=3) 

309 update_relations = not kwargs["clearUpdateTag"] 

310 

311 skel = cls.write(skel, update_relations=update_relations, _check_legacy=False) 

312 return skel["key"] 

313 

314 @classmethod 

315 def write( 

316 cls, 

317 skel: SkeletonInstance, 

318 key: t.Optional[KeyType] = None, 

319 *, 

320 update_relations: bool = True, 

321 _check_legacy: bool = True, 

322 ) -> SkeletonInstance: 

323 """ 

324 Write current Skeleton to the datastore. 

325 

326 Stores the current data of this instance into the database. 

327 If an *key* value is set to the object, this entity will ne updated; 

328 Otherwise a new entity will be created. 

329 

330 To read a Skeleton object from the data store, see :func:`~viur.core.skeleton.Skeleton.read`. 

331 

332 :param key: Allows to specify a key that is set to the skeleton and used for writing. 

333 :param update_relations: If False, this entity won't be marked dirty; 

334 This avoids from being fetched by the background task updating relations. 

335 

336 :returns: The Skeleton. 

337 """ 

338 # FIXME VIUR4: Stay backward compatible, call sub-classed toDB if available first! 

339 if _check_legacy and "toDB" in cls.__dict__: 

340 with warnings.catch_warnings(): 

341 warnings.simplefilter("ignore", DeprecationWarning) 

342 return cls.toDB(skel, update_relations=update_relations) 

343 

344 assert skel.renderPreparation is None, "Cannot modify values while rendering" 

345 

346 def __txn_write(write_skel): 

347 db_key = write_skel["key"] 

348 skel = write_skel.skeletonCls() 

349 

350 blob_list = set() 

351 change_list = [] 

352 old_copy = {} 

353 # Load the current values from Datastore or create a new, empty db.Entity 

354 if not db_key: 

355 # We'll generate the key we'll be stored under early so we can use it for locks etc 

356 db_key = db.allocate_ids(skel.kindName)[0] 

357 skel.dbEntity = db.Entity(db_key) 

358 is_add = True 

359 else: 

360 db_key = db.key_helper(db_key, skel.kindName) 

361 if db_obj := db.get(db_key): 

362 skel.dbEntity = db_obj 

363 old_copy = {k: v for k, v in skel.dbEntity.items()} 

364 is_add = False 

365 else: 

366 skel.dbEntity = db.Entity(db_key) 

367 is_add = True 

368 

369 skel.dbEntity.setdefault("viur", {}) 

370 

371 # Merge values and assemble unique properties 

372 # Move accessed Values from srcSkel over to skel 

373 skel.accessedValues = write_skel.accessedValues 

374 

375 write_skel["key"] = skel["key"] = db_key # Ensure key stays set 

376 write_skel.dbEntity = skel.dbEntity # update write_skel's dbEntity 

377 

378 for bone_name, bone in skel.items(): 

379 if bone_name == "key": # Explicitly skip key on top-level - this had been set above 

380 continue 

381 

382 # Allow bones to perform outstanding "magic" operations before saving to db 

383 bone.performMagic(skel, bone_name, isAdd=is_add) # FIXME VIUR4: ANY MAGIC IN OUR CODE IS DEPRECATED!!! 

384 

385 if not (bone_name in skel.accessedValues or bone.compute) and bone_name not in skel.dbEntity: 

386 _ = skel[bone_name] # Ensure the datastore is filled with the default value 

387 

388 if ( 

389 bone_name in skel.accessedValues or bone.compute # We can have a computed value on store 

390 or bone_name not in skel.dbEntity # It has not been written and is not in the database 

391 ): 

392 # Serialize bone into entity 

393 try: 

394 bone.serialize(skel, bone_name, True) 

395 except Exception as e: 

396 logging.error( 

397 f"Failed to serialize {bone_name=} ({bone=}): {skel.accessedValues[bone_name]=}" 

398 ) 

399 raise e 

400 

401 # Obtain referenced blobs 

402 blob_list.update(bone.getReferencedBlobs(skel, bone_name)) 

403 

404 # Check if the value has actually changed 

405 if skel.dbEntity.get(bone_name) != old_copy.get(bone_name): 

406 change_list.append(bone_name) 

407 

408 # Lock hashes from bones that must have unique values 

409 if bone.unique: 

410 # Remember old hashes for bones that must have an unique value 

411 old_unique_values = [] 

412 

413 if f"{bone_name}_uniqueIndexValue" in skel.dbEntity["viur"]: 

414 old_unique_values = skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] 

415 # Check if the property is unique 

416 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name) 

417 new_lock_kind = f"{skel.kindName}_{bone_name}_uniquePropertyIndex" 

418 for new_lock_value in new_unique_values: 

419 new_lock_key = db.Key(new_lock_kind, new_lock_value) 

420 if lock_db_obj := db.get(new_lock_key): 

421 

422 # There's already a lock for that value, check if we hold it 

423 if lock_db_obj["references"] != skel.dbEntity.key.id_or_name: 

424 # This value has already been claimed, and not by us 

425 # TODO: Use a custom exception class which is catchable with an try/except 

426 raise ValueError( 

427 f"The unique value {skel[bone_name]!r} of bone {bone_name!r} " 

428 f"has been recently claimed (by {new_lock_key=}).") 

429 else: 

430 # This value is locked for the first time, create a new lock-object 

431 lock_obj = db.Entity(new_lock_key) 

432 lock_obj["references"] = skel.dbEntity.key.id_or_name 

433 db.put(lock_obj) 

434 if new_lock_value in old_unique_values: 

435 old_unique_values.remove(new_lock_value) 

436 skel.dbEntity["viur"][f"{bone_name}_uniqueIndexValue"] = new_unique_values 

437 

438 # Remove any lock-object we're holding for values that we don't have anymore 

439 for old_unique_value in old_unique_values: 

440 # Try to delete the old lock 

441 

442 old_lock_key = db.Key(f"{skel.kindName}_{bone_name}_uniquePropertyIndex", old_unique_value) 

443 if old_lock_obj := db.get(old_lock_key): 

444 if old_lock_obj["references"] != skel.dbEntity.key.id_or_name: 

445 

446 # We've been supposed to have that lock - but we don't. 

447 # Don't remove that lock as it now belongs to a different entry 

448 logging.critical("Detected Database corruption! A Value-Lock had been reassigned!") 

449 else: 

450 # It's our lock which we don't need anymore 

451 db.delete(old_lock_key) 

452 else: 

453 logging.critical("Detected Database corruption! Could not delete stale lock-object!") 

454 

455 # Delete legacy property (PR #1244) #TODO: Remove in ViUR4 

456 skel.dbEntity.pop("viur_incomming_relational_locks", None) 

457 

458 # Ensure the SEO-Keys are up-to-date 

459 last_requested_seo_keys = skel.dbEntity["viur"].get("viurLastRequestedSeoKeys") or {} 

460 last_set_seo_keys = skel.dbEntity["viur"].get("viurCurrentSeoKeys") or {} 

461 # Filter garbage serialized into this field by the SeoKeyBone 

462 last_set_seo_keys = {k: v for k, v in last_set_seo_keys.items() if not k.startswith("_") and v} 

463 

464 if not isinstance(skel.dbEntity["viur"].get("viurCurrentSeoKeys"), dict): 

465 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = {} 

466 

467 if current_seo_keys := skel.getCurrentSEOKeys(): 

468 # Convert to lower-case and remove certain characters 

469 for lang, value in current_seo_keys.items(): 

470 current_seo_keys[lang] = value.lower().translate(Skeleton.__seo_key_trans).strip() 

471 

472 for language in (conf.i18n.available_languages or [conf.i18n.default_language]): 

473 if current_seo_keys and language in current_seo_keys: 

474 current_seo_key = current_seo_keys[language] 

475 

476 if current_seo_key != last_requested_seo_keys.get(language): # This one is new or has changed 

477 new_seo_key = current_seo_keys[language] 

478 

479 for _ in range(0, 3): 

480 entry_using_key = db.Query(skel.kindName).filter( 

481 "viur.viurActiveSeoKeys =", new_seo_key).getEntry() 

482 

483 if entry_using_key and entry_using_key.key != skel.dbEntity.key: 

484 # It's not unique; append a random string and try again 

485 new_seo_key = f"{current_seo_keys[language]}-{utils.string.random(5).lower()}" 

486 

487 else: 

488 # We found a new SeoKey 

489 break 

490 else: 

491 raise ValueError("Could not generate an unique seo key in 3 attempts") 

492 else: 

493 new_seo_key = current_seo_key 

494 last_set_seo_keys[language] = new_seo_key 

495 

496 else: 

497 # We'll use the database-key instead 

498 last_set_seo_keys[language] = str(skel.dbEntity.key.id_or_name) 

499 

500 # Store the current, active key for that language 

501 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] = last_set_seo_keys[language] 

502 

503 skel.dbEntity["viur"].setdefault("viurActiveSeoKeys", []) 

504 for language, seo_key in last_set_seo_keys.items(): 

505 if skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] not in \ 

506 skel.dbEntity["viur"]["viurActiveSeoKeys"]: 

507 # Ensure the current, active seo key is in the list of all seo keys 

508 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, seo_key) 

509 if str(skel.dbEntity.key.id_or_name) not in skel.dbEntity["viur"]["viurActiveSeoKeys"]: 

510 # Ensure that key is also in there 

511 skel.dbEntity["viur"]["viurActiveSeoKeys"].insert(0, str(skel.dbEntity.key.id_or_name)) 

512 # Trim to the last 200 used entries 

513 skel.dbEntity["viur"]["viurActiveSeoKeys"] = skel.dbEntity["viur"]["viurActiveSeoKeys"][:200] 

514 # Store lastRequestedKeys so further updates can run more efficient 

515 skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys 

516 

517 # mark entity as "dirty" when update_relations is set, to zero otherwise. 

518 skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0 

519 

520 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

521 

522 # Allow the database adapter to apply last minute changes to the object 

523 for adapter in skel.database_adapters: 

524 adapter.prewrite(skel, is_add, change_list) 

525 

526 # ViUR2 import compatibility - remove properties containing. if we have a dict with the same name 

527 def fixDotNames(entity): 

528 for k, v in list(entity.items()): 

529 if isinstance(v, dict): 

530 for k2, v2 in list(entity.items()): 

531 if k2.startswith(f"{k}."): 

532 del entity[k2] 

533 backupKey = k2.replace(".", "__") 

534 entity[backupKey] = v2 

535 entity.exclude_from_indexes = set(entity.exclude_from_indexes) | {backupKey} 

536 fixDotNames(v) 

537 elif isinstance(v, list): 

538 for x in v: 

539 if isinstance(x, dict): 

540 fixDotNames(x) 

541 

542 # FIXME: REMOVE IN VIUR4 

543 if conf.viur2import_blobsource: # Try to fix these only when converting from ViUR2 

544 fixDotNames(skel.dbEntity) 

545 

546 # Write the core entry back 

547 db.put(skel.dbEntity) 

548 

549 # Now write the blob-lock object 

550 blob_list = skel.preProcessBlobLocks(blob_list) 

551 if blob_list is None: 

552 raise ValueError("Did you forget to return the blob_list somewhere inside getReferencedBlobs()?") 

553 if None in blob_list: 

554 msg = f"None is not valid in {blob_list=}" 

555 logging.error(msg) 

556 raise ValueError(msg) 

557 

558 if not is_add and (old_blob_lock_obj := db.get(db.Key("viur-blob-locks", db_key.id_or_name))): 

559 removed_blobs = set(old_blob_lock_obj.get("active_blob_references", [])) - blob_list 

560 old_blob_lock_obj["active_blob_references"] = list(blob_list) 

561 if old_blob_lock_obj["old_blob_references"] is None: 

562 old_blob_lock_obj["old_blob_references"] = list(removed_blobs) 

563 else: 

564 old_blob_refs = set(old_blob_lock_obj["old_blob_references"]) 

565 old_blob_refs.update(removed_blobs) # Add removed blobs 

566 old_blob_refs -= blob_list # Remove active blobs 

567 old_blob_lock_obj["old_blob_references"] = list(old_blob_refs) 

568 

569 old_blob_lock_obj["has_old_blob_references"] = bool(old_blob_lock_obj["old_blob_references"]) 

570 old_blob_lock_obj["is_stale"] = False 

571 db.put(old_blob_lock_obj) 

572 else: # We need to create a new blob-lock-object 

573 blob_lock_obj = db.Entity(db.Key("viur-blob-locks", skel.dbEntity.key.id_or_name)) 

574 blob_lock_obj["active_blob_references"] = list(blob_list) 

575 blob_lock_obj["old_blob_references"] = [] 

576 blob_lock_obj["has_old_blob_references"] = False 

577 blob_lock_obj["is_stale"] = False 

578 db.put(blob_lock_obj) 

579 

580 return skel.dbEntity.key, write_skel, change_list, is_add 

581 

582 # Parse provided key, if any, and set it to skel["key"] 

583 if key: 

584 skel["key"] = db.key_helper(key, skel.kindName) 

585 

586 if skel._cascade_deletion is True: 

587 if skel["key"]: 

588 logging.info(f"{skel._cascade_deletion=}, will delete {skel["key"]!r}") 

589 skel.delete() 

590 

591 return skel 

592 

593 # Run transactional function 

594 if db.is_in_transaction(): 

595 key, skel, change_list, is_add = __txn_write(skel) 

596 else: 

597 key, skel, change_list, is_add = db.run_in_transaction(__txn_write, skel) 

598 

599 for bone_name, bone in skel.items(): 

600 bone.postSavedHandler(skel, bone_name, key) 

601 

602 skel.postSavedHandler(key, skel.dbEntity) 

603 

604 if update_relations and not is_add: 

605 if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually 

606 tasks.update_relations(key, changed_bones=change_list, _countdown=10) 

607 

608 else: # Update all inbound relations, regardless of which bones they mirror 

609 tasks.update_relations(key) 

610 

611 # Trigger the database adapter of the changes made to the entry 

612 for adapter in skel.database_adapters: 

613 adapter.write(skel, is_add, change_list) 

614 

615 return skel 

616 

617 @classmethod 

618 def delete(cls, skel: SkeletonInstance, key: t.Optional[KeyType] = None) -> None: 

619 """ 

620 Deletes the entity associated with the current Skeleton from the data store. 

621 

622 :param key: Allows to specify a key that is used for deletion, otherwise skel["key"] will be used. 

623 """ 

624 

625 def __txn_delete(skel: SkeletonInstance, key: db.Key): 

626 if not skel.read(key): 

627 raise ValueError("This skeleton is not in the database (anymore?)!") 

628 

629 # Is there any relation to this Skeleton which prevents the deletion? 

630 locked_relation = ( 

631 db.Query("viur-relations") 

632 .filter("dest.__key__ =", key) 

633 .filter("viur_relational_consistency =", RelationalConsistency.PreventDeletion.value) 

634 ).getEntry() 

635 

636 if locked_relation is not None: 

637 raise errors.Locked("This entry is still referenced by other Skeletons, which prevents deleting!") 

638 

639 # Ensure that any value lock objects remaining for this entry are being deleted 

640 viur_data = skel.dbEntity.get("viur") or {} 

641 

642 for boneName, bone in skel.items(): 

643 bone.delete(skel, boneName) 

644 if bone.unique: 

645 flushList = [] 

646 for lockValue in viur_data.get(f"{boneName}_uniqueIndexValue") or []: 

647 lockKey = db.Key(f"{skel.kindName}_{boneName}_uniquePropertyIndex", lockValue) 

648 lockObj = db.get(lockKey) 

649 if not lockObj: 

650 logging.error(f"{lockKey=} missing!") 

651 elif lockObj["references"] != key.id_or_name: 

652 logging.error( 

653 f"""{key!r} does not hold lock for {lockKey!r}""") 

654 else: 

655 flushList.append(lockObj) 

656 if flushList: 

657 db.delete(flushList) 

658 

659 # Delete the blob-key lock object 

660 lockObjectKey = db.Key("viur-blob-locks", key.id_or_name) 

661 lockObj = db.get(lockObjectKey) 

662 

663 if lockObj is not None: 

664 if lockObj["old_blob_references"] is None and lockObj["active_blob_references"] is None: 

665 db.delete(lockObjectKey) # Nothing to do here 

666 else: 

667 if lockObj["old_blob_references"] is None: 

668 # No old stale entries, move active_blob_references -> old_blob_references 

669 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

670 elif lockObj["active_blob_references"] is not None: 

671 # Append the current references to the list of old & stale references 

672 lockObj["old_blob_references"] += lockObj["active_blob_references"] 

673 lockObj["active_blob_references"] = [] # There are no active ones left 

674 lockObj["is_stale"] = True 

675 lockObj["has_old_blob_references"] = True 

676 db.put(lockObj) 

677 

678 db.delete(key) 

679 tasks.update_relations(key) 

680 

681 if key := (key or skel["key"]): 

682 key = db.key_helper(key, skel.kindName) 

683 else: 

684 raise ValueError("This skeleton has no key!") 

685 

686 # Full skeleton is required to have all bones! 

687 skel = skeletonByKind(skel.kindName)() 

688 

689 if db.is_in_transaction(): 

690 __txn_delete(skel, key) 

691 else: 

692 db.run_in_transaction(__txn_delete, skel, key) 

693 

694 for boneName, bone in skel.items(): 

695 bone.postDeletedHandler(skel, boneName, key) 

696 

697 skel.postDeletedHandler(key) 

698 

699 # Inform the custom DB Adapter 

700 for adapter in skel.database_adapters: 

701 adapter.delete(skel) 

702 

703 @classmethod 

704 def patch( 

705 cls, 

706 skel: SkeletonInstance, 

707 values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {}, 

708 *, 

709 key: t.Optional[db.Key | int | str] = None, 

710 check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None, 

711 create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None, 

712 update_relations: bool = True, 

713 ignore: t.Optional[t.Iterable[str]] = (), 

714 retry: int = 0, 

715 ) -> SkeletonInstance: 

716 """ 

717 Performs an edit operation on a Skeleton within a transaction. 

718 

719 The transaction performs a read, sets bones and afterwards does a write with exclusive access on the 

720 given Skeleton and its underlying database entity. 

721 

722 All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts, 

723 a callable can also be given that can individually modify the Skeleton that is edited. 

724 

725 :param values: A dict of key-values to update on the entry, or a callable that is executed within 

726 the transaction. 

727 

728 This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the 

729 given value, which can be used for counters. 

730 :param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched. 

731 If not provided, skel["key"] will be used. 

732 :param check: An optional dict of key-values or a callable to check on the Skeleton before updating. 

733 If something fails within this check, an AssertionError is being raised. 

734 :param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the 

735 given key does not exist. 

736 :param update_relations: Trigger update relations task on success. Defaults to False. 

737 :param ignore: optional list of bones to be ignored from values; Defaults to an empty list, 

738 so that all bones are accepted (even read-only ones, as skel.patch() is being used internally) 

739 :param retry: On RuntimeError, retry for this amount of times. - DEPRECATED! 

740 

741 If the function does not raise an Exception, all went well. 

742 The function always returns the input Skeleton. 

743 

744 Raises: 

745 ValueError: In case parameters where given wrong or incomplete. 

746 AssertionError: In case an asserted check parameter did not match. 

747 ReadFromClientException: In case a skel.fromClient() failed with a high severity. 

748 """ 

749 

750 # Transactional function 

751 def __update_txn(): 

752 # Try to read the skeleton, create on demand 

753 if not skel.read(key): 

754 if create is None or create is False: 

755 raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.") 

756 

757 if not (key or skel["key"]) and create in (False, None): 

758 return ValueError("No valid key provided") 

759 

760 if key or skel["key"]: 

761 skel["key"] = db.key_helper(key or skel["key"], skel.kindName) 

762 

763 if isinstance(create, dict): 

764 if create and not skel.fromClient(create, amend=True, ignore=ignore): 

765 raise ReadFromClientException(skel.errors) 

766 elif callable(create): 

767 create(skel) 

768 elif create is not True: 

769 raise ValueError("'create' must either be dict or a callable.") 

770 

771 # Handle check 

772 if isinstance(check, dict): 

773 for bone, value in check.items(): 

774 if skel[bone] != value: 

775 raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}") 

776 

777 elif callable(check): 

778 check(skel) 

779 

780 # Set values 

781 if isinstance(values, dict): 

782 if values and not skel.fromClient(values, amend=True, ignore=ignore): 

783 raise ReadFromClientException(skel.errors) 

784 

785 # Special-feature: "+" and "-" prefix for simple calculations 

786 # TODO: This can maybe integrated into skel.fromClient() later... 

787 for name, value in values.items(): 

788 match name[0]: 

789 case "+": # Increment by value? 

790 skel[name[1:]] += value 

791 case "-": # Decrement by value? 

792 skel[name[1:]] -= value 

793 

794 elif callable(values): 

795 values(skel) 

796 

797 else: 

798 raise ValueError("'values' must either be dict or a callable.") 

799 

800 return skel.write(update_relations=update_relations) 

801 

802 if not db.is_in_transaction(): 

803 # Retry loop 

804 while True: 

805 try: 

806 return db.run_in_transaction(__update_txn) 

807 

808 except RuntimeError as e: 

809 retry -= 1 

810 if retry < 0: 

811 raise 

812 

813 logging.debug(f"{e}, retrying {retry} more times") 

814 

815 time.sleep(1) 

816 else: 

817 return __update_txn() 

818 

819 @classmethod 

820 def preProcessBlobLocks(cls, skel: SkeletonInstance, locks): 

821 """ 

822 Can be overridden to modify the list of blobs referenced by this skeleton 

823 """ 

824 return locks 

825 

826 @classmethod 

827 def preProcessSerializedData(cls, skel: SkeletonInstance, entity): 

828 """ 

829 Can be overridden to modify the :class:`viur.core.db.Entity` before its actually 

830 written to the data store. 

831 """ 

832 return entity 

833 

834 @classmethod 

835 def postSavedHandler(cls, skel: SkeletonInstance, key, dbObj): 

836 """ 

837 Can be overridden to perform further actions after the entity has been written 

838 to the data store. 

839 """ 

840 pass 

841 

842 @classmethod 

843 def postDeletedHandler(cls, skel: SkeletonInstance, key): 

844 """ 

845 Can be overridden to perform further actions after the entity has been deleted 

846 from the data store. 

847 """ 

848 pass 

849 

850 @classmethod 

851 def getCurrentSEOKeys(cls, skel: SkeletonInstance) -> None | dict[str, str]: 

852 """ 

853 Should be overridden to return a dictionary of language -> SEO-Friendly key 

854 this entry should be reachable under. How theses names are derived are entirely up to the application. 

855 If the name is already in use for this module, the server will automatically append some random string 

856 to make it unique. 

857 :return: 

858 """ 

859 return