使用 CASE 和 GROUP BY 的动态替代方案

Posted

技术标签:

【中文标题】使用 CASE 和 GROUP BY 的动态替代方案【英文标题】:Dynamic alternative to pivot with CASE and GROUP BY 【发布时间】:2013-03-19 17:16:48 【问题描述】:

我有一张如下所示的表格:

id    feh    bar
1     10     A
2     20     A
3      3     B
4      4     B
5      5     C
6      6     D
7      7     D
8      8     D

我希望它看起来像这样:

bar  val1   val2   val3
A     10     20 
B      3      4 
C      5        
D      6      7     8

我有这样的查询:

SELECT bar, 
   MAX(CASE WHEN abc."row" = 1 THEN feh ELSE NULL END) AS "val1",
   MAX(CASE WHEN abc."row" = 2 THEN feh ELSE NULL END) AS "val2",
   MAX(CASE WHEN abc."row" = 3 THEN feh ELSE NULL END) AS "val3"
FROM
(
  SELECT bar, feh, row_number() OVER (partition by bar) as row
  FROM "Foo"
 ) abc
GROUP BY bar

这是一种非常随意的方法,如果要创建大量新列,则会变得笨拙。我想知道CASE 语句是否可以更好地使这个查询更加动态?另外,我很想看看其他方法来做到这一点。

【问题讨论】:

如果你能加载standard-contrib tablefunc module,crosstab函数就会做你想做的。 @flipflop99 您可以使用PL/PgSQL 查询具有最多值的条目,并使用EXECUTEformat 函数动态生成CASE 语句。丹尼尔是对的,尽管使用crosstab 可能会更好。 @CraigRinger:我详细探讨了crosstab() 的“动态”可能性。 【参考方案1】:

如果您尚未安装附加模块 tablefunc,请为每个数据库运行此命令一次

CREATE EXTENSION tablefunc;

回答问题

为您的案例提供一个非常基本的交叉表解决方案:

SELECT * FROM crosstab(
  'SELECT bar, 1 AS cat, feh
   FROM   tbl_org
   ORDER  BY bar, feh')
 AS ct (bar text, val1 int, val2 int, val3 int);  -- more columns?

这里的特殊困难是,基表中没有类别 (cat)。对于基本的 1 参数形式,我们可以只提供一个虚拟列,其中包含一个虚拟值作为类别。无论如何都会忽略该值。

这是极少数情况之一,其中crosstab() 函数的第二个参数不需要,因为所有NULL根据此问题的定义,值仅出现在右侧的悬空列中。并且顺序可以由value来决定。

如果我们有一个实际的 category 列,其名称决定结果中值的顺序,我们需要crosstab()2 参数形式。这里我在窗口函数row_number()的帮助下合成了一个类别列,以crosstab()为基础:

SELECT * FROM crosstab(
   $$
   SELECT bar, val, feh
   FROM  (
      SELECT *, 'val' || row_number() OVER (PARTITION BY bar ORDER BY feh) AS val
      FROM tbl_org
      ) x
   ORDER BY 1, 2
   $$
 , $$VALUES ('val1'), ('val2'), ('val3')$$         -- more columns?
) AS ct (bar text, val1 int, val2 int, val3 int);  -- more columns?

其余的几乎都是普通的。在这些密切相关的答案中找到更多解释和链接。

基础知识:如果您不熟悉 crosstab() 函数,请先阅读此内容!

PostgreSQL Crosstab Query

高级:

Pivot on Multiple Columns using Tablefunc Merge a table and a change log into a view in PostgreSQL

正确的测试设置

这就是你应该提供一个测试用例开始的方式:

CREATE TEMP TABLE tbl_org (id int, feh int, bar text);
INSERT INTO tbl_org (id, feh, bar) VALUES
   (1, 10, 'A')
 , (2, 20, 'A')
 , (3,  3, 'B')
 , (4,  4, 'B')
 , (5,  5, 'C')
 , (6,  6, 'D')
 , (7,  7, 'D')
 , (8,  8, 'D');

动态交叉表?

还不是很动态,就像@Clodoaldo commented。使用 plpgsql 很难实现动态返回类型。但是有 种方法可以解决它 - 有一些限制

所以为了不让其余部分进一步复杂化,我用一个更简单的测试用例来演示:

CREATE TEMP TABLE tbl (row_name text, attrib text, val int);
INSERT INTO tbl (row_name, attrib, val) VALUES
   ('A', 'val1', 10)
 , ('A', 'val2', 20)
 , ('B', 'val1', 3)
 , ('B', 'val2', 4)
 , ('C', 'val1', 5)
 , ('D', 'val3', 8)
 , ('D', 'val1', 6)
 , ('D', 'val2', 7);

呼叫:

SELECT * FROM crosstab('SELECT row_name, attrib, val FROM tbl ORDER BY 1,2')
AS ct (row_name text, val1 int, val2 int, val3 int);

返回:

 row_name | val1 | val2 | val3
----------+------+------+------
 A        | 10   | 20   |
 B        |  3   |  4   |
 C        |  5   |      |
 D        |  6   |  7   |  8

tablefunc 模块的内置功能

tablefunc 模块为通用crosstab() 调用提供了一个简单的基础架构,而无需提供列定义列表。用C 编写的一些函数(通常非常快):

<b>crosstab<i>N</i>()</b>

crosstab1() - crosstab4() 是预定义的。一个小问题:他们需要并返回所有text。所以我们需要转换我们的integer 值。但它简化了调用:

SELECT * FROM crosstab4('SELECT row_name, attrib, val::text  -- cast!
                         FROM tbl ORDER BY 1,2')

结果:

 row_name | category_1 | category_2 | category_3 | category_4
----------+------------+------------+------------+------------
 A        | 10         | 20         |            |
 B        | 3          | 4          |            |
 C        | 5          |            |            |
 D        | 6          | 7          | 8          |

自定义crosstab()函数

对于更多列其他数据类型,我们创建自己的复合类型 em> 和 function(一次)。 类型:

CREATE TYPE tablefunc_crosstab_int_5 AS (
  row_name text, val1 int, val2 int, val3 int, val4 int, val5 int);

功能:

CREATE OR REPLACE FUNCTION crosstab_int_5(text)
  RETURNS SETOF tablefunc_crosstab_int_5
AS '$libdir/tablefunc', 'crosstab' LANGUAGE c STABLE STRICT;

呼叫:

SELECT * FROM crosstab_int_5('SELECT row_name, attrib, val   -- no cast!
                              FROM tbl ORDER BY 1,2');

结果:

 row_name | val1 | val2 | val3 | val4 | val5
----------+------+------+------+------+------
 A        |   10 |   20 |      |      |
 B        |    3 |    4 |      |      |
 C        |    5 |      |      |      |
 D        |    6 |    7 |    8 |      |

一个多态、动态的函数

这超出了 tablefunc 模块所涵盖的范围。 为了使返回类型动态,我使用了多态类型,并在此相关答案中详细介绍了一种技术:

Refactor a PL/pgSQL function to return the output of various SELECT queries

1-参数形式:

CREATE OR REPLACE FUNCTION crosstab_n(_qry text, _rowtype anyelement)
  RETURNS SETOF anyelement AS
$func$
BEGIN
   RETURN QUERY EXECUTE 
   (SELECT format('SELECT * FROM crosstab(%L) t(%s)'
                , _qry
                , string_agg(quote_ident(attname) || ' ' || atttypid::regtype
                           , ', ' ORDER BY attnum))
    FROM   pg_attribute
    WHERE  attrelid = pg_typeof(_rowtype)::text::regclass
    AND    attnum > 0
    AND    NOT attisdropped);
END
$func$  LANGUAGE plpgsql;

使用此变体为 2 参数形式重载:

CREATE OR REPLACE FUNCTION crosstab_n(_qry text, _cat_qry text, _rowtype anyelement)
  RETURNS SETOF anyelement AS
$func$
BEGIN
   RETURN QUERY EXECUTE 
   (SELECT format('SELECT * FROM crosstab(%L, %L) t(%s)'
                , _qry, _cat_qry
                , string_agg(quote_ident(attname) || ' ' || atttypid::regtype
                           , ', ' ORDER BY attnum))
    FROM   pg_attribute
    WHERE  attrelid = pg_typeof(_rowtype)::text::regclass
    AND    attnum > 0
    AND    NOT attisdropped);
END
$func$  LANGUAGE plpgsql;

pg_typeof(_rowtype)::text::regclass:为每个用户定义的复合类型定义了一个行类型,因此属性(列)列在系统目录pg_attribute 中。获得它的快车道:将注册类型 (regtype) 转换为 text 并将此 text 转换为 regclass

一次创建复合类型:

您需要为每个要使用的返回类型定义一次:

CREATE TYPE tablefunc_crosstab_int_3 AS (
    row_name text, val1 int, val2 int, val3 int);

CREATE TYPE tablefunc_crosstab_int_4 AS (
    row_name text, val1 int, val2 int, val3 int, val4 int);

...

对于临时呼叫,您也可以只创建一个临时表来获得相同的(临时)效果:

CREATE TEMP TABLE temp_xtype7 AS (
    row_name text, x1 int, x2 int, x3 int, x4 int, x5 int, x6 int, x7 int);

或使用现有表、视图或物化视图的类型(如果可用)。

呼叫

使用上述行类型:

1-参数形式(无缺失值):

SELECT * FROM crosstab_n(
   'SELECT row_name, attrib, val FROM tbl ORDER BY 1,2'
 , NULL::tablefunc_crosstab_int_3);

2 参数形式(可能缺少某些值):

SELECT * FROM crosstab_n(
   'SELECT row_name, attrib, val FROM tbl ORDER BY 1'
 , $$VALUES ('val1'), ('val2'), ('val3')$$
 , NULL::tablefunc_crosstab_int_3);

这个一个函数适用于所有返回类型,而tablefunc 模块提供的crosstab<i>N</i>() 框架需要为每个返回类型提供一个单独的函数。 如果您按照上面演示的顺序命名了您的类型,则只需替换粗体数字。要查找基表中的最大类别数:

SELECT max(count(*)) OVER () FROM tbl  -- returns 3
GROUP  BY row_name
LIMIT  1;

如果您想要单独的列,这几乎是动态的。 demonstrated by @Clocoaldo 之类的数组或简单的文本表示形式或包装在 jsonhstore 之类的文档类型中的结果可以动态地用于任意数量的类别。

免责声明: 将用户输入转换为代码时总是存在潜在危险。确保这不能用于 SQL 注入。不要(直接)接受来自不受信任用户的输入。

征集原始问题:

SELECT * FROM crosstab_n('SELECT bar, 1, feh FROM tbl_org ORDER BY 1,2'
                       , NULL::tablefunc_crosstab_int_3);

【讨论】:

并非所有动态,因为必须提供计算的列名。 OP 说他们可以有很多。 @ClodoaldoNeto:我挖得更深了。您可能对我的答案的大量更新感兴趣。 是的非常好,但仍然必须知道列数并创建类型。我让它完全动态here,但我对复杂性有遗传过敏,所以我认为如果不需要从数据中提取的列名,我为这个问题提供的数组解决方案要好得多。 @ClodoaldoNeto:“更好”由需求定义。 @ClodoaldoNeto:我现在才意识到您在 here 下的链接指的是另一个答案。非常好。对于长类别的列表或临时查询似乎非常有用。但它需要 两个 查询。一个创建表,一个从中读取。通过两个查询,一个可以使一切“动态”。 1.构建查询字符串,2.执行它。挑战在于在单个查询中完成,而 SQL 希望预先知道返回类型。【参考方案2】:

虽然这是一个老问题,但我想添加另一个解决方案,这得益于 PostgreSQL 的最新改进。此解决方案实现了从动态数据集返回结构化结果的相同目标根本不使用交叉表函数。换句话说,这是重新检查无意和隐含假设的一个很好的例子,这些假设可以防止我们从发现旧问题的新解决方案。 ;)

为了说明,您要求使用以下结构转置数据的方法:

id    feh    bar
1     10     A
2     20     A
3      3     B
4      4     B
5      5     C
6      6     D
7      7     D
8      8     D

转换成这种格式:

bar  val1   val2   val3
A     10     20 
B      3      4 
C      5        
D      6      7     8

传统的解决方案是创建动态交叉表查询的一种聪明(而且知识渊博)的方法,Erwin Brandstetter 的回答中详细解释了这一点。

但是,如果您的特定用例足够灵活,可以接受稍微不同的结果格式,那么另一种解决方案可以很好地处理动态枢轴。这种技术,我在这里学到的

Dynamic Pivot Tables with JSON and PostgreSQL

使用 PostgreSQL 的新 jsonb_object_agg 函数以 JSON 对象的形式动态构建旋转数据。

我将使用Brandstetter先生的“更简单的测试用例”来说明:

CREATE TEMP TABLE tbl (row_name text, attrib text, val int);
INSERT INTO tbl (row_name, attrib, val) VALUES
   ('A', 'val1', 10)
 , ('A', 'val2', 20)
 , ('B', 'val1', 3)
 , ('B', 'val2', 4)
 , ('C', 'val1', 5)
 , ('D', 'val3', 8)
 , ('D', 'val1', 6)
 , ('D', 'val2', 7);

使用jsonb_object_agg 函数,我们可以创建所需的具有这种简洁美感的旋转结果集:

SELECT
  row_name AS bar,
  json_object_agg(attrib, val) AS data
FROM tbl
GROUP BY row_name
ORDER BY row_name;

哪些输出:

 bar |                  data                  
-----+----------------------------------------
 A   |  "val1" : 10, "val2" : 20 
 B   |  "val1" : 3, "val2" : 4 
 C   |  "val1" : 5 
 D   |  "val3" : 8, "val1" : 6, "val2" : 7 

如您所见,此函数通过在示例数据中的 attribvalue 列中创建 JSON 对象中的键/值对来工作,所有这些列均按 row_name 分组。

虽然这个结果集看起来明显不同,但我相信它实际上会满足许多(如果不是大多数)现实世界的用例,尤其是那些数据需要动态生成的数据透视,或者结果数据由父应用程序使用的情况(例如,需要重新格式化以在 http 响应中传输)。

这种方法的好处:

更简洁的语法。我想每个人都会同意,这种方法的语法比最基本的交叉表示例更简洁、更容易理解。

完全动态。无需事先指定有关基础数据的信息。不需要提前知道列名及其数据类型。

处理大量列。 由于透视数据保存为单个 jsonb 列,因此您不会遇到 PostgreSQL 的列限制(我相信≤1,600 列)。仍然有一个限制,但我相信它与文本字段相同:每个创建的 JSON 对象 1 GB(如果我错了,请纠正我)。这是很多键/值对!

简化数据处理。我相信在数据库中创建 JSON 数据将简化(并可能加快)父应用程序中的数据转换过程。 (您会注意到,我们的示例测试用例中的整数数据已正确存储在生成的 JSON 对象中。PostgreSQL 通过根据 JSON 规范自动将其内在数据类型转换为 JSON 来处理这个问题。)这将有效地消除需要手动转换传递给父应用的数据:都可以委托给应用的原生 JSON 解析器。

差异(和可能的缺点):

看起来不同。不可否认,这种方法的结果看起来不同。 JSON 对象不如交叉表结果集漂亮;但是,这些差异纯粹是表面上的。生成相同的信息,而且其格式可能方便父应用程序使用。

缺少键。 交叉表方法中的缺失值用空值填充,而 JSON 对象只是缺少适用的键。您必须自己决定这是否是您的用例可接受的折衷方案。在我看来,在 PostgreSQL 中解决这个问题的任何尝试都会使过程变得非常复杂,并且可能会以额外查询的形式进行一些自省。

密钥顺序未保留。我不知道这是否可以在 PostgreSQL 中解决,但这个问题主要也是装饰性的,因为任何父应用程序都不太可能依赖键顺序,或有能力通过其他方式确定正确的键顺序。最坏的情况可能只需要对数据库进行附加查询。

结论

我很想听听其他人(尤其是@ErwinBrandstetter)对这种方法的看法,尤其是在性能方面。当我在 Andrew Bender 的博客上发现这种方法时,就像被击中头部一样。在 PostrgeSQL 中采用全新的方法解决难题是多么美妙的方式。它完美地解决了我的用例,我相信它同样会为许多其他人服务。

【讨论】:

我建立在你的答案的完整性。 ***.com/a/42041676/131874 感谢您的专业知识!当需要动态查询时,我认为这种方法是交叉表的最佳替代方法。 对如何按特定数据键排序有任何想法吗?由于数据是别名,它似乎不喜欢传统的data-&gt;&gt;key 运算符。 在下面查看 Clodoaldo 出色而全面的答案:http://***.com/a/42041676/131874。 可靠的答案。好奇,现在如何按照 OP 的要求将 JSON 键转换为列?【参考方案3】:

这是为了完成@Damian 好答案。在 9.6 方便的 json_object_agg 函数之前,我已经在其他答案中建议了 JSON 方法。使用以前的工具集只是需要更多的工作。

所引用的两个可能的缺点实际上并非如此。如有必要,随机密钥顺序会被简单地纠正。缺少的键(如果相关)需要处理几乎微不足道的代码量:

select
    row_name as bar,
    json_object_agg(attrib, val order by attrib) as data
from
    tbl
    right join
    (
        (select distinct row_name from tbl) a
        cross join
        (select distinct attrib from tbl) b
    ) c using (row_name, attrib)
group by row_name
order by row_name
;
 bar |                     data                     
-----+----------------------------------------------
 a   |  "val1" : 10, "val2" : 20, "val3" : null 
 b   |  "val1" : 3, "val2" : 4, "val3" : null 
 c   |  "val1" : 5, "val2" : null, "val3" : null 
 d   |  "val1" : 6, "val2" : 7, "val3" : 8 

对于理解 JSON 的最终查询消费者来说,没有缺点。唯一的一点是不能作为表源消费。

【讨论】:

【参考方案4】:

在你的情况下,我猜一个数组是好的。 SQL Fiddle

select
    bar,
    feh || array_fill(null::int, array[c - array_length(feh, 1)]) feh
from
    (
        select bar, array_agg(feh) feh
        from foo
        group by bar
    ) s
    cross join (
        select count(*)::int c
        from foo
        group by bar
        order by c desc limit 1
    ) c(c)
;
 bar |      feh      
-----+---------------
 A   | 10,20,NULL
 B   | 3,4,NULL
 C   | 5,NULL,NULL
 D   | 6,7,8

【讨论】:

【参考方案5】:

很抱歉过去返回,但解决方案“动态交叉表”返回错误的结果表。因此,valN 值错误地“左对齐”并且它们不对应于列名。当输入表在值中有“洞”时,例如“C”有 val1 和 val3,但没有 val2。这会产生一个错误:val3 值将在最终表的 val2 列(即下一个空闲列)中进行范围。

CREATE TEMP TABLE tbl (row_name text, attrib text, val int); 
INSERT INTO tbl (row_name, attrib, val) VALUES ('C', 'val1', 5) ('C', 'val3', 7);

SELECT * FROM crosstab('SELECT row_name, attrib, val FROM tbl 
ORDER BY 1,2') AS ct (row_name text, val1 int, val2 int, val3 int);

row_name|val1|val2|val3
 C      |   5|  7 |

为了在右列中返回带有“孔”的正确单元格,交叉表查询需要在交叉表中进行第二次 SELECT,类似于 "crosstab('SELECT row_name, attrib, val FROM tbl ORDER BY 1,2', 'select distinct row_name from tbl order by 1')"

【讨论】:

【参考方案6】:

这并不是真正的动态,因为您仍然必须枚举与预期值一样多的列,但这样做很容易。一个问题是列需要一个序号键来匹配,没有中断。此外,如果有重复的键,它就会把它弄得一团糟,所以也需要重复数据删除。这些集合都必须预先分区以容纳适当的 N 集合。

对我来说,它看起来很笨拙,所以不确定它是否物有所值。但我将其添加到社区狗堆中,希望它能够激发其他人提出更好的方法。

/** build a dataset **/
DROP TABLE IF EXISTS tmpT ;
CREATE TEMP TABLE tmpT AS
SELECT
 NULL::INT AS key
 ,NULL::INT AS ints
 ,NULL::VARCHAR(1) AS chars
 ,NULL::VARCHAR(3) AS unnest
LIMIT 0 ;

insert into tmpT (key, ints, chars, unnest)
values   (1 , 1   , 'o',  CHR( 130 - 10 ) )       
        ,(2 , 2   , 'n',  CHR( 130 - 11 ) )       
        ,(3 , 3   , 'm',            NULL  )       
        --,(4 , 4   , 'l',  CHR( 130 - 13 ) ) -- missing set       
        ,(5 , 5   , null, CHR( 130 - 14 ) )        
        ,(6 , null, 'j',  CHR( 130 - 15 ) )        
        ,(7 , 7   , null, CHR( 130 - 16 ) )         
        ,(8 , null, 'h',  CHR( 130 - 17 ) )        
        ,(9 , 9   , null, CHR( 130 - 18 ) )         
        ,(10, null, 'f' ,           NULL  )        
        ,(11, null, 'a',  CHR( 130 - 20 ) )        
        ,(12, 12  , null, CHR( 130 - 21 ) )         
 ; /** end of build a dataset **/

/** set up full set of pivotal column positions, to backfill any missing  **/
DROP TABLE IF EXISTS tGenSer ; 
CREATE TEMP TABLE tGenSer AS SELECT generate_series( 1, 1000 )::INT AS key ;

/** 然后是枢轴 **/

/* Pivot 10 columns */
SELECT *
FROM     /* name the columns*/
(    SELECT a a ,a b ,a c ,a d ,a e ,a f ,a g ,a h ,a i ,a j /*,a k ,a l ,a m ,a n ,a o ,a p ,a q ,a r ,a s ,a t*/ /* ,a u ,a v ,a w ,a x ,a y ,a z*/ FROM ( SELECT NULL::VARCHAR(3) AS a /**seed the typed columns **/) a  
    
    UNION /** union is just a helper, to assign names to unnamed columns **/
    
    /** 20 columns **/
    SELECT * FROM
    (
        /* enumerate columns, no name */
        SELECT t1.x[1 ] ,t1.x[2 ] ,t1.x[3 ] ,t1.x[4 ] ,t1.x[5 ] ,t1.x[6 ] ,t1.x[7 ] ,t1.x[8 ] ,t1.x[9 ] ,t1.x[10] 
        FROM ( SELECT  ARRAY( SELECT  a.ints::TEXT  AS v   
                        FROM tGenSer tg /**backfill missing keys**/ 
                        LEFT JOIN tmpT a ON tg.key = a.key ORDER BY tg.key 
                        ) AS x 
             ) t1
        
        UNION ALL
        
        SELECT t1.x[1 ] ,t1.x[2 ] ,t1.x[3 ] ,t1.x[4 ] ,t1.x[5 ] ,t1.x[6 ] ,t1.x[7 ] ,t1.x[8 ] ,t1.x[9 ] ,t1.x[10] 
        FROM ( SELECT  ARRAY( SELECT  a.chars::TEXT AS v 
                        FROM tGenSer tg /**backfill missing keys**/ 
                        LEFT JOIN tmpT a ON tg.key = a.key ORDER BY tg.key 
                        ) AS x 
            ) t1
        
        UNION ALL
        
        SELECT t1.x[1 ] ,t1.x[2 ] ,t1.x[3 ] ,t1.x[4 ] ,t1.x[5 ] ,t1.x[6 ] ,t1.x[7 ] ,t1.x[8 ] ,t1.x[9 ] ,t1.x[10] 
        FROM ( SELECT  ARRAY( SELECT  a.unnest      AS v 
                        FROM tGenSer tg /**backfill missing keys**/ 
                        LEFT JOIN tmpT a ON tg.key = a.key 
                        ORDER BY tg.key 
                        ) AS x 
           ) t1
     ) a
)b
WHERE ( a,b,c,d,e,f,g,h,i,j) IS DISTINCT FROM ( NULL ,NULL ,NULL ,NULL ,NULL ,NULL ,NULL ,NULL ,NULL ,NULL )        

;
    

结果:

+---+---+--+--+--+--+--+--+--+--+
| a | b |c |d |e |f |g |h |i |j |
+---+---+--+--+--+--+--+--+--+--+
| x | w |  |  |t |s |r |q |p |  |
| o | n |m |  |  |j |  |h |  |f |
| 1 | 2 |3 |  |5 |  |7 |  |9 |  |
+---+---+--+--+--+--+--+--+--+--+

【讨论】:

以上是关于使用 CASE 和 GROUP BY 的动态替代方案的主要内容,如果未能解决你的问题,请参考以下文章

如何在 group by 中使用 case 表达式和 min()

将GROUP BY / CASE与WHEN或IF一起使用

在 MySQL 中使用 Case 加入 Group By & Order

跨表使用group by,case when的问题

GROUP BY + CASE 语句

如何使用 CASE 语句而不必将其放入 GROUP BY