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

411 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-25 14:23 +0000

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 

11from . import tasks 

12from .meta import BaseSkeleton, MetaSkel, _UNDEFINED_KINDNAME 

13from .utils import skeletonByKind 

14from ..bones.base import ( 

15 Compute, 

16 ComputeInterval, 

17 ComputeMethod, 

18 ReadFromClientError, 

19 ReadFromClientErrorSeverity, 

20 ReadFromClientException, 

21) 

22from ..bones.date import DateBone 

23from ..bones.key import KeyBone 

24from ..bones.raw import RawBone 

25from ..bones.relational import RelationalConsistency 

26from ..bones.string import StringBone 

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 shortkey = RawBone( 

102 descr="Shortkey", 

103 compute=Compute(lambda skel: skel["key"].id_or_name if skel["key"] else None), 

104 readOnly=True, 

105 visible=False, 

106 searchable=True, 

107 ) 

108 

109 name = StringBone( 

110 descr="Name", 

111 visible=False, 

112 compute=Compute( 

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

114 interval=ComputeInterval(ComputeMethod.OnWrite) 

115 ) 

116 ) 

117 

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

119 creationdate = DateBone( 

120 descr="created at", 

121 readOnly=True, 

122 visible=False, 

123 indexed=True, 

124 compute=Compute( 

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

126 interval=ComputeInterval(ComputeMethod.Once) 

127 ), 

128 ) 

129 

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

131 

132 changedate = DateBone( 

133 descr="updated at", 

134 readOnly=True, 

135 visible=False, 

136 indexed=True, 

137 compute=Compute( 

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

139 interval=ComputeInterval(ComputeMethod.OnWrite) 

140 ), 

141 ) 

142 

143 viurCurrentSeoKeys = SeoKeyBone( 

144 descr="SEO-Keys", 

145 readOnly=True, 

146 visible=False, 

147 languages=conf.i18n.available_languages 

148 ) 

149 

150 def __repr__(self): 

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

152 

153 def __str__(self): 

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

155 

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

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

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

159 

160 @classmethod 

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

162 """ 

163 Create a query with the current Skeletons kindName. 

164 

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

166 """ 

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

168 

169 @classmethod 

170 def fromClient( 

171 cls, 

172 skel: SkeletonInstance, 

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

174 *, 

175 amend: bool = False, 

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

177 ) -> bool: 

178 """ 

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

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

181 

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

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

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

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

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

187 

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

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

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

191 which is useful for edit-actions. 

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

193 

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

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

196 """ 

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

198 

199 # Load data into this skeleton 

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

201 

202 if ( 

203 not data # in case data is empty 

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

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

206 ): 

207 skel.errors = [] 

208 

209 # Check if all unique values are available 

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

211 if boneInstance.unique: 

212 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

213 for lockValue in lockValues: 

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

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

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

217 complete = False 

218 errorMsg = boneInstance.unique.message 

219 skel.errors.append( 

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

221 

222 # Check inter-Bone dependencies 

223 for checkFunc in skel.interBoneValidations: 

224 errors = checkFunc(skel) 

225 if errors: 

226 for error in errors: 

227 if error.severity.value > 1: 

228 complete = False 

229 if conf.debug.skeleton_from_client: 

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

231 

232 skel.errors.extend(errors) 

233 

234 return complete 

235 

236 @classmethod 

237 @deprecated( 

238 version="3.7.0", 

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

240 ) 

241 def fromDB(cls, skel: SkeletonInstance, key: db.KeyType) -> bool: 

242 """ 

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

244 """ 

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

246 

247 @classmethod 

248 def read( 

249 cls, 

250 skel: SkeletonInstance, 

251 key: t.Optional[db.KeyType] = None, 

252 *, 

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

254 _check_legacy: bool = True 

255 ) -> t.Optional[SkeletonInstance]: 

256 """ 

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

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

259 

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

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

262 data of the bones will discard. 

263 

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

265 

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

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

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

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

270 

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

272 

273 """ 

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

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

276 with warnings.catch_warnings(): 

277 warnings.simplefilter("ignore", DeprecationWarning) 

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

279 

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

281 

282 try: 

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

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

285 return None 

286 

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

288 skel.setEntity(db_res) 

289 return skel 

290 elif create in (False, None): 

291 return None 

292 elif isinstance(create, dict): 

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

294 raise ReadFromClientException(skel.errors) 

295 elif callable(create): 

296 create(skel) 

297 elif create is not True: 

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

299 

300 skel["key"] = db_key 

301 return skel.write() 

302 

303 @classmethod 

304 @deprecated( 

305 version="3.7.0", 

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

307 ) 

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

309 """ 

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

311 """ 

312 

313 # TODO: Remove with ViUR4 

314 if "clearUpdateTag" in kwargs: 

315 msg = "clearUpdateTag was replaced by update_relations" 

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

317 logging.warning(msg, stacklevel=3) 

318 update_relations = not kwargs["clearUpdateTag"] 

319 

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

321 return skel["key"] 

322 

323 @classmethod 

324 def write( 

325 cls, 

326 skel: SkeletonInstance, 

327 key: t.Optional[db.KeyType] = None, 

328 *, 

329 update_relations: bool = True, 

330 _check_legacy: bool = True, 

331 ) -> SkeletonInstance: 

332 """ 

333 Write current Skeleton to the datastore. 

334 

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

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

337 Otherwise a new entity will be created. 

338 

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

340 

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

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

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

344 

345 :returns: The Skeleton. 

346 """ 

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

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

349 with warnings.catch_warnings(): 

350 warnings.simplefilter("ignore", DeprecationWarning) 

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

352 

353 # FIXME: This check is incomplete as long it does nt check the entire tree! 

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

355 

356 def __txn_write(write_skel): 

357 db_key = write_skel["key"] 

358 skel = write_skel.skeletonCls() 

359 

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 

378 

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

380 

381 # Merge values and assemble unique properties 

382 # Move accessed Values from srcSkel over to skel 

383 skel.accessedValues = write_skel.accessedValues 

384 

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

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

387 

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 

391 

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

394 

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 

397 

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 

410 

411 # Obtain referenced blobs 

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

413 

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) 

417 

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 = [] 

422 

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): 

431 

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 

447 

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 

451 

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: 

455 

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

464 

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

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

467 

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} 

473 

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

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

476 

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() 

481 

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] 

485 

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] 

488 

489 for _ in range(0, 3): 

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

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

492 

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()}" 

496 

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 

505 

506 else: 

507 # We'll use the database-key instead 

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

509 

510 # Store the current, active key for that language 

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

512 

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

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

515 if ( 

516 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] 

517 not in skel.dbEntity["viur"]["viurActiveSeoKeys"] 

518 ): 

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

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

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

522 # Ensure that key is also in there 

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

524 # Trim to the last 200 used entries 

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

526 # Store lastRequestedKeys so further updates can run more efficient 

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

528 

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

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

531 

532 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

533 

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

535 for adapter in skel.database_adapters: 

536 adapter.prewrite(skel, is_add, change_list) 

537 

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

539 def fixDotNames(entity): 

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

541 if isinstance(v, dict): 

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

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

544 del entity[k2] 

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

546 entity[backupKey] = v2 

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

548 fixDotNames(v) 

549 elif isinstance(v, list): 

550 for x in v: 

551 if isinstance(x, dict): 

552 fixDotNames(x) 

553 

554 # FIXME: REMOVE IN VIUR4 

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

556 fixDotNames(skel.dbEntity) 

557 

558 # Write the core entry back 

559 db.put(skel.dbEntity) 

560 

561 # Now write the blob-lock object 

562 blob_list = skel.preProcessBlobLocks(blob_list) 

563 if blob_list is None: 

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

565 if None in blob_list: 

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

567 logging.error(msg) 

568 raise ValueError(msg) 

569 

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

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

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

573 if old_blob_lock_obj["old_blob_references"] is None: 

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

575 else: 

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

577 old_blob_refs.update(removed_blobs) # Add removed blobs 

578 old_blob_refs -= blob_list # Remove active blobs 

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

580 

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

582 old_blob_lock_obj["is_stale"] = False 

583 db.put(old_blob_lock_obj) 

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

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

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

587 blob_lock_obj["old_blob_references"] = [] 

588 blob_lock_obj["has_old_blob_references"] = False 

589 blob_lock_obj["is_stale"] = False 

590 db.put(blob_lock_obj) 

591 

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

593 

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

595 if key: 

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

597 

598 if skel._cascade_deletion is True: 

599 if skel["key"]: 

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

601 skel.delete() 

602 

603 return skel 

604 

605 # Run transactional function 

606 if db.is_in_transaction(): 

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

608 else: 

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

610 

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

612 bone.postSavedHandler(skel, bone_name, key) 

613 

614 skel.postSavedHandler(key, skel.dbEntity) 

615 

616 if update_relations and not is_add: 

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

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

619 

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

621 tasks.update_relations(key) 

622 

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

624 for adapter in skel.database_adapters: 

625 adapter.write(skel, is_add, change_list) 

626 

627 return skel 

628 

629 @classmethod 

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

631 """ 

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

633 

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

635 """ 

636 

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

638 if not skel.read(key): 

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

640 

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

642 locked_relation = ( 

643 db.Query("viur-relations") 

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

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

646 ).getEntry() 

647 

648 if locked_relation is not None: 

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

650 

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

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

653 

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

655 bone.delete(skel, boneName) 

656 if bone.unique: 

657 flushList = [] 

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

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

660 lockObj = db.get(lockKey) 

661 if not lockObj: 

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

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

664 logging.error( 

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

666 else: 

667 flushList.append(lockObj) 

668 if flushList: 

669 db.delete(flushList) 

670 

671 # Delete the blob-key lock object 

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

673 lockObj = db.get(lockObjectKey) 

674 

675 if lockObj is not None: 

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

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

678 else: 

679 if lockObj["old_blob_references"] is None: 

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

681 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

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

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

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

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

686 lockObj["is_stale"] = True 

687 lockObj["has_old_blob_references"] = True 

688 db.put(lockObj) 

689 

690 db.delete(key) 

691 tasks.update_relations(key) 

692 

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

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

695 else: 

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

697 

698 # Full skeleton is required to have all bones! 

699 skel = skeletonByKind(skel.kindName)() 

700 

701 if db.is_in_transaction(): 

702 __txn_delete(skel, key) 

703 else: 

704 db.run_in_transaction(__txn_delete, skel, key) 

705 

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

707 bone.postDeletedHandler(skel, boneName, key) 

708 

709 skel.postDeletedHandler(key) 

710 

711 # Inform the custom DB Adapter 

712 for adapter in skel.database_adapters: 

713 adapter.delete(skel) 

714 

715 @classmethod 

716 def patch( 

717 cls, 

718 skel: SkeletonInstance, 

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

720 *, 

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

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

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

724 update_relations: bool = True, 

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

726 internal: bool = True, 

727 retry: int = 0, 

728 ) -> SkeletonInstance: 

729 """ 

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

731 

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

733 given Skeleton and its underlying database entity. 

734 

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

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

737 

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

739 the transaction. 

740 

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

742 given value, which can be used for counters. 

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

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

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

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

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

748 given key does not exist. 

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

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

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

752 :param internal: Internal patch does ignore any NotSet and Empty errors that may raise in skel.fromClient() 

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

754 

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

756 The function always returns the input Skeleton. 

757 

758 Raises: 

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

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

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

762 """ 

763 

764 # Transactional function 

765 def __update_txn(): 

766 # Try to read the skeleton, create on demand 

767 if not skel.read(key): 

768 if create is None or create is False: 

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

770 

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

772 return ValueError("No valid key provided") 

773 

774 if key or skel["key"]: 

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

776 

777 if isinstance(create, dict): 

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

779 raise ReadFromClientException(skel.errors) 

780 elif callable(create): 

781 create(skel) 

782 elif create is not True: 

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

784 

785 # Handle check 

786 if isinstance(check, dict): 

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

788 if skel[bone] != value: 

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

790 

791 elif callable(check): 

792 check(skel) 

793 

794 # Set values 

795 if isinstance(values, dict): 

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

797 raise ReadFromClientException(skel.errors) 

798 

799 # In case we're in internal-mode, only raise fatal errors. 

800 if skel.errors and internal: 

801 for error in skel.errors: 

802 if error.severity in ( 

803 ReadFromClientErrorSeverity.Invalid, 

804 ReadFromClientErrorSeverity.InvalidatesOther, 

805 ): 

806 raise ReadFromClientException(skel.errors) 

807 

808 # otherwise, ignore any reported errors 

809 skel.errors.clear() 

810 

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

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

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

814 match name[0]: 

815 case "+": # Increment by value? 

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

817 case "-": # Decrement by value? 

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

819 

820 elif callable(values): 

821 values(skel) 

822 

823 else: 

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

825 

826 return skel.write(update_relations=update_relations) 

827 

828 if not db.is_in_transaction(): 

829 # Retry loop 

830 while True: 

831 try: 

832 return db.run_in_transaction(__update_txn) 

833 

834 except RuntimeError as e: 

835 retry -= 1 

836 if retry < 0: 

837 raise 

838 

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

840 

841 time.sleep(1) 

842 else: 

843 return __update_txn() 

844 

845 @classmethod 

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

847 """ 

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

849 """ 

850 return locks 

851 

852 @classmethod 

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

854 """ 

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

856 written to the data store. 

857 """ 

858 return entity 

859 

860 @classmethod 

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

862 """ 

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

864 to the data store. 

865 """ 

866 pass 

867 

868 @classmethod 

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

870 """ 

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

872 from the data store. 

873 """ 

874 pass 

875 

876 @classmethod 

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

878 """ 

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

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

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

882 to make it unique. 

883 :return: 

884 """ 

885 return