ภาพรวม
Firestore รุ่น Enterprise รองรับการรวมสไตล์เชิงสัมพันธ์ผ่านการสืบค้นย่อยที่สัมพันธ์กัน การค้นหาย่อยช่วยให้คุณรวมและรวบรวมข้อมูลจากคอลเล็กชันหรือคอลเล็กชันย่อยที่เกี่ยวข้องได้โดยตรงบนเซิร์ฟเวอร์ ซึ่งแตกต่างจากฐานข้อมูล NoSQL หลายรายการที่มักต้องทำการดีนอร์มัลไลซ์ข้อมูลหรือส่งคำขอฝั่งไคลเอ็นต์หลายรายการ
การค้นหาย่อยคือนิพจน์ที่เรียกใช้ไปป์ไลน์ที่ซ้อนกันสำหรับเอกสารทุกรายการที่ประมวลผลโดยการค้นหาภายนอก ซึ่งช่วยให้สามารถใช้รูปแบบการดึงข้อมูลที่ซับซ้อนได้ เช่น การดึงข้อมูลเอกสารพร้อมกับรายการคอลเล็กชันย่อยที่เกี่ยวข้อง หรือการรวมข้อมูลที่ลิงก์กันอย่างสมเหตุสมผลในคอลเล็กชันรูทที่ต่างกัน
แนวคิด
ส่วนนี้จะแนะนําแนวคิดหลักเบื้องหลังการใช้การค้นหาย่อยเพื่อทำการรวมในการดำเนินการของไปป์ไลน์
การค้นหาย่อยเป็นนิพจน์
การค้นหาย่อยไม่ใช่ระยะระดับบนสุด แต่เป็นนิพจน์ที่ใช้ได้ในระยะใดก็ได้ที่ยอมรับนิพจน์ เช่น select(...), add_fields(...), where(...) หรือ sort(...)
Cloud Firestore รองรับการค้นหาย่อย 3 ประเภท ได้แก่
- การสืบค้นย่อยของอาร์เรย์: แสดงชุดผลลัพธ์ทั้งหมดของการสืบค้นย่อยเป็นอาร์เรย์ของเอกสาร
- การสืบค้นย่อยแบบสเกลาร์: ประเมินเป็นค่าเดียว เช่น จำนวน ค่าเฉลี่ย หรือฟิลด์ที่เฉพาะเจาะจงจากเอกสารที่เกี่ยวข้อง
subcollection(...)การค้นหาย่อย: การรวมที่เรียบง่ายสำหรับความสัมพันธ์แบบ 1 ต่อหลายระหว่างหลักและย่อย
ขอบเขตและตัวแปร
เมื่อเขียนการรวม Subquery ที่ฝังมักจะต้องอ้างอิงฟิลด์จากเอกสาร "ภายนอก" (ระดับบนสุด) หากต้องการเชื่อมโยงขอบเขตเหล่านี้ ให้ใช้ระยะ let(...) (เรียกว่า define(...) ใน SDK บางรายการ) เพื่อกำหนดตัวแปรในขอบเขตระดับบนสุดที่สามารถอ้างอิงได้ในการค้นหาย่อยโดยใช้ฟังก์ชัน variable(...)
ไวยากรณ์
ส่วนต่อไปนี้จะแสดงภาพรวมของไวยากรณ์สำหรับการรวม
ระยะlet(...)
ระยะ let(...) (เรียกว่า define(...) ใน SDK บางรายการ) เป็นระยะที่ไม่มีการกรองซึ่งจะนำข้อมูลจากขอบเขตระดับบนมาไว้ในตัวแปรที่มีชื่ออย่างชัดเจนเพื่อใช้ในขอบเขตที่ฝังอยู่ถัดไป
การค้นหาย่อยของอาร์เรย์
การสืบค้นย่อยของอาร์เรย์เป็นกรณีพิเศษของการสืบค้นย่อยของนิพจน์ที่ทำให้ชุดผลลัพธ์ทั้งหมดของการสืบค้นย่อยเป็นอาร์เรย์ หากการค้นหาย่อยแสดงผลเป็น 0 แถว ระบบจะประเมินเป็นอาร์เรย์ว่าง โดยจะไม่แสดงผลอาร์เรย์ null การค้นหาดังกล่าวมีประโยชน์เมื่อต้องการผลลัพธ์แบบเต็มในผลลัพธ์สุดท้าย เช่น เมื่อสร้างคอลเล็กชันที่ซ้อนกันหรือสัมพันธ์กัน
การค้นหาสามารถกรอง จัดเรียง และรวมในการค้นหาย่อยเพื่อลดจำนวนข้อมูลที่ต้องดึงและแสดงผลเพื่อช่วยลดค่าใช้จ่ายในการค้นหา ระบบจะยึดตามลำดับของคำค้นหาย่อย ซึ่งหมายความว่าระยะ sort(...) ในคำค้นหาย่อยจะควบคุมลำดับของผลลัพธ์ในอาร์เรย์สุดท้าย
ใช้ Wrapper ของ toArrayExpression() SDK เพื่อแปลงการค้นหาเป็นอาร์เรย์
การสืบค้นย่อยแบบสเกลาร์
มักใช้การสืบค้นย่อยสเกลาร์ในระยะ select(...) หรือ where(...) เนื่องจากอนุญาตให้กรองหรือแสดงผลลัพธ์ของการสืบค้นย่อยโดยไม่ต้องสร้างการสืบค้นแบบเต็มโดยตรง
Subquery สเกลาร์ที่ให้ผลลัพธ์เป็น 0 จะประเมินเป็น null เอง ส่วน Subquery ที่ประเมินเป็นองค์ประกอบหลายรายการจะส่งผลให้เกิดข้อผิดพลาดขณะรันไทม์
เมื่อการสืบค้นย่อยแบบสเกลาร์สร้างฟิลด์เดียวต่อผลลัพธ์ ฟิลด์ดังกล่าวจะได้รับการยกระดับให้เป็นผลลัพธ์ระดับบนสุดสำหรับการสืบค้นย่อย กรณีนี้มักพบเมื่อการค้นหาย่อยลงท้ายด้วย select(field("user_name")) หรือ aggregate(countAll().as("total")) โดยที่สคีมาของการค้นหาย่อยเป็นเพียงช่องเดียว ไม่เช่นนั้น เมื่อการค้นหาย่อยสร้างหลายช่องได้ ระบบจะรวมช่องเหล่านั้นไว้ในแผนที่
ใช้ Wrapper ของ SDK toScalarExpression() เพื่อแปลงการค้นหาเป็นนิพจน์สเกลาร์
การค้นหาย่อย subcollection(...) รายการ
แม้ว่าจะเสนอเป็นระยะ แต่ระยะอินพุต subcollection(...) ช่วยให้ทำการรวมข้อมูลผ่านโมเดลข้อมูลแบบลำดับชั้นของ Cloud Firestore ได้ ในโมเดลลำดับชั้น การค้นหามักจะต้องดึงข้อมูลเอกสารพร้อมกับข้อมูลจากคอลเล็กชันย่อยของตัวเอง แม้ว่าคุณจะทำเช่นนี้ได้โดยใช้ขั้นตอนอินพุต collection_group(...) ตามด้วยตัวกรองในการอ้างอิงระดับบน แต่ subcollection(...) จะมีไวยากรณ์ที่กระชับกว่ามาก
นอกเหนือจากเงื่อนไขการรวมโดยนัยแล้ว การดำเนินการนี้จะทำงานคล้ายกับการค้นหาย่อยของอาร์เรย์ โดยจะแสดงผลลัพธ์ที่ว่างเปล่าหากไม่มีเอกสารที่ตรงกัน แม้ว่าจะไม่มีคอลเล็กชันที่ฝังอยู่ก็ตาม
โดยพื้นฐานแล้วจะเป็น Syntactic Sugar ซึ่งจะใช้ __name__ ของเอกสารในขอบเขตภายนอกเป็นคีย์การรวมโดยอัตโนมัติเพื่อแก้ไขความสัมพันธ์แบบลำดับชั้น วิธีนี้จึงเป็นวิธีที่แนะนำสำหรับการค้นหาในคอลเล็กชันที่ลิงก์กันในความสัมพันธ์แบบหลัก-ย่อย
ตัวอย่าง
ตัวอย่างข้อมูล
โค้ดต่อไปนี้จะโหลดชุดข้อมูลทดสอบเพื่อใช้ในตัวอย่างต่อไปนี้ทั้งหมด
Node.js
// Load set of cities.
const cities = collection(db, "cities");
await setDoc(doc(cities, "SF"), {
name: "San Francisco",
state: "CA",
country: "USA",
});
await setDoc(doc(cities, "LA"), {
name: "Los Angeles",
state: "CA",
country: "USA"
});
await setDoc(doc(cities, "DC"), {
name: "Washington, D.C.",
state: null,
country: "USA"
});
await setDoc(doc(cities, "TOK"), {
name: "Tokyo",
state: null,
country: "Japan"
});
// Load restaurants in various cities.
const sfRestaurants = collection(db, "cities", "SF", "restaurants");
const laRestaurants = collection(db, "cities", "LA", "restaurants");
const dcRestaurants = collection(db, "cities", "DC", "restaurants");
const rest1 = await addDoc(sfRestaurants, {
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
});
const rest2 = await addDoc(sfRestaurants, {
name: "Bay Area Burger",
type: "burger",
owner_id: "Sarah Jenkins"
});
const rest3 = await addDoc(sfRestaurants, {
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
});
const rest4 = await addDoc(laRestaurants, {
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
});
const rest5 = await addDoc(laRestaurants, {
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano"
});
const rest6 = await addDoc(dcRestaurants, {
name: "Capitol Tacos",
type: "mexican",
owner_id: "Maria Garcia"
});
const rest7 = await addDoc(dcRestaurants, {
name: "Georgetown Coffee",
type: "cafe",
owner_id: "David Kim"
});
// Load collection of reviews.
const reviews = collection(db, "reviews");
await addDoc(reviews, { restaurant: rest1, rating: 5, reviewer_id "Alice" });
await addDoc(reviews, { restaurant: rest1, rating: 4, reviewer_id "Bob" });
await addDoc(reviews, { restaurant: rest2, rating: 4, reviewer_id "Charlie" });
await addDoc(reviews, { restaurant: rest3, rating: 5, reviewer_id "Diana" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Edward" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Fiona" });
// rest4 has 0 reviews
await addDoc(reviews, { restaurant: rest5, rating: 3, reviewer_id "George" });
await addDoc(reviews, { restaurant: rest6, rating: 5, reviewer_id "Hannah" });
await addDoc(reviews, { restaurant: rest6, rating: 4, reviewer_id "Ian" });
await addDoc(reviews, { restaurant: rest7, rating: 5, reviewer_id "Julia" });
ค้นหาเอกสารในคอลเล็กชันอื่น
การค้นหาต่อไปนี้ในกลุ่มคอลเล็กชัน reviews จะทำการค้นหาในกลุ่มคอลเล็กชัน restaurant โดยใช้การอ้างอิงคีย์หลัก
Node.js
let results = await execute(db.pipeline()
.collectionGroup("reviews")
.define(field("restaurant").as("restaurant_name"))
.addFields(db.pipeline()
.collectionGroup("restaurant")
.where(field("__name__").equal(variable("restaurant_name")))
.select("name", "type")
.toScalarExpression()
.as("restaurant")));
คำตอบ
{
rating: 5,
reviewer_id "Alice",
restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
rating: 4,
reviewer_id "Bob",
restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
rating: 4,
reviewer_id "Charlie",
restaurant: { name: "Bay Area Burger", type: "burger" }
},
{
rating: 5,
reviewer_id "Diana",
restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
rating: 4,
reviewer_id "Edward",
restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
rating: 4,
reviewer_id "Fiona",
restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
rating: 3,
reviewer_id "George",
restaurant: { name: "Venice Pizza", type: "pizza" }
},
{
rating: 5,
reviewer_id "Hannah",
restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
rating: 4,
reviewer_id "Ian",
restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
rating: 5,
reviewer_id "Julia",
restaurant: { name: "Georgetown Coffee", type: "cafe" }
}
รวมคอลเล็กชันหลายรายการ
การค้นหาต่อไปนี้จะดึงข้อมูลร้านพิซซ่าทั้งหมดจากกลุ่มคอลเล็กชัน restaurants และใช้การค้นหาย่อยของอาร์เรย์เพื่อดึงข้อมูลและฝังรีวิวที่เกี่ยวข้องลงในการตอบกลับโดยตรง
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.where(field("type").equal("pizza"))
.define(field("__name__").as("restaurant_name"))
.select(
field("name"),
db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("restaurant_name")))
.select("rating", "reviewer_id")
.toArrayExpression()
.as("reviews")));
คำตอบ
{
name: "Golden Gate Pizza",
reviews: [
{ rating: 5, reviewer_id "Alice" },
{ rating: 4, reviewer_id "Bob" }
]
},
{
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano",
reviews: [
{ rating: 3, reviewer_id "George" }
]
}
รวมข้อมูลจากหลายคอลเล็กชัน
การค้นหาต่อไปนี้ในกลุ่มคอลเล็กชัน restaurants ใช้การค้นหาย่อยที่สัมพันธ์กันเพื่อรับคะแนนเฉลี่ยของร้านอาหารแต่ละแห่งจากกลุ่มคอลเล็กชัน reviews
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.where(field("type").equal("pizza"))
.define(field("__name__").as("restaurant_name"))
.select(
field("name"),
db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("restaurant_name")))
.aggregate(average("rating").as("avg_rating"))
.toScalarExpression()
.as("avg_rating")));
คำตอบ
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
N อันดับแรกต่อกลุ่ม (การค้นหาย่อยที่มีขีดจำกัด)
การค้นหาต่อไปนี้จะดึงเอกสารทั้งหมดจากกลุ่มคอลเล็กชัน restaurants และใช้การค้นหาย่อยที่สัมพันธ์กันเพื่อดึงรีวิวที่ได้คะแนนสูงสุด 2 อันดับแรกสำหรับร้านอาหารแต่ละแห่ง
วิธีนี้ช่วยให้มั่นใจได้ว่าอาร์เรย์ของรีวิวจะไม่ใหญ่เกินไปและถึงขีดจำกัดหน่วยความจำของคำค้นหา
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.define(field("__name__").as("restaurant_name"))
.select(
field("name"),
db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("restaurant_name")))
.sort(field("rating").descending())
.limit(2)
.select("rating", "reviewer_id")
.toArrayExpression()
.as("top_reviews")));
คำตอบ
{
name: "Golden Gate Pizza",
top_reviews: [
{ rating: 5, reviewer_id "Alice" },
{ rating: 4, reviewer_id "Bob" }
]
},
{
name: "Bay Area Burger",
top_reviews: [
{ rating: 4, reviewer_id "Charlie" }
]
},
{
name: "Sunset Taco",
top_reviews: [
{ rating: 5, reviewer_id "Diana" },
{ rating: 4, reviewer_id "Edward" }
]
},
{
name: "Hollywood Sushi",
top_reviews: []
},
{
name: "Venice Pizza",
top_reviews: [
{ rating: 3, reviewer_id "George" }
]
},
{
name: "Capitol Tacos",
top_reviews: [
{ rating: 5, reviewer_id "Hannah" },
{ rating: 4, reviewer_id "Ian" }
]
},
{
name: "Georgetown Coffee",
top_reviews: [
{ rating: 5, reviewer_id "Julia" }
]
}
เข้าร่วมคอลเล็กชันย่อย
การค้นหาต่อไปนี้จะสแกนคอลเล็กชัน cities และใช้ระยะ subcollection(...) เพื่อรวมเอกสารจากคอลเล็กชันที่ฝังไว้โดยนัยเพื่อค้นหาจำนวนร้านอาหารต่อเมือง
Node.js
let results = await execute(db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count")));
คำตอบ
{
__name__: cities/SF,
name: "San Francisco",
state: "CA",
country: "USA",
restaurant_count: 3
},
{
__name__: cities/LA,
name: "Los Angeles",
state: "CA",
country: "USA",
restaurant_count: 2
},
{
__name__: cities/DC,
name: "Washington, D.C.",
state: null,
country: "USA",
restaurant_count: 2
},
{
__name__: cities/TOK,
name: "Tokyo",
state: null,
country: "Japan",
restaurant_count: 0
}
แสดงเงื่อนไขการรวมหลายรายการ
การค้นหาต่อไปนี้จะสแกนกลุ่มคอลเล็กชัน restaurants และทำการรวมหลายฟิลด์กับกลุ่มคอลเล็กชัน reviews เพื่อค้นหาเจ้าของที่รีวิวร้านอาหารของตนเอง
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.define(field("owner_id"), field("__name__"))
.where(db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("__name__")))
.where(field("author").equal(variable("owner_id")))
.aggregate(count().as("c"))
.toScalarExpression()
.greaterThan(0)));
คำตอบ
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
Anti-Join (NOT EXISTS)
การค้นหาต่อไปนี้จะสแกนกลุ่มคอลเล็กชัน restaurants และค้นหาร้านอาหารทั้งหมดที่ยังไม่มีรีวิว
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.define(field("__name__").as("restaurant_name"))
.where(db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("restaurant_name")))
.aggregate(count().as("review_count"))
.toScalarExpression()
.equal(0)));
คำตอบ
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
การรวมการค้นหาย่อย
การค้นหาต่อไปนี้จะทำให้ความสัมพันธ์ระหว่างร้านพิซซ่าแต่ละร้านกับรีวิวของร้านนั้นๆ เป็นแบบแฟลต การวางการค้นหาย่อยไว้ในระยะ unnest(...) จะทำให้เซิร์ฟเวอร์ทำซ้ำเอกสารร้านอาหารด้านนอกสำหรับรีวิวที่ตรงกันแต่ละรายการ ซึ่งจะสร้างเอกสารที่รวมกันแบบแบน (คล้ายกับ SQL INNER JOIN)
Node.js
let results = await execute(db.pipeline()
.collectionGroup("restaurants")
.where(field("type").equal("pizza"))
.define(field("__name__").as("restaurant_name"))
.unnest(
db.pipeline()
.collectionGroup("reviews")
.where(field("restaurant").equal(variable("restaurant_name")))
.select("rating", "reviewer_id")
.toArrayExpression()
.as("review")));
คำตอบ
{
__name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
review: { rating: 5, reviewer_id "Alice" }
},
{
__name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi",
review: { rating: 4, reviewer_id "Bob" }
},
{
__name__: "cities/LA/restaurants/6CYntvNgbYzgaW652Gq1",
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano",
review: { rating: 3, reviewer_id "George" }
}
การสืบค้นย่อยที่ไม่สัมพันธ์กันเป็นตัวกรอง
การค้นหาต่อไปนี้ในคอลเล็กชัน reviews จะกรองโดยใช้การค้นหาย่อยที่ไม่สัมพันธ์กันในตัวเองเพื่อค้นหารีวิวที่สูงกว่าคะแนนเฉลี่ย
Node.js
let results = await execute(db.pipeline()
.collection("reviews")
// Average review rating is 4.3
.where(field("rating").greaterThan(db.pipeline()
.collection("reviews")
.aggregate(average("rating").as("avg"))
.toScalarExpression())))
.select("rating", "reviewer_id");
คำตอบ
{
rating: 5,
reviewer_id "Alice"
},
{
rating: 5,
reviewer_id "Diana"
},
{
rating: 5,
reviewer_id "Hannah"
},
{
rating: 5,
reviewer_id "Julia"
}
แนวทางปฏิบัติแนะนำ
- จัดการหน่วยความจำด้วย
toArrayExpression(): โปรดระมัดระวังการใช้การค้นหาย่อยtoArrayExpression()เนื่องจากเอกสารจำนวนมากอาจทำให้หน่วยความจำสำหรับการค้นหา (128 MiB) หมดลง เพื่อลดปัญหานี้ ให้ใช้select(...)ภายในคำค้นหาย่อยเพื่อแสดงเฉพาะฟิลด์ที่จำเป็น และใช้ตัวกรองwhere(...)เพื่อจำกัดจำนวนเอกสารที่แสดงผล ลองใช้limit(...)หากเหมาะสมเพื่อจำกัดจำนวนเอกสารที่แสดงผลโดยการค้นหาย่อย - การจัดทำดัชนี: ตรวจสอบว่าฟิลด์ที่ใช้ในส่วนคำสั่ง
where(...)ของการค้นหาย่อยได้รับการจัดทำดัชนีแล้ว การรวมที่มีประสิทธิภาพจะอาศัยความสามารถในการค้นหาดัชนีแทนการสแกนตารางแบบเต็ม
ดูแนวทางปฏิบัติแนะนำเพิ่มเติมเกี่ยวกับการค้นหาได้ที่คู่มือเกี่ยวกับการเพิ่มประสิทธิภาพการค้นหา
ข้อจำกัด
- ขอบเขต
subcollection(...): ระบบรองรับระยะอินพุตsubcollection(...)ภายในคิวรีย่อยเท่านั้น เนื่องจากต้องใช้บริบทของเอกสารหลักเพื่อแก้ไขความสัมพันธ์แบบลำดับชั้นและทำการรวม - ความลึกของการซ้อน: การค้นหาย่อยสามารถซ้อนกันได้สูงสุด 20 ชั้น
- การใช้งานหน่วยความจำ: ขีดจำกัด 128 MiB สำหรับข้อมูลที่แปลงเป็นรูปธรรมจะมีผลกับการค้นหาทั้งหมด รวมถึงเอกสารที่รวมทั้งหมด