Coverage for python/felis/check.py: 25%

94 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-14 01:56 -0800

1# This file is part of felis. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ["CheckingVisitor", "FelisValidator"] 

25 

26import logging 

27from collections.abc import Iterable, Mapping, MutableSet 

28from typing import Any 

29 

30from .types import FelisType 

31from .visitor import Visitor 

32 

33_Mapping = Mapping[str, Any] 

34 

35logger = logging.getLogger("felis") 

36 

37 

38class FelisValidator: 

39 """Class defining methods for validating individual objects in a felis 

40 structure. 

41 

42 The class implements all reasonable consistency checks for types of 

43 objects (mappings) that can appear in the Felis structure. It also 

44 verifies that object ID (``@id`` field) is unique, hence all check methods 

45 can only be called once for a given object. 

46 """ 

47 

48 def __init__(self) -> None: 

49 self._ids: MutableSet[str] = set() 

50 

51 def check_schema(self, schema_obj: _Mapping) -> None: 

52 """Validate contents of Felis schema object. 

53 

54 Parameters 

55 ---------- 

56 schema_obj : `Mapping` [ `str`, `Any` ] 

57 Felis object (mapping) representing a schema. 

58 

59 Raises 

60 ------ 

61 ValueError 

62 Raised if validation fails. 

63 """ 

64 _id = self._assert_id(schema_obj) 

65 self._check_visited(_id) 

66 

67 def check_table(self, table_obj: _Mapping, schema_obj: _Mapping) -> None: 

68 """Validate contents of Felis table object. 

69 

70 Parameters 

71 ---------- 

72 table_obj : `Mapping` [ `str`, `Any` ] 

73 Felis object (mapping) representing a table. 

74 schema_obj : `Mapping` [ `str`, `Any` ] 

75 Felis object (mapping) representing parent schema. 

76 

77 Raises 

78 ------ 

79 ValueError 

80 Raised if validation fails. 

81 """ 

82 _id = self._assert_id(table_obj) 

83 self._assert_name(table_obj) 

84 self._check_visited(_id) 

85 

86 def check_column(self, column_obj: _Mapping, table_obj: _Mapping) -> None: 

87 """Validate contents of Felis column object. 

88 

89 Parameters 

90 ---------- 

91 column_obj : `Mapping` [ `str`, `Any` ] 

92 Felis object (mapping) representing a column. 

93 table_obj : `Mapping` [ `str`, `Any` ] 

94 Felis object (mapping) representing parent table. 

95 

96 Raises 

97 ------ 

98 ValueError 

99 Raised if validation fails. 

100 """ 

101 _id = self._assert_id(column_obj) 

102 self._assert_name(column_obj) 

103 datatype_name = self._assert_datatype(column_obj) 

104 length = column_obj.get("length") 

105 felis_type = FelisType.felis_type(datatype_name) 

106 if not length and (felis_type.is_sized or felis_type.is_timestamp): 

107 # This is not a warning, because it's usually fine 

108 logger.info(f"No length defined for {_id} for type {datatype_name}") 

109 self._check_visited(_id) 

110 

111 def check_primary_key(self, primary_key_obj: str | Iterable[str], table: _Mapping) -> None: 

112 """Validate contents of Felis primary key object. 

113 

114 Parameters 

115 ---------- 

116 primary_key_obj : `str` or `Mapping` [ `str`, `Any` ] 

117 Felis object (mapping) representing a primary key. 

118 table_obj : `Mapping` [ `str`, `Any` ] 

119 Felis object (mapping) representing parent table. 

120 

121 Raises 

122 ------ 

123 ValueError 

124 Raised if validation fails. 

125 """ 

126 pass 

127 

128 def check_constraint(self, constraint_obj: _Mapping, table_obj: _Mapping) -> None: 

129 """Validate contents of Felis constraint object. 

130 

131 Parameters 

132 ---------- 

133 constraint_obj : `Mapping` [ `str`, `Any` ] 

134 Felis object (mapping) representing a constraint. 

135 table_obj : `Mapping` [ `str`, `Any` ] 

136 Felis object (mapping) representing parent table. 

137 

138 Raises 

139 ------ 

140 ValueError 

141 Raised if validation fails. 

142 """ 

143 _id = self._assert_id(constraint_obj) 

144 constraint_type = constraint_obj.get("@type") 

145 if not constraint_type: 

146 raise ValueError(f"Constraint has no @type: {_id}") 

147 if constraint_type not in ["ForeignKey", "Check", "Unique"]: 

148 raise ValueError(f"Not a valid constraint type: {constraint_type}") 

149 self._check_visited(_id) 

150 

151 def check_index(self, index_obj: _Mapping, table_obj: _Mapping) -> None: 

152 """Validate contents of Felis constraint object. 

153 

154 Parameters 

155 ---------- 

156 index_obj : `Mapping` [ `str`, `Any` ] 

157 Felis object (mapping) representing an index. 

158 table_obj : `Mapping` [ `str`, `Any` ] 

159 Felis object (mapping) representing parent table. 

160 

161 Raises 

162 ------ 

163 ValueError 

164 Raised if validation fails. 

165 """ 

166 _id = self._assert_id(index_obj) 

167 self._assert_name(index_obj) 

168 if "columns" in index_obj and "expressions" in index_obj: 

169 raise ValueError(f"Defining columns and expressions is not valid for index {_id}") 

170 self._check_visited(_id) 

171 

172 def _assert_id(self, obj: _Mapping) -> str: 

173 """Verify that an object has a non-empty ``@id`` field. 

174 

175 Parameters 

176 ---------- 

177 obj : `Mapping` [ `str`, `Any` ] 

178 Felis object. 

179 

180 Raises 

181 ------ 

182 ValueError 

183 Raised if ``@id`` field is missing or empty. 

184 

185 Returns 

186 ------- 

187 id : `str` 

188 The value of ``@id`` field. 

189 """ 

190 _id: str = obj.get("@id", "") 

191 if not _id: 

192 name = obj.get("name", "") 

193 maybe_string = f"(check object with name: {name})" if name else "" 

194 raise ValueError(f"No @id defined for object {maybe_string}") 

195 return _id 

196 

197 def _assert_name(self, obj: _Mapping) -> None: 

198 """Verify that an object has a ``name`` field. 

199 

200 Parameters 

201 ---------- 

202 obj : `Mapping` [ `str`, `Any` ] 

203 Felis object. 

204 

205 Raises 

206 ------ 

207 ValueError 

208 Raised if ``name`` field is missing. 

209 """ 

210 if "name" not in obj: 

211 _id = obj.get("@id") 

212 raise ValueError(f"No name for table object {_id}") 

213 

214 def _assert_datatype(self, obj: _Mapping) -> str: 

215 """Verify that an object has a valid ``datatype`` field. 

216 

217 Parameters 

218 ---------- 

219 obj : `Mapping` [ `str`, `Any` ] 

220 Felis object. 

221 

222 Raises 

223 ------ 

224 ValueError 

225 Raised if ``datatype`` field is missing or invalid. 

226 

227 Returns 

228 ------- 

229 datatype : `str` 

230 The value of ``datatype`` field. 

231 """ 

232 datatype_name: str = obj.get("datatype", "") 

233 _id = obj["@id"] 

234 if not datatype_name: 

235 raise ValueError(f"No datatype defined for id {_id}") 

236 try: 

237 FelisType.felis_type(datatype_name) 

238 except TypeError: 

239 raise ValueError(f"Incorrect Type Name for id {_id}: {datatype_name}") from None 

240 return datatype_name 

241 

242 def _check_visited(self, _id: str) -> None: 

243 """Check that given ID has not been visited, generates a warning 

244 otherwise. 

245 

246 Parameters 

247 _id : `str` 

248 Felis object ID. 

249 """ 

250 if _id in self._ids: 

251 logger.warning(f"Duplication of @id {_id}") 

252 self._ids.add(_id) 

253 

254 

255class CheckingVisitor(Visitor[None, None, None, None, None, None]): 

256 """Visitor implementation which validates felis structures and raises 

257 exceptions for errors. 

258 """ 

259 

260 def __init__(self) -> None: 

261 super().__init__() 

262 self.checker = FelisValidator() 

263 

264 def visit_schema(self, schema_obj: _Mapping) -> None: 

265 # Docstring is inherited. 

266 self.checker.check_schema(schema_obj) 

267 for table_obj in schema_obj["tables"]: 

268 self.visit_table(table_obj, schema_obj) 

269 

270 def visit_table(self, table_obj: _Mapping, schema_obj: _Mapping) -> None: 

271 # Docstring is inherited. 

272 self.checker.check_table(table_obj, schema_obj) 

273 for column_obj in table_obj["columns"]: 

274 self.visit_column(column_obj, table_obj) 

275 self.visit_primary_key(table_obj.get("primaryKey", []), table_obj) 

276 for constraint_obj in table_obj.get("constraints", []): 

277 self.visit_constraint(constraint_obj, table_obj) 

278 for index_obj in table_obj.get("indexes", []): 

279 self.visit_index(index_obj, table_obj) 

280 

281 def visit_column(self, column_obj: _Mapping, table_obj: _Mapping) -> None: 

282 # Docstring is inherited. 

283 self.checker.check_column(column_obj, table_obj) 

284 

285 def visit_primary_key(self, primary_key_obj: str | Iterable[str], table_obj: _Mapping) -> None: 

286 # Docstring is inherited. 

287 self.checker.check_primary_key(primary_key_obj, table_obj) 

288 

289 def visit_constraint(self, constraint_obj: _Mapping, table_obj: _Mapping) -> None: 

290 # Docstring is inherited. 

291 self.checker.check_constraint(constraint_obj, table_obj) 

292 

293 def visit_index(self, index_obj: _Mapping, table_obj: _Mapping) -> None: 

294 # Docstring is inherited. 

295 self.checker.check_index(index_obj, table_obj)