SQLでGROUP BYごとに最初の1件を取得する方法|データベース別完全ガイド

データベース・SQL

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;

ステップ解説

  1. サブクエリ: 顧客ごとの最早の注文日を求める
  2. INNER JOIN: 元のテーブルとサブクエリの結果を結合
  3. 条件: 顧客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-サブクエリウィンドウ関数未対応
PostgreSQLDISTINCT ON または ウィンドウ関数PostgreSQL独自機能が便利
SQLite 3.25+ウィンドウ関数軽量で高速
SQLite 3.25-サブクエリウィンドウ関数未対応
SQL Serverウィンドウ関数豊富な機能、高性能

覚えておきたいポイント

  1. GROUP BYだけでは「最初の1件」は取得できない
  2. サブクエリ方式は確実だが、同値で複数件の可能性
  3. ウィンドウ関数は柔軟で高性能
  4. インデックスの設定がパフォーマンスのカギ
  5. データベースの機能に応じて最適な方法を選択

コメント

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