WITH:名前の付いた導出テーブル
前のレッスンは FROM の中の導出テーブルで終わりました。同じクエリを共通テーブル式で書き直したものがこれです。
1
WITH category_stats AS (
SELECT cat.name AS category,
COUNT(*) AS products,
ROUND(AVG(p.price), 2) AS average
FROM products p
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
)
SELECT *
FROM category_stats
WHERE products >= 10
ORDER BY average DESC;
結果 — 26 行のうち先頭 5 行
| category | products | average |
|---|---|---|
| Wearables | 11 | 1672.29 |
| Programming | 10 | 1585.67 |
| Tablets | 17 | 1529.45 |
| Audio | 11 | 1482.89 |
| Kitchen | 12 | 1443.21 |
ブラウザの別タブで Planche が開き、サンプルデータベースを読み込んだ状態でこのクエリが入ります。このページの例はどれも同じタブを使い回します。F9 を押すまで実行はされません。
結果は同じで、今度は前から読めます。まず category_stats を定義し、それを使う。名前はこの1文の中だけで有効で、何も作られませんし、後片付けも要りません。
手順をいくつも並べる
CTEはつなげられます。それぞれが前のものを使えるので、入れ子のクエリが名前付きの手順の列に変わります。
2
WITH paid_orders AS (
SELECT id, customer_id, total
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
),
per_customer AS (
SELECT customer_id,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM paid_orders
GROUP BY customer_id
)
SELECT c.first_name || ' ' || c.last_name AS customer,
c.country,
pc.orders,
pc.revenue
FROM per_customer pc
JOIN customers c ON c.id = pc.customer_id
ORDER BY pc.revenue DESC
LIMIT 20;
結果 — 20 行のうち先頭 5 行
| customer | country | orders | revenue |
|---|---|---|---|
| Sofia Patel | BR | 10 | 70324.42 |
| Zara Novak | JP | 5 | 70149.53 |
| Elena Rossi | KR | 10 | 58867.23 |
| Ines Choi | CA | 6 | 58455.76 |
| Noah Costa | ES | 4 | 57721.52 |
名前の付いた手順が3つ。注文を絞り、顧客ごとにまとめ、顧客名を付ける。入れ子のサブクエリで書けば内側から読むことになりますが、こう書けば起きる順に並んだ手順の一覧です。クエリが追いにくくなってきたら、まずCTEに分けてみるのが定石です。
ウィンドウ関数:潰さずに集計する
GROUP BY は多くの行をひとつにします。ウィンドウ関数はひとまとまりの行をまたいで計算しつつ、行はすべて残します。
3
SELECT p.name,
p.price,
RANK() OVER (ORDER BY p.price DESC) AS price_rank,
DENSE_RANK() OVER (ORDER BY p.price DESC) AS dense_price_rank,
ROW_NUMBER() OVER (ORDER BY p.price DESC) AS row_no
FROM products p
ORDER BY p.price DESC
LIMIT 20;
結果 — 20 行のうち先頭 5 行
| name | price | price_rank | dense_price_rank | row_no |
|---|---|---|---|---|
| Glide GPS Watch Pro | 2484.4 | 1 | 1 | 1 |
| Terra 3D Puzzle | 2481.77 | 2 | 2 | 2 |
| Aria Castle Kit | 2479.41 | 3 | 3 | 3 |
| Aria LED Strip Pro | 2463.41 | 4 | 4 | 4 |
| Nova Brief Answers | 2445.49 | 5 | 5 | 5 |
OVER (ORDER BY p.price DESC) がウィンドウです。この計算がどの行を、どんな順序で見るかを決めます。同じ価格が並んだときの答えが、関数によって3通りに分かれます。
ROW_NUMBER()— 1, 2, 3, 4。必ず重複せず、同点は適当に決着します。RANK()— 1, 2, 2, 4。同点は同じ順位で、次の数字が飛びます。DENSE_RANK()— 1, 2, 2, 3。同点は同じ順位で、数字は飛びません。
どれを使うかは質問しだいです。「ちょうど10行ほしい」なら ROW_NUMBER、「2位は誰か」なら順位系のどちらかです。
グループごとの上位N件
ウィンドウ関数がなければ、同じカテゴリでより高い商品が何件あるかを数える相関サブクエリが要ります。PARTITION BY はグループごとに番号を振り直します。
4
WITH ranked AS (
SELECT cat.name AS category,
p.name AS product,
p.price,
ROW_NUMBER() OVER (PARTITION BY cat.id ORDER BY p.price DESC) AS rank_in_category
FROM products p
JOIN categories cat ON cat.id = p.category_id
)
SELECT category, product, price
FROM ranked
WHERE rank_in_category <= 3
ORDER BY category, rank_in_category;
結果 — 81 行のうち先頭 5 行
| category | product | price |
|---|---|---|
| Audio | Atlas Earbuds | 2334.28 |
| Audio | Kite Speaker | 2331.15 |
| Audio | Quill Speaker | 2177.84 |
| Bags | Pulse Crossbody Mini | 2377 |
| Bags | Zen Crossbody | 2295.35 |
累計
ウィンドウに ORDER BY を足すと、各行は自分自身までのすべてを見ます。
5
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
ROUND(SUM(revenue) OVER (ORDER BY month), 2) AS running_total
FROM monthly
ORDER BY month;
結果 — 12 行のうち先頭 5 行
| month | revenue | running_total |
|---|---|---|
| 2025-01 | 311707.73 | 311707.73 |
| 2025-02 | 350775.62 | 662483.35 |
| 2025-03 | 351184.96 | 1013668.31 |
| 2025-04 | 374465.94 | 1388134.25 |
| 2025-05 | 381541.09 | 1769675.34 |
CTEが月ごとに1行を作り、SUM(revenue) OVER (ORDER BY month) がそのリストを下に向かって足していきます。同じやり方で累計件数も、累積割合も、バーンダウンも書けます。ある行の答えがそれ以前の行に依存するものは、すべてこの形です。
ひとつ前の行と比べる
6
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_month,
ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change
FROM monthly
ORDER BY month;
結果 — 12 行のうち先頭 5 行
| month | revenue | previous_month | change |
|---|---|---|---|
| 2025-01 | 311707.73 | NULL | NULL |
| 2025-02 | 350775.62 | 311707.73 | 39067.89 |
| 2025-03 | 351184.96 | 350775.62 | 409.34 |
| 2025-04 | 374465.94 | 351184.96 | 23280.98 |
| 2025-05 | 381541.09 | 374465.94 | 7075.15 |
LAG(revenue) はウィンドウの中で1行前に手を伸ばし、LEAD は1行先に伸ばします。本来ならテーブルをずらして自分自身と結合しないと書けない前月比が、1行で済みます。最初の行の previous_month は NULL です。前がないのだから当然で、増減も NULL になります。誤解を招くゼロより、こちらが正直な答えです。
対応状況
ウィンドウ関数は標準機能で、出会う可能性のあるエンジンにはほぼ揃っています。PostgreSQL、SQL Server、Oracle は何年も前から、SQLite は 3.25(2018年)から、MySQL は 8.0(2018年)からです。MariaDB は 10.2 から使えます。まず出会うもので使えないのは MySQL 5.7 くらいです。
ここまでで書けるもの
読み取りは、必要な形にほぼ何でも整えられるようになりました。残る2つのレッスンは仕事のもう半分、つまりデータを安全に変えることと、遅い理由を突き止めることです。