การ query nested document
document ซ้อนกันได้ สมาชิกมี object address ฝังอยู่ และ object นั้นมี field ของตัวเอง มีสองวิธีที่ต่างกันในการ query เข้าไปใน sub-document และทั้งสองทำงานต่างกันมาก: เข้าถึง field ด้านในตัวเดียวด้วย dot notation หรือจับคู่ ทั้ง sub-document พร้อมกัน แบบแรกใจกว้าง แบบที่สองเป็นการเปรียบเทียบตรงเป๊ะที่ไวต่อลำดับซึ่งทำให้คนแปลกใจ บทนี้ทำให้ความต่างนั้นเป็นรูปธรรม ทุก output ด้านล่างจับมาจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 (แท็บ Python, Go และ Rust รันหนึ่งครั้งในโปรแกรม scratch บน server เดียวกัน)
ข้อมูลที่เรา query
หัวข้อที่มีชื่อว่า “ข้อมูลที่เรา query”สมาชิกด้านล่างเก็บที่อยู่เดียวกันใน รูปร่างที่ต่างกัน โดยตั้งใจ: Grace มี city ก่อน zip, Ada มี zip ก่อน city, Linus มี field country เพิ่ม และ Alan เก็บที่อยู่เป็น string ธรรมดา:
db.members.insertMany([ { name: "Grace", address: { city: "Cambridge", zip: "02139" }, contact: { email: "[email protected]", phone: "555-0101" }, reviews: [{ book: "Algorithms", rating: 3 }, { book: "Networks", rating: 5 }] }, { name: "Margaret", address: { city: "Boston", zip: "02110" }, contact: { email: "[email protected]" }, reviews: [{ book: "Algorithms", rating: 5 }, { book: "Networks", rating: 3 }] }, { name: "Ada", address: { zip: "02139", city: "Cambridge" }, reviews: [] }, // zip BEFORE city { name: "Linus", address: { city: "Cambridge", zip: "02139", country: "US" } }, // extra field { name: "Edsger" }, // no address { name: "Alan", address: "Cambridge, UK" }, // a string, not an object]);globalThis.names = (f) => db.members.find(f, { _id: 0, name: 1 }).sort({ name: 1 }).toArray().map((d) => d.name).join(", ");assert.strictEqual(db.members.countDocuments(), 6);await db.collection("members").insertMany([ { name: "Grace", address: { city: "Cambridge", zip: "02139" }, contact: { email: "[email protected]", phone: "555-0101" }, reviews: [{ book: "Algorithms", rating: 3 }, { book: "Networks", rating: 5 }] }, { name: "Margaret", address: { city: "Boston", zip: "02110" }, contact: { email: "[email protected]" }, reviews: [{ book: "Algorithms", rating: 5 }, { book: "Networks", rating: 3 }] }, { name: "Ada", address: { zip: "02139", city: "Cambridge" }, reviews: [] }, { name: "Linus", address: { city: "Cambridge", zip: "02139", country: "US" } }, { name: "Edsger" }, { name: "Alan", address: "Cambridge, UK" },]);if ((await db.collection("members").countDocuments()) !== 6) throw new Error("expected 6");เข้าถึง field ด้านในด้วย dot notation
หัวข้อที่มีชื่อว่า “เข้าถึง field ด้านในด้วย dot notation”การจับคู่ field หนึ่งที่ฝังอยู่ใน object ให้ระบุ path ด้วยจุด: "address.city" ต้องใส่เครื่องหมายคำพูดเพราะ key มีจุด ที่นี่เราหาสมาชิกทุกคนที่อยู่ Cambridge:
console.log("address.city = Cambridge :", names({ "address.city": "Cambridge" }));console.log("address.zip $exists :", names({ "address.zip": { $exists: true } }));console.log("address.city missing :", names({ "address.city": { $exists: false } }));console.log("contact.phone missing :", names({ "contact.phone": { $exists: false } }));assert.strictEqual(names({ "address.city": "Cambridge" }), "Ada, Grace, Linus");const docs = await db.collection("members") .find({ "address.city": "Cambridge" }) .toArray();console.log(docs.map((d) => d.name).sort());if (docs.length !== 3) throw new Error("expected Ada, Grace and Linus");docs = list(db.members.find({"address.city": "Cambridge"}))cur, err := coll.Find(ctx, bson.M{"address.city": "Cambridge"})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "address.city": "Cambridge" }) .await?;query เข้าไปใน address และจับคู่เฉพาะ city โดยไม่สนว่า sub-document มีอะไรอื่นและ field เรียงอย่างไร — Ada (zip มาก่อน) และ Linus (มี country เพิ่ม) ตรงเช่นเดียวกับ Grace dot path ต่อกันลึกได้เท่าที่ข้อมูลลึกและผสมกับ operator ได้อย่างอิสระ:
address.city = Cambridge : Ada, Grace, Linusaddress.zip $exists : Ada, Grace, Linus, Margaretaddress.city missing : Alan, Edsgercontact.phone missing : Ada, Alan, Edsger, Linus, MargaretAlan (ที่ address เป็น string ดังนั้น address.city ไม่มีทางมี) และ Edsger (ไม่มี address) ทั้งคู่ “ขาด” สำหรับ address.city; ไม่มี error สำหรับ path ที่ผ่าน non-object
จับคู่ทั้ง sub-document: ลำดับสำคัญ
หัวข้อที่มีชื่อว่า “จับคู่ทั้ง sub-document: ลำดับสำคัญ”คุณส่ง document ทั้งก้อนเป็นค่าให้ field address ได้เช่นกัน นี่ ไม่ใช่ เหมือน dotted query มันต้องการการจับคู่ ตรงเป๊ะ: field เดียวกัน ค่าเดียวกัน และ ลำดับเดียวกัน ไม่อนุญาต field เกิน ที่นี่เราจับคู่ที่อยู่เต็มใน สามรูปแบบ:
console.log("{ city, zip } :", names({ address: { city: "Cambridge", zip: "02139" } }));console.log("{ zip, city } reversed :", names({ address: { zip: "02139", city: "Cambridge" } }));console.log("{ zip } only :", names({ address: { zip: "02139" } }));assert.strictEqual(names({ address: { city: "Cambridge", zip: "02139" } }), "Grace");assert.strictEqual(names({ address: { zip: "02139" } }), "");const docs = await db.collection("members").find({ address: { city: "Cambridge", zip: "02139" }}).toArray();console.log(docs.map((d) => d.name));if (docs.length !== 1 || docs[0].name !== "Grace") throw new Error("expected only Grace");docs = list(db.members.find({ "address": {"city": "Cambridge", "zip": "02139"}}))cur, err := coll.Find(ctx, bson.M{ "address": bson.D{ {Key: "city", Value: "Cambridge"}, {Key: "zip", Value: "02139"}, },})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "address": { "city": "Cambridge", "zip": "02139" } }) .await?;สามคนที่ใช้ city และ zip เดียวกัน แต่รูปแบบ whole-document พบคนละคนเดียวสำหรับแต่ละการสะกด:
{ city, zip } : Grace{ zip, city } reversed : Ada{ zip } only :{ city, zip } ตรงเฉพาะ Grace ที่ลำดับที่เก็บเป็นแบบนั้น — ไม่ใช่ Ada (ค่าเดียวกัน ลำดับกลับ) และ ไม่ใช่ Linus (มี country เกิน) กลับ filter แล้วมันตรง Ada แทน sub-document บางส่วน ({ zip } เดี่ยว ๆ) ไม่ตรงใครเลย นั่นคือกับดักไวต่อลำดับ และเป็นเหตุผลที่ dot notation เป็นค่าเริ่มต้นที่ปลอดภัยกว่า ตัวอย่าง Go ใช้ bson.D ซึ่งเป็น document ที่ มีลำดับ: bson.M ของ Go คือ map และ Go ไม่กำหนดลำดับการวน map ในการรันของเรา (Go 1.27.1, driver v2.9.1) bson.M สองคีย์บังเอิญ marshal ตามลำดับต้นทาง — ตรงคนที่ถูก 500 ครั้งจาก 500 — แต่นั่นคือรายละเอียดการ implement ที่ไม่ควรพึ่งพา; bson.D คือรูปแบบที่รับประกัน object JavaScript, dict ของ Python และ doc! ของ Rust เก็บลำดับการแทรก
array ของ sub-document
หัวข้อที่มีชื่อว่า “array ของ sub-document”รูปร่างจริงที่พบบ่อยที่สุดคือ array ที่ element เป็น sub-document สมมติสมาชิกแต่ละคนมี array reviews แต่ละรีวิวมี book และ rating dotted query ธรรมดาอย่าง "reviews.rating" ตรวจเงื่อนไขข้าม ทุก รีวิว ดังนั้นเพื่อบังคับทั้งสอง field บนรีวิว เดียวกัน คุณใช้ $elemMatch ที่นี่เราหาสมาชิกที่มีรีวิว Algorithms คะแนนอย่างน้อย 4 — Grace ให้ Algorithms 3 และ Networks 5, Margaret กลับกัน:
console.log("plain dotted paths :", names({ "reviews.book": "Algorithms", "reviews.rating": { $gte: 4 } }));console.log("$elemMatch :", names({ reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } } }));console.log("reviews.0.book :", names({ "reviews.0.book": "Algorithms" }));assert.strictEqual(names({ reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } } }), "Margaret");const docs = await db.collection("members").find({ reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } }}).toArray();console.log(docs.map((d) => d.name));if (docs.length !== 1 || docs[0].name !== "Margaret") throw new Error("expected only Margaret");docs = list(db.members.find({ "reviews": {"$elemMatch": {"book": "Algorithms", "rating": {"$gte": 4}}}}))cur, err := coll.Find(ctx, bson.M{ "reviews": bson.M{"$elemMatch": bson.M{ "book": "Algorithms", "rating": bson.M{"$gte": 4}, }},})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "reviews": { "$elemMatch": { "book": "Algorithms", "rating": { "$gte": 4 } } } }) .await?;เฉพาะสมาชิกที่รีวิว เดียวกัน ระบุหนังสือนั้นและคะแนนผ่านเกณฑ์จึงตรง รูปแบบธรรมดายังคืน Grace ที่ให้ Algorithms 3 แต่หนังสือ เล่มอื่น 5:
plain dotted paths : Grace, Margaret$elemMatch : Margaretreviews.0.book : Grace, Margaretreviews.0.book ระบุรีวิวแรกด้วย index (เปราะถ้า array เรียงใหม่)
การอัปเดต field ซ้อน: กับดักลบพี่น้อง
หัวข้อที่มีชื่อว่า “การอัปเดต field ซ้อน: กับดักลบพี่น้อง”ความต่างระหว่าง whole กับ path เดียวกันตัดสินว่า การอัปเดต ทำอะไร การตั้ง field ด้านในเดียวด้วย dot path เก็บพี่น้องไว้; การตั้งพ่อเป็น document ใหม่ แทนที่ มัน:
db.people.insertMany([ { name: "Linus", address: { city: "Cambridge", zip: "02139", country: "US" } }, { name: "Ada", address: { city: "Cambridge", zip: "02139", country: "US" } },]);db.people.updateOne({ name: "Linus" }, { $set: { "address.zip": "02140" } }); // by pathdb.people.updateOne({ name: "Ada" }, { $set: { address: { zip: "02141" } } }); // by whole documentconsole.log(db.people.findOne({ name: "Linus" }, { _id: 0 }));console.log(db.people.findOne({ name: "Ada" }, { _id: 0 }));assert.strictEqual(db.people.findOne({ name: "Linus" }).address.city, "Cambridge");assert.strictEqual("city" in db.people.findOne({ name: "Ada" }).address, false);{ name: 'Linus', address: { city: 'Cambridge', zip: '02140', country: 'US' }}{ name: 'Ada', address: { zip: '02141' } }Ada เสีย city และ country กฎง่าย ๆ: query และอัปเดต field ซ้อนด้วย dot path
dot path และ index
หัวข้อที่มีชื่อว่า “dot path และ index”dotted path ทำ index เหมือน field ปกติ:
db.members.createIndex({ "address.city": 1 });const e = db.members.find({ "address.city": "Cambridge" }).explain("executionStats");console.log(e.queryPlanner.winningPlan.inputStage.stage, "keys", e.executionStats.totalKeysExamined, "docs", e.executionStats.totalDocsExamined);assert.strictEqual(e.queryPlanner.winningPlan.inputStage.stage, "IXSCAN");IXSCAN keys 3 docs 3ใน Compass: ช่อง Filter รับ dotted key ตรง ๆ เช่น { "address.city": "Cambridge" }; tab Documents แสดง object ซ้อนเป็นโหนดที่ขยายได้ ทำให้ยืนยันลำดับ field ก่อนลองจับคู่ทั้ง sub-document ได้ง่าย (เอกสาร Compass, query filter, มี { "school.name": "Northwestern" } เป็นตัวอย่าง field ที่ฝัง)
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ความไวต่อลำดับคือเรื่องใหญ่ การจับคู่ embedded document ด้วยการส่ง document เต็มให้ field เปรียบเทียบทั้งก้อนตามตัวอักษร — field เดียวกัน ค่าเดียวกัน ลำดับเดียวกัน ไม่มีเกิน สลับ key แล้วการจับคู่หายไปเงียบ ๆ (
GraceเทียบAdaด้านบน) - dot notation ไม่ขึ้นกับลำดับและเป็นบางส่วน
{ "address.city": "Cambridge" }สนใจ field เดียวและไม่มีอะไรอื่น จึงเป็นตัวเลือกประจำวันสำหรับข้อมูลที่ฝัง - ใส่เครื่องหมายคำพูดกับ key ที่มีจุด
address.cityต้องเขียนเป็น string"address.city" - array ของ sub-document ต้องใช้
$elemMatchสำหรับเงื่อนไขหลาย field เหตุผลเดียวกับ array ของ scalar — ไม่เช่นนั้นเงื่อนไขกระจายข้าม element - field ที่ ชื่อ มีจุดเข้าถึงด้วยชื่อธรรมดาไม่ได้ กับ
{ "a.b": 1, a: { b: 2 } },{ "a.b": 2 }พบ document และ{ "a.b": 1 }ไม่พบอะไร — string ที่มีจุดหมายถึง path เสมอ หลีกเลี่ยงจุดในชื่อ field - อัปเดต field ซ้อนด้วย path ไม่ใช่การส่งพ่อ (
address.zipเทียบaddress: { zip })
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ผู้ช่วย AI ที่ถูกขอ “สมาชิกในรหัสไปรษณีย์ 02139” มักเขียน filter แบบ sub-document ที่มีเฉพาะ field ที่สนใจ:
🐛 หา bug ในโค้ดนี้
// AI comment: "find members whose address has zip 02139"const local = await db.collection("members").find({ address: { zip: "02139" } }).toArray();console.log(`${local.length} local members`); // prints 0เฉลย
{ address: { zip: "02139" } } ไม่ได้แปลว่า “address มี zip 02139” — มันแปลว่า “address คือ document { zip: "02139" } เป๊ะ” บนข้อมูลของเรา มันตรง ไม่มีใคร ทั้งที่สามคนอยู่ใน zip นั้น (Ada, Grace และ Linus) โค้ดไม่ error; รายการเตือนแค่ว่างเปล่า วิธีแก้คือ dot path:
const bug = names({ address: { zip: "02139" } });const fixed = names({ "address.zip": "02139" });console.log("sub-document form :", JSON.stringify(bug));console.log("dot-path form :", fixed);assert.strictEqual(bug, "");assert.strictEqual(fixed, "Ada, Grace, Linus");sub-document form : ""dot-path form : Ada, Grace, LinusMental Model
หัวข้อที่มีชื่อว่า “Mental Model”nested document คือ ต้นไม้ของ path dot notation ถามเกี่ยวกับ ใบเดียว ของต้นไม้ โดยไม่สนอย่างอื่น; sub-document filter ถามว่า ทั้งกิ่ง เหมือนค่าที่คุณส่งให้ทุกไบต์หรือไม่ การแบ่งเดียวกันควบคุมการอัปเดต: "address.zip" แก้ใบ, address: {...} แทนที่กิ่ง ถามตัวเองว่า “ฉันพูดถึงใบหรือกิ่ง?” ก่อนเขียน filter
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| query แบบ dot notation บน field ซ้อนที่ตื้นและเสถียร | อ่านง่าย index ได้ดี — IXSCAN 3 key สำหรับ 3 ผลลัพธ์ด้านบน | ต้องรู้ path แน่นอนล่วงหน้า; schema เปลี่ยนแปลว่าต้องแก้ทุก query ที่อ้างถึง |
| ซ้อนลึกหรือรูปร่างต่างกันต่อ document | ยืดหยุ่นสำหรับข้อมูลที่รูปร่างต่างกันไปแต่ละ document | สร้าง covering index ยาก selectivity แย่ลง และ query ต้องรับมือ field ที่ขาดหรือชนิดต่างกัน (address เป็น string ของ Alan) |
| จับคู่ทั้ง sub-document เป๊ะ | ตรวจ “เหมือนกันทุกประการ” ในการเรียกเดียว | ไวต่อลำดับ field และ field เกิน (Ada, Linus พลาด) แคบกว่า dot notation มาก |
$set ด้วย dot path | เปลี่ยนใบเดียว เก็บพี่น้อง | ต้องจำ path ของทุก field ที่แตะ |
$set ทั้ง sub-document | แทนที่กิ่งในขั้นเดียว | ทิ้งพี่น้องที่ลืมใส่ (Ada เสีย city และ country) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
{ address: { zip: '02139' } }หาที่อยู่ที่มี zip นั้น” — ต้องการให้ sub-document เท่ากับมันเป๊ะ; มันตรงไม่มีใคร - “ลำดับ field ใน sub-document ไม่สำคัญ” — สำหรับการจับคู่ทั้ง sub-document สำคัญ (Grace เทียบ Ada)
- “dotted path ให้ error ถ้าพ่อไม่ใช่ object” — แค่ไม่ตรง (address เป็น string ของ Alan)
- “
$set: { address: { zip } }อัปเดต zip” — มันแทนที่addressทั้งก้อน - “index บน dotted path เป็นชั้นสอง” —
address.cityถูก index และใช้เหมือน field ทั่วไป - “เงื่อนไข dotted ธรรมดาบน array ของ sub-document ตรึง element เดียว” — กระจายข้าม element; ใช้
$elemMatch
ตัวอย่างจากของจริง
บทช่วยสอน embedded document ของคู่มือ MongoDB ระบุกฎที่เราวัด: “MongoDB does not recommend comparisons on embedded documents because the operations require an exact match of the specified
<value>document, including the field order” และสำหรับ dot notation ระบุว่า “the field and nested field must be inside quotation marks” (Query on Embedded/Nested Documents, เรียกอ่านเมื่อ 2026-09-30)
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกันและคืนสมาชิกชุดเดียวกัน (dot path: Grace และ Ada; whole document: Grace เท่านั้น; $elemMatch: Margaret) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส