Сводная таблица с несколькими столбцами значений

У меня есть таблица Postgres с данными о продуктах разных производителей, здесь упрощенная структура таблицы:

CREATE TABLE test_table (
  sku               text,
  manufacturer_name text,
  price             double precision,
  stock             int
);

INSERT INTO test_table
VALUES ('sku1', 'Manufacturer1', 110.00, 22),
       ('sku1', 'Manufacturer2', 120.00, 15),
       ('sku1', 'Manufacturer3', 130.00, 1),
       ('sku1', 'Manufacturer3', 30.00, 11),
       ('sku2', 'Manufacturer1', 10.00, 2),
       ('sku2', 'Manufacturer2', 9.00,  3),
       ('sku3', 'Manufacturer2', 21.00, 3),
       ('sku3', 'Manufacturer2', 1.00, 7),
       ('sku3', 'Manufacturer3', 19.00, 5);

Мне нужно вывести каждого производителя для каждого артикула, но если есть несколько идентичных производителей для одного и того же артикула, мне нужно выбрать производителя с самой низкой ценой (обратите внимание, что мне также нужно включить столбец «запас»), здесь желаемые результаты:

| sku  | man1_price | man1_stock | man2_price | man2_stock | man3_price | man3_stock |
|------|------------|------------|------------|------------|------------|------------|
| sku1 | 110.0      | 22         | 120.0      | 15         | 30.0       | 11         |
| sku2 | 10.0       | 2          | 9.0        | 3          |            |            |
| sku3 |            |            | 1.0        | 7          | 19.0       | 5          |

Я пытался использовать Postgres crosstab():

SELECT *
FROM crosstab('SELECT sku, manufacturer_name, price
              FROM test_table
              ORDER BY 1,2',
              $$ SELECT DISTINCT manufacturer_name FROM test_table ORDER BY 1 $$
       )
       AS ct (sku text, "man1_price" double precision,
              "man2_price" double precision,
              "man3_price" double precision
    );

Но это создает таблицу только с одним столбцом price. И я не нашел способа включить столбец stock.

Я также пытался использовать условную агрегацию:

SELECT sku,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer1' THEN price END) as man1_price,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer1' THEN stock END) as man1_stock,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer2' THEN price END) as man2_price,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer2' THEN stock END) as man2_stock,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer3' THEN price END) as man3_price,
   MIN(CASE WHEN manufacturer_name = 'Manufacturer3' THEN stock END) as man3_stock
FROM test_table
GROUP BY sku
ORDER BY sku

И этот запрос тоже не работает в моем случае - он просто выбирает минимальный уровень запаса - но если есть несколько одинаковых производителей для одного и того же артикула, но с разными ценами/наличиями - этот запрос выбирает минимальную цену от одного производителя и минимальный запас от Другой.

Как я могу вывести price каждого производителя и соответствующий stock из этой таблицы?

P.S. Спасибо всем за такие полезные ответы. Моя таблица Postgres довольно мала - там не более 15 тысяч продуктов (я не знаю, могут ли такие числа быть полезными для правильного сравнения), но поскольку Эрвин Брандштеттер попросил сравнить производительность разных запросов, я выполнил 3 запроса с EXPLAIN ANALYZE, вот время их выполнения:

Erwin Brandstetter query:        400 - 450 ms 
Kjetil S query:                  250 - 300 ms
Gordon Linoff query:             200 - 250 ms
a_horse_with_no_name query:      250 - 300 ms

Опять же - я не уверен, что эти цифры могут быть полезны в качестве справки. Для моего случая я выбрал комбинированный вариант запросов Kjetil S и Gordon Linoff, но варианты Erwin Brandstetter и a_horse_with_no_name также очень полезны и интересны. Стоит отметить, что если в моей таблице в будущем окажется больше, чем несколько производителей - корректировка запроса и ввод их имен каждый раз будет утомительным - и, следовательно, запрос из a_horse_with_no_name ответа будет наиболее удобным для использования.


person Vlad    schedule 22.03.2019    source источник
comment
Ожидаемый результат не соответствует требованию Мне нужно выбрать производителя с минимальной ценой — вы показываете всех производителей в виде столбцов, а не производителя с наименьшей ценой.   -  person a_horse_with_no_name    schedule 22.03.2019
comment
О, извините, я исправлю, в целом мне нужно вывести каждого производителя для каждого артикула, но если есть дубликаты производителей для одного и того же артикула, мне нужно выбрать запись с самой низкой ценой.   -  person Vlad    schedule 22.03.2019
comment
Это будет очень сложно (если не невозможно), потому что это означает, что количество столбцов может меняться для каждого SKU, например. один SKU, где все производители имеют одинаковую цену, приведет только к одному столбцу, если их три, но два имеют одинаковую цену, то это будет два столбца и так далее.   -  person a_horse_with_no_name    schedule 22.03.2019
comment
Как и где вы используете этот результат? Это было бы намного проще, если бы информация о цене/запасе могла быть возвращена, например. один столбец JSON.   -  person a_horse_with_no_name    schedule 22.03.2019
comment
Эта таблица является очень упрощенной версией - окончательные результаты должны включать не только цену и акции, но и несколько других столбцов. Вывод позже отправляется в структуру JS (Ag-Grid, которая выводит данные в виде сводной таблицы). Я думаю, что вывод данных в виде столбца Json в этом случае может работать - можете ли вы показать образец?   -  person Vlad    schedule 22.03.2019


Ответы (4)


Ваш последний выбор почти работает. Но вы должны добавить условие where, при котором удаляются строки с неминимальными ценами за артикул по производителю. Это дает ожидаемый результат:

select
  sku,
  min( case when manufacturer_name='Manufacturer1' then price end ) man1_price,
  min( case when manufacturer_name='Manufacturer1' then stock end ) man1_stock,
  min( case when manufacturer_name='Manufacturer2' then price end ) man2_price,
  min( case when manufacturer_name='Manufacturer2' then stock end ) man2_stock,
  min( case when manufacturer_name='Manufacturer3' then price end ) man3_price,
  min( case when manufacturer_name='Manufacturer3' then stock end ) man3_stock
from test_table t
where not exists (
    select 1 from test_table
    where sku=t.sku
    and manufacturer_name=t.manufacturer_name
    and price<t.price
)
group by sku
order by 1;
person Kjetil S.    schedule 22.03.2019
comment
Спасибо - этот запрос выдает именно то, что мне нужно. - person Vlad; 22.03.2019

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

Вы можете использовать что-то вроде этого:

select sku, 
       jsonb_object_agg(manufacturer_name, 
                          jsonb_build_object('price', price, 'stock', stock, 'isMinPrice', price = min_price)) as price_info
from (
  select sku, 
         manufacturer_name,
         price, 
         min(price) over (partition by sku) as min_price,
         stock
  from test_table
) t
group by sku;

Приведенное выше возвращает следующий результат, используя ваши образцы данных:

sku  | price_info                                                                                                                                                                                             
-----+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
sku1 | {"Manufacturer1": {"price": 110, "stock": 22, "isMinPrice": false}, "Manufacturer2": {"price": 120, "stock": 15, "isMinPrice": false}, "Manufacturer3": {"price": 30, "stock": 11, "isMinPrice": true}}
sku2 | {"Manufacturer1": {"price": 10, "stock": 2, "isMinPrice": false}, "Manufacturer2": {"price": 9, "stock": 3, "isMinPrice": true}}                                                                       
sku3 | {"Manufacturer2": {"price": 1, "stock": 7, "isMinPrice": true}, "Manufacturer3": {"price": 19, "stock": 5, "isMinPrice": false}}                                                                       
person a_horse_with_no_name    schedule 22.03.2019
comment
Я выбрал другой ответ, так как он создает простую таблицу в нужном формате, но мне очень нравится ваше предложение использовать Json для таких запросов. - person Vlad; 22.03.2019
comment
@Vlad: преимущество JSON в том, что вам не нужно корректировать свой запрос, если у вас будет больше производителей. - person a_horse_with_no_name; 22.03.2019

Я бы использовал distinct on, чтобы ограничить данные одним производителем по одной цене. И мне нравится функциональность filter в Postgres. Так:

select sku,
       max(price) filter (where manufacturer_name = 'Manufacturer1') as man1_price,
       max(stock) filter (where manufacturer_name = 'Manufacturer1') as man1_stock,
       max(price) filter (where manufacturer_name = 'Manufacturer2') as man2_price,
       max(stock) filter (where manufacturer_name = 'Manufacturer2') as man2_stock,
       max(price) filter (where manufacturer_name = 'Manufacturer3') as man3_price,
       max(stock) filter (where manufacturer_name = 'Manufacturer3') as man3_stock
from (select distinct on (manufacturer_name, sku) t.*
      from test_table t
      order by manufacturer_name, sku, price
     ) t
group by sku
order by sku;
person Gordon Linoff    schedule 22.03.2019

crosstab() должен предоставлять статический список определений столбцов. Ваш второй параметр:

$$ SELECT DISTINCT manufacturer_name FROM test_table ORDER BY 1 $$

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

Основная проблема вашей задачи заключается в том, что crosstab() ожидает одиночный столбец значений из запроса в его первом параметре. Но вы хотите обрабатывать два столбца значений в каждой строке (price и stock).

Один из способов обойти это — упаковать несколько значений в составной тип и извлечь значения во внешний SELECT.

Создайте составной тип один раз:

CREATE TYPE price_stock AS (price float8, stock int);

Временная таблица или представление также служат этой цели.
Тогда:

SELECT sku
     , (man1).price, (man1).stock
     , (man2).price, (man2).stock
     , (man3).price, (man3).stock
FROM   crosstab(
   'SELECT sku, manufacturer_name, (price, stock)::price_stock
    FROM   test_table
    ORDER  BY 1,2'
  , $$VALUES ('Manufacturer1'),('Manufacturer2'),('Manufacturer3')$$
    )
       AS ct (sku text
            , man1 price_stock
            , man2 price_stock
            , man3 price_stock
    );

Для быстрого теста или если строка вашей базовой таблицы не слишком широка, вы также можете просто использовать ее тип строки, не создавая собственный тип:

SELECT sku
     , (man1).price, (man1).stock
     , (man2).price, (man2).stock
     , (man3).price, (man3).stock
FROM   crosstab(
   'SELECT sku, manufacturer_name, t
    FROM   test_table t
    ORDER  BY 1,2'
  , $$VALUES ('Manufacturer1'),('Manufacturer2'),('Manufacturer3')$$
    )
       AS ct (sku text
            , man1 test_table
            , man2 test_table
            , man3 test_table
    );

db‹>скрипка здесь

Связанный:

person Erwin Brandstetter    schedule 22.03.2019
comment
@Vlad: Если ваша таблица большая, мне было бы интересно, как это работает по сравнению с запросом Кьетиля. Не могли бы вы запустить оба с EXPLAIN ANALYZE или с \timing в psql? - person Erwin Brandstetter; 22.03.2019
comment
Спасибо @Erwin Brandstetter за еще один отличный вариант с полезными пояснениями. Моя таблица небольшая, но тем не менее - я добавил результаты сравнения к вопросу. - person Vlad; 23.03.2019
comment
Спасибо за предоставленные результаты. Как правило, crosstab() быстрее. Однако накладные расходы, связанные с формированием составного типа и извлечением значений обратно, имеют значение. - person Erwin Brandstetter; 23.03.2019