Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom document transforms #10509

Merged
merged 132 commits into from
May 26, 2023
Merged

Custom document transforms #10509

merged 132 commits into from
May 26, 2023

Conversation

jerelmiller
Copy link
Member

@jerelmiller jerelmiller commented Feb 3, 2023

Closes #10481

This adds the ability to specify custom GraphQL document transforms. The default behavior is fully backwards compatible with the client today and allows custom caches (i.e. non InMemoryCache implementations) to operate on the transformed document, an improvement from the first iteration of this feature.

Behavior

Document transforms are run before reading from the cache, before local resolvers are used, and before the document is sent through the link chain. We run these as early as possible throughout the client to allow a custom transform an opportunity to make changes to the query document before it reaches the rest of the system. This means these situations are properly handled:

  • Adding an @client directive to a field from the transform will properly be sent through to local state
  • Fragment spreads added to the query with fragments defined in the fragment registry will properly be pulled in

Transforms can contain runtime conditions that may change the resulting document between requests. Because of this, custom transforms are also run again before every request is made. For example, if we use client.watchQuery(...), then call observable.refetch() on the returned observable, the custom transforms are run again before the refetch is executed.

When a custom transform is added to the client, we run the document through the cache.transformDocument hook twice; once before the custom transforms are executed, and once again afterward. This allows custom transforms to transform a full query after the cache has made changes, but also gives the cache a chance to fill in any gaps left by the custom transform. This is especially useful for caches that provide a fragment registry, such as InMemoryCache. By running the cache transforms before the custom transforms, custom transforms have a chance to modify the fragment definitions added to the query from the fragment registry. Since custom transforms have the freedom to make any change necessary to the document, such as adding additional field selections or fragment spreads, the cache transforms run again to fill any gaps left over from the custom transform.

It's important to note that transforms are run before we read data from the cache. This ensures the cache transforms have the freedom to make any modifications necessary and be confident the resulting data will be returned against the transformed query.

Transforms are also run before we send any data to local resolvers. A custom transform may add a @client directive to any field, so we want to ensure we can catch that and send it through local state.

API

To register a custom transform, create a document transform using the DocumentTransform class and set it on ApolloClient

import { DocumentTransform } from '@apollo/client';

const documentTransform = new DocumentTransform((document) => {
  // do something with document
  return transformedDocument;
});

const client = new ApolloClient({
  documentTransform
});

Document transforms are automatically cached. If your transform may rely on a runtime condition, you can provide your own cache key that will only re-run your transform when the cache key changes. We highly recommend using the passed document argument as part of your custom cache key:

const documentTransform = new DocumentTransform(
  (document) => { 
    // This will only be re-run when the cache key from `getCacheKey` changes
  },
  {
    getCacheKey: (document) => [document, someVariable]
  }
);

If you want to conditionally cache the document based on its contents, you may return undefined from getCacheKey to disable caching for that document.

{
  getCacheKey: (document) => {
    if (shouldBeCached(document)) {
      return [document]
    }
  }
}

To disable caching altogether to allow for your transform function to always be re-run, set the cache option to false:

const documentTransform = new DocumentTransform(
  (document) => {
    // ...
    return transformedDocument;
  }, 
  // the document will now be recomputed each time the transform is run
  { cache: false }
);

You can combine transforms using the .concat function on the transform. This allows you to form a "chain" of transforms. The cache option is respected for each transform in the chain.

const transform1 = new DocumentTransform(...)
const transform2 = new DocumentTransform(...)

// run the document through `transform1`, then `transform2`
const documentTransform = transform1.concat(transform2);

You may also conditionally run a set of transforms depending on some condition. Use the .split static function on the DocumentTransform constructor to handle this.

const queryTransforms = DocumentTransform.split(
  (document) => isQueryOperation(document),
  new DocumentTransform(...)
    .concat(new DocumentTransform(...))
    .concat(new DocumentTransform(...))
);

split also accepts a second transform to structure this like an if/else:

const subscriptionTransform = new DocumentTransform(...);
const defaultTransform = new DocumentTransform(...);

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

This PR also undoes some work done in #10346 that provided a new transformQuery option passed to the ApolloClient constructor, released in v3.8.0-alpha.2. With the work in this PR, I just don't see the need for this option. The work to remove @client directives in HttpLink and BatchHttpLink remains as that is important work on the goal to move the processing of @client to the link chain.

Checklist:

  • If this PR contains changes to the library itself (not necessary for e.g. docs updates), please include a changeset (see CONTRIBUTING.md)
  • If this PR is a new feature, please reference an issue where a consensus about the design was reached (not necessary for small changes)
  • Make sure all of the significant new logic is covered by tests

@jerelmiller jerelmiller requested review from benjamn and alessbell and removed request for benjamn February 3, 2023 22:41
@changeset-bot
Copy link

changeset-bot bot commented Feb 3, 2023

🦋 Changeset detected

Latest commit: 46748ba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jerelmiller
Copy link
Member Author

/release:pr

phryneas
phryneas previously approved these changes Feb 6, 2023
Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my side, it looks good. I have left some optional comments, but I would also approve as-is :)

src/cache/inmemory/inMemoryCache.ts Outdated Show resolved Hide resolved
src/cache/inmemory/inMemoryCache.ts Outdated Show resolved Hide resolved
src/cache/inmemory/documentTransformer.ts Outdated Show resolved Hide resolved
@alessbell
Copy link
Contributor

Aside: the /release:pr command failed to generate a snapshot release because changesets prohibits snapshot releases in prerelease mode. (It also currently fails when the branch being released is on a fork.) Testing a workaround for this now.

@jerelmiller
Copy link
Member Author

/release:pr

@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2023

A new release has been made for this PR. You can install it with npm i @apollo/client@0.0.0-pr-10509-20230206201014.

@bignimbus bignimbus linked an issue Feb 7, 2023 that may be closed by this pull request
@jerelmiller
Copy link
Member Author

jerelmiller commented May 3, 2023

It's been a while since there has been movement on this PR. There are lots of different things that we discussed internally to figure out a path forward so here is my attempt to document the latest thinking and potential changes.

Simplifying the API

The current implementation has both a documentTransforms and documentTransformsForLink option, which is confusing and hard to understand the difference between them. This was meant to represent when a transform should be cached by Apollo to avoid recomputing the output document more than necessary. The names don't represent this well however and it's not clear that there needs to be a distinction between the two sets of transforms. Instead, we plan to combine this into a single .transform function that you can call on the transformer that will send it through the transform pipeline.

This leads into the next point:

Caching transform output

One of the hold-ups in this PR was figuring out how best to cache the result of the transform to avoid recomputing the output document more than necessary. Transformations can be expensive, especially for large query documents, so ensuring this feature is performant is a must.

One of the ideas we discussed was trying to auto-cache all transforms out-of-the-box. This solution however has a lot of edge cases. This also makes it complicated for a developer to tell Apollo Client when to invalidate the cache for a query document if a transform needs to be recomputed.

In place of auto-caching, we discussed providing a utility to each transform function that would allow the user to determine which documents to cache, complete with an API to invalidate a document. While this is certainly feasible, we think it might be best to save some bytes and instead allow the user to use a cache mechanism that suites them best.

A popular way to achieve this is through memoization. There are a vast number of memoization libraries out there, so this allows the developer to use tools that they are already familiar with. We think memoization covers most use cases that a user might have. If a user needs a more than memoization, the user has the freedom to implement a cache strategy that works best for them. This is totally optional and purely a performance benefit, though we plan to encourage the use of memoization when possible to ensure apps are performant.

As such, we plan to make sure the API of the transform function can behave nicely with memoization. We may opt to add a general purpose utility later, but are ok with leaving this up to the developer for now.

Additional use cases

In the future, Apollo may provide some nice utilities to persist and extract query documents during build-time. As such, we want want to make sure the API of this document transform feature can be easily utilized to transform documents during a build.

I'm proposing the following changes to better faciliate this:

  1. Have the user import and create the DocumentTransformer. Pass this instance to InMemoryCache rather than an array of raw functions.
import { DocumentTransformer } from '@apollo/client';

const transformer = new DocumentTransformer();

transformer
  .add(transform1)
  .add(transform2);

new InMemoryCache({
  documentTransforms: transformer
});

By allowing the user to create the transformer instance outside of the cache, this allows the document transformer to be exported anywhere in an app, which can be useful to isolate the transformer for a build time feature.

  1. Allow multiple document transformers to be composed together
const transforms1 = new DocumentTransformer();
transforms1.add(...);

const transforms2 = new DocumentTransformer();
transforms2.add(...);

const transformer = transforms1.concat(transforms2);

Allowing a user to compose transformers together allows someone to organize or split document transforms however they wish.

  1. Create a default transformer that includes the existing transforms embedded throughout the client. This includes: adding __typename, adding fragments from the fragment registry, and removing @client and @connection directives. This should be exported by the Apollo Client.
import { createDefaultTransformer } from '@apollo/client';

// Allow users to disable typename
// This also allows InMemoryCache to configure this transform based on its addTypename option
const transformer = createDefaultTransformer({ addTypename: false });

@jerelmiller jerelmiller force-pushed the custom-document-transforms branch from ed81e55 to 4c99954 Compare May 5, 2023 22:04
@jerelmiller jerelmiller marked this pull request as draft May 12, 2023 16:28
@jerelmiller jerelmiller force-pushed the custom-document-transforms branch from 0913a64 to fc93384 Compare May 12, 2023 16:55
@jerelmiller jerelmiller dismissed phryneas’s stale review May 16, 2023 22:01

Making large changes that are completely different than the initial attempt, so the old review makes no sense anymore.

@jerelmiller jerelmiller force-pushed the custom-document-transforms branch from 4c7f1cd to bf9f535 Compare May 24, 2023 20:02
@jerelmiller jerelmiller requested review from benjamn and phryneas May 24, 2023 22:57
Copy link
Member

@benjamn benjamn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a few simplifying changes in ee0b4ae, but overall I am happy with the state of this PR (especially for a beta release). Thanks @jerelmiller!

type TransformFn = (document: DocumentNode) => DocumentNode;

interface DocumentTransformOptions {
cache?: boolean;
getCacheKey?: (document: DocumentNode) => DocumentTransformCacheKey;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found it useful in the past for cache-key-generating functions like getCacheKey to have the option of refusing to generate a key when the cache should be skipped/ignored. Specifically, getCacheKey could return DocumentTransformCacheKey | undefined, with undefined indicating to skip the cache for this document. That decision could be made dynamically based on the contents of the document, for example.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this change as part of ee0b4ae.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that makes a ton of sense. Thanks so much for this update!

@jerelmiller
Copy link
Member Author

/release:pr

@github-actions
Copy link
Contributor

A new release has been made for this PR. You can install it with npm i @apollo/client@0.0.0-pr-10509-20230525204036.

transformDocument(document: DocumentNode) {
// If a user passes an already transformed result back to this function,
// immediately return it.
if (this.resultCache.has(document)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the reasoning for the resultCache, but we have to make sure to mention in the docs that a transform(document) === transform(transform(document))

Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me - no further complaints from my side :)

@jerelmiller jerelmiller merged commit 79df2c7 into release-3.8 May 26, 2023
@jerelmiller jerelmiller deleted the custom-document-transforms branch May 26, 2023 15:21
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jun 26, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Custom query document transforms
5 participants