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 テーブル)
テーブル: 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=有効)

制約定義

DDL 制約定義例(PostgreSQL)
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_dateordersuser_id, order_date DESC複合ユーザー別注文履歴の一覧取得
idx_orders_status_dateordersstatus, order_date複合ステータス別の日次集計バッチ
idx_orders_activeordersdeleted_at(NULLのみ)部分有効注文のみを対象とする全件スキャン防止

リレーション定義

親テーブル子テーブル多重度FK カラムON DELETE
usersorders1:Norders.user_idRESTRICT(ユーザー削除時に注文があれば拒否)
ordersorder_items1:Norder_items.order_idCASCADE(注文削除時に明細も削除)
productsorder_items1:Norder_items.product_idRESTRICT(商品削除時に明細があれば拒否)
categoriesproducts1:Nproducts.category_idSET NULL(カテゴリ削除時は NULL に)

Python Tips — DB スキーマの自動取得

PostgreSQL の情報スキーマから既存テーブルのカラム定義・制約・インデックスを自動取得し、ER図のベースとなる Markdown を生成する。

Python — PostgreSQL スキーマ自動取得
"""
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論理削除カラムの方針が定義されているか
7updated_at の自動更新(トリガー等)が定義されているか