Effectuer des jointures avec des sous-requêtes

Présentation

L'édition Firestore Enterprise est compatible avec les jointures de type relationnel via des 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 des données provenant 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 d'utiliser des modèles complexes de récupération de données, par exemple pour extraire un document avec les éléments de sa sous-collection associée ou pour joindre des données liées logiquement 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 d'une expression qui peut être utilisée dans n'importe quelle étape acceptant des expressions, telles que select(...), add_fields(...), where(...) ou sort(...).

Cloud Firestore est compatible avec trois types de sous-requêtes :

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

Champ d'application et variables

Lors de l'écriture d'une jointure, la sous-requête imbriquée doit souvent référencer des champs du document "externe" (le parent). Pour relier ces champs d'application, vous utilisez l'étape let(...) (appelée define(...) dans certains SDK) afin de définir des variables dans le champ d'application parent, 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 importe explicitement des données du champ d'application parent dans une variable nommée pour une utilisation dans des champs d'application imbriqués ultérieurs.

Sous-requêtes de tableau

Une sous-requête de tableau 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 sur 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(...), car elles permettent de filtrer ou de générer le 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 est évaluée sur null, tandis qu'une sous-requête qui est évaluée sur plusieurs éléments génère 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 aggregate(countAll().as("total")) où le schéma de la sous-requête n'est 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.

Sous-requêtes subcollection(...)

Bien qu'elle soit proposée en tant qu'étape, l' subcollection(...) étape d'entrée permet d'effectuer des jointures sur le modèle de données hiérarchique de Cloud Firestore. Dans un modèle hiérarchique, les requêtes doivent souvent récupérer un document avec les données de ses propres sous-collections. Bien que vous puissiez y parvenir à l'aide d'une collection_group(...) étape d'entrée 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, cela fonctionne 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__ de le 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 des collections lié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 proposant des pizzas du groupe de collections restaurants, puis utilise une sous-requête de tableau pour extraire et intégrer leurs 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éger des données dans 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
}

N premiers éléments par groupe (sous-requête avec limite)

La requête suivante extrait tous les documents du groupe de collections restaurants, puis utilise une sous-requête corrélée pour extraire 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 des 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 à plusieurs champs avec le groupe de collections reviews pour trouver les propriétaires qui évaluent 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 établissement proposant des pizzas 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 génère des documents joints plats (similaires à une jointure 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")));

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 filtres à 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érer la mémoire avec toArrayExpression(): soyez prudent avec toArrayExpression() sous-requêtes, car la matérialisation d'un grand nombre de documents peut épuiser la limite de mémoire de la requête (128 Mio). Pour éviter cela, 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(...) si nécessaire pour limiter le nombre de documents renvoyés par la sous-requête.
  • Indexation : assurez-vous que les champs utilisés dans la where(...) clause d'une sous-requête sont indexés. Les jointures performantes reposent sur la possibilité d'effectuer des recherches d'index plutôt que des analyses complètes de tables.

Pour en savoir plus sur les bonnes pratiques concernant les requêtes, consultez notre guide sur l'optimisation des requêtes.

Limites

  • subcollection(...) champ d'application : 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.
  • 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 joints.