複雑なADTのリレーショナルデータベース永続化戦略

に公開

をClaudeに整理してもらってた

1. JSON/JSONB戦略

概要

ADT全体をJSONとして単一カラムに保存する最もシンプルなアプローチ。

実装例(Effect + PostgreSQL)

import { Effect, Schema } from "effect"
import * as SqlClient from "@effect/sql/SqlClient"

// 複雑なADT定義
const UserRole = Schema.Union(
  Schema.Struct({ 
    type: Schema.Literal("admin"), 
    permissions: Schema.Array(Schema.String),
    level: Schema.Number
  }),
  Schema.Struct({ 
    type: Schema.Literal("user"), 
    department: Schema.String,
    projects: Schema.Array(Schema.String)
  }),
  Schema.Struct({ 
    type: Schema.Literal("guest"),
    expiresAt: Schema.Date,
    sponsor: Schema.String
  })
)

const User = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
  email: Schema.String,
  role: UserRole,
  metadata: Schema.Record(Schema.String, Schema.Unknown)
})

// テーブル設計
const createUserTable = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(sql`
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      role JSONB NOT NULL,
      metadata JSONB DEFAULT '{}',
      created_at TIMESTAMP DEFAULT NOW()
    )
  `)
})

// インデックス設計
const createJsonIndexes = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(Effect.all([
    sql`CREATE INDEX idx_users_role_type ON users ((role->>'type'))`,
    sql`CREATE INDEX idx_users_role_gin ON users USING GIN (role)`,
    sql`CREATE INDEX idx_users_metadata_gin ON users USING GIN (metadata)`
  ]))
})

// CRUD操作
const UserRepository = {
  create: (userData: Omit<Schema.Schema.Type<typeof User>, 'id'>) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const result = yield* _(
        sql`INSERT INTO users (name, email, role, metadata) 
           VALUES (${userData.name}, ${userData.email}, 
                  ${JSON.stringify(userData.role)}, 
                  ${JSON.stringify(userData.metadata)}) 
           RETURNING *`
      )
      return yield* _(Schema.decodeUnknown(User)(result[0]))
    }),

  findByRoleType: (roleType: string) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT * FROM users WHERE role->>'type' = ${roleType}`
      )
      return yield* _(Effect.all(results.map(Schema.decodeUnknown(User))))
    }),

  findAdminsByPermission: (permission: string) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT * FROM users 
           WHERE role->>'type' = 'admin' 
           AND role->'permissions' @> ${JSON.stringify([permission])}`
      )
      return yield* _(Effect.all(results.map(Schema.decodeUnknown(User))))
    })
}

メリット・デメリット

メリット:

  • スキーマ変更が容易
  • 複雑な階層構造を自然に表現
  • 実装がシンプル
  • NoSQLライクな柔軟性

デメリット:

  • 型安全性がクエリ時に制限される
  • 部分的な更新が困難
  • リレーションが表現しにくい
  • インデックス設計が複雑

適用ケース:

  • スキーマが頻繁に変わる
  • 階層構造が深い
  • 検索要件がシンプル

2. テーブル分割戦略(Table per Type)

概要

Union型の各バリアントを別々のテーブルに保存し、基底テーブルで共通部分を管理。

実装例

// テーブル設計
const createRoleTablesSchema = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(Effect.all([
    // 基底テーブル
    sql`CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      role_type VARCHAR(20) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )`,
    
    // 管理者ロール専用テーブル
    sql`CREATE TABLE admin_roles (
      user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
      permissions TEXT[] NOT NULL,
      level INTEGER NOT NULL
    )`,
    
    // ユーザーロール専用テーブル
    sql`CREATE TABLE user_roles (
      user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
      department VARCHAR(100) NOT NULL,
      projects TEXT[] NOT NULL
    )`,
    
    // ゲストロール専用テーブル
    sql`CREATE TABLE guest_roles (
      user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
      expires_at TIMESTAMP NOT NULL,
      sponsor VARCHAR(255) NOT NULL
    )`
  ]))
})

// リポジトリ実装
const SplitTableUserRepository = {
  createAdmin: (userData: {
    name: string, 
    email: string, 
    permissions: string[], 
    level: number
  }) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      
      // トランザクション内で実行
      const result = yield* _(sql.withTransaction(
        Effect.gen(function* (_) {
          // ユーザー作成
          const userResult = yield* _(
            sql`INSERT INTO users (name, email, role_type) 
               VALUES (${userData.name}, ${userData.email}, 'admin') 
               RETURNING id`
          )
          const userId = userResult[0].id
          
          // 管理者ロール作成
          yield* _(
            sql`INSERT INTO admin_roles (user_id, permissions, level) 
               VALUES (${userId}, ${userData.permissions}, ${userData.level})`
          )
          
          return userId
        })
      ))
      
      return yield* _(SplitTableUserRepository.findById(result))
    }),

  findById: (id: number) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      
      // ユーザー基本情報取得
      const userResult = yield* _(
        sql`SELECT * FROM users WHERE id = ${id}`
      )
      
      if (userResult.length === 0) {
        return yield* _(Effect.fail(new Error("User not found")))
      }
      
      const user = userResult[0]
      
      // ロールタイプに応じて追加データ取得
      const role = yield* _(match(user.role_type)
        .with("admin", () => 
          Effect.gen(function* (_) {
            const adminResult = yield* _(
              sql`SELECT * FROM admin_roles WHERE user_id = ${id}`
            )
            return {
              type: "admin" as const,
              permissions: adminResult[0].permissions,
              level: adminResult[0].level
            }
          })
        )
        .with("user", () =>
          Effect.gen(function* (_) {
            const userRoleResult = yield* _(
              sql`SELECT * FROM user_roles WHERE user_id = ${id}`
            )
            return {
              type: "user" as const,
              department: userRoleResult[0].department,
              projects: userRoleResult[0].projects
            }
          })
        )
        .with("guest", () =>
          Effect.gen(function* (_) {
            const guestResult = yield* _(
              sql`SELECT * FROM guest_roles WHERE user_id = ${id}`
            )
            return {
              type: "guest" as const,
              expiresAt: guestResult[0].expires_at,
              sponsor: guestResult[0].sponsor
            }
          })
        )
        .exhaustive()
      )
      
      return {
        id: user.id,
        name: user.name,
        email: user.email,
        role,
        createdAt: user.created_at
      }
    }),

  findAllAdmins: () =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT u.*, a.permissions, a.level 
           FROM users u 
           JOIN admin_roles a ON u.id = a.user_id 
           WHERE u.role_type = 'admin'`
      )
      return results.map(row => ({
        id: row.id,
        name: row.name,
        email: row.email,
        role: {
          type: "admin" as const,
          permissions: row.permissions,
          level: row.level
        }
      }))
    })
}

メリット・デメリット

メリット:

  • 強い型安全性
  • 効率的なクエリ
  • 正規化されたデータ構造
  • リレーショナル制約が使える

デメリット:

  • スキーマ変更が複雑
  • 複数テーブルのJOINが必要
  • 実装が複雑
  • 新しいバリアント追加時の影響大

適用ケース:

  • スキーマが安定している
  • 複雑なクエリが多い
  • データ整合性が重要

3. 単一テーブル継承戦略(Single Table Inheritance)

概要

全てのバリアントを単一テーブルに保存し、type columnで区別。

実装例

// テーブル設計
const createSingleTableSchema = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(sql`
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      role_type VARCHAR(20) NOT NULL,
      
      -- Admin固有フィールド
      admin_permissions TEXT[],
      admin_level INTEGER,
      
      -- User固有フィールド
      user_department VARCHAR(100),
      user_projects TEXT[],
      
      -- Guest固有フィールド
      guest_expires_at TIMESTAMP,
      guest_sponsor VARCHAR(255),
      
      created_at TIMESTAMP DEFAULT NOW(),
      
      -- 制約条件
      CONSTRAINT chk_admin_fields CHECK (
        role_type != 'admin' OR 
        (admin_permissions IS NOT NULL AND admin_level IS NOT NULL)
      ),
      CONSTRAINT chk_user_fields CHECK (
        role_type != 'user' OR 
        (user_department IS NOT NULL AND user_projects IS NOT NULL)
      ),
      CONSTRAINT chk_guest_fields CHECK (
        role_type != 'guest' OR 
        (guest_expires_at IS NOT NULL AND guest_sponsor IS NOT NULL)
      )
    )
  `)
})

// インデックス設計
const createSingleTableIndexes = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(Effect.all([
    sql`CREATE INDEX idx_users_role_type ON users (role_type)`,
    sql`CREATE INDEX idx_users_admin_level ON users (admin_level) 
        WHERE role_type = 'admin'`,
    sql`CREATE INDEX idx_users_department ON users (user_department) 
        WHERE role_type = 'user'`,
    sql`CREATE INDEX idx_users_expires_at ON users (guest_expires_at) 
        WHERE role_type = 'guest'`
  ]))
})

const SingleTableUserRepository = {
  createAdmin: (userData: {
    name: string,
    email: string,
    permissions: string[],
    level: number
  }) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const result = yield* _(
        sql`INSERT INTO users (
          name, email, role_type, admin_permissions, admin_level
        ) VALUES (
          ${userData.name}, ${userData.email}, 'admin',
          ${userData.permissions}, ${userData.level}
        ) RETURNING *`
      )
      return SingleTableUserRepository.mapRowToUser(result[0])
    }),

  findByRoleType: (roleType: string) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT * FROM users WHERE role_type = ${roleType}`
      )
      return yield* _(Effect.all(
        results.map(SingleTableUserRepository.mapRowToUser)
      ))
    }),

  mapRowToUser: (row: any) =>
    Effect.gen(function* (_) {
      const role = yield* _(match(row.role_type)
        .with("admin", () => Effect.succeed({
          type: "admin" as const,
          permissions: row.admin_permissions,
          level: row.admin_level
        }))
        .with("user", () => Effect.succeed({
          type: "user" as const,
          department: row.user_department,
          projects: row.user_projects
        }))
        .with("guest", () => Effect.succeed({
          type: "guest" as const,
          expiresAt: row.guest_expires_at,
          sponsor: row.guest_sponsor
        }))
        .otherwise(() => Effect.fail(new Error(`Unknown role type: ${row.role_type}`)))
      )

      return {
        id: row.id,
        name: row.name,
        email: row.email,
        role,
        createdAt: row.created_at
      }
    })
}

メリット・デメリット

メリット:

  • 単一テーブルでシンプル
  • JOINが不要
  • 比較的高速
  • 全バリアント横断クエリが容易

デメリット:

  • 多くのNULL値
  • テーブルが横に広がる
  • 制約の管理が複雑
  • スキーマが冗長

適用ケース:

  • バリアント数が少ない
  • 横断的なクエリが多い
  • パフォーマンス重視

4. EAV (Entity-Attribute-Value) 戦略

概要

属性を動的に格納できる汎用的なスキーマ設計。

実装例

// テーブル設計
const createEAVSchema = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(Effect.all([
    sql`CREATE TABLE entities (
      id SERIAL PRIMARY KEY,
      entity_type VARCHAR(50) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )`,
    
    sql`CREATE TABLE attributes (
      id SERIAL PRIMARY KEY,
      name VARCHAR(100) UNIQUE NOT NULL,
      data_type VARCHAR(20) NOT NULL -- 'string', 'number', 'boolean', 'date', 'json'
    )`,
    
    sql`CREATE TABLE entity_attribute_values (
      entity_id INTEGER REFERENCES entities(id) ON DELETE CASCADE,
      attribute_id INTEGER REFERENCES attributes(id),
      string_value TEXT,
      number_value DECIMAL,
      boolean_value BOOLEAN,
      date_value TIMESTAMP,
      json_value JSONB,
      PRIMARY KEY (entity_id, attribute_id)
    )`
  ]))
})

const EAVUserRepository = {
  createUser: (userData: {
    name: string,
    email: string,
    role: Schema.Schema.Type<typeof UserRole>
  }) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      
      return yield* _(sql.withTransaction(
        Effect.gen(function* (_) {
          // エンティティ作成
          const entityResult = yield* _(
            sql`INSERT INTO entities (entity_type) 
               VALUES ('user') RETURNING id`
          )
          const entityId = entityResult[0].id
          
          // 基本属性の保存
          yield* _(EAVUserRepository.setAttribute(entityId, 'name', userData.name))
          yield* _(EAVUserRepository.setAttribute(entityId, 'email', userData.email))
          yield* _(EAVUserRepository.setAttribute(entityId, 'role', userData.role))
          
          return entityId
        })
      ))
    }),

  setAttribute: (entityId: number, attributeName: string, value: any) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      
      // 属性の取得または作成
      const attrResult = yield* _(
        sql`INSERT INTO attributes (name, data_type) 
           VALUES (${attributeName}, ${typeof value}) 
           ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
           RETURNING id`
      )
      const attributeId = attrResult[0].id
      
      // 値の型に応じて適切なカラムに保存
      const valueColumn = match(typeof value)
        .with('string', () => 'string_value')
        .with('number', () => 'number_value')
        .with('boolean', () => 'boolean_value')
        .with('object', () => 'json_value')
        .otherwise(() => 'string_value')
      
      const actualValue = typeof value === 'object' 
        ? JSON.stringify(value) 
        : value
      
      return yield* _(
        sql`INSERT INTO entity_attribute_values 
           (entity_id, attribute_id, ${sql.raw(valueColumn)}) 
           VALUES (${entityId}, ${attributeId}, ${actualValue})
           ON CONFLICT (entity_id, attribute_id) 
           DO UPDATE SET ${sql.raw(valueColumn)} = EXCLUDED.${sql.raw(valueColumn)}`
      )
    }),

  findByAttribute: (attributeName: string, value: any) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT DISTINCT e.id 
           FROM entities e
           JOIN entity_attribute_values eav ON e.id = eav.entity_id
           JOIN attributes a ON eav.attribute_id = a.id
           WHERE a.name = ${attributeName} 
           AND eav.string_value = ${String(value)}`
      )
      
      return yield* _(Effect.all(
        results.map(row => EAVUserRepository.findById(row.id))
      ))
    })
}

メリット・デメリット

メリット:

  • 極めて柔軟なスキーマ
  • 新しい属性の追加が容易
  • 異なる型の属性を統一的に扱える
  • メタデータ管理が可能

デメリット:

  • 複雑なJOINが必要
  • パフォーマンスが低い
  • 型安全性の確保が困難
  • 実装が非常に複雑

適用ケース:

  • 極めて動的なスキーマが必要
  • 属性が頻繁に変わる
  • メタデータ管理が重要

5. ハイブリッド戦略

概要

複数の戦略を組み合わせて、それぞれの利点を活用。

実装例

// コア情報 + JSON拡張の組み合わせ
const createHybridSchema = Effect.gen(function* (_) {
  const sql = yield* _(SqlClient.SqlClient)
  return yield* _(sql`
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      role_type VARCHAR(20) NOT NULL,
      
      -- よく検索される核となる属性
      department VARCHAR(100), -- user roleの場合のみ使用
      permission_level INTEGER, -- admin roleの場合のみ使用
      expires_at TIMESTAMP, -- guest roleの場合のみ使用
      
      -- 拡張可能なJSON領域
      role_details JSONB NOT NULL DEFAULT '{}',
      metadata JSONB NOT NULL DEFAULT '{}',
      
      created_at TIMESTAMP DEFAULT NOW()
    )
  `)
})

const HybridUserRepository = {
  createAdmin: (userData: {
    name: string,
    email: string,
    permissions: string[],
    level: number,
    additionalInfo?: Record<string, any>
  }) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      
      const roleDetails = {
        permissions: userData.permissions,
        ...userData.additionalInfo
      }
      
      const result = yield* _(
        sql`INSERT INTO users (
          name, email, role_type, permission_level, role_details
        ) VALUES (
          ${userData.name}, ${userData.email}, 'admin',
          ${userData.level}, ${JSON.stringify(roleDetails)}
        ) RETURNING *`
      )
      
      return HybridUserRepository.mapRowToUser(result[0])
    }),

  // 高速な基本検索(インデックス使用)
  findByPermissionLevel: (level: number) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT * FROM users 
           WHERE role_type = 'admin' AND permission_level >= ${level}`
      )
      return yield* _(Effect.all(
        results.map(HybridUserRepository.mapRowToUser)
      ))
    }),

  // 柔軟な詳細検索(JSON使用)
  findByDetailedCriteria: (criteria: Record<string, any>) =>
    Effect.gen(function* (_) {
      const sql = yield* _(SqlClient.SqlClient)
      const results = yield* _(
        sql`SELECT * FROM users 
           WHERE role_details @> ${JSON.stringify(criteria)}`
      )
      return yield* _(Effect.all(
        results.map(HybridUserRepository.mapRowToUser)
      ))
    })
}

戦略比較マトリックス

戦略 型安全性 パフォーマンス 柔軟性 実装複雑度 スキーマ進化
JSON/JSONB ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
テーブル分割 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐
単一テーブル ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
EAV ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
ハイブリッド ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐

選択指針

JSON/JSONB戦略を選ぶべき場合

  • スキーマが頻繁に変わる
  • プロトタイピング段階
  • 階層構造が深い
  • 検索要件がシンプル

テーブル分割戦略を選ぶべき場合

  • スキーマが安定している
  • 強い型安全性が必要
  • 複雑なリレーショナルクエリが多い
  • データ整合性が最重要

単一テーブル戦略を選ぶべき場合

  • バリアント数が少ない(3-5個)
  • 横断的なクエリが多い
  • シンプルな実装が優先
  • パフォーマンス重視

EAV戦略を選ぶべき場合

  • 極めて動的なスキーマが必要
  • 属性のメタデータ管理が重要
  • 設定やカスタマイゼーション機能
  • パフォーマンスよりも柔軟性優先

ハイブリッド戦略を選ぶべき場合

  • バランスの取れたソリューションが必要
  • 段階的な移行を計画している
  • 既存システムとの互換性が重要
  • 将来の要件変更に備えたい

実装のベストプラクティス

  1. スキーマバージョニング: ADTの進化に対応するためのマイグレーション戦略
  2. パフォーマンス監視: クエリパフォーマンスの継続的な監視
  3. 型安全性: コンパイル時とランタイムの両方での型チェック
  4. テスト戦略: 各戦略に適したテストアプローチ
  5. 監査ログ: データ変更の追跡とロールバック機能

Discussion