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

411 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 20:18 +0000

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.raw import RawBone 

24from ..bones.relational import RelationalConsistency 

25from ..bones.key import KeyBone 

26from ..bones.date import DateBone 

27from ..bones.string import StringBone 

28 

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 

32 

33 

34class SeoKeyBone(StringBone): 

35 """ 

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

37 """ 

38 

39 def unserialize(self, skel: SkeletonInstance, name: str) -> bool: 

40 try: 

41 skel.accessedValues[name] = skel.dbEntity["viur"]["viurCurrentSeoKeys"] 

42 except KeyError: 

43 skel.accessedValues[name] = self.getDefaultValue(skel) 

44 

45 def serialize(self, skel: SkeletonInstance, name: str, parentIndexed: bool) -> bool: 

46 # Serialize also to skel["viur"]["viurCurrentSeoKeys"], so we can use this bone in relations 

47 if name in skel.accessedValues: 

48 newVal = skel.accessedValues[name] 

49 if not skel.dbEntity.get("viur"): 

50 skel.dbEntity["viur"] = db.Entity() 

51 res = db.Entity() 

52 res["_viurLanguageWrapper_"] = True 

53 for language in (self.languages or []): 

54 if not self.indexed: 

55 res.exclude_from_indexes.add(language) 

56 res[language] = None 

57 if language in newVal: 

58 res[language] = self.singleValueSerialize(newVal[language], skel, name, parentIndexed) 

59 skel.dbEntity["viur"]["viurCurrentSeoKeys"] = res 

60 return True 

61 

62 

63class Skeleton(BaseSkeleton, metaclass=MetaSkel): 

64 kindName: str = _UNDEFINED_KINDNAME 

65 """ 

66 Specifies the entity kind name this Skeleton is associated with. 

67 Will be determined automatically when not explicitly set. 

68 """ 

69 

70 database_adapters: DatabaseAdapter | t.Iterable[DatabaseAdapter] | None = _UNDEFINED_KINDNAME 

71 """ 

72 Custom database adapters. 

73 Allows to hook special functionalities that during skeleton modifications. 

74 """ 

75 

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

77 

78 interBoneValidations: list[ 

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

80 

81 __seo_key_trans = str.maketrans( 

82 {"<": "", 

83 ">": "", 

84 "\"": "", 

85 "'": "", 

86 "\n": "", 

87 "\0": "", 

88 "/": "", 

89 "\\": "", 

90 "?": "", 

91 "&": "", 

92 "#": "" 

93 }) 

94 

95 # The "key" bone stores the current database key of this skeleton. 

96 # Warning: Assigning to this bones value now *will* set the key 

97 # it gets stored in. Must be kept readOnly to avoid security-issues with add/edit. 

98 key = KeyBone( 

99 descr="Key" 

100 ) 

101 

102 shortkey = RawBone( 

103 descr="Shortkey", 

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

105 readOnly=True, 

106 visible=False, 

107 searchable=True, 

108 ) 

109 

110 name = StringBone( 

111 descr="Name", 

112 visible=False, 

113 compute=Compute( 

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

115 interval=ComputeInterval(ComputeMethod.OnWrite) 

116 ) 

117 ) 

118 

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

120 creationdate = DateBone( 

121 descr="created at", 

122 readOnly=True, 

123 visible=False, 

124 indexed=True, 

125 compute=Compute( 

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

127 interval=ComputeInterval(ComputeMethod.Once) 

128 ), 

129 ) 

130 

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

132 

133 changedate = DateBone( 

134 descr="updated at", 

135 readOnly=True, 

136 visible=False, 

137 indexed=True, 

138 compute=Compute( 

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

140 interval=ComputeInterval(ComputeMethod.OnWrite) 

141 ), 

142 ) 

143 

144 viurCurrentSeoKeys = SeoKeyBone( 

145 descr="SEO-Keys", 

146 readOnly=True, 

147 visible=False, 

148 languages=conf.i18n.available_languages 

149 ) 

150 

151 def __repr__(self): 

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

153 

154 def __str__(self): 

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

156 

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

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

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

160 

161 @classmethod 

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

163 """ 

164 Create a query with the current Skeletons kindName. 

165 

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

167 """ 

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

169 

170 @classmethod 

171 def fromClient( 

172 cls, 

173 skel: SkeletonInstance, 

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

175 *, 

176 amend: bool = False, 

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

178 ) -> bool: 

179 """ 

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

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

182 

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

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

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

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

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

188 

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

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

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

192 which is useful for edit-actions. 

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

194 

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

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

197 """ 

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

199 

200 # Load data into this skeleton 

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

202 

203 if ( 

204 not data # in case data is empty 

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

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

207 ): 

208 skel.errors = [] 

209 

210 # Check if all unique values are available 

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

212 if boneInstance.unique: 

213 lockValues = boneInstance.getUniquePropertyIndexValues(skel, boneName) 

214 for lockValue in lockValues: 

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

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

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

218 complete = False 

219 errorMsg = boneInstance.unique.message 

220 skel.errors.append( 

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

222 

223 # Check inter-Bone dependencies 

224 for checkFunc in skel.interBoneValidations: 

225 errors = checkFunc(skel) 

226 if errors: 

227 for error in errors: 

228 if error.severity.value > 1: 

229 complete = False 

230 if conf.debug.skeleton_from_client: 

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

232 

233 skel.errors.extend(errors) 

234 

235 return complete 

236 

237 @classmethod 

238 @deprecated( 

239 version="3.7.0", 

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

241 ) 

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

243 """ 

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

245 """ 

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

247 

248 @classmethod 

249 def read( 

250 cls, 

251 skel: SkeletonInstance, 

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

253 *, 

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

255 _check_legacy: bool = True 

256 ) -> t.Optional[SkeletonInstance]: 

257 """ 

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

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

260 

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

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

263 data of the bones will discard. 

264 

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

266 

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

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

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

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

271 

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

273 

274 """ 

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

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

277 with warnings.catch_warnings(): 

278 warnings.simplefilter("ignore", DeprecationWarning) 

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

280 

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

282 

283 try: 

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

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

286 return None 

287 

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

289 skel.setEntity(db_res) 

290 return skel 

291 elif create in (False, None): 

292 return None 

293 elif isinstance(create, dict): 

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

295 raise ReadFromClientException(skel.errors) 

296 elif callable(create): 

297 create(skel) 

298 elif create is not True: 

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

300 

301 skel["key"] = db_key 

302 return skel.write() 

303 

304 @classmethod 

305 @deprecated( 

306 version="3.7.0", 

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

308 ) 

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

310 """ 

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

312 """ 

313 

314 # TODO: Remove with ViUR4 

315 if "clearUpdateTag" in kwargs: 

316 msg = "clearUpdateTag was replaced by update_relations" 

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

318 logging.warning(msg, stacklevel=3) 

319 update_relations = not kwargs["clearUpdateTag"] 

320 

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

322 return skel["key"] 

323 

324 @classmethod 

325 def write( 

326 cls, 

327 skel: SkeletonInstance, 

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

329 *, 

330 update_relations: bool = True, 

331 _check_legacy: bool = True, 

332 ) -> SkeletonInstance: 

333 """ 

334 Write current Skeleton to the datastore. 

335 

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

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

338 Otherwise a new entity will be created. 

339 

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

341 

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

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

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

345 

346 :returns: The Skeleton. 

347 """ 

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

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

350 with warnings.catch_warnings(): 

351 warnings.simplefilter("ignore", DeprecationWarning) 

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

353 

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 skel.dbEntity["viur"]["viurCurrentSeoKeys"][language] not in \ 

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

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

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

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

520 # Ensure that key is also in there 

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

522 # Trim to the last 200 used entries 

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

524 # Store lastRequestedKeys so further updates can run more efficient 

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

526 

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

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

529 

530 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

531 

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

533 for adapter in skel.database_adapters: 

534 adapter.prewrite(skel, is_add, change_list) 

535 

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

537 def fixDotNames(entity): 

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

539 if isinstance(v, dict): 

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

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

542 del entity[k2] 

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

544 entity[backupKey] = v2 

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

546 fixDotNames(v) 

547 elif isinstance(v, list): 

548 for x in v: 

549 if isinstance(x, dict): 

550 fixDotNames(x) 

551 

552 # FIXME: REMOVE IN VIUR4 

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

554 fixDotNames(skel.dbEntity) 

555 

556 # Write the core entry back 

557 db.put(skel.dbEntity) 

558 

559 # Now write the blob-lock object 

560 blob_list = skel.preProcessBlobLocks(blob_list) 

561 if blob_list is None: 

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

563 if None in blob_list: 

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

565 logging.error(msg) 

566 raise ValueError(msg) 

567 

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

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

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

571 if old_blob_lock_obj["old_blob_references"] is None: 

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

573 else: 

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

575 old_blob_refs.update(removed_blobs) # Add removed blobs 

576 old_blob_refs -= blob_list # Remove active blobs 

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

578 

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

580 old_blob_lock_obj["is_stale"] = False 

581 db.put(old_blob_lock_obj) 

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

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

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

585 blob_lock_obj["old_blob_references"] = [] 

586 blob_lock_obj["has_old_blob_references"] = False 

587 blob_lock_obj["is_stale"] = False 

588 db.put(blob_lock_obj) 

589 

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

591 

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

593 if key: 

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

595 

596 if skel._cascade_deletion is True: 

597 if skel["key"]: 

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

599 skel.delete() 

600 

601 return skel 

602 

603 # Run transactional function 

604 if db.is_in_transaction(): 

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

606 else: 

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

608 

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

610 bone.postSavedHandler(skel, bone_name, key) 

611 

612 skel.postSavedHandler(key, skel.dbEntity) 

613 

614 if update_relations and not is_add: 

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

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

617 

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

619 tasks.update_relations(key) 

620 

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

622 for adapter in skel.database_adapters: 

623 adapter.write(skel, is_add, change_list) 

624 

625 return skel 

626 

627 @classmethod 

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

629 """ 

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

631 

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

633 """ 

634 

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

636 if not skel.read(key): 

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

638 

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

640 locked_relation = ( 

641 db.Query("viur-relations") 

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

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

644 ).getEntry() 

645 

646 if locked_relation is not None: 

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

648 

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

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

651 

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

653 bone.delete(skel, boneName) 

654 if bone.unique: 

655 flushList = [] 

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

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

658 lockObj = db.get(lockKey) 

659 if not lockObj: 

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

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

662 logging.error( 

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

664 else: 

665 flushList.append(lockObj) 

666 if flushList: 

667 db.delete(flushList) 

668 

669 # Delete the blob-key lock object 

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

671 lockObj = db.get(lockObjectKey) 

672 

673 if lockObj is not None: 

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

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

676 else: 

677 if lockObj["old_blob_references"] is None: 

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

679 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

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

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

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

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

684 lockObj["is_stale"] = True 

685 lockObj["has_old_blob_references"] = True 

686 db.put(lockObj) 

687 

688 db.delete(key) 

689 tasks.update_relations(key) 

690 

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

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

693 else: 

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

695 

696 # Full skeleton is required to have all bones! 

697 skel = skeletonByKind(skel.kindName)() 

698 

699 if db.is_in_transaction(): 

700 __txn_delete(skel, key) 

701 else: 

702 db.run_in_transaction(__txn_delete, skel, key) 

703 

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

705 bone.postDeletedHandler(skel, boneName, key) 

706 

707 skel.postDeletedHandler(key) 

708 

709 # Inform the custom DB Adapter 

710 for adapter in skel.database_adapters: 

711 adapter.delete(skel) 

712 

713 @classmethod 

714 def patch( 

715 cls, 

716 skel: SkeletonInstance, 

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

718 *, 

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

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

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

722 update_relations: bool = True, 

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

724 internal: bool = True, 

725 retry: int = 0, 

726 ) -> SkeletonInstance: 

727 """ 

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

729 

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

731 given Skeleton and its underlying database entity. 

732 

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

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

735 

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

737 the transaction. 

738 

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

740 given value, which can be used for counters. 

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

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

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

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

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

746 given key does not exist. 

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

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

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

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

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

752 

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

754 The function always returns the input Skeleton. 

755 

756 Raises: 

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

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

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

760 """ 

761 

762 # Transactional function 

763 def __update_txn(): 

764 # Try to read the skeleton, create on demand 

765 if not skel.read(key): 

766 if create is None or create is False: 

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

768 

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

770 return ValueError("No valid key provided") 

771 

772 if key or skel["key"]: 

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

774 

775 if isinstance(create, dict): 

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

777 raise ReadFromClientException(skel.errors) 

778 elif callable(create): 

779 create(skel) 

780 elif create is not True: 

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

782 

783 # Handle check 

784 if isinstance(check, dict): 

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

786 if skel[bone] != value: 

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

788 

789 elif callable(check): 

790 check(skel) 

791 

792 # Set values 

793 if isinstance(values, dict): 

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

795 raise ReadFromClientException(skel.errors) 

796 

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

798 if skel.errors and internal: 

799 for error in skel.errors: 

800 if error.severity in ( 

801 ReadFromClientErrorSeverity.Invalid, 

802 ReadFromClientErrorSeverity.InvalidatesOther, 

803 ): 

804 raise ReadFromClientException(skel.errors) 

805 

806 # otherwise, ignore any reported errors 

807 skel.errors.clear() 

808 

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

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

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

812 match name[0]: 

813 case "+": # Increment by value? 

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

815 case "-": # Decrement by value? 

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

817 

818 elif callable(values): 

819 values(skel) 

820 

821 else: 

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

823 

824 return skel.write(update_relations=update_relations) 

825 

826 if not db.is_in_transaction(): 

827 # Retry loop 

828 while True: 

829 try: 

830 return db.run_in_transaction(__update_txn) 

831 

832 except RuntimeError as e: 

833 retry -= 1 

834 if retry < 0: 

835 raise 

836 

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

838 

839 time.sleep(1) 

840 else: 

841 return __update_txn() 

842 

843 @classmethod 

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

845 """ 

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

847 """ 

848 return locks 

849 

850 @classmethod 

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

852 """ 

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

854 written to the data store. 

855 """ 

856 return entity 

857 

858 @classmethod 

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

860 """ 

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

862 to the data store. 

863 """ 

864 pass 

865 

866 @classmethod 

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

868 """ 

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

870 from the data store. 

871 """ 

872 pass 

873 

874 @classmethod 

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

876 """ 

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

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

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

880 to make it unique. 

881 :return: 

882 """ 

883 return