PostgreSQL 入門

PostgreSQL CHECK制約

データバリデーションはデータベース管理における極めて重要なプロセスであり、保存される情報の正確性、一貫性、そして信頼性を保証するものです。前章で学んだように、カラムに適切なデータ型(データタイプ)を定義することは、データクオリティを維持するための第一歩となります。

しかし、データ型を指定するだけでは、複雑なビジネスルールやロジック条件を強制するには不十分な場合が多々あります。例えば、「数値カラムは正の数でなければならない」あるいは「日付カラムは未来の時間でなければならない」といったケースです。ここで登場するのが CHECK(チェック)制約 です。

CHECK制約を使用すると、単一のカラムに入力されるデータ、あるいは複数カラムにまたがるデータが満たすべき特定の条件を定義できます。これは強力なゲートキーパー(門番)のように機能し、無効なデータがデータベースに保存されるのを根本から防ぎます。CHECK制約を実装することで、データ整合性(データインテグリティ)を劇的に向上させ、より信頼性の高いデータ基盤と堅牢なアプリケーションを構築することが可能になります。

1. CHECK制約とは何か

PostgreSQLにおいて、CHECK制約とは整合性制約(インテグリティコンストレイント)の一種であり、データベース設計者がブーリアン式(Boolean expression)を指定することを可能にします。INSERT(インサート)UPDATE(アップデート) 操作によって作成または変更される全ての行は、この式を満たさなければ操作を成功させることはできません。

  • 式がその行に対して FALSE(偽) と評価された場合、操作は拒否され、エラーが返されます。
  • 式が TRUE(真) または UNKNOWN(未知、通常は式の中にNULL値が存在する場合) と評価された場合、操作は許可されます。

CHECK制約の主な目的は、単純なデータ型定義や、NOT NULLUNIQUE といった基本制約だけではカバーできないビジネスルールやドメインの整合性を強制することです。例えば:

  • ドメイン制限 (Domain Restriction): 数値が特定の範囲内に収まっていることを保証する(例:年齢が0から120の間、パーセンテージが0から100の間)。
  • 条件ロジック (Conditional Logic): 同一要素(行)内の他のカラムの値に基づいてデータを検証する(例:end_date(終了日)は start_date(開始日)より後でなければならない)。
  • パターンマッチング (Pattern Matching): テキストデータが特定のパターンに適合していることを保証する(LIKE / SIMILAR TO / ~ を利用したCHECK制約も可能ですが、複雑なパターンには正規表現関数が適しています。ただしパフォーマンス負荷には注意が必要です)。

CHECK制約はデータベースレイヤーでルールを強制するため、データクオリティの維持に不可欠です。つまり、アプリケーション経由、SQLスクリプトの直接実行、あるいは他のツール経由であっても、指定された条件は常にチェックされます。これによりバリデーションロジックが集中管理され、アプリケーションのバグによる「ダーティデータ(汚れたデータ)」の混入リスクを低減し、データベース全体の信頼性を高めることができます。

2. CHECK制約におけるNULL値の扱い

NULL 値がCHECK制約とどのようにインタラクションするかを理解することは非常に重要です。CHECK制約のブーリアン式内のいずれかのオペランド(演算数)が NULL と評価された場合、式全体の結果は UNKNOWN(未知) とみなされます。

SQLのロジック体系において、UNKNOWNFALSEと等価ではありません。したがって、式がUNKNOWNと評価された場合、システムはその制約が満たされたと判断し、その行のインサートやアップデートを許可します。

この挙動は、カラムがオプション(NULLを許可)でありながら、「値が提供された場合にのみ特定のルールに従わなければならない」という状況で特に便利です。例えば、discount_percentage(割引率)カラムは、割引が適用された場合にのみ0から100の間である必要があります。もし discount_percentageNULL(割引なしを意味する)であれば、制約はそれをブロックする必要がないからです。

3. テーブル作成時のCHECK制約の定義

CREATE TABLE 文を使用してテーブルを作成する際に、直接CHECK制約を定義できます。検証ルールが事前にはっきりしている場合、最初からデータ整合性を確保できるため、これが推奨される方法です。

CREATE TABLE 内でCHECK制約を定義するには、主に2つの方法があります:

  • カラムレベル制約 (Column-level constraint): カラム定義の一部として定義され、通常はそのカラムのみを参照します。
  • テーブルレベル制約 (Table-level constraint): カラム定義とは別に定義され、同一行内の複数カラムを参照することが可能です。

どちらの方法も CHECK (条件) 構文を使用しますが、配置される場所が異なります。一般的には、制約に明示的な名前を付けることが推奨されます(特にテーブルレベル制約の場合)。これにより、後からの識別や管理が容易になります。名前を指定しない場合、PostgreSQLはデフォルトの名称を自動生成します。

3.1 カラムレベルCHECK制約の構文

CREATE TABLE table_name (
    column_name data_type CHECK (condition),
    -- またはカスタム名称を使用する場合
    another_column_name data_type CONSTRAINT constraint_name CHECK (condition),
    ...
);

3.2 テーブルレベルCHECK制約の構文

CREATE TABLE table_name (
    column1 data_type,
    column2 data_type,
    ...,
    CONSTRAINT constraint_name CHECK (condition)
);

4. CHECK制約定義の実戦サンプル

従業員データと製品在庫を管理する企業の具体的なシナリオを考えてみましょう。

4.1 例 1:基本的な範囲バリデーション(従業員の年齢)

employees(従業員)テーブルを作成するとします。ビジネスルールにより、従業員の年齢は18歳から65歳の間(18と65を含む)でなければならないと規定されています。

CREATE TABLE employees (
    employee_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    email VARCHAR(100) UNIQUE,
    age INT CHECK (age >= 18 AND age <= 65), -- 名称なしのカラムレベルCHECK制約
    hire_date DATE DEFAULT CURRENT_DATE
);

コード解説:age INT CHECK (age >= 18 AND age <= 65): これがカラムレベルのCHECK制約です。age を18-65の範囲外に設定しようとする INSERTUPDATE 操作は全て失敗します。

実際にテストしてみましょう:

-- これは正常にインサートされます
INSERT INTO employees (first_name, last_name, email, age)
VALUES ('Alice', 'Smith', '[email protected]', 30);

-- これは失敗します。年齢が有効範囲を超えているためです
INSERT INTO employees (first_name, last_name, email, age)
VALUES ('Bob', 'Johnson', '[email protected]', 17);
-- エラー: relation "employees" の新行がチェック制約 "employees_age_check" に違反しています
-- 詳細: 失敗した行は (2, Bob, Johnson, [email protected], 17, 2023-10-27) を含んでいます

-- これも失敗します
INSERT INTO employees (first_name, last_name, email, age)
VALUES ('Charlie', 'Brown', '[email protected]', 70);
-- エラー: relation "employees" の新行がチェック制約 "employees_age_check" に違反しています

-- これは正常にインサートされます (ageがNULL。18 <= NULL AND NULL <= 65 の結果はUNKNOWNのため)
INSERT INTO employees (first_name, last_name, email, age)
VALUES ('Diana', 'Prince', '[email protected]', NULL);

PostgreSQLが制約名を employees_age_check として自動生成している点に注目してください。管理上、自分自身でより分かりやすい名前を付ける方がベターです。

4.2 例 2:製品在庫における命名CHECK制約

products(製品)テーブルにおいて、stock_quantity(在庫数)は常に非負(0以上)であり、price(価格)は0より大きくなければならないとします。可読性のために命名制約を使用します。

CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    price DECIMAL(10, 2) NOT NULL CONSTRAINT chk_product_price_positive CHECK (price > 0), -- カラムレベル、名称付き
    stock_quantity INT NOT NULL CONSTRAINT chk_product_stock_nonnegative CHECK (stock_quantity >= 0), -- カラムレベル、名称付き
    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

コード解説:

  • CONSTRAINT chk_product_price_positive CHECK (price > 0): 価格が常に正の数であることを保証します。
  • CONSTRAINT chk_product_stock_nonnegative CHECK (stock_quantity >= 0): 在庫数が負の数にならないことを保証します。

これらの制約をテストします:

-- 正常にインサート
INSERT INTO products (product_name, price, stock_quantity)
VALUES ('Laptop', 1200.50, 50);

-- 失敗。価格が正の数ではないため
INSERT INTO products (product_name, price, stock_quantity)
VALUES ('Mouse', 0.00, 100);
-- エラー: relation "products" の新行がチェック制約 "chk_product_price_positive" に違反しています

-- 失敗。在庫数が負の数のため
INSERT INTO products (product_name, price, stock_quantity)
VALUES ('Keyboard', 75.00, -5);
-- エラー: relation "products" の新行がチェック制約 "chk_product_stock_nonnegative" に違反しています

4.3 例 3:日付範囲のためのテーブルレベルCHECK制約

プロジェクト詳細を保存する際に、start_date(開始日)と end_date(終了日)が必要です。重要なビジネスルールは、「end_date は常に start_date 当日かそれ以降でなければならない」という点です。これは2つの異なるカラムを比較するため、テーブルレベル制約が必要になります。

CREATE TABLE projects (
    project_id SERIAL PRIMARY KEY,
    project_name VARCHAR(255) NOT NULL,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    budget DECIMAL(12, 2) CONSTRAINT chk_project_budget_positive CHECK (budget > 0),
    CONSTRAINT chk_project_dates_valid CHECK (end_date >= start_date) -- テーブルレベル、名称付き
);

コード解説:CONSTRAINT chk_project_dates_valid CHECK (end_date >= start_date): この制約は、end_date の値が常に start_date 以上であることを保証します。

chk_project_dates_valid 制約のテスト:

-- 正常にインサート
INSERT INTO projects (project_name, start_date, end_date, budget)
VALUES ('Website Redesign', '2023-01-15', '2023-06-30', 25000.00);

-- 失敗。終了日が開始日より早いため
INSERT INTO projects (project_name, start_date, end_date, budget)
VALUES ('Mobile App Development', '2023-07-01', '2023-06-30', 50000.00);
-- エラー: relation "projects" の新行がチェック制約 "chk_project_dates_valid" に違反しています
-- 詳細: 失敗した行は (2, Mobile App Development, 2023-07-01, 2023-06-30, 50000.00) を含んでいます

-- 正常にインサート (開始日と終了日が同じ日)
INSERT INTO projects (project_name, start_date, end_date, budget)
VALUES ('Database Migration', '2024-01-01', '2024-01-01', 10000.00);

5. 既存のテーブルへのCHECK制約の追加

ALTER TABLE 文を使用して、既に存在するテーブルにCHECK制約を追加できます。ビジネスルールが変更された場合や、テーブル作成時にバリデーションロジックを入れ忘れた場合に非常に役立ちます。

既存のテーブルにCHECK制約を追加すると、PostgreSQLは直ちにテーブル内の既存データをスキャンし、全ての行が新しい制約を満たしているか検証します。もし一行でも制約に違反しているデータがあれば、ALTER TABLE 文は失敗し、制約は追加されません。この挙動により、過去・現在を問わず全てのデータに対して制約が有効であることが保証されます。

5.1 CHECK制約追加の構文

ALTER TABLE table_name
ADD CONSTRAINT constraint_name CHECK (condition);

5.2 例:既存テーブルへの制約追加

当初 employees テーブルを作成した際に年齢制約を入れなかった、あるいは新しく給与のバリデーションが必要になったと仮定します。

まず、シンプルな employees_v2 テーブルを作成し、いくつかのデータをインサートします:

CREATE TABLE employees_v2 (
    employee_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    salary DECIMAL(10, 2)
);

-- いくつかのデータを挿入。中には将来の制約に違反するものを含めます
INSERT INTO employees_v2 (first_name, last_name, salary) VALUES
('John', 'Doe', 60000.00),
('Jane', 'Smith', 75000.00),
('Peter', 'Jones', -5000.00), -- 正の給与を求める制約に違反するデータ
('Maria', 'Garcia', NULL);

ここで、salary(給与)が常に正の値(NULLでない場合)であることを保証する chk_employee_salary_positive というチェック制約を追加してみましょう。

ALTER TABLE employees_v2
ADD CONSTRAINT chk_employee_salary_positive CHECK (salary > 0);

コード解説:

  • ALTER TABLE employees_v2: 変更するテーブルを指定。
  • ADD CONSTRAINT ...: 条件 salary > 0 を強制する新しい制約を追加。

既存データはどうなるか?

この例では、'Peter Jones' の行の salary が -5000.00 であり、salary > 0 に違反しています。そのため、ALTER TABLE 文は失敗します:

ERROR:  check constraint "chk_employee_salary_positive" is violated by some row
-- エラー: 某行がチェック制約 "chk_employee_salary_positive" に違反しています

この制約を正常に追加するためには、まず UPDATE または DELETE を使用して違反データを修正する必要があります:

-- まず違反データを修正
UPDATE employees_v2
SET salary = 50000.00
WHERE employee_id = 3; -- Peter Jones の employee_id

-- これで制約が追加可能になります
ALTER TABLE employees_v2
ADD CONSTRAINT chk_employee_salary_positive CHECK (salary > 0);

データを更新後、ALTER TABLE コマンドは成功します。salaryNULL の行(Maria Garcia)は、NULL > 0UNKNOWN と評価され、UNKNOWNFALSE ではないため制約に違反しません。

6. CHECK制約の削除

ビジネスルールが変更されたり適用されなくなったりした場合、CHECK制約を削除する必要があります。削除するには、その制約の名称を知っている必要があります。

CHECK制約削除の構文:

ALTER TABLE table_name
DROP CONSTRAINT constraint_name;

例:CHECK制約の削除

projects テーブル(前の例 3)から chk_project_budget_positive 制約を削除します。

ALTER TABLE projects
DROP CONSTRAINT chk_project_budget_positive;

コード解説:

  • ALTER TABLE projects: 変更するテーブルを指定。
  • DROP CONSTRAINT chk_project_budget_positive: 指定した名前の制約を削除。

この操作の実行後は、これまでブロックされていた budget が0や負の数のデータも projects テーブルにインサートやアップデートできるようになります。

7. 複雑なCHECK制約式

CHECK制約は様々なSQL演算子や関数を使用できるため、より複雑なビジネスルールの実行が可能です。論理演算子(AND, OR, NOT)を組み合わせて、精密なバリデーションルールを作成できます。

7.1 例 1:条件付き割引バリデーション

orders(注文)テーブルにおいて、商品に discount_percentage(割引率)を設定できるとします。このパーセンテージは is_discounted(割引あり)フラグが TRUE の場合のみ適用されるべきであり、かつ0から100の間でなければなりません。もし is_discountedFALSE なら、discount_percentageNULL または 0 であるべきです。

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL,
    order_date DATE NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    is_discounted BOOLEAN DEFAULT FALSE,
    discount_percentage DECIMAL(5, 2),
    CONSTRAINT chk_discount_valid CHECK (
        (NOT is_discounted AND discount_percentage IS NULL) OR -- 割引なしなら、率はNULLであるべき
        (is_discounted AND discount_percentage >= 0 AND discount_percentage <= 100) -- 割引ありなら、率は0-100の間
    )
);

コード解説:chk_discount_valid 制約は OR を使用して、2つの互いに排他的な有効状態を定義しています:

  • NOT is_discounted AND discount_percentage IS NULL: 割引が適用されていない場合、パーセンテージは空でなければならない。
  • is_discounted AND discount_percentage >= 0 AND discount_percentage <= 100: 割引が適用されている場合、パーセンテージは0から100の間でなければならない。

この複雑な制約をテストします:

-- 有効: 割引なし、率はNULL
INSERT INTO orders (customer_id, order_date, total_amount, is_discounted, discount_percentage)
VALUES (101, '2023-10-27', 150.00, FALSE, NULL);

-- 有効: 割引あり、率は範囲内
INSERT INTO orders (customer_id, order_date, total_amount, is_discounted, discount_percentage)
VALUES (102, '2023-10-27', 200.00, TRUE, 15.50);

-- 無効: 割引ありだが、率が範囲外
INSERT INTO orders (customer_id, order_date, total_amount, is_discounted, discount_percentage)
VALUES (103, '2023-10-27', 500.00, TRUE, 101.00);
-- エラー: relation "orders" の新行がチェック制約 "chk_discount_valid" に違反しています

-- 無効: 割引なしだが、率がNULLではない
INSERT INTO orders (customer_id, order_date, total_amount, is_discounted, discount_percentage)
VALUES (104, '2023-10-27', 300.00, FALSE, 10.00);
-- エラー: relation "orders" の新行がチェック制約 "chk_discount_valid" に違反しています

この例は、CHECK制約を使用して複雑なロジックルールを強制する強力な能力を示しています。

7.2 例 2:有効な「疑似列挙型」の保証

PostgreSQLにはネイティブの ENUM(列挙型)が存在しますが、CHECK制約を使用して VARCHARTEXT カラムを定義済みの値のセットに制限し、列挙型のような挙動を模倣することもできます。VARCHAR の柔軟性を保ちつつ内容を制限したい場合に便利です。

employee_status(従業員ステータス)テーブルにおいて、status カラムは 'Active'(在職)、'Inactive'(離職)、'On Leave'(休假)のいずれかでなければならないとします。

CREATE TABLE employee_status (
    status_id SERIAL PRIMARY KEY,
    employee_id INT NOT NULL,
    status VARCHAR(20) NOT NULL CONSTRAINT chk_valid_status CHECK (status IN ('Active', 'Inactive', 'On Leave')),
    status_date DATE DEFAULT CURRENT_DATE
);

コード解説:

status IN ('Active', 'Inactive', 'On Leave'): これにより、status カラムには指定された3つの文字列のうちの1つしか含めることができないよう保証されます。

テストしてみましょう:

-- 有効なインサート
INSERT INTO employee_status (employee_id, status) VALUES (1, 'Active');
INSERT INTO employee_status (employee_id, status) VALUES (2, 'On Leave');

-- 無効なインサート: 'Retired' は許可されたリストに含まれていない
INSERT INTO employee_status (employee_id, status) VALUES (3, 'Retired');
-- エラー: relation "employee_status" の新行がチェック制約 "chk_valid_status" に違反しています

8. CHECK制約のエラー処理

INSERT または UPDATE ステートメントが、CHECK制約に違反する形でデータ行を作成または変更しようとした場合、PostgreSQLはエラーを発生させます。エラーメッセージは通常、以下の内容を示します:

  • エラータイプ: check constraint is violated (チェック制約に違反)。
  • 違反した制約の名称。
  • 場合によっては DETAIL(詳細)メッセージとして、問題の原因となった具体的な行データが表示されます。

これらのエラーメッセージを理解することは、デバッグやアプリケーション内で適切なエラーハンドリングメカニズムを実装するために非常に重要です。例えば、アプリケーション側でこれらの特定のデータベースエラーをキャッチし、どのデータを修正すべきかをユーザーに分かりやすく提示することができます。

前述のエラー例を再確認しましょう:

ERROR:  new row for relation "employees" violates check constraint "employees_age_check"
DETAIL:  Failing row contains (2, Bob, Johnson, [email protected], 17, 2023-10-27).

これは、どのテーブル (employees)、どの制約 (employees_age_check)、そして違反の原因となった具体的なデータ (age が 17) を極めて明確に伝えています。