SQL と制約

設計を実装に落とす際は、SQLと制約を正しく使って「保存してはいけないデータ」を防ぐことが重要です。このページではSQLの基本操作から制約設計まで、実務で必要な知識を整理します。

SQLとは

SQL(Structured Query Language)は、RDBに対してデータの定義・操作・制御を行うための宣言的言語です。 「どうやって取得するか」ではなく「何を取得したいか」を書くのが特徴です。

SQLの4分類

分類正式名代表的な文用途
DDLData Definition LanguageCREATE, ALTER, DROPテーブル構造の定義・変更・削除
DMLData Manipulation LanguageSELECT, INSERT, UPDATE, DELETEデータの取得・追加・変更・削除
DCLData Control LanguageGRANT, REVOKE権限の付与・取り消し
TCLTransaction Control LanguageBEGIN, COMMIT, ROLLBACKトランザクション制御

標準SQLと方言

SQLはISO/IEC 9075で標準化されていますが、各RDBMSは独自の拡張(方言)を持ちます。

機能PostgreSQLMySQLOracle
自動採番GENERATED ALWAYS AS IDENTITYAUTO_INCREMENTGENERATED AS IDENTITY / SEQUENCE
文字列連結||CONCAT()||
件数制限LIMIT nLIMIT nFETCH FIRST n ROWS ONLY
現在日時CURRENT_TIMESTAMPNOW()SYSDATE

移植性を重視する場合は標準SQL寄りの構文を使い、RDBMS固有機能は必要な箇所に限定するのが実務的です。

DDL(データ定義言語)

CREATE TABLE

テーブルを新規作成します。列名・データ型・制約をまとめて定義します。

CREATE TABLE customer (
    customer_id   BIGINT GENERATED ALWAYS AS IDENTITY,
    customer_name VARCHAR(100) NOT NULL,
    email         VARCHAR(255) NOT NULL,
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT pk_customer PRIMARY KEY (customer_id),
    CONSTRAINT uq_customer_email UNIQUE (email)
);

ALTER TABLE

既存テーブルの構造を変更します。列追加・削除・制約変更などに使います。

-- 列の追加
ALTER TABLE customer ADD COLUMN phone VARCHAR(20);

-- 列のNOT NULL化
ALTER TABLE customer ALTER COLUMN phone SET NOT NULL;

-- 制約の追加
ALTER TABLE customer
    ADD CONSTRAINT chk_email CHECK (email LIKE '%@%.%');

-- 列の削除
ALTER TABLE customer DROP COLUMN phone;

DROP TABLE

テーブルを削除します。データも構造もすべて失われるため、本番環境では慎重に扱います。

-- テーブル削除(参照されている場合はエラー)
DROP TABLE customer;

-- 依存するFKも連鎖削除(PostgreSQL)
DROP TABLE customer CASCADE;

DROP TABLE CASCADE は外部キーで参照している他テーブルの制約まで削除します。本番では原則使わず、依存関係を手動で解消してからDROPしてください。

DML(データ操作言語)

INSERT(追加)

-- 単一行の挿入
INSERT INTO customer (customer_name, email)
VALUES ('田中商事', '[email protected]');

-- 複数行の一括挿入
INSERT INTO customer (customer_name, email) VALUES
    ('鈴木工業', '[email protected]'),
    ('佐藤物産', '[email protected]');

-- SELECTの結果を挿入
INSERT INTO customer_archive (customer_id, customer_name, email)
SELECT customer_id, customer_name, email
  FROM customer
 WHERE created_at < '2024-01-01';

UPDATE(更新)

-- WHERE条件で対象行を限定
UPDATE customer
   SET email = '[email protected]'
 WHERE customer_id = 1;

-- 複数列を同時に更新
UPDATE product
   SET unit_price = unit_price * 1.1,
       updated_at = CURRENT_TIMESTAMP
 WHERE category = '食品';

WHERE句なしのUPDATEはテーブル全行を更新します。本番環境では必ずWHERE句を付け、事前にSELECTで対象行を確認してから実行する習慣をつけましょう。

DELETE(削除)

-- 条件指定で削除
DELETE FROM customer WHERE customer_id = 99;

-- 期限切れデータの一括削除
DELETE FROM session_log
 WHERE created_at < CURRENT_DATE - INTERVAL '90 days';

WHERE句なしのDELETEはテーブル全行を削除します。UPDATEと同様、事前確認が必須です。

SELECTの基本

基本構文と処理順序

SELECTは書く順序と実行順序が異なります。実行順序を理解するとエラーの原因が分かりやすくなります。

実行順役割
1FROM / JOIN対象テーブルの決定
2WHERE行の絞り込み
3GROUP BYグループ化
4HAVINGグループ条件の絞り込み
5SELECT列の選択・計算
6DISTINCT重複排除
7ORDER BY並び替え
8LIMIT / OFFSET件数制限

WHERE による絞り込み

-- 比較演算子
SELECT * FROM product WHERE unit_price >= 1000;

-- BETWEEN
SELECT * FROM orders WHERE order_date BETWEEN '2025-04-01' AND '2025-04-30';

-- IN
SELECT * FROM customer WHERE customer_id IN (1, 3, 5);

-- LIKE(部分一致)
SELECT * FROM product WHERE product_name LIKE '%キーボード%';

-- IS NULL / IS NOT NULL
SELECT * FROM customer WHERE phone IS NULL;

ORDER BY(並び替え)

-- 昇順(デフォルト)
SELECT * FROM product ORDER BY unit_price;

-- 降順
SELECT * FROM orders ORDER BY order_date DESC;

-- 複合ソート
SELECT * FROM product ORDER BY category ASC, unit_price DESC;

DISTINCT(重複排除)

-- 受注実績のある顧客IDだけを取得
SELECT DISTINCT customer_id FROM orders;

GROUP BY / HAVING(集計)

-- 顧客ごとの受注件数
SELECT customer_id, COUNT(*) AS order_count
  FROM orders
 GROUP BY customer_id;

-- 受注件数が3件以上の顧客だけ
SELECT customer_id, COUNT(*) AS order_count
  FROM orders
 GROUP BY customer_id
HAVING COUNT(*) >= 3;

WHEREは集計前(個別行)の絞り込み、HAVINGは集計後(グループ)の絞り込みです。この違いを理解しておくとSQLのエラーが減ります。

JOIN(結合)

正規化で分割したテーブルを結合して元の情報を取り出すのがJOINです。

サンプルデータ

-- customer テーブル
-- customer_id | customer_name
-- ----------- | -------------
-- 1           | 田中商事
-- 2           | 鈴木工業
-- 3           | 佐藤物産

-- orders テーブル
-- order_id | customer_id | order_date
-- -------- | ----------- | ----------
-- 1001     | 1           | 2025-04-01
-- 1002     | 1           | 2025-04-15
-- 1003     | 2           | 2025-05-01

INNER JOIN — 両方に存在する行だけ結合

結合条件に一致する行のみが結果に含まれます。最も基本的で使用頻度の高い結合です。

SELECT
    o.order_id,
    c.customer_name,
    o.order_date
FROM orders o
INNER JOIN customer c ON o.customer_id = c.customer_id;
-- 結果:
-- order_id | customer_name | order_date
-- -------- | ------------- | ----------
-- 1001     | 田中商事       | 2025-04-01
-- 1002     | 田中商事       | 2025-04-15
-- 1003     | 鈴木工業       | 2025-05-01
-- ※ 佐藤物産は受注がないため結果に含まれない

LEFT JOIN — 左テーブルの全行を保持

左側テーブルの全行を残し、右側に一致がなければNULLで埋めます。

SELECT
    c.customer_id,
    c.customer_name,
    o.order_id
FROM customer c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
-- 結果:
-- customer_id | customer_name | order_id
-- ----------- | ------------- | --------
-- 1           | 田中商事       | 1001
-- 1           | 田中商事       | 1002
-- 2           | 鈴木工業       | 1003
-- 3           | 佐藤物産       | NULL

LEFT JOINで「一致しない行だけ」を取り出すには、WHERE o.order_id IS NULL を追加します。受注実績のない顧客の抽出など、実務でよく使うパターンです。

CROSS JOIN — 全行の組み合わせ(直積)

SELECT c.customer_name, o.order_id
FROM customer c
CROSS JOIN orders o;

CROSS JOINは行数が掛け算で増えます。カレンダーマスタの生成やテストデータ作成など限定的な用途で使います。

JOINの種類まとめ

JOIN種類結果に含まれる行主な用途
INNER JOIN両テーブルで一致する行のみ関連データの結合(最も一般的)
LEFT JOIN左テーブル全行 + 右の一致行(なければNULL)マスタと実績の突合、未使用データ検出
RIGHT JOIN右テーブル全行 + 左の一致行(なければNULL)LEFT JOINの逆(LEFT JOINで書き換え可能)
FULL OUTER JOIN両テーブルの全行(一致しない側はNULL)差分検出、データ移行時の照合
CROSS JOIN全行の直積(条件なし)組み合わせの生成

複数テーブルのJOIN

SELECT
    o.order_id,
    o.order_date,
    c.customer_name,
    p.product_name,
    oi.quantity,
    oi.unit_price,
    oi.quantity * oi.unit_price AS line_total
FROM orders o
JOIN customer c    ON o.customer_id = c.customer_id
JOIN order_item oi ON o.order_id    = oi.order_id
JOIN product p     ON oi.product_id = p.product_id
ORDER BY o.order_id, oi.line_no;

サブクエリと集計関数

集計関数

関数意味
COUNT(*)行数を数える受注件数
COUNT(列名)NULLでない値を数える電話番号登録済み顧客数
SUM(列名)合計売上合計金額
AVG(列名)平均商品の平均単価
MAX(列名)最大値最新の受注日
MIN(列名)最小値最安商品の価格
-- 受注ごとの合計金額と明細行数
SELECT
    o.order_id,
    COUNT(*)                        AS item_count,
    SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders o
JOIN order_item oi ON o.order_id = oi.order_id
GROUP BY o.order_id;

IN サブクエリ

-- 2025年4月に受注がある顧客を取得
SELECT customer_id, customer_name
FROM customer
WHERE customer_id IN (
    SELECT customer_id FROM orders
    WHERE order_date BETWEEN '2025-04-01' AND '2025-04-30'
);

EXISTS サブクエリ

-- 受注実績がある顧客
SELECT c.customer_id, c.customer_name
FROM customer c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

-- 受注実績がない顧客
SELECT c.customer_id, c.customer_name
FROM customer c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

相関サブクエリ

-- 各顧客の最新受注日を取得
SELECT
    c.customer_id,
    c.customer_name,
    (SELECT MAX(o.order_date)
     FROM orders o
     WHERE o.customer_id = c.customer_id) AS latest_order_date
FROM customer c;

-- 自カテゴリの平均より高い商品を抽出
SELECT product_name, category, unit_price
FROM product p
WHERE unit_price > (
    SELECT AVG(unit_price)
    FROM product
    WHERE category = p.category
);

相関サブクエリは理解しやすい反面、行数が多いと性能問題になりやすいです。同じ結果をJOIN + GROUP BYで書けないか検討する習慣を持ちましょう。

制約の種類と実践

制約は「保存してはいけないデータ」をDB側で自動的に拒否する仕組みです。アプリ側のバリデーションとは別に、データの最後の砦として機能します。

NOT NULL — NULLを禁止する

何を防ぐか: 値が未設定のまま保存されることを防ぐ。業務上「必須」の項目に設定します。

customer_name VARCHAR(100) NOT NULL,  -- 顧客名は必須
phone         VARCHAR(20)             -- 電話は任意(NULLを許容)

迷ったらNOT NULLにしましょう。後からNOT NULLを外すのは簡単ですが、NULLが混入した後にNOT NULLを追加するのはデータ移行が必要です。

UNIQUE — 重複値を禁止する

何を防ぐか: 同じ値が複数行に存在することを防ぐ。メールアドレスや業務コードに設定します。

CONSTRAINT uq_customer_email UNIQUE (email)

PRIMARY KEY — 行の一意識別子

何を防ぐか: 行の重複とNULLを防ぐ。全テーブルに必ず1つ設定します。

-- 単一列の主キー
CONSTRAINT pk_product PRIMARY KEY (product_id)

-- 複合主キー
CONSTRAINT pk_order_item PRIMARY KEY (order_id, line_no)

FOREIGN KEY — 参照整合性を保証する

何を防ぐか: 存在しない値への参照を防ぐ。

CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customer (customer_id)

CHECK — 値の範囲・条件を制限する

何を防ぐか: 業務ルール上ありえない値の保存を防ぐ。

CHECK (unit_price >= 0)
CHECK (quantity > 0)
CHECK (status IN ('未処理', '処理中', '出荷済', 'キャンセル'))
CHECK (start_date <= end_date)

DEFAULT — 既定値を設定する

何を防ぐか: 値を指定し忘れた場合に不定な状態になることを防ぐ。

order_date  DATE      NOT NULL DEFAULT CURRENT_DATE,
status      VARCHAR(20) NOT NULL DEFAULT '未処理',
created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

複合ユニーク制約

複数列の組み合わせで一意性を保証します。

-- カテゴリ内の商品名は一意
CONSTRAINT uq_product_cat_name UNIQUE (category, product_name)

複合ユニーク制約は (category, product_name) の組み合わせが一意であることを保証します。各列単体の重複は許容されます。

制約設計の実務ポイント

FOREIGN KEY の ON DELETE / ON UPDATE

親テーブルの行が削除・更新されたとき、子テーブルの参照行をどう扱うかを指定します。

オプション親を削除したとき適する場面
RESTRICT(デフォルト)子が存在すれば削除を拒否安全最優先(受注がある顧客は消せない)
CASCADE子も一緒に削除親子一体(受注削除時に明細も連動削除)
SET NULL子のFK列をNULLに変更担当者退職でも受注記録は残す
SET DEFAULT子のFK列を既定値に変更使用頻度は低い
NO ACTION文の終了時点で参照を検査RDBMSによってはRESTRICTと同等

CASCADEは慎重に使いましょう。親を1行消しただけで子テーブルの大量データが連鎖削除される可能性があります。CASCADEは「親子が一体」の関係に限定するのが安全です。

CHECK制約の活用パターン

-- 1. 数値の範囲制限
CHECK (quantity BETWEEN 1 AND 9999)

-- 2. 列挙値の制限
CHECK (status IN ('未処理', '処理中', '出荷済', 'キャンセル'))

-- 3. 列間の比較(開始日 ≦ 終了日)
CHECK (start_date <= end_date)

-- 4. 条件付きNOT NULL(出荷済なら出荷日は必須)
CHECK (status <> '出荷済' OR shipped_date IS NOT NULL)

制約設計のガイドライン

方針理由
全ての制約に名前を付けるエラーメッセージで原因を即座に特定できる
NOT NULLを原則、NULLは例外NULL許容は設計判断として明示する
業務コードにはUNIQUE制約を付けるサロゲートキーでも業務一意性は別途保証する
金額・数量にはCHECK制約を付けるマイナス金額やゼロ数量の混入はバグの温床
FKのON DELETEは明示的に選択するデフォルト(RESTRICT)で良いか業務要件を確認する

テーブル作成の総合例

受注業務を構成する4テーブルを、ここまで学んだ制約をフルに使って定義します。

ER構成

customer 1 ──< orders 1 ──< order_item >── 1 product

1. customer(顧客)

CREATE TABLE customer (
    customer_id   BIGINT        GENERATED ALWAYS AS IDENTITY,
    customer_code VARCHAR(20)   NOT NULL,
    customer_name VARCHAR(100)  NOT NULL,
    email         VARCHAR(255)  NOT NULL,
    phone         VARCHAR(20),
    created_at    TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT pk_customer       PRIMARY KEY (customer_id),
    CONSTRAINT uq_customer_code  UNIQUE (customer_code),
    CONSTRAINT uq_customer_email UNIQUE (email)
);

2. product(商品)

CREATE TABLE product (
    product_id   BIGINT         GENERATED ALWAYS AS IDENTITY,
    product_code VARCHAR(20)    NOT NULL,
    product_name VARCHAR(100)   NOT NULL,
    category     VARCHAR(50)    NOT NULL,
    unit_price   NUMERIC(10,2)  NOT NULL,
    is_active    BOOLEAN        NOT NULL DEFAULT TRUE,
    created_at   TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT pk_product          PRIMARY KEY (product_id),
    CONSTRAINT uq_product_code     UNIQUE (product_code),
    CONSTRAINT uq_product_cat_name UNIQUE (category, product_name),
    CONSTRAINT chk_product_price   CHECK (unit_price >= 0)
);

3. orders(受注)

CREATE TABLE orders (
    order_id     BIGINT       GENERATED ALWAYS AS IDENTITY,
    customer_id  BIGINT       NOT NULL,
    order_date   DATE         NOT NULL DEFAULT CURRENT_DATE,
    status       VARCHAR(20)  NOT NULL DEFAULT '未処理',
    total_amount NUMERIC(12,2),
    created_at   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT pk_orders          PRIMARY KEY (order_id),
    CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id)
        REFERENCES customer (customer_id) ON DELETE RESTRICT,
    CONSTRAINT chk_order_status   CHECK (
        status IN ('未処理', '処理中', '出荷済', 'キャンセル')
    ),
    CONSTRAINT chk_order_total    CHECK (total_amount >= 0)
);

4. order_item(受注明細)

CREATE TABLE order_item (
    order_id    BIGINT        NOT NULL,
    line_no     INT           NOT NULL,
    product_id  BIGINT        NOT NULL,
    quantity    INT           NOT NULL,
    unit_price  NUMERIC(10,2) NOT NULL,
    created_at  TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT pk_order_item    PRIMARY KEY (order_id, line_no),
    CONSTRAINT fk_item_order    FOREIGN KEY (order_id)
        REFERENCES orders (order_id) ON DELETE CASCADE,
    CONSTRAINT fk_item_product  FOREIGN KEY (product_id)
        REFERENCES product (product_id) ON DELETE RESTRICT,
    CONSTRAINT chk_item_line_no CHECK (line_no > 0),
    CONSTRAINT chk_item_qty     CHECK (quantity > 0),
    CONSTRAINT chk_item_price   CHECK (unit_price >= 0)
);

設計ポイントの解説

設計判断理由
主キーはサロゲートキー(IDENTITY)不変性を確保。業務コードはUNIQUEで別途管理
order_itemに明細単価を別途持つ商品マスタの価格変更後も受注時点の価格を保持
order_item → orders は ON DELETE CASCADE親子一体。受注削除時に明細だけ残る意味がない
order_item → product は ON DELETE RESTRICT受注実績がある商品の削除は業務上危険
statusはCHECKで列挙想定外のステータス値を防ぐ
created_at / updated_atを全テーブルに配置監査・デバッグに必須

このテーブル構成は第3正規形を満たしています。次章以降で正規化の理論を詳しく学び、なぜこの分割が妥当なのかを理解していきます。