Перейти к содержанию

212 уроков, 15 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 9 из 20 Средний 40 мин 140 XP

Связи один-ко-многим: 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 сам собирает его из дочерних строк, когда ты обратился. Никаких ручных запросов — обычное свойство объекта.

user.tasks без ручных запросов
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=[...]). Разберём объектные две.

append: связь через объект
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"))
Схематичный фрагмент без запуска: идея — в расстановке сторон. Список id в родителе ломает и базу, и relationship: связь один-ко-многим всегда описывается колонкой в дочерней таблице.

Что дальше

Связь заработала: 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])
Проверь себя
0 / 5

1. В какой таблице живёт колонка ForeignKey при связи один-ко-многим?

2. Что получает строка "users.id" внутри ForeignKey?

3. Что делает relationship("Task") на модели User?

4. Зачем в примере вызывается s.flush() перед созданием задач?

5. Что будет, если создать задачу и не назначить ей автора?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери связь «автор — задачи»: модели User (имя) и Task (название, user_id с ForeignKey на users.id, relationship с order_by по id на стороне User). Создай автора Сергея сразу с двумя задачами — «дизайн» и «вёрстка» — через конструктор, сохрани и выведи список названий его задач, а затем user_id первой из них.

practice.py
Вопросы и ответы по уроку

Как связать две таблицы в 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.