Coverage for python/felis/check.py: 25%
94 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-14 02:21 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-14 02:21 -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__ = ["CheckingVisitor", "FelisValidator"]
26import logging
27from collections.abc import Iterable, Mapping, MutableSet
28from typing import Any
30from .types import FelisType
31from .visitor import Visitor
33_Mapping = Mapping[str, Any]
35logger = logging.getLogger("felis")
38class FelisValidator:
39 """Class defining methods for validating individual objects in a felis
40 structure.
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 """
48 def __init__(self) -> None:
49 self._ids: MutableSet[str] = set()
51 def check_schema(self, schema_obj: _Mapping) -> None:
52 """Validate contents of Felis schema object.
54 Parameters
55 ----------
56 schema_obj : `Mapping` [ `str`, `Any` ]
57 Felis object (mapping) representing a schema.
59 Raises
60 ------
61 ValueError
62 Raised if validation fails.
63 """
64 _id = self._assert_id(schema_obj)
65 self._check_visited(_id)
67 def check_table(self, table_obj: _Mapping, schema_obj: _Mapping) -> None:
68 """Validate contents of Felis table object.
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.
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)
86 def check_column(self, column_obj: _Mapping, table_obj: _Mapping) -> None:
87 """Validate contents of Felis column object.
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.
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)
111 def check_primary_key(self, primary_key_obj: str | Iterable[str], table: _Mapping) -> None:
112 """Validate contents of Felis primary key object.
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.
121 Raises
122 ------
123 ValueError
124 Raised if validation fails.
125 """
126 pass
128 def check_constraint(self, constraint_obj: _Mapping, table_obj: _Mapping) -> None:
129 """Validate contents of Felis constraint object.
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.
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)
151 def check_index(self, index_obj: _Mapping, table_obj: _Mapping) -> None:
152 """Validate contents of Felis constraint object.
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.
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)
172 def _assert_id(self, obj: _Mapping) -> str:
173 """Verify that an object has a non-empty ``@id`` field.
175 Parameters
176 ----------
177 obj : `Mapping` [ `str`, `Any` ]
178 Felis object.
180 Raises
181 ------
182 ValueError
183 Raised if ``@id`` field is missing or empty.
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
197 def _assert_name(self, obj: _Mapping) -> None:
198 """Verify that an object has a ``name`` field.
200 Parameters
201 ----------
202 obj : `Mapping` [ `str`, `Any` ]
203 Felis object.
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}")
214 def _assert_datatype(self, obj: _Mapping) -> str:
215 """Verify that an object has a valid ``datatype`` field.
217 Parameters
218 ----------
219 obj : `Mapping` [ `str`, `Any` ]
220 Felis object.
222 Raises
223 ------
224 ValueError
225 Raised if ``datatype`` field is missing or invalid.
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
242 def _check_visited(self, _id: str) -> None:
243 """Check that given ID has not been visited, generates a warning
244 otherwise.
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)
255class CheckingVisitor(Visitor[None, None, None, None, None, None]):
256 """Visitor implementation which validates felis structures and raises
257 exceptions for errors.
258 """
260 def __init__(self) -> None:
261 super().__init__()
262 self.checker = FelisValidator()
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)
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)
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)
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)
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)
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)