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

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

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

Обратные связи: back_populates

Связь, читаемая в обе стороны: task.user знает автора, user.tasks — задачи, а back_populates следит, чтобы концы не разошлись.

Редакция Питоники

Связь из прошлого урока читается только вниз: у Анны есть tasks, а у её задачи — только user_id, число в колонке. Спросить «кто автор?» объект задачи не может — приходится идти вторым запросом и подставлять id руками. В настоящем коде связь нужна в обе стороны: у карточки задачи показывают исполнителя, у профиля автора — его задачи. Сегодня достраиваем обратную сторону и знакомимся с параметром, который не даст двум концам связи разойтись.

Механика простая. У Task появляется свой relationship — ссылка на автора: task.user. А чтобы SQLAlchemy знал, что user.tasks и task.user описывают одну и ту же связь, оба объявления снабжаются зеркальным параметром back_populates. С этого момента два атрибута живут одной жизнью: меняешь один — второй обновляется сам, без перечитывания базы.

Двусторонняя связь: оба конца

Соберём полную модель и назначим задачу автору через ссылку task.user. Ни одного id руками — и посмотрим, что произойдёт с коллекцией у Анны до и после сохранения.

task.user = anna — и список у Анны сам
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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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="Борис")
    t = Task(title="отчёт")
    s.add_all([anna, boris, t])
    s.commit()

    t.user = anna                      # назначили автора одной строкой
    print("в памяти:", [x.title for x in anna.tasks])
    s.commit()

    print("после commit:", [x.title for x in anna.tasks])
    print("user_id:", t.user_id)
Вывод
в памяти: ['отчёт']
после commit: ['отчёт']
user_id: 1

Ключевой момент — первая строка вывода: задача появилась в anna.tasks сразу после присваивания, ещё до всякого commit. Никто не перечитывал базу: back_populates синхронизирует объекты в памяти. Ссылка на авторе и список у автора — это два взгляда на одну связь, и SQLAlchemy теперь держит их согласованными. На commit колонка user_id получила единицу — база записала ту же связь на своём языке.

Назначил автора одному концу связи — второй обновился в ту же секунду: коллекция и ссылка всегда согласованы. Проверим, что симметрия работает и в обратную сторону: добавим задачу в список Бориса и спросим у задачи, кто её автор.

boris.tasks.append(t) — автор назначился сам
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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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="Борис")
    s.add(boris)
    s.commit()

    t = Task(title="багрепорт")
    boris.tasks.append(t)        # дописали в список - автор назначился
    print("автор:", t.user.name)
    print("user_id сразу:", t.user_id)
    s.commit()
    print("user_id после commit:", t.user_id)
Вывод
автор: Борис
user_id сразу: None
user_id после commit: 1

Любопытная деталь в середине вывода: автор у задачи уже есть, а user_id пока None — колонка заполняется только при сохранении. Не путай два уровня: relationship-атрибуты живут в памяти и синхронизируются мгновенно, колонка — на языке базы — обновляется на flush и commit. Для кода это неважно: читай t.user и не думай о колонке вовсе; база получит своё при сохранении.

Как это устроено под капотом? SQLAlchemy навешивает на relationship-атрибуты события присваивания: в момент t.user = anna срабатывает обработчик, который лезет в парный атрибут и дополняет его список; при append в коллекцию — зеркальный обработчик назначает ссылку. Никакой магии по расписанию и никаких фоновых процессов: связь синхронизируется ровно в момент операции с атрибутом, синхронно и предсказуемо. Именно поэтому синхронизация работает и без базы — до первого flush объекты уже согласованы между собой.

Когда писать через ссылку, а когда через коллекцию? Ссылка короче, когда у тебя на руках задача и автор: одно присваивание вместо поиска в списке. Коллекция естественнее, когда автор — главный герой сцены: «добавим Пете ещё одну задачу» звучит как append. А конструкторы позволяют собрать связанный граф за один шаг с любой стороны — этим и воспользуемся в следующем примере: создадим автора сразу с задачами и задачу, привязанную к уже существующему автору, в одной сессии.

оба конца в одной сессии
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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    def __repr__(self):
        return f"Task({self.title!r})"

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

with Session(engine) as s:
    olya = User(name="Оля", tasks=[Task(title="план"), Task(title="смета")])
    t = Task(title="аудит", user=olya)
    s.add_all([olya, t])
    s.commit()

    print("у Оли:", [x.title for x in olya.tasks])
    print("автор аудита:", t.user.name)

with Session(engine) as s:
    fresh = s.scalars(select(User).where(User.name == "Оля")).one()
    print("в новой сессии:", [x.title for x in fresh.tasks])
Вывод
у Оли: ['план', 'смета', 'аудит']
автор аудита: Оля
в новой сессии: ['план', 'смета', 'аудит']

Два способа записи в одном блоке — конструктор с задачами у автора и ссылка у задачи — дали одинаковый результат: у Оли три задачи, причём третья примкнула к списку из середины. А перечитывание в новой сессии подтверждает: синхронизация в памяти не была иллюзией — в базу легло ровно то, что видели объекты. Порядок в списке держится order_by="Task.id", поэтому «аудит» встал в конец, хотя привязали его после создания.

В старом коде ты встретишь и другой параметр — backref: он делает то же самое, но объявляет встречный конец неявно, из одного места. Мы выбрали back_populates — современный стиль SQLAlchemy 2.0: оба relationship прописаны явно на обеих моделях, и связь видна целиком, откуда бы файл ни открыли. Явная пара чуть многословнее, зато не прячет половину объявления в чужом классе — при чтении моделей это дороже экономии двух строк. Есть и практический бонус: у Task гарантированно есть атрибут user, и подсказки редактора это видят, тогда как атрибут, созданный неявным backref, статические анализаторы часто не находят.

Перекладывание и отвязка

Синхронизация особенно заметна, когда связь меняется: задачу передают другому автору. Присваивание t.user = boris должно сделать две вещи сразу — добавить задачу Борису и убрать её у Анны. С back_populates обе происходят сами.

задача сменила автора
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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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()
    t1 = Task(title="отчёт", user=anna)
    t2 = Task(title="багрепорт", user=boris)
    s.add_all([t1, t2])
    s.commit()

    print("было:", [x.title for x in anna.tasks], "|", [x.title for x in boris.tasks])

    t1.user = boris                  # перекладываем задачу Борису

    print("у Анны:", [x.title for x in anna.tasks])
    print("у Бориса:", [x.title for x in boris.tasks])
    s.commit()
Вывод
было: ['отчёт'] | ['багрепорт']
у Анны: []
у Бориса: ['багрепорт', 'отчёт']

Одна строка кода — а по сути полноценная бизнес-операция «передать задачу»: из списка Анны задача исчезла, в список Бориса легла, а на commit база получит UPDATE колонки user_id. Заодно в этом примере появился ещё один способ записи связи: конструктор Task(title="...", user=anna) принимает relationship-атрибут так же, как колонку. Меняй связь с любого конца — ссылка это или список — второй конец подтянется.

Симметричный вопрос: а как отвязать задачу от автора вовсе? Присвоить ссылке None — механика та же, только в обратную сторону: задача вылетит из списка автора, а колонка на сохранении получит NULL. Задача не удалится — она просто станет ничьей.

t.user = None — отвязать задачу
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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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_all([anna, Task(title="отчёт", user=anna)])
    s.commit()

    t = s.scalars(select(Task).where(Task.title == "отчёт")).one()
    print("было:", [x.title for x in anna.tasks])

    t.user = None                    # отвязали задачу
    print("после отвязки:", [x.title for x in anna.tasks])
    print("автор:", t.user)
    s.commit()
    print("user_id:", t.user_id)
Вывод
было: ['отчёт']
после отвязки: []
автор: None
user_id: None

Обрати внимание: задача осталась в базе — изменился только автор, точнее его отсутствие. Список Анны опустел в ту же секунду, без перечитывания. Отвязка — та же операция, что и назначение, просто со значением None; и точно так же база узнаёт о ней только на commit.

сырая колонка коллекцию не будит
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"))

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

with Session(engine) as s:
    anna = User(name="Анна")
    s.add(anna)
    s.commit()
    _ = anna.tasks                  # коллекция загружена в память

    t = Task(title="отчёт")
    t.user_id = anna.id             # сырой внешний ключ, без relationship
    s.add(t)
    print("в памяти:", [x.title for x in anna.tasks])
    s.commit()
    print("после commit:", [x.title for x in anna.tasks])
Вывод
в памяти: []
после commit: ['отчёт']

Дважды один и тот же вопрос — и два разных ответа. До commit коллекция уверена, что у Анны нет задач: она загружена в память и о ручной записи в колонку не слышала. Commit истёк объекты, обращение перечитало связь из базы — и задача нашлась. В маленьком скрипте это переживается легко, а в живом приложении именно так выглядят «задача есть, а в интерфейсе у автора её нет» — классический симптом связи, которую пишут мимо relationship.

Пиши через объект — читай с двух сторон

Соберём урок в одну привычку: связь записываем только через объектные атрибуты — t.user = кто-то или список.append(задача), — а читаем с любой стороны. Колонку user_id оставим 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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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=anna),
        Task(title="аудит", user=anna),
        Task(title="презентация", user=boris),
    ])
    s.commit()

with Session(engine) as s:
    t = s.scalars(select(Task).where(Task.title == "отчёт")).one()
    print(t.title, "-", t.user.name)

    u = s.scalars(select(User).where(User.name == "Анна")).one()
    print(u.name, "-", [x.title for x in u.tasks])
Вывод
отчёт - Анна
Анна - ['отчёт', 'аудит']
ДействиеЧто видит второй конецЧто получит база
t.user = annaсписок anna.tasks обновится сразуUPDATE user_id при commit
anna.tasks.append(t)t.user назначится сразуUPDATE user_id при commit
t.user_id = anna.idничего не увидит до перечитыванияUPDATE user_id при commit
как не надо: связь без обратного конца
# ТАК НЕЛЬЗЯ: tasks объявлен с back_populates, но атрибута user на Task нет
tasks = relationship("Task", back_populates="user")   # а user не существует!

# SQLAlchemy поднимет ошибку при настройке маппера, до первого запроса:
# back_populates ссылается на несуществующий атрибут

# ПРАВИЛЬНО: зеркальная пара на обеих моделях
class User(Base):
    tasks = relationship("Task", back_populates="user")

class Task(Base):
    user = relationship("User", back_populates="tasks")
Схематичный фрагмент без запуска. back_populates работает парами: имя встречного relationship должно существовать и указывать обратно. Пропущенный конец ловится при первом обращении к мапперу — это самая безобидная из ошибок связи.

Что дальше

Теперь связь симметрична и честна: task.user и user.tasks — два конца одной связи, синхронизированные в памяти и хранимые одной колонкой в базе. Код приложения окончательно перестал упоминать user_id: числа остались языком базы, объекты — языком логики. Навигация отвечает на вопрос «покажи задачи Анны», когда Анна уже на руках.

Но запросы бывают и обратные: «найди все задачи, у которых автор — Анна», при том что Анна не загружена, а известна по имени. Можно достать её select-ом по имени и пойти навигацией — два запроса там, где хватает одного. Фильтрация по связанной таблице решается join-ом — прямым наследником тех самых SQL-соединений, которые ORM держала за кадром: одна выборка сразу соединяет таблицы и фильтрует по чужим колонкам. Встречаем его в уроке 11.

back_populates делает связь честной в обе стороны: изменил один конец — второй узнал немедленно. Сырая колонка меняет базу, объектная связь меняет модель.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

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", back_populates="user", 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"))
    user = relationship("User", back_populates="tasks")

    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="Анна")
    t = Task(title="отчёт")
    s.add_all([anna, t])
    s.commit()
    t.user = anna
    print(len(anna.tasks), t.user.name)
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"))

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

with Session(engine) as s:
    anna = User(name="Анна")
    s.add(anna)
    s.commit()
    _ = anna.tasks
    t = Task(title="аудит")
    t.user_id = anna.id
    s.add(t)
    print(len(anna.tasks))
    s.commit()
    print(len(anna.tasks))
Проверь себя
0 / 5

1. Что даёт параметр back_populates в relationship?

2. После t.user = anna что вернёт anna.tasks до всякого commit?

3. Почему после boris.tasks.append(t) колонка t.user_id ещё None?

4. Что произойдёт при t.user_id = anna.id, если коллекция anna.tasks уже загружена?

5. Как объявить двустороннюю связь правильно?

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

Объяви двустороннюю связь: User с tasks = relationship("Task", back_populates="user", order_by="Task.id") и Task с user = relationship("User", back_populates="tasks"). Создай двух авторов — Олю и Плю — и задачу «смета»; назначь ей автором Олю через t.user, сохрани, затем передай задачу Пете тем же присваиванием. Выведи: список задач Оли, список задач Пети и имя автора задачи.

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

Что такое back_populates в SQLAlchemy?

Зеркальный параметр, объявляющий два relationship двумя концами одной связи: user.tasks и task.user. Благодаря ему SQLAlchemy синхронизирует концы в памяти: присваивание task.user немедленно пополняет user.tasks и наоборот, а база получает одну и ту же колонку user_id при commit.

Чем back_populates отличается от backref?

Оба делают связь двусторонней, но backref объявляет встречный конец неявно из одного места, а back_populates требует явно описать оба relationship на обеих моделях. В коде на SQLAlchemy 2.0 приняты явные back_populates: оба конца видно прямо в моделях, читать и править такой код проще.

Почему task.user_id заполнен, а user.tasks пуст?

Так проявляется связь, записанная мимо relationship: сырая запись в колонку не уведомляет загруженную коллекцию. Она перечитается только после commit, который истекает объекты. Записывай связь объектами — t.user = автор или автор.tasks.append(t) — и оба конца будут согласованы сразу.

Нужна ли колонка ForeignKey, если есть relationship с back_populates?

Да: relationship — только Python-уровень навигации, хранение связи остаётся на колонке ForeignKey в дочерней таблице. Убери колонку — и SQLAlchemy не сможет построить связь: мапперу нужен путь из внешнего ключа, а объектам — удобные атрибуты поверх него.

Понравился урок? Сошлитесь на него

«Назначил автора одному концу связи — второй обновился в ту же секунду: коллекция и ссылка всегда согласованы.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

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

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