lsst-felis 24.1.6rc1__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/metadata.py ADDED
@@ -0,0 +1,383 @@
1
+ """Build SQLAlchemy metadata from a Felis schema."""
2
+
3
+ # This file is part of felis.
4
+ #
5
+ # Developed for the LSST Data Management System.
6
+ # This product includes software developed by the LSST Project
7
+ # (https://www.lsst.org).
8
+ # See the COPYRIGHT file at the top-level directory of this distribution
9
+ # for details of code ownership.
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any, Literal
28
+
29
+ from lsst.utils.iteration import ensure_iterable
30
+ from sqlalchemy import (
31
+ CheckConstraint,
32
+ Column,
33
+ Constraint,
34
+ ForeignKeyConstraint,
35
+ Index,
36
+ MetaData,
37
+ PrimaryKeyConstraint,
38
+ Table,
39
+ TextClause,
40
+ UniqueConstraint,
41
+ text,
42
+ )
43
+ from sqlalchemy.dialects import mysql, postgresql
44
+ from sqlalchemy.types import TypeEngine
45
+
46
+ from felis.datamodel import Schema
47
+ from felis.db.variants import make_variant_dict
48
+
49
+ from . import datamodel
50
+ from .db import sqltypes
51
+ from .types import FelisType
52
+
53
+ __all__ = ("MetaDataBuilder", "get_datatype_with_variants")
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ def _handle_timestamp_column(column_obj: datamodel.Column, variant_dict: dict[str, TypeEngine[Any]]) -> None:
59
+ """Handle columns with the timestamp datatype.
60
+
61
+ Parameters
62
+ ----------
63
+ column_obj
64
+ The column object representing the timestamp.
65
+ variant_dict
66
+ The dictionary of variant overrides for the datatype.
67
+
68
+ Notes
69
+ -----
70
+ This function updates the variant dictionary with the appropriate
71
+ timestamp type for the column object but only if the precision is set.
72
+ Otherwise, the default timestamp objects defined in the Felis type system
73
+ will be used instead.
74
+ """
75
+ if column_obj.precision is not None:
76
+ args: Any = [False, column_obj.precision] # Turn off timezone.
77
+ variant_dict.update({"postgresql": postgresql.TIMESTAMP(*args), "mysql": mysql.DATETIME(*args)})
78
+
79
+
80
+ def get_datatype_with_variants(column_obj: datamodel.Column) -> TypeEngine:
81
+ """Use the Felis type system to get a SQLAlchemy datatype with variant
82
+ overrides from the information in a Felis column object.
83
+
84
+ Parameters
85
+ ----------
86
+ column_obj
87
+ The column object from which to get the datatype.
88
+
89
+ Returns
90
+ -------
91
+ `~sqlalchemy.types.TypeEngine`
92
+ The SQLAlchemy datatype object.
93
+
94
+ Raises
95
+ ------
96
+ ValueError
97
+ Raised if the column has a sized type but no length or if the datatype
98
+ is invalid.
99
+ """
100
+ variant_dict = make_variant_dict(column_obj)
101
+ felis_type = FelisType.felis_type(column_obj.datatype.value)
102
+ datatype_fun = getattr(sqltypes, column_obj.datatype.value, None)
103
+ if datatype_fun is None:
104
+ raise ValueError(f"Unknown datatype: {column_obj.datatype.value}")
105
+ args = []
106
+ if felis_type.is_sized:
107
+ # Add length argument for size types.
108
+ if not column_obj.length:
109
+ raise ValueError(f"Column {column_obj.name} has sized type '{column_obj.datatype}' but no length")
110
+ args = [column_obj.length]
111
+ if felis_type.is_timestamp:
112
+ _handle_timestamp_column(column_obj, variant_dict)
113
+ return datatype_fun(*args, **variant_dict)
114
+
115
+
116
+ _VALID_SERVER_DEFAULTS = ("CURRENT_TIMESTAMP", "NOW()", "LOCALTIMESTAMP", "NULL")
117
+
118
+
119
+ class MetaDataBuilder:
120
+ """Build a SQLAlchemy metadata object from a Felis schema.
121
+
122
+ Parameters
123
+ ----------
124
+ schema
125
+ The schema object from which to build the SQLAlchemy metadata.
126
+ apply_schema_to_metadata
127
+ Whether to apply the schema name to the metadata object.
128
+ apply_schema_to_tables
129
+ Whether to apply the schema name to the tables.
130
+ ignore_constraints
131
+ Whether to ignore constraints when building the metadata.
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ schema: Schema,
137
+ apply_schema_to_metadata: bool = True,
138
+ apply_schema_to_tables: bool = True,
139
+ ignore_constraints: bool = False,
140
+ ) -> None:
141
+ """Initialize the metadata builder."""
142
+ self.schema = schema
143
+ if not apply_schema_to_metadata:
144
+ logger.debug("Schema name will not be applied to metadata")
145
+ if not apply_schema_to_tables:
146
+ logger.debug("Schema name will not be applied to tables")
147
+ self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
148
+ self._objects: dict[str, Any] = {}
149
+ self.apply_schema_to_tables = apply_schema_to_tables
150
+ self.ignore_constraints = ignore_constraints
151
+
152
+ def build(self) -> MetaData:
153
+ """Build the SQLAlchemy tables and constraints from the schema.
154
+
155
+ Notes
156
+ -----
157
+ This first builds the tables and then makes a second pass to build the
158
+ constraints. This is necessary because the constraints may reference
159
+ objects that are not yet created when the tables are built.
160
+
161
+ Returns
162
+ -------
163
+ `~sqlalchemy.sql.schema.MetaData`
164
+ The SQLAlchemy metadata object.
165
+ """
166
+ self.build_tables()
167
+ if not self.ignore_constraints:
168
+ self.build_constraints()
169
+ else:
170
+ logger.warning("Ignoring constraints")
171
+ return self.metadata
172
+
173
+ def build_tables(self) -> None:
174
+ """Build the SQLAlchemy tables from the schema."""
175
+ for table in self.schema.tables:
176
+ self.build_table(table)
177
+ if table.primary_key:
178
+ primary_key = self.build_primary_key(table.primary_key)
179
+ self._objects[table.id].append_constraint(primary_key)
180
+
181
+ def build_primary_key(self, primary_key_columns: str | list[str]) -> PrimaryKeyConstraint:
182
+ """Build a SQAlchemy ``PrimaryKeyConstraint`` from a single column ID
183
+ or a list of them.
184
+
185
+ Parameters
186
+ ----------
187
+ primary_key_columns
188
+ The column ID or list of column IDs from which to build the primary
189
+ key.
190
+
191
+ Returns
192
+ -------
193
+ `~sqlalchemy.sql.schema.PrimaryKeyConstraint`
194
+ The SQLAlchemy primary key constraint object.
195
+
196
+ Notes
197
+ -----
198
+ The ``primary_key_columns`` is a string or a list of strings
199
+ representing IDs which will be used to find the columnn objects in the
200
+ builder's internal ID map.
201
+ """
202
+ return PrimaryKeyConstraint(
203
+ *[self._objects[column_id] for column_id in ensure_iterable(primary_key_columns)]
204
+ )
205
+
206
+ def build_table(self, table_obj: datamodel.Table) -> None:
207
+ """Build a SQLAlchemy ``Table`` from a Felis table and add it to the
208
+ metadata.
209
+
210
+ Parameters
211
+ ----------
212
+ table_obj
213
+ The Felis table object from which to build the SQLAlchemy table.
214
+
215
+ Notes
216
+ -----
217
+ Several MySQL table options, including the engine and charset, are
218
+ handled by adding annotations to the table. This is not needed for
219
+ Postgres, as Felis does not support any table options for this dialect.
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 column object.
252
+
253
+ Parameters
254
+ ----------
255
+ column_obj
256
+ The column object from which to build the SQLAlchemy column.
257
+
258
+ Returns
259
+ -------
260
+ `~sqlalchemy.sql.schema.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
+ value = column_obj.value
268
+ nullable = column_obj.nullable
269
+
270
+ # Get datatype, handling variant overrides such as "mysql:datatype".
271
+ datatype = get_datatype_with_variants(column_obj)
272
+
273
+ # Set autoincrement, depending on if it was provided explicitly.
274
+ autoincrement: Literal["auto"] | bool = (
275
+ column_obj.autoincrement if column_obj.autoincrement is not None else "auto"
276
+ )
277
+
278
+ server_default: str | TextClause | None = None
279
+ if value is not None:
280
+ server_default = str(value)
281
+ if server_default in _VALID_SERVER_DEFAULTS or not isinstance(value, str):
282
+ # If the server default is a valid keyword or not a string,
283
+ # use it as is.
284
+ server_default = text(server_default)
285
+
286
+ if server_default is not None:
287
+ logger.debug(f"Column '{id}' has default value: {server_default}")
288
+
289
+ column: Column = Column(
290
+ name,
291
+ datatype,
292
+ comment=description,
293
+ autoincrement=autoincrement,
294
+ nullable=nullable,
295
+ server_default=server_default,
296
+ )
297
+
298
+ self._objects[id] = column
299
+
300
+ return column
301
+
302
+ def build_constraints(self) -> None:
303
+ """Build the SQLAlchemy constraints from the Felis schema and append
304
+ them to the associated table in the metadata.
305
+
306
+ Notes
307
+ -----
308
+ This is performed as a separate step after building the tables so that
309
+ all the referenced objects in the constraints will be present and can
310
+ be looked up by their ID.
311
+ """
312
+ for table_obj in self.schema.tables:
313
+ table = self._objects[table_obj.id]
314
+ for constraint_obj in table_obj.constraints:
315
+ constraint = self.build_constraint(constraint_obj)
316
+ table.append_constraint(constraint)
317
+
318
+ def build_constraint(self, constraint_obj: datamodel.Constraint) -> Constraint:
319
+ """Build a SQLAlchemy ``Constraint`` from a Felis constraint.
320
+
321
+ Parameters
322
+ ----------
323
+ constraint_obj
324
+ The Felis object from which to build the constraint.
325
+
326
+ Returns
327
+ -------
328
+ `~sqlalchemy.sql.schema.Constraint`
329
+ The SQLAlchemy constraint object.
330
+
331
+ Raises
332
+ ------
333
+ ValueError
334
+ If the constraint type is not recognized.
335
+ TypeError
336
+ If the constraint object is not the expected type.
337
+ """
338
+ args: dict[str, Any] = {
339
+ "name": constraint_obj.name or None,
340
+ "comment": constraint_obj.description or None,
341
+ "deferrable": constraint_obj.deferrable or None,
342
+ "initially": constraint_obj.initially or None,
343
+ }
344
+ constraint: Constraint
345
+
346
+ if isinstance(constraint_obj, datamodel.ForeignKeyConstraint):
347
+ fk_obj: datamodel.ForeignKeyConstraint = constraint_obj
348
+ columns = [self._objects[column_id] for column_id in fk_obj.columns]
349
+ refcolumns = [self._objects[column_id] for column_id in fk_obj.referenced_columns]
350
+ constraint = ForeignKeyConstraint(columns, refcolumns, **args)
351
+ elif isinstance(constraint_obj, datamodel.CheckConstraint):
352
+ check_obj: datamodel.CheckConstraint = constraint_obj
353
+ expression = check_obj.expression
354
+ constraint = CheckConstraint(expression, **args)
355
+ elif isinstance(constraint_obj, datamodel.UniqueConstraint):
356
+ uniq_obj: datamodel.UniqueConstraint = constraint_obj
357
+ columns = [self._objects[column_id] for column_id in uniq_obj.columns]
358
+ constraint = UniqueConstraint(*columns, **args)
359
+ else:
360
+ raise ValueError(f"Unknown constraint type: {type(constraint_obj)}")
361
+
362
+ self._objects[constraint_obj.id] = constraint
363
+
364
+ return constraint
365
+
366
+ def build_index(self, index_obj: datamodel.Index) -> Index:
367
+ """Build a SQLAlchemy ``Index`` from a Felis `~felis.datamodel.Index`.
368
+
369
+ Parameters
370
+ ----------
371
+ index_obj
372
+ The Felis object from which to build the SQLAlchemy index.
373
+
374
+ Returns
375
+ -------
376
+ `~sqlalchemy.sql.schema.Index`
377
+ The SQLAlchemy index object.
378
+ """
379
+ columns = [self._objects[c_id] for c_id in (index_obj.columns if index_obj.columns else [])]
380
+ expressions = index_obj.expressions if index_obj.expressions else []
381
+ index = Index(index_obj.name, *columns, *expressions)
382
+ self._objects[index_obj.id] = index
383
+ return index
felis/py.typed ADDED
File without changes
@@ -0,0 +1,273 @@
1
+ name: TAP_SCHEMA
2
+ version: "1.1"
3
+ description: A TAP-standard-mandated schema to describe tablesets in a TAP 1.1 service
4
+ tables:
5
+ - name: "schemas"
6
+ description: description of schemas in this tableset
7
+ primaryKey: "#schemas.schema_name"
8
+ tap:table_index: 100000
9
+ mysql:engine: "InnoDB"
10
+ columns:
11
+ - name: "schema_name"
12
+ datatype: "string"
13
+ description: schema name for reference to tap_schema.schemas
14
+ length: 64
15
+ nullable: false
16
+ tap:principal: 1
17
+ tap:std: 1
18
+ tap:column_index: 1
19
+ - name: "utype"
20
+ datatype: "string"
21
+ description: lists the utypes of schemas in the tableset
22
+ length: 512
23
+ tap:principal: 1
24
+ tap:std: 1
25
+ tap:column_index: 2
26
+ - name: "description"
27
+ datatype: "string"
28
+ description: describes schemas in the tableset
29
+ length: 512
30
+ tap:principal: 1
31
+ tap:std: 1
32
+ tap:column_index: 3
33
+ - name: "schema_index"
34
+ datatype: "int"
35
+ description: recommended sort order when listing schemas
36
+ tap:principal: 1
37
+ tap:std: 1
38
+ tap:column_index: 4
39
+ - name: "tables"
40
+ description: description of tables in this tableset
41
+ primaryKey: "#tables.table_name"
42
+ tap:table_index: 101000
43
+ mysql:engine: "InnoDB"
44
+ columns:
45
+ - name: schema_name
46
+ datatype: string
47
+ description: the schema this table belongs to
48
+ length: 64
49
+ nullable: false
50
+ tap:principal: 1
51
+ tap:std: 1
52
+ tap:column_index: 1
53
+ - name: table_name
54
+ datatype: string
55
+ description: the fully qualified table name
56
+ length: 128
57
+ nullable: false
58
+ tap:principal: 1
59
+ tap:std: 1
60
+ tap:column_index: 2
61
+ - name: table_type
62
+ datatype: string
63
+ description: "one of: table view"
64
+ length: 8
65
+ nullable: false
66
+ tap:principal: 1
67
+ tap:std: 1
68
+ tap:column_index: 3
69
+ - name: utype
70
+ datatype: string
71
+ description: lists the utype of tables in the tableset
72
+ length: 512
73
+ tap:principal: 1
74
+ tap:std: 1
75
+ tap:column_index: 4
76
+ - name: description
77
+ datatype: string
78
+ description: describes tables in the tableset
79
+ length: 512
80
+ tap:principal: 1
81
+ tap:std: 1
82
+ tap:column_index: 5
83
+ - name: table_index
84
+ datatype: int
85
+ description: recommended sort order when listing tables
86
+ tap:principal: 1
87
+ tap:std: 1
88
+ tap:column_index: 6
89
+ constraints:
90
+ - name: "k1"
91
+ "@type": ForeignKey
92
+ columns: ["#tables.schema_name"]
93
+ referencedColumns: ["#schemas.schema_name"]
94
+ - name: "columns"
95
+ description: description of columns in this tableset
96
+ primaryKey: ["#columns.table_name", "#columns.column_name"]
97
+ tap_table_index: 102000
98
+ mysql:engine: "InnoDB"
99
+ columns:
100
+ - name: table_name
101
+ datatype: string
102
+ description: the table this column belongs to
103
+ length: 128
104
+ nullable: false
105
+ tap:principal: 1
106
+ tap:std: 1
107
+ tap:column_index: 1
108
+ - name: column_name
109
+ datatype: string
110
+ description: the column name
111
+ length: 64
112
+ nullable: false
113
+ tap:principal: 1
114
+ tap:std: 1
115
+ tap:column_index: 2
116
+ - name: utype
117
+ datatype: string
118
+ description: lists the utypes of columns in the tableset
119
+ length: 512
120
+ tap:principal: 1
121
+ tap:std: 1
122
+ tap:column_index: 3
123
+ - name: ucd
124
+ datatype: string
125
+ description: lists the UCDs of columns in the tableset
126
+ length: 64
127
+ tap:principal: 1
128
+ tap:std: 1
129
+ tap:column_index: 4
130
+ - name: unit
131
+ datatype: string
132
+ description: lists the unit used for column values in the tableset
133
+ length: 64
134
+ tap:principal: 1
135
+ tap:std: 1
136
+ tap:column_index: 5
137
+ - name: description
138
+ datatype: string
139
+ description: describes the columns in the tableset
140
+ length: 512
141
+ tap:principal: 1
142
+ tap:std: 1
143
+ tap:column_index: 6
144
+ - name: datatype
145
+ datatype: string
146
+ description: lists the ADQL datatype of columns in the tableset
147
+ length: 64
148
+ nullable: false
149
+ tap:principal: 1
150
+ tap:std: 1
151
+ tap:column_index: 7
152
+ - name: arraysize
153
+ datatype: string
154
+ description: lists the size of variable-length columns in the tableset
155
+ length: 16
156
+ tap:principal: 1
157
+ tap:std: 1
158
+ tap:column_index: 8
159
+ - name: xtype
160
+ datatype: string
161
+ description: a DALI or custom extended type annotation
162
+ length: 64
163
+ tap:principal: 1
164
+ tap:std: 1
165
+ tap:column_index: 9
166
+ - name: size
167
+ datatype: int
168
+ description: "deprecated: use arraysize"
169
+ tap:principal: 1
170
+ tap:std: 1
171
+ tap:column_index: 10
172
+ - name: principal
173
+ datatype: int
174
+ description: a principal column; 1 means 1, 0 means 0
175
+ nullable: false
176
+ tap:principal: 1
177
+ tap:std: 1
178
+ tap:column_index: 11
179
+ - name: indexed
180
+ datatype: int
181
+ description: an indexed column; 1 means 1, 0 means 0
182
+ nullable: false
183
+ tap:principal: 1
184
+ tap:std: 1
185
+ tap:column_index: 12
186
+ - name: std
187
+ datatype: int
188
+ description: a standard column; 1 means 1, 0 means 0
189
+ nullable: false
190
+ tap:principal: 1
191
+ tap:std: 1
192
+ tap:column_index: 13
193
+ - name: column_index
194
+ datatype: int
195
+ description: recommended sort order when listing columns
196
+ tap:principal: 1
197
+ tap:std: 1
198
+ tap:column_index: 14
199
+ constraints:
200
+ - name: "k2"
201
+ "@type": ForeignKey
202
+ columns: ["#columns.table_name"]
203
+ referencedColumns: ["#tables.table_name"]
204
+ - name: "keys"
205
+ description: description of foreign keys in this tableset
206
+ primaryKey: "#keys.key_id"
207
+ tap:table_index: 103000
208
+ mysql:engine: "InnoDB"
209
+ columns:
210
+ - name: key_id
211
+ datatype: string
212
+ description: unique key to join to tap_schema.key_columns
213
+ length: 64
214
+ nullable: false
215
+ - name: from_table
216
+ datatype: string
217
+ description: the table with the foreign key
218
+ length: 128
219
+ nullable: false
220
+ - name: target_table
221
+ datatype: string
222
+ description: the table with the primary key
223
+ length: 128
224
+ nullable: false
225
+ - name: utype
226
+ datatype: string
227
+ description: lists the utype of keys in the tableset
228
+ length: 512
229
+ - name: description
230
+ datatype: string
231
+ description: describes keys in the tableset
232
+ length: 512
233
+ constraints:
234
+ - name: "k3"
235
+ "@type": ForeignKey
236
+ columns: ["#keys.from_table"]
237
+ referencedColumns: ["#tables.table_name"]
238
+ - name: "k4"
239
+ "@type": ForeignKey
240
+ columns: ["#keys.target_table"]
241
+ referencedColumns: ["#tables.table_name"]
242
+ - name: "key_columns"
243
+ description: description of foreign key columns in this tableset
244
+ tap:table_index: 104000
245
+ mysql:engine: "InnoDB"
246
+ columns:
247
+ - name: key_id
248
+ datatype: string
249
+ length: 64
250
+ nullable: false
251
+ - name: from_column
252
+ datatype: string
253
+ length: 64
254
+ nullable: false
255
+ - name: target_column
256
+ datatype: string
257
+ length: 64
258
+ nullable: false
259
+ constraints:
260
+ - name: "k5"
261
+ "@type": ForeignKey
262
+ columns: ["#key_columns.key_id"]
263
+ referencedColumns: ["#keys.key_id"]
264
+ # FIXME: These can't be defined as FK constraints, because they refer
265
+ # to non-unique columns, e.g., column_name from the columns table.
266
+ # - name: "k6"
267
+ # "@type": ForeignKey
268
+ # columns: ["#key_columns.from_column"]
269
+ # referencedColumns: ["#columns.column_name"]
270
+ # - name: "k7"
271
+ # "@type": ForeignKey
272
+ # columns: ["#key_columns.target_column"]
273
+ # referencedColumns: ["#columns.column_name"]