lsst-felis 26.2024.1200__py3-none-any.whl → 26.2024.1400__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.
Potentially problematic release.
This version of lsst-felis might be problematic. Click here for more details.
- felis/cli.py +62 -44
- felis/datamodel.py +14 -5
- felis/db/_variants.py +94 -0
- felis/metadata.py +504 -0
- felis/version.py +1 -1
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/METADATA +2 -1
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/RECORD +13 -12
- felis/sql.py +0 -275
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/COPYRIGHT +0 -0
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/LICENSE +0 -0
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/WHEEL +0 -0
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/entry_points.txt +0 -0
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/top_level.txt +0 -0
- {lsst_felis-26.2024.1200.dist-info → lsst_felis-26.2024.1400.dist-info}/zip-safe +0 -0
felis/cli.py
CHANGED
|
@@ -26,7 +26,7 @@ import json
|
|
|
26
26
|
import logging
|
|
27
27
|
import sys
|
|
28
28
|
from collections.abc import Iterable, Mapping, MutableMapping
|
|
29
|
-
from typing import Any
|
|
29
|
+
from typing import IO, Any
|
|
30
30
|
|
|
31
31
|
import click
|
|
32
32
|
import yaml
|
|
@@ -38,7 +38,7 @@ from sqlalchemy.engine.mock import MockConnection
|
|
|
38
38
|
from . import DEFAULT_CONTEXT, DEFAULT_FRAME, __version__
|
|
39
39
|
from .check import CheckingVisitor
|
|
40
40
|
from .datamodel import Schema
|
|
41
|
-
from .
|
|
41
|
+
from .metadata import DatabaseContext, InsertDump, MetaDataBuilder
|
|
42
42
|
from .tap import Tap11Base, TapLoadingVisitor, init_tables
|
|
43
43
|
from .utils import ReorderingVisitor
|
|
44
44
|
from .validation import get_schema
|
|
@@ -71,27 +71,70 @@ def cli(log_level: str, log_file: str | None) -> None:
|
|
|
71
71
|
logging.basicConfig(level=log_level)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
@cli.command("create
|
|
75
|
-
@click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
76
|
-
@click.option("--schema-name", help="Alternate
|
|
77
|
-
@click.option(
|
|
74
|
+
@cli.command("create")
|
|
75
|
+
@click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
|
|
76
|
+
@click.option("--schema-name", help="Alternate schema name to override Felis file")
|
|
77
|
+
@click.option(
|
|
78
|
+
"--create-if-not-exists", is_flag=True, help="Create the schema in the database if it does not exist"
|
|
79
|
+
)
|
|
80
|
+
@click.option("--drop-if-exists", is_flag=True, help="Drop schema if it already exists in the database")
|
|
81
|
+
@click.option("--echo", is_flag=True, help="Echo database commands as they are executed")
|
|
82
|
+
@click.option("--dry-run", is_flag=True, help="Dry run only to print out commands instead of executing")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
|
|
85
|
+
)
|
|
78
86
|
@click.argument("file", type=click.File())
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
def create(
|
|
88
|
+
engine_url: str,
|
|
89
|
+
schema_name: str | None,
|
|
90
|
+
create_if_not_exists: bool,
|
|
91
|
+
drop_if_exists: bool,
|
|
92
|
+
echo: bool,
|
|
93
|
+
dry_run: bool,
|
|
94
|
+
output_file: IO[str] | None,
|
|
95
|
+
file: IO,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Create database objects from the Felis file."""
|
|
98
|
+
yaml_data = yaml.safe_load(file)
|
|
99
|
+
schema = Schema.model_validate(yaml_data)
|
|
100
|
+
url_obj = make_url(engine_url)
|
|
101
|
+
if schema_name:
|
|
102
|
+
logger.info(f"Overriding schema name with: {schema_name}")
|
|
103
|
+
schema.name = schema_name
|
|
104
|
+
elif url_obj.drivername == "sqlite":
|
|
105
|
+
logger.info("Overriding schema name for sqlite with: main")
|
|
106
|
+
schema.name = "main"
|
|
107
|
+
if not url_obj.host and not url_obj.drivername == "sqlite":
|
|
108
|
+
dry_run = True
|
|
109
|
+
logger.info("Forcing dry run for non-sqlite engine URL with no host")
|
|
110
|
+
|
|
111
|
+
builder = MetaDataBuilder(schema)
|
|
112
|
+
builder.build()
|
|
113
|
+
metadata = builder.metadata
|
|
114
|
+
logger.debug(f"Created metadata with schema name: {metadata.schema}")
|
|
86
115
|
|
|
87
116
|
engine: Engine | MockConnection
|
|
88
|
-
if not dry_run:
|
|
89
|
-
engine = create_engine(engine_url)
|
|
117
|
+
if not dry_run and not output_file:
|
|
118
|
+
engine = create_engine(engine_url, echo=echo)
|
|
90
119
|
else:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
if dry_run:
|
|
121
|
+
logger.info("Dry run will be executed")
|
|
122
|
+
engine = DatabaseContext.create_mock_engine(url_obj, output_file)
|
|
123
|
+
if output_file:
|
|
124
|
+
logger.info("Writing SQL output to: " + output_file.name)
|
|
125
|
+
|
|
126
|
+
context = DatabaseContext(metadata, engine)
|
|
127
|
+
|
|
128
|
+
if drop_if_exists:
|
|
129
|
+
logger.debug("Dropping schema if it exists")
|
|
130
|
+
context.drop_if_exists()
|
|
131
|
+
create_if_not_exists = True # If schema is dropped, it needs to be recreated.
|
|
132
|
+
|
|
133
|
+
if create_if_not_exists:
|
|
134
|
+
logger.debug("Creating schema if not exists")
|
|
135
|
+
context.create_if_not_exists()
|
|
136
|
+
|
|
137
|
+
context.create_all()
|
|
95
138
|
|
|
96
139
|
|
|
97
140
|
@cli.command("init-tap")
|
|
@@ -404,30 +447,5 @@ def _normalize(schema_obj: Mapping[str, Any], embed: str = "@last") -> MutableMa
|
|
|
404
447
|
return compacted
|
|
405
448
|
|
|
406
449
|
|
|
407
|
-
class InsertDump:
|
|
408
|
-
"""An Insert Dumper for SQL statements."""
|
|
409
|
-
|
|
410
|
-
dialect: Any = None
|
|
411
|
-
|
|
412
|
-
def dump(self, sql: Any, *multiparams: Any, **params: Any) -> None:
|
|
413
|
-
compiled = sql.compile(dialect=self.dialect)
|
|
414
|
-
sql_str = str(compiled) + ";"
|
|
415
|
-
params_list = [compiled.params]
|
|
416
|
-
for params in params_list:
|
|
417
|
-
if not params:
|
|
418
|
-
print(sql_str)
|
|
419
|
-
continue
|
|
420
|
-
new_params = {}
|
|
421
|
-
for key, value in params.items():
|
|
422
|
-
if isinstance(value, str):
|
|
423
|
-
new_params[key] = f"'{value}'"
|
|
424
|
-
elif value is None:
|
|
425
|
-
new_params[key] = "null"
|
|
426
|
-
else:
|
|
427
|
-
new_params[key] = value
|
|
428
|
-
|
|
429
|
-
print(sql_str % new_params)
|
|
430
|
-
|
|
431
|
-
|
|
432
450
|
if __name__ == "__main__":
|
|
433
451
|
cli()
|
felis/datamodel.py
CHANGED
|
@@ -31,7 +31,6 @@ from astropy.io.votable import ucd # type: ignore
|
|
|
31
31
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
32
32
|
|
|
33
33
|
logger = logging.getLogger(__name__)
|
|
34
|
-
# logger.setLevel(logging.DEBUG)
|
|
35
34
|
|
|
36
35
|
__all__ = (
|
|
37
36
|
"BaseObject",
|
|
@@ -50,7 +49,6 @@ __all__ = (
|
|
|
50
49
|
CONFIG = ConfigDict(
|
|
51
50
|
populate_by_name=True, # Populate attributes by name.
|
|
52
51
|
extra="forbid", # Do not allow extra fields.
|
|
53
|
-
use_enum_values=True, # Use enum values instead of names.
|
|
54
52
|
validate_assignment=True, # Validate assignments after model is created.
|
|
55
53
|
str_strip_whitespace=True, # Strip whitespace from string fields.
|
|
56
54
|
)
|
|
@@ -130,8 +128,13 @@ class Column(BaseObject):
|
|
|
130
128
|
length: int | None = None
|
|
131
129
|
"""The length of the column."""
|
|
132
130
|
|
|
133
|
-
nullable: bool =
|
|
134
|
-
"""Whether the column can be
|
|
131
|
+
nullable: bool | None = None
|
|
132
|
+
"""Whether the column can be ``NULL``.
|
|
133
|
+
|
|
134
|
+
If `None`, this value was not set explicitly in the YAML data. In this
|
|
135
|
+
case, it will be set to `False` for columns with numeric types and `True`
|
|
136
|
+
otherwise.
|
|
137
|
+
"""
|
|
135
138
|
|
|
136
139
|
value: Any = None
|
|
137
140
|
"""The default value of the column."""
|
|
@@ -142,6 +145,9 @@ class Column(BaseObject):
|
|
|
142
145
|
mysql_datatype: str | None = Field(None, alias="mysql:datatype")
|
|
143
146
|
"""The MySQL datatype of the column."""
|
|
144
147
|
|
|
148
|
+
postgresql_datatype: str | None = Field(None, alias="postgresql:datatype")
|
|
149
|
+
"""The PostgreSQL datatype of the column."""
|
|
150
|
+
|
|
145
151
|
ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
|
|
146
152
|
"""The IVOA UCD of the column."""
|
|
147
153
|
|
|
@@ -280,7 +286,7 @@ class Table(BaseObject):
|
|
|
280
286
|
indexes: list[Index] = Field(default_factory=list)
|
|
281
287
|
"""The indexes on the table."""
|
|
282
288
|
|
|
283
|
-
|
|
289
|
+
primary_key: str | list[str] | None = Field(None, alias="primaryKey")
|
|
284
290
|
"""The primary key of the table."""
|
|
285
291
|
|
|
286
292
|
tap_table_index: int | None = Field(None, alias="tap:table_index")
|
|
@@ -427,6 +433,9 @@ class Schema(BaseObject):
|
|
|
427
433
|
@model_validator(mode="after")
|
|
428
434
|
def create_id_map(self: Schema) -> Schema:
|
|
429
435
|
"""Create a map of IDs to objects."""
|
|
436
|
+
if len(self.id_map):
|
|
437
|
+
logger.debug("ID map was already populated")
|
|
438
|
+
return self
|
|
430
439
|
visitor: SchemaIdVisitor = SchemaIdVisitor()
|
|
431
440
|
visitor.visit_schema(self)
|
|
432
441
|
logger.debug(f"ID map contains {len(self.id_map.keys())} objects")
|
felis/db/_variants.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
import re
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from sqlalchemy import types
|
|
26
|
+
from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite
|
|
27
|
+
from sqlalchemy.types import TypeEngine
|
|
28
|
+
|
|
29
|
+
from ..datamodel import Column
|
|
30
|
+
|
|
31
|
+
MYSQL = "mysql"
|
|
32
|
+
ORACLE = "oracle"
|
|
33
|
+
POSTGRES = "postgresql"
|
|
34
|
+
SQLITE = "sqlite"
|
|
35
|
+
|
|
36
|
+
TABLE_OPTS = {
|
|
37
|
+
"mysql:engine": "mysql_engine",
|
|
38
|
+
"mysql:charset": "mysql_charset",
|
|
39
|
+
"oracle:compress": "oracle_compress",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
COLUMN_VARIANT_OVERRIDE = {
|
|
43
|
+
"mysql:datatype": "mysql",
|
|
44
|
+
"oracle:datatype": "oracle",
|
|
45
|
+
"postgresql:datatype": "postgresql",
|
|
46
|
+
"sqlite:datatype": "sqlite",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
DIALECT_MODULES = {MYSQL: mysql, ORACLE: oracle, SQLITE: sqlite, POSTGRES: postgresql}
|
|
50
|
+
|
|
51
|
+
_length_regex = re.compile(r"\((\d+)\)")
|
|
52
|
+
"""A regular expression that is looking for numbers within parentheses."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def process_variant_override(dialect_name: str, variant_override_str: str) -> types.TypeEngine:
|
|
56
|
+
"""Return variant type for given dialect."""
|
|
57
|
+
dialect = DIALECT_MODULES[dialect_name]
|
|
58
|
+
variant_type_name = variant_override_str.split("(")[0]
|
|
59
|
+
|
|
60
|
+
# Process Variant Type
|
|
61
|
+
if variant_type_name not in dir(dialect):
|
|
62
|
+
raise ValueError(f"Type {variant_type_name} not found in dialect {dialect_name}")
|
|
63
|
+
variant_type = getattr(dialect, variant_type_name)
|
|
64
|
+
length_params = []
|
|
65
|
+
if match := _length_regex.search(variant_override_str):
|
|
66
|
+
length_params.extend([int(i) for i in match.group(1).split(",")])
|
|
67
|
+
return variant_type(*length_params)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def make_variant_dict(column_obj: Column) -> dict[str, TypeEngine[Any]]:
|
|
71
|
+
"""Handle variant overrides for a `felis.datamodel.Column`.
|
|
72
|
+
|
|
73
|
+
This function will return a dictionary of `str` to
|
|
74
|
+
`sqlalchemy.types.TypeEngine` containing variant datatype information
|
|
75
|
+
(e.g., for mysql, postgresql, etc).
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
column_obj : `felis.datamodel.Column`
|
|
80
|
+
The column object from which to build the variant dictionary.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
variant_dict : `dict`
|
|
85
|
+
The dictionary of `str` to `sqlalchemy.types.TypeEngine` containing
|
|
86
|
+
variant datatype information (e.g., for mysql, postgresql, etc).
|
|
87
|
+
"""
|
|
88
|
+
variant_dict = {}
|
|
89
|
+
for field_name, value in iter(column_obj):
|
|
90
|
+
if field_name in COLUMN_VARIANT_OVERRIDE:
|
|
91
|
+
dialect = COLUMN_VARIANT_OVERRIDE[field_name]
|
|
92
|
+
variant: TypeEngine = process_variant_override(dialect, value)
|
|
93
|
+
variant_dict[dialect] = variant
|
|
94
|
+
return variant_dict
|
felis/metadata.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
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
|
+
import logging
|
|
25
|
+
from typing import IO, Any, Literal
|
|
26
|
+
|
|
27
|
+
import sqlalchemy.schema as sqa_schema
|
|
28
|
+
from lsst.utils.iteration import ensure_iterable
|
|
29
|
+
from sqlalchemy import (
|
|
30
|
+
CheckConstraint,
|
|
31
|
+
Column,
|
|
32
|
+
Constraint,
|
|
33
|
+
Engine,
|
|
34
|
+
ForeignKeyConstraint,
|
|
35
|
+
Index,
|
|
36
|
+
MetaData,
|
|
37
|
+
Numeric,
|
|
38
|
+
PrimaryKeyConstraint,
|
|
39
|
+
ResultProxy,
|
|
40
|
+
Table,
|
|
41
|
+
UniqueConstraint,
|
|
42
|
+
create_mock_engine,
|
|
43
|
+
make_url,
|
|
44
|
+
text,
|
|
45
|
+
)
|
|
46
|
+
from sqlalchemy.engine.interfaces import Dialect
|
|
47
|
+
from sqlalchemy.engine.mock import MockConnection
|
|
48
|
+
from sqlalchemy.engine.url import URL
|
|
49
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
50
|
+
from sqlalchemy.types import TypeEngine
|
|
51
|
+
|
|
52
|
+
from felis.datamodel import Schema
|
|
53
|
+
from felis.db._variants import make_variant_dict
|
|
54
|
+
|
|
55
|
+
from . import datamodel
|
|
56
|
+
from .db import sqltypes
|
|
57
|
+
from .types import FelisType
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InsertDump:
|
|
63
|
+
"""An Insert Dumper for SQL statements which supports writing messages
|
|
64
|
+
to stdout or a file.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, file: IO[str] | None = None) -> None:
|
|
68
|
+
"""Initialize the insert dumper.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
file : `io.TextIOBase` or `None`, optional
|
|
73
|
+
The file to write the SQL statements to. If None, the statements
|
|
74
|
+
will be written to stdout.
|
|
75
|
+
"""
|
|
76
|
+
self.file = file
|
|
77
|
+
self.dialect: Dialect | None = None
|
|
78
|
+
|
|
79
|
+
def dump(self, sql: Any, *multiparams: Any, **params: Any) -> None:
|
|
80
|
+
"""Dump the SQL statement to a file or stdout.
|
|
81
|
+
|
|
82
|
+
Statements with parameters will be formatted with the values
|
|
83
|
+
inserted into the resultant SQL output.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
sql : `typing.Any`
|
|
88
|
+
The SQL statement to dump.
|
|
89
|
+
multiparams : `typing.Any`
|
|
90
|
+
The multiparams to use for the SQL statement.
|
|
91
|
+
params : `typing.Any`
|
|
92
|
+
The params to use for the SQL statement.
|
|
93
|
+
"""
|
|
94
|
+
compiled = sql.compile(dialect=self.dialect)
|
|
95
|
+
sql_str = str(compiled) + ";"
|
|
96
|
+
params_list = [compiled.params]
|
|
97
|
+
for params in params_list:
|
|
98
|
+
if not params:
|
|
99
|
+
print(sql_str, file=self.file)
|
|
100
|
+
continue
|
|
101
|
+
new_params = {}
|
|
102
|
+
for key, value in params.items():
|
|
103
|
+
if isinstance(value, str):
|
|
104
|
+
new_params[key] = f"'{value}'"
|
|
105
|
+
elif value is None:
|
|
106
|
+
new_params[key] = "null"
|
|
107
|
+
else:
|
|
108
|
+
new_params[key] = value
|
|
109
|
+
print(sql_str % new_params, file=self.file)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_datatype_with_variants(column_obj: datamodel.Column) -> TypeEngine:
|
|
113
|
+
"""Use the Felis type system to get a SQLAlchemy datatype with variant
|
|
114
|
+
overrides from the information in a `Column` object.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
column_obj : `felis.datamodel.Column`
|
|
119
|
+
The column object from which to get the datatype.
|
|
120
|
+
|
|
121
|
+
Raises
|
|
122
|
+
------
|
|
123
|
+
ValueError
|
|
124
|
+
If the column has a sized type but no length.
|
|
125
|
+
"""
|
|
126
|
+
variant_dict = make_variant_dict(column_obj)
|
|
127
|
+
felis_type = FelisType.felis_type(column_obj.datatype.value)
|
|
128
|
+
datatype_fun = getattr(sqltypes, column_obj.datatype.value)
|
|
129
|
+
if felis_type.is_sized:
|
|
130
|
+
if not column_obj.length:
|
|
131
|
+
raise ValueError(f"Column {column_obj.name} has sized type '{column_obj.datatype}' but no length")
|
|
132
|
+
datatype = datatype_fun(column_obj.length, **variant_dict)
|
|
133
|
+
else:
|
|
134
|
+
datatype = datatype_fun(**variant_dict)
|
|
135
|
+
return datatype
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MetaDataBuilder:
|
|
139
|
+
"""A class for building a `MetaData` object from a Felis `Schema`."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self, schema: Schema, apply_schema_to_metadata: bool = True, apply_schema_to_tables: bool = True
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Initialize the metadata builder.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
schema : `felis.datamodel.Schema`
|
|
149
|
+
The schema object from which to build the SQLAlchemy metadata.
|
|
150
|
+
apply_schema_to_metadata : `bool`, optional
|
|
151
|
+
Whether to apply the schema name to the metadata object.
|
|
152
|
+
apply_schema_to_tables : `bool`, optional
|
|
153
|
+
Whether to apply the schema name to the tables.
|
|
154
|
+
"""
|
|
155
|
+
self.schema = schema
|
|
156
|
+
if not apply_schema_to_metadata:
|
|
157
|
+
logger.debug("Schema name will not be applied to metadata")
|
|
158
|
+
if not apply_schema_to_tables:
|
|
159
|
+
logger.debug("Schema name will not be applied to tables")
|
|
160
|
+
self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
|
|
161
|
+
self._objects: dict[str, Any] = {}
|
|
162
|
+
self.apply_schema_to_tables = apply_schema_to_tables
|
|
163
|
+
|
|
164
|
+
def build(self) -> MetaData:
|
|
165
|
+
"""Build the SQLAlchemy tables and constraints from the schema."""
|
|
166
|
+
self.build_tables()
|
|
167
|
+
self.build_constraints()
|
|
168
|
+
return self.metadata
|
|
169
|
+
|
|
170
|
+
def build_tables(self) -> None:
|
|
171
|
+
"""Build the SQLAlchemy tables from the schema.
|
|
172
|
+
|
|
173
|
+
Notes
|
|
174
|
+
-----
|
|
175
|
+
This function builds all the tables by calling ``build_table`` on
|
|
176
|
+
each Pydantic object. It also calls ``build_primary_key`` to create the
|
|
177
|
+
primary key constraints.
|
|
178
|
+
"""
|
|
179
|
+
for table in self.schema.tables:
|
|
180
|
+
self.build_table(table)
|
|
181
|
+
if table.primary_key:
|
|
182
|
+
primary_key = self.build_primary_key(table.primary_key)
|
|
183
|
+
self._objects[table.id].append_constraint(primary_key)
|
|
184
|
+
|
|
185
|
+
def build_primary_key(self, primary_key_columns: str | list[str]) -> PrimaryKeyConstraint:
|
|
186
|
+
"""Build a SQLAlchemy `PrimaryKeyConstraint` from a single column ID
|
|
187
|
+
or a list.
|
|
188
|
+
|
|
189
|
+
The `primary_key_columns` are strings or a list of strings representing
|
|
190
|
+
IDs pointing to columns that will be looked up in the internal object
|
|
191
|
+
dictionary.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
primary_key_columns : `str` or `list` of `str`
|
|
196
|
+
The column ID or list of column IDs from which to build the primary
|
|
197
|
+
key.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
primary_key: `sqlalchemy.PrimaryKeyConstraint`
|
|
202
|
+
The SQLAlchemy primary key constraint object.
|
|
203
|
+
"""
|
|
204
|
+
return PrimaryKeyConstraint(
|
|
205
|
+
*[self._objects[column_id] for column_id in ensure_iterable(primary_key_columns)]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def build_table(self, table_obj: datamodel.Table) -> None:
|
|
209
|
+
"""Build a `sqlalchemy.Table` from a `felis.datamodel.Table` and add
|
|
210
|
+
it to the `sqlalchemy.MetaData` object.
|
|
211
|
+
|
|
212
|
+
Several MySQL table options are handled by annotations on the table,
|
|
213
|
+
including the engine and charset. This is not needed for Postgres,
|
|
214
|
+
which does not have equivalent options.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
table_obj : `felis.datamodel.Table`
|
|
219
|
+
The table object to build the SQLAlchemy table from.
|
|
220
|
+
"""
|
|
221
|
+
# Process mysql table options.
|
|
222
|
+
optargs = {}
|
|
223
|
+
if table_obj.mysql_engine:
|
|
224
|
+
optargs["mysql_engine"] = table_obj.mysql_engine
|
|
225
|
+
if table_obj.mysql_charset:
|
|
226
|
+
optargs["mysql_charset"] = table_obj.mysql_charset
|
|
227
|
+
|
|
228
|
+
# Create the SQLAlchemy table object and its columns.
|
|
229
|
+
name = table_obj.name
|
|
230
|
+
id = table_obj.id
|
|
231
|
+
description = table_obj.description
|
|
232
|
+
columns = [self.build_column(column) for column in table_obj.columns]
|
|
233
|
+
table = Table(
|
|
234
|
+
name,
|
|
235
|
+
self.metadata,
|
|
236
|
+
*columns,
|
|
237
|
+
comment=description,
|
|
238
|
+
schema=self.schema.name if self.apply_schema_to_tables else None,
|
|
239
|
+
**optargs, # type: ignore[arg-type]
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Create the indexes and add them to the table.
|
|
243
|
+
indexes = [self.build_index(index) for index in table_obj.indexes]
|
|
244
|
+
for index in indexes:
|
|
245
|
+
index._set_parent(table)
|
|
246
|
+
table.indexes.add(index)
|
|
247
|
+
|
|
248
|
+
self._objects[id] = table
|
|
249
|
+
|
|
250
|
+
def build_column(self, column_obj: datamodel.Column) -> Column:
|
|
251
|
+
"""Build a SQLAlchemy column from a `felis.datamodel.Column` object.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
column_obj : `felis.datamodel.Column`
|
|
256
|
+
The column object from which to build the SQLAlchemy column.
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
column: `sqlalchemy.Column`
|
|
261
|
+
The SQLAlchemy column object.
|
|
262
|
+
"""
|
|
263
|
+
# Get basic column attributes.
|
|
264
|
+
name = column_obj.name
|
|
265
|
+
id = column_obj.id
|
|
266
|
+
description = column_obj.description
|
|
267
|
+
default = column_obj.value
|
|
268
|
+
|
|
269
|
+
# Handle variant overrides for the column (e.g., "mysql:datatype").
|
|
270
|
+
datatype = get_datatype_with_variants(column_obj)
|
|
271
|
+
|
|
272
|
+
# Set default value of nullable based on column type and then whether
|
|
273
|
+
# it was explicitly provided in the schema data.
|
|
274
|
+
nullable = column_obj.nullable
|
|
275
|
+
if nullable is None:
|
|
276
|
+
nullable = False if isinstance(datatype, Numeric) else True
|
|
277
|
+
|
|
278
|
+
# Set autoincrement depending on if it was provided explicitly.
|
|
279
|
+
autoincrement: Literal["auto"] | bool = (
|
|
280
|
+
column_obj.autoincrement if column_obj.autoincrement is not None else "auto"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
column: Column = Column(
|
|
284
|
+
name,
|
|
285
|
+
datatype,
|
|
286
|
+
comment=description,
|
|
287
|
+
autoincrement=autoincrement,
|
|
288
|
+
nullable=nullable,
|
|
289
|
+
server_default=default,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
self._objects[id] = column
|
|
293
|
+
|
|
294
|
+
return column
|
|
295
|
+
|
|
296
|
+
def build_constraints(self) -> None:
|
|
297
|
+
"""Build the SQLAlchemy constraints in the Felis schema and append them
|
|
298
|
+
to the associated `Table`.
|
|
299
|
+
|
|
300
|
+
Notes
|
|
301
|
+
-----
|
|
302
|
+
This is performed as a separate step after building the tables so that
|
|
303
|
+
all the referenced objects in the constraints will be present and can
|
|
304
|
+
be looked up by their ID.
|
|
305
|
+
"""
|
|
306
|
+
for table_obj in self.schema.tables:
|
|
307
|
+
table = self._objects[table_obj.id]
|
|
308
|
+
for constraint_obj in table_obj.constraints:
|
|
309
|
+
constraint = self.build_constraint(constraint_obj)
|
|
310
|
+
table.append_constraint(constraint)
|
|
311
|
+
|
|
312
|
+
def build_constraint(self, constraint_obj: datamodel.Constraint) -> Constraint:
|
|
313
|
+
"""Build a SQLAlchemy `Constraint` from a `felis.datamodel.Constraint`
|
|
314
|
+
object.
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
constraint_obj : `felis.datamodel.Constraint`
|
|
319
|
+
The constraint object from which to build the SQLAlchemy
|
|
320
|
+
constraint.
|
|
321
|
+
|
|
322
|
+
Returns
|
|
323
|
+
-------
|
|
324
|
+
constraint: `sqlalchemy.Constraint`
|
|
325
|
+
The SQLAlchemy constraint object.
|
|
326
|
+
|
|
327
|
+
Raises
|
|
328
|
+
------
|
|
329
|
+
ValueError
|
|
330
|
+
If the constraint type is not recognized.
|
|
331
|
+
TypeError
|
|
332
|
+
If the constraint object is not the expected type.
|
|
333
|
+
"""
|
|
334
|
+
args: dict[str, Any] = {
|
|
335
|
+
"name": constraint_obj.name or None,
|
|
336
|
+
"info": constraint_obj.description or None,
|
|
337
|
+
"deferrable": constraint_obj.deferrable or None,
|
|
338
|
+
"initially": constraint_obj.initially or None,
|
|
339
|
+
}
|
|
340
|
+
constraint: Constraint
|
|
341
|
+
constraint_type = constraint_obj.type
|
|
342
|
+
|
|
343
|
+
if isinstance(constraint_obj, datamodel.ForeignKeyConstraint):
|
|
344
|
+
fk_obj: datamodel.ForeignKeyConstraint = constraint_obj
|
|
345
|
+
columns = [self._objects[column_id] for column_id in fk_obj.columns]
|
|
346
|
+
refcolumns = [self._objects[column_id] for column_id in fk_obj.referenced_columns]
|
|
347
|
+
constraint = ForeignKeyConstraint(columns, refcolumns, **args)
|
|
348
|
+
elif isinstance(constraint_obj, datamodel.CheckConstraint):
|
|
349
|
+
check_obj: datamodel.CheckConstraint = constraint_obj
|
|
350
|
+
expression = check_obj.expression
|
|
351
|
+
constraint = CheckConstraint(expression, **args)
|
|
352
|
+
elif isinstance(constraint_obj, datamodel.UniqueConstraint):
|
|
353
|
+
uniq_obj: datamodel.UniqueConstraint = constraint_obj
|
|
354
|
+
columns = [self._objects[column_id] for column_id in uniq_obj.columns]
|
|
355
|
+
constraint = UniqueConstraint(*columns, **args)
|
|
356
|
+
else:
|
|
357
|
+
raise ValueError(f"Unknown constraint type: {constraint_type}")
|
|
358
|
+
|
|
359
|
+
self._objects[constraint_obj.id] = constraint
|
|
360
|
+
|
|
361
|
+
return constraint
|
|
362
|
+
|
|
363
|
+
def build_index(self, index_obj: datamodel.Index) -> Index:
|
|
364
|
+
"""Build a SQLAlchemy `Index` from a `felis.datamodel.Index` object.
|
|
365
|
+
|
|
366
|
+
Parameters
|
|
367
|
+
----------
|
|
368
|
+
index_obj : `felis.datamodel.Index`
|
|
369
|
+
The index object from which to build the SQLAlchemy index.
|
|
370
|
+
|
|
371
|
+
Returns
|
|
372
|
+
-------
|
|
373
|
+
index: `sqlalchemy.Index`
|
|
374
|
+
The SQLAlchemy index object.
|
|
375
|
+
"""
|
|
376
|
+
columns = [self._objects[c_id] for c_id in (index_obj.columns if index_obj.columns else [])]
|
|
377
|
+
expressions = index_obj.expressions if index_obj.expressions else []
|
|
378
|
+
index = Index(index_obj.name, *columns, *expressions)
|
|
379
|
+
self._objects[index_obj.id] = index
|
|
380
|
+
return index
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class ConnectionWrapper:
|
|
384
|
+
"""A wrapper for a SQLAlchemy engine or mock connection which provides a
|
|
385
|
+
consistent interface for executing SQL statements.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(self, engine: Engine | MockConnection):
|
|
389
|
+
"""Initialize the connection wrapper.
|
|
390
|
+
|
|
391
|
+
Parameters
|
|
392
|
+
----------
|
|
393
|
+
engine : `sqlalchemy.Engine` or `sqlalchemy.MockConnection`
|
|
394
|
+
The SQLAlchemy engine or mock connection to wrap.
|
|
395
|
+
"""
|
|
396
|
+
self.engine = engine
|
|
397
|
+
|
|
398
|
+
def execute(self, statement: Any) -> ResultProxy:
|
|
399
|
+
"""Execute a SQL statement on the engine and return the result."""
|
|
400
|
+
if isinstance(statement, str):
|
|
401
|
+
statement = text(statement)
|
|
402
|
+
if isinstance(self.engine, MockConnection):
|
|
403
|
+
return self.engine.connect().execute(statement)
|
|
404
|
+
else:
|
|
405
|
+
with self.engine.begin() as connection:
|
|
406
|
+
result = connection.execute(statement)
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class DatabaseContext:
|
|
411
|
+
"""A class for managing the schema and its database connection."""
|
|
412
|
+
|
|
413
|
+
def __init__(self, metadata: MetaData, engine: Engine | MockConnection):
|
|
414
|
+
"""Initialize the database context.
|
|
415
|
+
|
|
416
|
+
Parameters
|
|
417
|
+
----------
|
|
418
|
+
metadata : `sqlalchemy.MetaData`
|
|
419
|
+
The SQLAlchemy metadata object.
|
|
420
|
+
|
|
421
|
+
engine : `sqlalchemy.Engine` or `sqlalchemy.MockConnection`
|
|
422
|
+
The SQLAlchemy engine or mock connection object.
|
|
423
|
+
"""
|
|
424
|
+
self.engine = engine
|
|
425
|
+
self.metadata = metadata
|
|
426
|
+
self.connection = ConnectionWrapper(engine)
|
|
427
|
+
|
|
428
|
+
def create_if_not_exists(self) -> None:
|
|
429
|
+
"""Create the schema in the database if it does not exist.
|
|
430
|
+
|
|
431
|
+
In MySQL, this will create a new database. In PostgreSQL, it will
|
|
432
|
+
create a new schema. For other variants, this is an unsupported
|
|
433
|
+
operation.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
engine: `sqlalchemy.Engine`
|
|
438
|
+
The SQLAlchemy engine object.
|
|
439
|
+
schema_name: `str`
|
|
440
|
+
The name of the schema (or database) to create.
|
|
441
|
+
"""
|
|
442
|
+
db_type = self.engine.dialect.name
|
|
443
|
+
schema_name = self.metadata.schema
|
|
444
|
+
try:
|
|
445
|
+
if db_type == "mysql":
|
|
446
|
+
logger.info(f"Creating MySQL database: {schema_name}")
|
|
447
|
+
self.connection.execute(text(f"CREATE DATABASE IF NOT EXISTS {schema_name}"))
|
|
448
|
+
elif db_type == "postgresql":
|
|
449
|
+
logger.info(f"Creating PG schema: {schema_name}")
|
|
450
|
+
self.connection.execute(sqa_schema.CreateSchema(schema_name, if_not_exists=True))
|
|
451
|
+
else:
|
|
452
|
+
raise ValueError("Unsupported database type:" + db_type)
|
|
453
|
+
except SQLAlchemyError as e:
|
|
454
|
+
logger.error(f"Error creating schema: {e}")
|
|
455
|
+
raise
|
|
456
|
+
|
|
457
|
+
def drop_if_exists(self) -> None:
|
|
458
|
+
"""Drop the schema in the database if it exists.
|
|
459
|
+
|
|
460
|
+
In MySQL, this will drop a database. In PostgreSQL, it will drop a
|
|
461
|
+
schema. For other variants, this is unsupported for now.
|
|
462
|
+
|
|
463
|
+
Parameters
|
|
464
|
+
----------
|
|
465
|
+
engine: `sqlalchemy.Engine`
|
|
466
|
+
The SQLAlchemy engine object.
|
|
467
|
+
schema_name: `str`
|
|
468
|
+
The name of the schema (or database) to drop.
|
|
469
|
+
"""
|
|
470
|
+
db_type = self.engine.dialect.name
|
|
471
|
+
schema_name = self.metadata.schema
|
|
472
|
+
try:
|
|
473
|
+
if db_type == "mysql":
|
|
474
|
+
logger.info(f"Dropping MySQL database if exists: {schema_name}")
|
|
475
|
+
self.connection.execute(text(f"DROP DATABASE IF EXISTS {schema_name}"))
|
|
476
|
+
elif db_type == "postgresql":
|
|
477
|
+
logger.info(f"Dropping PostgreSQL schema if exists: {schema_name}")
|
|
478
|
+
self.connection.execute(sqa_schema.DropSchema(schema_name, if_exists=True))
|
|
479
|
+
else:
|
|
480
|
+
raise ValueError(f"Unsupported database type: {db_type}")
|
|
481
|
+
except SQLAlchemyError as e:
|
|
482
|
+
logger.error(f"Error dropping schema: {e}")
|
|
483
|
+
raise
|
|
484
|
+
|
|
485
|
+
def create_all(self) -> None:
|
|
486
|
+
"""Create all tables in the schema using the metadata object."""
|
|
487
|
+
self.metadata.create_all(self.engine)
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def create_mock_engine(engine_url: URL, output_file: IO[str] | None = None) -> MockConnection:
|
|
491
|
+
"""Create a mock engine for testing or dumping DDL statements.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
engine_url : `sqlalchemy.engine.url.URL`
|
|
496
|
+
The SQLAlchemy engine URL.
|
|
497
|
+
output_file : `typing.IO` [ `str` ] or `None`, optional
|
|
498
|
+
The file to write the SQL statements to. If None, the statements
|
|
499
|
+
will be written to stdout.
|
|
500
|
+
"""
|
|
501
|
+
dumper = InsertDump(output_file)
|
|
502
|
+
engine = create_mock_engine(make_url(engine_url), executor=dumper.dump)
|
|
503
|
+
dumper.dialect = engine.dialect
|
|
504
|
+
return engine
|
felis/version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "26.2024.
|
|
2
|
+
__version__ = "26.2024.1400"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 26.2024.
|
|
3
|
+
Version: 26.2024.1400
|
|
4
4
|
Summary: A vocabulary for describing catalogs and acting on those descriptions
|
|
5
5
|
Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
|
|
6
6
|
License: GNU General Public License v3 or later (GPLv3+)
|
|
@@ -23,6 +23,7 @@ Requires-Dist: click >=7
|
|
|
23
23
|
Requires-Dist: pyyaml >=6
|
|
24
24
|
Requires-Dist: pyld >=2
|
|
25
25
|
Requires-Dist: pydantic <3,>=2
|
|
26
|
+
Requires-Dist: lsst-utils
|
|
26
27
|
Provides-Extra: test
|
|
27
28
|
Requires-Dist: pytest >=3.2 ; extra == 'test'
|
|
28
29
|
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
felis/__init__.py,sha256=_Pw-QKMYj0WRgE8fW2N2pBXJUj-Pjv8dSKJBzykjyZU,1842
|
|
2
2
|
felis/check.py,sha256=RBxXq7XwPGIucrs1PPgPtgk8MrWAJlOmoxCNySEz9-I,13892
|
|
3
|
-
felis/cli.py,sha256=
|
|
4
|
-
felis/datamodel.py,sha256=
|
|
3
|
+
felis/cli.py,sha256=YeGSiA3ywPVMMdB1YxH1_Gdac1kl4oPJvJtajfCs5VU,16637
|
|
4
|
+
felis/datamodel.py,sha256=ooNSg68OuNk89EVu1MtxupLUWgSyzmb50wjza1joDO4,16002
|
|
5
|
+
felis/metadata.py,sha256=5DE2YMnu6YuhwntBSe-OheCD7C2-vA4yb64BpjTC68A,18542
|
|
5
6
|
felis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
7
|
felis/simple.py,sha256=yzv_aoZrZhfakd1Xm7gLDeVKyJjCDZ7wAyYYp-l_Sxs,14414
|
|
7
|
-
felis/sql.py,sha256=Kq4Y7XxXDUTrzz8eIR5VLQFq0n899uZlcjIyorZqZ70,10293
|
|
8
8
|
felis/tap.py,sha256=RBwEKyU3S0oXSNIMoI2WRAuC9WB5eai9BdQQUYN5Qdc,17704
|
|
9
9
|
felis/types.py,sha256=1GL6IkHcIsIydiyw1eX98REh-lWCVIRO-9qjmaZfqvw,4440
|
|
10
10
|
felis/utils.py,sha256=tYxr0xFdPN4gDHibeAD9d5DFgU8hKlSZVKmZoDzi4e8,4164
|
|
11
11
|
felis/validation.py,sha256=f9VKvp7q-cnim2D5voTKwCdt0NRsYBpTwom1Z_3OKkc,3469
|
|
12
|
-
felis/version.py,sha256=
|
|
12
|
+
felis/version.py,sha256=Bkf7vQojVYVWgjqmEFJRzUA7IlOUtWz7QH7SQ12F5YA,55
|
|
13
13
|
felis/visitor.py,sha256=EazU4nYbkKBj3mCZYvsTCBTNmh0qRaUNZIzCcM3dqOQ,6439
|
|
14
14
|
felis/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
felis/db/_variants.py,sha256=aW0Q7R4KEtxLR7VMashQjDLWdzDNrMVAH521MSvMey0,3346
|
|
15
16
|
felis/db/sqltypes.py,sha256=0HOEqvL0OailGP-j6Jj5tnOSu_Pt7Hi29PPof4Q5d2c,5787
|
|
16
|
-
lsst_felis-26.2024.
|
|
17
|
-
lsst_felis-26.2024.
|
|
18
|
-
lsst_felis-26.2024.
|
|
19
|
-
lsst_felis-26.2024.
|
|
20
|
-
lsst_felis-26.2024.
|
|
21
|
-
lsst_felis-26.2024.
|
|
22
|
-
lsst_felis-26.2024.
|
|
23
|
-
lsst_felis-26.2024.
|
|
17
|
+
lsst_felis-26.2024.1400.dist-info/COPYRIGHT,sha256=bUmNy19uUxqITMpjeHFe69q3IzQpjxvvBw6oV7kR7ho,129
|
|
18
|
+
lsst_felis-26.2024.1400.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
|
|
19
|
+
lsst_felis-26.2024.1400.dist-info/METADATA,sha256=y4cVRA28EHR2jiWXFDDzSRCm16TH4YnJrept5w3fkMM,1101
|
|
20
|
+
lsst_felis-26.2024.1400.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
21
|
+
lsst_felis-26.2024.1400.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
|
|
22
|
+
lsst_felis-26.2024.1400.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
|
|
23
|
+
lsst_felis-26.2024.1400.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
24
|
+
lsst_felis-26.2024.1400.dist-info/RECORD,,
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|