【PostgreSQL】もう手放せない!最新SQL標準の便利構文を使い倒して開発効率を爆上げしよう🐘✨

SQL

はじめに

今回は、データベース操作のコア技術であるSQL、特にPostgreSQLで利用できる最新SQL標準の便利な構文に焦点を当てます。これらの構文を使いこなすことで、複雑なデータ操作も驚くほどシンプルに、そして効率的に記述できるようになります。

「こんな書き方があったのか!」「もっと早く知りたかった!」と思っていただけるような、まさに目からウロコのテクニックをご紹介します。具体的な応用例や、これらの構文を使わなかった場合の「面倒くささ」も比較しながら、そのメリットを体感していきましょう!

また、この記事で紹介するPostgreSQLのSQLコードは、特別な環境構築なしにオンラインですぐに実行・検証できます。以下のサイトで手軽に試してみてくださいね!

  • OneCompiler – PostgreSQL: https://onecompiler.com/postgresql
    • 記事中のサンプルコードをコピー&ペーストして実行すれば、すぐに結果を確認できます。ぜひ、実際に手を動かしながら読み進めてみてください。

今回紹介する便利構文

  1. JOIN USING, NATURAL JOIN: 面倒な結合条件指定をシンプルに
  2. JOIN LATERAL: 行ごとの相関サブクエリをエレガントに
  3. WITH句 (CTE): 複雑なクエリを整理整頓
    • WITH MATERIALIZED: CTEの実行計画をコントロール
    • WITH RECURSIVE: 階層データもお手の物
  4. ウィンドウ関数 OVER(PARTITION BY … ORDER BY … ROWS BETWEEN …): 高度な集計・分析をSQLだけで完結

それでは、一つずつ見ていきましょう!

JOIN USING, NATURAL JOIN:面倒な結合条件指定をシンプルに

複数のテーブルを結合する際、結合キーとなるカラム名が同じであることはよくあります。そんなとき、ON table1.key_column = table2.key_column と書くのは少し冗長ですよね。JOIN USING や NATURAL JOIN は、このようなケースで結合条件の記述を簡潔にしてくれます。

サンプルデータ準備

まずは、社員 (employees) と部署 (departments) の簡単なテーブルをCTEとVALUES句で用意しましょう。

WITH departments AS (
    SELECT * FROM (VALUES
        (1, '営業部'),
        (2, '開発部'),
        (3, '人事部')
    ) AS t (department_id, department_name)
),
employees AS (
    SELECT * FROM (VALUES
        (101, '山田太郎', 1),
        (102, '佐藤花子', 2),
        (103, '田中一郎', 1),
        (104, '鈴木次郎', 2),
        (105, '高橋三郎', NULL) -- 所属部署なし
    ) AS t (employee_id, employee_name, department_id)
)
-- この後で実際のクエリを記述

JOIN USING

USING句は、結合するテーブル間で同名のカラムを結合キーとして指定します。

ピッタリな場面:

  • 結合キーとなるカラム名が両テーブルで共通している場合。
  • ON句で複数の結合キーを AND で繋げるよりもシンプルに書きたい場合(USING (key1, key2, …)のように複数指定も可能)。

応用例:

社員情報と部署情報を department_id で結合してみましょう。

WITH departments AS (
    SELECT * FROM (VALUES
        (1, '営業部'),
        (2, '開発部'),
        (3, '人事部')
    ) AS t (department_id, department_name)
),
employees AS (
    SELECT * FROM (VALUES
        (101, '山田太郎', 1),
        (102, '佐藤花子', 2),
        (103, '田中一郎', 1),
        (104, '鈴木次郎', 2),
        (105, '高橋三郎', NULL)
    ) AS t (employee_id, employee_name, department_id)
)
SELECT
    e.employee_name,
    d.department_name
FROM
    employees e
INNER JOIN
    departments d USING (department_id);

結果:

employee_namedepartment_name
山田太郎営業部
佐藤花子開発部
田中一郎営業部
鈴木次郎開発部

これを使わない場合(従来のON句):

-- データ定義文省略
SELECT
    e.employee_name,
    d.department_name
FROM
    employees e
INNER JOIN
    departments d ON e.department_id = d.department_id;

違いは僅かですが、結合キーが増えたり、テーブル名が長かったりすると USING の簡潔さが際立ちます。また、USING を使うと結合キー列は結果セットに一つだけ出力されるため、SELECT * をしても重複列が現れないというメリットもあります(ON句の場合は両方のテーブルのキー列が出力される)。

NATURAL JOIN

NATURAL JOIN はさらに大胆で、両テーブルに存在する全ての同名カラムを自動的に結合キーとして使用します。

ピッタリな場面:

  • 結合キーとなるカラム名が明確に共通しており、かつ他に同名で意味の異なるカラムが存在しないことが保証されている場合。
  • とにかく記述量を減らしたい場合。

注意点:

  • 意図しないカラム同士が結合されるリスクがあります。 例えば、両方のテーブルに updated_at という名前で最終更新日時を記録するカラムがあった場合、それらも結合条件に含まれてしまい、期待しない結果になることがあります。
  • そのため、スキーマ設計を完全に把握しており、かつ慎重に使用する必要があります。一般的には USING の方が安全で推奨されることが多いです。

応用例: (上記のデータであれば department_id で結合されます)

-- データ定義文省略
SELECT
    e.employee_name,
    d.department_name
FROM
    employees e
NATURAL INNER JOIN -- INNER は省略可能
    departments d;

これを使わない場合、ON句やUSING句で明示的に結合条件を書くことになります。NATURAL JOINの便利さは記述の究極的な簡潔さですが、前述の通りリスクも伴うため、利用シーンは限定的かもしれません。

JOIN LATERAL:行ごとの相関サブクエリをエレガントに

LATERAL句を伴うJOIN (通常は LEFT JOIN LATERAL や INNER JOIN LATERAL) は、JOINの右側に指定するテーブルやサブクエリが、JOINの左側のテーブルの各行を参照できる機能です。これにより、相関サブクエリでは実現しにくかったり、複雑になったりする処理をより直感的かつ効率的に記述できます。

ピッタリな場面:

  • トップN件取得: 各カテゴリの最新N件の商品、各ユーザーの直近M件の注文履歴など。
  • 行ごとの計算結果に基づく結合: 左側のテーブルの行の値を使って計算した結果を元に、右側のテーブルを動的にフィルタリングしたり、テーブル値関数を呼び出したりする場合。

サンプルデータ準備

カテゴリ (categories) と、各カテゴリに属する商品 (products) テーブルを用意します。

WITH categories AS (
    SELECT * FROM (VALUES
        (1, '果物'),
        (2, '野菜')
    ) AS t (category_id, category_name)
),
products AS (
    SELECT * FROM (VALUES
        (101, 'りんご', 1, 150, '2024-05-01'),
        (102, 'みかん', 1, 100, '2024-05-10'),
        (103, 'バナナ', 1, 120, '2024-04-20'),
        (201, 'キャベツ', 2, 200, '2024-05-05'),
        (202, 'レタス', 2, 180, '2024-05-12'),
        (203, 'トマト', 2, 220, '2024-04-25'),
        (204, 'きゅうり', 2, 160, '2024-05-15')
    ) AS t (product_id, product_name, category_id, price, registered_date)
)
-- この後で実際のクエリを記述

応用例: 各カテゴリの登録日が新しい商品トップ2件を取得する

WITH categories AS (
    SELECT * FROM (VALUES
        (1, '果物'),
        (2, '野菜')
    ) AS t (category_id, category_name)
),
products AS (
    SELECT * FROM (VALUES
        (101, 'りんご', 1, 150, '2024-05-01'),
        (102, 'みかん', 1, 100, '2024-05-10'),
        (103, 'バナナ', 1, 120, '2024-04-20'),
        (201, 'キャベツ', 2, 200, '2024-05-05'),
        (202, 'レタス', 2, 180, '2024-05-12'),
        (203, 'トマト', 2, 220, '2024-04-25'),
        (204, 'きゅうり', 2, 160, '2024-05-15')
    ) AS t (product_id, product_name, category_id, price, registered_date)
)
SELECT
    c.category_name,
    p_lat.product_name,
    p_lat.price,
    p_lat.registered_date
FROM
    categories c
LEFT JOIN LATERAL (
    SELECT
        p.product_name,
        p.price,
        p.registered_date
    FROM
        products p
    WHERE
        p.category_id = c.category_id -- 左側のテーブルの行(c)を参照
    ORDER BY
        p.registered_date DESC
    LIMIT 2
) AS p_lat ON TRUE; -- LATERALの場合、ON TRUE や ON p_lat IS NOT NULL など自明な条件でよい

結果:

category_nameproduct_namepriceregistered_date
果物みかん1002024-05-10
果物りんご1502024-05-01
野菜きゅうり1602024-05-15
野菜レタス1802024-05-12

LATERAL句内のサブクエリでは、外側の categories テーブルの c.category_id を参照して、カテゴリごとに商品をフィルタリングし、最新登録日のトップ2件を取得しています。

これを使わない場合(ウィンドウ関数や相関サブクエリを使う場合):

ウィンドウ関数を使っても同様の結果を得られますが、一度全商品に対してランク付けをしてからフィルタリングする形になります。

-- ウィンドウ関数を使った例
WITH categories AS (
    SELECT * FROM (VALUES
        (1, '果物'),
        (2, '野菜')
    ) AS t (category_id, category_name)
),
products AS (
    SELECT * FROM (VALUES
        (101, 'りんご', 1, 150, '2024-05-01'),
        (102, 'みかん', 1, 100, '2024-05-10'),
        (103, 'バナナ', 1, 120, '2024-04-20'),
        (201, 'キャベツ', 2, 200, '2024-05-05'),
        (202, 'レタス', 2, 180, '2024-05-12'),
        (203, 'トマト', 2, 220, '2024-04-25'),
        (204, 'きゅうり', 2, 160, '2024-05-15')
    ) AS t (product_id, product_name, category_id, price, registered_date)
),
ranked_products AS (
    SELECT
        p.product_name,
        p.price,
        p.registered_date,
        p.category_id,
        ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.registered_date DESC) as rn
    FROM
        products p
)
SELECT
    c.category_name,
    rp.product_name,
    rp.price,
    rp.registered_date
FROM
    categories c
INNER JOIN
    ranked_products rp ON c.category_id = rp.category_id
WHERE
    rp.rn <= 2
ORDER BY
    c.category_name, rp.registered_date DESC;

この例ではウィンドウ関数も有効ですが、JOIN LATERAL は「各行に対して処理を行う」という意図がより明確になり、複雑な条件やテーブル値関数を組み合わせる際にはさらに強力さを発揮します。特に、左側のテーブルの行の値に基づいて、サブクエリの内容が大きく変わる場合に非常に有効です。

例えば、PostgreSQLの generate_series() 関数と組み合わせて、各注文に対して未来の日付をN件生成する、といった動的な行生成も可能です。

使わない場合の面倒くささ:

  • 相関サブクエリの限界: SELECT句内のスカラサブクエリでは1行1列の値しか返せません。複数の行や列を返したい場合、JOIN LATERAL が必要になります。
  • 複雑なロジック: 無理に相関サブクエリや自己結合で書こうとすると、SQLが非常に複雑で読みにくくなり、パフォーマンスも悪化しがちです。
  • プロシージャやアプリケーションロジックへの依存: SQLだけで完結できず、ループ処理などをアプリケーション側で行う必要が出てくる場合があります。JOIN LATERAL はこれをSQLの範囲に留めるのに役立ちます。

WITH句 (CTE: Common Table Expression):複雑なクエリを整理整頓

WITH句(共通テーブル式、CTEとも呼ばれます)は、複雑なSQLクエリをより小さく、名前付きの論理的なブロックに分割するための強力な機能です。これにより、クエリの可読性、保守性、再利用性が大幅に向上します。

基本的なWITH句

ピッタリな場面:

  • 長大なクエリを複数のステップに分割して見通しを良くしたい場合。
  • 同じサブクエリをクエリ内で複数回参照する場合(ただし、PostgreSQLのオプティマイザは賢いので、単純な再利用だけではパフォーマンスメリットが必ずしもあるわけではありません)。
  • 段階的なデータ変換や集計を行いたい場合。

サンプルデータ準備

売上データ (sales) を用意します。

WITH sales AS (
    SELECT * FROM (VALUES
        ('2024-01-10'::date, '商品A', 100, 3),
        ('2024-01-12', '商品B', 150, 2),
        ('2024-01-15', '商品A', 100, 5),
        ('2024-02-05', '商品C', 200, 1),
        ('2024-02-10', '商品B', 150, 4),
        ('2024-02-20', '商品A', 100, 2)
    ) AS t (sale_date, product_name, unit_price, quantity)
)
-- この後で実際のクエリを記述

応用例: 商品別の月間売上合計と、全体の平均月間売上を計算する

WITH sales AS (
    SELECT * FROM (VALUES
        ('2024-01-10'::date, '商品A', 100, 3),
        ('2024-01-12', '商品B', 150, 2),
        ('2024-01-15', '商品A', 100, 5),
        ('2024-02-05', '商品C', 200, 1),
        ('2024-02-10', '商品B', 150, 4),
        ('2024-02-20', '商品A', 100, 2)
    ) AS t (sale_date, product_name, unit_price, quantity)
),
-- CTE 1: 各売上の売上金額を計算
daily_sales_amount AS (
    SELECT
        sale_date,
        product_name,
        unit_price * quantity AS amount
    FROM
        sales
),
-- CTE 2: 商品別・月別の売上合計を計算
monthly_product_sales AS (
    SELECT
        EXTRACT(YEAR FROM sale_date) AS sale_year,
        EXTRACT(MONTH FROM sale_date) AS sale_month,
        product_name,
        SUM(amount) AS total_monthly_amount
    FROM
        daily_sales_amount
    GROUP BY
        1, 2, 3 -- PostgreSQLではSELECTリストの序数を指定可能
),
-- CTE 3: 全体の月間平均売上を計算(これは月ごとの商品別売上の平均)
avg_monthly_product_sales AS (
    SELECT
        product_name,
        AVG(total_monthly_amount) AS avg_monthly_sale
    FROM
        monthly_product_sales
    GROUP BY
        product_name
)
-- メインクエリ: CTEの結果を結合して表示
SELECT
    mps.sale_year,
    mps.sale_month,
    mps.product_name,
    mps.total_monthly_amount,
    amps.avg_monthly_sale
FROM
    monthly_product_sales mps
JOIN
    avg_monthly_product_sales amps ON mps.product_name = amps.product_name
ORDER BY
    mps.sale_year, mps.sale_month, mps.product_name;

結果:

 sale_year | sale_month | product_name | total_monthly_amount |   avg_monthly_sale   
-----------+------------+--------------+----------------------+----------------------
2024 | 1 | 商品A | 800 | 500.0000000000000000
2024 | 1 | 商品B | 300 | 450.0000000000000000
2024 | 2 | 商品A | 200 | 500.0000000000000000
2024 | 2 | 商品B | 600 | 450.0000000000000000
2024 | 2 | 商品C | 200 | 200.0000000000000000

この例では、処理を3つのCTEに分割しています。

  1. daily_sales_amount: 日々の売上金額を計算。
  2. monthly_product_sales: 商品ごと、月ごとの売上合計を計算。
  3. avg_monthly_product_sales: 商品ごとの月間平均売上を計算。

このように分割することで、各ステップの処理内容が明確になり、デバッグもしやすくなります。

これを使わない場合(ネストしたサブクエリ):

SELECT
    EXTRACT(YEAR FROM s.sale_date) AS sale_year,
    EXTRACT(MONTH FROM s.sale_date) AS sale_month,
    s.product_name,
    SUM(s.unit_price * s.quantity) AS total_monthly_amount,
    (
        SELECT AVG(monthly_sum)
        FROM (
            SELECT SUM(s_inner.unit_price * s_inner.quantity) as monthly_sum
            FROM (VALUES
                ('2024-01-10', '商品A', 100, 3),
                ('2024-01-12', '商品B', 150, 2),
                ('2024-01-15', '商品A', 100, 5),
                ('2024-02-05', '商品C', 200, 1),
                ('2024-02-10', '商品B', 150, 4),
                ('2024-02-20', '商品A', 100, 2)
            ) AS s_inner (sale_date::date, product_name, unit_price, quantity)
            WHERE s_inner.product_name = s.product_name -- 相関
            GROUP BY EXTRACT(YEAR FROM s_inner.sale_date), EXTRACT(MONTH FROM s_inner.sale_date)
        ) as product_avg_sales
    ) as avg_monthly_sale_for_product -- 商品ごとの月平均売上
FROM (VALUES
    ('2024-01-10', '商品A', 100, 3),
    ('2024-01-12', '商品B', 150, 2),
    ('2024-01-15', '商品A', 100, 5),
    ('2024-02-05', '商品C', 200, 1),
    ('2024-02-10', '商品B', 150, 4),
    ('2024-02-20', '商品A', 100, 2)
) AS s (sale_date::date, product_name, unit_price, quantity)
GROUP BY
    EXTRACT(YEAR FROM s.sale_date),
    EXTRACT(MONTH FROM s.sale_date),
    s.product_name
ORDER BY
    sale_year, sale_month, s.product_name;

ネストしたサブクエリや相関サブクエリを使うと、クエリの構造が複雑になり、どこで何をしているのか把握するのが難しくなります。特に集計の段階が増えると、その傾向は顕著です。WITH句がいかに可読性向上に貢献するかがお分かりいただけるでしょう。

WITH MATERIALIZED

PostgreSQLでは、WITH句で定義されたCTEが複数回参照される場合や、CTEの結果セットが大きい場合に、オプティマイザがそのCTEを一時的に実体化(マテリアライズ)してパフォーマンスを改善しようとすることがあります。しかし、必ずしもオプティマイザの判断が最善とは限りません。

MATERIALIZEDヒントをCTEに付与することで、そのCTEの結果を強制的に実体化(一時テーブルのように扱う)するよう指示できます。逆に NOT MATERIALIZED はインライン展開を促します。

ピッタリな場面:

  • CTEがクエリ内で複数回参照され、かつそのCTEの計算コストが高い場合。実体化することで計算を一度で済ませる。
  • CTEの結果セットがそれほど大きくなく、複数回参照されるが、インライン展開した方が速いと判断される場合に、明示的に実体化を指示したい場合。
  • オプティマイザの判断とは異なる実行計画を試したい場合。

注意点:

  • MATERIALIZED は必ずしもパフォーマンスを向上させるわけではありません。一時テーブルの作成と書き込みコストが発生するため、ケースバイケースでの検証が必要です。
  • PostgreSQLのバージョンや設定によって挙動が変わる可能性もあります。
  • 通常、PostgreSQLのオプティマイザは非常に優秀なので、明示的な MATERIALIZED の使用は、パフォーマンスチューニングの最終手段として検討するのが良いでしょう。

応用例:

WITH sales AS ( /* ... 前述のsalesデータ ... */ ),
expensive_summary AS MATERIALIZED ( -- このCTEを実体化する
    SELECT
        product_name,
        SUM(unit_price * quantity) AS total_sales,
        AVG(unit_price * quantity) AS avg_sale_value
    FROM sales
    GROUP BY product_name
    -- 仮にこの集計が非常に重い処理だとする
)
SELECT
    s.product_name,
    s.sale_date,
    (s.unit_price * s.quantity) AS sale_amount,
    es.total_sales,
    es.avg_sale_value
FROM
    sales s
JOIN
    expensive_summary es ON s.product_name = es.product_name
WHERE
    (s.unit_price * s.quantity) > es.avg_sale_value; -- 平均売上より大きい売上のみ抽出

この例では、expensive_summary が複数回参照されるわけではありませんが、もしこのCTEが非常に計算コストが高く、かつその結果を後のクエリの複数箇所で利用するような場合(例えば、複数のJOINやサブクエリで参照する)、MATERIALIZED が効果を発揮する可能性があります。

これを使わない場合:

PostgreSQLのオプティマイザが自動的に判断します。多くの場合、最適な判断をしてくれますが、複雑なクエリではそうでないこともあります。その際は EXPLAIN ANALYZE で実行計画を確認し、MATERIALIZED の効果を検証することになります。WITH RECURSIVE:階層データもお手の物

WITH RECURSIVE:階層データもお手の物

WITH RECURSIVE は、再帰的な処理をSQLで記述するための強力な機能です。組織図、部品表 (Bill of Materials)、SNSの友達関係、ファイルシステムのディレクトリ構造など、階層的なデータを扱う際に非常に役立ちます。

再帰CTEは、以下の2つの部分から構成されます。

  1. 非再帰項 (Anchor Member): 再帰の開始点となる初期の行セットを定義します。
  2. 再帰項 (Recursive Member): 直前のステップで生成された行セットを参照し、新しい行セットを生成します。この処理が、新しい行が生成されなくなるまで繰り返されます。

UNION ALL (または UNION) を使って非再帰項と再帰項を結合します。

ピッタリな場面:

  • 組織階層で特定のマネージャー配下の全従業員をリストアップする。
  • 部品表で、ある製品を構成する全ての部品(下位部品も含む)を展開する。
  • グラフ構造データ(例:交通網)で、ある地点から到達可能な全ての地点を探索する。

サンプルデータ準備

従業員の階層関係を表す employees_hierarchy テーブルを用意します。manager_id が上司の employee_id を指します。トップの従業員は manager_id が NULL です。

WITH employees_hierarchy AS (
    SELECT * FROM (VALUES
        (1, 'CEO', NULL),
        (2, '部長A (CEO直下)', 1),
        (3, '部長B (CEO直下)', 1),
        (4, '課長A1 (部長A配下)', 2),
        (5, '課長A2 (部長A配下)', 2),
        (6, '課長B1 (部長B配下)', 3),
        (7, '社員A1-1 (課長A1配下)', 4),
        (8, '社員A1-2 (課長A1配下)', 4),
        (9, '社員B1-1 (課長B1配下)', 6)
    ) AS t (employee_id, employee_name, manager_id)
)
-- この後で実際のクエリを記述

応用例: IDが2の「部長A」配下の全従業員(自身も含む)を階層レベルと共に表示する

WITH RECURSIVE employees_hierarchy AS (
    SELECT * FROM (VALUES
        (1, 'CEO', NULL),
        (2, '部長A (CEO直下)', 1),
        (3, '部長B (CEO直下)', 1),
        (4, '課長A1 (部長A配下)', 2),
        (5, '課長A2 (部長A配下)', 2),
        (6, '課長B1 (部長B配下)', 3),
        (7, '社員A1-1 (課長A1配下)', 4),
        (8, '社員A1-2 (課長A1配下)', 4),
        (9, '社員B1-1 (課長B1配下)', 6)
    ) AS t (employee_id, employee_name, manager_id)
)
, subordinates AS (
    -- 非再帰項 (Anchor Member): 開始点となる従業員 (部長A)
    SELECT
        employee_id,
        employee_name,
        manager_id,
        0 AS level -- 階層レベル
    FROM
        employees_hierarchy
    WHERE
        employee_id = 2 -- 部長AのID

    UNION ALL

    -- 再帰項 (Recursive Member): 直前のステップで見つかった部下の、さらに部下を検索
    SELECT
        e.employee_id,
        e.employee_name,
        e.manager_id,
        s.level + 1
    FROM
        employees_hierarchy e
    INNER JOIN
        subordinates s ON e.manager_id = s.employee_id
)
SELECT
    employee_id,
    employee_name,
    manager_id,
    level
FROM
    subordinates
ORDER BY
    level, employee_id;

結果:

employee_idemployee_namemanager_idlevel
2部長A (CEO直下)10
4課長A1 (部長A配下)21
5課長A2 (部長A配下)21
7社員A1-1 (課長A1配下)42
8社員A1-2 (課長A1配下)42

subordinates という再帰CTEを定義し、まず employee_id = 2 の「部長A」を初期セット(level 0)として取得します。次に、その部下(manager_id が部長AのIDである従業員)を level 1 として取得し、さらにその部下を level 2 として取得…という処理を、部下が見つからなくなるまで繰り返します。

これを使わない場合の面倒くささ:

  • 固定回数の自己結合: もし階層の深さが固定されているなら、その回数だけ自己結合を繰り返すことで表現できますが、階層の深さが可変であったり、非常に深かったりする場合には対応できません。SQLも非常に長くなります。
-- 例えば3階層までなら... (非常に冗長で柔軟性がない)
SELECT e1.employee_name as level0_name, e2.employee_name as level1_name, e3.employee_name as level2_name
FROM employees_hierarchy e1
LEFT JOIN employees_hierarchy e2 ON e1.employee_id = e2.manager_id
LEFT JOIN employees_hierarchy e3 ON e2.employee_id = e3.manager_id
WHERE e1.employee_id = 2;

プロシージャやアプリケーションロジック: SQLだけで完結させようとすると非常に困難なため、ストアドプロシージャ内でループ処理を書いたり、アプリケーション側でデータを取得しながら再帰的に問い合わせを発行したりする必要が出てきます。これはパフォーマンス的にも不利であり、データベースの負荷も高めます。WITH RECURSIVE を使えば、これを単一のSQLクエリで効率的に処理できます。

ウィンドウ関数 OVER(PARTITION BY … ORDER BY … ROWS BETWEEN …):高度な集計・分析をSQLだけで完結

ウィンドウ関数は、現在の行に関連する行のセット(ウィンドウフレーム)に対して計算を行う関数です。GROUP BY による集約とは異なり、元の行のディテールを失うことなく、集計結果やランキングなどを各行に付与できます。

OVER()句の中には主に以下の3つの要素を指定できます。

  • PARTITION BY column_list: ウィンドウを分割する基準となる列を指定します。省略すると全体が一つのパーティションになります。
  • ORDER BY column_list: パーティション内での行の順序を定義します。多くのウィンドウ関数(特にランキング系や順序依存の集計系)で重要です。
  • ROWS BETWEEN frame_start AND frame_end (フレーム句): パーティション内で、現在の行に対して計算対象となる行の範囲(フレーム)を細かく指定します。
    • UNBOUNDED PRECEDING: フレームの開始がパーティションの最初の行
    • n PRECEDING: 現在の行からn行前
    • CURRENT ROW: 現在の行
    • n FOLLOWING: 現在の行からn行後
    • UNBOUNDED FOLLOWING: フレームの終了がパーティションの最後の行
    • デフォルトは RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (ORDER BY がある場合) または RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (ORDER BY がない場合)。RANGE と ROWS の違いにも注意が必要ですが、ここでは ROWS を中心に説明します。

ピッタリな場面:

  • ランキング: ROW_NUMBER(), RANK(), DENSE_RANK()
  • 移動平均: AVG(…) OVER (ORDER BY … ROWS BETWEEN N PRECEDING AND CURRENT ROW)
  • 累積合計: SUM(…) OVER (ORDER BY … ROWS UNBOUNDED PRECEDING) または SUM(…) OVER (ORDER BY … ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
  • LAG/LEAD: 前後の行の値を取得
  • FIRST_VALUE/LAST_VALUE/NTH_VALUE: フレーム内の最初/最後/N番目の値を取得

サンプルデータ準備

月別の売上データ (monthly_sales) を用意します。

WITH monthly_sales AS (
    SELECT * FROM (VALUES
        ('2024-01-01'::date, 1000),
        ('2024-02-01'::date, 1200),
        ('2024-03-01'::date, 1100),
        ('2024-04-01'::date, 1500),
        ('2024-05-01'::date, 1300),
        ('2024-06-01'::date, 1600)
    ) AS t (sale_month, amount)
)
-- この後で実際のクエリを記述

応用例: 各月の売上と、過去3ヶ月(当月含む)の移動平均売上を計算する

WITH monthly_sales AS (
    SELECT * FROM (VALUES
        ('2024-01-01'::date, 1000),
        ('2024-02-01'::date, 1200),
        ('2024-03-01'::date, 1100),
        ('2024-04-01'::date, 1500),
        ('2024-05-01'::date, 1300),
        ('2024-06-01'::date, 1600)
    ) AS t (sale_month, amount)
)
SELECT
    sale_month,
    amount,
    AVG(amount) OVER (
        ORDER BY sale_month
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 現在の行と、その前の2行 (合計3行)
    ) AS moving_avg_3months,
    SUM(amount) OVER (
        ORDER BY sale_month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- 最初から現在の行まで(累積合計)
    ) AS cumulative_sum
FROM
    monthly_sales
ORDER BY
    sale_month;

結果:

sale_monthamountmoving_avg_3monthscumulative_sum
2024-01-0110001000.001000
2024-02-0112001100.002200
2024-03-0111001100.003300
2024-04-0115001266.674800
2024-05-0113001300.006100
2024-06-0116001466.677700
  • moving_avg_3months:
    • 1月: (1000) / 1 = 1000
    • 2月: (1000 + 1200) / 2 = 1100
    • 3月: (1000 + 1200 + 1100) / 3 = 1100
    • 4月: (1200 + 1100 + 1500) / 3 = 1266.67…
  • cumulative_sum:
    • 1月: 1000
    • 2月: 1000 + 1200 = 2200
    • 3月: 1000 + 1200 + 1100 = 3300

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW により、各行に対して「その行自身とその直前2行」の範囲で平均を計算しています。最初の2行は先行する行が足りないため、利用可能な行だけで計算されます。

これを使わない場合(自己結合や相関サブクエリ):

移動平均や累積合計をウィンドウ関数なしで実現しようとすると、非常に複雑なSQLになります。

移動平均を相関サブクエリで書こうとした場合(非常に非効率で複雑):

WITH monthly_sales AS (
    SELECT *, ROW_NUMBER() OVER (ORDER BY sale_month) as rn -- 行番号を振っておく
    FROM (VALUES
        ('2024-01-01'::date, 1000),
        ('2024-02-01'::date, 1200),
        ('2024-03-01'::date, 1100),
        ('2024-04-01'::date, 1500),
        ('2024-05-01'::date, 1300),
        ('2024-06-01'::date, 1600)
    ) AS t (sale_month, amount)
)
SELECT
    m1.sale_month,
    m1.amount,
    (
        SELECT AVG(m2.amount)
        FROM monthly_sales m2
        WHERE m2.rn BETWEEN m1.rn - 2 AND m1.rn -- 行番号で範囲指定
          AND m2.rn <= m1.rn -- 先行する行のみ
    ) AS moving_avg_3months_subquery
FROM
    monthly_sales m1
ORDER BY
    m1.sale_month;

このサブクエリの例は、ROWS BETWEEN の挙動を単純化して模倣しようとしていますが、実際にはもっと複雑な条件分岐や、パーティションごとの処理を考慮すると、記述はさらに困難になります。パフォーマンスもウィンドウ関数に比べて大幅に劣ることがほとんどです。

ウィンドウ関数、特にフレーム句 (ROWS BETWEEN …) を使いこなすことで、複雑な時系列分析や行間の比較をSQLだけで、かつ効率的に行うことができます。

フレーム句 (ROWS BETWEEN …) の省略とその挙動

これまで ROWS BETWEEN … を明示的に指定してきましたが、このフレーム句は省略することも可能です。しかし、省略した場合のデフォルトの挙動を理解していないと、意図しない結果を招くことがあるため注意が必要です。

デフォルトのフレームは OVER() 句内に ORDER BY が指定されているかどうかで変わります。

  1. ORDER BY が指定されている場合:
    • デフォルトのフレームは RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW となります。
    • これは「パーティションの先頭から現在の行まで(現在の行と同じ順序キーの値を持つすべての行を含む)」という意味です。
    • 注意点(落とし穴):
      • ROWS ではなく RANGE がデフォルトである点に注意が必要です。ORDER BY で指定した列に同じ値が複数存在する場合、RANGE … CURRENT ROW はその同じ値を持つ行すべてをフレームに含みます。例えば、日付でソートしていて同じ日付のデータが複数ある場合、その日付のデータすべてが CURRENT ROW の範囲として扱われます。
      • 移動平均のように「現在の行を含めて過去N件」といった固定行数での計算を意図している場合、このデフォルトの挙動は期待と異なる結果(実質的な累積計算に近い動き)になることがあります。
      • 例: AVG(amount) OVER (ORDER BY sale_month) と書くと、AVG(amount) OVER (ORDER BY sale_month RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) と解釈されます。これは累積平均に近い動きとなり、3ヶ月移動平均のような結果にはなりません。
  2. ORDER BY が指定されていない場合:
    • デフォルトのフレームは ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (PostgreSQLの場合。標準SQLでは RANGE がデフォルトになることもあります) となります。
    • これは「パーティション内のすべての行」を意味します。
    • メリット: パーティション全体の合計や平均を各行に付与したい場合には簡潔に書けます(例: SUM(amount) OVER (PARTITION BY department_id))。
    • 注意点(落とし穴): ORDER BY を指定せずに集計関数を使うと、パーティション全体の集計値が全行に同じように表示されます。行ごとの順序に応じた変化を見たい場合には不適切です。

フレーム句省略のメリット:

  • 記述の簡潔化: 明示的に書くよりもSQLが短くなります。特にパーティション全体の集計(ORDER BYなし)や、単純な累積計算(ORDER BYありでデフォルト挙動が意図通り)の場合に有効です。

フレーム句省略のデメリットと落とし穴:

  • 意図しないデフォルト挙動:
    • 前述の通り、ORDER BY がある場合の RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW は、ROWS ベースの移動集計を期待している場合に誤解を生みやすいです。
    • ORDER BY 列に重複値がある場合の RANGE の挙動は直感的でない場合があります。例えば、同じ日付の売上が複数ある場合、CURRENT ROW はその日付の全ての行を含むため、行ごとの厳密な移動平均にはなりません。
  • 可読性の低下の可能性: フレーム句を省略すると、どのような範囲で集計が行われているのかが一見して分かりにくくなることがあります。特にチームで開発している場合や、後からクエリを見返す場合には、意図を明確にするために明示的に ROWS BETWEEN … や RANGE BETWEEN … を記述した方が良い場合が多いです。
  • ROWS と RANGE の混同:
    • ROWS は物理的な行数を基準にフレームを決定します。
    • RANGE は ORDER BY で指定された列の値を基準にフレームを決定します。同じ値を持つ行は同一とみなされ、フレームに含まれる範囲が変わります。
    • この違いを理解せずに省略形やデフォルト挙動に頼ると、特に ORDER BY 列に重複がある場合に予期せぬ結果に繋がります。

推奨されるプラクティス:

  • 意図を明確にするために、可能な限りフレーム句を明示的に記述する。
    • 特に移動平均や特定の範囲での集計を行いたい場合は、ROWS BETWEEN N PRECEDING AND CURRENT ROW や ROWS BETWEEN M PRECEDING AND N FOLLOWING のように具体的に指定しましょう。
  • 省略形を使う場合は、デフォルトの挙動 (RANGE なのか ROWS なのか、範囲はどこまでか) を正確に理解し、それが本当に意図した処理であるかを確認する。
  • ORDER BY 列に重複値が存在する可能性がある場合は、RANGE の挙動に特に注意し、必要であれば ROW_NUMBER() などで一意な順序を確保した上で ROWS を使うなどの工夫を検討する。

具体的な省略例と注意点:

WITH monthly_sales AS (
    SELECT * FROM (VALUES
        ('2024-01-01'::date, 1000),
        ('2024-02-01'::date, 1200),
        ('2024-03-01'::date, 1100),
        ('2024-03-01'::date, 900), -- 同じ日付のデータを追加
        ('2024-04-01'::date, 1500)
    ) AS t (sale_month, amount)
)
SELECT
    sale_month,
    amount,
    -- ORDER BY があるので RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW がデフォルト
    SUM(amount) OVER (ORDER BY sale_month) AS cumulative_sum_default_range,
    -- 明示的にROWSで累積合計 (こちらの方が直感的で安全な場合が多い)
    SUM(amount) OVER (ORDER BY sale_month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_sum_rows,
    -- ORDER BY があるので RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW がデフォルト
    AVG(amount) OVER (ORDER BY sale_month) AS avg_default_range,
    -- 3ヶ月移動平均 (直近3行。同じ日付は別行としてカウント)
    AVG(amount) OVER (ORDER BY sale_month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3rows
FROM
    monthly_sales
ORDER BY
    sale_month;

結果の比較 (一部抜粋):

sale_monthamountcumulative_sum_default_rangecumulative_sum_rowsavg_default_rangemoving_avg_3rows
2024-01-011000100010001000.001000.00
2024-02-011200220022001100.001100.00
2024-03-0111004200 (2200+1100+900)3300 (2200+1100)1050.00 ((1000+1200+1100+900)/4)1100.00 ((1000+1200+1100)/3)
2024-03-019004200 (同上、3月全体)4200 (3300+900)1050.00 (同上)1066.67 ((1200+1100+900)/3)
2024-04-011500570057001140.00 ((4200+1500)/5)1166.67 ((1100+900+1500)/3)

この例の cumulative_sum_default_range と avg_default_range の ‘2024-03-01′ の行を見てください。ORDER BY sale_month のデフォルト (RANGE … CURRENT ROW) では、’2024-03-01’ の値を持つ行 (1100と900) が両方とも CURRENT ROW の範囲に含まれて計算されます。
一方、cumulative_sum_rows や moving_avg_3rows のように ROWS を明示的に使うと、物理的な行数で範囲が決まるため、より直感的な結果や意図した移動集計が得られやすくなります。

結論として、フレーム句の省略はコードを短くしますが、そのデフォルトの挙動、特に RANGE と ROWS の違いや ORDER BY の有無による変化を正確に把握しておく必要があります。迷ったら、あるいは誤解を招きたくない場合は、常にフレーム句を明示的に記述することをお勧めします。


まとめ

今回は、PostgreSQLで利用できる最新SQL標準の便利な構文として、

  • JOIN USING, NATURAL JOIN
  • JOIN LATERAL
  • WITH句 (CTE), WITH MATERIALIZED, WITH RECURSIVE
  • ウィンドウ関数 OVER(PARTITION BY … ORDER BY … ROWS BETWEEN …)

をご紹介しました。

これらの構文は、単にSQLの記述量を減らすだけでなく、

  • 可読性の向上: クエリの意図が明確になり、メンテナンスが容易になります。
  • パフォーマンスの改善: 特に JOIN LATERAL や WITH RECURSIVE、ウィンドウ関数は、従来の方法では複雑で非効率だった処理を効率的に実行できます。
  • 表現力の拡大: SQLだけで実現できるデータ操作の幅が大きく広がります。

最初は少しとっつきにくいかもしれませんが、実際に手を動かしてサンプルクエリを試していただくことで、その便利さを実感できるはずです。これらのモダンなSQL構文を積極的に活用し、日々のデータ活用の「面倒くさい」を「楽しい」に変えていきましょう!

(本記事の本文作成には、Geminiを使用しました。)

コメント

タイトルとURLをコピーしました