ข้ามไปยังเนื้อหา

$unwind — การแบน array

array อยู่ทุกที่ใน document ของ MongoDB — tag บนโพสต์ รายการในออเดอร์ บทเรียนในคอร์ส เวลาจะ สรุป ข้ามสมาชิกของ array พวกนี้ ส่วนใหญ่ต้องมองทีละตัว

$unwind ทำหน้าที่นั้นพอดี: รับ document ที่มีฟิลด์ array แล้วปล่อยออกมา หนึ่ง document ต่อหนึ่งสมาชิก พร้อมคัดลอกส่วนที่เหลือของ document ติดไปด้วยทุกอัน document ที่มี array สามสมาชิกจึงกลายเป็นสาม document

สำหรับบทเรียนนี้ document sales แต่ละอันบันทึกออเดอร์ที่มีหลายรายการสินค้าใน array items:

{
"_id": "o1",
"buyer": "Ada",
"items": [
{ "title": "Dune", "qty": 2 },
{ "title": "Foundation", "qty": 1 }
]
}

ส่ง path ของฟิลด์ array ให้ $unwind (มี $ นำหน้า) แล้ว stage นี้จะแผ่ document ออก document ที่ปล่อยออกมาทุกอันหน้าตาเหมือนเดิมเป๊ะ ต่างแค่ฟิลด์ array กลายเป็นสมาชิกตัวเดียว

flowchart LR
  In["one order with items array of two"] --> U["$unwind items"]
  U --> D1["order, items = Dune"]
  U --> D2["order, items = Foundation"]
หนึ่ง document ที่มี array สองสมาชิกกลายเป็นสอง document หนึ่ง document ต่อหนึ่งสมาชิก
db.sales.aggregate([
{ $unwind: "$items" }
])

ออเดอร์เดียวกลายเป็นหนึ่ง document ต่อหนึ่งรายการสินค้า โดย items ตอนนี้เป็น object ธรรมดาแทนที่จะเป็น array:

[
{ "_id": "o1", "buyer": "Ada", "items": { "title": "Dune", "qty": 2 } },
{ "_id": "o1", "buyer": "Ada", "items": { "title": "Foundation", "qty": 1 } }
]

ใน Compass: เพิ่ม stage $unwind ในแท็บ Aggregations แล้วชี้ไปที่ฟิลด์ array ตัวอย่างผลลัพธ์จะแสดงจำนวน document เพิ่มขึ้น นั่นคือสัญญาณว่าแบน array สำเร็จ ตรงข้ามกับ $match และ $group ที่ทำให้จำนวนลดลง

$unwind แทบไม่เคยเป็น stage สุดท้าย งานปกติคือแบน array ไว้ให้ $group ที่ตามมาสรุปข้ามสมาชิกได้ ตรงนี้เราแบน items ของทุกออเดอร์ แล้วรวมยอดขายต่อชื่อหนังสือข้ามทุกออเดอร์:

db.sales.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$items.title",
sold: { $sum: "$items.qty" }
} },
{ $sort: { sold: -1 } }
])

ตอนนี้แต่ละชื่อหนังสือได้ยอดรวม นับข้ามรายการสินค้าของทุกออเดอร์แล้ว:

[
{ "_id": "Dune", "sold": 5 },
{ "_id": "Foundation", "sold": 3 }
]

ค่า default ของ $unwind จะ ทิ้ง document ที่ฟิลด์ array ว่าง ไม่มี หรือเป็น null เพราะไม่มีสมาชิกให้ปล่อยออกมา document จึงหายไปทั้งก้อน ถ้าอยากให้ document พวกนั้นรอด (แค่ไม่มีฟิลด์นั้นติดมา) ให้เขียนแบบ object form พร้อม preserveNullAndEmptyArrays:

db.sales.aggregate([
{ $unwind: {
path: "$items",
preserveNullAndEmptyArrays: true
} }
])

ออเดอร์ที่ไม่มี items ยังคงปรากฏหนึ่งครั้ง เพียงแต่ไม่มีฟิลด์ items:

[
{ "_id": "o2", "buyer": "Grace" }
]
  • $unwind ทวีคูณ document ออเดอร์ที่มีห้ารายการกลายเป็นห้า document การแบนจึงทำให้ stream พองขึ้นมาก ก่อนที่ $group ตัวถัดไปจะบีบกลับลงมา
  • ถ้าไม่ใส่ preserveNullAndEmptyArrays document ที่ array ว่างหรือไม่มี array จะหายไปเงียบ ๆ ถ้ายอดนับหลัง unwind ขาดไป ให้สงสัยข้อนี้ก่อนเลย
  • หลัง unwind ให้อ้างฟิลด์ของสมาชิกด้วย path เดิม: เมื่อ items กลายเป็น object เดี่ยว "$items.qty" จะอ่านจำนวนของสมาชิกตัวนั้น
  • ถ้าฟิลด์ไม่ใช่ array จริง ๆ $unwind จะมองค่า scalar เดี่ยวเป็น array หนึ่งสมาชิก แล้วปล่อย document ออกมาครั้งเดียว จึงค่อนข้างใจดีกับค่าที่ไม่ใช่ array
ตัวเลือกBenefitCost
$unwind เพื่อวิเคราะห์ทีละสมาชิกของ arrayทำให้ $group/$match มองเห็นและสรุปข้ามสมาชิกแต่ละตัวได้ ซึ่งทำกับ array ทั้งก้อนตรง ๆ ไม่ได้จำนวน document ใน stream เพิ่มขึ้นตามจำนวนสมาชิก array ยิ่ง array ใหญ่ยิ่งเปลือง CPU/memory ระหว่าง stage
ไม่ unwind ประมวลผล array เป็นก้อนด้วย operator อย่าง $reduce/$mapจำนวน document คงเดิม ไม่มี stream ขยายตัวexpression ซับซ้อนกว่า อ่านและ debug ยากกว่าการ unwind แล้วใช้ $group
ตั้ง preserveNullAndEmptyArrays: truedocument ที่ array ว่างหรือหายไปยังคงอยู่ในผลลัพธ์ ความถูกต้องของรายงานสูงขึ้นต้องเขียน syntax แบบยาว (object form) แทน string form สั้น ๆ และต้อง handle ฟิลด์ที่อาจไม่มีอยู่ใน stage ถัดไป
  • วาง $unwind ไว้ก่อน $match ที่กรองด้วยฟิลด์นอก array — document ระเบิดออกมาก่อนโดยไม่จำเป็น ควร $match ให้มากที่สุดก่อน $unwind เพื่อลดจำนวน document ที่ต้องแบน
  • ลืมว่า default ของ $unwind ทิ้ง document ที่ array ว่างหรือไม่มีอยู่แบบเงียบ ๆ — ถ้าจำนวนแถวหลัง unwind น้อยกว่าที่คาด มักเป็นเพราะ document พวกนั้นหลุดไปโดยไม่มี error เตือน
  • unwind แล้วไม่ตามด้วย $group — ถ้าปลายทางไม่ได้สรุปกลับ ก็แค่ทวีคูณข้อมูลเปล่า ๆ client จะได้ document ซ้ำ ๆ หลายเท่าโดยไม่ได้ประโยชน์อะไรเพิ่ม

💡 ตัวอย่างจากของจริง

Analytics dashboard สำหรับ order ที่มีหลายรายการสินค้า — ก่อนจะสรุป “ยอดขายต่อสินค้า” ระบบต้อง $unwind array รายการสินค้าในแต่ละออเดอร์ก่อน แล้วค่อย $group ตามชื่อสินค้า เพื่อให้เห็นยอดขายจริงในระดับ item ไม่ใช่ระดับออเดอร์

Keller Williams — ออเดอร์อสังหาริมทรัพย์ที่ผูกกับ property หลายรายการต่อสัญญา (เช่น listing แบบ bundle) ใช้ $unwind เพื่อแตก property แต่ละตัวออกมาให้รายงานนับแยกเป็นรายการ แล้วใช้ preserveNullAndEmptyArrays เพื่อไม่ให้สัญญาที่ยังไม่มี property ผูกอยู่หายไปจากรายงาน

document มี array items ที่มี 3 สมาชิก $unwind บน items ผลิตอะไร?
stage ไหนมักตามหลัง $unwind บ่อยที่สุด เพื่อสรุปสมาชิกที่แบนออกมา?
ค่า default แล้ว document ที่ array ที่ unwind ว่างหรือไม่มีอยู่ จะเกิดอะไรขึ้น?
จะเก็บ document ที่ array ว่างหรือไม่มีอยู่ ไว้ในผลลัพธ์ได้อย่างไร?