Урок курса

Engine, Session и база SQLite

FastAPI для начинающих: API с базой данных и тестами

В прошлом уроке мы описали модель Room через DeclarativeBase и получили класс, который SQLAlchemy умеет превращать в таблицу. Но саму таблицу мы ещё не создали — для этого нужен Engine, указывающий на конкретный файл базы данных.

Engine и metadata.create_all: подключение к SQLite и создание таблиц

Engine — это точка входа SQLAlchemy к базе данных. Его создают один раз для всего приложения:

from sqlalchemy import create_engine
from models import Base

DATABASE_URL = "sqlite:///./roomly.db"

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})

URL sqlite:///./roomly.db означает файловую базу SQLite. Три слеша — это протокол плюс путь; точка-слеш делает путь относительным. Важный момент: ./ отсчитывается от рабочего каталога, в котором вы запускаете uvicorn, а не от расположения файла database.py. Если запустить uvicorn из другого каталога, файл roomly.db окажется там же.

connect_args={"check_same_thread": False} — в SQLAlchemy 2.0.36 это значение уже выставлено по умолчанию для файловой SQLite. Явная передача аргумента не исправляет ошибку, а документирует сделанный выбор: при чтении кода сразу видно, что параметр проверен и сознательно установлен. Этот аргумент не означает, что одну сессию безопасно использовать в нескольких потоках одновременно — об этом ниже.

create_engine не открывает соединение в момент вызова. Движок создаётся мгновенно; реальное обращение к файлу произойдёт при первой операции с базой, например при create_all.

Создание таблиц при импорте

Когда database.py импортируется, Python исполняет строку:

Base.metadata.create_all(bind=engine)

К этому моменту from models import Base уже выполнил весь models.py — в том числе объявление класса Room. Поэтому metadata знает о таблице rooms и создаст её в файле roomly.db, если она там ещё не существует.

Повторный вызов create_all — при следующем перезапуске приложения — безопасен: существующую таблицу он пропустит, данные в ней не тронет. Однако если вы добавили новый столбец в модель Room, create_all его не добавит в уже созданную таблицу. Для изменения схемы существующей базы нужны инструменты миграций — это отдельная тема.

SessionLocal: фабрика сессий и три разных объекта

Engine, фабрика и сессия — три разных уровня, и их легко перепутать.

Engine управляет пулом соединений с базой. Один на всё приложение.

Фабрика создаётся через sessionmaker и знает, как порождать сессии с нужными настройками:

from sqlalchemy.orm import sessionmaker

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

bind=engine связывает фабрику с конкретным движком. Это не означает, что фабрика захватывает одно соединение навсегда — соединения берутся из пула по мере надобности.

Сессия появляется при вызове SessionLocal(). Каждый вызов возвращает отдельный объект Session. Два вызова — два независимых объекта, каждый со своим состоянием транзакции.

Что делают флаги

autocommit=False — стандартный режим работы: сессия не фиксирует изменения автоматически. Чтобы строки попали в базу, нужен явный commit. Закрытие сессии или завершение запроса без commit означает откат незафиксированных изменений.

autoflush=False отключает автоматический flush перед ORM-запросами. В обычном режиме SQLAlchemy перед SELECT сбрасывает накопленные изменения во временную транзакцию, чтобы запрос видел актуальное состояние. С autoflush=False этого не происходит. Но commit всё равно выполняет flush перед фиксацией — этот шаг не зависит от флага. Явный db.flush() тоже всегда доступен.

Flush отправляет изменения в рамках открытой транзакции (SQL-операторы уходят к базе, но транзакция не закрыта). Commit фиксирует транзакцию целиком. Эти операции будут применяться в следующем уроке при реальном CRUD; здесь важно понять, что close не заменяет ни одну из них.

get_db: сессия на запрос через yield и finally

Зависимости с return возвращают значение и на этом заканчиваются. Для ресурсов, которые нужно явно освободить, FastAPI поддерживает зависимости с yield: код до yield выполняется до обработчика, код после — после.

get_db выглядит так:

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Когда FastAPI видит функцию-генератор в Depends, он запускает её до yield, получает выданный объект и передаёт его в обработчик. После того как обработчик завершился и ответ сформирован, FastAPI продолжает выполнение генератора — то есть исполняет блок finally. Это происходит до отправки HTTP-ответа клиенту (поведение FastAPI 0.115.6).

finally нужен, чтобы db.close() выполнился в любом случае — и при нормальном завершении обработчика, и при исключении. Если убрать try/finally и оставить db.close() просто после yield, то при исключении в обработчике генератор прервётся на yield и строка закрытия не выполнится. Соединение, если сессия его уже заняла, окажется не возвращено в пул вовремя.

Каждый входящий запрос получает свою сессию: Depends(get_db) вызывает get_db() заново. Именно поэтому check_same_thread=False не делает общую сессию потокобезопасной — общей сессии просто нет.

Полный пример

Вот два файла нового минимального проекта. models.py остаётся из предыдущего урока.

database.py:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base

DATABASE_URL = "sqlite:///./roomly.db"

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base.metadata.create_all(bind=engine)

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

main.py:

from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
from database import get_db

app = FastAPI()

@app.get("/db-check")
def db_check(db: Session = Depends(get_db)):
    return {"status": "ok"}

Запустите из каталога с этими файлами:

uvicorn main:app --reload

При старте Python импортирует database, исполнит create_all и создаст файл roomly.db с таблицей rooms. GET-запрос на /db-check вернёт 200 {"status": "ok"}. Обработчик получает объект Session, но не делает никаких SQL-запросов — маршрут подтверждает только то, что сессия создана и передана без ошибок. Реальные операции с таблицей появятся в следующем уроке.

Обратите внимание: SessionLocal определён до def get_db(), но это не требование к порядку. Python читает тело функции при вызове, а не при объявлении. Имя SessionLocal должно быть разрешено к моменту, когда генератор доходит до строки db = SessionLocal() — то есть когда FastAPI его запускает, а не когда файл импортируется.

Для FastAPI0.115.6 get_db создаёт SessionLocal(), yield передаёт сессию обработчику. Обычная подготовка ответа и исключение в обработчике сходятся в finally: db.close(). Выход из зависимости выполняется до передачи HTTP-ответа клиенту. Создание Session не обещает немедленного получения соединения; close не заменяет commit.
В закреплённой FastAPI 0.115.6 выход из yield-зависимости выполняется до передачи ответа. finally закрывает Session и при обычной ошибке обработчика.

Задача 1

get_db объявлена так:

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Обработчик маршрута принял db через Depends(get_db) и выбросил HTTPException(status_code=404). Что произойдёт с сессией db?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

В рабочем каталоге ещё нет roomly.db. Выполнена только строка engine = create_engine("sqlite:///./roomly.db"), create_all и другие операции с базой не вызывались. Обязан ли файл уже появиться на диске?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

В файле roomly.db есть таблица rooms с двумя строками. Base.metadata.create_all(bind=engine) выполнен повторно, других операций с данными нет. Сколько из этих двух строк останется в rooms? Введите одно целое число.

Без регистрации · результат не сохраняется

Задача 4

Сессия для каждого обращения

Roomly занимается управлением переговорными комнатами и до недавнего времени хранила весь каталог прямо в памяти приложения. Теперь команда готовится перенести эти данные в полноценное хранилище и для начала закладывает правильный фундамент: нужно убедиться, что соединение с хранилищем открывается, используется и закрывается так, как этого требует работа с реальными ресурсами.

Главный принцип, который команда хочет соблюсти, прост: каждое обращение к системе должно работать со своей собственной сессией. Никакой общей сессии, которую все запросы делят между собой и которая рано или поздно оказывается в непредсказуемом состоянии. Сессия открывается в начале обработки, сопровождает её до конца и затем аккуратно освобождает занятые ресурсы — независимо от того, завершилось ли обращение успехом или привело к ошибке.

Особенно важно последнее: сбой в одном обращении не должен оставлять за собой незакрытых ресурсов и тем более мешать тем обращениям, которые придут следом. Это требование звучит очевидно, но именно оно чаще всего нарушается, когда освобождение ресурсов доверяют случаю. Отдельного внимания заслуживает и повторная инициализация хранилища: если данные в нём уже есть, они должны остаться нетронутыми.

Результат, к которому стремится команда, — это работающая обвязка вокруг хранилища, которая ведёт себя предсказуемо при любом исходе: сессия появляется тогда, когда нужна, и исчезает, когда её работа закончена.

Что уже дано

Это отдельная однофайловая лаба настройки SQLAlchemy, а не перенос полного Roomly. В редакторе уже есть Base, модель Room(id, name, capacity), app и два законченных диагностических маршрута. Они выполняют предоставленные SQL-операции; писать запросы к строкам самостоятельно в этом уроке не нужно. TODO относятся только к Engine, фабрике и жизненному циклу Session.

Перед видимым файлом служебная подготовка создаёт пустой временный каталог внутри рабочей папки и задаёт DATABASE_URL: str — SQLite URL собственного файла roomly.db. Используйте именно это имя; создавать путь или открывать прежние базы не нужно. Публичный переходник выполняет HTTP через TestClient, передаёт диагностические наблюдения и читает этот же файл отдельным SQLite-соединением. Данные для проверок поступают через RPC, ожидаемые ответы в переходнике отсутствуют. После проверки он закрывает свои ресурсы и удаляет только собственный временный каталог. Писать переходник, SQL-диагностику, управление файлами или тесты не требуется. Наблюдатель удерживает ссылки на выданные Session, их соединения и загруженные ORM-объекты, чтобы проверить освобождение после успеха и после преднамеренного исключения. Он не выдаёт и не закрывает Session вместо вашей get_db во время запросов.

Что нужно сделать

Создайте публичный engine для DATABASE_URL и SessionLocal через sessionmaker: каждый вызов фабрики даёт отдельную Session, связанную с engine; autoflush=False, обычный режим без autocommit. Для файловой SQLite SQLAlchemy2 допускает как явный check_same_thread=False, так и его стандартное значение.

Реализуйте init_db(): она создаёт отсутствующие таблицы зарегистрированного Base и сохраняет существующие строки при повторном вызове. Готовый вызов внизу выполняет инициализацию при запуске. Реализуйте get_db() как зависимость с yield: новая Session остаётся рабочей в течение каждого обработчика и обязательно закрывается после его завершения, в том числе при исключении. Не используйте общую Session для нескольких обращений. Способ гарантированного закрытия выбираете вы.

Сохраните готовые app, модель и обработчики. GET /db-check возвращает200 с status="ok" и числом строк комнаты; GET /db-failure после обращения к той же базе преднамеренно возвращает500 с detail="Intentional diagnostic failure". Следующий запрос должен снова получить свою рабочую Session. Эти диагностические ответы уже реализованы и не являются задачей на их форматирование.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После выполнения файла проверяющая система обращается к верхнеуровневому app: публичный переходник вызывает TestClient внутри процесса приложения и передаёт результат скрытой проверке. Запускать Uvicorn, сеть, клиентский скрипт или pytest не требуется.

Если в собственной учебной таблице две комнаты, GET /db-check возвращает {"status":"ok","room_count":2}. После GET /db-failure новый успешный запрос видит те же две комнаты; повтор init_db не очищает таблицу.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку