ORM на примере SQLAlchemy
Зачем 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
Модели. Опишите на 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("Два автора с одним именем не должны сохраниться")
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
year: Mapped[int | None]
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
Запросы. Даны модели 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"
from sqlalchemy import select, func
def books_after(session, year):
stmt = select(Book.title).where(Book.year > year).order_by(Book.title)
return list(session.scalars(stmt))
def authors_with_counts(session):
n_books = func.count(Book.id)
stmt = (
select(Author.name, n_books)
.outerjoin(Author.books)
.group_by(Author.id)
.order_by(n_books.desc(), Author.name)
)
return [tuple(row) for row in session.execute(stmt)]
def rename_author(session, old, new):
author = session.scalars(select(Author).where(Author.name == old)).one_or_none()
if author is None:
raise LookupError(old)
author.name = new
session.commit()
Код выводит авторов и число их книг: for a in session.scalars(select(Author)): print(a.name, len(a.books)). Авторов 100. Сколько SQL-запросов выполнится при ленивой загрузке связи по умолчанию?
Какая стратегия загрузки связи подходит?
| selectinload | joinedload | lazy (по умолчанию) | |
|---|---|---|---|
| Список из 50 заказов, у каждого много позиций (один-ко-многим) | |||
| Список книг, у каждой ровно один автор (многие-к-одному) | |||
| Открыли одного пользователя; его заказы нужны лишь изредка |
Войдите, чтобы отмечать пройденные уроки.