federation icon indicating copy to clipboard operation
federation copied to clipboard

New semantics to avoid "prop drilling" with overloaded keys

Open lennyburdette opened this issue 2 years ago • 6 comments

This is inspired from a customer use case. They're doing server-driven UI and have a subgraph for returning "blocks" of user interface elements. The goal is avoiding the "data" subgraphs from knowing anything about "presentational" concerns, but this is challenging when we need to thread presentational context through a hierarchy of blocks.

Example

Notice that we have to add template { id } to entity keys in both subgraphs. This is the "leak" between the data and presentational boundaries.

  • Experience subgraph
    • Returns a list of complex "page blocks" like product carousels. The blocks are associated with a data concern like a "product category".
    • It also returns a list of "component blocks" with "leaf" UI elements.
    • The leaf elements must know about a "template context" to determine the exact order and structure of a component. This should be strictly a presentational concern, but it leaks into the keys and other subgraphs types.
  • Data subgraph
    • This subgraph should care only about Product data and listing/pagination types. But the only way to "thread" the template context from the "page blocks" through the products data into the "leaf blocks" is to overload the keys with the template data.
    • I structured the SDL to nicely group the presentational concerns. But the real problem is that the resolvers have to forward the data from the category to the edge, which is easy to mess up.
Example operation
query HomePage($url: String!) {
  page(url: $url) {
    __typename
    ...ProductCategoryBlockFragment
  }
}

fragment ProductCategoryBlockFragment on ProductCategoryBlock {
  category {
    id
    products {
      edges {
        blocks {
          __typename
          ...ProductCardBlockFragment
        }
      }
    }
  }
}

fragment ProductCardBlockFragment on ProductCardBlock {
  __typename
  ...ProductCardTitleFragment
  ...ProductCardDescriptionFragment
  ...ProductCardImageFragment
  ...ProductCardPriceFragment
}

fragment ProductCardTitleFragment on ProductCardTitle {
  title
}
fragment ProductCardDescriptionFragment on ProductCardDescription {
  description
}
fragment ProductCardImageFragment on ProductCardImage {
  imageUrl
}
fragment ProductCardPriceFragment on ProductCardPrice {
  priceFormatted
}
Example query plan
QueryPlan {
  Sequence {
    Fetch(service: "experience") {
      {
        page(url: $url) {
          __typename
          ... on ProductCategoryBlock {
            category {
              __typename
              id
              template {
                id
              }
            }
          }
        }
      }
    },
    Flatten(path: "[email protected]") {
      Fetch(service: "data") {
        {
          ... on ProductCategory {
            __typename
            id
            template {
              id
            }
          }
        } =>
        {
          ... on ProductCategory {
            products {
              edges {
                __typename
                node {
                  id
                }
                template {
                  id
                }
              }
            }
          }
        }
      },
    },
    Flatten(path: "[email protected].@") {
      Fetch(service: "experience") {
        {
          ... on ProductEdge {
            __typename
            node {
              id
            }
            template {
              id
            }
          }
        } =>
        {
          ... on ProductEdge {
            blocks {
              __typename
              ... on ProductCardTitle {
                __typename
                product {
                  __typename
                  id
                }
              }
              ... on ProductCardDescription {
                __typename
                product {
                  __typename
                  id
                }
              }
              ... on ProductCardImage {
                __typename
                product {
                  __typename
                  id
                }
              }
              ... on ProductCardPrice {
                __typename
                product {
                  __typename
                  id
                }
              }
            }
          }
        }
      },
    },
    Flatten(path: "[email protected][email protected][email protected]") {
      Fetch(service: "data") {
        {
          ... on Product {
            __typename
            id
          }
        } =>
        {
          ... on Product {
            name
            description
            imageUrl
            price {
              value
              currency
            }
          }
        }
      },
    },
    Flatten(path: "[email protected][email protected].@") {
      Fetch(service: "experience") {
        {
          ... on ProductCardTitle {
            __typename
            product {
              name
              id
            }
          }
          ... on ProductCardDescription {
            __typename
            product {
              description
              id
            }
          }
          ... on ProductCardImage {
            __typename
            product {
              imageUrl
              id
            }
          }
          ... on ProductCardPrice {
            __typename
            product {
              price {
                value
                currency
              }
              id
            }
          }
        } =>
        {
          ... on ProductCardTitle {
            title
          }
          ... on ProductCardDescription {
            description
          }
          ... on ProductCardImage {
            imageUrl
          }
          ... on ProductCardPrice {
            priceFormatted
          }
        }
      },
    },
  },
}

Prop drilling vs setContext

This feels conceptually similar to "prop drilling" in React components. React has a concept of "context", state that is local to a tree of components. A parent component sets the context, and a descendent component can retrieve the context without the intermediary components knowing anything about it.

A Federation version of "context" might solve this elegantly. A directive for setting the context when an entity is resolved, and another for injecting the context into descendent fields might look like:

# 1. the query planner adds the context fields to the selection set, like it does with keys
# 2. it then stores some internal state mapping the context object 
#    ({ template: { id: "template-1" } }) to the keyed entity
type ProductCategoryBlock 
  @key(fields: "category { id }") 
  @setContext(name: "template", from: "template { id }") {
  category: ProductCategory!
  template: Template! @inaccessible
}

# 3. When the query planner needs to resolve the `blocks` field, it knows which 
#    `ProductCategoryBlock` this entity descends from, and can inject the context 
#    object from the internal state
type ProductEdge @key(fields: "node { id }") {
  node: Product!
  blocks(
    template: Template @fromContext(name: "template")
  ): [ProductCardBlock!]!
}

I tried hacking this together using coprocessors and it was much harder than it seemed. The particularly nasty part is that entities are deduplicated. If I have:

  • One ProductCategoryBlock with template-1, which contains products P1, P2, P3
  • Another ProductCategoryBlock with template-2, which contains product P2, P3, P4

The query planner flattens and batches the ProductEdge entities into a list of P1, P2, P3, P4. We've lost track of the association with the ProductCategoryBlocks, so we can't call ProductEdge.blocks with different template arguments. (This is avoided when overloading keys because the ProductEdge entities are unique by template.) The query planner must keep this mapping internally so that it can appropriately nest the edge objects within the block objects.

I bet there's another way to do this that we haven't thought of so I wanted to write this down and start the discussion!

lennyburdette avatar May 05 '23 16:05 lennyburdette

Exploring other use cases for context — this one is interesting but I'm less sure about it.

query FeaturedBookDetails {
  featuredBook {
    title
    author {
      name
      # I want a list of books that _does not_ include the featured book
      otherBooks {
        title
      }
    }
  }
}

Proposed solution using "context"

# books subgraph
type Book @key(fields: "id") @setContext(name: "bookFilter", from: "bookFilter { notEqual }") {
  id: ID!
  title: String
  bookFilter: BookFilter @inaccessible # { notEqual: [this.id] }
  author: Author
}

type Author @key(fields: "id") {
  id: ID!
}

# authors subgraph
type Author @key(fields: "id") {
  id: ID!

  # this argument is nullable. if called outside of a 
  # `Book -> author -> otherBooks` chain, it will just return the author's books.
  # (if not "inaccessible" it could also just be public API)
  otherBooks(filter: BookFilter @fromContext(name: "bookFilter")): BookConnection
}

The following alternatives are possible today, and they don't seem that bad. But they quickly become onerous if there are any intermediary layers necessitating more key stuffing or extraneous fields.

Key stuffing alternative solution
# books subgraph
type Book @key(fields: "id") {
  id: ID!
  title: String
  author: Author
}

type Author @key(fields: "id relevantBook { id }") {
  id: ID!
  relevantBook: Book @inaccessible
}

# authors subgraph
type Author @key(fields: "id relevantBook { id }") {
  id: ID!
  relevantBook: Book
  otherBooks: BookConnection
}
`@requires` alternative solution
# books subgraph
type Book @key(fields: "id") {
  id: ID!
  title: String
  author: Author
}

type Author @key(fields: "id") {
  id: ID!
  relevantBook: Book @inaccessible
}

# authors subgraph
type Author @key(fields: "id") {
  id: ID!
  relevantBook: Book @external
  otherBooks: BookConnection @requires(fields: "relevantBook { id }")
}
`@requires` alternative solution no. 2 (non-functional)
# books subgraph
type Book @key(fields: "id") {
  id: ID!
  title: String
  author: Author
  # this doesn't work when pagination is involved!
  otherBooksByAuthor: BookConnection @requires(fields: "author { books { edges { nodes { id } } } }")
}

type Author @key(fields: "id") {
  id: ID!
  books: BookConnection @external
}

# authors subgraph
type Author @key(fields: "id") {
  id: ID!
  books: BookBookConnection
}

lennyburdette avatar May 08 '23 14:05 lennyburdette

+1

Experiencing a similar issue with a customer and their experience graph proof of concept.

The use-case involves presentational context being provided by the client determination of the constraints on the device and platform (100s of different varieties), which lines similarly to the use-case mentioned in your second post (homepage(client: clientContext) {}). However in federation world, key stuffing is leaking presentational boundaries to other domains schemas and requires this pattern to be replicated across further nested entities. Thus, we've settled on header context propagation, and will explore a Rhai script or body extension approach driven by the experience subgraph.

brh55 avatar Jul 13 '23 14:07 brh55

Let's also consider the idea of the @export directive which could help with some of these issues. https://github.com/graphql/graphql-spec/issues/377

timbotnik avatar Jul 14 '23 00:07 timbotnik

I wanted to comment that a simpler option, while not declarative and not following any spec (and terrible to manage), is to use response extensions on subgraph responses and have a Router script/coprocessor to forward the extensions.context object to all subgraph requests

smyrick avatar Aug 07 '23 18:08 smyrick

@smyrick Part of the goal is for the context to be limited to a specific branch of resolving data cross subgraph as not all branches are guaranteed to need the same context (otherwise headers could just be used). I think that could be achieved with what you’re describing, but it would be a nightmare to manage. That being said, custom directives could probably be developed to make this extensions idea more declarative and simpler to manage at the subgraph level.

I do think this is a fairly large gap that federation can and should solve.

gwardwell avatar Aug 07 '23 19:08 gwardwell

@gwardwell Totally agree, this would be a perfect fit for the query planner. I wanted to just add some possible workarounds for those "blocked" today

smyrick avatar Aug 07 '23 21:08 smyrick

This feature request is resolved by the @context / @fromContext directives released in Federation 2.8.

clenfest avatar Jun 10 '24 19:06 clenfest