Coverage for python/felis/simple.py: 66%
154 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-14 01:56 -0800
« 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/>.
22from __future__ import annotations
24__all__ = [
25 "CheckConstraint",
26 "Column",
27 "Constraint",
28 "ForeignKeyConstraint",
29 "Index",
30 "Schema",
31 "SimpleVisitor",
32 "Table",
33 "UniqueConstraint",
34]
36import dataclasses
37import logging
38from collections.abc import Iterable, Mapping, MutableMapping
39from typing import Any, List, Optional, Type, Union
41from .check import FelisValidator
42from .types import FelisType
43from .visitor import Visitor
45_Mapping = Mapping[str, Any]
47logger = logging.getLogger("felis.generic")
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}
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
64@dataclasses.dataclass
65class Column:
66 """Column representation in schema."""
68 name: str
69 """Column name."""
71 id: str
72 """Felis ID for this column."""
74 datatype: Type[FelisType]
75 """Column type, one of the types/classes defined in `types`."""
77 length: Optional[int] = None
78 """Optional length for string/binary columns"""
80 nullable: bool = True
81 """True for nullable columns."""
83 value: Any = None
84 """Default value for column, can be `None`."""
86 autoincrement: Optional[bool] = None
87 """Unspecified value results in `None`."""
89 description: Optional[str] = None
90 """Column description."""
92 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
93 """Additional annotations for this column."""
95 table: Optional[Table] = None
96 """Table which defines this column, usually not `None`."""
99@dataclasses.dataclass
100class Index:
101 """Index representation."""
103 name: str
104 """index name, can be empty."""
106 id: str
107 """Felis ID for this index."""
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 """
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 """
119 description: Optional[str] = None
120 """Index description."""
122 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
123 """Additional annotations for this index."""
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 """
132 name: Optional[str]
133 """Constraint name."""
135 id: str
136 """Felis ID for this constraint."""
138 deferrable: bool = False
139 """If `True` then this constraint will be declared as deferrable."""
141 initially: Optional[str] = None
142 """Value for ``INITIALLY`` clause, only used of ``deferrable`` is True."""
144 description: Optional[str] = None
145 """Constraint description."""
147 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
148 """Additional annotations for this constraint."""
151@dataclasses.dataclass
152class UniqueConstraint(Constraint):
153 """Description of unique constraint."""
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 """
161@dataclasses.dataclass
162class ForeignKeyConstraint(Constraint):
163 """Description of foreign key constraint."""
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 """
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 """
177@dataclasses.dataclass
178class CheckConstraint(Constraint):
179 """Description of check constraint."""
181 expression: str = ""
182 """Expression on one or more columns on the table, must be non-empty."""
185@dataclasses.dataclass
186class Table:
187 """Description of a single table schema."""
189 name: str
190 """Table name."""
192 id: str
193 """Felis ID for this table."""
195 columns: List[Column]
196 """List of Column instances."""
198 primary_key: List[Column]
199 """List of Column that constitute a primary key, may be empty."""
201 constraints: List[Constraint]
202 """List of Constraint instances, can be empty."""
204 indexes: List[Index]
205 """List of Index instances, can be empty."""
207 description: Optional[str] = None
208 """Table description."""
210 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
211 """Additional annotations for this table."""
214@dataclasses.dataclass
215class Schema:
216 """Complete schema description, collection of tables."""
218 name: str
219 """Schema name."""
221 id: str
222 """Felis ID for this schema."""
224 tables: List[Table]
225 """Collection of table definitions."""
227 description: Optional[str] = None
228 """Schema description."""
230 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
231 """Additional annotations for this table."""
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.
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 """
246 def __init__(self) -> None:
247 self.checker = FelisValidator()
248 self.column_ids: MutableMapping[str, Column] = {}
250 def visit_schema(self, schema_obj: _Mapping) -> Schema:
251 # Docstring is inherited.
252 self.checker.check_schema(schema_obj)
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
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)
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
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)
288 datatype = FelisType.felis_type(column_obj["datatype"])
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
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 []
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)
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}")
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)
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 )