total не совпадает со списком: считаем группы через подзапрос SQLAlchemy
Endpoint показывает проекты, у которых не меньше двух задач. Список построен через GROUP BY и HAVING, а total считается отдельным count по таблице задач. В результате пользователь видит одну строку и двадцать страниц, половина которых пустая. Счётчик считает исходные записи, хотя интерфейс листает группы после фильтрации.
Посчитайте тот результат, который листает клиент
Для страницы групп единица результата — проект, прошедший HAVING. Сначала построим базовую выборку идентификаторов этих проектов. Затем превратим её в подзапрос и посчитаем строки снаружи. Тот же базовый запрос используем для страницы с порядком и limit. Так условие попадания в список имеет один источник и не дублируется вручную в двух ветках.
LIMIT и OFFSET применяются после подсчёта всего подходящего набора. Если засунуть уже ограниченную страницу в подзапрос count, total будет равен размеру страницы, а не общему количеству групп. ORDER BY для счётчика обычно не нужен; он нужен странице, чтобы соседние запросы имели определённый порядок. В примере сортируем по уникальному id проекта.
Минимальный набор обнаружит ошибку
Нужен SQLAlchemy. Подготовим три проекта: два с достаточным числом задач и один с недостаточным. Страница имеет размер один, поэтому отдельно проверим total=2 и один возвращённый идентификатор. Ошибочный подсчёт количества задач тоже вычислим, чтобы увидеть разницу на реальных данных.
from sqlalchemy import create_engine, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
class Base(DeclarativeBase):
pass
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
tasks: Mapped[list["Task"]] = relationship()
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
db.add_all([Project(id=1, tasks=[Task(id=10), Task(id=11)]), Project(id=2)])
db.commit()
from sqlalchemy import func
with Session(engine) as db:
db.add(Project(id=3, tasks=[Task(id=30), Task(id=31), Task(id=32)]))
db.commit()
grouped = (select(Project.id)
.join(Project.tasks)
.group_by(Project.id)
.having(func.count(Task.id) >= 2))
with Session(engine) as db:
wrong_total = db.scalar(select(func.count()).select_from(Task))
total = db.scalar(select(func.count()).select_from(grouped.subquery()))
page = db.scalars(grouped.order_by(Project.id).limit(1).offset(0)).all()
second = db.scalars(grouped.order_by(Project.id).limit(1).offset(1)).all()
assert wrong_total == 5
assert total == 2 and page == [1] and second == [3]
empty_groups = grouped.having(func.count(Task.id) >= 10)
assert db.scalar(select(func.count()).select_from(empty_groups.subquery())) == 0
print({"task_count": wrong_total, "total": total, "page": page, "next": second})
engine.dispose()
Не теряйте условия доступа
Если пользователь видит только проекты своей команды, соответствующий фильтр должен находиться в grouped до вычисления total. Иначе сам счётчик раскрывает количество недоступных записей или показывает несуществующие страницы. Общий базовый запрос уменьшает риск расхождения, особенно когда к фильтрам добавляются архив, даты и права просмотра.
Добавление HAVING вторым вызовом в примере объединяет условия через AND. Это позволяет проверить пустой набор без переписывания исходной выборки. В реальном коде параметры фильтра лучше передавать в отдельную функцию построения базового select. Её результат используется для списка и счётчика, а HTTP-слой только проверяет ограничения страницы и сериализует числа.
При конкурентных изменениях два отдельных запроса могут увидеть разные состояния. Если договор требует строго согласованных total и списка, выберите подходящий уровень изоляции и единую транзакцию на вашей базе. SQLite-пример показывает арифметику набора, а свойства изоляции PostgreSQL проверяются на PostgreSQL. Не выводите их из успешного локального теста в памяти.
Для больших наборов точный count может стать дорогой частью endpoint. Сначала убедитесь, что клиент действительно использует общее число: иногда ему достаточно next cursor или признака наличия следующей страницы. Но если total обещан договором, ускорение не должно менять единицу подсчёта с групп на исходные строки.
В статье о пагинации разобраны предел страницы и устойчивый порядок. Здесь к ним добавляется отдельная задача: согласовать total с GROUP BY и HAVING.
Продолжить на практике
Соедините фильтры, счётчик и страницу в одном учебном endpoint и проверьте пустой результат. В бесплатной программе FastAPI есть работа с базой и автотесты, на которых удобно закрепить этот договор списка. Откройте бесплатную программу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
