Wykonywanie złączeń z podzapytaniami

Przegląd

Wersja Firestore Enterprise obsługuje złączenia w stylu relacyjnym za pomocą powiązanych 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 złożone wzorce pobierania danych, takie 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 koncepcje związane z używaniem podzapytań do wykonywania złączeń w operacjach potoku.

Podzapytania jako wyrażenia

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

Cloud Firestore obsługuje 3 typy zapytań podrzędnych:

  • Podzapytania tablicowe: zmaterializuj 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 w relacji nadrzędny-podrzędny typu jeden do wielu.

Zakres i zmienne

Podczas pisania złączenia zagnieżdżone podzapytanie często musi odwoływać się do pól z dokumentu „zewnętrznego” (nadrzędnego). Aby połączyć te zakresy, użyj etapu let(...) (w niektórych pakietach SDK oznaczanego jako 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 wykonywania złączeń.

Etap let(...)

Etap let(...) (w niektórych pakietach SDK określany jako define(...)) to etap bez filtrowania, który jawnie przenosi dane z zakresu nadrzędnego do nazwanej zmiennej, aby można było ich używać w kolejnych zagnieżdżonych zakresach.

Podzapytania tablicowe

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

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 tablicy końcowej.

Użyj otoki pakietu SDK toArrayExpression(), aby przekształcić zapytanie w 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średniego materializowania pełnego zapytania.

Podzapytanie skalarne, które nie zwraca żadnych wyników, będzie miało wartość null, natomiast podzapytanie, które zwraca wiele elementów, spowoduje błąd w czasie działania.

Gdy podzapytanie skalarne zwraca tylko jedno pole na wynik, pole jest podnoszone do poziomu wyniku najwyższego poziomu dla podzapytania. Najczęściej występuje to, 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 generować wiele pól, są one zawijane w mapę.

Użyj toScalarExpression() otoki pakietu SDK, aby przekształcić zapytanie w wyrażenie skalarne.

subcollection(...) Podzapytania

Etap wejściowy subcollection(...) umożliwia wykonywanie złączeń w hierarchicznym modelu danych Cloud Firestore. W modelu hierarchicznym zapytania często muszą pobierać dokument wraz z danymi z jego podzbiorów. Możesz to osiągnąć, używając etapu wejściowego collection_group(...), a następnie filtra odwołującego się do elementu nadrzędnego, ale subcollection(...) zapewnia znacznie bardziej zwięzłą składnię.

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

Jest to w zasadzie syntaktyczny cukier: automatycznie używa __name__ dokumentu w zakresie zewnętrznym jako klucza łączenia, aby rozwiązać relację hierarchiczną. Dlatego jest to preferowany sposób wyszukiwania w kolekcjach połączonych relacją nadrzędny-podrzędny.

Przykłady

Przykładowe dane

Poniższy kod wczytuje zestaw danych testowych, które będą używane 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 w grupie kolekcji reviews wykonuje wyszukiwanie w grupie kolekcji restaurant za pomocą 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

To zapytanie pobiera wszystkie pizzerie z grupy kolekcji restaurants i używa podzapytania tablicy do pobierania i osadzania powiązanych opinii 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 w wielu kolekcjach

To zapytanie dotyczące grupy kolekcji restaurants używa powiązanego 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
}

N najlepszych wyników w grupie (podzapytanie z limitem)

To zapytanie pobiera wszystkie dokumenty z restaurants grupy kolekcji i używa powiązanego podzapytania, aby pobrać 2 najwyżej ocenione 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 do podkolekcji

Poniższe zapytanie skanuje kolekcję cities i używa etapu subcollection(...), aby niejawnie połączyć dokumenty z zagnieżdżonej kolekcji i znaleźć liczbę restauracji w każdym mieście.

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 złączenia

To zapytanie skanuje grupę kolekcji restaurants i wykonuje złączenie wielu pól z grupą kolekcji reviews, aby znaleźć właścicieli, którzy sprawdzają 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"
}

Anti-Join (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. Umieszczając podzapytanie 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" }
}

Podzapytanie nieskorelowane jako filtr

To zapytanie w kolekcji reviews wykonuje filtrowanie za pomocą nieskorelowanego podzapytania w tej samej kolekcji, 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ść w przypadku podzapytań toArrayExpression(), ponieważ zmaterializowanie dużej liczby dokumentów może wyczerpać limit pamięci zapytania (128 MiB). Aby temu zapobiec, użyj w podzapytaniu funkcji select(...), aby zwracać tylko niezbędne pola, i zastosuj filtry where(...), aby ograniczyć liczbę zwracanych dokumentów. W razie potrzeby użyj limit(...), aby ograniczyć liczbę dokumentów zwracanych przez podzapytanie.
  • Indeksowanie: sprawdź, czy pola używane w klauzuli where(...) podzapytania są indeksowane. Wydajne złączenia opierają się na możliwości wyszukiwania w indeksie, a nie na pełnym skanowaniu tabeli.

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

Ograniczenia

  • subcollection(...) zakres: etap wejściowy subcollection(...) jest obsługiwany tylko w podzapytaniach, ponieważ wymaga kontekstu dokumentu nadrzędnego do rozwiązania relacji hierarchicznej i wykonania złączenia.
  • Poziom zagnieżdżenia: podzapytania mogą być zagnieżdżone do 20 poziomów.
  • Wykorzystanie pamięci: limit 128 MiB na zmaterializowane dane obowiązuje w przypadku całego zapytania, w tym wszystkich połączonych dokumentów.