MongoDB Aggregation Pipelineを使った集計や検索でよく使うステージ・演算子

MongoDB Aggregation Pipeline（以下、単にaggregationと書きます）で、個人的によく使うものをまとめました。

（以下、MongoDB Aggregation Pipelineのクエリを単に“クエリ”と記載します）

MongoDB Aggregation Pipelineでは、絞り込みやグループ化など特定の機能を持った「ステージ」を繋いでいくことでクエリ全体を作ります。クエリはあるコレクションを対象に実施されます。前のステージの結果は、次のステージに渡されます。

この記事では、よく使うステージの簡単な解説を書くほか、ステージの中でよく使う演算子・式なども一部紹介します。

<br>

### 集計するデータのイメージ

以下の説明では、下記のような学校データベースを考えます。生徒、組（クラス）、テスト結果のコレクションがあるとします。

<details><summary>データサンプル</summary>

```
// classコレクションにinsertMany
[
  {
    "className": "A組",
    "grade": 2,
    "instructor": "Anzai"
  },
  {
    "className": "B組",
    "grade": 2,
    "instructor": "Inoue"
  },
  {
    "className": "C組",
    "grade": 2,
    "instructor": "Ukai"
  },
];

// studentコレクションにinsertMany
[
  {
    "name": "Akira",
    "studentNo": "S24001",
    "class": "6677b68af2d3fb740444195f",
    "club" : ["水泳部"]
  },
  {
    "name": "Sakura",
    "studentNo": "S24005",
    "class": "6677b68af2d3fb740444195f",
    "club" : ["茶道部","華道部"]
  },
  {
    "name": "Jesse",
    "studentNo": "S24034",
    "class": "6677b68af2d3fb740444195f",
    "club" : ["剣道部"]
  },
  {
    "name": "Nicolas",
    "studentNo": "S24025",
    "class": "6677b68af2d3fb7404441960",
    "club" : ["化学部","物理部","数学部"]
  },
  {
    "name": "Miku",
    "studentNo": "S24039",
    "class": "6677b68af2d3fb7404441960",
    "club" : ["合唱部"]
  },
  {
    "name": "Yuji",
    "studentNo": "S24999",
    "class": "6677b68af2d3fb7404441960",
    "club" : ["呪術部"]
  },
  {
    "name": "Satoru",
    "studentNo": "S24310",
    "class": "6677b68af2d3fb7404441961",
    "club" : ["呪術部"]
  },
  {
    "name": "Kairi",
    "studentNo": "S24009",
    "class": "6677b68af2d3fb7404441961",
    "club" : []
  },
  {
    "name": "Maki",
    "studentNo": "S24880",
    "class": "6677b68af2d3fb7404441961",
    "club" : ["呪術部","薙刀部"]
  },
];

// examination-resultコレクションにinsertMany
// examineeフィールドは、studentコレクションの各ドキュメントの_idを持たせてください
[
  {
    "examinee": "66781fbb912e3121933e5c61",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 94
  },
  {
    "examinee": "66781fbb912e3121933e5c62",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 34
  },
  {
    "examinee": "66781fbb912e3121933e5c63",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 9
  },
  {
    "examinee": "66781fbb912e3121933e5c64",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 90
  },
  {
    "examinee": "66781fbb912e3121933e5c65",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 56
  },
  {
    "examinee": "66781fbb912e3121933e5c66",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 67
  },
  {
    "examinee": "66781fbb912e3121933e5c67",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 80
  },
  {
    "examinee": "66781fbb912e3121933e5c68",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 85
  },
  {
    "examinee": "66781fbb912e3121933e5c69",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "国語",
    "score": 100
  },
  {
    "examinee": "66781fbb912e3121933e5c61",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 20
  },
  {
    "examinee": "66781fbb912e3121933e5c62",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 98
  },
  {
    "examinee": "66781fbb912e3121933e5c63",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 100
  },
  {
    "examinee": "66781fbb912e3121933e5c64",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 54
  },
  {
    "examinee": "66781fbb912e3121933e5c65",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 63
  },
  {
    "examinee": "66781fbb912e3121933e5c66",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 66
  },
  {
    "examinee": "66781fbb912e3121933e5c67",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 91
  },
  {
    "examinee": "66781fbb912e3121933e5c68",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 93
  },
  {
    "examinee": "66781fbb912e3121933e5c69",
    "scorer": "Sasaki",
    "examName": "2024中期期末考査",
    "subject": "数学",
    "score": 3
  },
]
```
</details>


##### 組コレクション（class）
※担当教員は他コレクションを参照すべきですが、今回は簡素化のためべた書きとします
|フィールド|型|内容|
|:-|:-|:-|
|_id|objectId|一意なID|
|className|string|クラスの名前|
|grade|number|学年|
|instructor|string|担当教員名|

##### 生徒コレクション（student）
|フィールド|型|内容|
|:-|:-|:-|
|_id|objectId|一意なID|
|name|string|生徒の氏名|
|studentNo|string|学生番号|
|class|string|どの組に所属するか。<br>classコレクションの_idをstringで持ちます|
|club|Array（要素はstring）|所属する部活動名（0〜複数）|

##### テスト結果コレクション（examination-result）
※採点者名は他コレクションを参照すべきですが、今回は簡素化のため教員名べた書きとします
|フィールド|型|内容|
|:-|:-|:-|
|_id|objectId|一意なID|
|examinee|string|受験者<br>studentコレクションの_idをstringで持ちます|
|scorer|string|採点者名|
|examName|string|試験名|
|subject|string|試験科目|
|score|number|得点|


## ステージの解説

### $match
コレクション内の、条件に一致するものを絞り込むことができます。
* あるフィールドの値が一致するものを絞る（例：国語科目の試験結果に絞る）
    ```javascript
    {
      subject:'国語'
    }
    ```

<br>
    
* かつ条件の使用・"以上"の表現（例：国語科目で得点が80以上の結果に絞る）
    * $andで、「かつ条件」を表現できます
    * $gteで、「以上」を表現できます
        ```javascript
        {
          $and: [
            { subject: "国語" },
            { score: { $gte: 80 } }
          ]
        }
        ```
<br>

* または条件の使用・"以下"の表現（例：10点以下または90点以上の結果に絞る）
    * $orで、「または条件」を表現できます
    * $lteで、「以下」を表現できます
        ```javascript
        {
          $or: [
            { score: { $lte: 10 } },
            { score: { $gte: 90 } }
          ]
        }
        ```

<br>

### $project
集計結果の見せ方を操作するステージです。
* どのフィールドを出力するか決める（例：試験結果の得点を出力する）
    ```javascript
    {
      score:1
    }

    // -----↓出力結果-----
    {
      "_id": { "$oid": "6677be48f2d3fb7404441975" },
      "score": 34
    },
    {
      "_id": { "$oid": "6677be48f2d3fb7404441975" },
      "score": 34
    },

    // ……and more
    ```
    `フィールド名 : 1`とすることで、そのフィールドを出力するように指定できます。
    `フィールド名 : 0`とすることで、そのフィールドを出力しないように指定できます。
    `_id`フィールドは明示的な指定がない場合、必ず出力されます。

<br>

* 特定のフィールドを指定の名前で出力する（例：`examniee`フィールドを「受験者id」という名前で出力する）
    ```javascript
    {
      _id:0,
      受験者id:'$examinee'
    }
    // -----↓出力結果-----
    {
      "受験者id": "6677b9c1f2d3fb7404441962"
    }
    ```
    `指定の名前:'$フィールド名'`とすると、対象フィールドを指定の名前で出力できます。これに限らず、クエリ内でどこかのフィールドを指定するとき`'$フィールド名'`とするケースが多いです。

<br>

* 特定フィールドを型変換して出力する（例：受験者のidフィールドを、ObjectIdに型変換して出力する）
    ```javascript
    {
      _id:0,
      受験者ObjectID:{$toObjectId:'$examinee'}
    }
    // -----↓出力結果-----
    {
      "受験者ObjectID": {
        "$oid": "6677b9c1f2d3fb7404441962"
      }
    }
    ```
    `指定のフィールド名:型変換式`とすることで、型変換しての出力が可能です。`$toObjectId`はObjectId型に変換を行います。文字列への変換`$toString`、時刻型への変換`$toDate`などもあります。


<br>

### $lookup
別コレクションや別フィールドを参照できるステージです。早い話が、JOINです。

* 別コレクションのフィールドを参照し、元コレクションの任意のフィールドと一致するドキュメントを取り込む（例：試験結果コレクションの受験者idフィールドと一致する、生徒コレクション内の生徒情報を取り込む）
    ```javascript
    {
      from: 'student',         // 参照先コレクション名
      localField: 'examinee',  // 元コレクションのフィールド
      foreignField: '_id',     // 参照先コレクションで、比較されるフィールド
      as: '生徒'                // 一致したドキュメントを格納するフィールド名
    }
    
    // -----↓出力結果-----
    
    {
      "_id": {
        "$oid": "66782108912e3121933e5c6a"
      },
      "scorer": "Sasaki",
      "examName": "2024中期期末考査",
      "subject": "国語",
      "score": 94,
      "examinee": {
        "$oid": "66781fbb912e3121933e5c61"
      },
      "生徒": [
        {
          "_id": {
            "$oid": "66781fbb912e3121933e5c61"
          },
          "name": "Akira",
          "studentNo": "S24001",
          "class": "6677b68af2d3fb740444195f",
          "club": [
            "水泳部"
          ]
        }
      ]
    }
    ```
    * lookupの第２、第３引数はフィールドを指定しますが、$が不要です
    * 上記クエリでは、事前に`examinee`フィールドをObjectId形式に型変換しました。$lookupでは、比較するフィールドの型が一致している必要があります
    * 同コレクションに対しても同じ書き方ができます   

<br>

### $group
指定したフィールドの値でグループ化するステージです。

* 特定フィールドでグループ化し、グループの件数を数える（例：生徒コレクション内の`class`フィールドでグループ化し、各クラスの人数を数える）
    ```javascript
    {
      _id: '$class',
      人数:{
        $count:{}
      }
    }
    ```

<br>


### $unwind
配列を展開するフィールドです。

* 指定の配列フィールドを展開する（例：生徒の所属部活ごとに展開する）
    * 第３引数は、対象のフィールドがnullや空配列の場合の動作を決めます。デフォルトはfalse、falseだと配列がnullや空の時にドキュメントを出力しません。trueだと、ドキュメントを出力します。
        ```javascript
        {
          path: '$club',
          includeArrayIndex: '配列インデックス',
          preserveNullAndEmptyArrays: true
        }
        
        // -----出力前-----
        {
          "_id": {
            "$oid": "66781fbb912e3121933e5c69"
          },
          "name": "Maki",
          "studentNo": "S24880",
          "class": "6677b68af2d3fb7404441961",
          "club": [
            "呪術部",
            "薙刀部"
          ]
        }
        
        // -----↓出力結果-----
        
        {
          "_id": {
            "$oid": "66781fbb912e3121933e5c69"
          },
          "name": "Maki",
          "studentNo": "S24880",
          "class": "6677b68af2d3fb7404441961",
          "club": "呪術部",
          "配列インデックス": {
            "$numberLong": "0"
          }
        }
        
        {
          "_id": {
            "$oid": "66781fbb912e3121933e5c69"
          },
          "name": "Maki",
          "studentNo": "S24880",
          "class": "6677b68af2d3fb7404441961",
          "club": "薙刀部",
          "配列インデックス": {
            "$numberLong": "1"
          }
        }
        ```

<br>


### $addfield
集計操作の中において、一時的にフィールドを追加するステージです。

* 型変換したフィールドを追加する（例：_idをstring形式にした'STRING_ID'というフィールドを用意する）
    * この操作は実際のドキュメントを書き換えるものではなく、集計時に仮置きするものです
        ```javascript
        {
          STRING_Id:{$toString:'$_id'}
        }
        ```

<br>


### $count
集計結果数を数えるステージです。
* ステージに渡されたドキュメント数を数える
    ```javascript
    'カウント'
    
    // -----↓出力結果-----
    
    カウント : 9
    ```

<br>


### $limit
後続ステージに渡すドキュメント数を制限するステージです。
* 後続ステージへ渡すドキュメント数を10に制限する
    ```
    10
    ```

<br>


### $skip
指定した数のドキュメントを先頭から省くステージです。
* 後続ステージに渡すドキュメントを先頭から10件スキップする
    ```javascript
    10
    ```

<br>

## 実例
では実際に集計を行なってみます。
「国語の試験結果が60点以上の生徒が、各クラスに何人いるか」を出してみます。
```javascript
[
  {
    $match:
      /**
       * 試験結果コレクションに対し絞り込み
       * 教科：国語で得点が60以上
       */
      {
        $and: [
          {
            subject: "国語"
          },
          {
            score: {
              $gte: 60
            }
          }
        ]
      }
  },
  {
    $addFields:
      /**
       * 後続のlookupを見越して、型変換したフィールドを用意する
       * 個人的な好みとして、クエリ内で追加したフィールドは命名規則を変える
       */
      {
        Examinee_Obj_Id: {
          $toObjectId: "$examinee"
        }
      }
  },
  {
    $lookup:
      /**
       * 生徒情報を取り出す
       */
      {
        from: "student",
        localField: "Examinee_Obj_Id",
        foreignField: "_id",
        as: "生徒情報"
      }
  },
  {
    $unwind:
      /**
       * lookup結果は配列に格納される。
       * 扱いにくいので展開する。配列のインデックスは不要なので省略。
       */
      {
        path: "$生徒情報",
        preserveNullAndEmptyArrays: true
      }
  },
  {
    $group:
      /**
       * 生徒情報が持つクラスのidでグループ化する。
       */
      {
        _id: "$生徒情報.class",
        人数: {
          $count: {}
        }
      }
  },
  {
    $project:
      /**
       * クラスの名前をlookupで取り出すのを見越し、また型変換。
       * $groupで「_id」という名前のフィールができているが、
       * これはグループ化基準に使った、生徒情報内のクラスid。
       * 名前的にわかりにくく、いると視覚的に邪魔なので
       * _idフィールドは消しつつ、型変換したフィールドを用意する。
       */
      {
        _id: 0,
        Class_Obj_Id: {
          $toObjectId: "$_id"
        },
        人数: 1
      }
  },
  {
    $lookup:
      /**
       * クラスの情報を取り出す
       */
      {
        from: "class",
        localField: "Class_Obj_Id",
        foreignField: "_id",
        as: "Class_Info"
      }
  },
  {
    $addFields:
      /**
       * lookupでクラスの情報が配列となって入ってきた。
       * またunwindしてもいいが、クラスごとにグループ化したので
       * 常に１つしか入ってこないはず。
       * そこで、配列のn番目要素を指定する$arrayElemAtを使ってみる
       * やり方が複数あるということでここはひとつ……
       */
      {
        Class_Info_Obj: {
          $arrayElemAt: ["$Class_Info", 0]
        }
      }
  },
  {
    $project:
      /**
       * 最終的な出力を整理する
       */
      {
        学年: "$Class_Info_Obj.grade",
        組: "$Class_Info_Obj.className",
        国語優秀者数: "$人数"
      }
  }
]
```
集計結果です。（JSON出力）
```json
[{
  "学年": 2,
  "組": "B組",
  "国語優秀者数": 2
},
{
  "学年": 2,
  "組": "A組",
  "国語優秀者数": 1
},
{
  "学年": 2,
  "組": "C組",
  "国語優秀者数": 3
}]
```




## 終わりに
私が頻繁に使用するステージ、演算子などを紹介しました。

最後に、MongoDB Aggregation Pipelineのクエリを触ってみたい方向けの記載です。

MongoDB Aggregation Pipelineは、MongoDB Compassというデスクトップツールを使用すると非常に書きやすいです。Aggregationの構文を補完・整形してくれたり、集計内容を直接確認しながら、各ステージを書き連ねていくことができます。

クエリの結果をJSON、CSV形式で出力でき、そのJSONやCSVを使ったDBへのインサートも可能です。

MongoDBの無料版に登録し、MongoDB Compassで自分のDBに接続すれば、好きなデータを使い好きなようにクエリを試せます。

興味のある方は、是非お試しください。

---

お読みいただきありがとうございました。
以上です。