はじめに
この記事について
この記事は、私がNestJS × mongoDBの環境で初学者からキャッチアップしつつ行ってきた開発を通して「リレーションっぽいことしたいな〜」「外部結合してフィルターにかけたい!」とRDB的思考で調べた手法の共有が目的です。
外部キー参照が発生するCollectionの設計が適切かどうかは置いておいて、既存のCollectionに対する外部参照をできればいいのに、と思う機会は少なからず発生し得ると思いますので、そんな方に届けばいいなと思います。
今回はmongoDBを扱うにあたって、NestJSでmongooseを使用した環境を例にしています。
populateメソッドを使ったデータの結合
schema定義
NestJSのschema定義において、他のモデルとの関係を指定したい場合、@Prop()
デコレーターのtypeとrefプロパティを指定して下記のように書くことができます。
MongoDB | NestJS
import * as mongoose from 'mongoose';
import { Owner } from '../schemas/owner.schema';
@Schema()
export class Cat {
@Prop()
name: string;
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' })
owner: Owner;
}
これによってCat schemaはOwner schemaに対して外部キー参照のような状態を定義できています。
もし、GraphQLスキーマの定義も兼ねる場合、
import { ObjectId } from 'mongoose';
import { Owner } from '../schemas/owner.schema';
@Field(() => Owner)
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Owner' })
owner: ObjectId;
}
データベースに保存される際、このフィールドはObjectIdとして保存されますが、GraphQLのスキーマ定義として用いる場合、下記のようにownerフィールドはOwner型のオブジェクトとして扱うことができます。
GraphQL + TypeScript - Resolvers
query {
findCatById(id:"254925s94c6qv4f37324506"){
_id
name
owner {
_id
name
}
}
}
また、私達のプロジェクトでは、Model操作を行うためのRepository層を定義しています。
ここではfindOneなど基本的なメソッドで自由にpopulateを併用できるようにしています。
export class CatRepository {
constructor(
@InjectModel(Cat.name)
private catModel: Model<Cat>,
) {}
async findOne(
query: FilterQuery<Cat>,
populate?: Populate,
opt?: QueryOptions<Cat>,
) {
const exeQuery = this.catModel.findOne(query, null, opt);
if (populate) {
exeQuery.populate(populate);
}
return exeQuery;
}
}
populateを使った結果、下記のようなオブジェクトを取得することができます。
これによって別のCollectionのレコードを結合して、取得することができました。
{ "_id": "65892c384c1ac5f3c32446b1",
"name": "Margot",
"owner": {
"_id": "65811eea4c1ac5f3c3195b92",
"name": "John",
"__v": 0,
},
"__v": 0
}
aggregate $lookupを使った左外部結合によるフィルター
先程のpopulateでは、RDBのリレーションのように、外部Collectionのデータをくっつけて取得することができました。しかし、例えばRDBのイメージでいうところの、親テーブルのあるフィールドの値でフィルターにかけて、子テーブルのデータを抽出したいといった場合にpopulateを用いると二度手間になってしまいます。
そこで、$lookup を使うことでCollectionの左外部結合を行い、「結合」されたコレクションからドキュメントをフィルタリングできます。
$lookup (aggregation) — MongoDB Manual
mongooseを使ったaggregateによる抽出
mongoDBのaggregate pipelineは複数のステージを指定することができ、一度のaggregateメソッドで複数の操作を同時に行うことが可能です。
Mongoose v8.0.3: Aggregate
集計パイプラインの使用の開始 | Microsoft Learn
$lookup (aggregation) — MongoDB Manual
今回は例として4ステージ用意してみます。
-
$matchステージでは亡くなっている猫の情報は含めないという意図を想定して、
passedAwayDate
(没日)フィールドが存在しないCatドキュメントを始めにフィルタリングしています。 -
$lookupステージでCatコレクションのドキュメントをOwnerコレクションのドキュメントと結合しています。
Catコレクションのowner
フィールド(ObjectId型)と Owner コレクションの_id
フィールドを基に結合が行われ、結果はmyOwner
という新しいフィールドに配列として格納されます。 -
$unwindステージでは、
myOwner
配列が展開され、配列内の各要素が個別のドキュメントに変換されます。これにより、各Cat
ドキュメントはそれに対応するOwner
ドキュメントと結合された状態になります。 -
2度目の
$match
ステージで、myOwner.code
フィールドがnull
でなく、かつmyOwner.code
の値が指定された値より大きいCat
ドキュメントをフィルタリングします。
const filteringCode = 10;
const sameOwnerCats = catModel.aggregate(
[
{
$match: {
passedAwayDate: { $exists: false },
},
},
{
$lookup: {
from: 'owners', //結合するコレクション
localField: 'owner', //元コレクションの結合するフィールド
foreignField: '_id', //"from"コレクションのフィールド
as: 'myOwner', //結合結果のフィールド
},
},
{
$unwind: '$myOwner',
},
{
$match: {
'myOwner.code': {
$ne: null,
$gt: filteringCode,
},
},
},
],
);
イメージとして、ステップ3を過ぎた時点で、populateで取得したときのように、owner
フィールドのドキュメント情報がmyOwner
として新たなフィールドに直接含まれています。
{
"_id": "65892c384c1ac5f3c32446b1",
"name": "Margot",
"owner": "65811eea4c1ac5f3c3195b92",
"myOwner": {
"_id": "65811eea4c1ac5f3c3195b92",
"name": "John",
"code": "12345",
"__v": 0
},
"__v": 0
}
これに対して、2度目の$matchでフィルタリングを行うことで、親テーブルのフィールドを使ってフィルタリングするような結果を実現できます。
おまけ find(), $lookup, populate()のパフォーマンス的な違い
ここまで、外部Collectionを使ったデータの取得方法について書いてきましたが、外部キーを使った参照の場合、RDB同様パフォーマンスに対する影響は少なからず発生すると思います。
そこで、調べてみたところ少し面白い検証をしている記事がありましたので参考に記載しておきます。
パフォーマンスの検証
手法を選ぶ基準の一助として、mongooseを使ったfind()、aggregate $lookup、populate()のベンチマークを行っている記事がありましたので簡単な共有です。
When to use find() vs aggregate $lookup vs populate() in Mongoose and MongoDB | by Michael Truong | Medium
ベンチマークの結果には、1000ドキュメントを超えると、populateが圧倒的にレスポンスまでの時間を要することが描かれていました。
populateメソッドは、Queryで取得した結果の1つ1つに対して、外部テーブルの参照結果への置き換えが行われている仕組みのようです。あくまで Mongoose が作成するものなので、$lookup や find() のように MongoDB ドライバから直接作成するものと比較すると効率が悪くなるようです。
Population is the process of automatically replacing the specified paths in the document with document(s) from other collection(s).
Mongoose v8.0.3: Query Population
そのため、N個のデータを取得するためにN+1回のクエリが実行されているようです。
(mongooseではQuery.prototype.explain()を使って実行されるクエリを確認することができるようなので別の機会に一度実際に確認してみたいですね。)
Use the populate method to execute the find operation on object a where the property b equals 'thing'.
Mongodb: Exploring the Inner Workings of Mongoose's Population Functionality
しかし、実際のコーディングの視点でいうと、上記で示した例のように$lookupを使ったクエリは複雑になる傾向があります。pupulateメソッドはそれに比べて可読性が高く、schemaファイルとの親和性も高いです。
1000件程度までのパフォーマンスに大差がないことからも、aggregateを外部結合も含めてどうしても用いる必要がある場合や、パフォーマンスが非常に重要になるデータを扱う場合以外は、populateを用いてフィルタリングすることも適切な選択肢のようですね。
まとめ
今回は、mongooseを使った外部結合の方法であるpopulate & aggregate $lookupについて使用方法と、どちらを使用するべきか選択するためにパフォーマンスの観点で簡単に調べたことの共有でした。
結論、mongooseのpopulateは非常に万能なツールであることは確かです。
もしどうしても外部結合をしてフィルタリングやデータの取得を行う必要がある場合は、まずはpopulateを使うことが私のお勧めです。
NestJSやmongoDB、mongooseに関する日本語の記事はまだまだ少なく、aggregateなど強力なツールがある一方で、初学者にとっては非常にハードルが高い状態だと日々感じています。
これからもっともっとmongoDBが活用され、日本語の界隈も盛り上がればいいなと思っています。