lsst-felis 29.2025.4500__py3-none-any.whl → 30.0.0rc3__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.
- felis/__init__.py +1 -4
- felis/cli.py +172 -87
- felis/config/tap_schema/tap_schema_extensions.yaml +73 -0
- felis/datamodel.py +2 -3
- felis/db/{dialects.py → _dialects.py} +69 -4
- felis/db/{variants.py → _variants.py} +1 -1
- felis/db/database_context.py +917 -0
- felis/metadata.py +79 -11
- felis/tap_schema.py +159 -177
- felis/tests/postgresql.py +1 -1
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/METADATA +1 -1
- lsst_felis-30.0.0rc3.dist-info/RECORD +31 -0
- felis/db/schema.py +0 -62
- felis/db/utils.py +0 -409
- lsst_felis-29.2025.4500.dist-info/RECORD +0 -31
- /felis/db/{sqltypes.py → _sqltypes.py} +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/WHEEL +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/entry_points.txt +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/licenses/COPYRIGHT +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/licenses/LICENSE +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/top_level.txt +0 -0
- {lsst_felis-29.2025.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/zip-safe +0 -0
felis/__init__.py
CHANGED
|
@@ -19,10 +19,7 @@
|
|
|
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
|
|
23
|
-
from .db.schema import create_database
|
|
24
|
-
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
25
|
-
from .metadata import MetaDataBuilder
|
|
22
|
+
from .datamodel import *
|
|
26
23
|
|
|
27
24
|
from importlib.metadata import PackageNotFoundError, version
|
|
28
25
|
|
felis/cli.py
CHANGED
|
@@ -29,15 +29,12 @@ from typing import IO
|
|
|
29
29
|
|
|
30
30
|
import click
|
|
31
31
|
from pydantic import ValidationError
|
|
32
|
-
from sqlalchemy.engine import Engine, create_engine, make_url
|
|
33
|
-
from sqlalchemy.engine.mock import MockConnection, create_mock_engine
|
|
34
32
|
|
|
35
33
|
from . import __version__
|
|
36
34
|
from .datamodel import Schema
|
|
37
|
-
from .db.
|
|
38
|
-
from .db.utils import DatabaseContext, is_mock_url
|
|
35
|
+
from .db.database_context import create_database_context
|
|
39
36
|
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
40
|
-
from .metadata import
|
|
37
|
+
from .metadata import create_metadata
|
|
41
38
|
from .tap_schema import DataLoader, MetadataInserter, TableManager
|
|
42
39
|
|
|
43
40
|
__all__ = ["cli"]
|
|
@@ -100,6 +97,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
|
|
|
100
97
|
"--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
|
|
101
98
|
)
|
|
102
99
|
@click.option("--ignore-constraints", is_flag=True, help="Ignore constraints when creating tables")
|
|
100
|
+
@click.option("--skip-indexes", is_flag=True, help="Skip creating indexes when building metadata")
|
|
103
101
|
@click.argument("file", type=click.File())
|
|
104
102
|
@click.pass_context
|
|
105
103
|
def create(
|
|
@@ -112,6 +110,7 @@ def create(
|
|
|
112
110
|
dry_run: bool,
|
|
113
111
|
output_file: IO[str] | None,
|
|
114
112
|
ignore_constraints: bool,
|
|
113
|
+
skip_indexes: bool,
|
|
115
114
|
file: IO[str],
|
|
116
115
|
) -> None:
|
|
117
116
|
"""Create database objects from the Felis file.
|
|
@@ -134,55 +133,111 @@ def create(
|
|
|
134
133
|
Write SQL commands to a file instead of executing.
|
|
135
134
|
ignore_constraints
|
|
136
135
|
Ignore constraints when creating tables.
|
|
136
|
+
skip_indexes
|
|
137
|
+
Skip creating indexes when building metadata.
|
|
137
138
|
file
|
|
138
139
|
Felis file to read.
|
|
139
140
|
"""
|
|
140
141
|
try:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if not url.host and not url.drivername == "sqlite":
|
|
150
|
-
dry_run = True
|
|
151
|
-
logger.info("Forcing dry run for non-sqlite engine URL with no host")
|
|
152
|
-
|
|
153
|
-
metadata = MetaDataBuilder(schema, ignore_constraints=ignore_constraints).build()
|
|
154
|
-
logger.debug(f"Created metadata with schema name: {metadata.schema}")
|
|
155
|
-
|
|
156
|
-
engine: Engine | MockConnection
|
|
157
|
-
if not dry_run and not output_file:
|
|
158
|
-
engine = create_engine(url, echo=echo)
|
|
159
|
-
else:
|
|
160
|
-
if dry_run:
|
|
161
|
-
logger.info("Dry run will be executed")
|
|
162
|
-
engine = DatabaseContext.create_mock_engine(url, output_file)
|
|
163
|
-
if output_file:
|
|
164
|
-
logger.info("Writing SQL output to: " + output_file.name)
|
|
142
|
+
metadata = create_metadata(
|
|
143
|
+
file,
|
|
144
|
+
id_generation=ctx.obj["id_generation"],
|
|
145
|
+
schema_name=schema_name,
|
|
146
|
+
ignore_constraints=ignore_constraints,
|
|
147
|
+
skip_indexes=skip_indexes,
|
|
148
|
+
engine_url=engine_url,
|
|
149
|
+
)
|
|
165
150
|
|
|
166
|
-
|
|
151
|
+
with create_database_context(
|
|
152
|
+
engine_url,
|
|
153
|
+
metadata,
|
|
154
|
+
echo=echo,
|
|
155
|
+
dry_run=dry_run,
|
|
156
|
+
output_file=output_file,
|
|
157
|
+
) as db_ctx:
|
|
158
|
+
if drop and initialize:
|
|
159
|
+
raise ValueError("Cannot drop and initialize schema at the same time")
|
|
167
160
|
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
if drop:
|
|
162
|
+
logger.debug("Dropping schema if it exists")
|
|
163
|
+
db_ctx.drop()
|
|
164
|
+
initialize = True # If schema is dropped, it needs to be recreated.
|
|
170
165
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
initialize = True # If schema is dropped, it needs to be recreated.
|
|
166
|
+
if initialize:
|
|
167
|
+
logger.debug("Creating schema if not exists")
|
|
168
|
+
db_ctx.initialize()
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
logger.debug("Creating schema if not exists")
|
|
178
|
-
context.initialize()
|
|
170
|
+
db_ctx.create_all()
|
|
179
171
|
|
|
180
|
-
context.create_all()
|
|
181
172
|
except Exception as e:
|
|
182
173
|
logger.exception(e)
|
|
183
174
|
raise click.ClickException(str(e))
|
|
184
175
|
|
|
185
176
|
|
|
177
|
+
@cli.command("create-indexes", help="Create database indexes defined in the Felis file")
|
|
178
|
+
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
|
|
179
|
+
@click.option("--schema-name", help="Alternate schema name to override Felis file")
|
|
180
|
+
@click.argument("file", type=click.File())
|
|
181
|
+
@click.pass_context
|
|
182
|
+
def create_indexes(
|
|
183
|
+
ctx: click.Context,
|
|
184
|
+
engine_url: str,
|
|
185
|
+
schema_name: str | None,
|
|
186
|
+
file: IO[str],
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Create indexes from a Felis YAML file in a target database.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
engine_url
|
|
193
|
+
SQLAlchemy Engine URL.
|
|
194
|
+
file
|
|
195
|
+
Felis file to read.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
metadata = create_metadata(
|
|
199
|
+
file, id_generation=ctx.obj["id_generation"], schema_name=schema_name, engine_url=engine_url
|
|
200
|
+
)
|
|
201
|
+
with create_database_context(engine_url, metadata) as db_ctx:
|
|
202
|
+
db_ctx.create_indexes()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.exception(e)
|
|
205
|
+
raise click.ClickException("Error creating indexes: " + str(e))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@cli.command("drop-indexes", help="Drop database indexes defined in the Felis file")
|
|
209
|
+
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
|
|
210
|
+
@click.option("--schema-name", help="Alternate schema name to override Felis file")
|
|
211
|
+
@click.argument("file", type=click.File())
|
|
212
|
+
@click.pass_context
|
|
213
|
+
def drop_indexes(
|
|
214
|
+
ctx: click.Context,
|
|
215
|
+
engine_url: str,
|
|
216
|
+
schema_name: str | None,
|
|
217
|
+
file: IO[str],
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Drop indexes from a Felis YAML file in a target database.
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
engine_url
|
|
224
|
+
SQLAlchemy Engine URL.
|
|
225
|
+
schema-name
|
|
226
|
+
Alternate schema name to override Felis file.
|
|
227
|
+
file
|
|
228
|
+
Felis file to read.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
metadata = create_metadata(
|
|
232
|
+
file, id_generation=ctx.obj["id_generation"], schema_name=schema_name, engine_url=engine_url
|
|
233
|
+
)
|
|
234
|
+
with create_database_context(engine_url, metadata) as db:
|
|
235
|
+
db.drop_indexes()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.exception(e)
|
|
238
|
+
raise click.ClickException("Error dropping indexes: " + str(e))
|
|
239
|
+
|
|
240
|
+
|
|
186
241
|
@cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
187
242
|
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
188
243
|
@click.option(
|
|
@@ -197,7 +252,9 @@ def create(
|
|
|
197
252
|
@click.option("--tap-schema-index", "-i", type=int, help="TAP_SCHEMA index of the schema in this environment")
|
|
198
253
|
@click.option("--dry-run", "-D", is_flag=True, help="Execute dry run only. Does not insert any data.")
|
|
199
254
|
@click.option("--echo", "-e", is_flag=True, help="Print out the generated insert statements to stdout")
|
|
200
|
-
@click.option(
|
|
255
|
+
@click.option(
|
|
256
|
+
"--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
|
|
257
|
+
)
|
|
201
258
|
@click.option(
|
|
202
259
|
"--force-unbounded-arraysize",
|
|
203
260
|
is_flag=True,
|
|
@@ -221,7 +278,7 @@ def load_tap_schema(
|
|
|
221
278
|
tap_schema_index: int,
|
|
222
279
|
dry_run: bool,
|
|
223
280
|
echo: bool,
|
|
224
|
-
output_file: str | None,
|
|
281
|
+
output_file: IO[str] | None,
|
|
225
282
|
force_unbounded_arraysize: bool,
|
|
226
283
|
unique_keys: bool,
|
|
227
284
|
file: IO[str],
|
|
@@ -250,42 +307,51 @@ def load_tap_schema(
|
|
|
250
307
|
The TAP_SCHEMA database must already exist or the command will fail. This
|
|
251
308
|
command will not initialize the TAP_SCHEMA tables.
|
|
252
309
|
"""
|
|
253
|
-
|
|
254
|
-
engine: Engine | MockConnection
|
|
255
|
-
if dry_run or is_mock_url(url):
|
|
256
|
-
engine = create_mock_engine(url, executor=None)
|
|
257
|
-
else:
|
|
258
|
-
engine = create_engine(engine_url)
|
|
310
|
+
# Create TableManager with automatic dialect detection
|
|
259
311
|
mgr = TableManager(
|
|
260
|
-
|
|
261
|
-
apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
|
|
312
|
+
engine_url=engine_url,
|
|
262
313
|
schema_name=tap_schema_name,
|
|
263
314
|
table_name_postfix=tap_tables_postfix,
|
|
264
315
|
)
|
|
265
316
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
317
|
+
# Create DatabaseContext using TableManager's metadata
|
|
318
|
+
with create_database_context(
|
|
319
|
+
engine_url, mgr.metadata, echo=echo, dry_run=dry_run, output_file=output_file
|
|
320
|
+
) as db_ctx:
|
|
321
|
+
schema = Schema.from_stream(
|
|
322
|
+
file,
|
|
323
|
+
context={
|
|
324
|
+
"id_generation": ctx.obj["id_generation"],
|
|
325
|
+
"force_unbounded_arraysize": force_unbounded_arraysize,
|
|
326
|
+
},
|
|
327
|
+
)
|
|
273
328
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
329
|
+
DataLoader(
|
|
330
|
+
schema,
|
|
331
|
+
mgr,
|
|
332
|
+
db_context=db_ctx,
|
|
333
|
+
tap_schema_index=tap_schema_index,
|
|
334
|
+
dry_run=dry_run,
|
|
335
|
+
print_sql=echo,
|
|
336
|
+
output_file=output_file,
|
|
337
|
+
unique_keys=unique_keys,
|
|
338
|
+
).load()
|
|
284
339
|
|
|
285
340
|
|
|
286
341
|
@cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
|
|
287
342
|
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
|
|
288
343
|
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
|
|
344
|
+
@click.option(
|
|
345
|
+
"--extensions",
|
|
346
|
+
type=str,
|
|
347
|
+
default=None,
|
|
348
|
+
help=(
|
|
349
|
+
"Optional path to extensions YAML file (system path or resource:// URI). "
|
|
350
|
+
"If not provided, no extensions will be applied. "
|
|
351
|
+
"Example (default packaged extensions): "
|
|
352
|
+
"--extensions resource://felis/config/tap_schema/tap_schema_extensions.yaml"
|
|
353
|
+
),
|
|
354
|
+
)
|
|
289
355
|
@click.option(
|
|
290
356
|
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
291
357
|
)
|
|
@@ -297,7 +363,12 @@ def load_tap_schema(
|
|
|
297
363
|
)
|
|
298
364
|
@click.pass_context
|
|
299
365
|
def init_tap_schema(
|
|
300
|
-
ctx: click.Context,
|
|
366
|
+
ctx: click.Context,
|
|
367
|
+
engine_url: str,
|
|
368
|
+
tap_schema_name: str,
|
|
369
|
+
extensions: str | None,
|
|
370
|
+
tap_tables_postfix: str,
|
|
371
|
+
insert_metadata: bool,
|
|
301
372
|
) -> None:
|
|
302
373
|
"""Initialize a standard TAP_SCHEMA database.
|
|
303
374
|
|
|
@@ -307,6 +378,8 @@ def init_tap_schema(
|
|
|
307
378
|
SQLAlchemy Engine URL.
|
|
308
379
|
tap_schema_name
|
|
309
380
|
Name of the TAP_SCHEMA schema in the database.
|
|
381
|
+
extensions
|
|
382
|
+
Extensions YAML file.
|
|
310
383
|
tap_tables_postfix
|
|
311
384
|
Postfix which is applied to standard TAP_SCHEMA table names.
|
|
312
385
|
insert_metadata
|
|
@@ -314,20 +387,19 @@ def init_tap_schema(
|
|
|
314
387
|
If set to False, only the TAP_SCHEMA tables will be created, but no
|
|
315
388
|
metadata will be inserted.
|
|
316
389
|
"""
|
|
317
|
-
|
|
318
|
-
engine: Engine | MockConnection
|
|
319
|
-
if is_mock_url(url):
|
|
320
|
-
raise click.ClickException("Mock engine URL is not supported for this command")
|
|
321
|
-
engine = create_engine(engine_url)
|
|
390
|
+
# Create TableManager with automatic dialect detection
|
|
322
391
|
mgr = TableManager(
|
|
323
|
-
|
|
392
|
+
engine_url=engine_url,
|
|
324
393
|
schema_name=tap_schema_name,
|
|
325
394
|
table_name_postfix=tap_tables_postfix,
|
|
395
|
+
extensions_path=extensions,
|
|
326
396
|
)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
397
|
+
|
|
398
|
+
# Create DatabaseContext using TableManager's metadata
|
|
399
|
+
with create_database_context(engine_url, mgr.metadata) as db_ctx:
|
|
400
|
+
mgr.initialize_database(db_context=db_ctx)
|
|
401
|
+
if insert_metadata:
|
|
402
|
+
MetadataInserter(mgr, db_context=db_ctx).insert_metadata()
|
|
331
403
|
|
|
332
404
|
|
|
333
405
|
@cli.command("validate", help="Validate one or more Felis YAML files")
|
|
@@ -440,24 +512,37 @@ def diff(
|
|
|
440
512
|
error_on_change: bool,
|
|
441
513
|
files: Iterable[IO[str]],
|
|
442
514
|
) -> None:
|
|
515
|
+
files_list = list(files)
|
|
443
516
|
schemas = [
|
|
444
|
-
Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in
|
|
517
|
+
Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files_list
|
|
445
518
|
]
|
|
446
|
-
|
|
447
519
|
diff: SchemaDiff
|
|
448
|
-
if len(schemas) == 2
|
|
520
|
+
if len(schemas) == 2:
|
|
449
521
|
if comparator == "alembic":
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
522
|
+
# Reset file stream to beginning before re-reading
|
|
523
|
+
files_list[0].seek(0)
|
|
524
|
+
metadata = create_metadata(
|
|
525
|
+
files_list[0], id_generation=ctx.obj["id_generation"], engine_url=engine_url
|
|
526
|
+
)
|
|
527
|
+
with create_database_context(
|
|
528
|
+
engine_url if engine_url else "sqlite:///:memory:", metadata
|
|
529
|
+
) as db_ctx:
|
|
530
|
+
db_ctx.initialize()
|
|
531
|
+
db_ctx.create_all()
|
|
532
|
+
diff = DatabaseDiff(schemas[1], db_ctx.engine)
|
|
453
533
|
else:
|
|
454
534
|
diff = FormattedSchemaDiff(schemas[0], schemas[1])
|
|
455
535
|
elif len(schemas) == 1 and engine_url is not None:
|
|
456
|
-
|
|
457
|
-
|
|
536
|
+
# Create minimal metadata for the context manager
|
|
537
|
+
from sqlalchemy import MetaData
|
|
538
|
+
|
|
539
|
+
metadata = MetaData()
|
|
540
|
+
|
|
541
|
+
with create_database_context(engine_url, metadata) as db_ctx:
|
|
542
|
+
diff = DatabaseDiff(schemas[0], db_ctx.engine)
|
|
458
543
|
else:
|
|
459
544
|
raise click.ClickException(
|
|
460
|
-
"Invalid arguments - provide two schemas or a schema and a database engine URL"
|
|
545
|
+
"Invalid arguments - provide two schemas or a single schema and a database engine URL"
|
|
461
546
|
)
|
|
462
547
|
|
|
463
548
|
diff.print()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# TAP_SCHEMA Extensions
|
|
2
|
+
# This file defines additional columns to be added to the standard TAP_SCHEMA tables
|
|
3
|
+
# These are extensions beyond the IVOA TAP 1.1 specification and needed for the CADC TAP service
|
|
4
|
+
|
|
5
|
+
# Extension columns for each TAP_SCHEMA table
|
|
6
|
+
name: tap_schema_extensions
|
|
7
|
+
description: Extensions to the standard TAP_SCHEMA tables
|
|
8
|
+
|
|
9
|
+
tables:
|
|
10
|
+
- name: schemas
|
|
11
|
+
description: "Extensions to TAP_SCHEMA.schemas table"
|
|
12
|
+
columns:
|
|
13
|
+
- name: owner_id
|
|
14
|
+
datatype: char
|
|
15
|
+
length: 32
|
|
16
|
+
description: "Owner identifier for user-created content"
|
|
17
|
+
|
|
18
|
+
- name: read_anon
|
|
19
|
+
datatype: int
|
|
20
|
+
description: "Anonymous read permission flag (0 or 1)"
|
|
21
|
+
|
|
22
|
+
- name: read_only_group
|
|
23
|
+
datatype: char
|
|
24
|
+
length: 128
|
|
25
|
+
description: "Read-only group identifier"
|
|
26
|
+
|
|
27
|
+
- name: read_write_group
|
|
28
|
+
datatype: char
|
|
29
|
+
length: 128
|
|
30
|
+
description: "Read-write group identifier"
|
|
31
|
+
|
|
32
|
+
- name: api_created
|
|
33
|
+
datatype: int
|
|
34
|
+
description: "Flag indicating if schema was created via TAP service API (0 or 1)"
|
|
35
|
+
|
|
36
|
+
- name: tables
|
|
37
|
+
description: "Extensions to TAP_SCHEMA.tables table"
|
|
38
|
+
columns:
|
|
39
|
+
- name: owner_id
|
|
40
|
+
datatype: char
|
|
41
|
+
length: 32
|
|
42
|
+
description: "Owner identifier for user-created content"
|
|
43
|
+
|
|
44
|
+
- name: read_anon
|
|
45
|
+
datatype: int
|
|
46
|
+
description: "Anonymous read permission flag (0 or 1)"
|
|
47
|
+
|
|
48
|
+
- name: read_only_group
|
|
49
|
+
datatype: char
|
|
50
|
+
length: 128
|
|
51
|
+
description: "Read-only group identifier"
|
|
52
|
+
|
|
53
|
+
- name: read_write_group
|
|
54
|
+
datatype: char
|
|
55
|
+
length: 128
|
|
56
|
+
description: "Read-write group identifier"
|
|
57
|
+
|
|
58
|
+
- name: api_created
|
|
59
|
+
datatype: int
|
|
60
|
+
description: "Flag indicating if table was created via TAP service API (0 or 1)"
|
|
61
|
+
|
|
62
|
+
- name: view_target
|
|
63
|
+
datatype: char
|
|
64
|
+
length: 128
|
|
65
|
+
description: "View target identifier"
|
|
66
|
+
|
|
67
|
+
- name: columns
|
|
68
|
+
description: "Extensions to TAP_SCHEMA.columns table"
|
|
69
|
+
columns:
|
|
70
|
+
- name: column_id
|
|
71
|
+
datatype: char
|
|
72
|
+
length: 32
|
|
73
|
+
description: "Globally unique columnID for use as an XML ID attribute on the FIELD in VOTable output"
|
felis/datamodel.py
CHANGED
|
@@ -47,9 +47,8 @@ from pydantic import (
|
|
|
47
47
|
)
|
|
48
48
|
from pydantic_core import InitErrorDetails
|
|
49
49
|
|
|
50
|
-
from .db.
|
|
51
|
-
from .db.
|
|
52
|
-
from .db.utils import string_to_typeengine
|
|
50
|
+
from .db._dialects import get_supported_dialects, string_to_typeengine
|
|
51
|
+
from .db._sqltypes import get_type_func
|
|
53
52
|
from .types import Boolean, Byte, Char, Double, FelisType, Float, Int, Long, Short, String, Text, Unicode
|
|
54
53
|
|
|
55
54
|
logger = logging.getLogger(__name__)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Utilities for accessing SQLAlchemy dialects and their type modules."""
|
|
2
2
|
|
|
3
3
|
# This file is part of felis.
|
|
4
4
|
#
|
|
@@ -23,16 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
import re
|
|
26
27
|
from collections.abc import Mapping
|
|
27
28
|
from types import MappingProxyType, ModuleType
|
|
28
29
|
|
|
29
|
-
from sqlalchemy import dialects
|
|
30
|
+
from sqlalchemy import dialects, types
|
|
30
31
|
from sqlalchemy.engine import Dialect
|
|
31
32
|
from sqlalchemy.engine.mock import create_mock_engine
|
|
33
|
+
from sqlalchemy.types import TypeEngine
|
|
32
34
|
|
|
33
|
-
from .
|
|
35
|
+
from ._sqltypes import MYSQL, POSTGRES, SQLITE
|
|
34
36
|
|
|
35
|
-
__all__ = ["get_dialect_module", "get_supported_dialects"]
|
|
37
|
+
__all__ = ["get_dialect_module", "get_supported_dialects", "string_to_typeengine"]
|
|
36
38
|
|
|
37
39
|
_DIALECT_NAMES = (MYSQL, POSTGRES, SQLITE)
|
|
38
40
|
"""List of supported dialect names.
|
|
@@ -40,6 +42,9 @@ _DIALECT_NAMES = (MYSQL, POSTGRES, SQLITE)
|
|
|
40
42
|
This list is used to create the dialect and module dictionaries.
|
|
41
43
|
"""
|
|
42
44
|
|
|
45
|
+
_DATATYPE_REGEXP = re.compile(r"(\w+)(\((.*)\))?")
|
|
46
|
+
"""Regular expression to match data types with parameters in parentheses."""
|
|
47
|
+
|
|
43
48
|
|
|
44
49
|
def _dialect(dialect_name: str) -> Dialect:
|
|
45
50
|
"""Create the SQLAlchemy dialect for the given name using a mock engine.
|
|
@@ -114,3 +119,63 @@ def get_dialect_module(dialect_name: str) -> ModuleType:
|
|
|
114
119
|
if dialect_name not in _DIALECT_MODULES:
|
|
115
120
|
raise ValueError(f"Unsupported dialect: {dialect_name}")
|
|
116
121
|
return _DIALECT_MODULES[dialect_name]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def string_to_typeengine(
|
|
125
|
+
type_string: str, dialect: Dialect | None = None, length: int | None = None
|
|
126
|
+
) -> TypeEngine:
|
|
127
|
+
"""Convert a string representation of a datatype to a SQLAlchemy type.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
type_string
|
|
132
|
+
The string representation of the data type.
|
|
133
|
+
dialect
|
|
134
|
+
The SQLAlchemy dialect to use. If None, the default dialect will be
|
|
135
|
+
used.
|
|
136
|
+
length
|
|
137
|
+
The length of the data type. If the data type does not have a length
|
|
138
|
+
attribute, this parameter will be ignored.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
`sqlalchemy.types.TypeEngine`
|
|
143
|
+
The SQLAlchemy type engine object.
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
Raised if the type string is invalid or the type is not supported.
|
|
149
|
+
|
|
150
|
+
Notes
|
|
151
|
+
-----
|
|
152
|
+
This function is used when converting type override strings defined in
|
|
153
|
+
fields such as ``mysql:datatype`` in the schema data.
|
|
154
|
+
"""
|
|
155
|
+
match = _DATATYPE_REGEXP.search(type_string)
|
|
156
|
+
if not match:
|
|
157
|
+
raise ValueError(f"Invalid type string: {type_string}")
|
|
158
|
+
|
|
159
|
+
type_name, _, params = match.groups()
|
|
160
|
+
if dialect is None:
|
|
161
|
+
type_class = getattr(types, type_name.upper(), None)
|
|
162
|
+
else:
|
|
163
|
+
try:
|
|
164
|
+
dialect_module = get_dialect_module(dialect.name)
|
|
165
|
+
except KeyError:
|
|
166
|
+
raise ValueError(f"Unsupported dialect: {dialect}")
|
|
167
|
+
type_class = getattr(dialect_module, type_name.upper(), None)
|
|
168
|
+
|
|
169
|
+
if not type_class:
|
|
170
|
+
raise ValueError(f"Unsupported type: {type_name.upper()}")
|
|
171
|
+
|
|
172
|
+
if params:
|
|
173
|
+
params = [int(param) if param.isdigit() else param for param in params.split(",")]
|
|
174
|
+
type_obj = type_class(*params)
|
|
175
|
+
else:
|
|
176
|
+
type_obj = type_class()
|
|
177
|
+
|
|
178
|
+
if hasattr(type_obj, "length") and getattr(type_obj, "length") is None and length is not None:
|
|
179
|
+
type_obj.length = length
|
|
180
|
+
|
|
181
|
+
return type_obj
|
|
@@ -32,7 +32,7 @@ from sqlalchemy import types
|
|
|
32
32
|
from sqlalchemy.types import TypeEngine
|
|
33
33
|
|
|
34
34
|
from ..datamodel import Column
|
|
35
|
-
from .
|
|
35
|
+
from ._dialects import get_dialect_module, get_supported_dialects
|
|
36
36
|
|
|
37
37
|
__all__ = ["make_variant_dict"]
|
|
38
38
|
|