MongoDB エンベデッドドキュメントの配列内の要素を抽出する
MongoDB でエンベデッドドキュメントの配列内の要素を条件抽出の仕方や再度配列に集約する方法を纏めました。
やりたいこと
以下のようなあるファーストフード店の売上伝票のようなコレクション mock
があるとします。
このコレクションから Mock Fly Potato
が売れた日時と個数を抽出したいとします。
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": [
{
"name": "Big Mock",
"quantity": 1,
"price": 450
},
{
"name": "Mock Fly Potato",
"quantity": 1,
"price": 190
}
]
},
{
"_id": {
"$oid": "656ecf5b579698b9bc63d984"
},
"saleDate": {
"$date": "2023-12-05T16:21:43.000Z"
},
"items": [
{
"name": "Teriyaki Mock Burger",
"quantity": 1,
"price": 370
},
{
"name": "Chiken Mock Nugget",
"quantity": 1,
"price": 240
}
]
}
同一の会計で他の商品と一緒に売られていた場合も Mock Fly Potato
のみ抽出したいとします。
db.mock.find({ "items.name": "Mock Fly Potato" })
とすると items
に Mock Fly Potato
が含まれるドキュメントの抽出はできるのですが、以下のように items
内の全ての要素が含まれている状態で抽出されます。(Big Mock
も抽出される)
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": [
{
"name": "Big Mock",
"quantity": 1,
"price": 450
},
{
"name": "Mock Fly Potato",
"quantity": 1,
"price": 190
}
]
}
このような 配列内のエンベデッドドキュメントを条件によって抽出する方法を紹介します。
環境
- OS
- Microsoft Windows 21H2
- MongoDB
- Community Edition v7.0.1
ドキュメント内配列をばらす
db.collection.aggregate()
の $unwind
ステージを使用することでドキュメント内の配列をばらす(RDB でいうところの非正規化)を行うことができます。
$unwind
Deconstructs an array field from the input documents to output a document for each element. Each output document is the input document with the value of the array field replaced by the element. -$unwind (aggregation) — MongoDB Manual
以下のように $unwind
ステージを含むパイプラインを db.collection.aggregate()
で実行します。path
に展開する配列のフィールドパスを指定します。必ず先頭に $
を付与し、ダブルクォーテーションで囲う必要があります。
db.mock.aggregate([{
$unwind: {
path: "$items"
}
}])
すると以下のように items
内のエンベデッドドキュメントが一つ一つばらされた出力を得ることができます。
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Big Mock",
"quantity": 1,
"price": 450
}
},
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Mock Fly Potato",
"quantity": 1,
"price": 190
}
},
{
"_id": {
"$oid": "656ecf5b579698b9bc63d984"
},
"saleDate": {
"$date": "2023-12-05T16:21:43.000Z"
},
"items": {
"name": "Teriyaki Mock Burger",
"quantity": 1,
"price": 370
}
},
{
"_id": {
"$oid": "656ecf5b579698b9bc63d984"
},
"saleDate": {
"$date": "2023-12-05T16:21:43.000Z"
},
"items": {
"name": "Chiken Mock Nugget",
"quantity": 1,
"price": 240
}
}
ちなみに includeArrayIndex
オプションにフィールド名を指定することで、配列のインデックスを格納する新たなフィールドを追加することができます。
db.mock.aggregate([{
$unwind: {
path: "$items",
includeArrayIndex: "idx"
}
}])
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Big Mock",
"quantity": 1,
"price": 450
},
"idx": {
"$numberLong": "0"
}
}
条件で抽出する
無事にドキュメント内の配列をばらせたので、目的の Mock Fly Potato
を抽出していきます。
パイプラインに $match
ステージを追加し、items.name
の条件を指定します。
db.mock.aggregate([{
$unwind: {
path: "$items",
}
},{
$match: {
"items.name": "Mock Fly Potato"
}
}])
無事に Mock Fly Potato
のみが抽出できました。
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Mock Fly Potato",
"quantity": 1,
"price": 190
}
}
ここでポイントは $match
ステージは必ず $unwind
ステージより後に追加してください。パイプラインは指定した順番に実行され、各ステージの結果が次のステージに渡されます。従って $match
ステージを $unwind
ステージより前に指定してしまうと、items
配列が展開される前にフィルターがかかるので冒頭の例と同様に Mock Fly Potato
と一緒に会計された Big Mock
も含まれた状態で次の $unwind
ステージに渡されます。その状態で items
配列が展開されるので、以下のように Big Mock
を含む 2 ドキュメントが返ってみます。
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Big Mock",
"quantity": 1,
"price": 450
},
"idx": {
"$numberLong": "0"
}
},
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": {
"name": "Mock Fly Potato",
"quantity": 1,
"price": 190
},
"idx": {
"$numberLong": "1"
}
}
もっとも $unwind
の結果ドキュメント数が items
の要素数分増えてしまいますので、予め items
に Mock Fly Potato
を含むドキュメントに絞った上で、$unwind
し、再度 Mock Fly Potato
でフィルターするほうが MongoDB の使用するハードウエアリソース的には優しい可能性があります。
元のドキュメント単位に集計する
今回の例では Mock Fly Potato
を一度の会計で複数個売った場合、quantity
にその個数が入るので単一の mock
ドキュメントの items
内に複数の Mock Fly Potato
が含まれる可能性はないかもしれませんが、仮に複数含まれるとした場合、再度元のドキュメント単位(会計単位)に戻したいと思うかもしれません。
このようなときは db.collection.aggregate()
の $group
ステージを使用することで任意のキーでドキュメントをグループ化することができます。
$group
The $group stage separates documents into groups according to a “group key”. The output is one document for each unique group key. -$group (aggregation) — MongoDB Manual
以下のようにパイプラインに $group
ステージを追加します。_id
フィールドには集約後のドキュメント単位のキーを指定します。今回は元のドキュメントの単位に集約するので元のドキュメントの _id
を指定しています。その他のフィールドは集約後のドキュメントのフィールドを指定していますが、_id
フィールド以外は全て集約演算子(accumulator operators)を使用する必要があります。ここでは $first
や $push
がそれです。
$first
は集約後のドキュメント単位のキーが同一のドキュメントの最初のドキュメントの値を使用する演算子です。元ドキュメントの _id
で集約するので、集約対象のドキュメントの saleDate
は全て同じはずですので最初のドキュメントの値を使用しています。
$push
は集約対象のドキュメントの指定したフィールドの値を配列に追加する演算子です。元ドキュメントの _id
が同一で複数の Mock Fly Potato
があった場合は配列に追加されて返される形になります。
db.mock.aggregate([{
$unwind: {
path: "$items",
}
},{
$match: {
"items.name": "Mock Fly Potato"
}
},
{
$group: {
_id: "$_id",
saleDate: {
$first: "$saleDate"
},
items: {
$push: {
name: "$items.name",
price: "$items.price",
quantity: "$items.quantity",
},
},
}
}])
これで元のドキュメントと同じ単位かつ items
が Mock Fly Potato
のみに抽出されたドキュメントが出来上がりました。
{
"_id": {
"$oid": "656eced4579698b9bc63d983"
},
"saleDate": {
"$date": "2023-12-05T16:18:27.000Z"
},
"items": [
{
"name": "Mock Fly Potato",
"price": 190,
"quantity": 1
}
]
}
あとがき
db.collection.aggregate()
はデータを扱う様々なステージが用意されているので、使いこなせば便利です。各言語のクライアントからも使用できるので、アプリケーションへもそのまま組み込めます。