Лекция 9. ORM и миграции (SQLAlchemy, Alembic)
Раздел 3. Работа с данными в backend-приложениях. Длительность: ~1 час 30 минут
План лекции
- Что такое ORM, плюсы и минусы vs сырой SQL.
- SQLAlchemy: engine, session, модели, типы и связи.
- CRUD через ORM и единица работы (Unit of Work).
- Интеграция с FastAPI (сессия через
Depends). - Миграции схемы и 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 datetimefrom sqlalchemy import Column, Integer, String, Boolean, DateTime, Numericfrom 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 ForeignKeyfrom 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()
# CREATEuser = User(email="anna@example.com", name="Анна")db.add(user) # пометить для вставкиdb.commit() # зафиксировать транзакцию (INSERT)db.refresh(user) # подтянуть сгенерированный id
# READusers = 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
# UPDATEuser.name = "Анна Иванова" # меняем атрибут объектаdb.commit() # ORM сам сгенерирует UPDATE# массовое обновление одним запросом:db.query(User).filter(User.is_active == False).update({"is_active": True})db.commit()
# DELETEdb.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() # при ошибке откатываем всё raisefinally: db.close()5. Интеграция с FastAPI
Сессия выдаётся эндпоинтам через зависимость-генератор Depends: она открывается перед
запросом и гарантированно закрывается после — даже при ошибке.
from fastapi import FastAPI, Depends, HTTPExceptionfrom 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 alembicalembic 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 Basetarget_metadata = Base.metadataАвтогенерация миграции
alembic revision --autogenerate -m "create users and orders tables"Появится файл в alembic/versions/. Сгенерированный скрипт нужно просматривать:
autogenerate не всегда улавливает переименования и сложные изменения.
"""create users and orders tables"""from alembic import opimport 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.
Вопросы для самопроверки
- Что такое ORM и какое соответствие она устанавливает между БД и объектной моделью?
- Назовите два преимущества и два недостатка ORM по сравнению с сырым SQL.
- За что отвечают
engineиSessionв SQLAlchemy? Чем они отличаются? - Как задать столбец, который является первичным ключом и уникален?
- В чём разница между
ForeignKeyиrelationship? - Опишите шаги создания записи через ORM. Зачем нужен
db.refresh()? - Что такое Unit of Work и как он связан с
flush,commit,rollback? - Почему в FastAPI сессию выдают через зависимость-генератор, а не глобально?
- Зачем нужны миграции, если можно вызвать
Base.metadata.create_all()? - Что делает
alembic revision --autogenerateи почему результат проверяют вручную? - Чем отличаются
alembic upgrade headиalembic downgrade -1? Где Alembic хранит текущую версию схемы?