【PL/SQL】分析関数(Analytic Function)を活用した集計ロジックの簡素化

【PL/SQL】分析関数(Analytic Function)を活用した集計ロジックの簡素化 PL/SQL

カーソル処理や一時表を多用して段階集計を作り込むよりも、分析関数を用いて「結果セットの各行に、同一パーティション内の集約・順位・前後関係を付与する」発想へ転換すると、SQL一発でロジックを簡素化できる。PL/SQLのループや状態変数を置き換え、読みやすさと実行効率を両立させるためには、PARTITION BYとORDER BY、そしてウィンドウフレーム(ROWS/RANGE)の三点を軸に設計するとよい。本稿では、典型的な業務要件を分析関数に落とし込む具体例を通じて、段階的集計、前後比較、トップN、重複排除、移動統計、欠損補完、パーセンタイルまでを実務視点で解説する。

累積集計と部分和で段階集計を置き換える

日次・月次の累積や小計・総計は、SUM OVERのパーティションと順序付けで一行に同居させる。従来の自己結合やPL/SQLカウンタは不要になり、明細と累積を同時に可視化できる。


-- 顧客別・購入日の累積金額と日次小計・総計を同時に出力
SELECT
  customer_id,
  purchase_dt,
  amount,
  SUM(amount) OVER (
    PARTITION BY customer_id
    ORDER BY purchase_dt
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS run_amount,
  SUM(amount) OVER (PARTITION BY customer_id, TRUNC(purchase_dt)) AS daily_sum,
  SUM(amount) OVER (PARTITION BY customer_id)                    AS cust_total
FROM app.sales
WHERE purchase_dt >= DATE '2025-01-01';

LAG/LEADで前回値との差分と変化点を検出する

前レコードの値比較や差分はLAG/LEADで一行に並べる。変化点検出や連番途切れの検知、日次増分の算出などがループなしで表現できる。


-- 口座残高の前回残高、増減、増減率、変化フラグ
SELECT
  acct_id,
  txn_dt,
  balance,
  LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt) AS prev_bal,
  balance - LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt) AS diff_bal,
  CASE
    WHEN LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt) IS NULL THEN 0
    WHEN LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt) = 0 THEN NULL
    ELSE (balance - LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt))
         / NULLIF(LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt),0)
  END AS diff_rate,
  CASE WHEN balance != LAG(balance) OVER (PARTITION BY acct_id ORDER BY txn_dt) THEN 1 ELSE 0 END AS changed
FROM app.ledger;

ROW_NUMBER/DENSE_RANKでトップNや最新行を抽出する

グループ内の上位N件や最新1件は、解析関数で順位を付与して外側でフィルタする。相関サブクエリよりも可読性が高く、最適化も受けやすい。


-- 顧客ごとの最新注文(購入日時の最大行)を取得
SELECT customer_id, order_id, order_ts, amount
FROM (
  SELECT
    o.*,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_ts DESC) AS rn
  FROM app.orders o
)
WHERE rn = 1;

-- 店舗ごとの売上トップ3商品
SELECT *
FROM (
  SELECT
    store_id, product_id, sales_amt,
    DENSE_RANK() OVER (PARTITION BY store_id ORDER BY sales_amt DESC) AS rnk
  FROM app.store_sales
)
WHERE rnk <= 3;

重複排除と代表行抽出をウィンドウ関数で明示する

ユニークキーが欠落した取り込みデータから代表行を一意に選ぶ場合も、順位付けで単純化できる。DELETEの代わりに対象を確定するSELECTをまず設計し、後段でMERGE/DELETEに接続する。


-- 同一顧客・同一日で複数行ある場合に金額が大きい行のみを代表にする
WITH ranked AS (
  SELECT
    t.*,
    ROW_NUMBER() OVER (PARTITION BY customer_id, TRUNC(purchase_dt) ORDER BY amount DESC, rowid) AS rn
  FROM app.stage_sales t
)
SELECT * FROM ranked WHERE rn = 1;

移動平均・移動合計・中心化ウィンドウの設計

期間ベースの移動統計はwindow frameで定義する。ROWSは物理行数、RANGEはソートキーの値範囲でウィンドウが決まるため、基準列の性質に応じて選択する。タイムスタンプのギャップが大きい実データでは、RANGE INTERVALを使うと自然な振る舞いになる。


-- 5行移動平均(行数基準)
SELECT
  product_id,
  sales_dt,
  sales,
  AVG(sales) OVER (
    PARTITION BY product_id
    ORDER BY sales_dt
    ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
  ) AS ma5_rows
FROM app.daily_sales;

-- 7日間の移動合計(日数基準)。同日複数行も自然に含む
SELECT
  product_id,
  sales_dt,
  sales,
  SUM(sales) OVER (
    PARTITION BY product_id
    ORDER BY sales_dt
    RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW
  ) AS sum7d
FROM app.daily_sales;

条件付き集計と比率を一度に付与する

WHEREで絞り込まずに明細を保ったまま条件付き集計や比率、構成比を同一行に並べると、レポートや検証用の中間結果をワンステップで得られる。


-- カテゴリ別の売上、小計、全体比率、カテゴリ内比率
SELECT
  category,
  product_id,
  sales_amt,
  SUM(sales_amt) OVER (PARTITION BY category)                        AS cat_sum,
  SUM(sales_amt) OVER ()                                             AS grand_sum,
  ROUND( 100 * sales_amt / NULLIF(SUM(sales_amt) OVER (),0), 2)      AS pct_of_total,
  ROUND( 100 * sales_amt / NULLIF(SUM(sales_amt) OVER (PARTITION BY category),0), 2) AS pct_in_cat,
  SUM(CASE WHEN sales_amt >= 1000 THEN sales_amt ELSE 0 END)
    OVER (PARTITION BY category)                                     AS cat_sum_ge1k
FROM app.prod_sales;

ギャップ&アイランド問題を分析関数で解く

連続する期間を島としてまとめる典型問題は、LAGと「開始点フラグ」の累積和で連番を振ると容易に解ける。PL/SQLの走査・状態遷移は不要になる。


-- 連続在庫(連日在庫あり)を島としてまとめ、島ごとの開始・終了と日数を算出
WITH base AS (
  SELECT
    item_id,
    inv_dt,
    qty,
    CASE
      WHEN LAG(inv_dt) OVER (PARTITION BY item_id ORDER BY inv_dt) = inv_dt - 1 THEN 0
      ELSE 1
    END AS is_start
  FROM app.inventory_daily
  WHERE qty > 0
),
grp AS (
  SELECT
    item_id,
    inv_dt,
    qty,
    SUM(is_start) OVER (PARTITION BY item_id ORDER BY inv_dt) AS grp_id
  FROM base
)
SELECT
  item_id,
  MIN(inv_dt) AS start_dt,
  MAX(inv_dt) AS end_dt,
  COUNT(*)    AS days,
  SUM(qty)    AS total_qty
FROM grp
GROUP BY item_id, grp_id;

欠損の前方補完や直近値参照を窓関数で表現する

センサ値の欠損を直近の実測値で補う前方補完は、LAST_VALUEにIGNORE NULLSを併用すると純SQLで安定して実装できる。


-- 時系列のNULLを直前の非NULL値で埋める前方補完
SELECT
  device_id,
  ts,
  reading,
  LAST_VALUE(reading IGNORE NULLS)
    OVER (PARTITION BY device_id ORDER BY ts
          ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS reading_ffill
FROM app.sensor_readings;

パーセンタイル・順位統計で外れ値やSLAを診断する

遅延時間や応答時間のSLA検証には、PERCENTILE_CONTやPERCENT_RANKを用いると閾値可視化が容易になる。分析バージョンを使えば、明細と同時に分位点を行へ投影できる。


-- サービス別の遅延分布。行と同じパーティションでp95を同居
SELECT
  service,
  latency_ms,
  PERCENT_RANK()  OVER (PARTITION BY service ORDER BY latency_ms) AS pr,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms)
    OVER (PARTITION BY service) AS p95
FROM app.request_latency
WHERE req_ts >= SYSTIMESTAMP - INTERVAL '7' DAY;

文字列集約・キー内ソートを分析版LISTAGGで同居させる

ユニークなキー内で時系列順に項目を並べた説明列を付与したい場合、LISTAGGの分析版を用いると、明細を保ったまま説明列を持たせられる。


-- 注文明細の品目一覧を注文行すべてに同居させる
SELECT
  order_id,
  line_no,
  item_name,
  LISTAGG(item_name, ',') WITHIN GROUP (ORDER BY line_no)
    OVER (PARTITION BY order_id) AS items_summary
FROM app.order_lines;

設計と性能の要点:パーティション鍵・順序列・フレームの整合

分析関数は「どの行を仲間とみなすか」「どの順序で時間軸を走らせるか」「どこからどこまでを窓とするか」の三点が一致して初めて意図通りに機能する。パーティション鍵は結合列と同じ選択性で設計し、必要ならパーティションキーとORDER BY列に複合索引を用意する。ROWSは行数で厳密に制御でき、RANGEは同値や多重レコードを自然に含むため、要件に応じて使い分ける。ウィンドウ関数はもとのSELECTに溶け込むため、PL/SQLへデータを持ち上げるよりもコンテキストスイッチが少なく、I/OとCPUの両面で有利である。複数の分析式を併用する際は、同一のPARTITION BY/ORDER BYを共有できるように式変形をそろえると、ソートの再利用で実行計画が安定する。

PL/SQLループから分析関数への書き換えテンプレート

売上明細に対する顧客別の累積金額、前回差分、カテゴリ内順位を一度に付与し、フィルタ条件で「累積が一定額を超えた最初の行」を抽出する例を示す。従来はループと状態変数、暫定表が必要だった処理をSQLで完結できる。


WITH enriched AS (
  SELECT
    s.*,
    SUM(amount) OVER (PARTITION BY customer_id ORDER BY sales_dt
                      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_amt,
    amount - LAG(amount) OVER (PARTITION BY customer_id ORDER BY sales_dt) AS diff_amt,
    DENSE_RANK() OVER (PARTITION BY customer_id, category ORDER BY amount DESC) AS rnk_in_cat
  FROM app.sales s
)
SELECT *
FROM (
  SELECT
    e.*,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY sales_dt) AS rn
  FROM enriched e
  WHERE run_amt >= 100000
)
WHERE rn = 1;

まとめ

分析関数は、明細を失わずに「その行が属する集合の性質」を同居させる表現力を提供し、PL/SQLの逐次処理や一時表の多用を大幅に削減する。PARTITION BYで集合を定義し、ORDER BYで時間や優先を与え、ROWS/RANGEで窓の広さを制御するという三点を設計の基準に据えれば、累積や前後比較、トップN、重複排除、移動統計、欠損補完、パーセンタイルまでが一貫した文法で記述できる。実装ではインデックスとフレームの整合、同一ソートの再利用、そしてSQL一発化の方針を守ることで、読みやすさと性能の両立が現場の標準として定着する。