Coverage for python/felis/simple.py: 66%

154 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-15 02:20 -0700

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__ = [ 

25 "CheckConstraint", 

26 "Column", 

27 "Constraint", 

28 "ForeignKeyConstraint", 

29 "Index", 

30 "Schema", 

31 "SimpleVisitor", 

32 "Table", 

33 "UniqueConstraint", 

34] 

35 

36import dataclasses 

37import logging 

38from collections.abc import Iterable, Mapping, MutableMapping 

39from typing import Any, List, Optional, Type, Union 

40 

41from .check import FelisValidator 

42from .types import FelisType 

43from .visitor import Visitor 

44 

45_Mapping = Mapping[str, Any] 

46 

47logger = logging.getLogger("felis.generic") 

48 

49 

50def _strip_keys(map: _Mapping, keys: Iterable[str]) -> _Mapping: 

51 """Return a copy of a dictionary with some keys removed.""" 

52 keys = set(keys) 

53 return {key: value for key, value in map.items() if key not in keys} 

54 

55 

56def _make_iterable(obj: Union[str, Iterable[str]]) -> Iterable[str]: 

57 """Make an iterable out of string or list of strings.""" 

58 if isinstance(obj, str): 

59 yield obj 

60 else: 

61 yield from obj 

62 

63 

64@dataclasses.dataclass 

65class Column: 

66 """Column representation in schema.""" 

67 

68 name: str 

69 """Column name.""" 

70 

71 id: str 

72 """Felis ID for this column.""" 

73 

74 datatype: Type[FelisType] 

75 """Column type, one of the types/classes defined in `types`.""" 

76 

77 length: Optional[int] = None 

78 """Optional length for string/binary columns""" 

79 

80 nullable: bool = True 

81 """True for nullable columns.""" 

82 

83 value: Any = None 

84 """Default value for column, can be `None`.""" 

85 

86 autoincrement: Optional[bool] = None 

87 """Unspecified value results in `None`.""" 

88 

89 description: Optional[str] = None 

90 """Column description.""" 

91 

92 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict) 

93 """Additional annotations for this column.""" 

94 

95 table: Optional[Table] = None 

96 """Table which defines this column, usually not `None`.""" 

97 

98 

99@dataclasses.dataclass 

100class Index: 

101 """Index representation.""" 

102 

103 name: str 

104 """index name, can be empty.""" 

105 

106 id: str 

107 """Felis ID for this index.""" 

108 

109 columns: List[Column] = dataclasses.field(default_factory=list) 

110 """List of columns in index, one of the ``columns`` or ``expressions`` 

111 must be non-empty. 

112 """ 

113 

114 expressions: List[str] = dataclasses.field(default_factory=list) 

115 """List of expressions in index, one of the ``columns`` or ``expressions`` 

116 must be non-empty. 

117 """ 

118 

119 description: Optional[str] = None 

120 """Index description.""" 

121 

122 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict) 

123 """Additional annotations for this index.""" 

124 

125 

126@dataclasses.dataclass 

127class Constraint: 

128 """Constraint description, this is a base class, actual constraints will be 

129 instances of one of the subclasses. 

130 """ 

131 

132 name: Optional[str] 

133 """Constraint name.""" 

134 

135 id: str 

136 """Felis ID for this constraint.""" 

137 

138 deferrable: bool = False 

139 """If `True` then this constraint will be declared as deferrable.""" 

140 

141 initially: Optional[str] = None 

142 """Value for ``INITIALLY`` clause, only used of ``deferrable`` is True.""" 

143 

144 description: Optional[str] = None 

145 """Constraint description.""" 

146 

147 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict) 

148 """Additional annotations for this constraint.""" 

149 

150 

151@dataclasses.dataclass 

152class UniqueConstraint(Constraint): 

153 """Description of unique constraint.""" 

154 

155 columns: List[Column] = dataclasses.field(default_factory=list) 

156 """List of columns in this constraint, all columns belong to the same table 

157 as the constraint itself. 

158 """ 

159 

160 

161@dataclasses.dataclass 

162class ForeignKeyConstraint(Constraint): 

163 """Description of foreign key constraint.""" 

164 

165 columns: List[Column] = dataclasses.field(default_factory=list) 

166 """List of columns in this constraint, all columns belong to the same table 

167 as the constraint itself. 

168 """ 

169 

170 referenced_columns: List[Column] = dataclasses.field(default_factory=list) 

171 """List of referenced columns, the number of columns must be the same as in 

172 ``Constraint.columns`` list. All columns must belong to the same table, 

173 which is different from the table of this constraint. 

174 """ 

175 

176 

177@dataclasses.dataclass 

178class CheckConstraint(Constraint): 

179 """Description of check constraint.""" 

180 

181 expression: str = "" 

182 """Expression on one or more columns on the table, must be non-empty.""" 

183 

184 

185@dataclasses.dataclass 

186class Table: 

187 """Description of a single table schema.""" 

188 

189 name: str 

190 """Table name.""" 

191 

192 id: str 

193 """Felis ID for this table.""" 

194 

195 columns: List[Column] 

196 """List of Column instances.""" 

197 

198 primary_key: List[Column] 

199 """List of Column that constitute a primary key, may be empty.""" 

200 

201 constraints: List[Constraint] 

202 """List of Constraint instances, can be empty.""" 

203 

204 indexes: List[Index] 

205 """List of Index instances, can be empty.""" 

206 

207 description: Optional[str] = None 

208 """Table description.""" 

209 

210 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict) 

211 """Additional annotations for this table.""" 

212 

213 

214@dataclasses.dataclass 

215class Schema: 

216 """Complete schema description, collection of tables.""" 

217 

218 name: str 

219 """Schema name.""" 

220 

221 id: str 

222 """Felis ID for this schema.""" 

223 

224 tables: List[Table] 

225 """Collection of table definitions.""" 

226 

227 description: Optional[str] = None 

228 """Schema description.""" 

229 

230 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict) 

231 """Additional annotations for this table.""" 

232 

233 

234class SimpleVisitor(Visitor[Schema, Table, Column, List[Column], Constraint, Index]): 

235 """Visitor implementation class that produces a simple in-memory 

236 representation of Felis schema using classes `Schema`, `Table`, etc. from 

237 this module. 

238 

239 Notes 

240 ----- 

241 Implementation of this visitor class uses `FelisValidator` to validate the 

242 contents of the schema. All visit methods can raise the same exceptions as 

243 corresponding `FelisValidator` methods (usually `ValueError`). 

244 """ 

245 

246 def __init__(self) -> None: 

247 self.checker = FelisValidator() 

248 self.column_ids: MutableMapping[str, Column] = {} 

249 

250 def visit_schema(self, schema_obj: _Mapping) -> Schema: 

251 # Docstring is inherited. 

252 self.checker.check_schema(schema_obj) 

253 

254 schema = Schema( 

255 name=schema_obj["name"], 

256 id=schema_obj["@id"], 

257 tables=[self.visit_table(t, schema_obj) for t in schema_obj["tables"]], 

258 description=schema_obj.get("description"), 

259 annotations=_strip_keys(schema_obj, ["name", "@id", "tables", "description"]), 

260 ) 

261 return schema 

262 

263 def visit_table(self, table_obj: _Mapping, schema_obj: _Mapping) -> Table: 

264 # Docstring is inherited. 

265 self.checker.check_table(table_obj, schema_obj) 

266 

267 columns = [self.visit_column(c, table_obj) for c in table_obj["columns"]] 

268 table = Table( 

269 name=table_obj["name"], 

270 id=table_obj["@id"], 

271 columns=columns, 

272 primary_key=self.visit_primary_key(table_obj.get("primaryKey", []), table_obj), 

273 constraints=[self.visit_constraint(c, table_obj) for c in table_obj.get("constraints", [])], 

274 indexes=[self.visit_index(i, table_obj) for i in table_obj.get("indexes", [])], 

275 description=table_obj.get("description"), 

276 annotations=_strip_keys( 

277 table_obj, ["name", "@id", "columns", "primaryKey", "constraints", "indexes", "description"] 

278 ), 

279 ) 

280 for column in columns: 

281 column.table = table 

282 return table 

283 

284 def visit_column(self, column_obj: _Mapping, table_obj: _Mapping) -> Column: 

285 # Docstring is inherited. 

286 self.checker.check_column(column_obj, table_obj) 

287 

288 datatype = FelisType.felis_type(column_obj["datatype"]) 

289 

290 column = Column( 

291 name=column_obj["name"], 

292 id=column_obj["@id"], 

293 datatype=datatype, 

294 length=column_obj.get("length"), 

295 value=column_obj.get("value"), 

296 description=column_obj.get("description"), 

297 nullable=column_obj.get("nullable", True), 

298 autoincrement=column_obj.get("autoincrement"), 

299 annotations=_strip_keys( 

300 column_obj, 

301 ["name", "@id", "datatype", "length", "nullable", "value", "autoincrement", "description"], 

302 ), 

303 ) 

304 if column.id in self.column_ids: 

305 logger.warning(f"Duplication of @id {column.id}") 

306 self.column_ids[column.id] = column 

307 return column 

308 

309 def visit_primary_key( 

310 self, primary_key_obj: Union[str, Iterable[str]], table_obj: _Mapping 

311 ) -> List[Column]: 

312 # Docstring is inherited. 

313 self.checker.check_primary_key(primary_key_obj, table_obj) 

314 if primary_key_obj: 

315 columns = [self.column_ids[c_id] for c_id in _make_iterable(primary_key_obj)] 

316 return columns 

317 return [] 

318 

319 def visit_constraint(self, constraint_obj: _Mapping, table_obj: _Mapping) -> Constraint: 

320 # Docstring is inherited. 

321 self.checker.check_constraint(constraint_obj, table_obj) 

322 

323 constraint_type = constraint_obj["@type"] 

324 if constraint_type == "Unique": 

325 return UniqueConstraint( 

326 name=constraint_obj.get("name"), 

327 id=constraint_obj["@id"], 

328 columns=[self.column_ids[c_id] for c_id in _make_iterable(constraint_obj["columns"])], 

329 deferrable=constraint_obj.get("deferrable", False), 

330 initially=constraint_obj.get("initially"), 

331 description=constraint_obj.get("description"), 

332 annotations=_strip_keys( 

333 constraint_obj, 

334 ["name", "@type", "@id", "columns", "deferrable", "initially", "description"], 

335 ), 

336 ) 

337 elif constraint_type == "ForeignKey": 

338 return ForeignKeyConstraint( 

339 name=constraint_obj.get("name"), 

340 id=constraint_obj["@id"], 

341 columns=[self.column_ids[c_id] for c_id in _make_iterable(constraint_obj["columns"])], 

342 referenced_columns=[ 

343 self.column_ids[c_id] for c_id in _make_iterable(constraint_obj["referencedColumns"]) 

344 ], 

345 deferrable=constraint_obj.get("deferrable", False), 

346 initially=constraint_obj.get("initially"), 

347 description=constraint_obj.get("description"), 

348 annotations=_strip_keys( 

349 constraint_obj, 

350 [ 

351 "name", 

352 "@id", 

353 "@type", 

354 "columns", 

355 "deferrable", 

356 "initially", 

357 "referencedColumns", 

358 "description", 

359 ], 

360 ), 

361 ) 

362 elif constraint_type == "Check": 

363 return CheckConstraint( 

364 name=constraint_obj.get("name"), 

365 id=constraint_obj["@id"], 

366 expression=constraint_obj["expression"], 

367 deferrable=constraint_obj.get("deferrable", False), 

368 initially=constraint_obj.get("initially"), 

369 description=constraint_obj.get("description"), 

370 annotations=_strip_keys( 

371 constraint_obj, 

372 ["name", "@id", "@type", "expression", "deferrable", "initially", "description"], 

373 ), 

374 ) 

375 else: 

376 raise ValueError(f"Unexpected constrint type: {constraint_type}") 

377 

378 def visit_index(self, index_obj: _Mapping, table_obj: _Mapping) -> Index: 

379 # Docstring is inherited. 

380 self.checker.check_index(index_obj, table_obj) 

381 

382 return Index( 

383 name=index_obj["name"], 

384 id=index_obj["@id"], 

385 columns=[self.column_ids[c_id] for c_id in _make_iterable(index_obj.get("columns", []))], 

386 expressions=index_obj.get("expressions", []), 

387 description=index_obj.get("description"), 

388 annotations=_strip_keys(index_obj, ["name", "@id", "columns", "expressions", "description"]), 

389 )