Effectuer des jointures avec des sous-requêtes

Présentation

L'édition Enterprise de Firestore est compatible avec les jointures de type relationnel via les sous-requêtes corrélées. Contrairement à de nombreuses bases de données NoSQL qui nécessitent souvent de dénormaliser les données ou d'effectuer plusieurs requêtes côté client, les sous-requêtes vous permettent de combiner et d'agréger les données de collections ou de sous-collections associées directement sur le serveur.

Les sous-requêtes sont des expressions qui exécutent un pipeline imbriqué pour chaque document traité par la requête externe. Cela permet des schémas de récupération de données complexes, comme l'extraction d'un document avec les éléments de sous-collection associés ou la jointure de données logiquement liées dans des collections racines disparates.

Concepts

Cette section présente les concepts de base liés à l'utilisation de sous-requêtes pour effectuer des jointures dans les opérations de pipeline.

Sous-requêtes en tant qu'expressions

Une sous-requête n'est pas une étape de premier niveau. Il s'agit plutôt d'une expression qui peut être utilisée dans n'importe quelle étape acceptant les expressions, comme select(...), add_fields(...), where(...) ou sort(...).

Cloud Firestore accepte trois types de sous-requêtes :

  • Sous-requêtes de tableau : matérialisez l'ensemble des résultats de la sous-requête sous forme de tableau de documents.
  • Sous-requêtes scalaires : elles renvoient une seule valeur, comme un nombre, une moyenne ou un champ spécifique d'un document associé.
  • subcollection(...) Sous-requêtes : jointures simplifiées pour une relation parent-enfant un à plusieurs.

Champ d'application et variables

Lorsque vous écrivez une jointure, la sous-requête imbriquée doit souvent faire référence à des champs du document "extérieur" (le parent). Pour relier ces portées, vous utilisez l'étape let(...) (appelée define(...) dans certains SDK) pour définir des variables dans la portée parente qui peuvent ensuite être référencées dans la sous-requête à l'aide de la fonction variable(...).

Syntaxe

Les sections suivantes présentent la syntaxe permettant d'effectuer des jointures.

Étape let(...)

L'étape let(...) (appelée define(...) dans certains SDK) est une étape sans filtrage qui transfère explicitement les données du champ parent dans une variable nommée pour une utilisation dans les champs imbriqués suivants.

Sous-requêtes de tableau

Une sous-requête de type ARRAY est un cas particulier de sous-requête d'expression qui matérialise l'ensemble des résultats de la sous-requête dans un tableau. Si la sous-requête ne renvoie aucune ligne, elle est évaluée comme un tableau vide. Elle ne renvoie jamais de tableau null. Ces requêtes sont utiles lorsque les résultats complets sont requis dans le résultat final, par exemple lors de la matérialisation d'une collection imbriquée ou corrélée.

Les requêtes peuvent filtrer, trier et agréger les données dans la sous-requête afin de réduire la quantité de données à extraire et à renvoyer, ce qui permet de réduire le coût de la requête. L'ordre de la sous-requête est respecté, ce qui signifie qu'une étape sort(...) dans la sous-requête contrôle l'ordre des résultats dans le tableau final.

Utilisez le wrapper SDK toArrayExpression() pour convertir une requête en tableau.

Sous-requêtes scalaires

Les sous-requêtes scalaires sont souvent utilisées dans une étape select(...) ou where(...) en tant que filtre autorisé ou résultat d'une sous-requête sans matérialiser directement la requête complète.

Une sous-requête scalaire qui ne produit aucun résultat sera évaluée à null elle-même, tandis qu'une sous-requête qui produit plusieurs éléments entraînera une erreur d'exécution.

Lorsqu'une sous-requête scalaire ne produit qu'un seul champ par résultat, le champ est élevé pour devenir le résultat de premier niveau de la sous-requête. Cela se produit le plus souvent lorsque la sous-requête se termine par un select(field("user_name")) ou un aggregate(countAll().as("total")) et que le schéma de la sous-requête ne comporte qu'un seul champ. Sinon, lorsqu'une sous-requête peut produire plusieurs champs, ils sont encapsulés dans une carte.

Utilisez le wrapper SDK toScalarExpression() pour convertir une requête en expression scalaire.

subcollection(...) Sous-requêtes

Bien qu'elle soit proposée en tant qu'étape, l'étape d'entrée subcollection(...) permet d'effectuer des jointures sur le modèle de données hiérarchiques de Cloud Firestore. Dans un modèle hiérarchique, les requêtes doivent souvent récupérer un document ainsi que les données de ses propres sous-collections. Bien que vous puissiez y parvenir à l'aide d'une étape d'entrée collection_group(...) suivie d'un filtre sur la référence parente, subcollection(...) offre une syntaxe beaucoup plus concise.

En dehors de la condition de jointure implicite, cette requête se comporte de la même manière qu'une sous-requête de tableau, en renvoyant un résultat vide si aucun document ne correspond, même si la collection imbriquée n'existe pas.

Il s'agit fondamentalement d'un sucre syntaxique : il utilise automatiquement le __name__ du document dans le champ d'application externe comme clé de jointure pour résoudre la relation hiérarchique. Il s'agit donc de la méthode privilégiée pour effectuer des recherches dans les collections associées dans une relation parent-enfant.

Exemples

Exemple de données

Le code suivant charge un ensemble de données de test à utiliser dans tous les exemples suivants.

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

Rechercher un document dans une autre collection

La requête suivante sur le groupe de collections reviews effectue une recherche dans le groupe de collections restaurant à l'aide d'une référence de clé primaire.

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

Réponse

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

Combiner plusieurs collections

La requête suivante extrait tous les établissements de vente de pizzas du groupe de collections restaurants et utilise une sous-requête de tableau pour extraire et intégrer les avis associés directement dans la réponse.

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

Réponse

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

Agrégation sur plusieurs collections

La requête suivante sur le groupe de collections restaurants utilise une sous-requête corrélée pour obtenir la note moyenne de chaque restaurant à partir du groupe de collections 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")));

Réponse

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

Top N par groupe (sous-requête avec limite)

La requête suivante récupère tous les documents du groupe de collections restaurants et utilise une sous-requête corrélée pour récupérer les deux avis les mieux notés pour chaque restaurant.

Cela permet de s'assurer que le tableau d'avis ne devient pas trop volumineux et n'atteint pas la limite de mémoire de la requête.

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

Réponse

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

Joindre des sous-collections

La requête suivante analyse la collection cities et utilise l'étape subcollection(...) pour joindre implicitement les documents d'une collection imbriquée afin de trouver le nombre de restaurants par ville.

Node.js

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

Réponse

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

Exprimer plusieurs conditions de jointure

La requête suivante analyse le groupe de collections restaurants et effectue une jointure multifield avec le groupe de collections reviews pour trouver les propriétaires qui examinent leurs propres restaurants.

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

Réponse

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

Anti-jointure (NOT EXISTS)

La requête suivante analyse le groupe de collections restaurants et recherche tous les restaurants qui n'ont pas encore d'avis.

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

Réponse

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

Sous-requête en tant que jointure

La requête suivante aplatit la relation entre chaque pizzeria et ses avis. En plaçant la sous-requête dans une étape unnest(...), le serveur duplique le document de restaurant externe pour chaque avis correspondant, ce qui produit des documents plats et joints (semblables à un INNER JOIN SQL).

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

Réponse

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

Sous-requête non corrélée en tant que filtre

La requête suivante sur la collection reviews effectue des filtrages à l'aide d'une sous-requête non corrélée sur elle-même pour trouver les avis dont la note est supérieure à la moyenne.

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

Réponse

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

Bonnes pratiques

  • Gérez la mémoire avec toArrayExpression() : soyez prudent avec les sous-requêtes toArrayExpression(), car la matérialisation d'un grand nombre de documents peut épuiser la limite de mémoire des requêtes (128 Mio). Pour atténuer ce problème, utilisez select(...) dans la sous-requête afin de ne renvoyer que les champs nécessaires et appliquez des filtres where(...) pour limiter le nombre de documents renvoyés. Envisagez d'utiliser limit(...), le cas échéant, pour limiter le nombre de documents renvoyés par la sous-requête.
  • Indexation : assurez-vous que les champs utilisés dans la clause where(...) d'une sous-requête sont indexés. Les jointures performantes reposent sur la capacité à effectuer des recherches d'index plutôt que des analyses complètes de tables.

Pour découvrir d'autres bonnes pratiques concernant les requêtes, consultez notre guide sur l'optimisation des requêtes.

Limites

  • Champ d'application subcollection(...) : l'étape d'entrée subcollection(...) n'est compatible qu'avec les sous-requêtes, car elle nécessite le contexte d'un document parent pour résoudre la relation hiérarchique et effectuer la jointure.
  • Profondeur d'imbrication : les sous-requêtes peuvent être imbriquées jusqu'à 20 niveaux de profondeur.
  • Utilisation de la mémoire : la limite de 128 Mio pour les données matérialisées s'applique à l'ensemble de la requête, y compris à tous les documents associés.