BATONZ Tech Blog

M&Aプラットフォーム運営の株式会社バトンズによる技術ブログです。

generate_seriesとCROSS JOINを用いた月次集計SQL

こんにちは!
株式会社バトンズでエンジニアをしているyamafです。

エンジニアはマーケや営業からデータ集計の依頼を受けることがよくあるのではないでしょうか。
以下のようなデータ集計の依頼があった場合、皆さんどのように取得しますか?

企業別の新規ユーザー登録数を月毎に出してほしい。
期間は2022年10月から2022年12月まで。
その後のデータ分析の都合上、月毎に抜けもれなくカウントを出してほしい。 例えば、その月対象企業が登録がなかった場合でもカウントは0と出してほしい。
以下のイメージ。

企業名 ユーザー登録者数
株式会社AAA 2022-10 3
株式会社AAA 2022-11 0
株式会社AAA 2022-12 1
株式会社BBB 2022-10 5
株式会社BBB 2022-11 0
株式会社BBB 2022-12 0
株式会社CCC 2022-10 0
株式会社CCC 2022-11 0
株式会社CCC 2022-12 3

私も実際に業務で似たような依頼を受けたことがあります。 一件何の論点もない簡単なデータ取得に見えますが、 少々苦戦したので途中の取得法も含めてご紹介していきます。

なお、DBはPostgreSQLを利用しており、ポイントはタイトルのとおり generate_series関数とCROSS JOIN構文になります。

※記事下部に、動作確認用のSQLを用意しておりますので、実際に動きを見たい方はそちらもご参照ください。

対応①:GROUP BY句で企業別・月別にカウントを取る

まずは、真っ先にこれが思いつく方も多いのではないでしょうか。

SELECT
  c.company_name, -- 企業名
  to_char(cu.created_at, 'YYYY-MM') AS target_month, -- 対象月 
  COUNT(cu.id) AS user_count -- 対象月におけるユーザー登録数
FROM company_users cu
INNER JOIN companies c ON c.id = cu.company_id
GROUP BY c.company_name, target_month
ORDER BY c.company_name, target_month

こちらを実行すると以下のような結果が得られます。

企業名 ユーザー登録者数
株式会社AAA 2022-10 3
株式会社AAA 2022-12 1
株式会社BBB 2022-10 5
株式会社CCC 2022-12 3

これで、企業毎の月別ユーザー登録数が取得できますね。 ただ、これだと以下の条件をみたすことができません。

その後のデータ分析の都合上、月毎に抜けもれなくカウントを出してほしい。 例えば、その月対象企業が登録がなかった場合でもカウントは0と出してほしい。
以下のイメージ。

企業名 ユーザー登録者数
株式会社AAA 2022-10 3
株式会社AAA(★このレコードがない) 2022-11 0
株式会社AAA 2022-12 1
株式会社BBB 2022-10 5
株式会社BBB(★このレコードがない) 2022-11 0
株式会社BBB(★このレコードがない) 2022-12 0
株式会社CCC(★このレコードがない) 2022-10 0
株式会社CCC(★このレコードがない) 2022-11 0
株式会社CCC 2022-12 3

その月に登録がない場合はレコードとして抽出されないのは当然の動きですね。
さて、困りました。
ここから色々と調査・試行錯誤し、以下に記載する対応②の方法で欲しいデータを得ることができましたのでご紹介させていただきます。

対応②:generate_series関数とcross joinを活用する

結果として、こちらの方法で期待するデータを得ることができました。
SQLとしては以下のようなイメージです。

WITH month_series AS (
  SELECT
    to_char(generate_series(
      '2022-10-01'::TIMESTAMP,
      '2022-12-01'::TIMESTAMP,
      '1 month'),
    'YYYY-MM') AS month
),
every_month_companies AS (
  SELECT
    ms.month,
    c.id,
    c.company_name
  FROM month_series ms
  CROSS JOIN companies c
)
SELECT
  emc.company_name, -- 企業名
  emc.month AS target_month, -- 対象月 
  COUNT(cu.id) AS user_count -- 対象月におけるユーザー登録数
FROM every_month_companies emc
LEFT OUTER JOIN company_users cu ON 
  cu.company_id = emc.id AND 
  to_char(cu.created_at , 'YYYY-MM') = emc.month
GROUP BY emc.company_name, emc.month
ORDER BY emc.company_name, emc.month

こちらを実行すると、以下のような結果が得られます。

企業名 ユーザー登録者数
株式会社AAA 2022-10 3
株式会社AAA 2022-11 0
株式会社AAA 2022-12 1
株式会社BBB 2022-10 5
株式会社BBB 2022-11 0
株式会社BBB 2022-12 0
株式会社CCC 2022-10 0
株式会社CCC 2022-11 0
株式会社CCC 2022-12 3

期待通りですね!
見慣れない関数や構文があると思いますので、このSQLについて順を追って解説していきます。

1.generate_series関数を用いて、連続した月を表す一時テーブルを生成する

WITH month_series AS (
  SELECT 
    to_char(generate_series(
      '2022-10-01'::TIMESTAMP, 
      '2022-12-01'::TIMESTAMP, 
      '1 month'), 
    'YYYY-MM') AS month  
)

上記クエリでは、WITH句を使ってmonth_seriesという名前で一時テーブルを生成しています。
ポイントはgenerate_seriesという関数で、以下の文法で連続した集合を返してくれます。

generate_series(start, stop, step)

generate_series関数は数値の連番も作れるので、大量のテストデータを用意したい場合などにも活用できますね。
今回はstartに10/1、stopに12/1を指定し、stepに1ヶ月を指定してるので、以下のようなデータを返してくれます。

month
2022-10
2022-11
2022-12

2.CROSS JOINを用いて、連続した月を表す一時テーブルを生成する

every_month_companies AS (
  SELECT
    c.id,
    c.company_name,
    ms.month
  FROM month_series ms
  CROSS JOIN companies c
)

上記クエリでは、WITH句を使ってevery_month_companiesという名前で一時テーブルを生成しています。
ポイントは先程作成したmonth_seriesとcompaniesテーブルを「CROSS JOIN」を使って直積結合している点です。

LEFT JOINやINNER JOINはよく使うと思いますが、CROSS JOINは聞いたことがない、または使ったことがない方も多いと思います(実際私もこの問いに対応するまで知りませんでした)

CROSS JOINについての詳細は、こちら などをご確認頂ければと思いますが、今回の例でいうと以下図のようなイメージで、2つの集合の直積(集合1の各要素と集合2の各要素を組み合わせて作られる新しい集合)を返してくれます。

crossjoinイメージ

このevery_month_companiesは以下のようなデータを返してくれます。 ここまでくれば、ゴールが見えてきますね!

企業ID 企業名
1 株式会社AAA 2022-10
2 株式会社BBB 2022-10
3 株式会社CCC 2022-10
1 株式会社AAA 2022-11
2 株式会社BBB 2022-11
3 株式会社CCC 2022-11
1 株式会社AAA 2022-12
2 株式会社BBB 2022-12
3 株式会社CCC 2022-12

3.LEFT JOINとGROUP BYを用いて、企業の月別レコードをベースに登録数をカウントする

SELECT
  emc.company_name, -- 企業名
  emc.month AS target_month, -- 対象月 
  COUNT(cu.id) AS user_count -- 対象月におけるユーザー登録数
FROM every_month_companies emc
LEFT OUTER JOIN company_users cu ON 
    cu.company_id = emc.id AND 
    to_char(cu.created_at , 'YYYY-MM') = emc.month
GROUP BY emc.company_name, emc.month
ORDER BY emc.company_name, emc.month

先程のevery_month_companiesに対して、company_users(企業内ユーザー)を左外部結合します。月別のカウントをとりたいので、結合条件は企業IDだけでなく、月も条件に加える必要があります。後は、GROUP BYで企業名と月を指定し、ユーザー数をカウントしてあげればOKです。

少々長くなりましたが、こちらで以上となります。
本記事がみなさまのデータ抽出や分析の一助となることができれば幸いです。

参考:動作を確認したい方向けのSQL

DDL(Data Definition Language)

以下のSQLでテーブルを作成します。

-- 企業テーブル
CREATE TABLE companies (
  id SERIAL PRIMARY KEY,
  company_name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ユーザーテーブル
CREATE TABLE company_users (
  id SERIAL PRIMARY KEY,
  user_name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  company_id INTEGER REFERENCES companies(id)
);

DML(Data Manipulation Language)

以下のSQLで冒頭でサンプルとして記載した状態になるようなデータを投入します。

-- 企業テーブルへのデータ挿入
INSERT INTO companies (company_name, created_at) VALUES
('株式会社AAA', '2022-10-01'::TIMESTAMPTZ),
('株式会社BBB', '2022-10-01'::TIMESTAMPTZ),
('株式会社CCC', '2022-10-01'::TIMESTAMPTZ);

-- ユーザーテーブルにデータを挿入
INSERT INTO company_users (user_name, created_at, company_id) VALUES
('ユーザーA1', '2022-10-01'::TIMESTAMPTZ, 1),
('ユーザーA2', '2022-10-01'::TIMESTAMPTZ, 1),
('ユーザーA3', '2022-10-01'::TIMESTAMPTZ, 1),
('ユーザーA4', '2022-12-01'::TIMESTAMPTZ, 1),
('ユーザーB1', '2022-10-01'::TIMESTAMPTZ, 2),
('ユーザーB2', '2022-10-01'::TIMESTAMPTZ, 2),
('ユーザーB3', '2022-10-01'::TIMESTAMPTZ, 2),
('ユーザーB4', '2022-10-01'::TIMESTAMPTZ, 2),
('ユーザーB5', '2022-10-01'::TIMESTAMPTZ, 2),
('ユーザーC1', '2022-12-01'::TIMESTAMPTZ, 3),
('ユーザーC2', '2022-12-01'::TIMESTAMPTZ, 3),
('ユーザーC3', '2022-12-01'::TIMESTAMPTZ, 3);