Joins mit Unterabfragen ausführen

Übersicht

Die Firestore Enterprise-Version unterstützt Joins im relationalen Stil über korrelierte Unterabfragen. Im Gegensatz zu vielen NoSQL-Datenbanken, bei denen häufig Daten denormalisiert oder mehrere clientseitige Anfragen ausgeführt werden müssen, können Sie mit Unterabfragen Daten aus verknüpften Sammlungen oder Untersammlungen direkt auf dem Server kombinieren und aggregieren.

Unterabfragen sind Ausdrücke, mit denen für jedes von der äußeren Abfrage verarbeitete Dokument eine verschachtelte Pipeline ausgeführt wird. Dies ermöglicht komplexe Muster für den Datenabruf, z. B. das Abrufen eines Dokuments zusammen mit den zugehörigen Untersammlungselementen oder das Verknüpfen von logisch verknüpften Daten aus verschiedenen Stammsammlungen.

Konzepte

In diesem Abschnitt werden die grundlegenden Konzepte für die Verwendung von Unterabfragen zum Ausführen von Joins in Pipelinevorgängen vorgestellt.

Unterabfragen als Ausdrücke

Eine Unterabfrage ist keine Phase der obersten Ebene, sondern ein Ausdruck, der in jeder Phase verwendet werden kann, in der Ausdrücke zulässig sind, z. B. select(...), add_fields(...), where(...) oder sort(...).

Cloud Firestore unterstützt drei Arten von Unterabfragen:

  • Array-Unterabfragen:Das gesamte Ergebnis-Set der Unterabfrage wird als Array von Dokumenten materialisiert.
  • Skalare Unterabfragen:Sie ergeben einen einzelnen Wert, z. B. eine Anzahl, einen Durchschnitt oder ein bestimmtes Feld aus einem zugehörigen Dokument.
  • subcollection(...) Unterabfragen: vereinfachte Joins für eine 1:n-Beziehung zwischen über- und untergeordneten Elementen.

Umfang und Variablen

Beim Schreiben eines Joins muss in der verschachtelten Unterabfrage häufig auf Felder aus dem „äußeren“ Dokument (dem übergeordneten Element) verwiesen werden. Um diese Bereiche zu überbrücken, verwenden Sie die Phase let(...) (in einigen SDKs als define(...) bezeichnet), um Variablen im übergeordneten Bereich zu definieren, auf die dann in der Unterabfrage mit der Funktion variable(...) verwiesen werden kann.

Syntax

In den folgenden Abschnitten erhalten Sie einen Überblick über die Syntax für Joins.

Die let(...)-Phase

Die Phase let(...) (in einigen SDKs als define(...) bezeichnet) ist eine Phase ohne Filterung, in der Daten aus dem übergeordneten Bereich explizit in eine benannte Variable für die Verwendung in nachfolgenden verschachtelten Bereichen übertragen werden.

Array-Teilabfragen

Eine Array-Unterabfrage ist ein Sonderfall der Ausdruck-Unterabfrage, bei der das gesamte Ergebnis der Unterabfrage in einem Array materialisiert wird. Wenn die Unterabfrage keine Zeilen zurückgibt, wird sie als leeres Array ausgewertet. Gibt niemals ein null-Array zurück. Solche Abfragen sind nützlich, wenn die vollständigen Ergebnisse im Endergebnis erforderlich sind, z. B. beim Materialisieren einer verschachtelten oder korrelierten Sammlung.

Mit Abfragen kann in der Unterabfrage gefiltert, sortiert und aggregiert werden, um die Menge der abzurufenden und zurückzugebenden Daten zu reduzieren und so die Kosten der Abfrage zu senken. Die Reihenfolge der Unterabfrage wird berücksichtigt. Das bedeutet, dass eine sort(...)-Phase in der Unterabfrage die Reihenfolge der Ergebnisse im endgültigen Array steuert.

Verwenden Sie den toArrayExpression()-SDK-Wrapper, um eine Abfrage in ein Array zu konvertieren.

Skalare Unterabfragen

Skalare Unterabfragen werden häufig in einer select(...)- oder where(...)-Phase verwendet, um das Filtern oder das Ergebnis einer Unterabfrage zu ermöglichen, ohne die vollständige Abfrage direkt zu materialisieren.

Eine skalare Unterabfrage, die keine Ergebnisse liefert, wird zu null ausgewertet. Eine Unterabfrage, die mehrere Elemente liefert, führt zu einem Laufzeitfehler.

Wenn eine skalare Unterabfrage nur ein Feld pro Ergebnis erzeugt, wird das Feld hochgestuft, um das Ergebnis der obersten Ebene für die Unterabfrage zu sein. Dies ist am häufigsten der Fall, wenn die Unterabfrage mit select(field("user_name")) oder aggregate(countAll().as("total")) endet und das Schema der Unterabfrage nur ein einzelnes Feld enthält. Andernfalls werden mehrere Felder, die von einer Unterabfrage zurückgegeben werden können, in einer Map zusammengefasst.

Verwenden Sie den toScalarExpression()-SDK-Wrapper, um eine Abfrage in einen skalaren Ausdruck zu konvertieren.

subcollection(...) Unterabfragen

Obwohl sie als Phase angeboten wird, ermöglicht die Eingabephase subcollection(...) das Ausführen von Joins über das hierarchische Datenmodell von Cloud Firestore. In einem hierarchischen Modell müssen mit Abfragen oft ein Dokument und Daten aus den zugehörigen Unterkollektionen abgerufen werden. Sie können dies zwar mit einer collection_group(...)-Eingabephase und einem anschließenden Filter für die übergeordnete Referenz erreichen, aber subcollection(...) bietet eine viel prägnantere Syntax.

Abgesehen von der impliziten Join-Bedingung verhält sich diese Funktion ähnlich wie eine Array-Unterabfrage. Sie gibt ein leeres Ergebnis zurück, wenn keine Dokumente gefunden werden, auch wenn die verschachtelte Sammlung nicht vorhanden ist.

Es handelt sich im Grunde um syntaktischen Zucker: Der __name__ des Dokuments im äußeren Bereich wird automatisch als Join-Schlüssel verwendet, um die hierarchische Beziehung aufzulösen. Daher ist dies die bevorzugte Methode für Suchvorgänge in Sammlungen, die über eine über- und untergeordnete Beziehung verknüpft sind.

Beispiele

Beispieldaten

Im Folgenden werden Testdaten geladen, die in allen nachfolgenden Beispielen verwendet werden.

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

Dokument in einer anderen Sammlung suchen

Die folgende Abfrage für die Sammlungsgruppe reviews führt eine Suche in der Sammlungsgruppe restaurant mit einem Primärschlüsselverweis durch.

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

Antwort

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

Mehrere Sammlungen kombinieren

Mit der folgenden Abfrage werden alle Pizzerien aus der Sammlungsgruppe restaurants abgerufen. Mit einer Array-Unterabfrage werden die zugehörigen Rezensionen abgerufen und direkt in die Antwort eingebettet.

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

Antwort

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

Daten aus mehreren Sammlungen zusammenfassen

Die folgende Abfrage für die Sammlungsgruppe restaurants verwendet eine korrelierte Unterabfrage, um die durchschnittliche Bewertung für jedes Restaurant aus der Sammlungsgruppe reviews abzurufen.

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

Antwort

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

Top-N pro Gruppe (Unterabfrage mit Limit)

Mit der folgenden Abfrage werden alle Dokumente aus der Sammlungsgruppe restaurants abgerufen. Außerdem wird eine korrelierte Unterabfrage verwendet, um die beiden am besten bewerteten Rezensionen für jedes Restaurant abzurufen.

So wird verhindert, dass das Array mit Rezensionen zu groß wird und das Speicherlimit der Abfrage erreicht.

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

Antwort

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

Untersammlungen zusammenführen

In der folgenden Abfrage wird die Sammlung cities gescannt und mit der Phase subcollection(...) werden Dokumente aus einer verschachtelten Sammlung implizit verknüpft, um die Anzahl der Restaurants pro Stadt zu ermitteln.

Node.js

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

Antwort

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

Mehrere Join-Bedingungen angeben

Mit der folgenden Abfrage wird die Sammlungsgruppe restaurants gescannt und ein Join mit mehreren Feldern mit der Sammlungsgruppe reviews ausgeführt, um Inhaber zu finden, die ihre eigenen Restaurants bewerten.

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

Antwort

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

Anti-Join (NOT EXISTS)

Mit der folgenden Abfrage wird die Sammlungsgruppe restaurants durchsucht und alle Restaurants ohne Bewertungen werden gefunden.

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

Antwort

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

Unterabfrage als Join

Mit der folgenden Abfrage wird die Beziehung zwischen den einzelnen Pizzerien und ihren Rezensionen vereinfacht. Wenn Sie die Unterabfrage in eine unnest(...)-Phase einfügen, dupliziert der Server das äußere Restaurantdokument für jede übereinstimmende Rezension. So werden flache, verknüpfte Dokumente erstellt (ähnlich wie bei einem 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")));

Antwort

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

Nicht korrelierte Unterabfrage als Filter

Die folgende Abfrage für die reviews-Sammlung führt Filter mithilfe einer nicht korrelierten Unterabfrage für sich selbst aus, um Rezensionen mit einer Bewertung über dem Durchschnitt zu finden.

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

Antwort

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

Best Practices

  • Speicher mit toArrayExpression() verwalten:Seien Sie vorsichtig mit toArrayExpression()-Unterabfragen, da die Materialisierung einer großen Anzahl von Dokumenten das Abfragespeicherlimit (128 MiB) überschreiten kann. Um dieses Problem zu beheben, verwenden Sie select(...) in der Unterabfrage, um nur die erforderlichen Felder zurückzugeben, und wenden Sie where(...)-Filter an, um die Anzahl der zurückgegebenen Dokumente zu begrenzen. Verwenden Sie gegebenenfalls limit(...), um die Anzahl der von der Unterabfrage zurückgegebenen Dokumente zu begrenzen.
  • Indexierung:Achten Sie darauf, dass Felder, die in der where(...)-Anweisung einer Unterabfrage verwendet werden, indexiert sind. Leistungsstarke Joins basieren auf der Möglichkeit, Indexsuchvorgänge anstelle von vollständigen Tabellenscans durchzuführen.

Weitere Best Practices für Abfragen finden Sie in unserem Leitfaden zur Abfrageoptimierung.

Beschränkungen

  • subcollection(...)-Bereich:Die Eingabephase subcollection(...) wird nur in Unterabfragen unterstützt, da sie den Kontext eines übergeordneten Dokuments erfordert, um die hierarchische Beziehung aufzulösen und den Join auszuführen.
  • Verschachtelungstiefe:Unterabfragen können bis zu 20 Ebenen tief verschachtelt werden.
  • Arbeitsspeichernutzung:Das Limit von 128 MiB für materialisierte Daten gilt für die gesamte Abfrage, einschließlich aller verknüpften Dokumente.