Thực hiện các thao tác kết hợp bằng truy vấn phụ

Tổng quan

Phiên bản Firestore Enterprise hỗ trợ các phép kết hợp theo kiểu quan hệ thông qua các truy vấn con tương quan. Không giống như nhiều cơ sở dữ liệu NoSQL thường yêu cầu bạn chuẩn hoá dữ liệu hoặc thực hiện nhiều yêu cầu phía máy khách, truy vấn con cho phép bạn kết hợp và tổng hợp dữ liệu từ các tập hợp hoặc tập hợp con có liên quan ngay trên máy chủ.

Truy vấn con là những biểu thức thực thi một quy trình lồng nhau cho mọi tài liệu được xử lý bằng truy vấn bên ngoài. Điều này cho phép các mẫu truy xuất dữ liệu phức tạp, chẳng hạn như tìm nạp một tài liệu cùng với các mục trong bộ sưu tập con có liên quan hoặc kết hợp dữ liệu được liên kết một cách hợp lý trên các bộ sưu tập gốc riêng biệt.

Khái niệm

Phần này giới thiệu các khái niệm cốt lõi đằng sau việc sử dụng truy vấn con để thực hiện các thao tác kết hợp trong các thao tác của Pipeline.

Truy vấn phụ dưới dạng biểu thức

Truy vấn phụ không phải là giai đoạn cấp cao nhất; thay vào đó, đây là một biểu thức có thể được dùng trong mọi giai đoạn chấp nhận biểu thức, chẳng hạn như select(...), add_fields(...), where(...) hoặc sort(...).

Cloud Firestore hỗ trợ 3 loại truy vấn phụ:

  • Truy vấn con mảng: Hiện thực hoá toàn bộ tập kết quả của truy vấn con dưới dạng một mảng tài liệu.
  • Truy vấn con vô hướng: Đánh giá thành một giá trị duy nhất, chẳng hạn như số lượng, giá trị trung bình hoặc một trường cụ thể trong tài liệu có liên quan.
  • subcollection(...) Truy vấn con: đơn giản hoá các thao tác kết hợp cho mối quan hệ một-nhiều giữa cha mẹ và con cái.

Phạm vi và biến

Khi viết một thao tác kết hợp, truy vấn con lồng nhau thường cần tham chiếu các trường từ tài liệu "bên ngoài" (tài liệu mẹ). Để kết nối các phạm vi này, bạn có thể sử dụng giai đoạn let(...) (được gọi là define(...) trong một số SDK) để xác định các biến trong phạm vi gốc. Sau đó, các biến này có thể được tham chiếu trong truy vấn phụ bằng hàm variable(...).

Cú pháp

Các phần sau đây trình bày tổng quan về cú pháp để thực hiện các thao tác kết hợp.

Giai đoạn let(...)

Giai đoạn let(...) (được gọi là define(...) trong một số SDK) là giai đoạn không lọc, đưa dữ liệu một cách rõ ràng từ phạm vi gốc vào một biến có tên để sử dụng trong các phạm vi lồng nhau tiếp theo.

Câu lệnh con mảng

Truy vấn con mảng là một trường hợp đặc biệt của truy vấn con biểu thức, giúp hiện thực hoá toàn bộ tập kết quả của truy vấn con thành một mảng. Nếu truy vấn phụ trả về 0 hàng, thì truy vấn phụ sẽ đánh giá thành một mảng trống. Phương thức này không bao giờ trả về mảng null. Các truy vấn như vậy rất hữu ích khi bạn cần kết quả đầy đủ trong kết quả cuối cùng, chẳng hạn như khi hiện thực hoá một bộ sưu tập lồng nhau hoặc tương quan.

Các truy vấn có thể lọc, sắp xếp và tổng hợp trong truy vấn phụ để giảm lượng dữ liệu cần tìm nạp và trả về, từ đó giúp giảm chi phí của truy vấn. Thứ tự của truy vấn phụ được tuân thủ, tức là giai đoạn sort(...) trong truy vấn phụ sẽ kiểm soát thứ tự của kết quả trong mảng cuối cùng.

Sử dụng trình bao bọc SDK toArrayExpression() để chuyển đổi một truy vấn thành một mảng.

Câu lệnh con vô hướng

Truy vấn con vô hướng thường được dùng trong giai đoạn select(...) hoặc where(...) dưới dạng cho phép lọc hoặc dẫn đến kết quả của một truy vấn con mà không hiện thực hoá trực tiếp toàn bộ truy vấn.

Một truy vấn con vô hướng tạo ra không có kết quả sẽ đánh giá thành chính null, trong khi một truy vấn con đánh giá thành nhiều phần tử sẽ dẫn đến lỗi thời gian chạy.

Khi một truy vấn con vô hướng chỉ tạo ra một trường duy nhất cho mỗi kết quả, trường đó sẽ được nâng cấp để trở thành kết quả cấp cao nhất cho truy vấn con. Trường hợp này thường gặp nhất khi truy vấn phụ kết thúc bằng select(field("user_name")) hoặc aggregate(countAll().as("total")), trong đó giản đồ của truy vấn phụ chỉ là một trường duy nhất. Nếu không, khi một truy vấn phụ có thể tạo ra nhiều trường, các trường đó sẽ được bao bọc trong một bản đồ.

Sử dụng trình bao bọc SDK toScalarExpression() để chuyển đổi một truy vấn thành biểu thức vô hướng.

subcollection(...) Truy vấn phụ

Mặc dù được cung cấp dưới dạng một giai đoạn, nhưng giai đoạn đầu vào subcollection(...) cho phép thực hiện các thao tác kết hợp trên mô hình dữ liệu phân cấp của Cloud Firestore. Trong mô hình phân cấp, các truy vấn thường cần truy xuất một tài liệu cùng với dữ liệu từ các bộ sưu tập con của riêng tài liệu đó. Mặc dù bạn có thể đạt được điều này bằng cách sử dụng giai đoạn đầu vào collection_group(...), sau đó là bộ lọc trên tham chiếu chính, subcollection(...) cung cấp một cú pháp ngắn gọn hơn nhiều.

Ngoài điều kiện kết hợp ngầm, điều kiện này hoạt động tương tự như một truy vấn con mảng, trả về kết quả trống nếu không có tài liệu nào khớp, ngay cả khi bộ sưu tập lồng nhau không tồn tại.

Đây về cơ bản là cú pháp rút gọn: nó tự động sử dụng __name__ của tài liệu trong phạm vi bên ngoài làm khoá kết hợp để phân giải mối quan hệ phân cấp. Điều này khiến đây trở thành cách thức ưu tiên để thực hiện các thao tác tra cứu trên các tập hợp được liên kết theo mối quan hệ mẹ con.

Ví dụ

Dữ liệu mẫu

Sau đây tải một tập hợp dữ liệu kiểm thử để sử dụng trong tất cả các ví dụ sau.

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

Tra cứu một tài liệu trong một bộ sưu tập khác

Truy vấn sau đây trên nhóm bộ sưu tập reviews sẽ thực hiện một thao tác tra cứu vào nhóm bộ sưu tập restaurant bằng cách sử dụng một giá trị tham chiếu khoá chính.

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")));

Đáp

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

Kết hợp nhiều bộ sưu tập

Truy vấn sau đây tìm nạp tất cả các địa điểm bán pizza từ nhóm tập hợp restaurants và sử dụng một truy vấn phụ mảng để tìm nạp và nhúng các bài đánh giá được liên kết của các địa điểm đó trực tiếp vào phản hồi.

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")));

Đáp

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

Tổng hợp trên nhiều bộ sưu tập

Truy vấn sau đây trên nhóm bộ sưu tập restaurants sử dụng một truy vấn con có liên quan để lấy điểm xếp hạng trung bình cho từng nhà hàng từ nhóm bộ sưu tập 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")));

Đáp

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

N hàng đầu cho mỗi nhóm (Truy vấn phụ có giới hạn)

Truy vấn sau đây tìm nạp tất cả tài liệu từ nhóm tập hợp restaurants và sử dụng một truy vấn con có liên quan để tìm nạp 2 bài đánh giá được xếp hạng cao nhất cho mỗi nhà hàng.

Điều này đảm bảo rằng mảng bài đánh giá không tăng quá lớn và đạt đến giới hạn bộ nhớ của truy vấn.

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")));

Đáp

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

Bộ sưu tập con kết hợp

Truy vấn sau đây quét tập hợp cities và sử dụng giai đoạn subcollection(...) để kết hợp ngầm định các tài liệu từ một tập hợp lồng nhau nhằm tìm số lượng nhà hàng theo thành phố.

Node.js

let results = await execute(db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count")));

Đáp

{
  __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
}

Thể hiện nhiều điều kiện kết hợp

Truy vấn sau đây sẽ quét nhóm bộ sưu tập restaurants và thực hiện thao tác kết hợp nhiều trường với nhóm bộ sưu tập reviews để tìm những chủ sở hữu đang đánh giá nhà hàng của chính họ.

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)));

Đáp

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

Anti-Join (NOT EXISTS)

Truy vấn sau đây sẽ quét nhóm bộ sưu tập restaurants và tìm tất cả các nhà hàng chưa có bài đánh giá.

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)));

Đáp

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

Truy vấn phụ dưới dạng phép kết hợp

Truy vấn sau đây sẽ làm phẳng mối quan hệ giữa mỗi địa điểm bán pizza và các bài đánh giá của địa điểm đó. Bằng cách đặt truy vấn phụ bên trong giai đoạn unnest(...), máy chủ sẽ sao chép tài liệu nhà hàng bên ngoài cho từng bài đánh giá phù hợp, tạo ra các tài liệu được kết hợp, đơn giản (tương tự như 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")));

Đáp

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

Truy vấn con không tương quan làm bộ lọc

Truy vấn sau đây trên tập hợp reviews thực hiện các bộ lọc bằng cách sử dụng một truy vấn con không tương quan trên chính nó để tìm những bài đánh giá có điểm xếp hạng cao hơn điểm xếp hạng trung bình.

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");

Đáp

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

Các phương pháp hay nhất

  • Quản lý bộ nhớ bằng toArrayExpression(): Hãy thận trọng với các truy vấn phụ toArrayExpression(), vì việc hiện thực hoá một số lượng lớn tài liệu có thể làm cạn kiệt giới hạn bộ nhớ truy vấn (128 MiB). Để giảm thiểu vấn đề này, hãy sử dụng select(...) trong truy vấn phụ để chỉ trả về các trường cần thiết và áp dụng bộ lọc where(...) để giới hạn số lượng tài liệu được trả về. Hãy cân nhắc sử dụng limit(...) nếu phù hợp để giới hạn số lượng tài liệu mà truy vấn phụ trả về.
  • Lập chỉ mục: Đảm bảo rằng các trường được dùng trong mệnh đề where(...) của một truy vấn phụ được lập chỉ mục. Các thao tác kết hợp hiệu quả dựa vào khả năng thực hiện các thao tác tìm kiếm chỉ mục thay vì quét toàn bộ bảng.

Để biết thêm các phương pháp hay nhất về cụm từ tìm kiếm, hãy tham khảo hướng dẫn của chúng tôi về cách tối ưu hoá cụm từ tìm kiếm.

Hạn chế

  • Phạm vi subcollection(...): Giai đoạn đầu vào subcollection(...) chỉ được hỗ trợ trong các truy vấn phụ, vì giai đoạn này yêu cầu ngữ cảnh của một tài liệu mẹ để phân giải mối quan hệ phân cấp và thực hiện thao tác kết hợp.
  • Độ sâu lồng nhau: Bạn có thể lồng các truy vấn phụ tối đa 20 lớp.
  • Mức sử dụng bộ nhớ: Giới hạn 128 MiB đối với dữ liệu cụ thể hoá áp dụng cho toàn bộ truy vấn, bao gồm cả tất cả các tài liệu được kết hợp.