lsst-felis 26.2024.900__py3-none-any.whl → 29.2025.4500__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- felis/__init__.py +10 -24
- felis/cli.py +437 -341
- felis/config/tap_schema/columns.csv +33 -0
- felis/config/tap_schema/key_columns.csv +8 -0
- felis/config/tap_schema/keys.csv +8 -0
- felis/config/tap_schema/schemas.csv +2 -0
- felis/config/tap_schema/tables.csv +6 -0
- felis/config/tap_schema/tap_schema_std.yaml +273 -0
- felis/datamodel.py +1386 -193
- felis/db/dialects.py +116 -0
- felis/db/schema.py +62 -0
- felis/db/sqltypes.py +275 -48
- felis/db/utils.py +409 -0
- felis/db/variants.py +159 -0
- felis/diff.py +234 -0
- felis/metadata.py +385 -0
- felis/tap_schema.py +767 -0
- felis/tests/__init__.py +0 -0
- felis/tests/postgresql.py +134 -0
- felis/tests/run_cli.py +79 -0
- felis/types.py +57 -9
- lsst_felis-29.2025.4500.dist-info/METADATA +38 -0
- lsst_felis-29.2025.4500.dist-info/RECORD +31 -0
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info}/WHEEL +1 -1
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info/licenses}/COPYRIGHT +1 -1
- felis/check.py +0 -381
- felis/simple.py +0 -424
- felis/sql.py +0 -275
- felis/tap.py +0 -433
- felis/utils.py +0 -100
- felis/validation.py +0 -103
- felis/version.py +0 -2
- felis/visitor.py +0 -180
- lsst_felis-26.2024.900.dist-info/METADATA +0 -28
- lsst_felis-26.2024.900.dist-info/RECORD +0 -23
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info}/entry_points.txt +0 -0
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info/licenses}/LICENSE +0 -0
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info}/top_level.txt +0 -0
- {lsst_felis-26.2024.900.dist-info → lsst_felis-29.2025.4500.dist-info}/zip-safe +0 -0
felis/simple.py
DELETED
|
@@ -1,424 +0,0 @@
|
|
|
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
|
-
|
|
22
|
-
from __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
|
-
|
|
37
|
-
import dataclasses
|
|
38
|
-
import logging
|
|
39
|
-
from collections.abc import Iterable, Mapping, MutableMapping
|
|
40
|
-
from typing import Any, cast
|
|
41
|
-
|
|
42
|
-
from .check import FelisValidator
|
|
43
|
-
from .types import FelisType
|
|
44
|
-
from .visitor import Visitor
|
|
45
|
-
|
|
46
|
-
_Mapping = Mapping[str, Any]
|
|
47
|
-
|
|
48
|
-
logger = logging.getLogger("felis.generic")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _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
|
-
|
|
57
|
-
def _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
|
|
66
|
-
class 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
|
|
101
|
-
class 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
|
|
128
|
-
class 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
|
|
153
|
-
class 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
|
|
163
|
-
class 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
|
|
179
|
-
class 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
|
|
187
|
-
class 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
|
|
216
|
-
class 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
|
|
231
|
-
class 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
|
-
|
|
253
|
-
class 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
|
-
)
|
felis/sql.py
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
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
|
-
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
__all__ = ["SQLVisitor"]
|
|
25
|
-
|
|
26
|
-
import logging
|
|
27
|
-
import re
|
|
28
|
-
from collections.abc import Iterable, Mapping, MutableMapping
|
|
29
|
-
from typing import Any, NamedTuple
|
|
30
|
-
|
|
31
|
-
from sqlalchemy import (
|
|
32
|
-
CheckConstraint,
|
|
33
|
-
Column,
|
|
34
|
-
Constraint,
|
|
35
|
-
ForeignKeyConstraint,
|
|
36
|
-
Index,
|
|
37
|
-
MetaData,
|
|
38
|
-
Numeric,
|
|
39
|
-
PrimaryKeyConstraint,
|
|
40
|
-
UniqueConstraint,
|
|
41
|
-
types,
|
|
42
|
-
)
|
|
43
|
-
from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite
|
|
44
|
-
from sqlalchemy.schema import Table
|
|
45
|
-
|
|
46
|
-
from .check import FelisValidator
|
|
47
|
-
from .db import sqltypes
|
|
48
|
-
from .types import FelisType
|
|
49
|
-
from .visitor import Visitor
|
|
50
|
-
|
|
51
|
-
_Mapping = Mapping[str, Any]
|
|
52
|
-
_MutableMapping = MutableMapping[str, Any]
|
|
53
|
-
|
|
54
|
-
logger = logging.getLogger("felis")
|
|
55
|
-
|
|
56
|
-
MYSQL = "mysql"
|
|
57
|
-
ORACLE = "oracle"
|
|
58
|
-
POSTGRES = "postgresql"
|
|
59
|
-
SQLITE = "sqlite"
|
|
60
|
-
|
|
61
|
-
TABLE_OPTS = {
|
|
62
|
-
"mysql:engine": "mysql_engine",
|
|
63
|
-
"mysql:charset": "mysql_charset",
|
|
64
|
-
"oracle:compress": "oracle_compress",
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
COLUMN_VARIANT_OVERRIDE = {
|
|
68
|
-
"mysql:datatype": "mysql",
|
|
69
|
-
"oracle:datatype": "oracle",
|
|
70
|
-
"postgresql:datatype": "postgresql",
|
|
71
|
-
"sqlite:datatype": "sqlite",
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
DIALECT_MODULES = {MYSQL: mysql, ORACLE: oracle, SQLITE: sqlite, POSTGRES: postgresql}
|
|
75
|
-
|
|
76
|
-
length_regex = re.compile(r"\((.+)\)")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class Schema(NamedTuple):
|
|
80
|
-
name: str | None
|
|
81
|
-
tables: list[Table]
|
|
82
|
-
metadata: MetaData
|
|
83
|
-
graph_index: Mapping[str, Any]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class SQLVisitor(Visitor[Schema, Table, Column, PrimaryKeyConstraint | None, Constraint, Index, None]):
|
|
87
|
-
"""A Felis Visitor which populates a SQLAlchemy metadata object.
|
|
88
|
-
|
|
89
|
-
Parameters
|
|
90
|
-
----------
|
|
91
|
-
schema_name : `str`, optional
|
|
92
|
-
Override for the schema name.
|
|
93
|
-
"""
|
|
94
|
-
|
|
95
|
-
def __init__(self, schema_name: str | None = None):
|
|
96
|
-
self.metadata = MetaData()
|
|
97
|
-
self.schema_name = schema_name
|
|
98
|
-
self.checker = FelisValidator()
|
|
99
|
-
self.graph_index: MutableMapping[str, Any] = {}
|
|
100
|
-
|
|
101
|
-
def visit_schema(self, schema_obj: _Mapping) -> Schema:
|
|
102
|
-
# Docstring is inherited.
|
|
103
|
-
self.checker.check_schema(schema_obj)
|
|
104
|
-
if (version_obj := schema_obj.get("version")) is not None:
|
|
105
|
-
self.visit_schema_version(version_obj, schema_obj)
|
|
106
|
-
|
|
107
|
-
# Create tables but don't add constraints yet.
|
|
108
|
-
tables = [self.visit_table(t, schema_obj) for t in schema_obj["tables"]]
|
|
109
|
-
|
|
110
|
-
# Process constraints after the tables are created so that all
|
|
111
|
-
# referenced columns are available.
|
|
112
|
-
for table_obj in schema_obj["tables"]:
|
|
113
|
-
constraints = [
|
|
114
|
-
self.visit_constraint(constraint, table_obj)
|
|
115
|
-
for constraint in table_obj.get("constraints", [])
|
|
116
|
-
]
|
|
117
|
-
table = self.graph_index[table_obj["@id"]]
|
|
118
|
-
for constraint in constraints:
|
|
119
|
-
table.append_constraint(constraint)
|
|
120
|
-
|
|
121
|
-
schema = Schema(
|
|
122
|
-
name=self.schema_name or schema_obj["name"],
|
|
123
|
-
tables=tables,
|
|
124
|
-
metadata=self.metadata,
|
|
125
|
-
graph_index=self.graph_index,
|
|
126
|
-
)
|
|
127
|
-
return schema
|
|
128
|
-
|
|
129
|
-
def visit_schema_version(
|
|
130
|
-
self, version_obj: str | Mapping[str, Any], schema_obj: Mapping[str, Any]
|
|
131
|
-
) -> None:
|
|
132
|
-
# Docstring is inherited.
|
|
133
|
-
|
|
134
|
-
# For now we ignore schema versioning completely, still do some checks.
|
|
135
|
-
self.checker.check_schema_version(version_obj, schema_obj)
|
|
136
|
-
|
|
137
|
-
def visit_table(self, table_obj: _Mapping, schema_obj: _Mapping) -> Table:
|
|
138
|
-
# Docstring is inherited.
|
|
139
|
-
self.checker.check_table(table_obj, schema_obj)
|
|
140
|
-
columns = [self.visit_column(c, table_obj) for c in table_obj["columns"]]
|
|
141
|
-
|
|
142
|
-
name = table_obj["name"]
|
|
143
|
-
table_id = table_obj["@id"]
|
|
144
|
-
description = table_obj.get("description")
|
|
145
|
-
schema_name = self.schema_name or schema_obj["name"]
|
|
146
|
-
|
|
147
|
-
table = Table(name, self.metadata, *columns, schema=schema_name, comment=description)
|
|
148
|
-
|
|
149
|
-
primary_key = self.visit_primary_key(table_obj.get("primaryKey", []), table_obj)
|
|
150
|
-
if primary_key:
|
|
151
|
-
table.append_constraint(primary_key)
|
|
152
|
-
|
|
153
|
-
indexes = [self.visit_index(i, table_obj) for i in table_obj.get("indexes", [])]
|
|
154
|
-
for index in indexes:
|
|
155
|
-
# FIXME: Hack because there's no table.add_index
|
|
156
|
-
index._set_parent(table)
|
|
157
|
-
table.indexes.add(index)
|
|
158
|
-
self.graph_index[table_id] = table
|
|
159
|
-
return table
|
|
160
|
-
|
|
161
|
-
def visit_column(self, column_obj: _Mapping, table_obj: _Mapping) -> Column:
|
|
162
|
-
# Docstring is inherited.
|
|
163
|
-
self.checker.check_column(column_obj, table_obj)
|
|
164
|
-
column_name = column_obj["name"]
|
|
165
|
-
column_id = column_obj["@id"]
|
|
166
|
-
datatype_name = column_obj["datatype"]
|
|
167
|
-
column_description = column_obj.get("description")
|
|
168
|
-
column_default = column_obj.get("value")
|
|
169
|
-
column_length = column_obj.get("length")
|
|
170
|
-
|
|
171
|
-
kwargs = {}
|
|
172
|
-
for column_opt in column_obj.keys():
|
|
173
|
-
if column_opt in COLUMN_VARIANT_OVERRIDE:
|
|
174
|
-
dialect = COLUMN_VARIANT_OVERRIDE[column_opt]
|
|
175
|
-
variant = _process_variant_override(dialect, column_obj[column_opt])
|
|
176
|
-
kwargs[dialect] = variant
|
|
177
|
-
|
|
178
|
-
felis_type = FelisType.felis_type(datatype_name)
|
|
179
|
-
datatype_fun = getattr(sqltypes, datatype_name)
|
|
180
|
-
|
|
181
|
-
if felis_type.is_sized:
|
|
182
|
-
datatype = datatype_fun(column_length, **kwargs)
|
|
183
|
-
else:
|
|
184
|
-
datatype = datatype_fun(**kwargs)
|
|
185
|
-
|
|
186
|
-
nullable_default = True
|
|
187
|
-
if isinstance(datatype, Numeric):
|
|
188
|
-
nullable_default = False
|
|
189
|
-
|
|
190
|
-
column_nullable = column_obj.get("nullable", nullable_default)
|
|
191
|
-
column_autoincrement = column_obj.get("autoincrement", "auto")
|
|
192
|
-
|
|
193
|
-
column: Column = Column(
|
|
194
|
-
column_name,
|
|
195
|
-
datatype,
|
|
196
|
-
comment=column_description,
|
|
197
|
-
autoincrement=column_autoincrement,
|
|
198
|
-
nullable=column_nullable,
|
|
199
|
-
server_default=column_default,
|
|
200
|
-
)
|
|
201
|
-
if column_id in self.graph_index:
|
|
202
|
-
logger.warning(f"Duplication of @id {column_id}")
|
|
203
|
-
self.graph_index[column_id] = column
|
|
204
|
-
return column
|
|
205
|
-
|
|
206
|
-
def visit_primary_key(
|
|
207
|
-
self, primary_key_obj: str | Iterable[str], table_obj: _Mapping
|
|
208
|
-
) -> PrimaryKeyConstraint | None:
|
|
209
|
-
# Docstring is inherited.
|
|
210
|
-
self.checker.check_primary_key(primary_key_obj, table_obj)
|
|
211
|
-
if primary_key_obj:
|
|
212
|
-
if isinstance(primary_key_obj, str):
|
|
213
|
-
primary_key_obj = [primary_key_obj]
|
|
214
|
-
columns = [self.graph_index[c_id] for c_id in primary_key_obj]
|
|
215
|
-
return PrimaryKeyConstraint(*columns)
|
|
216
|
-
return None
|
|
217
|
-
|
|
218
|
-
def visit_constraint(self, constraint_obj: _Mapping, table_obj: _Mapping) -> Constraint:
|
|
219
|
-
# Docstring is inherited.
|
|
220
|
-
self.checker.check_constraint(constraint_obj, table_obj)
|
|
221
|
-
constraint_type = constraint_obj["@type"]
|
|
222
|
-
constraint_id = constraint_obj["@id"]
|
|
223
|
-
|
|
224
|
-
constraint_args: _MutableMapping = {}
|
|
225
|
-
# The following are not used on every constraint
|
|
226
|
-
_set_if("name", constraint_obj.get("name"), constraint_args)
|
|
227
|
-
_set_if("info", constraint_obj.get("description"), constraint_args)
|
|
228
|
-
_set_if("expression", constraint_obj.get("expression"), constraint_args)
|
|
229
|
-
_set_if("deferrable", constraint_obj.get("deferrable"), constraint_args)
|
|
230
|
-
_set_if("initially", constraint_obj.get("initially"), constraint_args)
|
|
231
|
-
|
|
232
|
-
columns = [self.graph_index[c_id] for c_id in constraint_obj.get("columns", [])]
|
|
233
|
-
constraint: Constraint
|
|
234
|
-
if constraint_type == "ForeignKey":
|
|
235
|
-
refcolumns = [self.graph_index[c_id] for c_id in constraint_obj.get("referencedColumns", [])]
|
|
236
|
-
constraint = ForeignKeyConstraint(columns, refcolumns, **constraint_args)
|
|
237
|
-
elif constraint_type == "Check":
|
|
238
|
-
expression = constraint_obj["expression"]
|
|
239
|
-
constraint = CheckConstraint(expression, **constraint_args)
|
|
240
|
-
elif constraint_type == "Unique":
|
|
241
|
-
constraint = UniqueConstraint(*columns, **constraint_args)
|
|
242
|
-
else:
|
|
243
|
-
raise ValueError(f"Unexpected constraint type: {constraint_type}")
|
|
244
|
-
self.graph_index[constraint_id] = constraint
|
|
245
|
-
return constraint
|
|
246
|
-
|
|
247
|
-
def visit_index(self, index_obj: _Mapping, table_obj: _Mapping) -> Index:
|
|
248
|
-
# Docstring is inherited.
|
|
249
|
-
self.checker.check_index(index_obj, table_obj)
|
|
250
|
-
name = index_obj["name"]
|
|
251
|
-
description = index_obj.get("description")
|
|
252
|
-
columns = [self.graph_index[c_id] for c_id in index_obj.get("columns", [])]
|
|
253
|
-
expressions = index_obj.get("expressions", [])
|
|
254
|
-
return Index(name, *columns, *expressions, info=description)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def _set_if(key: str, value: Any, mapping: _MutableMapping) -> None:
|
|
258
|
-
if value is not None:
|
|
259
|
-
mapping[key] = value
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _process_variant_override(dialect_name: str, variant_override_str: str) -> types.TypeEngine:
|
|
263
|
-
"""Return variant type for given dialect."""
|
|
264
|
-
match = length_regex.search(variant_override_str)
|
|
265
|
-
dialect = DIALECT_MODULES[dialect_name]
|
|
266
|
-
variant_type_name = variant_override_str.split("(")[0]
|
|
267
|
-
|
|
268
|
-
# Process Variant Type
|
|
269
|
-
if variant_type_name not in dir(dialect):
|
|
270
|
-
raise ValueError(f"Type {variant_type_name} not found in dialect {dialect_name}")
|
|
271
|
-
variant_type = getattr(dialect, variant_type_name)
|
|
272
|
-
length_params = []
|
|
273
|
-
if match:
|
|
274
|
-
length_params.extend([int(i) for i in match.group(1).split(",")])
|
|
275
|
-
return variant_type(*length_params)
|