Drizzle ORMでの関連付けと外部キー制約について

に公開

Drizzle ORMでテーブル間の関連を定義する際、「relations」と 「foreignKey, references」という2つの方法があります。これらは似ているように見えますが、実際には役割を果たす層(レベル)が異なります。

この記事では、私自身が学んだ内容をもとに、Drizzle ORMでのリレーション定義についてまとめています。特に初めて使う方や、私のように使い分けで迷った方の参考になればと思います。

公式ドキュメントを参考にしながら自分なりに解釈し、特に理解が難しかった部分について補足を加えてみました。もし内容に誤りがあれば、ぜひご指摘いただければ幸いです。

1. relationsforeignKey:役割の違いと使い分け

まず、Drizzle ORMの relationsforeignKeyの違いについて、私の理解をシェアします。
Drizzle ORM特有の話というよりも、他のORMでも同じような話ができると思います。

  • relations (リレーション):

    • 役割: アプリケーションレベルでのテーブル間の関連性を定義します
    • 目的: Drizzle ORMが型安全なクエリ(特にJOIN操作など)を生成するために使用されます
    • 影響: データベーススキーマ自体には影響を与えません。DBに外部キー制約を作成したりはしません
    • 利用シーン: 型安全なリレーショナルクエリを利用したい場合に必須です
  • foreignKey (外部キー制約):

    • 役割: データベースレベルでのデータ整合性を保証するための制約です
    • 目的: 関連するテーブル間でデータの矛盾(存在しないユーザーを参照する投稿など)を防ぎます
    • 影響: データベーススキーマに物理的な制約を作成しますreferences()も同様です)
    • 利用シーン: DBレベルでデータの整合性を厳密に保ちたい場合に推奨されます

基本的な使い分け方針:

  • relationsforeignKey互いに独立しており、一方があっても他方が自動的に定義されるわけではありません
  • 型安全なクエリのみが必要でDB制約が不要ならrelationsだけを定義します
  • データベースレベルでデータの整合性を保証したい場合は、foreignKeyを定義します
  • relationsforeignKey 制約の両方を定義します。これが最も一般的なケースだと思っています

マイグレーションとの関係:

  • foreignKeyreferences()の変更はデータベースの物理的な構造に影響するため、マイグレーションが必要です
  • 一方、relationsはアプリケーションレベルの型定義のみなので、変更してもマイグレーションは不要です
  • これにより、リファクタリングの際にrelationsの変更は比較的リスクが低いと言えます

2. relations の詳細な定義方法

ここでは、アプリケーションレベルでの関連を定義する relations の具体的な使い方を、リレーションの種類ごとに見ていきます。

One-to-one (1対1)

テーブル間で一対一の関連を定義します。

例1: 自己参照(ユーザーが他のユーザーを招待する)

import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// usersテーブル
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name').notNull(),
	invitedBy: integer('invited_by'), // 招待者のID (NULLを許可)
});

// usersテーブル内の1対1リレーション (招待者)
export const usersRelations = relations(users, ({ one }) => ({
    // 'invitee' という名前でリレーションを定義
	invitee: one(users, {
		fields: [users.invitedBy],     // users.invitedBy カラムが
		references: [users.id],       // users.id カラムを参照する
	}),
}));
  • invitedByNULL の場合、招待者は存在しません。
  • relations 定義内の fieldsreferences は、どのカラム同士が関連しているかを示す構造定義であり、カラム自体の NULL 許容性(.notNull() の有無)とは別です。

例2: ユーザーとプロファイル情報

import { pgTable, serial, text, integer, jsonb } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// usersテーブル
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});

// profileInfoテーブル
export const profileInfo = pgTable('profile_info', {
	id: serial('id').primaryKey(),
	userId: integer('user_id').references(() => users.id), // DBレベルの外部キー制約も定義
	metadata: jsonb('metadata'),
});

// users から profileInfo への1対1リレーション
export const usersRelations = relations(users, ({ one }) => ({
    // fields/references を省略すると、相手テーブル(profileInfo)に外部キーがあると推測される
	profileInfo: one(profileInfo),
}));

// profileInfo から users への1対1リレーション
export const profileInfoRelations = relations(profileInfo, ({ one }) => ({
	user: one(users, {
		fields: [profileInfo.userId],   // profileInfo.userId が
		references: [users.id],         // users.id を参照する
	}),
}));
  • usersRelationsfieldsreferences を省略すると、Drizzle は profileInfo テーブル側に userId のような外部キーが存在すると推測します。
  • profileInfo はユーザーごとに存在しない可能性があるため、user.profileInfo は型推論により ProfileInfo | null となることがあります。
  • profileInfoRelations では fieldsreferences を指定し、「各プロファイル情報は必ず1人のユーザーに関連付けられる」ことを示しています。

One-to-many (1対多)

一つのテーブルのレコードが、他のテーブルの複数のレコードに関連する場合です。

例: ユーザーと投稿 (User 1 : N Posts)

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// usersテーブル
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});

// postsテーブル
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
    // posts.authorId は users.id を参照する (NULLを許可しない)
	authorId: integer('author_id').notNull().references(() => users.id),
});

// users から posts への1対多リレーション
export const usersRelations = relations(users, ({ many }) => ({
	posts: many(posts), // 1ユーザーが複数の投稿を持つ
}));

// posts から users への多対1リレーション
export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, { // 1投稿には1人の作者がいる
		fields: [posts.authorId],
		references: [users.id],
	}),
}));

例2を発展させた例: 投稿とコメント (Post 1 : N Comments)

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// users, posts テーブル定義は上記と同じ ...

// usersテーブルからのリレーション定義を拡張
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts), // 1ユーザーが複数の投稿を持つ
  comments: many(comments) // 1ユーザーが複数のコメントを持つ(直接アクセス用)
}));

// commentsテーブル
export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  text: text('text').notNull(),
  authorId: integer('author_id').notNull().references(() => users.id), // コメント投稿者(必須)
  postId: integer('post_id').notNull().references(() => posts.id), // どの投稿へのコメントか(必須)
  createdAt: timestamp().notNull().defaultNow()
});

// posts からのリレーション定義に comments を追加
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  comments: many(comments), // 1投稿に複数のコメントが付く
}));

// comments からのリレーション定義
export const commentsRelations = relations(comments, ({ one }) => ({
  // コメントから投稿への関連 (多対1)
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
  // コメントから投稿者への関連 (多対1)
  author: one(users, {
    fields: [comments.authorId],
    references: [users.id],
  })
}));

Many-to-many (多対多)

両方のテーブルのレコードが、互いに複数のレコードと関連する場合です。通常、中間テーブル(Junction Table / Join Table) を使用します。

例: ユーザーとグループ (Users N : M Groups)

import { relations } from 'drizzle-orm';
import { integer, pgTable, primaryKey, serial, text } from 'drizzle-orm/pg-core';

// usersテーブル
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name'),
});

// groupsテーブル
export const groups = pgTable('groups', {
  id: serial('id').primaryKey(),
  name: text('name'),
});

// 中間テーブル: users_to_groups
export const usersToGroups = pgTable(
  'users_to_groups',
  {
    userId: integer('user_id')
      .notNull()
      .references(() => users.id), // users.id への外部キー
    groupId: integer('group_id')
      .notNull()
      .references(() => groups.id), // groups.id への外部キー
  },
  // 複合主キー (userId, groupId の組み合わせが一意)
  (t) => ({
		pk: primaryKey({ columns: [t.userId, t.groupId] })
	}),
);

// users から 中間テーブルへの1対多リレーション
export const usersRelations = relations(users, ({ many }) => ({
  usersToGroups: many(usersToGroups),
  groups: many(groups, { // 直接グループにアクセスする関係
    through: {
      from: [usersToGroups.userId],
      to: [usersToGroups.groupId],
      fields: [usersToGroups] // 中間テーブルの情報も取得可能
    }
  })
}));

// groups から 中間テーブルへの1対多リレーション
export const groupsRelations = relations(groups, ({ many }) => ({
  usersToGroups: many(usersToGroups),
  users: many(users, {
    through: {
      from: [usersToGroups.groupId],
      to: [usersToGroups.userId],
      fields: [usersToGroups]
    }
  })
}));

// 中間テーブルから users, groups への多対1リレーション
export const usersToGroupsRelations = relations(usersToGroups, ({ one }) => ({
  group: one(groups, { // 中間テーブルの各レコードは1つのグループに属する
    fields: [usersToGroups.groupId],
    references: [groups.id],
  }),
  user: one(users, { // 中間テーブルの各レコードは1人のユーザーに属する
    fields: [usersToGroups.userId],
    references: [users.id],
  }),
}));
  • 多対多リレーションは、2つの1対多リレーション(users <-> usersToGroupsgroups <-> usersToGroups)として表現されます。
  • 中間テーブル (usersToGroups) が、usersgroups の間の関連付けを保持します。

3. foreignKey 制約の詳細な定義方法

次に、データベースレベルでデータの整合性を保証する foreignKey 制約について詳しく見ていきます。Drizzleでは主に2つの方法で定義できます。

方法1: カラム定義内での references()

カラムを定義する際に、.references() メソッドを使って外部キー制約を同時に指定します。シンプルで直感的な方法です。

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { users } from './users-schema'; // usersテーブルが別ファイルにあると仮定

export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
  // authorId カラム定義と同時に users.id への参照と削除時アクションを指定
	authorId: integer('author_id')
        .notNull()
        .references(() => users.id, { onDelete: 'cascade' }), // usersレコード削除時、関連するpostsも削除
});

方法2: foreignKey() 演算子

テーブル定義の第3引数(コールバック関数)内で foreignKey() 演算子を使って、より明示的に外部キー制約を定義します。

import { pgTable, serial, text, integer, foreignKey } from 'drizzle-orm/pg-core';
import { users } from './users-schema';
import { orderItems } from './order-items-schema'; // 例として追加
import { products } from './products-schema'; // 例として追加

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: integer('author_id').notNull(),
  // 複合外部キーの例のためのカラム
  orderItemId: integer('order_item_id').notNull(),
  productId: integer('product_id').notNull(),
}, (table) => ({ // 第3引数で制約を定義
  // authorId に対する外部キー制約
  authorFk: foreignKey({
    name: "posts_author_id_fk", // 制約に名前を付ける (推奨)
    columns: [table.authorId],
    foreignColumns: [users.id],
  })
    .onDelete('cascade') // 削除時アクション
    .onUpdate('restrict'), // 更新時アクション

  // 複合外部キーの例 (orderItemId, productId が orderItems と products を参照)
  orderProductFk: foreignKey({
    name: "posts_order_product_fk",
    columns: [table.orderItemId, table.productId],
    foreignColumns: [orderItems.id, products.id] // 複数のカラムを参照
  })
    .onDelete('set null'), // 関連レコード削除時に NULL に設定
}));

foreignKey() 演算子のメリット:

  • 明示性: カラム定義とは別に制約を定義するため、コードの意図が明確になります。
  • 関心の分離: カラム定義と制約定義が分かれているため、それぞれ独立して修正しやすくなります。
  • 命名可能: 制約に name を指定できます。これにより、データベースのエラーメッセージなどで制約を特定しやすくなります。64文字制限などDBの命名規則に注意してください。
  • 複合キー対応: 複数のカラムを組み合わせた外部キー(複合外部キー)を簡単に定義できます。
  • 柔軟性: カラム定義を変更せずに、外部キー制約だけを追加・削除・変更することが容易です。

外部キーアクション (onDelete/onUpdate)

参照先のデータが変更(onUpdate)または削除(onDelete)された場合に、参照元のデータをどう扱うかを指定します。

  • CASCADE: 親の変更/削除に合わせて、子の関連レコードも変更/削除する。
  • RESTRICT: 子に関連レコードが存在する場合、親の変更/削除を禁止する。
  • NO ACTION: RESTRICT とほぼ同じ(チェックタイミングが異なる場合があるが、通常は同じ挙動)。デフォルト。
  • SET NULL: 親の変更/削除に合わせて、子の外部キーカラムを NULL に設定する(カラムが NULL 許容の場合のみ)。
  • SET DEFAULT: 親の変更/削除に合わせて、子の外部キーカラムをデフォルト値に設定する(デフォルト値が設定されている場合のみ)。

references() では第2引数のオブジェクトで、foreignKey() ではメソッドチェーンで指定します。

// references() での指定例
authorId: integer('author_id').references(() => users.id, {
    onDelete: 'cascade',
    onUpdate: 'restrict',
})

// foreignKey() での指定例
foreignKey({ ... })
    .onDelete('cascade')
    .onUpdate('restrict')

4. リレーションの曖昧性解消 (relationName)

同じ2つのテーブル間に、複数の異なる意味のリレーション を定義したい場合があります。例えば、「投稿」テーブルに「作成者」と「レビュー担当者」の両方が「ユーザー」テーブルを参照する場合などです。

この場合、relations 定義だけではどちらのリレーションを指しているのか曖昧になります。
これを解消するために relationName オプションを使用します。

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});

export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id').references(() => users.id), // 作成者
	reviewerId: integer('reviewer_id').references(() => users.id), // レビュー担当者
});

// users から posts へのリレーション (2種類)
export const usersRelations = relations(users, ({ many }) => ({
    // 作成者としての投稿 (relationName: 'author')
	authoredPosts: many(posts, { relationName: 'author' }),
    // レビュー担当者としての投稿 (relationName: 'reviewer')
	reviewedPosts: many(posts, { relationName: 'reviewer' }),
}));

// posts から users へのリレーション (2種類)
export const postsRelations = relations(posts, ({ one }) => ({
    // 作成者 (relationName: 'author')
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
		relationName: 'author', // usersRelations と対応させる
	}),
    // レビュー担当者 (relationName: 'reviewer')
	reviewer: one(users, {
		fields: [posts.reviewerId],
		references: [users.id],
		relationName: 'reviewer', // usersRelations と対応させる
	}),
}));
  • usersRelationspostsRelations の両方で、対応するリレーションに同じ relationName を指定します。
  • これにより、db.query.users.findMany({ with: { authoredPosts: true } }) のように、区別してリレーションを扱うことができます。

5. おわりに

Drizzle ORM におけるテーブル間の関連定義について、relationsforeignKey の違いを中心にシェアさせていただきました。

  • relations: アプリケーションレベルでの型安全なクエリを実現します。
  • foreignKey / references(): データベースレベルでのデータ整合性を保証します。

これらをプロジェクトの要件(型安全性、データ整合性の必要度、パフォーマンスなど)に応じて適切に使い分けることで、堅牢で保守性の高いデータモデルを構築できると思います。
特に foreignKey() 演算子は、制約の命名や複合キーの扱いで柔軟性をもたらしてくれそうです。

この記事が、Drizzle ORM を使ったデータベース設計の参考になれば幸いです。

Discussion