Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Creating an @export GraphQL directive

11 min read 3259

Creating An @export GraphQL Directive

The goal for an @export directive in GraphQL is to export the value of a field (or set of fields) into a variable we can use somewhere else in the query.

Its most evident use case is to combine two queries into one, which eliminates the need for the second query to wait for the first to execute, thus improving performance. There is a proposal to add it to the GraphQL spec through issue [RFC] Dynamic variable declaration.

How would this directive be employed? Let’s suppose we want to search all posts that mention the name of the logged-in user. Normally, we would need two queries to accomplish this.

We first retrieve the user’s name:

query GetLoggedInUserName {
  me {
    name
  }
}

And then, having executed the first query, we can pass the retrieved user’s name as variable $search to perform the search in a second query:

query GetPostsContainingString($search: String = "") {
  posts(search: $search) {
    id
    title
  }
}

The @export directive would allow us to export the value from a field and inject this value into a second field through a dynamic variable, whose name is defined under argument as, thus combining the two queries into one:

query GetPostsContainingLoggedInUserName($search: String = "") {
  me {
    name @export(as: "search")
  }
  posts(search: $search) {
    id
    title
  }
}

Since being created, this issue has not been upgraded from the strawman stage because it is not simple to implement. In particular, graphql-js (the reference implementation of GraphQL in JavaScript) resolves fields in parallel through promises, so it doesn’t know in advance which field will be resolved before which other field.

This is a blocking problem for @export, because the field exporting the value must be executed before the field reading the value. As a consequence, this directive can only be implemented in GraphQL servers that allow us to control the flow in which fields are resolved, or which support query batching.

GraphQL by PoP is a GraphQL server in PHP which, luckily for this case, doesn’t use promises or resolve fields in parallel; it resolves them sequentially, using a deterministic order that the developer can manipulate in the query itself. So, it can support the implementation of the @export directive.

Because this directive could eventually become part of the spec, this specific solution must strive to be as generic as possible, using only features already present in the GraphQL spec, as to provide a model for other GraphQL servers to follow, and as to not deviate too much from an eventual official solution so it can be adapted with minimal rewriting.

In this article I’ll explain how this particular solution for @export works and how it was designed.

Examining how the directive could be used

As we saw in the query above, @export must handle exporting a single value from a single field: the user’s name.

Fields returning lists should also be exportable. For instance, in the query below, the exported value is the list of names from the logged-in user’s friends (hence, the type of the $search variable went from String to [String]):

query GetPostsContainingLoggedInUserName($search: [String] = []) {
  me {
    friends {
      name @export(as: "search")
    }
  }
  posts(searchAny: $search) {
    id
    title
  }
}

Please notice that argument "as" is also used to define the dynamic variable’s name to export lists, even though the original proposal suggests two different arguments: "as" for a single value, and "into" for lists. However, I believe that "as" is suitable for both cases and makes matters simple, so I stuck to it.

Even though it is not requested in the original proposal, in some circumstances we may also need to export several properties from the same object. Then, @export should allow us to export these properties to the same variable, as a dictionary of values.

For instance, the query could export both the name and surname fields from the user and have a searchByAnyProperty input that receives a dictionary (for which the input type changed to Map):

query GetPostsContainingLoggedInUserName($search: Map = {}) {
  me {
    name @export(as: "search")
    surname @export(as: "search")
  }
  posts(searchByAnyProperty: $search) {
    id
    title
  }
}

And then, similar to upgrading from a single value to a list of values, we can upgrade from a single dictionary to a list of dictionaries.

For instance, we could export fields name and surname from the list of the logged-in user’s friends (for which the input type changed to [Map]):

query GetPostsContainingLoggedInUserName($search: [Map] = []) {
  me {
    friends {
      name @export(as: "search")
      surname @export(as: "search")
    }
  }
  posts(searchAnyByAnyProperty: $search) {
    id
    title
  }
}

In summary, @export must be able to handle these four cases:

  1. Exporting a single value from a single field
  2. Exporting a list of values from a single field
  3. Exporting a dictionary of values, containing several fields from the same object
  4. Exporting a list of a dictionary of values, with each dictionary containing several fields from the same object

Implementation

The queries below have replaced the field me with user(id: 1) since, otherwise, non-logged-in users couldn’t run it. Later on, we’ll review why the variable name starts with _, why the query uses a field called self, and why the input has a default value even though it is never used.

The implementation for @export, handling all four cases detailed above, is this one. Let’s see how it behaves.

Executing the first case

The query below extracts the user’s name into variable $_authorName and then performs a search of all posts containing this string:

query GetPostsAuthorNames($_authorName: String = "") {
  user(id: 1) {
    name @export(as: "_authorName")
  }
  self {
    posts(searchfor: $_authorName) {
      id
      title
    }
  }
}

When running the query, it produces this response:

Running the first query
Running the first query.

It works: obtaining the user’s name "leo", and searching for all posts containing the keyword "leo", was performed within the same query.

Executing all four cases

This query comprises the four cases that @export must handle:

query GetSomeData(
  $_firstPostTitle: String = "",
  $_postTitles: [String] = [],
  $_firstPostData: Mixed = {},
  $_postData: [Mixed] = []
) {
  post(id: 1) {
    title @export(as:"_firstPostTitle")
    title @export(as:"_firstPostData")
    date @export(as:"_firstPostData")
  }
  posts(limit: 2) {
    title @export(as:"_postTitles")
    title @export(as:"_postData")
    date @export(as:"_postData")
  }
  self {
    _firstPostTitle: echoVar(variable: $_firstPostTitle)
    _postTitles: echoVar(variable: $_postTitles)
    _firstPostData: echoVar(variable: $_firstPostData)
    _postData: echoVar(variable: $_postData)
  }
}

Here’s how it works:

Case 1@export a single value:

post(id: 1) {
  title @export(as: "_firstPostTitle")
}

Case 2@export a list of values:

posts(limit: 2) {
  title @export(as: "_postTitles")
}

Case 3@export a dictionary of field/value, containing two properties (title and date) from the same object:

post(id: 1) {
  title @export(as: "_firstPostData")
  date @export(as: "_firstPostData")
}

Case 4@export a list of dictionaries of field/value:

posts(limit: 2) {
  title @export(as: "_postData")
  date @export(as: "_postData")
}

The query uses the field echoVar to visualize the content of the dynamic variables. When running the query, it produces this response:

Running the second query
Running the second query.

Once again, it works, as the response contains the results handling all four cases:

Case 1@export a single value:

{
  "data": {
    "self": {
      "_firstPostTitle": "Hello world!"
    }
  }
}

Case 2@export a list of values:

{
  "data": {
    "self": {
      "_postTitles": [
        "Scheduled by Leo",
        "COPE with WordPress: Post demo containing plenty of blocks"
      ]
    }
  }
}

Case 3@export a dictionary of field/value, containing two properties (title and date) from the same object:

{
  "data": {
    "self": {
      "_firstPostData": {
        "title": "Hello world!",
        "date": "August 2, 2019"
      }
    }
  }
}

Case 4@export a list of dictionaries of field/value:

{
  "data": {
    "self": {
      "_postData": [
        {
          "title": "Scheduled by Leo",
          "date": "January 1, 2020"
        },
        {
          "title": "COPE with WordPress: Post demo containing plenty of blocks",
          "date": "August 8, 2019"
        }
      ]
    }
  }
}

Gotchas of this solution

Nothing is perfect. In order for @export to work, the query must be coded with the following three peculiarities:

  1. The name of the dynamic variable must start with "_"
  2. The field self may be required to control the order in which fields are resolved
  3. The dynamic variable must be declared as a static (i.e., “normal”) variable in the operation name, and always receive a default value

I’ll explain why these are mandatory and how they work, one by one.

1. The name of the dynamic variable must start with "_"

As we mentioned earlier on, the @export directive is not part of the GraphQL spec, so there are no considerations on the language itself for its implementation. Therefore, the GraphQL server implementers must find their own way to satisfy their requirements without deviating too much from the GraphQL syntax, with the expectation that it could one day become part of the official solution.

For this solution, I decided that @export will export the value into a normal variable, accessible as $variable. Please notice that this is a design decision that may vary across implementers — for instance, Apollo’s @export directive is accessed under entry exportVariables (as doing {exportVariables.id}), not under entry args as its inputs.

As a consequence, Apollo’s solution doesn’t require us to declare the exported variables in the operation name, but my implementation does.

This design decision is challenging to implement because static variables and dynamic variables behave differently: while the value for a static variable can be determined when parsing the query, the value for a dynamic variable must be determined on runtime, right when reading the value of the variable. Then, the GraphQL engine must be able to tell which way to treat a variable, whether the static or the dynamic way.

Given the constraints, and in order to avoid introducing new, unsupported syntax into the query (such as having $staticVariables and %dynamicVariables%), the solution I found is to have the dynamic variable name start with "_", such as $_dynamicVariable. Then, if the name of the variable starts with "_", the GraphQL engine treats it as dynamic and doesn’t resolve it when parsing the query; otherwise, it is treated as a static variable.



This solution is undoubtedly a hack, but it works well. If eventually there is an official solution for the @export directive, and it provides guidelines on how to distinguish between dynamic and static variables, then my solution must be adapted.

2. Field self may be required to control the order in which fields are resolved

The @export directive would not work if reading the variable takes place before exporting the value into the variable. Hence, the engine needs to provide a way to control the field execution order. This is the issue that graphql-js cannot easily solve, with the consequence that @export cannot be officially supported by the GraphQL spec.

As mentioned earlier, GraphQL by PoP does provide a way to manipulate the field execution order through the query itself. Let’s see how it works.

N.B.: I have described in detail how the GraphQL engine resolves fields and loads their data in the articles “Designing a GraphQL server for optimal performance” and “Simplifying the GraphQL data model“.

The engine loads data in iterations for each type, resolving all fields from the first type it encounters in the query, then resolving all fields from the second type it encounters in the query, and so on until there are no more types to process.

For instance, the following query involving objects of type Director, Film and Actor:

{
  directors {
    name
    films {
      title
      actors {
        name
      }
    }
  }
}

Is resolved by the GraphQL engine in this order:

Dealing with types in iterations
Dealing with types in iterations.

If after processed, a type is referenced again in the query to retrieve non-loaded data (e.g., from additional objects or additional fields from previously loaded objects), then the type is added again at the end of the iteration list.

For instance, let’s say we also query the Actor‘s preferredDirector field (which returns an object of type Director), like this:

{
  directors {
    name
    films {
      title
      actors {
        name
        preferredDirector {
          name
        }
      }
    }
  }
}

The GraphQL engine would then process the query in this order:

Repeated types in iterations
Repeated types in iterations.

Let’s see how this plays out for executing @export. For our first attempt, we create the query as we normally would, without considering the execution order of the fields:

query GetPostsAuthorNames($_authorName: String = "") {
  user(id: 1) {
    name @export(as: "_authorName")
  }
  posts(searchfor: $_authorName) {
    id
    title
  }
}

When running the query, it produces this response:

Executing a query using a variable
Executing a query using a variable.

Which contains the following error:

{
  "errors": [
    {
      "message": "Expression '_authorName' is undefined",
    }
  ]
}

This error means that by the time the variable $_authorName was read, it had not yet been set — it was undefined.

Let’s see why this happens. First, we analyze what types appear in the query, added as comments below (type ID is a custom scalar):

# Type: Root
query GetPostsAuthorNames($_authorName: String = "") {
  # Type: User
  user(id: 1) {
    # Type: String
    name @export(as: "_authorName")
  }
  # Type: Post
  posts(searchfor: $_authorName) {
    # Type: ID
    id
    # Type: String
    title
  }
}

To process the types and load their data, the data-loading engine adds the query type Root into a FIFO (First-In, First-Out) list, thus making [Root] the initial list passed to the algorithm. Then it iterates over the types sequentially, like this:

#
Operation
List
0
Prepare FIFO list
[Root]
1a
Pop the first type of the list (Root)
[]
1b
Process all fields queried from the Root type:
  → user(id: 1)
  → posts(searchfor: $_authorName)
Add their types (User and Post) to the list
[User, Post]
2a
Pop the first type of the list (User)
[Post]
2b
Process the field queried from the User type:
  → name @export(as: "_authorName")
Because it is a scalar type (String), there is no need to add it to the list
[Post]
3a
Pop the first type of the list (Post)
[]
3b
Process all fields queried from the Post type:
  → id
  → title
Because these are scalar types (ID and String), there is no need to add them to the list
[]
4
List is empty, iteration ends

Here we can see the problem: @export is executed on step 2b, but it was read on step 1b.

It is here that we need to control the field execution flow. The solution I have implemented is to delay when the exported variable is read, achieved by artificially querying for field self from type Root.

The self field, as its name indicates, returns the same object; applied to the Root object, it returns the same Root object. You may wonder: “If I already have the root object, then why would I need to retrieve it again?”

Because then the GraphQL engine’s algorithm will need to add this new reference to Root at the end of the FIFO list, and we can deliberately distribute the queried fields before or after every one of these iterations.

This is a hack, but it allows us to effectively control the order in which fields are resolved.

That’s why the field posts(searchfor: $_authorName) is placed inside a self field in the query above, and running the query produces the expected response:

query GetPostsAuthorNames($_authorName: String = "") {
  user(id: 1) {
    name @export(as: "_authorName")
  }
  self {
    posts(searchfor: $_authorName) {
      id
      title
    }
  }
}
Running the first query
Running the first query.

Let’s explore the order in which types are processed for this query to understand why it works well:

#
Operation
List
0
Prepare FIFO list
[Root]
1a
Pop the first type of the list (Root)
[]
1b
Process all fields queried from the Root type:
  → user(id: 1)
  → self
Add their types (User and Root) to the list
[User, Root]
2a
Pop the first type of the list (User)
[Root]
2b
Process the field queried from the User type:
  → name @export(as: "_authorName")
Because it is a scalar type (String), there is no need to add it to the list
[Root]
3a
Pop the first type of the list (Root)
[]
3b
Process the field queried from the Root type:
  → posts(searchfor: $_authorName)
Add its type (Post) to the list
[Post]
4a
Pop the first type of the list (Post)
[]
4b
Process all fields queried from the Post type:
  → id
  → title
Because these are scalar types (ID and String), there is no need to add them to the list
[]
5
List is empty, iteration ends

Now, we can see that the problem has been resolved: @export is executed on step 2b, and it is read on step 3b.

3. The dynamic variable must be declared as a static variable in the operation name, and always receive a default value

The GraphQL parser still treats a dynamic variable as a variable, hence it validates that it has been defined, and that it has a value on parsing time, or it throws an error "The variable has not been set".

To avoid this error (which halts execution of the query), we must always define the variable in the operation name, and provide a default value for that argument, even if this value won’t be used.

Bonus: Making @skip and @include dynamic

I believe that, in some areas, GraphQL currently falls short of its real potential. That is the case concerning the @skip and @include directives, as in this query:

query GetPostTitleAndMaybeExcerpt(
  $showExcerpt: Bool!
) {
  post(id: 1) {
    id
    title
    excerpt @include(if: $showExcerpt)
  }
}

These directives receive the condition to evaluate through the argument "if", which can only be the actual Boolean value (true or false) or a variable with the Boolean value ($showExcerpt). As behaviors go, this is pretty static.

What about executing the "if" condition based on some property from the object itself? For instance, we may want to show the excerpt field based on whether the Post object has comments.

Well, the @export directive makes this possible. For this query:

query ShowExcerptIfPostHasComments(
  $id: ID!,
  $_hasComments: Boolean = false
) {
  post(id: $id) {
    hasComments @export(as: "_hasComments")
  }
  self {
    post(id: $id) {
      title
      excerpt @include(if: $_hasComments)
    }
  }
}

The response would or would not include the field excerpt, depending on whether the queried post had comments.

Let’s check it out. Running the query for post with ID 1 produces this response:

Query response, which includes the excerpt field
The response includes the excerpt field.

Running the query for post with ID 1499 produces this response:

The response does not include the excerpt field
The response does not include the excerpt field.

As we can see, @include became dynamic: the same query produces different results based on some property from the queried object itself, and not from an external variable.

This feature works only when the exported variable — in this case, $_hasComments — concerns a single value, but not for lists. This is because the algorithm evaluates the if condition for all objects in the list in the same iteration, overriding each other; then, when this result is checked to perform the @skip/@include validation in some later iteration from the algorithm, only the value from the last object in the list will be available.

Conclusion

In my article GraphQL directives are underrated, I expressed that offering good support for custom directives is possibly the most important feature to pay attention to in a GraphQL server.

The @export directive provides a case in point. Because this directive can improve the performance of our APIs, the community has requested it be added to the GraphQL spec, but support for it is stalled due to technical difficulties. Until an official solution is found, however, we can attempt to code this functionality through a custom directive — so long as the GraphQL server allows us to.

In this article, we explored the implementation of @export done for GraphQL by PoP, including the design decisions taken to make the solution as generic as possible, what issues were found along the way, and what compromises (or hacks) were needed to resolve them.

Keep reading more

This article is part of an ongoing series on conceptualizing, designing, and implementing a GraphQL server. The previous articles from the series are:

  1. Designing a GraphQL server for optimal performance
  2. Simplifying the GraphQL data model
  3. Schema-first vs. code-first development in GraphQL
  4. Speeding up changes to the GraphQL schema
  5. Versioning fields in GraphQL
  6. GraphQL directives are underrated
  7. Treating GraphQL directives as middleware

 

Monitor failed and slow GraphQL requests in production

While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Leave a Reply