SQLAlchemy ORM: пишем код без боли и страданий

SQLAlchemy ORM: пишем код без боли и страданий

Картинка к публикации: SQLAlchemy ORM: пишем код без боли и страданий

Первые шаги в SQLAlchemy ORM

SQLAlchemy ORM — это одна из тех технологий, после знакомства с которыми вы будете удивляться, почему до сих пор не начали использовать её в каждом проекте. ORM (Object-Relational Mapping) позволяет общаться с базой данных не на «сыром» SQL, а через удобные и интуитивно понятные классы и объекты языка Python. Забудьте о путанице в громоздких SQL-запросах — теперь можно писать понятный, читаемый код, от которого не будет стыдно даже перед самым придирчивым тимлидом.

Первым шагом станет установка библиотеки SQLAlchemy. Лучше всего использовать менеджер пакетов pip:

pip install sqlalchemy

После успешной установки можно переходить к настройке подключения к вашей базе данных. SQLAlchemy поддерживает различные базы данных: PostgreSQL, SQLite, MySQL и другие. Рассмотрим пример простого подключения к SQLite, идеальному варианту для тестов и локальной разработки:

# session.py
from sqlalchemy import create_engine

engine = create_engine("sqlite:///mydatabase.db")

Теперь ваш проект готов к использованию ORM.

Для работы с данными вам нужно описать модель — это класс, который представляет таблицу в базе данных. Например, создадим простейшую модель пользователя:

# base_model.py
from datetime import datetime
from uuid import UUID, uuid4

from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID as UUIDType
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from session import engine


# определим базовый класс от которого будем наследовать все модели
class Base(DeclarativeBase):
    __abstract__ = True
    id: Mapped[UUID] = mapped_column(UUIDType(as_uuid=True), primary_key=True, default=uuid4, index=True, nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
    
    # чтоб не писать имя таблицы в классах, определим метод
    @declared_attr
    def __tablename__(cls) -> str:
        # CamelCase → snake_case
        name = cls.__name__[0].lower() + cls.__name__[1:]
        name = ''.join(['_' + c.lower() if c.isupper() else c for c in name]).lstrip('_')

        # Множественное число
        if name.endswith(('s', 'x', 'z', 'ch', 'sh')):
            return name + 'es'
        elif name.endswith('y'):
            # Заменяем 'y' на 'ies' (company → companies)
            return name[:-1] + 'ies'
        else:
            # Добавляем 's' только если нет окончания 's'
            return name if name.endswith('s') else name + 's'


# добавим, временно, модель пользователя (удалим при переносе в другой файл)
class User(Base):
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

Здесь мы определили базовый класс, от которого будем наследоваться, и класс User с тремя полями в таблице users: идентификатором, временем создания, именем пользователя и email.

Следующий шаг — создание таблиц в самой базе данных на основе ваших моделей. SQLAlchemy позволяет это сделать автоматически:

Base.metadata.create_all(engine)

Теперь, если вы заглянете в файл базы данных (mydatabase.db), то обнаружите, что там появились нужные таблицы.

Когда модель готова, пора использовать её на практике. Создадим первый объект и сохраним его в базе:

# test.py
from sqlalchemy.orm import sessionmaker
from base_model import User
from session import engine

Session = sessionmaker(bind=engine)
session = Session()

new_user = User(username='ivan_petrov', email='ivan@example.com')
session.add(new_user)
session.commit()

Вот так, буквально за пару минут, вы совершили первую CRUD-операцию без написания ни единой строки SQL-кода.

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

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

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

Модели данных и основы взаимодействия

Разобравшись в базовых настройках и убедившись, что SQLAlchemy — это не страшная магия, которая крадёт ваш сон и нервы, самое время перейти к созданию полноценных моделей данных. Грамотно спроектированные модели — это фундамент, на котором будет держаться всё ваше приложение. Сделаете хорошо — будете спать спокойно, допустите ошибки — узнаете, что такое «ночной деплой» на собственном опыте.

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

# user_model.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from base_model import Base

class User(Base):
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

Что мы здесь сделали? Создали класс User, унаследованный от базового класса Base. Поле id является первичным ключом и обязательно для каждой таблицы, чтобы записи можно было чётко идентифицировать, его мы определили в абстрактном базовом классе. С полями username и email также всё очевидно: оба должны быть уникальными и не могут быть пустыми. Если вы не хотите потом отлавливать ошибки уникальности на продакшене, не пренебрегайте параметром unique=True.

Типы данных — это отдельный разговор. Казалось бы, всё просто: есть целые числа, строки, даты. Но на практике стоит учитывать несколько простых правил. Для идентификаторов (id) всегда используйте тип Integer или UUID. Если ваши поля будут содержать текст ограниченной длины, используйте String с указанием максимальной длины, иначе рискуете получить неэффективную структуру таблицы и лишние накладные расходы. Большие тексты (например, комментарии или описания) размещайте в Text. Не путайте их с обычным String, иначе вскоре ваши пользователи начнут жаловаться на лимиты длины.

Ключи — первичные и внешние — основа реляционной структуры данных. Если первичный ключ идентифицирует запись, то внешний ключ устанавливает связь между таблицами. Например, у пользователя могут быть статьи — это типичная связь один-ко-многим (one-to-many). Давайте рассмотрим её реализацию:

# article_model.py
from typing import TYPE_CHECKING
from uuid import UUID

from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship

from base_model import Base

if TYPE_CHECKING:
    from user_model import User


class Article(Base):
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)

    user_id: Mapped[UUID] = mapped_column(ForeignKey('users.id'), nullable=False)
    author: Mapped['User'] = relationship('User', foreign_keys=[user_id], back_populates='articles')


# Не забудьте добавить обратную связь в модель User
class User(Base):
    ...
    articles: Mapped[List["Article"]] = relationship(Article, back_populates='author')

Что здесь происходит? Поле user_id ссылается на таблицу пользователей, обеспечивая связь между статьями и их авторами. С помощью relationship мы устанавливаем объектную связь между двумя моделями, позволяя удобно обращаться к статьям пользователя и наоборот. Не пренебрегайте указанием back_populates — это облегчит жизнь вам и вашей команде, сохранив чёткость и прозрачность связей между моделями.

Связь многие-ко-многим (many-to-many) требует промежуточной таблицы. Представьте, что у нас есть статьи и теги, при этом статья может иметь несколько тегов, а каждый тег может принадлежать многим статьям. Реализация такой связи выглядит следующим образом:

# tag_model.py
from typing import TYPE_CHECKING, List

from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy.dialects.postgresql import UUID as UUIDType
from sqlalchemy.orm import Mapped, mapped_column, relationship

from base_model import Base

if TYPE_CHECKING:
    from article_model import Article


article_tag = Table(
    'article_tag',
    Base.metadata,
    Column('article_id', UUIDType(as_uuid=True), ForeignKey('articles.id'), primary_key=True, nullable=False),
    Column('tag_id', UUIDType(as_uuid=True), ForeignKey('tags.id'), primary_key=True, nullable=False)
)


class Tag(Base):
    name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)

    articles: Mapped[List["Article"]] = relationship('Article', secondary=article_tag, back_populates='tags')
    

# В классе Article добавляем обратную связь
class Article(Base):
    ...
    tags: Mapped[List["Tag"]] = relationship('Tag', secondary=article_tag, back_populates='articles')

Не забывайте, что промежуточная таблица (article_tag) — это простой и удобный способ управлять связью many-to-many без лишних сложностей и головной боли. Однако, избегайте перегрузки такими связями — избыточная сложность в структурах данных рано или поздно превратится в адские муки при отладке.

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

Первая распространённая ошибка — игнорирование ограничения уникальности и обязательности полей. Если не указать nullable=False или unique=True там, где это необходимо, база начнёт наполняться мусорными и дублирующимися данными. И вы будете очень неприятно удивлены, когда вечером в пятницу вам сообщат, что базу нужно срочно «почистить вручную».

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

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

Если вы будете следовать этим простым рекомендациям, создание моделей и проектирование базы данных будет для вас простым и даже приятным занятием, а ваши коллеги и пользователи скажут вам спасибо. Ну а если проигнорируете — что ж, готовьтесь к регулярным «приятным» ночным сюрпризам. Решать, конечно же, вам.

CRUD-операции и запросы

Когда вы уже умеете создавать модели данных и не пугаетесь выражения «внешний ключ», самое время освоить CRUD-операции. CRUD (Create, Read, Update, Delete) — это фундаментальный набор действий, с которым вы будете сталкиваться ежедневно. Хорошая новость: в SQLAlchemy эти действия выглядят логично и понятно, без необходимости устраивать регулярные ритуалы с бубном и жертвоприношениями серверу базы данных.

Начнём с простого: создания (Create). Допустим, нам нужно добавить нового пользователя. Всё, что для этого потребуется, — создать объект модели и сохранить его в базу через сессию:

from sqlalchemy.orm import sessionmaker
from base_model import Base
from user_model import User
from session import engine

Session = sessionmaker(bind=engine)
session = Session()

Base.metadata.create_all(engine)

# Создание нового пользователя
new_user = User(username='admin', email='admin@example.com')
session.add(new_user)
session.commit()

Главное — не забыть вызвать session.commit(), иначе ваш пользователь так и останется в подвешенном состоянии, где-то между оперативной памятью и вечностью.

Чтение данных (Read) не менее просто. Если хотите извлечь всех пользователей из базы, используйте:

# Получение всех пользователей
users = session.query(User).all()

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

# Получение одного пользователя по условию
user = session.query(User).filter_by(username='admin').first()

Обратите внимание: метод .first() возвращает первый найденный результат или None, если пользователь не найден. Если уверены, что результат обязательно должен быть, используйте .one(), но готовьтесь ловить исключения в случае, если результат пустой или не единственный.

Обновление данных (Update) также элементарно. Хотите сменить email пользователю? Без проблем:

# Обновляем email пользователя
user = session.query(User).filter_by(username='admin').first()
if user:
    user.email = 'new_admin@example.com'
    session.commit()

Здесь важно помнить, что SQLAlchemy отслеживает состояние объектов автоматически. Если объект был изменён, при вызове commit() изменения попадут в базу данных.

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

# Удаление пользователя
user = session.query(User).filter_by(username='admin').first()
if user:
    session.delete(user)
    session.commit()

Но CRUD — это не только простейшие операции. Реальные приложения требуют более сложных запросов: сортировки, пагинации и даже соединений между таблицами. Начнём с сортировки. Допустим, вы хотите получить пользователей в алфавитном порядке:

# Сортировка пользователей по имени в алфавитном порядке
users = session.query(User).order_by(User.username.asc()).all()

Или наоборот, в обратном порядке:

# Сортировка пользователей по убыванию id, если оно int
users = session.query(User).order_by(User.id.desc()).all()

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

# Создание пользователей
for i in range(20):
    new_user = User(username=f'admin_{i}', email=f'admin_{i}@example.com')
    session.add(new_user)

session.commit()

# Пагинация запросов
page = 2
page_size = 10
users = session.query(User).limit(page_size).offset((page - 1) * page_size).all()

Теперь пользователи будут приходить аккуратными порциями, и сервер скажет вам «спасибо».

Соединения таблиц (joins) — вещь полезная и абсолютно необходимая. Например, нужно получить все статьи конкретного пользователя. Вместо сложных конструкций SQL, которые обычно вызывают лёгкий нервный тик у начинающих разработчиков, вы легко и изящно делаете join с помощью SQLAlchemy:

# Создание статей
user = session.query(User).filter_by(username='admin').first()

for i in range(20):
    article = Article(title=f'title_{i}', content=f'content_{i}', user_id=user.id)
    session.add(article)
session.commit()

# Получение всех статей определённого пользователя
articles = session.query(Article).join(User).filter(User.username == 'admin').all()

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

# Запрос статей с именами авторов
results = session.query(Article.title, User.username).join(User).all()

for title, username in results:
    print(f'Автор {username} написал статью "{title}"')

Это не просто выглядит красиво — такой подход экономит ресурсы и минимизирует количество обращений к базе.

Теперь коротко о типичных ошибках, чтобы не повторять их в своём проекте:

Во-первых, избегайте лишних запросов (так называемый N+1 запрос). SQLAlchemy позволяет делать предзагрузку связанных объектов (joinedload и selectinload). Используйте их, чтобы избежать ситуации, когда вы загружаете пользователя, а затем отдельными запросами вытаскиваете каждую его статью:

from sqlalchemy.orm import joinedload

# Правильная загрузка пользователя вместе со статьями
user = session.query(User).options(joinedload(User.articles)).filter_by(username='admin').first()

Во-вторых, всегда проверяйте существование объекта перед обновлением или удалением. Запросы типа .first() и .one() очень удобны, но если объект не найден, вы получите ошибку, от которой не спасёт даже крепкий кофе.

И последнее: старайтесь как можно реже вызывать commit(). Каждое обращение к базе стоит времени, поэтому группируйте операции, когда это возможно, но не забывайте о транзакциях. Подробнее об этом поговорим далее.

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

Продвинутые отношения

До этого момента мы рассматривали основы проектирования моделей и простейшие связи. Настало время углубиться в детали и разобраться с продвинутыми отношениями между таблицами. Правильно спроектированные связи экономят нервы и время, неправильно — приводят к бессонным ночам и багам, которые пользователи почему-то воспринимают как «фичи».

Начнём со связи один-к-одному (one-to-one). Она редко используется в чистом виде, однако порой без неё не обойтись. Представьте, что у каждого пользователя должна быть ровно одна персональная настройка профиля. Реализовать это проще простого:

# profile_model.py

from typing import TYPE_CHECKING
from uuid import UUID

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from base_model import Base

if TYPE_CHECKING:
    from user_model import User


class Profile(Base):
    bio = mapped_column(String(250))

    user_id: Mapped[UUID] = mapped_column(ForeignKey('users.id'), unique=True)
    user: Mapped['User'] = relationship('User', back_populates='profile')


# Обратная связь в User
class User(Base):
	...
    profile: Mapped['Profile'] = relationship('Profile', uselist=False, back_populates='user')

Главная особенность здесь — параметр uselist=False. Он явно говорит SQLAlchemy, что связь будет одиночной, иначе вместо одного профиля вы получите список с одним элементом, а это уже не так удобно.

В чём типичная ошибка новичков? Они забывают указать ограничение уникальности на внешний ключ (unique=True). В итоге вместо чёткой связи один-к-одному получают связи один-ко-многим и удивляются, откуда взялось столько дублирующихся профилей. Не повторяйте эту ошибку — всегда явно указывайте уникальность.

Далее поговорим о связи один-ко-многим (one-to-many). С ней вы уже встречались ранее, но сейчас рассмотрим несколько нюансов. Возьмём уже знакомый пример пользователя и его статей, но добавим немного «магии» каскадного удаления:

class User(Base):
	...
    articles: Mapped[List['Article']] = relationship(Article, back_populates='author', cascade='all, delete-orphan')


class Article(Base):    
	...
    author: Mapped['User'] = relationship('User', back_populates='articles')

Каскадное удаление (cascade='all, delete-orphan') означает, что если вы удалите пользователя, то все его статьи также автоматически будут удалены. Это удобно, но помните: такая мощь накладывает ответственность. Прежде чем использовать каскадные операции на продакшене, трижды подумайте — удалённые данные не вернёшь, даже если очень попросить.

На практике часто упускают важный момент: ленивую загрузку (lazy loading). По умолчанию связанные объекты загружаются только при обращении к ним. Это звучит неплохо, но приводит к известной проблеме N+1 запросов, когда вы невольно загружаете дополнительные данные сотнями мелких запросов. Решается это предварительной загрузкой через joinedload или selectinload:

from sqlalchemy.orm import selectinload

# Предварительная загрузка статей для всех пользователей
users = session.query(User).options(selectinload(User.articles)).all()

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

Связь многие-ко-многим (many-to-many) заслуживает особого внимания. Она чаще всего используется в случаях, когда объекты двух таблиц могут относиться друг к другу взаимно. Например, уже знакомые нам статьи и теги:

article_tag = Table(
    'article_tag',
    Base.metadata,
    Column('article_id', UUIDType(as_uuid=True), ForeignKey('articles.id'), primary_key=True, nullable=False),
    Column('tag_id', UUIDType(as_uuid=True), ForeignKey('tags.id'), primary_key=True, nullable=False)
)


class Article(Base):
	...
    tags: Mapped[List["Tag"]] = relationship('Tag', secondary=article_tag, back_populates='articles')


class Tag(Base):
	...
    articles: Mapped[List["Article"]] = relationship('Article', secondary=article_tag, back_populates='tags')

Здесь важно понимать, что промежуточная таблица article_tag не является отдельной моделью, а просто связующей таблицей для установления взаимных отношений. Типичная ошибка при создании таких связей — это желание добавить в связующую таблицу дополнительные поля, такие как «дата присвоения тега». В таком случае её уже стоит превращать в полноценную модель.

Вот как это выглядит правильно с дополнительными полями:

from datetime import datetime
from typing import List
from uuid import UUID

from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from base_model import Base


class ArticleTag(Base):
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
    note: Mapped[String] = mapped_column(Text)

    article_id: Mapped[UUID] = mapped_column(ForeignKey('articles.id'), primary_key=True)
    article: Mapped["Article"] = relationship('Article', back_populates='article_tags')

    tag_id: Mapped[UUID] = mapped_column(ForeignKey('tags.id'), primary_key=True)
    tag: Mapped["Tag"] = relationship('Tag', back_populates='tag_articles')


class Article(Base):
    title: Mapped[String] = mapped_column(String(200))

    article_tags: Mapped[List["ArticleTag"]] = relationship('ArticleTag', back_populates='article')

    # Для удобного доступа к тегам можно добавить свойство:
    @property
    def tags(self) -> List["Tag"]:
        return [assoc.tag for assoc in self.article_tags]


class Tag(Base):
    name: Mapped[String] = mapped_column(String(50), unique=True)

    tag_articles: Mapped[List["ArticleTag"]] = relationship('ArticleTag', back_populates='tag')

    @property
    def articles(self) -> List["Article"]:
        return [assoc.article for assoc in self.tag_articles]

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

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

Используя эти рекомендации, вы сможете уверенно проектировать сложные и эффективные реляционные структуры данных, сохраняя ясность и здравый смысл даже в самых запутанных ситуациях. И никакие внезапные «фичи» на продакшене вас больше не побеспокоят.

Транзакции, сессии и контроль состояния

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

Сессия в SQLAlchemy — это не просто «соединение с базой данных», а продвинутый менеджер состояний объектов. Она отслеживает изменения, отвечает за кэширование и даже управляет транзакциями за вас, если вы её правильно используете. И наоборот, неправильно организованная работа с сессией приведёт к сюрпризам в виде непредсказуемых багов и бессонных ночей за чашкой уже холодного кофе.

Начнём с самого простого, но важного момента — инициализации сессий. Многие новички создают сессии напрямую через session = Session(), не задумываясь о последствиях. На самом деле, SQLAlchemy предлагает удобный паттерн управления сессиями через sessionmaker и контекстные менеджеры:

from sqlalchemy.orm import sessionmaker

SessionLocal = sessionmaker(bind=engine)

# Правильный способ использовать сессии через контекстный менеджер
with SessionLocal() as session:
    user = session.query(User).first()
    user.username = 'new_username'
    session.commit()

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

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

with SessionLocal() as session:
    try:
        user = User(username='testuser', email='testuser@example.com')
        session.add(user)
        session.flush()  # отправляем изменения в базу, но транзакция не завершена

        article = Article(title='Test Article', content='Test Content', author=user)
        session.add(article)
        session.commit()  # всё прошло гладко, изменения применены
    except Exception as e:
        session.rollback()  # произошла ошибка, откатываем транзакцию
        print(f"Ошибка: {e}")

Здесь стоит обратить внимание на вызов flush(): он отправляет изменения в базу данных, но транзакция остаётся открытой до вызова commit(). Это полезно, если вам нужно заранее получить идентификатор нового объекта до завершения транзакции.

Следующий важный аспект — это состояние объектов (object states). В SQLAlchemy ORM каждый объект находится в одном из состояний: transient (временный, не привязанный к сессии), pending (ожидающий сохранения), persistent (сохранённый и управляемый сессией) и detached (отсоединённый от сессии). От понимания этих состояний зависит ваша способность избегать типичных ошибок. Рассмотрим на примере:

# объект transient (ещё не привязан к сессии)
new_user = User(username='ghost', email='ghost@example.com')

with SessionLocal() as session:
    # объект становится pending после добавления в сессию
    session.add(new_user)
    session.flush()

    # объект persistent после коммита
    session.commit()

# после закрытия сессии объект становится detached
print(new_user.username)
print(new_user.id)

Главная ошибка новичков — попытка использовать связанные объекты после того, как сессия уже закрыта, что вызывает знаменитую ошибку DetachedInstanceError. Чтобы этого избежать, используйте методы предварительной загрузки (joinedload, selectinload) или явно извлекайте нужные данные до закрытия сессии.

Перейдём к производительности и оптимизации запросов, без которых немыслим ни один серьёзный проект. Важнейший принцип здесь — минимизация количества обращений к базе данных и выбор правильной стратегии загрузки данных. Рассмотрим простой пример неоптимального запроса:

# Пример запроса, порождающего проблему N+1
with SessionLocal() as session:
    users = session.query(User).all()
    for user in users:
        print(user.profile.bio)  # для каждого пользователя делается отдельный запрос к базе

Звучит безобидно, пока вы не обнаружите, что на миллион пользователей у вас миллион дополнительных запросов. Решение простое и элегантное — используем предварительную загрузку:

from sqlalchemy.orm import joinedload

with SessionLocal() as session:
    users = session.query(User).options(joinedload(User.profile)).all()
    for user in users:
        print(user.profile.bio)  # данные загружены сразу одним запросом

Это не просто мелочь, а настоящая экономия ресурсов и нервов. К счастью, SQLAlchemy предоставляет целый арсенал инструментов (joinedload, selectinload, subqueryload), позволяющих управлять стратегией загрузки данных в зависимости от ситуации.

Ещё один полезный подход к оптимизации — это массовые операции (bulk operations). Когда вам нужно добавить или изменить сразу много объектов, стандартный подход с вызовом add() в цикле становится медленным. Лучше использовать bulk_save_objects():

with SessionLocal() as session:
    new_users = [User(username=f'user{i}', email=f'user{i}@example.com') for i in range(1000)]
    session.bulk_save_objects(new_users)
    session.commit()

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

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

Миграции баз данных с Alembic

Наступает момент, когда даже идеально спроектированные модели данных требуют изменений. Появляются новые поля, отношения усложняются, а старые решения оказываются не такими уж и удобными. Конечно, можно попробовать править базу вручную, но рано или поздно вы столкнётесь с ситуацией, когда на продакшене одно, у вас в локальной разработке другое, а на тестовом окружении вообще третье. Именно здесь на помощь приходят миграции, и именно сейчас вам стоит подружиться с Alembic — лучшим другом (иногда врагом, но это уже отдельная история) всех разработчиков, использующих SQLAlchemy.

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

Начнём, как всегда, с установки Alembic:

pip install alembic psycopg2-binary

После этого инициализируйте проект командой:

alembic init migrations

Эта команда создаст структуру каталогов и файл alembic.ini, который вам предстоит полюбить и регулярно открывать для настройки подключения к БД. После установки нужно указать подключение к базе данных в файле alembic.ini:

sqlalchemy.url = sqlite:///mydatabase.db

Затем настройте базовый путь моделей и подключение в файле env.py:

from base_model import Base

target_metadata = Base.metadata

Это позволит Alembic автоматически видеть ваши модели и понимать, какие именно изменения произошли.

Теперь можно создать первую миграцию, используя команду:

alembic revision --autogenerate -m "initial migration"

# и применим миграцию к базе данных
alembic upgrade head

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

Допустим, у вас появилась необходимость добавить новое поле в модель пользователя. Добавляем поле в модель:

class User(Base):
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

    articles: Mapped[List['Article']] = relationship(Article, back_populates='author', cascade='all, delete-orphan')
    profile: Mapped['Profile'] = relationship('Profile', uselist=False, back_populates='user')
    age: Mapped[int] = mapped_column(Integer)  # новое поле

Теперь нужно создать миграцию. Выполняем команду:

alembic revision --autogenerate -m "Add age column to users"

Alembic создаст файл миграции примерно такого содержания:

def upgrade():
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))

def downgrade():
    op.drop_column('users', 'age')

Миграции Alembic всегда содержат две функции: upgrade() и downgrade(). upgrade() применяет изменения, а downgrade() — откатывает их обратно. Если вы забыли про downgrade, когда-то он обязательно напомнит о себе ночью, на продакшене.

Применить миграцию легко:

alembic upgrade head

Откатить миграцию обратно тоже элементарно:

alembic downgrade -1

Но не стоит забывать, что откат миграций на продакшене — мероприятие рискованное и не всегда безопасное, поэтому всегда проверяйте и тестируйте миграции в локальной среде перед деплоем.

Что насчёт сложных миграций, например, добавления ограничений, уникальных индексов или новых таблиц с внешними ключами? Alembic справляется и с этим:

def upgrade():
    op.create_table(
        'comments',
        sa.Column('id', sa.Integer(), primary_key=True),
        sa.Column('content', sa.Text(), nullable=False),
        sa.Column('article_id', sa.Integer(), sa.ForeignKey('articles.id', ondelete='CASCADE')),
    )
    op.create_index('ix_comments_article_id', 'comments', ['article_id'])

def downgrade():
    op.drop_table('comments')

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

Теперь о том, почему миграции стоит любить, даже если они периодически снятся в кошмарах. Представьте, что ваше приложение выросло, пользователей стало много, и одна маленькая миграция с неправильным индексом или неэффективным запросом может превратить ваши быстрые запросы в медленные, а сервер — в тыкву. Регулярное создание небольших, контролируемых миграций позволяет избегать «больших взрывов», когда одной миграцией вы пытаетесь поменять сразу половину схемы базы.

И последнее, но важное: никогда не редактируйте вручную таблицу alembic_version, а также избегайте изменения уже применённых миграций. Если миграция уже была применена на продакшене, а вы решили её изменить, поздравляю — теперь вас ждёт незабываемый вечер с восстановлением базы из бекапов, и далеко не факт, что вам понравится этот опыт.

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

Заключение

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

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

Помните, хороший код — это код, который легко читать, поддерживать и масштабировать. SQLAlchemy ORM — ваш верный помощник в достижении именно такого результата. Используйте его грамотно, проектируйте базы данных с умом, и тогда никакие неожиданные баги и страшные «фичи» не потревожат ваш спокойный сон. И кто знает, возможно, скоро вы будете вспоминать о временах, когда приходилось писать чистые SQL-запросы, с улыбкой и лёгкой грустью, удивляясь, как же вы вообще справлялись без этого прекрасного инструмента.


Читайте также:

ChatGPT
Eva
💫 Eva assistant

Выберите способ входа