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

การ query nested document

document ซ้อนกันได้ สมาชิกหนึ่งคนสามารถถือ object address ที่ฝังอยู่ และ object นั้นมี field ของตัวเอง มีสองวิธีที่ต่างกันชัดเจนในการ query เข้าไปใน sub-document และทั้งสองทำงานต่างกันมาก: คุณสามารถเข้าถึง field ภายในเดียวด้วย dot notation หรือจับคู่ sub-document ทั้งก้อน ในคราวเดียว วิธีแรกผ่อนปรน วิธีที่สองคือการเปรียบเทียบแบบตรงเป๊ะที่ไวต่อลำดับซึ่งทำคนประหลาดใจ บทเรียนนี้ทำให้ความต่างนั้นเป็นรูปธรรม

ตอนนี้สมาชิกแต่ละคนมี address ที่ฝังอยู่พร้อม city และ zip และมี contact พร้อม email และ phone

หากต้องการจับคู่ field เดียวที่ฝังลึกอยู่ใน object ให้ระบุ path ด้วยจุด: "address.city" เครื่องหมายอัญประกาศจำเป็นเพราะ key มีจุดอยู่ ตรงนี้เราหาสมาชิกทุกคนที่อาศัยอยู่ใน Cambridge:

db.members.find({ "address.city": "Cambridge" })

query เข้าถึงเข้าไปใน address แล้ว match เฉพาะ city โดยไม่สนใจอย่างอื่นที่ sub-document เก็บไว้:

[
{
"_id": "65f0b3d4e4b0a1c2e4b0a1d4",
"name": "Grace",
"address": { "city": "Cambridge", "zip": "02139" }
}
]

dot path ต่อกันได้ลึกเท่าที่ข้อมูลของคุณลึก และรวมกับ operator ได้อย่างอิสระ — { "address.zip": { $exists: true } } ทำงานตรงตามที่คุณคาดเป๊ะ

คุณยังสามารถส่ง document ทั้งก้อนเป็นค่าให้ field address ได้ด้วย วิธีนี้ ไม่ เหมือน query แบบ dot notation เพราะบังคับ match แบบ เป๊ะทุกอย่าง: field เดียวกัน ค่าเดียวกัน และ ลำดับเดียวกัน โดยไม่อนุญาตให้มี field เกิน ตรงนี้เราจับคู่ที่อยู่เต็ม:

db.members.find({
address: { city: "Cambridge", zip: "02139" }
})

อันนี้ตรงกับ Grace — แต่เป็นเพราะ address ที่เก็บไว้ของเธอมี city มาก่อนและ zip มาทีหลัง โดยไม่มี field อื่น:

[
{
"_id": "65f0b3d4e4b0a1c2e4b0a1d4",
"name": "Grace",
"address": { "city": "Cambridge", "zip": "02139" }
}
]

หาก document ที่เก็บไว้เรียง zip ก่อน city หรือเพิ่ม field country เข้ามา filter เดียวกันนี้จะส่งกลับ ไม่มีอะไรเลย — นั่นคือกับดักความไวต่อลำดับ และเป็นเหตุผลว่าทำไม dot notation จึงมักเป็นตัวเลือกที่ปลอดภัยกว่า สังเกตว่าตัวอย่าง Go ใช้ bson.D ที่เป็น document ที่มีลำดับ ก็เพราะลำดับมีความสำคัญตรงนี้พอดี

รูปร่างจริงที่พบบ่อยที่สุดคือ array ที่ element เป็น sub-document สมมติว่าสมาชิกแต่ละคนมี array reviews แต่ละรีวิวมี book และ rating query แบบใช้จุดธรรมดาอย่าง "reviews.rating" ตรวจเงื่อนไข ข้ามทุก รีวิว ดังนั้นหากต้องการบังคับทั้งสอง field บนรีวิว เดียวกัน คุณรวมจิตวิญญาณของ dot path เข้ากับ $elemMatch ตรงนี้เราหาสมาชิกที่มีรีวิวของ Algorithms ที่ให้คะแนนอย่างน้อย 4:

db.members.find({
reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } }
})

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

[
{
"_id": "65f0b3d4e4b0a1c2e4b0a1d2",
"name": "Margaret",
"reviews": [
{ "book": "Algorithms", "rating": 5 },
{ "book": "Networks", "rating": 3 }
]
}
]

ใน Compass: แถบ filter รับ key แบบมีจุดได้โดยตรง เช่น { "address.city": "Cambridge" } แท็บ Documents แสดง object ที่ซ้อนกันเป็น tree node ที่กางได้ ซึ่งทำให้ง่ายต่อการยืนยันลำดับ field ที่แน่นอนก่อนที่คุณจะลองจับคู่ sub-document ทั้งก้อน

  • ความไวต่อลำดับคือเรื่องใหญ่ การจับคู่ embedded document โดยส่ง document ทั้งก้อนให้ field เปรียบเทียบทั้งก้อนแบบตามตัวอักษร — field เดียวกัน ค่าเดียวกัน ลำดับเดียวกัน ไม่มีส่วนเกิน จัดเรียง key ใหม่แล้วการจับคู่จะหายไปอย่างเงียบ ๆ
  • dot notation ไม่ขึ้นกับลำดับและจับคู่บางส่วน { "address.city": "Cambridge" } สนใจ field เดียวและไม่สนใจอย่างอื่น จึงเป็นตัวเลือกที่ใช้กันประจำวันกับข้อมูลที่ embed อยู่
  • ใส่อัญประกาศให้ key ใดก็ตามที่มีจุด address.city ต้องเขียนเป็น string "address.city" ตัว identifier ที่มีจุดโดยไม่ใส่อัญประกาศไม่ถูกต้องใน document literal ของ driver ส่วนใหญ่
  • array ของ sub-document ต้องใช้ $elemMatch สำหรับเงื่อนไขหลาย field ด้วยเหตุผลเดียวกับ array แบบ scalar — มิฉะนั้นเงื่อนไขจะกระจายไปทั่ว element ต่าง ๆ ของ array
ตัวเลือกBenefitCost
dot-notation query บน nested field ที่ตื้นและ schema นิ่งอ่านง่าย ใช้ index ได้ดี — compound index บน dot path ทำงานเหมือน field ปกติต้องรู้ path ที่แน่นอนล่วงหน้า เปลี่ยน schema แล้วต้องแก้ query ทุกจุดที่อ้างถึง
document ที่ซ้อนลึกหรือ shape ไม่แน่นอนในแต่ละ documentยืดหยุ่นสำหรับข้อมูลที่มีรูปร่างต่างกันในแต่ละ documentสร้าง index ที่ครอบคลุมยาก selectivity แย่ลง และ query ต้องรับมือกรณี field หายไปหรือ type ต่างกัน
จับคู่ sub-document ทั้งก้อนแบบตรงเป๊ะตรวจสอบ “เหมือนกันทั้งหมด” ได้ในคำสั่งเดียวไวต่อลำดับ field และ field ส่วนเกิน ใช้งานได้แคบกว่า dot notation มาก
  • คาดหวังว่า index บน dot path ที่ซ้อนลึกจะมีประสิทธิภาพเท่า index บน field ระดับบนสุดเสมอ — ยิ่งซ้อนลึกและ shape ของแต่ละ document ไม่สม่ำเสมอ ยิ่งทำให้ selectivity แย่ลงและ index ต้องครอบคลุมกรณีที่กว้างเกินจำเป็น
  • ใช้ dot notation กับ field ที่บาง document เก็บเป็น object ซ้อน แต่บางอันเก็บเป็นค่าอื่นหรือไม่มี field เลย — query ไม่ error แต่ผลลัพธ์ขาดหายไปเงียบ ๆ เพราะ document ที่รูปร่างต่างกันจะไม่ถูกจับคู่ ทำให้ debug ยาก
  • ปล่อยให้ schema ซ้อนลึกขึ้นเรื่อย ๆ (4-5 ชั้น) โดยไม่ทบทวนว่าควร flatten หรือแยก collection — ยิ่งซ้อนลึก dot path ยิ่งยาวและเปราะบางต่อการเปลี่ยน schema ทุกครั้งที่มีการปรับโครงสร้างข้อมูล

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

ระบบ user profile ของแอป e-commerce — สร้าง index บน address.zipcode และ address.country เพราะ schema ของ address นิ่งและตื้น ทำให้ query หาผู้ใช้ตามพื้นที่จัดส่งเร็วและใช้ index ได้เต็มที่

CMS ที่เก็บ content block เป็น nested JSON ที่มีรูปร่างต่างกันในแต่ละหน้า — เพจแต่ละประเภท (บทความ, landing page, product page) เก็บ field ซ้อนลึกไม่เท่ากัน ทีม backend มักหลีกเลี่ยงการ query ลึกเข้าไปใน block เหล่านี้โดยตรง แล้วใช้ field สรุปไว้ระดับบนสุดแทนเพื่อให้ query และ index ง่ายขึ้น

คุณจะจับคู่ field city ภายใน object address ที่ฝังอยู่ได้อย่างไร?
อะไรคือสิ่งพิเศษเกี่ยวกับการจับคู่ embedded sub-document ทั้งก้อน อย่าง { address: { city, zip } }?
ทำไมตัวอย่าง Go จึงใช้ bson.D สำหรับการจับคู่ sub-document ทั้งก้อน?
หากต้องการบังคับ book และ rating บน element เดียวกันของ array reviews คุณใช้: