PostgreSQLグループごとに連番を振る完全ガイド:ROW_NUMBERからシーケンスまで徹底解説

データベース・SQL

「部署ごとに社員番号を1から振り直したい」 「カテゴリー別に商品コードを連番で管理したい」 「月ごとに注文番号をリセットして採番したい」

データベースを使っていると、こんな要求がよく出てきますよね。

全体で一つの連番なら簡単ですが、グループごとに1から始まる連番を振るのは、ちょっとしたテクニックが必要です。でも、PostgreSQLのウィンドウ関数を使えば、驚くほど簡単に実現できるんです。

この記事では、ROW_NUMBER()関数を中心に、グループごとの連番を振る様々な方法を、実例を交えながら分かりやすく解説します。読み終わる頃には、複雑な採番要求にも対応できるようになっているはずです!

スポンサーリンク

グループごとの連番が必要な場面

ビジネスでよくある要求

実際の使用例:

  • 請求書番号:年度ごとに「2024-001」「2024-002」…
  • 社員コード:部署ごとに「営業部-01」「営業部-02」…
  • 在庫管理番号:倉庫ごとに連番管理
  • 受付番号:日付ごとにリセット
  • ランキング:カテゴリー内での順位付け

これらはすべて、グループごとの連番で解決できます。

なぜデータベースで連番を管理するの?

メリット:

  • 一意性が保証される
  • 欠番が発生しにくい
  • 自動化できる
  • 履歴管理が容易
  • 同時アクセスに対応

Excelで手動管理するより、はるかに安全で効率的です。

サンプルデータの準備

テストテーブルの作成

まず、実例で使うサンプルデータを用意しましょう。

-- 従業員テーブルの作成
CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(50),
    hire_date DATE,
    salary INTEGER
);

-- サンプルデータの挿入
INSERT INTO employees (name, department, hire_date, salary) VALUES
-- 営業部
('田中太郎', '営業部', '2023-04-01', 450000),
('鈴木花子', '営業部', '2023-06-15', 420000),
('佐藤次郎', '営業部', '2024-01-10', 380000),
('高橋美咲', '営業部', '2024-03-20', 400000),
-- 開発部
('山田一郎', '開発部', '2022-10-01', 550000),
('伊藤さくら', '開発部', '2023-02-15', 480000),
('渡辺健', '開発部', '2023-08-01', 520000),
('中村優子', '開発部', '2024-04-01', 460000),
-- 総務部
('小林明', '総務部', '2021-04-01', 420000),
('加藤綾', '総務部', '2023-09-01', 380000),
('吉田隆', '総務部', '2024-02-01', 360000);

-- 注文テーブルの作成
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    order_date DATE,
    customer_name VARCHAR(100),
    product VARCHAR(100),
    amount INTEGER,
    status VARCHAR(20)
);

-- 注文データの挿入
INSERT INTO orders (order_date, customer_name, product, amount, status) VALUES
('2024-01-05', 'A社', 'ノートPC', 150000, '完了'),
('2024-01-10', 'B社', 'モニター', 45000, '完了'),
('2024-01-15', 'C社', 'キーボード', 8000, '処理中'),
('2024-02-03', 'D社', 'ノートPC', 180000, '完了'),
('2024-02-08', 'A社', 'マウス', 3500, '完了'),
('2024-02-20', 'E社', 'モニター', 48000, '処理中'),
('2024-03-01', 'B社', 'ノートPC', 160000, '完了'),
('2024-03-10', 'C社', 'マウス', 3000, '完了'),
('2024-03-15', 'F社', 'キーボード', 8500, '処理中');

ROW_NUMBER()関数の基本

シンプルな連番付与

まず、最も基本的な使い方から始めましょう。

-- 部署ごとに連番を振る
SELECT 
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY hire_date) AS 社員番号,
    department AS 部署,
    name AS 氏名,
    hire_date AS 入社日
FROM employees
ORDER BY department, 社員番号;

結果:

社員番号 | 部署    | 氏名      | 入社日
---------|---------|-----------|------------
1        | 営業部  | 田中太郎  | 2023-04-01
2        | 営業部  | 鈴木花子  | 2023-06-15
3        | 営業部  | 佐藤次郎  | 2024-01-10
4        | 営業部  | 高橋美咲  | 2024-03-20
1        | 開発部  | 山田一郎  | 2022-10-01
2        | 開発部  | 伊藤さくら| 2023-02-15
3        | 開発部  | 渡辺健    | 2023-08-01
4        | 開発部  | 中村優子  | 2024-04-01
1        | 総務部  | 小林明    | 2021-04-01
2        | 総務部  | 加藤綾    | 2023-09-01
3        | 総務部  | 吉田隆    | 2024-02-01

各部署で1から始まる連番が振られています!

PARTITION BY句の詳細

PARTITION BY句で、グループ分けの基準を指定します。

-- 複数の条件でグループ分け
SELECT 
    ROW_NUMBER() OVER (
        PARTITION BY department, EXTRACT(YEAR FROM hire_date)
        ORDER BY hire_date
    ) AS 年度別番号,
    department AS 部署,
    EXTRACT(YEAR FROM hire_date) AS 入社年,
    name AS 氏名,
    hire_date AS 入社日
FROM employees
ORDER BY department, 入社年, 年度別番号;

部署と入社年の組み合わせごとに連番が振られます。

ORDER BY句の重要性

ORDER BY句で、連番を振る順序を決定します。

-- 給与の高い順に連番を振る
SELECT 
    ROW_NUMBER() OVER (
        PARTITION BY department 
        ORDER BY salary DESC
    ) AS 給与順位,
    department AS 部署,
    name AS 氏名,
    salary AS 給与
FROM employees
ORDER BY department, 給与順位;

RANK()とDENSE_RANK()との違い

3つの関数の比較

同じ値がある場合の動作が異なります。

-- 3つの関数を同時に使って比較
SELECT 
    department AS 部署,
    name AS 氏名,
    salary AS 給与,
    ROW_NUMBER() OVER w AS row_num,
    RANK() OVER w AS rank_num,
    DENSE_RANK() OVER w AS dense_rank_num
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC)
ORDER BY department, salary DESC;

違いの説明:

  • ROW_NUMBER():必ず連続した番号(1,2,3,4…)
  • RANK():同じ値は同じ順位、次は飛ばす(1,2,2,4…)
  • DENSE_RANK():同じ値は同じ順位、次は連続(1,2,2,3…)

実践的な連番付与パターン

日付ごとのリセット番号

-- 日付ごとに注文番号を採番
SELECT 
    TO_CHAR(order_date, 'YYYY-MM-DD') || '-' || 
    LPAD(
        ROW_NUMBER() OVER (
            PARTITION BY order_date 
            ORDER BY id
        )::TEXT, 
        3, 
        '0'
    ) AS 注文番号,
    order_date AS 注文日,
    customer_name AS 顧客名,
    product AS 商品,
    amount AS 金額
FROM orders
ORDER BY order_date, id;

「2024-01-05-001」のような形式の注文番号が生成されます。

月ごとの連番with年またぎ対応

-- 月ごとに請求書番号を採番
WITH monthly_orders AS (
    SELECT 
        *,
        TO_CHAR(order_date, 'YYYY-MM') AS year_month
    FROM orders
)
SELECT 
    year_month || '-' || 
    LPAD(
        ROW_NUMBER() OVER (
            PARTITION BY year_month 
            ORDER BY order_date, id
        )::TEXT, 
        4, 
        '0'
    ) AS 請求書番号,
    order_date AS 日付,
    customer_name AS 顧客,
    amount AS 金額
FROM monthly_orders
ORDER BY year_month, order_date;

条件付き連番

-- ステータスが「完了」の注文のみに連番を振る
SELECT 
    CASE 
        WHEN status = '完了' THEN 
            ROW_NUMBER() OVER (
                PARTITION BY status 
                ORDER BY order_date
            )
        ELSE NULL
    END AS 完了番号,
    order_date AS 日付,
    customer_name AS 顧客,
    product AS 商品,
    status AS ステータス
FROM orders
ORDER BY order_date;

連番をテーブルに保存する

UPDATE文での連番更新

既存のテーブルに連番カラムを追加して更新する方法:

-- 連番用のカラムを追加
ALTER TABLE employees ADD COLUMN emp_number VARCHAR(10);

-- 部署ごとの連番を更新
WITH numbered AS (
    SELECT 
        id,
        department || '-' || 
        LPAD(
            ROW_NUMBER() OVER (
                PARTITION BY department 
                ORDER BY hire_date
            )::TEXT, 
            3, 
            '0'
        ) AS new_number
    FROM employees
)
UPDATE employees e
SET emp_number = n.new_number
FROM numbered n
WHERE e.id = n.id;

-- 結果確認
SELECT emp_number, department, name, hire_date 
FROM employees 
ORDER BY department, emp_number;

トリガーで自動採番

新規挿入時に自動で連番を振る仕組み:

-- 連番管理テーブル
CREATE TABLE sequence_manager (
    table_name VARCHAR(50),
    group_key VARCHAR(100),
    last_number INTEGER,
    PRIMARY KEY (table_name, group_key)
);

-- 採番関数
CREATE OR REPLACE FUNCTION get_next_number(
    p_table_name VARCHAR,
    p_group_key VARCHAR
) RETURNS INTEGER AS $$
DECLARE
    v_next_number INTEGER;
BEGIN
    -- 排他ロックで番号を取得・更新
    UPDATE sequence_manager
    SET last_number = last_number + 1
    WHERE table_name = p_table_name 
      AND group_key = p_group_key
    RETURNING last_number INTO v_next_number;
    
    -- レコードがなければ新規作成
    IF NOT FOUND THEN
        INSERT INTO sequence_manager (table_name, group_key, last_number)
        VALUES (p_table_name, p_group_key, 1)
        ON CONFLICT (table_name, group_key) 
        DO UPDATE SET last_number = sequence_manager.last_number + 1
        RETURNING last_number INTO v_next_number;
    END IF;
    
    RETURN v_next_number;
END;
$$ LANGUAGE plpgsql;

-- 使用例
SELECT get_next_number('orders', '2024-01');  -- 1
SELECT get_next_number('orders', '2024-01');  -- 2
SELECT get_next_number('orders', '2024-02');  -- 1

ウィンドウ関数の応用

累積和と連番の組み合わせ

-- 部署ごとの累積給与と順位
SELECT 
    ROW_NUMBER() OVER w AS 順位,
    department AS 部署,
    name AS 氏名,
    salary AS 給与,
    SUM(salary) OVER (
        PARTITION BY department 
        ORDER BY hire_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS 累積給与
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY hire_date)
ORDER BY department, 順位;

前後のレコードとの比較

-- 前の従業員との給与差を計算
SELECT 
    ROW_NUMBER() OVER w AS 番号,
    department AS 部署,
    name AS 氏名,
    salary AS 給与,
    salary - LAG(salary, 1, 0) OVER w AS 前との差額,
    LEAD(name, 1, '最後') OVER w AS 次の人
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY hire_date)
ORDER BY department, 番号;

パーセンタイル順位

-- 部署内での給与パーセンタイル
SELECT 
    ROW_NUMBER() OVER w AS 番号,
    department AS 部署,
    name AS 氏名,
    salary AS 給与,
    ROUND(
        PERCENT_RANK() OVER (
            PARTITION BY department 
            ORDER BY salary
        ) * 100, 
        1
    ) AS "パーセンタイル%"
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC)
ORDER BY department, 番号;

パフォーマンス最適化

インデックスの活用

-- PARTITION BYとORDER BYに使うカラムにインデックス
CREATE INDEX idx_emp_dept_hire ON employees(department, hire_date);
CREATE INDEX idx_emp_dept_salary ON employees(department, salary DESC);

-- 複合インデックスで高速化
CREATE INDEX idx_orders_date_id ON orders(order_date, id);

マテリアライズドビューの活用

-- 連番付きのマテリアライズドビュー
CREATE MATERIALIZED VIEW mv_numbered_employees AS
SELECT 
    id,
    ROW_NUMBER() OVER (
        PARTITION BY department 
        ORDER BY hire_date
    ) AS emp_seq,
    department,
    name,
    hire_date,
    salary
FROM employees;

-- インデックスの作成
CREATE INDEX idx_mv_emp_dept_seq ON mv_numbered_employees(department, emp_seq);

-- 定期的な更新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_numbered_employees;

大量データでの考慮事項

-- バッチ処理での連番付与
DO $$
DECLARE
    v_department VARCHAR(50);
BEGIN
    -- 部署ごとに処理
    FOR v_department IN 
        SELECT DISTINCT department FROM employees
    LOOP
        -- 部署単位で連番を更新
        WITH numbered AS (
            SELECT 
                id,
                ROW_NUMBER() OVER (ORDER BY hire_date) AS seq
            FROM employees
            WHERE department = v_department
        )
        UPDATE employees e
        SET emp_number = v_department || '-' || LPAD(n.seq::TEXT, 3, '0')
        FROM numbered n
        WHERE e.id = n.id;
        
        -- コミット(必要に応じて)
        RAISE NOTICE '部署 % の処理完了', v_department;
    END LOOP;
END $$;

複雑な要件への対応

欠番を埋める連番

-- 欠番を検出して埋める
WITH RECURSIVE number_series AS (
    -- 1から最大値までの連番を生成
    SELECT 1 AS num
    UNION ALL
    SELECT num + 1
    FROM number_series
    WHERE num < (SELECT MAX(id) FROM orders)
),
existing_numbers AS (
    -- 既存の番号を取得
    SELECT id FROM orders
)
-- 欠番のリスト
SELECT 
    num AS 欠番,
    'ORDER-' || LPAD(num::TEXT, 5, '0') AS 新規番号
FROM number_series
WHERE num NOT IN (SELECT id FROM existing_numbers)
ORDER BY num
LIMIT 10;

階層構造での連番

-- 階層ごとの連番(組織図など)
WITH RECURSIVE org_tree AS (
    -- ルートレベル
    SELECT 
        id,
        name,
        NULL::INTEGER AS parent_id,
        1 AS level,
        ARRAY[ROW_NUMBER() OVER (ORDER BY name)] AS path
    FROM employees
    WHERE department = '経営層'
    
    UNION ALL
    
    -- 再帰部分
    SELECT 
        e.id,
        e.name,
        ot.id AS parent_id,
        ot.level + 1,
        ot.path || ARRAY[ROW_NUMBER() OVER (
            PARTITION BY ot.id 
            ORDER BY e.name
        )]
    FROM employees e
    JOIN org_tree ot ON e.department != '経営層'
    WHERE ot.level < 3
)
SELECT 
    REPEAT('  ', level - 1) || 
    ARRAY_TO_STRING(path, '.') || ' ' || 
    name AS 階層番号付き名前
FROM org_tree
ORDER BY path;

トラブルシューティング

よくあるエラーと対処法

エラー1:window関数はGROUP BY句では使えない

-- NG: GROUP BYと同時に使用
SELECT department, ROW_NUMBER() OVER (ORDER BY department)
FROM employees
GROUP BY department;  -- エラー!

-- OK: サブクエリで分離
SELECT department, row_num
FROM (
    SELECT 
        department,
        ROW_NUMBER() OVER (ORDER BY department) AS row_num
    FROM employees
) sub
GROUP BY department, row_num;

エラー2:ORDER BYの曖昧さ

-- NG: 同じ値で順序が不定
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary)

-- OK: 一意になるようにする
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary, id)

よくある質問と回答

Q: 連番を後から振り直したい

A: DELETE後も連番を維持したい場合は、ROW_NUMBER()で再計算するか、別テーブルで管理します。

Q: 同時アクセスで重複する

A: シーケンスやADVISORY LOCKを使用して排他制御します:

SELECT pg_advisory_lock(12345);
-- 採番処理
SELECT pg_advisory_unlock(12345);

Q: 文字列での並び順がおかしい

A: 数値を文字列でソートすると「1,10,2」の順になります。LPADで桁数を揃えるか、数値型でソートしてください。

Q: NULLの扱いは?

A: PARTITION BYでNULLは別グループ、ORDER BYでは最後(NULLS FIRST/LASTで制御可能)になります。

Q: パフォーマンスが遅い

A: 適切なインデックス、EXPLAIN ANALYZEでの分析、必要に応じてマテリアライズドビューを検討してください。

まとめ:適切な方法を選んで効率的な連番管理を

PostgreSQLでのグループごとの連番は、ROW_NUMBER()関数で簡単に実現できます。

方法の選び方:

  • 一時的な表示:ROW_NUMBER()をSELECT文で使用
  • 永続的な保存:UPDATE文で更新、またはトリガー
  • 採番管理:専用テーブルとファンクション
  • 高速化が必要:マテリアライズドビュー
  • 同時実行制御:ADVISORY LOCKやシーケンス

ベストプラクティス:

  • ORDER BYで一意性を保証
  • 適切なインデックスを設定
  • 大量データはバッチ処理
  • 要件に応じて最適な方法を選択

この記事で紹介した技術を使えば、どんな連番要求にも対応できるはずです。シンプルな要件から始めて、徐々に複雑な処理にチャレンジしてみてください。

グループごとの連番は、データ管理の基本中の基本。マスターすれば、業務システムの開発効率が大幅に向上します。さあ、あなたのデータベースでも試してみましょう!

コメント

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