Связи один-ко-многим: ForeignKey и relationship
Одна таблица — это заметка, две связанные — база: ForeignKey держит ссылку, relationship превращает её в навигацию по объектам.
Редакция Питоники
Восемь уроков подряд таблица была одна: задачи шли сплошным списком, и мы делали вид, что не замечаем странности. В настоящем менеджере задач не бывает «задач вообще» — у каждой есть автор, и «показать задачи Анны» должно быть одной строкой кода, а не двумя запросами, склеенными руками. Сегодня в приложение появляются отношения: таблица users и связь «один автор — много задач», самый частый вид связи во всех базах мира.
Связь собирается из двух половин, и путать их не стоит. Нижняя, хранимая половина — колонка ForeignKey в дочерней таблице: честная ссылка на строку родителя, понятная самой базе. Верхняя, объектная половина — relationship: правило, по которому SQLAlchemy из этих ссылок собирает готовые списки на Python-объектах. Первая половина про данные, вторая — про удобство. Начнём с нижней.
Если ты писал запросы на чистом SQL, то связь «автор — задачи» там описывается словом JOIN: две таблицы склеиваются при чтении, и из БД возвращаются пары строк. ORM предлагает ту же связь в двух других видах — колонка в схеме и атрибут на объекте, — а склеивание таблиц откладывает до момента, когда оно действительно нужно. Сегодня мы обойдёмся вообще без склеивания: навигации по объектам хватит, чтобы и записать связь, и прочитать её. Когда дойдёт до фильтрации по чужой таблице, на сцену выйдет join — это тема урока 11.
ForeignKey: ссылка на чужую строку
Правило расстановки сторон простое, и его стоит запомнить как считалочку. Внешний ключ живёт в дочерней таблице, на стороне «многих». Не автор хранит список задач — каждая задача помнит своего автора. Поэтому в tasks появляется колонка user_id, а в users ничего не добавляется: список автору не нужен, его всегда можно собрать из ссылок.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.id}, {self.title!r}, user_id={self.user_id})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
boris = User(name="Борис")
s.add_all([anna, boris])
s.flush() # объекты ушли в базу и получили id
s.add_all([
Task(title="отчёт", user_id=anna.id),
Task(title="багрепорт", user_id=anna.id),
Task(title="презентация", user_id=boris.id),
])
s.commit()
for t in s.scalars(select(Task).order_by(Task.id)):
print(t)
Task(1, 'отчёт', user_id=1) Task(2, 'багрепорт', user_id=1) Task(3, 'презентация', user_id=2)
Разбираем по строкам. ForeignKey("users.id") получает строку «имя таблицы.точка.колонка» — именно таблицы, а не класса: на этом уровне SQLAlchemy пишет схему, как её поймёт база. Mapped[int | None] помечает колонку необязательной: задача без автора — легальная строка с пустым user_id. Вызов s.flush() отправляет накопленное в базу, не завершая транзакцию: после него у Анны и Бориса появляются настоящие id, которые мы сразу используем в задачах. Без flush anna.id был бы None — id выдаёт база, и пока мы её не спросили, его ни у кого нет.
Имена на двух уровнях — постоянный источник путаницы, так что проговорим ещё раз. В ForeignKey — имена из базы: таблица users, колонка id, всё маленькими буквами, как в схеме. В relationship — имя Python-класса: "Task", с большой буквы. Одна связь описывается в двух мирах, и каждый мир говорит на своём языке. Если перепутать, SQLAlchemy не станет угадывать: ошибка вылезет при первой настройке маппера, до всякого запроса, — это самый безобидный способ поймать опечатку.
Зачем базе ForeignKey, если ссылку можно хранить как обычное число? Затем, что колонка с ForeignKey — это обещание: в этом поле могут стоять только id, существующие в users. База начинает стеречь ссылку сама — и ловит ошибки раньше, чем они разрастутся.
from sqlalchemy import create_engine, event, ForeignKey, String
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
@event.listens_for(Engine, "connect")
def turn_on_fk(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.commit()
try:
s.add(Task(title="призрак", user_id=99)) # у пользователя 99 нет
s.commit()
except Exception as e:
print("поймали:", type(e).__name__)
s.rollback()
поймали: IntegrityError
Задача со ссылкой на несуществующего пользователя №99 до базы не дошла: commit поднял IntegrityError, и откат вернул сессию в целое состояние. Именно так внешние ключи работают в PostgreSQL и MySQL — проверка включена всегда. Здесь же в коде виден рецепт для SQLite, который по умолчанию ссылки не проверяет: слушатель события connect включает режим PRAGMA foreign_keys=ON на каждом новом соединении. В промышленном коде с SQLite этот рецепт ставят в один модуль настройки — и забывают о нём.
relationship: навигация по объектам
Ссылки в колонках — хранилище, но работать с ними руками утомительно: достал задачу, прочитал user_id, пошёл вторым запросом за автором. Вторая половина связи снимает и это: relationship на родителе объявляет атрибут-список, и SQLAlchemy сам собирает его из дочерних строк, когда ты обратился. Никаких ручных запросов — обычное свойство объекта.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
boris = User(name="Борис")
s.add_all([anna, boris])
s.flush()
s.add_all([
Task(title="отчёт", user_id=anna.id),
Task(title="багрепорт", user_id=anna.id),
Task(title="презентация", user_id=boris.id),
])
s.commit()
a = s.scalars(select(User).where(User.name == "Анна")).one()
print("задачи Анны:", a.tasks)
print("сколько:", len(a.tasks))
for u in s.scalars(select(User).order_by(User.id)):
print(u.name, "->", [t.title for t in u.tasks])
задачи Анны: [Task('отчёт'), Task('багрепорт')]
сколько: 2
Анна -> ['отчёт', 'багрепорт']
Борис -> ['презентация']a.tasks — это не колонка и не сохранённый список: в момент обращения relationship посылает в базу выборку задач с user_id Анны и собирает из них список. Такой отложенный поход в базу называется ленивой загрузкой: данные едут из базы только когда реально понадобились. Параметр order_by="Task.id" приказывает собирать список в порядке первичных ключей — без него порядок строк не гарантирован, а нам нужен предсказуемый вывод. Имя "Task" в кавычках — это имя класса, не таблицы: relationship живёт на Python-уровне и про классы и говорит.
У ленивой загрузки есть цена, о которой стоит знать сразу. В цикле по десяти авторам обращение к u.tasks для каждого — это десять отдельных запросов к базе: один на список авторов и по одному на каждого. В учебной базе с памятью это незаметно, в продакшене под таким циклом иногда прячутся сотни запросов — знаменитая проблема N+1. Лечится она заранее загруженными связями и диагностируется по логам запросов, поэтому пока просто держи в голове: у удобного списка на объекте есть счёт, и в чужом коде его полезно проверять.
А теперь взгляд с обратной стороны: у задачи виден только user_id — число. Чтобы назвать автора задачи, пока приходится ходить вторым запросом по id вручную. Чувствуешь зазор? Связь читается вверх легко, а вниз — через лишнюю строку кода. В следующем уроке мы достроим вторую половину relationship и получим task.user.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.flush() # у Анны появился настоящий id
s.add(Task(title="багрепорт", user_id=anna.id))
s.commit()
t = s.scalars(select(Task).where(Task.title == "багрепорт")).one()
author = s.scalars(select(User).where(User.id == t.user_id)).one()
print("автор задачи:", author.name)
автор задачи: Анна
Записать связь: три способа
Читать связь научились — осталось записывать, и здесь у SQLAlchemy три идиомы на выбор. Первая — прямая, как в прошлом примере: положить id руками в user_id. Вторая — объектная: append в коллекцию relationship, и колонка заполнится сама при сохранении. Третья — через конструктор: список задач прямо в User(tasks=[...]). Разберём объектные две.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.commit()
t = Task(title="вторая задача")
anna.tasks.append(t) # связываем объекты - без user_id руками
s.commit()
print("у Анны:", [x.title for x in anna.tasks])
print("user_id новой задачи:", t.user_id)
у Анны: ['вторая задача'] user_id новой задачи: 1
Мы не трогали user_id ни разу, но после commit в колонке стоит единица. Append в коллекцию relationship — это команда «задача теперь принадлежит Анне»: SQLAlchemy сам проставит дочерней строке ключ родителя на сохранении. Код читается как бизнес-логика — «добавили Анне задачу» — а не как раскладка по колонкам.
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
vera = User(name="Вера", tasks=[Task(title="план"), Task(title="проверка плана")])
s.add(vera)
s.commit()
print(vera.name, "-", vera.tasks)
Вера - [Task('план'), Task('проверка плана')]Конструктор принимает любой именованный атрибут модели, включая relationship: вложенная связь собирается за один add и один commit, и SQLAlchemy сам разберётся, в каком порядке сохранить строки, чтобы ссылки сошлись. Три способа не конкурируют, а делят случаи: user_id руками — когда есть только число, append — когда задача уже создана, конструктор — когда создаём всё сразу.
| Способ | Код | Когда удобен |
|---|---|---|
| Напрямую в колонку | Task(title="...", user_id=anna.id) | есть только id, объекта автора нет |
| Append в коллекцию | anna.tasks.append(t) | задача уже создана, автор на руках |
| Конструктор | User(name="...", tasks=[t1, t2]) | создаём автора сразу с задачами |
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add_all([anna, Task(title="задача-сирота")]) # автора не назначили
s.commit()
stray = s.scalars(select(Task).where(Task.title == "задача-сирота")).one()
print("user_id сироты:", stray.user_id)
print("у Анны:", [t.title for t in anna.tasks])
user_id сироты: None у Анны: []
# ТАК НЕЛЬЗЯ: список ссылок в родительской таблице
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
task_ids: Mapped[list[int]] = mapped_column() # чужие ключи у родителя
# ПРАВИЛЬНО: ссылка живёт у дочерней строки
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
Что дальше
Связь заработала: ForeignKey держит ссылку в данных, relationship показывает её как список на объекте, три способа записи покрывают все ситуации. Осталась асимметрия: от автора к задаче — атрибутом, от задачи к автору — вторым запросом и руками. Нормальная объектная модель обязана читать связь в обе стороны, как в настоящем коде: у задачи спрашивают исполнителя, у исполнителя — его задачи.
База при этом собрана так, что для каждого автора relationship делает отдельный запрос — в нашем учебном объёме незаметно, а на списках из сотни авторов это уже известная ловушка по имени N+1, которой мы посвятим отдельный урок 19. Сейчас же достроим связь до симметричной: научим задачу знать своего автора через task.user, а колонку с числом оставим базе — этим и займётся урок 10, целиком посвящённый обратным связям и их синхронизации.
ForeignKey — это ссылка в данных, relationship — привычный список на объекте. Первое понимает база, второе — твой код; описал обе половины — ходишь по связи без единого ручного запроса.
Сначала предскажи ответ в голове — это главный навык программиста.
from sqlalchemy import create_engine, ForeignKey, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
anna = User(name="Анна")
s.add(anna)
s.commit()
t = Task(title="отчёт")
anna.tasks.append(t)
s.commit()
print(t.user_id, len(anna.tasks))
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
tasks = relationship("Task", order_by="Task.id")
def __repr__(self):
return f"User({self.name})"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(80))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
def __repr__(self):
return f"Task({self.title!r})"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
boris = User(name="Борис", tasks=[Task(title="отчёт"), Task(title="аудит")])
s.add(boris)
s.commit()
print([t.title for t in boris.tasks])
1. В какой таблице живёт колонка ForeignKey при связи один-ко-многим?
2. Что получает строка "users.id" внутри ForeignKey?
3. Что делает relationship("Task") на модели User?
4. Зачем в примере вызывается s.flush() перед созданием задач?
5. Что будет, если создать задачу и не назначить ей автора?
Собери связь «автор — задачи»: модели User (имя) и Task (название, user_id с ForeignKey на users.id, relationship с order_by по id на стороне User). Создай автора Сергея сразу с двумя задачами — «дизайн» и «вёрстка» — через конструктор, сохрани и выведи список названий его задач, а затем user_id первой из них.
Как связать две таблицы в SQLAlchemy?
Двумя половинами: колонка ForeignKey("users.id") в дочерней таблице хранит ссылку, relationship("Task") на родителе даёт навигацию user.tasks. ForeignKey получает имена таблицы и колонки базы, relationship — имя Python-класса. Записывать связь можно через user_id, append в коллекцию или список задач в конструкторе.
Почему relationship, а не просто колонка с id?
Колонка с ForeignKey — хранимая ссылка, понятная базе; relationship — удобство на Python: обращение к user.tasks само собирает список задач, без второго запроса руками. Без relationship пришлось бы читать user_id и доставать автора или его задачи отдельными select — именно так связь читалась до появления relationship в уроке.
Почему SQLite не ругается на неверный внешний ключ?
SQLite по умолчанию не проверяет внешние ключи: на каждом соединении нужно выполнить PRAGMA foreign_keys=ON — обычно через event.listens_for(Engine, "connect"). PostgreSQL и MySQL проверяют ссылки всегда и поднимут IntegrityError без дополнительных настроек.
Что означает Mapped[int | None] у колонки с ForeignKey?
Необязательность: задача может не иметь автора, тогда user_id хранит NULL. Если по логике приложения автор обязателен, объявляй Mapped[int] — попытка сохранить запись без ссылки закончится ошибкой при flush, и «задачи-сироты» не появятся.
Понравился урок? Сошлитесь на него
«Внешний ключ живёт в дочерней таблице, на стороне «многих». Не автор хранит список задач — каждая задача помнит своего автора.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
SQLAlchemy · Урок 8
Обновление и удаление записей
Данные записаны — теперь их надо менять: два пути SQLAlchemy, ручной через объект и массовый через update() и delete().
SQLAlchemy · Урок 10
Обратные связи: back_populates
Связь, читаемая в обе стороны: task.user знает автора, user.tasks — задачи, а back_populates следит, чтобы концы не разошлись.
SQLAlchemy · Урок 19
N+1: главная ловушка ORM и selectinload
Удобный атрибут user.tasks в цикле — это по запросу на каждого автора: знаменитое N+1, главная ловушка ORM. Лечится одной строкой — selectinload.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Matplotlib / Seaborn · Урок 4
Диаграммы рассеяния scatter: ищем связи между величинами
scatter для поиска связей между величинами: форма облака точек, цвет и размер как третье измерение, alpha против оверплоттинга и кейс с 300 измерениями.
связь между величинами график python
openpyxl · Урок 18
Консолидация: собираем несколько Excel-файлов в один через openpyxl
Три точки кофейни присылают три файла продаж — собираем их в одну сводную книгу циклом: обход папки, чтение каждого файла, фильтр мусора и нормализация колонок.
openpyxl несколько файлов в одинобъединение excel таблиц автоматизация
SQLAlchemy · Урок 11
Join в ORM: запросы по связям
Навигация читает связь у готового объекта, join фильтрует по чужой таблице в одном запросе: все задачи автора по имени.
sqlalchemy joinsqlalchemy join запросы