【Oracle】WITH句(共通表式)の使い方完全ガイド|サブクエリの可読性改善・再帰CTE・パフォーマンス制御まで解説

【Oracle】WITH句(共通表式)の使い方完全ガイド|サブクエリの可読性改善・再帰CTE・パフォーマンス制御まで解説 Oracle

SQLを書いていると、同じサブクエリを複数箇所で使い回したり、ネストが深くなって読めなくなることがあります。そんなときに役立つのがWITH句(共通表式 / CTE: Common Table Expression)です。本記事では、OracleにおけるWITH句の基本から、再帰CTE・パフォーマンス制御・実務パターンまで体系的に解説します。

この記事でわかること

  • WITH句の基本構文と使いどころ
  • 複数のCTEを連鎖して定義する方法
  • サブクエリとWITH句の使い分け
  • 再帰CTE(組織図・連番生成)の実装方法(Oracle 11g R2以降)
  • MATERIALIZEとINLINEヒントによるパフォーマンス制御
  • 累計計算・ランキングなど実務パターン
スポンサーリンク

WITH句とは

WITH句は、SELECT文の先頭に一時的な名前付きサブクエリ(共通表式)を定義できる構文です。定義したCTEはメインのSELECT文の中でテーブルと同じように参照できます。

WITH句を使う主なメリットは次の3点です。

メリット 説明
可読性の向上 深くネストしたサブクエリを分かりやすく分解できる
再利用性 同じサブクエリを1回定義して複数箇所から参照できる
再帰クエリ 自己参照を使った階層データの取得が書ける(11g R2以降)

OracleではOracle 9i以降でWITH句(非再帰)が使え、Oracle 11g Release 2以降で再帰CTEが利用可能になりました。

基本構文

WITH句の基本構文
WITH cte_name AS (
    -- ここにサブクエリを書く
    SELECT col1, col2
    FROM   some_table
    WHERE  条件
)
SELECT *
FROM   cte_name;   -- CTEをテーブルのように参照する

WITH句のあとにCTE名と定義を書き、メインのSELECT文でそのCTE名を参照します。CTEはSELECT文が完了するまでのみ有効であり、次のSQL文では参照できません(一時テーブルとは異なります)。

具体例:サブクエリとの比較

部署ごとの平均給与を算出し、全社平均より高い部署だけを抽出するSQLを例に、サブクエリ版とWITH句版を比較します。

サブクエリ版(ネストが深くて読みにくい)
SELECT dept_id, avg_salary
FROM (
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM   employees
    GROUP BY dept_id
) dept_avg
WHERE avg_salary > (
    SELECT AVG(salary)
    FROM   employees
);
WITH句版(意図が明確で読みやすい)
WITH
dept_avg AS (
    -- 部署別平均給与
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM   employees
    GROUP BY dept_id
),
company_avg AS (
    -- 全社平均給与
    SELECT AVG(salary) AS avg_salary
    FROM   employees
)
SELECT d.dept_id, d.avg_salary
FROM   dept_avg d, company_avg c
WHERE  d.avg_salary > c.avg_salary
ORDER BY d.avg_salary DESC;

WITH句版はCTEごとに意図を名前で表現できるため、SQLを読んでいない人でもロジックを追いやすくなります。

複数のCTEを連鎖して定義する

WITH句では複数のCTEをカンマ区切りで列挙し、後に定義したCTEで前のCTEを参照することができます。

複数CTE ── 前のCTEを後のCTEで参照する
WITH
-- ステップ1: 注文金額を集計する
order_summary AS (
    SELECT
        customer_id,
        COUNT(*)       AS order_count,
        SUM(amount)    AS total_amount
    FROM   orders
    WHERE  order_date >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -12)
    GROUP BY customer_id
),
-- ステップ2: 顧客情報と結合してランク付け
ranked_customers AS (
    SELECT
        c.customer_name,
        o.order_count,
        o.total_amount,
        RANK() OVER (ORDER BY o.total_amount DESC) AS sales_rank
    FROM   order_summary o
    JOIN   customers c ON o.customer_id = c.customer_id
)
-- ステップ3: 上位10社だけを抽出
SELECT customer_name, order_count, total_amount, sales_rank
FROM   ranked_customers
WHERE  sales_rank <= 10;

各CTEに名前をつけることでSQLがドキュメントのように読めます。RANK関数の詳しい使い方は【Oracle】RANK関数でデータの順位を付ける方法も参照してください。

WITH句を使ったデータ更新・削除

Oracle 12c以降では、UPDATE文やDELETE文の中でもWITH句を使えます(DML with CTE)。ただし、使用頻度が高いのはSELECT文内での使用です。

DML with CTE ── CTEを使ったUPDATE(12c以降)
-- 12c以降: WITH句をUPDATEでも使える
WITH obsolete_products AS (
    SELECT product_id
    FROM   products
    WHERE  last_sold_date < ADD_MONTHS(SYSDATE, -24)
      AND  stock_qty = 0
)
UPDATE products
SET    status = 'DISCONTINUED'
WHERE  product_id IN (SELECT product_id FROM obsolete_products);

再帰CTE(Oracle 11g R2以降)

再帰CTEはCTE自身を参照する特殊な形式で、階層データの取得や連番生成に活用できます。Oracle 11g Release 2以降で使用可能です。

構文は「アンカーメンバー(基底部)」と「再帰メンバー」をUNION ALLで連結する形式です。

再帰CTEの基本構文
WITH cte_name (col1, col2, ...) AS (
    -- アンカーメンバー(再帰の起点)
    SELECT ...
    FROM   ...
    WHERE  ...      -- 最上位データを取得

    UNION ALL

    -- 再帰メンバー(自身を参照して繰り返す)
    SELECT ...
    FROM   some_table t
    JOIN   cte_name c ON t.parent_id = c.col1
)
SELECT * FROM cte_name;

活用例1:組織図(階層データ)の展開

上司・部下の関係を持つ従業員テーブルから、特定の部門の全階層を一度に取得する例です。

再帰CTE ── 組織図の全階層展開
WITH org_tree (emp_id, emp_name, mgr_id, dept_name, level_num, path) AS (
    -- アンカー:最上位(manager_idがNULL)の従業員
    SELECT
        emp_id,
        emp_name,
        manager_id,
        dept_name,
        1               AS level_num,
        emp_name        AS path
    FROM   employees
    WHERE  manager_id IS NULL

    UNION ALL

    -- 再帰:一つ下の階層を結合
    SELECT
        e.emp_id,
        e.emp_name,
        e.manager_id,
        e.dept_name,
        t.level_num + 1,
        t.path || ' > ' || e.emp_name
    FROM   employees e
    JOIN   org_tree  t ON e.manager_id = t.emp_id
)
SELECT
    LPAD(' ', (level_num - 1) * 4) || emp_name AS indented_name,
    level_num,
    dept_name,
    path
FROM   org_tree
ORDER BY path;

LPADでインデントを付けることで、コンソール上でも組織図のような見た目で出力できます。

CONNECT BY との比較
Oracleには以前から START WITH ... CONNECT BY PRIOR という独自の階層問い合わせ構文があります。再帰CTEとの主な違いは次の通りです。

  • CONNECT BY:Oracleの独自構文。シンプルな階層取得ではコードが短い。LEVEL擬似列・SYS_CONNECT_BY_PATH関数が使える
  • 再帰CTE:標準SQL(SQL:1999)に準拠。他DBへの移植性が高い。複雑な再帰ロジックが書きやすい

新規開発では移植性の高い再帰CTEを選ぶ場面が増えています。

活用例2:連番の生成

1から指定した数までの連番を動的に生成したい場合も再帰CTEが便利です。

再帰CTE ── 1〜30の連番生成
WITH nums (n) AS (
    SELECT 1 FROM dual          -- アンカー:最初の値
    UNION ALL
    SELECT n + 1 FROM nums      -- 再帰:1ずつ増やす
    WHERE  n < 30               -- 終了条件
)
SELECT n FROM nums;

再帰の深さ上限について

再帰CTEはデフォルトで最大1000回まで再帰します。無限再帰を防ぐため、終了条件(WHERE句)を必ず書いてください。上限を変えたい場合は CYCLE 句を使います。

CYCLE句 ── 循環データを検出して無限再帰を防ぐ
WITH org_tree (emp_id, emp_name, mgr_id, level_num) AS (
    SELECT emp_id, emp_name, manager_id, 1
    FROM   employees
    WHERE  manager_id IS NULL
    UNION ALL
    SELECT e.emp_id, e.emp_name, e.manager_id, t.level_num + 1
    FROM   employees e
    JOIN   org_tree  t ON e.manager_id = t.emp_id
)
CYCLE emp_id SET is_cycle TO '1' DEFAULT '0'   -- emp_idが循環したらis_cycle='1'
SELECT emp_id, emp_name, level_num, is_cycle
FROM   org_tree
WHERE  is_cycle = '0';    -- 循環していない行だけ取得

WITH句のパフォーマンス制御

OracleはWITH句をデフォルトでインライン展開(各参照箇所でサブクエリを展開)します。CTEを複数箇所で参照する場合、オプティマイザが同じクエリを複数回実行することがあります。この動作を制御するために、MATERIALIZEヒントINLINEヒントを使います。

MATERIALIZEヒント ── CTEを一時領域に物化して再利用
WITH expensive_cte AS (
    /*+ MATERIALIZE */                -- 一度だけ実行して一時領域に保存
    SELECT dept_id, SUM(salary) AS total_sal
    FROM   employees
    GROUP BY dept_id
)
-- expensive_cteを2回参照しても実行は1回
SELECT e.dept_id, e.total_sal
FROM   expensive_cte e
WHERE  e.total_sal > 500000
UNION ALL
SELECT e.dept_id, e.total_sal
FROM   expensive_cte e
WHERE  e.total_sal <= 500000;
INLINEヒント ── CTEをインライン展開(デフォルト動作を明示)
WITH simple_filter AS (
    /*+ INLINE */          -- 参照箇所ごとにインライン展開(物化しない)
    SELECT * FROM products WHERE status = 'ACTIVE'
)
SELECT * FROM simple_filter WHERE price > 1000;
ヒント 動作 適したケース
MATERIALIZE 一時領域にCTEの結果を保存。複数参照でも1回実行 コストの高いCTEを複数回参照するとき
INLINE CTEをサブクエリとして展開。オプティマイザに統計を渡せる CTEが1回しか参照されない・小さい結果セットのとき
MATERIALIZEはTEMP表領域を消費する
MATERIALIZEを使うとCTEの結果がTEMP表領域(一時表領域)に書き込まれます。大量データを扱う場合はTEMP表領域の容量に注意してください。TEMP表領域の管理方法については【Oracle】TEMP表領域の肥大化の原因とクリーンアップ方法完全ガイドを参照してください。

実務でよく使うパターン

パターン1:累計計算

月次売上の累計をWITH句と分析関数を組み合わせて計算します。

累計売上の計算
WITH monthly_sales AS (
    SELECT
        TO_CHAR(order_date, 'YYYY-MM')  AS month,
        SUM(amount)                      AS monthly_amount
    FROM   orders
    WHERE  order_date >= TRUNC(SYSDATE, 'YYYY')   -- 今年のデータ
    GROUP BY TO_CHAR(order_date, 'YYYY-MM')
)
SELECT
    month,
    monthly_amount,
    SUM(monthly_amount) OVER (ORDER BY month
                               ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
        AS cumulative_amount    -- 累計売上
FROM   monthly_sales
ORDER BY month;

パターン2:中間テーブルが不要なデータクレンジング

複数ステップの変換処理をCTEで段階的に表現することで、一時テーブルなしにデータクレンジングができます。

CTEによる段階的なデータクレンジング
WITH
-- ステップ1: 重複レコードを除外
deduped AS (
    SELECT DISTINCT
        customer_id,
        TRIM(customer_name)  AS customer_name,   -- 前後の空白を除去
        email,
        phone
    FROM   raw_customers
),
-- ステップ2: メールアドレスの形式チェック
validated AS (
    SELECT
        customer_id,
        customer_name,
        CASE
            WHEN REGEXP_LIKE(email, '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
            THEN email
            ELSE NULL
        END  AS email,      -- 不正な形式はNULLに置き換え
        phone
    FROM deduped
),
-- ステップ3: 電話番号から数字以外を除去
cleaned AS (
    SELECT
        customer_id,
        customer_name,
        email,
        REGEXP_REPLACE(phone, '[^0-9]', '') AS phone  -- ハイフン等を除去
    FROM validated
    WHERE customer_name IS NOT NULL
)
-- 最終結果
SELECT * FROM cleaned;

パターン3:集計値の差分・比率を計算する

前月比や前年同月比の計算では、同じ集計を2回参照するためWITH句が効果的です。

前月比・前年同月比の計算
WITH monthly AS (
    SELECT
        TO_CHAR(order_date, 'YYYYMM')   AS ym,
        SUM(amount)                      AS sales
    FROM   orders
    GROUP BY TO_CHAR(order_date, 'YYYYMM')
)
SELECT
    cur.ym,
    cur.sales                                    AS current_sales,
    prev.sales                                   AS prev_month_sales,
    ROUND((cur.sales - prev.sales) / NULLIF(prev.sales, 0) * 100, 1)
        AS mom_growth_pct,   -- 前月比(%)
    ly.sales                                     AS last_year_sales,
    ROUND((cur.sales - ly.sales) / NULLIF(ly.sales, 0) * 100, 1)
        AS yoy_growth_pct    -- 前年同月比(%)
FROM
    monthly cur
    LEFT JOIN monthly prev
        ON prev.ym = TO_CHAR(ADD_MONTHS(TO_DATE(cur.ym, 'YYYYMM'), -1), 'YYYYMM')
    LEFT JOIN monthly ly
        ON ly.ym   = TO_CHAR(ADD_MONTHS(TO_DATE(cur.ym, 'YYYYMM'), -12), 'YYYYMM')
ORDER BY cur.ym;

パターン4:上位N件の取得をCTEで整理する

部署別上位3件の給与を取得
WITH dept_ranking AS (
    SELECT
        dept_id,
        emp_name,
        salary,
        RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk
    FROM employees
)
SELECT dept_id, emp_name, salary, rk
FROM   dept_ranking
WHERE  rk <= 3           -- 部署ごと上位3名
ORDER BY dept_id, rk;

CASE文を使った条件分岐との組み合わせ方は【Oracle】CASE文とDECODE関数の違いと使い分けも参考になります。

注意点・制限事項

制限・注意点 説明
スコープ CTEは定義したSELECT文の中でのみ有効。次のSQL文では使えない
更新不可 CTEに対してINSERT/UPDATE/DELETEは直接行えない(ベーステーブルに対して行う)
DML制約 UPDATE/DELETE内でのCTE使用はOracle 12c以降
再帰の上限 デフォルトで最大1000回。終了条件を必ず書く
バージョン 非再帰CTE: Oracle 9i以降。再帰CTE: Oracle 11g R2以降

WITH句 vs サブクエリ vs 一時テーブル

どの方法を使うべきか迷った場合の目安です。

方法 適したケース 注意点
WITH句(CTE) 可読性重視・複数箇所で同じ中間結果を参照・再帰クエリ SQL文の中でのみ有効
インラインビュー(サブクエリ) 1回だけ使う単純なフィルタ・最適化ヒントをオプティマイザに委ねたい ネストが深くなると可読性低下
一時テーブル(GLOBAL TEMPORARY TABLE) 複数のSQL文を跨いで中間結果を使い回す・PL/SQLとの連携 テーブル定義が必要・セッション管理が要る

まとめ

OracleのWITH句(CTE)は、複雑なSQLをステップに分解して可読性を高める強力な道具です。

  • 基本形はWITH句でCTEを定義し、SELECT文からテーブルのように参照する
  • 複数CTEはカンマ区切りで連鎖定義でき、後のCTEで前のCTEを参照できる
  • 再帰CTE(11g R2以降)は階層データの展開・連番生成に使う。終了条件を忘れずに
  • MATERIALIZEヒントはコストの高いCTEを複数回参照するときに実行回数を減らせる
  • 累計・前月比・ランキングなどの実務パターンでWITH句は特に威力を発揮する

OracleのSQLチューニング全般については【Oracle】SQLの実行計画を確認する方法|EXPLAIN PLANとAUTOTRACEの違い、HAVING句を使った集計条件については【Oracle】HAVING句でグループ化されたデータに条件を適用する方法も合わせて参照してください。