0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Prismaで動的カテゴリシステムを実装する方法 - 商品×カテゴリの柔軟な関係性

Posted at

はじめに

このような設計で困ったことありませんか?

  • 商品に「ブランド」「価格帯」「用途」など複数の分類軸を設定したい
  • カテゴリは後から自由に追加できるようにしたい
  • 一つの商品が複数のカテゴリに属することを許可したい

静的なテーブル設計では限界があり、柔軟性と型安全性の両立が困難になってしまう。

一般的な手法の課題

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()

メリット

  • 柔軟性: 新しいカテゴリ軸を自由に追加
  • スケーラビリティ: 商品数・カテゴリ数に関係なく性能維持
  • 複数選択: 一つの商品が複数カテゴリに属することが可能

実装は複雑ですが、適切に設計すればスケーラブルで保守性の高いシステムを構築できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?