sqlpostgresqlintervaldates

JUSTIFY_* в PostgreSQL: как привести интервалы к понятному виду

JUSTIFY_INTERVAL переносит часы в дни и дни в месяцы, но его 30-дневный месяц не равен календарной логике бизнеса.

7 мин чтенияСправочникsql · postgresql · interval · dates · justify

Когда в PostgreSQL вычитаете одну дату из другой или складываете интервалы, результат не всегда выглядит так, как ожидает человек.

База данных может честно вернуть:

  • 36 hours;
  • 90 days;
  • 800 minutes;
  • 52:30:00.

Для компьютера всё нормально: длительность посчитана правильно. Но человеку в отчёте удобнее увидеть не «52 часа», а «2 дня и 4 часа». Вот здесь и пригодится семейство функций JUSTIFY_*.

Эти функции приводят интервал к более аккуратному виду: лишние часы переносят в дни, а лишние дни — в месяцы.

Но сразу важная мысль: JUSTIFY_* не пересчитывает время по настоящему календарю. Это не волшебная функция для точного возраста, стажа или срока договора. Это скорее «косметическая уборка» интервала, чтобы он выглядел понятнее.

Например, JUSTIFY_HOURS считает просто:

24 часа = 1 день

И всё. Она не знает, был ли в этот день переход на летнее время, короткий февраль или длинный июль. Она работает с интервалом как с длительностью, а не как с реальным календарным периодом.

Что делает семейство JUSTIFY_*

В PostgreSQL есть три основные функции:

  • JUSTIFY_HOURS(interval) — переводит каждые 24 часа в 1 день.
  • JUSTIFY_DAYS(interval) — переводит каждые 30 дней в 1 месяц.
  • JUSTIFY_INTERVAL(interval) — делает оба переноса сразу: часы в дни, дни в месяцы, а ещё приводит знаки к более согласованному виду.

Посмотрим на простом примере:

SELECT
    JUSTIFY_HOURS(INTERVAL '36 hours') AS hours_result,
    JUSTIFY_DAYS(INTERVAL '90 days') AS days_result,
    JUSTIFY_INTERVAL(INTERVAL '1 mon 33 days 27 hours') AS full_result;

Результат будет примерно таким:

hours_result   -> 1 day 12:00:00
days_result    -> 3 mons
full_result    -> 2 mons 4 days 03:00:00

Разберём последний пример.

Было:

1 mon 33 days 27 hours

JUSTIFY_INTERVAL делает два шага:

  1. 27 hours превращает в 1 day 03:00:00.
  2. 33 days + 1 day превращает в 34 days.
  3. Из 34 days забирает 30 days и превращает их в 1 mon.

Получается:

2 mons 4 days 03:00:00

Выглядит аккуратнее и читается легче.

Важная деталь: interval — это не просто секунды

У новичков часто возникает ощущение, что интервал — это просто количество секунд, а PostgreSQL потом красиво его показывает. Но в PostgreSQL всё интереснее.

Интервал внутри хранится не одним числом, а несколькими частями:

  • месяцы;
  • дни;
  • время, вплоть до микросекунд.

Именно поэтому 1 mon и 30 days — не совсем одно и то же. В одних ситуациях они могут выглядеть близко, но при прибавлении к реальной дате дадут разный результат.

Например, месяц от 1 февраля и месяц от 1 июля — это разные по длине периоды в днях. А 30 days — это всегда просто 30 дней.

Функции JUSTIFY_* не превращают интервал в абсолютное количество секунд. Они лишь перекладывают значения между частями интервала по простым правилам.

Пример: сколько заказ находится в обработке

Представим таблицу заказов:

SELECT
    id,
    created_at,
    NOW() - created_at AS raw_age
FROM orders
WHERE status = 'processing';

Такой запрос показывает, сколько времени прошло с момента создания заказа.

Результат может выглядеть так:

raw_age -> 52:30:00

Технически всё правильно: заказ обрабатывается 52 часа 30 минут. Но менеджеру, который смотрит отчёт, это читать неудобно. Ему проще увидеть:

2 days 04:30:00

Используем JUSTIFY_HOURS:

SELECT
    id,
    JUSTIFY_HOURS(NOW() - created_at) AS readable_age
FROM orders
WHERE status = 'processing';

Теперь PostgreSQL перенесёт каждые 24 часа в дни.

Было:

52:30:00

Стало:

2 days 04:30:00

Смысл тот же, но глазами воспринимается намного лучше.

Когда использовать JUSTIFY_HOURS

JUSTIFY_HOURS полезна, когда интервал получился в часах, а вы хотите показать его в днях и часах.

Например:

SELECT JUSTIFY_HOURS(INTERVAL '80 hours') AS result;

Результат:

3 days 08:00:00

Это удобно для:

  • времени обработки заказа;
  • длительности задачи;
  • времени простоя сервиса;
  • возраста заявки в поддержке;
  • любых отчётов, где «80 часов» хуже читается, чем «3 дня 8 часов».

Важно: JUSTIFY_HOURS не трогает дни и месяцы. Она занимается только часами.

Когда использовать JUSTIFY_DAYS

JUSTIFY_DAYS берёт дни и переводит каждые 30 дней в месяц.

SELECT JUSTIFY_DAYS(INTERVAL '75 days') AS result;

Результат:

2 mons 15 days

Это бывает удобно, когда нужна грубая, отчётная нормализация.

Например, мы хотим посмотреть суммарный «возраст» записей по отделам:

SELECT
    dept,
    JUSTIFY_DAYS(SUM(NOW() - created_at)) AS dept_age
FROM employees
GROUP BY dept;

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

Почему? Потому что JUSTIFY_DAYS считает:

30 days = 1 mon

А настоящий календарь так не работает.

Когда использовать JUSTIFY_INTERVAL

JUSTIFY_INTERVAL — самый полный вариант. Он нормализует и часы, и дни.

SELECT JUSTIFY_INTERVAL(INTERVAL '33 days 49 hours') AS result;

Сначала 49 hours превратятся в 2 days 01:00:00.

Получится:

35 days 01:00:00

Потом 30 days превратятся в 1 mon.

Итог:

1 mon 5 days 01:00:00

Обычно JUSTIFY_INTERVAL удобно брать, когда интервал может содержать всё сразу: месяцы, дни, часы, минуты и секунды.

Например:

SELECT
    name,
    JUSTIFY_INTERVAL(salary_review_at - hired_at) AS service_period
FROM employees
ORDER BY service_period DESC;

Такой запрос делает вывод аккуратнее: вместо смеси больших чисел в днях и часах вы получаете более читаемый интервал.

Главная ловушка: 30 дней — это не календарный месяц

Самая опасная часть JUSTIFY_DAYS и JUSTIFY_INTERVAL — правило про 30 дней.

Для этих функций:

30 days = 1 mon

Но в реальной жизни месяцы разные:

  • в феврале 28 или 29 дней;
  • в апреле 30 дней;
  • в июле 31 день.

Поэтому такой результат может обмануть:

SELECT JUSTIFY_INTERVAL(INTERVAL '60 days') AS result;

PostgreSQL вернёт:

2 mons

Но это не значит «два настоящих календарных месяца» от какой-то даты. Это значит только:

60 days = 2 * 30 days

То есть JUSTIFY_INTERVAL не знает, от какой даты вы считаете. А без начальной даты невозможно понять, сколько дней было в конкретных месяцах.

JUSTIFY_* и AGE: в чём разница

Для человекочитаемой нормализации интервала используйте JUSTIFY_*.

Для календарного возраста между двумя датами используйте AGE.

Сравним идею:

SELECT
    AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2024-02-01') AS calendar_age,
    JUSTIFY_INTERVAL(INTERVAL '29 days') AS justified_interval;

AGE смотрит на реальные даты. Она понимает, что между 1 февраля 2024 года и 1 марта 2024 года прошёл календарный месяц, потому что 2024 год был високосным.

А JUSTIFY_INTERVAL смотрит только на сам интервал. Для неё 29 days — это просто 29 дней. До месяца не хватает, потому что по её правилу месяц равен 30 дням.

Запомнить можно так:

AGE отвечает на вопрос:

Сколько прошло по календарю между двумя датами?

JUSTIFY_* отвечает на другой вопрос:

Как красивее разложить этот интервал на месяцы, дни и часы?

Это разные задачи.

Где JUSTIFY_* использовать безопасно

Функции JUSTIFY_* хорошо подходят для последнего шага — вывода результата на экран.

Например:

SELECT
    id,
    JUSTIFY_HOURS(NOW() - created_at) AS readable_processing_time
FROM orders
WHERE status = 'processing';

Здесь мы не принимаем финансовое или юридическое решение. Мы просто показываем пользователю длительность в удобном виде.

Хорошие сценарии:

  • отчёты;
  • дашборды;
  • внутренние админки;
  • человекочитаемые подписи;
  • приблизительная аналитика;
  • вывод длительности задач, заказов, тикетов.

Плохие сценарии:

  • расчёт зарплаты;
  • расчёт отпусков;
  • расчёт штрафов;
  • расчёт стажа для выплат;
  • юридические сроки;
  • точные календарные дедлайны.

В таких случаях лучше считать через реальные даты, AGE, прямую разницу дат или бизнес-логику, где явно прописаны правила.

Почему не стоит нормализовать слишком рано

Распространённая ошибка — применить JUSTIFY_* в середине расчётов, а потом использовать результат дальше.

Например, так делать опасно:

SELECT
    dept,
    SUM(JUSTIFY_DAYS(NOW() - hired_at)) AS total_service
FROM employees
GROUP BY dept;

Проблема в том, что вы сначала превращаете каждые 30 дней в условный месяц для каждого сотрудника, а потом суммируете эти условные месяцы. Ошибка может накопиться.

Лучше сначала выполнить расчёт, а нормализацию оставить на финальный вывод:

SELECT
    dept,
    JUSTIFY_DAYS(SUM(NOW() - hired_at)) AS total_service
FROM employees
GROUP BY dept;

Даже этот вариант подходит скорее для отчётной оценки, а не для точных выплат. Но принцип правильный: сначала считаем, потом красиво показываем.

Отрицательные интервалы

JUSTIFY_INTERVAL также полезна, когда в интервале появляются смешанные знаки.

Например, интервал может выглядеть странно: месяц в одну сторону, дни или часы — в другую. JUSTIFY_INTERVAL старается привести такой интервал к более согласованному виду.

SELECT JUSTIFY_INTERVAL(INTERVAL '1 mon -1 hour') AS result;

Результат будет нормализован так, чтобы интервал выглядел логичнее для чтения.

С отрицательными интервалами особенно важно проверять граничные случаи. Если вы переносите такую логику в другую СУБД или пишете ручную нормализацию, обязательно проверьте:

  • ровно 24 hours;
  • ровно 30 days;
  • отрицательные значения;
  • интервалы, где есть месяцы, дни и часы одновременно.

Именно на таких местах чаще всего появляются расхождения.

Аналоги в MySQL и ClickHouse

JUSTIFY_* — это особенность PostgreSQL. В других популярных СУБД прямых аналогов обычно нет.

В MySQL нет такого же полноценного типа interval, как в PostgreSQL. Часто длительность считают в секундах или днях, а потом вручную раскладывают по частям.

Например, если у вас есть количество секунд, дни можно получить так:

SELECT
    seconds_value DIV 86400 AS days_part,
    seconds_value MOD 86400 AS rest_seconds
FROM durations;

Здесь 86400 — количество секунд в сутках.

В ClickHouse часто используют функции вроде dateDiff, а затем тоже раскладывают результат вручную:

SELECT
    dateDiff('day', started_at, finished_at) AS days_count
FROM events;

Если нужно получить отдельно месяцы, дни и часы, правила придётся описывать самостоятельно.

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

Практическое правило

Используйте JUSTIFY_*, когда хотите сделать интервал красивым и понятным для человека.

Не используйте JUSTIFY_*, когда от результата зависят деньги, сроки, права, дедлайны или точные календарные вычисления.

Хорошая привычка:

  1. Сначала честно посчитать интервал.
  2. Выполнить нужные проверки и бизнес-расчёты.
  3. Только в самом конце применить JUSTIFY_HOURS, JUSTIFY_DAYS или JUSTIFY_INTERVAL для вывода.

Пример:

SELECT
    id,
    created_at,
    NOW() - created_at AS raw_age,
    JUSTIFY_HOURS(NOW() - created_at) AS readable_age
FROM orders
WHERE status = 'processing';

Так вы видите и исходную длительность, и красивую версию для отчёта.

Главное

JUSTIFY_* в PostgreSQL — это семейство функций для нормализации интервалов.

JUSTIFY_HOURS переносит часы в дни:

36 hours -> 1 day 12:00:00

JUSTIFY_DAYS переносит дни в месяцы по правилу 30 дней:

90 days -> 3 mons

JUSTIFY_INTERVAL делает оба переноса сразу:

1 mon 33 days 27 hours -> 2 mons 4 days 03:00:00

Главное — не путать красивый вывод с точной календарной арифметикой.

JUSTIFY_* не знает реальный календарь. Для неё месяц — это 30 дней, а день — это 24 часа. Поэтому она отлично подходит для отчётов, но не подходит для точного расчёта стажа, выплат, отпусков и юридических сроков.

Для календарной разницы между датами используйте AGE или явную бизнес-логику. А JUSTIFY_* оставьте там, где она действительно хороша: на последнем шаге, когда нужно показать интервал человеку красиво и понятно.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр