MongoDB 新手入門 - Aggregation

mylxsw發表於2022-05-30
[TOC]

本文是 MongoDB 新手入門 系列的第二篇,在本文中,我們將會講解 MongoDB 的聚合框架,在看完本文後,讀者可以掌握使用 MongoDB 進行常用的資料統計分析方法。

本文將會持續修正和更新,最新內容請參考我的 GITHUB 上的 程式猿成長計劃 專案,歡迎 Star,更多精彩內容請 follow me

簡介

image-20220530113736173

聚合管道(Aggregation Pipelines)中包含一個或多個用於處理文件的步驟(stages):

  • 每一個步驟(stage)都會對輸入的文件執行某個操作,例如,$match 步驟可以用於篩選文件,$group 步驟可以對文件進行分組並且計算欄位的平均值
  • 每個步驟的輸出文件將會作為下一個步驟的輸入文件
  • 所有步驟執行完成後,聚合管道會返回文件處理後的結果,比如返回當前值,平均值,最大值和最小值等

MongoDB 4.2 開始,可以使用聚合管道來更新文件了。

聚合管道的語法為

db.collection.aggregate( [ { <stage> }, ... ] )

為了演示聚合管道的功能,我們現在 MongoDB 中建立一個 orders 集合,插入以下資料

db.orders.insertMany( [
   { _id: 0, name: "Pepperoni", size: "small", price: 19,
     quantity: 10, date: ISODate( "2021-03-13T08:14:30Z" ) },
   { _id: 1, name: "Pepperoni", size: "medium", price: 20,
     quantity: 20, date : ISODate( "2021-03-13T09:13:24Z" ) },
   { _id: 2, name: "Pepperoni", size: "large", price: 21,
     quantity: 30, date : ISODate( "2021-03-17T09:22:12Z" ) },
   { _id: 3, name: "Cheese", size: "small", price: 12,
     quantity: 15, date : ISODate( "2021-03-13T11:21:39.736Z" ) },
   { _id: 4, name: "Cheese", size: "medium", price: 13,
     quantity:50, date : ISODate( "2022-01-12T21:23:13.331Z" ) },
   { _id: 5, name: "Cheese", size: "large", price: 14,
     quantity: 10, date : ISODate( "2022-01-12T05:08:13Z" ) },
   { _id: 6, name: "Vegan", size: "small", price: 17,
     quantity: 10, date : ISODate( "2021-01-13T05:08:13Z" ) },
   { _id: 7, name: "Vegan", size: "medium", price: 18,
     quantity: 10, date : ISODate( "2021-01-13T05:10:13Z" ) }
] )

下面的示例會計算兩個日期之間每天的的披薩訂單總價值和平均數量

image-20220530101059434

// SQL:SELECT 
//                 DATE_FORMAT(date, '%Y-%m-%d') AS _id, 
//        SUM(price * quantity) AS totalOrderValue,
//        AVG(quantity) AS averageOrderQuantity
//      FROM orders
//      WHERE date >= '2020-01-30' AND date < '2022-01-30'
//      GROUP BY DATE_FORMAT(date, '%Y-%m-%d')
//      ORDER BY SUM(price * quantity) DESC
db.orders.aggregate( [
   // Stage 1: 透過時間範圍過濾披薩訂單
   {
      $match:
      {
         "date": { $gte: new ISODate( "2020-01-30" ), $lt: new ISODate( "2022-01-30" ) }
      }
   },
   // Stage 2: 對匹配的訂單進行分組,並且計算總價值和平均數量
   {
      $group:
      {
         _id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
         totalOrderValue: { $sum: { $multiply: [ "$price", "$quantity" ] } },
         averageOrderQuantity: { $avg: "$quantity" }
      }
   },
   // Stage 3: 按照 totalOrderValue 對文件進行反向排序
   {
      $sort: { totalOrderValue: -1 }
   }
 ] )

命令輸出如下所示

[
   { _id: '2022-01-12', totalOrderValue: 790, averageOrderQuantity: 30 },
   { _id: '2021-03-13', totalOrderValue: 770, averageOrderQuantity: 15 },
   { _id: '2021-03-17', totalOrderValue: 630, averageOrderQuantity: 30 },
   { _id: '2021-01-13', totalOrderValue: 350, averageOrderQuantity: 10 }
]

系統變數

在聚合管道的步驟中可以使用系統變數或者使用者自定義的變數,變數可以是任意的 BSON 型別資料,要訪問變數的值,使用字首 , 如 <variable>。如果變數引用的是一個物件,可以這樣訪問指定的欄位 $$<variable>.<field>

MongoDB 中定義了以下系統變數

變數 描述
NOW 當前日期時間
CLUSTER_TIME 當前時間戳,CLUSTER_TIME 只在副本集和分片叢集中有效
ROOT 引用根文件
CURRENT 引用聚合管道正在處理的欄位路徑開始部分,除非特別說明,所有的 stage 開始的時候 $CURRENT 都和 $ROOT 相同。$CURRENT 是可修改的,$<field> 等價於 $$CURRENT.<field>,重新繫結 CURRENT 會改變 $ 的含義
REMOVE 標識值為缺失,用於按條件來排除欄位,配合 $project使用時,把一個欄位設定為變數 REMOVE 可以在輸出中排除這個欄位,參考 有條件的排除欄位
DESCEND $redact 表示式允許的結果之一
PRUNE $redact 表示式允許的結果之一
KEEP $redact 表示式允許的結果之一

這裡以 $$REMOVE 為例,說明系統變數的使用

db.books.aggregate( [
   {
      $project: {
         title: 1,
         "author.first": 1,
         "author.last" : 1,
         "author.middle": {
            // 這裡判斷 $author.middle 是否為空,為空則將該欄位移除,否則返回該欄位
            $cond: {
               if: { $eq: [ "", "$author.middle" ] },
               then: "$$REMOVE",
               else: "$author.middle"
            }
         }
      }
   }
] )

這裡的 $cond 運算子用於計算一個 Bool 表示式,類似於程式語言中的三元運算子。

聚合管道中常用的步驟

db.collection.aggreagte() 方法中,除了 $out$merge$geoNear 之外,其它的 stage 都可以出現多次。

Stage 描述
$addFields 在文件中新增新的欄位,與 $project 類似,$addFields 會在文件中新增新的欄位,$set$addFields 的別名
$bucket 根據指定的表示式和桶邊界將傳入的文件分組,這些組稱之為儲存桶
$bucketAuto $bucket,只不過該 stage 會自動的確定儲存桶的邊界,嘗試將文件均勻的分配到指定數量的儲存桶中
$collStats 返回關於集合或者檢視的統計資訊
$count 返回聚合管道在當前 stage 中的文件總數
$facet 對同一批輸入文件,在一個 stage 中處理多個聚合管道,每一個子管道都有它自己的輸出文件,最終的結果是一個文件陣列
$geoNear 根據地理空間位置的遠近返回排序後的文件流,結合 $match$sort$limit 的功能。輸出文件中新增了一個額外的距離欄位
$graphLookup 在集合上執行遞迴搜尋,對於每一個輸出的文件都新增一個新的陣列欄位,該欄位包含了對文件進行遞迴搜尋的遍歷結果
$group 透過指定的表示式對文件進行分組,並且對每個分組應用累加表示式
$indexStats 返回集合中每個索引的統計資訊
$limit 限制返回的文件數量
$listSessions 列出在 system.sessions 集合中所有的會話記錄
$lookup 對同一個資料庫中的集合執行執行左外連線(left outer join)操作
$match 文件過濾
$merge MongoDB 4.2 新增功能,將聚合管道的輸出文件寫入到一個集合。當前 stage 可以將合併結果納入到輸出集合中。該 stage 必須是管道中的最後一個 stage
$out 將聚合管道的結果寫入到一個集合中,該 stage 必須是管道中的最後一個 stage
$planCacheStats 返回集合的計劃快取資訊
$project 對集合文件返回的欄位進行處理,新增或者刪除欄位
$redact 透過文件本身儲存的資訊限制每個文件的內容,等價於 $project$match 一起使用,可以用來實現欄位級的修訂,對於每個輸入的文件,輸出1個或者0個文件
$replaceRoot 使用指定的內嵌文件替換當前文件。該操作會替換輸入文件中包含 _id 在內的所有已經存在的欄位
$replaceWith $replaceRoot 操作的別名
$sample 從輸入文件中隨機選擇指定數量的文件
$search 對文件執行全文搜尋(只在 MongoDB Atlas 叢集中有效,本地部署服務不可用)
$set 為文件新增新的欄位,與 $project 類似, $set 會在輸出文件中新增新的欄位。$set$addFields 的別名
$setWindowFields MongoDB 4.0 新增功能,將文件以視窗的形式分組,然後對於每一個視窗的文件執行一個或者多個操作
$skip 跳過指定數量的文件
$sort 按照指定的 Key 對文件進行排序
$sortByCount 對輸入的文件基於指定的表示式進行分組,然後計算每一個唯一組中文件的數量
$unionWith MongoDB 4.4 新增功能,對兩個集合執行合併操作,例如將兩個集合中的結果合併為一個結果集
$unset 從文件中移除指定欄位
$unwind 將文件中的陣列欄位拆分為多個文件

本文只對常用的幾個 stage 進行重點介紹,它們分別是 $match$count$limit$project$lookup$group$facet$unwind$bucket$bucketAuto

文件過濾 $match

$match 用於過濾篩選文件,語法如下

{ $match: { <query> } }

在 MongoDB 中建立名為 articles 的集合

{ "_id" : ObjectId("512bc95fe835e68f199c8686"), "author" : "dave", "score" : 80, "views" : 100 }
{ "_id" : ObjectId("512bc962e835e68f199c8687"), "author" : "dave", "score" : 85, "views" : 521 }
{ "_id" : ObjectId("55f5a192d4bede9ac365b257"), "author" : "ahn", "score" : 60, "views" : 1000 }
{ "_id" : ObjectId("55f5a192d4bede9ac365b258"), "author" : "li", "score" : 55, "views" : 5000 }
{ "_id" : ObjectId("55f5a1d3d4bede9ac365b259"), "author" : "annT", "score" : 60, "views" : 50 }
{ "_id" : ObjectId("55f5a1d3d4bede9ac365b25a"), "author" : "li", "score" : 94, "views" : 999 }
{ "_id" : ObjectId("55f5a1d3d4bede9ac365b25b"), "author" : "ty", "score" : 95, "views" : 1000 }

執行查詢

// SQL:SELECT * FROM articles WHERE author = "dave"
db.articles.aggregate(
    [ { $match : { author : "dave" } } ]
);

查詢結果

{ "_id" : ObjectId("512bc95fe835e68f199c8686"), "author" : "dave", "score" : 80, "views" : 100 }
{ "_id" : ObjectId("512bc962e835e68f199c8687"), "author" : "dave", "score" : 85, "views" : 521 }

文件計數 $count

$count 用於統計輸入中的文件數量,語法如下

{ $count: <string> }

這裡的 <string> 是輸出欄位的名稱。

db.getCollection("orders").aggregate([
    { $match: {price: {$gt: 15}} },
    { $count: "price_gt_15_count" }
])

輸出

{"price_gt_15_count" : NumberInt(5) }

文件數量限制 $limit

$limit 用於控制傳遞給下一個 stage 的文件數量,語法為

{ $limit: <positive 64-bit integer> }

比如只返回 2 條資料

db.getCollection("orders").aggregate([{$limit: 2}])

文件欄位對映 $project

$project 用於控制文件中包含的欄位,類似於 SQL 中的 AS,它會把文件中指定的欄位傳遞個下一個 stage。

語法為

{ $project: { <specification(s)> } }

這裡的 <specification(s)> 支援以下形式

形式 說明
<field>: <1 or true> 指定包含欄位,非 0 的整數都為 true
_id: <0 or false> 指定消除 _id 欄位,預設是包含 _id 欄位的
<field>: <expression> 新增新欄位或者是覆蓋已有欄位
<field>: <0 or false> 指定排除欄位

查詢訂單,只返回名稱和尺寸

// SQL:SELECT name, size FROM orders WHERE quantity > 20
db.orders.aggregate([
    { $match: { quantity: { $gt: 20 } } },
    { $project: { name: true, size: 1, _id: false } }
])

返回值如下

{ "name" : "Pepperoni", "size" : "large" }
{ "name" : "Cheese", "size" : "medium" }

左外連線 $lookup

$lookup 用於對同一個資料庫中的集合進行 left outer join 操作。

image-20220530112940621

單個 Join 條件的等值匹配

語法如下

{
   $lookup:
     {
       from: <collection to join>,
       localField: <field from the input documents>,
       foreignField: <field from the documents of the "from" collection>,
       as: <output array field>
     }
}

引數

  • from: 指定要進行關聯的集合,from 集合不能是分片集合
  • localField:輸入文件中用於關聯的欄位,localField 的值與 from 集合中的 foreignField 相等,如果輸入文件中不包含 localField,則該值為 null
  • foreignField: 指定 from 集合中的關聯欄位,如果集合中沒有該欄位,則認為其為 null
  • as: 指定要新增到輸入文件中的陣列欄位名稱。這個陣列欄位包含了 from 集合中匹配的文件。如果指定的欄位名在輸入文件中已經存在,則覆蓋該欄位

這個操作等價於以下的偽 SQL:

SELECT *, <output array field>
FROM collection
WHERE <output array field> IN (
   SELECT *
   FROM <collection to join>
   WHERE <foreignField> = <collection.localField>
);

我們先在 orders 集合中插入幾個文件

db.orders.insertMany( [
   { "_id" : 1, "item" : "almonds", "price" : 12, "quantity" : 2 },
   { "_id" : 2, "item" : "pecans", "price" : 20, "quantity" : 1 },
   { "_id" : 3  }
] )

然後建立另外一個 inventory 集合

db.inventory.insertMany( [
   { "_id" : 1, "sku" : "almonds", "description": "product 1", "instock" : 120 },
   { "_id" : 2, "sku" : "bread", "description": "product 2", "instock" : 80 },
   { "_id" : 3, "sku" : "cashews", "description": "product 3", "instock" : 60 },
   { "_id" : 4, "sku" : "pecans", "description": "product 4", "instock" : 70 },
   { "_id" : 5, "sku": null, "description": "Incomplete" },
   { "_id" : 6 }
] )

下面的查詢使用 orders 集合來關聯 inventory 集合,使用 item 和 sku 來進行關聯

// SQL:SELECT *, inventory_docs
//      FROM orders
//      WHERE inventory_docs IN (
//         SELECT *
//         FROM inventory
//         WHERE sku = orders.item
//      );
db.orders.aggregate( [
   {
     $lookup:
       {
         from: "inventory",
         localField: "item",
         foreignField: "sku",
         as: "inventory_docs"
       }
  }
] )

該操作返回值如下

{
   "_id" : 1,
   "item" : "almonds",
   "price" : 12,
   "quantity" : 2,
   "inventory_docs" : [
      { "_id" : 1, "sku" : "almonds", "description" : "product 1", "instock" : 120 }
   ]
}
{
   "_id" : 2,
   "item" : "pecans",
   "price" : 20,
   "quantity" : 1,
   "inventory_docs" : [
      { "_id" : 4, "sku" : "pecans", "description" : "product 4", "instock" : 70 }
   ]
}
{
   "_id" : 3,
   "inventory_docs" : [
      { "_id" : 5, "sku" : null, "description" : "Incomplete" },
      { "_id" : 6 }
   ]
}

聯表後的集合上的 Join 條件和子查詢

語法如下

{
   $lookup:
      {
         from: <joined collection>,
         let: { <var_1>: <expression>,, <var_n>: <expression> },
         pipeline: [ <pipeline to run on joined collection> ],
         as: <output array field>
      }
}

引數:

  • let:可選引數,指定了在 pipeline 步驟中可以使用的變數,這些變數用於作為 pipeline 的輸入訪問聯表後的集合文件。在 pipeline 中使用 $$<variable> 語法來訪問變數
  • pipeline:指定在聯表後的集合上執行的 pipeline,這些 pipeline 決定了聯表後集合的輸出,要返回所有文件的話,指定 pipeline 為 []

該操作等價於下面的偽 SQL:

SELECT *, <output array field>
FROM collection
WHERE <output array field> IN (
   SELECT <documents as determined from the pipeline>
   FROM <collection to join>
   WHERE <pipeline>
);

我們首先建立下面兩個集合

db.orders.insertMany( [
  { "_id" : 1, "item" : "almonds", "price" : 12, "ordered" : 2 },
  { "_id" : 2, "item" : "pecans", "price" : 20, "ordered" : 1 },
  { "_id" : 3, "item" : "cookies", "price" : 10, "ordered" : 60 }
] )

db.warehouses.insertMany( [
  { "_id" : 1, "stock_item" : "almonds", warehouse: "A", "instock" : 120 },
  { "_id" : 2, "stock_item" : "pecans", warehouse: "A", "instock" : 80 },
  { "_id" : 3, "stock_item" : "almonds", warehouse: "B", "instock" : 60 },
  { "_id" : 4, "stock_item" : "cookies", warehouse: "B", "instock" : 40 },
  { "_id" : 5, "stock_item" : "cookies", warehouse: "A", "instock" : 80 }
] )

執行查詢

// SQL: SELECT *, stockdata
//      FROM orders
//      WHERE stockdata IN (
//         SELECT warehouse, instock
//         FROM warehouses
//         WHERE stock_item = orders.item
//         AND instock >= orders.ordered
//      );
db.orders.aggregate( [
   {
      $lookup:
         {
           from: "warehouses",
           let: { order_item: "$item", order_qty: "$ordered" },
           pipeline: [
              { $match:
                 { $expr:
                    { $and:
                       [
                         { $eq: [ "$stock_item",  "$$order_item" ] },
                         { $gte: [ "$instock", "$$order_qty" ] }
                       ]
                    }
                 }
              },
              { $project: { stock_item: 0, _id: 0 } }
           ],
           as: "stockdata"
         }
    }
] )

該操作返回以下結果

{
  _id: 1,
  item: 'almonds',
  price: 12,
  ordered: 2,
  stockdata: [
    { warehouse: 'A', instock: 120 },
    { warehouse: 'B', instock: 60 }
  ]
},
{
  _id: 2,
  item: 'pecans',
  price: 20,
  ordered: 1,
  stockdata: [ { warehouse: 'A', instock: 80 } ]
},
{
  _id: 3,
  item: 'cookies',
  price: 10,
  ordered: 60,
  stockdata: [ { warehouse: 'A', instock: 80 } ]
}

使用簡潔語法的相關子查詢

該特性為 MongoDB 5.0 的新功能。從 MongoDB 5.0 開始,可以使用簡潔的語法進行相關子查詢,相關子查詢的子查詢文件欄位來自於連線的 foreign 和 local 集合。

下面是新的簡潔的語法,它移除了 $expr 表示式中 foreign 和 local 欄位的等值匹配:

{
   $lookup:
      {
         from: <foreign collection>,
         localField: <field from local collection's documents>,
         foreignField: <field from foreign collection's documents>,
         let: { <var_1>: <expression>,, <var_n>: <expression> },
         pipeline: [ <pipeline to run> ],
         as: <output array field>
      }
}

該操作的偽 SQL 如下

SELECT *, <output array field>
FROM localCollection
WHERE <output array field> IN (
   SELECT <documents as determined from the pipeline>
   FROM <foreignCollection>
   WHERE <foreignCollection.foreignField> = <localCollection.localField>
   AND <pipeline match condition>
);

分組 $group

image-20220530104816469

$group 對輸入的文件按照指定的 _id 表示式進行分組,語法如下

{
  $group:
    {
      _id: <expression>, // Group By Expression
      <field1>: { <accumulator1> : <expression1> },
      ...
    }
 }

選項 _id 指定了用於分組的 key 表示式,類似於 SQL 中的 group by,如果設定為 null 或者任何常數值,則對所有的文件作為一個整體進行計算。

accumulator 支援以下操作

名稱 描述
$accumulator 使用者定義的 accumulator 函式執行結果
$addToSet 為每一個分組返回唯一性表示式值的陣列,陣列元素的順序不確定
$avg 數值型值的平均值,非數值型的值會被忽略
$count 分組包含的文件數量
$first 分組中的第一個文件
$last 分組中的最後一個文件
$max 分組中的最大值
$mergeObjects 將分組中的文件合併到一起做為一個文件
$min 分組中的最小值
$push 返回每個分組中文件的表示式值陣列
$stdDevPop 輸入值的總體標準偏差
$stdDevSamp 輸入值的樣本標準偏差
$sum 數值型值的總和,非數值型將會被忽略

預設情況下, $group 步驟有 100M 的記憶體限制,如果超過這個限制將會報錯。可以使用 allowDiskUse 選項來啟用磁碟臨時檔案來解決這個問題。

統計不同大小的披薩訂單銷售總量

db.getCollection("orders").aggregate(
    [
        { 
            $group : { 
                _id : "$size", 
                count : { $sum : 1 }
            }
        }
    ], 
    { 
        "allowDiskUse" : true
    }
);

輸出如下

{ "_id" : "medium", "count" : 3.0 }
{ "_id" : "small", "count" : 3.0 }
{ "_id" : "large", "count" : 2.0 }

查詢訂單中有幾種尺寸的披薩

db.getCollection("orders").aggregate([
    { 
        $group: {_id: "$size"}
    }
]);

輸出如下

{ "_id" : "medium" }
{ "_id" : "large" }
{ "_id" : "small" }

查詢銷量大於等於 3 個的披薩尺寸

類似於 SQL 中的 GROUP BY ... HAVING COUNT(*) >= 3

// SQL: SELECT size as _id, count(*) as count FROM orders GROUP BY size HAVING COUNT(*) >= 3 
db.getCollection("orders").aggregate(
    [
        { 
            $group : { 
                _id : "$size", 
                count : { $sum : 1 }
            }
        },
        {
            $match: { count: { $gte: 3} }
        }
    ]
);

輸出如下

{ "_id" : "medium", "count" : 3.0 }
{ "_id" : "small", "count" : 3.0 }

對披薩訂單按照尺寸分組,返回每個組中披薩的名稱集合

db.getCollection("orders").aggregate([
    { 
        $group: {
            _id: "$size", 
            names: { $push: "$name" } 
        }
    }
])

輸出如下

{ "_id" : "large", "names" : [ "Pepperoni", "Cheese" ] }
{ "_id" : "small", "names" : [ "Pepperoni", "Cheese", "Vegan" ] }
{ "_id" : "medium", "names" : [ "Pepperoni", "Cheese", "Vegan" ] }

按照披薩訂單尺寸分組,返回包含的訂單以及披薩數量

db.getCollection("orders").aggregate([
    { $group: { _id: "$size", orders: { $push: "$$ROOT" } } },
    {
        $addFields: {
            totalQuantity: { $sum: "$orders.quantity" }
        }
    }
])

輸出如下

image-20220529152338217

這裡的 $$ROOT 是 MongoDB 中內建的系統變數,引用了根文件(頂級文件),這裡透過該變數和 $push 操作,將文件放到了分組後新文件的 orders 欄位,更多系統變數見下一章節。

多切面文件聚合 $facet

$facet 用於在一個 stage 中對同一批文件執行多個聚合管道處理。每一個聚合管道的輸出文件都有自己的欄位,最終輸出是這些管道的結果陣列。

輸入文件只傳遞給 $facet 階段一次,它可以在同一批輸入文件集合上執行不同的聚合操作。

image-20220530120154856

語法如下

{ $facet:
   {
      <outputField1>: [ <stage1>, <stage2>, ... ],
      <outputField2>: [ <stage1>, <stage2>, ... ],
      ...
   }
}

建立一個名為 artwork 的集合

{ "_id" : 1, "title" : "The Pillars of Society", "artist" : "Grosz", "year" : 1926,
  "price" : NumberDecimal("199.99"),
  "tags" : [ "painting", "satire", "Expressionism", "caricature" ] }
{ "_id" : 2, "title" : "Melancholy III", "artist" : "Munch", "year" : 1902,
  "price" : NumberDecimal("280.00"),
  "tags" : [ "woodcut", "Expressionism" ] }
{ "_id" : 3, "title" : "Dancer", "artist" : "Miro", "year" : 1925,
  "price" : NumberDecimal("76.04"),
  "tags" : [ "oil", "Surrealism", "painting" ] }
{ "_id" : 4, "title" : "The Great Wave off Kanagawa", "artist" : "Hokusai",
  "price" : NumberDecimal("167.30"),
  "tags" : [ "woodblock", "ukiyo-e" ] }
{ "_id" : 5, "title" : "The Persistence of Memory", "artist" : "Dali", "year" : 1931,
  "price" : NumberDecimal("483.00"),
  "tags" : [ "Surrealism", "painting", "oil" ] }
{ "_id" : 6, "title" : "Composition VII", "artist" : "Kandinsky", "year" : 1913,
  "price" : NumberDecimal("385.00"),
  "tags" : [ "oil", "painting", "abstract" ] }
{ "_id" : 7, "title" : "The Scream", "artist" : "Munch", "year" : 1893,
  "tags" : [ "Expressionism", "painting", "oil" ] }
{ "_id" : 8, "title" : "Blue Flower", "artist" : "O'Keefe", "year" : 1918,
  "price" : NumberDecimal("118.42"),
  "tags" : [ "abstract", "painting" ] }

使用 $facet 對資料按照三個維度進行統計

image-20220530102752351

db.artwork.aggregate( [
  {
    $facet: {
      "categorizedByTags": [
        { $unwind: "$tags" },
        { $sortByCount: "$tags" }
      ],
      "categorizedByPrice": [
        // Filter out documents without a price e.g., _id: 7
        { $match: { price: { $exists: 1 } } },
        {
          $bucket: {
            groupBy: "$price",
            boundaries: [  0, 150, 200, 300, 400 ],
            default: "Other",
            output: {
              "count": { $sum: 1 },
              "titles": { $push: "$title" }
            }
          }
        }
      ],
      "categorizedByYears(Auto)": [
        {
          $bucketAuto: {
            groupBy: "$year",
            buckets: 4
          }
        }
      ]
    }
  }
])

輸出文件

{
  "categorizedByYears(Auto)" : [
    // First bucket includes the document without a year, e.g., _id: 4
    { "_id" : { "min" : null, "max" : 1902 }, "count" : 2 },
    { "_id" : { "min" : 1902, "max" : 1918 }, "count" : 2 },
    { "_id" : { "min" : 1918, "max" : 1926 }, "count" : 2 },
    { "_id" : { "min" : 1926, "max" : 1931 }, "count" : 2 }
  ],
  "categorizedByPrice" : [
    {
      "_id" : 0,
      "count" : 2,
      "titles" : [
        "Dancer",
        "Blue Flower"
      ]
    },
    {
      "_id" : 150,
      "count" : 2,
      "titles" : [
        "The Pillars of Society",
        "The Great Wave off Kanagawa"
      ]
    },
    {
      "_id" : 200,
      "count" : 1,
      "titles" : [
        "Melancholy III"
      ]
    },
    {
      "_id" : 300,
      "count" : 1,
      "titles" : [
        "Composition VII"
      ]
    },
    {
      // Includes document price outside of bucket boundaries, e.g., _id: 5
      "_id" : "Other",
      "count" : 1,
      "titles" : [
        "The Persistence of Memory"
      ]
    }
  ],
  "categorizedByTags" : [
    { "_id" : "painting", "count" : 6 },
    { "_id" : "oil", "count" : 4 },
    { "_id" : "Expressionism", "count" : 3 },
    { "_id" : "Surrealism", "count" : 2 },
    { "_id" : "abstract", "count" : 2 },
    { "_id" : "woodblock", "count" : 1 },
    { "_id" : "woodcut", "count" : 1 },
    { "_id" : "ukiyo-e", "count" : 1 },
    { "_id" : "satire", "count" : 1 },
    { "_id" : "caricature", "count" : 1 }
  ]
}

陣列元素拆分為文件 $unwind

$unwind 用於將輸入文件中的陣列欄位解構,為陣列中的每一個元素生成一個獨立的文件,簡單說就是將一條資料拆分為多條。

image-20220530121529519

語法如下

{
  $unwind:
    {
      path: <field path>,
      includeArrayIndex: <string>,
      preserveNullAndEmptyArrays: <boolean>
    }
}

引數說明

  • path:陣列欄位的路徑,欄位路徑需要使用字首 $
  • includeArrayIndex:可選,陣列元素的作為新的欄位,這裡指定了欄位名
  • preserveNullAndEmptyArrays:可選,如果設定為 true,則如果 path 引數為 null,沒有該欄位或者是一個空陣列時,$unwind 會輸出文件,否則不輸出,預設值為 false

建立集合 inventory ,插入一條資料

db.inventory.insertOne({ "_id" : 1, "item" : "ABC1", sizes: [ "S", "M", "L"] })

執行以下命令

db.inventory.aggregate([ { $unwind: "$sizes" } ])

該命令會將一條資料拆分為 3 條

{ "_id" : 1, "item" : "ABC1", "sizes" : "S" }
{ "_id" : 1, "item" : "ABC1", "sizes" : "M" }
{ "_id" : 1, "item" : "ABC1", "sizes" : "L" }

文件分桶 $bucket

按照指定的表示式和邊界對輸入的文件進行分組,這裡的分組稱之為 儲存桶,每個桶作為一個文件輸出。每一個輸出的文件都包含了一個 _id 欄位,該欄位表示了桶的下邊界。

image-20220530141245217

語法如下

{
  $bucket: {
      groupBy: <expression>,
      boundaries: [ <lowerbound1>, <lowerbound2>, ... ],
      default: <literal>,
      output: {
         <output1>: { <$accumulator expression> },
         ...
         <outputN>: { <$accumulator expression> }
      }
   }
}

引數說明

  • groupBy 文件分組表示式
  • boundaries:基於 groupBy 表示式分組的值陣列,陣列中的值指定了每一個桶的邊界。相鄰的兩個值分別為桶的上邊界和下邊界,指定的值型別必須相同並且正序排列。例如 [0, 5, 10] 建立了兩個桶,[0, 5)[5, 10)
  • default:可選,當 groupBy 結果不在 boundaries 的範圍內時,將結果放在 default 指定的桶中(該引數指定了桶的 _id
  • output:可選,指定文件中包含到輸出文件中的欄位,預設只有 _id 欄位

$bucket 的使用必須滿足以下條件之一

  • 每一個輸入文件經過 groupBy 之後都在桶邊界範圍 boundaries
  • 當包含不再桶邊界範圍內的值時,必須指定 default 引數

在 MongoDB 中插入以下文件

db.artists.insertMany([
  { "_id" : 1, "last_name" : "Bernard", "first_name" : "Emil", "year_born" : 1868, "year_died" : 1941, "nationality" : "France" },
  { "_id" : 2, "last_name" : "Rippl-Ronai", "first_name" : "Joszef", "year_born" : 1861, "year_died" : 1927, "nationality" : "Hungary" },
  { "_id" : 3, "last_name" : "Ostroumova", "first_name" : "Anna", "year_born" : 1871, "year_died" : 1955, "nationality" : "Russia" },
  { "_id" : 4, "last_name" : "Van Gogh", "first_name" : "Vincent", "year_born" : 1853, "year_died" : 1890, "nationality" : "Holland" },
  { "_id" : 5, "last_name" : "Maurer", "first_name" : "Alfred", "year_born" : 1868, "year_died" : 1932, "nationality" : "USA" },
  { "_id" : 6, "last_name" : "Munch", "first_name" : "Edvard", "year_born" : 1863, "year_died" : 1944, "nationality" : "Norway" },
  { "_id" : 7, "last_name" : "Redon", "first_name" : "Odilon", "year_born" : 1840, "year_died" : 1916, "nationality" : "France" },
  { "_id" : 8, "last_name" : "Diriks", "first_name" : "Edvard", "year_born" : 1855, "year_died" : 1930, "nationality" : "Norway" }
])

下面的操作將會把文件基於 year_born 欄位分組,然後基於桶中的文件數量進行過濾

db.artists.aggregate( [
  // First Stage
  {
    $bucket: {
      groupBy: "$year_born",                        // Field to group by
      boundaries: [ 1840, 1850, 1860, 1870, 1880 ], // Boundaries for the buckets
      default: "Other",                             // Bucket id for documents which do not fall into a bucket
      output: {                                     // Output for each bucket
        "count": { $sum: 1 },
        "artists" :
          {
            $push: {
              "name": { $concat: [ "$first_name", " ", "$last_name"] },
              "year_born": "$year_born"
            }
          }
      }
    }
  },
  // Second Stage
  {
    $match: { count: {$gt: 3} }
  }
] )

輸出如下

{ "_id" : 1860, "count" : 4, "artists" :
  [
    { "name" : "Emil Bernard", "year_born" : 1868 },
    { "name" : "Joszef Rippl-Ronai", "year_born" : 1861 },
    { "name" : "Alfred Maurer", "year_born" : 1868 },
    { "name" : "Edvard Munch", "year_born" : 1863 }
  ]
}

文件自動分桶 $bucketAuto

$bucket 功能一樣,不過 $bucketAuto 會自動的確定桶的邊界,並將文件均勻的分佈到桶中。

每一個桶中包含以下內容

  • _id 物件指定了桶的邊界
  • count 欄位包含了桶中的文件數量,如果沒有指定 output 選項,預設會自動包含 count 欄位

語法如下

{
  $bucketAuto: {
      groupBy: <expression>,
      buckets: <number>,
      output: {
         <output1>: { <$accumulator expression> },
         ...
      }
      granularity: <string>
  }
}

引數說明

  • buckets 指定了桶的個數
  • granularity 可選,指定了使用哪種型別的桶邊界首選序列(Preferred number),支援的值有 R5R10R20R40R801-2-5E6E12E24E48E96E192POWERSOF2

查詢不同年份範圍死亡人口統計

db.artists.aggregate([
    { $bucketAuto: { groupBy: "$year_died", buckets: 4} }
])

輸出如下

{ "_id" : { "min" : 1890.0, "max" : 1927.0 }, "count" : 2 }
{ "_id" : { "min" : 1927.0, "max" : 1932.0 }, "count" : 2 }
{ "_id" : { "min" : 1932.0, "max" : 1944.0 }, "count" : 2 }
{ "_id" : { "min" : 1944.0, "max" : 1955.0 }, "count" : 2 }

參考文件

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章