gatsby-source-sanity icon indicating copy to clipboard operation
gatsby-source-sanity copied to clipboard

Create reverse relationships between documents

Open aaronadamsCA opened this issue 4 years ago • 8 comments

From the Gatsby docs:

It’s often convenient for querying to add to the schema backwards references. For example, you might want to query the author of a post, but you might also want to query all the posts an author has written.

This is exactly what I'm looking for. I'd like to see reverse relationships supported in this plugin, in particular for one-to-many relationships implemented using type: "reference" in the Sanity schema, so that I can query references in both directions.

I'd file a PR, but it's a bit beyond me where to start; I don't have TypeScript experience, and I'm not familiar enough with the internals of this plugin.

Some potentially helpful resources:

aaronadamsCA avatar Jul 01 '20 19:07 aaronadamsCA

@aaronadamsCA I was able to do this on Gastby by using the following code on gastby-node

// gastby-node.js

// create a resolver to add the inverse relationship between Author and Pinsels
// Author has many Pinsels
// Pinsel belongs to Author


exports.createResolvers = ({ createResolvers }) => {
  const resolvers = {
    SanityAuthor: {
      pinsels: {
        type: ["SanityPinsel"],
        resolve(source, args, context, info) {
          return context.nodeModel.runQuery({
            type: "SanityPinsel",
            query: {
              filter: {
                author: {
                  elemMatch: {
                    _id: {
                      eq: source._id,
                    },
                  },
                },
              },
            },
          })
        },
      },
    },
  }
  createResolvers(resolvers)
}

Then you can query like

export const query = graphql`
  query AuthorPageTemplateQuery($id: String!) {
    author: sanityAuthor(id: { eq: $id }) {
      name
      email
      pinsels {
        _id
      }
    }
  }
`

orlando avatar Nov 18 '20 05:11 orlando

Thanks @orlando, that is indeed what I'm doing now.

~~Note Gatsby still has a few core issues with using runQuery in resolvers, but there have been major improvements and continue to be. Most remaining issues are around Gatsby failing to detect new node relationships, so you need to gatsby clean between builds to pick up new relationships.~~ This is fixed now.

~~There's also a quirk where runQuery returns null instead of [] when there are no results; be sure to guard against this. It should be fixed in Gatsby v3, but it requires waiting for a major release since it's a significant API behaviour change.~~ This is also fixed now, sample code is updated.

Here is my approach. Note that I've found it's important to use createSchemaCustomization to avoid encountering race conditions as you begin to define more and more custom relationships:

exports.createSchemaCustomization = ({ actions }) => {
  actions.createTypes(`
    type SanityNeighbourhood implements Node @infer {
      stores: GatsbyStoreMiniConnection!
    }

    type GatsbyStoreMiniConnection @dontInfer {
      totalCount: Int!
      nodes: [SanityStore!]!
    }
  `);
};

exports.createResolvers = ({ createResolvers }) => {
  createResolvers({
    SanityNeighbourhood: {
      stores: {
        resolve: async ({ id }, _, { nodeModel }) =>
          nodeModel.runQuery({
            type: "SanityStore",
            query: {
              filter: {
                neighbourhoods: { elemMatch: { id: { eq: id } } },
              },
            },
          }),
      },
    },
    GatsbyStoreMiniConnection: {
      totalCount: {
        resolve: async (parent) => (await parent).length,
      },
      nodes: {
        resolve: (parent) => parent,
      },
    },
  });
};

This is still something I think is worth implementing in the plugin, of course; Sanity knows the relationship model, and a reverse connection pattern like this would be immensely valuable to adoption of the platform.

aaronadamsCA avatar Nov 19 '20 13:11 aaronadamsCA

I'm trying to do something similar with categories... (show other posts referencing the same category). I've generated category pages with all posts referencing the category… but really struggling from the other direction.

This post seemed like the answer, but a reference only contains the _key, _ref, and _type ...none of which matches the id. It may have worked in a past version of the API... https://nimblewebdeveloper.com/blog/gatsby-generate-related-posts-at-build-time

Question posted in Slack... https://sanity-io-land.slack.com/archives/CEMV34XAB/p1632951724115300

exports.createSchemaCustomization = ({ actions, schema }) => {
  actions.createTypes([
    schema.buildObjectType({
      name: "SanityPost",
      fields: {
        related: {
          type: "[SanityPost]",
          resolve: async (source, args, context, info) => {

            // the id of the referenced category is not available
            const categories = source.categories.map((c) => c._ref);
            if (!categories.length) return [];

            const posts = await context.nodeModel.runQuery({
              query: {
                filter: {
                  categories: {
                    elemMatch: {
                      _id: { in: categories },
                    },
                  },
                  _id: { ne: source._id },
                },
              },
              type: "SanityPost",
            });
            return posts && posts.length ? posts : [];
          },
        },
      },
    }),
  ]);
};

doublejosh avatar Sep 29 '21 20:09 doublejosh

SOLVED

Turns out the magic 🪄 is using _rawDataCategories rather than categories in the source object!

doublejosh avatar Sep 30 '21 01:09 doublejosh

hey @doublejosh I am also trying to achieve the same thing;

show other posts referencing the same category

but as you are aware, none of _key, _ref, _id matches so I am pretty stuck. Can you share a snippet of what you mean by using _rawDataCategories because I don‘t seem to have those in my GraphQL. Thank you!

ajmalafif avatar Oct 17 '21 15:10 ajmalafif

In case this is useful for anyone else attempting to create a reverse relationship

/***** CUSTOM DATA TYPES *******************************************************
 * 
 * FUTURE SELF
 * Purpose of the code below: Establish a foreign key relationship.
 *
 * Articles and Pages reference Tags. Put another way, Pages and Article can have
 * many tags.
 * 
 * Sanity source plugin automatically resolves this parent child relationship into the
 * Gatsby data layer. That enables you to query an Article or a Page and find out which
 * tags have been referenced.
 * 
 * However, you may want to do this in reverse. You may want to...
 * Query a tag for all of the pages that reference it.
 * 
 * This reverse reference is NOT automatically created by the Sanity Source Plugin.
 * So, you will need to create it yourself. I've described this verbosely for my future self
 * in the code below.
 * 
 * High Level Steps
 * 1. Creates a new field called "relatedContent" on the SanityTag type.
 * 2. Explicitly define the shape of the relatedContent data.
 * 3. Query all Articles and Pages that contain a reference to the Tag
 * 4. Add the data from Articles and Pages to the relatedContent field and return it.
 * 
 *********************************************************************************/

exports.createSchemaCustomization = ({ actions, schema }) => {
  const { 
    createTypes,
    printTypeDefinitions // uncomment this to print the existing type definitions in case you need to see them.
  } = actions
  
  // printTypeDefinitions({ path: "./typeDefs.txt" }) //uncomment to see type defs

  // Array of Type Definitions that you would like to create
  const typeDefs = [
    // Create a "relatedContent" type on the root type of "SanityTag"
    "type SanityTag implements Node { relatedContent: [RelatedContent]  }",
    // Define the "RelatedContent" type that was applied to the "relatedContent" key in the line above this one.
    `type RelatedContent {
      id: String!
      name: String!
      slug: String
    }`,
    
    // Now that you have the type, let's populate it with data, or "Resolve" the data.
    schema.buildObjectType({
      name: "SanityTag", // This is the "source" that is passed into the resolve function below
      fields: { 
        relatedContent: { // This is the field that we will resolve the data for. 
          type: ["RelatedContent"], // As we established above it is of type "RelatedContent" and it is an array.
          // The resolve function will return the data that populates the field
          // in this case we are populating the "relatedContent" field
          resolve: async (source, args, context, info) => { 
          
            /** 
             * context.nodeModel gives you access to query the data layer
             * Below, I'm finding all articles and all pages where
             * the SanityTag's "id" field value exists in the array of tags that
             *  referenced by the parent object.
             */

            // Find all articles (SanityArticle) that contain a reference to the
            // tag (SanityTag). Note that the ID field is used as the reference. Put more verbosely...
            // When an article references a tag, is uses the ID field of the tag.
            const articles = await context.nodeModel.findAll({
              type: "SanityArticle", // Find All SanityArticle's
              query: { // Where 
                filter: {
                  tags: { // The tags array
                    elemMatch: { // Has an element or object within the array
                      // who's id field matches the source id field.
                      // remember that the source is "SanityTag"                      
                      id: { eq: source.id },
                    },
                  },
                },
              },
            })


            // Same as above but find all pages
            const pages = await context.nodeModel.findAll({
              type: "SanityPage",
              query: {
                filter: {
                  tags: {
                    elemMatch: {
                      id: { eq: source.id },
                    },
                  },
                },
              },
            })
            
            /**
             * Now that we have all of the related pages and articles
             * let's create the data objects that will populate or resolve the data
             * 
             * I've explicitly set the shape of the data for the type.
             * Below we are creating the data objects for articles and pages separately
             * because the shape of articles and pages data is different.
             * 
             * pages and articles are arrays of objects.
             * We are using the Array.map function to create and populate a new object
             * in the shape of the RelatedContent type.
             */

            // Create the relatedArticles array
            const relatedArticles = articles.entries.map( entry => {
              return {
                id: entry?.id ?? 'no value',
                name: entry?.meta?.name ?? 'no value',
                slug: entry?.meta?.slug?.current ?? ''
              }
            })

            // Create the relatedPages array
            const relatedPages = pages.entries.map( entry => {
              return {
                id: entry.id,
                name: entry.name,
                slug: entry?.slug?.current ?? ''
              }
            })

            /**
             * We are finally ready to return the data that we would like
             * to have populate our new relatedArticles field
             */
            // Establish and empty entries array
            let entries = []
            // Use the Array.push method and the spread operator to add
            // relatedArticles and relatedPages to the entries array.
            entries.push(...relatedArticles, ...relatedPages)

            // Return the entries
            return entries
          },
        },
      },
    }),
  ]

  /**
   * The last step is to pass the typeDefs to the createTypes function
   * And like magic...
   * SanityTag now contains a relatedContent field which is an array of
   * all Articles and Pages that reference the tag.
   */
  createTypes(typeDefs)
}

Below is the query and result from the Graph QL Interface in Gatsby Dev. For sake of clarity. In the Sanity CMS...

  • There is a tag named "One" with a slug of "one"
  • Three articles reference the tag named "One"

The Query Get the tag named "One" and show me all of the pages and articles that reference this tag.

query MyQuery {
  allSanityTag(filter: {slug: {current: {eq: "one"}}}) {
    nodes {
      name
      id
      relatedContent {        
        name
        slug
      }      
    }
  }
}

The Response Here's the response showing the reverse relationship. Three content objects reference this tag.

{
  "data": {
    "allSanityTag": {
      "nodes": [
        {
          "name": "One",
          "relatedContent": [
            {
              "id": "-9dfe5f2e-4a10-5efe-8e9e-ede618873020",
              "name": "Hello World",
              "slug": "hello-world"
            },
            {
              "id": "-200f023f-b8ff-511b-b05f-543870a447bf",
              "name": "Extension BMI Calculator",
              "slug": "extension-bmi-calculator"
            },
            {
              "id": "-a8ad8fc7-d22e-5939-b110-f3ef337fd9f6",
              "name": "Home",
              "slug": "home"
            }
          ]
        }
      ]
    }
  },
  "extensions": {}
}

Now you can return the your Gatsby code and make some magic happen...

Hope this helps someone save a few minutes in their day. :)

paulwhitaker avatar Nov 04 '22 13:11 paulwhitaker

@ajmalafif

exports.createSchemaCustomization = ({ actions, schema }) => {
  actions.createTypes([
    // attach related posts to each post page
    schema.buildObjectType({
      interfaces: ["Node"],
      name: "SanityPost",
      fields: {
        relatedPosts: {
          type: "[SanityPost]",
          resolve: async (source, args, context, info) => {
            const categories = source._rawDataCategories
              ? source._rawDataCategories.map((c) => c._ref)
              : [];
            if (!categories.length) return [];

            // deprecated in Gatsby 5
            const posts = await context.nodeModel.runQuery({
              query: {
                filter: {
                  categories: {
                    elemMatch: {
                      _id: { in: categories },
                    },
                  },
                  status: { eq: "published" },
                  // exclude current node
                  _id: { ne: source._id },
                },
                sort: {
                  fields: ["publishedAt"],
                  order: ["DESC"],
                },
                // no way to limit results in runQuery
                // see: https://github.com/gatsbyjs/gatsby/issues/15453
              },
              type: "SanityPost",
            });

            return posts && posts.length > 0 ? posts : [];
          },
        },
      },
    }),
  ]);
};

doublejosh avatar Nov 15 '22 18:11 doublejosh

@doublejosh thank you so much for sharing this, really appreciate it!

ajmalafif avatar Nov 17 '22 17:11 ajmalafif