Привет, Хабр! Важной составной частью Time Intelligence в DAX являются функции работы со временем, в частности, DATEADD, т.к. она является базовой для других (например, SAMEPERIODLASTYEAR является псевдонимом DATEADD('Date'[Date], -1, YEAR)) и возвращает таблицу (в отличие, например, от EDATE , которая возвращает только скаляр), так и использоваться в качестве фильтра в CALCULATE.
Информацию о DATEADD приходится собирать из разных источников. Часть описано в официальной документации DATEADD, что-то есть в DAX Guide, что-то есть в материалах SQL BI, поэтому картина составляется по частям, хотя логика функции неочевидна и велики риски ошибок при использовании DATEADD в случае некорректного её использования.
Интересующимся правилами DATEADD для обеспечения Time Intelligence в DAX — добро пожаловать под кат :)
Важной задачей при смещении периодов времени (месяцы, годы) является необходимость учесть разное количество дней в разных месяцах и годах (для смещений на годы — с учетом високосного года). Можно сказать, что у функции DATEADD нет таких проблем, она возвращает целый месяц при смещении, если в исходном диапазоне был выбран ровно месяц. Для реализации такого поведения функция DATEADD следует трем следующим правилам.
1. Функция DATEADD возвращает только даты, существующие в столбце с датами. Если после смещения даты в этой таблице не найдены, функция вернет только те даты, которые есть в таблице.
2. Если дата отсутствует в соответствующем месяце после операц��и смещения, то функция DATEADD вернет последний день соответствующего месяца.
3. Если текущие даты для сдвига включают в себя два последних дня месяца, то в результат функции DATEADD войдут все даты между соответствующими днями в смещенной таблице и окончанием месяца. Как следствие, если выбрана одна дата, то Time Intelligence логика с периодами не применяется к одной дате, поскольку единственная дата — это не период.
Рассмотрим правила детальнее с DAX примерами и примерами из ClickHouse. Примеры ClickHouse нужны лишь для демонстрации базовой возможной реализации.
Правило 1 — только даты из календаря
DAX при сдвиге периодов времени не генерирует даты — он берёт их только из таблицы дат date.
В связи с этим следующее поведение не соответствует DAX:
SELECT addMonths(date, 1) FROM selected_dates
Приведенный выше пример создаёт даты, даже если их нет в календаре, что не соответствует DAX. DAX-эквивалент в ClickHouse может быть следующим:
SELECT c.date FROM selected_dates s INNER JOIN calendar c ON c.date = addMonths(s.date, 1)
В этом случае если даты нет в календаре, то она не попадёт в результат, что совпадает с DAX.
Правило 2 — если дня нет, то берется последний день месяца
Пример DAX:
31 января + 1 месяц = 28 февраля
В ClickHouse это корректно имплементировано через addMonths , то есть правило 2 уже соблюдается автоматически:
SELECT addMonths(toDate('2024-01-31'), 1) -- 2024-02-29
Правило 3 — если выбраны два последних дня месяца, то вернуть период до конца месяца
Это самое нетривиальное правило DAX и СУБД зачастую не имеют аналогичного функционала. В частности, в ClickHouse этого нет, поэтому для демонстрации соответствующей логики SQL был реализован, который в общих чертах показыавет особенности логики Time Intelligence.
Если выбраны два последних дня месяца, то включается Time Intelligence логика, обеспечивающая корректное количество дней во всех месяцах:
Remember that when it comes to the DAX time intelligence functions, saying that an entire month is selected means that all the values in the Date table for that month are visible in the filter context. This algorithm is required to return the 31 days in January in case February (which has only 28 or 29 days) is selected.
Пусть выбраны даты:
29 апреля 30 апреля
DATEADD со смещением на один месяц назад возвращет:
29 марта 30 марта 31 марта - день добавился автоматически
Рассмотрим схематичное решение-прототип на ClickHouse с ограничениями (диапазон дат для смещения непрерывный и в рамках одного месяца) — лишь для иллюстрации объема логики SQL, которая нужна для имплементации DATEADD из DAX.
Шаг1 — проверить, что выбраны два последних дня месяца:
WITH last_two_check AS ( SELECT count() AS cnt FROM selected_dates WHERE date >= addDays(toLastDayOfMonth(date), -1) ) SELECT cnt FROM last_two_check
Шаг 2 — границы выбранного диапазона:
WITH bounds AS ( SELECT min(date) AS min_d, max(date) AS max_d FROM selected_dates )
Шаг 3 — смещённый диапазон:
WITH bounds AS ( SELECT min(date) AS min_d, max(date) AS max_d FROM selected_dates ) SELECT addMonths(min_d, 1) AS new_start, toLastDayOfMonth(addMonths(max_d, 1)) AS new_end FROM bounds
Полный DAX-эквивалент DATEADD в ClickHouse с описанными ограничениями:
-- Ограничения: -- начало и конец выбранного периода в одном месяце -- нет промежутков в таблице дат WITH -- смещение в месяцах -1 AS shift, selected AS ( SELECT arrayJoin([ toDate('2024-04-29'), toDate('2024-04-30') ]) AS date ), -- проверка на два последний дня месяца last_two_check AS ( SELECT count() AS cnt FROM selected WHERE date >= addDays(toLastDayOfMonth(date), -1) ), bounds AS ( SELECT min(date) AS min_d, max(date) AS max_d FROM selected ), -- смещенный период дат range_shift AS ( SELECT addMonths(min_d, shift) AS new_start, toLastDayOfMonth(addMonths(max_d, shift)) AS new_end FROM bounds ), calendar AS ( SELECT toDate('2000-01-01') + number AS date FROM numbers(36500) ) -- режим DAX "расширить до конца месяца" SELECT c.date FROM calendar c CROSS JOIN range_shift r CROSS JOIN last_two_check l WHERE c.date BETWEEN r.new_start AND r.new_end AND l.cnt = 2 UNION ALL -- обычный режим DAX SELECT addMonths(date, shift) FROM selected CROSS JOIN last_two_check l WHERE l.cnt != 2 ORDER BY date;
Результат выполнения запроса:

Как было описано, в этой имплементации есть ограничения — непрерывный период в рамках одного месяца и непрерывная таблица дат date.
Важно отметить, что для корректного количества дней в месяце и для корректного применения DATEADD важно использовать функцию как фильтр в CALCULATE, поскольку другие подходы могут отключить логику Time Intelligence и приведут к некорретному количеству дней в месяцах.
Так, при использовании CALCULATE и DATEADD — все корректно, но при использовании конструкций с CALCULATE и FILTER происхоит «выкалывание дат» и логика Time Intelligence отключается, что видно на примере этого DAX:
EVALUATE SUMMARIZECOLUMNS ( 'Date'[Calendar Year Month], "DATEADD в FILTER", CALCULATE ( DISTINCTCOUNT ( 'Date'[Date] ), FILTER ( ALL ( 'Date' ), 'Date'[Date] >= MINX ( 'Date', DATEADD ( 'Date'[Date], -1, MONTH ) ) && 'Date'[Date] <= MAXX ( 'Date', DATEADD ( 'Date'[Date], -1, MONTH ) ) ) ), "DATEADD как фильтр CALCULATE", CALCULATE ( DISTINCTCOUNT ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ), "Обычная сумма за текущий месяц", DISTINCTCOUNT ( 'Date'[Date] ) )
В результаты выполнения запроса видно, что DATEADD в FILTER "выкалывает" даты и берет минимальное количество дней в текущем и предыдущем месяце, а DATEADD в CALCULATE реализует ожидаемую TIme Intelligence логику с актуальным количеством дней в каждом месяце.

Примеры DAX для DATEADD на модели Contoso
Рассмотрим примеры на Contoso из dax.do. Во всех примерах используется таблица 'Date' из Contoso.

Правило 1
DATEADD возвращает только даты, существующие в таблице дат
Проверим, что DATEADD не создаёт новые даты, а работает только с теми, что есть в календаре.
Пример 1 — демонстрация возврата только существующих дат
Выберем один месяц и посмотрим, какие даты вернёт DATEADD.
EVALUATE VAR CurrentMonth = FILTER ( 'Date', 'Date'[Calendar Year] = "CY 2008" && 'Date'[Month Number] = 3 ) RETURN SELECTCOLUMNS ( ADDCOLUMNS ( CurrentMonth, "Shifted Date", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), 'Date'[Date], [Shifted Date] ) ORDER BY 'Date'[Date]

Видно, что каждая дата марта сопоставляется только с реально существующей датой февраля.
Пример 2 — проверка количества дней
EVALUATE SUMMARIZECOLUMNS ( 'Date'[Calendar Year Month], "Days Current", DISTINCTCOUNT ( 'Date'[Date] ), "Days Previous Month", CALCULATE ( DISTINCTCOUNT ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ) ORDER BY 'Date'[Calendar Year Month]
Здесь можно увидеть, что количество дней всегда совпадает с фактическим количеством дат в календарной таблице.

Правило 2
Если дата отсутствует в целевом месяце — возвращается последний день месяца
Contoso отлично показывает этот эффект на переходах между месяцами с разной длиной.
Пример 3 — переход 31-го дня месяца в дату конца месяца
EVALUATE SELECTCOLUMNS ( FILTER ( ADDCOLUMNS ( FILTER ( 'Date', DAY ( 'Date'[Date] ) >= 28 ), "Previous Month Date", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), MONTH ( 'Date'[Date] ) = 3 && 'Date'[Calendar Year] = "CY 2008" ), 'Date'[Date], [Previous Month Date] ) ORDER BY 'Date'[Date]
Получаем результат (2008 — високосный год):

Пример 4 — сравнение с ручным смещением
EVALUATE SELECTCOLUMNS ( ADDCOLUMNS ( FILTER ( 'Date', MONTH ( 'Date'[Date] ) = 3 && DAY ( 'Date'[Date] ) >= 29 ), "Manual Shift", EDATE ( 'Date'[Date], -1 ), "DATEADD Shift", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), 'Date'[Date], [Manual Shift], [DATEADD Shift] )
Можно показать, что DATEADD повторяет поведение EDATE, т.к. всего одна дата

Правило 3
Если выбраны два последних дня месяца — DATEADD расширяет диапазон до конца месяца
Это самое интересное правило, которое может вызывать трудности у аналитиков и разработчиков.
Пример 5 — сравнение 1 последнего дня vs 2 последних дней
EVALUATE VAR OneDay = FILTER ( 'Date', 'Date'[Date] = DATE ( 2008, 3, 31 ) ) VAR TwoDays = FILTER ( 'Date', 'Date'[Date] IN { DATE ( 2008, 3, 30 ), DATE ( 2008, 3, 31 ) } ) RETURN UNION ( SELECTCOLUMNS ( OneDay, "Date", 'Date'[Date], "Scenario", "One Day", "Previous Month Date", CALCULATE ( SELECTEDVALUE ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), SELECTCOLUMNS ( TwoDays, "Date", 'Date'[Date], "Scenario", "Two Days", "Previous Month Date", CALCULATE ( SELECTEDVALUE ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ) ) ORDER BY [Scenario], [Date]
Этот пример иллюстрирует включение Time Intelligence логики.

Демонстрация: DATEADD всегда возвращает таблицу
Пример 6
EVALUATE { COUNTROWS ( DATEADD ( VALUES ( 'Date'[Date] ), -1, MONTH ) ) }
Этот пример хорошо объясняет, почему нельзя сравнивать DATEADD с одиночным значением.

Контрастный пример — некорректное использование
Пример 7 — пустой результат
EVALUATE CALCULATETABLE ( VALUES ( 'Date'[Date] ), FILTER ( ALL ( 'Date' ), 'Date'[Date] = DATEADD ( 'Date'[Date], -1, MONTH ) ) )
Этот пример хорошо показывает классическую ошибку, этот DAX запрос не возвращает ни одной строки.
Edge-кейсы DATEADD в DAX
Ниже приведены запросы на примере схемы Contoso в dax.do, в которых поведение DATEADD может быть интуитивно непонятным.
Edge-кейс №1
Выбор одной даты — Time Intelligence не включается
Это фундаментальное, но редко явно проговариваемое правило.
EVALUATE VAR SingleDate = FILTER ( 'Date', 'Date'[Date] = DATE ( 2008, 3, 31 ) ) RETURN CALCULATETABLE ( VALUES ( 'Date'[Date] ), DATEADD ( CALCULATETABLE ( VALUES ( 'Date'[Date] ), SingleDate ), -1, MONTH ) )
Возвращаемый результат:

Если выбрана одна дата, DATEADD ведёт себя как обычное смещение даты и расширение диапазона не применяется. Если добавить ещё одну дату — логика уже меняется.
Edge-кейс №2
Две даты включают логику периодов
EVALUATE VAR TwoDates = FILTER ( 'Date', 'Date'[Date] IN { DATE ( 2008, 3, 30 ), DATE ( 2008, 3, 31 ) } ) RETURN SELECTCOLUMNS ( TwoDates, "Date", 'Date'[Date], "Previous Month Date", CALCULATE ( SELECTEDVALUE ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ) ORDER BY [Date]
В результате получаем:

Разработчик может интуитивно ожидать увидеть две даты:
28.02 29.02
Но DAX воспринимает выбор как конец месяца и возвращает хвост месяца.
Edge-кейс №3
Пропуски в календаре ломают Time Intelligence
Рассмотрим выбранный диапазон дат с выколотыми датами.
EVALUATE VAR CustomCalendar = FILTER ( 'Date', NOT ( DAY ( 'Date'[Date] ) IN { 10, 11, 12 } ) && 'Date'[Calendar Year] = "CY 2008" && 'Date'[Month Number] = 3 ) RETURN ROW ( "Rows", CALCULATE ( COUNTROWS ( 'Date' ), CustomCalendar, DATEADD ( 'Date'[Date], -1, MONTH ) ) )
В результате запроса количество дат становится меньше ожидаемого, с учетом того, что в високосном 2008 году в февраля 29 дней.

Если календарная таблица не содержит все даты — DATEADD начинает вести себя нестабильно. Это может быть частым источним багов.
Edge-кейс №4
Високосные годы
Рассмотрим пример для февраля високосного 2008 года:
EVALUATE SELECTCOLUMNS ( FILTER ( ADDCOLUMNS ( FILTER ( 'Date', MONTH ( 'Date'[Date] ) = 3 ), "Previous Month", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), DAY ( 'Date'[Date] ) >= 28 && 'Date'[Calendar Year] = "CY 2008" ), 'Date'[Date], [Previous Month] ) ORDER BY 'Date'[Date]
В результаты получим такой маппинг, т.к. DATEADD использует последний день месяца:
30 марта → 29 февраля 31 марта → 29 февраля

Edge-кейс №5
Переход через границу года
Пример
EVALUATE VAR January = FILTER ( 'Date', 'Date'[Calendar Year] = "CY 2008" && 'Date'[Month Number] = 1 ) RETURN SUMMARIZECOLUMNS ( January, "Previous Period", CALCULATE ( MIN ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) )
DATEADD корректно переходит в декабрь предыдущего года:

Но если календарь обрезан — вернётся пустой результат.
Edge-кейс №6
Несмежные диапазоны дат
Рассмотрим поведение для несмежного диапазона дат:
EVALUATE VAR RandomDays = FILTER ( 'Date', 'Date'[Date] IN { DATE ( 2008, 3, 5 ), DATE ( 2008, 3, 25 ) } ) RETURN CALCULATETABLE ( VALUES ( 'Date'[Date] ), RandomDays, DATEADD ( 'Date'[Date], -1, MONTH ) ) ORDER BY 'Date'[Date]
Будут возвращены две отдельные даты:

DATEADD не пытается расширять несмежные диапазоны.
Edge-кейс №7
Использование DATEADD вне CALCULATE
Рассмотрим использование DATEADD в EVALUATE:
EVALUATE DATEADD ( VALUES ( 'Date'[Date] ), -1, MONTH )
DATEADD возвращает таблицу, DATEADD становится фильтром только внутри CALCULATE:

Edge-кейс №8
DATEADD vs EDATE
Сравним поведение DATEADD и EDATE:
EVALUATE SELECTCOLUMNS ( ADDCOLUMNS ( FILTER ( 'Date', DAY ( 'Date'[Date] ) = 31 ), "EDATE", EDATE ( 'Date'[Date], -1 ), "DATEADD", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) ), [EDATE], [DATEADD] ) ORDER BY [EDATE]
Вижно, что DATEADD работает в рамках таблицы-календаря:

Edge-кейс №9
Последний день месяца и фильтр по рабочим дням
Использование фильтра может отключить логику Time Intelligence по периодам:
EVALUATE VAR Mondays = FILTER ( 'Date', 'Date'[Day of Week] = "Monday" && 'Date'[Calendar Year] = "CY 2008" && 'Date'[Month Number] = 3 ) RETURN ROW ( "Result", CALCULATE ( COUNTROWS ( 'Date' ), Mondays, DATEADD ( 'Date'[Date], -1, MONTH ) ) )
Из-за фильтра DATEADD перестаёт воспринимать период как месяц:

Edge-кейс №10
Потеря дат из-за контекста DATEADD
Если в контексте группировки нет даты 'Date'[Date], то это может привести к некорректной работе DATEADD и «выколотым» датам:
EVALUATE ADDCOLUMNS ( FILTER ( 'Date', 'Date'[Date] >= DATE ( 2008, 1, 25 ) && 'Date'[Date] <= DATE ( 2008, 3, 5 ) ), "Prev Month Date", CALCULATE ( MAX ( 'Date'[Date] ), DATEADD ( 'Date'[Date], -1, MONTH ) ) )
В результате в этом запросе месяц январь неполный:

Выводы
Если кратко резюмировать, то помимо трех перечисленных правил, поведение DATEADD также зависит от:
непрерывности диапазона
количества выбранных дат
положения внутри месяца
полноты календаря
контекста
CALCULATE
Надеюсь, приведенные примеры могут быть полезны, успешных дашдордов! :)
