web-dev-qa-db-ja.com

時系列を配列集約セルで日付期間列にピボットする

各イベントの結果が成功または失敗であるイベントの時系列を前提として、エンティティと期間の列によるイベントの比率を、集計配列のセル値でピボットするにはどうすればよいですか?これはcrosstabクエリとarray_aggで実行できると思います。

稼働時間ステータスレポートと同様に、SQLで次のようなものを計算しようとしています。

Heatmap of success ratios

データ量は十分に少ないので、汎用言語でクライアント側で削減を実行できますが、データ量が多い場合は、データベースでこれを効率的に実行すると便利です。

時系列の例

+-----------------------------------------+
|  DateTime             Entity    Result  |
+-----------------------------------------+
| 2016-01-01 11:00...  :a        :success |
| 2016-01-01 17:00...  :a        :success |  -- two events for :a on same day
| 2016-01-01 11:01...  :b        :fail    |
| 2016-01-01 11:03...  :c        :success |
| 2016-01-01 13:00...  :d        :success |  -- only one event for :d
| 2016-01-02 11:00...  :a        :success |
| 2016-01-02 11:01...  :b        :fail    |
| 2016-01-02 11:03...  :c        :success |
| ...                                     |
+-----------------------------------------+

必要な集計

キー列の後の各値セルは、形状[cnt_total cnt_success cnt_fail]の配列です。

+-----------------------------------------------+
| Entity     2016-01-01  2016-01-02  2016-01-xx |
+-----------------------------------------------+
| :a            [2 2 0]     [1 1 0]         ... |
| :b            [1 0 1]     [1 0 1]         ... |
| :c            [1 1 0]     [1 0 1]         ... |
| :d            [1 1 0]     [0 0 0]         ... |
+-----------------------------------------------+

簡単にするために、このレポートには10​​を超える日付ウィンドウ列は必要ありません。SQLピボット出力を動的にテンプレート化できます。

この変換を分解する必要がある場合:

  1. 日付ウィンドウ(時間/日/週/月/四半期/年)および結果ごとに時系列を集計します。
  2. カウントされた集計をハッシュマップや[count_total count_success count_fail]の配列などのデータ構造に蓄積します
  3. 累積された2次元の結果を[entity period1 period2 ...]として返し、クライアントに%を表示します。
2
Petrus Theron

この質問は古いですが、まだ回答を受け入れていません。別の質問を追加します。

データの集計とピボットテーブルが必要です。前者を行う最もエレガントな方法はCTEを使用することであり、後者を行う最もエレガントな方法はCROSSTABを使用することです。ただし、Postgres 9.6以降では、他のDBMSとは異なり、CROSSTABからCTEを参照することはできません。 2つの可能な方法のそれぞれの例を示します。1)CTEを使用して、貧しい人のピボットを自分で再実装します。2)CTEの代わりに、すべてに対してビューを1回作成し、CROSSTABクエリでそれを参照します。どちらの場合も、レポートごとに1つのクエリを発行するだけでよく、一時テーブルを作成する必要はありません。

ピボットの一般的な問題は、純粋なSQLでは、結果の列数が可変であるクエリを定義できず、列見出しを動的に定義できないことです。それが必要な場合は、サーバー側(plpgsql、Abelistoの回答のように)またはクライアント側(PHPJavaなど)のいずれかの手続き型言語でクエリを作成する必要があります。以下の私の例は純粋なSQLであるので、固定された日数(例のデータのように3日)、固定の列見出し("day 1", "day 2", "day 3")がありますが、変更すると必要な編集を最小限に抑える方法で構築されます。

まず、初期データ。私はjoanoloを使用したものから始めましたが、SMALLINTではなくBOOLEANの代わりにresultを使用しているため、私のアプローチは異なります。これを行う理由は以下で明らかになります。

CREATE TABLE time_series (
  date_time TIMESTAMP NOT NULL,
  entity TEXT NOT NULL,
  result SMALLINT DEFAULT 0 -- 1 means success, 0 failure.
);

INSERT INTO time_series VALUES
  ('2016-01-01 11:00', 'a', 1),
  ('2016-01-01 17:00', 'a', 1),
  ('2016-01-01 11:01', 'b', 0),
  ('2016-01-01 11:03', 'c', 1),
  ('2016-01-01 13:00', 'd', 1),
  ('2016-01-02 11:00', 'a', 1),
  ('2016-01-02 11:01', 'b', 0),
  ('2016-01-03 11:03', 'e', 1),
  ('2016-01-03 11:04', 'e', 1),
  ('2016-01-03 11:05', 'e', 1),
  ('2016-01-03 11:06', 'e', 0);

本当に必要なのは、2つの整数(私の例ではa)の配列、a[1](合計数)とa[2](成功数)だけです。失敗数は単にa[1] - a[2]で、成功率は100*(a[2]::float)/a[1]です。合計数はCOUNT(result)で計算できます。 result SMALLINTを定義する場合は、SUM(result)を使用して成功数を追跡できます。 resultBOOLEANとして保存する場合は、SUM(CASE WHEN result THEN 1 ELSE 0 END)を使用する必要があります。それらを文字列として保存する場合は、SUM(CASE WHEN result = 'success' THEN 1 ELSE 0 END)time_seriesテーブルを変更できない場合は、以下のコードを適切に編集してください。

ソリューション1

これは見苦しい広告ですが、CTEの使用方法とCROSSTABが登場する前に苦労しなければならなかった苦痛を示すために示す価値はあります。間隔を変更する場合は、初日、最終日、メイン選択リストの行、結合テーブルのリストの行の4か所で変更する必要があります。ただし、数値列rnを使用すると、結合されたテーブルに明示的な日付を書き込まないため、タスクが簡略化されます。

WITH ct AS (
  SELECT EXTRACT('days' FROM day - MIN(day) OVER()) + 1 AS rn, sub.* 
    FROM (
     SELECT 
          entity, 
          DATE_TRUNC('day', date_time) AS day, 
          ARRAY[COUNT(result), SUM(result)] AS a
       FROM time_series
      WHERE date_time BETWEEN TIMESTAMP '2016-01-01'                 -- initial day
                          AND TIMESTAMP '2016-01-03 23:59:59'        -- last second of final day
      GROUP BY 1,2
    ) AS sub
)
SELECT e.entity
       , d1.a AS "day 1"                                             -- add as many as you need
       , d2.a AS "day 2"
       , d3.a AS "day 3"
  FROM (SELECT DISTINCT entity FROM ct) e
  LEFT JOIN (SELECT entity, a FROM ct WHERE rn = 1) d1 USING(entity) -- add as many as you need
  LEFT JOIN (SELECT entity, a FROM ct WHERE rn = 2) d2 USING(entity)
  LEFT JOIN (SELECT entity, a FROM ct WHERE rn = 3) d3 USING(entity)
  ORDER BY e.entity;


 entity | day 1 | day 2 | day 3 
--------+-------+-------+-------
 a      | {2,2} | {1,1} | 
 b      | {1,0} | {1,0} | 
 c      | {1,1} |       | 
 d      | {1,1} |       | 
 e      |       |       | {4,3}

ソリューション2

ビューのコードは基本的に前の例のCTEコードですが、CROSSTABはタイムスタンプ値でGENERATE_SERIESを使用できるため、データを分類するために数値のrn列を必要としないため、より単純です。このビューは、作成後は変更する必要がないことに注意してください。

CREATE VIEW ts_view AS
  SELECT 
      entity,
      DATE_TRUNC('day', date_time) AS day, 
      ARRAY[COUNT(result), SUM(result)] AS a
    FROM time_series
    GROUP BY 1,2;

これがメインのクエリです。間隔を変更するときは、threeの場所で変更する必要があります:初日、最終日、出力列。フォーマットはクライアント側で行うのが最適ですが、この場合はサーバー側で行っています。これを変更する方法の説明はコメントにあります

SELECT * FROM CROSSTAB ($$
    SELECT 
        entity, 
        day,
        -- You have to repeat the result type of the following expression
        -- as the type of the "day N" columns below.
        -- e.g.  a --> INTEGER[] ,  100*(a[2]::FLOAT)/a[1] --> FLOAT , etc. 
        -- In TO_CHAR, D is changed to your locale's decimal point
        TO_CHAR(100*(a[2]::float)/a[1], '990D99')||'%' 
    FROM ts_view ORDER BY 1
  $$,$$
    SELECT GENERATE_SERIES (
      TIMESTAMP '2016-01-01',  -- initial day
      TIMESTAMP '2016-01-03',  -- final day
      '1 day'
    )
  $$
) AS (
    entity TEXT
    , "day 1" TEXT             -- add as many as you need
    , "day 2" TEXT
    , "day 3" TEXT
);

 entity |  day 1   |  day 2   |  day 3   
--------+----------+----------+----------
 a      |  100.00% |  100.00% | 
 b      |    0.00% |    0.00% | 
 c      |  100.00% |          | 
 d      |  100.00% |          | 
 e      |          |          |   75.00%

最後に、CROSSTABの2番目の引数は次のようになります。

$$ 
WITH n(ow) AS (VALUES(DATE_TRUNC('day', NOW()))) 
SELECT GENERATE_SERIES(n.ow + '-2 days', n.ow, '1 day') FROM n
$$

また、クエリは常に過去3日間(今日を含む)の動的レポートを返します。

3
Dario

リクエストのわずかなバリエーションで、同等のレスポンス(と思います).

まず、これが開始データであると仮定しましょう。

_CREATE TABLE t
(
    date_time timestamp NOT NULL,
    entity text NOT NULL,
    result boolean NOT NULL,  /* true means 'success', false 'fail' */
    PRIMARY KEY(date_time, entity, result)
) ;

INSERT INTO
    t
    (date_time, entity, result)
VALUES
    ('2016-01-01 11:00', 'a', true),
    ('2016-01-01 17:00', 'a', true),    -- two events for a on same day
    ('2016-01-01 11:01', 'b', false),
    ('2016-01-01 11:03', 'c', true),
    ('2016-01-01 13:00', 'd', true),    -- only one event for d
    ('2016-01-02 11:00', 'a', true),
    ('2016-01-02 11:01', 'b', false),
    ('2016-01-03 11:03', 'e', true),     -- 75% success 'e' on day 3 
    ('2016-01-03 11:04', 'e', true),
    ('2016-01-03 11:05', 'e', true),
    ('2016-01-03 11:06', 'e', false) ;
_

中間の「success_summary」テーブル

(一時的な中間)テーブルを作成します。これを_"success_summary"_と呼びます。これには、さまざまなエンティティと日(実際に発生する場所)ごとの成功率がすべて含まれています。

_CREATE TEMPORARY TABLE success_summary
AS
SELECT
    date_period, 
    entity, 
    /* If needed, following line gives approx. your aggs. */
    /* array[count_successes + count_failures, count_successes, count_failures] AS summary, */
    /* Next computation renders the percentage of success as etxt */
    to_char(
         count_successes::double precision*100.0 / (count_successes + count_failures), 
         '990.00%') AS pct_text
FROM
(
    SELECT
        /* date_trunc('day', date_time) AS date_period */
        date_time::date AS date_period, 
        entity, 
        /* We count successes and failures.  We profit from the facxt that CASE always has an implicit ELSE NULL */
        count(CASE WHEN     result THEN 1 END) AS count_successes, 
        count(CASE WHEN not result THEN 1 END) AS count_failures
    FROM
        t
    GROUP BY
        date_period, entity 
) AS q1 ;
_

この時点で、テーブルには次のものが含まれています。

_SELECT to_char(date_period, 'yyyy-mm-dd') AS date_period, entity, pct_text 
FROM success_summary 
ORDER BY entity, date_period;

| date_period | entity | pct_text |
|-------------|--------|----------|
|  2016-01-01 |      a |  100.00% |
|  2016-01-02 |      a |  100.00% |
|  2016-01-01 |      b |    0.00% |
|  2016-01-02 |      b |    0.00% |
|  2016-01-01 |      c |  100.00% |
|  2016-01-01 |      d |  100.00% |
|  2016-01-03 |      e |   75.00% |
_

中間の「all_success_summary」テーブル

ここで、(簡単に)crosstabを実行できるようにするために、すべての欠損値を「埋める」必要があります。これにより、すべてが矩形行列に入力されます。これは、たとえば、前の表には実際には存在しない_(2016-01-01, 'a', *something*)_値を持つ行があることを意味します。 (注:何かとしてNULLを選択しましたが、代わりにcoalesce(pct_text, 'N/A')を使用して「N/A」などのテキストを使用できます_pct_text_)。

それには、さらに別の中間テーブルを使用して、_(date_periods) x (entities)_のデカルト積を作成します。

_CREATE TEMPORARY TABLE all_success_summary AS
SELECT 
    date_period, entity, pct_text
FROM
(
  -- Cross join to have all (date_period, entity) possible pairs
    (SELECT DISTINCT date_period FROM success_summary) AS q00
    CROSS JOIN
    (SELECT DISTINCT      entity FROM success_summary) AS q01
) AS q0
-- Left join with original data to retrieve actual pct_text
-- where it exists (it will be NULL, otherwise)
LEFT JOIN success_summary USING(date_period, entity) ;
_

この中間テーブルの内容は次のとおりです。

_| date_period | entity | pct_text |
|-------------|--------|----------|
|  2016-01-01 |      a |  100.00% |
|  2016-01-02 |      a |  100.00% |
|  2016-01-03 |      a |   (null) |
|  2016-01-01 |      b |    0.00% |
|  2016-01-02 |      b |    0.00% |
|  2016-01-03 |      b |   (null) |
|  2016-01-01 |      c |  100.00% |
|  2016-01-02 |      c |   (null) |
|  2016-01-03 |      c |   (null) |
|  2016-01-01 |      d |  100.00% |
|  2016-01-02 |      d |   (null) |
|  2016-01-03 |      d |   (null) |
|  2016-01-01 |      e |   (null) |
|  2016-01-02 |      e |   (null) |
|  2016-01-03 |      e |   75.00% |
_

最終的なPIVOTテーブル

この時点で、最初のバージョンの crosstab を使用してすべてのデータを取得できますPIVOTed

_SELECT
    *
FROM
    crosstab(
        'SELECT entity, date_period, pct_text 
           FROM all_success_summary 
           ORDER BY entity, date_period')
    AS ct (entity text, "2016-01-01" text, "2016-01-02" text, "2016-01-03" text) ;
_

結果のテーブルは次のとおりです。

_| entity | 2016-01-01 | 2016-01-02 | 2016-01-03 |
|--------|------------|------------|------------|
|     a  |   100.00%  |   100.00%  |            | 
|     b  |     0.00%  |     0.00%  |            |  
|     c  |   100.00%  |            |            |  
|     d  |   100.00%  |            |            |  
|     e  |            |            |     75.00% |
_

集計ベクトルやその他の表現は含まれていませんが、SpreadSheetに直接インポートできるフォーマットされた結果が含まれています。

ノート

1:CrossTab関数を使用するときに必要な適切な列定義を知るには、次のクエリを使用できます。

_SELECT
    '(entity text, ' || string_agg(c, ', ') || ')' AS column_definition
FROM
(
    SELECT DISTINCT
        '"' || date_period || '" text' AS c
    FROM
        all_success_summary 
    ORDER BY
        c 
) AS q1 ;
_

2:_"date_period"_を1日だけに設定しました(場所によっては、表示しやすいように結果をフォーマットしました)。 date_trunc('week', date_time) AS date_periodなどを使用して、私が使用した定義の代わりに、日ではなく週で要約することで、すべて同じことが実現できます。これは、あらゆるタイプのグループに一般化できます。

3:配列が必要な場合は、success_summaryの定義に関するヒントをどこから取得するかについてのヒントがあります。

4:中間テーブルをすべてスキップできます(名前が表示される場所に定義を重複して配置することにより)。また、ユーザー定義関数内に非表示にして、使用後に削除することもできます。 crosstabWITHステートメントによって作成された仮想テーブルを「理解」しないため、CTEを使用してそれらを回避することはできません。とにかく、通常は_column_definition_...も必要なので、一時テーブルが便利です。

5crosstabを使用する代わりに、JSON形式で_all_success_summary_からすべての行を取得することもできます、この情報を後処理します。それはすべて、特定のユースケースに依存します(ただし、Excelのスクリーンショットを見ました...そして、最も近い方向に移動しました;-)。 Excelは、「success_summary」データ(およびおそらく元のデータ)からのすべてのデータをPIVOT自体にPIVOTできると言わなければなりません。


これのほとんど(クロス集計自体を除く)は SQLFiddle で確認できます。

1
joanolo
-- Test data
drop table if exists t cascade;
create table t(datetime timestamptz, entity char(1), result bool);
insert into t values
  ('2016-01-01 11:00:01', 'a', true),
  ('2016-01-01 17:00:01', 'a', true),  -- two events for :a on same day
  ('2016-01-01 11:01:01', 'b', false),
  ('2016-01-01 11:03:01', 'c', true),
  ('2016-01-01 13:00:01', 'd', true),  -- only one event for :d
  ('2016-01-02 11:00:01', 'a', true),
  ('2016-01-02 11:01:01', 'b', false),
  ('2016-01-02 11:03:01', 'c', true);

do $$ -- Here we will create the view to select desired data
declare
  select_clause text := 'entity';
  dates date[];
  d date;
  date_filter text;
begin
  -- Generate array with dates for our columns
  select array_agg(dt) into dates
  from generate_series((select min(datetime) from t)::date, (select max(datetime) from t)::date, '1 day') as dt;
  raise info '%', dates;

  -- Generate "select part"
  foreach d in array dates loop
    date_filter := format('datetime::date = %L', d);
    raise notice '%', date_filter;
    select_clause :=
      select_clause ||
      ', array[count(*) filter(where ' || date_filter ||
      '), count(*) filter(where ' || date_filter ||
      ' and result), count(*) filter(where ' || date_filter ||
      ' and not result)] as ' ||
      quote_ident(d::text);
  end loop;
  raise info '%', select_clause;

  -- Create temporary view using previously generated "select part"
  -- "temp view" is session-wide
  execute 'create or replace temp view v as select ' || select_clause || ' from t group by entity';
end $$;

select * from v order by entity;

http://rextester.com/OLISJ7634

1
Abelisto