Почему группировка по таблице заказов не показывает дни без заказов, даже если в отчёте нужны нулевые продажи?
GROUP BY формирует группы только из строк, существующих во входном наборе. Поэтому день без заказов не появляется сам по себе. Чтобы показать его с нулём, нужен полный набор дней — например, календарная таблица — и LEFT JOIN к заказам с последующей агрегацией.
Агрегации SQL изначально предназначены для свёртки имеющихся фактов: строк продаж, заказов или событий. Они не создают отсутствующие комбинации измерений, поскольку отсутствие строки во входных данных неотличимо от отсутствия соответствующего факта без дополнительного источника измерений.
В аналитических отчётах требуется так называемый плотный временной ряд: показать каждый день, включая дни с нулевой активностью. Для этого календарь или таблица измерений выступает независимым каркасом отчёта.
Если группировать непосредственно таблицу заказов по дате, результат будет содержать только даты, встречающиеся в заказах. Пропущенные дни исчезнут, что может исказить графики, средние значения по дням, расчёт конверсии и поиск периодов без активности.
Простая замена INNER JOIN на LEFT JOIN не решает проблему, если слева также находится таблица заказов: без исходной строки для дня создать результат невозможно. Кроме того, при внешнем соединении важно считать идентификатор заказа, а не COUNT(*), иначе пустая присоединённая строка даст единицу.
Сначала формируют набор всех требуемых дней: календарной таблицей, рекурсивным CTE или средствами конкретной СУБД. Затем этот набор делают левой стороной LEFT JOIN, а заказы присоединяют по нужной дате или диапазону дат.
Для дня без заказа все поля o имеют значение NULL, поэтому COUNT(o.order_id) возвращает ноль. В отличие от него, COUNT(*) посчитал бы строку календаря и вернул единицу.
Если в заказах хранится временная метка, соединять обычно нужно по диапазону от начала дня включительно до начала следующего дня не включительно. Такой подход не зависит от времени внутри order_timestamp и часто позволяет эффективнее использовать индекс по этому столбцу.
Календарь должен охватывать именно требуемый период. Если его диапазон шире или уже периода отчёта, фильтр по датам следует применять к календарю и учитывать границы соединения. Для нескольких измерений, например день и регион, понадобится каркас всех нужных комбинаций или отдельная таблица измерений регионов.
Команда строила ежедневный отчёт по заказам за месяц. Вариант с GROUP BY order_date был простым и быстрым, но полностью пропускал выходные без заказов. Вариант с генерацией дат внутри запроса не требовал постоянной таблицы, однако зависел от диалекта SQL и усложнял повторное использование календаря.
Выбрали календарную таблицу и LEFT JOIN, потому что она уже использовалась в других отчётах, имела индексы и содержала признаки рабочего дня, недели и месяца. Заказы считались через COUNT(order_id), поэтому пустые дни корректно отображались как нулевые. В результате графики стали непрерывными, а расчёты средних дневных продаж перестали исключать дни без активности.
Почему фильтр по заказам нельзя бездумно помещать в WHERE после LEFT JOIN?
Условие в WHERE применяется после соединения. Для дня без заказа присоединённые столбцы имеют NULL, и условие вроде проверки даты или статуса заказа удалит такую строку. В итоге LEFT JOIN фактически превратится в INNER JOIN для этого условия.
Ограничения на таблицу заказов, сохраняющие пустые дни, обычно размещают в условии ON либо предварительно фильтруют заказы в отдельном подзапросе. Тогда календарные строки остаются, а неподходящие заказы не участвуют в подсчёте.
Почему для временных меток опасно группировать только по преобразованной дате?
Преобразование временной метки к календарной дате может быть корректным логически, но иногда мешает использованию индекса на исходном столбце. Кроме того, нужно учитывать часовой пояс: одна и та же временная метка может относиться к разным календарным дням в разных зонах.
Часто эффективнее заранее определить границы дня в нужном часовом поясе и соединять по диапазону. Конкретный синтаксис преобразований зависит от СУБД, но принцип одинаков: границы должны быть однозначными, а фильтрация — совместимой с индексом.
Как получить нули не только для пустых дней, но и для пустых комбинаций «день — регион»?
Одного календаря недостаточно: он создаёт только дни. Нужно сформировать каркас календарь × регионы, затем выполнить LEFT JOIN фактов по обеим координатам и считать nullable-идентификатор факта.
Такой каркас увеличивает число строк пропорционально произведению количества дней и регионов, поэтому период и набор регионов следует ограничивать заранее. Это обеспечивает корректную полноту отчёта, но может потребовать дополнительного объёма памяти и вычислений.