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

412 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 12: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 

214 for lockValue in lockValues: 

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

216 lock_entity = db.get(lock_key) 

217 

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

219 logging.error(f"{boneName=} {lock_key=} already taken by {lock_entity["references"]!r}") 

220 

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

222 complete = False 

223 skel.errors.append( 

224 ReadFromClientError( 

225 ReadFromClientErrorSeverity.Invalid, 

226 boneInstance.unique.message, 

227 [boneName] 

228 ) 

229 ) 

230 

231 # Check inter-Bone dependencies 

232 for checkFunc in skel.interBoneValidations: 

233 errors = checkFunc(skel) 

234 if errors: 

235 for error in errors: 

236 if error.severity.value > 1: 

237 complete = False 

238 if conf.debug.skeleton_from_client: 

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

240 

241 skel.errors.extend(errors) 

242 

243 return complete 

244 

245 @classmethod 

246 @deprecated( 

247 version="3.7.0", 

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

249 ) 

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

251 """ 

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

253 """ 

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

255 

256 @classmethod 

257 def read( 

258 cls, 

259 skel: SkeletonInstance, 

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

261 *, 

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

263 _check_legacy: bool = True 

264 ) -> t.Optional[SkeletonInstance]: 

265 """ 

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

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

268 

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

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

271 data of the bones will discard. 

272 

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

274 

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

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

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

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

279 

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

281 

282 """ 

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

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

285 with warnings.catch_warnings(): 

286 warnings.simplefilter("ignore", DeprecationWarning) 

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

288 

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

290 

291 try: 

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

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

294 return None 

295 

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

297 skel.setEntity(db_res) 

298 return skel 

299 elif create in (False, None): 

300 return None 

301 elif isinstance(create, dict): 

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

303 raise ReadFromClientException(skel.errors) 

304 elif callable(create): 

305 create(skel) 

306 elif create is not True: 

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

308 

309 skel["key"] = db_key 

310 return skel.write() 

311 

312 @classmethod 

313 @deprecated( 

314 version="3.7.0", 

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

316 ) 

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

318 """ 

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

320 """ 

321 

322 # TODO: Remove with ViUR4 

323 if "clearUpdateTag" in kwargs: 

324 msg = "clearUpdateTag was replaced by update_relations" 

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

326 logging.warning(msg, stacklevel=3) 

327 update_relations = not kwargs["clearUpdateTag"] 

328 

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

330 return skel["key"] 

331 

332 @classmethod 

333 def write( 

334 cls, 

335 skel: SkeletonInstance, 

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

337 *, 

338 update_relations: bool = True, 

339 _check_legacy: bool = True, 

340 ) -> SkeletonInstance: 

341 """ 

342 Write current Skeleton to the datastore. 

343 

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

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

346 Otherwise a new entity will be created. 

347 

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

349 

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

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

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

353 

354 :returns: The Skeleton. 

355 """ 

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

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

358 with warnings.catch_warnings(): 

359 warnings.simplefilter("ignore", DeprecationWarning) 

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

361 

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

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

364 

365 def __txn_write(write_skel): 

366 db_key = write_skel["key"] 

367 skel = write_skel.skeletonCls() 

368 

369 blob_list = set() 

370 change_list = [] 

371 old_copy = {} 

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

373 if not db_key: 

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

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

376 skel.dbEntity = db.Entity(db_key) 

377 is_add = True 

378 else: 

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

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

381 skel.dbEntity = db_obj 

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

383 is_add = False 

384 else: 

385 skel.dbEntity = db.Entity(db_key) 

386 is_add = True 

387 

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

389 

390 # Merge values and assemble unique properties 

391 # Move accessed Values from srcSkel over to skel 

392 skel.accessedValues = write_skel.accessedValues 

393 

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

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

396 

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

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

399 continue 

400 

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

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

403 

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

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

406 

407 if ( 

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

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

410 ): 

411 # Serialize bone into entity 

412 try: 

413 bone.serialize(skel, bone_name, True) 

414 except Exception as e: 

415 logging.error( 

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

417 ) 

418 raise e 

419 

420 # Obtain referenced blobs 

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

422 

423 # Check if the value has actually changed 

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

425 change_list.append(bone_name) 

426 

427 # Lock hashes from bones that must have unique values 

428 if bone.unique: 

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

430 old_unique_values = [] 

431 

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

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

434 # Check if the property is unique 

435 new_unique_values = bone.getUniquePropertyIndexValues(skel, bone_name) 

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

437 for new_lock_value in new_unique_values: 

438 new_lock_key = db.Key(new_lock_kind, new_lock_value) 

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

440 

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

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

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

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

445 raise ValueError( 

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

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

448 else: 

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

450 lock_obj = db.Entity(new_lock_key) 

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

452 db.put(lock_obj) 

453 if new_lock_value in old_unique_values: 

454 old_unique_values.remove(new_lock_value) 

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

456 

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

458 for old_unique_value in old_unique_values: 

459 # Try to delete the old lock 

460 

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

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

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

464 

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

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

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

468 else: 

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

470 db.delete(old_lock_key) 

471 else: 

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

473 

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

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

476 

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

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

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

480 # Filter garbage serialized into this field by the SeoKeyBone 

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

482 

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

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

485 

486 if current_seo_keys := skel.getCurrentSEOKeys(): 

487 # Convert to lower-case and remove certain characters 

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

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

490 

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

492 if current_seo_keys and language in current_seo_keys: 

493 current_seo_key = current_seo_keys[language] 

494 

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

496 new_seo_key = current_seo_keys[language] 

497 

498 for _ in range(0, 3): 

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

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

501 

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

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

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

505 

506 else: 

507 # We found a new SeoKey 

508 break 

509 else: 

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

511 else: 

512 new_seo_key = current_seo_key 

513 last_set_seo_keys[language] = new_seo_key 

514 

515 else: 

516 # We'll use the database-key instead 

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

518 

519 # Store the current, active key for that language 

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

521 

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

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

524 if ( 

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

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

527 ): 

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

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

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

531 # Ensure that key is also in there 

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

533 # Trim to the last 200 used entries 

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

535 # Store lastRequestedKeys so further updates can run more efficient 

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

537 

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

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

540 

541 skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity) 

542 

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

544 for adapter in skel.database_adapters: 

545 adapter.prewrite(skel, is_add, change_list) 

546 

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

548 def fixDotNames(entity): 

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

550 if isinstance(v, dict): 

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

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

553 del entity[k2] 

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

555 entity[backupKey] = v2 

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

557 fixDotNames(v) 

558 elif isinstance(v, list): 

559 for x in v: 

560 if isinstance(x, dict): 

561 fixDotNames(x) 

562 

563 # FIXME: REMOVE IN VIUR4 

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

565 fixDotNames(skel.dbEntity) 

566 

567 # Write the core entry back 

568 db.put(skel.dbEntity) 

569 

570 # Now write the blob-lock object 

571 blob_list = skel.preProcessBlobLocks(blob_list) 

572 if blob_list is None: 

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

574 if None in blob_list: 

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

576 logging.error(msg) 

577 raise ValueError(msg) 

578 

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

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

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

582 if old_blob_lock_obj["old_blob_references"] is None: 

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

584 else: 

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

586 old_blob_refs.update(removed_blobs) # Add removed blobs 

587 old_blob_refs -= blob_list # Remove active blobs 

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

589 

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

591 old_blob_lock_obj["is_stale"] = False 

592 db.put(old_blob_lock_obj) 

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

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

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

596 blob_lock_obj["old_blob_references"] = [] 

597 blob_lock_obj["has_old_blob_references"] = False 

598 blob_lock_obj["is_stale"] = False 

599 db.put(blob_lock_obj) 

600 

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

602 

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

604 if key: 

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

606 

607 if skel._cascade_deletion is True: 

608 if skel["key"]: 

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

610 skel.delete() 

611 

612 return skel 

613 

614 # Run transactional function 

615 if db.is_in_transaction(): 

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

617 else: 

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

619 

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

621 bone.postSavedHandler(skel, bone_name, key) 

622 

623 skel.postSavedHandler(key, skel.dbEntity) 

624 

625 if update_relations and not is_add: 

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

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

628 

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

630 tasks.update_relations(key) 

631 

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

633 for adapter in skel.database_adapters: 

634 adapter.write(skel, is_add, change_list) 

635 

636 return skel 

637 

638 @classmethod 

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

640 """ 

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

642 

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

644 """ 

645 

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

647 if not skel.read(key): 

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

649 

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

651 locked_relation = ( 

652 db.Query("viur-relations") 

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

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

655 ).getEntry() 

656 

657 if locked_relation is not None: 

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

659 

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

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

662 

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

664 bone.delete(skel, boneName) 

665 if bone.unique: 

666 flushList = [] 

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

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

669 lockObj = db.get(lockKey) 

670 if not lockObj: 

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

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

673 logging.error( 

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

675 else: 

676 flushList.append(lockObj) 

677 if flushList: 

678 db.delete(flushList) 

679 

680 # Delete the blob-key lock object 

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

682 lockObj = db.get(lockObjectKey) 

683 

684 if lockObj is not None: 

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

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

687 else: 

688 if lockObj["old_blob_references"] is None: 

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

690 lockObj["old_blob_references"] = lockObj["active_blob_references"] 

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

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

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

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

695 lockObj["is_stale"] = True 

696 lockObj["has_old_blob_references"] = True 

697 db.put(lockObj) 

698 

699 db.delete(key) 

700 tasks.update_relations(key) 

701 

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

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

704 else: 

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

706 

707 # Full skeleton is required to have all bones! 

708 skel = skeletonByKind(skel.kindName)() 

709 

710 if db.is_in_transaction(): 

711 __txn_delete(skel, key) 

712 else: 

713 db.run_in_transaction(__txn_delete, skel, key) 

714 

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

716 bone.postDeletedHandler(skel, boneName, key) 

717 

718 skel.postDeletedHandler(key) 

719 

720 # Inform the custom DB Adapter 

721 for adapter in skel.database_adapters: 

722 adapter.delete(skel) 

723 

724 @classmethod 

725 def patch( 

726 cls, 

727 skel: SkeletonInstance, 

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

729 *, 

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

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

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

733 update_relations: bool = True, 

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

735 internal: bool = True, 

736 retry: int = 0, 

737 ) -> SkeletonInstance: 

738 """ 

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

740 

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

742 given Skeleton and its underlying database entity. 

743 

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

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

746 

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

748 the transaction. 

749 

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

751 given value, which can be used for counters. 

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

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

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

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

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

757 given key does not exist. 

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

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

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

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

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

763 

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

765 The function always returns the input Skeleton. 

766 

767 Raises: 

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

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

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

771 """ 

772 

773 # Transactional function 

774 def __update_txn(): 

775 # Try to read the skeleton, create on demand 

776 if not skel.read(key): 

777 if create is None or create is False: 

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

779 

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

781 return ValueError("No valid key provided") 

782 

783 if key or skel["key"]: 

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

785 

786 if isinstance(create, dict): 

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

788 raise ReadFromClientException(skel.errors) 

789 elif callable(create): 

790 create(skel) 

791 elif create is not True: 

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

793 

794 # Handle check 

795 if isinstance(check, dict): 

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

797 if skel[bone] != value: 

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

799 

800 elif callable(check): 

801 check(skel) 

802 

803 # Set values 

804 if isinstance(values, dict): 

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

806 raise ReadFromClientException(skel.errors) 

807 

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

809 if skel.errors and internal: 

810 for error in skel.errors: 

811 if error.severity in ( 

812 ReadFromClientErrorSeverity.Invalid, 

813 ReadFromClientErrorSeverity.InvalidatesOther, 

814 ): 

815 raise ReadFromClientException(skel.errors) 

816 

817 # otherwise, ignore any reported errors 

818 skel.errors.clear() 

819 

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

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

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

823 match name[0]: 

824 case "+": # Increment by value? 

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

826 case "-": # Decrement by value? 

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

828 

829 elif callable(values): 

830 values(skel) 

831 

832 else: 

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

834 

835 return skel.write(update_relations=update_relations) 

836 

837 if not db.is_in_transaction(): 

838 # Retry loop 

839 while True: 

840 try: 

841 return db.run_in_transaction(__update_txn) 

842 

843 except RuntimeError as e: 

844 retry -= 1 

845 if retry < 0: 

846 raise 

847 

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

849 

850 time.sleep(1) 

851 else: 

852 return __update_txn() 

853 

854 @classmethod 

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

856 """ 

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

858 """ 

859 return locks 

860 

861 @classmethod 

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

863 """ 

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

865 written to the data store. 

866 """ 

867 return entity 

868 

869 @classmethod 

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

871 """ 

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

873 to the data store. 

874 """ 

875 pass 

876 

877 @classmethod 

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

879 """ 

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

881 from the data store. 

882 """ 

883 pass 

884 

885 @classmethod 

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

887 """ 

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

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

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

891 to make it unique. 

892 :return: 

893 """ 

894 return