# Полный гайд по связям в SQLModel ## Оглавление 1. [Основы](#1-основы) 2. [One-to-Many (Один ко многим)](#2-one-to-many) 3. [Many-to-One (Многие к одному)](#3-many-to-one) 4. [One-to-One (Один к одному)](#4-one-to-one) 5. [Many-to-Many (Многие ко многим)](#5-many-to-many) 6. [Самореферентные связи](#6-самореферентные-связи) 7. [Каскадные операции](#7-каскадные-операции) 8. [Загрузка связей (Lazy / Eager)](#8-загрузка-связей) 9. [Связи и Pydantic-схемы (read models)](#9-связи-и-pydantic-схемы) 10. [Связи + FastAPI](#10-связи--fastapi) 11. [Частые ошибки и подводные камни](#11-частые-ошибки) --- ## 1. Основы ### Два ключевых инструмента ```python from sqlmodel import SQLModel, Field, Relationship ``` | Инструмент | Назначение | |---|---| | `Field(foreign_key="table.column")` | Создаёт **внешний ключ** в БД (колонку) | | `Relationship(back_populates="...")` | Создаёт **Python-атрибут** для доступа к связанным объектам (НЕ колонку в БД) | ### Базовая настройка ```python from sqlmodel import SQLModel, Field, Relationship, Session, create_engine, select from typing import Optional engine = create_engine("sqlite:///database.db", echo=True) def create_db(): SQLModel.metadata.create_all(engine) ``` --- ## 2. One-to-Many (Один ко многим) **Один** `Team` → **Много** `Hero` ```python class Team(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str = Field(index=True) # Связь: список героев, принадлежащих этой команде heroes: list["Hero"] = Relationship(back_populates="team") class Hero(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str = Field(index=True) # Внешний ключ — реальная колонка в БД team_id: int | None = Field(default=None, foreign_key="team.id") # Связь: объект команды team: Team | None = Relationship(back_populates="heroes") ``` ### Создание записей ```python with Session(engine) as session: # Способ 1: Через FK team = Team(name="Avengers") session.add(team) session.commit() session.refresh(team) hero = Hero(name="Iron Man", team_id=team.id) session.add(hero) session.commit() # Способ 2: Через relationship (удобнее) team2 = Team(name="X-Men") hero2 = Hero(name="Wolverine", team=team2) session.add(hero2) # team2 добавится автоматически! session.commit() # Способ 3: Добавление в список team3 = Team(name="Justice League") team3.heroes = [ Hero(name="Batman"), Hero(name="Superman"), ] session.add(team3) session.commit() ``` ### Чтение связей ```python with Session(engine) as session: # Получить команду героя hero = session.exec(select(Hero).where(Hero.name == "Iron Man")).first() print(hero.team.name) # "Avengers" — lazy load # Получить героев команды team = session.exec(select(Team).where(Team.name == "Avengers")).first() for h in team.heroes: # lazy load print(h.name) ``` --- ## 3. Many-to-One (Многие к одному) Это **обратная сторона** One-to-Many. Технически — то же самое, просто смотрим с другой стороны. ```python class Country(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str cities: list["City"] = Relationship(back_populates="country") class City(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str country_id: int = Field(foreign_key="country.id") # обязательная связь # Many-to-One: много городов → одна страна country: Country = Relationship(back_populates="cities") ``` > **Правило:** FK всегда на стороне "Many". Кто хранит `foreign_key` — тот "Many". --- ## 4. One-to-One (Один к одному) SQLModel не имеет специального параметра для One-to-One. Используем `Relationship` + `sa_relationship_kwargs`. ```python class User(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) username: str profile: "Profile | None" = Relationship( back_populates="user", sa_relationship_kwargs={"uselist": False} # ← ключевой момент! ) class Profile(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) bio: str = "" user_id: int = Field(foreign_key="user.id", unique=True) # unique! user: User = Relationship(back_populates="profile") ``` > **`uselist=False`** — говорит SQLAlchemy возвращать один объект, а не список. > **`unique=True`** на FK — гарантирует уникальность на уровне БД. ```python with Session(engine) as session: user = User(username="john") profile = Profile(bio="Hello!", user=user) session.add(profile) session.commit() session.refresh(user) print(user.profile.bio) # "Hello!" print(profile.user.username) # "john" ``` --- ## 5. Many-to-Many (Многие ко многим) Нужна **промежуточная (link/association) таблица**. ### Вариант A: Простая link-таблица ```python class HeroMissionLink(SQLModel, table=True): """Промежуточная таблица — только два FK""" hero_id: int = Field(foreign_key="hero.id", primary_key=True) mission_id: int = Field(foreign_key="mission.id", primary_key=True) class Hero(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str missions: list["Mission"] = Relationship( back_populates="heroes", link_model=HeroMissionLink, # ← указываем link-модель ) class Mission(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) title: str heroes: list["Hero"] = Relationship( back_populates="missions", link_model=HeroMissionLink, ) ``` ### Использование ```python with Session(engine) as session: hero1 = Hero(name="Spider-Man") hero2 = Hero(name="Black Widow") mission1 = Mission(title="Save the World") mission2 = Mission(title="Stealth Op") # Добавляем через relationship hero1.missions = [mission1, mission2] hero2.missions = [mission1] session.add(hero1) session.add(hero2) session.commit() # Чтение session.refresh(mission1) for h in mission1.heroes: print(h.name) # Spider-Man, Black Widow ``` ### Вариант B: Link-таблица с дополнительными полями ```python class Enrollment(SQLModel, table=True): """Связь студент-курс с дополнительными данными""" student_id: int = Field(foreign_key="student.id", primary_key=True) course_id: int = Field(foreign_key="course.id", primary_key=True) grade: float | None = None # ← доп. поле enrolled_at: str | None = None # ← доп. поле class Student(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str # Связь напрямую с Course через link_model courses: list["Course"] = Relationship( back_populates="students", link_model=Enrollment, ) # Доступ к самим записям Enrollment enrollments: list[Enrollment] = Relationship(back_populates="student") class Course(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) title: str students: list["Student"] = Relationship( back_populates="courses", link_model=Enrollment, ) enrollments: list[Enrollment] = Relationship(back_populates="course") ``` Чтобы `enrollments` работал, добавьте обратные связи в `Enrollment`: ```python class Enrollment(SQLModel, table=True): student_id: int = Field(foreign_key="student.id", primary_key=True) course_id: int = Field(foreign_key="course.id", primary_key=True) grade: float | None = None student: "Student" = Relationship(back_populates="enrollments") course: "Course" = Relationship(back_populates="enrollments") ``` ```python # Создание с доп. данными with Session(engine) as session: student = Student(name="Alice") course = Course(title="Math") session.add_all([student, course]) session.commit() enrollment = Enrollment( student_id=student.id, course_id=course.id, grade=95.5, ) session.add(enrollment) session.commit() ``` --- ## 6. Самореферентные связи ### Дерево (parent → children) ```python class Category(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str parent_id: int | None = Field(default=None, foreign_key="category.id") # Родитель parent: "Category | None" = Relationship( back_populates="children", sa_relationship_kwargs={"remote_side": "Category.id"}, ) # Дети children: list["Category"] = Relationship(back_populates="parent") ``` ```python with Session(engine) as session: root = Category(name="Electronics") phones = Category(name="Phones", parent=root) laptops = Category(name="Laptops", parent=root) iphone = Category(name="iPhone", parent=phones) session.add(root) session.commit() session.refresh(root) for child in root.children: print(child.name) # Phones, Laptops ``` ### Подписчики (Many-to-Many на себя) ```python class FollowLink(SQLModel, table=True): follower_id: int = Field(foreign_key="usermodel.id", primary_key=True) following_id: int = Field(foreign_key="usermodel.id", primary_key=True) class UserModel(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) username: str # Те, на кого я подписан following: list["UserModel"] = Relationship( back_populates="followers", link_model=FollowLink, sa_relationship_kwargs={ "primaryjoin": "UserModel.id == FollowLink.follower_id", "secondaryjoin": "UserModel.id == FollowLink.following_id", }, ) # Мои подписчики followers: list["UserModel"] = Relationship( back_populates="following", link_model=FollowLink, sa_relationship_kwargs={ "primaryjoin": "UserModel.id == FollowLink.following_id", "secondaryjoin": "UserModel.id == FollowLink.follower_id", }, ) ``` --- ## 7. Каскадные операции Каскады настраиваются через `sa_relationship_kwargs`: ```python class Author(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str books: list["Book"] = Relationship( back_populates="author", sa_relationship_kwargs={ "cascade": "all, delete-orphan", # ← каскадное удаление }, ) class Book(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) title: str author_id: int = Field(foreign_key="author.id") author: Author = Relationship(back_populates="books") ``` ### Варианты каскадов | Каскад | Описание | |---|---| | `"save-update"` | Автоматический add связанных объектов (по умолчанию) | | `"merge"` | Каскадный merge (по умолчанию) | | `"delete"` | Удаление связанных при удалении родителя | | `"delete-orphan"` | Удаление "осиротевших" (убранных из списка) | | `"all"` | `save-update + merge + delete + refresh-expire` | | `"all, delete-orphan"` | Самый агрессивный — полное владение | ```python with Session(engine) as session: author = Author(name="Tolkien", books=[ Book(title="The Hobbit"), Book(title="LOTR"), ]) session.add(author) session.commit() # Удаляем автора → книги удалятся автоматически session.delete(author) session.commit() ``` ### ON DELETE на уровне БД Помимо каскадов SQLAlchemy, можно задать `ON DELETE` на уровне DDL: ```python from sqlmodel import Field from sqlalchemy import Column, ForeignKey, Integer class Book(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) title: str author_id: int = Field( sa_column=Column( Integer, ForeignKey("author.id", ondelete="CASCADE"), nullable=False, ) ) ``` --- ## 8. Загрузка связей ### Проблема: LazyLoad вне сессии ```python with Session(engine) as session: team = session.exec(select(Team)).first() # Вне сессии: print(team.heroes) # 💥 DetachedInstanceError! ``` ### Решение 1: Eager Loading (selectinload / joinedload) ```python from sqlalchemy.orm import selectinload, joinedload with Session(engine) as session: # selectinload — отдельный SELECT IN (лучше для коллекций) statement = select(Team).options(selectinload(Team.heroes)) team = session.exec(statement).first() # Теперь безопасно вне сессии: print(team.heroes) # ✅ работает ``` ```python with Session(engine) as session: # joinedload — LEFT JOIN (лучше для единичных объектов) statement = select(Hero).options(joinedload(Hero.team)) heroes = session.exec(statement).unique().all() # .unique() нужен при joinedload, чтобы убрать дубли for h in heroes: print(h.name, h.team.name) # ✅ ``` ### Решение 2: Настройка lazy по умолчанию ```python class Team(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str heroes: list["Hero"] = Relationship( back_populates="team", sa_relationship_kwargs={"lazy": "selectin"}, # ← всегда загружать ) ``` | Стратегия | Описание | |---|---| | `"select"` (default) | Lazy — отдельный запрос при обращении | | `"selectin"` | Eager — `SELECT ... WHERE id IN (...)` | | `"joined"` | Eager — `LEFT JOIN` | | `"subquery"` | Eager — подзапрос | | `"raise"` | Запрет lazy load (упадёт с ошибкой) | | `"noload"` | Не загружать (вернёт None/[]) | ### Решение 3: Вложенная eager-загрузка ```python from sqlalchemy.orm import selectinload # Team → Heroes → Powers statement = ( select(Team) .options( selectinload(Team.heroes).selectinload(Hero.powers) ) ) ``` --- ## 9. Связи и Pydantic-схемы (read models) **Проблема:** Relationship-поля не включаются в JSON-схему автоматически. Нужно разделять модели. ### Паттерн: Base / Table / Read ```python # ---- Базовые (общие поля) ---- class TeamBase(SQLModel): name: str class HeroBase(SQLModel): name: str team_id: int | None = None # ---- Табличные (для БД) ---- class Team(TeamBase, table=True): id: int | None = Field(default=None, primary_key=True) heroes: list["Hero"] = Relationship(back_populates="team") class Hero(HeroBase, table=True): id: int | None = Field(default=None, primary_key=True) team: Team | None = Relationship(back_populates="heroes") # ---- Read-модели (для API) ---- class HeroRead(HeroBase): id: int class TeamRead(TeamBase): id: int # ---- Read-модели со связями ---- class HeroReadWithTeam(HeroRead): team: TeamRead | None = None class TeamReadWithHeroes(TeamRead): heroes: list[HeroRead] = [] # ---- Create / Update ---- class HeroCreate(HeroBase): pass class HeroUpdate(SQLModel): name: str | None = None team_id: int | None = None ``` --- ## 10. Связи + FastAPI ```python from fastapi import FastAPI, HTTPException, Depends from sqlmodel import Session, select from sqlalchemy.orm import selectinload app = FastAPI() def get_session(): with Session(engine) as session: yield session @app.post("/teams/", response_model=TeamRead) def create_team(team: TeamBase, session: Session = Depends(get_session)): db_team = Team.model_validate(team) session.add(db_team) session.commit() session.refresh(db_team) return db_team @app.get("/teams/{team_id}", response_model=TeamReadWithHeroes) def read_team(team_id: int, session: Session = Depends(get_session)): statement = ( select(Team) .where(Team.id == team_id) .options(selectinload(Team.heroes)) # ← eager load! ) team = session.exec(statement).first() if not team: raise HTTPException(status_code=404, detail="Team not found") return team @app.get("/heroes/{hero_id}", response_model=HeroReadWithTeam) def read_hero(hero_id: int, session: Session = Depends(get_session)): statement = ( select(Hero) .where(Hero.id == hero_id) .options(joinedload(Hero.team)) ) hero = session.exec(statement).first() if not hero: raise HTTPException(status_code=404, detail="Hero not found") return hero @app.post("/heroes/", response_model=HeroRead) def create_hero(hero: HeroCreate, session: Session = Depends(get_session)): db_hero = Hero.model_validate(hero) session.add(db_hero) session.commit() session.refresh(db_hero) return db_hero # Many-to-Many: добавить героя к миссии @app.post("/missions/{mission_id}/heroes/{hero_id}") def add_hero_to_mission( mission_id: int, hero_id: int, session: Session = Depends(get_session), ): mission = session.get(Mission, mission_id) hero = session.get(Hero, hero_id) if not mission or not hero: raise HTTPException(status_code=404) mission.heroes.append(hero) session.add(mission) session.commit() return {"ok": True} ``` --- ## 11. Частые ошибки ### ❌ Ошибка 1: Забыли `back_populates` ```python # ПЛОХО — связь работает только в одну сторону class Team(SQLModel, table=True): heroes: list["Hero"] = Relationship() # нет back_populates class Hero(SQLModel, table=True): team: Team | None = Relationship() # нет back_populates ``` ```python # ХОРОШО class Team(SQLModel, table=True): heroes: list["Hero"] = Relationship(back_populates="team") class Hero(SQLModel, table=True): team: Team | None = Relationship(back_populates="heroes") ``` ### ❌ Ошибка 2: Relationship без Foreign Key ```python # 💥 sqlalchemy.exc.NoForeignKeysError class Hero(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) team: "Team" = Relationship() # нет team_id с foreign_key! ``` ### ❌ Ошибка 3: Циклический импорт ```python # models/team.py from models.hero import Hero # 💥 circular import # Решение: используйте строковые аннотации heroes: list["Hero"] = Relationship(back_populates="team") ``` И вызовите `model_rebuild()` после определения всех моделей: ```python Team.model_rebuild() Hero.model_rebuild() ``` ### ❌ Ошибка 4: Доступ к связям вне сессии ```python with Session(engine) as session: team = session.exec(select(Team)).first() print(team.heroes) # 💥 DetachedInstanceError # Решение: eager loading (см. раздел 8) ``` ### ❌ Ошибка 5: Дублирование при joinedload ```python # ПЛОХО — получите дубликаты statement = select(Team).options(joinedload(Team.heroes)) teams = session.exec(statement).all() # дубли! # ХОРОШО teams = session.exec(statement).unique().all() # ← .unique() ``` ### ❌ Ошибка 6: Relationship-поля в JSON ```python # Relationship-поля НЕ сериализуются автоматически team = session.get(Team, 1) team.model_dump() # {"id": 1, "name": "Avengers"} — heroes нет! # Для API используйте read-модели (раздел 9) ``` --- ## Шпаргалка ``` ┌─────────────────────────────────────────────────────────────────┐ │ SQLModel Relationships │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ONE-TO-MANY: │ │ Parent: children: list["Child"] = Relationship(bp="parent") │ │ Child: parent_id: int = Field(FK) │ │ parent: Parent = Relationship(bp="children") │ │ │ │ ONE-TO-ONE: │ │ Как One-to-Many, но: │ │ • unique=True на FK │ │ • sa_relationship_kwargs={"uselist": False} │ │ │ │ MANY-TO-MANY: │ │ • Создать LinkModel с двумя FK (оба PK) │ │ • link_model=LinkModel в обоих Relationship │ │ │ │ SELF-REF: │ │ • FK ссылается на свою же таблицу │ │ • sa_relationship_kwargs={"remote_side": "Model.id"} │ │ │ │ CASCADE: │ │ sa_relationship_kwargs={"cascade": "all, delete-orphan"} │ │ │ │ EAGER LOAD: │ │ select(Model).options(selectinload(Model.relation)) │ │ или lazy="selectin" в Relationship │ │ │ │ bp = back_populates FK = foreign_key="table.col" │ └─────────────────────────────────────────────────────────────────┘ ``` Этот гайд покрывает все основные сценарии работы со связями в SQLModel. Для более сложных случаев (полиморфные связи, гибридные свойства) используйте прямые возможности SQLAlchemy через `sa_relationship_kwargs`.