PostgreSQL: PostgreSQLストアドプロシージャとPL/pgSQL
最終更新:2026-08-26
1. 学習内容
- CREATE FUNCTION / CREATE PROCEDURE(PGは関数とストアドプロシージャを区別する)
- PL/pgSQL構文:変数、代入、IF/CASE/LOOP/WHILE/FOR
- パラメータモード:IN / OUT / INOUT / VARIADIC
- RETURNとRETURN QUERY
- カーソル:CURSOR / REFCURSOR
- 例外処理:EXCEPTION / RAISE
- トリガー関数
- 動的SQL:EXECUTE
2. ストーリー
BobはEコマースプラットフォームのバックエンドエンジニアです。毎朝、一連のデータタスクを自動実��する必要があります:
- 前日の売上サマリーを計算
- マテリアライズドビュー
mv_daily_salesをリフレッシュ - 計算が失敗した場合、運用チームに通知
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の基本
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:
count
-------
5
(1 row)
▶ サンプル:CREATE PROCEDUREの基本
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:
INSERT 0 1
(2) FUNCTIONとPROCEDUREの使い分け
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 計算して値を返す | FUNCTION | SQLに埋め込める |
| バッチETL操作 | PROCEDURE | 内部トランザクション制御をサポート |
| トリガーコールバック | FUNCTION | トリガーは関数のみ受け付ける |
| スケジュールタスク | PROCEDURE | ステップごとにコミット可能 |
▶ サンプル:デフォルト値付き関数
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:
CREATE TABLE
4. 概念:PL/pgSQLの基本
(1) 変数と代入
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
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:
result
----------
42.50
(1 row)
(2) 条件文
▶ サンプル:IF/ELSIF/ELSE
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:
CREATE TABLE
▶ サンプル:CASE文
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:
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
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:
result
----------
42.50
(1 row)
▶ サンプル:FORクエリループ
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:
result
----------
42.50
(1 row)
5. 概念:パラメータモード
(1) IN / OUT / INOUT / VARIADIC
| モード | 入力 | 出力 | 説明 |
|---|---|---|---|
| IN(デフォルト) | あり | なし | 読み取り専用パラメータ |
| OUT | なし | あり | 出力パラメータ、自動的に戻り値の列になる |
| INOUT | あり | あり | 双方向パラメータ |
| VARIADIC | あり | なし | 可変長引数リスト(配列) |
▶ サンプル:OUTパラメータ付き関数
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:
count
-------
5
(1 row)
▶ サンプル:INOUTパラメータ
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:
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でセットを返す
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:
CREATE TABLE
▶ サンプル:RETURN TABLEでカスタム戻り値の形を定義
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:
count
-------
5
(1 row)
6. 概念:カーソル
(1) 明示カーソルとREFCURSOR
| カーソル型 | 宣言 | 最適な用途 |
|---|---|---|
| バインドカーソル | CURSOR (query) FOR |
固定クエリ |
| REFCURSOR | REFCURSOR |
動的クエリ、呼び出し元に返却可能 |
| 暗黙カーソル | FOR rec IN SELECT | 単純な走査 |
▶ サンプル:明示カーソル走査
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:
count
-------
5
(1 row)
▶ サンプル:REFCURSORでカーソルを返す
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:
CREATE TABLE
7. 概念:例外処理
(1) EXCEPTIONブロック
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で一意制約違反をキャッチ
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:
INSERT 0 1
(2) RAISE通知とエラー
| RAISEレベル | 動作 |
|---|---|
DEBUG |
開発ログのみ |
LOG |
サーバーログに書き込み |
NOTICE |
クライアントに表示 |
WARNING |
クライアントに表示+ログ |
EXCEPTION |
エラーをスローし、トランザクションをロールバック |
▶ サンプル:RAISE NOTICEとRAISE EXCEPTION
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:
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
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:
CREATE TABLE
▶ サンプル:USING付き動的クエリ
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:
CREATE TABLE
9. フローチャート:PL/pgSQL構造の決定
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の日次売上サマリーストアドプロシージャ — 売上を計算し、マテリアライズドビューをリフレッシュし、エラー時に通知します:
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;
$$;
❓ よくある質問
IF NOT FOUND THEN または EXCEPTION WHEN NO_DATA_FOUND を使用してください。%I は識別子を自動引用符で囲み、%L はリテラルを処理しま��。また、USINGは実行をパラメータ化してSQLインジェクションを防ぎます。直接の文字列連結はインジェクションリスクがあり、引用符のエスケープを手動で処理する必要があります。FOR rec IN SELECT ループのパフォーマンスは?📖 まとめ
- PGはFUNCTION(戻り値必須、SQLに埋め込み可能)とPROCEDURE(戻り値なし、トランザクション制御をサポート)を区別する
- PL/pgSQLでは
:=で変数を代入し、SELECT INTOでクエリ値を取得し、%ROWTYPEで行型に一致させる - 条件分岐:IF/ELSIF/ELSEとCASEの両方があり、CASEは多分岐ロジックに適している
- ループ:LOOP(EXITが必要)、WHILE(条件を先にチェック)、FOR(固定回数またはクエリ走査)
- パラメータモード:INは読み取り専用、OUTは出力列、INOUTは双方向、VARIADICは可変引数を受け取る
- RETURNは単一値を返し、RETURN NEXT/QUERYはセットを返す
- カーソル:バインドカーソルは固定クエリに適し、REFCURSORは動的クエリと遅延取得に適する
- EXCEPTIONブロックはエラーをキャッチするがサブトランザクションのオーバーヘッドを追加し、RAISEはメッセージレベルを制御する
- EXECUTE + format + USINGで動的SQLを実行し、インジェクションを防止する
📝 練習問題
-
⭐
productsテーブルからunit_priceをSELECT INTOでクエリし、行が存在しない場合は0を返す関数fn_get_product_price(p_id INT)を書いてください。 -
⭐⭐ 顧客の合計支出をクエリし、IF/ELSIFを使用してPLATINUM/GOLD/SILVER/BRONZEのティアを返す関数
fn_customer_tier(p_customer_id INT)を書いてください(しきい値:10000 / 5000 / 1000 USD)。 -
⭐⭐⭐ ストアドプロシージャ
sp_monthly_revenue_report(p_year INT, p_month INT)を書いてください:その月の注文サマリーを動的にクエリし、monthly_reportsテーブルにINSERTし、EXCEPTIONでエラーをキャッチしてerror_logに書き込み、RAISE NOTICEで進捗を出力し、formatでテーブル名を動的に構築してください(例:orders_y2025m06)。