lsst-felis 29.2025.2400__tar.gz → 29.2025.2600__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.

Files changed (44) hide show
  1. {lsst_felis-29.2025.2400/python/lsst_felis.egg-info → lsst_felis-29.2025.2600}/PKG-INFO +1 -1
  2. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/pyproject.toml +1 -1
  3. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/cli.py +27 -5
  4. lsst_felis-29.2025.2600/python/felis/config/tap_schema/columns.csv +33 -0
  5. lsst_felis-29.2025.2600/python/felis/config/tap_schema/key_columns.csv +8 -0
  6. lsst_felis-29.2025.2600/python/felis/config/tap_schema/keys.csv +8 -0
  7. lsst_felis-29.2025.2600/python/felis/config/tap_schema/schemas.csv +2 -0
  8. lsst_felis-29.2025.2600/python/felis/config/tap_schema/tables.csv +6 -0
  9. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/datamodel.py +237 -23
  10. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/metadata.py +5 -0
  11. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/tap_schema.py +49 -2
  12. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600/python/lsst_felis.egg-info}/PKG-INFO +1 -1
  13. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/SOURCES.txt +6 -1
  14. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_cli.py +42 -0
  15. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_datamodel.py +105 -5
  16. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_metadata.py +17 -0
  17. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/COPYRIGHT +0 -0
  18. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/LICENSE +0 -0
  19. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/README.rst +0 -0
  20. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/__init__.py +0 -0
  21. {lsst_felis-29.2025.2400/python/felis/schemas → lsst_felis-29.2025.2600/python/felis/config/tap_schema}/tap_schema_std.yaml +0 -0
  22. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/__init__.py +0 -0
  23. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/dialects.py +0 -0
  24. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/schema.py +0 -0
  25. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/sqltypes.py +0 -0
  26. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/utils.py +0 -0
  27. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/db/variants.py +0 -0
  28. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/diff.py +0 -0
  29. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/py.typed +0 -0
  30. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/tests/__init__.py +0 -0
  31. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/tests/postgresql.py +0 -0
  32. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/tests/run_cli.py +0 -0
  33. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/felis/types.py +0 -0
  34. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  35. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  36. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/requires.txt +0 -0
  37. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/top_level.txt +0 -0
  38. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/python/lsst_felis.egg-info/zip-safe +0 -0
  39. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/setup.cfg +0 -0
  40. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_db.py +0 -0
  41. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_diff.py +0 -0
  42. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_postgres.py +0 -0
  43. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_tap_schema.py +0 -0
  44. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2600}/tests/test_tap_schema_postgres.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-felis
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2600
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+)
@@ -60,7 +60,7 @@ zip-safe = true
60
60
  license-files = ["COPYRIGHT", "LICENSE"]
61
61
 
62
62
  [tool.setuptools.package-data]
63
- "felis" = ["py.typed", "schemas/*.yaml"]
63
+ "felis" = ["py.typed", "config/tap_schema/*.yaml", "config/tap_schema/*.csv"]
64
64
 
65
65
  [tool.setuptools.dynamic]
66
66
  version = { attr = "lsst_versions.get_lsst_version" }
@@ -38,7 +38,7 @@ 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_schema import DataLoader, TableManager
41
+ from .tap_schema import DataLoader, MetadataInserter, TableManager
42
42
 
43
43
  __all__ = ["cli"]
44
44
 
@@ -284,14 +284,20 @@ def load_tap_schema(
284
284
 
285
285
 
286
286
  @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
287
- @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
287
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
288
288
  @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
289
289
  @click.option(
290
290
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
291
291
  )
292
+ @click.option(
293
+ "--insert-metadata/--no-insert-metadata",
294
+ is_flag=True,
295
+ help="Insert metadata describing TAP_SCHEMA itself",
296
+ default=True,
297
+ )
292
298
  @click.pass_context
293
299
  def init_tap_schema(
294
- ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
300
+ ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str, insert_metadata: bool
295
301
  ) -> None:
296
302
  """Initialize a standard TAP_SCHEMA database.
297
303
 
@@ -303,6 +309,10 @@ def init_tap_schema(
303
309
  Name of the TAP_SCHEMA schema in the database.
304
310
  tap_tables_postfix
305
311
  Postfix which is applied to standard TAP_SCHEMA table names.
312
+ insert_metadata
313
+ Insert metadata describing TAP_SCHEMA itself.
314
+ If set to False, only the TAP_SCHEMA tables will be created, but no
315
+ metadata will be inserted.
306
316
  """
307
317
  url = make_url(engine_url)
308
318
  engine: Engine | MockConnection
@@ -315,6 +325,9 @@ def init_tap_schema(
315
325
  table_name_postfix=tap_tables_postfix,
316
326
  )
317
327
  mgr.initialize_database(engine)
328
+ if insert_metadata:
329
+ inserter = MetadataInserter(mgr, engine)
330
+ inserter.insert_metadata()
318
331
 
319
332
 
320
333
  @cli.command("validate", help="Validate one or more Felis YAML files")
@@ -465,12 +478,21 @@ def diff(
465
478
  felis dump schema.yaml schema_dump.yaml
466
479
  """,
467
480
  )
481
+ @click.option(
482
+ "--strip-ids/--no-strip-ids",
483
+ is_flag=True,
484
+ help="Strip IDs from the output schema",
485
+ default=False,
486
+ )
468
487
  @click.argument("files", nargs=2, type=click.Path())
469
488
  @click.pass_context
470
489
  def dump(
471
490
  ctx: click.Context,
491
+ strip_ids: bool,
472
492
  files: list[str],
473
493
  ) -> None:
494
+ if strip_ids:
495
+ logger.info("Stripping IDs from the output schema")
474
496
  if files[1].endswith(".json"):
475
497
  format = "json"
476
498
  elif files[1].endswith(".yaml"):
@@ -480,9 +502,9 @@ def dump(
480
502
  schema = Schema.from_uri(files[0], context={"id_generation": ctx.obj["id_generation"]})
481
503
  with open(files[1], "w") as f:
482
504
  if format == "yaml":
483
- schema.dump_yaml(f)
505
+ schema.dump_yaml(f, strip_ids=strip_ids)
484
506
  elif format == "json":
485
- schema.dump_json(f)
507
+ schema.dump_json(f, strip_ids=strip_ids)
486
508
 
487
509
 
488
510
  if __name__ == "__main__":
@@ -0,0 +1,33 @@
1
+ table_name,column_name,utype,ucd,unit,description,datatype,arraysize,xtype,size,principal,indexed,std,column_index
2
+ tap_schema.columns,"""size""",\N,\N,\N,deprecated: use arraysize,int,\N,\N,\N,1,0,1,9
3
+ tap_schema.columns,arraysize,\N,\N,\N,lists the size of variable-length columns in the tableset,char,16*,\N,16,1,0,1,8
4
+ tap_schema.columns,column_index,\N,\N,\N,recommended sort order when listing columns of a table,int,\N,\N,\N,1,0,1,13
5
+ tap_schema.columns,column_name,\N,\N,\N,the column name,char,64*,\N,64,1,0,1,2
6
+ tap_schema.columns,datatype,\N,\N,\N,lists the ADQL datatype of columns in the tableset,char,64*,\N,64,1,0,1,7
7
+ tap_schema.columns,description,\N,\N,\N,describes the columns in the tableset,char,512*,\N,512,1,0,1,6
8
+ tap_schema.columns,indexed,\N,\N,\N,"an indexed column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,11
9
+ tap_schema.columns,principal,\N,\N,\N,"a principal column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,10
10
+ tap_schema.columns,std,\N,\N,\N,"a standard column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,12
11
+ tap_schema.columns,table_name,\N,\N,\N,the table this column belongs to,char,64*,\N,64,1,0,1,1
12
+ tap_schema.columns,ucd,\N,\N,\N,lists the UCDs of columns in the tableset,char,64*,\N,64,1,0,1,4
13
+ tap_schema.columns,unit,\N,\N,\N,lists the unit used for column values in the tableset,char,64*,\N,64,1,0,1,5
14
+ tap_schema.columns,utype,\N,\N,\N,lists the utypes of columns in the tableset,char,512*,\N,512,1,0,1,3
15
+ tap_schema.columns,xtype,\N,\N,\N,a DALI or custom extended type annotation,char,64*,\N,64,1,0,1,7
16
+ tap_schema.key_columns,from_column,\N,\N,\N,column in the from_table,char,64*,\N,64,1,0,1,2
17
+ tap_schema.key_columns,key_id,\N,\N,\N,key to join to tap_schema.keys,char,64*,\N,64,1,0,1,1
18
+ tap_schema.key_columns,target_column,\N,\N,\N,column in the target_table,char,64*,\N,64,1,0,1,3
19
+ tap_schema.keys,description,\N,\N,\N,describes keys in the tableset,char,512*,\N,512,1,0,1,5
20
+ tap_schema.keys,from_table,\N,\N,\N,the table with the foreign key,char,64*,\N,64,1,0,1,2
21
+ tap_schema.keys,key_id,\N,\N,\N,unique key to join to tap_schema.key_columns,char,64*,\N,64,1,0,1,1
22
+ tap_schema.keys,target_table,\N,\N,\N,the table with the primary key,char,64*,\N,64,1,0,1,3
23
+ tap_schema.keys,utype,\N,\N,\N,lists the utype of keys in the tableset,char,512*,\N,512,1,0,1,4
24
+ tap_schema.schemas,description,\N,\N,\N,describes schemas in the tableset,char,512*,\N,512,1,0,1,3
25
+ tap_schema.schemas,schema_index,\N,\N,\N,recommended sort order when listing schemas,int,\N,\N,\N,1,0,1,4
26
+ tap_schema.schemas,schema_name,\N,\N,\N,schema name for reference to tap_schema.schemas,char,64*,\N,64,1,0,1,1
27
+ tap_schema.schemas,utype,\N,\N,\N,lists the utypes of schemas in the tableset,char,512*,\N,512,1,0,1,2
28
+ tap_schema.tables,description,\N,\N,\N,describes tables in the tableset,char,512*,\N,512,1,0,1,5
29
+ tap_schema.tables,schema_name,\N,\N,\N,the schema this table belongs to,char,512*,\N,512,1,0,1,1
30
+ tap_schema.tables,table_index,\N,\N,\N,recommended sort order when listing tables,int,\N,\N,\N,1,0,1,6
31
+ tap_schema.tables,table_name,\N,\N,\N,the fully qualified table name,char,64*,\N,64,1,0,1,2
32
+ tap_schema.tables,table_type,\N,\N,\N,one of: table view,char,8*,\N,8,1,0,1,3
33
+ tap_schema.tables,utype,\N,\N,\N,lists the utype of tables in the tableset,char,512*,\N,512,1,0,1,4
@@ -0,0 +1,8 @@
1
+ key_id,from_column,target_column
2
+ k1,schema_name,schema_name
3
+ k2,table_name,table_name
4
+ k3,from_table,table_name
5
+ k4,target_table,table_name
6
+ k5,key_id,key_id
7
+ k6,from_column,column_name
8
+ k7,target_column,column_name
@@ -0,0 +1,8 @@
1
+ key_id,from_table,target_table,utype,description
2
+ k1,tap_schema.tables,tap_schema.schemas,\N,\N
3
+ k2,tap_schema.columns,tap_schema.tables,\N,\N
4
+ k3,tap_schema.keys,tap_schema.tables,\N,\N
5
+ k4,tap_schema.keys,tap_schema.tables,\N,\N
6
+ k5,tap_schema.key_columns,tap_schema.keys,\N,\N
7
+ k6,tap_schema.key_columns,tap_schema.columns,\N,\N
8
+ k7,tap_schema.key_columns,tap_schema.columns,\N,\N
@@ -0,0 +1,2 @@
1
+ schema_name,utype,description,schema_index
2
+ tap_schema,\N,A TAP-standard-mandated schema to describe tablesets in a TAP 1.1 service,100000
@@ -0,0 +1,6 @@
1
+ schema_name,table_name,table_type,utype,description,table_index
2
+ tap_schema,tap_schema.columns,table,\N,description of columns in this tableset,102000
3
+ tap_schema,tap_schema.key_columns,table,\N,description of foreign key columns in this tableset,104000
4
+ tap_schema,tap_schema.keys,table,\N,description of foreign keys in this tableset,103000
5
+ tap_schema,tap_schema.schemas,table,\N,description of schemas in this tableset,100000
6
+ tap_schema,tap_schema.tables,table,\N,description of tables in this tableset,101000
@@ -39,11 +39,13 @@ from pydantic import (
39
39
  ConfigDict,
40
40
  Field,
41
41
  PrivateAttr,
42
+ ValidationError,
42
43
  ValidationInfo,
43
44
  field_serializer,
44
45
  field_validator,
45
46
  model_validator,
46
47
  )
48
+ from pydantic_core import InitErrorDetails
47
49
 
48
50
  from .db.dialects import get_supported_dialects
49
51
  from .db.sqltypes import get_type_func
@@ -633,6 +635,12 @@ class ForeignKeyConstraint(Constraint):
633
635
  referenced_columns: list[str] = Field(alias="referencedColumns")
634
636
  """The columns referenced by the foreign key."""
635
637
 
638
+ on_delete: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None
639
+ """Action to take when the referenced row is deleted."""
640
+
641
+ on_update: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None
642
+ """Action to take when the referenced row is updated."""
643
+
636
644
  @field_serializer("type")
637
645
  def serialize_type(self, value: str) -> str:
638
646
  """Ensure '@type' is included in serialized output.
@@ -752,7 +760,10 @@ class ColumnGroup(BaseObject):
752
760
  for col in self.columns:
753
761
  if isinstance(col, str):
754
762
  # Dereference ColumnRef to Column object
755
- col_obj = self.table._find_column_by_id(col)
763
+ try:
764
+ col_obj = self.table._find_column_by_id(col)
765
+ except KeyError as e:
766
+ raise ValueError(f"Column '{col}' not found in table '{self.table.name}'") from e
756
767
  dereferenced_columns.append(col_obj)
757
768
  else:
758
769
  dereferenced_columns.append(col)
@@ -902,7 +913,7 @@ class Table(BaseObject):
902
913
  for column in self.columns:
903
914
  if column.id == id:
904
915
  return column
905
- raise ValueError(f"Column '{id}' not found in table '{self.name}'")
916
+ raise KeyError(f"Column '{id}' not found in table '{self.name}'")
906
917
 
907
918
  @model_validator(mode="after")
908
919
  def dereference_column_groups(self: Table) -> Table:
@@ -1022,6 +1033,56 @@ class SchemaIdVisitor:
1022
1033
  T = TypeVar("T", bound=BaseObject)
1023
1034
 
1024
1035
 
1036
+ def _strip_ids(data: Any) -> Any:
1037
+ """Recursively strip '@id' fields from a dictionary or list.
1038
+
1039
+ Parameters
1040
+ ----------
1041
+ data
1042
+ The data to strip IDs from, which can be a dictionary, list, or any
1043
+ other type. Other types will be returned unchanged.
1044
+ """
1045
+ if isinstance(data, dict):
1046
+ data.pop("@id", None)
1047
+ for k, v in data.items():
1048
+ data[k] = _strip_ids(v)
1049
+ return data
1050
+ elif isinstance(data, list):
1051
+ return [_strip_ids(item) for item in data]
1052
+ else:
1053
+ return data
1054
+
1055
+
1056
+ def _append_error(
1057
+ errors: list[InitErrorDetails],
1058
+ loc: tuple,
1059
+ input_value: Any,
1060
+ error_message: str,
1061
+ error_type: str = "value_error",
1062
+ ) -> None:
1063
+ """Append an error to the errors list.
1064
+
1065
+ Parameters
1066
+ ----------
1067
+ errors : list[InitErrorDetails]
1068
+ The list of errors to append to.
1069
+ loc : tuple
1070
+ The location of the error in the schema.
1071
+ input_value : Any
1072
+ The input value that caused the error.
1073
+ error_message : str
1074
+ The error message to include in the context.
1075
+ """
1076
+ errors.append(
1077
+ {
1078
+ "type": error_type,
1079
+ "loc": loc,
1080
+ "input": input_value,
1081
+ "ctx": {"error": error_message},
1082
+ }
1083
+ )
1084
+
1085
+
1025
1086
  class Schema(BaseObject, Generic[T]):
1026
1087
  """Database schema model.
1027
1088
 
@@ -1204,18 +1265,19 @@ class Schema(BaseObject, Generic[T]):
1204
1265
 
1205
1266
  return self
1206
1267
 
1207
- def _create_id_map(self: Schema) -> Schema:
1268
+ @model_validator(mode="after")
1269
+ def create_id_map(self: Schema) -> Schema:
1208
1270
  """Create a map of IDs to objects.
1209
1271
 
1272
+ Returns
1273
+ -------
1274
+ `Schema`
1275
+ The schema with the ID map created.
1276
+
1210
1277
  Raises
1211
1278
  ------
1212
1279
  ValueError
1213
1280
  Raised if duplicate identifiers are found in the schema.
1214
-
1215
- Notes
1216
- -----
1217
- This is called automatically by the `model_post_init` method. If the
1218
- ID map is already populated, this method will return immediately.
1219
1281
  """
1220
1282
  if self._id_map:
1221
1283
  logger.debug("Ignoring call to create_id_map() - ID map was already populated")
@@ -1226,25 +1288,152 @@ class Schema(BaseObject, Generic[T]):
1226
1288
  raise ValueError(
1227
1289
  "Duplicate IDs found in schema:\n " + "\n ".join(visitor.duplicates) + "\n"
1228
1290
  )
1291
+ logger.debug("Created ID map with %d entries", len(self._id_map))
1229
1292
  return self
1230
1293
 
1231
- def model_post_init(self, ctx: Any) -> None:
1232
- """Post-initialization hook for the model.
1294
+ def _validate_column_id(
1295
+ self: Schema,
1296
+ column_id: str,
1297
+ loc: tuple,
1298
+ errors: list[InitErrorDetails],
1299
+ ) -> None:
1300
+ """Validate a column ID from a constraint and append errors if invalid.
1233
1301
 
1234
1302
  Parameters
1235
1303
  ----------
1236
- ctx
1237
- The context object which was passed to the model.
1304
+ schema : Schema
1305
+ The schema being validated.
1306
+ column_id : str
1307
+ The column ID to validate.
1308
+ loc : tuple
1309
+ The location of the error in the schema.
1310
+ errors : list[InitErrorDetails]
1311
+ The list of errors to append to.
1312
+ """
1313
+ if column_id not in self:
1314
+ _append_error(
1315
+ errors,
1316
+ loc,
1317
+ column_id,
1318
+ f"Column ID '{column_id}' not found in schema",
1319
+ )
1320
+ elif not isinstance(self[column_id], Column):
1321
+ _append_error(
1322
+ errors,
1323
+ loc,
1324
+ column_id,
1325
+ f"ID '{column_id}' does not refer to a Column object",
1326
+ )
1238
1327
 
1239
- Notes
1240
- -----
1241
- This method is called automatically by Pydantic after the model is
1242
- initialized. It is used to create the ID map for the schema.
1328
+ def _validate_foreign_key_column(
1329
+ self: Schema,
1330
+ column_id: str,
1331
+ table: Table,
1332
+ loc: tuple,
1333
+ errors: list[InitErrorDetails],
1334
+ ) -> None:
1335
+ """Validate a foreign key column ID from a constraint and append errors
1336
+ if invalid.
1243
1337
 
1244
- The ``ctx`` argument has the type `Any` because this is the function
1245
- signature in Pydantic itself.
1338
+ Parameters
1339
+ ----------
1340
+ schema : Schema
1341
+ The schema being validated.
1342
+ column_id : str
1343
+ The foreign key column ID to validate.
1344
+ loc : tuple
1345
+ The location of the error in the schema.
1346
+ errors : list[InitErrorDetails]
1347
+ The list of errors to append to.
1246
1348
  """
1247
- self._create_id_map()
1349
+ try:
1350
+ table._find_column_by_id(column_id)
1351
+ except KeyError:
1352
+ _append_error(
1353
+ errors,
1354
+ loc,
1355
+ column_id,
1356
+ f"Column '{column_id}' not found in table '{table.name}'",
1357
+ )
1358
+
1359
+ @model_validator(mode="after")
1360
+ def check_constraints(self: Schema) -> Schema:
1361
+ """Check constraint objects for validity. This needs to be deferred
1362
+ until after the schema is fully loaded and the ID map is created.
1363
+
1364
+ Raises
1365
+ ------
1366
+ pydantic.ValidationError
1367
+ Raised if any constraints are invalid.
1368
+
1369
+ Returns
1370
+ -------
1371
+ `Schema`
1372
+ The schema being validated.
1373
+ """
1374
+ errors: list[InitErrorDetails] = []
1375
+
1376
+ for table_index, table in enumerate(self.tables):
1377
+ for constraint_index, constraint in enumerate(table.constraints):
1378
+ column_ids: list[str] = []
1379
+ referenced_column_ids: list[str] = []
1380
+
1381
+ if isinstance(constraint, ForeignKeyConstraint):
1382
+ column_ids += constraint.columns
1383
+ referenced_column_ids += constraint.referenced_columns
1384
+ elif isinstance(constraint, UniqueConstraint):
1385
+ column_ids += constraint.columns
1386
+ # No extra checks are required on CheckConstraint objects.
1387
+
1388
+ # Validate the foreign key columns
1389
+ for column_id in column_ids:
1390
+ self._validate_column_id(
1391
+ column_id,
1392
+ (
1393
+ "tables",
1394
+ table_index,
1395
+ "constraints",
1396
+ constraint_index,
1397
+ "columns",
1398
+ column_id,
1399
+ ),
1400
+ errors,
1401
+ )
1402
+ # Check that the foreign key column is within the source
1403
+ # table.
1404
+ self._validate_foreign_key_column(
1405
+ column_id,
1406
+ table,
1407
+ (
1408
+ "tables",
1409
+ table_index,
1410
+ "constraints",
1411
+ constraint_index,
1412
+ "columns",
1413
+ column_id,
1414
+ ),
1415
+ errors,
1416
+ )
1417
+
1418
+ # Validate the primary key (reference) columns
1419
+ for referenced_column_id in referenced_column_ids:
1420
+ self._validate_column_id(
1421
+ referenced_column_id,
1422
+ (
1423
+ "tables",
1424
+ table_index,
1425
+ "constraints",
1426
+ constraint_index,
1427
+ "referenced_columns",
1428
+ referenced_column_id,
1429
+ ),
1430
+ errors,
1431
+ )
1432
+
1433
+ if errors:
1434
+ raise ValidationError.from_exception_data("Schema validation failed", errors)
1435
+
1436
+ return self
1248
1437
 
1249
1438
  def __getitem__(self, id: str) -> BaseObject:
1250
1439
  """Get an object by its ID.
@@ -1388,31 +1577,56 @@ class Schema(BaseObject, Generic[T]):
1388
1577
  yaml_data = yaml.safe_load(source)
1389
1578
  return Schema.model_validate(yaml_data, context=context)
1390
1579
 
1391
- def dump_yaml(self, stream: IO[str] = sys.stdout) -> None:
1580
+ def _model_dump(self, strip_ids: bool = False) -> dict[str, Any]:
1581
+ """Dump the schema as a dictionary with some default arguments
1582
+ applied.
1583
+
1584
+ Parameters
1585
+ ----------
1586
+ strip_ids
1587
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1588
+
1589
+ Returns
1590
+ -------
1591
+ `dict` [ `str`, `Any` ]
1592
+ The dumped schema data as a dictionary.
1593
+ """
1594
+ data = self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True)
1595
+ if strip_ids:
1596
+ data = _strip_ids(data)
1597
+ return data
1598
+
1599
+ def dump_yaml(self, stream: IO[str] = sys.stdout, strip_ids: bool = False) -> None:
1392
1600
  """Pretty print the schema as YAML.
1393
1601
 
1394
1602
  Parameters
1395
1603
  ----------
1396
1604
  stream
1397
1605
  The stream to write the YAML data to.
1606
+ strip_ids
1607
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1398
1608
  """
1609
+ data = self._model_dump(strip_ids=strip_ids)
1399
1610
  yaml.safe_dump(
1400
- self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True),
1611
+ data,
1401
1612
  stream,
1402
1613
  default_flow_style=False,
1403
1614
  sort_keys=False,
1404
1615
  )
1405
1616
 
1406
- def dump_json(self, stream: IO[str] = sys.stdout) -> None:
1617
+ def dump_json(self, stream: IO[str] = sys.stdout, strip_ids: bool = False) -> None:
1407
1618
  """Pretty print the schema as JSON.
1408
1619
 
1409
1620
  Parameters
1410
1621
  ----------
1411
1622
  stream
1412
1623
  The stream to write the JSON data to.
1624
+ strip_ids
1625
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1413
1626
  """
1627
+ data = self._model_dump(strip_ids=strip_ids)
1414
1628
  json.dump(
1415
- self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True),
1629
+ data,
1416
1630
  stream,
1417
1631
  indent=4,
1418
1632
  sort_keys=False,
@@ -338,12 +338,17 @@ class MetaDataBuilder:
338
338
  "deferrable": constraint_obj.deferrable or None,
339
339
  "initially": constraint_obj.initially or None,
340
340
  }
341
+
341
342
  constraint: Constraint
342
343
 
343
344
  if isinstance(constraint_obj, datamodel.ForeignKeyConstraint):
344
345
  fk_obj: datamodel.ForeignKeyConstraint = constraint_obj
345
346
  columns = [self._objects[column_id] for column_id in fk_obj.columns]
346
347
  refcolumns = [self._objects[column_id] for column_id in fk_obj.referenced_columns]
348
+ if constraint_obj.on_delete is not None:
349
+ args["ondelete"] = constraint_obj.on_delete
350
+ if constraint_obj.on_update is not None:
351
+ args["onupdate"] = constraint_obj.on_update
347
352
  constraint = ForeignKeyConstraint(columns, refcolumns, **args)
348
353
  elif isinstance(constraint_obj, datamodel.CheckConstraint):
349
354
  check_obj: datamodel.CheckConstraint = constraint_obj
@@ -21,6 +21,8 @@
21
21
  # You should have received a copy of the GNU General Public License
22
22
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
23
 
24
+ import csv
25
+ import io
24
26
  import logging
25
27
  import os
26
28
  import re
@@ -208,7 +210,7 @@ class TableManager:
208
210
  str
209
211
  The path to the standard TAP_SCHEMA schema resource.
210
212
  """
211
- return os.path.join(os.path.dirname(__file__), "schemas", "tap_schema_std.yaml")
213
+ return os.path.join(os.path.dirname(__file__), "config", "tap_schema", "tap_schema_std.yaml")
212
214
 
213
215
  @classmethod
214
216
  def get_tap_schema_std_resource(cls) -> ResourcePath:
@@ -219,7 +221,7 @@ class TableManager:
219
221
  `~lsst.resources.ResourcePath`
220
222
  The standard TAP_SCHEMA schema resource.
221
223
  """
222
- return ResourcePath("resource://felis/schemas/tap_schema_std.yaml")
224
+ return ResourcePath("resource://felis/config/tap_schema/tap_schema_std.yaml")
223
225
 
224
226
  @classmethod
225
227
  def get_table_names_std(cls) -> list[str]:
@@ -708,3 +710,48 @@ class DataLoader:
708
710
  if index.columns and len(index.columns) == 1 and index.columns[0] == column.id:
709
711
  return 1
710
712
  return 0
713
+
714
+
715
+ class MetadataInserter:
716
+ """Insert TAP_SCHEMA self-description rows into the database.
717
+
718
+ Parameters
719
+ ----------
720
+ mgr
721
+ The table manager that contains the TAP_SCHEMA tables.
722
+ engine
723
+ The engine for connecting to the TAP_SCHEMA database.
724
+ """
725
+
726
+ def __init__(self, mgr: TableManager, engine: Engine):
727
+ """Initialize the metadata inserter.
728
+
729
+ Parameters
730
+ ----------
731
+ mgr
732
+ The table manager representing the TAP_SCHEMA tables.
733
+ engine
734
+ The SQLAlchemy engine for connecting to the database.
735
+ """
736
+ self._mgr = mgr
737
+ self._engine = engine
738
+
739
+ def insert_metadata(self) -> None:
740
+ """Insert the TAP_SCHEMA metadata into the database."""
741
+ for table_name in self._mgr.get_table_names_std():
742
+ table = self._mgr[table_name]
743
+ csv_bytes = ResourcePath(f"resource://felis/config/tap_schema/{table_name}.csv").read()
744
+ text_stream = io.TextIOWrapper(io.BytesIO(csv_bytes), encoding="utf-8")
745
+ reader = csv.reader(text_stream)
746
+ headers = next(reader)
747
+ rows = [
748
+ {key: None if value == "\\N" else value for key, value in zip(headers, row)} for row in reader
749
+ ]
750
+ logger.debug(
751
+ "Inserting %d rows into table '%s' with headers: %s",
752
+ len(rows),
753
+ table_name,
754
+ headers,
755
+ )
756
+ with self._engine.begin() as conn:
757
+ conn.execute(table.insert(), rows)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-felis
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2600
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+)
@@ -11,13 +11,18 @@ python/felis/metadata.py
11
11
  python/felis/py.typed
12
12
  python/felis/tap_schema.py
13
13
  python/felis/types.py
14
+ python/felis/config/tap_schema/columns.csv
15
+ python/felis/config/tap_schema/key_columns.csv
16
+ python/felis/config/tap_schema/keys.csv
17
+ python/felis/config/tap_schema/schemas.csv
18
+ python/felis/config/tap_schema/tables.csv
19
+ python/felis/config/tap_schema/tap_schema_std.yaml
14
20
  python/felis/db/__init__.py
15
21
  python/felis/db/dialects.py
16
22
  python/felis/db/schema.py
17
23
  python/felis/db/sqltypes.py
18
24
  python/felis/db/utils.py
19
25
  python/felis/db/variants.py
20
- python/felis/schemas/tap_schema_std.yaml
21
26
  python/felis/tests/__init__.py
22
27
  python/felis/tests/postgresql.py
23
28
  python/felis/tests/run_cli.py
@@ -23,7 +23,9 @@ import os
23
23
  import shutil
24
24
  import tempfile
25
25
  import unittest
26
+ from typing import Any
26
27
 
28
+ import yaml
27
29
  from sqlalchemy import create_engine
28
30
 
29
31
  import felis.tap_schema as tap_schema
@@ -170,11 +172,51 @@ class CliTestCase(unittest.TestCase):
170
172
  with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
171
173
  run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
172
174
 
175
+ @classmethod
176
+ def _check_strip_ids(cls, obj: Any) -> None:
177
+ """
178
+ Recursively check that a dict/list structure has no attributes with key
179
+ '@id'. Raises a ValueError if any '@id' key is found. This is used to
180
+ check the output of the `--strip-ids` option in the `dump` command.
181
+ """
182
+ if isinstance(obj, dict):
183
+ for k, v in obj.items():
184
+ if k == "@id":
185
+ raise ValueError("Found forbidden key '@id'")
186
+ cls._check_strip_ids(v)
187
+ elif isinstance(obj, list):
188
+ for item in obj:
189
+ cls._check_strip_ids(item)
190
+
191
+ def test_dump_yaml_with_strip_ids(self) -> None:
192
+ """Test for ``dump`` command with YAML output and stripped IDs."""
193
+ with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
194
+ run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
195
+ dumped_data = temp_file.read().decode("utf-8")
196
+ try:
197
+ # Load the dumped YAML data to check for '@id' keys.
198
+ data = yaml.safe_load(dumped_data)
199
+ self._check_strip_ids(data)
200
+ except ValueError:
201
+ self.fail("Dumped YAML contains forbidden key '@id'")
202
+
173
203
  def test_dump_json(self) -> None:
174
204
  """Test for ``dump`` command with JSON output."""
175
205
  with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
176
206
  run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
177
207
 
208
+ def test_dump_json_with_strip_ids(self) -> None:
209
+ """Test for ``dump`` command with JSON output."""
210
+ with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
211
+ run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
212
+ dumped_data = temp_file.read().decode("utf-8")
213
+ try:
214
+ # Load the dumped YAML data to check for '@id' keys.
215
+ data = yaml.safe_load(dumped_data)
216
+ self._check_strip_ids(data)
217
+ except ValueError:
218
+ self.fail("Dumped YAML contains forbidden key '@id'")
219
+
178
220
  def test_dump_with_invalid_file_extension_error(self) -> None:
179
221
  """Test for ``dump`` command with JSON output."""
180
222
  run_cli(["dump", TEST_YAML, "out.bad"], expect_error=True)
@@ -451,6 +451,105 @@ class ConstraintTestCase(unittest.TestCase):
451
451
  with self.assertRaises(ValidationError):
452
452
  UniqueConstraint(name="uniq_test", id="#uniq_test", columns=["test_column"], type="BAD_TYPE")
453
453
 
454
+ def test_constraint_column_checks(self) -> None:
455
+ """Test the extra validation in the ``Schema`` that checks the
456
+ constraint column references.
457
+ """
458
+
459
+ def _create_test_schema(constraint: Constraint) -> None:
460
+ """Create a test schema with the given constraint."""
461
+ test_col = Column(name="testColumn", id="#test_col_id", datatype="int")
462
+ test_col2 = Column(name="testColumn2", id="#test_col_id2", datatype="int")
463
+ test_tbl = Table(
464
+ name="testTable", id="#test_tbl_id", columns=[test_col, test_col2], constraints=[constraint]
465
+ )
466
+ test_col = Column(name="testColumn", id="#test_col2_id", datatype="int")
467
+ test_col2 = Column(name="testColumn2", id="#test_col2_id2", datatype="int")
468
+ test_tbl2 = Table(name="testTable2", id="#test_tbl2_id", columns=[test_col, test_col2])
469
+ Schema(name="testSchema", id="#test_schema_id", tables=[test_tbl, test_tbl2])
470
+
471
+ # Creating a unique constraint on a bad column should raise an
472
+ # exception.
473
+ with self.assertRaises(ValidationError):
474
+ _create_test_schema(
475
+ UniqueConstraint(name="testConstraint", id="#test_constraint_id", columns=["bad_column"])
476
+ )
477
+
478
+ # Creating a foreign key constraint with a bad column should raise an
479
+ # exception.
480
+ with self.assertRaises(ValidationError):
481
+ _create_test_schema(
482
+ ForeignKeyConstraint(
483
+ name="testForeignKey",
484
+ id="#test_fk_id",
485
+ columns=["bad_column"],
486
+ referenced_columns=["#test_col2_id"],
487
+ )
488
+ )
489
+
490
+ # Creating a foreign key constraint with a bad referenced column should
491
+ # raise an exception.
492
+ with self.assertRaises(ValidationError):
493
+ _create_test_schema(
494
+ ForeignKeyConstraint(
495
+ name="testForeignKey",
496
+ id="#test_fk_id",
497
+ columns=["#test_col_id"],
498
+ referenced_columns=["bad_column"],
499
+ )
500
+ )
501
+
502
+ # Creating a foreign key constraint where the source column is not in
503
+ # the same table as the constraint should raise an exception.
504
+ with self.assertRaises(ValidationError):
505
+ _create_test_schema(
506
+ ForeignKeyConstraint(
507
+ name="testForeignKey",
508
+ id="#test_fk_id",
509
+ columns=["#test_col2_id"], # This column is in test_tbl2, not test_tbl
510
+ referenced_columns=["#test_col_id"],
511
+ )
512
+ )
513
+
514
+ # Creating a foreign key constraint where the referenced column is not
515
+ # a column object should raise an exception.
516
+ with self.assertRaises(ValidationError):
517
+ _create_test_schema(
518
+ ForeignKeyConstraint(
519
+ name="testForeignKey",
520
+ id="#test_fk_id",
521
+ columns=["#test_col_id"],
522
+ referenced_columns=["#test_schema_id"],
523
+ )
524
+ )
525
+
526
+ # Creating a valid unique constraint should not raise an exception.
527
+ _create_test_schema(
528
+ UniqueConstraint(name="testConstraint", id="#test_constraint_id", columns=["#test_col_id"])
529
+ )
530
+
531
+ # Creating a valid foreign key constraint should not raise an
532
+ # exception.
533
+ _create_test_schema(
534
+ ForeignKeyConstraint(
535
+ name="testForeignKey",
536
+ id="#test_fk_id",
537
+ columns=["#test_col_id"],
538
+ referenced_columns=["#test_col2_id"],
539
+ )
540
+ )
541
+
542
+ # Creating a foreign key constraint with a composite key should not
543
+ # raise an exception.
544
+ _create_test_schema(
545
+ ForeignKeyConstraint(
546
+ name="testCompositeForeignKey",
547
+ id="#test_composite_fk_id",
548
+ columns=["#test_col_id", "#test_col_id2"],
549
+ referenced_columns=["#test_col2_id", "#test_col2_id2"],
550
+ )
551
+ )
552
+
454
553
 
455
554
  class IndexTestCase(unittest.TestCase):
456
555
  """Test Pydantic validation of the ``Index`` class."""
@@ -575,8 +674,8 @@ class SchemaTestCase(unittest.TestCase):
575
674
 
576
675
  def test_check_unique_index_names(self) -> None:
577
676
  """Test that index names are unique."""
578
- test_col = Column(name="test_column1", id="#test_table#test_column1", datatype="int")
579
- test_col2 = Column(name="test_column2", id="##test_table#test_column2", datatype="string", length=256)
677
+ test_col = Column(name="test_column1", id="#test_table.test_column1", datatype="int")
678
+ test_col2 = Column(name="test_column2", id="#test_table.test_column2", datatype="string", length=256)
580
679
  test_tbl = Table(name="test_table", id="#test_table", columns=[test_col, test_col2])
581
680
  test_idx = Index(name="idx_test", id="#idx_test", columns=[test_col.id])
582
681
  test_idx2 = Index(name="idx_test", id="#idx_test2", columns=[test_col2.id])
@@ -646,13 +745,14 @@ class SchemaTestCase(unittest.TestCase):
646
745
  """Test loading a schema from a resource."""
647
746
  # Test loading a schema from a resource string.
648
747
  schema = Schema.from_uri(
649
- "resource://felis/schemas/tap_schema_std.yaml", context={"id_generation": True}
748
+ "resource://felis/config/tap_schema/tap_schema_std.yaml", context={"id_generation": True}
650
749
  )
651
750
  self.assertIsInstance(schema, Schema)
652
751
 
653
752
  # Test loading a schema from a ResourcePath.
654
753
  schema = Schema.from_uri(
655
- ResourcePath("resource://felis/schemas/tap_schema_std.yaml"), context={"id_generation": True}
754
+ ResourcePath("resource://felis/config/tap_schema/tap_schema_std.yaml"),
755
+ context={"id_generation": True},
656
756
  )
657
757
  self.assertIsInstance(schema, Schema)
658
758
 
@@ -662,7 +762,7 @@ class SchemaTestCase(unittest.TestCase):
662
762
 
663
763
  # Without ID generation enabled, this schema should fail validation.
664
764
  with self.assertRaises(ValidationError):
665
- Schema.from_uri("resource://felis/schemas/tap_schema_std.yaml")
765
+ Schema.from_uri("resource://felis/config/tap_schema/tap_schema_std.yaml")
666
766
 
667
767
 
668
768
  class SchemaVersionTest(unittest.TestCase):
@@ -118,6 +118,8 @@ class MetaDataTestCase(unittest.TestCase):
118
118
  md_db_fk: ForeignKeyConstraint = md_db_constraint
119
119
  self.assertEqual(md_fk.referred_table.name, md_db_fk.referred_table.name)
120
120
  self.assertEqual(md_fk.column_keys, md_db_fk.column_keys)
121
+ self.assertEqual(md_fk.ondelete, md_db_fk.ondelete)
122
+ self.assertEqual(md_fk.onupdate, md_db_fk.onupdate)
121
123
  elif isinstance(md_constraint, UniqueConstraint) and isinstance(
122
124
  md_db_constraint, UniqueConstraint
123
125
  ):
@@ -240,6 +242,21 @@ class MetaDataTestCase(unittest.TestCase):
240
242
  for table in md.tables.values():
241
243
  self.assertTrue(table.name.endswith("_test"))
242
244
 
245
+ def test_fk_actions(self) -> None:
246
+ """Test that foreign key constraints with on delete and on update
247
+ actions are created correctly.
248
+ """
249
+ schema = Schema.model_validate(self.yaml_data)
250
+ schema.name = "main"
251
+ builder = MetaDataBuilder(schema)
252
+ md = builder.build()
253
+
254
+ for table in md.tables.values():
255
+ for constraint in table.constraints:
256
+ if isinstance(constraint, ForeignKeyConstraint):
257
+ self.assertIn(constraint.ondelete, ["SET NULL"])
258
+ self.assertIn(constraint.onupdate, ["CASCADE"])
259
+
243
260
 
244
261
  if __name__ == "__main__":
245
262
  unittest.main()