$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 } ]}หนึ่ง document ต่อหนึ่งสมาชิก array
หัวข้อที่มีชื่อว่า “หนึ่ง document ต่อหนึ่งสมาชิก array”ส่ง 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"]
db.sales.aggregate([ { $unwind: "$items" }])const rows = await db.collection("sales").aggregate([ { $unwind: "$items" },]).toArray();rows = list(db.sales.aggregate([ {"$unwind": "$items"},]))pipeline := mongo.Pipeline{ {{"$unwind", "$items"}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$unwind": "$items" },];let mut cursor = coll.aggregate(pipeline).await?;ออเดอร์เดียวกลายเป็นหนึ่ง 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 แล้วตามด้วย $group
หัวข้อที่มีชื่อว่า “การจับคู่คลาสสิก: $unwind แล้วตามด้วย $group”$unwind แทบไม่เคยเป็น stage สุดท้าย งานปกติคือแบน array ไว้ให้ $group ที่ตามมาสรุปข้ามสมาชิกได้ ตรงนี้เราแบน items ของทุกออเดอร์ แล้วรวมยอดขายต่อชื่อหนังสือข้ามทุกออเดอร์:
db.sales.aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.title", sold: { $sum: "$items.qty" } } }, { $sort: { sold: -1 } }])const rows = await db.collection("sales").aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.title", sold: { $sum: "$items.qty" }, } }, { $sort: { sold: -1 } },]).toArray();rows = list(db.sales.aggregate([ {"$unwind": "$items"}, {"$group": { "_id": "$items.title", "sold": {"$sum": "$items.qty"}, }}, {"$sort": {"sold": -1}},]))pipeline := mongo.Pipeline{ {{"$unwind", "$items"}}, {{"$group", bson.D{ {"_id", "$items.title"}, {"sold", bson.D{{"$sum", "$items.qty"}}}, }}}, {{"$sort", bson.D{{"sold", -1}}}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$unwind": "$items" }, doc! { "$group": { "_id": "$items.title", "sold": { "$sum": "$items.qty" } } }, doc! { "$sort": { "sold": -1 } },];let mut cursor = coll.aggregate(pipeline).await?;ตอนนี้แต่ละชื่อหนังสือได้ยอดรวม นับข้ามรายการสินค้าของทุกออเดอร์แล้ว:
[ { "_id": "Dune", "sold": 5 }, { "_id": "Foundation", "sold": 3 }]การเก็บ array ที่ว่างและที่หายไป
หัวข้อที่มีชื่อว่า “การเก็บ array ที่ว่างและที่หายไป”ค่า default ของ $unwind จะ ทิ้ง document ที่ฟิลด์ array ว่าง ไม่มี หรือเป็น null เพราะไม่มีสมาชิกให้ปล่อยออกมา document จึงหายไปทั้งก้อน ถ้าอยากให้ document พวกนั้นรอด (แค่ไม่มีฟิลด์นั้นติดมา) ให้เขียนแบบ object form พร้อม preserveNullAndEmptyArrays:
db.sales.aggregate([ { $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }])const rows = await db.collection("sales").aggregate([ { $unwind: { path: "$items", preserveNullAndEmptyArrays: true, } },]).toArray();rows = list(db.sales.aggregate([ {"$unwind": { "path": "$items", "preserveNullAndEmptyArrays": True, }},]))pipeline := mongo.Pipeline{ {{"$unwind", bson.D{ {"path", "$items"}, {"preserveNullAndEmptyArrays", true}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$unwind": { "path": "$items", "preserveNullAndEmptyArrays": true } },];let mut cursor = coll.aggregate(pipeline).await?;ออเดอร์ที่ไม่มี items ยังคงปรากฏหนึ่งครั้ง เพียงแต่ไม่มีฟิลด์ items:
[ { "_id": "o2", "buyer": "Grace" }]ทิปและกับดัก
หัวข้อที่มีชื่อว่า “ทิปและกับดัก”$unwindทวีคูณ document ออเดอร์ที่มีห้ารายการกลายเป็นห้า document การแบนจึงทำให้ stream พองขึ้นมาก ก่อนที่$groupตัวถัดไปจะบีบกลับลงมา- ถ้าไม่ใส่
preserveNullAndEmptyArraysdocument ที่ array ว่างหรือไม่มี array จะหายไปเงียบ ๆ ถ้ายอดนับหลัง unwind ขาดไป ให้สงสัยข้อนี้ก่อนเลย - หลัง unwind ให้อ้างฟิลด์ของสมาชิกด้วย path เดิม: เมื่อ
itemsกลายเป็น object เดี่ยว"$items.qty"จะอ่านจำนวนของสมาชิกตัวนั้น - ถ้าฟิลด์ไม่ใช่ array จริง ๆ
$unwindจะมองค่า scalar เดี่ยวเป็น array หนึ่งสมาชิก แล้วปล่อย document ออกมาครั้งเดียว จึงค่อนข้างใจดีกับค่าที่ไม่ใช่ array
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
$unwind เพื่อวิเคราะห์ทีละสมาชิกของ array | ทำให้ $group/$match มองเห็นและสรุปข้ามสมาชิกแต่ละตัวได้ ซึ่งทำกับ array ทั้งก้อนตรง ๆ ไม่ได้ | จำนวน document ใน stream เพิ่มขึ้นตามจำนวนสมาชิก array ยิ่ง array ใหญ่ยิ่งเปลือง CPU/memory ระหว่าง stage |
ไม่ unwind ประมวลผล array เป็นก้อนด้วย operator อย่าง $reduce/$map | จำนวน document คงเดิม ไม่มี stream ขยายตัว | expression ซับซ้อนกว่า อ่านและ debug ยากกว่าการ unwind แล้วใช้ $group |
ตั้ง preserveNullAndEmptyArrays: true | document ที่ 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 ที่มีหลายรายการสินค้า — ก่อนจะสรุป “ยอดขายต่อสินค้า” ระบบต้อง
$unwindarray รายการสินค้าในแต่ละออเดอร์ก่อน แล้วค่อย$groupตามชื่อสินค้า เพื่อให้เห็นยอดขายจริงในระดับ item ไม่ใช่ระดับออเดอร์Keller Williams — ออเดอร์อสังหาริมทรัพย์ที่ผูกกับ property หลายรายการต่อสัญญา (เช่น listing แบบ bundle) ใช้
$unwindเพื่อแตก property แต่ละตัวออกมาให้รายงานนับแยกเป็นรายการ แล้วใช้preserveNullAndEmptyArraysเพื่อไม่ให้สัญญาที่ยังไม่มี property ผูกอยู่หายไปจากรายงาน