Составление сопоставленных иерархий с помощью миксинов

При отображении классов с помощью стиля Declarative часто возникает необходимость разделить некоторую функциональность, например, набор общих столбцов, некоторые общие параметры таблицы или другие отображаемые свойства, между многими классами. Стандартная идиома Python для этого заключается в том, чтобы классы наследовались от суперкласса, который включает эти общие свойства.

При использовании декларативных отображений эта идиома допускается через использование классов-миксинов, а также через дополнение декларативной базы, создаваемой либо методом registry.generate_base(), либо функциями declarative_base().

При использовании миксинов или абстрактных базовых классов в Declarative часто используется декоратор, известный как declared_attr(). Этот декоратор позволяет создавать методы класса, создающие параметр или ORM-конструкцию, которая будет частью декларативного отображения. Генерация конструкций с помощью callable позволяет Declarative получать новую копию объекта определенного типа каждый раз, когда он обращается к mixin или абстрактной базе от имени нового отображаемого класса.

Ниже приведен пример некоторых часто смешиваемых идиом:

from sqlalchemy.orm import declarative_mixin, declared_attr


@declarative_mixin
class MyMixin:
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()

    __table_args__ = {"mysql_engine": "InnoDB"}
    __mapper_args__ = {"always_refresh": True}

    id = Column(Integer, primary_key=True)


class MyModel(MyMixin, Base):
    name = Column(String(1000))

Как показано выше, класс MyModel будет содержать столбец «id» в качестве первичного ключа, атрибут __tablename__, который происходит от имени самого класса, а также __table_args__ и __mapper_args__, определенные классом MyMixin mixin. Декоратор declared_attr(), применяемый к методу класса с именем def __tablename__(cls):, превращает метод в метод класса, а также указывает Declarative на то, что этот атрибут является значимым в отображении.

Совет

Использование декоратора класса declarative_mixin() отмечает определенный класс как предоставляющий услугу по обеспечению декларативных назначений SQLAlchemy в качестве миксина для других классов. В настоящее время этот декоратор необходим только для того, чтобы дать подсказку Mypy plugin, что данный класс должен обрабатываться как часть декларативных отображений.

Не существует фиксированного соглашения о том, предшествует ли MyMixin Base или нет. Применяются обычные правила разрешения методов Python, и приведенный выше пример будет работать так же хорошо с:

class MyModel(Base, MyMixin):
    name = Column(String(1000))

Это работает потому, что Base здесь не определяет ни одну из переменных, которые определяет MyMixin, т.е. __tablename__, __table_args__, id и т.д. Если бы Base определял одноименный атрибут, то класс, стоящий первым в списке наследований, определял бы, какой атрибут используется во вновь определенном классе.

Расширение базы

В дополнение к использованию чистого миксина, большинство приемов из этого раздела можно применить и к самому базовому классу, для шаблонов, которые должны применяться ко всем классам, производным от конкретной базы. Это достигается с помощью аргумента cls функции declarative_base():

from sqlalchemy.orm import declarative_base, declared_attr


class Base:
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()

    __table_args__ = {"mysql_engine": "InnoDB"}

    id = Column(Integer, primary_key=True)


Base = declarative_base(cls=Base)


class MyModel(Base):
    name = Column(String(1000))

Там, где указано выше, MyModel и все другие классы, производные от Base, будут иметь имя таблицы, производное от имени класса, колонку первичного ключа id, а также движок «InnoDB» для MySQL.

Смешивание в колоннах

Самый простой способ указать колонку в миксине - это простое объявление:

@declarative_mixin
class TimestampMixin:
    created_at = Column(DateTime, default=func.now())


class MyModel(TimestampMixin, Base):
    __tablename__ = "test"

    id = Column(Integer, primary_key=True)
    name = Column(String(1000))

Как указано выше, все декларативные классы, включающие TimestampMixin, также будут иметь колонку created_at, которая применяет временную метку ко всем вставкам строк.

Те, кто знаком с языком выражений SQLAlchemy, знают, что объектная идентичность элементов клаузулы определяет их роль в схеме. Два объекта Table a и b могут оба иметь столбец с именем id, но их различие заключается в том, что a.c.id и b.c.id - это два разных объекта Python, ссылающиеся на свои родительские таблицы a и b соответственно.

В случае с графом mixin, кажется, что явно создается только один объект Column, однако конечный граф created_at выше должен существовать как отдельный объект Python для каждого отдельного целевого класса. Для этого декларативное расширение создает копию каждого объекта Column, встречающегося в классе, который определяется как mixin.

Этот механизм копирования ограничен простыми колонками, не имеющими внешних ключей, так как ForeignKey сам содержит ссылки на колонки, которые не могут быть правильно воссозданы на этом уровне. Для столбцов, имеющих внешние ключи, а также для различных конструкций на уровне маппера, которые требуют явного контекста назначения, предусмотрен декоратор declared_attr, чтобы шаблоны, общие для многих классов, можно было определить как callables:

from sqlalchemy.orm import declared_attr


@declarative_mixin
class ReferenceAddressMixin:
    @declared_attr
    def address_id(cls):
        return Column(Integer, ForeignKey("address.id"))


class User(ReferenceAddressMixin, Base):
    __tablename__ = "user"
    id = Column(Integer, primary_key=True)

Там, где описано выше, вызываемый объект уровня класса address_id выполняется в момент построения класса User, и декларативное расширение может использовать результирующий объект Column, возвращаемый методом, без необходимости его копирования.

Колонки, создаваемые declared_attr, также могут ссылаться на __mapper_args__ в ограниченной степени, в настоящее время на polymorphic_on и version_id_col; декларативное расширение будет разрешать их во время построения класса:

@declarative_mixin
class MyMixin:
    @declared_attr
    def type_(cls):
        return Column(String(50))

    __mapper_args__ = {"polymorphic_on": type_}


class MyModel(MyMixin, Base):
    __tablename__ = "test"
    id = Column(Integer, primary_key=True)

Смешение в отношениях

Отношения, созданные с помощью relationship(), снабжаются декларативными классами-миксинами исключительно с использованием подхода declared_attr, что устраняет любую двусмысленность, которая может возникнуть при копировании отношения и его, возможно, связанного с колонками содержимого. Ниже приведен пример, в котором столбец внешнего ключа и отношение сочетаются таким образом, что два класса Foo и Bar могут быть настроены так, чтобы ссылаться на общий целевой класс по принципу «многие к одному:»:

@declarative_mixin
class RefTargetMixin:
    @declared_attr
    def target_id(cls):
        return Column("target_id", ForeignKey("target.id"))

    @declared_attr
    def target(cls):
        return relationship("Target")


class Foo(RefTargetMixin, Base):
    __tablename__ = "foo"
    id = Column(Integer, primary_key=True)


class Bar(RefTargetMixin, Base):
    __tablename__ = "bar"
    id = Column(Integer, primary_key=True)


class Target(Base):
    __tablename__ = "target"
    id = Column(Integer, primary_key=True)

Использование расширенных аргументов отношений (например, primaryjoin и т.д.)

Определения relationship(), которые требуют явных выражений primaryjoin, order_by и т.д., должны во всех случаях, кроме самых простых, использовать late bound формы для этих аргументов, то есть использовать либо строковую форму, либо функцию/лямбду. Причина этого в том, что связанные Column объекты, которые должны быть настроены с помощью @declared_attr, недоступны для другого @declared_attr атрибута; хотя методы будут работать и возвращать новые Column объекты, это не те Column объекты, которые будет использовать Declarative, поскольку он вызывает методы самостоятельно, таким образом используя различные Column объекты.

Каноническим примером является условие primaryjoin, которое зависит от другого смешанного столбца:

@declarative_mixin
class RefTargetMixin:
    @declared_attr
    def target_id(cls):
        return Column("target_id", ForeignKey("target.id"))

    @declared_attr
    def target(cls):
        return relationship(
            Target,
            primaryjoin=Target.id == cls.target_id,  # this is *incorrect*
        )

При отображении класса, использующего вышеуказанный миксин, мы получим ошибку вида:

sqlalchemy.exc.InvalidRequestError: this ForeignKey's parent column is not
yet associated with a Table.

Это происходит потому, что target_id Column, к которому мы обратились в нашем методе target(), - это не тот Column, который declarative собирается отобразить в нашу таблицу.

Приведенное выше условие решается с помощью лямбды:

@declarative_mixin
class RefTargetMixin:
    @declared_attr
    def target_id(cls):
        return Column("target_id", ForeignKey("target.id"))

    @declared_attr
    def target(cls):
        return relationship(Target, primaryjoin=lambda: Target.id == cls.target_id)

или альтернативно, в строковой форме (которая в конечном итоге генерирует лямбду):

@declarative_mixin
class RefTargetMixin:
    @declared_attr
    def target_id(cls):
        return Column("target_id", ForeignKey("target.id"))

    @declared_attr
    def target(cls):
        return relationship(Target, primaryjoin=f"Target.id=={cls.__name__}.target_id")

Смешивание в классах deferred(), column_property() и других классах MapperProperty

Как и relationship(), все подклассы MapperProperty, такие как deferred(), column_property() и т.д., в конечном итоге включают ссылки на колонки, и поэтому, при использовании с декларативными миксинами, имеют требование declared_attr, так что копирование не требуется:

@declarative_mixin
class SomethingMixin:
    @declared_attr
    def dprop(cls):
        return deferred(Column(Integer))


class Something(SomethingMixin, Base):
    __tablename__ = "something"

Конструкция column_property() или другая конструкция может ссылаться на другие столбцы из миксина. Они копируются заранее, до вызова declared_attr:

@declarative_mixin
class SomethingMixin:
    x = Column(Integer)
    y = Column(Integer)

    @declared_attr
    def x_plus_y(cls):
        return column_property(cls.x + cls.y)

Изменено в версии 1.0.0: колонки mixin копируются в конечный отображаемый класс, чтобы методы declared_attr могли получить доступ к реальной колонке, которая будет отображена.

Управление наследованием таблиц с помощью миксинов

Атрибут __tablename__ может быть использован для предоставления функции, которая будет определять имя таблицы, используемой для каждого класса в иерархии наследования, а также то, имеет ли класс свою собственную отдельную таблицу.

Это достигается с помощью индикатора declared_attr в сочетании с методом с именем __tablename__(). Declarative всегда будет вызывать declared_attr для специальных имен __tablename__, __mapper_args__ и __table_args__ функцию для каждого сопоставленного класса в иерархии, кроме случаев переопределения в подклассе. Поэтому функция должна ожидать получения каждого класса в отдельности и предоставлять правильный ответ для каждого из них.

Например, для создания миксина, который дает каждому классу простое имя таблицы, основанное на имени класса:

from sqlalchemy.orm import declarative_mixin, declared_attr


@declarative_mixin
class Tablename:
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()


class Person(Tablename, Base):
    id = Column(Integer, primary_key=True)
    discriminator = Column("type", String(50))
    __mapper_args__ = {"polymorphic_on": discriminator}


class Engineer(Person):
    __tablename__ = None
    __mapper_args__ = {"polymorphic_identity": "engineer"}
    primary_language = Column(String(50))

В качестве альтернативы мы можем модифицировать нашу функцию __tablename__, чтобы она возвращала None для подклассов, используя has_inherited_table(). Это приведет к тому, что эти подклассы будут отображены с однотабличным наследованием на родительский:

from sqlalchemy.orm import (
    declarative_mixin,
    declared_attr,
    has_inherited_table,
)


@declarative_mixin
class Tablename:
    @declared_attr
    def __tablename__(cls):
        if has_inherited_table(cls):
            return None
        return cls.__name__.lower()


class Person(Tablename, Base):
    id = Column(Integer, primary_key=True)
    discriminator = Column("type", String(50))
    __mapper_args__ = {"polymorphic_on": discriminator}


class Engineer(Person):
    primary_language = Column(String(50))
    __mapper_args__ = {"polymorphic_identity": "engineer"}

Смешивание столбцов в сценариях наследования

В отличие от того, как обрабатываются __tablename__ и другие специальные имена при использовании с declared_attr, когда мы смешиваем столбцы и свойства (например, отношения, свойства столбцов и т.д.), функция вызывается только для базового класса в иерархии. Ниже, только класс Person получит столбец с именем id; отображение будет неудачным для Engineer, которому не присвоен первичный ключ:

@declarative_mixin
class HasId:
    @declared_attr
    def id(cls):
        return Column("id", Integer, primary_key=True)


class Person(HasId, Base):
    __tablename__ = "person"
    discriminator = Column("type", String(50))
    __mapper_args__ = {"polymorphic_on": discriminator}


class Engineer(Person):
    __tablename__ = "engineer"
    primary_language = Column(String(50))
    __mapper_args__ = {"polymorphic_identity": "engineer"}

Обычно при наследовании объединенных таблиц мы хотим иметь колонки с разными именами в каждом подклассе. Однако в данном случае мы можем захотеть иметь столбец id в каждой таблице, и чтобы они ссылались друг на друга через внешний ключ. Мы можем добиться этого с помощью модификатора declared_attr.cascading, который указывает, что функция должна быть вызвана для каждого класса в иерархии, почти (см. предупреждение ниже) таким же образом, как и для __tablename__:

@declarative_mixin
class HasIdMixin:
    @declared_attr.cascading
    def id(cls):
        if has_inherited_table(cls):
            return Column(ForeignKey("person.id"), primary_key=True)
        else:
            return Column(Integer, primary_key=True)


class Person(HasIdMixin, Base):
    __tablename__ = "person"
    discriminator = Column("type", String(50))
    __mapper_args__ = {"polymorphic_on": discriminator}


class Engineer(Person):
    __tablename__ = "engineer"
    primary_language = Column(String(50))
    __mapper_args__ = {"polymorphic_identity": "engineer"}

Предупреждение

Функция declared_attr.cascading в настоящее время не позволяет подклассу переопределить атрибут с другой функцией или значением. Это текущее ограничение в механике того, как разрешается @declared_attr, и при обнаружении этого условия выдается предупреждение. Это ограничение не существует для специальных имен атрибутов, таких как __tablename__, которые разрешаются внутренним образом иначе, чем declared_attr.cascading.

Добавлено в версии 1.0.0: добавлено declared_attr.cascading.

Объединение аргументов таблицы/маппера из нескольких миксинов

В случае __table_args__ или __mapper_args__, заданных с помощью декларативных миксинов, вы можете захотеть объединить некоторые параметры из нескольких миксинов с теми, которые вы хотите определить в самом классе. Декоратор declared_attr может быть использован здесь для создания определяемых пользователем процедур свертки, которые берут данные из нескольких коллекций:

from sqlalchemy.orm import declarative_mixin, declared_attr


@declarative_mixin
class MySQLSettings:
    __table_args__ = {"mysql_engine": "InnoDB"}


@declarative_mixin
class MyOtherMixin:
    __table_args__ = {"info": "foo"}


class MyModel(MySQLSettings, MyOtherMixin, Base):
    __tablename__ = "my_model"

    @declared_attr
    def __table_args__(cls):
        args = dict()
        args.update(MySQLSettings.__table_args__)
        args.update(MyOtherMixin.__table_args__)
        return args

    id = Column(Integer, primary_key=True)

Создание индексов с помощью миксинов

Чтобы определить именованный, потенциально многоколоночный Index, который применяется ко всем таблицам, полученным из миксина, используйте «inline» форму Index и установите его как часть __table_args__:

@declarative_mixin
class MyMixin:
    a = Column(Integer)
    b = Column(Integer)

    @declared_attr
    def __table_args__(cls):
        return (Index(f"test_idx_{cls.__tablename__}", "a", "b"),)


class MyModel(MyMixin, Base):
    __tablename__ = "atable"
    c = Column(Integer, primary_key=True)
Вернуться на верх