最終更新 2 weeks ago

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

wowlikon's Avatar wowlikon revised this gist 2 weeks ago. Go to revision

2 files changed, 1777 insertions

base.md(file created)

@@ -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(file created)

@@ -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`.
Newer Older