lsst-felis 27.2024.2400__py3-none-any.whl → 27.2024.2600__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 +162 -45
- felis/datamodel.py +377 -95
- felis/db/dialects.py +65 -12
- felis/db/sqltypes.py +255 -16
- felis/db/utils.py +108 -52
- felis/db/variants.py +66 -8
- felis/metadata.py +70 -54
- felis/tap.py +180 -18
- felis/types.py +56 -8
- felis/version.py +1 -1
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/COPYRIGHT +1 -1
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/METADATA +4 -2
- lsst_felis-27.2024.2600.dist-info/RECORD +21 -0
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/WHEEL +1 -1
- felis/validation.py +0 -103
- lsst_felis-27.2024.2400.dist-info/RECORD +0 -22
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/LICENSE +0 -0
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/entry_points.txt +0 -0
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/top_level.txt +0 -0
- {lsst_felis-27.2024.2400.dist-info → lsst_felis-27.2024.2600.dist-info}/zip-safe +0 -0
felis/datamodel.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Define Pydantic data models for Felis."""
|
|
2
|
+
|
|
1
3
|
# This file is part of felis.
|
|
2
4
|
#
|
|
3
5
|
# Developed for the LSST Data Management System.
|
|
@@ -42,7 +44,6 @@ __all__ = (
|
|
|
42
44
|
"Column",
|
|
43
45
|
"CheckConstraint",
|
|
44
46
|
"Constraint",
|
|
45
|
-
"DescriptionStr",
|
|
46
47
|
"ForeignKeyConstraint",
|
|
47
48
|
"Index",
|
|
48
49
|
"Schema",
|
|
@@ -64,39 +65,47 @@ DESCR_MIN_LENGTH = 3
|
|
|
64
65
|
"""Minimum length for a description field."""
|
|
65
66
|
|
|
66
67
|
DescriptionStr: TypeAlias = Annotated[str, Field(min_length=DESCR_MIN_LENGTH)]
|
|
67
|
-
"""
|
|
68
|
-
characters long. Stripping of whitespace is done globally on all str fields."""
|
|
68
|
+
"""Type for a description, which must be three or more characters long."""
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
class BaseObject(BaseModel):
|
|
72
|
-
"""Base
|
|
72
|
+
"""Base model.
|
|
73
|
+
|
|
74
|
+
All classes representing objects in the Felis data model should inherit
|
|
75
|
+
from this class.
|
|
76
|
+
"""
|
|
73
77
|
|
|
74
78
|
model_config = CONFIG
|
|
75
79
|
"""Pydantic model configuration."""
|
|
76
80
|
|
|
77
81
|
name: str
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
All Felis database objects must have a name.
|
|
81
|
-
"""
|
|
82
|
+
"""Name of the database object."""
|
|
82
83
|
|
|
83
84
|
id: str = Field(alias="@id")
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
All Felis database objects must have a unique identifier.
|
|
87
|
-
"""
|
|
85
|
+
"""Unique identifier of the database object."""
|
|
88
86
|
|
|
89
87
|
description: DescriptionStr | None = None
|
|
90
|
-
"""
|
|
88
|
+
"""Description of the database object."""
|
|
91
89
|
|
|
92
90
|
votable_utype: str | None = Field(None, alias="votable:utype")
|
|
93
|
-
"""
|
|
91
|
+
"""VOTable utype (usage-specific or unique type) of the object."""
|
|
94
92
|
|
|
95
93
|
@model_validator(mode="after")
|
|
96
94
|
def check_description(self, info: ValidationInfo) -> BaseObject:
|
|
97
|
-
"""Check that the description is present if required.
|
|
95
|
+
"""Check that the description is present if required.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
info
|
|
100
|
+
Validation context used to determine if the check is enabled.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
`BaseObject`
|
|
105
|
+
The object being validated.
|
|
106
|
+
"""
|
|
98
107
|
context = info.context
|
|
99
|
-
if not context or not context.get("
|
|
108
|
+
if not context or not context.get("check_description", False):
|
|
100
109
|
return self
|
|
101
110
|
if self.description is None or self.description == "":
|
|
102
111
|
raise ValueError("Description is required and must be non-empty")
|
|
@@ -124,61 +133,66 @@ class DataType(StrEnum):
|
|
|
124
133
|
|
|
125
134
|
|
|
126
135
|
class Column(BaseObject):
|
|
127
|
-
"""
|
|
136
|
+
"""Column model."""
|
|
128
137
|
|
|
129
138
|
datatype: DataType
|
|
130
|
-
"""
|
|
139
|
+
"""Datatype of the column."""
|
|
131
140
|
|
|
132
141
|
length: int | None = Field(None, gt=0)
|
|
133
|
-
"""
|
|
142
|
+
"""Length of the column."""
|
|
134
143
|
|
|
135
144
|
nullable: bool = True
|
|
136
145
|
"""Whether the column can be ``NULL``."""
|
|
137
146
|
|
|
138
147
|
value: str | int | float | bool | None = None
|
|
139
|
-
"""
|
|
148
|
+
"""Default value of the column."""
|
|
140
149
|
|
|
141
150
|
autoincrement: bool | None = None
|
|
142
151
|
"""Whether the column is autoincremented."""
|
|
143
152
|
|
|
144
153
|
mysql_datatype: str | None = Field(None, alias="mysql:datatype")
|
|
145
|
-
"""
|
|
154
|
+
"""MySQL datatype override on the column."""
|
|
146
155
|
|
|
147
156
|
postgresql_datatype: str | None = Field(None, alias="postgresql:datatype")
|
|
148
|
-
"""
|
|
157
|
+
"""PostgreSQL datatype override on the column."""
|
|
149
158
|
|
|
150
159
|
ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
|
|
151
|
-
"""
|
|
160
|
+
"""IVOA UCD of the column."""
|
|
152
161
|
|
|
153
162
|
fits_tunit: str | None = Field(None, alias="fits:tunit")
|
|
154
|
-
"""
|
|
163
|
+
"""FITS TUNIT of the column."""
|
|
155
164
|
|
|
156
165
|
ivoa_unit: str | None = Field(None, alias="ivoa:unit")
|
|
157
|
-
"""
|
|
166
|
+
"""IVOA unit of the column."""
|
|
158
167
|
|
|
159
168
|
tap_column_index: int | None = Field(None, alias="tap:column_index")
|
|
160
|
-
"""
|
|
169
|
+
"""TAP_SCHEMA column index of the column."""
|
|
161
170
|
|
|
162
171
|
tap_principal: int | None = Field(0, alias="tap:principal", ge=0, le=1)
|
|
163
|
-
"""Whether this is a TAP_SCHEMA principal column
|
|
164
|
-
"""
|
|
172
|
+
"""Whether this is a TAP_SCHEMA principal column."""
|
|
165
173
|
|
|
166
174
|
votable_arraysize: int | Literal["*"] | None = Field(None, alias="votable:arraysize")
|
|
167
|
-
"""
|
|
175
|
+
"""VOTable arraysize of the column."""
|
|
168
176
|
|
|
169
177
|
tap_std: int | None = Field(0, alias="tap:std", ge=0, le=1)
|
|
170
178
|
"""TAP_SCHEMA indication that this column is defined by an IVOA standard.
|
|
171
179
|
"""
|
|
172
180
|
|
|
173
181
|
votable_xtype: str | None = Field(None, alias="votable:xtype")
|
|
174
|
-
"""
|
|
182
|
+
"""VOTable xtype (extended type) of the column."""
|
|
175
183
|
|
|
176
184
|
votable_datatype: str | None = Field(None, alias="votable:datatype")
|
|
177
|
-
"""
|
|
185
|
+
"""VOTable datatype of the column."""
|
|
178
186
|
|
|
179
187
|
@model_validator(mode="after")
|
|
180
188
|
def check_value(self) -> Column:
|
|
181
|
-
"""Check that the default value is valid.
|
|
189
|
+
"""Check that the default value is valid.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
`Column`
|
|
194
|
+
The column being validated.
|
|
195
|
+
"""
|
|
182
196
|
if (value := self.value) is not None:
|
|
183
197
|
if value is not None and self.autoincrement is True:
|
|
184
198
|
raise ValueError("Column cannot have both a default value and be autoincremented")
|
|
@@ -200,7 +214,18 @@ class Column(BaseObject):
|
|
|
200
214
|
@field_validator("ivoa_ucd")
|
|
201
215
|
@classmethod
|
|
202
216
|
def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
|
|
203
|
-
"""Check that IVOA UCD values are valid.
|
|
217
|
+
"""Check that IVOA UCD values are valid.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
ivoa_ucd
|
|
222
|
+
IVOA UCD value to check.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
`str`
|
|
227
|
+
The IVOA UCD value if it is valid.
|
|
228
|
+
"""
|
|
204
229
|
if ivoa_ucd is not None:
|
|
205
230
|
try:
|
|
206
231
|
ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
|
|
@@ -208,12 +233,24 @@ class Column(BaseObject):
|
|
|
208
233
|
raise ValueError(f"Invalid IVOA UCD: {e}")
|
|
209
234
|
return ivoa_ucd
|
|
210
235
|
|
|
211
|
-
@model_validator(mode="
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
236
|
+
@model_validator(mode="after")
|
|
237
|
+
def check_units(self) -> Column:
|
|
238
|
+
"""Check that the ``fits:tunit`` or ``ivoa:unit`` field has valid
|
|
239
|
+
units according to astropy. Only one may be provided.
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
`Column`
|
|
244
|
+
The column being validated.
|
|
245
|
+
|
|
246
|
+
Raises
|
|
247
|
+
------
|
|
248
|
+
ValueError
|
|
249
|
+
If both FITS and IVOA units are provided, or if the unit is
|
|
250
|
+
invalid.
|
|
251
|
+
"""
|
|
252
|
+
fits_unit = self.fits_tunit
|
|
253
|
+
ivoa_unit = self.ivoa_unit
|
|
217
254
|
|
|
218
255
|
if fits_unit and ivoa_unit:
|
|
219
256
|
raise ValueError("Column cannot have both FITS and IVOA units")
|
|
@@ -225,12 +262,28 @@ class Column(BaseObject):
|
|
|
225
262
|
except ValueError as e:
|
|
226
263
|
raise ValueError(f"Invalid unit: {e}")
|
|
227
264
|
|
|
228
|
-
return
|
|
265
|
+
return self
|
|
229
266
|
|
|
230
267
|
@model_validator(mode="before")
|
|
231
268
|
@classmethod
|
|
232
269
|
def check_length(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
233
|
-
"""Check that a valid length is provided for sized types.
|
|
270
|
+
"""Check that a valid length is provided for sized types.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
values
|
|
275
|
+
Values of the column.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
`dict` [ `str`, `Any` ]
|
|
280
|
+
The values of the column.
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
ValueError
|
|
285
|
+
If a length is not provided for a sized type.
|
|
286
|
+
"""
|
|
234
287
|
datatype = values.get("datatype")
|
|
235
288
|
if datatype is None:
|
|
236
289
|
# Skip this validation if datatype is not provided
|
|
@@ -250,8 +303,24 @@ class Column(BaseObject):
|
|
|
250
303
|
return values
|
|
251
304
|
|
|
252
305
|
@model_validator(mode="after")
|
|
253
|
-
def
|
|
254
|
-
"""Check for redundant datatypes on columns.
|
|
306
|
+
def check_redundant_datatypes(self, info: ValidationInfo) -> Column:
|
|
307
|
+
"""Check for redundant datatypes on columns.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
info
|
|
312
|
+
Validation context used to determine if the check is enabled.
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
`Column`
|
|
317
|
+
The column being validated.
|
|
318
|
+
|
|
319
|
+
Raises
|
|
320
|
+
------
|
|
321
|
+
ValueError
|
|
322
|
+
If a datatype override is redundant.
|
|
323
|
+
"""
|
|
255
324
|
context = info.context
|
|
256
325
|
if not context or not context.get("check_redundant_datatypes", False):
|
|
257
326
|
return self
|
|
@@ -296,51 +365,69 @@ class Column(BaseObject):
|
|
|
296
365
|
|
|
297
366
|
|
|
298
367
|
class Constraint(BaseObject):
|
|
299
|
-
"""
|
|
368
|
+
"""Table constraint model."""
|
|
300
369
|
|
|
301
370
|
deferrable: bool = False
|
|
302
|
-
"""
|
|
371
|
+
"""Whether this constraint will be declared as deferrable."""
|
|
303
372
|
|
|
304
373
|
initially: str | None = None
|
|
305
|
-
"""Value for ``INITIALLY`` clause
|
|
374
|
+
"""Value for ``INITIALLY`` clause; only used if `deferrable` is
|
|
375
|
+
`True`."""
|
|
306
376
|
|
|
307
377
|
annotations: Mapping[str, Any] = Field(default_factory=dict)
|
|
308
378
|
"""Additional annotations for this constraint."""
|
|
309
379
|
|
|
310
380
|
type: str | None = Field(None, alias="@type")
|
|
311
|
-
"""
|
|
381
|
+
"""Type of the constraint."""
|
|
312
382
|
|
|
313
383
|
|
|
314
384
|
class CheckConstraint(Constraint):
|
|
315
|
-
"""
|
|
385
|
+
"""Table check constraint model."""
|
|
316
386
|
|
|
317
387
|
expression: str
|
|
318
|
-
"""
|
|
388
|
+
"""Expression for the check constraint."""
|
|
319
389
|
|
|
320
390
|
|
|
321
391
|
class UniqueConstraint(Constraint):
|
|
322
|
-
"""
|
|
392
|
+
"""Table unique constraint model."""
|
|
323
393
|
|
|
324
394
|
columns: list[str]
|
|
325
|
-
"""
|
|
395
|
+
"""Columns in the unique constraint."""
|
|
326
396
|
|
|
327
397
|
|
|
328
398
|
class Index(BaseObject):
|
|
329
|
-
"""
|
|
399
|
+
"""Table index model.
|
|
330
400
|
|
|
331
401
|
An index can be defined on either columns or expressions, but not both.
|
|
332
402
|
"""
|
|
333
403
|
|
|
334
404
|
columns: list[str] | None = None
|
|
335
|
-
"""
|
|
405
|
+
"""Columns in the index."""
|
|
336
406
|
|
|
337
407
|
expressions: list[str] | None = None
|
|
338
|
-
"""
|
|
408
|
+
"""Expressions in the index."""
|
|
339
409
|
|
|
340
410
|
@model_validator(mode="before")
|
|
341
411
|
@classmethod
|
|
342
412
|
def check_columns_or_expressions(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
343
|
-
"""Check that columns or expressions are specified, but not both.
|
|
413
|
+
"""Check that columns or expressions are specified, but not both.
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
values
|
|
418
|
+
Values of the index.
|
|
419
|
+
|
|
420
|
+
Returns
|
|
421
|
+
-------
|
|
422
|
+
`dict` [ `str`, `Any` ]
|
|
423
|
+
The values of the index.
|
|
424
|
+
|
|
425
|
+
Raises
|
|
426
|
+
------
|
|
427
|
+
ValueError
|
|
428
|
+
If both columns and expressions are specified, or if neither are
|
|
429
|
+
specified.
|
|
430
|
+
"""
|
|
344
431
|
if "columns" in values and "expressions" in values:
|
|
345
432
|
raise ValueError("Defining columns and expressions is not valid")
|
|
346
433
|
elif "columns" not in values and "expressions" not in values:
|
|
@@ -349,9 +436,15 @@ class Index(BaseObject):
|
|
|
349
436
|
|
|
350
437
|
|
|
351
438
|
class ForeignKeyConstraint(Constraint):
|
|
352
|
-
"""
|
|
439
|
+
"""Table foreign key constraint model.
|
|
440
|
+
|
|
441
|
+
This constraint is used to define a foreign key relationship between two
|
|
442
|
+
tables in the schema.
|
|
353
443
|
|
|
354
|
-
|
|
444
|
+
Notes
|
|
445
|
+
-----
|
|
446
|
+
These relationships will be reflected in the TAP_SCHEMA ``keys`` and
|
|
447
|
+
``key_columns`` data.
|
|
355
448
|
"""
|
|
356
449
|
|
|
357
450
|
columns: list[str]
|
|
@@ -362,41 +455,46 @@ class ForeignKeyConstraint(Constraint):
|
|
|
362
455
|
|
|
363
456
|
|
|
364
457
|
class Table(BaseObject):
|
|
365
|
-
"""
|
|
458
|
+
"""Table model."""
|
|
366
459
|
|
|
367
460
|
columns: Sequence[Column]
|
|
368
|
-
"""
|
|
461
|
+
"""Columns in the table."""
|
|
369
462
|
|
|
370
463
|
constraints: list[Constraint] = Field(default_factory=list)
|
|
371
|
-
"""
|
|
464
|
+
"""Constraints on the table."""
|
|
372
465
|
|
|
373
466
|
indexes: list[Index] = Field(default_factory=list)
|
|
374
|
-
"""
|
|
467
|
+
"""Indexes on the table."""
|
|
375
468
|
|
|
376
469
|
primary_key: str | list[str] | None = Field(None, alias="primaryKey")
|
|
377
|
-
"""
|
|
470
|
+
"""Primary key of the table."""
|
|
378
471
|
|
|
379
472
|
tap_table_index: int | None = Field(None, alias="tap:table_index")
|
|
380
|
-
"""
|
|
473
|
+
"""IVOA TAP_SCHEMA table index of the table."""
|
|
381
474
|
|
|
382
475
|
mysql_engine: str | None = Field(None, alias="mysql:engine")
|
|
383
|
-
"""
|
|
384
|
-
|
|
385
|
-
For now this is a freeform string but it could be constrained to a list of
|
|
386
|
-
known engines in the future.
|
|
387
|
-
"""
|
|
476
|
+
"""MySQL engine to use for the table."""
|
|
388
477
|
|
|
389
478
|
mysql_charset: str | None = Field(None, alias="mysql:charset")
|
|
390
|
-
"""
|
|
391
|
-
|
|
392
|
-
For now this is a freeform string but it could be constrained to a list of
|
|
393
|
-
known charsets in the future.
|
|
394
|
-
"""
|
|
479
|
+
"""MySQL charset to use for the table."""
|
|
395
480
|
|
|
396
481
|
@model_validator(mode="before")
|
|
397
482
|
@classmethod
|
|
398
483
|
def create_constraints(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
399
|
-
"""Create
|
|
484
|
+
"""Create specific constraint types from the data in the
|
|
485
|
+
``constraints`` field of a table.
|
|
486
|
+
|
|
487
|
+
Parameters
|
|
488
|
+
----------
|
|
489
|
+
values
|
|
490
|
+
The values of the table containing the constraint data.
|
|
491
|
+
|
|
492
|
+
Returns
|
|
493
|
+
-------
|
|
494
|
+
`dict` [ `str`, `Any` ]
|
|
495
|
+
The values of the table with the constraints converted to their
|
|
496
|
+
respective types.
|
|
497
|
+
"""
|
|
400
498
|
if "constraints" in values:
|
|
401
499
|
new_constraints: list[Constraint] = []
|
|
402
500
|
for item in values["constraints"]:
|
|
@@ -414,14 +512,84 @@ class Table(BaseObject):
|
|
|
414
512
|
@field_validator("columns", mode="after")
|
|
415
513
|
@classmethod
|
|
416
514
|
def check_unique_column_names(cls, columns: list[Column]) -> list[Column]:
|
|
417
|
-
"""Check that column names are unique.
|
|
515
|
+
"""Check that column names are unique.
|
|
516
|
+
|
|
517
|
+
Parameters
|
|
518
|
+
----------
|
|
519
|
+
columns
|
|
520
|
+
The columns to check.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
`list` [ `Column` ]
|
|
525
|
+
The columns if they are unique.
|
|
526
|
+
|
|
527
|
+
Raises
|
|
528
|
+
------
|
|
529
|
+
ValueError
|
|
530
|
+
If column names are not unique.
|
|
531
|
+
"""
|
|
418
532
|
if len(columns) != len(set(column.name for column in columns)):
|
|
419
533
|
raise ValueError("Column names must be unique")
|
|
420
534
|
return columns
|
|
421
535
|
|
|
536
|
+
@model_validator(mode="after")
|
|
537
|
+
def check_tap_table_index(self, info: ValidationInfo) -> Table:
|
|
538
|
+
"""Check that the table has a TAP table index.
|
|
539
|
+
|
|
540
|
+
Parameters
|
|
541
|
+
----------
|
|
542
|
+
info
|
|
543
|
+
Validation context used to determine if the check is enabled.
|
|
544
|
+
|
|
545
|
+
Returns
|
|
546
|
+
-------
|
|
547
|
+
`Table`
|
|
548
|
+
The table being validated.
|
|
549
|
+
|
|
550
|
+
Raises
|
|
551
|
+
------
|
|
552
|
+
ValueError
|
|
553
|
+
If the table is missing a TAP table index.
|
|
554
|
+
"""
|
|
555
|
+
context = info.context
|
|
556
|
+
if not context or not context.get("check_tap_table_indexes", False):
|
|
557
|
+
return self
|
|
558
|
+
if self.tap_table_index is None:
|
|
559
|
+
raise ValueError("Table is missing a TAP table index")
|
|
560
|
+
return self
|
|
561
|
+
|
|
562
|
+
@model_validator(mode="after")
|
|
563
|
+
def check_tap_principal(self, info: ValidationInfo) -> Table:
|
|
564
|
+
"""Check that at least one column is flagged as 'principal' for TAP
|
|
565
|
+
purposes.
|
|
566
|
+
|
|
567
|
+
Parameters
|
|
568
|
+
----------
|
|
569
|
+
info
|
|
570
|
+
Validation context used to determine if the check is enabled.
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
`Table`
|
|
575
|
+
The table being validated.
|
|
576
|
+
|
|
577
|
+
Raises
|
|
578
|
+
------
|
|
579
|
+
ValueError
|
|
580
|
+
If the table is missing a column flagged as 'principal'.
|
|
581
|
+
"""
|
|
582
|
+
context = info.context
|
|
583
|
+
if not context or not context.get("check_tap_principal", False):
|
|
584
|
+
return self
|
|
585
|
+
for col in self.columns:
|
|
586
|
+
if col.tap_principal == 1:
|
|
587
|
+
return self
|
|
588
|
+
raise ValueError(f"Table '{self.name}' is missing at least one column designated as 'tap:principal'")
|
|
589
|
+
|
|
422
590
|
|
|
423
591
|
class SchemaVersion(BaseModel):
|
|
424
|
-
"""
|
|
592
|
+
"""Schema version model."""
|
|
425
593
|
|
|
426
594
|
current: str
|
|
427
595
|
"""The current version of the schema."""
|
|
@@ -434,15 +602,16 @@ class SchemaVersion(BaseModel):
|
|
|
434
602
|
|
|
435
603
|
|
|
436
604
|
class SchemaIdVisitor:
|
|
437
|
-
"""
|
|
605
|
+
"""Visit a schema and build the map of IDs to objects.
|
|
438
606
|
|
|
607
|
+
Notes
|
|
608
|
+
-----
|
|
439
609
|
Duplicates are added to a set when they are encountered, which can be
|
|
440
|
-
accessed via the
|
|
610
|
+
accessed via the ``duplicates`` attribute. The presence of duplicates will
|
|
441
611
|
not throw an error. Only the first object with a given ID will be added to
|
|
442
|
-
the map, but this should not matter, since a ValidationError will be
|
|
443
|
-
by the
|
|
444
|
-
|
|
445
|
-
This class is intended for internal use only.
|
|
612
|
+
the map, but this should not matter, since a ``ValidationError`` will be
|
|
613
|
+
thrown by the ``model_validator`` method if any duplicates are found in the
|
|
614
|
+
schema.
|
|
446
615
|
"""
|
|
447
616
|
|
|
448
617
|
def __init__(self) -> None:
|
|
@@ -451,7 +620,13 @@ class SchemaIdVisitor:
|
|
|
451
620
|
self.duplicates: set[str] = set()
|
|
452
621
|
|
|
453
622
|
def add(self, obj: BaseObject) -> None:
|
|
454
|
-
"""Add an object to the ID map.
|
|
623
|
+
"""Add an object to the ID map.
|
|
624
|
+
|
|
625
|
+
Parameters
|
|
626
|
+
----------
|
|
627
|
+
obj
|
|
628
|
+
The object to add to the ID map.
|
|
629
|
+
"""
|
|
455
630
|
if hasattr(obj, "id"):
|
|
456
631
|
obj_id = getattr(obj, "id")
|
|
457
632
|
if self.schema is not None:
|
|
@@ -461,8 +636,15 @@ class SchemaIdVisitor:
|
|
|
461
636
|
self.schema.id_map[obj_id] = obj
|
|
462
637
|
|
|
463
638
|
def visit_schema(self, schema: Schema) -> None:
|
|
464
|
-
"""Visit the schema
|
|
639
|
+
"""Visit the objects in a schema and build the ID map.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
schema
|
|
644
|
+
The schema object to visit.
|
|
465
645
|
|
|
646
|
+
Notes
|
|
647
|
+
-----
|
|
466
648
|
This will set an internal variable pointing to the schema object.
|
|
467
649
|
"""
|
|
468
650
|
self.schema = schema
|
|
@@ -472,7 +654,13 @@ class SchemaIdVisitor:
|
|
|
472
654
|
self.visit_table(table)
|
|
473
655
|
|
|
474
656
|
def visit_table(self, table: Table) -> None:
|
|
475
|
-
"""Visit a table object.
|
|
657
|
+
"""Visit a table object.
|
|
658
|
+
|
|
659
|
+
Parameters
|
|
660
|
+
----------
|
|
661
|
+
table
|
|
662
|
+
The table object to visit.
|
|
663
|
+
"""
|
|
476
664
|
self.add(table)
|
|
477
665
|
for column in table.columns:
|
|
478
666
|
self.visit_column(column)
|
|
@@ -480,16 +668,31 @@ class SchemaIdVisitor:
|
|
|
480
668
|
self.visit_constraint(constraint)
|
|
481
669
|
|
|
482
670
|
def visit_column(self, column: Column) -> None:
|
|
483
|
-
"""Visit a column object.
|
|
671
|
+
"""Visit a column object.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
column
|
|
676
|
+
The column object to visit.
|
|
677
|
+
"""
|
|
484
678
|
self.add(column)
|
|
485
679
|
|
|
486
680
|
def visit_constraint(self, constraint: Constraint) -> None:
|
|
487
|
-
"""Visit a constraint object.
|
|
681
|
+
"""Visit a constraint object.
|
|
682
|
+
|
|
683
|
+
Parameters
|
|
684
|
+
----------
|
|
685
|
+
constraint
|
|
686
|
+
The constraint object to visit.
|
|
687
|
+
"""
|
|
488
688
|
self.add(constraint)
|
|
489
689
|
|
|
490
690
|
|
|
491
691
|
class Schema(BaseObject):
|
|
492
|
-
"""
|
|
692
|
+
"""Database schema model.
|
|
693
|
+
|
|
694
|
+
This is the root object of the Felis data model.
|
|
695
|
+
"""
|
|
493
696
|
|
|
494
697
|
version: SchemaVersion | str | None = None
|
|
495
698
|
"""The version of the schema."""
|
|
@@ -503,17 +706,65 @@ class Schema(BaseObject):
|
|
|
503
706
|
@field_validator("tables", mode="after")
|
|
504
707
|
@classmethod
|
|
505
708
|
def check_unique_table_names(cls, tables: list[Table]) -> list[Table]:
|
|
506
|
-
"""Check that table names are unique.
|
|
709
|
+
"""Check that table names are unique.
|
|
710
|
+
|
|
711
|
+
Parameters
|
|
712
|
+
----------
|
|
713
|
+
tables
|
|
714
|
+
The tables to check.
|
|
715
|
+
|
|
716
|
+
Returns
|
|
717
|
+
-------
|
|
718
|
+
`list` [ `Table` ]
|
|
719
|
+
The tables if they are unique.
|
|
720
|
+
|
|
721
|
+
Raises
|
|
722
|
+
------
|
|
723
|
+
ValueError
|
|
724
|
+
If table names are not unique.
|
|
725
|
+
"""
|
|
507
726
|
if len(tables) != len(set(table.name for table in tables)):
|
|
508
727
|
raise ValueError("Table names must be unique")
|
|
509
728
|
return tables
|
|
510
729
|
|
|
730
|
+
@model_validator(mode="after")
|
|
731
|
+
def check_tap_table_indexes(self, info: ValidationInfo) -> Schema:
|
|
732
|
+
"""Check that the TAP table indexes are unique.
|
|
733
|
+
|
|
734
|
+
Parameters
|
|
735
|
+
----------
|
|
736
|
+
info
|
|
737
|
+
The validation context used to determine if the check is enabled.
|
|
738
|
+
|
|
739
|
+
Returns
|
|
740
|
+
-------
|
|
741
|
+
`Schema`
|
|
742
|
+
The schema being validated.
|
|
743
|
+
"""
|
|
744
|
+
context = info.context
|
|
745
|
+
if not context or not context.get("check_tap_table_indexes", False):
|
|
746
|
+
return self
|
|
747
|
+
table_indicies = set()
|
|
748
|
+
for table in self.tables:
|
|
749
|
+
table_index = table.tap_table_index
|
|
750
|
+
if table_index is not None:
|
|
751
|
+
if table_index in table_indicies:
|
|
752
|
+
raise ValueError(f"Duplicate 'tap:table_index' value {table_index} found in schema")
|
|
753
|
+
table_indicies.add(table_index)
|
|
754
|
+
return self
|
|
755
|
+
|
|
511
756
|
def _create_id_map(self: Schema) -> Schema:
|
|
512
757
|
"""Create a map of IDs to objects.
|
|
513
758
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
759
|
+
Raises
|
|
760
|
+
------
|
|
761
|
+
ValueError
|
|
762
|
+
If duplicate IDs are found in the schema.
|
|
763
|
+
|
|
764
|
+
Notes
|
|
765
|
+
-----
|
|
766
|
+
This is called automatically by the `model_post_init` method. If the
|
|
767
|
+
ID map is already populated, this method will return immediately.
|
|
517
768
|
"""
|
|
518
769
|
if len(self.id_map):
|
|
519
770
|
logger.debug("Ignoring call to create_id_map() - ID map was already populated")
|
|
@@ -527,15 +778,46 @@ class Schema(BaseObject):
|
|
|
527
778
|
return self
|
|
528
779
|
|
|
529
780
|
def model_post_init(self, ctx: Any) -> None:
|
|
530
|
-
"""Post-initialization hook for the model.
|
|
781
|
+
"""Post-initialization hook for the model.
|
|
782
|
+
|
|
783
|
+
Parameters
|
|
784
|
+
----------
|
|
785
|
+
ctx
|
|
786
|
+
The context object which was passed to the model.
|
|
787
|
+
|
|
788
|
+
Notes
|
|
789
|
+
-----
|
|
790
|
+
This method is called automatically by Pydantic after the model is
|
|
791
|
+
initialized. It is used to create the ID map for the schema.
|
|
792
|
+
|
|
793
|
+
The ``ctx`` argument has the type `Any` because this is the function
|
|
794
|
+
signature in Pydantic itself.
|
|
795
|
+
"""
|
|
531
796
|
self._create_id_map()
|
|
532
797
|
|
|
533
798
|
def __getitem__(self, id: str) -> BaseObject:
|
|
534
|
-
"""Get an object by its ID.
|
|
799
|
+
"""Get an object by its ID.
|
|
800
|
+
|
|
801
|
+
Parameters
|
|
802
|
+
----------
|
|
803
|
+
id
|
|
804
|
+
The ID of the object to get.
|
|
805
|
+
|
|
806
|
+
Raises
|
|
807
|
+
------
|
|
808
|
+
KeyError
|
|
809
|
+
If the object with the given ID is not found in the schema.
|
|
810
|
+
"""
|
|
535
811
|
if id not in self:
|
|
536
812
|
raise KeyError(f"Object with ID '{id}' not found in schema")
|
|
537
813
|
return self.id_map[id]
|
|
538
814
|
|
|
539
815
|
def __contains__(self, id: str) -> bool:
|
|
540
|
-
"""Check if an object with the given ID is in the schema.
|
|
816
|
+
"""Check if an object with the given ID is in the schema.
|
|
817
|
+
|
|
818
|
+
Parameters
|
|
819
|
+
----------
|
|
820
|
+
id
|
|
821
|
+
The ID of the object to check.
|
|
822
|
+
"""
|
|
541
823
|
return id in self.id_map
|