Melakukan penggabungan dengan subkueri

Ringkasan

Edisi Firestore Enterprise mendukung penggabungan gaya relasional melalui subkueri yang berkorelasi. Tidak seperti banyak database NoSQL yang sering memerlukan denormalisasi data atau melakukan beberapa permintaan sisi klien, subkueri memungkinkan Anda menggabungkan dan mengagregasi data dari koleksi atau subkoleksi terkait langsung di server.

Subkueri adalah ekspresi yang menjalankan pipeline bertingkat untuk setiap dokumen yang diproses oleh kueri luar. Hal ini memungkinkan pola pengambilan data yang kompleks, seperti mengambil dokumen beserta item subkoleksi terkaitnya atau menggabungkan data yang ditautkan secara logis di berbagai koleksi root yang berbeda.

Konsep

Bagian ini memperkenalkan konsep inti di balik penggunaan subkueri untuk melakukan penggabungan dalam operasi Pipeline.

Subkueri sebagai ekspresi

Subkueri bukanlah tahap tingkat teratas; melainkan ekspresi yang dapat digunakan di tahap mana pun yang menerima ekspresi, seperti select(...), add_fields(...), where(...), atau sort(...).

Cloud Firestore mendukung tiga jenis subkueri:

  • Subkueri Array: Mewujudkan seluruh kumpulan hasil subkueri sebagai array dokumen.
  • Subkueri Skalar: Mengevaluasi ke satu nilai, seperti jumlah, rata-rata, atau kolom tertentu dari dokumen terkait.
  • subcollection(...) Subkueri: join yang disederhanakan untuk hubungan induk-turunan one-to-many.

Cakupan dan variabel

Saat menulis join, subkueri bertingkat sering kali perlu mereferensikan kolom dari dokumen "luar" (induk). Untuk menjembatani cakupan ini, Anda menggunakan tahap let(...) (disebut sebagai define(...) di beberapa SDK) untuk menentukan variabel dalam cakupan induk yang kemudian dapat direferensikan dalam subkueri menggunakan fungsi variable(...).

Sintaksis

Bagian berikut memberikan ringkasan sintaksis untuk melakukan penggabungan.

Tahap let(...)

Tahap let(...) (disebut sebagai define(...) di beberapa SDK) adalah tahap non-pemfilteran yang secara eksplisit membawa data dari cakupan induk ke dalam variabel bernama untuk digunakan dalam cakupan bertingkat berikutnya.

Subkueri Array

Subkueri Array adalah kasus khusus subkueri ekspresi yang mewujudkan seluruh set hasil subkueri menjadi array. Jika subkueri menampilkan nol baris, subkueri akan dievaluasi ke array kosong. Fungsi ini tidak pernah menampilkan array null. Kueri tersebut berguna saat hasil lengkap diperlukan dalam hasil akhir, seperti saat mewujudkan koleksi bertingkat atau berkorelasi.

Kueri dapat memfilter, mengurutkan, & menggabungkan dalam subkueri untuk mengurangi jumlah data yang perlu diambil dan ditampilkan guna membantu mengurangi biaya kueri. Urutan subkueri dihormati, yang berarti bahwa tahap sort(...) dalam subkueri mengontrol urutan hasil dalam array akhir.

Gunakan wrapper SDK toArrayExpression() untuk mengonversi kueri menjadi array.

Subkueri Skalar

Subkueri skalar sering digunakan dalam tahap select(...) atau where(...) karena memungkinkan pemfilteran atau menghasilkan hasil subkueri tanpa mewujudkan kueri lengkap secara langsung.

Subkueri skalar yang menghasilkan nol hasil akan dievaluasi ke null itu sendiri, sementara subkueri yang dievaluasi ke beberapa elemen akan menghasilkan error runtime.

Jika subkueri skalar hanya menghasilkan satu kolom per hasil, kolom tersebut akan dinaikkan menjadi hasil tingkat teratas untuk subkueri. Hal ini paling sering terlihat saat subkueri diakhiri dengan select(field("user_name")) atau aggregate(countAll().as("total")), dengan skema subkueri hanya berupa satu kolom. Jika tidak, saat subkueri dapat menghasilkan beberapa kolom, subkueri tersebut akan digabungkan dalam peta.

Gunakan wrapper SDK toScalarExpression() untuk mengonversi kueri menjadi ekspresi skalar.

subcollection(...) Subkueri

Meskipun ditawarkan sebagai tahap, tahap input subcollection(...) memungkinkan penggabungan dilakukan pada model data hierarkis Cloud Firestore. Dalam model hierarkis, kueri sering kali perlu mengambil dokumen beserta data dari subkoleksinya sendiri. Meskipun Anda dapat melakukannya menggunakan tahap input collection_group(...) yang diikuti dengan filter pada referensi induk, subcollection(...) menyediakan sintaksis yang jauh lebih ringkas.

Selain kondisi join implisit, ini bertindak mirip dengan subkueri array, yang menampilkan hasil kosong jika tidak ada dokumen yang cocok, meskipun koleksi bertingkat tidak ada.

Pada dasarnya, ini adalah syntactic sugar: secara otomatis menggunakan __name__ dokumen dalam cakupan luar sebagai kunci gabungan untuk menyelesaikan hubungan hierarkis. Hal ini menjadikannya cara yang lebih disukai untuk melakukan pencarian di seluruh koleksi yang ditautkan dalam hubungan induk-turunan.

Contoh

Contoh data

Berikut ini cara memuat set data pengujian untuk digunakan dalam semua contoh berikut.

Node.js

// Load set of cities.
const cities = db.collection("cities");

await cities.doc("SF").set({
  name: "San Francisco",
  state: "CA",
  country: "USA",
});
await cities.doc("LA").set({
  name: "Los Angeles",
  state: "CA",
  country: "USA"
});
await cities.doc("DC").set({
  name: "Washington, D.C.",
  state: null,
  country: "USA"
});
await cities.doc("TOK").set({
  name: "Tokyo",
  state: null,
  country: "Japan"
});

// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");

const rest1 = await sfRestaurants.add({
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
  name: "Bay Area Burger",
  type: "burger",
  owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
});

const rest4 = await laRestaurants.add({
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano"
});

const rest6 = await dcRestaurants.add({
  name: "Capitol Tacos",
  type: "mexican",
  owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
  name: "Georgetown Coffee",
  type: "cafe",
  owner_id: "David Kim"
});

// Load collection of reviews.
const reviews = db.collection("reviews");

await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ restaurant: rest7, rating: 5, reviewer_id: "Julia" });

Mencari Dokumen di Koleksi Lain

Kueri berikut pada grup koleksi reviews melakukan pencarian ke grup koleksi restaurant menggunakan referensi kunci utama.

Node.js

let results = await 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"))
  .execute();

Respons

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

Menggabungkan Beberapa Koleksi

Kueri berikut mengambil semua tempat pizza dari grup koleksi restaurants, dan menggunakan subkueri array untuk mengambil dan menyematkan ulasan terkaitnya langsung ke dalam respons.

Node.js

let results = await 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"))
  .execute();

Respons

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

Menggabungkan di Beberapa Koleksi

Kueri berikut pada grup koleksi restaurants menggunakan subkueri yang berkorelasi untuk mendapatkan rating rata-rata setiap restoran dari grup koleksi reviews.

Node.js

let results = await 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"))
  .execute();

Respons

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

N Teratas Per Grup (Subkueri dengan Batas)

Kueri berikut mengambil semua dokumen dari grup koleksi restaurants, dan menggunakan subkueri yang berkorelasi untuk mengambil 2 ulasan dengan rating tertinggi untuk setiap restoran.

Hal ini memastikan bahwa array ulasan tidak bertambah terlalu besar dan mencapai batas memori kueri.

Node.js

let results = await 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"))
  .execute();

Respons

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

Menggabungkan Subkoleksi

Kueri berikut memindai koleksi cities dan menggunakan tahap subcollection(...) untuk menggabungkan dokumen secara implisit dari koleksi bertingkat guna menemukan jumlah restoran per kota.

Node.js

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

Respons

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

Mengekspresikan Beberapa Kondisi Join

Kueri berikut memindai grup koleksi restaurants dan melakukan penggabungan multi-kolom dengan grup koleksi reviews untuk menemukan pemilik yang mengulas restoran mereka sendiri.

Node.js

let results = await 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))
  .execute();

Respons

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

Anti-Join (NOT EXISTS)

Kueri berikut memindai grup koleksi restaurants dan menemukan semua restoran yang belum memiliki ulasan.

Node.js

let results = await 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))
  .execute();

Respons

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

Subkueri sebagai Gabungan

Kueri berikut meratakan hubungan antara setiap restoran pizza dan ulasannya. Dengan menempatkan subkueri di dalam tahap unnest(...), server akan menduplikasi dokumen restoran luar untuk setiap ulasan yang cocok, sehingga menghasilkan dokumen gabungan yang datar (mirip dengan INNER JOIN SQL).

Node.js

let results = await 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"))
  .execute();

Respons

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

Subkueri Tidak Berkorelasi sebagai Filter

Kueri berikut pada koleksi reviews melakukan filter menggunakan subkueri yang tidak berkorelasi pada dirinya sendiri untuk menemukan ulasan yang lebih besar dari rating rata-rata.

Node.js

let results = await 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")
  .execute();

Respons

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

Praktik terbaik

  • Kelola memori dengan toArrayExpression(): Berhati-hatilah dengan subkueri toArrayExpression(), karena mewujudkan sejumlah besar dokumen dapat menghabiskan batas memori kueri (128 MiB). Untuk mengatasinya, gunakan select(...) dalam subkueri untuk menampilkan hanya kolom yang diperlukan dan terapkan filter where(...) untuk membatasi jumlah dokumen yang ditampilkan. Pertimbangkan untuk menggunakan limit(...) jika sesuai untuk membatasi jumlah dokumen yang ditampilkan oleh subkueri.
  • Pengindeksan: Pastikan kolom yang digunakan dalam klausa where(...) subkueri diindeks. Penggabungan yang berperforma tinggi mengandalkan kemampuan untuk melakukan pencarian indeks, bukan pemindaian tabel penuh.

Untuk praktik terbaik kueri lainnya, lihat panduan kami yang membahas pengoptimalan kueri.

Keterbatasan

  • Cakupan subcollection(...): Tahap input subcollection(...) hanya didukung dalam subkueri, karena memerlukan konteks dokumen induk untuk menyelesaikan hubungan hierarkis dan melakukan penggabungan.
  • Kedalaman Bertingkat: Subkueri dapat dibuat bertingkat hingga kedalaman 20 lapisan.
  • Penggunaan Memori: Batas 128 MiB untuk data terwujud berlaku di seluruh kueri, termasuk semua dokumen yang digabungkan.