Drizzle ORMでの関連付けと外部キー制約について
Drizzle ORMでテーブル間の関連を定義する際、「relations」と 「foreignKey, references」という2つの方法があります。これらは似ているように見えますが、実際には役割を果たす層(レベル)が異なります。
この記事では、私自身が学んだ内容をもとに、Drizzle ORMでのリレーション定義についてまとめています。特に初めて使う方や、私のように使い分けで迷った方の参考になればと思います。
公式ドキュメントを参考にしながら自分なりに解釈し、特に理解が難しかった部分について補足を加えてみました。もし内容に誤りがあれば、ぜひご指摘いただければ幸いです。
relations
と foreignKey
:役割の違いと使い分け
1. まず、Drizzle ORMの relations
とforeignKey
の違いについて、私の理解をシェアします。
Drizzle ORM特有の話というよりも、他のORMでも同じような話ができると思います。
-
relations
(リレーション):- 役割: アプリケーションレベルでのテーブル間の関連性を定義します
- 目的: Drizzle ORMが型安全なクエリ(特にJOIN操作など)を生成するために使用されます
- 影響: データベーススキーマ自体には影響を与えません。DBに外部キー制約を作成したりはしません
- 利用シーン: 型安全なリレーショナルクエリを利用したい場合に必須です
-
foreignKey
(外部キー制約):- 役割: データベースレベルでのデータ整合性を保証するための制約です
- 目的: 関連するテーブル間でデータの矛盾(存在しないユーザーを参照する投稿など)を防ぎます
-
影響: データベーススキーマに物理的な制約を作成します(
references()
も同様です) - 利用シーン: DBレベルでデータの整合性を厳密に保ちたい場合に推奨されます
基本的な使い分け方針:
-
relations
とforeignKey
は互いに独立しており、一方があっても他方が自動的に定義されるわけではありません - 型安全なクエリのみが必要でDB制約が不要なら
relations
だけを定義します - データベースレベルでデータの整合性を保証したい場合は、
foreignKey
を定義します -
relations
とforeignKey
制約の両方を定義します。これが最も一般的なケースだと思っています
マイグレーションとの関係:
-
foreignKey
やreferences()
の変更はデータベースの物理的な構造に影響するため、マイグレーションが必要です - 一方、
relations
はアプリケーションレベルの型定義のみなので、変更してもマイグレーションは不要です - これにより、リファクタリングの際に
relations
の変更は比較的リスクが低いと言えます
relations
の詳細な定義方法
2. ここでは、アプリケーションレベルでの関連を定義する 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 カラムを参照する
}),
}));
-
invitedBy
がNULL
の場合、招待者は存在しません。 -
relations
定義内のfields
とreferences
は、どのカラム同士が関連しているかを示す構造定義であり、カラム自体の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 を参照する
}),
}));
-
usersRelations
でfields
とreferences
を省略すると、Drizzle はprofileInfo
テーブル側にuserId
のような外部キーが存在すると推測します。 -
profileInfo
はユーザーごとに存在しない可能性があるため、user.profileInfo
は型推論によりProfileInfo | null
となることがあります。 -
profileInfoRelations
ではfields
とreferences
を指定し、「各プロファイル情報は必ず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
<->usersToGroups
とgroups
<->usersToGroups
)として表現されます。 - 中間テーブル (
usersToGroups
) が、users
とgroups
の間の関連付けを保持します。
foreignKey
制約の詳細な定義方法
3. 次に、データベースレベルでデータの整合性を保証する foreignKey
制約について詳しく見ていきます。Drizzleでは主に2つの方法で定義できます。
references()
方法1: カラム定義内での カラムを定義する際に、.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も削除
});
foreignKey()
演算子
方法2: テーブル定義の第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')
relationName
)
4. リレーションの曖昧性解消 (同じ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 と対応させる
}),
}));
-
usersRelations
とpostsRelations
の両方で、対応するリレーションに同じrelationName
を指定します。 - これにより、
db.query.users.findMany({ with: { authoredPosts: true } })
のように、区別してリレーションを扱うことができます。
5. おわりに
Drizzle ORM におけるテーブル間の関連定義について、relations
と foreignKey
の違いを中心にシェアさせていただきました。
-
relations
: アプリケーションレベルでの型安全なクエリを実現します。 -
foreignKey
/references()
: データベースレベルでのデータ整合性を保証します。
これらをプロジェクトの要件(型安全性、データ整合性の必要度、パフォーマンスなど)に応じて適切に使い分けることで、堅牢で保守性の高いデータモデルを構築できると思います。
特に foreignKey()
演算子は、制約の命名や複合キーの扱いで柔軟性をもたらしてくれそうです。
この記事が、Drizzle ORM を使ったデータベース設計の参考になれば幸いです。
Discussion