lsst-felis 27.2024.3300__tar.gz → 27.2024.3500__tar.gz
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.
- {lsst_felis-27.2024.3300/python/lsst_felis.egg-info → lsst_felis-27.2024.3500}/PKG-INFO +1 -1
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/pyproject.toml +7 -2
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/cli.py +15 -2
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/datamodel.py +158 -57
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/metadata.py +1 -2
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/tap.py +2 -3
- lsst_felis-27.2024.3500/python/felis/version.py +2 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500/python/lsst_felis.egg-info}/PKG-INFO +1 -1
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/tests/test_cli.py +16 -1
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/tests/test_datamodel.py +134 -87
- lsst_felis-27.2024.3300/python/felis/version.py +0 -2
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/COPYRIGHT +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/LICENSE +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/README.rst +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/__init__.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/db/__init__.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/db/dialects.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/db/sqltypes.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/db/utils.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/db/variants.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/py.typed +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/tests/__init__.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/tests/postgresql.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/felis/types.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/SOURCES.txt +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/entry_points.txt +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/requires.txt +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/top_level.txt +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/zip-safe +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/setup.cfg +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/tests/test_metadata.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/tests/test_postgresql.py +0 -0
- {lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/tests/test_tap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 27.2024.
|
|
3
|
+
Version: 27.2024.3500
|
|
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+)
|
|
@@ -66,9 +66,9 @@ felis = "felis.cli:cli"
|
|
|
66
66
|
[tool.towncrier]
|
|
67
67
|
package = "felis"
|
|
68
68
|
package_dir = "python"
|
|
69
|
-
filename = "docs/
|
|
69
|
+
filename = "docs/CHANGES.rst"
|
|
70
70
|
directory = "docs/changes"
|
|
71
|
-
title_format = "felis {version} {project_date}"
|
|
71
|
+
title_format = "felis {version} ({project_date})"
|
|
72
72
|
issue_format = "`{issue} <https://jira.lsstcorp.org/browse/{issue}>`_"
|
|
73
73
|
|
|
74
74
|
|
|
@@ -87,6 +87,11 @@ felis = "felis.cli:cli"
|
|
|
87
87
|
name = "Bug Fixes"
|
|
88
88
|
showcontent = true
|
|
89
89
|
|
|
90
|
+
[[tool.towncrier.type]]
|
|
91
|
+
directory = "doc"
|
|
92
|
+
name = "Documentation Improvements"
|
|
93
|
+
showcontent = true
|
|
94
|
+
|
|
90
95
|
[[tool.towncrier.type]]
|
|
91
96
|
directory = "perf"
|
|
92
97
|
name = "Performance Enhancement"
|
|
@@ -62,8 +62,16 @@ loglevel_choices = ["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"]
|
|
|
62
62
|
envvar="FELIS_LOGFILE",
|
|
63
63
|
help="Felis log file path",
|
|
64
64
|
)
|
|
65
|
-
|
|
65
|
+
@click.option(
|
|
66
|
+
"--id-generation", is_flag=True, help="Generate IDs for all objects that do not have them", default=False
|
|
67
|
+
)
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation: bool) -> None:
|
|
66
70
|
"""Felis command line tools"""
|
|
71
|
+
ctx.ensure_object(dict)
|
|
72
|
+
ctx.obj["id_generation"] = id_generation
|
|
73
|
+
if ctx.obj["id_generation"]:
|
|
74
|
+
logger.info("ID generation is enabled")
|
|
67
75
|
if log_file:
|
|
68
76
|
logging.basicConfig(filename=log_file, level=log_level)
|
|
69
77
|
else:
|
|
@@ -88,7 +96,9 @@ def cli(log_level: str, log_file: str | None) -> None:
|
|
|
88
96
|
)
|
|
89
97
|
@click.option("--ignore-constraints", is_flag=True, help="Ignore constraints when creating tables")
|
|
90
98
|
@click.argument("file", type=click.File())
|
|
99
|
+
@click.pass_context
|
|
91
100
|
def create(
|
|
101
|
+
ctx: click.Context,
|
|
92
102
|
engine_url: str,
|
|
93
103
|
schema_name: str | None,
|
|
94
104
|
initialize: bool,
|
|
@@ -124,7 +134,7 @@ def create(
|
|
|
124
134
|
"""
|
|
125
135
|
try:
|
|
126
136
|
yaml_data = yaml.safe_load(file)
|
|
127
|
-
schema = Schema.model_validate(yaml_data)
|
|
137
|
+
schema = Schema.model_validate(yaml_data, context={"id_generation": ctx.obj["id_generation"]})
|
|
128
138
|
url = make_url(engine_url)
|
|
129
139
|
if schema_name:
|
|
130
140
|
logger.info(f"Overriding schema name with: {schema_name}")
|
|
@@ -355,7 +365,9 @@ def load_tap(
|
|
|
355
365
|
default=False,
|
|
356
366
|
)
|
|
357
367
|
@click.argument("files", nargs=-1, type=click.File())
|
|
368
|
+
@click.pass_context
|
|
358
369
|
def validate(
|
|
370
|
+
ctx: click.Context,
|
|
359
371
|
check_description: bool,
|
|
360
372
|
check_redundant_datatypes: bool,
|
|
361
373
|
check_tap_table_indexes: bool,
|
|
@@ -402,6 +414,7 @@ def validate(
|
|
|
402
414
|
"check_redundant_datatypes": check_redundant_datatypes,
|
|
403
415
|
"check_tap_table_indexes": check_tap_table_indexes,
|
|
404
416
|
"check_tap_principal": check_tap_principal,
|
|
417
|
+
"id_generation": ctx.obj["id_generation"],
|
|
405
418
|
},
|
|
406
419
|
)
|
|
407
420
|
except ValidationError as e:
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import logging
|
|
27
|
-
from collections.abc import
|
|
27
|
+
from collections.abc import Sequence
|
|
28
28
|
from enum import StrEnum, auto
|
|
29
|
-
from typing import Annotated, Any, TypeAlias
|
|
29
|
+
from typing import Annotated, Any, Literal, TypeAlias, Union
|
|
30
30
|
|
|
31
31
|
from astropy import units as units # type: ignore
|
|
32
32
|
from astropy.io.votable import ucd # type: ignore
|
|
@@ -390,20 +390,31 @@ class Constraint(BaseObject):
|
|
|
390
390
|
deferrable: bool = False
|
|
391
391
|
"""Whether this constraint will be declared as deferrable."""
|
|
392
392
|
|
|
393
|
-
initially:
|
|
393
|
+
initially: Literal["IMMEDIATE", "DEFERRED"] | None = None
|
|
394
394
|
"""Value for ``INITIALLY`` clause; only used if `deferrable` is
|
|
395
395
|
`True`."""
|
|
396
396
|
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
@model_validator(mode="after")
|
|
398
|
+
def check_deferrable(self) -> Constraint:
|
|
399
|
+
"""Check that the ``INITIALLY`` clause is only used if `deferrable` is
|
|
400
|
+
`True`.
|
|
399
401
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
`Constraint`
|
|
405
|
+
The constraint being validated.
|
|
406
|
+
"""
|
|
407
|
+
if self.initially is not None and not self.deferrable:
|
|
408
|
+
raise ValueError("INITIALLY clause can only be used if deferrable is True")
|
|
409
|
+
return self
|
|
402
410
|
|
|
403
411
|
|
|
404
412
|
class CheckConstraint(Constraint):
|
|
405
413
|
"""Table check constraint model."""
|
|
406
414
|
|
|
415
|
+
type: Literal["Check"] = Field("Check", alias="@type")
|
|
416
|
+
"""Type of the constraint."""
|
|
417
|
+
|
|
407
418
|
expression: str
|
|
408
419
|
"""Expression for the check constraint."""
|
|
409
420
|
|
|
@@ -411,10 +422,35 @@ class CheckConstraint(Constraint):
|
|
|
411
422
|
class UniqueConstraint(Constraint):
|
|
412
423
|
"""Table unique constraint model."""
|
|
413
424
|
|
|
425
|
+
type: Literal["Unique"] = Field("Unique", alias="@type")
|
|
426
|
+
"""Type of the constraint."""
|
|
427
|
+
|
|
414
428
|
columns: list[str]
|
|
415
429
|
"""Columns in the unique constraint."""
|
|
416
430
|
|
|
417
431
|
|
|
432
|
+
class ForeignKeyConstraint(Constraint):
|
|
433
|
+
"""Table foreign key constraint model.
|
|
434
|
+
|
|
435
|
+
This constraint is used to define a foreign key relationship between two
|
|
436
|
+
tables in the schema.
|
|
437
|
+
|
|
438
|
+
Notes
|
|
439
|
+
-----
|
|
440
|
+
These relationships will be reflected in the TAP_SCHEMA ``keys`` and
|
|
441
|
+
``key_columns`` data.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
type: Literal["ForeignKey"] = Field("ForeignKey", alias="@type")
|
|
445
|
+
"""Type of the constraint."""
|
|
446
|
+
|
|
447
|
+
columns: list[str]
|
|
448
|
+
"""The columns comprising the foreign key."""
|
|
449
|
+
|
|
450
|
+
referenced_columns: list[str] = Field(alias="referencedColumns")
|
|
451
|
+
"""The columns referenced by the foreign key."""
|
|
452
|
+
|
|
453
|
+
|
|
418
454
|
class Index(BaseObject):
|
|
419
455
|
"""Table index model.
|
|
420
456
|
|
|
@@ -455,23 +491,10 @@ class Index(BaseObject):
|
|
|
455
491
|
return values
|
|
456
492
|
|
|
457
493
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
tables in the schema.
|
|
463
|
-
|
|
464
|
-
Notes
|
|
465
|
-
-----
|
|
466
|
-
These relationships will be reflected in the TAP_SCHEMA ``keys`` and
|
|
467
|
-
``key_columns`` data.
|
|
468
|
-
"""
|
|
469
|
-
|
|
470
|
-
columns: list[str]
|
|
471
|
-
"""The columns comprising the foreign key."""
|
|
472
|
-
|
|
473
|
-
referenced_columns: list[str] = Field(alias="referencedColumns")
|
|
474
|
-
"""The columns referenced by the foreign key."""
|
|
494
|
+
_ConstraintType = Annotated[
|
|
495
|
+
Union[CheckConstraint, ForeignKeyConstraint, UniqueConstraint], Field(discriminator="type")
|
|
496
|
+
]
|
|
497
|
+
"""Type alias for a constraint type."""
|
|
475
498
|
|
|
476
499
|
|
|
477
500
|
class Table(BaseObject):
|
|
@@ -480,7 +503,7 @@ class Table(BaseObject):
|
|
|
480
503
|
columns: Sequence[Column]
|
|
481
504
|
"""Columns in the table."""
|
|
482
505
|
|
|
483
|
-
constraints: list[
|
|
506
|
+
constraints: list[_ConstraintType] = Field(default_factory=list, discriminator="type")
|
|
484
507
|
"""Constraints on the table."""
|
|
485
508
|
|
|
486
509
|
indexes: list[Index] = Field(default_factory=list)
|
|
@@ -492,43 +515,12 @@ class Table(BaseObject):
|
|
|
492
515
|
tap_table_index: int | None = Field(None, alias="tap:table_index")
|
|
493
516
|
"""IVOA TAP_SCHEMA table index of the table."""
|
|
494
517
|
|
|
495
|
-
mysql_engine: str | None = Field(
|
|
518
|
+
mysql_engine: str | None = Field("MyISAM", alias="mysql:engine")
|
|
496
519
|
"""MySQL engine to use for the table."""
|
|
497
520
|
|
|
498
521
|
mysql_charset: str | None = Field(None, alias="mysql:charset")
|
|
499
522
|
"""MySQL charset to use for the table."""
|
|
500
523
|
|
|
501
|
-
@model_validator(mode="before")
|
|
502
|
-
@classmethod
|
|
503
|
-
def create_constraints(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
504
|
-
"""Create specific constraint types from the data in the
|
|
505
|
-
``constraints`` field of a table.
|
|
506
|
-
|
|
507
|
-
Parameters
|
|
508
|
-
----------
|
|
509
|
-
values
|
|
510
|
-
The values of the table containing the constraint data.
|
|
511
|
-
|
|
512
|
-
Returns
|
|
513
|
-
-------
|
|
514
|
-
`dict` [ `str`, `Any` ]
|
|
515
|
-
The values of the table with the constraints converted to their
|
|
516
|
-
respective types.
|
|
517
|
-
"""
|
|
518
|
-
if "constraints" in values:
|
|
519
|
-
new_constraints: list[Constraint] = []
|
|
520
|
-
for item in values["constraints"]:
|
|
521
|
-
if item["@type"] == "ForeignKey":
|
|
522
|
-
new_constraints.append(ForeignKeyConstraint(**item))
|
|
523
|
-
elif item["@type"] == "Unique":
|
|
524
|
-
new_constraints.append(UniqueConstraint(**item))
|
|
525
|
-
elif item["@type"] == "Check":
|
|
526
|
-
new_constraints.append(CheckConstraint(**item))
|
|
527
|
-
else:
|
|
528
|
-
raise ValueError(f"Unknown constraint type: {item['@type']}")
|
|
529
|
-
values["constraints"] = new_constraints
|
|
530
|
-
return values
|
|
531
|
-
|
|
532
524
|
@field_validator("columns", mode="after")
|
|
533
525
|
@classmethod
|
|
534
526
|
def check_unique_column_names(cls, columns: list[Column]) -> list[Column]:
|
|
@@ -723,6 +715,55 @@ class Schema(BaseObject):
|
|
|
723
715
|
id_map: dict[str, Any] = Field(default_factory=dict, exclude=True)
|
|
724
716
|
"""Map of IDs to objects."""
|
|
725
717
|
|
|
718
|
+
@model_validator(mode="before")
|
|
719
|
+
@classmethod
|
|
720
|
+
def generate_ids(cls, values: dict[str, Any], info: ValidationInfo) -> dict[str, Any]:
|
|
721
|
+
"""Generate IDs for objects that do not have them.
|
|
722
|
+
|
|
723
|
+
Parameters
|
|
724
|
+
----------
|
|
725
|
+
values
|
|
726
|
+
The values of the schema.
|
|
727
|
+
info
|
|
728
|
+
Validation context used to determine if ID generation is enabled.
|
|
729
|
+
|
|
730
|
+
Returns
|
|
731
|
+
-------
|
|
732
|
+
`dict` [ `str`, `Any` ]
|
|
733
|
+
The values of the schema with generated IDs.
|
|
734
|
+
"""
|
|
735
|
+
context = info.context
|
|
736
|
+
if not context or not context.get("id_generation", False):
|
|
737
|
+
logger.debug("Skipping ID generation")
|
|
738
|
+
return values
|
|
739
|
+
schema_name = values["name"]
|
|
740
|
+
if "@id" not in values:
|
|
741
|
+
values["@id"] = f"#{schema_name}"
|
|
742
|
+
logger.debug(f"Generated ID '{values['@id']}' for schema '{schema_name}'")
|
|
743
|
+
if "tables" in values:
|
|
744
|
+
for table in values["tables"]:
|
|
745
|
+
if "@id" not in table:
|
|
746
|
+
table["@id"] = f"#{table['name']}"
|
|
747
|
+
logger.debug(f"Generated ID '{table['@id']}' for table '{table['name']}'")
|
|
748
|
+
if "columns" in table:
|
|
749
|
+
for column in table["columns"]:
|
|
750
|
+
if "@id" not in column:
|
|
751
|
+
column["@id"] = f"#{table['name']}.{column['name']}"
|
|
752
|
+
logger.debug(f"Generated ID '{column['@id']}' for column '{column['name']}'")
|
|
753
|
+
if "constraints" in table:
|
|
754
|
+
for constraint in table["constraints"]:
|
|
755
|
+
if "@id" not in constraint:
|
|
756
|
+
constraint["@id"] = f"#{constraint['name']}"
|
|
757
|
+
logger.debug(
|
|
758
|
+
f"Generated ID '{constraint['@id']}' for constraint '{constraint['name']}'"
|
|
759
|
+
)
|
|
760
|
+
if "indexes" in table:
|
|
761
|
+
for index in table["indexes"]:
|
|
762
|
+
if "@id" not in index:
|
|
763
|
+
index["@id"] = f"#{index['name']}"
|
|
764
|
+
logger.debug(f"Generated ID '{index['@id']}' for index '{index['name']}'")
|
|
765
|
+
return values
|
|
766
|
+
|
|
726
767
|
@field_validator("tables", mode="after")
|
|
727
768
|
@classmethod
|
|
728
769
|
def check_unique_table_names(cls, tables: list[Table]) -> list[Table]:
|
|
@@ -773,6 +814,66 @@ class Schema(BaseObject):
|
|
|
773
814
|
table_indicies.add(table_index)
|
|
774
815
|
return self
|
|
775
816
|
|
|
817
|
+
@model_validator(mode="after")
|
|
818
|
+
def check_unique_constraint_names(self: Schema) -> Schema:
|
|
819
|
+
"""Check for duplicate constraint names in the schema.
|
|
820
|
+
|
|
821
|
+
Returns
|
|
822
|
+
-------
|
|
823
|
+
`Schema`
|
|
824
|
+
The schema being validated.
|
|
825
|
+
|
|
826
|
+
Raises
|
|
827
|
+
------
|
|
828
|
+
ValueError
|
|
829
|
+
Raised if duplicate constraint names are found in the schema.
|
|
830
|
+
"""
|
|
831
|
+
constraint_names = set()
|
|
832
|
+
duplicate_names = []
|
|
833
|
+
|
|
834
|
+
for table in self.tables:
|
|
835
|
+
for constraint in table.constraints:
|
|
836
|
+
constraint_name = constraint.name
|
|
837
|
+
if constraint_name in constraint_names:
|
|
838
|
+
duplicate_names.append(constraint_name)
|
|
839
|
+
else:
|
|
840
|
+
constraint_names.add(constraint_name)
|
|
841
|
+
|
|
842
|
+
if duplicate_names:
|
|
843
|
+
raise ValueError(f"Duplicate constraint names found in schema: {duplicate_names}")
|
|
844
|
+
|
|
845
|
+
return self
|
|
846
|
+
|
|
847
|
+
@model_validator(mode="after")
|
|
848
|
+
def check_unique_index_names(self: Schema) -> Schema:
|
|
849
|
+
"""Check for duplicate index names in the schema.
|
|
850
|
+
|
|
851
|
+
Returns
|
|
852
|
+
-------
|
|
853
|
+
`Schema`
|
|
854
|
+
The schema being validated.
|
|
855
|
+
|
|
856
|
+
Raises
|
|
857
|
+
------
|
|
858
|
+
ValueError
|
|
859
|
+
Raised if duplicate index names are found in the schema.
|
|
860
|
+
"""
|
|
861
|
+
index_names = set()
|
|
862
|
+
duplicate_names = []
|
|
863
|
+
|
|
864
|
+
for table in self.tables:
|
|
865
|
+
for index in table.indexes:
|
|
866
|
+
index_name = index.name
|
|
867
|
+
if index_name in index_names:
|
|
868
|
+
duplicate_names.append(index_name)
|
|
869
|
+
else:
|
|
870
|
+
index_names.add(index_name)
|
|
871
|
+
|
|
872
|
+
if duplicate_names:
|
|
873
|
+
raise ValueError(f"Duplicate index names found in schema: {duplicate_names}")
|
|
874
|
+
|
|
875
|
+
return self
|
|
876
|
+
|
|
776
877
|
def _create_id_map(self: Schema) -> Schema:
|
|
777
878
|
"""Create a map of IDs to objects.
|
|
778
879
|
|
|
@@ -342,7 +342,6 @@ class MetaDataBuilder:
|
|
|
342
342
|
"initially": constraint_obj.initially or None,
|
|
343
343
|
}
|
|
344
344
|
constraint: Constraint
|
|
345
|
-
constraint_type = constraint_obj.type
|
|
346
345
|
|
|
347
346
|
if isinstance(constraint_obj, datamodel.ForeignKeyConstraint):
|
|
348
347
|
fk_obj: datamodel.ForeignKeyConstraint = constraint_obj
|
|
@@ -358,7 +357,7 @@ class MetaDataBuilder:
|
|
|
358
357
|
columns = [self._objects[column_id] for column_id in uniq_obj.columns]
|
|
359
358
|
constraint = UniqueConstraint(*columns, **args)
|
|
360
359
|
else:
|
|
361
|
-
raise ValueError(f"Unknown constraint type: {
|
|
360
|
+
raise ValueError(f"Unknown constraint type: {type(constraint_obj)}")
|
|
362
361
|
|
|
363
362
|
self._objects[constraint_obj.id] = constraint
|
|
364
363
|
|
|
@@ -37,7 +37,7 @@ from sqlalchemy.sql.expression import Insert, insert
|
|
|
37
37
|
|
|
38
38
|
from felis import datamodel
|
|
39
39
|
|
|
40
|
-
from .datamodel import Constraint, Index, Schema, Table
|
|
40
|
+
from .datamodel import Constraint, ForeignKeyConstraint, Index, Schema, Table
|
|
41
41
|
from .types import FelisType
|
|
42
42
|
|
|
43
43
|
__all__ = ["TapLoadingVisitor", "init_tables"]
|
|
@@ -480,10 +480,9 @@ class TapLoadingVisitor:
|
|
|
480
480
|
A tuple of the SQLAlchemy ORM objects for the TAP_SCHEMA ``key``
|
|
481
481
|
and ``key_columns`` data.
|
|
482
482
|
"""
|
|
483
|
-
constraint_type = constraint_obj.type
|
|
484
483
|
key = None
|
|
485
484
|
key_columns = []
|
|
486
|
-
if
|
|
485
|
+
if isinstance(constraint_obj, ForeignKeyConstraint):
|
|
487
486
|
constraint_name = constraint_obj.name
|
|
488
487
|
description = constraint_obj.description
|
|
489
488
|
utype = constraint_obj.votable_utype
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 27.2024.
|
|
3
|
+
Version: 27.2024.3500
|
|
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+)
|
|
@@ -31,7 +31,6 @@ from felis.db.dialects import get_supported_dialects
|
|
|
31
31
|
|
|
32
32
|
TESTDIR = os.path.abspath(os.path.dirname(__file__))
|
|
33
33
|
TEST_YAML = os.path.join(TESTDIR, "data", "test.yml")
|
|
34
|
-
TEST_MERGE_YAML = os.path.join(TESTDIR, "data", "test-merge.yml")
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
class CliTestCase(unittest.TestCase):
|
|
@@ -126,6 +125,22 @@ class CliTestCase(unittest.TestCase):
|
|
|
126
125
|
result = runner.invoke(cli, ["validate", TEST_YAML], catch_exceptions=False)
|
|
127
126
|
self.assertEqual(result.exit_code, 0)
|
|
128
127
|
|
|
128
|
+
def test_id_generation(self) -> None:
|
|
129
|
+
"""Test the ``--id-generation`` flag."""
|
|
130
|
+
test_yaml = os.path.join(TESTDIR, "data", "test_id_generation.yaml")
|
|
131
|
+
runner = CliRunner()
|
|
132
|
+
result = runner.invoke(cli, ["--id-generation", "validate", test_yaml], catch_exceptions=False)
|
|
133
|
+
self.assertEqual(result.exit_code, 0)
|
|
134
|
+
|
|
135
|
+
def test_no_id_generation(self) -> None:
|
|
136
|
+
"""Test that loading a schema without IDs fails if ID generation is not
|
|
137
|
+
enabled.
|
|
138
|
+
"""
|
|
139
|
+
test_yaml = os.path.join(TESTDIR, "data", "test_id_generation.yaml")
|
|
140
|
+
runner = CliRunner()
|
|
141
|
+
result = runner.invoke(cli, ["validate", test_yaml], catch_exceptions=False)
|
|
142
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
143
|
+
|
|
129
144
|
def test_validation_flags(self) -> None:
|
|
130
145
|
"""Test schema validation flags."""
|
|
131
146
|
runner = CliRunner()
|
|
@@ -29,6 +29,7 @@ from pydantic import ValidationError
|
|
|
29
29
|
from felis.datamodel import (
|
|
30
30
|
CheckConstraint,
|
|
31
31
|
Column,
|
|
32
|
+
Constraint,
|
|
32
33
|
DataType,
|
|
33
34
|
ForeignKeyConstraint,
|
|
34
35
|
Index,
|
|
@@ -258,140 +259,150 @@ class TableTestCase(unittest.TestCase):
|
|
|
258
259
|
class ConstraintTestCase(unittest.TestCase):
|
|
259
260
|
"""Test Pydantic validation of the different constraint classes."""
|
|
260
261
|
|
|
261
|
-
def
|
|
262
|
-
"""Test validation of
|
|
262
|
+
def test_base_constraint(self) -> None:
|
|
263
|
+
"""Test validation of base constraint type."""
|
|
263
264
|
# Default initialization should throw an exception.
|
|
264
265
|
with self.assertRaises(ValidationError):
|
|
265
|
-
|
|
266
|
+
Constraint()
|
|
266
267
|
|
|
267
268
|
# Setting only name should throw an exception.
|
|
268
269
|
with self.assertRaises(ValidationError):
|
|
269
|
-
|
|
270
|
+
Constraint(name="test_constraint")
|
|
270
271
|
|
|
271
|
-
# Setting name and id should throw an exception
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# Setting name, id, and columns should not throw an exception and
|
|
276
|
-
# should load data correctly.
|
|
277
|
-
col = UniqueConstraint(name="testConstraint", id="#test_id", columns=["testColumn"])
|
|
278
|
-
self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
|
|
279
|
-
self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
|
|
280
|
-
self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
|
|
281
|
-
|
|
282
|
-
# Creating from data dictionary should work and load data correctly.
|
|
283
|
-
data = {"name": "testConstraint", "id": "#test_id", "columns": ["testColumn"]}
|
|
284
|
-
col = UniqueConstraint(**data)
|
|
285
|
-
self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
|
|
286
|
-
self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
|
|
287
|
-
self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
|
|
272
|
+
# Setting name and id should not throw an exception and should load
|
|
273
|
+
# data correctly.
|
|
274
|
+
Constraint(name="test_constraint", id="#test_constraint")
|
|
288
275
|
|
|
289
|
-
|
|
290
|
-
"""Test validation of indexes."""
|
|
291
|
-
# Default initialization should throw an exception.
|
|
276
|
+
# Setting initially without deferrable should throw an exception.
|
|
292
277
|
with self.assertRaises(ValidationError):
|
|
293
|
-
|
|
278
|
+
Constraint(name="test_constraint", id="#test_constraint", deferrable=False, initially="IMMEDIATE")
|
|
294
279
|
|
|
295
|
-
#
|
|
280
|
+
# Seting a bad value for initially should throw an exception.
|
|
296
281
|
with self.assertRaises(ValidationError):
|
|
297
|
-
|
|
282
|
+
Constraint(name="test_constraint", id="#test_constraint", deferrable=True, initially="BAD_VALUE")
|
|
283
|
+
|
|
284
|
+
# Setting a valid value for initially should not throw an exception.
|
|
285
|
+
Constraint(name="test_constraint", id="#test_constraint", deferrable=True, initially="IMMEDIATE")
|
|
286
|
+
Constraint(name="test_constraint", id="#test_constraint", deferrable=True, initially="DEFERRED")
|
|
298
287
|
|
|
288
|
+
def test_unique_constraint(self) -> None:
|
|
289
|
+
"""Test validation of unique constraints."""
|
|
299
290
|
# Setting name and id should throw an exception from missing columns.
|
|
300
291
|
with self.assertRaises(ValidationError):
|
|
301
|
-
|
|
292
|
+
UniqueConstraint(name="test_constraint", id="#test_constraint")
|
|
302
293
|
|
|
303
294
|
# Setting name, id, and columns should not throw an exception and
|
|
304
295
|
# should load data correctly.
|
|
305
|
-
|
|
306
|
-
self.assertEqual(
|
|
307
|
-
self.assertEqual(
|
|
308
|
-
self.assertEqual(
|
|
296
|
+
constraint = UniqueConstraint(name="uniq_test", id="#uniq_test", columns=["test_column"])
|
|
297
|
+
self.assertEqual(constraint.name, "uniq_test", "name should be 'uniq_test'")
|
|
298
|
+
self.assertEqual(constraint.id, "#uniq_test", "id should be '#uniq_test'")
|
|
299
|
+
self.assertEqual(constraint.columns, ["test_column"], "columns should be ['test_column']")
|
|
309
300
|
|
|
310
301
|
# Creating from data dictionary should work and load data correctly.
|
|
311
|
-
data = {"name": "
|
|
312
|
-
|
|
313
|
-
self.assertEqual(
|
|
314
|
-
self.assertEqual(
|
|
315
|
-
self.assertEqual(
|
|
316
|
-
|
|
317
|
-
# Setting both columns and expressions on an index should throw an
|
|
318
|
-
# exception.
|
|
319
|
-
with self.assertRaises(ValidationError):
|
|
320
|
-
Index(name="testConstraint", id="#test_id", columns=["testColumn"], expressions=["1+2"])
|
|
302
|
+
data = {"name": "uniq_test", "id": "#uniq_test", "columns": ["test_column"]}
|
|
303
|
+
constraint = UniqueConstraint(**data)
|
|
304
|
+
self.assertEqual(constraint.name, "uniq_test", "name should be 'uniq_test'")
|
|
305
|
+
self.assertEqual(constraint.id, "#uniq_test", "id should be '#uniq_test'")
|
|
306
|
+
self.assertEqual(constraint.columns, ["test_column"], "columns should be ['test_column']")
|
|
321
307
|
|
|
322
|
-
def
|
|
308
|
+
def test_foreign_key_constraint(self) -> None:
|
|
323
309
|
"""Test validation of foreign key constraints."""
|
|
324
|
-
# Default initialization should throw an exception.
|
|
325
|
-
with self.assertRaises(ValidationError):
|
|
326
|
-
ForeignKeyConstraint()
|
|
327
|
-
|
|
328
|
-
# Setting only name should throw an exception.
|
|
329
|
-
with self.assertRaises(ValidationError):
|
|
330
|
-
ForeignKeyConstraint(name="testConstraint")
|
|
331
|
-
|
|
332
310
|
# Setting name and id should throw an exception from missing columns.
|
|
333
311
|
with self.assertRaises(ValidationError):
|
|
334
|
-
ForeignKeyConstraint(name="
|
|
312
|
+
ForeignKeyConstraint(name="fk_test", id="#fk_test")
|
|
335
313
|
|
|
336
314
|
# Setting name, id, and columns should not throw an exception and
|
|
337
315
|
# should load data correctly.
|
|
338
|
-
|
|
339
|
-
name="
|
|
316
|
+
constraint = ForeignKeyConstraint(
|
|
317
|
+
name="fk_test", id="#fk_test", columns=["test_column"], referenced_columns=["test_column"]
|
|
340
318
|
)
|
|
341
|
-
self.assertEqual(
|
|
342
|
-
self.assertEqual(
|
|
343
|
-
self.assertEqual(
|
|
319
|
+
self.assertEqual(constraint.name, "fk_test", "name should be 'fk_test'")
|
|
320
|
+
self.assertEqual(constraint.id, "#fk_test", "id should be '#fk_test'")
|
|
321
|
+
self.assertEqual(constraint.columns, ["test_column"], "columns should be ['test_column']")
|
|
344
322
|
self.assertEqual(
|
|
345
|
-
|
|
323
|
+
constraint.referenced_columns, ["test_column"], "referenced_columns should be ['test_column']"
|
|
346
324
|
)
|
|
347
325
|
|
|
348
326
|
# Creating from data dictionary should work and load data correctly.
|
|
349
327
|
data = {
|
|
350
|
-
"name": "
|
|
351
|
-
"id": "#
|
|
352
|
-
"columns": ["
|
|
353
|
-
"referenced_columns": ["
|
|
328
|
+
"name": "fk_test",
|
|
329
|
+
"id": "#fk_test",
|
|
330
|
+
"columns": ["test_column"],
|
|
331
|
+
"referenced_columns": ["test_column"],
|
|
354
332
|
}
|
|
355
|
-
|
|
356
|
-
self.assertEqual(
|
|
357
|
-
self.assertEqual(
|
|
358
|
-
self.assertEqual(
|
|
333
|
+
constraint = ForeignKeyConstraint(**data)
|
|
334
|
+
self.assertEqual(constraint.name, "fk_test", "name should be 'fk_test'")
|
|
335
|
+
self.assertEqual(constraint.id, "#fk_test", "id should be '#fk_test'")
|
|
336
|
+
self.assertEqual(constraint.columns, ["test_column"], "columns should be ['test_column']")
|
|
359
337
|
self.assertEqual(
|
|
360
|
-
|
|
338
|
+
constraint.referenced_columns, ["test_column"], "referenced_columns should be ['test_column']"
|
|
361
339
|
)
|
|
362
340
|
|
|
363
|
-
def
|
|
341
|
+
def test_check_constraint(self) -> None:
|
|
364
342
|
"""Test validation of check constraints."""
|
|
365
|
-
# Default initialization should throw an exception.
|
|
366
|
-
with self.assertRaises(ValidationError):
|
|
367
|
-
CheckConstraint()
|
|
368
|
-
|
|
369
|
-
# Setting only name should throw an exception.
|
|
370
|
-
with self.assertRaises(ValidationError):
|
|
371
|
-
CheckConstraint(name="testConstraint")
|
|
372
|
-
|
|
373
343
|
# Setting name and id should throw an exception from missing
|
|
374
344
|
# expression.
|
|
375
345
|
with self.assertRaises(ValidationError):
|
|
376
|
-
CheckConstraint(name="
|
|
346
|
+
CheckConstraint(name="check_test", id="#check_test")
|
|
377
347
|
|
|
378
348
|
# Setting name, id, and expression should not throw an exception and
|
|
379
349
|
# should load data correctly.
|
|
380
|
-
|
|
381
|
-
self.assertEqual(
|
|
382
|
-
self.assertEqual(
|
|
383
|
-
self.assertEqual(
|
|
350
|
+
constraint = CheckConstraint(name="check_test", id="#check_test", expression="1+2")
|
|
351
|
+
self.assertEqual(constraint.name, "check_test", "name should be 'check_test'")
|
|
352
|
+
self.assertEqual(constraint.id, "#check_test", "id should be '#check_test'")
|
|
353
|
+
self.assertEqual(constraint.expression, "1+2", "expression should be '1+2'")
|
|
384
354
|
|
|
385
355
|
# Creating from data dictionary should work and load data correctly.
|
|
386
356
|
data = {
|
|
387
|
-
"name": "
|
|
388
|
-
"id": "#
|
|
357
|
+
"name": "check_test",
|
|
358
|
+
"id": "#check_test",
|
|
389
359
|
"expression": "1+2",
|
|
390
360
|
}
|
|
391
|
-
|
|
392
|
-
self.assertEqual(
|
|
393
|
-
self.assertEqual(
|
|
394
|
-
self.assertEqual(
|
|
361
|
+
constraint = CheckConstraint(**data)
|
|
362
|
+
self.assertEqual(constraint.name, "check_test", "name should be 'check_test'")
|
|
363
|
+
self.assertEqual(constraint.id, "#check_test", "id should be '#test_id'")
|
|
364
|
+
self.assertEqual(constraint.expression, "1+2", "expression should be '1+2'")
|
|
365
|
+
|
|
366
|
+
def test_bad_constraint_type(self) -> None:
|
|
367
|
+
with self.assertRaises(ValidationError):
|
|
368
|
+
UniqueConstraint(name="uniq_test", id="#uniq_test", columns=["test_column"], type="BAD_TYPE")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class IndexTestCase(unittest.TestCase):
|
|
372
|
+
"""Test Pydantic validation of the ``Index`` class."""
|
|
373
|
+
|
|
374
|
+
def test_index_validation(self) -> None:
|
|
375
|
+
"""Test validation of indexes."""
|
|
376
|
+
# Default initialization should throw an exception.
|
|
377
|
+
with self.assertRaises(ValidationError):
|
|
378
|
+
Index()
|
|
379
|
+
|
|
380
|
+
# Setting only name should throw an exception.
|
|
381
|
+
with self.assertRaises(ValidationError):
|
|
382
|
+
Index(name="idx_test")
|
|
383
|
+
|
|
384
|
+
# Setting name and id should throw an exception from missing columns.
|
|
385
|
+
with self.assertRaises(ValidationError):
|
|
386
|
+
Index(name="idx_test", id="#idx_test")
|
|
387
|
+
|
|
388
|
+
# Setting name, id, and columns should not throw an exception and
|
|
389
|
+
# should load data correctly.
|
|
390
|
+
idx = Index(name="idx_test", id="#idx_test", columns=["#test_column"])
|
|
391
|
+
self.assertEqual(idx.name, "idx_test", "name should be 'test_constraint'")
|
|
392
|
+
self.assertEqual(idx.id, "#idx_test", "id should be '#test_id'")
|
|
393
|
+
self.assertEqual(idx.columns, ["#test_column"], "columns should be ['test_column']")
|
|
394
|
+
|
|
395
|
+
# Creating from data dictionary should work and load data correctly.
|
|
396
|
+
data = {"name": "idx_test", "id": "#idx_test", "columns": ["test_column"]}
|
|
397
|
+
idx = Index(**data)
|
|
398
|
+
self.assertEqual(idx.name, "idx_test", "name should be 'idx_test'")
|
|
399
|
+
self.assertEqual(idx.id, "#idx_test", "id should be '#idx_test'")
|
|
400
|
+
self.assertEqual(idx.columns, ["test_column"], "columns should be ['test_column']")
|
|
401
|
+
|
|
402
|
+
# Setting both columns and expressions on an index should throw an
|
|
403
|
+
# exception.
|
|
404
|
+
with self.assertRaises(ValidationError):
|
|
405
|
+
Index(name="idx_test", id="#idx_test", columns=["test_column"], expressions=["1+2"])
|
|
395
406
|
|
|
396
407
|
|
|
397
408
|
class SchemaTestCase(unittest.TestCase):
|
|
@@ -466,12 +477,48 @@ class SchemaTestCase(unittest.TestCase):
|
|
|
466
477
|
# Test that an invalid id raises an exception.
|
|
467
478
|
sch["#bad_id"]
|
|
468
479
|
|
|
480
|
+
def test_check_unique_constraint_names(self) -> None:
|
|
481
|
+
"""Test that constraint names are unique."""
|
|
482
|
+
test_col = Column(name="testColumn", id="#test_col_id", datatype="string", length=256)
|
|
483
|
+
test_tbl = Table(name="testTable", id="#test_table_id", columns=[test_col])
|
|
484
|
+
test_cons = UniqueConstraint(name="testConstraint", id="#test_constraint_id", columns=["testColumn"])
|
|
485
|
+
test_cons2 = UniqueConstraint(
|
|
486
|
+
name="testConstraint", id="#test_constraint2_id", columns=["testColumn"]
|
|
487
|
+
)
|
|
488
|
+
test_tbl.constraints = [test_cons, test_cons2]
|
|
489
|
+
with self.assertRaises(ValidationError):
|
|
490
|
+
Schema(name="testSchema", id="#test_id", tables=[test_tbl])
|
|
491
|
+
|
|
492
|
+
def test_check_unique_index_names(self) -> None:
|
|
493
|
+
"""Test that index names are unique."""
|
|
494
|
+
test_col = Column(name="test_column1", id="#test_table#test_column1", datatype="int")
|
|
495
|
+
test_col2 = Column(name="test_column2", id="##test_table#test_column2", datatype="string", length=256)
|
|
496
|
+
test_tbl = Table(name="test_table", id="#test_table", columns=[test_col, test_col2])
|
|
497
|
+
test_idx = Index(name="idx_test", id="#idx_test", columns=[test_col.id])
|
|
498
|
+
test_idx2 = Index(name="idx_test", id="#idx_test2", columns=[test_col2.id])
|
|
499
|
+
test_tbl.indexes = [test_idx, test_idx2]
|
|
500
|
+
with self.assertRaises(ValidationError):
|
|
501
|
+
Schema(name="test_schema", id="#test-schema", tables=[test_tbl])
|
|
502
|
+
|
|
469
503
|
def test_model_validate(self) -> None:
|
|
470
504
|
"""Load a YAML test file and validate the schema data model."""
|
|
471
505
|
with open(TEST_YAML) as test_yaml:
|
|
472
506
|
data = yaml.safe_load(test_yaml)
|
|
473
507
|
Schema.model_validate(data)
|
|
474
508
|
|
|
509
|
+
def test_id_generation(self) -> None:
|
|
510
|
+
"""Test ID generation."""
|
|
511
|
+
test_path = os.path.join(TESTDIR, "data", "test_id_generation.yaml")
|
|
512
|
+
with open(test_path) as test_yaml:
|
|
513
|
+
yaml_data = yaml.safe_load(test_yaml)
|
|
514
|
+
# Generate IDs for objects in the test schema.
|
|
515
|
+
Schema.model_validate(yaml_data, context={"id_generation": True})
|
|
516
|
+
with open(test_path) as test_yaml:
|
|
517
|
+
yaml_data = yaml.safe_load(test_yaml)
|
|
518
|
+
# Test that an error is raised when id generation is disabled.
|
|
519
|
+
with self.assertRaises(ValidationError):
|
|
520
|
+
Schema.model_validate(yaml_data, context={"id_generation": False})
|
|
521
|
+
|
|
475
522
|
|
|
476
523
|
class SchemaVersionTest(unittest.TestCase):
|
|
477
524
|
"""Test the schema version."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{lsst_felis-27.2024.3300 → lsst_felis-27.2024.3500}/python/lsst_felis.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|