wowlikon revidoval tento gist 2 weeks ago. Přejít na revizi
2 files changed, 1777 insertions
base.md(vytvořil soubor)
| @@ -0,0 +1,994 @@ | |||
| 1 | + | ||
| 2 | + | ||
| 3 | + | # Все важные темы SQLModel — полная карта | |
| 4 | + | ||
| 5 | + | ## Общая картина | |
| 6 | + | ||
| 7 | + | ``` | |
| 8 | + | SQLModel | |
| 9 | + | ├── 1. Определение моделей (Models) | |
| 10 | + | ├── 2. Типы полей и валидация (Fields & Validation) | |
| 11 | + | ├── 3. Связи (Relationships) ✅ уже разобрали | |
| 12 | + | ├── 4. Engine и подключение к БД | |
| 13 | + | ├── 5. Session и Unit of Work | |
| 14 | + | ├── 6. CRUD-операции | |
| 15 | + | ├── 7. Запросы (select, where, join, group_by...) | |
| 16 | + | ├── 8. Миграции (Alembic) | |
| 17 | + | ├── 9. Паттерн "множественных моделей" (Create/Read/Update) | |
| 18 | + | ├── 10. Индексы и ограничения (Constraints) | |
| 19 | + | ├── 11. Асинхронность (async) | |
| 20 | + | ├── 12. Интеграция с FastAPI | |
| 21 | + | ├── 13. События и хуки (Events) | |
| 22 | + | ├── 14. Наследование моделей | |
| 23 | + | ├── 15. Работа с JSON/ARRAY полями | |
| 24 | + | ├── 16. Raw SQL и гибридные свойства | |
| 25 | + | ├── 17. Тестирование | |
| 26 | + | └── 18. Производительность и оптимизация | |
| 27 | + | ``` | |
| 28 | + | ||
| 29 | + | --- | |
| 30 | + | ||
| 31 | + | ## 1. Определение моделей | |
| 32 | + | ||
| 33 | + | ```python | |
| 34 | + | from sqlmodel import SQLModel, Field | |
| 35 | + | from datetime import datetime, date | |
| 36 | + | from decimal import Decimal | |
| 37 | + | from enum import Enum | |
| 38 | + | import uuid | |
| 39 | + | ||
| 40 | + | # --- Базовая модель (не таблица) --- | |
| 41 | + | class UserBase(SQLModel): | |
| 42 | + | """Pydantic-модель для валидации, без таблицы в БД""" | |
| 43 | + | name: str | |
| 44 | + | email: str | |
| 45 | + | ||
| 46 | + | # --- Табличная модель --- | |
| 47 | + | class User(UserBase, table=True): | |
| 48 | + | """Реальная таблица в БД""" | |
| 49 | + | __tablename__ = "users" # кастомное имя таблицы | |
| 50 | + | ||
| 51 | + | id: int | None = Field(default=None, primary_key=True) | |
| 52 | + | created_at: datetime = Field(default_factory=datetime.utcnow) | |
| 53 | + | ||
| 54 | + | # --- Модель с UUID --- | |
| 55 | + | class Item(SQLModel, table=True): | |
| 56 | + | id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) | |
| 57 | + | name: str | |
| 58 | + | ||
| 59 | + | # --- Модель с Enum --- | |
| 60 | + | class Status(str, Enum): | |
| 61 | + | active = "active" | |
| 62 | + | inactive = "inactive" | |
| 63 | + | banned = "banned" | |
| 64 | + | ||
| 65 | + | class Account(SQLModel, table=True): | |
| 66 | + | id: int | None = Field(default=None, primary_key=True) | |
| 67 | + | status: Status = Field(default=Status.active) | |
| 68 | + | ``` | |
| 69 | + | ||
| 70 | + | --- | |
| 71 | + | ||
| 72 | + | ## 2. Типы полей и валидация | |
| 73 | + | ||
| 74 | + | ```python | |
| 75 | + | from sqlmodel import SQLModel, Field | |
| 76 | + | from pydantic import field_validator, model_validator | |
| 77 | + | from sqlalchemy import Column, String, Text, Numeric | |
| 78 | + | ||
| 79 | + | class Product(SQLModel, table=True): | |
| 80 | + | id: int | None = Field(default=None, primary_key=True) | |
| 81 | + | ||
| 82 | + | # --- Строки --- | |
| 83 | + | name: str = Field(min_length=1, max_length=100, index=True) | |
| 84 | + | slug: str = Field(max_length=100, unique=True) | |
| 85 | + | description: str = Field( | |
| 86 | + | sa_column=Column(Text) # TEXT вместо VARCHAR | |
| 87 | + | ) | |
| 88 | + | ||
| 89 | + | # --- Числа --- | |
| 90 | + | price: Decimal = Field( | |
| 91 | + | max_digits=10, | |
| 92 | + | decimal_places=2, | |
| 93 | + | ge=0, # >= 0 | |
| 94 | + | ) | |
| 95 | + | quantity: int = Field(default=0, ge=0, le=999999) | |
| 96 | + | ||
| 97 | + | # --- Булевы --- | |
| 98 | + | is_active: bool = Field(default=True) | |
| 99 | + | ||
| 100 | + | # --- Nullable --- | |
| 101 | + | sku: str | None = Field(default=None, max_length=50) | |
| 102 | + | ||
| 103 | + | # --- Со значением по умолчанию --- | |
| 104 | + | rating: float = Field(default=0.0, ge=0, le=5) | |
| 105 | + | ||
| 106 | + | # --- Кастомная колонка через SQLAlchemy --- | |
| 107 | + | code: str = Field( | |
| 108 | + | sa_column=Column(String(10), nullable=False, server_default="NEW") | |
| 109 | + | ) | |
| 110 | + | ||
| 111 | + | # --- Pydantic-валидаторы работают! --- | |
| 112 | + | @field_validator("name") | |
| 113 | + | @classmethod | |
| 114 | + | def name_must_not_be_empty(cls, v: str) -> str: | |
| 115 | + | if not v.strip(): | |
| 116 | + | raise ValueError("Name cannot be blank") | |
| 117 | + | return v.strip() | |
| 118 | + | ||
| 119 | + | @field_validator("slug") | |
| 120 | + | @classmethod | |
| 121 | + | def slug_format(cls, v: str) -> str: | |
| 122 | + | import re | |
| 123 | + | if not re.match(r'^[a-z0-9-]+$', v): | |
| 124 | + | raise ValueError("Slug must be lowercase alphanumeric with hyphens") | |
| 125 | + | return v | |
| 126 | + | ||
| 127 | + | @model_validator(mode="after") | |
| 128 | + | def check_consistency(self): | |
| 129 | + | if self.price == 0 and self.quantity > 0: | |
| 130 | + | raise ValueError("Free products cannot have stock") | |
| 131 | + | return self | |
| 132 | + | ``` | |
| 133 | + | ||
| 134 | + | ### Маппинг типов Python → SQL | |
| 135 | + | ||
| 136 | + | | Python | SQLite | PostgreSQL | MySQL | | |
| 137 | + | |---|---|---|---| | |
| 138 | + | | `int` | INTEGER | INTEGER | INTEGER | | |
| 139 | + | | `float` | FLOAT | FLOAT | FLOAT | | |
| 140 | + | | `str` | VARCHAR | VARCHAR | VARCHAR | | |
| 141 | + | | `bool` | BOOLEAN | BOOLEAN | BOOLEAN | | |
| 142 | + | | `datetime` | DATETIME | TIMESTAMP | DATETIME | | |
| 143 | + | | `date` | DATE | DATE | DATE | | |
| 144 | + | | `time` | TIME | TIME | TIME | | |
| 145 | + | | `Decimal` | NUMERIC | NUMERIC | DECIMAL | | |
| 146 | + | | `bytes` | BLOB | BYTEA | BLOB | | |
| 147 | + | | `uuid.UUID` | CHAR(32) | UUID | CHAR(36) | | |
| 148 | + | ||
| 149 | + | --- | |
| 150 | + | ||
| 151 | + | ## 4. Engine и подключение | |
| 152 | + | ||
| 153 | + | ```python | |
| 154 | + | from sqlmodel import create_engine, SQLModel | |
| 155 | + | ||
| 156 | + | # --- SQLite --- | |
| 157 | + | engine = create_engine( | |
| 158 | + | "sqlite:///database.db", | |
| 159 | + | echo=True, # логировать SQL | |
| 160 | + | connect_args={"check_same_thread": False}, # для FastAPI | |
| 161 | + | ) | |
| 162 | + | ||
| 163 | + | # --- PostgreSQL --- | |
| 164 | + | engine = create_engine( | |
| 165 | + | "postgresql://user:password@localhost:5432/dbname", | |
| 166 | + | echo=False, | |
| 167 | + | pool_size=20, # размер пула | |
| 168 | + | max_overflow=10, # доп. соединения сверх пула | |
| 169 | + | pool_timeout=30, # ожидание свободного соединения | |
| 170 | + | pool_recycle=1800, # пересоздание соединения каждые 30 мин | |
| 171 | + | pool_pre_ping=True, # проверка соединения перед использованием | |
| 172 | + | ) | |
| 173 | + | ||
| 174 | + | # --- PostgreSQL через asyncpg --- | |
| 175 | + | # pip install asyncpg | |
| 176 | + | from sqlmodel.ext.asyncio.session import AsyncSession | |
| 177 | + | from sqlalchemy.ext.asyncio import create_async_engine | |
| 178 | + | async_engine = create_async_engine( | |
| 179 | + | "postgresql+asyncpg://user:password@localhost:5432/dbname" | |
| 180 | + | ) | |
| 181 | + | ||
| 182 | + | # --- MySQL --- | |
| 183 | + | engine = create_engine( | |
| 184 | + | "mysql+pymysql://user:password@localhost:3306/dbname" | |
| 185 | + | ) | |
| 186 | + | ||
| 187 | + | # --- Из переменных окружения --- | |
| 188 | + | from pydantic_settings import BaseSettings | |
| 189 | + | ||
| 190 | + | class Settings(BaseSettings): | |
| 191 | + | database_url: str = "sqlite:///db.sqlite3" | |
| 192 | + | db_echo: bool = False | |
| 193 | + | ||
| 194 | + | class Config: | |
| 195 | + | env_file = ".env" | |
| 196 | + | ||
| 197 | + | settings = Settings() | |
| 198 | + | engine = create_engine(settings.database_url, echo=settings.db_echo) | |
| 199 | + | ||
| 200 | + | # --- Создание таблиц --- | |
| 201 | + | SQLModel.metadata.create_all(engine) | |
| 202 | + | ``` | |
| 203 | + | ||
| 204 | + | --- | |
| 205 | + | ||
| 206 | + | ## 5. Session и Unit of Work | |
| 207 | + | ||
| 208 | + | ```python | |
| 209 | + | from sqlmodel import Session, select | |
| 210 | + | ||
| 211 | + | # --- Базовое использование --- | |
| 212 | + | with Session(engine) as session: | |
| 213 | + | hero = Hero(name="Batman") | |
| 214 | + | session.add(hero) | |
| 215 | + | session.commit() | |
| 216 | + | session.refresh(hero) # обновить объект из БД (получить id) | |
| 217 | + | print(hero.id) | |
| 218 | + | ||
| 219 | + | # --- Транзакции --- | |
| 220 | + | with Session(engine) as session: | |
| 221 | + | try: | |
| 222 | + | session.add(Hero(name="A")) | |
| 223 | + | session.add(Hero(name="B")) | |
| 224 | + | session.commit() # обе записи или ни одной | |
| 225 | + | except Exception: | |
| 226 | + | session.rollback() | |
| 227 | + | raise | |
| 228 | + | ||
| 229 | + | # --- Вложенные транзакции (savepoints) --- | |
| 230 | + | with Session(engine) as session: | |
| 231 | + | session.add(Hero(name="Safe")) | |
| 232 | + | session.flush() # отправить в БД без коммита | |
| 233 | + | ||
| 234 | + | session.begin_nested() # savepoint | |
| 235 | + | try: | |
| 236 | + | session.add(Hero(name="Risky")) | |
| 237 | + | session.flush() | |
| 238 | + | raise ValueError("oops") | |
| 239 | + | except ValueError: | |
| 240 | + | session.rollback() # откат до savepoint, "Safe" останется | |
| 241 | + | ||
| 242 | + | session.commit() | |
| 243 | + | ||
| 244 | + | # --- Основные методы Session --- | |
| 245 | + | session.add(obj) # Добавить объект | |
| 246 | + | session.add_all([a, b]) # Добавить несколько | |
| 247 | + | session.delete(obj) # Удалить | |
| 248 | + | session.commit() # Зафиксировать транзакцию | |
| 249 | + | session.rollback() # Откатить | |
| 250 | + | session.refresh(obj) # Обновить из БД | |
| 251 | + | session.flush() # Отправить в БД без коммита | |
| 252 | + | session.get(Model, pk) # Получить по PK (кэшируется) | |
| 253 | + | session.exec(statement) # Выполнить запрос | |
| 254 | + | session.expire(obj) # Пометить как устаревший | |
| 255 | + | session.expunge(obj) # Отсоединить от сессии | |
| 256 | + | session.merge(obj) # Слить объект в сессию | |
| 257 | + | ``` | |
| 258 | + | ||
| 259 | + | --- | |
| 260 | + | ||
| 261 | + | ## 6. CRUD-операции | |
| 262 | + | ||
| 263 | + | ```python | |
| 264 | + | from sqlmodel import Session, select | |
| 265 | + | ||
| 266 | + | # ═══════════════ CREATE ═══════════════ | |
| 267 | + | with Session(engine) as session: | |
| 268 | + | hero = Hero(name="Spider-Man", age=25) | |
| 269 | + | session.add(hero) | |
| 270 | + | session.commit() | |
| 271 | + | session.refresh(hero) | |
| 272 | + | ||
| 273 | + | # Массовое создание | |
| 274 | + | heroes = [Hero(name=f"Hero-{i}") for i in range(100)] | |
| 275 | + | session.add_all(heroes) | |
| 276 | + | session.commit() | |
| 277 | + | ||
| 278 | + | # ═══════════════ READ ═══════════════ | |
| 279 | + | with Session(engine) as session: | |
| 280 | + | # По ID | |
| 281 | + | hero = session.get(Hero, 1) | |
| 282 | + | ||
| 283 | + | # Один результат | |
| 284 | + | stmt = select(Hero).where(Hero.name == "Spider-Man") | |
| 285 | + | hero = session.exec(stmt).first() # None если нет | |
| 286 | + | hero = session.exec(stmt).one() # Ошибка если нет или > 1 | |
| 287 | + | hero = session.exec(stmt).one_or_none() # None или ошибка если > 1 | |
| 288 | + | ||
| 289 | + | # Все результаты | |
| 290 | + | heroes = session.exec(select(Hero)).all() | |
| 291 | + | ||
| 292 | + | # ═══════════════ UPDATE ═══════════════ | |
| 293 | + | with Session(engine) as session: | |
| 294 | + | hero = session.get(Hero, 1) | |
| 295 | + | hero.name = "New Name" | |
| 296 | + | hero.age = 30 | |
| 297 | + | session.add(hero) | |
| 298 | + | session.commit() | |
| 299 | + | session.refresh(hero) | |
| 300 | + | ||
| 301 | + | # Обновление из словаря (Pydantic) | |
| 302 | + | update_data = HeroUpdate(name="Updated") | |
| 303 | + | hero_data = update_data.model_dump(exclude_unset=True) | |
| 304 | + | for key, value in hero_data.items(): | |
| 305 | + | setattr(hero, key, value) | |
| 306 | + | session.add(hero) | |
| 307 | + | session.commit() | |
| 308 | + | ||
| 309 | + | # ═══════════════ DELETE ═══════════════ | |
| 310 | + | with Session(engine) as session: | |
| 311 | + | hero = session.get(Hero, 1) | |
| 312 | + | session.delete(hero) | |
| 313 | + | session.commit() | |
| 314 | + | ``` | |
| 315 | + | ||
| 316 | + | --- | |
| 317 | + | ||
| 318 | + | ## 7. Запросы (самая обширная тема) | |
| 319 | + | ||
| 320 | + | ```python | |
| 321 | + | from sqlmodel import select, or_, and_, not_, col, func, text | |
| 322 | + | ||
| 323 | + | # ═══════════════ WHERE ═══════════════ | |
| 324 | + | select(Hero).where(Hero.name == "Batman") | |
| 325 | + | select(Hero).where(Hero.age >= 18) | |
| 326 | + | select(Hero).where(Hero.age != None) # noqa | |
| 327 | + | select(Hero).where(Hero.name.contains("man")) | |
| 328 | + | select(Hero).where(Hero.name.startswith("B")) | |
| 329 | + | select(Hero).where(Hero.name.endswith("man")) | |
| 330 | + | select(Hero).where(Hero.name.in_(["Batman", "Superman"])) | |
| 331 | + | select(Hero).where(Hero.name.not_in(["Joker"])) | |
| 332 | + | select(Hero).where(Hero.age.between(18, 30)) | |
| 333 | + | select(Hero).where(Hero.name.like("%man%")) | |
| 334 | + | select(Hero).where(Hero.name.ilike("%man%")) # case-insensitive | |
| 335 | + | ||
| 336 | + | # --- Комбинированные условия --- | |
| 337 | + | select(Hero).where(Hero.age >= 18, Hero.age <= 30) # AND (неявный) | |
| 338 | + | select(Hero).where(and_(Hero.age >= 18, Hero.age <= 30)) # AND (явный) | |
| 339 | + | select(Hero).where(or_(Hero.name == "A", Hero.name == "B")) | |
| 340 | + | select(Hero).where(not_(Hero.age < 18)) | |
| 341 | + | ||
| 342 | + | # ═══════════════ ORDER BY ═══════════════ | |
| 343 | + | select(Hero).order_by(Hero.name) # ASC | |
| 344 | + | select(Hero).order_by(Hero.name.desc()) # DESC | |
| 345 | + | select(Hero).order_by(Hero.age.desc(), Hero.name) # несколько | |
| 346 | + | ||
| 347 | + | # ═══════════════ LIMIT / OFFSET ═══════════════ | |
| 348 | + | select(Hero).offset(10).limit(20) # пагинация | |
| 349 | + | ||
| 350 | + | # ═══════════════ DISTINCT ═══════════════ | |
| 351 | + | select(Hero.name).distinct() | |
| 352 | + | ||
| 353 | + | # ═══════════════ GROUP BY + HAVING ═══════════════ | |
| 354 | + | from sqlmodel import func | |
| 355 | + | ||
| 356 | + | statement = ( | |
| 357 | + | select(Hero.team_id, func.count(Hero.id).label("cnt")) | |
| 358 | + | .group_by(Hero.team_id) | |
| 359 | + | .having(func.count(Hero.id) > 3) | |
| 360 | + | ) | |
| 361 | + | ||
| 362 | + | # ═══════════════ AGGREGATE ═══════════════ | |
| 363 | + | session.exec(select(func.count()).select_from(Hero)).one() | |
| 364 | + | session.exec(select(func.max(Hero.age))).one() | |
| 365 | + | session.exec(select(func.min(Hero.age))).one() | |
| 366 | + | session.exec(select(func.avg(Hero.age))).one() | |
| 367 | + | session.exec(select(func.sum(Hero.age))).one() | |
| 368 | + | ||
| 369 | + | # ═══════════════ JOIN ═══════════════ | |
| 370 | + | # Автоматический (по FK) | |
| 371 | + | select(Hero, Team).join(Team) | |
| 372 | + | select(Hero, Team).join(Team, isouter=True) # LEFT JOIN | |
| 373 | + | ||
| 374 | + | # Явный | |
| 375 | + | select(Hero, Team).join(Team, Hero.team_id == Team.id) | |
| 376 | + | ||
| 377 | + | # Выбор конкретных полей | |
| 378 | + | select(Hero.name, Team.name).join(Team) | |
| 379 | + | ||
| 380 | + | # ═══════════════ SUBQUERY ═══════════════ | |
| 381 | + | subq = select(func.avg(Hero.age)).scalar_subquery() | |
| 382 | + | statement = select(Hero).where(Hero.age > subq) | |
| 383 | + | ||
| 384 | + | # ═══════════════ RAW SQL ═══════════════ | |
| 385 | + | from sqlmodel import text | |
| 386 | + | ||
| 387 | + | with Session(engine) as session: | |
| 388 | + | result = session.exec( | |
| 389 | + | text("SELECT * FROM hero WHERE age > :age"), | |
| 390 | + | params={"age": 18} | |
| 391 | + | ) | |
| 392 | + | for row in result: | |
| 393 | + | print(row) | |
| 394 | + | ||
| 395 | + | # ═══════════════ EXISTS ═══════════════ | |
| 396 | + | from sqlalchemy import exists | |
| 397 | + | ||
| 398 | + | subq = select(Hero).where(Hero.team_id == Team.id).exists() | |
| 399 | + | statement = select(Team).where(subq) | |
| 400 | + | ||
| 401 | + | # ═══════════════ CASE ═══════════════ | |
| 402 | + | from sqlalchemy import case | |
| 403 | + | ||
| 404 | + | statement = select( | |
| 405 | + | Hero.name, | |
| 406 | + | case( | |
| 407 | + | (Hero.age < 18, "minor"), | |
| 408 | + | (Hero.age < 65, "adult"), | |
| 409 | + | else_="senior" | |
| 410 | + | ).label("category") | |
| 411 | + | ) | |
| 412 | + | ``` | |
| 413 | + | ||
| 414 | + | --- | |
| 415 | + | ||
| 416 | + | ## 8. Миграции (Alembic) | |
| 417 | + | ||
| 418 | + | ```bash | |
| 419 | + | # Установка | |
| 420 | + | pip install alembic | |
| 421 | + | ||
| 422 | + | # Инициализация | |
| 423 | + | alembic init alembic | |
| 424 | + | ``` | |
| 425 | + | ||
| 426 | + | **`alembic/env.py`** — ключевые изменения: | |
| 427 | + | ||
| 428 | + | ```python | |
| 429 | + | from sqlmodel import SQLModel | |
| 430 | + | from app.models import * # импортировать ВСЕ модели! | |
| 431 | + | ||
| 432 | + | target_metadata = SQLModel.metadata # ← вместо None | |
| 433 | + | ``` | |
| 434 | + | ||
| 435 | + | ```bash | |
| 436 | + | # Создать миграцию | |
| 437 | + | alembic revision --autogenerate -m "create users table" | |
| 438 | + | ||
| 439 | + | # Применить | |
| 440 | + | alembic upgrade head | |
| 441 | + | ||
| 442 | + | # Откатить | |
| 443 | + | alembic downgrade -1 | |
| 444 | + | ||
| 445 | + | # Посмотреть историю | |
| 446 | + | alembic history | |
| 447 | + | ||
| 448 | + | # Текущая версия | |
| 449 | + | alembic current | |
| 450 | + | ``` | |
| 451 | + | ||
| 452 | + | **Пример сгенерированной миграции:** | |
| 453 | + | ||
| 454 | + | ```python | |
| 455 | + | def upgrade(): | |
| 456 | + | op.create_table( | |
| 457 | + | 'hero', | |
| 458 | + | sa.Column('id', sa.Integer(), nullable=False), | |
| 459 | + | sa.Column('name', sa.String(), nullable=False), | |
| 460 | + | sa.Column('age', sa.Integer(), nullable=True), | |
| 461 | + | sa.PrimaryKeyConstraint('id'), | |
| 462 | + | ) | |
| 463 | + | op.create_index('ix_hero_name', 'hero', ['name']) | |
| 464 | + | ||
| 465 | + | def downgrade(): | |
| 466 | + | op.drop_index('ix_hero_name', 'hero') | |
| 467 | + | op.drop_table('hero') | |
| 468 | + | ``` | |
| 469 | + | ||
| 470 | + | --- | |
| 471 | + | ||
| 472 | + | ## 9. Паттерн множественных моделей | |
| 473 | + | ||
| 474 | + | ```python | |
| 475 | + | # ═══════ Base (общие поля) ═══════ | |
| 476 | + | class HeroBase(SQLModel): | |
| 477 | + | name: str = Field(min_length=1, max_length=100) | |
| 478 | + | age: int | None = Field(default=None, ge=0) | |
| 479 | + | team_id: int | None = None | |
| 480 | + | ||
| 481 | + | # ═══════ Table (БД) ═══════ | |
| 482 | + | class Hero(HeroBase, table=True): | |
| 483 | + | id: int | None = Field(default=None, primary_key=True) | |
| 484 | + | created_at: datetime = Field(default_factory=datetime.utcnow) | |
| 485 | + | team: "Team | None" = Relationship(back_populates="heroes") | |
| 486 | + | ||
| 487 | + | # ═══════ Create (входные данные) ═══════ | |
| 488 | + | class HeroCreate(HeroBase): | |
| 489 | + | pass # наследует name, age, team_id | |
| 490 | + | ||
| 491 | + | # ═══════ Read (ответ API) ═══════ | |
| 492 | + | class HeroRead(HeroBase): | |
| 493 | + | id: int | |
| 494 | + | created_at: datetime | |
| 495 | + | ||
| 496 | + | # ═══════ Read with relations ═══════ | |
| 497 | + | class HeroReadFull(HeroRead): | |
| 498 | + | team: "TeamRead | None" = None | |
| 499 | + | ||
| 500 | + | # ═══════ Update (частичное обновление) ═══════ | |
| 501 | + | class HeroUpdate(SQLModel): | |
| 502 | + | name: str | None = None # все поля Optional | |
| 503 | + | age: int | None = None | |
| 504 | + | team_id: int | None = None | |
| 505 | + | ||
| 506 | + | # ═══════ List response ═══════ | |
| 507 | + | class HeroListResponse(SQLModel): | |
| 508 | + | data: list[HeroRead] | |
| 509 | + | total: int | |
| 510 | + | page: int | |
| 511 | + | per_page: int | |
| 512 | + | ``` | |
| 513 | + | ||
| 514 | + | --- | |
| 515 | + | ||
| 516 | + | ## 10. Индексы и ограничения | |
| 517 | + | ||
| 518 | + | ```python | |
| 519 | + | from sqlmodel import SQLModel, Field | |
| 520 | + | from sqlalchemy import UniqueConstraint, Index, CheckConstraint | |
| 521 | + | ||
| 522 | + | class Product(SQLModel, table=True): | |
| 523 | + | __table_args__ = ( | |
| 524 | + | # Составной уникальный ключ | |
| 525 | + | UniqueConstraint("sku", "warehouse_id", name="uq_sku_warehouse"), | |
| 526 | + | ||
| 527 | + | # Составной индекс | |
| 528 | + | Index("ix_category_name", "category", "name"), | |
| 529 | + | ||
| 530 | + | # Check constraint | |
| 531 | + | CheckConstraint("price >= 0", name="ck_positive_price"), | |
| 532 | + | CheckConstraint("quantity >= 0", name="ck_positive_qty"), | |
| 533 | + | ) | |
| 534 | + | ||
| 535 | + | id: int | None = Field(default=None, primary_key=True) | |
| 536 | + | name: str = Field(index=True) # простой индекс | |
| 537 | + | sku: str = Field(max_length=50) | |
| 538 | + | warehouse_id: int | |
| 539 | + | category: str | |
| 540 | + | price: float = Field(ge=0) | |
| 541 | + | quantity: int = Field(ge=0) | |
| 542 | + | email: str = Field(unique=True) # уникальное поле | |
| 543 | + | ``` | |
| 544 | + | ||
| 545 | + | --- | |
| 546 | + | ||
| 547 | + | ## 11. Асинхронность (async) | |
| 548 | + | ||
| 549 | + | ```python | |
| 550 | + | # pip install sqlmodel aiosqlite (или asyncpg для PostgreSQL) | |
| 551 | + | ||
| 552 | + | from sqlmodel import SQLModel, Field, select | |
| 553 | + | from sqlmodel.ext.asyncio.session import AsyncSession | |
| 554 | + | from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine | |
| 555 | + | from sqlalchemy.orm import sessionmaker | |
| 556 | + | ||
| 557 | + | # --- Engine --- | |
| 558 | + | async_engine = create_async_engine( | |
| 559 | + | "sqlite+aiosqlite:///database.db", | |
| 560 | + | # "postgresql+asyncpg://user:pass@localhost/db", | |
| 561 | + | echo=True, | |
| 562 | + | ) | |
| 563 | + | ||
| 564 | + | # --- Создание таблиц --- | |
| 565 | + | async def create_db(): | |
| 566 | + | async with async_engine.begin() as conn: | |
| 567 | + | await conn.run_sync(SQLModel.metadata.create_all) | |
| 568 | + | ||
| 569 | + | # --- Session factory --- | |
| 570 | + | async_session_factory = sessionmaker( | |
| 571 | + | async_engine, | |
| 572 | + | class_=AsyncSession, | |
| 573 | + | expire_on_commit=False, | |
| 574 | + | ) | |
| 575 | + | ||
| 576 | + | # --- CRUD --- | |
| 577 | + | async def create_hero(name: str) -> Hero: | |
| 578 | + | async with AsyncSession(async_engine) as session: | |
| 579 | + | hero = Hero(name=name) | |
| 580 | + | session.add(hero) | |
| 581 | + | await session.commit() | |
| 582 | + | await session.refresh(hero) | |
| 583 | + | return hero | |
| 584 | + | ||
| 585 | + | async def get_heroes() -> list[Hero]: | |
| 586 | + | async with AsyncSession(async_engine) as session: | |
| 587 | + | result = await session.exec(select(Hero)) | |
| 588 | + | return result.all() | |
| 589 | + | ||
| 590 | + | async def get_hero(hero_id: int) -> Hero | None: | |
| 591 | + | async with AsyncSession(async_engine) as session: | |
| 592 | + | return await session.get(Hero, hero_id) | |
| 593 | + | ||
| 594 | + | # --- FastAPI + async --- | |
| 595 | + | from fastapi import FastAPI, Depends | |
| 596 | + | ||
| 597 | + | app = FastAPI() | |
| 598 | + | ||
| 599 | + | async def get_session(): | |
| 600 | + | async with AsyncSession(async_engine) as session: | |
| 601 | + | yield session | |
| 602 | + | ||
| 603 | + | @app.get("/heroes/") | |
| 604 | + | async def read_heroes(session: AsyncSession = Depends(get_session)): | |
| 605 | + | heroes = await session.exec(select(Hero)) | |
| 606 | + | return heroes.all() | |
| 607 | + | ||
| 608 | + | # --- Eager loading (async) --- | |
| 609 | + | from sqlalchemy.orm import selectinload | |
| 610 | + | ||
| 611 | + | async def get_team_with_heroes(team_id: int): | |
| 612 | + | async with AsyncSession(async_engine) as session: | |
| 613 | + | statement = ( | |
| 614 | + | select(Team) | |
| 615 | + | .where(Team.id == team_id) | |
| 616 | + | .options(selectinload(Team.heroes)) | |
| 617 | + | ) | |
| 618 | + | result = await session.exec(statement) | |
| 619 | + | return result.first() | |
| 620 | + | ``` | |
| 621 | + | ||
| 622 | + | --- | |
| 623 | + | ||
| 624 | + | ## 12. Интеграция с FastAPI (продвинутая) | |
| 625 | + | ||
| 626 | + | ```python | |
| 627 | + | from fastapi import FastAPI, Depends, HTTPException, Query | |
| 628 | + | from sqlmodel import Session, select, func | |
| 629 | + | from contextlib import asynccontextmanager | |
| 630 | + | ||
| 631 | + | # --- Lifespan --- | |
| 632 | + | @asynccontextmanager | |
| 633 | + | async def lifespan(app: FastAPI): | |
| 634 | + | SQLModel.metadata.create_all(engine) | |
| 635 | + | yield | |
| 636 | + | # cleanup | |
| 637 | + | ||
| 638 | + | app = FastAPI(lifespan=lifespan) | |
| 639 | + | ||
| 640 | + | # --- Dependency --- | |
| 641 | + | def get_session(): | |
| 642 | + | with Session(engine) as session: | |
| 643 | + | yield session | |
| 644 | + | ||
| 645 | + | SessionDep = Depends(get_session) | |
| 646 | + | ||
| 647 | + | # --- Пагинация --- | |
| 648 | + | @app.get("/heroes/", response_model=list[HeroRead]) | |
| 649 | + | def list_heroes( | |
| 650 | + | offset: int = Query(default=0, ge=0), | |
| 651 | + | limit: int = Query(default=20, le=100), | |
| 652 | + | search: str | None = None, | |
| 653 | + | session: Session = SessionDep, | |
| 654 | + | ): | |
| 655 | + | statement = select(Hero) | |
| 656 | + | if search: | |
| 657 | + | statement = statement.where(Hero.name.ilike(f"%{search}%")) | |
| 658 | + | statement = statement.offset(offset).limit(limit) | |
| 659 | + | return session.exec(statement).all() | |
| 660 | + | ||
| 661 | + | # --- Фильтрация + сортировка --- | |
| 662 | + | @app.get("/heroes/advanced", response_model=list[HeroRead]) | |
| 663 | + | def list_heroes_advanced( | |
| 664 | + | min_age: int | None = None, | |
| 665 | + | max_age: int | None = None, | |
| 666 | + | team_id: int | None = None, | |
| 667 | + | sort_by: str = "name", | |
| 668 | + | sort_order: str = "asc", | |
| 669 | + | session: Session = SessionDep, | |
| 670 | + | ): | |
| 671 | + | statement = select(Hero) | |
| 672 | + | ||
| 673 | + | if min_age is not None: | |
| 674 | + | statement = statement.where(Hero.age >= min_age) | |
| 675 | + | if max_age is not None: | |
| 676 | + | statement = statement.where(Hero.age <= max_age) | |
| 677 | + | if team_id is not None: | |
| 678 | + | statement = statement.where(Hero.team_id == team_id) | |
| 679 | + | ||
| 680 | + | sort_column = getattr(Hero, sort_by, Hero.name) | |
| 681 | + | if sort_order == "desc": | |
| 682 | + | sort_column = sort_column.desc() | |
| 683 | + | statement = statement.order_by(sort_column) | |
| 684 | + | ||
| 685 | + | return session.exec(statement).all() | |
| 686 | + | ||
| 687 | + | # --- PATCH (частичное обновление) --- | |
| 688 | + | @app.patch("/heroes/{hero_id}", response_model=HeroRead) | |
| 689 | + | def update_hero( | |
| 690 | + | hero_id: int, | |
| 691 | + | hero_update: HeroUpdate, | |
| 692 | + | session: Session = SessionDep, | |
| 693 | + | ): | |
| 694 | + | hero = session.get(Hero, hero_id) | |
| 695 | + | if not hero: | |
| 696 | + | raise HTTPException(status_code=404, detail="Hero not found") | |
| 697 | + | ||
| 698 | + | hero_data = hero_update.model_dump(exclude_unset=True) | |
| 699 | + | hero.sqlmodel_update(hero_data) # SQLModel 0.0.14+ | |
| 700 | + | session.add(hero) | |
| 701 | + | session.commit() | |
| 702 | + | session.refresh(hero) | |
| 703 | + | return hero | |
| 704 | + | ``` | |
| 705 | + | ||
| 706 | + | --- | |
| 707 | + | ||
| 708 | + | ## 13. События и хуки | |
| 709 | + | ||
| 710 | + | ```python | |
| 711 | + | from sqlalchemy import event | |
| 712 | + | from sqlmodel import Session | |
| 713 | + | ||
| 714 | + | # --- Before insert --- | |
| 715 | + | @event.listens_for(Hero, "before_insert") | |
| 716 | + | def hero_before_insert(mapper, connection, target): | |
| 717 | + | target.name = target.name.strip().title() | |
| 718 | + | ||
| 719 | + | # --- After update --- | |
| 720 | + | @event.listens_for(Hero, "after_update") | |
| 721 | + | def hero_after_update(mapper, connection, target): | |
| 722 | + | print(f"Hero {target.id} was updated") | |
| 723 | + | ||
| 724 | + | # --- Before delete --- | |
| 725 | + | @event.listens_for(Hero, "before_delete") | |
| 726 | + | def hero_before_delete(mapper, connection, target): | |
| 727 | + | print(f"About to delete hero {target.name}") | |
| 728 | + | ||
| 729 | + | # --- Session events --- | |
| 730 | + | @event.listens_for(Session, "after_commit") | |
| 731 | + | def after_commit(session): | |
| 732 | + | print("Transaction committed") | |
| 733 | + | ||
| 734 | + | # --- Валидация через set --- | |
| 735 | + | @event.listens_for(Hero.age, "set") | |
| 736 | + | def validate_age(target, value, oldvalue, initiator): | |
| 737 | + | if value is not None and value < 0: | |
| 738 | + | raise ValueError("Age cannot be negative") | |
| 739 | + | ``` | |
| 740 | + | ||
| 741 | + | --- | |
| 742 | + | ||
| 743 | + | ## 14. Наследование моделей | |
| 744 | + | ||
| 745 | + | ### Single Table Inheritance | |
| 746 | + | ||
| 747 | + | ```python | |
| 748 | + | from sqlmodel import SQLModel, Field | |
| 749 | + | ||
| 750 | + | class Employee(SQLModel, table=True): | |
| 751 | + | __tablename__ = "employee" | |
| 752 | + | ||
| 753 | + | id: int | None = Field(default=None, primary_key=True) | |
| 754 | + | name: str | |
| 755 | + | type: str # discriminator | |
| 756 | + | ||
| 757 | + | __mapper_args__ = { | |
| 758 | + | "polymorphic_on": "type", | |
| 759 | + | "polymorphic_identity": "employee", | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | class Manager(Employee): | |
| 763 | + | department: str | None = None | |
| 764 | + | ||
| 765 | + | __mapper_args__ = { | |
| 766 | + | "polymorphic_identity": "manager", | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | class Engineer(Employee): | |
| 770 | + | language: str | None = None | |
| 771 | + | ||
| 772 | + | __mapper_args__ = { | |
| 773 | + | "polymorphic_identity": "engineer", | |
| 774 | + | } | |
| 775 | + | ``` | |
| 776 | + | ||
| 777 | + | --- | |
| 778 | + | ||
| 779 | + | ## 15. JSON и сложные типы | |
| 780 | + | ||
| 781 | + | ```python | |
| 782 | + | from sqlmodel import SQLModel, Field | |
| 783 | + | from sqlalchemy import Column, JSON | |
| 784 | + | from typing import Any | |
| 785 | + | ||
| 786 | + | class Config(SQLModel, table=True): | |
| 787 | + | id: int | None = Field(default=None, primary_key=True) | |
| 788 | + | name: str | |
| 789 | + | ||
| 790 | + | # JSON поле | |
| 791 | + | settings: dict[str, Any] = Field( | |
| 792 | + | default_factory=dict, | |
| 793 | + | sa_column=Column(JSON), | |
| 794 | + | ) | |
| 795 | + | ||
| 796 | + | tags: list[str] = Field( | |
| 797 | + | default_factory=list, | |
| 798 | + | sa_column=Column(JSON), | |
| 799 | + | ) | |
| 800 | + | ``` | |
| 801 | + | ||
| 802 | + | ```python | |
| 803 | + | # PostgreSQL ARRAY | |
| 804 | + | from sqlalchemy.dialects.postgresql import ARRAY | |
| 805 | + | from sqlalchemy import Column, String | |
| 806 | + | ||
| 807 | + | class Post(SQLModel, table=True): | |
| 808 | + | id: int | None = Field(default=None, primary_key=True) | |
| 809 | + | title: str | |
| 810 | + | tags: list[str] = Field( | |
| 811 | + | sa_column=Column(ARRAY(String)) | |
| 812 | + | ) | |
| 813 | + | ``` | |
| 814 | + | ||
| 815 | + | --- | |
| 816 | + | ||
| 817 | + | ## 16. Raw SQL и гибридные свойства | |
| 818 | + | ||
| 819 | + | ```python | |
| 820 | + | from sqlalchemy.ext.hybrid import hybrid_property | |
| 821 | + | from sqlmodel import SQLModel, Field | |
| 822 | + | ||
| 823 | + | class User(SQLModel, table=True): | |
| 824 | + | id: int | None = Field(default=None, primary_key=True) | |
| 825 | + | first_name: str | |
| 826 | + | last_name: str | |
| 827 | + | birth_year: int | |
| 828 | + | ||
| 829 | + | # Вычисляемое свойство (Python-уровень) | |
| 830 | + | @hybrid_property | |
| 831 | + | def full_name(self) -> str: | |
| 832 | + | return f"{self.first_name} {self.last_name}" | |
| 833 | + | ||
| 834 | + | # SQL-уровень (можно использовать в WHERE) | |
| 835 | + | @full_name.expression | |
| 836 | + | @classmethod | |
| 837 | + | def full_name(cls): | |
| 838 | + | return cls.first_name + " " + cls.last_name | |
| 839 | + | ||
| 840 | + | # Теперь можно: | |
| 841 | + | select(User).where(User.full_name == "John Doe") | |
| 842 | + | ``` | |
| 843 | + | ||
| 844 | + | --- | |
| 845 | + | ||
| 846 | + | ## 17. Тестирование | |
| 847 | + | ||
| 848 | + | ```python | |
| 849 | + | import pytest | |
| 850 | + | from sqlmodel import SQLModel, Session, create_engine, select | |
| 851 | + | from sqlmodel.pool import StaticPool | |
| 852 | + | ||
| 853 | + | @pytest.fixture(name="session") | |
| 854 | + | def session_fixture(): | |
| 855 | + | """Новая in-memory БД для каждого теста""" | |
| 856 | + | engine = create_engine( | |
| 857 | + | "sqlite://", # in-memory | |
| 858 | + | connect_args={"check_same_thread": False}, | |
| 859 | + | poolclass=StaticPool, # ← важно для SQLite in-memory | |
| 860 | + | ) | |
| 861 | + | SQLModel.metadata.create_all(engine) | |
| 862 | + | with Session(engine) as session: | |
| 863 | + | yield session | |
| 864 | + | ||
| 865 | + | def test_create_hero(session: Session): | |
| 866 | + | hero = Hero(name="Test Hero", age=25) | |
| 867 | + | session.add(hero) | |
| 868 | + | session.commit() | |
| 869 | + | session.refresh(hero) | |
| 870 | + | ||
| 871 | + | assert hero.id is not None | |
| 872 | + | assert hero.name == "Test Hero" | |
| 873 | + | ||
| 874 | + | def test_read_heroes(session: Session): | |
| 875 | + | session.add_all([ | |
| 876 | + | Hero(name="A"), | |
| 877 | + | Hero(name="B"), | |
| 878 | + | ]) | |
| 879 | + | session.commit() | |
| 880 | + | ||
| 881 | + | heroes = session.exec(select(Hero)).all() | |
| 882 | + | assert len(heroes) == 2 | |
| 883 | + | ||
| 884 | + | # --- Тестирование FastAPI --- | |
| 885 | + | from fastapi.testclient import TestClient | |
| 886 | + | ||
| 887 | + | @pytest.fixture(name="client") | |
| 888 | + | def client_fixture(session: Session): | |
| 889 | + | def get_session_override(): | |
| 890 | + | yield session | |
| 891 | + | ||
| 892 | + | app.dependency_overrides[get_session] = get_session_override | |
| 893 | + | client = TestClient(app) | |
| 894 | + | yield client | |
| 895 | + | app.dependency_overrides.clear() | |
| 896 | + | ||
| 897 | + | def test_api_create_hero(client: TestClient): | |
| 898 | + | response = client.post("/heroes/", json={"name": "API Hero", "age": 30}) | |
| 899 | + | assert response.status_code == 200 | |
| 900 | + | data = response.json() | |
| 901 | + | assert data["name"] == "API Hero" | |
| 902 | + | assert "id" in data | |
| 903 | + | ``` | |
| 904 | + | ||
| 905 | + | --- | |
| 906 | + | ||
| 907 | + | ## 18. Производительность и оптимизация | |
| 908 | + | ||
| 909 | + | ```python | |
| 910 | + | # ═══════ Bulk операции ═══════ | |
| 911 | + | from sqlalchemy import insert, update, delete | |
| 912 | + | ||
| 913 | + | # Массовая вставка (быстрее чем add_all) | |
| 914 | + | with Session(engine) as session: | |
| 915 | + | session.exec( | |
| 916 | + | insert(Hero), | |
| 917 | + | [{"name": f"Hero-{i}", "age": i} for i in range(10000)] | |
| 918 | + | ) | |
| 919 | + | session.commit() | |
| 920 | + | ||
| 921 | + | # Массовое обновление | |
| 922 | + | with Session(engine) as session: | |
| 923 | + | session.exec( | |
| 924 | + | update(Hero) | |
| 925 | + | .where(Hero.age < 18) | |
| 926 | + | .values(team_id=None) | |
| 927 | + | ) | |
| 928 | + | session.commit() | |
| 929 | + | ||
| 930 | + | # Массовое удаление | |
| 931 | + | with Session(engine) as session: | |
| 932 | + | session.exec( | |
| 933 | + | delete(Hero).where(Hero.team_id == None) | |
| 934 | + | ) | |
| 935 | + | session.commit() | |
| 936 | + | ||
| 937 | + | # ═══════ Выборка только нужных колонок ═══════ | |
| 938 | + | # Вместо select(Hero) — все колонки | |
| 939 | + | select(Hero.id, Hero.name) # только id и name | |
| 940 | + | ||
| 941 | + | # ═══════ Подсчёт без загрузки ═══════ | |
| 942 | + | count = session.exec( | |
| 943 | + | select(func.count()).select_from(Hero) | |
| 944 | + | ).one() | |
| 945 | + | ||
| 946 | + | # ═══════ Пагинация по курсору (для больших таблиц) ═══════ | |
| 947 | + | def get_heroes_cursor(session: Session, after_id: int = 0, limit: int = 20): | |
| 948 | + | statement = ( | |
| 949 | + | select(Hero) | |
| 950 | + | .where(Hero.id > after_id) | |
| 951 | + | .order_by(Hero.id) | |
| 952 | + | .limit(limit) | |
| 953 | + | ) | |
| 954 | + | return session.exec(statement).all() | |
| 955 | + | ||
| 956 | + | # ═══════ expire_on_commit ═══════ | |
| 957 | + | # Отключить автообновление после коммита (быстрее) | |
| 958 | + | with Session(engine, expire_on_commit=False) as session: | |
| 959 | + | hero = Hero(name="Fast") | |
| 960 | + | session.add(hero) | |
| 961 | + | session.commit() | |
| 962 | + | # hero.name доступен без refresh | |
| 963 | + | ``` | |
| 964 | + | ||
| 965 | + | --- | |
| 966 | + | ||
| 967 | + | ## Итоговая шпаргалка | |
| 968 | + | ||
| 969 | + | ``` | |
| 970 | + | ╔══════════════════════════════════════════════════════════════╗ | |
| 971 | + | ║ SQLModel — ВСЁ В ОДНОМ ║ | |
| 972 | + | ╠══════════════════════════════════════════════════════════════╣ | |
| 973 | + | ║ ║ | |
| 974 | + | ║ МОДЕЛИ: SQLModel + table=True / table=False ║ | |
| 975 | + | ║ ПОЛЯ: Field(FK, index, unique, ge, le, max_length) ║ | |
| 976 | + | ║ СВЯЗИ: Relationship(back_populates, link_model) ║ | |
| 977 | + | ║ ЗАПРОСЫ: select().where().join().order_by().limit() ║ | |
| 978 | + | ║ СЕССИЯ: add / commit / refresh / delete / exec ║ | |
| 979 | + | ║ ВАЛИДАЦИЯ: field_validator / model_validator (Pydantic) ║ | |
| 980 | + | ║ МИГРАЦИИ: Alembic + SQLModel.metadata ║ | |
| 981 | + | ║ ASYNC: AsyncSession + create_async_engine ║ | |
| 982 | + | ║ ТЕСТЫ: sqlite:// + StaticPool + dependency override ║ | |
| 983 | + | ║ КАСКАДЫ: sa_relationship_kwargs={"cascade": "..."} ║ | |
| 984 | + | ║ ИНДЕКСЫ: Field(index=True) / __table_args__ ║ | |
| 985 | + | ║ JSON: sa_column=Column(JSON) ║ | |
| 986 | + | ║ ПРОИЗВОД-ТЬ: bulk insert/update, selectinload, курсоры ║ | |
| 987 | + | ║ ║ | |
| 988 | + | ║ Если не хватает SQLModel → используй sa_column / sa_* ║ | |
| 989 | + | ║ SQLModel = SQLAlchemy + Pydantic (всё из обоих доступно) ║ | |
| 990 | + | ║ ║ | |
| 991 | + | ╚══════════════════════════════════════════════════════════════╝ | |
| 992 | + | ``` | |
| 993 | + | ||
| 994 | + | Это покрывает **~95% всех задач** с SQLModel. Оставшиеся 5% — это прямое использование SQLAlchemy Core/ORM, которое SQLModel полностью поддерживает через `sa_column`, `sa_relationship_kwargs` и прямой доступ к `__table__`. | |
relationships.md(vytvořil soubor)
| @@ -0,0 +1,783 @@ | |||
| 1 | + | ||
| 2 | + | ||
| 3 | + | # Полный гайд по связям в SQLModel | |
| 4 | + | ||
| 5 | + | ## Оглавление | |
| 6 | + | 1. [Основы](#1-основы) | |
| 7 | + | 2. [One-to-Many (Один ко многим)](#2-one-to-many) | |
| 8 | + | 3. [Many-to-One (Многие к одному)](#3-many-to-one) | |
| 9 | + | 4. [One-to-One (Один к одному)](#4-one-to-one) | |
| 10 | + | 5. [Many-to-Many (Многие ко многим)](#5-many-to-many) | |
| 11 | + | 6. [Самореферентные связи](#6-самореферентные-связи) | |
| 12 | + | 7. [Каскадные операции](#7-каскадные-операции) | |
| 13 | + | 8. [Загрузка связей (Lazy / Eager)](#8-загрузка-связей) | |
| 14 | + | 9. [Связи и Pydantic-схемы (read models)](#9-связи-и-pydantic-схемы) | |
| 15 | + | 10. [Связи + FastAPI](#10-связи--fastapi) | |
| 16 | + | 11. [Частые ошибки и подводные камни](#11-частые-ошибки) | |
| 17 | + | ||
| 18 | + | --- | |
| 19 | + | ||
| 20 | + | ## 1. Основы | |
| 21 | + | ||
| 22 | + | ### Два ключевых инструмента | |
| 23 | + | ||
| 24 | + | ```python | |
| 25 | + | from sqlmodel import SQLModel, Field, Relationship | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | | Инструмент | Назначение | | |
| 29 | + | |---|---| | |
| 30 | + | | `Field(foreign_key="table.column")` | Создаёт **внешний ключ** в БД (колонку) | | |
| 31 | + | | `Relationship(back_populates="...")` | Создаёт **Python-атрибут** для доступа к связанным объектам (НЕ колонку в БД) | | |
| 32 | + | ||
| 33 | + | ### Базовая настройка | |
| 34 | + | ||
| 35 | + | ```python | |
| 36 | + | from sqlmodel import SQLModel, Field, Relationship, Session, create_engine, select | |
| 37 | + | from typing import Optional | |
| 38 | + | ||
| 39 | + | engine = create_engine("sqlite:///database.db", echo=True) | |
| 40 | + | ||
| 41 | + | def create_db(): | |
| 42 | + | SQLModel.metadata.create_all(engine) | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | --- | |
| 46 | + | ||
| 47 | + | ## 2. One-to-Many (Один ко многим) | |
| 48 | + | ||
| 49 | + | **Один** `Team` → **Много** `Hero` | |
| 50 | + | ||
| 51 | + | ```python | |
| 52 | + | class Team(SQLModel, table=True): | |
| 53 | + | id: int | None = Field(default=None, primary_key=True) | |
| 54 | + | name: str = Field(index=True) | |
| 55 | + | ||
| 56 | + | # Связь: список героев, принадлежащих этой команде | |
| 57 | + | heroes: list["Hero"] = Relationship(back_populates="team") | |
| 58 | + | ||
| 59 | + | ||
| 60 | + | class Hero(SQLModel, table=True): | |
| 61 | + | id: int | None = Field(default=None, primary_key=True) | |
| 62 | + | name: str = Field(index=True) | |
| 63 | + | ||
| 64 | + | # Внешний ключ — реальная колонка в БД | |
| 65 | + | team_id: int | None = Field(default=None, foreign_key="team.id") | |
| 66 | + | ||
| 67 | + | # Связь: объект команды | |
| 68 | + | team: Team | None = Relationship(back_populates="heroes") | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | ### Создание записей | |
| 72 | + | ||
| 73 | + | ```python | |
| 74 | + | with Session(engine) as session: | |
| 75 | + | # Способ 1: Через FK | |
| 76 | + | team = Team(name="Avengers") | |
| 77 | + | session.add(team) | |
| 78 | + | session.commit() | |
| 79 | + | session.refresh(team) | |
| 80 | + | ||
| 81 | + | hero = Hero(name="Iron Man", team_id=team.id) | |
| 82 | + | session.add(hero) | |
| 83 | + | session.commit() | |
| 84 | + | ||
| 85 | + | # Способ 2: Через relationship (удобнее) | |
| 86 | + | team2 = Team(name="X-Men") | |
| 87 | + | hero2 = Hero(name="Wolverine", team=team2) | |
| 88 | + | session.add(hero2) # team2 добавится автоматически! | |
| 89 | + | session.commit() | |
| 90 | + | ||
| 91 | + | # Способ 3: Добавление в список | |
| 92 | + | team3 = Team(name="Justice League") | |
| 93 | + | team3.heroes = [ | |
| 94 | + | Hero(name="Batman"), | |
| 95 | + | Hero(name="Superman"), | |
| 96 | + | ] | |
| 97 | + | session.add(team3) | |
| 98 | + | session.commit() | |
| 99 | + | ``` | |
| 100 | + | ||
| 101 | + | ### Чтение связей | |
| 102 | + | ||
| 103 | + | ```python | |
| 104 | + | with Session(engine) as session: | |
| 105 | + | # Получить команду героя | |
| 106 | + | hero = session.exec(select(Hero).where(Hero.name == "Iron Man")).first() | |
| 107 | + | print(hero.team.name) # "Avengers" — lazy load | |
| 108 | + | ||
| 109 | + | # Получить героев команды | |
| 110 | + | team = session.exec(select(Team).where(Team.name == "Avengers")).first() | |
| 111 | + | for h in team.heroes: # lazy load | |
| 112 | + | print(h.name) | |
| 113 | + | ``` | |
| 114 | + | ||
| 115 | + | --- | |
| 116 | + | ||
| 117 | + | ## 3. Many-to-One (Многие к одному) | |
| 118 | + | ||
| 119 | + | Это **обратная сторона** One-to-Many. Технически — то же самое, просто смотрим с другой стороны. | |
| 120 | + | ||
| 121 | + | ```python | |
| 122 | + | class Country(SQLModel, table=True): | |
| 123 | + | id: int | None = Field(default=None, primary_key=True) | |
| 124 | + | name: str | |
| 125 | + | ||
| 126 | + | cities: list["City"] = Relationship(back_populates="country") | |
| 127 | + | ||
| 128 | + | ||
| 129 | + | class City(SQLModel, table=True): | |
| 130 | + | id: int | None = Field(default=None, primary_key=True) | |
| 131 | + | name: str | |
| 132 | + | ||
| 133 | + | country_id: int = Field(foreign_key="country.id") # обязательная связь | |
| 134 | + | ||
| 135 | + | # Many-to-One: много городов → одна страна | |
| 136 | + | country: Country = Relationship(back_populates="cities") | |
| 137 | + | ``` | |
| 138 | + | ||
| 139 | + | > **Правило:** FK всегда на стороне "Many". Кто хранит `foreign_key` — тот "Many". | |
| 140 | + | ||
| 141 | + | --- | |
| 142 | + | ||
| 143 | + | ## 4. One-to-One (Один к одному) | |
| 144 | + | ||
| 145 | + | SQLModel не имеет специального параметра для One-to-One. Используем `Relationship` + `sa_relationship_kwargs`. | |
| 146 | + | ||
| 147 | + | ```python | |
| 148 | + | class User(SQLModel, table=True): | |
| 149 | + | id: int | None = Field(default=None, primary_key=True) | |
| 150 | + | username: str | |
| 151 | + | ||
| 152 | + | profile: "Profile | None" = Relationship( | |
| 153 | + | back_populates="user", | |
| 154 | + | sa_relationship_kwargs={"uselist": False} # ← ключевой момент! | |
| 155 | + | ) | |
| 156 | + | ||
| 157 | + | ||
| 158 | + | class Profile(SQLModel, table=True): | |
| 159 | + | id: int | None = Field(default=None, primary_key=True) | |
| 160 | + | bio: str = "" | |
| 161 | + | ||
| 162 | + | user_id: int = Field(foreign_key="user.id", unique=True) # unique! | |
| 163 | + | ||
| 164 | + | user: User = Relationship(back_populates="profile") | |
| 165 | + | ``` | |
| 166 | + | ||
| 167 | + | > **`uselist=False`** — говорит SQLAlchemy возвращать один объект, а не список. | |
| 168 | + | > **`unique=True`** на FK — гарантирует уникальность на уровне БД. | |
| 169 | + | ||
| 170 | + | ```python | |
| 171 | + | with Session(engine) as session: | |
| 172 | + | user = User(username="john") | |
| 173 | + | profile = Profile(bio="Hello!", user=user) | |
| 174 | + | session.add(profile) | |
| 175 | + | session.commit() | |
| 176 | + | ||
| 177 | + | session.refresh(user) | |
| 178 | + | print(user.profile.bio) # "Hello!" | |
| 179 | + | print(profile.user.username) # "john" | |
| 180 | + | ``` | |
| 181 | + | ||
| 182 | + | --- | |
| 183 | + | ||
| 184 | + | ## 5. Many-to-Many (Многие ко многим) | |
| 185 | + | ||
| 186 | + | Нужна **промежуточная (link/association) таблица**. | |
| 187 | + | ||
| 188 | + | ### Вариант A: Простая link-таблица | |
| 189 | + | ||
| 190 | + | ```python | |
| 191 | + | class HeroMissionLink(SQLModel, table=True): | |
| 192 | + | """Промежуточная таблица — только два FK""" | |
| 193 | + | hero_id: int = Field(foreign_key="hero.id", primary_key=True) | |
| 194 | + | mission_id: int = Field(foreign_key="mission.id", primary_key=True) | |
| 195 | + | ||
| 196 | + | ||
| 197 | + | class Hero(SQLModel, table=True): | |
| 198 | + | id: int | None = Field(default=None, primary_key=True) | |
| 199 | + | name: str | |
| 200 | + | ||
| 201 | + | missions: list["Mission"] = Relationship( | |
| 202 | + | back_populates="heroes", | |
| 203 | + | link_model=HeroMissionLink, # ← указываем link-модель | |
| 204 | + | ) | |
| 205 | + | ||
| 206 | + | ||
| 207 | + | class Mission(SQLModel, table=True): | |
| 208 | + | id: int | None = Field(default=None, primary_key=True) | |
| 209 | + | title: str | |
| 210 | + | ||
| 211 | + | heroes: list["Hero"] = Relationship( | |
| 212 | + | back_populates="missions", | |
| 213 | + | link_model=HeroMissionLink, | |
| 214 | + | ) | |
| 215 | + | ``` | |
| 216 | + | ||
| 217 | + | ### Использование | |
| 218 | + | ||
| 219 | + | ```python | |
| 220 | + | with Session(engine) as session: | |
| 221 | + | hero1 = Hero(name="Spider-Man") | |
| 222 | + | hero2 = Hero(name="Black Widow") | |
| 223 | + | ||
| 224 | + | mission1 = Mission(title="Save the World") | |
| 225 | + | mission2 = Mission(title="Stealth Op") | |
| 226 | + | ||
| 227 | + | # Добавляем через relationship | |
| 228 | + | hero1.missions = [mission1, mission2] | |
| 229 | + | hero2.missions = [mission1] | |
| 230 | + | ||
| 231 | + | session.add(hero1) | |
| 232 | + | session.add(hero2) | |
| 233 | + | session.commit() | |
| 234 | + | ||
| 235 | + | # Чтение | |
| 236 | + | session.refresh(mission1) | |
| 237 | + | for h in mission1.heroes: | |
| 238 | + | print(h.name) # Spider-Man, Black Widow | |
| 239 | + | ``` | |
| 240 | + | ||
| 241 | + | ### Вариант B: Link-таблица с дополнительными полями | |
| 242 | + | ||
| 243 | + | ```python | |
| 244 | + | class Enrollment(SQLModel, table=True): | |
| 245 | + | """Связь студент-курс с дополнительными данными""" | |
| 246 | + | student_id: int = Field(foreign_key="student.id", primary_key=True) | |
| 247 | + | course_id: int = Field(foreign_key="course.id", primary_key=True) | |
| 248 | + | ||
| 249 | + | grade: float | None = None # ← доп. поле | |
| 250 | + | enrolled_at: str | None = None # ← доп. поле | |
| 251 | + | ||
| 252 | + | ||
| 253 | + | class Student(SQLModel, table=True): | |
| 254 | + | id: int | None = Field(default=None, primary_key=True) | |
| 255 | + | name: str | |
| 256 | + | ||
| 257 | + | # Связь напрямую с Course через link_model | |
| 258 | + | courses: list["Course"] = Relationship( | |
| 259 | + | back_populates="students", | |
| 260 | + | link_model=Enrollment, | |
| 261 | + | ) | |
| 262 | + | ||
| 263 | + | # Доступ к самим записям Enrollment | |
| 264 | + | enrollments: list[Enrollment] = Relationship(back_populates="student") | |
| 265 | + | ||
| 266 | + | ||
| 267 | + | class Course(SQLModel, table=True): | |
| 268 | + | id: int | None = Field(default=None, primary_key=True) | |
| 269 | + | title: str | |
| 270 | + | ||
| 271 | + | students: list["Student"] = Relationship( | |
| 272 | + | back_populates="courses", | |
| 273 | + | link_model=Enrollment, | |
| 274 | + | ) | |
| 275 | + | ||
| 276 | + | enrollments: list[Enrollment] = Relationship(back_populates="course") | |
| 277 | + | ``` | |
| 278 | + | ||
| 279 | + | Чтобы `enrollments` работал, добавьте обратные связи в `Enrollment`: | |
| 280 | + | ||
| 281 | + | ```python | |
| 282 | + | class Enrollment(SQLModel, table=True): | |
| 283 | + | student_id: int = Field(foreign_key="student.id", primary_key=True) | |
| 284 | + | course_id: int = Field(foreign_key="course.id", primary_key=True) | |
| 285 | + | grade: float | None = None | |
| 286 | + | ||
| 287 | + | student: "Student" = Relationship(back_populates="enrollments") | |
| 288 | + | course: "Course" = Relationship(back_populates="enrollments") | |
| 289 | + | ``` | |
| 290 | + | ||
| 291 | + | ```python | |
| 292 | + | # Создание с доп. данными | |
| 293 | + | with Session(engine) as session: | |
| 294 | + | student = Student(name="Alice") | |
| 295 | + | course = Course(title="Math") | |
| 296 | + | session.add_all([student, course]) | |
| 297 | + | session.commit() | |
| 298 | + | ||
| 299 | + | enrollment = Enrollment( | |
| 300 | + | student_id=student.id, | |
| 301 | + | course_id=course.id, | |
| 302 | + | grade=95.5, | |
| 303 | + | ) | |
| 304 | + | session.add(enrollment) | |
| 305 | + | session.commit() | |
| 306 | + | ``` | |
| 307 | + | ||
| 308 | + | --- | |
| 309 | + | ||
| 310 | + | ## 6. Самореферентные связи | |
| 311 | + | ||
| 312 | + | ### Дерево (parent → children) | |
| 313 | + | ||
| 314 | + | ```python | |
| 315 | + | class Category(SQLModel, table=True): | |
| 316 | + | id: int | None = Field(default=None, primary_key=True) | |
| 317 | + | name: str | |
| 318 | + | ||
| 319 | + | parent_id: int | None = Field(default=None, foreign_key="category.id") | |
| 320 | + | ||
| 321 | + | # Родитель | |
| 322 | + | parent: "Category | None" = Relationship( | |
| 323 | + | back_populates="children", | |
| 324 | + | sa_relationship_kwargs={"remote_side": "Category.id"}, | |
| 325 | + | ) | |
| 326 | + | ||
| 327 | + | # Дети | |
| 328 | + | children: list["Category"] = Relationship(back_populates="parent") | |
| 329 | + | ``` | |
| 330 | + | ||
| 331 | + | ```python | |
| 332 | + | with Session(engine) as session: | |
| 333 | + | root = Category(name="Electronics") | |
| 334 | + | phones = Category(name="Phones", parent=root) | |
| 335 | + | laptops = Category(name="Laptops", parent=root) | |
| 336 | + | iphone = Category(name="iPhone", parent=phones) | |
| 337 | + | ||
| 338 | + | session.add(root) | |
| 339 | + | session.commit() | |
| 340 | + | ||
| 341 | + | session.refresh(root) | |
| 342 | + | for child in root.children: | |
| 343 | + | print(child.name) # Phones, Laptops | |
| 344 | + | ``` | |
| 345 | + | ||
| 346 | + | ### Подписчики (Many-to-Many на себя) | |
| 347 | + | ||
| 348 | + | ```python | |
| 349 | + | class FollowLink(SQLModel, table=True): | |
| 350 | + | follower_id: int = Field(foreign_key="usermodel.id", primary_key=True) | |
| 351 | + | following_id: int = Field(foreign_key="usermodel.id", primary_key=True) | |
| 352 | + | ||
| 353 | + | ||
| 354 | + | class UserModel(SQLModel, table=True): | |
| 355 | + | id: int | None = Field(default=None, primary_key=True) | |
| 356 | + | username: str | |
| 357 | + | ||
| 358 | + | # Те, на кого я подписан | |
| 359 | + | following: list["UserModel"] = Relationship( | |
| 360 | + | back_populates="followers", | |
| 361 | + | link_model=FollowLink, | |
| 362 | + | sa_relationship_kwargs={ | |
| 363 | + | "primaryjoin": "UserModel.id == FollowLink.follower_id", | |
| 364 | + | "secondaryjoin": "UserModel.id == FollowLink.following_id", | |
| 365 | + | }, | |
| 366 | + | ) | |
| 367 | + | ||
| 368 | + | # Мои подписчики | |
| 369 | + | followers: list["UserModel"] = Relationship( | |
| 370 | + | back_populates="following", | |
| 371 | + | link_model=FollowLink, | |
| 372 | + | sa_relationship_kwargs={ | |
| 373 | + | "primaryjoin": "UserModel.id == FollowLink.following_id", | |
| 374 | + | "secondaryjoin": "UserModel.id == FollowLink.follower_id", | |
| 375 | + | }, | |
| 376 | + | ) | |
| 377 | + | ``` | |
| 378 | + | ||
| 379 | + | --- | |
| 380 | + | ||
| 381 | + | ## 7. Каскадные операции | |
| 382 | + | ||
| 383 | + | Каскады настраиваются через `sa_relationship_kwargs`: | |
| 384 | + | ||
| 385 | + | ```python | |
| 386 | + | class Author(SQLModel, table=True): | |
| 387 | + | id: int | None = Field(default=None, primary_key=True) | |
| 388 | + | name: str | |
| 389 | + | ||
| 390 | + | books: list["Book"] = Relationship( | |
| 391 | + | back_populates="author", | |
| 392 | + | sa_relationship_kwargs={ | |
| 393 | + | "cascade": "all, delete-orphan", # ← каскадное удаление | |
| 394 | + | }, | |
| 395 | + | ) | |
| 396 | + | ||
| 397 | + | ||
| 398 | + | class Book(SQLModel, table=True): | |
| 399 | + | id: int | None = Field(default=None, primary_key=True) | |
| 400 | + | title: str | |
| 401 | + | author_id: int = Field(foreign_key="author.id") | |
| 402 | + | ||
| 403 | + | author: Author = Relationship(back_populates="books") | |
| 404 | + | ``` | |
| 405 | + | ||
| 406 | + | ### Варианты каскадов | |
| 407 | + | ||
| 408 | + | | Каскад | Описание | | |
| 409 | + | |---|---| | |
| 410 | + | | `"save-update"` | Автоматический add связанных объектов (по умолчанию) | | |
| 411 | + | | `"merge"` | Каскадный merge (по умолчанию) | | |
| 412 | + | | `"delete"` | Удаление связанных при удалении родителя | | |
| 413 | + | | `"delete-orphan"` | Удаление "осиротевших" (убранных из списка) | | |
| 414 | + | | `"all"` | `save-update + merge + delete + refresh-expire` | | |
| 415 | + | | `"all, delete-orphan"` | Самый агрессивный — полное владение | | |
| 416 | + | ||
| 417 | + | ```python | |
| 418 | + | with Session(engine) as session: | |
| 419 | + | author = Author(name="Tolkien", books=[ | |
| 420 | + | Book(title="The Hobbit"), | |
| 421 | + | Book(title="LOTR"), | |
| 422 | + | ]) | |
| 423 | + | session.add(author) | |
| 424 | + | session.commit() | |
| 425 | + | ||
| 426 | + | # Удаляем автора → книги удалятся автоматически | |
| 427 | + | session.delete(author) | |
| 428 | + | session.commit() | |
| 429 | + | ``` | |
| 430 | + | ||
| 431 | + | ### ON DELETE на уровне БД | |
| 432 | + | ||
| 433 | + | Помимо каскадов SQLAlchemy, можно задать `ON DELETE` на уровне DDL: | |
| 434 | + | ||
| 435 | + | ```python | |
| 436 | + | from sqlmodel import Field | |
| 437 | + | from sqlalchemy import Column, ForeignKey, Integer | |
| 438 | + | ||
| 439 | + | class Book(SQLModel, table=True): | |
| 440 | + | id: int | None = Field(default=None, primary_key=True) | |
| 441 | + | title: str | |
| 442 | + | ||
| 443 | + | author_id: int = Field( | |
| 444 | + | sa_column=Column( | |
| 445 | + | Integer, | |
| 446 | + | ForeignKey("author.id", ondelete="CASCADE"), | |
| 447 | + | nullable=False, | |
| 448 | + | ) | |
| 449 | + | ) | |
| 450 | + | ``` | |
| 451 | + | ||
| 452 | + | --- | |
| 453 | + | ||
| 454 | + | ## 8. Загрузка связей | |
| 455 | + | ||
| 456 | + | ### Проблема: LazyLoad вне сессии | |
| 457 | + | ||
| 458 | + | ```python | |
| 459 | + | with Session(engine) as session: | |
| 460 | + | team = session.exec(select(Team)).first() | |
| 461 | + | ||
| 462 | + | # Вне сессии: | |
| 463 | + | print(team.heroes) # 💥 DetachedInstanceError! | |
| 464 | + | ``` | |
| 465 | + | ||
| 466 | + | ### Решение 1: Eager Loading (selectinload / joinedload) | |
| 467 | + | ||
| 468 | + | ```python | |
| 469 | + | from sqlalchemy.orm import selectinload, joinedload | |
| 470 | + | ||
| 471 | + | with Session(engine) as session: | |
| 472 | + | # selectinload — отдельный SELECT IN (лучше для коллекций) | |
| 473 | + | statement = select(Team).options(selectinload(Team.heroes)) | |
| 474 | + | team = session.exec(statement).first() | |
| 475 | + | ||
| 476 | + | # Теперь безопасно вне сессии: | |
| 477 | + | print(team.heroes) # ✅ работает | |
| 478 | + | ``` | |
| 479 | + | ||
| 480 | + | ```python | |
| 481 | + | with Session(engine) as session: | |
| 482 | + | # joinedload — LEFT JOIN (лучше для единичных объектов) | |
| 483 | + | statement = select(Hero).options(joinedload(Hero.team)) | |
| 484 | + | heroes = session.exec(statement).unique().all() | |
| 485 | + | # .unique() нужен при joinedload, чтобы убрать дубли | |
| 486 | + | ||
| 487 | + | for h in heroes: | |
| 488 | + | print(h.name, h.team.name) # ✅ | |
| 489 | + | ``` | |
| 490 | + | ||
| 491 | + | ### Решение 2: Настройка lazy по умолчанию | |
| 492 | + | ||
| 493 | + | ```python | |
| 494 | + | class Team(SQLModel, table=True): | |
| 495 | + | id: int | None = Field(default=None, primary_key=True) | |
| 496 | + | name: str | |
| 497 | + | ||
| 498 | + | heroes: list["Hero"] = Relationship( | |
| 499 | + | back_populates="team", | |
| 500 | + | sa_relationship_kwargs={"lazy": "selectin"}, # ← всегда загружать | |
| 501 | + | ) | |
| 502 | + | ``` | |
| 503 | + | ||
| 504 | + | | Стратегия | Описание | | |
| 505 | + | |---|---| | |
| 506 | + | | `"select"` (default) | Lazy — отдельный запрос при обращении | | |
| 507 | + | | `"selectin"` | Eager — `SELECT ... WHERE id IN (...)` | | |
| 508 | + | | `"joined"` | Eager — `LEFT JOIN` | | |
| 509 | + | | `"subquery"` | Eager — подзапрос | | |
| 510 | + | | `"raise"` | Запрет lazy load (упадёт с ошибкой) | | |
| 511 | + | | `"noload"` | Не загружать (вернёт None/[]) | | |
| 512 | + | ||
| 513 | + | ### Решение 3: Вложенная eager-загрузка | |
| 514 | + | ||
| 515 | + | ```python | |
| 516 | + | from sqlalchemy.orm import selectinload | |
| 517 | + | ||
| 518 | + | # Team → Heroes → Powers | |
| 519 | + | statement = ( | |
| 520 | + | select(Team) | |
| 521 | + | .options( | |
| 522 | + | selectinload(Team.heroes).selectinload(Hero.powers) | |
| 523 | + | ) | |
| 524 | + | ) | |
| 525 | + | ``` | |
| 526 | + | ||
| 527 | + | --- | |
| 528 | + | ||
| 529 | + | ## 9. Связи и Pydantic-схемы (read models) | |
| 530 | + | ||
| 531 | + | **Проблема:** Relationship-поля не включаются в JSON-схему автоматически. Нужно разделять модели. | |
| 532 | + | ||
| 533 | + | ### Паттерн: Base / Table / Read | |
| 534 | + | ||
| 535 | + | ```python | |
| 536 | + | # ---- Базовые (общие поля) ---- | |
| 537 | + | class TeamBase(SQLModel): | |
| 538 | + | name: str | |
| 539 | + | ||
| 540 | + | ||
| 541 | + | class HeroBase(SQLModel): | |
| 542 | + | name: str | |
| 543 | + | team_id: int | None = None | |
| 544 | + | ||
| 545 | + | ||
| 546 | + | # ---- Табличные (для БД) ---- | |
| 547 | + | class Team(TeamBase, table=True): | |
| 548 | + | id: int | None = Field(default=None, primary_key=True) | |
| 549 | + | heroes: list["Hero"] = Relationship(back_populates="team") | |
| 550 | + | ||
| 551 | + | ||
| 552 | + | class Hero(HeroBase, table=True): | |
| 553 | + | id: int | None = Field(default=None, primary_key=True) | |
| 554 | + | team: Team | None = Relationship(back_populates="heroes") | |
| 555 | + | ||
| 556 | + | ||
| 557 | + | # ---- Read-модели (для API) ---- | |
| 558 | + | class HeroRead(HeroBase): | |
| 559 | + | id: int | |
| 560 | + | ||
| 561 | + | ||
| 562 | + | class TeamRead(TeamBase): | |
| 563 | + | id: int | |
| 564 | + | ||
| 565 | + | ||
| 566 | + | # ---- Read-модели со связями ---- | |
| 567 | + | class HeroReadWithTeam(HeroRead): | |
| 568 | + | team: TeamRead | None = None | |
| 569 | + | ||
| 570 | + | ||
| 571 | + | class TeamReadWithHeroes(TeamRead): | |
| 572 | + | heroes: list[HeroRead] = [] | |
| 573 | + | ||
| 574 | + | ||
| 575 | + | # ---- Create / Update ---- | |
| 576 | + | class HeroCreate(HeroBase): | |
| 577 | + | pass | |
| 578 | + | ||
| 579 | + | ||
| 580 | + | class HeroUpdate(SQLModel): | |
| 581 | + | name: str | None = None | |
| 582 | + | team_id: int | None = None | |
| 583 | + | ``` | |
| 584 | + | ||
| 585 | + | --- | |
| 586 | + | ||
| 587 | + | ## 10. Связи + FastAPI | |
| 588 | + | ||
| 589 | + | ```python | |
| 590 | + | from fastapi import FastAPI, HTTPException, Depends | |
| 591 | + | from sqlmodel import Session, select | |
| 592 | + | from sqlalchemy.orm import selectinload | |
| 593 | + | ||
| 594 | + | app = FastAPI() | |
| 595 | + | ||
| 596 | + | ||
| 597 | + | def get_session(): | |
| 598 | + | with Session(engine) as session: | |
| 599 | + | yield session | |
| 600 | + | ||
| 601 | + | ||
| 602 | + | @app.post("/teams/", response_model=TeamRead) | |
| 603 | + | def create_team(team: TeamBase, session: Session = Depends(get_session)): | |
| 604 | + | db_team = Team.model_validate(team) | |
| 605 | + | session.add(db_team) | |
| 606 | + | session.commit() | |
| 607 | + | session.refresh(db_team) | |
| 608 | + | return db_team | |
| 609 | + | ||
| 610 | + | ||
| 611 | + | @app.get("/teams/{team_id}", response_model=TeamReadWithHeroes) | |
| 612 | + | def read_team(team_id: int, session: Session = Depends(get_session)): | |
| 613 | + | statement = ( | |
| 614 | + | select(Team) | |
| 615 | + | .where(Team.id == team_id) | |
| 616 | + | .options(selectinload(Team.heroes)) # ← eager load! | |
| 617 | + | ) | |
| 618 | + | team = session.exec(statement).first() | |
| 619 | + | if not team: | |
| 620 | + | raise HTTPException(status_code=404, detail="Team not found") | |
| 621 | + | return team | |
| 622 | + | ||
| 623 | + | ||
| 624 | + | @app.get("/heroes/{hero_id}", response_model=HeroReadWithTeam) | |
| 625 | + | def read_hero(hero_id: int, session: Session = Depends(get_session)): | |
| 626 | + | statement = ( | |
| 627 | + | select(Hero) | |
| 628 | + | .where(Hero.id == hero_id) | |
| 629 | + | .options(joinedload(Hero.team)) | |
| 630 | + | ) | |
| 631 | + | hero = session.exec(statement).first() | |
| 632 | + | if not hero: | |
| 633 | + | raise HTTPException(status_code=404, detail="Hero not found") | |
| 634 | + | return hero | |
| 635 | + | ||
| 636 | + | ||
| 637 | + | @app.post("/heroes/", response_model=HeroRead) | |
| 638 | + | def create_hero(hero: HeroCreate, session: Session = Depends(get_session)): | |
| 639 | + | db_hero = Hero.model_validate(hero) | |
| 640 | + | session.add(db_hero) | |
| 641 | + | session.commit() | |
| 642 | + | session.refresh(db_hero) | |
| 643 | + | return db_hero | |
| 644 | + | ||
| 645 | + | ||
| 646 | + | # Many-to-Many: добавить героя к миссии | |
| 647 | + | @app.post("/missions/{mission_id}/heroes/{hero_id}") | |
| 648 | + | def add_hero_to_mission( | |
| 649 | + | mission_id: int, | |
| 650 | + | hero_id: int, | |
| 651 | + | session: Session = Depends(get_session), | |
| 652 | + | ): | |
| 653 | + | mission = session.get(Mission, mission_id) | |
| 654 | + | hero = session.get(Hero, hero_id) | |
| 655 | + | if not mission or not hero: | |
| 656 | + | raise HTTPException(status_code=404) | |
| 657 | + | ||
| 658 | + | mission.heroes.append(hero) | |
| 659 | + | session.add(mission) | |
| 660 | + | session.commit() | |
| 661 | + | return {"ok": True} | |
| 662 | + | ``` | |
| 663 | + | ||
| 664 | + | --- | |
| 665 | + | ||
| 666 | + | ## 11. Частые ошибки | |
| 667 | + | ||
| 668 | + | ### ❌ Ошибка 1: Забыли `back_populates` | |
| 669 | + | ||
| 670 | + | ```python | |
| 671 | + | # ПЛОХО — связь работает только в одну сторону | |
| 672 | + | class Team(SQLModel, table=True): | |
| 673 | + | heroes: list["Hero"] = Relationship() # нет back_populates | |
| 674 | + | ||
| 675 | + | class Hero(SQLModel, table=True): | |
| 676 | + | team: Team | None = Relationship() # нет back_populates | |
| 677 | + | ``` | |
| 678 | + | ||
| 679 | + | ```python | |
| 680 | + | # ХОРОШО | |
| 681 | + | class Team(SQLModel, table=True): | |
| 682 | + | heroes: list["Hero"] = Relationship(back_populates="team") | |
| 683 | + | ||
| 684 | + | class Hero(SQLModel, table=True): | |
| 685 | + | team: Team | None = Relationship(back_populates="heroes") | |
| 686 | + | ``` | |
| 687 | + | ||
| 688 | + | ### ❌ Ошибка 2: Relationship без Foreign Key | |
| 689 | + | ||
| 690 | + | ```python | |
| 691 | + | # 💥 sqlalchemy.exc.NoForeignKeysError | |
| 692 | + | class Hero(SQLModel, table=True): | |
| 693 | + | id: int | None = Field(default=None, primary_key=True) | |
| 694 | + | team: "Team" = Relationship() # нет team_id с foreign_key! | |
| 695 | + | ``` | |
| 696 | + | ||
| 697 | + | ### ❌ Ошибка 3: Циклический импорт | |
| 698 | + | ||
| 699 | + | ```python | |
| 700 | + | # models/team.py | |
| 701 | + | from models.hero import Hero # 💥 circular import | |
| 702 | + | ||
| 703 | + | # Решение: используйте строковые аннотации | |
| 704 | + | heroes: list["Hero"] = Relationship(back_populates="team") | |
| 705 | + | ``` | |
| 706 | + | ||
| 707 | + | И вызовите `model_rebuild()` после определения всех моделей: | |
| 708 | + | ```python | |
| 709 | + | Team.model_rebuild() | |
| 710 | + | Hero.model_rebuild() | |
| 711 | + | ``` | |
| 712 | + | ||
| 713 | + | ### ❌ Ошибка 4: Доступ к связям вне сессии | |
| 714 | + | ||
| 715 | + | ```python | |
| 716 | + | with Session(engine) as session: | |
| 717 | + | team = session.exec(select(Team)).first() | |
| 718 | + | ||
| 719 | + | print(team.heroes) # 💥 DetachedInstanceError | |
| 720 | + | ||
| 721 | + | # Решение: eager loading (см. раздел 8) | |
| 722 | + | ``` | |
| 723 | + | ||
| 724 | + | ### ❌ Ошибка 5: Дублирование при joinedload | |
| 725 | + | ||
| 726 | + | ```python | |
| 727 | + | # ПЛОХО — получите дубликаты | |
| 728 | + | statement = select(Team).options(joinedload(Team.heroes)) | |
| 729 | + | teams = session.exec(statement).all() # дубли! | |
| 730 | + | ||
| 731 | + | # ХОРОШО | |
| 732 | + | teams = session.exec(statement).unique().all() # ← .unique() | |
| 733 | + | ``` | |
| 734 | + | ||
| 735 | + | ### ❌ Ошибка 6: Relationship-поля в JSON | |
| 736 | + | ||
| 737 | + | ```python | |
| 738 | + | # Relationship-поля НЕ сериализуются автоматически | |
| 739 | + | team = session.get(Team, 1) | |
| 740 | + | team.model_dump() # {"id": 1, "name": "Avengers"} — heroes нет! | |
| 741 | + | ||
| 742 | + | # Для API используйте read-модели (раздел 9) | |
| 743 | + | ``` | |
| 744 | + | ||
| 745 | + | --- | |
| 746 | + | ||
| 747 | + | ## Шпаргалка | |
| 748 | + | ||
| 749 | + | ``` | |
| 750 | + | ┌─────────────────────────────────────────────────────────────────┐ | |
| 751 | + | │ SQLModel Relationships │ | |
| 752 | + | ├─────────────────────────────────────────────────────────────────┤ | |
| 753 | + | │ │ | |
| 754 | + | │ ONE-TO-MANY: │ | |
| 755 | + | │ Parent: children: list["Child"] = Relationship(bp="parent") │ | |
| 756 | + | │ Child: parent_id: int = Field(FK) │ | |
| 757 | + | │ parent: Parent = Relationship(bp="children") │ | |
| 758 | + | │ │ | |
| 759 | + | │ ONE-TO-ONE: │ | |
| 760 | + | │ Как One-to-Many, но: │ | |
| 761 | + | │ • unique=True на FK │ | |
| 762 | + | │ • sa_relationship_kwargs={"uselist": False} │ | |
| 763 | + | │ │ | |
| 764 | + | │ MANY-TO-MANY: │ | |
| 765 | + | │ • Создать LinkModel с двумя FK (оба PK) │ | |
| 766 | + | │ • link_model=LinkModel в обоих Relationship │ | |
| 767 | + | │ │ | |
| 768 | + | │ SELF-REF: │ | |
| 769 | + | │ • FK ссылается на свою же таблицу │ | |
| 770 | + | │ • sa_relationship_kwargs={"remote_side": "Model.id"} │ | |
| 771 | + | │ │ | |
| 772 | + | │ CASCADE: │ | |
| 773 | + | │ sa_relationship_kwargs={"cascade": "all, delete-orphan"} │ | |
| 774 | + | │ │ | |
| 775 | + | │ EAGER LOAD: │ | |
| 776 | + | │ select(Model).options(selectinload(Model.relation)) │ | |
| 777 | + | │ или lazy="selectin" в Relationship │ | |
| 778 | + | │ │ | |
| 779 | + | │ bp = back_populates FK = foreign_key="table.col" │ | |
| 780 | + | └─────────────────────────────────────────────────────────────────┘ | |
| 781 | + | ``` | |
| 782 | + | ||
| 783 | + | Этот гайд покрывает все основные сценарии работы со связями в SQLModel. Для более сложных случаев (полиморфные связи, гибридные свойства) используйте прямые возможности SQLAlchemy через `sa_relationship_kwargs`. | |
Novější
Starší