Coverage for python/lsst/dax/apdb/apdbSqlSchema.py: 20%
212 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-12 10:17 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-12 10:17 +0000
1# This file is part of dax_apdb.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22"""Module responsible for APDB schema operations.
23"""
25from __future__ import annotations
27__all__ = ["ApdbSqlSchema", "ExtraTables"]
29import enum
30import logging
31import uuid
32from collections.abc import Mapping
33from typing import Any
35import felis.types
36import sqlalchemy
37from felis import simple
38from sqlalchemy import (
39 DDL,
40 Column,
41 ForeignKeyConstraint,
42 Index,
43 MetaData,
44 PrimaryKeyConstraint,
45 Table,
46 UniqueConstraint,
47 event,
48 inspect,
49)
50from sqlalchemy.dialects.postgresql import UUID
52from .apdbSchema import ApdbSchema, ApdbTables
54_LOG = logging.getLogger(__name__)
57#
58# Copied from daf_butler.
59#
60class GUID(sqlalchemy.TypeDecorator):
61 """Platform-independent GUID type.
63 Uses PostgreSQL's UUID type, otherwise uses CHAR(32), storing as
64 stringified hex values.
65 """
67 impl = sqlalchemy.CHAR
69 cache_ok = True
71 def load_dialect_impl(self, dialect: sqlalchemy.engine.Dialect) -> sqlalchemy.types.TypeEngine:
72 if dialect.name == "postgresql":
73 return dialect.type_descriptor(UUID())
74 else:
75 return dialect.type_descriptor(sqlalchemy.CHAR(32))
77 def process_bind_param(self, value: Any, dialect: sqlalchemy.engine.Dialect) -> str | None:
78 if value is None:
79 return value
81 # Coerce input to UUID type, in general having UUID on input is the
82 # only thing that we want but there is code right now that uses ints.
83 if isinstance(value, int):
84 value = uuid.UUID(int=value)
85 elif isinstance(value, bytes):
86 value = uuid.UUID(bytes=value)
87 elif isinstance(value, str):
88 # hexstring
89 value = uuid.UUID(hex=value)
90 elif not isinstance(value, uuid.UUID):
91 raise TypeError(f"Unexpected type of a bind value: {type(value)}")
93 if dialect.name == "postgresql":
94 return str(value)
95 else:
96 return "%.32x" % value.int
98 def process_result_value(
99 self, value: str | uuid.UUID | None, dialect: sqlalchemy.engine.Dialect
100 ) -> uuid.UUID | None:
101 if value is None:
102 return value
103 elif isinstance(value, uuid.UUID):
104 # sqlalchemy 2 converts to UUID internally
105 return value
106 else:
107 return uuid.UUID(hex=value)
110@enum.unique
111class ExtraTables(enum.Enum):
112 """Names of the tables used for tracking insert IDs."""
114 DiaInsertId = "DiaInsertId"
115 """Name of the table for insert ID records."""
117 DiaObjectInsertId = "DiaObjectInsertId"
118 """Name of the table for DIAObject insert ID records."""
120 DiaSourceInsertId = "DiaSourceInsertId"
121 """Name of the table for DIASource insert ID records."""
123 DiaForcedSourceInsertId = "DiaFSourceInsertId"
124 """Name of the table for DIAForcedSource insert ID records."""
126 def table_name(self, prefix: str = "") -> str:
127 """Return full table name."""
128 return prefix + self.value
130 @classmethod
131 def insert_id_tables(cls) -> Mapping[ExtraTables, ApdbTables]:
132 """Return mapping of tables used for insert ID tracking to their
133 corresponding regular tables.
134 """
135 return {
136 cls.DiaObjectInsertId: ApdbTables.DiaObject,
137 cls.DiaSourceInsertId: ApdbTables.DiaSource,
138 cls.DiaForcedSourceInsertId: ApdbTables.DiaForcedSource,
139 }
142class ApdbSqlSchema(ApdbSchema):
143 """Class for management of APDB schema.
145 Attributes
146 ----------
147 objects : `sqlalchemy.Table`
148 DiaObject table instance
149 objects_last : `sqlalchemy.Table`
150 DiaObjectLast table instance, may be None
151 sources : `sqlalchemy.Table`
152 DiaSource table instance
153 forcedSources : `sqlalchemy.Table`
154 DiaForcedSource table instance
155 has_insert_id : `bool`
156 If true then schema has tables for insert ID tracking.
158 Parameters
159 ----------
160 engine : `sqlalchemy.engine.Engine`
161 SQLAlchemy engine instance
162 dia_object_index : `str`
163 Indexing mode for DiaObject table, see `ApdbSqlConfig.dia_object_index`
164 for details.
165 htm_index_column : `str`
166 Name of a HTM index column for DiaObject and DiaSource tables.
167 schema_file : `str`
168 Name of the YAML schema file.
169 schema_name : `str`, optional
170 Name of the schema in YAML files.
171 prefix : `str`, optional
172 Prefix to add to all schema elements.
173 namespace : `str`, optional
174 Namespace (or schema name) to use for all APDB tables.
175 use_insert_id : `bool`, optional
177 """
179 pixel_id_tables = (ApdbTables.DiaObject, ApdbTables.DiaObjectLast, ApdbTables.DiaSource)
180 """Tables that need pixelId column for spatial indexing."""
182 def __init__(
183 self,
184 engine: sqlalchemy.engine.Engine,
185 dia_object_index: str,
186 htm_index_column: str,
187 schema_file: str,
188 schema_name: str = "ApdbSchema",
189 prefix: str = "",
190 namespace: str | None = None,
191 use_insert_id: bool = False,
192 ):
193 super().__init__(schema_file, schema_name)
195 self._engine = engine
196 self._dia_object_index = dia_object_index
197 self._htm_index_column = htm_index_column
198 self._prefix = prefix
199 self._use_insert_id = use_insert_id
201 self._metadata = MetaData(schema=namespace)
203 # map YAML column types to SQLAlchemy
204 self._type_map = {
205 felis.types.Double: self._getDoubleType(engine),
206 felis.types.Float: sqlalchemy.types.Float,
207 felis.types.Timestamp: sqlalchemy.types.TIMESTAMP,
208 felis.types.Long: sqlalchemy.types.BigInteger,
209 felis.types.Int: sqlalchemy.types.Integer,
210 felis.types.Short: sqlalchemy.types.Integer,
211 felis.types.Byte: sqlalchemy.types.Integer,
212 felis.types.Binary: sqlalchemy.types.LargeBinary,
213 felis.types.Text: sqlalchemy.types.CHAR,
214 felis.types.String: sqlalchemy.types.CHAR,
215 felis.types.Char: sqlalchemy.types.CHAR,
216 felis.types.Unicode: sqlalchemy.types.CHAR,
217 felis.types.Boolean: sqlalchemy.types.Boolean,
218 }
220 # Add pixelId column and index to tables that need it
221 for table in self.pixel_id_tables:
222 tableDef = self.tableSchemas.get(table)
223 if not tableDef:
224 continue
225 column = simple.Column(
226 id=f"#{htm_index_column}",
227 name=htm_index_column,
228 datatype=felis.types.Long,
229 nullable=False,
230 value=None,
231 description="Pixelization index column.",
232 table=tableDef,
233 )
234 tableDef.columns.append(column)
236 # Adjust index if needed
237 if table == ApdbTables.DiaObject and self._dia_object_index == "pix_id_iov":
238 tableDef.primary_key.insert(0, column)
240 if table is ApdbTables.DiaObjectLast:
241 # use it as a leading PK column
242 tableDef.primary_key.insert(0, column)
243 else:
244 # make a regular index
245 name = f"IDX_{tableDef.name}_{htm_index_column}"
246 index = simple.Index(id=f"#{name}", name=name, columns=[column])
247 tableDef.indexes.append(index)
249 # generate schema for all tables, must be called last
250 self._apdb_tables = self._make_apdb_tables()
251 self._extra_tables = self._make_extra_tables(self._apdb_tables)
253 self._has_insert_id: bool | None = None
255 def makeSchema(self, drop: bool = False) -> None:
256 """Create or re-create all tables.
258 Parameters
259 ----------
260 drop : `bool`, optional
261 If True then drop tables before creating new ones.
262 """
263 # Create namespace if it does not exist yet, for now this only makes
264 # sense for postgres.
265 if self._metadata.schema:
266 dialect = self._engine.dialect
267 quoted_schema = dialect.preparer(dialect).quote_schema(self._metadata.schema)
268 create_schema = DDL(
269 "CREATE SCHEMA IF NOT EXISTS %(schema)s", context={"schema": quoted_schema}
270 ).execute_if(dialect="postgresql")
271 event.listen(self._metadata, "before_create", create_schema)
273 # create all tables (optionally drop first)
274 if drop:
275 _LOG.info("dropping all tables")
276 self._metadata.drop_all(self._engine)
277 _LOG.info("creating all tables")
278 self._metadata.create_all(self._engine)
280 # Reset possibly cached value.
281 self._has_insert_id = None
283 def get_table(self, table_enum: ApdbTables | ExtraTables) -> Table:
284 """Return SQLAlchemy table instance for a specified table type/enum.
286 Parameters
287 ----------
288 table_enum : `ApdbTables` or `ExtraTables`
289 Type of table to return.
291 Returns
292 -------
293 table : `sqlalchemy.schema.Table`
294 Table instance.
296 Raises
297 ------
298 ValueError
299 Raised if ``table_enum`` is not valid for this database.
300 """
301 try:
302 if isinstance(table_enum, ApdbTables):
303 return self._apdb_tables[table_enum]
304 else:
305 return self._extra_tables[table_enum]
306 except LookupError:
307 raise ValueError(f"Table type {table_enum} does not exist in the schema") from None
309 def get_apdb_columns(self, table_enum: ApdbTables | ExtraTables) -> list[Column]:
310 """Return list of columns defined for a table in APDB schema.
312 Returned list excludes columns that are implementation-specific, e.g.
313 ``pixelId`` column is not include in the returned list.
315 Parameters
316 ----------
317 table_enum : `ApdbTables` or `ExtraTables`
318 Type of table.
320 Returns
321 -------
322 table : `list` [`sqlalchemy.schema.Column`]
323 Table instance.
325 Raises
326 ------
327 ValueError
328 Raised if ``table_enum`` is not valid for this database.
329 """
330 table = self.get_table(table_enum)
331 exclude_columns = set()
332 if table_enum in self.pixel_id_tables:
333 exclude_columns.add(self._htm_index_column)
334 return [column for column in table.columns if column.name not in exclude_columns]
336 @property
337 def has_insert_id(self) -> bool:
338 """Whether insert ID tables are to be used (`bool`)."""
339 if self._has_insert_id is None:
340 self._has_insert_id = self._use_insert_id and self._check_insert_id()
341 return self._has_insert_id
343 def _check_insert_id(self) -> bool:
344 """Check whether database has tables for tracking insert IDs."""
345 inspector = inspect(self._engine)
346 db_tables = set(inspector.get_table_names(schema=self._metadata.schema))
347 return ExtraTables.DiaInsertId.table_name(self._prefix) in db_tables
349 def _make_apdb_tables(self, mysql_engine: str = "InnoDB") -> Mapping[ApdbTables, Table]:
350 """Generate schema for regular tables.
352 Parameters
353 ----------
354 mysql_engine : `str`, optional
355 MySQL engine type to use for new tables.
356 """
357 tables = {}
358 for table_enum in ApdbTables:
359 if table_enum is ApdbTables.DiaObjectLast and self._dia_object_index != "last_object_table":
360 continue
362 columns = self._tableColumns(table_enum)
363 constraints = self._tableIndices(table_enum)
364 table = Table(
365 table_enum.table_name(self._prefix),
366 self._metadata,
367 *columns,
368 *constraints,
369 mysql_engine=mysql_engine,
370 )
371 tables[table_enum] = table
373 return tables
375 def _make_extra_tables(
376 self, apdb_tables: Mapping[ApdbTables, Table], mysql_engine: str = "InnoDB"
377 ) -> Mapping[ExtraTables, Table]:
378 """Generate schema for insert ID tables."""
379 tables: dict[ExtraTables, Table] = {}
380 if not self._use_insert_id:
381 return tables
383 # Parent table needs to be defined first
384 column_defs = [
385 Column("insert_id", GUID, primary_key=True),
386 Column("insert_time", sqlalchemy.types.TIMESTAMP, nullable=False),
387 ]
388 parent_table = Table(
389 ExtraTables.DiaInsertId.table_name(self._prefix),
390 self._metadata,
391 *column_defs,
392 mysql_engine=mysql_engine,
393 )
394 tables[ExtraTables.DiaInsertId] = parent_table
396 for table_enum, apdb_enum in ExtraTables.insert_id_tables().items():
397 apdb_table = apdb_tables[apdb_enum]
398 columns = self._insertIdColumns(table_enum)
399 constraints = self._insertIdIndices(table_enum, apdb_table, parent_table)
400 table = Table(
401 table_enum.table_name(self._prefix),
402 self._metadata,
403 *columns,
404 *constraints,
405 mysql_engine=mysql_engine,
406 )
407 tables[table_enum] = table
409 return tables
411 def _tableColumns(self, table_name: ApdbTables) -> list[Column]:
412 """Return set of columns in a table
414 Parameters
415 ----------
416 table_name : `ApdbTables`
417 Name of the table.
419 Returns
420 -------
421 column_defs : `list`
422 List of `Column` objects.
423 """
424 # get the list of columns in primary key, they are treated somewhat
425 # specially below
426 table_schema = self.tableSchemas[table_name]
428 # convert all column dicts into alchemy Columns
429 column_defs: list[Column] = []
430 for column in table_schema.columns:
431 kwargs: dict[str, Any] = dict(nullable=column.nullable)
432 if column.value is not None:
433 kwargs.update(server_default=str(column.value))
434 if column in table_schema.primary_key:
435 kwargs.update(autoincrement=False)
436 ctype = self._type_map[column.datatype]
437 column_defs.append(Column(column.name, ctype, **kwargs))
439 return column_defs
441 def _tableIndices(self, table_name: ApdbTables) -> list[sqlalchemy.schema.SchemaItem]:
442 """Return set of constraints/indices in a table
444 Parameters
445 ----------
446 table_name : `ApdbTables`
447 Name of the table.
448 info : `dict`
449 Additional options passed to SQLAlchemy index constructor.
451 Returns
452 -------
453 index_defs : `list`
454 List of SQLAlchemy index/constraint objects.
455 """
456 table_schema = self.tableSchemas[table_name]
458 # convert all index dicts into alchemy Columns
459 index_defs: list[sqlalchemy.schema.SchemaItem] = []
460 if table_schema.primary_key:
461 index_defs.append(PrimaryKeyConstraint(*[column.name for column in table_schema.primary_key]))
462 for index in table_schema.indexes:
463 name = self._prefix + index.name if index.name else ""
464 index_defs.append(Index(name, *[column.name for column in index.columns]))
465 for constraint in table_schema.constraints:
466 constr_name: str | None = None
467 if constraint.name:
468 constr_name = self._prefix + constraint.name
469 if isinstance(constraint, simple.UniqueConstraint):
470 index_defs.append(
471 UniqueConstraint(*[column.name for column in constraint.columns], name=constr_name)
472 )
474 return index_defs
476 def _insertIdColumns(self, table_enum: ExtraTables) -> list[Column]:
477 """Return list of columns for insert ID tables."""
478 column_defs: list[Column] = [Column("insert_id", GUID, nullable=False)]
479 insert_id_tables = ExtraTables.insert_id_tables()
480 if table_enum in insert_id_tables:
481 column_defs += self._tablePkColumns(insert_id_tables[table_enum])
482 else:
483 assert False, "Above branches have to cover all enum values"
484 return column_defs
486 def _tablePkColumns(self, table_enum: ApdbTables) -> list[Column]:
487 """Return a list of columns for table PK."""
488 table_schema = self.tableSchemas[table_enum]
489 column_defs: list[Column] = []
490 for column in table_schema.primary_key:
491 ctype = self._type_map[column.datatype]
492 column_defs.append(Column(column.name, ctype, nullable=False, autoincrement=False))
493 return column_defs
495 def _insertIdIndices(
496 self,
497 table_enum: ExtraTables,
498 apdb_table: sqlalchemy.schema.Table,
499 parent_table: sqlalchemy.schema.Table,
500 ) -> list[sqlalchemy.schema.SchemaItem]:
501 """Return set of constraints/indices for insert ID tables."""
502 index_defs: list[sqlalchemy.schema.SchemaItem] = []
504 # Special case for insert ID tables that are not in felis schema.
505 insert_id_tables = ExtraTables.insert_id_tables()
506 if table_enum in insert_id_tables:
507 # PK is the same as for original table
508 pk_names = [column.name for column in self._tablePkColumns(insert_id_tables[table_enum])]
509 index_defs.append(PrimaryKeyConstraint(*pk_names))
510 # Non-unique index on insert_id column.
511 name = self._prefix + table_enum.name + "_idx"
512 index_defs.append(Index(name, "insert_id"))
513 # Foreign key to original table
514 pk_columns = [apdb_table.columns[column] for column in pk_names]
515 index_defs.append(
516 ForeignKeyConstraint(pk_names, pk_columns, onupdate="CASCADE", ondelete="CASCADE")
517 )
518 # Foreign key to parent table
519 index_defs.append(
520 ForeignKeyConstraint(
521 ["insert_id"], [parent_table.columns["insert_id"]], onupdate="CASCADE", ondelete="CASCADE"
522 )
523 )
524 else:
525 assert False, "Above branches have to cover all enum values"
526 return index_defs
528 @classmethod
529 def _getDoubleType(cls, engine: sqlalchemy.engine.Engine) -> type | sqlalchemy.types.TypeEngine:
530 """DOUBLE type is database-specific, select one based on dialect.
532 Parameters
533 ----------
534 engine : `sqlalchemy.engine.Engine`
535 Database engine.
537 Returns
538 -------
539 type_object : `object`
540 Database-specific type definition.
541 """
542 if engine.name == "mysql":
543 from sqlalchemy.dialects.mysql import DOUBLE
545 return DOUBLE(asdecimal=False)
546 elif engine.name == "postgresql":
547 from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION
549 return DOUBLE_PRECISION
550 elif engine.name == "oracle":
551 from sqlalchemy.dialects.oracle import DOUBLE_PRECISION
553 return DOUBLE_PRECISION
554 elif engine.name == "sqlite":
555 # all floats in sqlite are 8-byte
556 from sqlalchemy.dialects.sqlite import REAL
558 return REAL
559 else:
560 raise TypeError("cannot determine DOUBLE type, unexpected dialect: " + engine.name)