lsst-felis 28.2024.4800__tar.gz → 28.2025.500__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 (43) hide show
  1. {lsst_felis-28.2024.4800/python/lsst_felis.egg-info → lsst_felis-28.2025.500}/PKG-INFO +12 -8
  2. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/README.rst +0 -2
  3. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/pyproject.toml +10 -6
  4. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/__init__.py +4 -0
  5. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/cli.py +95 -167
  6. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/datamodel.py +131 -6
  7. lsst_felis-28.2025.500/python/felis/db/schema.py +62 -0
  8. lsst_felis-28.2025.500/python/felis/diff.py +229 -0
  9. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/metadata.py +5 -8
  10. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tap_schema.py +11 -5
  11. lsst_felis-28.2025.500/python/felis/version.py +2 -0
  12. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500/python/lsst_felis.egg-info}/PKG-INFO +12 -8
  13. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/SOURCES.txt +4 -3
  14. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/requires.txt +9 -6
  15. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_cli.py +74 -35
  16. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_datamodel.py +79 -8
  17. lsst_felis-28.2025.500/tests/test_db.py +79 -0
  18. lsst_felis-28.2025.500/tests/test_diff.py +275 -0
  19. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_metadata.py +10 -1
  20. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_tap_schema.py +23 -52
  21. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_tap_schema_postgres.py +9 -5
  22. lsst_felis-28.2024.4800/python/felis/tap.py +0 -597
  23. lsst_felis-28.2024.4800/python/felis/tests/utils.py +0 -122
  24. lsst_felis-28.2024.4800/python/felis/version.py +0 -2
  25. lsst_felis-28.2024.4800/tests/test_tap.py +0 -66
  26. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/COPYRIGHT +0 -0
  27. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/LICENSE +0 -0
  28. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/__init__.py +0 -0
  29. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/dialects.py +0 -0
  30. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/sqltypes.py +0 -0
  31. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/utils.py +0 -0
  32. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/variants.py +0 -0
  33. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/py.typed +0 -0
  34. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/schemas/tap_schema_std.yaml +0 -0
  35. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tests/__init__.py +0 -0
  36. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tests/postgresql.py +0 -0
  37. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/types.py +0 -0
  38. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  39. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  40. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/top_level.txt +0 -0
  41. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/zip-safe +0 -0
  42. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/setup.cfg +0 -0
  43. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_postgres.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: lsst-felis
3
- Version: 28.2024.4800
3
+ Version: 28.2025.500
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+)
@@ -13,18 +13,22 @@ Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Topic :: Scientific/Engineering :: Astronomy
17
18
  Requires-Python: >=3.11.0
18
19
  Description-Content-Type: text/markdown
19
20
  License-File: COPYRIGHT
20
21
  License-File: LICENSE
21
- Requires-Dist: astropy>=4
22
- Requires-Dist: sqlalchemy>=1.4
23
- Requires-Dist: click>=7
24
- Requires-Dist: pyyaml>=6
25
- Requires-Dist: pydantic<3,>=2
26
- Requires-Dist: lsst-utils
22
+ Requires-Dist: alembic
23
+ Requires-Dist: astropy
24
+ Requires-Dist: click
25
+ Requires-Dist: deepdiff
27
26
  Requires-Dist: lsst-resources
27
+ Requires-Dist: lsst-utils
28
+ Requires-Dist: numpy
29
+ Requires-Dist: pydantic<3,>=2
30
+ Requires-Dist: pyyaml
31
+ Requires-Dist: sqlalchemy
28
32
  Provides-Extra: test
29
33
  Requires-Dist: pytest>=3.2; extra == "test"
30
34
  Provides-Extra: dev
@@ -19,8 +19,6 @@ Felis
19
19
  :target: https://github.com/lsst/felis/tags
20
20
  :alt: Latest Tag
21
21
 
22
- YAML Schema Definition Language for Databases
23
-
24
22
  Overview
25
23
  --------
26
24
 
@@ -17,17 +17,21 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3.11",
19
19
  "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
20
21
  "Topic :: Scientific/Engineering :: Astronomy"
21
22
  ]
22
23
  keywords = ["lsst"]
23
24
  dependencies = [
24
- "astropy >= 4",
25
- "sqlalchemy >= 1.4",
26
- "click >= 7",
27
- "pyyaml >= 6",
28
- "pydantic >= 2, < 3",
25
+ "alembic",
26
+ "astropy",
27
+ "click",
28
+ "deepdiff",
29
+ "lsst-resources",
29
30
  "lsst-utils",
30
- "lsst-resources"
31
+ "numpy",
32
+ "pydantic >=2,<3",
33
+ "pyyaml",
34
+ "sqlalchemy"
31
35
  ]
32
36
  requires-python = ">=3.11.0"
33
37
  dynamic = ["version"]
@@ -19,4 +19,8 @@
19
19
  # You should have received a copy of the GNU General Public License
20
20
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
21
 
22
+ from .datamodel import Schema
23
+ from .db.schema import create_database
24
+ from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
25
+ from .metadata import MetaDataBuilder
22
26
  from .version import *
@@ -34,9 +34,10 @@ from sqlalchemy.engine.mock import MockConnection, create_mock_engine
34
34
 
35
35
  from . import __version__
36
36
  from .datamodel import Schema
37
+ from .db.schema import create_database
37
38
  from .db.utils import DatabaseContext, is_mock_url
39
+ from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
38
40
  from .metadata import MetaDataBuilder
39
- from .tap import Tap11Base, TapLoadingVisitor, init_tables
40
41
  from .tap_schema import DataLoader, TableManager
41
42
 
42
43
  __all__ = ["cli"]
@@ -177,174 +178,9 @@ def create(
177
178
  raise click.ClickException(str(e))
178
179
 
179
180
 
180
- @cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
181
- @click.option("--tap-schema-name", help="Alternate database schema name for 'TAP_SCHEMA'")
182
- @click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
183
- @click.option("--tap-tables-table", help="Alternate table name for 'tables'")
184
- @click.option("--tap-columns-table", help="Alternate table name for 'columns'")
185
- @click.option("--tap-keys-table", help="Alternate table name for 'keys'")
186
- @click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
187
- @click.argument("engine-url")
188
- def init_tap(
189
- engine_url: str,
190
- tap_schema_name: str,
191
- tap_schemas_table: str,
192
- tap_tables_table: str,
193
- tap_columns_table: str,
194
- tap_keys_table: str,
195
- tap_key_columns_table: str,
196
- ) -> None:
197
- """Initialize TAP_SCHEMA objects in the database.
198
-
199
- Parameters
200
- ----------
201
- engine_url
202
- SQLAlchemy Engine URL. The target PostgreSQL schema or MySQL database
203
- must already exist and be referenced in the URL.
204
- tap_schema_name
205
- Alterate name for the database schema ``TAP_SCHEMA``.
206
- tap_schemas_table
207
- Alterate table name for ``schemas``.
208
- tap_tables_table
209
- Alterate table name for ``tables``.
210
- tap_columns_table
211
- Alterate table name for ``columns``.
212
- tap_keys_table
213
- Alterate table name for ``keys``.
214
- tap_key_columns_table
215
- Alterate table name for ``key_columns``.
216
-
217
- Notes
218
- -----
219
- The supported version of TAP_SCHEMA in the SQLAlchemy metadata is 1.1. The
220
- tables are created in the database schema specified by the engine URL,
221
- which must be a PostgreSQL schema or MySQL database that already exists.
222
- """
223
- engine = create_engine(engine_url)
224
- init_tables(
225
- tap_schema_name,
226
- tap_schemas_table,
227
- tap_tables_table,
228
- tap_columns_table,
229
- tap_keys_table,
230
- tap_key_columns_table,
231
- )
232
- Tap11Base.metadata.create_all(engine)
233
-
234
-
235
- @cli.command("load-tap", help="Load metadata from a Felis file into a TAP_SCHEMA database")
236
- @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
237
- @click.option("--schema-name", help="Alternate Schema Name for Felis file")
238
- @click.option("--catalog-name", help="Catalog Name for Schema")
239
- @click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
240
- @click.option("--tap-schema-name", help="Alternate schema name for 'TAP_SCHEMA'")
241
- @click.option("--tap-tables-postfix", help="Postfix for TAP_SCHEMA table names")
242
- @click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
243
- @click.option("--tap-tables-table", help="Alternate table name for 'tables'")
244
- @click.option("--tap-columns-table", help="Alternate table name for 'columns'")
245
- @click.option("--tap-keys-table", help="Alternate table name for 'keys'")
246
- @click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
247
- @click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
248
- @click.argument("file", type=click.File())
249
- def load_tap(
250
- engine_url: str,
251
- schema_name: str,
252
- catalog_name: str,
253
- dry_run: bool,
254
- tap_schema_name: str,
255
- tap_tables_postfix: str,
256
- tap_schemas_table: str,
257
- tap_tables_table: str,
258
- tap_columns_table: str,
259
- tap_keys_table: str,
260
- tap_key_columns_table: str,
261
- tap_schema_index: int,
262
- file: IO[str],
263
- ) -> None:
264
- """Load TAP metadata from a Felis file.
265
-
266
- This command loads the associated TAP metadata from a Felis YAML file
267
- into the TAP_SCHEMA tables.
268
-
269
- Parameters
270
- ----------
271
- engine_url
272
- SQLAlchemy Engine URL to catalog.
273
- schema_name
274
- Alternate schema name. This overrides the schema name in the
275
- ``catalog`` field of the Felis file.
276
- catalog_name
277
- Catalog name for the schema. This possibly duplicates the
278
- ``tap_schema_name`` argument (DM-44870).
279
- dry_run
280
- Dry run only to print out commands instead of executing.
281
- tap_schema_name
282
- Alternate name for the schema of TAP_SCHEMA in the database.
283
- tap_tables_postfix
284
- Postfix for TAP table names that will be automatically appended.
285
- tap_schemas_table
286
- Alternate table name for ``schemas``.
287
- tap_tables_table
288
- Alternate table name for ``tables``.
289
- tap_columns_table
290
- Alternate table name for ``columns``.
291
- tap_keys_table
292
- Alternate table name for ``keys``.
293
- tap_key_columns_table
294
- Alternate table name for ``key_columns``.
295
- tap_schema_index
296
- TAP_SCHEMA index of the schema in this TAP environment.
297
- file
298
- Felis file to read.
299
-
300
- Notes
301
- -----
302
- The data will be loaded into the TAP_SCHEMA from the engine URL. The
303
- tables must have already been initialized or an error will occur.
304
- """
305
- schema = Schema.from_stream(file)
306
-
307
- tap_tables = init_tables(
308
- tap_schema_name,
309
- tap_tables_postfix,
310
- tap_schemas_table,
311
- tap_tables_table,
312
- tap_columns_table,
313
- tap_keys_table,
314
- tap_key_columns_table,
315
- )
316
-
317
- if not dry_run:
318
- engine = create_engine(engine_url)
319
-
320
- if engine_url == "sqlite://" and not dry_run:
321
- # In Memory SQLite - Mostly used to test
322
- Tap11Base.metadata.create_all(engine)
323
-
324
- tap_visitor = TapLoadingVisitor(
325
- engine,
326
- catalog_name=catalog_name,
327
- schema_name=schema_name,
328
- tap_tables=tap_tables,
329
- tap_schema_index=tap_schema_index,
330
- )
331
- tap_visitor.visit_schema(schema)
332
- else:
333
- conn = DatabaseContext.create_mock_engine(engine_url)
334
-
335
- tap_visitor = TapLoadingVisitor.from_mock_connection(
336
- conn,
337
- catalog_name=catalog_name,
338
- schema_name=schema_name,
339
- tap_tables=tap_tables,
340
- tap_schema_index=tap_schema_index,
341
- )
342
- tap_visitor.visit_schema(schema)
343
-
344
-
345
181
  @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
346
182
  @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
347
- @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
183
+ @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)")
348
184
  @click.option(
349
185
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
350
186
  )
@@ -415,6 +251,40 @@ def load_tap_schema(
415
251
  ).load()
416
252
 
417
253
 
254
+ @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
255
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
256
+ @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
257
+ @click.option(
258
+ "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
259
+ )
260
+ @click.pass_context
261
+ def init_tap_schema(
262
+ ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
263
+ ) -> None:
264
+ """Initialize a standard TAP_SCHEMA database.
265
+
266
+ Parameters
267
+ ----------
268
+ engine_url
269
+ SQLAlchemy Engine URL.
270
+ tap_schema_name
271
+ Name of the TAP_SCHEMA schema in the database.
272
+ tap_tables_postfix
273
+ Postfix which is applied to standard TAP_SCHEMA table names.
274
+ """
275
+ url = make_url(engine_url)
276
+ engine: Engine | MockConnection
277
+ if is_mock_url(url):
278
+ raise click.ClickException("Mock engine URL is not supported for this command")
279
+ engine = create_engine(engine_url)
280
+ mgr = TableManager(
281
+ apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
282
+ schema_name=tap_schema_name,
283
+ table_name_postfix=tap_tables_postfix,
284
+ )
285
+ mgr.initialize_database(engine)
286
+
287
+
418
288
  @cli.command("validate", help="Validate one or more Felis YAML files")
419
289
  @click.option(
420
290
  "--check-description", is_flag=True, help="Check that all objects have a description", default=False
@@ -493,5 +363,63 @@ def validate(
493
363
  raise click.exceptions.Exit(rc)
494
364
 
495
365
 
366
+ @cli.command(
367
+ "diff",
368
+ help="""
369
+ Compare two schemas or a schema and a database for changes
370
+
371
+ Examples:
372
+
373
+ felis diff schema1.yaml schema2.yaml
374
+
375
+ felis diff -c alembic schema1.yaml schema2.yaml
376
+
377
+ felis diff --engine-url sqlite:///test.db schema.yaml
378
+ """,
379
+ )
380
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
381
+ @click.option(
382
+ "-c",
383
+ "--comparator",
384
+ type=click.Choice(["alembic", "deepdiff"], case_sensitive=False),
385
+ help="Comparator to use for schema comparison",
386
+ default="deepdiff",
387
+ )
388
+ @click.option("-E", "--error-on-change", is_flag=True, help="Exit with error code if schemas are different")
389
+ @click.argument("files", nargs=-1, type=click.File())
390
+ @click.pass_context
391
+ def diff(
392
+ ctx: click.Context,
393
+ engine_url: str | None,
394
+ comparator: str,
395
+ error_on_change: bool,
396
+ files: Iterable[IO[str]],
397
+ ) -> None:
398
+ schemas = [
399
+ Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files
400
+ ]
401
+
402
+ diff: SchemaDiff
403
+ if len(schemas) == 2 and engine_url is None:
404
+ if comparator == "alembic":
405
+ db_context = create_database(schemas[0])
406
+ assert isinstance(db_context.engine, Engine)
407
+ diff = DatabaseDiff(schemas[1], db_context.engine)
408
+ else:
409
+ diff = FormattedSchemaDiff(schemas[0], schemas[1])
410
+ elif len(schemas) == 1 and engine_url is not None:
411
+ engine = create_engine(engine_url)
412
+ diff = DatabaseDiff(schemas[0], engine)
413
+ else:
414
+ raise click.ClickException(
415
+ "Invalid arguments - provide two schemas or a schema and a database engine URL"
416
+ )
417
+
418
+ diff.print()
419
+
420
+ if diff.has_changes and error_on_change:
421
+ raise click.ClickException("Schema was changed")
422
+
423
+
496
424
  if __name__ == "__main__":
497
425
  cli()
@@ -134,6 +134,32 @@ class DataType(StrEnum):
134
134
  timestamp = auto()
135
135
 
136
136
 
137
+ def validate_ivoa_ucd(ivoa_ucd: str) -> str:
138
+ """Validate IVOA UCD values.
139
+
140
+ Parameters
141
+ ----------
142
+ ivoa_ucd
143
+ IVOA UCD value to check.
144
+
145
+ Returns
146
+ -------
147
+ `str`
148
+ The IVOA UCD value if it is valid.
149
+
150
+ Raises
151
+ ------
152
+ ValueError
153
+ If the IVOA UCD value is invalid.
154
+ """
155
+ if ivoa_ucd is not None:
156
+ try:
157
+ ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
158
+ except ValueError as e:
159
+ raise ValueError(f"Invalid IVOA UCD: {e}")
160
+ return ivoa_ucd
161
+
162
+
137
163
  class Column(BaseObject):
138
164
  """Column model."""
139
165
 
@@ -235,12 +261,7 @@ class Column(BaseObject):
235
261
  `str`
236
262
  The IVOA UCD value if it is valid.
237
263
  """
238
- if ivoa_ucd is not None:
239
- try:
240
- ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
241
- except ValueError as e:
242
- raise ValueError(f"Invalid IVOA UCD: {e}")
243
- return ivoa_ucd
264
+ return validate_ivoa_ucd(ivoa_ucd)
244
265
 
245
266
  @model_validator(mode="after")
246
267
  def check_units(self) -> Column:
@@ -551,6 +572,70 @@ _ConstraintType = Annotated[
551
572
  """Type alias for a constraint type."""
552
573
 
553
574
 
575
+ ColumnRef: TypeAlias = str
576
+ """Type alias for a column reference."""
577
+
578
+
579
+ class ColumnGroup(BaseObject):
580
+ """Column group model."""
581
+
582
+ columns: list[ColumnRef | Column] = Field(..., min_length=1)
583
+ """Columns in the group."""
584
+
585
+ ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
586
+ """IVOA UCD of the column."""
587
+
588
+ table: Table | None = None
589
+ """Reference to the parent table."""
590
+
591
+ @field_validator("ivoa_ucd")
592
+ @classmethod
593
+ def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
594
+ """Check that IVOA UCD values are valid.
595
+
596
+ Parameters
597
+ ----------
598
+ ivoa_ucd
599
+ IVOA UCD value to check.
600
+
601
+ Returns
602
+ -------
603
+ `str`
604
+ The IVOA UCD value if it is valid.
605
+ """
606
+ return validate_ivoa_ucd(ivoa_ucd)
607
+
608
+ @model_validator(mode="after")
609
+ def check_unique_columns(self) -> ColumnGroup:
610
+ """Check that the columns list contains unique items.
611
+
612
+ Returns
613
+ -------
614
+ `ColumnGroup`
615
+ The column group being validated.
616
+ """
617
+ column_ids = [col if isinstance(col, str) else col.id for col in self.columns]
618
+ if len(column_ids) != len(set(column_ids)):
619
+ raise ValueError("Columns in the group must be unique")
620
+ return self
621
+
622
+ def _dereference_columns(self) -> None:
623
+ """Dereference ColumnRef to Column objects."""
624
+ if self.table is None:
625
+ raise ValueError("ColumnGroup must have a reference to its parent table")
626
+
627
+ dereferenced_columns: list[ColumnRef | Column] = []
628
+ for col in self.columns:
629
+ if isinstance(col, str):
630
+ # Dereference ColumnRef to Column object
631
+ col_obj = self.table._find_column_by_id(col)
632
+ dereferenced_columns.append(col_obj)
633
+ else:
634
+ dereferenced_columns.append(col)
635
+
636
+ self.columns = dereferenced_columns
637
+
638
+
554
639
  class Table(BaseObject):
555
640
  """Table model."""
556
641
 
@@ -563,6 +648,9 @@ class Table(BaseObject):
563
648
  indexes: list[Index] = Field(default_factory=list)
564
649
  """Indexes on the table."""
565
650
 
651
+ column_groups: list[ColumnGroup] = Field(default_factory=list, alias="columnGroups")
652
+ """Column groups in the table."""
653
+
566
654
  primary_key: str | list[str] | None = Field(None, alias="primaryKey")
567
655
  """Primary key of the table."""
568
656
 
@@ -653,6 +741,43 @@ class Table(BaseObject):
653
741
  return self
654
742
  raise ValueError(f"Table '{self.name}' is missing at least one column designated as 'tap:principal'")
655
743
 
744
+ def _find_column_by_id(self, id: str) -> Column:
745
+ """Find a column by ID.
746
+
747
+ Parameters
748
+ ----------
749
+ id
750
+ The ID of the column to find.
751
+
752
+ Returns
753
+ -------
754
+ `Column`
755
+ The column with the given ID.
756
+
757
+ Raises
758
+ ------
759
+ ValueError
760
+ Raised if the column is not found.
761
+ """
762
+ for column in self.columns:
763
+ if column.id == id:
764
+ return column
765
+ raise ValueError(f"Column '{id}' not found in table '{self.name}'")
766
+
767
+ @model_validator(mode="after")
768
+ def dereference_column_groups(self: Table) -> Table:
769
+ """Dereference columns in column groups.
770
+
771
+ Returns
772
+ -------
773
+ `Table`
774
+ The table with dereferenced column groups.
775
+ """
776
+ for group in self.column_groups:
777
+ group.table = self
778
+ group._dereference_columns()
779
+ return self
780
+
656
781
 
657
782
  class SchemaVersion(BaseModel):
658
783
  """Schema version model."""
@@ -0,0 +1,62 @@
1
+ """Database utilities for Felis schemas."""
2
+
3
+ # This file is part of felis.
4
+ #
5
+ # Developed for the LSST Data Management System.
6
+ # This product includes software developed by the LSST Project
7
+ # (https://www.lsst.org).
8
+ # See the COPYRIGHT file at the top-level directory of this distribution
9
+ # for details of code ownership.
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ from sqlalchemy import Engine, create_engine
25
+
26
+ from ..datamodel import Schema
27
+ from ..metadata import MetaDataBuilder
28
+ from .utils import DatabaseContext
29
+
30
+ __all__ = ["create_database"]
31
+
32
+
33
+ def create_database(schema: Schema, engine_or_url_str: Engine | str | None = None) -> DatabaseContext:
34
+ """
35
+ Create a database from the specified `Schema`.
36
+
37
+ Parameters
38
+ ----------
39
+ schema
40
+ The schema to create.
41
+ engine_or_url_str
42
+ The SQLAlchemy engine or URL to use for database creation.
43
+ If None, an in-memory SQLite database will be created.
44
+
45
+ Returns
46
+ -------
47
+ `DatabaseContext`
48
+ The database context object.
49
+ """
50
+ if engine_or_url_str is not None:
51
+ engine = (
52
+ engine_or_url_str if isinstance(engine_or_url_str, Engine) else create_engine(engine_or_url_str)
53
+ )
54
+ else:
55
+ engine = create_engine("sqlite:///:memory:")
56
+ metadata = MetaDataBuilder(
57
+ schema, apply_schema_to_metadata=False if engine.url.drivername == "sqlite" else True
58
+ ).build()
59
+ ctx = DatabaseContext(metadata, engine)
60
+ ctx.initialize()
61
+ ctx.create_all()
62
+ return ctx