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

571 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 10:50 +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, i18n, utils 

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: 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was always true

255 self.module = self.kind 

256 

257 if self.kind is None or self.module is None: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

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

259 

260 # Referenced keys 

261 self.refKeys = {"key", "shortkey"} 

262 if refKeys: 262 ↛ 266line 262 didn't jump to line 266 because the condition on line 262 was always true

263 self.refKeys |= set(refKeys) 

264 

265 # Parent keys 

266 self.parentKeys = {"key"} 

267 if parentKeys: 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true

268 self.parentKeys |= set(parentKeys) 

269 

270 self.using = using 

271 

272 # FIXME: Remove in VIUR4!! 

273 if isinstance(updateLevel, int): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

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(): 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

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 super().serialize(skel, name, parentIndexed) 

433 

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

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

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

437 del skel.dbEntity[key] 

438 

439 indexed = self.indexed and parentIndexed 

440 

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

442 return False 

443 

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

445 if self.languages: 

446 res = {"_viurLanguageWrapper_": True} 

447 for language in self.languages: 

448 if language in new_vals: 

449 if self.multiple: 

450 res[language] = [] 

451 for val in new_vals[language]: 

452 if val: 

453 using_data, ref_data = serialize_dest_rel(val) 

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

455 else: 

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

457 using_data, ref_data = serialize_dest_rel(val) 

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

459 elif self.multiple: 

460 res = [] 

461 for val in new_vals: 

462 if val: 

463 using_data, ref_data = serialize_dest_rel(val) 

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

465 elif new_vals: 

466 using_data, ref_data = serialize_dest_rel(new_vals) 

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

468 

469 skel.dbEntity[name] = res 

470 

471 # Ensure our indexed flag is up2date 

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

473 skel.dbEntity.exclude_from_indexes.discard(name) 

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

475 skel.dbEntity.exclude_from_indexes.add(name) 

476 

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

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

479 

480 return True 

481 

482 def _get_single_destinct_hash(self, value): 

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

484 

485 if self.using: 

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

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

488 

489 return tuple(parts) 

490 

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

492 """ 

493 Handle relational updates after a skeleton is saved. 

494 

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

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

497 

498 :param skel: The saved skeleton instance. 

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

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

501 """ 

502 viur_src_kind = key.kind 

503 viur_src_property = boneName 

504 

505 # Hack for RelationalBones in containers (like RecordBones) 

506 if "." in boneName: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

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

508 

509 if not skel[boneName]: 509 ↛ 511line 509 didn't jump to line 511 because the condition on line 509 was always true

510 values = [] 

511 elif self.multiple and self.languages: 

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

513 elif self.languages: 

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

515 elif self.multiple: 

516 values = skel[boneName] 

517 else: 

518 values = [skel[boneName]] 

519 

520 # Keep a set of all referenced keys 

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

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

523 

524 # Referenced parent values 

525 src_values = db.Entity(key) 

526 if skel.dbEntity: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

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

528 

529 # Now is now, nana nananaaaaaaa... 

530 now = time.time() 

531 

532 # All relation entities share the source entity's group (parent=key), so they are 

533 # collected here and written in one commit instead of one put per relation: that is a 

534 # single round-trip, and a single write against the one-write-per-second-and-entity-group 

535 # rate limit rather than one per relation. 

536 to_put: list[db.Entity] = [] 

537 to_delete: list[db.Key] = [] 

538 

539 # Helper function to fill a relation entity from a bone value 

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

541 ref_skel = data["dest"] 

542 rel_skel = data["rel"] 

543 

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

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

546 entity["src"] = src_values 

547 

548 entity["viur_src_kind"] = viur_src_kind 

549 entity["viur_src_property"] = viur_src_property 

550 entity["viur_dest_kind"] = self.kind 

551 entity["viur_delayed_update_tag"] = now 

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

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

554 # Store expanded bone names, not raw refKeys patterns. 

555 # refKeys may contain fnmatch wildcards (e.g. "delivery_time_*" matching 

556 # "delivery_time_min", "delivery_time_max", "delivery_time_range"). 

557 # update_relations filters viur-relations via Datastore IN-query with the 

558 # literal changed bone name — wildcard patterns would never match there. 

559 entity["viur_foreign_keys"] = list(self._ref_keys) 

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

561 

562 to_put.append(entity) 

563 

564 # Query and update existing entries pointing to this bone 

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

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

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

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

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

570 

571 for entity in query.iter(): 

572 try: 

573 if entity["dest"].key not in values_keys: # Relation has been removed 573 ↛ anywhereline 573 didn't jump anywhere: it always raised an exception.

574 to_delete.append(entity.key) 

575 continue 

576 

577 except (KeyError, AttributeError): # This entry is corrupt (missing or empty dest) 

578 to_delete.append(entity.key) 

579 

580 else: # Relation: Updated 

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

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

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

584 values.remove(value) 

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

586 

587 # Update existing database entry 

588 __update_relation(entity, value) 

589 

590 # Add new database entries for the remaining values 

591 for value in values: 591 ↛ 592line 591 didn't jump to line 592 because the loop on line 591 never started

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

593 

594 # A key is either deleted or written, never both, so the order of the two is irrelevant 

595 if to_delete: 595 ↛ 598line 595 didn't jump to line 598 because the condition on line 595 was always true

596 db.delete(to_delete) 

597 

598 if to_put: 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true

599 db.put(to_put) 

600 

601 # Call postSavedHandler on UsingSkel (RelSkel) 

602 if self.using: 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true

603 for idx, lang, value in self.iter_bone_value(skel, boneName): 

604 if not value or not value["rel"]: 

605 continue 

606 for bone_name, bone in value["rel"].items(): 

607 bone.postSavedHandler(value["rel"], bone_name, key) 

608 

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

610 """ 

611 Handle relational updates after a skeleton is deleted. 

612 

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

614 for the given relational bone. 

615 

616 :param skel: The deleted SkeletonInstance. 

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

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

619 """ 

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

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

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

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

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

625 

626 # iter() deliberately ignores the query limit, run() would stop after 

627 # conf.db.query_default_limit entries and orphan every relation beyond it 

628 db.delete(list(query.iter(keys_only=True))) 

629 

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

631 """ 

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

633 

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

635 is performed in other methods of the RelationalBone class. 

636 

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

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

639 """ 

640 return None 

641 

642 def parseSubfieldsFromClient(self): 

643 """ 

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

645 

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

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

648 be parsed. Otherwise, it returns False. 

649 

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

651 :rtype: bool 

652 """ 

653 return self.using is not None 

654 

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

656 errors = [] 

657 

658 if isinstance(value, dict): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

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

660 else: 

661 dest_key = value 

662 value = {} 

663 

664 if not isinstance(dest_key, db.KeyType): 664 ↛ 665line 664 didn't jump to line 665 because the condition on line 664 was never true

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

666 return self.getEmptyValue(), errors 

667 

668 if self.using: 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 rel = self.using() 

670 if not rel.fromClient(value): 

671 errors.append( 

672 ReadFromClientError( 

673 ReadFromClientErrorSeverity.Invalid, 

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

675 ) 

676 ) 

677 

678 errors.extend(rel.errors) 

679 else: 

680 rel = None 

681 

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

683 if ret := self.createRelSkelFromKey(dest_key, None): # ...therefore we need to first give None... 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

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

685 

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

687 ret = self.getEmptyValue() 

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

689 

690 return ret, errors 

691 

692 elif self.consistency == RelationalConsistency.Ignore: 692 ↛ 700line 692 didn't jump to line 700 because the condition on line 692 was always true

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

694 dest_key = db.key_helper(dest_key, self.kind, adjust_kind=True) # also resolves shortkeys 

695 for _, _, value in self.iter_bone_value(skel, bone_name): 695 ↛ 700line 695 didn't jump to line 700 because the loop on line 695 didn't complete

696 if value["dest"]["key"] == dest_key: 696 ↛ 695line 696 didn't jump to line 695 because the condition on line 696 was always true

697 value["rel"] = rel 

698 return value, errors 

699 

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

701 return self.getEmptyValue(), errors 

702 

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

704 """ 

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

706 

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

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

709 

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

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

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

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

714 

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

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

717 

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

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

720 properties not in parentKeys. 

721 """ 

722 origQueries = dbFilter.queries 

723 if isinstance(origQueries, list): 

724 raise NotImplementedError( 

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

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

727 "viur_src_kind =": skel.kindName, 

728 "viur_dest_kind =": self.kind, 

729 "viur_src_property =": name 

730 

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

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

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

734 if k == db.KEY_SPECIAL_PROPERTY: 

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

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

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

738 f"filters is unsupported!") 

739 raise RuntimeError() 

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

741 v = db.Key(v) 

742 dbFilter.ancestor(v) 

743 continue 

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

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

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

747 raise RuntimeError() 

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

749 orderList = [] 

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

751 if k == db.KEY_SPECIAL_PROPERTY: 

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

753 elif not k in self.parentKeys: 

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

755 raise RuntimeError() 

756 else: 

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

758 if orderList: 

759 dbFilter.order(*orderList) 

760 return name, skel, dbFilter, rawFilter 

761 

762 def buildDBFilter( 

763 self, 

764 name: str, 

765 skel: "SkeletonInstance", 

766 dbFilter: db.Query, 

767 rawFilter: dict, 

768 prefix: t.Optional[str] = None 

769 ) -> db.Query: 

770 """ 

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

772 

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

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

775 

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

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

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

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

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

781 

782 :return: The modified datastore query. 

783 :rtype: db.Query 

784 

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

786 or not a bone in 'using'. 

787 """ 

788 relSkel, _usingSkelCache = self._getSkels() 

789 origQueries = dbFilter.queries 

790 

791 if origQueries is None: # This query is unsatisfiable 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true

792 return dbFilter 

793 

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

795 if len(myKeys) > 0: # We filter by some properties 795 ↛ 866line 795 didn't jump to line 866 because the condition on line 795 was always true

796 if dbFilter.getKind() != "viur-relations" and self.multiple: 796 ↛ 797line 796 didn't jump to line 797 because the condition on line 796 was never true

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

798 

799 # Merge the relational filters in 

800 for myKey in myKeys: 

801 value = rawFilter[myKey] 

802 

803 try: 

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

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

806 except: 

807 if self.using is None: 

808 # This will be a "dest" query 

809 _type = "dest" 

810 try: 

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

812 except: 

813 continue 

814 else: 

815 continue 

816 

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

818 checkKey = key 

819 if "." in checkKey: 819 ↛ 820line 819 didn't jump to line 820 because the condition on line 819 was never true

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

821 

822 if "$" in checkKey: 822 ↛ 823line 822 didn't jump to line 823 because the condition on line 822 was never true

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

824 

825 if _type == "dest": 825 ↛ 845line 825 didn't jump to line 845 because the condition on line 825 was always true

826 

827 # Ensure that the relational-filter is in refKeys 

828 if checkKey not in self._ref_keys: 828 ↛ 829line 828 didn't jump to line 829 because the condition on line 828 was never true

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

830 raise RuntimeError() 

831 

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

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

834 if checkKey == bname: 834 ↛ 833line 834 didn't jump to line 833 because the condition on line 834 was always true

835 if key == "key": # resolve shortkeys (id_or_name) to full keys 835 ↛ 838line 835 didn't jump to line 838 because the condition on line 835 was always true

836 value = [db.key_helper(v, self.kind, adjust_kind=True) for v in value] \ 

837 if isinstance(value, list) else db.key_helper(value, self.kind, adjust_kind=True) 

838 newFilter = {key: value} 

839 if self.multiple: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true

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

841 else: 

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

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

844 

845 elif _type == "rel": 

846 

847 # Ensure that the relational-filter is in refKeys 

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

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

850 raise RuntimeError() 

851 

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

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

854 if key.startswith(bname): 

855 newFilter = {key: value} 

856 if self.multiple: 

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

858 else: 

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

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

861 

862 if self.multiple: 862 ↛ 863line 862 didn't jump to line 863 because the condition on line 862 was never true

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

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

865 

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

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

868 

869 return dbFilter 

870 

871 def buildDBSort( 

872 self, 

873 name: str, 

874 skel: "SkeletonInstance", 

875 query: db.Query, 

876 params: dict, 

877 postfix: str = "", 

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

879 """ 

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

881 

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

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

884 

885 :param name: The name of the bone. 

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

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

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

889 

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

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

892 

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

894 or not a bone in 'using'. 

895 """ 

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

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

898 # This query has not been rewritten (yet) 

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

900 

901 try: 

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

903 except ValueError as e: 

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

905 return query 

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

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

908 return query 

909 

910 # Ensure that the relational-filter is in refKeys 

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

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

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

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

915 

916 if self.multiple: 

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

918 else: 

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

920 

921 order = db.SortOrder.from_str(params.get("orderdir")) 

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

923 

924 if self.multiple: 

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

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

927 

928 return query 

929 

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

931 """ 

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

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

934 

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

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

937 index and checks if the query is possible. 

938 

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

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

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

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

943 

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

945 the filter parameter is a key special property. 

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

947 

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

949 """ 

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

951 # This filter is already valid in our relation 

952 return param, value 

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

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

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

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

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

958 if refKey not in self._ref_keys: 

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

960 raise RuntimeError() 

961 if self.multiple: 

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

963 else: 

964 return param, value 

965 else: 

966 # We filter by a property of this entity 

967 if not self.multiple: 

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

969 return param, value 

970 # Prepend "src." 

971 srcKey = param 

972 if " " in srcKey: 

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

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

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

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

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

978 raise RuntimeError() 

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

980 value = db.Key(value) 

981 query.ancestor(value) 

982 return None 

983 if srcKey not in self.parentKeys: 

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

985 raise RuntimeError() 

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

987 

988 def orderHook( 

989 self, 

990 name: str, 

991 query: db.Query, 

992 orderings: list[db.QueryOrder | str] | tuple[db.QueryOrder | str, ...], 

993 ) -> list[db.QueryOrder | str] | tuple[db.QueryOrder | str]: 

994 """ 

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

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

997 

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

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

1000 index and checks if the query is possible. 

1001 

1002 :param name: The name of the bone. 

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

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

1005 

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

1007 

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

1009 """ 

1010 res = [] 

1011 if isinstance(orderings, (str, db.QueryOrder)): 

1012 orderings = [orderings] 

1013 elif not isinstance(orderings, list): 

1014 orderings = list(orderings) 

1015 for order in orderings: 

1016 if isinstance(order, db.QueryOrder): 

1017 orderKey = order.name 

1018 elif isinstance(order, str): 

1019 orderKey = order 

1020 else: 

1021 raise TypeError(f"Invalid ordering {order!r} in orderHook") 

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

1023 # This is already valid for our relational index 

1024 res.append(order) 

1025 continue 

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

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

1028 if k not in self._ref_keys: 

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

1030 raise RuntimeError() 

1031 if not self.multiple: 

1032 res.append(order) 

1033 else: 

1034 if isinstance(order, tuple): 

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

1036 else: 

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

1038 else: 

1039 if not self.multiple: 

1040 # Nothing to do here 

1041 res.append(order) 

1042 continue 

1043 else: 

1044 if orderKey not in self.parentKeys: 

1045 logging.warning( 

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

1047 raise RuntimeError() 

1048 if isinstance(order, tuple): 

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

1050 else: 

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

1052 return res 

1053 

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

1055 """ 

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

1057 

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

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

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

1061 

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

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

1064 """ 

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

1066 return 

1067 

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

1069 if value and value["dest"]: 

1070 try: 

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

1072 except ValueError: 

1073 

1074 # Handle removed reference according to the RelationalConsistency settings 

1075 match self.consistency: 

1076 case RelationalConsistency.CascadeDeletion: 

1077 logging.info( 

1078 f"{name}: " 

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

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

1081 ) 

1082 skel._cascade_deletion = True 

1083 break 

1084 

1085 case RelationalConsistency.SetNull: 

1086 logging.info( 

1087 f"{name}: " 

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

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

1090 ) 

1091 value.clear() 

1092 

1093 case _: 

1094 logging.info( 

1095 f"{name}: " 

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

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

1098 ) 

1099 

1100 continue 

1101 

1102 # Reset the dbEntity for a clean rewrite 

1103 value["dest"].dbEntity = None 

1104 

1105 # Copy over the refKey values using expanded bone names (_ref_keys), 

1106 # not raw refKeys patterns. refKeys may contain fnmatch wildcards 

1107 # (e.g. "delivery_time_*" → "delivery_time_min", "delivery_time_max", 

1108 # "delivery_time_range"). Iterating raw patterns would attempt 

1109 # target_skel["delivery_time_*"] which doesn't exist → copies None. 

1110 for key in self._ref_keys: 

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

1112 # logging.debug(f"Refreshed {key=} to {value["dest"][key]!r} ({str(value["dest"][key])!r})") 

1113 

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

1115 """ 

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

1117 

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

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

1120 

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

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

1123 

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

1125 """ 

1126 result = set() 

1127 

1128 def get_values(skel_, values_cache): 

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

1130 if not bone.searchable: 

1131 continue 

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

1133 result.add(tag) 

1134 

1135 ref_skel_cache, using_skel_cache = self._getSkels() 

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

1137 if not value: 

1138 continue 

1139 if value["dest"]: 

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

1141 if value["rel"]: 

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

1143 

1144 return result 

1145 

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

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

1148 return rel_skel[0] 

1149 return None 

1150 

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

1152 """ 

1153 Resolves a list of keys into reference skeletons valid for this bone. 

1154 

1155 Each key is loaded from the datastore and unserialized into a reference skeleton. 

1156 Resolution is all-or-nothing: if any requested key cannot be resolved, an empty 

1157 list is returned. 

1158 

1159 :param key_rel_list: List of ``(key, rel)`` tuples, where ``rel`` is a RelSkel dict or None. 

1160 

1161 :return: A list of dicts, each with the reference skeleton under ``dest`` and the 

1162 optional relation data under ``rel``. Empty if not all keys resolved. 

1163 """ 

1164 

1165 keys = [db.key_helper(value[0], self.kind, adjust_kind=True) for value in key_rel_list] 

1166 db_objs = {db_obj.key: db_obj for db_obj in db.get(keys)} 

1167 if any(key not in db_objs for key in keys): 

1168 return [] # return empty data when not all data is found 

1169 

1170 res_rel_skels = [] 

1171 

1172 for key, (_, rel) in zip(keys, key_rel_list): 

1173 dest_skel = self._refSkelCache() 

1174 dest_skel.unserialize(db_objs[key]) 

1175 for bone_name in dest_skel: 

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

1177 _ = dest_skel[bone_name] 

1178 dest_skel.dbEntity = None 

1179 res_rel_skels.append( 

1180 { 

1181 "dest": dest_skel, 

1182 "rel": rel or None 

1183 } 

1184 ) 

1185 

1186 return res_rel_skels 

1187 

1188 def setBoneValue( 

1189 self, 

1190 skel: "SkeletonInstance", 

1191 boneName: str, 

1192 value: t.Any, 

1193 append: bool, 

1194 language: None | str = None 

1195 ) -> bool: 

1196 """ 

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

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

1199 

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

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

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

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

1204 Only supported on bones with multiple=True. 

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

1206 supports languages. 

1207 

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

1209 """ 

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

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

1212 

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

1214 """ 

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

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

1217 Furthermore, the second field must be a skeletonInstanceClassRef. 

1218 """ 

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

1220 and isinstance(in_value[0], db.KeyType) 

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

1222 

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

1224 if not isinstance(value, db.KeyType): 

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

1226 parsed_value = (value, None) 

1227 elif not self.multiple and self.using: 

1228 if not tuple_check(value): 

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

1230 parsed_value = value 

1231 elif self.multiple and not self.using: 

1232 if ( 

1233 not isinstance(value, db.KeyType) 

1234 and not (isinstance(value, list)) 

1235 and all(isinstance(val, db.KeyType) for val in value) 

1236 ): 

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

1238 if isinstance(value, list): 

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

1240 else: 

1241 parsed_value = [(value, None)] 

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

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

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

1245 if isinstance(value, list): 

1246 parsed_value = value 

1247 else: 

1248 parsed_value = [value] 

1249 

1250 if boneName not in skel: 

1251 skel[boneName] = {} 

1252 if language: 

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

1254 

1255 if self.multiple: 

1256 rel_list = self.relskels_from_keys(parsed_value) 

1257 if append: 

1258 if language: 

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

1260 else: 

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

1262 skel[boneName] = [] 

1263 skel[boneName].extend(rel_list) 

1264 else: 

1265 if language: 

1266 skel[boneName][language] = rel_list 

1267 else: 

1268 skel[boneName] = rel_list 

1269 else: 

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

1271 return False 

1272 if language: 

1273 skel[boneName][language] = rel 

1274 else: 

1275 skel[boneName] = rel 

1276 return True 

1277 

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

1279 """ 

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

1281 

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

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

1284 

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

1286 :rtype: Set[str] 

1287 """ 

1288 result = set() 

1289 

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

1291 if not value: 

1292 continue 

1293 

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

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

1296 

1297 if value["rel"]: 

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

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

1300 

1301 return result 

1302 

1303 def getUniquePropertyIndexValues(self, skel: "SkeletonInstance", name: str) -> list[str]: 

1304 """ 

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

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

1307 

1308 :param skel: The skeleton instance. 

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

1310 

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

1312 :rtype: List[str] 

1313 """ 

1314 values = [] 

1315 

1316 for _, _, v in self.iter_bone_value(skel, name): 

1317 if not v: 

1318 continue 

1319 

1320 if self.using and (rel_skel := v.get("rel")): 

1321 values.append(json.dumps( 

1322 {"key": str(v["dest"]["key"]), "rel": rel_skel.dump()}, 

1323 sort_keys=True, default=str, 

1324 )) 

1325 else: 

1326 values.append(v["dest"]["key"]) 

1327 

1328 return self._hashValueForUniquePropertyIndex(values) if values else [] 

1329 

1330 def structure(self) -> dict: 

1331 return super().structure() | { 

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

1333 "module": self.module, 

1334 "format": self.format, 

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

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

1337 } 

1338 

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

1340 if value and isinstance(value, dict): # can be an empty dict due RelationalConsistency.SetNull 

1341 return { 

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

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

1344 }