Joins mit Unterabfragen ausführen

Übersicht

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

Unterabfragen sind Ausdrücke, die für jedes von der äußeren Abfrage verarbeitete Dokument eine verschachtelte Pipeline ausführen. Dies ermöglicht komplexe Datenabrufmuster, z. B. das Abrufen eines Dokuments zusammen mit den zugehörigen Elementen der Untersammlung oder das Zusammenführen logisch verknüpfter 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 auf oberster Ebene, sondern ein Ausdruck, der in jeder Phase verwendet werden kann, die Ausdrücke akzeptiert, z. B. select(...), add_fields(...), where(...) oder sort(...).

Cloud Firestore unterstützt drei Arten von Unterabfragen:

  • Array-Unterabfragen: Die gesamte Ergebnismenge der Unterabfrage wird als Array von Dokumenten materialisiert.
  • Skalare Unterabfragen: Werden zu einem einzelnen Wert ausgewertet, z. B. zu einer Anzahl, einem Durchschnitt oder einem bestimmten Feld aus einem zugehörigen Dokument.
  • subcollection(...)-Unterabfragen: Vereinfachte Joins für eine 1:n-Beziehung zwischen übergeordneten und untergeordneten Elementen.

Gültigkeitsbereich und Variablen

Beim Schreiben eines Joins muss die verschachtelte Unterabfrage häufig auf Felder aus dem „äußeren“ Dokument (dem übergeordneten Dokument) verweisen. 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 finden Sie eine Übersicht über die Syntax zum Ausführen von Joins.

Die Phase let(...)

Die Phase let(...) (in einigen SDKs als define(...) bezeichnet) ist eine nicht filternde Phase, die Daten explizit aus dem übergeordneten Bereich in eine benannte Variable überträgt, um sie in nachfolgenden verschachtelten Bereichen zu verwenden.

Array-Unterabfragen

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

Abfragen können in der Unterabfrage filtern, sortieren und aggregieren, um die Menge der Daten zu reduzieren, die abgerufen und zurückgegeben werden müssen. So lassen sich die Kosten der Abfrage 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 SDK-Wrapper toArrayExpression(), um eine Abfrage in ein Array umzuwandeln.

Skalare Unterabfragen

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

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

Wenn eine skalare Unterabfrage nur ein einzelnes Feld pro Ergebnis erzeugt, wird das Feld auf das Ergebnis der obersten Ebene für die Unterabfrage erhöht. 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 ist. Andernfalls werden die Felder in einer Map zusammengefasst, wenn eine Unterabfrage mehrere Felder erzeugen kann.

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

subcollection(...)-Unterabfragen

Die Eingabephase subcollection(...) wird zwar als Phase angeboten, ermöglicht aber die Durchführung von Joins über das hierarchische Datenmodell von Cloud Firestore. In einem hierarchischen Modell müssen Abfragen häufig ein Dokument zusammen mit Daten aus den eigenen Untersammlungen abrufen. Sie können dies zwar mit einer collection_group(...)-Eingabephase und einem Filter für die übergeordnete Referenz erreichen, aber subcollection(...) bietet eine viel prägnantere Syntax.

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

Es handelt sich im Grunde um syntaktischen Zucker: Die __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 in einer über- und untergeordneten Beziehung verknüpft sind.

Beispiele

Beispieldaten

Im Folgenden wird ein Satz von Testdaten geladen, der in allen folgenden Beispielen verwendet wird.

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

Dokument in einer anderen Sammlung suchen

Die folgende Abfrage für die Sammlungsgruppe reviews führt eine Suche in der Sammlungsgruppe restaurant mithilfe einer Primärschlüsselreferenz durch.

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

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 Pizzarestaurants aus der Sammlungsgruppe restaurants abgerufen. Außerdem wird eine Array-Unterabfrage verwendet, um die zugehörigen Rezensionen abzurufen und direkt in die Antwort einzubetten.

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

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

Über mehrere Sammlungen hinweg aggregieren

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

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

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 zwei am besten bewerteten Rezensionen für jedes Restaurant abzurufen.

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

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

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

Untergeordnete Sammlungen zusammenführen

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

Node.js

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

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 ausdrücken

Mit der folgenden Abfrage wird die Sammlungsgruppe restaurants gescannt und ein Multi-Feld-Join mit der Sammlungsgruppe reviews durchgeführt, um Inhaber zu finden, die ihre eigenen Restaurants bewerten.

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

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 gescannt und alle Restaurants gefunden, die noch keine Rezensionen haben.

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

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 einer unnest(...)-Phase platzieren, dupliziert der Server das äußere Restaurantdokument für jede übereinstimmende Rezension und erzeugt flache, zusammengeführte Dokumente (ähnlich einem SQL-INNER JOIN).

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

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

Unkorrelierte Unterabfrage als Filter

Die folgende Abfrage für die Sammlung reviews führt Filter mit einer unkorrelierten Unterabfrage für sich selbst aus, um Rezensionen zu finden, die über der durchschnittlichen Bewertung liegen.

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

Antwort

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

Best Practices

  • Arbeitsspeicher mit toArrayExpression() verwalten: Seien Sie vorsichtig mit toArrayExpression()-Unterabfragen, da die Materialisierung einer großen Anzahl von Dokumenten das Speicherlimit für Abfragen (128 MiB) überschreiten kann. 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(...)-Klausel einer Unterabfrage verwendet werden, indexiert sind. Leistungsstarke Joins basieren auf der Möglichkeit, Indexsuchen 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 der Kontext eines übergeordneten Dokuments erforderlich ist, 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.