четверг, 23 февраля 2017 г.

Как вывести по N строк из каждой группы?

Как вывести по N строк из каждой группы?

    Моисеенко С.И. (23-05-2009)
Такой вопрос возникает, например, когда на сайте требуется вывести по 3 самых свежих анонса в каждой новостной группе, или рекламу 5 самых популярных товаров в каждой категории.
Чтобы решить эту задачу, нужно выполнить разбиение всего набора строк на группы, произвести сортировку по требуемому критерию (по дате или количеству продаж) в пределах каждой группы и последовательно выбрать требуемое количество строк, начиная с первой строки в каждой группе.
Подобную задачу можно решать процедурно, используя временные таблицы и/или курсоры. Здесь же я хочу предложить два решения в стиле тех задач, которые мы решаем на сайте, т.е. одним запросом SELECT. Первое решение - "классическое", которое должно работать на большинстве СУБД; второе решение использует новые конструкции, которые появились в стандарте SQL:1999 и поддерживаются еще не так широко.
Рассмотрим следующую задачу:
Вывести из таблицы Product по три модели с наименьшими номерами из каждой группы, характеризуемой типом продукции.
Т.е. требует получить 3 компьютера, 3 ноутбука и 3 принтера, номера которых меньше номеров остальных моделей в своей группе. Поскольку номер модели является уникальным в таблице Product, то тут не возникает проблем с дубликатами. Заметим, что проблема дубликатов не является принципиальной, однако потребует уточнения формулировки.

"Классическое" решение

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

SELECT Pr1.model, COUNT(*) num
FROM Product Pr1 JOIN Product Pr2
 ON Pr1.model >= Pr2.model
GROUP BY Pr1.model
Только для решения нашей задачи нужно пронумеровать не весь набор, а каждую группу в отдельности. Этого легко добиться, если в условие соединения таблиц добавить условие совпадения типов продукции, а также добавить группировку по типу:

SELECT Pr1.model, Pr1.type, COUNT(*) num
FROM Product Pr1 JOIN Product Pr2
 ON Pr1.type = Pr2.type AND Pr1.model >= Pr2.model
GROUP BY Pr1.type, Pr1.model
HAVING COUNT (*) <= 3
ORDER BY type, model
Предложение

HAVING COUNT (*) < = 3
в соответствии с условием задачи ограничивает тремя количество строк в каждой группе. Фактически мы уже решили задачу. Осталось лишь добавить производителя (maker), что также можно сделать разными способами. Например, еще раз соединить по номеру модели приведенный выше запрос с таблицей Product, или использовать коррелирующий подзапрос в предложении SELECT. В учебных целях приведу оба подхода.

1. Соединение


SELECT maker, X.model, X.type
FROM product JOIN (
  SELECT Pr1.model, Pr1.type
  FROM Product Pr1 JOIN Product Pr2
   ON Pr1.type = Pr2.type AND Pr1.model >= Pr2.model
  GROUP BY Pr1.type, Pr1.model
  HAVING COUNT (*) <= 3
    ) X on X.model = product.model
ORDER BY type,model
Здесь мы исключили лишний столбец num, который использовался в демонстрационных целях, поскольку нам не требуется выводить номер строки.

2. Подзапрос в предложении SELECT


SELECT (SELECT maker
  FROM Product
  WHERE Product.model = Pr1.model) maker,
 Pr1.model, Pr1.type
FROM product Pr1 JOIN product Pr2
 ON Pr1.type = Pr2.type AND Pr1.model >= Pr2.model
GROUP BY Pr1.type, Pr1.model
HAVING COUNT (*) <= 3
ORDER BY type,model
Использование подзапроса в предложении SELECT допускается, если он возвращает всего одно значение для каждой строки основного запроса. Это условие у нас выполняется, т.к. мы выбираем производителя модели, которая передается из основного запроса и является уникальной (первичный ключ в таблице Product).

Решение на основе ранжирующих функций

Ранжирующие функции - ROW_NUMBER, RANK, DENSE_RANK и NTILE появились в составе SQL Server, начиная с версии 2005. Их появление в языке SQL было вызвано потребностью выполнять упорядоченные вычисления. Собственно, наше упражнение как раз и относится к этому классу задач. И теперь у нас есть возможность оценить данное приобретение. :
Для решения нашей задачи воспользуемся функцией RANK. Эта функция позволяет разбить все строки, возвращаемые запросом, на группы и вычислить ранг каждой строки в группе в соответствии заданной сортировкой. Поскольку мы будем сортировать по уникальному номеру модели, то ранг фактически будет совпадать с номером строки в группе. Итак, решение

SELECT maker, model, type FROM
(
SELECT maker, model, type, RANK() OVER(PARTITION BY type ORDER BY model) num
FROM Product
) X
WHERE num <= 3
Собственно, все делается в подзапросе. Внешний запрос служит лишь для того, чтобы ограничить выборку тремя моделями по каждой группе. Говоря другими словами, мы оставляем только те строки, у которых ранг не превышает трех.
Экономно, не так ли. Однако давайте разберем более детально конструкцию

RANK() OVER(PARTITION BY type ORDER BY model)
Предложение PARTITION BY type формирует группы; в одну группу у нас попадают строки, имеющий один и тот же тип продукции (одно и то же значение в столбце type).
Предложение ORDER BY model задает сортировку строк в группе (по возрастанию номера модели).
Наконец, RANK() присваивает ранг каждой строке в группе на основе заданной сортировки, т.е. первая строка в группе получает ранг 1, следующая, если она имеет отличный номер модели, ранг 2 и т.д. Как я уже сказал, поскольку номер модели уникальный, то каждая строка в группе будет иметь отличный ранг. В противном случае, строки с одинаковым номером модели имели бы одинаковый ранг.
Подробное описание функций ранжирования выходит за рамки данной статьи, но, возможно, я напишу нечто подобное для Учебника по SQL.