PostgreSQL: PostgreSQLストアドプロシージャとPL/pgSQL

最終更新:2026-08-26

1. 学習内容


2. ストーリー

BobはEコマースプラットフォームのバックエンドエンジニアです。毎朝、一連のデータタスクを自動実��する必要があります:

  1. 前日の売上サマリーを計算
  2. マテリアライズドビュー mv_daily_sales をリフレッシュ
  3. 計算が失敗した場合、運用チームに通知

BobはPL/pgSQLでストアドプロシージャ sp_daily_sales_refresh() を作成し、すべてのロジックをデータベース側にカプセル化して「ワンクリック実行」できるようにすることにしました。


3. 概念:FUNCTION vs PROCEDURE

(1) PGにおける関数とプロシージャの違い

機能 FUNCTION PROCEDURE
戻り値 RETURNSが必要 RETURNSなし(何も返さなくてよい)
呼び出しスタイル SELECT func() CALL proc()
トランザクション制御 内部でCOMMIT/ROLLBACK不可 内部でCOMMIT/ROLLBACK可
SQL内での使用 SELECT/WHERE内で可 SQLに埋め込めない
INOUTパラメータ サポート、自動的に戻り値の列になる サポート、CALL経由で返却

▶ サンプル:CREATE FUNCTIONの基本

SQL
CREATE OR REPLACE FUNCTION fn_get_order_count(p_customer_id INT)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
  v_count INT;
BEGIN
  SELECT COUNT(*) INTO v_count
  FROM orders
  WHERE customer_id = p_customer_id;
  RETURN v_count;
END;
$$;

-- SELECTでスカラーとして呼び出す
SELECT fn_get_order_count(1001);

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

▶ サンプル:CREATE PROCEDUREの基本

SQL
CREATE OR REPLACE PROCEDURE sp_reset_daily_stats()
LANGUAGE plpgsql
AS $$
BEGIN
  TRUNCATE TABLE daily_stats;
  INSERT INTO daily_stats (stat_date, total_orders, total_revenue)
  VALUES (CURRENT_DATE, 0, 0.00);
  COMMIT;
END;
$$;

-- CALLで呼び出す
CALL sp_reset_daily_stats();

Output:

TEXT 📖 参照専用
INSERT 0 1

(2) FUNCTIONとPROCEDUREの使い分け

シナリオ 推奨 理由
計算して値を返す FUNCTION SQLに埋め込める
バッチETL操作 PROCEDURE 内部トランザクション制御をサポート
トリガーコールバック FUNCTION トリガーは関数のみ受け付ける
スケジュールタスク PROCEDURE ステップごとにコミット可能

▶ サンプル:デフォルト値付き関数

SQL
CREATE OR REPLACE FUNCTION fn_calc_tax(
  p_amount NUMERIC,
  p_rate NUMERIC DEFAULT 0.08
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN ROUND(p_amount * p_rate, 2);
END;
$$;

SELECT fn_calc_tax(1000);        -- デフォルトの8%を使用
SELECT fn_calc_tax(1000, 0.10);  -- カスタムの10%

Output:

TEXT 📖 参照専用
CREATE TABLE

4. 概念:PL/pgSQLの基本

(1) 変数と代入

SQL
DECLARE
  v_name TEXT;
  v_price NUMERIC(10,2) := 0.00;
  v_count INT;
  v_row orders%ROWTYPE;       -- テーブルからの行型
  v_status TEXT NOT NULL := 'pending';
BEGIN
  v_name := 'Bob';
  SELECT unit_price INTO v_price FROM products WHERE product_id = 1;
END;
宣言スタイル 構文 説明
基本型 v_name TEXT; 直接宣言
デフォルト値 v_price NUMERIC := 0; := または DEFAULT
行型 v_row orders%ROWTYPE; テーブル構造に一致
NOT NULL v_status TEXT NOT NULL := 'x'; 初期値の代入が必須

▶ サンプル:変数とSELECT INTO

SQL
CREATE OR REPLACE FUNCTION fn_customer_summary(p_id INT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
  v_name TEXT;
  v_total NUMERIC(12,2);
  v_msg TEXT;
BEGIN
  SELECT first_name || ' ' || last_name, SUM(total_amount)
    INTO v_name, v_total
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  WHERE c.customer_id = p_id
  GROUP BY c.customer_id, c.first_name, c.last_name;

  v_msg := v_name || ': $' || COALESCE(v_total::TEXT, '0');
  RETURN v_msg;
END;
$$;

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

(2) 条件文

▶ サンプル:IF/ELSIF/ELSE

SQL
CREATE OR REPLACE FUNCTION fn_discount_tier(p_total NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
  IF p_total >= 10000 THEN
    RETURN 'PLATINUM';
  ELSIF p_total >= 5000 THEN
    RETURN 'GOLD';
  ELSIF p_total >= 1000 THEN
    RETURN 'SILVER';
  ELSE
    RETURN 'BRONZE';
  END IF;
END;
$$;

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル:CASE文

SQL
CREATE OR REPLACE FUNCTION fn_order_priority(p_amount NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
  CASE
    WHEN p_amount >= 5000 THEN RETURN 'URGENT';
    WHEN p_amount >= 1000 THEN RETURN 'HIGH';
    WHEN p_amount >= 100  THEN RETURN 'NORMAL';
    ELSE RETURN 'LOW';
  END CASE;
END;
$$;

Output:

TEXT 📖 参照専用
CREATE TABLE

(3) ループ文

ループ型 構文 最適な用途
LOOP LOOP ... END LOOP; 手動EXITが必要な場合
WHILE WHILE cond LOOP ... END LOOP; 条件を先にチェック
FOR(整数) FOR i IN 1..10 LOOP ... END LOOP; 固定回数の反復
FOR(クエリ) FOR rec IN SELECT ... LOOP ... END LOOP; クエリ結果を反復処理

▶ サンプル:LOOPとEXIT

SQL
CREATE OR REPLACE FUNCTION fn_find_price_threshold(
  p_target NUMERIC
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
  v_limit INT := 10;
  v_sum NUMERIC := 0;
  v_id INT;
BEGIN
  LOOP
    SELECT unit_price INTO v_sum FROM products
    WHERE product_id = v_limit;
    v_sum := v_sum + p_target;
    EXIT WHEN v_limit > 100 OR v_sum > p_target * 10;
    v_limit := v_limit + 10;
  END LOOP;
  RETURN v_limit;
END;
$$;

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

▶ サンプル:FORクエリループ

SQL
CREATE OR REPLACE FUNCTION fn_sum_category_revenue()
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
  v_rec RECORD;
  v_total NUMERIC(14,2) := 0;
BEGIN
  FOR v_rec IN
    SELECT category, SUM(unit_price * stock_qty) AS cat_rev
    FROM products
    GROUP BY category
  LOOP
    v_total := v_total + v_rec.cat_rev;
    RAISE NOTICE 'カテゴリ %: $%', v_rec.category, v_rec.cat_rev;
  END LOOP;
  RETURN v_total;
END;
$$;

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

5. 概念:パラメータモード

(1) IN / OUT / INOUT / VARIADIC

モード 入力 出力 説明
IN(デフォルト) あり なし 読み取り専用パラメータ
OUT なし あり 出力パラメータ、自動的に戻り値の列になる
INOUT あり あり 双方向パラメータ
VARIADIC あり なし 可変長引数リスト(配列)

▶ サンプル:OUTパラメータ付き関数

SQL
CREATE OR REPLACE FUNCTION fn_order_stats(
  p_customer_id INT,
  OUT o_count INT,
  OUT o_total NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
  SELECT COUNT(*), COALESCE(SUM(total_amount), 0)
    INTO o_count, o_total
  FROM orders
  WHERE customer_id = p_customer_id;
END;
$$;

SELECT * FROM fn_order_stats(1001);

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

▶ サンプル:INOUTパラメータ

SQL
CREATE OR REPLACE FUNCTION fn_apply_discount(
  p_price INOUT NUMERIC,
  p_rate NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
  p_price := ROUND(p_price * (1 - p_rate), 2);
END;
$$;

-- 変更されたp_priceを結果として返す
SELECT fn_apply_discount(99.99, 0.15);

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

(2) RETURN vs RETURN QUERY

目的 戻り値の型
RETURN value; 単一の値を返す RETURNS型に一致
RETURN NEXT row; 結果セットに1行を追加 RETURNS SETOF
RETURN QUERY SELECT ...; クエリ結果全体を返す RETURNS SETOF/TABLE

▶ サンプル:RETURN QUERYでセットを返す

SQL
CREATE OR REPLACE FUNCTION fn_orders_by_date(p_date DATE)
RETURNS SETOF orders
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
    SELECT * FROM orders
    WHERE order_date = p_date
    ORDER BY order_id;
END;
$$;

SELECT * FROM fn_orders_by_date('2025-06-01');

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル:RETURN TABLEでカスタム戻り値の形を定義

SQL
CREATE OR REPLACE FUNCTION fn_top_customers(p_limit INT)
RETURNS TABLE(
  customer_name TEXT,
  total_spent NUMERIC,
  order_count INT
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
    SELECT
      c.first_name || ' ' || c.last_name,
      SUM(o.total_amount)::NUMERIC,
      COUNT(*)::INT
    FROM customers c
    JOIN orders o ON o.customer_id = c.customer_id
    GROUP BY c.customer_id, c.first_name, c.last_name
    ORDER BY total_spent DESC
    LIMIT p_limit;
END;
$$;

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

6. 概念:カーソル

(1) 明示カーソルとREFCURSOR

カーソル型 宣言 最適な用途
バインドカーソル CURSOR (query) FOR 固定クエリ
REFCURSOR REFCURSOR 動的クエリ、呼び出し元に返却可能
暗黙カーソル FOR rec IN SELECT 単純な走査

▶ サンプル:明示カーソル走査

SQL
CREATE OR REPLACE FUNCTION fn_process_overdue_orders()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
  v_cur CURSOR(p_date DATE) FOR
    SELECT order_id, customer_id, total_amount
    FROM orders
    WHERE order_date < p_date
      AND order_status = 'pending';
  v_rec RECORD;
  v_count INT := 0;
BEGIN
  OPEN v_cur(CURRENT_DATE - INTERVAL '7 days');
  LOOP
    FETCH v_cur INTO v_rec;
    EXIT WHEN NOT FOUND;
    UPDATE orders SET order_status = 'cancelled'
    WHERE order_id = v_rec.order_id;
    v_count := v_count + 1;
  END LOOP;
  CLOSE v_cur;
  RETURN v_count;
END;
$$;

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

▶ サンプル:REFCURSORでカーソルを返す

SQL
CREATE OR REPLACE FUNCTION fn_open_category_cursor(
  p_category TEXT
)
RETURNS REFCURSOR
LANGUAGE plpgsql
AS $$
DECLARE
  v_ref REFCURSOR;
BEGIN
  OPEN v_ref FOR
    SELECT product_id, product_name, unit_price
    FROM products
    WHERE category = p_category
    ORDER BY unit_price DESC;
  RETURN v_ref;
END;
$$;

BEGIN;
SELECT fn_open_category_cursor('Electronics');
-- "<unnamed portal 1>"のようなカーソル名を返す
FETCH ALL FROM "<unnamed portal 1>";
COMMIT;

Output:

TEXT 📖 参照専用
CREATE TABLE

7. 概念:例外処理

(1) EXCEPTIONブロック

SQL
BEGIN
  -- 通常のロジック
EXCEPTION
  WHEN OTHERS THEN
    -- エラー処理
END;
条件 説明
NO_DATA_FOUND SELECT INTOが行を返さなかった
TOO_MANY_ROWS SELECT INTOが複数行を返した
UNIQUE_VIOLATION 一意制約違反
FOREIGN_KEY_VIOLATION 外部キー違反
DIVISION_BY_ZERO ゼロ除算
OTHERS すべての例外をキャッチ

▶ サンプル:EXCEPTIONで一意制約違反をキャッチ

SQL
CREATE OR REPLACE FUNCTION fn_safe_insert_product(
  p_name TEXT, p_price NUMERIC, p_cat TEXT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO products (product_name, unit_price, category)
  VALUES (p_name, p_price, p_cat);
  RETURN 'OK';
EXCEPTION
  WHEN unique_violation THEN
    RETURN '重複: ' || p_name;
END;
$$;

Output:

TEXT 📖 参照専用
INSERT 0 1

(2) RAISE通知とエラー

RAISEレベル 動作
DEBUG 開発ログのみ
LOG サーバーログに書き込み
NOTICE クライアントに表示
WARNING クライアントに表示+ログ
EXCEPTION エラーをスローし、トランザクションをロールバック

▶ サンプル:RAISE NOTICEとRAISE EXCEPTION

SQL
CREATE OR REPLACE FUNCTION fn_validate_order(p_amount NUMERIC)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
  IF p_amount <= 0 THEN
    RAISE EXCEPTION '無効な注文金額: $%', p_amount;
  ELSIF p_amount > 100000 THEN
    RAISE WARNING '大口注文: $% - 承認が必要です', p_amount;
  ELSE
    RAISE NOTICE '注文が検証されました: $%', p_amount;
  END IF;
  RETURN true;
END;
$$;

Output:

TEXT 📖 参照専用
CREATE TABLE

8. 概念:動的SQL

(1) EXECUTEの使用

シナリオ 構文 説明
動的DDL EXECUTE 'CREATE TABLE ...'; 実行時にSQLを構築
パラメータ化 EXECUTE fmt USING v1, v2; パラメータ化、インジェクション安全
結果取得 EXECUTE sql INTO v_var; 変数に格納
複数行取得 FOR rec IN EXECUTE sql LOOP 動的クエリを反復処理

▶ サンプル:月次パーティショニングの動的SQL

SQL
CREATE OR REPLACE FUNCTION fn_create_monthly_partition(
  p_year INT, p_month INT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
  v_sql TEXT;
  v_table TEXT;
  v_start DATE;
  v_end DATE;
BEGIN
  v_table := 'orders_y' || p_year || 'm' || LPAD(p_month::TEXT, 2, '0');
  v_start := make_date(p_year, p_month, 1);
  v_end := v_start + INTERVAL '1 month';

  v_sql := format(
    'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders
     FOR VALUES FROM (%L) TO (%L)',
    v_table, v_start, v_end
  );
  EXECUTE v_sql;
  RETURN v_table || ' が作成されました';
END;
$$;

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル:USING付き動的クエリ

SQL
CREATE OR REPLACE FUNCTION fn_search_products(
  p_col TEXT, p_val TEXT
)
RETURNS SETOF products
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY EXECUTE format(
    'SELECT * FROM products WHERE %I = $1',
    p_col
  ) USING p_val;
END;
$$;

SELECT * FROM fn_search_products('category', 'Electronics');

Output:

TEXT 📖 参照専用
CREATE TABLE

9. フローチャート:PL/pgSQL構造の決定

100%
flowchart TD
    A[ロジックをカプセル化したい?] --> B{戻り値が必要?}
    B -->|はい| C[CREATE FUNCTION]
    B -->|いいえ| D{トランザクション制御が必要?}
    D -->|はい| E[CREATE PROCEDURE]
    D -->|いいえ| C
    C --> F{1行返す?複数行返す?}
    F -->|単一値| G[RETURN value]
    F -->|複数行| H{クエリは固定?}
    H -->|はい| I[RETURN QUERY SELECT]
    H -->|いいえ| J[FOR rec IN EXECUTE ... LOOP]
    E --> K{動的SQLが必要?}
    K -->|はい| L[EXECUTE format(...) USING]
    K -->|いいえ| M[静的SQL文]
    G --> N{エラーの可能性あり?}
    N -->|はい| O[EXCEPTIONブロック]
    N -->|いいえ| P[直接ロジック]
    I --> N

    style A fill:#e1f5fe
    style O fill:#ffcdd2
    style L fill:#c8e6c9

10. 総合的な例

Bobの日次売上サマリーストアドプロシージャ — 売上を計算し、マテリアライズドビューをリフレッシュし、エラー時に通知します:

SQL
CREATE OR REPLACE PROCEDURE sp_daily_sales_refresh()
LANGUAGE plpgsql
AS $$
DECLARE
  v_yesterday DATE := CURRENT_DATE - INTERVAL '1 day';
  v_order_count INT;
  v_total_revenue NUMERIC(14,2);
  v_avg_order NUMERIC(10,2);
BEGIN
  RAISE NOTICE '% の日次リフレッシュを開始', v_yesterday;

  SELECT COUNT(*), COALESCE(SUM(total_amount), 0),
         COALESCE(AVG(total_amount), 0)
    INTO v_order_count, v_total_revenue, v_avg_order
  FROM orders
  WHERE order_date = v_yesterday
    AND order_status = 'completed';

  INSERT INTO daily_sales_summary
    (stat_date, order_count, total_revenue, avg_order_value, created_at)
  VALUES
    (v_yesterday, v_order_count, v_total_revenue, v_avg_order, NOW())
  ON CONFLICT (stat_date) DO UPDATE
    SET order_count = EXCLUDED.order_count,
        total_revenue = EXCLUDED.total_revenue,
        avg_order_value = EXCLUDED.avg_order_value;

  REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

  RAISE NOTICE '完了: % 件の注文, $% の収益, $% の平均',
    v_order_count, v_total_revenue, v_avg_order;

EXCEPTION
  WHEN OTHERS THEN
    INSERT INTO error_log (error_time, procedure_name, error_msg)
    VALUES (NOW(), 'sp_daily_sales_refresh', SQLERRM);
    RAISE NOTICE '日次リフレッシュでエラー: %', SQLERRM;
    -- pg_notifyで運用チームに通知
    PERFORM pg_notify('ops_alerts',
      '日次売上リフレッシュが失敗しました: ' || SQLERRM);
END;
$$;

❓ よくある質問

Q FUNCTIONとPROCEDUREの最も重要な違いは何ですか?
A PROCEDUREは内部でCOMMIT/ROLLBACKを実行できますが(自律トランザクション)、FUNCTIONはできません。FUNCTIONはRETURNS句が必須でSQL内部で呼び出せますが、PROCEDUREはCALLで呼び出します。
Q SELECT INTOが一致する行を見つけられなかった場合どうなりますか?
A 変数は元の値を保持します(NULLに設定されません)。「データなし」を検出するには、IF NOT FOUND THEN または EXCEPTION WHEN NO_DATA_FOUND を使用してください。
Q RETURN NEXTとRETURN QUERYの違いは?
A RETURN NEXTは結果セットに1行ずつ追加し、ループと併用する必要があります。RETURN QUERYはSELECT結果全体を一度に返し、より簡潔です。どちらもRETURNS SETOFが必要です。
Q 動的SQLでformat + USINGが文字列連結より推奨される理由は?
A formatの %I は識別子を自動引用符で囲み、%L はリテラルを処理しま��。また、USINGは実行をパラメータ化してSQLインジェクションを防ぎます。直接の文字列連結はインジェクションリスクがあり、引用符のエスケープを手動で処理する必要があります。
Q EXCEPTIONブロックはパフォーマンスに影響しますか?
A はい。EXCEPTIONブロックはサブトランザクションのセーブポイントを作成するため、オーバーヘッドが追加されます。頻繁に呼び出されるホットパス関数では不要なEXCEPTIONブロックを避けるべきです。
Q REFCURSORカーソルはどのような場合に便利ですか?
A クエリ結果の取得を呼び出し元に遅延させる必要がある場合です。例えば、アプリケーション層が大規模なデータセットをバッチで取得する場合や、プロシージャがカーソルを返して呼び出し元が反復方法を決定する場合です。カーソルはトランザクション内で使用する必要があることに注意してください。
Q PL/pgSQLの FOR rec IN SELECT ループのパフォーマンスは?
A サーバーサイドカーソル走査と同等で、アプリケーション層で1行ずつ取得するよりはるかに効率的です。ただし、大規模なデータ操作にはセットベースのSQL(INSERT/UPDATE ... SELECT)を優先し、セットベースのSQLで表現でき��いロジックにのみループを使用してください。
Q RAISE NOTICEメッセージはクライアントに常に表示されますか?
A クライアントの設定に依存します。psqlはデフォルトでNOTICEを表示しますが、多くのORMやドライバーはデフォルトで無視します。RAISE WARNINGを使用するか、log_min_messagesを調整して制御してください。

📖 まとめ


📝 練習問題

  1. products テーブルから unit_price をSELECT INTOでクエリし、行が存在しない場合は0を返す関数 fn_get_product_price(p_id INT) を書いてください。

  2. ⭐⭐ 顧客の合計支出をクエリし、IF/ELSIFを使用してPLATINUM/GOLD/SILVER/BRONZEのティアを返す関数 fn_customer_tier(p_customer_id INT) を書いてください(しきい値:10000 / 5000 / 1000 USD)。

  3. ⭐⭐⭐ ストアドプロシージャ sp_monthly_revenue_report(p_year INT, p_month INT) を書いてください:その月の注文サマリーを動的にクエリし、monthly_reports テーブルにINSERTし、EXCEPTIONでエラーをキャッチして error_log に書き込み、RAISE NOTICEで進捗を出力し、formatでテーブル名を動的に構築してください(例:orders_y2025m06)。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%