Перейти к содержанию

Фильтр и постраничный список своих броней

Оглавление серии

Отдельная карточка полезна после создания, но пользователь накапливает несколько броней. Нужен список, который не выдаёт всю таблицу и не смешивает разных владельцев. Добавим GET /bookings с фильтром кабинета и курсором after. Результат — клиент получает ограниченное число своих записей, знает значение следующего курсора и понимает, что такой список не является неизменным снимком расписания.

Курсор обозначает границу продолжения в выбранном порядке. В этой серии порядок — возрастающий id, а after хранит последний увиденный номер. Это простой явный договор, а не непрозрачный подписанный токен. Число из запроса само по себе не даёт доступа к чужим строкам: условие actor_id применяется при каждом обращении к списку. Если позже порядок станет временным, курсор и индекс нужно менять вместе, а не оставлять прежний id без объяснения.

Условия выборки

Исходный SELECT содержит Booking.actor_id == actor и Booking.id > after. Первая часть удерживает личную область, вторая — выбирает следующую страницу. Не переносим ownership в фильтр интерфейса после получения всех данных. Сервер должен прочитать и вернуть только разрешённые строки. Если клиент отправит большое after, он получит пустую свою страницу, но не перескочит в чужой список. Это помогает увидеть, что курсор и право решают разные задачи.

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

Query ограничивает limit от одного до пятидесяти и after от нуля. Значения не используются для построения SQL строковой подстановкой. SQLAlchemy связывает данные как параметры выражения. Отрицательный after или чрезмерный limit отклоняется на границе запроса. Ограничение размера защищает предсказуемость одной выдачи, но не является полной политикой нагрузки. Клиент всё ещё может часто обращаться к API, и управление частотой было бы отдельной задачей эксплуатации.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-13/.

migrations/004_owner_page.sql

CREATE INDEX bookings_owner_page ON bookings (actor_id, id);

schemas.py

from datetime import timezone
from typing import Literal

from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator


class ResourceOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str


class BookingIn(BaseModel):
    model_config = ConfigDict(extra="forbid")
    resource_id: int = Field(gt=0)
    starts_at: AwareDatetime
    ends_at: AwareDatetime

    @model_validator(mode="after")
    def valid_interval(self):
        if self.ends_at <= self.starts_at:
            raise ValueError("End must follow start")
        self.starts_at = self.starts_at.astimezone(timezone.utc)
        self.ends_at = self.ends_at.astimezone(timezone.utc)
        return self


class BookingOut(BookingIn):
    model_config = ConfigDict(from_attributes=True, extra="ignore")
    id: int
    actor_id: int
    state: Literal["confirmed", "cancelled"] = "confirmed"


class BookingPage(BaseModel):
    items: list[BookingOut]
    next_after: int | None

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.

GET /bookings?limit=2 → items из двух своих карточек, next_after = последняя id
GET /bookings?after=последняя_id&limit=2 → продолжение
Пустая страница → {"items":[],"next_after":null}

Лишняя строка для продолжения

Запрос получает limit + 1 строку. Первые limit становятся items, а дополнительная строка говорит, что продолжение существует в текущем чтении. Тогда next_after равен номеру последней выданной карточки. Если строк не больше limit, next_after равен None. Такой способ не требует отдельного общего COUNT для каждого запроса и не обещает точное число страниц. Пользователь получает конкретную возможность продолжить список, а не оценку всей таблицы.

Важно вычислять курсор по последней выданной строке, а не по дополнительной. Если поставить after равным номеру скрытой лишней записи, она пропадёт из следующего результата. Это распространённая логическая ошибка пагинации, которую легко заметить на маленьком наборе. Представьте id 4, 7, 9 и limit=2: выдаются 4 и 7, курсор 7, следующая выборка начинается строго после 7 и возвращает 9. Пропуски в номерах не требуют искусственного заполнения.

Индекс bookings_owner_page создаётся по actor_id и id. Его порядок соответствует обязательному равенству владельца и последующему диапазону номера. Мы не обещаем измеренный план запроса: база и данные не запускались. Дополнительный фильтр ресурса может менять полезность индекса при больших объёмах. Учебное объяснение показывает связь условий и структуры индекса, а реальная оценка потребовала бы данных, статистики и наблюдаемого плана в отдельном исследовании.

Изменения между страницами

Два HTTP-запроса выполняются в разных транзакциях. Между ними может появиться новая бронь или измениться состояние старой. Поэтому это не единый снимок всех данных на момент первой страницы. Порядок по id позволяет продолжать после увиденной границы, но не гарантирует, что будут замечены строки, чей меньший id зафиксировался позже. Генерация номера и порядок commit могут различаться. Такой предел особенно важен, если кто-то пытается использовать курсор как надёжный журнал событий.

Для журнала, который должен обработать каждый факт без пропуска, нужен другой договор, например отдельная очередь событий и граница подтверждения. Наш список предназначен для обозрения личных карточек. Новая запись с меньшим id может быть видна при обновлении списка с начала, а не обязана появиться в уже продолжаемой странице. Мы не скрываем это ограничение за словом «устойчивый»: устойчивым здесь является критерий порядка, а не замороженное содержимое базы.

Ожидаемая выдача содержит items и next_after. При трёх своих записях и limit=2 первая страница возвращает две, следующая — одну и null. Если выбрать кабинет без своих записей, items пуст, next_after null. Второй участник видит собственную область при тех же параметрах. В карточках может присутствовать cancelled, поскольку по умолчанию список показывает историю, а не только занятые интервалы. Это намеренный предметный смысл данного маршрута.

Полные схемы и main.py находятся в снимке lesson-13. Ни выдача, ни план индекса фактически не измерялись. Для самостоятельного разбора составьте на бумаге пять номеров с пропусками и вычислите две страницы при limit=2. Затем добавьте чужую запись между ними: объясните, почему она не попадает в items. Следующий урок выделит событие успешного создания в отдельную транзакционную запись, чтобы дополнительная реакция не терялась вместе с жизнью процесса API.

Фильтр сопровождает продолжение

Следующий запрос должен сохранить выбранный resource_id, если пользователь продолжает тот же отфильтрованный список. Курсор after сам по себе не запоминает остальные условия. Если первый экран ограничивал кабинет 1, а продолжение убрало фильтр, клиент получит уже другой набор собственных броней. Это не ошибка SQL: он исполнил новую выборку. В простом числовом договоре клиент отвечает за перенос всех условий вместе с курсором.

Также не используем next_after как обещание вечной следующей страницы. Между запросами данные могли измениться; даже найденное продолжение относится к текущему чтению. Для нашего списка отмена не удаляет строку, поэтому одни изменения состояния не разрушают её номер, но новые конкурентные фиксации всё равно возможны. Если интерфейсу нужно обновить обзор целиком, он начинает с after=0. Такое действие отличается от продолжения и должно быть понятным человеку: первая операция собирает нынешнюю историю, вторая двигается дальше выбранного порядка.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.