【PL/SQL】仮想列とトリガーの整合性設計|制約との使い分け・mutating table・監査

【PL/SQL】仮想列(Virtual Column)とトリガーを使った自動整合設計 PL/SQL

Oracleでデータの整合性を守る方法は、アプリケーション側のif文だけではありません。同じ行の値から必ず導ける派生値は仮想列(Virtual Column)、単純な禁止ルールは制約、監査ログや別表更新などの副作用が必要な場合はトリガーに寄せると、更新漏れや実装差を減らせます。

ただし、仮想列とトリガーを何でも自動整合の道具として使うと、逆に保守しにくくなります。仮想列は同じ行の値から計算できる式に向き、トリガーはDMLの裏で処理が走るため、性能、ロック、例外、無効化、mutating tableを意識する必要があります。トリガー単体の基礎は PL/SQLトリガー完全ガイド、例外処理は PL/SQL例外処理完全ガイド もあわせて確認してください。

この記事で整理すること

  • 仮想列、制約、トリガー、アプリ実装の使い分け
  • 仮想列で派生値を持つ基本パターン
  • CHECK制約・UNIQUE制約・索引との組み合わせ
  • トリガーで監査・補正・別表反映を行うときの注意点
  • mutating table、compound trigger、INVALIDトリガーの実務対策
  • 本番投入前のチェックリスト
スポンサーリンク

最初に結論:まず制約、次に仮想列、最後にトリガー

整合性設計では、トリガーを最初に選ばない方が安全です。トリガーは強力ですが、DMLの裏で処理が走るため、呼び出し元から見えにくく、失敗するとINSERTやUPDATE自体を止めます。まず制約で守れるか、次に仮想列で表現できるか、それでも足りない場合にトリガーを使う順番にします。

制約で守るNOT NULLCHECKUNIQUE、外部キーで表現できるルールは制約にします。最も明示的で、DBが最適化しやすいです。
仮想列で表す同じ行の列から計算できる派生値、正規化した検索キー、JSONから取り出した値などは仮想列に向きます。
トリガーで補う監査ログ、更新者情報の自動設定、別表への履歴出力、複数列の補正など、副作用が必要な処理に限定します。
アプリ側で扱う画面表示だけの値、ユーザー操作に依存する確認、外部APIが絡む判断はDBトリガーに閉じ込めない方が扱いやすいです。

性能が理由でDB側に寄せる場合でも、まず現状計測が必要です。実行計画や待機を見ずにトリガーを増やすと、原因が追いにくい遅延になります。性能改善全体は PL/SQLパフォーマンス改善プレイブック、運用観測は PL/SQLインストゥルメンテーション設計 が関連します。

仮想列が向いているケース

仮想列は、表に格納されている列をもとに計算される列です。通常の列のようにSELECTでき、必要に応じて制約や索引と組み合わせられます。同じ計算式をアプリやSQLの各所に散らさず、テーブル定義に寄せられるのが利点です。

同じ行から計算できる派生値

virtual-column-basic.sql
CREATE TABLE order_lines (
  order_line_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  quantity      NUMBER NOT NULL,
  unit_price    NUMBER NOT NULL,
  tax_rate      NUMBER DEFAULT 0.10 NOT NULL,
  subtotal      NUMBER GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL,
  tax_amount    NUMBER GENERATED ALWAYS AS (quantity * unit_price * tax_rate) VIRTUAL,
  total_amount  NUMBER GENERATED ALWAYS AS (quantity * unit_price * (1 + tax_rate)) VIRTUAL
);

SELECT order_line_id,
       subtotal,
       tax_amount,
       total_amount
FROM order_lines;

合計金額のような派生値を実列として持つと、更新漏れで整合性が崩れます。仮想列なら、元の列が変われば計算結果も常に変わります。ただし、仮想列は計算式で表せる範囲に限られるため、別表参照や複数行集計まで抱え込む設計にはしません。

検索キーを正規化する

virtual-column-search-key.sql
CREATE TABLE customers (
  customer_id     NUMBER PRIMARY KEY,
  customer_code   VARCHAR2(30) NOT NULL,
  customer_name   VARCHAR2(200) NOT NULL,
  normalized_code VARCHAR2(30)
    GENERATED ALWAYS AS (UPPER(TRIM(customer_code))) VIRTUAL
);

CREATE UNIQUE INDEX ux_customers_normalized_code
ON customers(normalized_code);

大文字小文字や前後空白を無視して一意にしたい場合、仮想列と索引を組み合わせると、アプリごとの正規化漏れを防げます。索引を作る場合は、登録・更新時のコストも測定します。

制約で済むケース

値の範囲、必須、重複禁止、状態遷移の一部などは、トリガーより制約で表現した方が明確です。制約はデータモデルの一部として見えやすく、トリガーのように裏で何かが動くわけではありません。

CHECK制約と仮想列を組み合わせる

constraint-with-virtual-column.sql
CREATE TABLE contracts (
  contract_id   NUMBER PRIMARY KEY,
  start_date    DATE NOT NULL,
  end_date      DATE,
  status        VARCHAR2(20) NOT NULL,
  active_flag   NUMBER
    GENERATED ALWAYS AS (
      CASE
        WHEN end_date IS NULL OR end_date >= start_date THEN 1
        ELSE 0
      END
    ) VIRTUAL,
  CONSTRAINT ck_contract_period
    CHECK (end_date IS NULL OR end_date >= start_date),
  CONSTRAINT ck_contract_status
    CHECK (status IN ('DRAFT', 'ACTIVE', 'CLOSED'))
);

CREATE INDEX ix_contracts_active_flag
ON contracts(active_flag);

この例では、期間の妥当性はCHECK制約で止め、検索や判定に使う値を仮想列にしています。「エラーにしたいルール」は制約、「読みやすくしたい派生値」は仮想列、と分けると設計が読みやすくなります。

仮想列の注意点

仮想列は便利ですが、万能ではありません。同じ行の値から安定して計算できる式に絞り、複雑な業務手続きや副作用を持たせないことが重要です。

別表参照にしない価格表やマスタ表を見に行くようなロジックは、仮想列ではなくJOIN、MV、バッチ更新などを検討します。
複数行集計にしない同じ顧客の合計、同じ日の件数などは仮想列の守備範囲ではありません。集計SQLやMVの領域です。
式を複雑にしすぎない長すぎるCASE式や業務分岐を押し込むと、DDLを読まないと仕様が分からなくなります。
索引コストを測る仮想列に索引を付けると検索は速くなる可能性がありますが、DML時の索引メンテナンスは増えます。

仮想列をパーティションキーに使う設計もありますが、データ量、ロード方式、検索条件、メンテナンス手順を含めて検討します。パーティション設計は Oracleパーティションテーブル完全ガイド が関連します。

JSON値を仮想列として検索しやすくする

JSONをそのまま保存する設計では、よく検索する属性だけ仮想列として取り出すと扱いやすくなります。すべてを仮想列にするのではなく、WHERE句やJOIN条件で頻繁に使う項目だけに絞ります。

json-virtual-column.sql
CREATE TABLE api_events (
  event_id     NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  payload      CLOB CHECK (payload IS JSON),
  event_type   VARCHAR2(50)
    GENERATED ALWAYS AS (
      JSON_VALUE(payload, '$.type' RETURNING VARCHAR2(50))
    ) VIRTUAL,
  tenant_id    VARCHAR2(50)
    GENERATED ALWAYS AS (
      JSON_VALUE(payload, '$.tenantId' RETURNING VARCHAR2(50))
    ) VIRTUAL
);

CREATE INDEX ix_api_events_tenant_type
ON api_events(tenant_id, event_type);

JSON処理の詳細は Oracle JSON完全ガイドPL/SQL JSON_TABLEでJSONを取り込む方法 が近いテーマです。REST APIで例外をJSONとして返す設計は PL/SQL例外をJSON形式で返すREST対応API設計 も参考になります。

トリガーが向いているケース

トリガーは、DMLに連動して別の処理を必ず実行したい場合に使います。代表例は監査ログ、作成者・更新者の自動設定、履歴表への出力、非正規化列の補正です。ただし、処理が見えにくくなるため、業務の中心ロジックを大量に詰め込まないようにします。

更新者と更新日時を自動設定する

before-row-trigger.sql
CREATE OR REPLACE TRIGGER trg_orders_biu
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
BEGIN
  IF INSERTING THEN
    :NEW.created_at := SYSTIMESTAMP;
    :NEW.created_by := SYS_CONTEXT('USERENV', 'SESSION_USER');
  END IF;

  :NEW.updated_at := SYSTIMESTAMP;
  :NEW.updated_by := SYS_CONTEXT('USERENV', 'SESSION_USER');
END;
/

:NEW は登録・更新後の値、:OLD は更新前・削除前の値です。行トリガーでは1行ごとに処理が走るため、大量更新時のコストを必ず確認します。権限や実行者の考え方は AUTHIDと権限管理の設計 も関連します。

監査ログを残す

audit-trigger.sql
CREATE TABLE order_audit_logs (
  audit_id      NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  order_id      NUMBER NOT NULL,
  action_name   VARCHAR2(20) NOT NULL,
  old_status    VARCHAR2(20),
  new_status    VARCHAR2(20),
  changed_by    VARCHAR2(128),
  changed_at    TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
);

CREATE OR REPLACE TRIGGER trg_orders_audit
AFTER UPDATE OF status ON orders
FOR EACH ROW
WHEN (OLD.status <> NEW.status)
BEGIN
  INSERT INTO order_audit_logs (
    order_id,
    action_name,
    old_status,
    new_status,
    changed_by
  ) VALUES (
    :NEW.order_id,
    'STATUS_CHANGE',
    :OLD.status,
    :NEW.status,
    SYS_CONTEXT('USERENV', 'SESSION_USER')
  );
END;
/

監査目的なら、トリガーで業務ログを残す設計のほかに、Oracleの監査機能を使う選択肢もあります。細かい条件で監査したい場合は DBMS_FGAでファイングレイン監査を設定する方法 も検討対象になります。

mutating tableを避ける

行トリガーの中で、同じ表をSELECTしたり更新したりすると、mutating table問題にぶつかることがあります。「そのDMLの途中で変化中の表」を、行ごとのトリガーから参照しようとするためです。

避けたい例

mutating-table-ng.sql
CREATE OR REPLACE TRIGGER trg_orders_check_ng
AFTER INSERT OR UPDATE ON order_lines
FOR EACH ROW
DECLARE
  l_total NUMBER;
BEGIN
  SELECT SUM(amount)
  INTO l_total
  FROM order_lines
  WHERE order_id = :NEW.order_id;

  IF l_total > 1000000 THEN
    RAISE_APPLICATION_ERROR(-20001, 'order total limit exceeded');
  END IF;
END;
/

このような検証は、行トリガーの中で同じ表を集計するのではなく、制約、アプリ側の明示処理、バッチ検証、compound triggerなどに分けて考えます。

compound triggerで行情報をまとめる

compound-trigger.sql
CREATE OR REPLACE TRIGGER trg_order_lines_limit
FOR INSERT OR UPDATE OR DELETE ON order_lines
COMPOUND TRIGGER

  TYPE t_order_ids IS TABLE OF order_lines.order_id%TYPE INDEX BY PLS_INTEGER;
  g_order_ids t_order_ids;
  g_count     PLS_INTEGER := 0;

  AFTER EACH ROW IS
  BEGIN
    g_count := g_count + 1;
    g_order_ids(g_count) := COALESCE(:NEW.order_id, :OLD.order_id);
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    l_total NUMBER;
  BEGIN
    FOR i IN 1 .. g_count LOOP
      SELECT SUM(amount)
      INTO l_total
      FROM order_lines
      WHERE order_id = g_order_ids(i);

      IF l_total > 1000000 THEN
        RAISE_APPLICATION_ERROR(-20001, 'order total limit exceeded');
      END IF;
    END LOOP;
  END AFTER STATEMENT;

END trg_order_lines_limit;
/

compound triggerは、行ごとの情報を保持し、文の最後にまとめて処理できます。ただし、これも大量DMLでは重くなります。複雑な検証はトランザクション設計やバッチ検証に寄せる方が安定する場合があります。トランザクション全体は PL/SQLトランザクション設計パターン、大量処理のCOMMIT設計は 大量データ処理のコミット頻度とUNDO最適化 を確認してください。

トリガーの例外処理

トリガー内で例外が発生すると、呼び出し元のDMLも失敗します。WHEN OTHERS THEN NULL で握りつぶすと、データ不整合や監査漏れに気づけません。業務上止めるべきエラーは明示的に止め、ログだけ残したい処理は設計を分けます。

trigger-error-handling.sql
CREATE OR REPLACE TRIGGER trg_orders_validate
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
BEGIN
  IF :NEW.order_status = 'SHIPPED'
     AND :NEW.shipped_at IS NULL THEN
    RAISE_APPLICATION_ERROR(
      -20010,
      'shipped_at is required when order_status is SHIPPED'
    );
  END IF;
END;
/

トリガーで止めるエラーは、アプリ側に返るメッセージも含めて設計します。例外設計や再試行が絡む場合は 例外設計と再試行パターン を組み合わせると整理しやすくなります。

INVALIDトリガーと依存関係を監視する

トリガーが依存する表、列、パッケージ、関数を変更すると、トリガーがINVALIDになることがあります。無効なトリガーは、DML時に再コンパイルを試み、失敗するとDMLを止める可能性があります。本番では、リリース後にトリガー状態を確認する手順が必要です。

invalid-trigger-check.sql
SELECT object_name,
       object_type,
       status
FROM user_objects
WHERE object_type = 'TRIGGER'
ORDER BY status, object_name;

SELECT name,
       type,
       referenced_name,
       referenced_type
FROM user_dependencies
WHERE type = 'TRIGGER'
ORDER BY name, referenced_type, referenced_name;

ALTER TRIGGER trg_orders_biu COMPILE;

トリガーが無効でDMLが失敗する例は ORA-04098の原因と解決方法、依存オブジェクトの管理は 依存オブジェクトとINVALID再コンパイルの制御 が関連します。

避けたい設計

表示用の値までトリガーで保存する画面表示だけならSELECTやビューで十分な場合があります。保存すると更新漏れの温床になります。
制約で済むルールをトリガーにする単純な範囲チェックや必須チェックは制約の方が明確です。
トリガーから外部APIを呼ぶDMLが外部状態に引きずられ、遅延や失敗の原因がDB更新に混ざります。
大量DMLで重い処理を走らせる行ごとに集計、別表更新、ログ出力を行うと、バッチ性能が急落します。
例外を握りつぶす失敗したのに成功扱いになると、あとから原因を追えません。必要なら明示的にログ化し、RAISEします。

本番投入前チェックリスト

  • 制約で表現できるルールをトリガーにしていない
  • 仮想列は同じ行の値から安定して計算できる式に限定している
  • 仮想列に索引を付ける場合、検索側とDML側の両方を測定している
  • トリガー内で同じ表を不用意に参照していない
  • 大量INSERT/UPDATE/DELETE時の実行時間、UNDO、REDO、ロックを確認している
  • :NEW:OLD の使えるタイミングを確認している
  • トリガー内の例外を握りつぶしていない
  • リリース後に USER_OBJECTS でINVALIDトリガーを確認する
  • 監査ログの保存期間、削除、パーティション、検索条件を決めている
  • アプリ側から見えない副作用を設計書やコメントに残している
production-check.sql
-- Virtual columns
SELECT table_name,
       column_name,
       data_default
FROM user_tab_cols
WHERE virtual_column = 'YES'
ORDER BY table_name, column_id;

-- Triggers
SELECT trigger_name,
       table_name,
       triggering_event,
       trigger_type,
       status
FROM user_triggers
ORDER BY table_name, trigger_name;

-- Invalid objects
SELECT object_name,
       object_type,
       status
FROM user_objects
WHERE status <> 'VALID'
ORDER BY object_type, object_name;

まとめ

仮想列とトリガーは、どちらもデータベース側で整合性を守るための強い道具です。しかし、まず制約で守れるかを確認し、同じ行から導ける派生値は仮想列にし、副作用が必要な場合だけトリガーを使う、という順番が大切です。

トリガーは特に、mutating table、性能、例外、INVALID、監査ログ肥大の影響を受けます。本番では、DML側の性能測定、依存関係の確認、リリース後のINVALIDチェック、ログ運用まで含めて設計しましょう。