Última atividade 2 weeks ago

гайд по некоторым моментам SQLModel

base.md Bruto

Все важные темы SQLModel — полная карта

Общая картина

SQLModel
├── 1.  Определение моделей (Models)
├── 2.  Типы полей и валидация (Fields & Validation)
├── 3.  Связи (Relationships) ✅ уже разобрали
├── 4.  Engine и подключение к БД
├── 5.  Session и Unit of Work
├── 6.  CRUD-операции
├── 7.  Запросы (select, where, join, group_by...)
├── 8.  Миграции (Alembic)
├── 9.  Паттерн "множественных моделей" (Create/Read/Update)
├── 10. Индексы и ограничения (Constraints)
├── 11. Асинхронность (async)
├── 12. Интеграция с FastAPI
├── 13. События и хуки (Events)
├── 14. Наследование моделей
├── 15. Работа с JSON/ARRAY полями
├── 16. Raw SQL и гибридные свойства
├── 17. Тестирование
└── 18. Производительность и оптимизация

1. Определение моделей

from sqlmodel import SQLModel, Field
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
import uuid

# --- Базовая модель (не таблица) ---
class UserBase(SQLModel):
    """Pydantic-модель для валидации, без таблицы в БД"""
    name: str
    email: str

# --- Табличная модель ---
class User(UserBase, table=True):
    """Реальная таблица в БД"""
    __tablename__ = "users"  # кастомное имя таблицы

    id: int | None = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)

# --- Модель с UUID ---
class Item(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    name: str

# --- Модель с Enum ---
class Status(str, Enum):
    active = "active"
    inactive = "inactive"
    banned = "banned"

class Account(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    status: Status = Field(default=Status.active)

2. Типы полей и валидация

from sqlmodel import SQLModel, Field
from pydantic import field_validator, model_validator
from sqlalchemy import Column, String, Text, Numeric

class Product(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)

    # --- Строки ---
    name: str = Field(min_length=1, max_length=100, index=True)
    slug: str = Field(max_length=100, unique=True)
    description: str = Field(
        sa_column=Column(Text)  # TEXT вместо VARCHAR
    )

    # --- Числа ---
    price: Decimal = Field(
        max_digits=10,
        decimal_places=2,
        ge=0,  # >= 0
    )
    quantity: int = Field(default=0, ge=0, le=999999)

    # --- Булевы ---
    is_active: bool = Field(default=True)

    # --- Nullable ---
    sku: str | None = Field(default=None, max_length=50)

    # --- Со значением по умолчанию ---
    rating: float = Field(default=0.0, ge=0, le=5)

    # --- Кастомная колонка через SQLAlchemy ---
    code: str = Field(
        sa_column=Column(String(10), nullable=False, server_default="NEW")
    )

    # --- Pydantic-валидаторы работают! ---
    @field_validator("name")
    @classmethod
    def name_must_not_be_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Name cannot be blank")
        return v.strip()

    @field_validator("slug")
    @classmethod
    def slug_format(cls, v: str) -> str:
        import re
        if not re.match(r'^[a-z0-9-]+$', v):
            raise ValueError("Slug must be lowercase alphanumeric with hyphens")
        return v

    @model_validator(mode="after")
    def check_consistency(self):
        if self.price == 0 and self.quantity > 0:
            raise ValueError("Free products cannot have stock")
        return self

Маппинг типов Python → SQL

Python SQLite PostgreSQL MySQL
int INTEGER INTEGER INTEGER
float FLOAT FLOAT FLOAT
str VARCHAR VARCHAR VARCHAR
bool BOOLEAN BOOLEAN BOOLEAN
datetime DATETIME TIMESTAMP DATETIME
date DATE DATE DATE
time TIME TIME TIME
Decimal NUMERIC NUMERIC DECIMAL
bytes BLOB BYTEA BLOB
uuid.UUID CHAR(32) UUID CHAR(36)

4. Engine и подключение

from sqlmodel import create_engine, SQLModel

# --- SQLite ---
engine = create_engine(
    "sqlite:///database.db",
    echo=True,           # логировать SQL
    connect_args={"check_same_thread": False},  # для FastAPI
)

# --- PostgreSQL ---
engine = create_engine(
    "postgresql://user:password@localhost:5432/dbname",
    echo=False,
    pool_size=20,         # размер пула
    max_overflow=10,      # доп. соединения сверх пула
    pool_timeout=30,      # ожидание свободного соединения
    pool_recycle=1800,    # пересоздание соединения каждые 30 мин
    pool_pre_ping=True,   # проверка соединения перед использованием
)

# --- PostgreSQL через asyncpg ---
# pip install asyncpg
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
async_engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost:5432/dbname"
)

# --- MySQL ---
engine = create_engine(
    "mysql+pymysql://user:password@localhost:3306/dbname"
)

# --- Из переменных окружения ---
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = "sqlite:///db.sqlite3"
    db_echo: bool = False

    class Config:
        env_file = ".env"

settings = Settings()
engine = create_engine(settings.database_url, echo=settings.db_echo)

# --- Создание таблиц ---
SQLModel.metadata.create_all(engine)

5. Session и Unit of Work

from sqlmodel import Session, select

# --- Базовое использование ---
with Session(engine) as session:
    hero = Hero(name="Batman")
    session.add(hero)
    session.commit()
    session.refresh(hero)  # обновить объект из БД (получить id)
    print(hero.id)

# --- Транзакции ---
with Session(engine) as session:
    try:
        session.add(Hero(name="A"))
        session.add(Hero(name="B"))
        session.commit()  # обе записи или ни одной
    except Exception:
        session.rollback()
        raise

# --- Вложенные транзакции (savepoints) ---
with Session(engine) as session:
    session.add(Hero(name="Safe"))
    session.flush()  # отправить в БД без коммита

    session.begin_nested()  # savepoint
    try:
        session.add(Hero(name="Risky"))
        session.flush()
        raise ValueError("oops")
    except ValueError:
        session.rollback()  # откат до savepoint, "Safe" останется

    session.commit()

# --- Основные методы Session ---
session.add(obj)           # Добавить объект
session.add_all([a, b])    # Добавить несколько
session.delete(obj)        # Удалить
session.commit()           # Зафиксировать транзакцию
session.rollback()         # Откатить
session.refresh(obj)       # Обновить из БД
session.flush()            # Отправить в БД без коммита
session.get(Model, pk)     # Получить по PK (кэшируется)
session.exec(statement)    # Выполнить запрос
session.expire(obj)        # Пометить как устаревший
session.expunge(obj)       # Отсоединить от сессии
session.merge(obj)         # Слить объект в сессию

6. CRUD-операции

from sqlmodel import Session, select

# ═══════════════ CREATE ═══════════════
with Session(engine) as session:
    hero = Hero(name="Spider-Man", age=25)
    session.add(hero)
    session.commit()
    session.refresh(hero)

    # Массовое создание
    heroes = [Hero(name=f"Hero-{i}") for i in range(100)]
    session.add_all(heroes)
    session.commit()

# ═══════════════ READ ═══════════════
with Session(engine) as session:
    # По ID
    hero = session.get(Hero, 1)

    # Один результат
    stmt = select(Hero).where(Hero.name == "Spider-Man")
    hero = session.exec(stmt).first()       # None если нет
    hero = session.exec(stmt).one()          # Ошибка если нет или > 1
    hero = session.exec(stmt).one_or_none()  # None или ошибка если > 1

    # Все результаты
    heroes = session.exec(select(Hero)).all()

# ═══════════════ UPDATE ═══════════════
with Session(engine) as session:
    hero = session.get(Hero, 1)
    hero.name = "New Name"
    hero.age = 30
    session.add(hero)
    session.commit()
    session.refresh(hero)

    # Обновление из словаря (Pydantic)
    update_data = HeroUpdate(name="Updated")
    hero_data = update_data.model_dump(exclude_unset=True)
    for key, value in hero_data.items():
        setattr(hero, key, value)
    session.add(hero)
    session.commit()

# ═══════════════ DELETE ═══════════════
with Session(engine) as session:
    hero = session.get(Hero, 1)
    session.delete(hero)
    session.commit()

7. Запросы (самая обширная тема)

from sqlmodel import select, or_, and_, not_, col, func, text

# ═══════════════ WHERE ═══════════════
select(Hero).where(Hero.name == "Batman")
select(Hero).where(Hero.age >= 18)
select(Hero).where(Hero.age != None)  # noqa
select(Hero).where(Hero.name.contains("man"))
select(Hero).where(Hero.name.startswith("B"))
select(Hero).where(Hero.name.endswith("man"))
select(Hero).where(Hero.name.in_(["Batman", "Superman"]))
select(Hero).where(Hero.name.not_in(["Joker"]))
select(Hero).where(Hero.age.between(18, 30))
select(Hero).where(Hero.name.like("%man%"))
select(Hero).where(Hero.name.ilike("%man%"))  # case-insensitive

# --- Комбинированные условия ---
select(Hero).where(Hero.age >= 18, Hero.age <= 30)         # AND (неявный)
select(Hero).where(and_(Hero.age >= 18, Hero.age <= 30))   # AND (явный)
select(Hero).where(or_(Hero.name == "A", Hero.name == "B"))
select(Hero).where(not_(Hero.age < 18))

# ═══════════════ ORDER BY ═══════════════
select(Hero).order_by(Hero.name)                    # ASC
select(Hero).order_by(Hero.name.desc())             # DESC
select(Hero).order_by(Hero.age.desc(), Hero.name)   # несколько

# ═══════════════ LIMIT / OFFSET ═══════════════
select(Hero).offset(10).limit(20)  # пагинация

# ═══════════════ DISTINCT ═══════════════
select(Hero.name).distinct()

# ═══════════════ GROUP BY + HAVING ═══════════════
from sqlmodel import func

statement = (
    select(Hero.team_id, func.count(Hero.id).label("cnt"))
    .group_by(Hero.team_id)
    .having(func.count(Hero.id) > 3)
)

# ═══════════════ AGGREGATE ═══════════════
session.exec(select(func.count()).select_from(Hero)).one()
session.exec(select(func.max(Hero.age))).one()
session.exec(select(func.min(Hero.age))).one()
session.exec(select(func.avg(Hero.age))).one()
session.exec(select(func.sum(Hero.age))).one()

# ═══════════════ JOIN ═══════════════
# Автоматический (по FK)
select(Hero, Team).join(Team)
select(Hero, Team).join(Team, isouter=True)  # LEFT JOIN

# Явный
select(Hero, Team).join(Team, Hero.team_id == Team.id)

# Выбор конкретных полей
select(Hero.name, Team.name).join(Team)

# ═══════════════ SUBQUERY ═══════════════
subq = select(func.avg(Hero.age)).scalar_subquery()
statement = select(Hero).where(Hero.age > subq)

# ═══════════════ RAW SQL ═══════════════
from sqlmodel import text

with Session(engine) as session:
    result = session.exec(
        text("SELECT * FROM hero WHERE age > :age"),
        params={"age": 18}
    )
    for row in result:
        print(row)

# ═══════════════ EXISTS ═══════════════
from sqlalchemy import exists

subq = select(Hero).where(Hero.team_id == Team.id).exists()
statement = select(Team).where(subq)

# ═══════════════ CASE ═══════════════
from sqlalchemy import case

statement = select(
    Hero.name,
    case(
        (Hero.age < 18, "minor"),
        (Hero.age < 65, "adult"),
        else_="senior"
    ).label("category")
)

8. Миграции (Alembic)

# Установка
pip install alembic

# Инициализация
alembic init alembic

alembic/env.py — ключевые изменения:

from sqlmodel import SQLModel
from app.models import *  # импортировать ВСЕ модели!

target_metadata = SQLModel.metadata  # ← вместо None
# Создать миграцию
alembic revision --autogenerate -m "create users table"

# Применить
alembic upgrade head

# Откатить
alembic downgrade -1

# Посмотреть историю
alembic history

# Текущая версия
alembic current

Пример сгенерированной миграции:

def upgrade():
    op.create_table(
        'hero',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(), nullable=False),
        sa.Column('age', sa.Integer(), nullable=True),
        sa.PrimaryKeyConstraint('id'),
    )
    op.create_index('ix_hero_name', 'hero', ['name'])

def downgrade():
    op.drop_index('ix_hero_name', 'hero')
    op.drop_table('hero')

9. Паттерн множественных моделей

# ═══════ Base (общие поля) ═══════
class HeroBase(SQLModel):
    name: str = Field(min_length=1, max_length=100)
    age: int | None = Field(default=None, ge=0)
    team_id: int | None = None

# ═══════ Table (БД) ═══════
class Hero(HeroBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    team: "Team | None" = Relationship(back_populates="heroes")

# ═══════ Create (входные данные) ═══════
class HeroCreate(HeroBase):
    pass  # наследует name, age, team_id

# ═══════ Read (ответ API) ═══════
class HeroRead(HeroBase):
    id: int
    created_at: datetime

# ═══════ Read with relations ═══════
class HeroReadFull(HeroRead):
    team: "TeamRead | None" = None

# ═══════ Update (частичное обновление) ═══════
class HeroUpdate(SQLModel):
    name: str | None = None      # все поля Optional
    age: int | None = None
    team_id: int | None = None

# ═══════ List response ═══════
class HeroListResponse(SQLModel):
    data: list[HeroRead]
    total: int
    page: int
    per_page: int

10. Индексы и ограничения

from sqlmodel import SQLModel, Field
from sqlalchemy import UniqueConstraint, Index, CheckConstraint

class Product(SQLModel, table=True):
    __table_args__ = (
        # Составной уникальный ключ
        UniqueConstraint("sku", "warehouse_id", name="uq_sku_warehouse"),

        # Составной индекс
        Index("ix_category_name", "category", "name"),

        # Check constraint
        CheckConstraint("price >= 0", name="ck_positive_price"),
        CheckConstraint("quantity >= 0", name="ck_positive_qty"),
    )

    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)               # простой индекс
    sku: str = Field(max_length=50)
    warehouse_id: int
    category: str
    price: float = Field(ge=0)
    quantity: int = Field(ge=0)
    email: str = Field(unique=True)              # уникальное поле

11. Асинхронность (async)

# pip install sqlmodel aiosqlite  (или asyncpg для PostgreSQL)

from sqlmodel import SQLModel, Field, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.orm import sessionmaker

# --- Engine ---
async_engine = create_async_engine(
    "sqlite+aiosqlite:///database.db",
    # "postgresql+asyncpg://user:pass@localhost/db",
    echo=True,
)

# --- Создание таблиц ---
async def create_db():
    async with async_engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)

# --- Session factory ---
async_session_factory = sessionmaker(
    async_engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

# --- CRUD ---
async def create_hero(name: str) -> Hero:
    async with AsyncSession(async_engine) as session:
        hero = Hero(name=name)
        session.add(hero)
        await session.commit()
        await session.refresh(hero)
        return hero

async def get_heroes() -> list[Hero]:
    async with AsyncSession(async_engine) as session:
        result = await session.exec(select(Hero))
        return result.all()

async def get_hero(hero_id: int) -> Hero | None:
    async with AsyncSession(async_engine) as session:
        return await session.get(Hero, hero_id)

# --- FastAPI + async ---
from fastapi import FastAPI, Depends

app = FastAPI()

async def get_session():
    async with AsyncSession(async_engine) as session:
        yield session

@app.get("/heroes/")
async def read_heroes(session: AsyncSession = Depends(get_session)):
    heroes = await session.exec(select(Hero))
    return heroes.all()

# --- Eager loading (async) ---
from sqlalchemy.orm import selectinload

async def get_team_with_heroes(team_id: int):
    async with AsyncSession(async_engine) as session:
        statement = (
            select(Team)
            .where(Team.id == team_id)
            .options(selectinload(Team.heroes))
        )
        result = await session.exec(statement)
        return result.first()

12. Интеграция с FastAPI (продвинутая)

from fastapi import FastAPI, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from contextlib import asynccontextmanager

# --- Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)
    yield
    # cleanup

app = FastAPI(lifespan=lifespan)

# --- Dependency ---
def get_session():
    with Session(engine) as session:
        yield session

SessionDep = Depends(get_session)

# --- Пагинация ---
@app.get("/heroes/", response_model=list[HeroRead])
def list_heroes(
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=20, le=100),
    search: str | None = None,
    session: Session = SessionDep,
):
    statement = select(Hero)
    if search:
        statement = statement.where(Hero.name.ilike(f"%{search}%"))
    statement = statement.offset(offset).limit(limit)
    return session.exec(statement).all()

# --- Фильтрация + сортировка ---
@app.get("/heroes/advanced", response_model=list[HeroRead])
def list_heroes_advanced(
    min_age: int | None = None,
    max_age: int | None = None,
    team_id: int | None = None,
    sort_by: str = "name",
    sort_order: str = "asc",
    session: Session = SessionDep,
):
    statement = select(Hero)

    if min_age is not None:
        statement = statement.where(Hero.age >= min_age)
    if max_age is not None:
        statement = statement.where(Hero.age <= max_age)
    if team_id is not None:
        statement = statement.where(Hero.team_id == team_id)

    sort_column = getattr(Hero, sort_by, Hero.name)
    if sort_order == "desc":
        sort_column = sort_column.desc()
    statement = statement.order_by(sort_column)

    return session.exec(statement).all()

# --- PATCH (частичное обновление) ---
@app.patch("/heroes/{hero_id}", response_model=HeroRead)
def update_hero(
    hero_id: int,
    hero_update: HeroUpdate,
    session: Session = SessionDep,
):
    hero = session.get(Hero, hero_id)
    if not hero:
        raise HTTPException(status_code=404, detail="Hero not found")

    hero_data = hero_update.model_dump(exclude_unset=True)
    hero.sqlmodel_update(hero_data)  # SQLModel 0.0.14+
    session.add(hero)
    session.commit()
    session.refresh(hero)
    return hero

13. События и хуки

from sqlalchemy import event
from sqlmodel import Session

# --- Before insert ---
@event.listens_for(Hero, "before_insert")
def hero_before_insert(mapper, connection, target):
    target.name = target.name.strip().title()

# --- After update ---
@event.listens_for(Hero, "after_update")
def hero_after_update(mapper, connection, target):
    print(f"Hero {target.id} was updated")

# --- Before delete ---
@event.listens_for(Hero, "before_delete")
def hero_before_delete(mapper, connection, target):
    print(f"About to delete hero {target.name}")

# --- Session events ---
@event.listens_for(Session, "after_commit")
def after_commit(session):
    print("Transaction committed")

# --- Валидация через set ---
@event.listens_for(Hero.age, "set")
def validate_age(target, value, oldvalue, initiator):
    if value is not None and value < 0:
        raise ValueError("Age cannot be negative")

14. Наследование моделей

Single Table Inheritance

from sqlmodel import SQLModel, Field

class Employee(SQLModel, table=True):
    __tablename__ = "employee"

    id: int | None = Field(default=None, primary_key=True)
    name: str
    type: str  # discriminator

    __mapper_args__ = {
        "polymorphic_on": "type",
        "polymorphic_identity": "employee",
    }

class Manager(Employee):
    department: str | None = None

    __mapper_args__ = {
        "polymorphic_identity": "manager",
    }

class Engineer(Employee):
    language: str | None = None

    __mapper_args__ = {
        "polymorphic_identity": "engineer",
    }

15. JSON и сложные типы

from sqlmodel import SQLModel, Field
from sqlalchemy import Column, JSON
from typing import Any

class Config(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

    # JSON поле
    settings: dict[str, Any] = Field(
        default_factory=dict,
        sa_column=Column(JSON),
    )

    tags: list[str] = Field(
        default_factory=list,
        sa_column=Column(JSON),
    )
# PostgreSQL ARRAY
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Column, String

class Post(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    tags: list[str] = Field(
        sa_column=Column(ARRAY(String))
    )

16. Raw SQL и гибридные свойства

from sqlalchemy.ext.hybrid import hybrid_property
from sqlmodel import SQLModel, Field

class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    first_name: str
    last_name: str
    birth_year: int

    # Вычисляемое свойство (Python-уровень)
    @hybrid_property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

    # SQL-уровень (можно использовать в WHERE)
    @full_name.expression
    @classmethod
    def full_name(cls):
        return cls.first_name + " " + cls.last_name

# Теперь можно:
select(User).where(User.full_name == "John Doe")

17. Тестирование

import pytest
from sqlmodel import SQLModel, Session, create_engine, select
from sqlmodel.pool import StaticPool

@pytest.fixture(name="session")
def session_fixture():
    """Новая in-memory БД для каждого теста"""
    engine = create_engine(
        "sqlite://",  # in-memory
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,  # ← важно для SQLite in-memory
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session

def test_create_hero(session: Session):
    hero = Hero(name="Test Hero", age=25)
    session.add(hero)
    session.commit()
    session.refresh(hero)

    assert hero.id is not None
    assert hero.name == "Test Hero"

def test_read_heroes(session: Session):
    session.add_all([
        Hero(name="A"),
        Hero(name="B"),
    ])
    session.commit()

    heroes = session.exec(select(Hero)).all()
    assert len(heroes) == 2

# --- Тестирование FastAPI ---
from fastapi.testclient import TestClient

@pytest.fixture(name="client")
def client_fixture(session: Session):
    def get_session_override():
        yield session

    app.dependency_overrides[get_session] = get_session_override
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

def test_api_create_hero(client: TestClient):
    response = client.post("/heroes/", json={"name": "API Hero", "age": 30})
    assert response.status_code == 200
    data = response.json()
    assert data["name"] == "API Hero"
    assert "id" in data

18. Производительность и оптимизация

# ═══════ Bulk операции ═══════
from sqlalchemy import insert, update, delete

# Массовая вставка (быстрее чем add_all)
with Session(engine) as session:
    session.exec(
        insert(Hero),
        [{"name": f"Hero-{i}", "age": i} for i in range(10000)]
    )
    session.commit()

# Массовое обновление
with Session(engine) as session:
    session.exec(
        update(Hero)
        .where(Hero.age < 18)
        .values(team_id=None)
    )
    session.commit()

# Массовое удаление
with Session(engine) as session:
    session.exec(
        delete(Hero).where(Hero.team_id == None)
    )
    session.commit()

# ═══════ Выборка только нужных колонок ═══════
# Вместо select(Hero) — все колонки
select(Hero.id, Hero.name)  # только id и name

# ═══════ Подсчёт без загрузки ═══════
count = session.exec(
    select(func.count()).select_from(Hero)
).one()

# ═══════ Пагинация по курсору (для больших таблиц) ═══════
def get_heroes_cursor(session: Session, after_id: int = 0, limit: int = 20):
    statement = (
        select(Hero)
        .where(Hero.id > after_id)
        .order_by(Hero.id)
        .limit(limit)
    )
    return session.exec(statement).all()

# ═══════ expire_on_commit ═══════
# Отключить автообновление после коммита (быстрее)
with Session(engine, expire_on_commit=False) as session:
    hero = Hero(name="Fast")
    session.add(hero)
    session.commit()
    # hero.name доступен без refresh

Итоговая шпаргалка

╔══════════════════════════════════════════════════════════════╗
║                    SQLModel — ВСЁ В ОДНОМ                    ║
╠══════════════════════════════════════════════════════════════╣
║                                                              ║
║  МОДЕЛИ:        SQLModel + table=True / table=False          ║
║  ПОЛЯ:          Field(FK, index, unique, ge, le, max_length) ║
║  СВЯЗИ:         Relationship(back_populates, link_model)     ║
║  ЗАПРОСЫ:       select().where().join().order_by().limit()    ║
║  СЕССИЯ:        add / commit / refresh / delete / exec       ║
║  ВАЛИДАЦИЯ:     field_validator / model_validator (Pydantic)  ║
║  МИГРАЦИИ:      Alembic + SQLModel.metadata                  ║
║  ASYNC:         AsyncSession + create_async_engine            ║
║  ТЕСТЫ:         sqlite:// + StaticPool + dependency override  ║
║  КАСКАДЫ:       sa_relationship_kwargs={"cascade": "..."}    ║
║  ИНДЕКСЫ:       Field(index=True) / __table_args__           ║
║  JSON:          sa_column=Column(JSON)                       ║
║  ПРОИЗВОД-ТЬ:  bulk insert/update, selectinload, курсоры    ║
║                                                              ║
║  Если не хватает SQLModel → используй sa_column / sa_*       ║
║  SQLModel = SQLAlchemy + Pydantic (всё из обоих доступно)    ║
║                                                              ║
╚══════════════════════════════════════════════════════════════╝

Это покрывает ~95% всех задач с SQLModel. Оставшиеся 5% — это прямое использование SQLAlchemy Core/ORM, которое SQLModel полностью поддерживает через sa_column, sa_relationship_kwargs и прямой доступ к __table__.

relationships.md Bruto

Полный гайд по связям в SQLModel

Оглавление

  1. Основы
  2. One-to-Many (Один ко многим)
  3. Many-to-One (Многие к одному)
  4. One-to-One (Один к одному)
  5. Many-to-Many (Многие ко многим)
  6. Самореферентные связи
  7. Каскадные операции
  8. Загрузка связей (Lazy / Eager)
  9. Связи и Pydantic-схемы (read models)
  10. Связи + FastAPI
  11. Частые ошибки и подводные камни

1. Основы

Два ключевых инструмента

from sqlmodel import SQLModel, Field, Relationship
Инструмент Назначение
Field(foreign_key="table.column") Создаёт внешний ключ в БД (колонку)
Relationship(back_populates="...") Создаёт 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

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")

Создание записей

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()

Чтение связей

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. Технически — то же самое, просто смотрим с другой стороны.

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.

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 — гарантирует уникальность на уровне БД.

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-таблица

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,
    )

Использование

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-таблица с дополнительными полями

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:

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")
# Создание с доп. данными
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)

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")
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 на себя)

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:

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" Самый агрессивный — полное владение
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:

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 вне сессии

with Session(engine) as session:
    team = session.exec(select(Team)).first()

# Вне сессии:
print(team.heroes)  # 💥 DetachedInstanceError!

Решение 1: Eager Loading (selectinload / joinedload)

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)  # ✅ работает
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 по умолчанию

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-загрузка

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

# ---- Базовые (общие поля) ----
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

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

# ПЛОХО — связь работает только в одну сторону
class Team(SQLModel, table=True):
    heroes: list["Hero"] = Relationship()  # нет back_populates

class Hero(SQLModel, table=True):
    team: Team | None = Relationship()  # нет back_populates
# ХОРОШО
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

# 💥 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: Циклический импорт

# models/team.py
from models.hero import Hero  # 💥 circular import

# Решение: используйте строковые аннотации
heroes: list["Hero"] = Relationship(back_populates="team")

И вызовите model_rebuild() после определения всех моделей:

Team.model_rebuild()
Hero.model_rebuild()

❌ Ошибка 4: Доступ к связям вне сессии

with Session(engine) as session:
    team = session.exec(select(Team)).first()

print(team.heroes)  # 💥 DetachedInstanceError

# Решение: eager loading (см. раздел 8)

❌ Ошибка 5: Дублирование при joinedload

# ПЛОХО — получите дубликаты
statement = select(Team).options(joinedload(Team.heroes))
teams = session.exec(statement).all()  # дубли!

# ХОРОШО
teams = session.exec(statement).unique().all()  # ← .unique()

❌ Ошибка 6: Relationship-поля в JSON

# 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.