はじめに

アプリケーション開発において、「データの整合性をどこで担保するか」は重要な設計判断です。すべてのビジネスロジックをアプリケーション層で実装すると、複数のアプリケーションからデータベースにアクセスする場合に整合性を保つことが難しくなります。また、監査ログの記録や自動計算といった処理を各アプリケーションで重複実装することになり、保守性も低下します。

PostgreSQLのトリガーとユーザー定義関数を使えば、これらの課題をデータベース層で解決できます。PL/pgSQLという手続き型言語を使って複雑なロジックを記述し、トリガーによってINSERT、UPDATE、DELETE操作に応じて自動的に実行させることができます。

本記事では、PL/pgSQLの基本構文、ユーザー定義関数の作成方法、トリガーの仕組みと実装例(監査ログ、自動計算、データ検証)、そして注意点とベストプラクティスについて、実践的なサンプルコードとともに解説します。

この記事を読むことで、以下のことができるようになります。

  • PL/pgSQLの基本構文を理解し、ユーザー定義関数を作成できる
  • トリガーの仕組み(BEFORE/AFTER、行レベル/文レベル)を理解できる
  • 監査ログ、自動計算、データ検証など実践的なトリガーを実装できる
  • トリガー利用時の注意点とベストプラクティスを把握できる

前提条件

  • PostgreSQL 14以降がインストールされていること
  • psqlまたはGUIツール(pgAdmin、DBeaver等)でデータベースに接続できること
  • 基本的なSQLの構文(SELECT、INSERT、UPDATE、DELETE)を理解していること

基本的なSQL操作については、以下の関連記事を参照してください。

サンプルデータの準備

本記事で使用するサンプルテーブルを準備します。従業員管理システムを想定したデータ構成です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- 部署テーブル
CREATE TABLE departments (
    department_id   SERIAL PRIMARY KEY,
    name            VARCHAR(100) NOT NULL,
    budget          NUMERIC(15, 2) DEFAULT 0
);

-- 従業員テーブル
CREATE TABLE employees (
    employee_id     SERIAL PRIMARY KEY,
    name            VARCHAR(100) NOT NULL,
    email           VARCHAR(255) UNIQUE,
    department_id   INTEGER REFERENCES departments(department_id),
    salary          NUMERIC(10, 2) NOT NULL CHECK (salary >= 0),
    hire_date       DATE NOT NULL DEFAULT CURRENT_DATE,
    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 初期データ挿入
INSERT INTO departments (name, budget) VALUES
    ('エンジニアリング', 50000000),
    ('営業', 30000000),
    ('人事', 15000000);

INSERT INTO employees (name, email, department_id, salary, hire_date) VALUES
    ('田中太郎', 'tanaka@example.com', 1, 450000, '2023-04-01'),
    ('佐藤花子', 'sato@example.com', 1, 520000, '2022-01-15'),
    ('鈴木一郎', 'suzuki@example.com', 2, 380000, '2024-07-01');

PL/pgSQLの基礎

PL/pgSQLとは

PL/pgSQL(Procedural Language/PostgreSQL)は、PostgreSQLに組み込まれた手続き型言語です。SQLの宣言的な記述に加えて、変数、条件分岐、ループ、例外処理といったプログラミング言語の機能を使用できます。

PL/pgSQLを使う主なメリットは以下のとおりです。

メリット 説明
パフォーマンス 複数のSQL文をサーバー側でまとめて実行でき、クライアント・サーバー間の通信オーバーヘッドを削減
再利用性 共通のロジックを関数として定義し、複数箇所から呼び出せる
セキュリティ SECURITY DEFINER機能により、特定の権限でのみ実行可能な処理をカプセル化できる
データ整合性 トリガーと組み合わせることで、アプリケーション非依存でデータ整合性を保証できる

基本的な構文

PL/pgSQLの基本構造は以下のとおりです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CREATE OR REPLACE FUNCTION function_name(parameters)
RETURNS return_type
LANGUAGE plpgsql
AS $$
DECLARE
    -- 変数宣言
    variable_name data_type;
BEGIN
    -- 処理本体
    RETURN result;
END;
$$;

各部分の役割を見ていきましょう。

flowchart TD
    A["CREATE OR REPLACE FUNCTION"] --> B["関数名と引数定義"]
    B --> C["RETURNS: 戻り値の型"]
    C --> D["LANGUAGE plpgsql"]
    D --> E["$$ ... $$: 関数本体"]
    E --> F["DECLARE: 変数宣言"]
    F --> G["BEGIN ... END: 処理ブロック"]
    G --> H["RETURN: 戻り値"]

変数と代入

PL/pgSQLでは、DECLARE節で変数を宣言し、:=演算子またはSELECT INTOで値を代入します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE OR REPLACE FUNCTION calculate_annual_salary(monthly_salary NUMERIC)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
    annual_salary NUMERIC;
    bonus_rate NUMERIC := 0.15;  -- 初期値を設定
BEGIN
    -- 年収 = 月給 × 12 + ボーナス(月給 × ボーナス率 × 12)
    annual_salary := monthly_salary * 12 * (1 + bonus_rate);
    RETURN annual_salary;
END;
$$;

-- 実行例
SELECT calculate_annual_salary(450000);
-- 結果: 6210000

データベースから値を取得して変数に代入する場合は、SELECT INTO構文を使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CREATE OR REPLACE FUNCTION get_department_employee_count(dept_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
    emp_count INTEGER;
BEGIN
    SELECT COUNT(*) INTO emp_count
    FROM employees
    WHERE department_id = dept_id;
    
    RETURN emp_count;
END;
$$;

-- 実行例
SELECT get_department_employee_count(1);
-- 結果: 2

条件分岐

IF文を使って条件分岐を記述できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
CREATE OR REPLACE FUNCTION get_salary_grade(salary NUMERIC)
RETURNS VARCHAR
LANGUAGE plpgsql
AS $$
BEGIN
    IF salary >= 700000 THEN
        RETURN 'S';
    ELSIF salary >= 500000 THEN
        RETURN 'A';
    ELSIF salary >= 350000 THEN
        RETURN 'B';
    ELSE
        RETURN 'C';
    END IF;
END;
$$;

-- 実行例
SELECT name, salary, get_salary_grade(salary) AS grade
FROM employees;

CASE式も使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE OR REPLACE FUNCTION get_employment_status(hire_date DATE)
RETURNS VARCHAR
LANGUAGE plpgsql
AS $$
DECLARE
    years_employed INTEGER;
BEGIN
    years_employed := EXTRACT(YEAR FROM age(CURRENT_DATE, hire_date));
    
    RETURN CASE
        WHEN years_employed >= 10 THEN 'ベテラン'
        WHEN years_employed >= 5 THEN '中堅'
        WHEN years_employed >= 1 THEN '一般'
        ELSE '新入社員'
    END;
END;
$$;

ループ処理

PL/pgSQLでは複数のループ構文を使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- 基本的なFORループ
CREATE OR REPLACE FUNCTION sum_to_n(n INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
    total INTEGER := 0;
    i INTEGER;
BEGIN
    FOR i IN 1..n LOOP
        total := total + i;
    END LOOP;
    
    RETURN total;
END;
$$;

-- クエリ結果をループで処理
CREATE OR REPLACE FUNCTION list_employees_in_department(dept_id INTEGER)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
    emp RECORD;
    result TEXT := '';
BEGIN
    FOR emp IN 
        SELECT name, salary FROM employees WHERE department_id = dept_id
    LOOP
        result := result || emp.name || ': ' || emp.salary || E'\n';
    END LOOP;
    
    RETURN result;
END;
$$;

-- 実行例
SELECT list_employees_in_department(1);

例外処理

BEGIN … EXCEPTION … END構文でエラーをキャッチし、適切に処理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
CREATE OR REPLACE FUNCTION safe_divide(a NUMERIC, b NUMERIC)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN a / b;
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'ゼロ除算が発生しました。NULLを返します。';
        RETURN NULL;
    WHEN OTHERS THEN
        RAISE NOTICE '予期しないエラー: %', SQLERRM;
        RETURN NULL;
END;
$$;

-- 実行例
SELECT safe_divide(10, 0);
-- NOTICE: ゼロ除算が発生しました。NULLを返します。
-- 結果: NULL

RAISEを使ってエラーや通知メッセージを出力できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE OR REPLACE FUNCTION validate_salary(salary NUMERIC)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
    IF salary < 0 THEN
        RAISE EXCEPTION '給与は0以上である必要があります。入力値: %', salary;
    ELSIF salary > 10000000 THEN
        RAISE WARNING '給与が異常に高い値です: %', salary;
    END IF;
    
    RETURN TRUE;
END;
$$;

RAISEのレベルには以下の種類があります。

レベル 説明
DEBUG デバッグ用メッセージ
LOG ログ出力用メッセージ
INFO 情報メッセージ
NOTICE 通知メッセージ(デフォルト)
WARNING 警告メッセージ
EXCEPTION エラー(処理を中断)

ユーザー定義関数の作成

関数の種類と用途

PostgreSQLでは、用途に応じて様々な関数を作成できます。

種類 用途
スカラー関数 単一の値を返す 計算、変換、検証
テーブル関数 複数行を返す 検索、集計
トリガー関数 トリガーから呼び出される 監査、自動更新

スカラー関数の例

業務でよく使う計算ロジックを関数化する例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- 税込み価格計算関数
CREATE OR REPLACE FUNCTION calculate_tax_included_price(
    price NUMERIC,
    tax_rate NUMERIC DEFAULT 0.10
)
RETURNS NUMERIC
LANGUAGE plpgsql
IMMUTABLE  -- 同じ引数なら常に同じ結果を返す
AS $$
BEGIN
    RETURN ROUND(price * (1 + tax_rate), 0);
END;
$$;

-- 実行例
SELECT 
    calculate_tax_included_price(1000) AS with_default_tax,
    calculate_tax_included_price(1000, 0.08) AS with_reduced_tax;
-- 結果: 1100, 1080

関数の属性(IMMUTABLE、STABLE、VOLATILE)は最適化に影響します。

属性 説明 使用例
IMMUTABLE 同じ引数で常に同じ結果。DBを参照しない 数学計算、文字列変換
STABLE 同一トランザクション内で同じ結果 現在時刻参照、設定値参照
VOLATILE 結果が毎回変わりうる(デフォルト) 乱数生成、シーケンス参照

テーブル関数の例

複数行を返す関数はRETURNS TABLEまたはRETURNS SETOFを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 部署別従業員サマリーを返す関数
CREATE OR REPLACE FUNCTION get_department_summary()
RETURNS TABLE (
    department_name VARCHAR,
    employee_count BIGINT,
    total_salary NUMERIC,
    avg_salary NUMERIC
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    SELECT 
        d.name,
        COUNT(e.employee_id),
        COALESCE(SUM(e.salary), 0),
        COALESCE(ROUND(AVG(e.salary), 0), 0)
    FROM departments d
    LEFT JOIN employees e ON d.department_id = e.department_id
    GROUP BY d.department_id, d.name
    ORDER BY d.name;
END;
$$;

-- 実行例
SELECT * FROM get_department_summary();

RETURN QUERYを使うことで、クエリ結果をそのまま関数の戻り値として返せます。

OUT引数の活用

複数の値を返す場合は、OUT引数を使う方法もあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CREATE OR REPLACE FUNCTION get_employee_stats(
    dept_id INTEGER,
    OUT min_salary NUMERIC,
    OUT max_salary NUMERIC,
    OUT avg_salary NUMERIC,
    OUT employee_count INTEGER
)
LANGUAGE plpgsql
AS $$
BEGIN
    SELECT 
        MIN(salary),
        MAX(salary),
        ROUND(AVG(salary), 0),
        COUNT(*)::INTEGER
    INTO min_salary, max_salary, avg_salary, employee_count
    FROM employees
    WHERE department_id = dept_id;
END;
$$;

-- 実行例
SELECT * FROM get_employee_stats(1);

トリガーの基本

トリガーとは

トリガーは、テーブルに対する操作(INSERT、UPDATE、DELETE、TRUNCATE)が発生したときに、自動的に特定の関数を実行する仕組みです。

sequenceDiagram
    participant App as アプリケーション
    participant DB as PostgreSQL
    participant Trigger as トリガー関数
    
    App->>DB: INSERT/UPDATE/DELETE
    DB->>Trigger: トリガー起動
    Trigger->>Trigger: ビジネスロジック実行
    Trigger->>DB: 処理結果返却
    DB->>App: 操作完了

トリガーの種類

トリガーは実行タイミングと粒度で分類されます。

分類 種類 説明
タイミング BEFORE 操作の実行前に起動
AFTER 操作の実行後に起動
INSTEAD OF 操作の代わりに起動(ビュー用)
粒度 FOR EACH ROW 影響を受ける各行で起動
FOR EACH STATEMENT SQL文ごとに1回起動

BEFOREトリガーとAFTERトリガーの使い分けを図で示します。

flowchart LR
    subgraph BEFORE["BEFOREトリガー"]
        B1["データ検証"]
        B2["値の自動設定"]
        B3["操作のキャンセル"]
    end
    
    subgraph Operation["データ操作"]
        O1["INSERT/UPDATE/DELETE"]
    end
    
    subgraph AFTER["AFTERトリガー"]
        A1["監査ログ記録"]
        A2["他テーブル更新"]
        A3["通知送信"]
    end
    
    BEFORE --> Operation --> AFTER

トリガー関数の特殊変数

トリガー関数では、以下の特殊変数が自動的に利用可能になります。

変数 説明
NEW RECORD INSERT/UPDATEでの新しい行データ
OLD RECORD UPDATE/DELETEでの古い行データ
TG_OP TEXT 操作種別(INSERT、UPDATE、DELETE、TRUNCATE)
TG_NAME NAME トリガー名
TG_TABLE_NAME NAME トリガーが設定されたテーブル名
TG_TABLE_SCHEMA NAME テーブルのスキーマ名
TG_WHEN TEXT BEFORE、AFTER、INSTEAD OF
TG_LEVEL TEXT ROWまたはSTATEMENT

基本的なトリガーの作成

トリガーの作成は2ステップで行います。

  1. トリガー関数を作成する
  2. トリガーを定義してテーブルに関連付ける
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- ステップ1: トリガー関数の作成
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.updated_at := CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$;

-- ステップ2: トリガーの定義
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION update_timestamp();

これにより、employeesテーブルの行が更新されるたびに、updated_atカラムが自動的に現在時刻に更新されます。

1
2
3
-- 動作確認
UPDATE employees SET salary = 480000 WHERE employee_id = 1;
SELECT employee_id, name, salary, updated_at FROM employees WHERE employee_id = 1;

実践的なトリガー実装例

監査ログの自動記録

データの変更履歴を自動的に記録する監査ログトリガーを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
-- 監査ログテーブルの作成
CREATE TABLE audit_log (
    audit_id        SERIAL PRIMARY KEY,
    table_name      VARCHAR(100) NOT NULL,
    operation       CHAR(1) NOT NULL,  -- I: INSERT, U: UPDATE, D: DELETE
    old_data        JSONB,
    new_data        JSONB,
    changed_by      VARCHAR(100) NOT NULL,
    changed_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- インデックスの作成(検索性能向上)
CREATE INDEX idx_audit_log_table ON audit_log(table_name);
CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at);

-- 監査ログトリガー関数
CREATE OR REPLACE FUNCTION log_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF (TG_OP = 'DELETE') THEN
        INSERT INTO audit_log (table_name, operation, old_data, changed_by)
        VALUES (TG_TABLE_NAME, 'D', to_jsonb(OLD), current_user);
        RETURN OLD;
    ELSIF (TG_OP = 'UPDATE') THEN
        INSERT INTO audit_log (table_name, operation, old_data, new_data, changed_by)
        VALUES (TG_TABLE_NAME, 'U', to_jsonb(OLD), to_jsonb(NEW), current_user);
        RETURN NEW;
    ELSIF (TG_OP = 'INSERT') THEN
        INSERT INTO audit_log (table_name, operation, new_data, changed_by)
        VALUES (TG_TABLE_NAME, 'I', to_jsonb(NEW), current_user);
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$;

-- employeesテーブルに監査ログトリガーを設定
CREATE TRIGGER employees_audit
    AFTER INSERT OR UPDATE OR DELETE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION log_changes();

動作を確認してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- データ操作
INSERT INTO employees (name, email, department_id, salary, hire_date)
VALUES ('山田次郎', 'yamada@example.com', 2, 400000, '2025-01-01');

UPDATE employees SET salary = 420000 WHERE name = '山田次郎';

DELETE FROM employees WHERE name = '山田次郎';

-- 監査ログの確認
SELECT 
    audit_id,
    table_name,
    operation,
    old_data->>'name' AS old_name,
    old_data->>'salary' AS old_salary,
    new_data->>'name' AS new_name,
    new_data->>'salary' AS new_salary,
    changed_by,
    changed_at
FROM audit_log
ORDER BY audit_id;

自動計算フィールドの更新

部署の従業員数や給与合計を自動的に更新するトリガーを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
-- 部署テーブルに集計カラムを追加
ALTER TABLE departments 
ADD COLUMN employee_count INTEGER DEFAULT 0,
ADD COLUMN total_salary NUMERIC(15, 2) DEFAULT 0;

-- 部署統計更新トリガー関数
CREATE OR REPLACE FUNCTION update_department_stats()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    affected_dept_ids INTEGER[];
BEGIN
    -- 影響を受ける部署IDを収集
    IF (TG_OP = 'DELETE') THEN
        affected_dept_ids := ARRAY[OLD.department_id];
    ELSIF (TG_OP = 'INSERT') THEN
        affected_dept_ids := ARRAY[NEW.department_id];
    ELSIF (TG_OP = 'UPDATE') THEN
        IF OLD.department_id = NEW.department_id THEN
            affected_dept_ids := ARRAY[NEW.department_id];
        ELSE
            affected_dept_ids := ARRAY[OLD.department_id, NEW.department_id];
        END IF;
    END IF;
    
    -- 影響を受ける部署の統計を再計算
    UPDATE departments d
    SET 
        employee_count = (
            SELECT COUNT(*) FROM employees e WHERE e.department_id = d.department_id
        ),
        total_salary = (
            SELECT COALESCE(SUM(salary), 0) FROM employees e WHERE e.department_id = d.department_id
        )
    WHERE department_id = ANY(affected_dept_ids);
    
    IF (TG_OP = 'DELETE') THEN
        RETURN OLD;
    ELSE
        RETURN NEW;
    END IF;
END;
$$;

-- トリガーの作成
CREATE TRIGGER sync_department_stats
    AFTER INSERT OR UPDATE OR DELETE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION update_department_stats();

-- 初期データの同期
UPDATE departments d
SET 
    employee_count = (SELECT COUNT(*) FROM employees e WHERE e.department_id = d.department_id),
    total_salary = (SELECT COALESCE(SUM(salary), 0) FROM employees e WHERE e.department_id = d.department_id);

-- 動作確認
SELECT department_id, name, employee_count, total_salary FROM departments;

データ検証トリガー

CHECK制約では表現できない複雑なビジネスルールを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
-- 給与変更制限トリガー関数
-- ルール: 一度の給与変更は現在給与の20%以内
CREATE OR REPLACE FUNCTION validate_salary_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    max_change_rate NUMERIC := 0.20;
    change_rate NUMERIC;
BEGIN
    -- 新規登録時は制限なし
    IF TG_OP = 'INSERT' THEN
        RETURN NEW;
    END IF;
    
    -- 給与が変更されていない場合はスキップ
    IF OLD.salary = NEW.salary THEN
        RETURN NEW;
    END IF;
    
    -- 変更率を計算
    change_rate := ABS(NEW.salary - OLD.salary) / OLD.salary;
    
    IF change_rate > max_change_rate THEN
        RAISE EXCEPTION '給与変更は現在給与の20%%以内である必要があります。変更率: %.1f%%', 
            change_rate * 100;
    END IF;
    
    RETURN NEW;
END;
$$;

CREATE TRIGGER check_salary_change
    BEFORE UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION validate_salary_change();

-- 動作確認(エラーになる例)
-- UPDATE employees SET salary = 700000 WHERE employee_id = 1;
-- ERROR: 給与変更は現在給与の20%以内である必要があります。変更率: 55.6%

-- 動作確認(成功する例)
UPDATE employees SET salary = 470000 WHERE employee_id = 1;

タイムスタンプの自動更新

作成日時と更新日時を自動管理するトリガーを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 汎用タイムスタンプトリガー関数
CREATE OR REPLACE FUNCTION manage_timestamps()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        NEW.created_at := COALESCE(NEW.created_at, CURRENT_TIMESTAMP);
        NEW.updated_at := CURRENT_TIMESTAMP;
    ELSIF TG_OP = 'UPDATE' THEN
        NEW.updated_at := CURRENT_TIMESTAMP;
        -- created_atは変更させない
        NEW.created_at := OLD.created_at;
    END IF;
    
    RETURN NEW;
END;
$$;

-- トリガーの作成
DROP TRIGGER IF EXISTS set_updated_at ON employees;
CREATE TRIGGER manage_employee_timestamps
    BEFORE INSERT OR UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION manage_timestamps();

条件付きトリガー(WHEN句)

特定の条件でのみトリガーを実行することで、パフォーマンスを最適化できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 給与変更時のみログを記録するトリガー
CREATE TABLE salary_change_log (
    log_id          SERIAL PRIMARY KEY,
    employee_id     INTEGER NOT NULL,
    old_salary      NUMERIC(10, 2),
    new_salary      NUMERIC(10, 2),
    changed_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE OR REPLACE FUNCTION log_salary_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO salary_change_log (employee_id, old_salary, new_salary)
    VALUES (NEW.employee_id, OLD.salary, NEW.salary);
    
    RETURN NEW;
END;
$$;

-- WHEN句で条件を指定
CREATE TRIGGER track_salary_changes
    AFTER UPDATE ON employees
    FOR EACH ROW
    WHEN (OLD.salary IS DISTINCT FROM NEW.salary)
    EXECUTE FUNCTION log_salary_change();

WHEN句を使うことで、条件に合致しない行ではトリガー関数が実行されないため、パフォーマンスが向上します。

トリガーの管理

トリガーの一覧確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 特定テーブルのトリガー一覧
SELECT 
    trigger_name,
    event_manipulation AS event,
    action_timing AS timing,
    action_orientation AS level
FROM information_schema.triggers
WHERE event_object_table = 'employees';

-- システムカタログから詳細情報を取得
SELECT 
    t.tgname AS trigger_name,
    CASE t.tgtype & 2 WHEN 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing,
    CASE t.tgtype & 4 WHEN 4 THEN 'INSERT' END ||
    CASE t.tgtype & 8 WHEN 8 THEN ' DELETE' END ||
    CASE t.tgtype & 16 WHEN 16 THEN ' UPDATE' END AS events,
    p.proname AS function_name,
    t.tgenabled AS enabled
FROM pg_trigger t
JOIN pg_proc p ON t.tgfoid = p.oid
JOIN pg_class c ON t.tgrelid = c.oid
WHERE c.relname = 'employees'
  AND NOT t.tgisinternal;

トリガーの有効化・無効化

メンテナンス作業時など、一時的にトリガーを無効化したい場合があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 特定のトリガーを無効化
ALTER TABLE employees DISABLE TRIGGER employees_audit;

-- テーブルの全トリガーを無効化
ALTER TABLE employees DISABLE TRIGGER ALL;

-- 特定のトリガーを有効化
ALTER TABLE employees ENABLE TRIGGER employees_audit;

-- テーブルの全トリガーを有効化
ALTER TABLE employees ENABLE TRIGGER ALL;

大量データ投入時のパフォーマンス向上のためにトリガーを一時無効化する場合は、トランザクション内で行うことを推奨します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
BEGIN;
    -- トリガーを無効化
    ALTER TABLE employees DISABLE TRIGGER ALL;
    
    -- 大量データ投入
    INSERT INTO employees (name, email, department_id, salary, hire_date)
    SELECT ... FROM staging_table;
    
    -- トリガーを有効化
    ALTER TABLE employees ENABLE TRIGGER ALL;
    
    -- 必要に応じて手動で集計値を更新
    UPDATE departments d
    SET 
        employee_count = (SELECT COUNT(*) FROM employees e WHERE e.department_id = d.department_id),
        total_salary = (SELECT COALESCE(SUM(salary), 0) FROM employees e WHERE e.department_id = d.department_id);
COMMIT;

トリガーの削除

1
2
3
4
5
-- トリガーの削除
DROP TRIGGER IF EXISTS employees_audit ON employees;

-- トリガー関数の削除(依存するトリガーがないことを確認)
DROP FUNCTION IF EXISTS log_changes();

注意点とベストプラクティス

パフォーマンスへの影響

トリガーはデータ操作のたびに実行されるため、パフォーマンスへの影響を考慮する必要があります。

考慮点 対策
トリガー関数の処理時間 複雑な処理は避け、シンプルに保つ
大量データ操作時の負荷 バッチ処理時はトリガーを一時無効化
カスケード(連鎖)実行 トリガーから別テーブルを更新する際の連鎖に注意
1
2
3
-- パフォーマンス計測例
EXPLAIN ANALYZE
UPDATE employees SET salary = salary * 1.05 WHERE department_id = 1;

デバッグとテスト

トリガー関数のデバッグにはRAISE NOTICEを活用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CREATE OR REPLACE FUNCTION debug_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    RAISE NOTICE 'トリガー発火: %, 操作: %, テーブル: %', 
        TG_NAME, TG_OP, TG_TABLE_NAME;
    RAISE NOTICE 'OLD: %, NEW: %', 
        CASE WHEN OLD IS NULL THEN 'NULL' ELSE OLD::TEXT END,
        CASE WHEN NEW IS NULL THEN 'NULL' ELSE NEW::TEXT END;
    
    IF TG_OP = 'DELETE' THEN
        RETURN OLD;
    ELSE
        RETURN NEW;
    END IF;
END;
$$;

トリガーの設計原則

トリガーを適切に設計するための原則をまとめます。

mindmap
  root((トリガー設計原則))
    単純性
      1つのトリガーに1つの責務
      複雑なロジックは分割
    べき等性
      同じ操作を複数回実行しても同じ結果
      再実行可能な設計
    透明性
      予期しない副作用を避ける
      ドキュメント化
    保守性
      命名規則の統一
      テスト可能な設計

アプリケーション層との役割分担

トリガーとアプリケーション層でロジックを適切に分担することが重要です。

実装場所 適したロジック
データベース(トリガー) データ整合性、監査、自動計算 タイムスタンプ更新、監査ログ、集計値更新
アプリケーション UI連携、外部API呼び出し、複雑なビジネスルール メール送信、Webhook、承認フロー

トリガーに向いているケースと向いていないケースを判断する基準は以下のとおりです。

トリガーに向いているケース:

  • すべてのアプリケーションで一貫して適用したいルール
  • データベースの整合性に直接関わる処理
  • 監査・ログ記録
  • 派生データの自動計算

トリガーに向いていないケース:

  • 外部システムとの連携(API呼び出し、メール送信など)
  • 長時間実行される処理
  • ユーザーインタラクションが必要な処理
  • 頻繁に変更されるビジネスルール

セキュリティ考慮事項

トリガー関数は、関数を定義したユーザーの権限ではなく、トリガーを発火させた操作を実行したユーザーの権限で実行されます。必要に応じてSECURITY DEFINERを使用できますが、セキュリティリスクに注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- SECURITY DEFINERを使用する場合は、search_pathを固定する
CREATE OR REPLACE FUNCTION secure_audit_log()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
    -- 監査ログ処理
    INSERT INTO audit_log (table_name, operation, new_data, changed_by)
    VALUES (TG_TABLE_NAME, 'I', to_jsonb(NEW), current_user);
    
    RETURN NEW;
END;
$$;

まとめ

本記事では、PostgreSQLのトリガーと関数を使ったデータベースレベルのビジネスロジック実装について解説しました。

主なポイントを振り返ります。

  • PL/pgSQLの基礎: 変数、条件分岐、ループ、例外処理などの基本構文を習得することで、複雑なロジックを実装できます
  • ユーザー定義関数: スカラー関数、テーブル関数を作成し、共通処理を再利用可能な形でカプセル化できます
  • トリガーの仕組み: BEFORE/AFTER、行レベル/文レベルの違いを理解し、用途に応じて使い分けます
  • 実践的な実装: 監査ログ、自動計算、データ検証など、実務で役立つトリガーパターンを実装できます
  • ベストプラクティス: パフォーマンス、保守性、セキュリティを考慮した設計が重要です

トリガーと関数を適切に活用することで、データ整合性の向上、アプリケーションコードの簡素化、監査機能の一元化といったメリットを得られます。一方で、過度な使用はデバッグの困難さやパフォーマンス低下を招く可能性があるため、アプリケーション層との適切な役割分担を意識して設計してください。

参考リンク