Wykonywanie złączeń z podzapytaniami

Przegląd

Wersja Enterprise Firestore obsługuje złączenia w stylu relacyjnym za pomocą podzapytań skorelowanych. 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 w różnych kolekcjach głównych.

Pojęcia

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

Podzapytania jako wyrażenia

Podzapytanie nie jest etapem najwyższego poziomu. Jest to wyrażenie, którego można używać na każdym etapie akceptującym wyrażenia, np. 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.
  • Podzapytania subcollection(...): uproszczone złączenia dla 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 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 kolejnych sekcjach znajdziesz omówienie składni 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, aby można było ich używać w kolejnych zakresach zagnieżdżonych.

Podzapytania tablicowe

Podzapytanie tablicowe to szczególny przypadek podzapytania wyrażeniowego, które materializuje cały zbiór wyników podzapytania w tablicy. Jeśli podzapytanie zwraca 0 wierszy, jego 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 tablicy końcowej.

Aby przekonwertować zapytanie na tablicę, użyj otoki pakietu SDK toArrayExpression().

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, samo w sobie będzie miało wartość null, natomiast podzapytanie, które zwraca wiele elementów, spowoduje błąd wykonania.

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

Aby przekonwertować zapytanie na wyrażenie skalarne, użyj otoki pakietu SDK toScalarExpression().

Podzapytania subcollection(...)

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 podkolekcji. Możesz to zrobić za pomocą etapu wejściowego collection_group(...), a następnie filtra odwołania nadrzędnego, subcollection(...) zapewnia znacznie bardziej zwięzłą składnię.

Poza niejawnym warunkiem złączenia działa to podobnie jak podzapytanie tablicowe, zwracając pusty wynik, jeśli nie zostaną znalezione żadne dokumenty, nawet jeśli kolekcja zagnieżdżona nie istnieje.

Jest to zasadniczo cukier syntaktyczny: automatycznie używa __name__ dokumentu w zakresie zewnętrznym jako klucza złączenia do rozwiązywania relacji hierarchicznej. Dzięki temu 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 wyszukuje dane 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

Poniższe zapytanie pobiera wszystkie pizzerie z grupy kolekcji restaurants i używa podzapytania tablicowego 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

Poniższe zapytanie w grupie 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
}

N najlepszych w każdej grupie (podzapytanie z limitem)

Poniższe zapytanie pobiera wszystkie dokumenty z grupy kolekcji restaurants i używa skorelowanego podzapytania, aby pobrać 2 najlepsze 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" }
  ]
}

Łączenie podkolekcji

Poniższe zapytanie skanuje kolekcję cities i używa etapu subcollection(...), aby niejawnie połączyć dokumenty z kolekcji zagnieżdżonej 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

Poniższe zapytanie skanuje grupę kolekcji restaurants i wykonuje złączenie wielopolowe 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"
}

Złączenie wykluczające (NOT EXISTS)

Poniższe zapytanie skanuje grupę kolekcji restaurants i znajduje wszystkie restauracje, które nie mają jeszcze żadnych 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 (podobnie jak w przypadku 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

Poniższe zapytanie w kolekcji reviews filtruje dane za pomocą nieskorelowanego podzapytania w tej samej kolekcji, aby znaleźć opinie o ocenie wyższej 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 toArrayExpression() podzapytań, ponieważ materializowanie dużej liczby dokumentów może wyczerpać limit pamięci zapytania (128 MiB). Aby temu zapobiec, użyj select(...) w podzapytaniu, aby zwracać tylko niezbędne pola, i zastosuj filtry where(...) , aby ograniczyć liczbę zwracanych dokumentów. W razie potrzeby rozważ użycie 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 indeksów, a nie pełnego skanowania tabel.

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

Ograniczenia

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