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

การ 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 เดียวกัน)

สมาชิกด้านล่างเก็บที่อยู่เดียวกันใน รูปร่างที่ต่างกัน โดยตั้งใจ: Grace มี city ก่อน zip, Ada มี zip ก่อน city, Linus มี field country เพิ่ม และ Alan เก็บที่อยู่เป็น string ธรรมดา:

mongosh/nest-seed.js
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);

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

mongosh/nest-dot.js
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");

query เข้าไปใน address และจับคู่เฉพาะ city โดยไม่สนว่า sub-document มีอะไรอื่นและ field เรียงอย่างไร — Ada (zip มาก่อน) และ Linus (มี country เพิ่ม) ตรงเช่นเดียวกับ Grace dot path ต่อกันลึกได้เท่าที่ข้อมูลลึกและผสมกับ operator ได้อย่างอิสระ:

address.city = Cambridge : Ada, Grace, Linus
address.zip $exists : Ada, Grace, Linus, Margaret
address.city missing : Alan, Edsger
contact.phone missing : Ada, Alan, Edsger, Linus, Margaret

Alan (ที่ address เป็น string ดังนั้น address.city ไม่มีทางมี) และ Edsger (ไม่มี address) ทั้งคู่ “ขาด” สำหรับ address.city; ไม่มี error สำหรับ path ที่ผ่าน non-object

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

mongosh/nest-whole.js
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" } }), "");

สามคนที่ใช้ 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 ที่ element เป็น sub-document สมมติสมาชิกแต่ละคนมี array reviews แต่ละรีวิวมี book และ rating dotted query ธรรมดาอย่าง "reviews.rating" ตรวจเงื่อนไขข้าม ทุก รีวิว ดังนั้นเพื่อบังคับทั้งสอง field บนรีวิว เดียวกัน คุณใช้ $elemMatch ที่นี่เราหาสมาชิกที่มีรีวิว Algorithms คะแนนอย่างน้อย 4 — Grace ให้ Algorithms 3 และ Networks 5, Margaret กลับกัน:

mongosh/nest-array.js
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");

เฉพาะสมาชิกที่รีวิว เดียวกัน ระบุหนังสือนั้นและคะแนนผ่านเกณฑ์จึงตรง รูปแบบธรรมดายังคืน Grace ที่ให้ Algorithms 3 แต่หนังสือ เล่มอื่น 5:

plain dotted paths : Grace, Margaret
$elemMatch : Margaret
reviews.0.book : Grace, Margaret

reviews.0.book ระบุรีวิวแรกด้วย index (เปราะถ้า array เรียงใหม่)

ความต่างระหว่าง whole กับ path เดียวกันตัดสินว่า การอัปเดต ทำอะไร การตั้ง field ด้านในเดียวด้วย dot path เก็บพี่น้องไว้; การตั้งพ่อเป็น document ใหม่ แทนที่ มัน:

mongosh/nest-update.js
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 path
db.people.updateOne({ name: "Ada" }, { $set: { address: { zip: "02141" } } }); // by whole document
console.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

dotted path ทำ index เหมือน field ปกติ:

mongosh/nest-index.js
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 ที่ถูกขอ “สมาชิกในรหัสไปรษณีย์ 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:

mongosh/nest-zip-fix.js
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, Linus

nested document คือ ต้นไม้ของ path dot notation ถามเกี่ยวกับ ใบเดียว ของต้นไม้ โดยไม่สนอย่างอื่น; sub-document filter ถามว่า ทั้งกิ่ง เหมือนค่าที่คุณส่งให้ทุกไบต์หรือไม่ การแบ่งเดียวกันควบคุมการอัปเดต: "address.zip" แก้ใบ, address: {...} แทนที่กิ่ง ถามตัวเองว่า “ฉันพูดถึงใบหรือกิ่ง?” ก่อนเขียน filter

ตัวเลือกBenefitCost
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)
  • “{ 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) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

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