Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/relational.py: 7%

547 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 09:00 +0000

1""" 

2This module contains the RelationalBone to create and manage relationships between skeletons 

3and enums to parameterize it. 

4""" 

5import enum 

6import json 

7import logging 

8import time 

9import typing as t 

10import warnings 

11from itertools import chain 

12 

13from viur.core import db, utils, i18n 

14from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity, getSystemInitialized 

15 

16if t.TYPE_CHECKING: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true

17 from viur.core.skeleton import SkeletonInstance, RelSkel 

18 

19 

20class RelationalConsistency(enum.IntEnum): 

21 """ 

22 An enumeration representing the different consistency strategies for handling stale relations in 

23 the RelationalBone class. 

24 """ 

25 Ignore = 1 

26 """Ignore stale relations, which represents the old behavior.""" 

27 PreventDeletion = 2 

28 """Lock the target object so that it cannot be deleted.""" 

29 SetNull = 3 

30 """Drop the relation if the target object is deleted.""" 

31 CascadeDeletion = 4 

32 """ 

33 .. warning:: Delete this object also if the referenced entry is deleted (Dangerous!) 

34 """ 

35 

36 

37class RelationalUpdateLevel(enum.Enum): 

38 """ 

39 An enumeration representing the different update levels for the RelationalBone class. 

40 """ 

41 Always = 0 

42 """Always update the relational information, regardless of the context.""" 

43 OnRebuildSearchIndex = 1 

44 """Update the relational information only when rebuilding the search index.""" 

45 OnValueAssignment = 2 

46 """Update the relational information only when a new value is assigned to the bone.""" 

47 

48 

49class RelDict(t.TypedDict): 

50 dest: "SkeletonInstance" 

51 rel: t.Optional["RelSkel"] 

52 

53 

54class RelationalBone(BaseBone): 

55 """ 

56 The base class for all relational bones in the ViUR framework. 

57 RelationalBone is used to create and manage relationships between database entities. This class provides 

58 basic functionality and attributes that can be extended by other specialized relational bone classes, 

59 such as N1Relation, N2NRelation, and Hierarchy. 

60 This implementation prioritizes read efficiency and is suitable for situations where data is read more 

61 frequently than written. However, it comes with increased write operations when writing an entity to the 

62 database. The additional write operations depend on the type of relationship: multiple=True RelationalBones 

63 or 1:N relations. 

64 

65 The implementation does not instantly update relational information when a skeleton is updated; instead, 

66 it triggers a deferred task to update references. This may result in outdated data until the task is completed. 

67 

68 Note: Filtering a list by relational properties uses the outdated data. 

69 

70 Example: 

71 - Entity A references Entity B. 

72 - Both have a property "name." 

73 - Entity B is updated (its name changes). 

74 - Entity A's RelationalBone values still show Entity B's old name. 

75 

76 It is not recommended for cases where data is read less frequently than written, as there is no 

77 write-efficient method available yet. 

78 

79 :param kind: KindName of the referenced property. 

80 :param module: Name of the module which should be used to select entities of kind "kind". If not set, 

81 the value of "kind" will be used (the kindName must match the moduleName) 

82 :param refKeys: A list of properties to include from the referenced property. These properties will be 

83 available in the template without having to fetch the referenced property. Filtering is also only possible 

84 by properties named here! 

85 :param parentKeys: A list of properties from the current skeleton to include. If mixing filtering by 

86 relational properties and properties of the class itself, these must be named here. 

87 :param multiple: If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation). 

88 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this 

89 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use 

90 a ```class MultipleConstraints``` instead. 

91 

92 :param format: 

93 Hint for the frontend how to display such an relation. This is now a python expression 

94 evaluated by safeeval on the client side. The following values will be passed to the expression: 

95 

96 - value 

97 The value to display. This will be always a dict (= a single value) - even if the relation is 

98 multiple (in which case the expression is evaluated once per referenced entity) 

99 

100 - structure 

101 The structure of the skeleton this bone is part of as a dictionary as it's transferred to the 

102 fronted by the admin/vi-render. 

103 

104 - language 

105 The current language used by the frontend in ISO2 code (eg. "de"). This will be always set, even if 

106 the project did not enable the multi-language feature. 

107 

108 :param updateLevel: 

109 Indicates how ViUR should keep the values copied from the referenced entity into our 

110 entity up to date. If this bone is indexed, it's recommended to leave this set to 

111 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results. 

112 

113 :param RelationalUpdateLevel.Always: 

114 

115 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this 

116 entity also (after a small delay, as these updates happen deferred) 

117 

118 :param RelationalUpdateLevel.OnRebuildSearchIndex: 

119 

120 update refKeys only on rebuildSearchIndex. If the referenced entity changes, this entity will 

121 remain unchanged (this RelationalBone will still have the old values), but it can be updated 

122 by either by editing this entity or running a rebuildSearchIndex over our kind. 

123 

124 :param RelationalUpdateLevel.OnValueAssignment: 

125 

126 update only if explicitly set. A rebuildSearchIndex will not trigger an update, this bone has to be 

127 explicitly modified (in an edit) to have it's values updated 

128 

129 :param consistency: 

130 Can be used to implement SQL-like constrains on this relation. Possible values are: 

131 - RelationalConsistency.Ignore 

132 If the referenced entity gets deleted, this bone will not change. It will still reflect the old 

133 values. This will be even be preserved over edits, however if that referenced value is once 

134 deleted by the user (assigning a different value to this bone or removing that value of the list 

135 of relations if we are multiple) there's no way of restoring it 

136 

137 - RelationalConsistency.PreventDeletion 

138 Will prevent deleting the referenced entity as long as it's selected in this bone (calling 

139 skel.delete() on the referenced entity will raise errors.Locked). It's still (technically) 

140 possible to remove the underlying datastore entity using db.delete manually, but this *must not* 

141 be used on a skeleton object as it will leave a whole bunch of references in a stale state. 

142 

143 - RelationalConsistency.SetNull 

144 Will set this bone to None (or remove the relation from the list in 

145 case we are multiple) when the referenced entity is deleted. 

146 

147 - RelationalConsistency.CascadeDeletion: 

148 (Dangerous!) Will delete this entity when the referenced entity is deleted. Warning: Unlike 

149 relational updates this will cascade. If Entity A references B with CascadeDeletion set, and 

150 B references C also with CascadeDeletion; if C gets deleted, both B and A will be deleted as well. 

151 

152 """ 

153 type = "relational" 

154 kind = None 

155 

156 def __init__( 

157 self, 

158 *, 

159 consistency: RelationalConsistency = RelationalConsistency.Ignore, 

160 format: str = "$(dest.name)", 

161 kind: str = None, 

162 module: t.Optional[str] = None, 

163 parentKeys: t.Optional[t.Iterable[str]] = {"name"}, 

164 refKeys: t.Optional[t.Iterable[str]] = {"name"}, 

165 updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always, 

166 using: t.Optional["RelSkel"] = None, 

167 **kwargs 

168 ): 

169 """ 

170 Initialize a new RelationalBone. 

171 

172 :param kind: 

173 KindName of the referenced property. 

174 :param module: 

175 Name of the module which should be used to select entities of kind "type". If not set, 

176 the value of "type" will be used (the kindName must match the moduleName) 

177 :param refKeys: 

178 An iterable of properties to include from the referenced property. These properties will be 

179 available in the template without having to fetch the referenced property. Filtering is also only 

180 possible by properties named here! 

181 :param parentKeys: 

182 An iterable of properties from the current skeleton to include. If mixing filtering by 

183 relational properties and properties of the class itself, these must be named here. 

184 :param multiple: 

185 If True, allow referencing multiple Elements of the given class. (Eg. n:n-relation). 

186 Otherwise its n:1, (you can only select exactly one). It's possible to use a unique constraint on this 

187 bone, allowing for at-most-1:1 or at-most-1:n relations. Instead of true, it's also possible to use 

188 a :class:MultipleConstraints instead. 

189 

190 :param format: Hint for the frontend how to display such an relation. This is now a python expression 

191 evaluated by safeeval on the client side. The following values will be passed to the expression 

192 

193 :param value: 

194 The value to display. This will be always a dict (= a single value) - even if the 

195 relation is multiple (in which case the expression is evaluated once per referenced entity) 

196 :param structure: 

197 The structure of the skeleton this bone is part of as a dictionary as it's 

198 transferred to the fronted by the admin/vi-render. 

199 :param language: 

200 The current language used by the frontend in ISO2 code (eg. "de"). This will be 

201 always set, even if the project did not enable the multi-language feature. 

202 

203 :param updateLevel: 

204 Indicates how ViUR should keep the values copied from the referenced entity into our 

205 entity up to date. If this bone is indexed, it's recommended to leave this set to 

206 RelationalUpdateLevel.Always, as filtering/sorting by this bone will produce stale results. 

207 

208 :param RelationalUpdateLevel.Always: 

209 always update refkeys (old behavior). If the referenced entity is edited, ViUR will update this 

210 entity also (after a small delay, as these updates happen deferred) 

211 :param RelationalUpdateLevel.OnRebuildSearchIndex: 

212 update refKeys only on rebuildSearchIndex. If the 

213 referenced entity changes, this entity will remain unchanged 

214 (this RelationalBone will still have the old values), but it can be updated 

215 by either by editing this entity or running a rebuildSearchIndex over our kind. 

216 :param RelationalUpdateLevel.OnValueAssignment: 

217 update only if explicitly set. A rebuildSearchIndex will not trigger 

218 an update, this bone has to be explicitly modified (in an edit) to have it's values updated 

219 

220 :param consistency: 

221 Can be used to implement SQL-like constrains on this relation. 

222 

223 :param RelationalConsistency.Ignore: 

224 If the referenced entity gets deleted, this bone will not change. It 

225 will still reflect the old values. This will be even be preserved over edits, however if that 

226 referenced value is once deleted by the user (assigning a different value to this bone or 

227 removing that value of the list of relations if we are multiple) there's no way of restoring it 

228 

229 :param RelationalConsistency.PreventDeletion: 

230 Will prevent deleting the referenced entity as long as it's 

231 selected in this bone (calling skel.delete() on the referenced entity will raise errors.Locked). 

232 It's still (technically) possible to remove the underlying datastore entity using db.delete 

233 manually, but this *must not* be used on a skeleton object as it will leave a whole bunch of 

234 references in a stale state. 

235 

236 :param RelationalConsistency.SetNull: 

237 Will set this bone to None (or remove the relation from the list in 

238 case we are multiple) when the referenced entity is deleted. 

239 

240 :param RelationalConsistency.CascadeDeletion: 

241 (Dangerous!) Will delete this entity when the referenced entity 

242 is deleted. Warning: Unlike relational updates this will cascade. If Entity A references B with 

243 CascadeDeletion set, and B references C also with CascadeDeletion; if C gets deleted, both B and 

244 A will be deleted as well. 

245 """ 

246 super().__init__(**kwargs) 

247 self.format = format 

248 

249 if kind: 

250 self.kind = kind 

251 

252 if module: 

253 self.module = module 

254 elif self.kind: 

255 self.module = self.kind 

256 

257 if self.kind is None or self.module is None: 

258 raise NotImplementedError("'kind' and 'module' of RelationalBone must not be None") 

259 

260 # Referenced keys 

261 self.refKeys = {"key"} 

262 if refKeys: 

263 self.refKeys |= set(refKeys) 

264 

265 # Parent keys 

266 self.parentKeys = {"key"} 

267 if parentKeys: 

268 self.parentKeys |= set(parentKeys) 

269 

270 self.using = using 

271 

272 # FIXME: Remove in VIUR4!! 

273 if isinstance(updateLevel, int): 

274 msg = f"parameter updateLevel={updateLevel} in RelationalBone is deprecated. " \ 

275 f"Please use the RelationalUpdateLevel enum instead" 

276 logging.warning(msg, stacklevel=3) 

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

278 

279 assert 0 <= updateLevel < 3 

280 for n in RelationalUpdateLevel: 

281 if updateLevel == n.value: 

282 updateLevel = n 

283 

284 self.updateLevel = updateLevel 

285 self.consistency = consistency 

286 

287 if getSystemInitialized(): 

288 from viur.core.skeleton import RefSkel, SkeletonInstance 

289 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys) 

290 self._skeletonInstanceClassRef = SkeletonInstance 

291 self._ref_keys = set(self._refSkelCache.__boneMap__.keys()) 

292 

293 def setSystemInitialized(self): 

294 """ 

295 Set the system initialized for the current class and cache the RefSkel and SkeletonInstance. 

296 

297 This method calls the superclass's setSystemInitialized method and initializes the RefSkel 

298 and SkeletonInstance classes. The RefSkel is created from the current kind and refKeys, 

299 while the SkeletonInstance class is stored as a reference. 

300 

301 :rtype: None 

302 """ 

303 super().setSystemInitialized() 

304 from viur.core.skeleton import RefSkel, SkeletonInstance 

305 

306 try: 

307 self._refSkelCache = RefSkel.fromSkel(self.kind, *self.refKeys) 

308 except AssertionError: 

309 raise NotImplementedError( 

310 f"Skeleton {self.skel_cls!r} {self.__class__.__name__} {self.name!r}: Kind {self.kind!r} unknown" 

311 ) 

312 

313 self._skeletonInstanceClassRef = SkeletonInstance 

314 self._ref_keys = set(self._refSkelCache.__boneMap__.keys()) 

315 

316 def _getSkels(self): 

317 """ 

318 Retrieve the reference skeleton and the 'using' skeleton for the current RelationalBone instance. 

319 

320 This method returns a tuple containing the reference skeleton (RefSkel) and the 'using' skeleton 

321 (UsingSkel) associated with the current RelationalBone instance. The 'using' skeleton is only 

322 retrieved if the 'using' attribute is defined. 

323 

324 :return: A tuple containing the reference skeleton and the 'using' skeleton. 

325 :rtype: tuple 

326 """ 

327 refSkel = self._refSkelCache() 

328 usingSkel = self.using() if self.using else None 

329 return refSkel, usingSkel 

330 

331 def singleValueUnserialize(self, val): 

332 """ 

333 Restore a value, including the Rel- and Using-Skeleton, from the serialized data read from the datastore. 

334 

335 This method takes a serialized value from the datastore, deserializes it, and returns the corresponding 

336 value with restored RelSkel and Using-Skel. It also handles ViUR 2 compatibility by handling string values. 

337 

338 :param val: A JSON-encoded datastore property. 

339 :type val: str or dict 

340 :return: The deserialized value with restored RelSkel and Using-Skel. 

341 :rtype: dict 

342 

343 :raises AssertionError: If the deserialized value is not a dictionary. 

344 """ 

345 

346 def fixFromDictToEntry(inDict): 

347 """ 

348 Convert a dictionary to an entry with properly restored keys and values. 

349 

350 :param dict inDict: The input dictionary to convert. 

351 : return: The resulting entry. 

352 :rtype: dict 

353 """ 

354 if not isinstance(inDict, dict): 

355 return None 

356 res = {} 

357 if "dest" in inDict: 

358 res["dest"] = db.Entity() 

359 for k, v in inDict["dest"].items(): 

360 res["dest"][k] = v 

361 if "key" in res["dest"]: 

362 res["dest"].key = db.normalize_key(res["dest"]["key"]) 

363 if "rel" in inDict and inDict["rel"]: 

364 res["rel"] = db.Entity() 

365 for k, v in inDict["rel"].items(): 

366 res["rel"][k] = v 

367 else: 

368 res["rel"] = None 

369 return res 

370 

371 if isinstance(val, str): # ViUR2 compatibility 

372 try: 

373 value = json.loads(val) 

374 if isinstance(value, list): 

375 value = [fixFromDictToEntry(x) for x in value] 

376 elif isinstance(value, dict): 

377 value = fixFromDictToEntry(value) 

378 else: 

379 value = None 

380 except ValueError: 

381 value = None 

382 else: 

383 value = val 

384 if not value: 

385 return None 

386 elif isinstance(value, list) and value: 

387 value = value[0] 

388 assert isinstance(value, dict), \ 

389 f"Read something from the datastore that's not a dict: {self.name=} -> {type(value)}" 

390 if "dest" not in value: 

391 return None 

392 relSkel, usingSkel = self._getSkels() 

393 relSkel.unserialize(value["dest"]) 

394 if self.using is not None: 

395 usingSkel.unserialize(value["rel"] or db.Entity()) 

396 usingData = usingSkel 

397 else: 

398 usingData = None 

399 return {"dest": relSkel, "rel": usingData} 

400 

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

402 """ 

403 Serialize the RelationalBone for the given skeleton, updating relational locks as necessary. 

404 

405 This method serializes the RelationalBone values for a given skeleton and stores the serialized 

406 values in the skeleton's dbEntity. It also updates the relational locks, adding new locks and 

407 removing old ones as needed. 

408 

409 :param SkeletonInstance skel: The skeleton instance containing the values to be serialized. 

410 :param str name: The name of the bone to be serialized. 

411 :param bool parentIndexed: A flag indicating whether the parent bone is indexed. 

412 :return: True if the serialization is successful, False otherwise. 

413 :rtype: bool 

414 

415 :raises AssertionError: If a programming error is detected. 

416 """ 

417 

418 def serialize_dest_rel(in_value: dict | None = None) -> (dict | None, dict | None): 

419 if not in_value: 

420 return None, None 

421 if dest_val := in_value.get("dest"): 

422 ref_data_serialized = dest_val.serialize(parentIndexed=indexed) 

423 else: 

424 ref_data_serialized = None 

425 if rel_data := in_value.get("rel"): 

426 using_data_serialized = rel_data.serialize(parentIndexed=indexed) 

427 else: 

428 using_data_serialized = None 

429 

430 return using_data_serialized, ref_data_serialized 

431 

432 

433 super().serialize(skel, name, parentIndexed) 

434 

435 # Clean old properties from entry (prevent name collision) 

436 for key in tuple(skel.dbEntity.keys()): 

437 if key.startswith(f"{name}."): 

438 del skel.dbEntity[key] 

439 

440 indexed = self.indexed and parentIndexed 

441 

442 if not (new_vals := skel.accessedValues.get(name)): 

443 return False 

444 

445 # TODO: The good old leier... modernize this. 

446 if self.languages: 

447 res = {"_viurLanguageWrapper_": True} 

448 for language in self.languages: 

449 if language in new_vals: 

450 if self.multiple: 

451 res[language] = [] 

452 for val in new_vals[language]: 

453 if val: 

454 using_data, ref_data = serialize_dest_rel(val) 

455 res[language].append({"rel": using_data, "dest": ref_data}) 

456 else: 

457 if (val := new_vals[language]) and val["dest"]: 

458 using_data, ref_data = serialize_dest_rel(val) 

459 res[language] = {"rel": using_data, "dest": ref_data} 

460 elif self.multiple: 

461 res = [] 

462 for val in new_vals: 

463 if val: 

464 using_data, ref_data = serialize_dest_rel(val) 

465 res.append({"rel": using_data, "dest": ref_data}) 

466 elif new_vals: 

467 using_data, ref_data = serialize_dest_rel(new_vals) 

468 res = {"rel": using_data, "dest": ref_data} 

469 

470 skel.dbEntity[name] = res 

471 

472 # Ensure our indexed flag is up2date 

473 if indexed and name in skel.dbEntity.exclude_from_indexes: 

474 skel.dbEntity.exclude_from_indexes.discard(name) 

475 elif not indexed and name not in skel.dbEntity.exclude_from_indexes: 

476 skel.dbEntity.exclude_from_indexes.add(name) 

477 

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

479 skel.dbEntity.pop(f"{name}_outgoingRelationalLocks", None) 

480 

481 return True 

482 

483 def _get_single_destinct_hash(self, value): 

484 parts = [value["dest"]["key"]] 

485 

486 if self.using: 

487 for name, bone in self.using.__boneMap__.items(): 

488 parts.append(bone._get_destinct_hash(value["rel"][name])) 

489 

490 return tuple(parts) 

491 

492 def postSavedHandler(self, skel, boneName, key) -> None: 

493 """ 

494 Handle relational updates after a skeleton is saved. 

495 

496 This method updates, removes, or adds relations between the saved skeleton and the referenced entities. 

497 It also takes care of updating the relational properties and consistency levels. 

498 

499 :param skel: The saved skeleton instance. 

500 :param boneName: The name of the relational bone. 

501 :param key: The key of the saved skeleton instance. 

502 """ 

503 viur_src_kind = key.kind 

504 viur_src_property = boneName 

505 

506 # Hack for RelationalBones in containers (like RecordBones) 

507 if "." in boneName: 

508 _, boneName = boneName.rsplit(".", 1) # bone name to fummel out of the skeleton (again...) 

509 

510 if not skel[boneName]: 

511 values = [] 

512 elif self.multiple and self.languages: 

513 values = chain(*skel[boneName].values()) 

514 elif self.languages: 

515 values = list(skel[boneName].values()) 

516 elif self.multiple: 

517 values = skel[boneName] 

518 else: 

519 values = [skel[boneName]] 

520 

521 # Keep a set of all referenced keys 

522 values = [value for value in values if value] 

523 values_keys = {value["dest"]["key"] for value in values} 

524 

525 # Referenced parent values 

526 src_values = db.Entity(key) 

527 if skel.dbEntity: 

528 src_values |= {bone: skel.dbEntity.get(bone) for bone in self.parentKeys or ()} 

529 

530 # Now is now, nana nananaaaaaaa... 

531 now = time.time() 

532 

533 # Helper fcuntion to 

534 def __update_relation(entity: db.Entity, data: dict): 

535 ref_skel = data["dest"] 

536 rel_skel = data["rel"] 

537 

538 entity["dest"] = ref_skel.serialize(parentIndexed=True) 

539 entity["rel"] = rel_skel.serialize(parentIndexed=True) if rel_skel else None 

540 entity["src"] = src_values 

541 

542 entity["viur_src_kind"] = viur_src_kind 

543 entity["viur_src_property"] = viur_src_property 

544 entity["viur_dest_kind"] = self.kind 

545 entity["viur_delayed_update_tag"] = now 

546 entity["viur_relational_updateLevel"] = self.updateLevel.value 

547 entity["viur_relational_consistency"] = self.consistency.value 

548 entity["viur_foreign_keys"] = list(self.refKeys) 

549 entity["viurTags"] = skel.dbEntity.get("viurTags") if skel.dbEntity else None 

550 

551 db.put(entity) 

552 

553 # Query and update existing entries pointing to this bone 

554 query = db.Query("viur-relations") \ 

555 .filter("viur_src_kind =", viur_src_kind) \ 

556 .filter("viur_dest_kind =", self.kind) \ 

557 .filter("viur_src_property =", viur_src_property) \ 

558 .filter("src.__key__ =", key) 

559 

560 for entity in query.iter(): 

561 try: 

562 if entity["dest"].key not in values_keys: # Relation has been removed 

563 db.delete(entity.key) 

564 continue 

565 

566 except KeyError: # This entry is corrupt 

567 db.delete(entity.key) 

568 

569 else: # Relation: Updated 

570 # Find the newest item matching this key (this has to been done this way)... 

571 value = [value for value in values if value["dest"]["key"] == entity["dest"].key][0] 

572 # ... and remove it from the list of values 

573 values.remove(value) 

574 values_keys.remove(value["dest"]["key"]) 

575 

576 # Update existing database entry 

577 __update_relation(entity, value) 

578 

579 # Add new database entries for the remaining values 

580 for value in values: 

581 __update_relation(db.Entity(db.Key("viur-relations", parent=key)), value) 

582 

583 def postDeletedHandler(self, skel: "SkeletonInstance", boneName: str, key: db.Key) -> None: 

584 """ 

585 Handle relational updates after a skeleton is deleted. 

586 

587 This method deletes all relations associated with the deleted skeleton and the referenced entities 

588 for the given relational bone. 

589 

590 :param skel: The deleted SkeletonInstance. 

591 :param boneName: The name of the RelationalBone in the Skeleton. 

592 :param key: The key of the deleted Entity. 

593 """ 

594 query = db.Query("viur-relations") \ 

595 .filter("viur_src_kind =", key.kind) \ 

596 .filter("viur_dest_kind =", self.kind) \ 

597 .filter("viur_src_property =", boneName) \ 

598 .filter("src.__key__ =", key) 

599 

600 db.delete([entity for entity in query.run()]) 

601 

602 def isInvalid(self, key) -> None: 

603 """ 

604 Check if the given key is invalid for this relational bone. 

605 

606 This method always returns None, as the actual validation of the key 

607 is performed in other methods of the RelationalBone class. 

608 

609 :param key: The key to be checked for validity. 

610 :return: None, as the actual validation is performed elsewhere. 

611 """ 

612 return None 

613 

614 def parseSubfieldsFromClient(self): 

615 """ 

616 Determine if the RelationalBone should parse subfields from the client. 

617 

618 This method returns True if the `using` attribute is not None, indicating 

619 that this RelationalBone has a using-skeleton, and its subfields should 

620 be parsed. Otherwise, it returns False. 

621 

622 :return: True if the using-skeleton is not None and subfields should be parsed, False otherwise. 

623 :rtype: bool 

624 """ 

625 return self.using is not None 

626 

627 def singleValueFromClient(self, value, skel, bone_name, client_data): 

628 errors = [] 

629 

630 if isinstance(value, dict): 

631 dest_key = value.pop("key", None) 

632 else: 

633 dest_key = value 

634 value = {} 

635 

636 if self.using: 

637 rel = self.using() 

638 if not rel.fromClient(value): 

639 errors.append( 

640 ReadFromClientError( 

641 ReadFromClientErrorSeverity.Invalid, 

642 i18n.translate("core.bones.error.incomplete", "Incomplete data"), 

643 ) 

644 ) 

645 

646 errors.extend(rel.errors) 

647 else: 

648 rel = None 

649 

650 # FIXME VIUR4: createRelSkelFromKey doesn't accept an instance of a RelSkel... 

651 if ret := self.createRelSkelFromKey(dest_key, None): # ...therefore we need to first give None... 

652 ret["rel"] = rel # ...and then assign it manually. 

653 

654 if err := self.isInvalid(ret): 

655 ret = self.getEmptyValue() 

656 errors.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)) 

657 

658 return ret, errors 

659 

660 elif self.consistency == RelationalConsistency.Ignore: 

661 # when RelationalConsistency.Ignore is on, keep existing relations, even when they where deleted 

662 for _, _, value in self.iter_bone_value(skel, bone_name): 

663 if str(value["dest"]["key"]) == str(dest_key): 

664 value["rel"] = rel 

665 return value, errors 

666 

667 errors.append(ReadFromClientError(ReadFromClientErrorSeverity.Invalid)) 

668 return self.getEmptyValue(), errors 

669 

670 def _rewriteQuery(self, name, skel, dbFilter, rawFilter): 

671 """ 

672 Rewrites a datastore query to operate on "viur-relations" instead of the original kind. 

673 

674 This method is needed to perform relational queries on n:m relations. It takes the original datastore query 

675 and rewrites it to target the "viur-relations" kind. It also adjusts filters and sort orders accordingly. 

676 

677 :param str name: The name of the bone. 

678 :param SkeletonInstance skel: The skeleton instance the bone is a part of. 

679 :param viur.core.db.Query dbFilter: The original datastore query to be rewritten. 

680 :param dict rawFilter: The raw filter applied to the original datastore query. 

681 

682 :return: A tuple containing the name, skeleton, rewritten query, and raw filter. 

683 :rtype: Tuple[str, 'viur.core.skeleton.SkeletonInstance', 'viur.core.db.Query', dict] 

684 

685 :raises NotImplementedError: If the original query contains multiple filters with "IN" or "!=" operators. 

686 :raises RuntimeError: If the filtering is invalid, e.g., using multiple key filters or querying 

687 properties not in parentKeys. 

688 """ 

689 origQueries = dbFilter.queries 

690 if isinstance(origQueries, list): 

691 raise NotImplementedError( 

692 "Doing a relational Query with multiple=True and \"IN or !=\"-filters is currently unsupported!") 

693 dbFilter.queries = db.QueryDefinition("viur-relations", { 

694 "viur_src_kind =": skel.kindName, 

695 "viur_dest_kind =": self.kind, 

696 "viur_src_property =": name 

697 

698 }, orders=[], startCursor=origQueries.startCursor, endCursor=origQueries.endCursor) 

699 for k, v in origQueries.filters.items(): # Merge old filters in 

700 # Ensure that all non-relational-filters are in parentKeys 

701 if k == db.KEY_SPECIAL_PROPERTY: 

702 # We must process the key-property separately as its meaning changes as we change the datastore kind were querying 

703 if isinstance(v, list) or isinstance(v, tuple): 

704 logging.warning(f"Invalid filtering! Doing an relational Query on {name} with multiple key= " 

705 f"filters is unsupported!") 

706 raise RuntimeError() 

707 if not isinstance(v, db.Key): 

708 v = db.Key(v) 

709 dbFilter.ancestor(v) 

710 continue 

711 boneName = k.split(".")[0].split(" ")[0] 

712 if boneName not in self.parentKeys and boneName != "__key__": 

713 logging.warning(f"Invalid filtering! {boneName} is not in parentKeys of RelationalBone {name}!") 

714 raise RuntimeError() 

715 dbFilter.filter(f"src.{k}", v) 

716 orderList = [] 

717 for k, d in origQueries.orders: # Merge old sort orders in 

718 if k == db.KEY_SPECIAL_PROPERTY: 

719 orderList.append((f"{k}", d)) 

720 elif not k in self.parentKeys: 

721 logging.warning(f"Invalid filtering! {k} is not in parentKeys of RelationalBone {name}!") 

722 raise RuntimeError() 

723 else: 

724 orderList.append((f"src.{k}", d)) 

725 if orderList: 

726 dbFilter.order(*orderList) 

727 return name, skel, dbFilter, rawFilter 

728 

729 def buildDBFilter( 

730 self, 

731 name: str, 

732 skel: "SkeletonInstance", 

733 dbFilter: db.Query, 

734 rawFilter: dict, 

735 prefix: t.Optional[str] = None 

736 ) -> db.Query: 

737 """ 

738 Builds a datastore query by modifying the given filter based on the RelationalBone's properties. 

739 

740 This method takes a datastore query and modifies it according to the relational bone properties. 

741 It also merges any related filters based on the 'refKeys' and 'using' attributes of the bone. 

742 

743 :param str name: The name of the bone. 

744 :param SkeletonInstance skel: The skeleton instance the bone is a part of. 

745 :param db.Query dbFilter: The original datastore query to be modified. 

746 :param dict rawFilter: The raw filter applied to the original datastore query. 

747 :param str prefix: Optional prefix to be applied to filter keys. 

748 

749 :return: The modified datastore query. 

750 :rtype: db.Query 

751 

752 :raises RuntimeError: If the filtering is invalid, e.g., querying properties not in 'refKeys' 

753 or not a bone in 'using'. 

754 """ 

755 relSkel, _usingSkelCache = self._getSkels() 

756 origQueries = dbFilter.queries 

757 

758 if origQueries is None: # This query is unsatisfiable 

759 return dbFilter 

760 

761 myKeys = [x for x in rawFilter.keys() if x.startswith(f"{name}.")] 

762 if len(myKeys) > 0: # We filter by some properties 

763 if dbFilter.getKind() != "viur-relations" and self.multiple: 

764 name, skel, dbFilter, rawFilter = self._rewriteQuery(name, skel, dbFilter, rawFilter) 

765 

766 # Merge the relational filters in 

767 for myKey in myKeys: 

768 value = rawFilter[myKey] 

769 

770 try: 

771 unused, _type, key = myKey.split(".", 2) 

772 assert _type in ["dest", "rel"] 

773 except: 

774 if self.using is None: 

775 # This will be a "dest" query 

776 _type = "dest" 

777 try: 

778 unused, key = myKey.split(".", 1) 

779 except: 

780 continue 

781 else: 

782 continue 

783 

784 # just use the first part of "key" to check against our refSkel / relSkel (strip any leading .something and $something) 

785 checkKey = key 

786 if "." in checkKey: 

787 checkKey = checkKey.split(".")[0] 

788 

789 if "$" in checkKey: 

790 checkKey = checkKey.split("$")[0] 

791 

792 if _type == "dest": 

793 

794 # Ensure that the relational-filter is in refKeys 

795 if checkKey not in self._ref_keys: 

796 logging.warning(f"Invalid filtering! {key} is not in refKeys of RelationalBone {name}!") 

797 raise RuntimeError() 

798 

799 # Iterate our relSkel and let these bones write their filters in 

800 for bname, bone in relSkel.items(): 

801 if checkKey == bname: 

802 newFilter = {key: value} 

803 if self.multiple: 

804 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "dest.") 

805 else: 

806 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, 

807 prefix=(prefix or "") + name + ".dest.") 

808 

809 elif _type == "rel": 

810 

811 # Ensure that the relational-filter is in refKeys 

812 if self.using is None or checkKey not in self.using(): 

813 logging.warning(f"Invalid filtering! {key} is not a bone in 'using' of {name}") 

814 raise RuntimeError() 

815 

816 # Iterate our usingSkel and let these bones write their filters in 

817 for bname, bone in self.using().items(): 

818 if key.startswith(bname): 

819 newFilter = {key: value} 

820 if self.multiple: 

821 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, prefix=(prefix or "") + "rel.") 

822 else: 

823 bone.buildDBFilter(bname, relSkel, dbFilter, newFilter, 

824 prefix=(prefix or "") + name + ".rel.") 

825 

826 if self.multiple: 

827 dbFilter.setFilterHook(lambda s, filter, value: self.filterHook(name, s, filter, value)) 

828 dbFilter.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings)) 

829 

830 elif name in rawFilter and isinstance(rawFilter[name], str) and rawFilter[name].lower() == "none": 

831 dbFilter = dbFilter.filter(f"{name} =", None) 

832 

833 return dbFilter 

834 

835 def buildDBSort( 

836 self, 

837 name: str, 

838 skel: "SkeletonInstance", 

839 query: db.Query, 

840 params: dict, 

841 postfix: str = "", 

842 ) -> t.Optional[db.Query]: 

843 """ 

844 Builds a datastore query by modifying the given filter based on the RelationalBone's properties for sorting. 

845 

846 This method takes a datastore query and modifies its sorting behavior according to the relational bone 

847 properties. It also checks if the sorting is valid based on the 'refKeys' and 'using' attributes of the bone. 

848 

849 :param name: The name of the bone. 

850 :param skel: The skeleton instance the bone is a part of. 

851 :param query: The original datastore query to be modified. 

852 :param params: The raw filter applied to the original datastore query. 

853 

854 :return: The modified datastore query with updated sorting behavior. 

855 :rtype: t.Optional[db.Query] 

856 

857 :raises RuntimeError: If the sorting is invalid, e.g., using properties not in 'refKeys' 

858 or not a bone in 'using'. 

859 """ 

860 if query.queries and (orderby := params.get("orderby")) and utils.string.is_prefix(orderby, name): 

861 if self.multiple and query.getKind() != "viur-relations": 

862 # This query has not been rewritten (yet) 

863 name, skel, query, params = self._rewriteQuery(name, skel, query, params) 

864 

865 try: 

866 _, _type, param = orderby.split(".") 

867 except ValueError as e: 

868 logging.exception(f"Invalid layout of {orderby=}: {e}") 

869 return query 

870 if _type not in ("dest", "rel"): 

871 logging.error("Invalid type {_type}") 

872 return query 

873 

874 # Ensure that the relational-filter is in refKeys 

875 if _type == "dest" and param not in self._ref_keys: 

876 raise RuntimeError(f"Invalid filtering! {param!r} is not in refKeys of RelationalBone {name!r}!") 

877 elif _type == "rel" and (self.using is None or param not in self.using()): 

878 raise RuntimeError(f"Invalid filtering! {param!r} is not a bone in 'using' of RelationalBone {name!r}") 

879 

880 if self.multiple: 

881 path = f"{_type}.{param}" 

882 else: 

883 path = f"{name}.{_type}.{param}" 

884 

885 order = utils.parse.sortorder(params.get("orderdir")) 

886 query = query.order((path, order)) 

887 

888 if self.multiple: 

889 query.setFilterHook(lambda s, query, value: self.filterHook(name, s, query, value)) 

890 query.setOrderHook(lambda s, orderings: self.orderHook(name, s, orderings)) 

891 

892 return query 

893 

894 def filterHook(self, name, query, param, value): # FIXME 

895 """ 

896 Hook installed by buildDbFilter that rewrites filters added to the query to match the layout of the 

897 viur-relations index and performs sanity checks on the query. 

898 

899 This method rewrites and validates filters added to a datastore query after the `buildDbFilter` method 

900 has been executed. It ensures that the filters are compatible with the structure of the viur-relations 

901 index and checks if the query is possible. 

902 

903 :param str name: The name of the bone. 

904 :param db.Query query: The datastore query to be modified. 

905 :param str param: The filter parameter to be checked and potentially modified. 

906 :param value: The value associated with the filter parameter. 

907 

908 :return: A tuple containing the modified filter parameter and its associated value, or None if 

909 the filter parameter is a key special property. 

910 :rtype: Tuple[str, Any] or None 

911 

912 :raises RuntimeError: If the filtering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'. 

913 """ 

914 if param.startswith("src.") or param.startswith("dest.") or param.startswith("viur_"): 

915 # This filter is already valid in our relation 

916 return param, value 

917 if param.startswith(f"{name}."): 

918 # We add a constrain filtering by properties of the referenced entity 

919 refKey = param.replace(f"{name}.", "") 

920 if " " in refKey: # Strip >, < or = params 

921 refKey = refKey[:refKey.find(" ")] 

922 if refKey not in self._ref_keys: 

923 logging.warning(f"Invalid filtering! {refKey} is not in refKeys of RelationalBone {name}!") 

924 raise RuntimeError() 

925 if self.multiple: 

926 return param.replace(f"{name}.", "dest."), value 

927 else: 

928 return param, value 

929 else: 

930 # We filter by a property of this entity 

931 if not self.multiple: 

932 # Not relational, not multiple - nothing to do here 

933 return param, value 

934 # Prepend "src." 

935 srcKey = param 

936 if " " in srcKey: 

937 srcKey = srcKey[: srcKey.find(" ")] # Cut <, >, and = 

938 if srcKey == db.KEY_SPECIAL_PROPERTY: # Rewrite key= filter as its meaning has changed 

939 if isinstance(value, list) or isinstance(value, tuple): 

940 logging.warning(f"Invalid filtering! Doing an relational Query on {name} " 

941 f"with multiple key= filters is unsupported!") 

942 raise RuntimeError() 

943 if not isinstance(value, db.Key): 

944 value = db.Key(value) 

945 query.ancestor(value) 

946 return None 

947 if srcKey not in self.parentKeys: 

948 logging.warning(f"Invalid filtering! {srcKey} is not in parentKeys of RelationalBone {name}!") 

949 raise RuntimeError() 

950 return f"src.{param}", value 

951 

952 def orderHook(self, name: str, query: db.Query, orderings): # FIXME 

953 """ 

954 Hook installed by buildDbFilter that rewrites orderings added to the query to match the layout of the 

955 viur-relations index and performs sanity checks on the query. 

956 

957 This method rewrites and validates orderings added to a datastore query after the `buildDbFilter` method 

958 has been executed. It ensures that the orderings are compatible with the structure of the viur-relations 

959 index and checks if the query is possible. 

960 

961 :param name: The name of the bone. 

962 :param query: The datastore query to be modified. 

963 :param orderings: A list or tuple of orderings to be checked and potentially modified. 

964 :type orderings: List[Union[str, Tuple[str, db.SortOrder]]] or Tuple[Union[str, Tuple[str, db.SortOrder]]] 

965 

966 :return: A list of modified orderings that are compatible with the viur-relations index. 

967 :rtype: List[Union[str, Tuple[str, db.SortOrder]]] 

968 

969 :raises RuntimeError: If the ordering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'. 

970 """ 

971 res = [] 

972 if not isinstance(orderings, list) and not isinstance(orderings, tuple): 

973 orderings = [orderings] 

974 for order in orderings: 

975 if isinstance(order, tuple): 

976 orderKey = order[0] 

977 else: 

978 orderKey = order 

979 if orderKey.startswith("dest.") or orderKey.startswith("rel.") or orderKey.startswith("src."): 

980 # This is already valid for our relational index 

981 res.append(order) 

982 continue 

983 if orderKey.startswith(f"{name}."): 

984 k = orderKey.replace(f"{name}.", "") 

985 if k not in self._ref_keys: 

986 logging.warning(f"Invalid ordering! {k} is not in refKeys of RelationalBone {name}!") 

987 raise RuntimeError() 

988 if not self.multiple: 

989 res.append(order) 

990 else: 

991 if isinstance(order, tuple): 

992 res.append((f"dest.{k}", order[1])) 

993 else: 

994 res.append(f"dest.{k}") 

995 else: 

996 if not self.multiple: 

997 # Nothing to do here 

998 res.append(order) 

999 continue 

1000 else: 

1001 if orderKey not in self.parentKeys: 

1002 logging.warning( 

1003 f"Invalid ordering! {orderKey} is not in parentKeys of RelationalBone {name}!") 

1004 raise RuntimeError() 

1005 if isinstance(order, tuple): 

1006 res.append((f"src.{orderKey}", order[1])) 

1007 else: 

1008 res.append(f"src.{orderKey}") 

1009 return res 

1010 

1011 def refresh(self, skel: "SkeletonInstance", name: str) -> None: 

1012 """ 

1013 Refreshes all values that might be cached from other entities in the provided skeleton. 

1014 

1015 This method updates the cached values for relational bones in the provided skeleton, which 

1016 correspond to other entities. It fetches the updated values for the relational bone's 

1017 reference keys and replaces the cached values in the skeleton with the fetched values. 

1018 

1019 :param SkeletonInstance skel: The skeleton containing the bone to be refreshed. 

1020 :param str boneName: The name of the bone to be refreshed. 

1021 """ 

1022 if not skel[name] or self.updateLevel == RelationalUpdateLevel.OnValueAssignment: 

1023 return 

1024 

1025 for _, _, value in self.iter_bone_value(skel, name): 

1026 if value and value["dest"]: 

1027 try: 

1028 target_skel = value["dest"].read() 

1029 except ValueError: 

1030 

1031 # Handle removed reference according to the RelationalConsistency settings 

1032 match self.consistency: 

1033 case RelationalConsistency.CascadeDeletion: 

1034 logging.info( 

1035 f"{name}: " 

1036 f"Cascade deleting {skel["key"]!r} ({skel["name"]!r}) " 

1037 f"due removal of relation {value["dest"]["key"]!r} ({value["dest"]["name"]!r})" 

1038 ) 

1039 skel._cascade_deletion = True 

1040 break 

1041 

1042 case RelationalConsistency.SetNull: 

1043 logging.info( 

1044 f"{name}: " 

1045 f"Emptying relation {skel["key"]!r} ({skel["name"]!r}) " 

1046 f"due removal of {value["dest"]["key"]!r} ({value["dest"]["name"]!r})" 

1047 ) 

1048 value.clear() 

1049 

1050 case _: 

1051 logging.info( 

1052 f"{name}: " 

1053 f"Relation from {skel["key"]!r} ({skel["name"]!r}) " 

1054 f"refers to deleted {value["dest"]["key"]!r} ({value["dest"]["name"]!r}), skipping" 

1055 ) 

1056 

1057 continue 

1058 

1059 # Copy over the refKey values 

1060 for key in self.refKeys: 

1061 value["dest"][key] = target_skel[key] 

1062 

1063 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]: 

1064 """ 

1065 Retrieves the search tags for the given RelationalBone in the provided skeleton. 

1066 

1067 This method iterates over the values of the relational bone and gathers search tags from the 

1068 reference and using skeletons. It combines all the tags into a set to avoid duplicates. 

1069 

1070 :param skel: The skeleton containing the bone for which search tags are to be retrieved. 

1071 :param name: The name of the bone for which search tags are to be retrieved. 

1072 

1073 :return: A set of search tags for the specified relational bone. 

1074 """ 

1075 result = set() 

1076 

1077 def get_values(skel_, values_cache): 

1078 for key, bone in skel_.items(): 

1079 if not bone.searchable: 

1080 continue 

1081 for tag in bone.getSearchTags(values_cache, key): 

1082 result.add(tag) 

1083 

1084 ref_skel_cache, using_skel_cache = self._getSkels() 

1085 for idx, lang, value in self.iter_bone_value(skel, name): 

1086 if value is None: 

1087 continue 

1088 if value["dest"]: 

1089 get_values(ref_skel_cache, value["dest"]) 

1090 if value["rel"]: 

1091 get_values(using_skel_cache, value["rel"]) 

1092 

1093 return result 

1094 

1095 def createRelSkelFromKey(self, key: db.Key, rel: dict | None = None) -> RelDict | None: 

1096 if rel_skel := self.relskels_from_keys([(key, rel)]): 

1097 return rel_skel[0] 

1098 return None 

1099 

1100 def relskels_from_keys(self, key_rel_list: list[tuple[db.Key, dict | None]]) -> list[RelDict]: 

1101 """ 

1102 Creates a list of RelSkel instances valid for this bone from the given database key. 

1103 

1104 This method retrieves the entity corresponding to the provided key from the database, unserializes it 

1105 into a reference skeleton, and returns a dictionary containing the reference skeleton and optional 

1106 relation data. 

1107 

1108 :param key_rel_list: List of tuples with the first value in the tuple is the 

1109 key and the second is and RelSkel or None 

1110 

1111 :return: A dictionary containing a reference skeleton and optional relation data. 

1112 """ 

1113 

1114 if not all(db_objs := db.get([db.key_helper(value[0], self.kind, adjust_kind=True) for value in key_rel_list])): 

1115 return [] # return emtpy data when not all data is found 

1116 

1117 res_rel_skels = [] 

1118 

1119 for (key, rel), db_obj in zip(key_rel_list, db_objs): 

1120 dest_skel = self._refSkelCache() 

1121 dest_skel.unserialize(db_obj) 

1122 for bone_name in dest_skel: 

1123 # Unserialize all bones from refKeys, then drop dbEntity - otherwise all properties will be copied 

1124 _ = dest_skel[bone_name] 

1125 dest_skel.dbEntity = None 

1126 res_rel_skels.append( 

1127 { 

1128 "dest": dest_skel, 

1129 "rel": rel or None 

1130 } 

1131 ) 

1132 

1133 return res_rel_skels 

1134 

1135 def setBoneValue( 

1136 self, 

1137 skel: "SkeletonInstance", 

1138 boneName: str, 

1139 value: t.Any, 

1140 append: bool, 

1141 language: None | str = None 

1142 ) -> bool: 

1143 """ 

1144 Sets the value of the specified bone in the given skeleton. Sanity checks are performed to ensure the 

1145 value is valid. If the value is invalid, no modifications are made. 

1146 

1147 :param skel: Dictionary with the current values from the skeleton we belong to. 

1148 :param boneName: The name of the bone to be modified. 

1149 :param value: The value to be assigned. The type depends on the bone type. 

1150 :param append: If true, the given value is appended to the values of the bone instead of replacing it. 

1151 Only supported on bones with multiple=True. 

1152 :param language: Set/append for a specific language (optional). Required if the bone 

1153 supports languages. 

1154 

1155 :return: True if the operation succeeded, False otherwise. 

1156 """ 

1157 assert not (bool(self.languages) ^ bool(language)), "Language is required or not supported" 

1158 assert not append or self.multiple, "Can't append - bone is not multiple" 

1159 

1160 def tuple_check(in_value: tuple | None = None) -> bool: 

1161 """ 

1162 Return True if the given value is a tuple with a length of two. 

1163 In addition, the first field in the tuple must be a str,int or db.key. 

1164 Furthermore, the second field must be a skeletonInstanceClassRef. 

1165 """ 

1166 return (isinstance(in_value, tuple) and len(in_value) == 2 

1167 and isinstance(in_value[0], (str, int, db.Key)) 

1168 and isinstance(in_value[1], self._skeletonInstanceClassRef)) 

1169 

1170 if not self.multiple and not self.using: 

1171 if not isinstance(value, (str, int, db.Key)): 

1172 raise ValueError(f"You must supply exactly one Database-Key str or int to {boneName}") 

1173 parsed_value = (value, None) 

1174 elif not self.multiple and self.using: 

1175 if not tuple_check(value): 

1176 raise ValueError(f"You must supply a tuple of (Database-Key, relSkel) to {boneName}") 

1177 parsed_value = value 

1178 elif self.multiple and not self.using: 

1179 if not isinstance(value, (str, int, db.Key)) and not (isinstance(value, list)) \ 

1180 and all([isinstance(val, (str, int, db.Key)) for val in value]): 

1181 raise ValueError(f"You must supply a Database-Key or a list hereof to {boneName}") 

1182 if isinstance(value, list): 

1183 parsed_value = [(key, None) for key in value] 

1184 else: 

1185 parsed_value = [(value, None)] 

1186 else: # which means (self.multiple and self.using) 

1187 if not tuple_check(value) and (not isinstance(value, list) or not all(tuple_check(val) for val in value)): 

1188 raise ValueError(f"You must supply (db.Key, RelSkel) or a list hereof to {boneName}") 

1189 if isinstance(value, list): 

1190 parsed_value = value 

1191 else: 

1192 parsed_value = [value] 

1193 

1194 if boneName not in skel: 

1195 skel[boneName] = {} 

1196 if language: 

1197 skel[boneName].setdefault(language, []) 

1198 

1199 if self.multiple: 

1200 rel_list = self.relskels_from_keys(parsed_value) 

1201 if append: 

1202 if language: 

1203 skel[boneName][language].extend(rel_list) 

1204 else: 

1205 if not isinstance(skel[boneName], list): 

1206 skel[boneName] = [] 

1207 skel[boneName].extend(rel_list) 

1208 else: 

1209 if language: 

1210 skel[boneName][language] = rel_list 

1211 else: 

1212 skel[boneName] = rel_list 

1213 else: 

1214 if not (rel := self.createRelSkelFromKey(parsed_value[0], parsed_value[1])): 

1215 return False 

1216 if language: 

1217 skel[boneName][language] = rel 

1218 else: 

1219 skel[boneName] = rel 

1220 return True 

1221 

1222 def getReferencedBlobs(self, skel: "SkeletonInstance", name: str) -> set[str]: 

1223 """ 

1224 Retrieves the set of referenced blobs from the specified bone in the given skeleton instance. 

1225 

1226 :param SkeletonInstance skel: The skeleton instance to extract the referenced blobs from. 

1227 :param str name: The name of the bone to retrieve the referenced blobs from. 

1228 

1229 :return: A set containing the unique blob keys referenced by the specified bone. 

1230 :rtype: Set[str] 

1231 """ 

1232 result = set() 

1233 

1234 for idx, lang, value in self.iter_bone_value(skel, name): 

1235 if not value: 

1236 continue 

1237 

1238 for key, bone in value["dest"].items(): 

1239 result.update(bone.getReferencedBlobs(value["dest"], key)) 

1240 

1241 if value["rel"]: 

1242 for key, bone in value["rel"].items(): 

1243 result.update(bone.getReferencedBlobs(value["rel"], key)) 

1244 

1245 return result 

1246 

1247 def getUniquePropertyIndexValues(self, valuesCache: dict, name: str) -> list[str]: 

1248 """ 

1249 Generates unique property index values for the RelationalBone based on the referenced keys. 

1250 Can be overridden if different behavior is required (e.g., examining values from `prop:usingSkel`). 

1251 

1252 :param dict valuesCache: The cache containing the current values of the bone. 

1253 :param str name: The name of the bone for which to generate unique property index values. 

1254 

1255 :return: A list containing the unique property index values for the specified bone. 

1256 :rtype: List[str] 

1257 """ 

1258 value = valuesCache.get(name) 

1259 if not value: # We don't have a value to lock 

1260 return [] 

1261 if isinstance(value, dict): 

1262 return self._hashValueForUniquePropertyIndex(value["dest"]["key"]) 

1263 elif isinstance(value, list): 

1264 return self._hashValueForUniquePropertyIndex([entry["dest"]["key"] for entry in value if entry]) 

1265 

1266 def structure(self) -> dict: 

1267 return super().structure() | { 

1268 "type": f"{self.type}.{self.kind}", 

1269 "module": self.module, 

1270 "format": self.format, 

1271 "using": self.using().structure() if self.using else None, 

1272 "relskel": self._refSkelCache().structure(), 

1273 } 

1274 

1275 def _atomic_dump(self, value: dict[str, "SkeletonInstance"]) -> dict | None: 

1276 if isinstance(value, dict): 

1277 return { 

1278 "dest": value["dest"].dump(), 

1279 "rel": value["rel"].dump() if value["rel"] else None, 

1280 }