Effectuer des jointures avec des sous-requêtes

Arrière-plan

Les opérations de pipeline constituent une nouvelle interface de requête pour Cloud Firestore. Cette interface fournit des fonctionnalités de requête avancées, y compris des expressions complexes. L'édition Firestore Enterprise est compatible avec les jointures de style 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'obtenir des modèles de récupération de données complexes, par exemple en récupérant un document avec les éléments de sa sous-collection associée ou en joignant 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 plutôt 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 à 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, 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.

Web

    async function defineStageData() {
      await setDoc(doc(collection(db, "Authors"), "author_123"), {
        "id": "author_123",
        "name": "Jane Austen"
      });
    }
    
Swift
      func defineStageData() async throws {
      try await db.collection("authors").document("author_123").setData([
        "id": "author_123",
        "name": "Jane Austen"
      ])
    }
    

Kotlin

    fun defineStageData() {
        val author = hashMapOf(
            "id" to "author_123",
            "name" to "Jane Austen",
        )

        db.collection("Authors").document("author_123").set(author)
    }
  

Java

    public void defineStageData() {
        Map<String, Object> author = new HashMap<>();
        author.put("id", "author_123");
        author.put("name", "Jane Austen");

        db.collection("Authors").document("author_123").set(author);
    }
  

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 à 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 dans la sous-requête afin de réduire la quantité de données à récupérer 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.

Web

    async function toArrayExpressionStageData() {
      await setDoc(doc(collection(db, "Projects"), "project_1"), {
        "id": "project_1",
        "name": "Alpha Build"
      });
      await addDoc(collection(db, "Tasks"), {
        "project_id": "project_1",
        "title": "System Architecture"
      });
      await addDoc(collection(db, "Tasks"), {
        "project_id": "project_1",
        "title": "Database Schema Design"
      });
    }
  

Réponse

    {
        id: "project_1",
        name: "Alpha Build",
        taskTitles: [
          "System Architecture", "Database Schema Design"
      ]
    }
    
Swift
    async function toArrayExpressionStageData() {
      await setDoc(doc(collection(db, "Projects"), "project_1"), {
        "id": "project_1",
        "name": "Alpha Build"
      });
      await addDoc(collection(db, "Tasks"), {
        "project_id": "project_1",
        "title": "System Architecture"
      });
      await addDoc(collection(db, "Tasks"), {
        "project_id": "project_1",
        "title": "Database Schema Design"
      });
    }
    

Réponse

    {
      id: "project_1",
      name: "Alpha Build",
      taskTitles: [
        "System Architecture", "Database Schema Design"
      ]
    }
    

Kotlin

    fun toArrayExpressionData() {
        val project = hashMapOf(
            "id" to "project_1",
            "name" to "Alpha Build",
        )
        db.collection("Projects").document("project_1").set(project)

        val task1 = hashMapOf(
            "project_id" to "project_1",
            "title" to "System Architecture",
        )
        db.collection("Tasks").add(task1)

        val task2 = hashMapOf(
            "project_id" to "project_1",
            "title" to "Database Schema Design",
        )
        db.collection("Tasks").add(task2)
    }
  

Réponse

    {
      id: "project_1",
      name: "Alpha Build",
      taskTitles: [
        "System Architecture", "Database Schema Design"
      ]
    }
    

Java

      public void toArrayExpressionData() {
        Map<String, Object> project = new HashMap<>();
        project.put("id", "project_1");
        project.put("name", "Alpha Build");
        db.collection("Projects").document("project_1").set(project);

        Map<String, Object> task1 = new HashMap<>();
        task1.put("project_id", "project_1");
        task1.put("title", "System Architecture");
        db.collection("Tasks").add(task1);

        Map<String, Object> task2 = new HashMap<>();
        task2.put("project_id", "project_1");
        task2.put("title", "Database Schema Design");
        db.collection("Tasks").add(task2);
    }
  

Réponse

    {
        id: "project_1",
        name: "Alpha Build",
        taskTitles: [
          "System Architecture", "Database Schema Design"
      ]
    }
    

Sous-requêtes scalaires

Les sous-requêtes scalaires sont souvent utilisées dans une étape select(...) ou where(...) pour permettre 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 à null, tandis qu'une sous-requête qui est évaluée à 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.

Web

            async function toScalarExpressionStageData() {
              await setDoc(doc(collection(db, "Authors"), "author_202"), {
                "id": "author_202",
                "name": "Charles Dickens"
              });
              await addDoc(collection(db, "Books"), {
                "author_id": "author_202",
                "title": "Great Expectations",
                "rating": 4.8
              });
              await addDoc(collection(db, "Books"), {
                "author_id": "author_202",
                "title": "Oliver Twist",
                "rating": 4.5
              });
            }
        

Réponse

        {
            "id": "author_202",
            "name": "Charles Dickens",
            "averageBookRating": 4.65
        }
      
Swift
        try await db.collection("authors").document("author_202").setData([
          "id": "author_202",
          "name": "Charles Dickens"
        ])
        try await db.collection("books").document().setData([
          "author_id": "author_202",
          "title": "Great Expectations",
          "rating": 4.8
        ])
        try await db.collection("books").document().setData([
          "author_id": "author_202",
          "title": "Oliver Twist",
          "rating": 4.5
        ])
        

Réponse

        {
            "id": "author_202",
            "name": "Charles Dickens",
            "averageBookRating": 4.65
        }
      

Kotlin

        fun toScalarExpressionData() {
        val author = hashMapOf(
            "id" to "author_202",
            "name" to "Charles Dickens",
        )
        db.collection("Authors").document("author_202").set(author)

        val book1 = hashMapOf(
            "author_id" to "author_202",
            "title" to "Great Expectations",
            "rating" to 4.8,
        )
        db.collection("Books").add(book1)

        val book2 = hashMapOf(
            "author_id" to "author_202",
            "title" to "Oliver Twist",
            "rating" to 4.5,
        )
        db.collection("Books").add(book2)
    }
  

Réponse

        {
            "id": "author_202",
            "name": "Charles Dickens",
            "averageBookRating": 4.65
        }
      

Java

       public void toScalarExpressionData() {
        Map<String, Object> author = new HashMap<>();
        author.put("id", "author_202");
        author.put("name", "Charles Dickens");
        db.collection("Authors").document("author_202").set(author);

        Map<String, Object> book1 = new HashMap<>();
        book1.put("author_id", "author_202");
        book1.put("title", "Great Expectations");
        book1.put("rating", 4.8);
        db.collection("Books").add(book1);

        Map<String, Object> book2 = new HashMap<>();
        book2.put("author_id", "author_202");
        book2.put("title", "Oliver Twist");
        book2.put("rating", 4.5);
        db.collection("Books").add(book2);
    }
  

Réponse

        {
            "id": "author_202",
            "name": "Charles Dickens",
            "averageBookRating": 4.65
        }
      

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 parent, subcollection(...) fournit une syntaxe beaucoup plus concise.

Outre 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 les collections liées dans une relation parent-enfant.

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 clause where(...) 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 de table complètes.

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 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 joints.