ORM на примере SQLAlchemy

⏱ 50 мин · 🧩 заданий: 4

Зачем ORM

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

# SQL
cur.execute("SELECT * FROM users WHERE email = ?", (email,))
# ORM
session.scalars(select(User).where(User.email == email)).first()

Плюсы: меньше шаблонного кода, объекты вместо кортежей, защита от инъекций, переносимость между СУБД, миграции, удобные связи. Минусы: SQL никуда не исчезает — неосторожный код порождает сотни запросов (N+1) или тянет лишние данные; сложные отчёты проще и быстрее написать на SQL. Хороший разработчик знает и ORM, и SQL под ним.

SQLAlchemy — самая популярная ORM в Python вне Django: её используют с FastAPI, Flask, в скриптах и сервисах. Ниже — версия 2.0, и все примеры работают прямо в браузере (первый запуск подгружает библиотеку).

Engine, модели, Session

from sqlalchemy import create_engine, event, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True)
    name: Mapped[str | None]                                   # необязательное поле (NULL)
    posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")

    def __repr__(self):
        return f"User(id={self.id}, email={self.email!r})"

class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))
    likes: Mapped[int] = mapped_column(default=0)
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    author: Mapped[User] = relationship(back_populates="posts")

engine = create_engine("sqlite://")              # с echo=True SQLAlchemy сам логирует весь SQL
Base.metadata.create_all(engine)                 # CREATE TABLE (в проекте — миграции Alembic)

@event.listens_for(engine, "before_cursor_execute")
def show_sql(conn, cursor, statement, parameters, context, executemany):
    print("SQL>", " ".join(statement.split()), parameters)

with Session(engine) as session:
    anna = User(email="anna@example.com", name="Анна")
    anna.posts.append(Post(title="Привет, SQLAlchemy"))
    session.add(anna)
    session.commit()                             # INSERT в одной транзакции
    print(anna.id, anna.posts[0].author)

Посмотрите на напечатанный SQL: ORM всего лишь генерирует обычные запросы. В разработке то же самое даёт create_engine(..., echo=True). Обратите внимание на два SELECT после commit(): по умолчанию сессия помечает объекты устаревшими (expire_on_commit=True) и перечитывает их при следующем обращении, а anna.posts[0].author — ленивая загрузка связи.

  • Engine — фабрика соединений с базой (пул соединений). Создаётся один раз на приложение.
  • Session — «рабочая единица»: отслеживает загруженные и изменённые объекты и при commit() отправляет изменения одной транзакцией. Сессия короткоживущая: одна на запрос или задачу.
  • Модель — класс с Mapped[...]-аннотациями; тип Python определяет тип колонки, | None — разрешён ли NULL.

Запросы: select

from sqlalchemy import create_engine, ForeignKey, String, select, func, or_
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    city: Mapped[str]
    posts: Mapped[list["Post"]] = relationship(back_populates="author")

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    likes: Mapped[int] = mapped_column(default=0)
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    author: Mapped[User] = relationship(back_populates="posts")

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
anna, boris, vera = User(name="Анна", city="Казань"), User(name="Борис", city="Москва"), User(name="Вера", city="Москва")
anna.posts = [Post(title="Генераторы", likes=40), Post(title="asyncio", likes=95)]
boris.posts = [Post(title="Docker за час", likes=70)]
session.add_all([anna, boris, vera])
session.commit()

# объекты целиком
for user in session.scalars(select(User).where(User.city == "Москва").order_by(User.name)):
    print(user.name)

# один объект
print(session.get(User, 1).name)                                            # по первичному ключу
print(session.scalars(select(User).where(User.name == "Вера")).one().city)   # .one(), .first(), .one_or_none()

# отдельные колонки, условия, LIMIT
stmt = select(Post.title, Post.likes).where(or_(Post.likes > 80, Post.title.like("D%"))).order_by(Post.likes.desc()).limit(5)
print(session.execute(stmt).all())

# JOIN и агрегаты
stmt = (
    select(User.name, func.count(Post.id).label("posts"), func.coalesce(func.sum(Post.likes), 0))
    .outerjoin(User.posts)
    .group_by(User.id)
    .order_by(User.name)
)
print(stmt)          # можно посмотреть сгенерированный SQL
print(session.execute(stmt).all())
Метод Что возвращает
session.scalars(stmt) итератор по первому столбцу — обычно объекты модели
session.execute(stmt) строки (Row) с несколькими колонками
.all(), .first(), .one(), .one_or_none() список, первый или ровно один (иначе исключение)
session.get(Model, pk) объект по первичному ключу или None

Изменение и удаление

from sqlalchemy import create_engine, select, update, delete
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    price: Mapped[int]
    stock: Mapped[int] = mapped_column(default=0)

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

with Session(engine) as session:
    session.add_all([Product(title="Кружка", price=500, stock=3), Product(title="Футболка", price=1500),
                     Product(title="Блокнот", price=300, stock=10)])
    session.commit()

    mug = session.scalars(select(Product).where(Product.title == "Кружка")).one()
    mug.price = 450                      # просто меняем атрибут — сессия отследит
    session.commit()                     # UPDATE products SET price=450 WHERE id=1

    session.execute(update(Product).where(Product.stock == 0).values(price=Product.price * 0.9))   # массовое обновление
    session.execute(delete(Product).where(Product.price < 400))                                   # массовое удаление
    session.commit()

    print([(p.title, p.price) for p in session.scalars(select(Product).order_by(Product.id))])

    try:
        with session.begin_nested():     # точка сохранения (SAVEPOINT)
            session.add(Product(title="Ошибка", price=None))
            session.flush()              # отправить в базу сейчас → IntegrityError
    except Exception as e:
        print("откат:", type(e).__name__)
    print(session.scalars(select(Product.title)).all())

Состояния объекта: transient (создан, не в сессии) → pending (add) → persistent (сохранён, отслеживается) → detached (сессия закрыта). Обращение к незагруженной связи у detached-объекта вызывает DetachedInstanceError — частая ошибка в веб-приложениях.

Проблема N+1

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

from sqlalchemy import create_engine, ForeignKey, select, event
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session, selectinload, joinedload

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship(back_populates="author")

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    author: Mapped[User] = relationship(back_populates="posts")

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add_all([User(name=f"user{i}", posts=[Post(title=f"p{i}-{j}") for j in range(3)]) for i in range(20)])
    s.commit()

queries = []
event.listen(engine, "before_cursor_execute", lambda *args: queries.append(args[2]))

def count_queries(stmt, label):
    queries.clear()
    with Session(engine) as s:
        total = sum(len(u.posts) for u in s.scalars(stmt).unique())
    print(f"{label:14} постов: {total}, SQL-запросов: {len(queries)}")

count_queries(select(User), "lazy")
count_queries(select(User).options(selectinload(User.posts)), "selectinload")
count_queries(select(User).options(joinedload(User.posts)), "joinedload")
print(queries[-1][:120], "...")
  • selectinload — второй запрос WHERE author_id IN (...). Лучший выбор для «один-ко-многим».
  • joinedload — один запрос с LEFT JOIN. Хорош для «многие-к-одному» (post.author), для коллекций размножает строки.
  • В Django то же самое: select_related (JOIN) и prefetch_related (второй запрос).

Как ловить N+1

Включите echo=True или логирование SQL в разработке, считайте запросы в тестах. На собеседовании про N+1 спрашивают почти всегда, когда в вакансии есть ORM.

Многие-ко-многим

from sqlalchemy import create_engine, Table, Column, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

student_course = Table(                       # промежуточная таблица
    "student_course", Base.metadata,
    Column("student_id", ForeignKey("students.id"), primary_key=True),
    Column("course_id", ForeignKey("courses.id"), primary_key=True),
)

class Student(Base):
    __tablename__ = "students"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    courses: Mapped[list["Course"]] = relationship(secondary=student_course, back_populates="students")

class Course(Base):
    __tablename__ = "courses"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    students: Mapped[list[Student]] = relationship(secondary=student_course, back_populates="courses")

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
    python, sql = Course(title="Python"), Course(title="SQL")
    s.add_all([Student(name="Аня", courses=[python, sql]), Student(name="Борис", courses=[python])])
    s.commit()
    print([st.name for st in python.students])
    print(s.scalars(select(Student.name).join(Student.courses).where(Course.title == "SQL")).all())

Если у связи есть свои данные (дата записи, оценка), промежуточную таблицу делают полноценной моделью (паттерн Association Object).

Core и сырой SQL

У SQLAlchemy два слоя: ORM (модели и сессии) и Core (конструктор SQL-выражений и соединения). Для отчётов и массовых операций удобно работать ближе к SQL:

from sqlalchemy import create_engine, text

engine = create_engine("sqlite://")
with engine.begin() as conn:                 # транзакция: commit при выходе, rollback при ошибке
    conn.execute(text("CREATE TABLE t (x INTEGER)"))
    conn.execute(text("INSERT INTO t VALUES (:x)"), [{"x": i} for i in range(5)])
    print(conn.execute(text("SELECT sum(x) FROM t WHERE x > :min"), {"min": 1}).scalar())

text() с параметрами :имя безопасен; f-строка внутри text() — та же SQL-инъекция.

Миграции: Alembic

create_all создаёт только отсутствующие таблицы и не умеет менять существующие. В проекте схема меняется миграциями — версионированными скриптами изменений.

pip install alembic
alembic init migrations                          # каталог migrations/ и alembic.ini
alembic revision --autogenerate -m "add posts.likes"   # сравнить модели с базой и сгенерировать миграцию
alembic upgrade head                             # применить все миграции
alembic downgrade -1                             # откатить последнюю
# migrations/versions/3f1c_add_posts_likes.py
def upgrade():
    op.add_column("posts", sa.Column("likes", sa.Integer(), nullable=False, server_default="0"))

def downgrade():
    op.drop_column("posts", "likes")

Автогенерацию всегда просматривайте глазами: переименование колонки она видит как «удалить + добавить», а это потеря данных.

Сессия в веб-приложении

Правило: одна сессия на запрос. В FastAPI это зависимость:

from sqlalchemy.orm import sessionmaker

engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(engine, expire_on_commit=False)

def get_session():
    with SessionLocal() as session:
        yield session            # FastAPI закроет сессию после ответа

@app.get("/users/{user_id}")
def read_user(user_id: int, session: Session = Depends(get_session)):
    return session.get(User, user_id)

Для async-кода есть create_async_engine и AsyncSession (драйвер asyncpg для PostgreSQL); ленивые загрузки в async-режиме запрещены — связи загружают явно через selectinload.

SQLAlchemy против Django ORM

SQLAlchemy Django ORM
Запрос select(User).where(User.age > 18) User.objects.filter(age__gt=18)
Сохранение через Session (Unit of Work), commit() obj.save() (Active Record)
Миграции Alembic встроены: makemigrations / migrate
N+1 selectinload / joinedload prefetch_related / select_related
Где FastAPI, Flask, скрипты Django

Итог

  • Engine — один на приложение, Session — короткая, на запрос; изменения уходят в базу при commit().
  • Модели: Mapped[...] + mapped_column, связи — relationship(back_populates=...).
  • Запросы: select().where().join().group_by().order_by(), scalars для объектов, execute для строк.
  • N+1 лечится selectinload/joinedload или агрегатом в базе.
  • Схему меняют миграциями Alembic, а не create_all.

Практика решено 0 из 4

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Модели. Опишите на SQLAlchemy 2.0 две модели с общим базовым классом Base(DeclarativeBase):

  • Author (таблица authors): id — первичный ключ, name — строка до 100 символов, обязательная и уникальная; связь books — список книг автора;
  • Book (таблица books): id, title — строка до 200 символов, year — целое, необязательное (Mapped[int | None]), author_id — внешний ключ на authors.id, связь author.

Свяжите их через relationship(back_populates=...).

Какие тесты проверяют код (2)
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
    a = Author(name="Пушкин")
    a.books.append(Book(title="Евгений Онегин", year=1833))
    a.books.append(Book(title="Повести Белкина"))
    s.add(a); s.commit()
    book = s.scalars(select(Book).where(Book.title == "Повести Белкина")).one()
    assert book.author.name == "Пушкин" and book.year is None and len(a.books) == 2

from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
cols = Author.__table__.c
assert cols.name.type.length == 100 and not cols.name.nullable and cols.name.unique, "name: String(100), обязательное, уникальное"
assert Book.__table__.c.year.nullable and not Book.__table__.c.title.nullable
assert [fk.target_fullname for fk in Book.__table__.c.author_id.foreign_keys] == ["authors.id"]
with Session(engine) as s:
    s.add_all([Author(name="Гоголь"), Author(name="Гоголь")])
    try:
        s.commit()
    except IntegrityError:
        pass
    else:
        raise AssertionError("Два автора с одним именем не должны сохраниться")

Задание 2 Программа на Python

Запросы. Даны модели Author(id, name, books) и Book(id, title, year, author_id, author) (они уже определены в тестах). Напишите три функции, принимающие session:

  • books_after(session, year) — названия книг, вышедших после year, по алфавиту;
  • authors_with_counts(session) — список (имя, число книг) по всем авторам, включая авторов без книг, отсортированный по числу книг по убыванию, затем по имени;
  • rename_author(session, old, new) — переименовать автора и закоммитить; если автора нет — LookupError.

Используйте select(...), func.count, outerjoin, group_by, order_by.

Какие тесты проверяют код (4)
from sqlalchemy import ForeignKey, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase):
    pass

class Author(Base):
    __tablename__ = "authors"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    books: Mapped[list["Book"]] = relationship(back_populates="author")

class Book(Base):
    __tablename__ = "books"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    year: Mapped[int | None]
    author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
    author: Mapped[Author] = relationship(back_populates="books")

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
tolstoy, chekhov, gogol, blok = Author(name="Толстой"), Author(name="Чехов"), Author(name="Гоголь"), Author(name="Блок")
tolstoy.books = [Book(title="Война и мир", year=1869), Book(title="Анна Каренина", year=1878)]
chekhov.books = [Book(title="Вишнёвый сад", year=1904), Book(title="Чайка", year=1896)]
gogol.books = [Book(title="Мёртвые души", year=1842)]
session.add_all([tolstoy, chekhov, gogol, blok]); session.commit()
assert books_after(session, 1870) == ["Анна Каренина", "Вишнёвый сад", "Чайка"]
assert books_after(session, 2000) == []

assert authors_with_counts(session) == [("Толстой", 2), ("Чехов", 2), ("Гоголь", 1), ("Блок", 0)]

rename_author(session, "Блок", "Александр Блок")
from sqlalchemy import select
assert session.scalars(select(Author.name).where(Author.id == blok.id)).one() == "Александр Блок"
try:
    rename_author(session, "Пелевин", "x")
except LookupError:
    pass
else:
    raise AssertionError("Нет такого автора — ожидается LookupError")

import inspect; src = inspect.getsource(books_after) + inspect.getsource(authors_with_counts); assert "len(" not in src and "if " not in src, "Фильтруйте и считайте в базе (where, func.count), а не в Python"
Задание 3 Один вариант ответа

Код выводит авторов и число их книг: for a in session.scalars(select(Author)): print(a.name, len(a.books)). Авторов 100. Сколько SQL-запросов выполнится при ленивой загрузке связи по умолчанию?

Задание 4 Заполнить таблицу

Какая стратегия загрузки связи подходит?

selectinloadjoinedloadlazy (по умолчанию)
Список из 50 заказов, у каждого много позиций (один-ко-многим)
Список книг, у каждой ровно один автор (многие-к-одному)
Открыли одного пользователя; его заказы нужны лишь изредка
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.