Что нового в SQLAlchemy 2.0?

Примечание для читателей

Документы по переходу на SQLAlchemy 2.0 разделены на два документа - один из которых описывает основные изменения в API от серии 1.x к 2.x, а другой - новые возможности и поведение по сравнению с SQLAlchemy 1.4:

Читатели, которые еще не обновили свои приложения версии 1.4, чтобы следовать соглашениям движка SQLAlchemy 2.0 и ORM, могут перейти по ссылке SQLAlchemy 2.0 - руководство по миграции для получения руководства по обеспечению совместимости с SQLAlchemy 2.0, что является необходимым условием для получения рабочего кода под версией 2.0.

О данном документе

Этот документ описывает изменения между SQLAlchemy версии 1.4 и SQLAlchemy версии 2.0, независимо от основных изменений между использованием 1.x style и 2.0 style. Читателям следует начать с документа SQLAlchemy 2.0 - руководство по миграции, чтобы получить общее представление об основных изменениях совместимости между версиями 1.x и 2.x.

Помимо основного пути миграции 1.x->2.x, следующим крупнейшим изменением парадигмы в SQLAlchemy 2.0 является глубокая интеграция с практикой типизации PEP 484 и текущими возможностями, особенно в рамках ORM. Новые декларативные стили ORM, ориентированные на типы, вдохновленные Python dataclasses, а также новые интеграции с самими dataclasses дополняют общий подход, который больше не требует использования заглушек, а также очень далеко продвинулся в обеспечении цепочки методов с учетом типов от SQL-оператора до набора результатов.

Значимость типизации Python важна не только для того, чтобы такие средства проверки типов, как mypy, могли работать без плагинов; более того, она позволяет таким IDE, как vscode и pycharm, принимать более активное участие в создании приложения SQLAlchemy.

Поддержка новых типов в Core и ORM - заглушки/расширения больше не используются

Подход к типизации для Core и ORM был полностью переработан по сравнению с временным подходом, который был реализован в версии 1.4 с помощью пакета sqlalchemy2-stubs. Новый подход начинается с самого фундаментального элемента SQLAlchemy - Column, или, точнее, ColumnElement, который лежит в основе всех SQL-выражений, имеющих тип. Затем эта типизация на уровне выражений распространяется на построение операторов, выполнение операторов, наборы результатов и, наконец, на ORM, где новые формы declarative позволяют создавать полностью типизированные модели ORM, которые интегрируются на всем пути от операторов до наборов результатов.

Совет

Поддержка типизации должна рассматриваться как бета-уровень программного обеспечения для серии 2.0. Детали типизации могут быть изменены, однако существенных изменений, несовместимых с обратным ходом событий, не планируется.

Типизация выражений/выражений/наборов результатов SQL

В этом разделе представлены справочные материалы и примеры нового подхода SQLAlchemy к типизации выражений SQL, который распространяется от базовых конструкций ColumnElement через SQL-операторы и наборы результатов и переходит в сферу ORM-маппинга.

Обоснование и обзор

Совет

Этот раздел представляет собой обсуждение архитектуры. Перейдите к Типизация выражений SQL - примеры, чтобы просто посмотреть, как выглядит новая типизация.

В sqlalchemy2-stubs выражения SQL типизировались как generics, которые затем ссылались на объект TypeEngine, такой как Integer, DateTime или String, как на свой общий аргумент (например, Column[Integer]). Это само по себе было отходом от того, что делал оригинальный пакет Dropbox sqlalchemy-stubs, где Column и его основополагающие конструкции были непосредственно родовыми по типам Python, таким как int, datetime и str. Предполагалось, что поскольку Integer / DateTime / String сами являются родовыми по отношению к int / datetime / str, то можно будет сохранить оба уровня информации и иметь возможность извлекать тип Python из выражения столбца через TypeEngine в качестве промежуточной конструкции. Однако это не так, поскольку PEP 484 не обладает достаточно широким набором функций для этого, не имея таких возможностей, как higher kinded TypeVars.

Таким образом, после deep assessment изучения текущих возможностей PEP 484, SQLAlchemy 2.0 осознал изначальную мудрость sqlalchemy-stubs в этой области и вернулся к привязке выражений столбцов непосредственно к типам Python. Это означает, что при использовании SQL-выражений для различных подтипов, например, Column(VARCHAR) против Column(Unicode), специфика этих двух подтипов String не передается, поскольку тип передается только str, но на практике это обычно не является проблемой, и гораздо полезнее, чтобы Python-тип присутствовал сразу, поскольку он представляет данные в Python, которые будут храниться и получаться непосредственно для этого столбца.

В конкретном случае это означает, что выражение типа Column('id', Integer) будет типизировано как Column[int]. Это позволяет организовать жизнеспособный конвейер SQLAlchemy construct -> Python datatype, не прибегая к помощи плагинов типизации. Очень важно, что это обеспечивает полную совместимость с парадигмой ORM, в которой используются конструкции select() и Row, ссылающиеся на отображаемые ORM типы классов (например, Row, содержащий экземпляры отображаемых пользователем экземпляров, такие как примеры User и Address, используемые в наших учебниках). Хотя в настоящее время типизация в Python имеет весьма ограниченную поддержку настройки кортежных типов (где PEP 646, первый pep, пытавшийся работать с кортежными объектами, был intentionally limited in its functionality и сам по себе еще не пригоден для произвольного манипулирования кортежами), был разработан достаточно приличный подход, позволяющий функционировать базовой типизации select() -> Result -> Row, В том числе и для ORM-классов, где в момент распаковки объекта Row на отдельные записи в столбцах добавляется небольшой аксессор, ориентированный на типизацию, который позволяет отдельным Python-значениям сохранять Python-тип, связанный с SQL-выражением, из которого они были получены (перевод: это работает).

Типизация выражений SQL - примеры

Краткий обзор поведения при наборе текста. Комментарии указывают на то, что можно увидеть, наведя курсор на код в vscode (или примерно то, что отображают средства набора текста при использовании помощника reveal_type()):

  • Простые типы Python, присваиваемые выражениям SQL

    # (variable) str_col: ColumnClause[str]
    str_col = column("a", String)
    
    # (variable) int_col: ColumnClause[int]
    int_col = column("a", Integer)
    
    # (variable) expr1: ColumnElement[str]
    expr1 = str_col + "x"
    
    # (variable) expr2: ColumnElement[int]
    expr2 = int_col + 10
    
    # (variable) expr3: ColumnElement[bool]
    expr3 = int_col == 15
  • Отдельные SQL-выражения, назначенные конструкциям select(), а также любые конструкции с возвратом строки, включая DML с возвратом строки, такие как Insert с Insert.returning(), упаковываются в тип Tuple[], который сохраняет тип Python для каждого элемента.

    # (variable) stmt: Select[Tuple[str, int]]
    stmt = select(str_col, int_col)
    
    # (variable) stmt: ReturningInsert[Tuple[str, int]]
    ins_stmt = insert(table("t")).returning(str_col, int_col)
  • Тип Tuple[] из любой конструкции, возвращающей строку, при вызове метода .execute() переносится на Result и Row. Для того чтобы распаковать объект Row в виде кортежа, аксессор Row.tuple() или Row.t по сути приводит Row к соответствующему Tuple[] (хотя во время выполнения остается тем же объектом Row).

    with engine.connect() as conn:
        # (variable) stmt: Select[Tuple[str, int]]
        stmt = select(str_col, int_col)
    
        # (variable) result: Result[Tuple[str, int]]
        result = conn.execute(stmt)
    
        # (variable) row: Row[Tuple[str, int]] | None
        row = result.first()
    
        if row is not None:
            # for typed tuple unpacking or indexed access,
            # use row.tuple() or row.t  (this is the small typing-oriented accessor)
            strval, intval = row.t
    
            # (variable) strval: str
            strval
    
            # (variable) intval: int
            intval
  • Скалярные значения для одноколоночных операторов правильно использовать с помощью методов Connection.scalar(), Result.scalars() и т.д.

    # (variable) data: Sequence[str]
    data = connection.execute(select(str_col)).scalars().all()
  • Описанная выше поддержка конструкций с возвратом строк лучше всего работает с сопоставленными классами ORM, так как сопоставленный класс может указывать конкретные типы для своих членов. В приведенном ниже примере задается класс, использующий new type-aware syntaxes, описанный в следующем разделе:

    from sqlalchemy.orm import DeclarativeBase
    from sqlalchemy.orm import Mapped
    from sqlalchemy.orm import mapped_column
    
    
    class Base(DeclarativeBase):
        pass
    
    
    class User(Base):
        __tablename__ = "user_account"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]
        addresses: Mapped[List["Address"]] = relationship()
    
    
    class Address(Base):
        __tablename__ = "address"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        email_address: Mapped[str]
        user_id = mapped_column(ForeignKey("user_account.id"))

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

    with Session(engine) as session:
        # (variable) stmt: Select[Tuple[int, str]]
        stmt_1 = select(User.id, User.name)
    
        # (variable) result_1: Result[Tuple[int, str]]
        result_1 = session.execute(stmt_1)
    
        # (variable) intval: int
        # (variable) strval: str
        intval, strval = result_1.one().t

    Сами сопоставленные классы также являются типами и ведут себя аналогичным образом, например, SELECT по отношению к двум сопоставленным классам:

    with Session(engine) as session:
        # (variable) stmt: Select[Tuple[User, Address]]
        stmt_2 = select(User, Address).join_from(User, Address)
    
        # (variable) result_2: Result[Tuple[User, Address]]
        result_2 = session.execute(stmt_2)
    
        # (variable) user_obj: User
        # (variable) address_obj: Address
        user_obj, address_obj = result_2.one().t

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

    with Session(engine) as session:
        # this is in fact an Annotated type, but typing tools don't
        # generally display this
    
        # (variable) u1: Type[User]
        u1 = aliased(User)
    
        # (variable) stmt: Select[Tuple[User, User, str]]
        stmt = select(User, u1, User.name).filter(User.id == 5)
    
        # (variable) result: Result[Tuple[User, User, str]]
        result = session.execute(stmt)
  • В Core Table пока нет достойного способа поддерживать типизацию объектов Column при обращении к ним через аксор Table.c.

    Поскольку Table задается как экземпляр класса, а аксессор Table.c обычно обращается к объектам Column динамически по имени, для этого пока не существует устоявшегося подхода к типизации; потребуется какой-то альтернативный синтаксис.

  • Классы, скаляры и т.д. в ORM работают отлично.

    Типичный случай выбора классов ORM, как скаляров или кортежей, работает как в 2.0, так и в 1.x, возвращая точный тип либо сам по себе, либо содержащийся в соответствующем контейнере, таком как Sequence[], List[] или Iterator[]:

    # (variable) users1: Sequence[User]
    users1 = session.scalars(select(User)).all()
    
    # (variable) user: User
    user = session.query(User).one()
    
    # (variable) user_iter: Iterator[User]
    user_iter = iter(session.scalars(select(User)))
  • Наследие Query также приобретает кортежную типизацию.

    Поддержка типизации для Query выходит далеко за рамки того, что предлагали sqlalchemy-stubs или sqlalchemy2-stubs, где как скалярно-объектные, так и кортежные Query объекты сохраняют типизацию уровня результата для большинства случаев:

    # (variable) q1: RowReturningQuery[Tuple[int, str]]
    q1 = session.query(User.id, User.name)
    
    # (variable) rows: List[Row[Tuple[int, str]]]
    rows = q1.all()
    
    # (variable) q2: Query[User]
    q2 = session.query(User)
    
    # (variable) users: List[User]
    users = q2.all()

загвоздка в том, что все заглушки должны быть деинсталлированы

Ключевая оговорка, связанная с поддержкой типизации, заключается в том, что все пакеты-заглушки SQLAlchemy должны быть деинсталлированы для работы типизации. При запуске mypy в виртуальной среде Python это сводится к удалению этих пакетов. Однако пакет SQLAlchemy stubs также является частью typeshed, который входит в состав некоторых инструментов типизации, таких как Pylance, поэтому в некоторых случаях может потребоваться найти файлы этих пакетов и удалить их, если они действительно мешают корректной работе новой типизации.

Как только SQLAlchemy 2.0 будет выпущен в окончательном виде, typeshed удалит SQLAlchemy из своего собственного источника stubs.

Декларативные модели ORM

В SQLAlchemy 1.4 впервые была реализована поддержка типизации в SQLAlchemy-родных ORM с использованием комбинации sqlalchemy2-stubs и Mypy Plugin. В SQLAlchemy 2.0 плагин Mypy остался доступным и был обновлен для работы с системой типизации SQLAlchemy 2.0. Однако теперь его следует считать устаревшим, так как теперь у приложений есть прямой путь к использованию новой поддержки типизации, не использующий плагины или заглушки.

Обзор

Основной подход новой системы заключается в том, что объявления отображаемых столбцов при использовании полностью Declarative модели (т.е. не hybrid declarative или imperative конфигураций, которые остаются неизменными) сначала выводятся во время выполнения путем проверки аннотации типа в левой части каждого объявления атрибута, если она присутствует. Ожидается, что левые аннотации типов будут содержаться внутри общего типа Mapped, в противном случае атрибут не будет считаться сопоставленным. Затем объявление атрибута может ссылаться на конструкцию mapped_column() в правой части, которая используется для предоставления дополнительной информации схемы уровня Core о создаваемом и отображаемом Column. Это объявление в правой части необязательно, если в левой части присутствует аннотация Mapped; если аннотация в левой части отсутствует, то mapped_column() может быть использовано в качестве точной замены директивы Column, где оно обеспечит более точное (но не точное) поведение типизации атрибута, даже если аннотация не присутствует.

Подход вдохновлен подходом Python dataclasses, который начинается с аннотации слева, а затем допускает необязательную спецификацию dataclasses.field() справа; ключевое отличие от подхода dataclasses состоит в том, что подход SQLAlchemy строго opt-in, где существующие отображения, использующие Column без аннотаций типов, продолжают работать как и прежде, а конструкция mapped_column() может использоваться как прямая замена Column без каких-либо явных аннотаций типов. Только в случае наличия точных типов Python на уровне атрибутов требуется использование явных аннотаций с помощью Mapped. Эти аннотации могут использоваться по мере необходимости, для тех атрибутов, для которых полезны конкретные типы; неаннотированные атрибуты, использующие mapped_column(), будут типизированы как Any на уровне экземпляра.

Перенос существующего отображения

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

Шаг первый - declarative_base() заменяется на DeclarativeBase.

Одним из замеченных ограничений в типизации Python является отсутствие возможности динамически генерировать класс из функции, который затем понимается средствами типизации как основа для новых классов. Чтобы решить эту проблему без плагинов, обычный вызов declarative_base() можно заменить использованием класса DeclarativeBase, который производит тот же объект Base, что и обычно, за исключением того, что средства типизации понимают его:

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass
Шаг второй - заменить декларативное использование Column на mapped_column()

mapped_column() является конструкцией, учитывающей ORM-типизацию, и может быть заменена непосредственно на использование Column. Если отображение в стиле 1.x выглядит так:

from sqlalchemy import Column
from sqlalchemy.orm import relationship
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id = Column(Integer, primary_key=True)
    name = Column(String(30), nullable=False)
    fullname = Column(String)
    addresses = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    email_address = Column(String, nullable=False)
    user_id = Column(ForeignKey("user_account.id"), nullable=False)
    user = relationship("User", back_populates="addresses")

Заменяем Column на mapped_column(); никакие аргументы менять не нужно:

from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id = mapped_column(Integer, primary_key=True)
    name = mapped_column(String(30), nullable=False)
    fullname = mapped_column(String)
    addresses = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id = mapped_column(Integer, primary_key=True)
    email_address = mapped_column(String, nullable=False)
    user_id = mapped_column(ForeignKey("user_account.id"), nullable=False)
    user = relationship("User", back_populates="addresses")

Отдельные столбцы, приведенные выше, еще не типизированы типами Python, а вместо этого типизированы как Mapped[Any]; это связано с тем, что мы можем объявить любой столбец как с Optional, так и без него, и нет способа «угадать», что не вызовет ошибок при явном типе.

Однако уже на этом этапе для всех атрибутов нашего отображения установлены соответствующие типы descriptor, которые можно использовать как в запросах, так и для манипуляций на уровне экземпляра, причем все это будет проходить в режиме mypy –strict mode без каких-либо плагинов.

Третий шаг - применение точных типов Python по мере необходимости с помощью Mapped.

Это можно сделать для всех атрибутов, для которых требуется точная типизация; атрибуты, которые вполне можно оставить в виде Any, можно пропустить. Для контекста мы также покажем, как Mapped используется для relationship(), где мы применяем точный тип. Отображение в рамках этого промежуточного шага будет более многословным, однако при определенном навыке этот шаг может быть объединен с последующими шагами для более прямого обновления отображений:

from typing import List
from typing import Optional
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(30), nullable=False)
    fullname: Mapped[Optional[str]] = mapped_column(String)
    addresses: Mapped[List["Address"]] = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email_address: Mapped[str] = mapped_column(String, nullable=False)
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"), nullable=False)
    user: Mapped["User"] = relationship("User", back_populates="addresses")

На этом этапе наше ORM-отображение полностью типизировано и будет создавать точно-типизированные конструкции select(), Query и Result. Теперь мы можем приступить к устранению избыточности в объявлении отображения.

Шаг четвертый - удалить директивы mapped_column() там, где они больше не нужны

Все параметры nullable могут быть подразумеваемы с помощью Optional[]; при отсутствии Optional[] по умолчанию используется nullable, а при отсутствии - False. Все типы SQL без аргументов, такие как Integer и String, могут быть выражены только в виде аннотации Python. Директива mapped_column() без параметров может быть полностью удалена. relationship() теперь получает свой класс от левой аннотации, поддерживая также прямые ссылки (так как relationship() уже десять лет поддерживает строковые прямые ссылки ;) ):

from typing import List
from typing import Optional
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user: Mapped["User"] = relationship(back_populates="addresses")
Шаг пятый - использование pep-593 Annotated для упаковки общих директив в типы

Это принципиально новая возможность, которая представляет собой альтернативу или дополнение к declarative mixins как средству обеспечения конфигурации, ориентированной на тип, а также в большинстве случаев заменяет необходимость использования декорированных функций declared_attr.

Во-первых, декларативное отображение позволяет настраивать отображение типа Python на тип SQL, например str на String, с помощью registry.type_annotation_map. Использование PEP 593 Annotated позволяет нам создавать варианты определенного типа Python, так что один и тот же тип, например str, может использоваться, каждый из которых предоставляет варианты String, как показано ниже, где использование Annotated str под названием str50 будет указывать String(50):

from typing_extensions import Annotated
from sqlalchemy.orm import DeclarativeBase

str50 = Annotated[str, 50]


# declarative base with a type-level override, using a type that is
# expected to be used in multiple places
class Base(DeclarativeBase):
    type_annotation_map = {
        str50: String(50),
    }

Во-вторых, Declarative будет извлекать полные определения mapped_column() из левого типа, если используется Annotated[], передавая конструкцию mapped_column() в качестве любого аргумента конструкции Annotated[] (за иллюстрацию этой идеи спасибо @adriangb01). В будущих версиях эта возможность может быть расширена за счет включения в нее relationship(), composite() и других конструкций, но в настоящее время она ограничивается mapped_column(). Приведенный ниже пример добавляет дополнительные типы Annotated в дополнение к нашему примеру str50, чтобы проиллюстрировать эту возможность:

from typing_extensions import Annotated
from typing import List
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship

# declarative base from previous example
str50 = Annotated[str, 50]


class Base(DeclarativeBase):
    type_annotation_map = {
        str50: String(50),
    }


# set up mapped_column() overrides, using whole column styles that are
# expected to be used in multiple places
intpk = Annotated[int, mapped_column(primary_key=True)]
user_fk = Annotated[int, mapped_column(ForeignKey("user_account.id"))]


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[intpk]
    name: Mapped[str50]
    fullname: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[intpk]
    email_address: Mapped[str50]
    user_id: Mapped[user_fk]
    user: Mapped["User"] = relationship(back_populates="addresses")

Выше, столбцы, которые отображаются с помощью Mapped[str50], Mapped[intpk] или Mapped[user_fk], напрямую используют как конструкцию registry.type_annotation_map, так и конструкцию Annotated, чтобы повторно использовать уже установленную типизацию и конфигурацию столбцов.

Шаг 6 - превращение картографических классов в классы данных_

Мы можем превратить отображенные классы в dataclasses, ключевым преимуществом которых является возможность построения строго типизированного метода __init__() с явными позиционными, только ключевыми словами и аргументами по умолчанию, не говоря уже о том, что такие методы, как __str__() и __repr__(), достаются нам бесплатно. Следующий раздел Встроенная поддержка классов данных, отображаемых как модели ORM иллюстрирует дальнейшее преобразование приведенной модели.

Ввод текста поддерживается начиная с шага 3 и далее

С учетом приведенных примеров любой пример, начиная с «шага 3», будет включать в себя то, что атрибуты модели являются типизированными и заполняются через объекты select(), Query и Row:

# (variable) stmt: Select[Tuple[int, str]]
stmt = select(User.id, User.name)

with Session(e) as sess:
    for row in sess.execute(stmt):
        # (variable) row: Row[Tuple[int, str]]
        print(row)

    # (variable) users: Sequence[User]
    users = sess.scalars(select(User)).all()

    # (variable) users_legacy: List[User]
    users_legacy = sess.query(User).all()

См.также

Декларативная таблица с mapped_column() - Обновлена документация по декларативной генерации и отображению столбцов Table.

Использование унаследованных моделей Mypy-Typed

Приложения SQLAlchemy, использующие Mypy plugin с явными аннотациями, которые не используют Mapped в своих аннотациях, в новой системе подвержены ошибкам, поскольку такие аннотации помечаются как ошибочные при использовании таких конструкций, как relationship().

В разделе Миграция на 2.0 Шаг шестой - добавление __allow_unmapped__ в явно типизированные модели ORM показано, как временно запретить выдачу этих ошибок для унаследованной ORM-модели, использующей явные аннотации.

Встроенная поддержка классов данных, отображаемых как модели ORM

Новые декларативные возможности ORM, представленные выше в разделе Декларативные модели ORM, представили новую конструкцию mapped_column() и проиллюстрировали отображение, ориентированное на тип, с необязательным использованием PEP 593 Annotated. Мы можем сделать еще один шаг вперед, интегрировав отображение с Python dataclasses. Эта новая возможность стала возможной благодаря PEP 681, которая позволяет программам проверки типов распознавать классы, совместимые с dataclass, или полностью являющиеся dataclass, но объявленные с помощью альтернативных API.

При использовании функции dataclasses отображаемые классы получают метод __init__(), поддерживающий позиционные аргументы, а также настраиваемые значения по умолчанию для необязательных ключевых аргументов. Как уже упоминалось, классы данных также генерируют множество полезных методов, таких как __str__(), __eq__(). Такие методы сериализации классов данных, как dataclasses.asdict() и dataclasses.astuple(), также работают, но в настоящее время не учитывают самореферентные структуры, что делает их менее жизнеспособными для отображений, имеющих двунаправленные связи.

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

Для поддержки классов данных в соответствии с PEP 681 такие конструкции ORM, как mapped_column() и relationship(), принимают дополнительные PEP 681 аргументы init, default и default_factory, которые передаются в процессе создания класса данных. В настоящее время эти аргументы должны присутствовать в явной директиве в правой части, как и в случае с dataclasses.field(); в настоящее время они не могут быть локальными для конструкции Annotated в левой части. Для поддержки удобства использования Annotated при сохранении поддержки конфигурации классов данных, mapped_column() может объединять минимальный набор аргументов правой части с аргументами существующей конструкции mapped_column(), расположенной слева, внутри конструкции Annotated, что позволяет сохранить большую часть лаконичности, как будет показано ниже.

Для включения классов данных с использованием наследования классов мы используем миксин MappedAsDataclass либо непосредственно на каждом классе, либо на классе Base, как показано ниже, где мы дополнительно модифицируем пример отображения из «Шага 5» Декларативные модели ORM:

from typing_extensions import Annotated
from typing import List
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import MappedAsDataclass
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(MappedAsDataclass, DeclarativeBase):
    """subclasses will be converted to dataclasses"""


intpk = Annotated[int, mapped_column(primary_key=True)]
str30 = Annotated[str, mapped_column(String(30))]
user_fk = Annotated[int, mapped_column(ForeignKey("user_account.id"))]


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[intpk] = mapped_column(init=False)
    name: Mapped[str30]
    fullname: Mapped[Optional[str]] = mapped_column(default=None)
    addresses: Mapped[List["Address"]] = relationship(
        back_populates="user", default_factory=list
    )


class Address(Base):
    __tablename__ = "address"

    id: Mapped[intpk] = mapped_column(init=False)
    email_address: Mapped[str]
    user_id: Mapped[user_fk] = mapped_column(init=False)
    user: Mapped["User"] = relationship(back_populates="addresses", default=None)

Приведенное выше отображение использовало декоратор @dataclasses.dataclass непосредственно на каждом отображаемом классе одновременно с установкой декларативного отображения, внутренне устанавливая каждую директиву dataclasses.field(), как указано. <<<Конструкции User / Address могут быть созданы с использованием позиционных аргументов в соответствии с конфигурацией:

>>> u1 = User("username", fullname="full name", addresses=[Address("email@address")])
>>> u1
User(id=None, name='username', fullname='full name', addresses=[Address(id=None, email_address='email@address', user_id=None, user=...)])

Оптимизированная массовая вставка ORM теперь реализована для всех бэкендов, кроме MySQL

Значительное повышение производительности, представленное в серии 1.4 и описанное в ORM Пакетные вставки с psycopg2 теперь в большинстве случаев выполняют пакетные операции с RETURNING, теперь распространено на все включенные бэкенды, поддерживающие RETURNING, то есть на все бэкенды, кроме MySQL: SQLite, MariaDB, PostgreSQL (все драйверы) и Oracle; у SQL Server поддержка есть, но временно отключена в версии 2.0.9 [1]. В то время как первоначальная возможность была наиболее важна для драйвера psycopg2, который в противном случае имел значительные проблемы с производительностью при использовании cursor.executemany(), изменение также важно для других драйверов PostgreSQL, таких как asyncpg, поскольку при использовании RETURNING одноэтапные операторы INSERT по-прежнему работают недопустимо медленно, а также при использовании SQL Server, который также имеет очень низкую скорость выполнения операторов INSERT независимо от того, используется RETURNING или нет.

Производительность новой функции обеспечивает практически повсеместное увеличение производительности на порядок практически для всех драйверов при INSERT ORM-объектов, не имеющих предварительно назначенного значения первичного ключа, как показано в таблице ниже, причем в большинстве случаев это связано с использованием RETURNING, который обычно не поддерживается в executemany().

Подход psycopg2 «помощник быстрого выполнения» заключается в преобразовании оператора INSERT…RETURNING с одним набором параметров в один оператор, который выполняет INSERT с большим количеством наборов параметров, используя несколько предложений «VALUES…», чтобы можно было одновременно обрабатывать множество наборов параметров. Наборы параметров обычно разбиваются на группы по 1000 или около того, так что ни один INSERT-оператор не является чрезмерно большим, и INSERT-оператор вызывается для каждой группы параметров, а не для каждого отдельного набора параметров. Значения первичных ключей и значения по умолчанию сервера возвращаются с помощью RETURNING, который продолжает работать, поскольку при выполнении каждого оператора используется cursor.execute(), а не cursor.executemany().

Это позволяет вставлять много строк в одном операторе, возвращая при этом только что сгенерированные значения первичных ключей, а также значения SQL и сервера по умолчанию. Исторически сложилось так, что SQLAlchemy всегда приходилось вызывать один оператор для каждого набора параметров, поскольку она опиралась на такие возможности Python DBAPI, как cursor.lastrowid, которые не поддерживают работу с несколькими строками.

Поскольку большинство баз данных теперь предлагают RETURNING (за заметным исключением MySQL, поскольку MariaDB его поддерживает), новое изменение обобщает подход psycopg2 «fast execution helper» на все диалекты, которые поддерживают RETURNING, что теперь включает SQlite и MariaDB, и для которых невозможен другой подход «executemany plus RETURNING», что включает SQLite, MariaDB и все драйверы PG. Драйверы cx_Oracle и oracledb, используемые для Oracle, поддерживают RETURNING с executemany нативно, и это также было реализовано для обеспечения эквивалентного повышения производительности. Теперь, когда SQLite и MariaDB поддерживают RETURNING, использование cursor.lastrowid в ORM практически ушло в прошлое, и только MySQL все еще полагается на него.

Для операторов INSERT, не использующих RETURNING, для большинства бэкендов используется традиционное поведение executemany(), за исключением psycopg2, который в целом имеет очень низкую производительность executemany(), которая все еще улучшается подходом «insertmanyvalues».

Бенчмарки

SQLAlchemy включает в себя Performance Suite в каталоге examples/, где мы можем воспользоваться набором bulk_insert для бенчмарка INSERT’ов большого количества строк с использованием Core и ORM различными способами.

В приведенных ниже тестах мы вставляем 100 000 объектов, и во всех случаях в памяти находится 100 000 реальных объектов Python ORM, либо созданных заранее, либо сгенерированных на лету. Все базы данных, кроме SQLite, работают через локальное сетевое соединение, а не через localhost; это приводит к тому, что «более медленные» результаты оказываются чрезвычайно медленными.

К числу операций, которые улучшаются благодаря этой функции, относятся:

  • единица работы прошивается для объектов, добавленных в сессию с помощью Session.add() и Session.add_all().

  • Новая функция ORM Bulk Insert Statement, которая улучшает экспериментальную версию этой функции, впервые представленную в SQLAlchemy 1.4.

  • «массовые» операции Session, описанные в Операции с сыпучими материалами, которые заменены упомянутой выше функцией ORM Bulk Insert.

Чтобы получить представление о масштабах этой операции, ниже приведены измерения производительности с использованием набора производительности test_flush_no_pk, который исторически представляет собой наихудшую задачу SQLAlchemy по выполнению INSERT, когда объекты, не имеющие значений первичных ключей, должны быть INSERT, а затем должны быть получены вновь созданные значения первичных ключей, чтобы объекты могли быть использованы для последующих операций flush, таких как создание внутри отношений, очистка моделей объединенного наследования и т.д.:

@Profiler.profile
def test_flush_no_pk(n):
    """INSERT statements via the ORM (batched with RETURNING if available),
    fetching generated row id"""
    session = Session(bind=engine)
    for chunk in range(0, n, 1000):
        session.add_all(
            [
                Customer(
                    name="customer name %d" % i,
                    description="customer description %d" % i,
                )
                for i in range(chunk, chunk + 1000)
            ]
        )
        session.flush()
    session.commit()

Этот тест может быть запущен из любого дерева исходных текстов SQLAlchemy следующим образом:

python -m examples.performance.bulk_inserts --test test_flush_no_pk

В таблице ниже приведены результаты измерений производительности последней версии SQLAlchemy серии 1.4 в сравнении с 2.0 при выполнении одного и того же теста:

Драйвер

SQLA 1.4 Время (сек.)

SQLA 2.0 Время (сек.)

sqlite+pysqlite2 (память)

6.204843

3.554856

postgresql+asyncpg (сеть)

88.292285

4.561492

postgresql+psycopg (сеть)

N/A (psycopg3)

4.861368

mssql+pyodbc (сеть)

158.396667

4.825139

oracle+cx_Oracle (сеть)

92.603953

4.809520

mariadb+mysqldb (сеть)

71.705197

4.075377

Примечание

Еще два драйвера не изменились в производительности: драйверы psycopg2, для которого в SQLAlchemy 1.4 уже был реализован быстрый executemany, и MySQL, который по-прежнему не предлагает поддержку RETURNING:

Драйвер

SQLA 1.4 Время (сек.)

SQLA 2.0 Время (сек.)

postgresql+psycopg2 (сеть)

4.704876

4.699883

mysql+mysqldb (сеть)

77.281997

76.132995

Краткое описание изменений

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

  • RETURNING реализован для SQLite - #6195.

  • RETURNING реализован для MariaDB - #7011.

  • Исправить многорядный RETURNING для Oracle - #6245.

  • сделать insert() executemany() поддерживающим RETURNING для как можно большего числа диалектов, обычно с помощью VALUES() - #6047.

  • Выдавать предупреждение при использовании RETURNING w/ executemany для неподдерживающего бэкенда (в настоящее время ни один бэкенд RETURNING не имеет такого ограничения) - #7907.

  • Параметр ORM Mapper.eager_defaults теперь устанавливает по умолчанию новую настройку "auto", которая будет автоматически включать «eager defaults» для операторов INSERT, если используемый бэкенд поддерживает RETURNING с «insertmanyvalues». Документацию см. в разделе Получение генерируемых сервером значений по умолчанию.

См.также

Поведение «Вставка многих значений» для операторов INSERT - Документация и справочная информация о новой функции, а также о том, как ее настроить

Вставка, вставка, обновление и удаление с поддержкой ORM, с ORM RETURNING

В SQLAlchemy 1.4 функции унаследованного объекта Query перенесены на исполнение 2.0 style, что означает, что конструкция Select может быть передана в Session.execute() для получения результатов ORM. Также была добавлена поддержка передачи Update и Delete в Session.execute(), чтобы они могли предоставлять реализации Query.update() и Query.delete().

Основным недостающим элементом была поддержка конструкции Insert. В документации к версии 1.4 этот вопрос решался с помощью некоторых рецептов «вставок» и «апсетов» с использованием Select.from_statement() для интеграции RETURNING в контекст ORM. В версии 2.0 этот пробел полностью устранен за счет интеграции прямой поддержки Insert в виде расширенной версии метода Session.bulk_insert_mappings(), а также полной поддержки ORM RETURNING для всех структур DML.

Объемная вставка с возвратом

Insert может быть передан в Session.execute(), с или без Insert.returning(), который при передаче с отдельным списком параметров вызовет тот же процесс, который ранее был реализован в Session.bulk_insert_mappings(), с дополнительными улучшениями. Это позволит оптимизировать пакетную обработку строк с использованием новой функции fast insertmany, а также добавить поддержку неоднородных наборов параметров и отображений нескольких таблиц, таких как наследование объединенных таблиц:

>>> users = session.scalars(
...     insert(User).returning(User),
...     [
...         {"name": "spongebob", "fullname": "Spongebob Squarepants"},
...         {"name": "sandy", "fullname": "Sandy Cheeks"},
...         {"name": "patrick", "fullname": "Patrick Star"},
...         {"name": "squidward", "fullname": "Squidward Tentacles"},
...         {"name": "ehkrabs", "fullname": "Eugene H. Krabs"},
...     ],
... )
>>> print(users.all())
[User(name='spongebob', fullname='Spongebob Squarepants'),
 User(name='sandy', fullname='Sandy Cheeks'),
 User(name='patrick', fullname='Patrick Star'),
 User(name='squidward', fullname='Squidward Tentacles'),
 User(name='ehkrabs', fullname='Eugene H. Krabs')]

RETURNING поддерживается для всех этих случаев, когда ORM будет строить полный набор результатов из нескольких вызовов оператора.

См.также

ORM Bulk INSERT Statements

Массовое обновление

Аналогично методу Insert, передача конструкции Update вместе со списком параметров, включающим значения первичных ключей, в Session.execute() вызовет тот же процесс, который ранее поддерживался методом Session.bulk_update_mappings(). Однако эта функция не поддерживает RETURNING, так как использует SQL-оператор UPDATE, который вызывается с помощью DBAPI executemany:

>>> from sqlalchemy import update
>>> session.execute(
...     update(User),
...     [
...         {"id": 1, "fullname": "Spongebob Squarepants"},
...         {"id": 3, "fullname": "Patrick Star"},
...     ],
... )

INSERT / upsert … VALUES … ВОЗВРАТ

При использовании Insert с Insert.values() набор параметров может включать SQL-выражения. Кроме того, поддерживаются варианты upsert, например, для SQLite, PostgreSQL и MariaDB. Теперь эти операторы могут включать предложения Insert.returning() с выражениями столбцов или полными сущностями ORM:

>>> from sqlalchemy.dialects.sqlite import insert as sqlite_upsert
>>> stmt = sqlite_upsert(User).values(
...     [
...         {"name": "spongebob", "fullname": "Spongebob Squarepants"},
...         {"name": "sandy", "fullname": "Sandy Cheeks"},
...         {"name": "patrick", "fullname": "Patrick Star"},
...         {"name": "squidward", "fullname": "Squidward Tentacles"},
...         {"name": "ehkrabs", "fullname": "Eugene H. Krabs"},
...     ]
... )
>>> stmt = stmt.on_conflict_do_update(
...     index_elements=[User.name], set_=dict(fullname=stmt.excluded.fullname)
... )
>>> result = session.scalars(stmt.returning(User))
>>> print(result.all())
[User(name='spongebob', fullname='Spongebob Squarepants'),
User(name='sandy', fullname='Sandy Cheeks'),
User(name='patrick', fullname='Patrick Star'),
User(name='squidward', fullname='Squidward Tentacles'),
User(name='ehkrabs', fullname='Eugene H. Krabs')]

ORM UPDATE / DELETE с WHERE … RETURNING

В SQLAlchemy 1.4 также была реализована скромная поддержка функции RETURNING для использования с конструкциями update() и delete(), когда они используются вместе с Session.execute(). Теперь эта поддержка была усовершенствована и стала полностью собственной, включая то, что стратегия синхронизации fetch также может выполняться независимо от того, присутствует ли явное использование RETURNING или нет:

>>> from sqlalchemy import update
>>> stmt = (
...     update(User)
...     .where(User.name == "squidward")
...     .values(name="spongebob")
...     .returning(User)
... )
>>> result = session.scalars(stmt, execution_options={"synchronize_session": "fetch"})
>>> print(result.all())

Улучшено поведение synchronize_session для ORM UPDATE / DELETE

Стратегией по умолчанию для synchronize_session теперь является новое значение "auto". Эта стратегия будет пытаться использовать стратегию "evaluate", а затем автоматически возвращаться к стратегии "fetch". Для всех бэкендов, кроме MySQL / MariaDB, "fetch" использует RETURNING для получения идентификаторов первичного ключа UPDATE/DELETEd в рамках одного оператора, поэтому в целом более эффективна, чем предыдущие версии (в 1.4 RETURNING был доступен только для PostgreSQL, SQL Server).

Краткое описание изменений

Перечислены билеты для новых ORM DML с функциями RETURNING:

  • преобразование insert() на уровне ORM для интерпретации values() в контексте ORM - #7864

  • оценить возможность использования dml.returning(Entity) для доставки ORM-выражений, автоматического применения select().from_statement equiv - #7865

  • учитывая вставку ORM, попытаться перенести массовые методы, re: inheritance - #8360.

Новая стратегия отношений «Только запись» заменяет «динамическую»

Стратегия загрузчика lazy="dynamic" становится унаследованной, поскольку она жестко закодирована для использования унаследованной Query. Эта стратегия загрузчика несовместима с asyncio и, кроме того, имеет множество поведений, которые неявно итерируют ее содержимое, что нарушает первоначальное назначение «динамических» отношений, поскольку они предназначены для очень больших коллекций, которые не должны быть неявно полностью загружены в память в любой момент времени.

На смену стратегии «динамический» пришла новая стратегия lazy="write_only". Конфигурация «только запись» может быть достигнута с помощью параметра relationship.lazy в relationship(), либо при использовании type annotated mappings, указывая в качестве стиля отображения аннотацию WriteOnlyMapped:

from sqlalchemy.orm import WriteOnlyMapped


class Base(DeclarativeBase):
    pass


class Account(Base):
    __tablename__ = "account"
    id: Mapped[int] = mapped_column(primary_key=True)
    identifier: Mapped[str]
    account_transactions: WriteOnlyMapped["AccountTransaction"] = relationship(
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="AccountTransaction.timestamp",
    )


class AccountTransaction(Base):
    __tablename__ = "account_transaction"
    id: Mapped[int] = mapped_column(primary_key=True)
    account_id: Mapped[int] = mapped_column(
        ForeignKey("account.id", ondelete="cascade")
    )
    description: Mapped[str]
    amount: Mapped[Decimal]
    timestamp: Mapped[datetime] = mapped_column(default=func.now())

Коллекция с отображением только для записи напоминает lazy="dynamic" тем, что коллекция может быть назначена заранее, а также имеет методы WriteOnlyCollection.add() и WriteOnlyCollection.remove() для модификации коллекции на основе отдельных элементов:

new_account = Account(
    identifier="account_01",
    account_transactions=[
        AccountTransaction(description="initial deposit", amount=Decimal("500.00")),
        AccountTransaction(description="transfer", amount=Decimal("1000.00")),
        AccountTransaction(description="withdrawal", amount=Decimal("-29.50")),
    ],
)

new_account.account_transactions.add(
    AccountTransaction(description="transfer", amount=Decimal("2000.00"))
)

Более существенное отличие заключается в загрузке базы данных, где коллекция не имеет возможности загружать объекты из базы данных напрямую; вместо этого используются методы построения SQL, такие как WriteOnlyCollection.select(), для создания SQL-конструкций, таких как Select, которые затем выполняются с помощью 2.0 style для загрузки нужных объектов в явном виде:

account_transactions = session.scalars(
    existing_account.account_transactions.select()
    .where(AccountTransaction.amount < 0)
    .limit(10)
).all()

WriteOnlyCollection также интегрируется с новыми возможностями ORM bulk dml, включая поддержку массового INSERT и UPDATE/DELETE с критериями WHERE, в том числе и с поддержкой RETURNING. Полная документация приведена на сайте Писать только отношения.

Новый pep-484 / поддержка аннотированных отображений типов для динамических отношений

Несмотря на то, что в версии 2.0 «динамические» отношения являются наследием, поскольку ожидается, что эти паттерны будут иметь долгую жизнь, поддержка type annotated mapping для «динамических» отношений теперь добавлена так же, как и для нового подхода lazy="write_only", с помощью аннотации DynamicMapped:

from sqlalchemy.orm import DynamicMapped


class Base(DeclarativeBase):
    pass


class Account(Base):
    __tablename__ = "account"
    id: Mapped[int] = mapped_column(primary_key=True)
    identifier: Mapped[str]
    account_transactions: DynamicMapped["AccountTransaction"] = relationship(
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="AccountTransaction.timestamp",
    )


class AccountTransaction(Base):
    __tablename__ = "account_transaction"
    id: Mapped[int] = mapped_column(primary_key=True)
    account_id: Mapped[int] = mapped_column(
        ForeignKey("account.id", ondelete="cascade")
    )
    description: Mapped[str]
    amount: Mapped[Decimal]
    timestamp: Mapped[datetime] = mapped_column(default=func.now())

Приведенное выше отображение позволяет получить коллекцию Account.account_transactions, которая типизирована как возвращающая тип коллекции AppenderQuery, включая тип ее элемента, например AppenderQuery[AccountTransaction]. Это позволяет при итерации и запросах получать объекты, типизированные как AccountTransaction.

#7123

Теперь установка полностью поддерживает pep-517

В дистрибутив исходных текстов теперь включен файл pyproject.toml для обеспечения полной поддержки PEP 517. В частности, это позволяет при локальной сборке исходного кода с использованием pip автоматически устанавливать дополнительную зависимость Cython.

#7311

Расширения языка C теперь перенесены на Cython

Расширения SQLAlchemy на языке Си были заменены новыми расширениями, написанными на языке Cython. Хотя оценка Cython проводилась еще в 2010 году, когда впервые были созданы расширения на языке C, характер и направленность используемых сегодня расширений на языке C с тех пор претерпели значительные изменения. В то же время Cython, очевидно, претерпел значительные изменения, как и инструментарий для сборки и распространения Python, что и позволило нам вернуться к этой теме.

Переход на Cython дает новые значительные преимущества при отсутствии явных недостатков:

  • Расширения Cython, заменяющие специфические расширения языка C, по результатам бенчмарков оказались быстрее, часто незначительно, но иногда и значительно, чем практически весь код на языке C, который ранее был включен в SQLAlchemy. Хотя это и кажется удивительным, но, по-видимому, является результатом неочевидных оптимизаций в реализации Cython, которые не были бы реализованы при прямом переносе функции с Python на C, что особенно характерно для многих пользовательских типов коллекций, добавленных к расширениям C.

  • Расширения Cython гораздо проще в написании, сопровождении и отладке по сравнению с исходным кодом на Си, и в большинстве случаев они построчно эквивалентны коду на Python. Ожидается, что в ближайших релизах на Cython будет перенесено еще много элементов SQLAlchemy, что откроет новые возможности для повышения производительности, которые раньше были недоступны.

  • Cython очень развит и широко используется, в том числе является основой некоторых известных драйверов баз данных, поддерживаемых SQLAlchemy, включая asyncpg, psycopg3 и asyncmy.

Как и предыдущие расширения для языка C, расширения для языка Cython предварительно собираются в дистрибутивах SQLAlchemy на колесах, которые автоматически доступны для pip из PyPi. Ручные инструкции по сборке также не изменились, за исключением требования Cython.

#7256

Основные улучшения архитектуры, производительности и API для отражения в базах данных

Внутренняя система, с помощью которой объекты Table и их компоненты становятся reflected, была полностью перестроена, чтобы обеспечить высокую производительность массового отражения тысяч таблиц одновременно для участвующих диалектов. В настоящее время в новой архитектуре участвуют диалекты PostgreSQL и Oracle, причем диалект PostgreSQL теперь может отражать большую серию Table объектов Table почти в три раза быстрее, а диалект Oracle - в десять раз быстрее.

Реорганизация в наибольшей степени относится к диалектам, использующим для отражения таблиц запросы SELECT к таблицам системного каталога, и оставшимся диалектом, который может выиграть от такого подхода, будет диалект SQL Server. Диалекты MySQL/MariaDB и SQLite, напротив, используют нереляционные системы для отражения таблиц баз данных и не были подвержены ранее существовавшей проблеме производительности.

Новый API обратно совместим с предыдущей системой и не требует изменений в диалектах сторонних разработчиков для сохранения совместимости; диалекты сторонних разработчиков также могут перейти на новую систему, реализовав пакетные запросы для отражения схем.

Вместе с этим изменением были улучшены API и поведение объекта Inspector, добавлены более согласованные междиалектные поведения, а также новые методы и новые возможности производительности.

Обзор производительности

В состав исходного дистрибутива входит скрипт test/perf/many_table_reflection.py, который тестирует как существующие, так и новые возможности отражения. Ограниченный набор его тестов может быть запущен на старых версиях SQLAlchemy, а здесь мы используем его для иллюстрации разницы в производительности при вызове metadata.reflect() для отражения 250 Table объектов одновременно через локальное сетевое соединение:

Диалект

Операция

SQLA 1.4 Время (сек.)

SQLA 2.0 Время (сек.)

postgresql+psycopg2

metadata.reflect(), 250 таблиц

8.2

3.3

oracle+cx_oracle

metadata.reflect(), 250 таблиц

60.4

6.8

Поведенческие изменения для Inspector()

Для диалектов SQLAlchemy, входящих в состав SQLite, PostgreSQL, MySQL/MariaDB, Oracle и SQL Server, функции Inspector.has_table(), Inspector.has_sequence(), Inspector.has_index(), Inspector.get_table_names() и Inspector.get_sequence_names() теперь ведут себя последовательно в плане кэширования: они полностью кэшируют свой результат после первого вызова для конкретного объекта Inspector. Программы, создающие или сбрасывающие таблицы/последовательности при обращении к одному и тому же объекту Inspector, не будут получать обновленный статус после изменения состояния базы данных. При выполнении DDL-изменений следует использовать вызов Inspector.clear_cache() или новый Inspector. Ранее методы Inspector.has_table(), Inspector.has_sequence() не реализовывали кэширование и Inspector не поддерживали кэширование для этих методов, в то время как методы Inspector.get_table_names() и Inspector.get_sequence_names() поддерживали, что приводило к несогласованным результатам между этими двумя типами методов.

Поведение сторонних диалектов зависит от того, реализуют ли они декоратор «кэш отражений» для реализации этих методов на уровне диалекта.

Новые методы и усовершенствования для Inspector()

  • добавлен метод Inspector.has_schema(), который возвращает, присутствует ли схема в целевой базе данных

  • добавлен метод Inspector.has_index(), возвращающий, имеет ли таблица определенный индекс.

  • Методы проверки, такие как Inspector.get_columns(), работающие с одной таблицей за раз, теперь должны последовательно вызывать NoSuchTableError, если таблица или представление не найдены; это изменение относится к отдельным диалектам, поэтому может не соответствовать существующим диалектам сторонних разработчиков.

  • Разделена работа с «представлениями» и «материализованными представлениями», поскольку в реальных условиях эти две конструкции используют разные DDL для CREATE и DROP; это означает, что теперь существуют отдельные методы Inspector.get_view_names() и Inspector.get_materialized_view_names().

#4379

Поддержка диалекта psycopg 3 (он же «psycopg»)

Добавлена поддержка диалекта psycopg 3 DBAPI, который, несмотря на цифру «3», теперь называется пакетом psycopg, заменив предыдущий пакет psycopg2, который пока остается драйвером SQLAlchemy «по умолчанию» для диалектов postgresql. psycopg - это полностью переработанный и модернизированный адаптер баз данных для PostgreSQL, поддерживающий такие концепции, как подготовленные операторы, а также Python asyncio.

psycopg - это первый DBAPI, поддерживаемый SQLAlchemy, который предоставляет как синхронный API pep-249, так и драйвер asyncio. Один и тот же URL базы данных psycopg может быть использован в функциях создания движка create_engine() и create_async_engine(), при этом автоматически будет выбрана соответствующая синхронная или асинхронная версия диалекта.

См.также

psycopg

Поддержка диалекта для oracledb

Добавлена поддержка диалекта oracledb DBAPI, который является переименованным, новым основным выпуском популярного драйвера cx_Oracle.

См.также

python-oracledb

Новый условный DDL для ограничений и индексов

Новый метод Constraint.ddl_if() и Index.ddl_if() позволяет выводить такие конструкции, как CheckConstraint, UniqueConstraint и Index, условно для заданного Table, на основе тех же критериев, которые принимаются методом DDLElement.execute_if(). В приведенном ниже примере ограничение CHECK и индекс будут создаваться только для бэкенда PostgreSQL:

meta = MetaData()


my_table = Table(
    "my_table",
    meta,
    Column("id", Integer, primary_key=True),
    Column("num", Integer),
    Column("data", String),
    Index("my_pg_index", "data").ddl_if(dialect="postgresql"),
    CheckConstraint("num > 5").ddl_if(dialect="postgresql"),
)

e1 = create_engine("sqlite://", echo=True)
meta.create_all(e1)  # will not generate CHECK and INDEX


e2 = create_engine("postgresql://scott:tiger@localhost/test", echo=True)
meta.create_all(e2)  # will generate CHECK and INDEX

#7631

Типы данных DATE, TIME, DATETIME теперь поддерживают литеральный рендеринг на всех бэкендах

Буквальный рендеринг теперь реализован для типов даты и времени для компиляции, специфичной для бэкендов, включая PostgreSQL и Oracle:

>>> import datetime

>>> from sqlalchemy import DATETIME
>>> from sqlalchemy import literal
>>> from sqlalchemy.dialects import oracle
>>> from sqlalchemy.dialects import postgresql

>>> date_literal = literal(datetime.datetime.now(), DATETIME)

>>> print(
...     date_literal.compile(
...         dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
...     )
... )
{printsql}'2022-12-17 11:02:13.575789'{stop}

>>> print(
...     date_literal.compile(
...         dialect=oracle.dialect(), compile_kwargs={"literal_binds": True}
...     )
... )
{printsql}TO_TIMESTAMP('2022-12-17 11:02:13.575789', 'YYYY-MM-DD HH24:MI:SS.FF'){stop}

Ранее такой буквенный рендеринг работал только при стринге высказываний без указания диалекта; при попытке рендеринга с указанием диалекта возникала ошибка NotImplementedError, вплоть до версии SQLAlchemy 1.4.45, где она стала CompileError (часть #8800).

При использовании literal_binds с SQL-компиляторами, предоставляемыми диалектами PostgreSQL, MySQL, MariaDB, MSSQL, Oracle, по умолчанию используется модифицированное отображение ISO-8601 (т.е. ISO-8601 с T, преобразованным в пробел). Для Oracle формат ISO обернут внутри соответствующего вызова функции TO_DATE(). Рендеринг для SQLite не изменился, так как в этом диалекте всегда использовался строковый рендеринг для значений даты.

#5052

Поддержка контекстного менеджера для Result, AsyncResult

Объект Result теперь поддерживает использование контекстного менеджера, который обеспечит закрытие объекта и лежащего в его основе курсора в конце блока. Это полезно, в частности, при работе с курсорами на стороне сервера, когда важно, чтобы открытый объект курсора был закрыт по окончании операции, даже если произошли определенные пользователем исключения:

with engine.connect() as conn:
    with conn.execution_options(yield_per=100).execute(
        text("select * from table")
    ) as result:
        for row in result:
            print(f"{row}")

При использовании asyncio, AsyncResult и AsyncConnection были изменены, чтобы обеспечить опциональное использование менеджера контекста async, как в:

async with async_engine.connect() as conn:
    async with conn.execution_options(yield_per=100).execute(
        text("select * from table")
    ) as result:
        for row in result:
            print(f"{row}")

#8710

Поведенческие изменения

В этом разделе рассматриваются изменения в поведении SQLAlchemy 2.0, которые не являются частью основного пути миграции 1.4->2.0; ожидается, что изменения здесь не окажут существенного влияния на обратную совместимость.

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

Поведение «присоединения внешней транзакции к сессии» было пересмотрено и улучшено, что позволяет явно контролировать, как Session будет воспринимать входящую Connection, которая уже имеет транзакцию и, возможно, точку сохранения. Новый параметр Session.join_transaction_mode включает в себя ряд опций, которые могут учитывать существующую транзакцию несколькими способами, наиболее важным из которых является то, что Session может работать в полностью транзакционном стиле, используя исключительно точки сохранения, оставляя инициированную извне транзакцию нефиксированной и активной при любых обстоятельствах, что позволяет тестовым наборам откатывать все изменения, происходящие внутри тестов.

Основное улучшение, которое это дает, заключается в том, что рецепт, задокументированный в Присоединение сеанса к внешней транзакции (например, для тестовых наборов), который также изменился с SQLAlchemy 1.3 на 1.4, теперь упрощен и не требует явного использования обработчика событий или упоминания явной точки сохранения; используя join_transaction_mode="create_savepoint", Session никогда не будет влиять на состояние входящей транзакции, а будет создавать точку сохранения (т.е. «вложенную транзакцию») как свою корневую транзакцию.

Ниже приведена часть примера, приведенного в разделе Присоединение сеанса к внешней транзакции (например, для тестовых наборов); полный пример см. в этом разделе:

class SomeTest(TestCase):
    def setUp(self):
        # connect to the database
        self.connection = engine.connect()

        # begin a non-ORM transaction
        self.trans = self.connection.begin()

        # bind an individual Session to the connection, selecting
        # "create_savepoint" join_transaction_mode
        self.session = Session(
            bind=self.connection, join_transaction_mode="create_savepoint"
        )

    def tearDown(self):
        self.session.close()

        # rollback non-ORM transaction
        self.trans.rollback()

        # return connection to the Engine
        self.connection.close()

По умолчанию для Session.join_transaction_mode выбран режим "conditional_savepoint", который использует поведение "create_savepoint", если данный Connection сам уже находится в точке сохранения. Если данный Connection находится в транзакции, но не в точке сохранения, то Session будет распространять вызовы «отката», но не вызовы «фиксации», но не будет самостоятельно начинать новую точку сохранения. Такое поведение выбрано по умолчанию для максимальной совместимости со старыми версиями SQLAlchemy, а также для того, чтобы не начинать новый SAVEPOINT, если данный драйвер уже не использует SAVEPOINT, так как поддержка SAVEPOINT зависит не только от конкретного бэкенда и драйвера, но и от конфигурации.

Ниже приведен пример, который работал в SQLAlchemy 1.3, перестал работать в SQLAlchemy 1.4, а теперь восстановлен в SQLAlchemy 2.0:

engine = create_engine("...")

# setup outer connection with a transaction and a SAVEPOINT
conn = engine.connect()
trans = conn.begin()
nested = conn.begin_nested()

# bind a Session to that connection and operate upon it, including
# a commit
session = Session(conn)
session.connection()
session.commit()
session.close()

# assert both SAVEPOINT and transaction remain active
assert nested.is_active
nested.rollback()
trans.rollback()

В случае, описанном выше, Session присоединяется к Connection, на котором запущена точка сохранения; состояние этих двух единиц остается неизменным после того, как Session поработает с транзакцией. В SQLAlchemy 1.3 описанный выше случай работал, поскольку Session начинал «субтранзакцию» на Connection, что позволяло внешней точке сохранения/транзакции оставаться незатронутой для простых случаев, описанных выше. Поскольку субтранзакции были отменены в версии 1.4 и теперь удалены в версии 2.0, такое поведение стало недоступным. Новое поведение по умолчанию улучшает поведение «субтранзакций», используя вместо них реальный, второй SAVEPOINT, так что даже вызовы Session.rollback() не позволяют Session «вырваться» в инициированный извне SAVEPOINT или транзакцию.

Новый код, который присоединяет запущенную транзакцию Connection к Session, должен, однако, явно выбрать Session.join_transaction_mode, чтобы желаемое поведение было явно определено.

#9015

str(engine.url) по умолчанию обфусцирует пароль

Чтобы избежать утечки паролей баз данных, вызов str() на URL теперь по умолчанию включает функцию обфускации паролей. Ранее обфускация включалась при вызове __repr__(), но не __str__(). Это изменение повлияет на приложения и тестовые пакеты, которые пытаются вызвать create_engine(), получив строгированный URL от другого механизма, например:

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> e2 = create_engine(str(e1.url))

В приведенном выше движке e2 не будет правильного пароля, а будет обфусцированная строка "***".

Предпочтительным подходом для приведенного шаблона является передача объекта URL напрямую, нет необходимости в строковой обработке:

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> e2 = create_engine(e1.url)

В противном случае для строкового URL с паролем в открытом виде используйте метод URL.render_as_string(), передавая параметр URL.render_as_string.hide_password в виде False:

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> url_string = e1.url.render_as_string(hide_password=False)
>>> e2 = create_engine(url_string)

#8567

Более строгие правила замены столбцов в объектах Table с одинаковыми именами, ключами

Ужесточены правила добавления объектов Column к объектам Table, что позволило перевести некоторые предыдущие предупреждения об устаревании в исключения и предотвратить некоторые предыдущие сценарии, которые приводили к появлению дублирующихся столбцов в таблицах, когда Table.extend_existing устанавливался в True, как при программном построении Table, так и при операциях отражения.

  • Ни при каких обстоятельствах объект Table не должен иметь два или более объектов Column с одинаковым именем, независимо от того, какой .key они имеют. Был выявлен и исправлен крайний случай, когда это все же было возможно.

  • Добавление Column к Table, имеющему то же имя или ключ, что и существующий Column, всегда будет вызывать DuplicateColumnError (новый подкласс ArgumentError в версии 2.0.0b4), если нет дополнительных параметров; Table.append_column.replace_existing для Table.append_column() и Table.extend_existing для построения Table с тем же именем, что и существующий, с использованием или без использования отражения. Ранее для этого сценария действовало предупреждение об устаревании.

  • Теперь при создании Table, включающего Table.extend_existing, выдается предупреждение о том, что входящий Column, не имеющий отдельного Column.key, полностью заменит существующий Column, имеющий ключ, что говорит о том, что операция выполняется не так, как задумано пользователем. Это может произойти, в частности, на этапе вторичного отражения, например, metadata.reflect(extend_existing=True). Для предотвращения этого в предупреждении предлагается установить параметр Table.autoload_replace в значение False. Ранее, в версии 1.4 и более ранних, входящий столбец добавлялся в дополнение к существующему столбцу. Это было ошибкой, а в версии 2.0 (начиная с 2.0.0b4) поведение изменилось, так как предыдущий ключ больше не будет присутствовать в коллекции столбцов, когда это произойдет.

#8925

Декларативный ORM применяет порядок столбцов по-разному; управление поведением с помощью sort_order

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

class Foo:
    col1 = mapped_column(Integer)
    col3 = mapped_column(Integer)


class Bar:
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)


class Model(Base, Foo, Bar):
    id = mapped_column(Integer, primary_key=True)
    __tablename__ = "model"

Выдает CREATE TABLE, как показано ниже на 1.4:

CREATE TABLE model (
  col1 INTEGER,
  col3 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  id INTEGER NOT NULL,
  PRIMARY KEY (id)
)

В то время как на 2.0 он производит:

CREATE TABLE model (
  id INTEGER NOT NULL,
  col1 INTEGER,
  col3 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  PRIMARY KEY (id)
)

Для приведенного выше конкретного случая это можно рассматривать как улучшение, поскольку столбцы первичного ключа в Model теперь находятся там, где обычно предпочтительнее. Однако это не утешает приложение, которое определило модели наоборот, так как:

class Foo:
    id = mapped_column(Integer, primary_key=True)
    col1 = mapped_column(Integer)
    col3 = mapped_column(Integer)


class Model(Foo, Base):
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)
    __tablename__ = "model"

Теперь вывод CREATE TABLE выглядит так:

CREATE TABLE model (
  col2 INTEGER,
  col4 INTEGER,
  id INTEGER NOT NULL,
  col1 INTEGER,
  col3 INTEGER,
  PRIMARY KEY (id)
)

Для решения этой проблемы в SQLAlchemy 2.0.4 введен новый параметр mapped_column(), называемый mapped_column.sort_order, который представляет собой целочисленное значение, по умолчанию равное 0, которое может быть установлено в положительное или отрицательное значение, чтобы столбцы располагались перед или после других столбцов, как в приведенном ниже примере:

class Foo:
    id = mapped_column(Integer, primary_key=True, sort_order=-10)
    col1 = mapped_column(Integer, sort_order=-1)
    col3 = mapped_column(Integer)


class Model(Foo, Base):
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)
    __tablename__ = "model"

В приведенной выше модели «id» располагается перед всеми остальными, а «col1» - после «id»:

CREATE TABLE model (
  id INTEGER NOT NULL,
  col1 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  col3 INTEGER,
  PRIMARY KEY (id)
)

В будущих версиях SQLAlchemy может быть решено предоставить явную подсказку по упорядочиванию для конструкции mapped_column, поскольку такое упорядочивание является специфическим для ORM.

Конструкция Sequence возвращается к отсутствию явного значения «start» по умолчанию; влияет на MS SQL Server

До версии SQLAlchemy 1.4 конструкция Sequence выдавала только простой DDL CREATE SEQUENCE, если не были указаны дополнительные аргументы:

>>> # SQLAlchemy 1.3 (and 2.0)
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq")))
{printsql}CREATE SEQUENCE my_seq

Однако, поскольку была добавлена поддержка Sequence для MS SQL Server, где по умолчанию начальное значение неудобно установлено в -2**63, в версии 1.4 было принято решение по умолчанию выдавать в DDL начальное значение 1, если Sequence.start не предусмотрено иное:

>>> # SQLAlchemy 1.4 (only)
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq")))
{printsql}CREATE SEQUENCE my_seq START WITH 1

Это изменение привело к другим сложностям, в том числе к тому, что при включении параметра Sequence.min_value по умолчанию 1 фактически должно устанавливаться то, что указано в Sequence.min_value, иначе значение min_value, которое меньше значения start_value, может быть воспринято как противоречивое. Поскольку рассмотрение этого вопроса начало превращаться в кроличью нору с другими различными крайними случаями, мы решили вместо этого отменить это изменение и восстановить первоначальное поведение Sequence, которое заключается в том, чтобы не иметь никакого мнения и просто выдавать CREATE SEQUENCE, позволяя базе данных самой принимать решения о том, как различные параметры SEQUENCE должны взаимодействовать друг с другом.

Поэтому, чтобы гарантировать, что начальное значение равно 1 на всех бэкендах, начальное значение 1 может быть указано явно, как показано ниже:

>>> # All SQLAlchemy versions
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq", start=1)))
{printsql}CREATE SEQUENCE my_seq START WITH 1

Помимо всего прочего, для автогенерации целочисленных первичных ключей на современных бэкендах, включая PostgreSQL, Oracle, SQL Server, следует предпочесть конструкцию Identity, которая без изменений работает и в 1.4, и в 2.0.

#7211

«with_variant()» клонирует исходный TypeEngine, а не изменяет тип

Метод TypeEngine.with_variant(), используемый для применения альтернативных вариантов поведения базы данных к определенному типу, теперь возвращает копию исходного объекта TypeEngine с информацией о варианте, хранящейся внутри, а не оборачивает ее в класс Variant.

В то время как предыдущий подход Variant позволял сохранить все in-Python-поведения исходного типа с помощью динамических геттеров атрибутов, здесь улучшение заключается в том, что при обращении к варианту возвращаемый тип остается экземпляром исходного типа, что позволяет более гладко работать с программами проверки типов, такими как mypy и pylance. Ниже приведена программа:

import typing

from sqlalchemy import String
from sqlalchemy.dialects.mysql import VARCHAR

type_ = String(255).with_variant(VARCHAR(255, charset="utf8mb4"), "mysql", "mariadb")

if typing.TYPE_CHECKING:
    reveal_type(type_)

Теперь программа проверки типов, например, pyright, сообщит, что тип имеет вид:

info: Type of "type_" is "String"

Кроме того, как показано выше, для одного типа можно передавать несколько имен диалектов, в частности, это полезно для пары диалектов "mysql" и "mariadb", которые в SQLAlchemy 1.4 рассматриваются отдельно.

#6980

Оператор деления в Python выполняет истинное деление для всех бэкендов; добавлено деление на пол

Язык выражений Core теперь поддерживает как «истинное деление» (т.е. оператор / Python), так и «деление на пол» (т.е. оператор // Python), включая специфическое для бэкенда поведение для нормализации различных баз данных в этом отношении.

Дана операция «истинного деления» на два целочисленных значения:

expr = literal(5, Integer) / literal(10, Integer)

Например, оператор деления SQL в PostgreSQL при работе с целыми числами обычно работает как «деление на пол», то есть приведенный выше результат вернет целое число «0». Для этого и подобных бэкендов SQLAlchemy теперь отображает SQL в эквивалентной форме:

%(param_1)s / CAST(%(param_2)s AS NUMERIC)

С param_1=5, param_2=10, так что возвращаемое выражение будет иметь тип NUMERIC, обычно в виде значения Python decimal.Decimal("0.5").

Дана операция «поэтажного деления» над двумя целыми значениями:

expr = literal(5, Integer) // literal(10, Integer)

Оператор деления SQL в MySQL и Oracle, например, обычно работает как «истинное деление» при использовании с целыми числами, то есть приведенный выше результат вернет значение с плавающей точкой «0,5». Для этих и подобных им бэкендов SQLAlchemy теперь отображает SQL в эквивалентной форме:

FLOOR(%(param_1)s / %(param_2)s)

При param_1=5, param_2=10, так что возвращаемое выражение будет иметь тип INTEGER, как значение Python 0.

Обратное несовместимое изменение будет иметь место, если приложение, использующее PostgreSQL, SQL Server или SQLite, полагается на оператор Python «truediv», который во всех случаях возвращает целочисленное значение. Приложения, полагающиеся на такое поведение, должны вместо этого использовать для этих операций оператор Python «floor division» // или, для обратной совместимости при использовании предыдущей версии SQLAlchemy, функцию floor:

expr = func.floor(literal(5, Integer) / literal(10, Integer))

Приведенная выше форма необходима на любой версии SQLAlchemy, предшествующей 2.0, для обеспечения разделения этажей, не зависящего от бэкенда.

#4926

Проактивно поднимать сессию при обнаружении незаконного одновременного или реентерабельного доступа

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

Известна одна ошибка, возникающая при одновременном использовании Session в нескольких потоках, - AttributeError: 'NoneType' object has no attribute 'twophase', которая является совершенно загадочной. Эта ошибка возникает, когда поток вызывает Session.commit(), который внутренне вызывает метод SessionTransaction.close() для завершения транзакционного контекста, в то время, когда другой поток выполняет запрос из Session.execute(). Внутри Session.execute() внутренний метод, приобретающий соединение с базой данных для текущей транзакции, сначала утверждает, что сессия «активна», но после того, как это утверждение проходит, одновременный вызов Session.close() вмешивается в это состояние, что приводит к неопределенному состоянию, описанному выше.

Изменение накладывает защиту на все методы изменения состояния, окружающие объект SessionTransaction, так что в приведенном выше случае метод Session.commit() вместо этого потерпит неудачу, поскольку будет стремиться изменить состояние на такое, которое запрещено на время работы уже запущенного метода, который хочет получить текущее соединение для выполнения запроса к базе данных.

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

Traceback (most recent call last):
File "/home/classic/dev/sqlalchemy/test3.py", line 30, in worker
    sess.execute(select(A)).all()
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1691, in execute
    conn = self._connection_for_bind(bind)
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1532, in _connection_for_bind
    return self._transaction._connection_for_bind(
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 754, in _connection_for_bind
    if self.session.twophase and self._parent is None:
AttributeError: 'NoneType' object has no attribute 'twophase'

Где метод _connection_for_bind() не может продолжить работу, поскольку одновременный доступ перевел его в недопустимое состояние. При использовании нового подхода вместо ошибки выбрасывается инициатор изменения состояния:

File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1785, in close
   self._close_impl(invalidate=False)
File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1827, in _close_impl
   transaction.close(invalidate)
File "<string>", line 2, in close
File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 506, in _go
   raise sa_exc.InvalidRequestError(
sqlalchemy.exc.InvalidRequestError: Method 'close()' can't be called here;
method '_connection_for_bind()' is already in progress and this would cause
an unexpected state change to symbol('CLOSED')

Проверки перехода состояния намеренно не используют явных блокировок для обнаружения активности параллельных потоков, а полагаются на простые операции проверки набора атрибутов/значений, которые неизбежно терпят неудачу при неожиданных параллельных изменениях. Это объясняется тем, что такой подход позволяет обнаружить неправомерные изменения состояния, происходящие полностью внутри одного потока, например, если обработчик событий, выполняющийся на событиях транзакций сессии, вызывает метод, изменяющий состояние, который не ожидается, или в asyncio, если конкретный Session был разделен между несколькими задачами asyncio, а также при использовании подходов к параллелизму в стиле патчей, таких как gevent.

#7433

Диалект SQLite использует QueuePool для файловых баз данных

Диалект SQLite теперь по умолчанию принимает значение QueuePool при использовании базы данных на основе файлов. Это устанавливается вместе с установкой параметра check_same_thread в значение False. Было замечено, что предыдущий подход, при котором по умолчанию устанавливалось значение NullPool, не удерживающее соединения с базой данных после их освобождения, действительно оказывал ощутимое негативное влияние на производительность. Как обычно, класс пула настраивается с помощью параметра create_engine.poolclass.

#7490

Новый тип Oracle FLOAT с двоичной точностью; десятичная точность напрямую не принимается

В диалект Oracle добавлен новый тип данных FLOAT, который сопровождается добавлением Double и специфических для баз данных типов DOUBLE, DOUBLE_PRECISION и REAL. Oracle FLOAT принимает так называемый параметр «двоичной точности», который, согласно документации Oracle, представляет собой стандартное значение «точности», деленное на 0.3103:

from sqlalchemy.dialects import oracle

Table("some_table", metadata, Column("value", oracle.FLOAT(126)))

Значение двоичной точности 126 является синонимом использования типа данных DOUBLE_PRECISION, а значение 63 эквивалентно использованию типа данных REAL. Другие значения точности специфичны для самого типа FLOAT.

Тип данных SQLAlchemy Float также принимает параметр «precision», но это десятичная точность, которая не принимается Oracle. Вместо того чтобы пытаться угадать преобразование, диалект Oracle теперь будет выдавать информационную ошибку, если Float используется со значением precision в бэкенде Oracle. Чтобы указать тип данных Float с явным значением точности для поддерживающих бэкендов и при этом поддерживать другие бэкенды, используйте метод TypeEngine.with_variant() следующим образом:

from sqlalchemy.types import Float
from sqlalchemy.dialects import oracle

Table(
    "some_table",
    metadata,
    Column("value", Float(5).with_variant(oracle.FLOAT(16), "oracle")),
)

Новая поддержка RANGE / MULTIRANGE и изменения для бэкендов PostgreSQL

Поддержка RANGE / MULTIRANGE полностью реализована для диалектов psycopg2, psycopg3 и asyncpg. Для поддержки используется новый объект Range, специфичный для SQLAlchemy, который не зависит от различных бэкендов и не требует использования импорта или шагов расширения, специфичных для бэкенда. Для поддержки многодиапазонности используются списки объектов Range.

Код, использовавший ранее специфические для psycopg2 типы, должен быть модифицирован для использования Range, который представляет собой совместимый интерфейс.

В объекте Range также реализована поддержка сравнения, аналогичная той, что имеется в PostgreSQL. На данный момент реализованы методы Range.contains() и Range.contained_by(), которые работают так же, как и PostgreSQL @> и <@. В последующих выпусках может быть добавлена поддержка дополнительных операторов.

Информацию об использовании новой возможности см. в документации по адресу Типы диапазонов и мультидиапазонов.

#7156 #8706

Оператор match() в PostgreSQL использует plainto_tsquery(), а не to_tsquery()

Функция Operators.match() теперь выводит на бэкенд PostgreSQL не col @@ to_tsquery(), а col @@ plainto_tsquery(expr). Функция plainto_tsquery() принимает обычный текст, в то время как to_tsquery() принимает специализированные символы запроса, и поэтому менее совместима с другими бэкендами.

Все функции и операторы поиска PostgreSQL доступны через использование func для генерации специфических для PostgreSQL функций и Operators.bool_op() (boolean-typed версия Operators.op()) для генерации произвольных операторов, аналогично тому, как они доступны в предыдущих версиях. См. примеры в Полнотекстовый поиск.

Существующие проекты SQLAlchemy, использующие специфические для PG директивы в рамках Operators.match(), должны использовать непосредственно func.to_tsquery(). Чтобы отобразить SQL в том же виде, что и в версии 1.4, смотрите примечание к версии Простое сопоставление обычного текста с помощью match().

#7086

Вернуться на верх