リレーション
リレーションは、それを持つモデルの隣に宣言し、あとはどこからでも eager load します。リレーションは、hasMany() や belongsTo() など、以下で紹介するメソッドでモデルのクラスに登録します。モデルそのものについてはモデルを参照してください。
リレーション
リレーションは、それを持つモデルの隣に宣言し、あとはどこからでも eager load します。リレーションは、hasMany() や belongsTo() など、以下で紹介するメソッドでモデルのクラスに登録します。モデルそのものについてはモデルを参照してください。
hasMany / belongsTo
// app/Models/User.ts
import { defineModel, type HasManyRecord } from '@guren/core'
import { users } from '@/db/schema'
import type { PostRecord } from '@/app/Models/Post'
export type UserRecord = typeof users.$inferSelect
export class User extends defineModel(users) {
static override relationTypes: { posts: HasManyRecord<PostRecord> } = {
posts: [],
}
}
User.hasMany('posts', () => import('./Post.js').then((m) => m.Post), 'authorId', 'id')
// app/Models/Post.ts
import { defineModel, type BelongsToRecord } from '@guren/core'
import { posts } from '@/db/schema'
import type { UserRecord } from '@/app/Models/User'
export type PostRecord = typeof posts.$inferSelect
export class Post extends defineModel(posts) {
static override relationTypes: { author: BelongsToRecord<UserRecord> } = {
author: null,
}
}
Post.belongsTo('author', () => import('./User.js').then((m) => m.User), 'authorId', 'id')
relationTypes は、各リレーションを読み込んだときの型を TypeScript に伝えます。値([] や null)は実行時のプレースホルダーです。値の要らない declare の書き方は読み込んだリレーションの型で紹介します。関連モデルは、それを import する関数として渡すので、2 つのモデルが互いを参照しても循環 import になりません。
その他のリレーション
import { userRoles } from '@/db/schema'
User.hasOne('profile', Profile, 'userId', 'id')
User.belongsToMany('roles', Role, userRoles, 'userId', 'roleId')
Country.hasManyThrough('posts', Post, User, 'countryId', 'authorId')
| メソッド | 引数 |
|---|---|
hasMany(name, Related, foreignKey, localKey) |
関連テーブル側の外部キーと、それが指すこのモデルのキー(通常は id)。配列を読み込み、一致しなければ [] |
belongsTo(name, Related, foreignKey, ownerKey) |
このモデルの外部キーと、関連モデルのキー。1 件のレコードか null を読み込む |
hasOne(name, Related, foreignKey, localKey) |
hasMany と同じだが、1 件のレコードか null を読み込む |
belongsToMany(name, Related, pivotTable, foreignPivotKey, relatedPivotKey, parentKey?, relatedKey?) |
ピボットテーブルを介した多対多 |
hasManyThrough(name, Related, Through, firstKey, secondKey) |
中間のモデルを経由してたどるレコード |
morphMany(name, Related, morphName, localKey) / morphTo(name, morphName) |
ポリモーフィックリレーション(次の節) |
belongsToMany の pivotTable には、テーブル名の文字列ではなく、@/db/schema から export した Drizzle のテーブルオブジェクトを渡します。foreignPivotKey と relatedPivotKey はどちらもピボット側の列で、それぞれこのモデルと関連モデルを参照します。parentKey と relatedKey はその参照先のローカルキーで、既定値はどちらも 'id' です。
ポリモーフィックリレーション
ポリモーフィックリレーションを使うと、1 つのリレーションで複数の種類の親モデルに属せます。たとえば、投稿にも動画にもコメントを付けられます。
関連テーブルには、type と id のカラムを定義します。
// db/schema.ts
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
body: text('body').notNull(),
commentableType: text('commentable_type').notNull(),
commentableId: integer('commentable_id').notNull(),
})
次に、リレーションを登録します。
// 親モデル
Post.morphMany('comments', Comment, 'commentable', 'id')
Video.morphMany('comments', Comment, 'commentable', 'id')
// 子モデル(逆向き)
Comment.morphTo('commentable', 'commentable')
// type の文字列とモデルクラスの対応
Model.morphMap = {
'Post': Post,
'Video': Video,
}
あとは通常のリレーションと同じように取得できます。
const postWithComments = await Post.with('comments')
const comment = await Comment.findWith(1, 'commentable')
console.log(comment.commentable) // Post か Video のレコード
Eager Loading
Model.with() を使うと、1 つ以上のリレーションを eager load できます。戻り値は各レコードの浅いコピーで、リレーションに付けた名前のプロパティに関連データが入ります。
// ユーザーと投稿を一緒に読み込む
const users = await User.with('posts')
// eager loading とフィルタを組み合わせる
const posts = await Post.with('author', { authorId: [1, 2] })
// posts[0].author は関連する UserRecord か null(belongsTo の場合)
eager loading は QueryBuilder でも使えるので、フィルタや並べ替えと組み合わせられます。レコードを返す get()、first()、firstOrFail()、paginate() のどれを使っても、リレーションが付いてきます。
const activeUsers = await User.where('active', true)
.with('posts')
.orderBy('name')
.get()
const user = await User.newQuery().with('posts').first()
// ページの各行にも author が付きます
const page = await Post.newQuery().with('author').orderBy('id', 'desc').paginate({ page: 1, perPage: 10 })
ネストしたリレーション
ネストしたリレーションは、ドットでつないで指定します。
const users = await User.with('posts.comments')
同じリレーションから複数のパスに枝分かれさせることもできます。共通する先頭のリレーションは一度だけ読み込まれるので、どの枝の結果も同じレコードに付きます。
const users = await User.newQuery().with('posts.comments', 'posts.tags').get()
users[0].posts[0].comments // 読み込まれます
users[0].posts[0].tags // こちらも読み込まれます
読み込む関連レコードを絞り込む
QueryBuilder では、with() にオブジェクトを渡してリレーションごとにコールバックを指定し、読み込むクエリに条件を加えられます。コールバックが受け取るクエリビルダーには外部キーでの絞り込みがすでにかかっているので、where() を呼ぶと、読み込む関連レコードがさらに絞り込まれます。
const users = await User.newQuery()
.with({ posts: (q) => q.where('published', true) })
.get()
users[0].posts // published な投稿だけが入る
それぞれのキーは、そのキーが指す階層にだけ効き、キーを書く順番は結果に影響しません。ドットを含むキーは最後の階層にだけ効くので、先頭のリレーションは絞り込まれません。両方の階層を絞り込みたい場合は、両方のキーを指定してください。
// `posts` は絞り込まれず、`comments` だけが絞り込まれる
await User.newQuery()
.with({ 'posts.comments': (q) => q.where('approved', true) })
.get()
// 両方の階層を絞り込む
await User.newQuery()
.with({
posts: (q) => q.where('published', true),
'posts.comments': (q) => q.where('approved', true),
})
.get()
rule
コールバックの一番外側で orWhere() を呼ぶと、ローダーが加えた外部キーの条件と
OR でつながるので、絞り込むどころか条件が広がってしまいます。次のように
グループにまとめてください: q.where((g) => g.where('a', 1).orWhere('b', 2))
select() を使う場合は、リレーションのキーになる列(hasMany や hasOne なら
外部キー、belongsTo ならオーナーキー)を必ず含めてください。含めないと、ローダー
が親レコードと突き合わせられず、リレーションが空になります。
note
リレーションは親レコードごとに読み込むのではなく、まとめて読み込みます。その
ため、コールバックの中の limit() は、親ごとではなく結果全体に効きます。
limit()・offset()・orderBy() を含むコールバックでは、キーを分けずに 1 本の
クエリで読み込みます。含まない場合は、ドライバーのパラメータ数の上限に収まるよう
にキーを分けて読み込みます。belongsToMany と hasManyThrough で分けられるのは、
親のキーではなく関連先のキーです。morphTo ではコールバックが対象の型ごとに
実行されるので、参照できるのはすべての対象に共通する列だけです。
belongsToMany と hasManyThrough では、コールバックは関連先モデルのクエリに条件を加えます。取得する行を決めるピボットテーブルや中間テーブルの検索には効かないので、関連先モデル自身の列で絞り込んでください。
// 各投稿の `news` タグだけを残す。ピボットの検索はそのまま
await Post.newQuery()
.with({ tags: (q) => q.where('label', 'news') })
.get()
静的メソッドの Model.with() はシグネチャが違い、第 2 引数は親レコードを絞り込む条件です。コールバックで条件を加えたい場合は、Model.newQuery().with({ ... }) を使ってください。
読み込んだリレーションの型
relationTypes から型が付くのは、ネストしたパスの先頭のセグメントに限られます。ネストの先まで型を付けたい場合は、先頭のリレーションのレコード型の中に、ネストした形を宣言してください。
export class User extends defineModel(users) {
declare static relationTypes: {
posts: HasManyRecord<PostRecord & { comments: CommentRecord[] }>
}
}
const loaded = await User.with('posts.comments')
loaded[0].posts[0].comments // CommentRecord[] — 末端まで型付き
note
relationTypes と照合するのは、先頭のセグメント(上の例では posts)に限られます。最初のドットより後ろは検証されない文字列なので、打ち間違いや不正な末尾('posts.'・'posts..comments'・'posts.typo')があってもコンパイルは通ります。実行時には、末尾が知らないリレーション名ならエラーになります。ただしそれは、ローダーが読み込んだ子レコードの中まで実際にたどった場合に限られます。先頭のリレーションがどのレコードでも 0 件だった場合は、末尾は一切検査されず、何も起きずに終わります。morphTo のリレーションを経由したネストは実行時に必ずエラーになりますが、この制約も型では表現されていません。
BelongsToRecord<T> は常に T | null です。外部キーが NOT NULL で、親レコードが必ず存在する場合は、代わりに BelongsToRequiredRecord<T> で宣言できます。declare 修飾子を使えば、実行時用のプレースホルダーの値も要りません。
export class Comment extends defineModel(comments) {
declare static relationTypes: {
author: BelongsToRequiredRecord<UserRecord>
}
}
const comments = await Comment.with('author')
comments[0].author.name // null チェック不要
リレーション件数の取得
withCount() は、関連レコードそのものは読み込まずに、${name}Count フィールドだけを付けます。件数だけを表示する一覧ページに向いています。
const users = await User.withCount('posts') // users[0].postsCount は number
const posts = await Post.withCount(['comments', 'author'], { published: true })
対応しているのは、hasMany / hasOne / morphMany(レコードごとの子の件数)と、belongsTo(0 か 1)です。