論理設計は終わっていたのに、物理設計の段階でインデックス方針が誰にも決まっておらず、レビューが何度も差し戻された経験があります。「完全定義」と呼べるレベルまで詰める視点を整理しました。
ER図(物理設計)の位置づけ
図1: 詳細データ設計の3段階(ER詳細→テーブル定義詳細→データ項目詳細)
基本設計フェーズで作成した論理 ER 図を、詳細設計フェーズで「物理 ER 図(物理設計書)」に落とし込む。物理設計では DBMS に依存した型・制約・ストレージパラメータまで定義する。
💡 論理 ER 図との違い
論理 ER 図はエンティティ・属性・リレーションを「概念」で定義する。物理 ER 図はテーブル名・カラム名・データ型・制約・インデックスを「実装可能な形」で定義する。DDL はこの物理 ER 図から直接生成できる状態でなければならない。
定義すべき項目一覧
| 分類 | 定義項目 | 必須度 |
|---|---|---|
| テーブル | テーブル物理名・論理名 | ◎ |
| テーブル | テーブル説明・分類(マスタ/トランザクション/ワーク) | ◎ |
| カラム | カラム物理名・論理名・データ型・桁数 | ◎ |
| カラム | NULL 可否・デフォルト値 | ◎ |
| カラム | カラム説明・設定可能値(コード列) | ◎ |
| 制約 | 主キー制約(PK) | ◎ |
| 制約 | 外部キー制約(FK)・参照先・ON DELETE 動作 | ◎ |
| 制約 | UNIQUE 制約 | ○ |
| 制約 | CHECK 制約 | ○ |
| インデックス | インデックス名・対象カラム・種別(通常/複合/部分) | ◎ |
| インデックス | インデックス作成理由(対象SQL) | ○ |
| リレーション | 多重度(1:1 / 1:N / M:N) | ◎ |
| リレーション | 参照整合性方針(CASCADE / SET NULL / RESTRICT) | ◎ |
エンティティ(テーブル)定義
| 物理名 | 論理名 | 分類 | 説明 |
|---|---|---|---|
| users | ユーザー | マスタ | システム利用者の基本情報 |
| orders | 注文 | トランザクション | 顧客からの注文ヘッダー情報 |
| order_items | 注文明細 | トランザクション | 注文ごとの商品明細 |
| products | 商品 | マスタ | 販売商品の基本情報・在庫 |
| categories | 商品カテゴリ | マスタ | 商品分類(階層構造) |
カラム定義
テーブル: orders(注文)
カラム物理名 | 論理名 | データ型 | NULL | デフォルト | 説明
-----------------|-------------|---------------------|------|------------|-----------------------------
order_id | 注文ID | BIGINT | × | AUTO | PK。シーケンス自動採番
user_id | ユーザーID | BIGINT | × | — | FK → users.user_id
order_date | 注文日時 | TIMESTAMP WITH TZ | × | NOW() | 注文を受け付けた日時
status | ステータス | SMALLINT | × | 10 | 10=仮受付 20=確認済 30=出荷済 40=完了 90=キャンセル
total_amount | 合計金額 | NUMERIC(12,2) | × | 0 | 消費税込み合計金額(円)
shipping_address | 配送先住所 | VARCHAR(500) | ○ | NULL | NULL の場合は登録住所を使用
note | 備考 | TEXT | ○ | NULL | 顧客からの申し送り事項
created_at | 作成日時 | TIMESTAMP WITH TZ | × | NOW() | レコード作成日時
updated_at | 更新日時 | TIMESTAMP WITH TZ | × | NOW() | 最終更新日時(更新トリガーで自動設定)
deleted_at | 削除日時 | TIMESTAMP WITH TZ | ○ | NULL | 論理削除日時(NULL=有効)
制約定義
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status SMALLINT NOT NULL DEFAULT 10,
total_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
shipping_address VARCHAR(500),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE RESTRICT,
CONSTRAINT chk_orders_status
CHECK (status IN (10, 20, 30, 40, 90)),
CONSTRAINT chk_orders_amount
CHECK (total_amount >= 0)
);
インデックス定義
| インデックス名 | テーブル | 対象カラム | 種別 | 作成理由(対象SQL) |
|---|---|---|---|---|
| idx_orders_user_date | orders | user_id, order_date DESC | 複合 | ユーザー別注文履歴の一覧取得 |
| idx_orders_status_date | orders | status, order_date | 複合 | ステータス別の日次集計バッチ |
| idx_orders_active | orders | deleted_at(NULLのみ) | 部分 | 有効注文のみを対象とする全件スキャン防止 |
リレーション定義
| 親テーブル | 子テーブル | 多重度 | FK カラム | ON DELETE |
|---|---|---|---|---|
| users | orders | 1:N | orders.user_id | RESTRICT(ユーザー削除時に注文があれば拒否) |
| orders | order_items | 1:N | order_items.order_id | CASCADE(注文削除時に明細も削除) |
| products | order_items | 1:N | order_items.product_id | RESTRICT(商品削除時に明細があれば拒否) |
| categories | products | 1:N | products.category_id | SET NULL(カテゴリ削除時は NULL に) |
Python Tips — DB スキーマの自動取得
PostgreSQL の情報スキーマから既存テーブルのカラム定義・制約・インデックスを自動取得し、ER図のベースとなる Markdown を生成する。
"""
PostgreSQL の情報スキーマからテーブル定義を取得する
pip install psycopg2-binary
"""
import psycopg2
DSN = "host=localhost dbname=myapp user=postgres password=secret"
def get_columns(conn, schema="public", table=None):
query = """
SELECT
c.table_name,
c.column_name,
c.data_type,
c.character_maximum_length,
c.is_nullable,
c.column_default,
pgd.description AS column_comment
FROM information_schema.columns c
LEFT JOIN pg_catalog.pg_statio_all_tables st
ON st.schemaname = c.table_schema AND st.relname = c.table_name
LEFT JOIN pg_catalog.pg_description pgd
ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
WHERE c.table_schema = %s
AND (%s IS NULL OR c.table_name = %s)
ORDER BY c.table_name, c.ordinal_position
"""
with conn.cursor() as cur:
cur.execute(query, (schema, table, table))
return cur.fetchall()
def get_indexes(conn, schema="public", table=None):
query = """
SELECT
t.relname AS table_name,
i.relname AS index_name,
ix.indisunique,
ix.indisprimary,
array_to_string(array_agg(a.attname ORDER BY k.n), ', ') AS columns
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_namespace n ON t.relnamespace = n.oid
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
WHERE n.nspname = %s
AND (%s IS NULL OR t.relname = %s)
GROUP BY t.relname, i.relname, ix.indisunique, ix.indisprimary
ORDER BY t.relname, i.relname
"""
with conn.cursor() as cur:
cur.execute(query, (schema, table, table))
return cur.fetchall()
conn = psycopg2.connect(DSN)
cols = get_columns(conn, table="orders")
print("## orders テーブル カラム定義")
for row in cols:
print(f" {row[1]:25s} {row[2]:20s} NULL={row[4]:3s} default={row[5]}")
idxs = get_indexes(conn, table="orders")
print("\n## orders テーブル インデックス")
for row in idxs:
kind = "PK" if row[3] else ("UNIQUE" if row[2] else "INDEX")
print(f" [{kind}] {row[1]}: ({row[4]})")
conn.close()
レビューチェックリスト
| # | チェック項目 |
|---|---|
| 1 | 全カラムにデータ型・桁数・NULL可否が定義されているか |
| 2 | 全テーブルに主キー(PK)が定義されているか |
| 3 | 外部キー制約とON DELETE動作が定義されているか |
| 4 | 検索に使われるカラムにインデックスが定義されているか |
| 5 | コード列に CHECK 制約または説明が定義されているか |
| 6 | 論理削除カラムの方針が定義されているか |
| 7 | updated_at の自動更新(トリガー等)が定義されているか |