Coverage for python/lsst/daf/butler/core/schema.py : 38%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
# This file is part of daf_butler. # # Developed for the LSST Data Management System. # This product includes software developed by the LSST Project # (http://www.lsst.org). # See the COPYRIGHT file at the top-level directory of this distribution # for details of code ownership. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>.
Float, ForeignKeyConstraint, Table, MetaData, TypeDecorator, UniqueConstraint,\ Sequence
"""Exceptions used to indicate problems in Registry schema configuration. """
def translate(cls, caught, message): """A decorator that re-raises exceptions as `SchemaValidationError`.
Decorated functions must be class or instance methods, with a ``config`` parameter as their first argument. This will be passed to ``message.format()`` as a keyword argument, along with ``err``, the original exception.
Parameters ---------- caught : `type` (`Exception` subclass) The type of exception to catch. message : `str` A `str.format` string that may contain named placeholders for ``config``, ``err``, or any keyword-only argument accepted by the decorated function. """ try: return func(self, config, *args, **kwds) except caught as err: raise cls(message.format(config=str(config), err=err))
"""A SQLAlchemy custom type that maps Python `bytes` to a base64-encoded `sqlalchemy.String`. """
length = 4*ceil(nbytes/3) TypeDecorator.__init__(self, *args, length=length, **kwds) self.nbytes = nbytes
# 'value' is native `bytes`. We want to encode that to base64 `bytes` # and then ASCII `str`, because `str` is what SQLAlchemy expects for # String fields. if value is None: return None if not isinstance(value, bytes): raise TypeError( f"Base64Bytes fields require 'bytes' values; got {value} with type {type(value)}" ) return b64encode(value).decode('ascii')
# 'value' is a `str` that must be ASCII because it's base64-encoded. # We want to transform that to base64-encoded `bytes` and then # native `bytes`. return b64decode(value.encode('ascii')) if value is not None else None
"""A SQLAlchemy custom type that maps Python `sphgeom.ConvexPolygon` to a base64-encoded `sqlalchemy.LargeBinary`. """
if value is None: return None return b64encode(value.encode())
return ConvexPolygon.decode(b64decode(value)) if value is not None else None
"bool": Boolean, "blob": LargeBinary, "datetime": DateTime, "hash": Base64Bytes}
class FieldSpec: """A struct-like class used to define a column in a logical Registry table. """
"""Name of the column."""
"""Type of the column; usually a `type` subclass provided by SQLAlchemy that defines both a Python type and a corresponding precise SQL type. """
"""Length of the type in the database, for variable-length types."""
"""Natural length used for hash and encoded-region columns, to be converted into the post-encoding length. """
"""Whether this field is (part of) its table's primary key."""
"""Whether the database should insert automatically incremented values when no value is provided in an INSERT. """
"""Whether this field is allowed to be NULL."""
"""Documentation for this field."""
return self.name == other.name
return hash(self.name)
def fromConfig(cls, config: Config, **kwds) -> FieldSpec: """Create a `FieldSpec` from a subset of a `SchemaConfig`.
Parameters ---------- config: `Config` Configuration describing the column. Nested configuration keys correspond to `FieldSpec` attributes. kwds Additional keyword arguments that provide defaults for values not present in config.
Returns ------- spec: `FieldSpec` Specification structure for the column.
Raises ------ SchemaValidationError Raised if configuration keys are missing or have invalid values. """ dtype = VALID_COLUMN_TYPES.get(config["type"]) if dtype is None: raise SchemaValidationError(f"Invalid field type string: '{config['type']}'.") if not config["name"].islower(): raise SchemaValidationError(f"Column name '{config['name']}' is not all lowercase.") self = cls(name=config["name"], dtype=dtype, **kwds) self.length = config.get("length", self.length) self.nbytes = config.get("nbytes", self.nbytes) if self.length is not None and self.nbytes is not None: raise SchemaValidationError(f"Both length and nbytes provided for field '{self.name}'.") self.primaryKey = config.get("primaryKey", self.primaryKey) self.autoincrement = config.get("autoincrement", self.autoincrement) self.nullable = config.get("nullable", False if self.primaryKey else self.nullable) self.doc = stripIfNotNone(config.get("doc", None)) return self
"""Return a sized version of the column type, utilizing either (or neither) of ``self.length`` and ``self.nbytes``.
Returns ------- dtype : `sqlalchemy.types.TypeEngine` A SQLAlchemy column type object. """ if self.length is not None: return self.dtype(length=self.length) if self.nbytes is not None: return self.dtype(nbytes=self.nbytes) return self.dtype
"""Construct a SQLAlchemy `Column` object from this specification.
Parameters ---------- tableName : `str` Name of the logical table to which this column belongs. schema : `Schema` Object represening the full schema. May be modified in-place.
Returns ------- column : `sqlalchemy.Column` SQLAlchemy column object. """ args = [self.name, self.getSizedColumnType()] if self.autoincrement: # Generate a sequence to use for auto incrementing for databases # that do not support it natively. This will be ignored by # sqlalchemy for databases that do support it. args.append(Sequence(f"{tableName}_{self.name}_seq", metadata=schema.metadata)) return Column(*args, nullable=self.nullable, primary_key=self.primaryKey, comment=self.doc)
class ForeignKeySpec: """A struct-like class used to define a foreign key constraint in a logical Registry table. """
"""Name of the target table."""
"""Tuple of source table column names."""
"""Tuple of target table column names."""
"""SQL clause indicating how to handle deletes to the target table.
If not `None`, should be either "SET NULL" or "CASCADE". """
def fromConfig(cls, config: Config) -> ForeignKeySpec: """Create a `ForeignKeySpec` from a subset of a `SchemaConfig`.
Parameters ---------- config: `Config` Configuration describing the constraint. Nested configuration keys correspond to `ForeignKeySpec` attributes.
Returns ------- spec: `ForeignKeySpec` Specification structure for the constraint.
Raises ------ SchemaValidationError Raised if configuration keys are missing or have invalid values. """ return cls(table=config["table"], source=tuple(iterable(config["source"])), target=tuple(iterable(config["target"])), onDelete=config.get("onDelete", None))
"""Construct a SQLAlchemy `ForeignKeyConstraint` corresponding to this specification.
Parameters ---------- tableName : `str` Name of the logical table to which this constraint belongs (the table for source columns). schema : `Schema` Object represening the full schema. May be modified in-place.
Returns ------- constraint : `sqlalchemy.ForeignKeyConstraint` SQLAlchemy version of the foreign key constraint. """ name = "_".join(["fkey", tableName, self.table] + list(self.target) + list(self.source)) return ForeignKeyConstraint(self.source, [f"{self.table}.{col}" for col in self.target], name=name, ondelete=self.onDelete)
class TableSpec: """A struct-like class used to define a logical Registry table (which may also be implemented in the database as a view). """
"""Specifications for the columns in this table."""
"""Non-primary-key unique constraints for the table."""
"""Foreign key constraints for the table."""
"""A SQL SELECT statement that can be used to define this logical table as a view.
Should be `None` if this table's contents is not defined in terms of other tables. """
"""Documentation for the table."""
def fromConfig(cls, config: Config) -> TableSpec: """Create a `ForeignKeySpec` from a subset of a `SchemaConfig`.
Parameters ---------- config: `Config` Configuration describing the constraint. Nested configuration keys correspond to `TableSpec` attributes.
Returns ------- spec: `TableSpec` Specification structure for the table.
Raises ------ SchemaValidationError Raised if configuration keys are missing or have invalid values. """ return cls( fields=NamedValueSet(FieldSpec.fromConfig(c) for c in config["columns"]), unique={tuple(u) for u in config.get("unique", ())}, foreignKeys=[ForeignKeySpec.fromConfig(c) for c in config.get("foreignKeys", ())], sql=config.get("sql"), doc=stripIfNotNone(config.get("doc")), )
"""Return `True` if this logical table should be implemented as some kind of view. """ return self.sql is not None
"""Construct a SQLAlchemy `Table` or `View` corresponding to this specification.
This does not emit the actual DDL statements that would create the table or view in the database; it merely creates a SQLAlchemy representation of the table or view.
Parameters ---------- tableName : `str` Name of the logical table. schema : `Schema` Object represening the full schema. Will be modified in-place.
Returns ------- table : `sqlalchemy.Table` or `View` A SQLAlchemy object representing the logical table.
Notes ----- This does *not* add foreign key constraints, as all tables must be created (in the SQLAlchemy metadata sense) before foreign keys can safely be created. `addForeignKeys` must be called to complete this process. """ if tableName in schema.metadata.tables: raise SchemaValidationError(f"Table with name '{tableName}' already exists.") if not tableName.islower(): raise SchemaValidationError(f"Table name '{tableName}' is not all lowercase.") if self.isView(): table = View(tableName, schema.metadata, selectable=self.sql, comment=self.doc, info=self) schema.views.add(tableName) else: table = Table(tableName, schema.metadata, comment=self.doc, info=self) schema.tables[tableName] = table for fieldSpec in self.fields: table.append_column(fieldSpec.toSqlAlchemy(tableName, schema)) for columns in self.unique: table.append_constraint(UniqueConstraint(*columns)) return table
"""Add SQLAlchemy foreign key constraints for this table to the corresponding entry in the given schema.
Parameters ---------- tableName : `str` Name of the logical table. schema : `Schema` Object represening the full schema. Will be modified in-place.
Notes ----- This must be called after `toSqlAlchemy` has been called on all tables in the schema. """ table = schema.tables[tableName] if not self.isView(): for foreignKeySpec in self.foreignKeys: if foreignKeySpec.table not in schema.views: table.append_constraint(foreignKeySpec.toSqlAlchemy(tableName, schema.metadata))
return {name: TableSpec.fromConfig(config) for name, config in self["tables"].items()}
"""The SQL schema for a Butler Registry.
Parameters ---------- spec : `dict`, optional Dictionary mapping `str` name to `TableSpec`, as returned by `SchemaConfig.toSpec`. Will be constructed from default configuration if not provided.
Attributes ---------- metadata : `sqlalchemy.MetaData` The sqlalchemy schema description. tables : `dict` A mapping from table or view name to the associated SQLAlchemy object. Note that this contains both true tables and views. views : `set` The names of entries in ``tables`` that are actually implemented as views. """ if spec is None: config = SchemaConfig() spec = config.toSpec() self.metadata = MetaData() self.views = set() self.tables = dict() # We ignore the return values of the `toSqlAlchemy` calls below because # they also mutate `self`, and that's all we need. When we refactor # `SqlRegistry`'s dataset operations in the future, we'll probably # replace `Schema` with a simple `dict` and clean this up. for tableName, tableSpec in spec.items(): tableSpec.toSqlAlchemy(tableName, self) for tableName, tableSpec in spec.items(): tableSpec.addForeignKeys(tableName, self) |