Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/record.py: 18%

115 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1import json 

2import logging 

3import typing as t 

4 

5from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity 

6from viur.core import db, utils, tasks, i18n 

7 

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

9 from ..skeleton import SkeletonInstance 

10 

11 

12class RecordBone(BaseBone): 

13 """ 

14 The RecordBone class is a specialized bone type used to store structured data. It inherits from 

15 the BaseBone class. The RecordBone class is designed to store complex data structures, such as 

16 nested dictionaries or objects, by using a related skeleton class (the using parameter) to manage 

17 the internal structure of the data. 

18 

19 :param format: Optional string parameter to specify the format of the record bone. 

20 :param indexed: Optional boolean parameter to indicate if the record bone is indexed. 

21 Defaults to False. 

22 :param using: A class that inherits from 'viur.core.skeleton.RelSkel' to be used with the 

23 RecordBone. 

24 :param kwargs: Additional keyword arguments to be passed to the BaseBone constructor. 

25 """ 

26 type = "record" 

27 

28 def __init__( 

29 self, 

30 *, 

31 format: str = None, 

32 indexed: bool = False, 

33 using: 'viur.core.skeleton.RelSkel' = None, 

34 **kwargs 

35 ): 

36 from viur.core.skeleton.relskel import RelSkel 

37 if not issubclass(using, RelSkel): 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true

38 raise ValueError("RecordBone requires for valid using-parameter (subclass of viur.core.skeleton.RelSkel)") 

39 

40 super().__init__(indexed=indexed, **kwargs) 

41 self.using = using 

42 self.format = format 

43 if not format or indexed: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true

44 raise NotImplementedError("A RecordBone must not be indexed and must have a format set") 

45 

46 def singleValueUnserialize(self, val): 

47 """ 

48 Unserializes a single value, creating an instance of the 'using' class and unserializing 

49 the value into it. 

50 

51 :param val: The value to unserialize. 

52 :return: An instance of the 'using' class with the unserialized data. 

53 :raises AssertionError: If the unserialized value is not a dictionary. 

54 """ 

55 if isinstance(val, str): 

56 try: 

57 value = json.loads(val) 

58 except ValueError: 

59 value = None 

60 else: 

61 value = val 

62 

63 if not value: 

64 return None 

65 

66 if isinstance(value, list) and value: 

67 value = value[0] 

68 

69 assert isinstance(value, dict), f"Read {value=} ({type(value)})" 

70 

71 usingSkel = self.using() 

72 usingSkel.unserialize(value) 

73 return usingSkel 

74 

75 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool): 

76 """ 

77 Serializes a single value by calling the serialize method of the 'using' skeleton instance. 

78 

79 :param value: The value to be serialized, which should be an instance of the 'using' skeleton. 

80 :param skel: The parent skeleton instance. 

81 :param name: The name of the bone. 

82 :param parentIndexed: A boolean indicating if the parent bone is indexed. 

83 :return: The serialized value. 

84 """ 

85 if not value: 

86 return None 

87 

88 return value.serialize(parentIndexed=False) 

89 

90 def _get_single_destinct_hash(self, value): 

91 return tuple(bone._get_destinct_hash(value[name]) for name, bone in self.using.__boneMap__.items()) 

92 

93 def parseSubfieldsFromClient(self) -> bool: 

94 """ 

95 Determines if the current request should attempt to parse subfields received from the client. 

96 This should only be set to True if a list of dictionaries is expected to be transmitted. 

97 """ 

98 return True 

99 

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

101 usingSkel = self.using() 

102 

103 if not usingSkel.fromClient(value): 

104 usingSkel.errors.append( 

105 ReadFromClientError( 

106 ReadFromClientErrorSeverity.Invalid, 

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

108 ) 

109 ) 

110 

111 return usingSkel, usingSkel.errors 

112 

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

114 super().postSavedHandler(skel, boneName, key) 

115 

116 drop_relations_higher = {} 

117 

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

119 if idx > 99: 

120 logging.warning("postSavedHandler entry limit maximum reached") 

121 drop_relations_higher.clear() 

122 break 

123 

124 for sub_bone_name, bone in value.items(): 

125 path = ".".join(name for name in (boneName, lang, f"{idx:02}", sub_bone_name) if name) 

126 if utils.string.is_prefix(bone.type, "relational"): 

127 drop_relations_higher[sub_bone_name] = path 

128 

129 bone.postSavedHandler(value, path, key) 

130 

131 if drop_relations_higher: 

132 for viur_src_property in drop_relations_higher.values(): 

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

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

135 .filter("src.__key__ =", key) \ 

136 .filter("viur_src_property >", viur_src_property) 

137 

138 logging.debug(f"Delete viur-relations with {query=}") 

139 tasks.DeleteEntitiesIter.startIterOnQuery(query) 

140 

141 def postDeletedHandler(self, skel, boneName, key) -> None: 

142 super().postDeletedHandler(skel, boneName, key) 

143 

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

145 for sub_bone_name, bone in value.items(): 

146 path = ".".join(name for name in (boneName, lang, f"{idx:02}", sub_bone_name) if name) 

147 bone.postDeletedHandler(value, path, key) 

148 

149 def getSearchTags(self, skel: 'viur.core.skeleton.SkeletonInstance', name: str) -> set[str]: 

150 """ 

151 Collects search tags from the 'using' skeleton instance for the given bone. 

152 

153 :param skel: The parent skeleton instance. 

154 :param name: The name of the bone. 

155 :return: A set of search tags generated from the 'using' skeleton instance. 

156 """ 

157 result = set() 

158 

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

160 if value is None: 

161 continue 

162 

163 for key, bone in value.items(): 

164 if not bone.searchable: 

165 continue 

166 

167 for tag in bone.getSearchTags(value, key): 

168 result.add(tag) 

169 

170 return result 

171 

172 def getSearchDocumentFields(self, valuesCache, name, prefix=""): 

173 """ 

174 Generates a list of search document fields for the given values cache, name, and optional prefix. 

175 

176 :param dict valuesCache: A dictionary containing the cached values. 

177 :param str name: The name of the bone to process. 

178 :param str prefix: An optional prefix to use for the search document fields, defaults to an empty string. 

179 :return: A list of search document fields. 

180 :rtype: list 

181 """ 

182 

183 def getValues(res, skel, valuesCache, searchPrefix): 

184 for key, bone in skel.items(): 

185 if bone.searchable: 

186 res.extend(bone.getSearchDocumentFields(valuesCache, key, prefix=searchPrefix)) 

187 

188 value = valuesCache.get(name) 

189 res = [] 

190 

191 if not value: 

192 return res 

193 uskel = self.using() 

194 for idx, val in enumerate(value): 

195 getValues(res, uskel, val, f"{prefix}{name}_{idx}") 

196 

197 return res 

198 

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

200 """ 

201 Retrieves a set of referenced blobs for the given skeleton instance and name. 

202 

203 :param skel: The skeleton instance to process. 

204 :param name: The name of the bone to process. 

205 :return: A set of referenced blobs. 

206 """ 

207 result = set() 

208 

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

210 if value is None: 

211 continue 

212 

213 for key, bone in value.items(): 

214 result |= bone.getReferencedBlobs(value, key) 

215 

216 return result 

217 

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

219 """ 

220 This method is intentionally not implemented as it's not possible to determine how to derive 

221 a key from the related skeleton being used (i.e., which fields to include and how). 

222 

223 """ 

224 raise NotImplementedError() 

225 

226 def structure(self) -> dict: 

227 return super().structure() | { 

228 "format": self.format, 

229 "using": self.using().structure(), 

230 } 

231 

232 def _atomic_dump(self, value: "SkeletonInstance") -> dict | None: 

233 if value is not None: 

234 return value.dump() 

235 

236 def refresh(self, skel, bone_name): 

237 for _, _, using_skel in self.iter_bone_value(skel, bone_name): 

238 for key, bone in using_skel.items(): 

239 bone.refresh(using_skel, key) 

240 

241 # When the value (acting as a skel) is marked for deletion, clear it. 

242 if using_skel._cascade_deletion is True: 

243 # Unset the Entity, so the skeleton becomes a False truthyness. 

244 using_skel.setEntity(db.Entity())