Wykonywanie złączeń z podzapytaniami

Przegląd

Firestore Enterprise obsługuje złączenia w stylu relacyjnym za pomocą skorelowanych podzapytań. W przeciwieństwie do wielu baz danych NoSQL, które często wymagają denormalizacji danych lub wykonywania wielu żądań po stronie klienta, podzapytania umożliwiają łączenie i agregowanie danych z powiązanych kolekcji lub podkolekcji bezpośrednio na serwerze.

Podzapytania to wyrażenia, które wykonują zagnieżdżony potok dla każdego dokumentu przetwarzanego przez zapytanie zewnętrzne. Umożliwia to stosowanie złożonych wzorców pobierania danych, takich jak pobieranie dokumentu wraz z powiązanymi elementami podkolekcji lub łączenie logicznie powiązanych danych z różnych kolekcji głównych.

Pojęcia

W tej sekcji przedstawiamy podstawowe pojęcia związane z używaniem podzapytań do wykonywania operacji łączenia w operacjach potoku.

Podzapytania jako wyrażenia

Podzapytanie nie jest etapem najwyższego poziomu, lecz wyrażeniem, którego można użyć na dowolnym etapie akceptującym wyrażenia, takim jak select(...), add_fields(...), where(...) lub sort(...).

Cloud Firestore obsługuje 3 typy podzapytań:

  • Podzapytania tablicowe: materializują cały zbiór wyników podzapytania jako tablicę dokumentów.
  • Podzapytania skalarne: zwracają pojedynczą wartość, np. liczbę, średnią lub konkretne pole z powiązanego dokumentu.
  • subcollection(...)Podzapytania: uproszczone złączenia dla relacji jeden do wielu między elementem nadrzędnym a podrzędnym.

Zakres i zmienne

Podczas pisania złączenia zagnieżdżone podzapytanie często musi odwoływać się do pól z „zewnętrznego” dokumentu (rodzica). Aby połączyć te zakresy, użyj etapu let(...) (w niektórych pakietach SDK nazywanego define(...)), aby zdefiniować zmienne w zakresie nadrzędnym, do których można się odwoływać w podzapytaniu za pomocą funkcji variable(...).

Składnia

W sekcjach poniżej znajdziesz omówienie składni używanej do wykonywania złączeń.

Etap let(...)

Etap let(...) (w niektórych pakietach SDK nazywany define(...)) to etap niefiltrujący, który jawnie przenosi dane z zakresu nadrzędnego do nazwanej zmiennej do użycia w kolejnych zakresach zagnieżdżonych.

Podzapytania tablicowe

Podzapytanie tablicowe to szczególny przypadek podzapytania wyrażenia, które materializuje cały zbiór wyników podzapytania w tablicę. Jeśli podzapytanie nie zwraca żadnych wierszy, wynikiem jest pusta tablica. Nigdy nie zwraca tablicy null. Takie zapytania są przydatne, gdy w wyniku końcowym wymagane są pełne wyniki, np. podczas materializowania kolekcji zagnieżdżonej lub skorelowanej.

Zapytania mogą filtrować, sortować i agregować dane w podzapytaniu, aby zmniejszyć ilość danych, które trzeba pobrać i zwrócić, co pomaga obniżyć koszt zapytania. Kolejność podzapytania jest zachowywana, co oznacza, że etap sort(...) w podzapytaniu kontroluje kolejność wyników w ostatecznej tablicy.

Użyj kodu otaczającego pakietu SDK toArrayExpression(), aby przekonwertować zapytanie na tablicę.

Podzapytania skalarne

Podzapytania skalarne są często używane na etapie select(...) lub where(...), ponieważ umożliwiają filtrowanie lub zwracanie wyniku podzapytania bez bezpośredniej materializacji pełnego zapytania.

Podzapytanie skalarne, które nie zwraca żadnych wyników, zostanie ocenione jako null, natomiast podzapytanie, które zwraca wiele elementów, spowoduje błąd środowiska wykonawczego.

Gdy podzapytanie skalarne generuje tylko jedno pole na wynik, pole to jest podnoszone do wyniku najwyższego poziomu podzapytania. Najczęściej dzieje się tak, gdy podzapytanie kończy się na select(field("user_name")) lub aggregate(countAll().as("total")), a schemat podzapytania zawiera tylko jedno pole. W przeciwnym razie, gdy podzapytanie może wygenerować wiele pól, są one opakowane w mapę.

Użyj kodu opakowującego pakietu SDK toScalarExpression(), aby przekonwertować zapytanie na wyrażenie skalarne.

subcollection(...) podzapytania

Chociaż etap wejściowy subcollection(...) jest oferowany jako etap, umożliwia on wykonywanie złączeń w hierarchicznym modelu danych Cloud Firestore. W modelu hierarchicznym zapytania często muszą pobierać dokument wraz z danymi z jego podkolekcji. Możesz to osiągnąć, używając etapu wejściowego collection_group(...), a następnie filtra w odwołaniu nadrzędnym, ale subcollection(...) zapewnia znacznie bardziej zwięzłą składnię.

Poza niejawnym warunkiem łączenia działa to podobnie do podzapytania tablicy, zwracając pusty wynik, jeśli nie ma pasujących dokumentów, nawet jeśli zagnieżdżona kolekcja nie istnieje.

Jest to zasadniczo lukier składniowy: automatycznie używa pola __name__ dokumentu w zakresie zewnętrznym jako klucza złączenia, aby rozwiązać relację hierarchiczną. Dzięki temu jest to preferowany sposób wyszukiwania w kolekcjach połączonych relacją nadrzędny-podrzędny.

Przykłady

Przykładowe dane

Poniżej wczytujemy zestaw danych testowych, który będzie używany we wszystkich kolejnych przykładach.

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

Wyszukiwanie dokumentu w innej kolekcji

Poniższe zapytanie dotyczące grupy kolekcji reviews wykonuje wyszukiwanie w grupie kolekcji restaurant przy użyciu odwołania do klucza podstawowego.

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

Odpowiedź

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

Łączenie wielu kolekcji

Poniższe zapytanie pobiera wszystkie pizzerie z grupy kolekcji restaurants i używa podzapytania tablicowego, aby pobrać i umieścić powiązane z nimi opinie bezpośrednio w odpowiedzi.

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

Odpowiedź

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

Agregowanie danych z wielu kolekcji

To zapytanie dotyczące grupy kolekcji restaurants używa skorelowanego podzapytania, aby uzyskać średnią ocenę każdej restauracji z grupy kolekcji 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")));

Odpowiedź

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

Najlepsze N na grupę (podzapytanie z limitem)

Poniższe zapytanie pobiera wszystkie dokumenty z grupy kolekcji restaurants i używa skorelowanego podzapytania, aby pobrać 2 najwyżej oceniane opinie o każdej restauracji.

Dzięki temu tablica opinii nie będzie zbyt duża i nie przekroczy limitu pamięci zapytania.

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

Odpowiedź

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

Dołączanie podkolekcji

To zapytanie skanuje kolekcję cities i używa etapu subcollection(...), aby niejawnie połączyć dokumenty z kolekcji zagnieżdżonej i znaleźć liczbę restauracji w poszczególnych miastach.

Node.js

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

Odpowiedź

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

Wyrażanie wielu warunków łączenia

Poniższe zapytanie skanuje grupę kolekcji restaurants i wykonuje wielopolowe złączenie z grupą kolekcji reviews, aby znaleźć właścicieli, którzy oceniają własne restauracje.

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

Odpowiedź

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

Antyzłączenie (NOT EXISTS)

Poniższe zapytanie skanuje grupę kolekcji restaurants i znajduje wszystkie restauracje, które nie mają jeszcze opinii.

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

Odpowiedź

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

Podzapytanie jako złączenie

Poniższe zapytanie spłaszcza relację między każdą pizzerią a jej opiniami. Dzięki umieszczeniu podzapytania w etapie unnest(...) serwer duplikuje zewnętrzny dokument restauracji dla każdej pasującej opinii, tworząc płaskie, połączone dokumenty (podobne do 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")));

Odpowiedź

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

Nieskorelowane podzapytanie jako filtr

To zapytanie w kolekcji reviews filtruje dane za pomocą nieskorelowanego podzapytania, aby znaleźć opinie z oceną wyższą niż średnia.

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

Odpowiedź

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

Sprawdzone metody

  • Zarządzanie pamięcią za pomocą toArrayExpression(): zachowaj ostrożność podczas korzystania z podzapytań toArrayExpression(), ponieważ materializacja dużej liczby dokumentów może wyczerpać limit pamięci zapytania (128 MiB). Aby rozwiązać ten problem, użyj select(...) w podzapytaniu, aby zwracać tylko niezbędne pola, i zastosuj filtry where(...), aby ograniczyć liczbę zwracanych dokumentów. Rozważ użycie limit(...), aby ograniczyć liczbę dokumentów zwracanych przez podzapytanie.
  • Indeksowanie: upewnij się, że pola używane w klauzuli where(...) podzapytania są indeksowane. Wydajne złączenia opierają się na możliwości wyszukiwania indeksów, a nie pełnego skanowania tabeli.

Więcej sprawdzonych metod dotyczących zapytań znajdziesz w przewodniku po optymalizacji zapytań.

Ograniczenia

  • Zakres subcollection(...): etap wejściowy subcollection(...) jest obsługiwany tylko w podzapytaniach, ponieważ wymaga kontekstu dokumentu nadrzędnego, aby rozwiązać relację hierarchiczną i wykonać połączenie.
  • Głębokość zagnieżdżenia: podzapytania mogą być zagnieżdżone do 20 warstw.
  • Wykorzystanie pamięci: limit 128 MiB na dane zmaterializowane dotyczy całego zapytania, w tym wszystkich połączonych dokumentów.