Coverage for python/felis/simple.py: 65%
170 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 03:38 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 03:38 -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/>.
22from __future__ import annotations
24__all__ = [
25 "CheckConstraint",
26 "Column",
27 "Constraint",
28 "ForeignKeyConstraint",
29 "Index",
30 "Schema",
31 "SchemaVersion",
32 "SimpleVisitor",
33 "Table",
34 "UniqueConstraint",
35]
37import dataclasses
38import logging
39from collections.abc import Iterable, Mapping, MutableMapping
40from typing import Any, cast
42from .check import FelisValidator
43from .types import FelisType
44from .visitor import Visitor
46_Mapping = Mapping[str, Any]
48logger = logging.getLogger("felis.generic")
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}
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
65@dataclasses.dataclass
66class Column:
67 """Column representation in schema."""
69 name: str
70 """Column name."""
72 id: str
73 """Felis ID for this column."""
75 datatype: type[FelisType]
76 """Column type, one of the types/classes defined in `types`."""
78 length: int | None = None
79 """Optional length for string/binary columns"""
81 nullable: bool = True
82 """True for nullable columns."""
84 value: Any = None
85 """Default value for column, can be `None`."""
87 autoincrement: bool | None = None
88 """Unspecified value results in `None`."""
90 description: str | None = None
91 """Column description."""
93 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
94 """Additional annotations for this column."""
96 table: Table | None = None
97 """Table which defines this column, usually not `None`."""
100@dataclasses.dataclass
101class Index:
102 """Index representation."""
104 name: str
105 """index name, can be empty."""
107 id: str
108 """Felis ID for this index."""
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 """
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 """
120 description: str | None = None
121 """Index description."""
123 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
124 """Additional annotations for this index."""
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 """
133 name: str | None
134 """Constraint name."""
136 id: str
137 """Felis ID for this constraint."""
139 deferrable: bool = False
140 """If `True` then this constraint will be declared as deferrable."""
142 initially: str | None = None
143 """Value for ``INITIALLY`` clause, only used of ``deferrable`` is True."""
145 description: str | None = None
146 """Constraint description."""
148 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
149 """Additional annotations for this constraint."""
152@dataclasses.dataclass
153class UniqueConstraint(Constraint):
154 """Description of unique constraint."""
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 """
162@dataclasses.dataclass
163class ForeignKeyConstraint(Constraint):
164 """Description of foreign key constraint."""
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 """
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 """
178@dataclasses.dataclass
179class CheckConstraint(Constraint):
180 """Description of check constraint."""
182 expression: str = ""
183 """Expression on one or more columns on the table, must be non-empty."""
186@dataclasses.dataclass
187class Table:
188 """Description of a single table schema."""
190 name: str
191 """Table name."""
193 id: str
194 """Felis ID for this table."""
196 columns: list[Column]
197 """List of Column instances."""
199 primary_key: list[Column]
200 """List of Column that constitute a primary key, may be empty."""
202 constraints: list[Constraint]
203 """List of Constraint instances, can be empty."""
205 indexes: list[Index]
206 """List of Index instances, can be empty."""
208 description: str | None = None
209 """Table description."""
211 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
212 """Additional annotations for this table."""
215@dataclasses.dataclass
216class SchemaVersion:
217 """Schema versioning description."""
219 current: str
220 """Current schema version defined by the document."""
222 compatible: list[str] | None = None
223 """Optional list of versions which are compatible with current version."""
225 read_compatible: list[str] | None = None
226 """Optional list of versions with which current version is read-compatible.
227 """
230@dataclasses.dataclass
231class Schema:
232 """Complete schema description, collection of tables."""
234 name: str
235 """Schema name."""
237 id: str
238 """Felis ID for this schema."""
240 tables: list[Table]
241 """Collection of table definitions."""
243 version: SchemaVersion | None = None
244 """Schema version description."""
246 description: str | None = None
247 """Schema description."""
249 annotations: Mapping[str, Any] = dataclasses.field(default_factory=dict)
250 """Additional annotations for this table."""
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.
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 """
265 def __init__(self) -> None:
266 self.checker = FelisValidator()
267 self.column_ids: MutableMapping[str, Column] = {}
269 def visit_schema(self, schema_obj: _Mapping) -> Schema:
270 # Docstring is inherited.
271 self.checker.check_schema(schema_obj)
273 version_obj = schema_obj.get("version")
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
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)
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 )
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)
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
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)
325 datatype = FelisType.felis_type(column_obj["datatype"])
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
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 []
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)
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}")
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)
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 )