データの一貫性をアプリ任せにすると、更新漏れや整合崩れが必ず起きます。Oracleでは「仮想列(Virtual Column)」と「トリガー(Trigger)」を組み合わせることで、導出値や監査項目、正規化の補助列をデータベース側で自動生成・検証できます。仮想列は式から常に計算される読み取り専用の列で、索引や制約、パーティションキーにも使えます。トリガーは行の挿入・更新・削除のタイミングで任意の処理を差し込みます。この記事では、仮想列で“宣言的”に整合を担保し、足りない箇所だけトリガーで“命令的”に補う設計を、実用コードとともに解説します。
仮想列の基本:式で定義し、保存せずに常に最新
仮想列は「列 = 式」で宣言し、物理的には保存されません。読み取り時に評価されるため、派生値が常に最新で、更新漏れが起きません。索引化や制約付与も可能なので、検証や検索の“足場”として使えます。
-- 氏名の正規化とメールドメイン抽出を仮想列で定義
CREATE TABLE customers (
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR2(50) NOT NULL,
last_name VARCHAR2(50) NOT NULL,
email VARCHAR2(320) NOT NULL,
full_name VARCHAR2(120) GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL,
email_domain VARCHAR2(255) GENERATED ALWAYS AS (SUBSTR(email, INSTR(email, '@')+1)) VIRTUAL
);
-- 仮想列に索引や制約を付与(検索と品質担保をDB側で)
CREATE INDEX ix_customers_email_domain ON customers(email_domain);
ALTER TABLE customers ADD CONSTRAINT ck_email_has_at CHECK (INSTR(email, '@') > 1);
仮想列は式の評価結果に依存するため、関数を含める場合は決定的な(DETERMINISTIC)関数を使うか、関数ベース索引の特性を理解して設計します。高頻度参照の仮想列には索引を張ると実行計画が安定します。
導出キーと重複排除:仮想列+一意制約で冪等性を担保
外部連携などで重複登録を防ぐには、入力から導出した冪等キーを仮想列で表し、一意制約で“契約”にします。アプリ側の判定に依存しないため、並行実行でも安全です。
-- 受注明細の冪等キー(注文ID+行番号)を仮想列で生成し一意化
CREATE TABLE order_items (
order_id NUMBER NOT NULL,
line_no NUMBER NOT NULL,
sku VARCHAR2(30) NOT NULL,
qty NUMBER NOT NULL,
idem_key VARCHAR2(100) GENERATED ALWAYS AS (
TO_CHAR(order_id) || ':' || TO_CHAR(line_no)
) VIRTUAL,
CONSTRAINT pk_order_items PRIMARY KEY(order_id, line_no),
CONSTRAINT uk_order_items_idem UNIQUE (idem_key)
);
この構成だと、同一の注文行が再投入されても一意制約で自然に弾けます。冪等キーを別列として保持しないため、整合の二重管理も発生しません。
検証を宣言的に:仮想列×CHECKで複雑ルールを簡潔に表現
複数列の整合ルールは、まず仮想列で「検証用の導出値」に落とし、CHECK制約で許容値を限定すると読みやすく運用しやすい形になります。
-- 価格整合の検証。税込額が税抜×(1+税率)に一致しているかを仮想列で算出
CREATE TABLE invoices (
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
amount_ex NUMBER(12,2) NOT NULL,
tax_rate NUMBER(5,4) NOT NULL, -- 0.1000 = 10%
amount_inc NUMBER(12,2) NOT NULL,
diff_chk NUMBER(12,4) GENERATED ALWAYS AS (
amount_inc - ROUND(amount_ex * (1 + tax_rate), 2)
) VIRTUAL,
CONSTRAINT ck_invoices_consistent CHECK (ABS(diff_chk) < 0.005)
);
検証ロジックがDBに常設されるため、バッチやAPI、GUIなど複数経路からの投入でも一貫した品質保証が効きます。
JSONや日付正規化でも効果的:仮想列×JSON_VALUE/正規化関数
半構造データのキー抽出や、日付丸め、テキスト正規化を仮想列で表すと、下流の検索や集計がシンプルになります。
-- JSONペイロードからカスタマIDと発生日(UTC→日単位)を切り出す
CREATE TABLE events_raw (
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
payload CLOB CHECK (payload IS JSON),
cust_id NUMBER GENERATED ALWAYS AS (
JSON_VALUE(payload, '$.customer.id' RETURNING NUMBER)
) VIRTUAL,
event_d DATE GENERATED ALWAYS AS (
CAST(FROM_TZ(CAST(JSON_VALUE(payload,'$.occurredAt' RETURNING TIMESTAMP) AS TIMESTAMP),'UTC')
AT LOCAL AS DATE)
) VIRTUAL
);
CREATE INDEX ix_events_cust ON events_raw(cust_id);
CREATE INDEX ix_events_day ON events_raw(event_d);
このようにしておけば、外部からの投入形式が多少揺れても、検索は仮想列に対して一定の書き方で行えます。
トリガーは“最後の一押し”に限定:ID付与・監査・交差表検証
仮想列で表現しづらい「外部副作用の伴わない小さな命令的処理」はトリガーで補います。ID採番や監査項目の自動入力、同一表内で完結するビジネスルールの補正などが相性の良い領域です。
-- 監査項目の自動入力(自治トランザクションは使わない:本流と同一で整合)
CREATE TABLE audit_demo (
id NUMBER PRIMARY KEY,
payload VARCHAR2(200),
created_at TIMESTAMP,
created_by VARCHAR2(128),
updated_at TIMESTAMP,
updated_by VARCHAR2(128)
);
CREATE OR REPLACE TRIGGER trg_audit_demo_biu
BEFORE INSERT OR UPDATE ON audit_demo
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;
/
監査の書き込みを本流と分離する必要がある場合のみ、別テーブルへ自治トランザクションで複製する方針にします。むやみにAUTONOMOUS_TRANSACTIONを使うと整合を乱すため、本当に必要な場面に限定します。
トリガーでの防波堤:業務ルールの最終チェック
アプリの都合で完全な検証ができない場合、BEFOREトリガーで“最後の確認”を行い、逸脱時は明示的にエラーを出します。
-- 在庫マイナスの禁止を最終防御(業務上の例外はアプリ側で事前チェックが原則)
CREATE TABLE stock (
sku VARCHAR2(30) PRIMARY KEY,
qty NUMBER NOT NULL
);
CREATE OR REPLACE TRIGGER trg_stock_no_negative
BEFORE UPDATE OF qty ON stock
FOR EACH ROW
BEGIN
IF :NEW.qty < 0 THEN
RAISE_APPLICATION_ERROR(-20001, '在庫がマイナスになります');
END IF;
END;
/
本来は集合志向で整合を作るのが王道ですが、どうしても漏れうる境界条件はトリガーで拒否してデータ破壊を防ぎます。
仮想列とトリガーのハイブリッド:軽い算出は仮想、重い整形はトリガー
設計の指針は、計算が決定的で参照頻度が高く、保存するまでもないものは仮想列に寄せ、正規化や外部参照が絡む重い処理はアプリまたはトリガーに寄せることです。片側に寄せすぎると、仮想列は複雑化してコストが読めなくなり、トリガーは副作用の温床になります。次のように役割を分けると実装が安定します。
-- 例:検索キーは仮想列、冗長保持が必要な軽量サマリはトリガーでキャッシュ列へ
CREATE TABLE payments (
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
amount NUMBER(12,2) NOT NULL,
fee_rate NUMBER(5,4) NOT NULL,
fee_v NUMBER(12,2) GENERATED ALWAYS AS (ROUND(amount * fee_rate, 2)) VIRTUAL, -- 参照時算出
fee_cached NUMBER(12,2) -- レポート要件で物理保持が必要な場合のみ
);
CREATE OR REPLACE TRIGGER trg_payments_fee_cache
BEFORE INSERT OR UPDATE OF amount, fee_rate ON payments
FOR EACH ROW
BEGIN
:NEW.fee_cached := ROUND(:NEW.amount * :NEW.fee_rate, 2);
END;
/
まずは仮想列で済ませ、どうしても物理列が必要な要件(外部連携の固定レイアウトや履歴保持など)が出たら最小限のトリガーで補完します。
パーティションやクエリ最適化への応用:仮想列をキーに使う
日付丸めや区分値の仮想列は、パーティションキーやフィルタ条件に直結させるとスキャン量を大幅に削減できます。
-- 月単位の仮想列でレンジパーティション
CREATE TABLE fact_sales (
id NUMBER,
sale_ts TIMESTAMP,
ym DATE GENERATED ALWAYS AS (TRUNC(CAST(sale_ts AS DATE),'MM')) VIRTUAL
)
PARTITION BY RANGE (ym) (
PARTITION p202510 VALUES LESS THAN (DATE '2025-11-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
この設計だと、WHERE ym = DATE ‘2025-10-01’ の問い合わせが自然とパーティションプルーニングされ、索引不要でも高速に走ります。
落とし穴と対処:決定性・コスト・副作用の三点を見る
仮想列に非決定的関数(SYSDATEや乱数など)を使うと、キャッシュや索引との相性が悪化します。高コストな関数やサブクエリを仮想列に入れるとSELECTごとに実行され遅くなるため、必要なら関数ベース索引や物理列+トリガーへ切り替えます。トリガーは外部呼び出しや重いループ、自治トランザクションの乱用でスループットを下げやすく、またデバッグも難しくなるので、処理は極力小さく、失敗時は明確なエラーコードで終了させます。DDLや依存関数の再コンパイルは仮想列の実行計画に影響するため、リリース直後に代表クエリのウォームアップと統計更新を実施すると安定します。
まとめ
まずは仮想列で導出値と検証を“宣言的”に定義し、索引と制約で性能と品質を同時に確保します。どうしても宣言的に表現できない最終チェックや軽量な冗長保持だけをトリガーに託し、命令的な処理は最小化します。日付丸めやJSON抽出の仮想列を検索やパーティションキーへ繋げると、読み取りの性能が安定します。決定性・コスト・副作用の三点を見極め、仮想列を“常に最新・更新漏れゼロ”の柱に、トリガーを“最後の一押し”として添えるのが、実務で強い自動整合設計の最短ルートです。