lsst-felis 26.2024.1100__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 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 .sql import SQLVisitor
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-all")
75
- @click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL")
76
- @click.option("--schema-name", help="Alternate Schema Name for Felis File")
77
- @click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
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 create_all(engine_url: str, schema_name: str, dry_run: bool, file: io.TextIOBase) -> None:
80
- """Create schema objects from the Felis FILE."""
81
- schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
82
- visitor = SQLVisitor(schema_name=schema_name)
83
- schema = visitor.visit_schema(schema_obj)
84
-
85
- metadata = schema.metadata
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
- _insert_dump = InsertDump()
92
- engine = create_mock_engine(make_url(engine_url), executor=_insert_dump.dump)
93
- _insert_dump.dialect = engine.dialect
94
- metadata.create_all(engine)
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 = True
134
- """Whether the column can be `NULL`."""
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
- primaryKey: str | list[str] | None = None
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.1100"
2
+ __version__ = "26.2024.1400"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lsst-felis
3
- Version: 26.2024.1100
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=7lxVM46uaHdBhlNmIGz9PKleI0jrx6VswVQdAv2v-mM,15785
4
- felis/datamodel.py,sha256=6i_BtmU1c5RaIZxeA-XbnHkikJb-fnxSWrsX0zdVwwU,15648
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=xrLeMJU99wLKp0EMd5_cq5lPeP_4FfIT_M5Mei_Lydc,55
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.1100.dist-info/COPYRIGHT,sha256=bUmNy19uUxqITMpjeHFe69q3IzQpjxvvBw6oV7kR7ho,129
17
- lsst_felis-26.2024.1100.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
18
- lsst_felis-26.2024.1100.dist-info/METADATA,sha256=T6zHQB8sB6wuj48oVXRsJBn4LBgU-cKoj7sDb7eZPI4,1075
19
- lsst_felis-26.2024.1100.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
20
- lsst_felis-26.2024.1100.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
21
- lsst_felis-26.2024.1100.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
22
- lsst_felis-26.2024.1100.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
23
- lsst_felis-26.2024.1100.dist-info/RECORD,,
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)