Docs
Launch GraphOS Studio
Since 3.8.0

Document transforms

Make custom modifications to your GraphQL documents


This article assumes you're familiar with the anatomy of a GraphQL query and the concept of an abstract syntax tree (AST). To explore a AST, visit AST Explorer.

Have you noticed that modifies your queries—such as adding the __typename —before sending those queries to your ? It does this through document transforms, functions that modify before execution.

provides an advanced capability that lets you define your own transforms to modify your GraphQL queries. This article explains how to make and use custom GraphQL document transforms.

Overview

transforms allow you to programmatically modify documents used to data in your application. A GraphQL document is an AST that defines one or more and , parsed from a raw string using the gql function. You can create your own transforms using the DocumentTransform class. The created transform is then passed to the ApolloClient constructor.

import { DocumentTransform } from '@apollo/client';
const documentTransform = new DocumentTransform((document) => {
// modify the document
return transformedDocument;
});
const client = new ApolloClient({
documentTransform
});

Lifecycle

runs transforms before every request for all . This extends to any API that performs a network request, such as the useQuery hook or the refetch function on ObservableQuery.

transforms are run early in the request's lifecycle. This makes it possible for the cache to see modifications to documents—an essential distinction from document modifications made to GraphQL documents in an Apollo Link. Since transforms are run early in the request lifecycle, this makes it possible to add @client to in your transform to turn the field into a local-only field, or to add selections for fragments defined in the fragment registry.

Interacting with built-in transforms

ships with built-in transforms that are essential to the client's functionality.

  • The __typename is added to every selection set in a to identify the type of all objects returned by the .
  • that use defined in the fragment registry are added to the before the request is sent over the network (requires 3.7 or later).

It's crucial for custom transforms to interact with these built-in features. To make the most of your custom document transform, runs these built-in transforms twice: once before and once after your transform.

Running the built-in transforms before your custom transform allows your transform to both see the __typename added to each field's selection set and modify definitions defined in the fragment registry. understands that your transform may add new selection sets or new fragment selections to the . Because of this, Apollo Client reruns the built-in transforms after your custom transforms.

Running built-in transforms twice is a convenient capability because it means that you don't have to remember to include the __typename for any added selection sets. Nor do you need to look up definitions in the fragment registry for fragment selections added to the .

Write your own document transform

As an example, let's write a transform that ensures an id is selected anytime currentUser is queried. We will use several helper functions and utilities provided by the graphql-js library to help us traverse the AST and modify the .

First, we must create a new transform using the DocumentTransform class provided by . The DocumentTransform constructor takes a callback function that runs for each transformed. The GraphQL document is passed as the only to this callback function.

import { DocumentTransform } from '@apollo/client';
const documentTransform = new DocumentTransform((document) => {
// Modify the document
});

To modify the , we bring in the visit function from graphql-js that walks the AST and allows us to modify its nodes. The visit function takes a AST as the first and a visitor as the second argument. The visit function returns our modified or unmodified , which we return from our document transform callback function.

import { DocumentTransform } from '@apollo/client';
import { visit } from 'graphql';
const documentTransform = new DocumentTransform((document) => {
const transformedDocument = visit(document, {
// visitor
});
return transformedDocument;
});

Visitors allow you to visit many types of nodes in the AST, such as , , and . In our example, we only care about visiting fields since we want to modify the currentUser in our queries. To visit a field, we need to define a Field callback function that will be called whenever the traversal encounters one.

const transformedDocument = visit(document, {
Field(field) {
// ...
}
});

This example uses the shorthand visitor syntax, which defines the enter function on this node for us. This is equivalent to the following:

visit(document, {
Field: {
enter(field) {
// ...
}
}
});

Our transform only needs to modify a named currentUser, so we need to check the 's name property to determine if we are working with the currentUser . Let's add a conditional check and return early if we encounter any field not named currentUser.

const transformedDocument = visit(document, {
Field(field) {
if (field.name.value !== 'currentUser') {
return;
}
}
});

Returning undefined from our Field visitor tells the visit function to leave the node unchanged.

Now that we've determined we are working with the currentUser , we need to figure out if our id is already part of the currentUser 's selection set. This ensures we don't accidentally select the field twice in our .

To do so, let's get the 's selectionSet property and loop over its selections property to determine if the id is included.

It's important to note that a selectionSet may contain selections of both and . Our implementation only needs to perform checks against fields, so we also check the selection's kind property. If we find a match on a named id, we can stop traversal of the AST.

We will bring in both the Kind enum from graphql-js, which allows us to compare against the selection's kind property, and the BREAK sentinel, which directs the visit function to stop traversal of the AST.

import { visit, Kind, BREAK } from 'graphql';
const transformedDocument = visit(document, {
Field(field) {
// ...
const selections = field.selectionSet?.selections ?? [];
for (const selection of selections) {
if (
selection.kind === Kind.FIELD &&
selection.name.value === 'id'
) {
return BREAK;
}
}
}
});

To keep our transform simple, it does not traverse within the currentUser to determine if those contain an id . A more complete version of this transform might perform this check.

Now that we know the id is missing, we can add it to our currentUser 's selection set. To do so, let's create a new field and give it a name of id. This is represented as a plain object with the kind property set to Kind.FIELD and a name node that defines the 's name.

const idField = {
kind: Kind.FIELD,
name: {
kind: Kind.NAME,
value: 'id',
},
};

We now return a modified from our visitor that adds the id to the currentUser 's selectionSet. This updates our .

const transformedDocument = visit(document, {
Field(field) {
// ...
const idField = {
// ...
};
return {
...field,
selectionSet: {
...field.selectionSet,
selections: [...selections, idField],
},
};
}
});

This example adds the id to the end of the selection set. Order doesn't matter—you may prefer to put the field elsewhere in the selections array.

Hooray! We now have a working transform that ensures the id is selected whenever a containing the currentUser is sent to our server. For completeness, here is the full definition of our custom transform after completing this example.

import { DocumentTransform } from '@apollo/client';
import { visit, Kind, BREAK } from 'graphql';
const documentTransform = new DocumentTransform((document) => {
const transformedDocument = visit(document, {
Field(field) {
if (field.name.value !== 'currentUser') {
return;
}
const selections = field.selectionSet?.selections ?? [];
for (const selection of selections) {
if (
selection.kind === Kind.FIELD &&
selection.name.value === 'id'
) {
return BREAK;
}
}
const idField = {
kind: Kind.FIELD,
name: {
kind: Kind.NAME,
value: 'id',
},
};
return {
...field,
selectionSet: {
...field.selectionSet,
selections: [...selections, idField],
},
};
},
});
return transformedDocument;
});

Check our document transform

We can check our custom transform by calling the transformDocument function and passing a to it.

import { print } from 'graphql';
const query = gql`
query TestQuery {
currentUser {
name
}
}
`;
const documentTransform = new DocumentTransform((document) => {
// ...
});
const modifiedQuery = documentTransform.transformDocument(query);
console.log(print(modifiedQuery));
// query TestQuery {
// currentUser {
// name
// id
// }
// }

We use the print function exported by graphql-js to make the human-readable.

Similarly, we can verify that passing a that doesn't for currentUser is unaffected by our transform.

const query = gql`
query TestQuery {
user {
name
}
}
`;
const modifiedQuery = documentTransform.transformDocument(query);
console.log(print(modifiedQuery));
// query TestQuery {
// user {
// name
// }
// }

Query your server using the document transform

The transformDocument function is useful to spot check your transform. In practice, however, this will be done for you by .

Let's add our transform to and send a to the server. The network request will contain the updated query and the data returned from the server will include the id .

import { ApolloClient, DocumentTransform } from '@apollo/client';
const query = gql`
query TestQuery {
currentUser {
name
}
}
`;
const documentTransform = new DocumentTransform((document) => {
// ...
});
const client = new ApolloClient({
// ...
documentTransform
});
const result = await client.query({ query });
console.log(result.data);
// {
// currentUser: {
// id: "...",
// name: "..."
// }
// }

Composing document transforms

You may have noticed that the ApolloClient constructor only takes a single documentTransform option. As you add new capabilities to your transforms, it may grow unwieldy. The DocumentTransform class makes it easy to split and compose multiple transforms into a single one.

Combining multiple document transforms

You can combine multiple transforms together using the concat() function. This forms a "chain" of transforms that are run one right after the other.

const documentTransform1 = new DocumentTransform(transform1);
const documentTransform2 = new DocumentTransform(transform2);
const documentTransform = documentTransform1.concat(documentTransform2);

Here documentTransform1 is combined with documentTransform2 into a single transform. Calling the transformDocument() function on documentTransform runs the through documentTransform1 and then through documentTransform2. Changes made to the in documentTransform1 are seen by documentTransform2.

A note about performance

Combining multiple transforms is a powerful feature that makes it easy to split up transform logic, which can boost maintainability. Depending on the implementation of your visitor, this can result in the traversal of the AST multiple times. Most of the time, this shouldn't be an issue. We recommend using the BREAK sentinel from graphql-js to prevent unnecessary traversal.

Suppose you are sending very large queries that require several traversals and have already optimized your visitors with the BREAK sentinel. In that case, it's best to combine the transforms into a single visitor that traverses the AST once.

See the section on document caching to learn how applies optimizations to individual transforms to mitigate the performance impact when transforming the same document multiple times.

Conditionally running document transforms

At times, you may need to conditionally run a transform depending on the document. You can conditionally run a transform by calling the split() static function on the DocumentTransform constructor.

import { isSubscriptionOperation } from '@apollo/client/utilities';
const subscriptionTransform = new DocumentTransform(transform);
const documentTransform = DocumentTransform.split(
(document) => isSubscriptionOperation(document),
subscriptionTransform
);

This example uses the isSubscriptionOperation utility function added to in 3.8. Similarly, isQueryOperation and isMutationOperation utility functions are available for use.

Here the subscriptionTransform is only run for . For all other operations, no modifications are made to the . The resulting document transform will first check to see if the document is a , and if so, proceed to run subscriptionTransform. If not, subscriptionTransform is bypassed, and the is returned as-is.

The split function also allows you to pass a second transform to its function, allowing you to replicate an if/else condition.

const subscriptionTransform = new DocumentTransform(transform1);
const defaultTransform = new DocumentTransform(transform2)
const documentTransform = DocumentTransform.split(
(document) => isSubscriptionOperation(document),
subscriptionTransform,
defaultTransform
);

Here the subscriptionTransform is only run for . For all other operations, the is run through the defaultTransform.

Why should I use the split() function instead of a conditional check inside of the transform function?

Sometimes, using the split() function is more efficient than running a conditional check inside the transform function.

For example, you can run a transform by adding a conditional check inside the transform function itself:

const documentTransform = new DocumentTransform((document) => {
if (shouldTransform(document)) {
// ...
return transformedDocument
}
return document
});

Consider the case where you've combined multiple transforms using the concat() function:

const documentTransform1 = new DocumentTransform(transform1);
const documentTransform2 = new DocumentTransform(transform2);
const documentTransform3 = new DocumentTransform(transform3);
const documentTransform = documentTransform1
.concat(documentTransform2)
.concat(documentTransform3);

The split() function makes skipping the entire chain of transforms easier.

const documentTransform = DocumentTransform.split(
(document) => shouldTransform(document),
documentTransform1
.concat(documentTransform2)
.concat(documentTransform3)
);

Document caching

You should strive to make your transforms deterministic. This means the document transform should always output the same transformed document when given the same input GraphQL document. The DocumentTransform class optimizes for this case by caching the transformed result for each input . This speeds up repeated calls to the document transform to avoid unnecessary work.

The DocumentTransform class takes this further and records all transformed . That means that passing an already transformed to the document transform will immediately return the document.

const transformed1 = documentTransform.transformDocument(document);
const transformed2 = documentTransform.transformDocument(transformed1);
transformed1 === transformed2; // => true

In practice, this optimization is invisible to you. calls the transformDocument function on the transform for you. This optimization primarily benefits the internals of where the transformed document is passed around several areas of the code base.

Non-deterministic document transforms

In rare circumstances, you may need to rely on a runtime condition from outside the transform function that changes the result of the transform. Due to the automatic caching of the document transform, this becomes a problem when that runtime condition changes between calls to your document transform.

Instead of completely disabling the cache in these situations, you can provide a custom cache key that will be used to cache the result of the document transform. This ensures your transform is only called as often as necessary while maintaining the flexibility of the runtime condition.

To customize the cache key, pass the getCacheKey function as an option to the second of the DocumentTransform constructor. This function receives the document that will be passed to your transform function and is expected to return an array.

As an example, here is a transform that depends on whether the user is connected to the network.

const documentTransform = new DocumentTransform(
(document) => {
if (window.navigator.onLine) {
// Transform the document when the user is online
} else {
// Transform the document when the user is offline
}
},
{
getCacheKey: (document) => [document, window.navigator.onLine]
}
);

⚠️ It is highly recommended you use the document as part of your cache key. In this example, if the document is omitted from the cache key, the transform will only output two transformed documents: one for the true condition and one for the false condition. Using the document in the cache key ensures that each unique in your application will be transformed accordingly.

You may conditionally disable the cache for select by returning undefined from the getCacheKey function. This will force the transform to run, regardless of whether the input document has been seen.

const documentTransform = new DocumentTransform(
(document) => {
// ...
},
{
getCacheKey: (document) => {
// Always run the transform function when `shouldCache` is `false`
if (shouldCache(document)) {
return [document]
}
}
}
);

As a last resort, you may completely disable caching to force your transform function to run each time your document transform is used. Set the cache option to false to disable the cache.

const documentTransform = new DocumentTransform(
(document) => {
// ...
},
{
cache: false
}
);

Caching within combined transforms

When you combine multiple transforms using the concat() function, each transform's cache configuration is honored. This allows you to mix and match transforms that contain varying cache configurations and be confident the resulting document is correctly transformed.

const cachedTransform = new DocumentTransform(transform);
const varyingTransform = new DocumentTransform(transform, {
getCacheKey: (document) => [document, window.navigator.onLine]
});
const conditionalCachedTransform = new DocumentTransform(transform, {
getCacheKey: (document) => {
if (shouldCache(document)) {
return [document]
}
}
});
const nonCachedTransform = new DocumentTransform(transform, {
cache: false
});
const documentTransform =
cachedTransform
.concat(varyingTransform)
.concat(conditionalCachedTransform)
.concat(nonCachedTransform);

We recommend adding non-cached transforms to the end of the concat() chain. caching relies on referential equality to determine if the document has been seen. If a non-cached document transform is defined before a cached transform, the cached transform will store new GraphQL documents created by the non-cached document transform each run. This could result in a memory leak.

TypeScript and GraphQL Code Generator

GraphQL Code Generator is a popular tool that generates TypeScript types for your . It does this by statically analyzing your code to search for GraphQL strings.

transforms present a challenge for this tool. Because document transforms are used at runtime, there's no way for static analysis to understand the changes applied to documents from within document transforms.

Thankfully, Code Generator provides a document transform feature that allows you to connect transforms to Code Generator. Use your document transform inside the transform function passed to the Code Generator config:

codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
import { documentTransform } from './path/to/your/transform';
const config: CodegenConfig = {
schema: 'https://localhost:4000/graphql',
documents: ['src/**/*.tsx'],
generates: {
'./src/gql/': {
preset: 'client',
documentTransforms: [
{
transform: ({ documents }) => {
return documents.map((documentFile) => {
documentFile.document = documentTransform
.transformDocument(documentFile.document);
return documentFile;
});
}
}
]
}
}
}

You might not need document transforms

transforms are a powerful feature of . After reading this article, you may be rushing to find as many use cases for this feature as possible. While we encourage you to use this feature where it makes sense in your application, there can be a hidden cost to using it.

Consider what happens when working in a large production application that spans many teams within your organization. transforms are typically defined in the code base far from where queries are defined. Not all developers may be aware of their existence nor understand their impact on the final GraphQL document.

transforms can make endless modifications to documents before they are sent to the network. You may find yourself in a position where the result returned from the GraphQL does not match the original GraphQL document. This can get especially confusing when document transforms remove or make other destructive changes.

Consider leaning on existing techniques as a first resort, such as linting. For example, if you require that every selection set in your should include an id , you may find it more useful to create a lint rule that complains when you forget to include it. This makes it more obvious exactly what to expect from your queries since the lint rule is applied where your GraphQL queries are defined. Adding an id via a transform makes this relationship an implicit one.

We encourage you to your own document transforms to create a shared knowledge base to help avoid confusion. This doesn't mean we consider this feature dangerous. After all, has been performing document transformations for nearly its entire existence and they are necessary for its core functionality.

Can I use this to define my own custom directives?

At a glance, transforms seem like a great place to create and define custom since they can detect their presence in the document. Document transforms, however, don't have access to the cache, nor can they interact with the data returned from your . If your custom directive needs access to these features in , you will have difficulty finding ways to make this work.

Custom directives are limited to use cases that depend on modifications to the GraphQL document itself.

Here is an example that uses a DSL-like that depends on a feature flagging system to conditionally include in queries. The transform modifies a custom @feature to a regular @include and adds a definition to the .

const query = gql`
query MyQuery {
myCustomField @feature(name: "custom", version: 2)
}
`;
const documentTransform = new DocumentTransform((document) => {
// convert `@feature` directives to `@include` directives and update variable definitions
});
documentTransform.transformDocument(query);
// query MyQuery($feature_custom_v2: Boolean!) {
// myCustomField @include(if: $feature_custom_v2)
// }

API Reference

Options

Properties

Name / Type
Description
Other

cache (optional)

boolean

Determines whether to cache the transformed . Caching can speed up repeated calls to the document transform for the same input document. Set to false to completely disable caching for the transform. When disabled, this option takes precedence over the getCacheKey option.

The default value is true.

(document: DocumentNode) => DocumentTransformCacheKey | undefined

Defines a custom cache key for a that will determine whether to re-run the document transform when given the same input GraphQL document. Returns an array that defines the cache key. Return undefined to disable caching for that .

Note: The items in the array may be any type, but also need to be referentially stable to guarantee a stable cache key.

The default implementation of this function returns the document as the cache key.

Previous
Error handling
Next
Best practices
Edit on GitHubEditForumsDiscord

© 2024 Apollo Graph Inc.

Privacy Policy

Company