Перейти к содержимому

Лекция 9. ORM и миграции (SQLAlchemy, Alembic)

Раздел 3. Работа с данными в backend-приложениях. Длительность: ~1 час 30 минут

План лекции

  1. Что такое ORM, плюсы и минусы vs сырой SQL.
  2. SQLAlchemy: engine, session, модели, типы и связи.
  3. CRUD через ORM и единица работы (Unit of Work).
  4. Интеграция с FastAPI (сессия через Depends).
  5. Миграции схемы и Alembic.

1. Что такое ORM

ORM (Object-Relational Mapping) — технология, которая связывает таблицы реляционной БД с классами в коде, а строки таблиц — с объектами. Вместо строк SQL разработчик работает с обычными Python-объектами, а ORM сама генерирует и выполняет нужный SQL.

Реляционная БДОбъектная модель (Python)
ТаблицаКласс (модель)
СтрокаЭкземпляр класса (объект)
СтолбецАтрибут объекта
Внешний ключСсылка на другой объект

Плюсы ORM:

  • Меньше шаблонного кода — не нужно вручную писать однотипные SELECT/INSERT.
  • Работа на языке приложения: данные — объекты, а не кортежи строк.
  • Защита от SQL-инъекций: значения подставляются как параметры автоматически.
  • Переносимость между СУБД (SQLite, PostgreSQL, MySQL) — меняется лишь URL подключения.
  • Удобные связи: переход по внешнему ключу выглядит как user.orders, а не ручной JOIN.

Минусы ORM:

  • Накладные расходы: дополнительный слой абстракции медленнее «голого» SQL.
  • Скрытая стоимость запросов: легко получить неэффективный SQL (проблема N+1 запросов).
  • Кривая обучения: нужно понимать и ORM, и SQL «под капотом».
  • Сложные аналитические запросы порой проще и понятнее на чистом SQL.

Вывод: ORM удобна для типовой CRUD-логики, но знание SQL она не отменяет.


2. SQLAlchemy: подключение

SQLAlchemy — самая популярная ORM в Python. Установка: pip install sqlalchemy.

Engine — точка подключения

Engine управляет подключением к базе и пулом соединений. Создаётся один раз на приложение.

from sqlalchemy import create_engine
# SQLite (файл на диске)
engine = create_engine("sqlite:///./app.db", echo=True)
# PostgreSQL
# engine = create_engine("postgresql+psycopg2://user:pass@localhost:5432/mydb",
# pool_size=5, max_overflow=10)

URL имеет формат диалект+драйвер://пользователь:пароль@хост:порт/база. echo=True выводит в лог все SQL-запросы — удобно при отладке. Параметры pool_size и max_overflow настраивают пул переиспользуемых соединений (открывать новое на каждый запрос дорого).

Session — рабочая сессия

Session — «рабочее пространство» для операций в рамках одной задачи. Создаётся фабрикой:

from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = SessionLocal() # конкретная сессия
# ... работа с данными ...
db.close()

3. Модели (declarative)

В декларативном стиле модель — это класс-наследник общего Base. Атрибуты класса описывают столбцы таблицы.

from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Numeric
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)

Base хранит реестр всех моделей (метаданные) — по нему SQLAlchemy и Alembic узнают, какие таблицы должны существовать.

Основные типы столбцов: Integer, String(n) (VARCHAR), Text, Boolean, Numeric(p, s) (точные числа, деньги), DateTime.

Параметры столбца: primary_key=True, nullable=False (NOT NULL), unique=True, index=True (индекс для ускорения поиска), default=... (значение по умолчанию).

На раннем этапе таблицы можно создать прямо из моделей (на проде нужны миграции):

Base.metadata.create_all(bind=engine)

Связи: ForeignKey и relationship

Связь строится на внешнем ключе (ForeignKey), а relationship добавляет удобную навигацию.

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True, nullable=False)
# Один пользователь -> много заказов
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
amount = Column(Numeric(10, 2), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="orders")
  • ForeignKey("users.id") — уровень БД: столбец orders.user_id ссылается на users.id.
  • relationship(...) — уровень ORM: позволяет писать user.orders и order.user без JOIN.
  • back_populates связывает две стороны отношения, синхронизируя их.
  • cascade="all, delete-orphan" — при удалении пользователя удалятся и его заказы.

Это связь один-ко-многим — самая распространённая. Для многие-ко-многим нужна промежуточная (ассоциативная) таблица.


4. CRUD через ORM

CRUD = Create, Read, Update, Delete. Все операции идут через сессию.

db = SessionLocal()
# CREATE
user = User(email="anna@example.com", name="Анна")
db.add(user) # пометить для вставки
db.commit() # зафиксировать транзакцию (INSERT)
db.refresh(user) # подтянуть сгенерированный id
# READ
users = db.query(User).all() # все записи
user = db.query(User).get(1) # по первичному ключу
user = db.query(User).filter(User.email == "anna@example.com").first()
active = (db.query(User)
.filter(User.is_active == True)
.order_by(User.created_at.desc())
.limit(10).all())
total = db.query(User).count() # COUNT
# UPDATE
user.name = "Анна Иванова" # меняем атрибут объекта
db.commit() # ORM сам сгенерирует UPDATE
# массовое обновление одним запросом:
db.query(User).filter(User.is_active == False).update({"is_active": True})
db.commit()
# DELETE
db.delete(user)
db.commit()

.filter() принимает выражения сравнения по столбцам модели и превращает их в безопасный параметризованный SQL — конкатенации строк нет, инъекции невозможны.

Сессия и единица работы (Unit of Work)

Сессия реализует паттерн Unit of Work: накапливает изменения объектов в памяти и применяет их к БД одной транзакцией при commit().

Состояния объекта: transient (создан, не добавлен) → pending (после add()) → persistent (после commit/flush, связан со строкой БД) → detached (сессия закрыта).

Ключевые методы:

  • flush() — отправить SQL в БД, но не завершать транзакцию.
  • commit() — зафиксировать транзакцию (включает flush).
  • rollback() — откатить изменения с момента последнего commit.
  • close() — закрыть сессию, вернуть соединение в пул.

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

db = SessionLocal()
try:
user = User(email="bob@example.com", name="Боб")
db.add(user)
db.flush() # получить user.id, ещё не коммитя
order = Order(amount=999.99, user_id=user.id)
db.add(order)
db.commit() # обе вставки фиксируются вместе
except Exception:
db.rollback() # при ошибке откатываем всё
raise
finally:
db.close()

5. Интеграция с FastAPI

Сессия выдаётся эндпоинтам через зависимость-генератор Depends: она открывается перед запросом и гарантированно закрывается после — даже при ошибке.

from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
app = FastAPI()
def get_db(): # одна сессия на один HTTP-запрос
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
@app.post("/users")
def create_user(email: str, name: str, db: Session = Depends(get_db)):
user = User(email=email, name=name)
db.add(user)
db.commit()
db.refresh(user)
return user
@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return user

Зачем так: каждый запрос получает свою сессию (изоляция), закрытие происходит автоматически в finally, а зависимость легко подменить в тестах на тестовую БД.


6. Зачем нужны миграции схемы

Модели в коде меняются: добавляются столбцы, таблицы, индексы. Но у рабочей БД есть данные, её нельзя просто пересоздать. Подход «удалить таблицы и снова вызвать create_all» потеряет данные. Нужен способ эволюционно менять схему. Эту задачу решают миграции.

Миграция — версионированный скрипт изменения схемы. Преимущества:

  • История изменений схемы хранится в репозитории рядом с кодом.
  • Воспроизводимость: все разработчики и серверы приходят к одинаковой схеме.
  • Откат (downgrade) к предыдущей версии.
  • Изменения схемы проходят review как обычный код.

7. Alembic

Alembic — официальный инструмент миграций от автора SQLAlchemy. Он сравнивает модели с текущей схемой БД и автоматически генерирует скрипты изменений.

Инициализация

Окно терминала
pip install alembic
alembic init alembic # создаёт каталог alembic/ и файл alembic.ini

В alembic.ini указывается строка подключения, а в alembic/env.py — метаданные моделей (нужно для autogenerate):

# alembic.ini: sqlalchemy.url = sqlite:///./app.db
# alembic/env.py:
from myapp.models import Base
target_metadata = Base.metadata

Автогенерация миграции

Окно терминала
alembic revision --autogenerate -m "create users and orders tables"

Появится файл в alembic/versions/. Сгенерированный скрипт нужно просматривать: autogenerate не всегда улавливает переименования и сложные изменения.

"""create users and orders tables"""
from alembic import op
import sqlalchemy as sa
revision = "a1b2c3d4e5f6" # идентификатор этой версии
down_revision = None # предыдущая миграция (здесь её нет)
def upgrade():
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
# ... аналогично op.create_table("orders", ...) с ForeignKeyConstraint
def downgrade():
op.drop_table("orders")
op.drop_table("users")

Каждая миграция содержит revision (id версии), down_revision (ссылка на предыдущую — так выстраивается цепочка), upgrade() (применить) и downgrade() (откатить).

Применение, откат и просмотр

Окно терминала
alembic upgrade head # применить все миграции до самой свежей
alembic upgrade a1b2c3 # применить до конкретной версии
alembic downgrade -1 # откатить одну миграцию назад
alembic downgrade base # откатить все
alembic current # текущая версия БД
alembic history # вся цепочка миграций

Текущую версию схемы Alembic хранит в служебной таблице alembic_version внутри самой БД, поэтому всегда «знает», что уже применено.

Пример: добавление столбца

Изменили модель — добавили User.is_active, генерируем новую миграцию:

revision = "b2c3d4e5f6a7"
down_revision = "a1b2c3d4e5f6" # ссылка на предыдущую миграцию
def upgrade():
op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=True))
op.execute("UPDATE users SET is_active = TRUE WHERE is_active IS NULL")
def downgrade():
op.drop_column("users", "is_active")

При добавлении NOT NULL-столбца к таблице с данными сначала добавляют его как nullable, заполняют значения и лишь потом делают обязательным.


Краткие итоги

  • ORM связывает таблицы с классами, строки — с объектами; избавляет от рутинного SQL и защищает от инъекций, но добавляет накладные расходы и требует знания SQL.
  • SQLAlchemy: engine — подключение и пул, Session — рабочее пространство операций, модели описываются декларативно как наследники Base.
  • Связи: ForeignKey (уровень БД) + relationship (навигация в ORM).
  • CRUD идёт через сессию: add+commit, query/filter, изменение атрибута+commit, delete.
  • Сессия реализует Unit of Work: накапливает изменения и применяет одной транзакцией; commit/rollback дают атомарность.
  • В FastAPI сессия подаётся через Depends(get_db) — по одной на запрос, с закрытием.
  • Миграции меняют схему рабочей БД без потери данных. Alembic генерирует скрипты (autogenerate), версионирует их и умеет upgrade/downgrade.

Вопросы для самопроверки

  1. Что такое ORM и какое соответствие она устанавливает между БД и объектной моделью?
  2. Назовите два преимущества и два недостатка ORM по сравнению с сырым SQL.
  3. За что отвечают engine и Session в SQLAlchemy? Чем они отличаются?
  4. Как задать столбец, который является первичным ключом и уникален?
  5. В чём разница между ForeignKey и relationship?
  6. Опишите шаги создания записи через ORM. Зачем нужен db.refresh()?
  7. Что такое Unit of Work и как он связан с flush, commit, rollback?
  8. Почему в FastAPI сессию выдают через зависимость-генератор, а не глобально?
  9. Зачем нужны миграции, если можно вызвать Base.metadata.create_all()?
  10. Что делает alembic revision --autogenerate и почему результат проверяют вручную?
  11. Чем отличаются alembic upgrade head и alembic downgrade -1? Где Alembic хранит текущую версию схемы?