SQLでデータを集計していて、こんな困った経験はありませんか?
- 「顧客ごとの最初の注文だけを取得したい」
- 「カテゴリーごとの最新記事だけを表示したい」
- 「ユーザーごとの最高スコアを1件だけ取りたい」
実は、GROUP BYを使っただけでは「各グループの最初の1件」を正しく取得することはできません。でも正しい方法を知れば、この問題は確実に解決できます。
この記事では、SQLで「グループごとの最初の1件」を取得する方法を、データベースの種類別に詳しく解説します。
なぜGROUP BYだけでは「最初の1件」が取れないの?
よくある間違いパターン
多くの人がやってしまう間違った書き方を見てみましょう。
間違った例1: GROUP BY + LIMIT
-- これは間違い!
SELECT * FROM orders
GROUP BY customer_id
LIMIT 1;
何が問題?
- 全体で1件しか取得されない
- 各顧客の最初の注文ではなく、全顧客の中から1件だけ
間違った例2: GROUP BYでの全列選択
-- これも問題あり!
SELECT * FROM orders
GROUP BY customer_id;
何が問題?
- どのレコードが選ばれるかが不確定
- データベースによって結果が変わる可能性
- MySQLでは警告が出ることもある
GROUP BYの基本的な仕組み
GROUP BYが本来やること
- 指定した列の値が同じレコードをグループ化
- 各グループに対して集計関数(COUNT、SUM、AVGなど)を適用
- 結果は「グループあたり1行」
なぜ「最初の1件」が取れないの?
- GROUP BYは集計のための機能
- 「最初」や「最新」という概念がない
- 特定の条件で1件を選ぶ機能ではない
解決方法1: サブクエリ + MIN/MAX(最も確実)
基本的な考え方
「各グループの最小値(または最大値)を先に求めて、その値に合致するレコードを取得する」という2段階のアプローチです。
実装例
例: 顧客ごとの最初の注文を取得
SELECT o.*
FROM orders o
INNER JOIN (
SELECT customer_id, MIN(order_date) AS earliest_date
FROM orders
GROUP BY customer_id
) AS first_orders
ON o.customer_id = first_orders.customer_id
AND o.order_date = first_orders.earliest_date;
ステップ解説
- サブクエリ: 顧客ごとの最早の注文日を求める
- INNER JOIN: 元のテーブルとサブクエリの結果を結合
- 条件: 顧客IDと日付の両方が一致するレコードを取得
より複雑な例
例: 部署ごとの最高給与の社員情報
SELECT e.*
FROM employees e
INNER JOIN (
SELECT department, MAX(salary) AS max_salary
FROM employees
GROUP BY department
) AS top_salaries
ON e.department = top_salaries.department
AND e.salary = top_salaries.max_salary;
この方法のメリット・デメリット
メリット
- ほぼ全てのデータベースで動作する
- 理解しやすい
- 確実に動作する
デメリット
- 同じ値(日付、金額など)のレコードが複数ある場合、複数件取得される
- サブクエリの実行コストがかかる場合がある
解決方法2: ウィンドウ関数(より柔軟)
ウィンドウ関数とは?
ウィンドウ関数は、グループ内での順序付けや順位付けができる強力な機能です。
対応データベース
- MySQL 8.0以降
- PostgreSQL 8.4以降
- SQL Server 2005以降
- SQLite 3.25以降
- Oracle 8i以降
ROW_NUMBER()を使った方法
基本構文
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY グループ化する列
ORDER BY 順序付けする列
) AS row_num
FROM テーブル名
) AS numbered
WHERE row_num = 1;
実装例: 顧客ごとの最初の注文
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS row_num
FROM orders
) AS numbered_orders
WHERE row_num = 1;
他のウィンドウ関数の活用
RANK()を使った方法
-- 同じ値があっても同順位として扱う
SELECT *
FROM (
SELECT *,
RANK() OVER (
PARTITION BY category
ORDER BY created_at DESC
) AS rank_num
FROM posts
) AS ranked_posts
WHERE rank_num = 1;
DENSE_RANK()を使った方法
-- 順位に欠番がない連続した順位
SELECT *
FROM (
SELECT *,
DENSE_RANK() OVER (
PARTITION BY user_id
ORDER BY score DESC
) AS dense_rank_num
FROM game_results
) AS ranked_results
WHERE dense_rank_num = 1;
ウィンドウ関数の使い分け
関数 | 特徴 | 使用場面 |
---|---|---|
ROW_NUMBER() | 必ず1件だけ取得 | 確実に1件だけ欲しい場合 |
RANK() | 同値は同順位(欠番あり) | 同点1位を全て取得したい場合 |
DENSE_RANK() | 同値は同順位(欠番なし) | 順位に連続性が必要な場合 |
データベース別の実装方法
MySQL
MySQL 8.0以降(ウィンドウ関数対応)
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
) AS t
WHERE rn = 1;
MySQL 5.7以前(サブクエリ使用)
SELECT o1.*
FROM orders o1
WHERE o1.order_date = (
SELECT MIN(o2.order_date)
FROM orders o2
WHERE o2.customer_id = o1.customer_id
);
PostgreSQL
ウィンドウ関数(推奨)
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
) t
WHERE rn = 1;
DISTINCT ON(PostgreSQL独自機能)
SELECT DISTINCT ON (customer_id) *
FROM orders
ORDER BY customer_id, order_date;
SQLite
SQLite 3.25以降
-- ウィンドウ関数が使える
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
)
WHERE rn = 1;
SQLite 3.25未満
-- サブクエリを使用
SELECT *
FROM orders o1
WHERE NOT EXISTS (
SELECT 1
FROM orders o2
WHERE o2.customer_id = o1.customer_id
AND o2.order_date < o1.order_date
);
SQL Server
ウィンドウ関数
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
) AS numbered
WHERE rn = 1;
TOP句を使った方法
SELECT *
FROM (
SELECT TOP 1 WITH TIES *
FROM orders
ORDER BY ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
)
) AS result;
実用的な応用例
例1: ECサイトの顧客分析
要件: 各顧客の初回購入商品を分析したい
-- 顧客ごとの初回購入情報を取得
SELECT
customer_id,
product_name,
order_date,
amount
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS purchase_order
FROM orders o
JOIN products p ON o.product_id = p.product_id
) AS first_purchases
WHERE purchase_order = 1;
例2: ブログサイトの記事管理
要件: カテゴリーごとの最新記事を表示
-- カテゴリー別最新記事の取得
SELECT
category_name,
title,
author,
published_at,
view_count
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY category_id
ORDER BY published_at DESC
) AS recency_rank
FROM articles a
JOIN categories c ON a.category_id = c.category_id
WHERE status = 'published'
) AS latest_articles
WHERE recency_rank = 1;
例3: 人事システムでの昇格履歴
要件: 部署ごとの最新昇格者を確認
-- 部署別最新昇格者の取得
SELECT
department_name,
employee_name,
new_position,
promotion_date,
salary_change
FROM (
SELECT
d.name AS department_name,
e.name AS employee_name,
p.new_position,
p.promotion_date,
p.salary_change,
ROW_NUMBER() OVER (
PARTITION BY e.department_id
ORDER BY p.promotion_date DESC
) AS promotion_order
FROM promotions p
JOIN employees e ON p.employee_id = e.employee_id
JOIN departments d ON e.department_id = d.department_id
) AS recent_promotions
WHERE promotion_order = 1;
パフォーマンス最適化のコツ
インデックスの効果的な利用
推奨されるインデックス
-- PARTITION BYとORDER BYの列に複合インデックス
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, order_date);
-- サブクエリ方式の場合
CREATE INDEX idx_orders_customer
ON orders (customer_id);
CREATE INDEX idx_orders_date
ON orders (order_date);
クエリ最適化のポイント
1. 適切な方式の選択
- 小〜中規模データ: サブクエリ方式でも十分
- 大規模データ: ウィンドウ関数の方が効率的
- 古いデータベース: サブクエリ方式を使用
2. 条件の事前絞り込み
-- 良い例: 先に条件で絞り込む
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
WHERE order_date >= '2023-01-01' -- 先に絞り込み
) AS t
WHERE rn = 1;
3. 必要な列のみ選択
-- 効率的: 必要な列だけ選択
SELECT customer_id, order_date, amount
FROM (
SELECT customer_id, order_date, amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM orders
) AS t
WHERE rn = 1;
よくある問題とその解決法
問題1: 同じ値のレコードが複数件取得される
症状
-- 同じ日付の注文が複数あると、複数件取得される
SELECT o.*
FROM orders o
INNER JOIN (
SELECT customer_id, MIN(order_date) AS min_date
FROM orders
GROUP BY customer_id
) sub ON o.customer_id = sub.customer_id
AND o.order_date = sub.min_date;
解決法: 追加の条件で一意にする
-- IDを追加条件に使って1件に絞る
SELECT o.*
FROM orders o
INNER JOIN (
SELECT customer_id, MIN(order_date) AS min_date, MIN(id) AS min_id
FROM orders
GROUP BY customer_id
) sub ON o.customer_id = sub.customer_id
AND o.order_date = sub.min_date
AND o.id = sub.min_id;
問題2: NULLの扱い
注意点
- ORDER BYでNULLがある場合の並び順
- MIN/MAXでNULLがある場合の動作
対策
-- NULLを考慮した順序付け
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY COALESCE(order_date, '9999-12-31')
) AS rn
FROM orders
) AS t
WHERE rn = 1;
問題3: パフォーマンスが悪い
原因
- 適切なインデックスがない
- 不要な列を大量に選択している
- 事前の絞り込みができていない
対策
-- パフォーマンス改善版
WITH filtered_orders AS (
SELECT customer_id, order_date, amount, product_id
FROM orders
WHERE order_date >= '2023-01-01'
AND status = 'completed'
),
ranked_orders AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS rn
FROM filtered_orders
)
SELECT customer_id, order_date, amount, product_id
FROM ranked_orders
WHERE rn = 1;
まとめ
SQLで「GROUP BYごとに最初の1件」を取得するには、GROUP BYだけでは不十分で、特別な手法が必要です。
手法の選び方
サブクエリ + MIN/MAX方式
- 古いデータベースでも確実に動作
- 理解しやすい
- 同値レコードが複数件取得される可能性
ウィンドウ関数方式
- モダンなデータベースで高性能
- 柔軟な条件指定が可能
- 確実に1件だけ取得
データベース別推奨方法
データベース | 推奨方法 | 理由 |
---|---|---|
MySQL 8.0+ | ウィンドウ関数 | 高性能、柔軟性 |
MySQL 5.7- | サブクエリ | ウィンドウ関数未対応 |
PostgreSQL | DISTINCT ON または ウィンドウ関数 | PostgreSQL独自機能が便利 |
SQLite 3.25+ | ウィンドウ関数 | 軽量で高速 |
SQLite 3.25- | サブクエリ | ウィンドウ関数未対応 |
SQL Server | ウィンドウ関数 | 豊富な機能、高性能 |
覚えておきたいポイント
- GROUP BYだけでは「最初の1件」は取得できない
- サブクエリ方式は確実だが、同値で複数件の可能性
- ウィンドウ関数は柔軟で高性能
- インデックスの設定がパフォーマンスのカギ
- データベースの機能に応じて最適な方法を選択
コメント