ทำการรวมด้วยการสืบค้นย่อย

ภาพรวม

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 สำหรับข้อมูลที่แปลงเป็นรูปธรรมจะมีผลกับการค้นหาทั้งหมด รวมถึงเอกสารที่รวมทั้งหมด