✨
複雑な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戦略を選ぶべき場合
- 極めて動的なスキーマが必要
- 属性のメタデータ管理が重要
- 設定やカスタマイゼーション機能
- パフォーマンスよりも柔軟性優先
ハイブリッド戦略を選ぶべき場合
- バランスの取れたソリューションが必要
- 段階的な移行を計画している
- 既存システムとの互換性が重要
- 将来の要件変更に備えたい
実装のベストプラクティス
- スキーマバージョニング: ADTの進化に対応するためのマイグレーション戦略
- パフォーマンス監視: クエリパフォーマンスの継続的な監視
- 型安全性: コンパイル時とランタイムの両方での型チェック
- テスト戦略: 各戦略に適したテストアプローチ
- 監査ログ: データ変更の追跡とロールバック機能
Discussion