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

170 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-02 02:10 -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 "SchemaVersion", 

32 "SimpleVisitor", 

33 "Table", 

34 "UniqueConstraint", 

35] 

36 

37import dataclasses 

38import logging 

39from collections.abc import Iterable, Mapping, MutableMapping 

40from typing import Any, cast 

41 

42from .check import FelisValidator 

43from .types import FelisType 

44from .visitor import Visitor 

45 

46_Mapping = Mapping[str, Any] 

47 

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

49 

50 

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

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

53 keys = set(keys) 

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

55 

56 

57def _make_iterable(obj: str | Iterable[str]) -> Iterable[str]: 

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

59 if isinstance(obj, str): 

60 yield obj 

61 else: 

62 yield from obj 

63 

64 

65@dataclasses.dataclass 

66class Column: 

67 """Column representation in schema.""" 

68 

69 name: str 

70 """Column name.""" 

71 

72 id: str 

73 """Felis ID for this column.""" 

74 

75 datatype: type[FelisType] 

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

77 

78 length: int | None = None 

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

80 

81 nullable: bool = True 

82 """True for nullable columns.""" 

83 

84 value: Any = None 

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

86 

87 autoincrement: bool | None = None 

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

89 

90 description: str | None = None 

91 """Column description.""" 

92 

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

94 """Additional annotations for this column.""" 

95 

96 table: Table | None = None 

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

98 

99 

100@dataclasses.dataclass 

101class Index: 

102 """Index representation.""" 

103 

104 name: str 

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

106 

107 id: str 

108 """Felis ID for this index.""" 

109 

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

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

112 must be non-empty. 

113 """ 

114 

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

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

117 must be non-empty. 

118 """ 

119 

120 description: str | None = None 

121 """Index description.""" 

122 

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

124 """Additional annotations for this index.""" 

125 

126 

127@dataclasses.dataclass 

128class Constraint: 

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

130 instances of one of the subclasses. 

131 """ 

132 

133 name: str | None 

134 """Constraint name.""" 

135 

136 id: str 

137 """Felis ID for this constraint.""" 

138 

139 deferrable: bool = False 

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

141 

142 initially: str | None = None 

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

144 

145 description: str | None = None 

146 """Constraint description.""" 

147 

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

149 """Additional annotations for this constraint.""" 

150 

151 

152@dataclasses.dataclass 

153class UniqueConstraint(Constraint): 

154 """Description of unique constraint.""" 

155 

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

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

158 as the constraint itself. 

159 """ 

160 

161 

162@dataclasses.dataclass 

163class ForeignKeyConstraint(Constraint): 

164 """Description of foreign key constraint.""" 

165 

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

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

168 as the constraint itself. 

169 """ 

170 

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

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

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

174 which is different from the table of this constraint. 

175 """ 

176 

177 

178@dataclasses.dataclass 

179class CheckConstraint(Constraint): 

180 """Description of check constraint.""" 

181 

182 expression: str = "" 

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

184 

185 

186@dataclasses.dataclass 

187class Table: 

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

189 

190 name: str 

191 """Table name.""" 

192 

193 id: str 

194 """Felis ID for this table.""" 

195 

196 columns: list[Column] 

197 """List of Column instances.""" 

198 

199 primary_key: list[Column] 

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

201 

202 constraints: list[Constraint] 

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

204 

205 indexes: list[Index] 

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

207 

208 description: str | None = None 

209 """Table description.""" 

210 

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

212 """Additional annotations for this table.""" 

213 

214 

215@dataclasses.dataclass 

216class SchemaVersion: 

217 """Schema versioning description.""" 

218 

219 current: str 

220 """Current schema version defined by the document.""" 

221 

222 compatible: list[str] | None = None 

223 """Optional list of versions which are compatible with current version.""" 

224 

225 read_compatible: list[str] | None = None 

226 """Optional list of versions with which current version is read-compatible. 

227 """ 

228 

229 

230@dataclasses.dataclass 

231class Schema: 

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

233 

234 name: str 

235 """Schema name.""" 

236 

237 id: str 

238 """Felis ID for this schema.""" 

239 

240 tables: list[Table] 

241 """Collection of table definitions.""" 

242 

243 version: SchemaVersion | None = None 

244 """Schema version description.""" 

245 

246 description: str | None = None 

247 """Schema description.""" 

248 

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

250 """Additional annotations for this table.""" 

251 

252 

253class SimpleVisitor(Visitor[Schema, Table, Column, list[Column], Constraint, Index, SchemaVersion]): 

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

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

256 this module. 

257 

258 Notes 

259 ----- 

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

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

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

263 """ 

264 

265 def __init__(self) -> None: 

266 self.checker = FelisValidator() 

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

268 

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

270 # Docstring is inherited. 

271 self.checker.check_schema(schema_obj) 

272 

273 version_obj = schema_obj.get("version") 

274 

275 schema = Schema( 

276 name=schema_obj["name"], 

277 id=schema_obj["@id"], 

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

279 version=self.visit_schema_version(version_obj, schema_obj) if version_obj is not None else None, 

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

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

282 ) 

283 return schema 

284 

285 def visit_schema_version( 

286 self, version_obj: str | Mapping[str, Any], schema_obj: Mapping[str, Any] 

287 ) -> SchemaVersion: 

288 # Docstring is inherited. 

289 self.checker.check_schema_version(version_obj, schema_obj) 

290 

291 if isinstance(version_obj, str): 

292 return SchemaVersion(current=version_obj) 

293 else: 

294 return SchemaVersion( 

295 current=cast(str, version_obj["current"]), 

296 compatible=version_obj.get("compatible"), 

297 read_compatible=version_obj.get("read_compatible"), 

298 ) 

299 

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

301 # Docstring is inherited. 

302 self.checker.check_table(table_obj, schema_obj) 

303 

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

305 table = Table( 

306 name=table_obj["name"], 

307 id=table_obj["@id"], 

308 columns=columns, 

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

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

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

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

313 annotations=_strip_keys( 

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

315 ), 

316 ) 

317 for column in columns: 

318 column.table = table 

319 return table 

320 

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

322 # Docstring is inherited. 

323 self.checker.check_column(column_obj, table_obj) 

324 

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

326 

327 column = Column( 

328 name=column_obj["name"], 

329 id=column_obj["@id"], 

330 datatype=datatype, 

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

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

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

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

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

336 annotations=_strip_keys( 

337 column_obj, 

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

339 ), 

340 ) 

341 if column.id in self.column_ids: 

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

343 self.column_ids[column.id] = column 

344 return column 

345 

346 def visit_primary_key(self, primary_key_obj: str | Iterable[str], table_obj: _Mapping) -> list[Column]: 

347 # Docstring is inherited. 

348 self.checker.check_primary_key(primary_key_obj, table_obj) 

349 if primary_key_obj: 

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

351 return columns 

352 return [] 

353 

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

355 # Docstring is inherited. 

356 self.checker.check_constraint(constraint_obj, table_obj) 

357 

358 constraint_type = constraint_obj["@type"] 

359 if constraint_type == "Unique": 

360 return UniqueConstraint( 

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

362 id=constraint_obj["@id"], 

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

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

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

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

367 annotations=_strip_keys( 

368 constraint_obj, 

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

370 ), 

371 ) 

372 elif constraint_type == "ForeignKey": 

373 return ForeignKeyConstraint( 

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

375 id=constraint_obj["@id"], 

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

377 referenced_columns=[ 

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

379 ], 

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

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

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

383 annotations=_strip_keys( 

384 constraint_obj, 

385 [ 

386 "name", 

387 "@id", 

388 "@type", 

389 "columns", 

390 "deferrable", 

391 "initially", 

392 "referencedColumns", 

393 "description", 

394 ], 

395 ), 

396 ) 

397 elif constraint_type == "Check": 

398 return CheckConstraint( 

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

400 id=constraint_obj["@id"], 

401 expression=constraint_obj["expression"], 

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

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

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

405 annotations=_strip_keys( 

406 constraint_obj, 

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

408 ), 

409 ) 

410 else: 

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

412 

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

414 # Docstring is inherited. 

415 self.checker.check_index(index_obj, table_obj) 

416 

417 return Index( 

418 name=index_obj["name"], 

419 id=index_obj["@id"], 

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

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

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

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

424 )