lsst-felis 28.2025.402__py3-none-any.whl → 28.2025.600__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 +35 -167
- felis/datamodel.py +131 -6
- felis/metadata.py +5 -1
- felis/tap_schema.py +13 -5
- felis/version.py +1 -1
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/METADATA +1 -1
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/RECORD +13 -14
- felis/tap.py +0 -597
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/COPYRIGHT +0 -0
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/LICENSE +0 -0
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/WHEEL +0 -0
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/entry_points.txt +0 -0
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/top_level.txt +0 -0
- {lsst_felis-28.2025.402.dist-info → lsst_felis-28.2025.600.dist-info}/zip-safe +0 -0
felis/cli.py
CHANGED
|
@@ -38,7 +38,6 @@ from .db.schema import create_database
|
|
|
38
38
|
from .db.utils import DatabaseContext, is_mock_url
|
|
39
39
|
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
40
40
|
from .metadata import MetaDataBuilder
|
|
41
|
-
from .tap import Tap11Base, TapLoadingVisitor, init_tables
|
|
42
41
|
from .tap_schema import DataLoader, TableManager
|
|
43
42
|
|
|
44
43
|
__all__ = ["cli"]
|
|
@@ -179,174 +178,9 @@ def create(
|
|
|
179
178
|
raise click.ClickException(str(e))
|
|
180
179
|
|
|
181
180
|
|
|
182
|
-
@cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
|
|
183
|
-
@click.option("--tap-schema-name", help="Alternate database schema name for 'TAP_SCHEMA'")
|
|
184
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
185
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
186
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
187
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
188
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
189
|
-
@click.argument("engine-url")
|
|
190
|
-
def init_tap(
|
|
191
|
-
engine_url: str,
|
|
192
|
-
tap_schema_name: str,
|
|
193
|
-
tap_schemas_table: str,
|
|
194
|
-
tap_tables_table: str,
|
|
195
|
-
tap_columns_table: str,
|
|
196
|
-
tap_keys_table: str,
|
|
197
|
-
tap_key_columns_table: str,
|
|
198
|
-
) -> None:
|
|
199
|
-
"""Initialize TAP_SCHEMA objects in the database.
|
|
200
|
-
|
|
201
|
-
Parameters
|
|
202
|
-
----------
|
|
203
|
-
engine_url
|
|
204
|
-
SQLAlchemy Engine URL. The target PostgreSQL schema or MySQL database
|
|
205
|
-
must already exist and be referenced in the URL.
|
|
206
|
-
tap_schema_name
|
|
207
|
-
Alterate name for the database schema ``TAP_SCHEMA``.
|
|
208
|
-
tap_schemas_table
|
|
209
|
-
Alterate table name for ``schemas``.
|
|
210
|
-
tap_tables_table
|
|
211
|
-
Alterate table name for ``tables``.
|
|
212
|
-
tap_columns_table
|
|
213
|
-
Alterate table name for ``columns``.
|
|
214
|
-
tap_keys_table
|
|
215
|
-
Alterate table name for ``keys``.
|
|
216
|
-
tap_key_columns_table
|
|
217
|
-
Alterate table name for ``key_columns``.
|
|
218
|
-
|
|
219
|
-
Notes
|
|
220
|
-
-----
|
|
221
|
-
The supported version of TAP_SCHEMA in the SQLAlchemy metadata is 1.1. The
|
|
222
|
-
tables are created in the database schema specified by the engine URL,
|
|
223
|
-
which must be a PostgreSQL schema or MySQL database that already exists.
|
|
224
|
-
"""
|
|
225
|
-
engine = create_engine(engine_url)
|
|
226
|
-
init_tables(
|
|
227
|
-
tap_schema_name,
|
|
228
|
-
tap_schemas_table,
|
|
229
|
-
tap_tables_table,
|
|
230
|
-
tap_columns_table,
|
|
231
|
-
tap_keys_table,
|
|
232
|
-
tap_key_columns_table,
|
|
233
|
-
)
|
|
234
|
-
Tap11Base.metadata.create_all(engine)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
@cli.command("load-tap", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
238
|
-
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
239
|
-
@click.option("--schema-name", help="Alternate Schema Name for Felis file")
|
|
240
|
-
@click.option("--catalog-name", help="Catalog Name for Schema")
|
|
241
|
-
@click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
|
|
242
|
-
@click.option("--tap-schema-name", help="Alternate schema name for 'TAP_SCHEMA'")
|
|
243
|
-
@click.option("--tap-tables-postfix", help="Postfix for TAP_SCHEMA table names")
|
|
244
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
245
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
246
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
247
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
248
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
249
|
-
@click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
|
|
250
|
-
@click.argument("file", type=click.File())
|
|
251
|
-
def load_tap(
|
|
252
|
-
engine_url: str,
|
|
253
|
-
schema_name: str,
|
|
254
|
-
catalog_name: str,
|
|
255
|
-
dry_run: bool,
|
|
256
|
-
tap_schema_name: str,
|
|
257
|
-
tap_tables_postfix: str,
|
|
258
|
-
tap_schemas_table: str,
|
|
259
|
-
tap_tables_table: str,
|
|
260
|
-
tap_columns_table: str,
|
|
261
|
-
tap_keys_table: str,
|
|
262
|
-
tap_key_columns_table: str,
|
|
263
|
-
tap_schema_index: int,
|
|
264
|
-
file: IO[str],
|
|
265
|
-
) -> None:
|
|
266
|
-
"""Load TAP metadata from a Felis file.
|
|
267
|
-
|
|
268
|
-
This command loads the associated TAP metadata from a Felis YAML file
|
|
269
|
-
into the TAP_SCHEMA tables.
|
|
270
|
-
|
|
271
|
-
Parameters
|
|
272
|
-
----------
|
|
273
|
-
engine_url
|
|
274
|
-
SQLAlchemy Engine URL to catalog.
|
|
275
|
-
schema_name
|
|
276
|
-
Alternate schema name. This overrides the schema name in the
|
|
277
|
-
``catalog`` field of the Felis file.
|
|
278
|
-
catalog_name
|
|
279
|
-
Catalog name for the schema. This possibly duplicates the
|
|
280
|
-
``tap_schema_name`` argument (DM-44870).
|
|
281
|
-
dry_run
|
|
282
|
-
Dry run only to print out commands instead of executing.
|
|
283
|
-
tap_schema_name
|
|
284
|
-
Alternate name for the schema of TAP_SCHEMA in the database.
|
|
285
|
-
tap_tables_postfix
|
|
286
|
-
Postfix for TAP table names that will be automatically appended.
|
|
287
|
-
tap_schemas_table
|
|
288
|
-
Alternate table name for ``schemas``.
|
|
289
|
-
tap_tables_table
|
|
290
|
-
Alternate table name for ``tables``.
|
|
291
|
-
tap_columns_table
|
|
292
|
-
Alternate table name for ``columns``.
|
|
293
|
-
tap_keys_table
|
|
294
|
-
Alternate table name for ``keys``.
|
|
295
|
-
tap_key_columns_table
|
|
296
|
-
Alternate table name for ``key_columns``.
|
|
297
|
-
tap_schema_index
|
|
298
|
-
TAP_SCHEMA index of the schema in this TAP environment.
|
|
299
|
-
file
|
|
300
|
-
Felis file to read.
|
|
301
|
-
|
|
302
|
-
Notes
|
|
303
|
-
-----
|
|
304
|
-
The data will be loaded into the TAP_SCHEMA from the engine URL. The
|
|
305
|
-
tables must have already been initialized or an error will occur.
|
|
306
|
-
"""
|
|
307
|
-
schema = Schema.from_stream(file)
|
|
308
|
-
|
|
309
|
-
tap_tables = init_tables(
|
|
310
|
-
tap_schema_name,
|
|
311
|
-
tap_tables_postfix,
|
|
312
|
-
tap_schemas_table,
|
|
313
|
-
tap_tables_table,
|
|
314
|
-
tap_columns_table,
|
|
315
|
-
tap_keys_table,
|
|
316
|
-
tap_key_columns_table,
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
if not dry_run:
|
|
320
|
-
engine = create_engine(engine_url)
|
|
321
|
-
|
|
322
|
-
if engine_url == "sqlite://" and not dry_run:
|
|
323
|
-
# In Memory SQLite - Mostly used to test
|
|
324
|
-
Tap11Base.metadata.create_all(engine)
|
|
325
|
-
|
|
326
|
-
tap_visitor = TapLoadingVisitor(
|
|
327
|
-
engine,
|
|
328
|
-
catalog_name=catalog_name,
|
|
329
|
-
schema_name=schema_name,
|
|
330
|
-
tap_tables=tap_tables,
|
|
331
|
-
tap_schema_index=tap_schema_index,
|
|
332
|
-
)
|
|
333
|
-
tap_visitor.visit_schema(schema)
|
|
334
|
-
else:
|
|
335
|
-
conn = DatabaseContext.create_mock_engine(engine_url)
|
|
336
|
-
|
|
337
|
-
tap_visitor = TapLoadingVisitor.from_mock_connection(
|
|
338
|
-
conn,
|
|
339
|
-
catalog_name=catalog_name,
|
|
340
|
-
schema_name=schema_name,
|
|
341
|
-
tap_tables=tap_tables,
|
|
342
|
-
tap_schema_index=tap_schema_index,
|
|
343
|
-
)
|
|
344
|
-
tap_visitor.visit_schema(schema)
|
|
345
|
-
|
|
346
|
-
|
|
347
181
|
@cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
348
182
|
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
349
|
-
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
|
|
183
|
+
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)")
|
|
350
184
|
@click.option(
|
|
351
185
|
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
352
186
|
)
|
|
@@ -417,6 +251,40 @@ def load_tap_schema(
|
|
|
417
251
|
).load()
|
|
418
252
|
|
|
419
253
|
|
|
254
|
+
@cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
|
|
255
|
+
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
256
|
+
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
|
|
257
|
+
@click.option(
|
|
258
|
+
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
259
|
+
)
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def init_tap_schema(
|
|
262
|
+
ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Initialize a standard TAP_SCHEMA database.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
engine_url
|
|
269
|
+
SQLAlchemy Engine URL.
|
|
270
|
+
tap_schema_name
|
|
271
|
+
Name of the TAP_SCHEMA schema in the database.
|
|
272
|
+
tap_tables_postfix
|
|
273
|
+
Postfix which is applied to standard TAP_SCHEMA table names.
|
|
274
|
+
"""
|
|
275
|
+
url = make_url(engine_url)
|
|
276
|
+
engine: Engine | MockConnection
|
|
277
|
+
if is_mock_url(url):
|
|
278
|
+
raise click.ClickException("Mock engine URL is not supported for this command")
|
|
279
|
+
engine = create_engine(engine_url)
|
|
280
|
+
mgr = TableManager(
|
|
281
|
+
apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
|
|
282
|
+
schema_name=tap_schema_name,
|
|
283
|
+
table_name_postfix=tap_tables_postfix,
|
|
284
|
+
)
|
|
285
|
+
mgr.initialize_database(engine)
|
|
286
|
+
|
|
287
|
+
|
|
420
288
|
@cli.command("validate", help="Validate one or more Felis YAML files")
|
|
421
289
|
@click.option(
|
|
422
290
|
"--check-description", is_flag=True, help="Check that all objects have a description", default=False
|
felis/datamodel.py
CHANGED
|
@@ -134,6 +134,32 @@ class DataType(StrEnum):
|
|
|
134
134
|
timestamp = auto()
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def validate_ivoa_ucd(ivoa_ucd: str) -> str:
|
|
138
|
+
"""Validate IVOA UCD values.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
ivoa_ucd
|
|
143
|
+
IVOA UCD value to check.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
`str`
|
|
148
|
+
The IVOA UCD value if it is valid.
|
|
149
|
+
|
|
150
|
+
Raises
|
|
151
|
+
------
|
|
152
|
+
ValueError
|
|
153
|
+
If the IVOA UCD value is invalid.
|
|
154
|
+
"""
|
|
155
|
+
if ivoa_ucd is not None:
|
|
156
|
+
try:
|
|
157
|
+
ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
|
|
158
|
+
except ValueError as e:
|
|
159
|
+
raise ValueError(f"Invalid IVOA UCD: {e}")
|
|
160
|
+
return ivoa_ucd
|
|
161
|
+
|
|
162
|
+
|
|
137
163
|
class Column(BaseObject):
|
|
138
164
|
"""Column model."""
|
|
139
165
|
|
|
@@ -235,12 +261,7 @@ class Column(BaseObject):
|
|
|
235
261
|
`str`
|
|
236
262
|
The IVOA UCD value if it is valid.
|
|
237
263
|
"""
|
|
238
|
-
|
|
239
|
-
try:
|
|
240
|
-
ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
|
|
241
|
-
except ValueError as e:
|
|
242
|
-
raise ValueError(f"Invalid IVOA UCD: {e}")
|
|
243
|
-
return ivoa_ucd
|
|
264
|
+
return validate_ivoa_ucd(ivoa_ucd)
|
|
244
265
|
|
|
245
266
|
@model_validator(mode="after")
|
|
246
267
|
def check_units(self) -> Column:
|
|
@@ -551,6 +572,70 @@ _ConstraintType = Annotated[
|
|
|
551
572
|
"""Type alias for a constraint type."""
|
|
552
573
|
|
|
553
574
|
|
|
575
|
+
ColumnRef: TypeAlias = str
|
|
576
|
+
"""Type alias for a column reference."""
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class ColumnGroup(BaseObject):
|
|
580
|
+
"""Column group model."""
|
|
581
|
+
|
|
582
|
+
columns: list[ColumnRef | Column] = Field(..., min_length=1)
|
|
583
|
+
"""Columns in the group."""
|
|
584
|
+
|
|
585
|
+
ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
|
|
586
|
+
"""IVOA UCD of the column."""
|
|
587
|
+
|
|
588
|
+
table: Table | None = None
|
|
589
|
+
"""Reference to the parent table."""
|
|
590
|
+
|
|
591
|
+
@field_validator("ivoa_ucd")
|
|
592
|
+
@classmethod
|
|
593
|
+
def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
|
|
594
|
+
"""Check that IVOA UCD values are valid.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
ivoa_ucd
|
|
599
|
+
IVOA UCD value to check.
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
`str`
|
|
604
|
+
The IVOA UCD value if it is valid.
|
|
605
|
+
"""
|
|
606
|
+
return validate_ivoa_ucd(ivoa_ucd)
|
|
607
|
+
|
|
608
|
+
@model_validator(mode="after")
|
|
609
|
+
def check_unique_columns(self) -> ColumnGroup:
|
|
610
|
+
"""Check that the columns list contains unique items.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
`ColumnGroup`
|
|
615
|
+
The column group being validated.
|
|
616
|
+
"""
|
|
617
|
+
column_ids = [col if isinstance(col, str) else col.id for col in self.columns]
|
|
618
|
+
if len(column_ids) != len(set(column_ids)):
|
|
619
|
+
raise ValueError("Columns in the group must be unique")
|
|
620
|
+
return self
|
|
621
|
+
|
|
622
|
+
def _dereference_columns(self) -> None:
|
|
623
|
+
"""Dereference ColumnRef to Column objects."""
|
|
624
|
+
if self.table is None:
|
|
625
|
+
raise ValueError("ColumnGroup must have a reference to its parent table")
|
|
626
|
+
|
|
627
|
+
dereferenced_columns: list[ColumnRef | Column] = []
|
|
628
|
+
for col in self.columns:
|
|
629
|
+
if isinstance(col, str):
|
|
630
|
+
# Dereference ColumnRef to Column object
|
|
631
|
+
col_obj = self.table._find_column_by_id(col)
|
|
632
|
+
dereferenced_columns.append(col_obj)
|
|
633
|
+
else:
|
|
634
|
+
dereferenced_columns.append(col)
|
|
635
|
+
|
|
636
|
+
self.columns = dereferenced_columns
|
|
637
|
+
|
|
638
|
+
|
|
554
639
|
class Table(BaseObject):
|
|
555
640
|
"""Table model."""
|
|
556
641
|
|
|
@@ -563,6 +648,9 @@ class Table(BaseObject):
|
|
|
563
648
|
indexes: list[Index] = Field(default_factory=list)
|
|
564
649
|
"""Indexes on the table."""
|
|
565
650
|
|
|
651
|
+
column_groups: list[ColumnGroup] = Field(default_factory=list, alias="columnGroups")
|
|
652
|
+
"""Column groups in the table."""
|
|
653
|
+
|
|
566
654
|
primary_key: str | list[str] | None = Field(None, alias="primaryKey")
|
|
567
655
|
"""Primary key of the table."""
|
|
568
656
|
|
|
@@ -653,6 +741,43 @@ class Table(BaseObject):
|
|
|
653
741
|
return self
|
|
654
742
|
raise ValueError(f"Table '{self.name}' is missing at least one column designated as 'tap:principal'")
|
|
655
743
|
|
|
744
|
+
def _find_column_by_id(self, id: str) -> Column:
|
|
745
|
+
"""Find a column by ID.
|
|
746
|
+
|
|
747
|
+
Parameters
|
|
748
|
+
----------
|
|
749
|
+
id
|
|
750
|
+
The ID of the column to find.
|
|
751
|
+
|
|
752
|
+
Returns
|
|
753
|
+
-------
|
|
754
|
+
`Column`
|
|
755
|
+
The column with the given ID.
|
|
756
|
+
|
|
757
|
+
Raises
|
|
758
|
+
------
|
|
759
|
+
ValueError
|
|
760
|
+
Raised if the column is not found.
|
|
761
|
+
"""
|
|
762
|
+
for column in self.columns:
|
|
763
|
+
if column.id == id:
|
|
764
|
+
return column
|
|
765
|
+
raise ValueError(f"Column '{id}' not found in table '{self.name}'")
|
|
766
|
+
|
|
767
|
+
@model_validator(mode="after")
|
|
768
|
+
def dereference_column_groups(self: Table) -> Table:
|
|
769
|
+
"""Dereference columns in column groups.
|
|
770
|
+
|
|
771
|
+
Returns
|
|
772
|
+
-------
|
|
773
|
+
`Table`
|
|
774
|
+
The table with dereferenced column groups.
|
|
775
|
+
"""
|
|
776
|
+
for group in self.column_groups:
|
|
777
|
+
group.table = self
|
|
778
|
+
group._dereference_columns()
|
|
779
|
+
return self
|
|
780
|
+
|
|
656
781
|
|
|
657
782
|
class SchemaVersion(BaseModel):
|
|
658
783
|
"""Schema version model."""
|
felis/metadata.py
CHANGED
|
@@ -127,6 +127,8 @@ class MetaDataBuilder:
|
|
|
127
127
|
Whether to apply the schema name to the metadata object.
|
|
128
128
|
ignore_constraints
|
|
129
129
|
Whether to ignore constraints when building the metadata.
|
|
130
|
+
table_name_postfix
|
|
131
|
+
A string to append to the table names when building the metadata.
|
|
130
132
|
"""
|
|
131
133
|
|
|
132
134
|
def __init__(
|
|
@@ -134,6 +136,7 @@ class MetaDataBuilder:
|
|
|
134
136
|
schema: Schema,
|
|
135
137
|
apply_schema_to_metadata: bool = True,
|
|
136
138
|
ignore_constraints: bool = False,
|
|
139
|
+
table_name_postfix: str = "",
|
|
137
140
|
) -> None:
|
|
138
141
|
"""Initialize the metadata builder."""
|
|
139
142
|
self.schema = schema
|
|
@@ -142,6 +145,7 @@ class MetaDataBuilder:
|
|
|
142
145
|
self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
|
|
143
146
|
self._objects: dict[str, Any] = {}
|
|
144
147
|
self.ignore_constraints = ignore_constraints
|
|
148
|
+
self.table_name_postfix = table_name_postfix
|
|
145
149
|
|
|
146
150
|
def build(self) -> MetaData:
|
|
147
151
|
"""Build the SQLAlchemy tables and constraints from the schema.
|
|
@@ -225,7 +229,7 @@ class MetaDataBuilder:
|
|
|
225
229
|
description = table_obj.description
|
|
226
230
|
columns = [self.build_column(column) for column in table_obj.columns]
|
|
227
231
|
table = Table(
|
|
228
|
-
name,
|
|
232
|
+
name + self.table_name_postfix,
|
|
229
233
|
self.metadata,
|
|
230
234
|
*columns,
|
|
231
235
|
comment=description,
|
felis/tap_schema.py
CHANGED
|
@@ -91,9 +91,15 @@ class TableManager:
|
|
|
91
91
|
self.table_name_postfix = table_name_postfix
|
|
92
92
|
self.apply_schema_to_metadata = apply_schema_to_metadata
|
|
93
93
|
self.schema_name = schema_name or TableManager._SCHEMA_NAME_STD
|
|
94
|
+
self.table_name_postfix = table_name_postfix
|
|
94
95
|
|
|
95
96
|
if is_valid_engine(engine):
|
|
96
97
|
assert isinstance(engine, Engine)
|
|
98
|
+
if table_name_postfix != "":
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Table name postfix '%s' will be ignored when reflecting TAP_SCHEMA database",
|
|
101
|
+
table_name_postfix,
|
|
102
|
+
)
|
|
97
103
|
logger.debug(
|
|
98
104
|
"Reflecting TAP_SCHEMA database from existing database at %s",
|
|
99
105
|
engine.url._replace(password="***"),
|
|
@@ -131,7 +137,9 @@ class TableManager:
|
|
|
131
137
|
self.schema_name = self.schema.name
|
|
132
138
|
|
|
133
139
|
self._metadata = MetaDataBuilder(
|
|
134
|
-
self.schema,
|
|
140
|
+
self.schema,
|
|
141
|
+
apply_schema_to_metadata=self.apply_schema_to_metadata,
|
|
142
|
+
table_name_postfix=self.table_name_postfix,
|
|
135
143
|
).build()
|
|
136
144
|
|
|
137
145
|
logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name)
|
|
@@ -353,7 +361,7 @@ class TableManager:
|
|
|
353
361
|
engine
|
|
354
362
|
The SQLAlchemy engine to use to create the tables.
|
|
355
363
|
"""
|
|
356
|
-
logger.info("Creating TAP_SCHEMA database '%s'", self.
|
|
364
|
+
logger.info("Creating TAP_SCHEMA database '%s'", self.schema_name)
|
|
357
365
|
self._create_schema(engine)
|
|
358
366
|
self.metadata.create_all(engine)
|
|
359
367
|
|
|
@@ -424,7 +432,7 @@ class DataLoader:
|
|
|
424
432
|
# Execute the inserts if not in dry run mode.
|
|
425
433
|
self._execute_inserts()
|
|
426
434
|
else:
|
|
427
|
-
logger.info("Dry run
|
|
435
|
+
logger.info("Dry run - not loading data into database")
|
|
428
436
|
|
|
429
437
|
def _insert_schemas(self) -> None:
|
|
430
438
|
"""Insert the schema data into the schemas table."""
|
|
@@ -565,7 +573,7 @@ class DataLoader:
|
|
|
565
573
|
def _print_sql(self) -> None:
|
|
566
574
|
"""Print the generated inserts to stdout."""
|
|
567
575
|
for insert_str in self._compiled_inserts():
|
|
568
|
-
print(insert_str)
|
|
576
|
+
print(insert_str + ";")
|
|
569
577
|
|
|
570
578
|
def _write_sql_to_file(self) -> None:
|
|
571
579
|
"""Write the generated insert statements to a file."""
|
|
@@ -573,7 +581,7 @@ class DataLoader:
|
|
|
573
581
|
raise ValueError("No output path specified")
|
|
574
582
|
with open(self.output_path, "w") as outfile:
|
|
575
583
|
for insert_str in self._compiled_inserts():
|
|
576
|
-
outfile.write(insert_str + "\n")
|
|
584
|
+
outfile.write(insert_str + ";" + "\n")
|
|
577
585
|
|
|
578
586
|
def _insert(self, table_name: str, record: list[Any] | dict[str, Any]) -> None:
|
|
579
587
|
"""Generate an insert statement for a record.
|
felis/version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "28.2025.
|
|
2
|
+
__version__ = "28.2025.600"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.2025.
|
|
3
|
+
Version: 28.2025.600
|
|
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+)
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
felis/__init__.py,sha256=r1KFSnc55gziwUuYb9s2EfwrI_85aa3LpaKwk6rUvvs,1108
|
|
2
|
-
felis/cli.py,sha256=
|
|
3
|
-
felis/datamodel.py,sha256=
|
|
2
|
+
felis/cli.py,sha256=Wf-sEUZ-B9zzn4M1huY2ruV1nkgVmpzX8f8iuFfyxZc,14469
|
|
3
|
+
felis/datamodel.py,sha256=NczAA4HBBC4-uxPNsrKAFX-hdlgvCT2qqEJCEqDy4yg,39265
|
|
4
4
|
felis/diff.py,sha256=0N4OcBCzbL9DW_XGAeuvGsQ0zIhq8fY-Kx2QdvLv-Ds,7492
|
|
5
|
-
felis/metadata.py,sha256=
|
|
5
|
+
felis/metadata.py,sha256=cYx_qizkLBqcoxWV46h4TbwTi1KVJAkuA2OuUmD-K5k,13536
|
|
6
6
|
felis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
felis/
|
|
8
|
-
felis/tap_schema.py,sha256=MvRZdNYl9ObJCsfiWyAp-3EpTdVeKCKimYba-pSZNos,22392
|
|
7
|
+
felis/tap_schema.py,sha256=DgHH4hBf4q_F540TAR9GTKcALwUkk8iTw5pzQlmv1DA,22753
|
|
9
8
|
felis/types.py,sha256=m80GSGfNHQ3-NzRuTzKOyRXLJboPxdk9kzpp1SO8XdY,5510
|
|
10
|
-
felis/version.py,sha256=
|
|
9
|
+
felis/version.py,sha256=d9ENfpCeS2I77L3quCjEA1SqEJuEwmWi9q2wJwk_Xhw,54
|
|
11
10
|
felis/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
11
|
felis/db/dialects.py,sha256=n5La-shu-8fHLIyf8rrazHDyrzATmMCdELtKV_0ymxI,3517
|
|
13
12
|
felis/db/schema.py,sha256=NOFXzBoBQcgpoRlgT3LoC70FKp7pCSmFEJ7rU8FIT-c,2101
|
|
@@ -17,11 +16,11 @@ felis/db/variants.py,sha256=eahthrbVeV8ZdGamWQccNmWgx6CCscGrU0vQRs5HZK8,5260
|
|
|
17
16
|
felis/schemas/tap_schema_std.yaml,sha256=sPW-Vk72nY0PFpCvP5d8L8fWvhkif-x32sGtcfDZ8bU,7131
|
|
18
17
|
felis/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
18
|
felis/tests/postgresql.py,sha256=B_xk4fLual5-viGDqP20r94okuc0pbSvytRH_L0fvMs,4035
|
|
20
|
-
lsst_felis-28.2025.
|
|
21
|
-
lsst_felis-28.2025.
|
|
22
|
-
lsst_felis-28.2025.
|
|
23
|
-
lsst_felis-28.2025.
|
|
24
|
-
lsst_felis-28.2025.
|
|
25
|
-
lsst_felis-28.2025.
|
|
26
|
-
lsst_felis-28.2025.
|
|
27
|
-
lsst_felis-28.2025.
|
|
19
|
+
lsst_felis-28.2025.600.dist-info/COPYRIGHT,sha256=vJAFLFTSF1mhy9eIuA3P6R-3yxTWKQgpig88P-1IzRw,129
|
|
20
|
+
lsst_felis-28.2025.600.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
|
|
21
|
+
lsst_felis-28.2025.600.dist-info/METADATA,sha256=spGx-It8JoY8mgWkQLwxngz3gD2Jr1bJi_0T1NeMAjs,1410
|
|
22
|
+
lsst_felis-28.2025.600.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
23
|
+
lsst_felis-28.2025.600.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
|
|
24
|
+
lsst_felis-28.2025.600.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
|
|
25
|
+
lsst_felis-28.2025.600.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
26
|
+
lsst_felis-28.2025.600.dist-info/RECORD,,
|
felis/tap.py
DELETED
|
@@ -1,597 +0,0 @@
|
|
|
1
|
-
"""Translate a Felis schema into a TAP_SCHEMA representation."""
|
|
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
|
-
import re
|
|
28
|
-
from collections.abc import Iterable, MutableMapping
|
|
29
|
-
from typing import Any
|
|
30
|
-
|
|
31
|
-
from sqlalchemy import Column, Integer, String
|
|
32
|
-
from sqlalchemy.engine import Engine
|
|
33
|
-
from sqlalchemy.engine.mock import MockConnection
|
|
34
|
-
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
|
35
|
-
from sqlalchemy.schema import MetaData
|
|
36
|
-
from sqlalchemy.sql.expression import Insert, insert
|
|
37
|
-
|
|
38
|
-
from felis import datamodel
|
|
39
|
-
|
|
40
|
-
from .datamodel import Constraint, ForeignKeyConstraint, Index, Schema, Table
|
|
41
|
-
from .types import FelisType
|
|
42
|
-
|
|
43
|
-
__all__ = ["TapLoadingVisitor", "init_tables"]
|
|
44
|
-
|
|
45
|
-
logger = logging.getLogger(__name__)
|
|
46
|
-
|
|
47
|
-
Tap11Base: Any = declarative_base() # Any to avoid mypy mess with SA 2
|
|
48
|
-
|
|
49
|
-
IDENTIFIER_LENGTH = 128
|
|
50
|
-
SMALL_FIELD_LENGTH = 32
|
|
51
|
-
SIMPLE_FIELD_LENGTH = 128
|
|
52
|
-
TEXT_FIELD_LENGTH = 2048
|
|
53
|
-
QUALIFIED_TABLE_LENGTH = 3 * IDENTIFIER_LENGTH + 2
|
|
54
|
-
|
|
55
|
-
_init_table_once = False
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def init_tables(
|
|
59
|
-
tap_schema_name: str | None = None,
|
|
60
|
-
tap_tables_postfix: str | None = None,
|
|
61
|
-
tap_schemas_table: str | None = None,
|
|
62
|
-
tap_tables_table: str | None = None,
|
|
63
|
-
tap_columns_table: str | None = None,
|
|
64
|
-
tap_keys_table: str | None = None,
|
|
65
|
-
tap_key_columns_table: str | None = None,
|
|
66
|
-
) -> MutableMapping[str, Any]:
|
|
67
|
-
"""Generate definitions for TAP tables.
|
|
68
|
-
|
|
69
|
-
Parameters
|
|
70
|
-
----------
|
|
71
|
-
tap_schema_name
|
|
72
|
-
Name of the TAP schema.
|
|
73
|
-
tap_tables_postfix
|
|
74
|
-
Postfix for table names.
|
|
75
|
-
tap_schemas_table
|
|
76
|
-
Name of the schemas table.
|
|
77
|
-
tap_tables_table
|
|
78
|
-
Name of the tables table.
|
|
79
|
-
tap_columns_table
|
|
80
|
-
Name of the columns table.
|
|
81
|
-
tap_keys_table
|
|
82
|
-
Name of the keys table.
|
|
83
|
-
tap_key_columns_table
|
|
84
|
-
Name of the key columns table.
|
|
85
|
-
|
|
86
|
-
Returns
|
|
87
|
-
-------
|
|
88
|
-
`dict` [ `str`, `Any`]
|
|
89
|
-
A dictionary of table definitions.
|
|
90
|
-
"""
|
|
91
|
-
postfix = tap_tables_postfix or ""
|
|
92
|
-
|
|
93
|
-
# Dirty hack to enable this method to be called more than once, replaces
|
|
94
|
-
# MetaData instance with a fresh copy if called more than once.
|
|
95
|
-
# TODO: probably replace ORM stuff with core sqlalchemy functions.
|
|
96
|
-
global _init_table_once
|
|
97
|
-
if not _init_table_once:
|
|
98
|
-
_init_table_once = True
|
|
99
|
-
else:
|
|
100
|
-
Tap11Base.metadata = MetaData()
|
|
101
|
-
|
|
102
|
-
if tap_schema_name:
|
|
103
|
-
Tap11Base.metadata.schema = tap_schema_name
|
|
104
|
-
|
|
105
|
-
class Tap11Schemas(Tap11Base):
|
|
106
|
-
__tablename__ = (tap_schemas_table or "schemas") + postfix
|
|
107
|
-
schema_name = Column(String(IDENTIFIER_LENGTH), primary_key=True, nullable=False)
|
|
108
|
-
utype = Column(String(SIMPLE_FIELD_LENGTH))
|
|
109
|
-
description = Column(String(TEXT_FIELD_LENGTH))
|
|
110
|
-
schema_index = Column(Integer)
|
|
111
|
-
|
|
112
|
-
class Tap11Tables(Tap11Base):
|
|
113
|
-
__tablename__ = (tap_tables_table or "tables") + postfix
|
|
114
|
-
schema_name = Column(String(IDENTIFIER_LENGTH), nullable=False)
|
|
115
|
-
table_name = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False, primary_key=True)
|
|
116
|
-
table_type = Column(String(SMALL_FIELD_LENGTH), nullable=False)
|
|
117
|
-
utype = Column(String(SIMPLE_FIELD_LENGTH))
|
|
118
|
-
description = Column(String(TEXT_FIELD_LENGTH))
|
|
119
|
-
table_index = Column(Integer)
|
|
120
|
-
|
|
121
|
-
class Tap11Columns(Tap11Base):
|
|
122
|
-
__tablename__ = (tap_columns_table or "columns") + postfix
|
|
123
|
-
table_name = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False, primary_key=True)
|
|
124
|
-
column_name = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
|
|
125
|
-
datatype = Column(String(SIMPLE_FIELD_LENGTH), nullable=False)
|
|
126
|
-
arraysize = Column(String(10))
|
|
127
|
-
xtype = Column(String(SIMPLE_FIELD_LENGTH))
|
|
128
|
-
# Size is deprecated
|
|
129
|
-
size = Column("size", Integer(), quote=True)
|
|
130
|
-
description = Column(String(TEXT_FIELD_LENGTH))
|
|
131
|
-
utype = Column(String(SIMPLE_FIELD_LENGTH))
|
|
132
|
-
unit = Column(String(SIMPLE_FIELD_LENGTH))
|
|
133
|
-
ucd = Column(String(SIMPLE_FIELD_LENGTH))
|
|
134
|
-
indexed = Column(Integer, nullable=False)
|
|
135
|
-
principal = Column(Integer, nullable=False)
|
|
136
|
-
std = Column(Integer, nullable=False)
|
|
137
|
-
column_index = Column(Integer)
|
|
138
|
-
|
|
139
|
-
class Tap11Keys(Tap11Base):
|
|
140
|
-
__tablename__ = (tap_keys_table or "keys") + postfix
|
|
141
|
-
key_id = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
|
|
142
|
-
from_table = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False)
|
|
143
|
-
target_table = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False)
|
|
144
|
-
description = Column(String(TEXT_FIELD_LENGTH))
|
|
145
|
-
utype = Column(String(SIMPLE_FIELD_LENGTH))
|
|
146
|
-
|
|
147
|
-
class Tap11KeyColumns(Tap11Base):
|
|
148
|
-
__tablename__ = (tap_key_columns_table or "key_columns") + postfix
|
|
149
|
-
key_id = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
|
|
150
|
-
from_column = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
|
|
151
|
-
target_column = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
|
|
152
|
-
|
|
153
|
-
return dict(
|
|
154
|
-
schemas=Tap11Schemas,
|
|
155
|
-
tables=Tap11Tables,
|
|
156
|
-
columns=Tap11Columns,
|
|
157
|
-
keys=Tap11Keys,
|
|
158
|
-
key_columns=Tap11KeyColumns,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
class TapLoadingVisitor:
|
|
163
|
-
"""Generate TAP_SCHEMA data and insert it into a database using the
|
|
164
|
-
SQLAlchemy ORM.
|
|
165
|
-
|
|
166
|
-
Parameters
|
|
167
|
-
----------
|
|
168
|
-
engine
|
|
169
|
-
SQLAlchemy engine instance.
|
|
170
|
-
catalog_name
|
|
171
|
-
Name of the database catalog.
|
|
172
|
-
schema_name
|
|
173
|
-
Name of the schema.
|
|
174
|
-
tap_tables
|
|
175
|
-
Mapping of TAP_SCHEMA table name to its SQLAlchemy table object.
|
|
176
|
-
tap_schema_index
|
|
177
|
-
The index of the schema for this TAP environment.
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
def __init__(
|
|
181
|
-
self,
|
|
182
|
-
engine: Engine | None,
|
|
183
|
-
catalog_name: str | None = None,
|
|
184
|
-
schema_name: str | None = None,
|
|
185
|
-
tap_tables: MutableMapping[str, Any] | None = None,
|
|
186
|
-
tap_schema_index: int | None = None,
|
|
187
|
-
) -> None:
|
|
188
|
-
"""Create a TAP loading visitor."""
|
|
189
|
-
self.graph_index: MutableMapping[str, Any] = {}
|
|
190
|
-
self.catalog_name = catalog_name
|
|
191
|
-
self.schema_name = schema_name
|
|
192
|
-
self.engine = engine
|
|
193
|
-
self._mock_connection: MockConnection | None = None
|
|
194
|
-
self.tables = tap_tables or init_tables()
|
|
195
|
-
self.tap_schema_index = tap_schema_index
|
|
196
|
-
|
|
197
|
-
@classmethod
|
|
198
|
-
def from_mock_connection(
|
|
199
|
-
cls,
|
|
200
|
-
mock_connection: MockConnection,
|
|
201
|
-
catalog_name: str | None = None,
|
|
202
|
-
schema_name: str | None = None,
|
|
203
|
-
tap_tables: MutableMapping[str, Any] | None = None,
|
|
204
|
-
tap_schema_index: int | None = None,
|
|
205
|
-
) -> TapLoadingVisitor:
|
|
206
|
-
"""Create a TAP visitor from a mock connection.
|
|
207
|
-
|
|
208
|
-
Parameters
|
|
209
|
-
----------
|
|
210
|
-
mock_connection
|
|
211
|
-
Mock connection object.
|
|
212
|
-
catalog_name
|
|
213
|
-
Name of the database catalog.
|
|
214
|
-
schema_name
|
|
215
|
-
Name of the database schema.
|
|
216
|
-
tap_tables
|
|
217
|
-
Optional mapping of table name to its SQLAlchemy table object.
|
|
218
|
-
tap_schema_index
|
|
219
|
-
The index of the schema for this TAP environment.
|
|
220
|
-
|
|
221
|
-
Returns
|
|
222
|
-
-------
|
|
223
|
-
`TapLoadingVisitor`
|
|
224
|
-
The TAP loading visitor.
|
|
225
|
-
"""
|
|
226
|
-
visitor = cls(engine=None, catalog_name=catalog_name, schema_name=schema_name, tap_tables=tap_tables)
|
|
227
|
-
visitor._mock_connection = mock_connection
|
|
228
|
-
visitor.tap_schema_index = tap_schema_index
|
|
229
|
-
return visitor
|
|
230
|
-
|
|
231
|
-
def visit_schema(self, schema_obj: Schema) -> None:
|
|
232
|
-
"""Visit a schema object and insert it into the TAP_SCHEMA database.
|
|
233
|
-
|
|
234
|
-
Parameters
|
|
235
|
-
----------
|
|
236
|
-
schema_obj
|
|
237
|
-
The schema object to visit.
|
|
238
|
-
"""
|
|
239
|
-
schema = self.tables["schemas"]()
|
|
240
|
-
# Override with default
|
|
241
|
-
self.schema_name = self.schema_name or schema_obj.name
|
|
242
|
-
|
|
243
|
-
schema.schema_name = self._schema_name()
|
|
244
|
-
schema.description = schema_obj.description
|
|
245
|
-
schema.utype = schema_obj.votable_utype
|
|
246
|
-
schema.schema_index = self.tap_schema_index
|
|
247
|
-
logger.debug(f"Set TAP_SCHEMA index: {self.tap_schema_index}")
|
|
248
|
-
|
|
249
|
-
if self.engine is not None:
|
|
250
|
-
session: Session = sessionmaker(self.engine)()
|
|
251
|
-
|
|
252
|
-
session.add(schema)
|
|
253
|
-
|
|
254
|
-
for table_obj in schema_obj.tables:
|
|
255
|
-
table, columns = self.visit_table(table_obj, schema_obj)
|
|
256
|
-
session.add(table)
|
|
257
|
-
session.add_all(columns)
|
|
258
|
-
|
|
259
|
-
keys, key_columns = self.visit_constraints(schema_obj)
|
|
260
|
-
session.add_all(keys)
|
|
261
|
-
session.add_all(key_columns)
|
|
262
|
-
|
|
263
|
-
logger.debug("Committing TAP schema: %s", schema_obj.name)
|
|
264
|
-
logger.debug("TAP tables: %s", len(self.tables))
|
|
265
|
-
session.commit()
|
|
266
|
-
else:
|
|
267
|
-
logger.info("Dry run, not inserting into database")
|
|
268
|
-
|
|
269
|
-
# Only if we are mocking (dry run)
|
|
270
|
-
assert self._mock_connection is not None, "Mock connection must not be None"
|
|
271
|
-
conn = self._mock_connection
|
|
272
|
-
conn.execute(_insert(self.tables["schemas"], schema))
|
|
273
|
-
|
|
274
|
-
for table_obj in schema_obj.tables:
|
|
275
|
-
table, columns = self.visit_table(table_obj, schema_obj)
|
|
276
|
-
conn.execute(_insert(self.tables["tables"], table))
|
|
277
|
-
for column in columns:
|
|
278
|
-
conn.execute(_insert(self.tables["columns"], column))
|
|
279
|
-
|
|
280
|
-
keys, key_columns = self.visit_constraints(schema_obj)
|
|
281
|
-
for key in keys:
|
|
282
|
-
conn.execute(_insert(self.tables["keys"], key))
|
|
283
|
-
for key_column in key_columns:
|
|
284
|
-
conn.execute(_insert(self.tables["key_columns"], key_column))
|
|
285
|
-
|
|
286
|
-
def visit_constraints(self, schema_obj: Schema) -> tuple:
|
|
287
|
-
"""Visit all constraints in a schema.
|
|
288
|
-
|
|
289
|
-
Parameters
|
|
290
|
-
----------
|
|
291
|
-
schema_obj
|
|
292
|
-
The schema object to visit.
|
|
293
|
-
|
|
294
|
-
Returns
|
|
295
|
-
-------
|
|
296
|
-
`tuple`
|
|
297
|
-
A tuple of all TAP_SCHEMA keys and key columns that were created.
|
|
298
|
-
"""
|
|
299
|
-
all_keys = []
|
|
300
|
-
all_key_columns = []
|
|
301
|
-
for table_obj in schema_obj.tables:
|
|
302
|
-
for c in table_obj.constraints:
|
|
303
|
-
key, key_columns = self.visit_constraint(c)
|
|
304
|
-
if not key:
|
|
305
|
-
continue
|
|
306
|
-
all_keys.append(key)
|
|
307
|
-
all_key_columns += key_columns
|
|
308
|
-
return all_keys, all_key_columns
|
|
309
|
-
|
|
310
|
-
def visit_table(self, table_obj: Table, schema_obj: Schema) -> tuple:
|
|
311
|
-
"""Visit a table object and build its TAP_SCHEMA representation.
|
|
312
|
-
|
|
313
|
-
Parameters
|
|
314
|
-
----------
|
|
315
|
-
table_obj
|
|
316
|
-
The table object to visit.
|
|
317
|
-
schema_obj
|
|
318
|
-
The schema object which the table belongs to.
|
|
319
|
-
|
|
320
|
-
Returns
|
|
321
|
-
-------
|
|
322
|
-
`tuple`
|
|
323
|
-
A tuple of the SQLAlchemy ORM objects for the tables and columns.
|
|
324
|
-
"""
|
|
325
|
-
table_id = table_obj.id
|
|
326
|
-
table = self.tables["tables"]()
|
|
327
|
-
table.schema_name = self._schema_name()
|
|
328
|
-
table.table_name = self._table_name(table_obj.name)
|
|
329
|
-
table.table_type = "table"
|
|
330
|
-
table.utype = table_obj.votable_utype
|
|
331
|
-
table.description = table_obj.description
|
|
332
|
-
table.table_index = 0 if table_obj.tap_table_index is None else table_obj.tap_table_index
|
|
333
|
-
|
|
334
|
-
columns = [self.visit_column(c, table_obj) for c in table_obj.columns]
|
|
335
|
-
self.visit_primary_key(table_obj.primary_key, table_obj)
|
|
336
|
-
|
|
337
|
-
for i in table_obj.indexes:
|
|
338
|
-
self.visit_index(i, table)
|
|
339
|
-
|
|
340
|
-
self.graph_index[table_id] = table
|
|
341
|
-
return table, columns
|
|
342
|
-
|
|
343
|
-
def check_column(self, column_obj: datamodel.Column) -> None:
|
|
344
|
-
"""Check consistency of VOTable attributes for a column.
|
|
345
|
-
|
|
346
|
-
Parameters
|
|
347
|
-
----------
|
|
348
|
-
column_obj
|
|
349
|
-
The column object to check.
|
|
350
|
-
|
|
351
|
-
Notes
|
|
352
|
-
-----
|
|
353
|
-
This method checks that a column with a sized datatype has either a
|
|
354
|
-
``votable:arraysize`` or a ``length`` attribute and issues a warning
|
|
355
|
-
message if not. It also checks if a column with a timestamp datatype
|
|
356
|
-
has a ``arraysize`` attribute and issues a warning if not.
|
|
357
|
-
"""
|
|
358
|
-
_id = column_obj.id
|
|
359
|
-
datatype_name = column_obj.datatype
|
|
360
|
-
felis_type = FelisType.felis_type(datatype_name.value)
|
|
361
|
-
if felis_type.is_sized:
|
|
362
|
-
# It is expected that both arraysize and length are fine for
|
|
363
|
-
# length types.
|
|
364
|
-
arraysize = column_obj.votable_arraysize or column_obj.length
|
|
365
|
-
if arraysize is None:
|
|
366
|
-
logger.warning(
|
|
367
|
-
f"votable:arraysize and length for {_id} are None for type {datatype_name}. "
|
|
368
|
-
'Using length "*". '
|
|
369
|
-
"Consider setting `votable:arraysize` or `length`."
|
|
370
|
-
)
|
|
371
|
-
if felis_type.is_timestamp:
|
|
372
|
-
# datetime types really should have a votable:arraysize, because
|
|
373
|
-
# they are converted to strings and the `length` is loosely to the
|
|
374
|
-
# string size
|
|
375
|
-
if not column_obj.votable_arraysize:
|
|
376
|
-
logger.warning(
|
|
377
|
-
f"votable:arraysize for {_id} is None for type {datatype_name}. "
|
|
378
|
-
f'Using length "*". '
|
|
379
|
-
"Consider setting `votable:arraysize` to an appropriate size for "
|
|
380
|
-
"materialized datetime/timestamp strings."
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
def visit_column(self, column_obj: datamodel.Column, table_obj: Table) -> Tap11Base:
|
|
384
|
-
"""Visit a column object and build its TAP_SCHEMA representation.
|
|
385
|
-
|
|
386
|
-
Parameters
|
|
387
|
-
----------
|
|
388
|
-
column_obj
|
|
389
|
-
The column object to visit.
|
|
390
|
-
table_obj
|
|
391
|
-
The table object which the column belongs to.
|
|
392
|
-
|
|
393
|
-
Returns
|
|
394
|
-
-------
|
|
395
|
-
``Tap11Base``
|
|
396
|
-
The SQLAlchemy ORM object for the column.
|
|
397
|
-
"""
|
|
398
|
-
self.check_column(column_obj)
|
|
399
|
-
column_id = column_obj.id
|
|
400
|
-
table_name = self._table_name(table_obj.name)
|
|
401
|
-
|
|
402
|
-
column = self.tables["columns"]()
|
|
403
|
-
column.table_name = table_name
|
|
404
|
-
column.column_name = column_obj.name
|
|
405
|
-
|
|
406
|
-
felis_datatype = column_obj.datatype
|
|
407
|
-
felis_type = FelisType.felis_type(felis_datatype.value)
|
|
408
|
-
column.datatype = column_obj.votable_datatype or felis_type.votable_name
|
|
409
|
-
|
|
410
|
-
column.arraysize = column_obj.votable_arraysize
|
|
411
|
-
|
|
412
|
-
def _is_int(s: str) -> bool:
|
|
413
|
-
try:
|
|
414
|
-
int(s)
|
|
415
|
-
return True
|
|
416
|
-
except ValueError:
|
|
417
|
-
return False
|
|
418
|
-
|
|
419
|
-
# Handle the deprecated size attribute
|
|
420
|
-
arraysize = column.arraysize
|
|
421
|
-
if arraysize is not None and arraysize != "":
|
|
422
|
-
if isinstance(arraysize, int):
|
|
423
|
-
column.size = arraysize
|
|
424
|
-
elif _is_int(arraysize):
|
|
425
|
-
column.size = int(arraysize)
|
|
426
|
-
elif bool(re.match(r"^[0-9]+\*$", arraysize)):
|
|
427
|
-
column.size = int(arraysize.replace("*", ""))
|
|
428
|
-
|
|
429
|
-
if column.size is not None:
|
|
430
|
-
logger.debug(f"Set size to {column.size} for {column.column_name} with arraysize {arraysize}")
|
|
431
|
-
|
|
432
|
-
column.xtype = column_obj.votable_xtype
|
|
433
|
-
column.description = column_obj.description
|
|
434
|
-
column.utype = column_obj.votable_utype
|
|
435
|
-
|
|
436
|
-
unit = column_obj.ivoa_unit or column_obj.fits_tunit
|
|
437
|
-
column.unit = unit
|
|
438
|
-
column.ucd = column_obj.ivoa_ucd
|
|
439
|
-
|
|
440
|
-
# We modify this after we process columns
|
|
441
|
-
column.indexed = 0
|
|
442
|
-
|
|
443
|
-
column.principal = column_obj.tap_principal
|
|
444
|
-
column.std = column_obj.tap_std
|
|
445
|
-
column.column_index = column_obj.tap_column_index
|
|
446
|
-
|
|
447
|
-
self.graph_index[column_id] = column
|
|
448
|
-
return column
|
|
449
|
-
|
|
450
|
-
def visit_primary_key(self, primary_key_obj: str | Iterable[str] | None, table_obj: Table) -> None:
|
|
451
|
-
"""Visit a primary key object and update the TAP_SCHEMA representation.
|
|
452
|
-
|
|
453
|
-
Parameters
|
|
454
|
-
----------
|
|
455
|
-
primary_key_obj
|
|
456
|
-
The primary key object to visit.
|
|
457
|
-
table_obj
|
|
458
|
-
The table object which the primary key belongs to.
|
|
459
|
-
"""
|
|
460
|
-
if primary_key_obj:
|
|
461
|
-
if isinstance(primary_key_obj, str):
|
|
462
|
-
primary_key_obj = [primary_key_obj]
|
|
463
|
-
columns = [self.graph_index[c_id] for c_id in primary_key_obj]
|
|
464
|
-
# if just one column and it's indexed, update the object
|
|
465
|
-
if len(columns) == 1:
|
|
466
|
-
columns[0].indexed = 1
|
|
467
|
-
|
|
468
|
-
def visit_constraint(self, constraint_obj: Constraint) -> tuple:
|
|
469
|
-
"""Visit a constraint object and build its TAP_SCHEMA representation.
|
|
470
|
-
|
|
471
|
-
Parameters
|
|
472
|
-
----------
|
|
473
|
-
constraint_obj
|
|
474
|
-
The constraint object to visit.
|
|
475
|
-
|
|
476
|
-
Returns
|
|
477
|
-
-------
|
|
478
|
-
`tuple`
|
|
479
|
-
A tuple of the SQLAlchemy ORM objects for the TAP_SCHEMA ``key``
|
|
480
|
-
and ``key_columns`` data.
|
|
481
|
-
"""
|
|
482
|
-
key = None
|
|
483
|
-
key_columns = []
|
|
484
|
-
if isinstance(constraint_obj, ForeignKeyConstraint):
|
|
485
|
-
constraint_name = constraint_obj.name
|
|
486
|
-
description = constraint_obj.description
|
|
487
|
-
utype = constraint_obj.votable_utype
|
|
488
|
-
|
|
489
|
-
columns = [self.graph_index[col_id] for col_id in getattr(constraint_obj, "columns", [])]
|
|
490
|
-
refcolumns = [
|
|
491
|
-
self.graph_index[refcol_id] for refcol_id in getattr(constraint_obj, "referenced_columns", [])
|
|
492
|
-
]
|
|
493
|
-
|
|
494
|
-
table_name = None
|
|
495
|
-
for column in columns:
|
|
496
|
-
if not table_name:
|
|
497
|
-
table_name = column.table_name
|
|
498
|
-
if table_name != column.table_name:
|
|
499
|
-
raise ValueError("Inconsisent use of table names")
|
|
500
|
-
|
|
501
|
-
table_name = None
|
|
502
|
-
for column in refcolumns:
|
|
503
|
-
if not table_name:
|
|
504
|
-
table_name = column.table_name
|
|
505
|
-
if table_name != column.table_name:
|
|
506
|
-
raise ValueError("Inconsisent use of table names")
|
|
507
|
-
first_column = columns[0]
|
|
508
|
-
first_refcolumn = refcolumns[0]
|
|
509
|
-
|
|
510
|
-
key = self.tables["keys"]()
|
|
511
|
-
key.key_id = constraint_name
|
|
512
|
-
key.from_table = first_column.table_name
|
|
513
|
-
key.target_table = first_refcolumn.table_name
|
|
514
|
-
key.description = description
|
|
515
|
-
key.utype = utype
|
|
516
|
-
for column, refcolumn in zip(columns, refcolumns):
|
|
517
|
-
key_column = self.tables["key_columns"]()
|
|
518
|
-
key_column.key_id = constraint_name
|
|
519
|
-
key_column.from_column = column.column_name
|
|
520
|
-
key_column.target_column = refcolumn.column_name
|
|
521
|
-
key_columns.append(key_column)
|
|
522
|
-
return key, key_columns
|
|
523
|
-
|
|
524
|
-
def visit_index(self, index_obj: Index, table_obj: Table) -> None:
|
|
525
|
-
"""Visit an index object and update the TAP_SCHEMA representation.
|
|
526
|
-
|
|
527
|
-
Parameters
|
|
528
|
-
----------
|
|
529
|
-
index_obj
|
|
530
|
-
The index object to visit.
|
|
531
|
-
table_obj
|
|
532
|
-
The table object which the index belongs to.
|
|
533
|
-
"""
|
|
534
|
-
columns = [self.graph_index[col_id] for col_id in getattr(index_obj, "columns", [])]
|
|
535
|
-
# if just one column and it's indexed, update the object
|
|
536
|
-
if len(columns) == 1:
|
|
537
|
-
columns[0].indexed = 1
|
|
538
|
-
return None
|
|
539
|
-
|
|
540
|
-
def _schema_name(
|
|
541
|
-
self, schema_name: str | None = None
|
|
542
|
-
) -> str | None: # DM-44870: Usage of this method needs to be better understood and possibly removed
|
|
543
|
-
"""Return the schema name.
|
|
544
|
-
|
|
545
|
-
Parameters
|
|
546
|
-
----------
|
|
547
|
-
schema_name
|
|
548
|
-
Name of the schema.
|
|
549
|
-
|
|
550
|
-
Returns
|
|
551
|
-
-------
|
|
552
|
-
schema_name
|
|
553
|
-
The schema name.
|
|
554
|
-
"""
|
|
555
|
-
# If _schema_name is None, SQLAlchemy will catch it
|
|
556
|
-
_schema_name = schema_name or self.schema_name
|
|
557
|
-
if self.catalog_name and _schema_name:
|
|
558
|
-
return ".".join([self.catalog_name, _schema_name])
|
|
559
|
-
return _schema_name
|
|
560
|
-
|
|
561
|
-
def _table_name(self, table_name: str) -> str:
|
|
562
|
-
"""Return the table name.
|
|
563
|
-
|
|
564
|
-
Parameters
|
|
565
|
-
----------
|
|
566
|
-
table_name
|
|
567
|
-
Name of the table.
|
|
568
|
-
"""
|
|
569
|
-
schema_name = self._schema_name()
|
|
570
|
-
if schema_name:
|
|
571
|
-
return ".".join([schema_name, table_name])
|
|
572
|
-
return table_name
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
def _insert(table: Tap11Base, value: Any) -> Insert:
|
|
576
|
-
"""Return a SQLAlchemy insert statement.
|
|
577
|
-
|
|
578
|
-
Parameters
|
|
579
|
-
----------
|
|
580
|
-
table
|
|
581
|
-
The table we are inserting into.
|
|
582
|
-
value
|
|
583
|
-
An object representing the object we are inserting to the table.
|
|
584
|
-
|
|
585
|
-
Returns
|
|
586
|
-
-------
|
|
587
|
-
`Insert`
|
|
588
|
-
SQLAlchemy insert statement.
|
|
589
|
-
"""
|
|
590
|
-
values_dict = {}
|
|
591
|
-
for i in table.__table__.columns:
|
|
592
|
-
name = i.name
|
|
593
|
-
column_value = getattr(value, i.name)
|
|
594
|
-
if isinstance(column_value, str):
|
|
595
|
-
column_value = column_value.replace("'", "''")
|
|
596
|
-
values_dict[name] = column_value
|
|
597
|
-
return insert(table).values(values_dict)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|