はじめに
このような設計で困ったことありませんか?
- 商品に「ブランド」「価格帯」「用途」など複数の分類軸を設定したい
- カテゴリは後から自由に追加できるようにしたい
- 一つの商品が複数のカテゴリに属することを許可したい
静的なテーブル設計では限界があり、柔軟性と型安全性の両立が困難になってしまう。
一般的な手法の課題
1. 静的テーブル設計の限界
model Product {
id String @id @default(uuid())
name String
brand String? // ブランドカラム
priceRange String? // 価格帯カラム
usage String? // 用途カラム
}
問題点:
- 新しい分類軸を追加するたびにテーブル変更が必要
- 複数選択(例:「ビジネス」と「ゲーム」両方)に対応できない
2. 単純な中間テーブルの限界
// 単純な中間テーブル
model ProductCategory {
id String @id @default(uuid())
productId String
categoryValue String // "Apple", "高価格帯"が混在
product Product @relation(fields: [productId], references: [id])
}
問題点:
- カテゴリの種類(ブランド/価格帯/用途)と値の区別ができない
- データの一貫性を保つのが困難
- 複雑な検索クエリが必要
解決方法:EAVパターンを4つのテーブルで実装
全体構造図
Product (商品)
↓
ProductCategory (商品がどのカテゴリを持つか)
↓
Category (カテゴリ定義: "ブランド", "価格帯", "用途")
↓
CategoryOption (選択肢: "Apple", "高価格帯", "ゲーム")
↓
ProductCategoryOption (実際の選択)
スキーマ設計
// 商品テーブル
model Product {
id String @id @default(uuid())
name String
price Int
productCategories ProductCategory[]
}
// カテゴリ定義(ブランド、価格帯、用途など)
model Category {
id String @id @default(uuid())
name String @unique // "ブランド", "価格帯", "用途"
categoryOptions CategoryOption[]
productCategories ProductCategory[]
}
// カテゴリの選択肢(Apple、高価格帯、ゲームなど)
model CategoryOption {
id String @id @default(uuid())
value String // "Apple", "高価格帯", "ゲーム"
categoryId String
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
productCategoryOptions ProductCategoryOption[]
@@unique([categoryId, value])
}
// 商品とカテゴリの関連
model ProductCategory {
id String @id @default(uuid())
productId String
categoryId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
options ProductCategoryOption[]
@@unique([productId, categoryId])
}
// 実際の選択値
model ProductCategoryOption {
id String @id @default(uuid())
productCategoryId String
categoryOptionId String
productCategory ProductCategory @relation(fields: [productCategoryId], references: [id], onDelete: Cascade)
categoryOption CategoryOption @relation(fields: [categoryOptionId], references: [id], onDelete: Cascade)
@@unique([productCategoryId, categoryOptionId])
}
使用例
1. データの準備
// カテゴリ作成(3つのカテゴリ軸)
const categories = [
{
name: 'ブランド',
options: ['Apple', 'Samsung', 'Sony'],
},
{
name: '価格帯',
options: ['低価格帯', '中価格帯', '高価格帯'],
},
{
name: '用途',
options: ['ビジネス', 'ゲーム', '写真撮影'],
},
]
// カテゴリを作成
for (const category of categories) {
await prisma.category.create({
data: {
name: category.name,
categoryOptions: {
create: category.options.map((value) => ({ value })),
},
},
})
}
// 商品作成
const products = [
{ name: 'iPhone 15 Pro', price: 150000 },
{ name: 'Galaxy S24', price: 120000 },
{ name: 'Xperia 1 V', price: 180000 },
{ name: 'iPhone SE', price: 60000 },
]
for (const productData of products) {
await prisma.product.create({ data: productData })
}
2. 商品にカテゴリを設定
// 商品にカテゴリを設定する関数
const setProductCategory = async (
productId: string,
categoryName: string,
optionValues: string[],
) => {
const category = await prisma.category.findUnique({
where: { name: categoryName },
include: { categoryOptions: true },
})
const productCategory = await prisma.productCategory.create({
data: {
productId,
categoryId: category.id,
},
})
const options = category.categoryOptions.filter((opt) =>
optionValues.includes(opt.value),
)
await prisma.productCategoryOption.createMany({
data: options.map((option) => ({
productCategoryId: productCategory.id,
categoryOptionId: option.id,
})),
})
}
// iPhone 15 Pro に複数カテゴリを設定
const iphoneProId = 'iphone-pro-id'
await setProductCategory(iphoneProId, 'ブランド', ['Apple'])
await setProductCategory(iphoneProId, '価格帯', ['高価格帯'])
await setProductCategory(iphoneProId, '用途', ['ビジネス', '写真撮影'])
// Galaxy S24の設定
const galaxyId = 'galaxy-s24-id'
await setProductCategory(galaxyId, 'ブランド', ['Samsung'])
await setProductCategory(galaxyId, '価格帯', ['中価格帯'])
await setProductCategory(galaxyId, '用途', ['ゲーム', 'ビジネス'])
3. カテゴリ別で商品を取得
// 単一カテゴリで商品を取得
const getProductsByCategory = async (
categoryName: string,
optionValue: string,
) => {
return await prisma.product.findMany({
where: {
productCategories: {
some: {
category: { name: categoryName },
options: {
some: {
categoryOption: { value: optionValue },
},
},
},
},
},
include: {
productCategories: {
include: {
category: true,
options: {
include: {
categoryOption: true,
},
},
},
},
},
})
}
// 複数条件で商品を絞り込み
const getProductsByMultipleCategories = async () => {
return await prisma.product.findMany({
where: {
AND: [
{
productCategories: {
some: {
category: { name: 'ブランド' },
options: { some: { categoryOption: { value: 'Apple' } } },
},
},
},
{
productCategories: {
some: {
category: { name: '価格帯' },
options: { some: { categoryOption: { value: '高価格帯' } } },
},
},
},
],
},
})
}
const appleProducts = await getProductsByCategory('ブランド', 'Apple')
const gamingProducts = await getProductsByCategory('用途', 'ゲーム')
const expensiveAppleProducts = await getProductsByMultipleCategories()
メリット
- ✅ 柔軟性: 新しいカテゴリ軸を自由に追加
- ✅ スケーラビリティ: 商品数・カテゴリ数に関係なく性能維持
- ✅ 複数選択: 一つの商品が複数カテゴリに属することが可能
実装は複雑ですが、適切に設計すればスケーラブルで保守性の高いシステムを構築できます。