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" })

とすると itemsMock 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 の要素数分増えてしまいますので、予め itemsMock 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",
      },
    },
  }
}])

これで元のドキュメントと同じ単位かつ itemsMock 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() はデータを扱う様々なステージが用意されているので、使いこなせば便利です。各言語のクライアントからも使用できるので、アプリケーションへもそのまま組み込めます。