[Перевод] Что нового в SQLAlchemy 2.0?

412920b7f3d2c1148882d71b98f73090

Эта статья является переводом статьи Мигеля Гринберга.

Возможно, вы слышали, что основная версия SQLAlchemy 2.0, была выпущена в январе 2023 года. Или, может быть, вы пропустили объявление и это новость для вас. В любом случае, я подумал, что вам будет интересно узнать, что в нем нового, стоит ли его обновлять и насколько сложно это сделать.

Как и в предыдущих обзорах программного обеспечения, это будет субъективный обзор. Я давно использую SQLAlchemy ORM в веб-проектах, поэтому в этой статье я расскажу о функциях, которые влияют на мою собственную работу, как в положительную, так и в отрицательную сторону. Если вместо этого вам интересно увидеть список всех изменений, внесенных в этот новый релиз, то официальный журнал изменений — это то что вам нужно.

Для нетерпеливых.

Если вы просто хотите посмотреть, как выглядит реальный проект, написанный для SQLAlchemy 2.0 и не хотите читать последующий анализ, вы можете перейти к моему проекту microblog-api, который я только что обновил, чтобы использовать последнюю версию. Возможности SQLAlchemy.

Никаких вам Query Object

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

Теперь «устаревший» способ выдачи запросов в SQLAlchemy ORM состоял в использовании объекта Query, доступного из метода Session.query (), или для тех, кто использует расширение Flask-SQLAlchemy для Flask, также как Model.query. Вот некоторые примеры:

# using native SQLAlchemy
user = session.query(User).filter(User.username == 'susan').first()

# using Flask-SQLAlchemy
user = User.query.filter(User.username == 'susan').first()

В версии 2.0 это считается «старым» способом выполнения запросов. Вы по-прежнему можете запускать запросы таким образом, но в документации этот интерфейс называется »1.x Query API» или «legacy Query API».

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

query = select(User).where(User.username == 'susan')

В этом примере запрос сохраняется в переменной query. На данный момент запрос еще не выполнен и даже не связан с сеансом.

Чтобы выполнить запрос, его можно передать методу execute() объекта session:

results = session.execute(query)

Возвращаемое значение из вызова execute() — это объект Result, который функционирует как итерируемый объект, возвращающий объекты Row с интерфейсом, аналогичным именованному кортежу. Если вы предпочитаете получать результаты, не повторяя их, есть несколько методов, которые можно вызвать для этого объекта.

Вот некоторые из них:

  • all() чтобы вернуть список с объектом строки для каждой строки результата.

  • first() чтобы вернуть первую строку результата.

  • one() чтобы вернуть первую строку результата и вызвать исключение, если результатов нет или их несколько.

  • one_or_none() чтобы вернуть первую строку результата или None если результатов нет, или вызвать исключение если есть более одного результата.

Работа с результатами в виде кортежей имеет смысл, когда каждая строка результата может содержать несколько значений, но когда в каждой строке (row) есть одно значение, может быть утомительно извлекать значения из одноэлементных кортежей.

Session имеет два дополнительных метода выполнения, которые делают работу со строками (row) с одним значением более удобной в использовании:

  • scalars() возвращает объект ScalarResult с первым значением каждой строки результата. Перечисленные выше методы Result также доступны для этого нового объекта результата.

  • scalar() возвращает первое значение первой строки результата.

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

user = session.scalar(query)

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

Менеджеры контекста Session

Меня очень радует появление менеджеров контекста для session, которые также были включены в версию 1.4, чтобы помочь разработчикам перейти на версию 2.0. Я всегда реализовывал свои собственные менеджеры контекста session и даже объяснял, как это сделать в видеоролике, выпущенном несколько лет назад.

В версии 1.3 и более ранних версиях session с заданной областью был основным шаблоном для работы с сеансами. Расширение Flask-SQLAlchemy, например, включило его в переменную db.session, которая является его сигнатурой. Честно говоря, мне никогда не нравились session с ограниченной областью действия, потому что они привязывают session к потоку, что совершенно произвольно. Во многих случаях срок жизни session намного короче, чем у потока, поэтому вам приходится прибегать к ручному управлению, чтобы все работало должным образом.

Теперь session можно инициировать с помощью менеджера контекста, поэтому начало и конец четко видны. Вот пример:

with Session() as session:
    session.add(user)
    session.commit()

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

Вариант этого шаблона можно использовать для session, который автоматически фиксируется в конце, но все еще откатывается при ошибках:

with Session() as session:
    with session.begin():
        session.add(user)

Typing подвезли.

Еще одно интересное изменение, представленное в версии 2.0 — это возможность использовать типизацию при вводе для объявления столбцов и связей в моделях. Рассмотрим следующее определение модели User:

class User(Model):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    username = Column(String(64), index=True, unique=True, nullable=False)
    email = Column(String(120), index=True, unique=True, nullable=False)
    password_hash = Column(String(128))
    about_me = Column(String(140))

В версии 2.0 тип столбца можно определить с помощью Mapped. Если есть какие-либо дополнительные параметры, их можно указать в вызове mapped_column().

import sqlalchemy as sa
import sqlalchemy.orm as so

class User(Model):
    __tablename__ = 'users'

    id: so.Mapped[int] = so.mapped_column(primary_key=True)
    username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, unique=True)
    email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, unique=True)
    password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(128))
    about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))

Взаимосвязи типизируются таким же образом. Вот пример:

class Post(Model):
    # ...
    author: so.Mapped['User'] = so.relationship(back_populates='posts')

Обратите внимание, что тип может быть задан в виде строки, что иногда необходимо, чтобы избежать прямых ссылок.

В целом это не сильно отличается, но использование типизации может дать некоторые преимущества:

  • Если вы используете IDE, которая выполняет статический анализ вашего кода и предлагает изменения по мере ввода, строго типизированная модель поможет вашей IDE лучше понять ваш код.

  • Связанная функция, представленная в версии 2.0 — это интеграция между моделями и классами данных, которая также опирается на типизацию.

  • Одно небольшое преимущество, которое я заметил, заключается в том, что из SQLAlchemy нужно импортировать меньше символов. Для столбцов, которые являются числами, датами, временем или даже UUID, теперь вы можете использовать типизацию для их определения без необходимости импортировать соответствующие классы типов из SQLAlchemy (к сожалению, класс String() по-прежнему необходим, поскольку для многих баз данных должна быть указана максимальная длина).

Как и в случае с запросами, по-прежнему поддерживается старый способ определения столбцов и связей.

Взаимосвязи только для записи (Write-only)

Если вы следовали моим руководствам по Flask, вы знаете, что я всегда рекомендовал параметр lazy='dynamic' для взаимосвязей, которые могут быть большими, так как это позволяет добавить разбиение на страницы, сортировку и фильтрацию до того, как SQLAlchemy пойдет и извлечет связанные объекты.

К сожалению, динамические взаимосвязи также считаются устаревшими в SQLAlchemy 2.0 поскольку они несовместимы с новым интерфейсом запросов. Вместо этого рекомендуемым решением является новый тип взаимосвязи под названием «Write-only». Вот как определить взаимосвязь только для записи:

class User(Model):
    # ...
    tokens: so.WriteOnlyMapped['Token'] = so.relationship(back_populates='user')

Так почему «write-only»? Отличие от старой динамической связи заключается в том, что связь только для записи не загружает (или не читает) связанные объекты, а предоставляет методы add() и remove() для внесения изменений (или записи) в них.

Как тогда получить связанные объекты? Связь предоставляет метод select(), который возвращает запрос, который вы можете выполнить в session, возможно, после добавления фильтров, сортировки или разбивки на страницы. Это менее автоматическое и определенно менее волшебное, чем lazy='dynamic', но оно поддерживает те же варианты использования.

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

tokens = session.scalars(user.tokens.select().order_by(Token.expiration)).all()

Если честно, мне потребовалось некоторое время, чтобы признать, что lazy='dynamic' уже в прошлом, потому что такой способ мне действительно очень нравится. Но я понимаю, что такой дизайн несовместим с новым интерфейсом запросов. Я изо всех сил пытался создать приложение с SQLAlchemy 1.4, которое не использовало динамические взаимосвязи и жаловался от себя и других пользователей на доске обсуждений SQLAlchemy, что привело к добавлению отношения write-only в 2.0.

Асинхронная поддержка.

Версия 1.4 представила бета-версию расширения asyncio, которое предоставляет асинхронные версии объектов Engine и Session с ожидаемыми методами. В версии 2.0 это расширение больше не считается бета-версией.

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

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

Заключение.

Я надеюсь, что это был полезный обзор того, что я считаю наиболее важными изменениями в SQLAlchemy 2.0. Репозиторий microblog-api содержит полный и нетривиальный проект API, основанный на Flask, с поддержкой базы данных, обеспечиваемой SQLAlchemy 2.0 и моим расширением Alchemical. Не стесняйтесь попробовать, если хотите увидеть новые функции SQLAlchemy в действии!

© Habrahabr.ru