DISTINCT ON

Частый запрос в Brew: «покажи по последнему заказу каждого клиента». Не дату последнего заказа — а сам заказ целиком: его номер, сумму, статус. Через GROUP BY это неудобно: max(created_at) отдаёт только дату, а чтобы вытащить остальные колонки именно той строки, приходится делать второй заход (джойнить результат обратно на orders по клиенту и дате). Громоздко и легко ошибиться на одинаковых датах.

Postgres даёт короткий путь — DISTINCT ON. Это не стандарт SQL (фишка именно Postgres), но он решает «по одной строке на группу, причём целую строку» одним выражением.

Как работает DISTINCT ON

SELECT DISTINCT ON (выражение) ... оставляет первую строку для каждого уникального значения выражения. А что считать «первой» — задаёт ORDER BY. Отсюда железное правило: ORDER BY обязан начинаться с того же выражения, что и DISTINCT ON, а дальше идёт критерий «какую строку группы выбрать».

«Последний заказ на клиента» читается так:

sql
SELECT DISTINCT ON (o.customer_id) ...
FROM orders o
ORDER BY o.customer_id, o.created_at DESC, o.id DESC;

DISTINCT ON (o.customer_id) — по одной строке на клиента. ORDER BY o.customer_id (обязательное начало) группирует строки клиента вместе, а created_at DESC ставит самый свежий заказ первым в группе — его DISTINCT ON и оставит. id DESC — tie-break на случай одинаковых created_at (две строки с равной датой — иначе «первая» не определена).

Сначала ORDER BY раскладывает строки, потом DISTINCT ON идёт сверху вниз и берёт первую в каждой группе, остальные в группе пропускает:

plaintext
orders, отсортированные по ORDER BY (o.customer_id, o.created_at DESC, o.id DESC):
 
  customer_id   created_at    id      DISTINCT ON (o.customer_id)
  ──────────────────────────────     ───────────────────────────
  1 · Алиса     2025-01-16   #3  ◀──  первая для клиента 1 → берём
  1 · Алиса     2025-01-15   #1       клиент 1 уже взят → пропуск
  2 · Борис     2025-01-15   #2  ◀──  первая для клиента 2 → берём

Сменив хвост ORDER BY на amount DESC, той же конструкцией получаем самый дорогой заказ на клиента. Критерий выбора живёт в ORDER BY — это и делает DISTINCT ON гибким.

DISTINCT ON против альтернатив

Ту же задачу решают и другие инструменты, и полезно понимать, когда что:

  • GROUP BY + max() даёт агрегат (дату/сумму), но не целую строку. Чтобы вернуть весь заказ, нужен повторный join — лишний и хрупкий.
  • Оконные функции (ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) и фильтр = 1) — стандартный, портируемый способ; он же умеет «top-3 на клиента», а не только top-1. Это юнит 08-02.
  • DISTINCT ON — самый короткий, когда нужна ровно одна строка на группу и проект уже на Postgres.
приёмчто возвращаетстрок на группупортируемость
GROUP BY + max()агрегат (дату/сумму), не строку — за остальными колонками нужен повторный joinстандарт SQL
DISTINCT ON (k)целую строку, по одной на каждое kровно однатолько Postgres
ROW_NUMBER() … = 1целую строку; условие <= N даёт топ-Nодна или Nстандарт SQL (08-02)

DISTINCT ONDISTINCT: обычный DISTINCT убирает полные дубликаты строк, DISTINCT ON (col) — оставляет по одной строке на значение col.

Что показывает наш код

Два запроса в query.sql — одинаковая конструкция, разный хвост ORDER BY:

sql
-- name: LatestOrderPerCustomer :many
SELECT DISTINCT ON (o.customer_id) c.name, o.id, o.amount::numeric(10,2)::text, o.status, o.created_at::date::text
FROM orders o JOIN customers c ON c.id::text = o.customer_id
ORDER BY o.customer_id, o.created_at DESC, o.id DESC;
-- PriciestOrderPerCustomer: ... ORDER BY o.customer_id, o.amount DESC, o.id DESC;

amount (NUMERIC) приводим к numeric(10,2)::text для стабильного «Ц.КК», дату — к ::date::text. Обе колонки приходят в Go как string — без возни с pgtype.Numeric/pgtype.Date.

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew:

sh
docker compose up -d
make lecture L=04-querying-across-tables/04-04-distinct-on T=db-reset
make lecture L=04-querying-across-tables/04-04-distinct-on

(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)

Вывод:

plaintext
1) Последний заказ на клиента — DISTINCT ON (customer_id), свежесть по created_at:
   клиент            заказ    сумма статус    дата
   Алиса Иванова    #3         9.60 shipped   2025-01-16
   Борис Петров     #2         3.00 created   2025-01-15
   → у Алисы два заказа (#1 и #3), DISTINCT ON оставил один свежий — #3.
     Карины нет: у неё заказов нет, а выбираем мы из orders.
 
2) Самый дорогой заказ на клиента — тот же DISTINCT ON, но хвост ORDER BY = amount DESC:
   клиент            заказ    сумма
   Алиса Иванова    #1        10.50
   Борис Петров     #2         3.00
   → у Алисы теперь #1 (10.50 > 9.60): сменили критерий — сменился победитель группы.

У Алисы два заказа; первый запрос оставил самый свежий (#3), второй — самый дорогой (#1). Сменился только хвост ORDER BY — сменился и «победитель» группы. Карина в выборку не попала: мы выбираем из orders, а у неё заказов нет (это не LEFT JOIN).

Заборчик

Что мы упростили.

  • DISTINCT ON зависит от ORDER BY целиком. Без явного tie-break (id DESC) при двух заказах с одинаковым created_at «первая» строка не определена — результат «плавал» бы между прогонами. В проде это источник нестабильных отчётов, поэтому tie-break по уникальной колонке обязателен.
  • DISTINCT ON нестандартен: при переезде на другую СУБД его придётся переписать на оконные функции — в переносимом коде иногда сразу берут ROW_NUMBER() (08-02).
  • На больших таблицах эффективность DISTINCT ON упирается в индекс под ORDER BY — без него сервер отсортирует весь набор (вопрос планов — модуль 06).
  • Если нужны не «последняя», а «последние N» строк на группу, DISTINCT ON уже не подходит — это снова оконные функции.

Что забрать с собой

  • DISTINCT ON (выражение) оставляет одну строку на каждое значение выражения — и отдаёт строку ЦЕЛИКОМ, не только агрегат.
  • ORDER BY обязан начинаться с выражения DISTINCT ON; дальше — критерий «какую строку группы оставить».
  • Меняя хвост ORDER BY, выбираешь другую строку группы (последний заказ ↔ самый дорогой) — конструкция гибкая.
  • DISTINCT ONDISTINCT: первый — по одной строке на ключ, второй — убирает полные дубликаты.
  • Нужна портируемость или «топ-N на группу» → оконные функции (08-02); DISTINCT ON — про «ровно одну на группу» в Postgres.

DISTINCT ON и агрегаты отвечали «какая строка» и «сколько». Но часто вопрос про наличие связи: «какие напитки ни разу не заказывали?», «кто из клиентов уже что-то купил?». Это подзапросы — и там снова ждёт та самая ловушка NOT IN со списком, где затесался NULL (тизер из 01-02, разобранный в 03-06): она молча отдаёт пусто. Дальше — 04-05 «Подзапросы: EXISTS vs IN», где эта ловушка станет главным доводом за EXISTS.

·Модуль 05

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Запросы по таблицам / DISTINCT ON