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

ภาพรวม

Firestore รุ่น Enterprise รองรับการผนวกแบบเชิงสัมพันธ์ผ่านการค้นหาย่อยแบบสัมพันธ์ การค้นหาย่อยช่วยให้คุณรวมและรวมข้อมูลจากคอลเล็กชันหรือคอลเล็กชันย่อยที่เกี่ยวข้องได้โดยตรงบนเซิร์ฟเวอร์ ซึ่งแตกต่างจากฐานข้อมูล NoSQL หลายรายการที่มักจะต้องทำให้ข้อมูลเป็นปกติหรือทำการขอฝั่งไคลเอ็นต์หลายรายการ

การค้นหาย่อยเป็นนิพจน์ที่เรียกใช้ไปป์ไลน์แบบซ้อนสำหรับเอกสารแต่ละรายการที่การค้นหาภายนอกประมวลผล ซึ่งช่วยให้สามารถดึงข้อมูลที่ซับซ้อนได้ เช่น การดึงเอกสารพร้อมกับรายการคอลเล็กชันย่อยที่เกี่ยวข้อง หรือการรวมข้อมูลที่ลิงก์กันอย่างมีเหตุผลจากคอลเล็กชันรากที่แตกต่างกัน

แนวคิด

ส่วนนี้จะแนะนำแนวคิดหลักเบื้องหลังการใช้การค้นหาย่อยเพื่อทำการผนวกในการดำเนินการไปป์ไลน์

การค้นหาย่อยเป็นนิพจน์

การค้นหาย่อยไม่ใช่ระยะระดับบนสุด แต่เป็นนิพจน์ ที่ ใช้ได้ในทุกระยะที่ยอมรับนิพจน์ เช่น select(...), add_fields(...), where(...) หรือ sort(...)

Cloud Firestore รองรับการค้นหาย่อย 3 ประเภท ได้แก่

  • การค้นหาย่อยแบบอาร์เรย์: สร้างชุดผลลัพธ์ทั้งหมดของการค้นหาย่อยเป็นอาร์เรย์ของเอกสาร
  • การค้นหาย่อยแบบสเกลาร์: ประเมินเป็นค่าเดียว เช่น จำนวน ค่าเฉลี่ย หรือฟิลด์ที่เฉพาะเจาะจงจากเอกสารที่เกี่ยวข้อง
  • การค้นหาย่อย subcollection(...): การผนวกที่ง่ายขึ้นสำหรับความสัมพันธ์แบบหนึ่งต่อหลายรายการระหว่างเอกสารหลักกับเอกสารย่อย

ขอบเขตและตัวแปร

เมื่อเขียนการผนวก การค้นหาย่อยแบบซ้อนมักจะต้องอ้างอิงฟิลด์จากเอกสาร "ภายนอก" (เอกสารหลัก) หากต้องการเชื่อมขอบเขตเหล่านี้ ให้ใช้ระยะ let(...) (เรียกว่า define(...) ใน SDK บางรายการ) เพื่อกำหนดตัวแปรในขอบเขตหลัก ซึ่งจากนั้นจะอ้างอิงในการ ค้นหาย่อยได้โดยใช้ฟังก์ชัน variable(...)

ไวยากรณ์

ส่วนต่อไปนี้จะให้ภาพรวมของไวยากรณ์สำหรับการผนวก

ระยะ let(...)

ระยะ let(...) (เรียกว่า define(...) ใน SDK บางรายการ) เป็นระยะที่ไม่กรองซึ่งนำข้อมูลจากขอบเขตหลัก ไปยังตัวแปรที่มีชื่ออย่างชัดเจนเพื่อใช้ในขอบเขตแบบซ้อนที่ตามมา

การค้นหาย่อยแบบอาร์เรย์

การค้นหาย่อยแบบอาร์เรย์เป็นการค้นหาย่อยแบบนิพจน์ในกรณีพิเศษที่สร้างชุดผลลัพธ์ทั้งหมดของการค้นหาย่อยเป็นอาร์เรย์ หากการค้นหาย่อยแสดงผล 0 แถว การค้นหาย่อยจะประเมินเป็นอาร์เรย์ว่าง และจะไม่แสดงผลอาร์เรย์ null การค้นหาดังกล่าวมีประโยชน์เมื่อต้องการผลลัพธ์ทั้งหมดในผลลัพธ์สุดท้าย เช่น เมื่อสร้างคอลเล็กชันแบบซ้อนหรือแบบสัมพันธ์

การค้นหาสามารถกรอง จัดเรียง และรวมในการค้นหาย่อยเพื่อลดจำนวนข้อมูลที่ต้องดึงและแสดงผล ซึ่งจะช่วยลดค่าใช้จ่ายในการค้นหา ระบบจะพิจารณาลำดับของการค้นหาย่อย ซึ่งหมายความว่าระยะ sort(...) ในการค้นหาย่อยจะควบคุมลำดับของผลลัพธ์ในอาร์เรย์สุดท้าย

ใช้ Wrapper SDK toArrayExpression() เพื่อแปลงการค้นหาเป็นอาร์เรย์

การค้นหาย่อยแบบสเกลาร์

การค้นหาย่อยแบบสเกลาร์มักใช้ในระยะ select(...) หรือ where(...) เพื่ออนุญาตการกรองหรือแสดงผลลัพธ์ของการค้นหาย่อยโดยไม่ต้องสร้างการค้นหาทั้งหมดโดยตรง

การค้นหาย่อยแบบสเกลาร์ที่แสดงผลลัพธ์ 0 รายการจะประเมินเป็น null เอง ในขณะที่การค้นหาย่อยที่ประเมินเป็นองค์ประกอบหลายรายการจะทำให้เกิดข้อผิดพลาดรันไทม์

เมื่อการค้นหาย่อยแบบสเกลาร์แสดงผลเพียงฟิลด์เดียวต่อผลลัพธ์ ระบบจะ ยกระดับ ฟิลด์ดังกล่าวให้เป็นผลลัพธ์ระดับบนสุดสำหรับการค้นหาย่อย ซึ่งมักจะเกิดขึ้นเมื่อการค้นหาย่อยลงท้ายด้วย select(field("user_name")) หรือ aggregate(countAll().as("total")) โดยที่สคีมาของการค้นหาย่อยเป็นเพียง ฟิลด์เดียว มิเช่นนั้น เมื่อการค้นหาย่อยแสดงผลหลายฟิลด์ได้ ระบบจะห่อฟิลด์เหล่านั้นไว้ในแผนที่

ใช้ Wrapper SDK toScalarExpression() เพื่อแปลงการค้นหาเป็นนิพจน์สเกลาร์

การค้นหาย่อย subcollection(...)

แม้ว่าจะมีให้ใช้เป็นระยะ แต่ระยะอินพุต subcollection(...) ช่วยให้ ทำการผนวกกับโมเดลข้อมูลแบบลำดับชั้นของ Cloud Firestoreได้ ในโมเดลแบบลำดับชั้น การค้นหามักจะต้องดึงเอกสารพร้อมกับข้อมูลจากคอลเล็กชันย่อยของเอกสารเอง แม้ว่าคุณจะทำเช่นนี้ได้โดยใช้ระยะอินพุต collection_group(...) ตามด้วยตัวกรองการอ้างอิงเอกสารหลัก subcollection(...) มีไวยากรณ์ที่กระชับกว่ามาก

การค้นหาย่อยนี้จะทำงานคล้ายกับการค้นหาย่อยแบบอาร์เรย์ โดยจะแสดงผลลัพธ์ว่างเปล่าหากไม่มีเอกสารที่ตรงกัน แม้ว่าคอลเล็กชันแบบซ้อนจะไม่มีอยู่ก็ตาม นอกเหนือจากเงื่อนไขการผนวกโดยนัย

การค้นหาย่อยนี้เป็นไวยากรณ์ที่ใช้งานง่าย โดยจะใช้ __name__ of เอกสารในขอบเขตภายนอกเป็นคีย์การผนวกโดยอัตโนมัติเพื่อแก้ความสัมพันธ์แบบลำดับชั้น จึงเป็นวิธีที่แนะนำในการค้นหาในคอลเล็กชันที่ลิงก์กันในความสัมพันธ์ระหว่างเอกสารหลักกับเอกสารย่อย

ตัวอย่าง

ตัวอย่างข้อมูล

คำสั่งต่อไปนี้จะโหลดชุดข้อมูลทดสอบเพื่อใช้ในตัวอย่างทั้งหมดที่ตามมา

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"
}

การผนวกแบบไม่ตรงกัน (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(...) จะทำให้เซิร์ฟเวอร์ทำซ้ำเอกสารร้านอาหารภายนอกสำหรับรีวิวที่ตรงกันแต่ละรายการ ซึ่งจะสร้างเอกสารที่รวมกันแบบแบน (คล้ายกับ INNER JOIN ของ SQL)

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