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

Introduce the removeTypenameFromVariables link to remove __typename from variables #10853

Merged
merged 24 commits into from
May 16, 2023

Conversation

jerelmiller
Copy link
Member

@jerelmiller jerelmiller commented May 10, 2023

Closes #10767

The removal of __typename from variables introduced in #10724 was determined to be too much of a breaking change as JSON-scalars could be utilizing __typename without violating the GraphQL spec (more information on this discussion can be found in #10767).

This PR moves this behavior into a new link called removeTypenameFromVariables that can be added to any link chain. This has the advantage that it can be used by any terminating link, unlike #10724 where this functionality was limited to HttpLink, BatchHttpLink, and GraphQLWsLink.

By default, this link will remove all __typename keys from variables. If a user has a JSON-scalar that utilizes __typename, the scalar can be configured with the link to keep __typename in those fields. JSON scalar fields nested inside input objects can also be configured to ensure __typename is kept at those paths. NOTE: JSON scalars don't literally have to be called JSON. These scalars can show up with other names (e.g. Raw, Config, etc).

The API introduced in this PR was created with code generation in mind. Much like possibleTypes, we should be able to recommend a code generation approach that will extract the configuration for you.

The API is as follows:

import { removeTypenameFromVariables, KEEP } from '@apollo/client/link/remove-typename';

// With no configuration, all __typenames are removed by default
const link = removeTypenameFromVariables();

const link = removeTypenameFromVariables({
  except: {
    // Keep `__typename` on any variable that is declared as a `JSON` type
    JSON: KEEP,

    // Input objects may have JSON scalar fields. These can configured using an object declaration.

    // Keep __typename for `bar` and `baz` fields on any variable declared as a `FooInput` type
    FooInput: {
      bar: KEEP,
      baz: KEEP,
    },

    // Configure deeply nested fields for complex objects
    BarInput: {
      baz: {
        qux: KEEP
      }
    },

    // JSON scalars may show up at varying nesting levels.
    // This keeps `__typename` on `bar.baz` and `bar.qux.foo`
    FooInput: {
      bar: {
        baz: KEEP, 
        qux: {
          foo: KEEP
        }
      }
    },

    // Array fields are configured like objects. 
    // Keep all `__typename`s on the `config` field for each `widget`
    DashboardInput: {
      widgets: {
        config: KEEP
      }
    }
  }
});

These types are detected from the variable declarations in the query.

query FooQuery(
  # `foo` is detected as a `JSON` type
  $foo: JSON, 

  # `bar` is detected as a `BarInput` type
  # scalar fields on `BarInput` can be configured as discussed above
  $bar: BarInput
) {
  someField(foo: $foo, bar: $bar)
}

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

@changeset-bot
Copy link

changeset-bot bot commented May 10, 2023

🦋 Changeset detected

Latest commit: b94fbda

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

excludeScalars?: (string | ScalarConfig)[];
}

export function removeTypenameFromVariables(
Copy link
Member Author

Choose a reason for hiding this comment

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

We've been using the term "strip typename", but I felt "remove typename" felt a bit more "professional". Let me know what you think! I'm happy to rename this back to stripTypenameFromVariables if we prefer that verbiage.

@@ -1,14 +1,28 @@
import { DeepOmit } from '../types/DeepOmit';
import { isPlainObject } from './objects';

export function omitDeep<T, K extends string>(value: T, key: K) {
return __omitDeep(value, key);
const BREAK: unique symbol = Symbol('BREAK');
Copy link
Member Author

Choose a reason for hiding this comment

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

I wonder if naming this something like KEEP_ALL would be more semantically meaningful. Would love to hear thoughts on whether BREAK makes sense as a name here or not. This is meant to mean "stop traversal and keep the omitted field for all values inside this object"

Copy link
Member

Choose a reason for hiding this comment

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

"break" as in the sense of "early return for this subtree" makes sense for me.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Thats where I got the name from initially 😆

@jerelmiller jerelmiller changed the title Introduce the removeTypenameFromVariables link to allow __typename to be stripped from variables Introduce the removeTypenameFromVariables link to remove __typename from variables May 10, 2023
const link = removeTypenameFromVariables({
excludeScalars: [
{
scalar: 'JSON',
Copy link
Member

Choose a reason for hiding this comment

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

Wait... in this test, nothing mentions JSON anywhere. Can you explain what exactly the scalar property here does?

Copy link
Member Author

@jerelmiller jerelmiller May 10, 2023

Choose a reason for hiding this comment

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

The paths describe all the fields where JSON scalar live. For example, if your schema with an input object looks like this:

scalar JSON

type Query {
  someField(foo: FooInput)
}

input FooInput {
  bar: JSON
  baz: JSON
}

All we can detect from the query is that foo is of type FooInput, but without full schema introspection, its difficult to know which fields on input objects are of type JSON, hence the paths option.

This scalar declaration declares JSON as a type that should be excluded when removing the typename. While this test doesn't include it, this means variables of type JSON should be excluded when removing the typename:

query Test($foo: JSON) {
  someField(foo: $foo)
}

Does this help?

Copy link
Member Author

@jerelmiller jerelmiller May 10, 2023

Choose a reason for hiding this comment

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

I'll add one more clarifying example that might hammer this home. Let's say my query looks like this:

query TestQuery($foo: FooInput, $json: JSON) {
  someField(foo: $foo, json: $json)
}

With configuration:

removeTypenameFromVariables({
  excludeScalars: [
    { scalar: 'JSON', paths: { FooInput: ['bar'] } }
  ]
});

This would keep __typename on variable json since its of type JSON and it would also keep __typename on the field bar of variable foo since its of type FooInput with a JSON scalar field bar.

Since excludeScalars is an array, this allows you to define several scalar types that should keep the __typename value:

removeTypenameFromVariables({
  excludeScalars: [
    { scalar: 'JSON', paths: { FooInput: ['bar'] }, 
    { scalar: 'Raw' }, 
    { scalar: 'Config' }
  ]
});

// or you can use the string shorthand for scalar configs that don't have a `paths` option
removeTypenameFromVariables({
  excludeScalars: [
    'Raw', 
    'Config',
    { scalar: 'JSON', paths: { FooInput: ['bar'] },
  ]
});

Hopefully that helps give some more context

Copy link
Member Author

Choose a reason for hiding this comment

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

@phryneas I changed the API signature after some feedback. I've updated the PR description to reflect the change. Let me know what you think about the change!

@jerelmiller
Copy link
Member Author

jerelmiller commented May 10, 2023

After the initial review with @phryneas and talking this over a bit more with @benjamn, the API proposed in this PR seems a bit too confusing. It's not clear enough the effect the scalar configuration has grouped with the path configuration.

Instead, I am going to experiment with a fully object-based configuration. Scalars are just types that happens to live at the leaves of the tree, so the configuration doesn't have to differentiate itself from other non-scalar types with path configurations. The configuration might look something like this:

removeTypenameFromVariables({
  exclude: {
    JSON: true,
    FooInput: {
      bar: true,
      deeply: {
        nested: true
      }
    }
  }
})

This also has the advantage that it looks a little more like GraphQL queries themselves.

The trick here is figuring out the leaf delimiter. While true works well here and is plenty readable, one might surmise that you can also pass a false, which is not a valid value. Instead, we could consider exporting a special (serializable) identifier that denotes the leaf nodes. The advantage here is that it disambiguates what values are allowed at leaf fields. This might look something like:

// import name TBD
import { removeTypenameFromVariables, KEEP } from '@apollo/client/link/remove-typename';

removeTypenameFromVariables({
  exclude: {
    JSON: KEEP,
    FooInput: {
      bar: KEEP,
      deeply: {
        nested: KEEP
      }
    }
  }
})

where KEEP identifies types/fields that should be left alone. Here, all variables declared as type JSON would keep their __typename fields intact. Variables of type FooInput would keep __typename within their bar and deeply.nested fields.

Comment on lines +318 to +327
const link = removeTypenameFromVariables({
except: {
FooInput: {
bar: KEEP,
baz: {
qux: KEEP,
},
},
},
});
Copy link
Member

Choose a reason for hiding this comment

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

So readable!

src/utilities/common/stripTypename.ts Show resolved Hide resolved
Comment on lines +49 to +62
variables: stripTypename(variables, {
keep: (variablePath) => {
const typename = variableDefinitions[variablePath[0]];

// The path configurations do not include array indexes, so we
// omit them when checking the `trie` for a configured path
const withoutArrayIndexes = variablePath.filter(
(segment) => typeof segment === 'string'
);

// Our path configurations use the typename as the root so we need to
// replace the first segment in the variable path with the typename
// instead of the top-level variable name.
return trie.peekArray([typename, ...withoutArrayIndexes.slice(1)]);
Copy link
Member

Choose a reason for hiding this comment

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

How essential is this options.keep callback API for stripTypename and omitDeep? If there was another way to implement this code, would we still want to ship options.keep?

Copy link
Member Author

@jerelmiller jerelmiller May 10, 2023

Choose a reason for hiding this comment

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

Its essential in the fact that we need to know when to leave the key at a certain path alone vs remove it, but the callback is more an implementation detail than a necessity. I had considered an object-based approach, but couldn't figure out a good way to represent the syntax for array items.

Since omitDeep is designed to be a general purpose utility, I could see cases where we might want to use it in some way to iterate over data from an interface type, where we conditionally omit a field in that union type based on the concrete type (though I have no use case for something like this at the moment). The callback style API felt the most flexible way to allow for everything.

I'm curious, what do you have in mind? I'm all ears and not attached to this if you have a better idea in mind.

Copy link
Member

Choose a reason for hiding this comment

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

My hypothetical concern was that we might end up calling keep for every possible path in a very large variables object, which could be expensive since the keep function is creating three new arrays in addition to the Trie lookup.

Instead, we might be able to call stripTypename on just the subtrees that are not protected by KEEP, without needing to configure the stripTypename call with a keep callback. In other words, we'd be calling stripTypename multiple times on different subtrees of the object, and since each of those calls strips all __typename fields in its subtree, we already know what keep would return for that entire subtree, so calling keep becomes unnecessary.

On the other hand, if/when we come up with something more efficient, we should be able to switch over to a different implementation without changing the behavior of this code. In that scenario, we might not end up using keep internally, but I don't have a problem with the keep API existing as part of stripTypename and omitDeep.

In other words, this question doesn't need to block the PR, but I will keep thinking about it, in case a good alternative occurs to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ya this is a great observation. Totally agree, let's keep noodling on this. If we can find a good way to implement this, I'd be all for this optimization.

I'll go ahead and merge this PR as-is and once we have a good optimization solution, we can open up a follow-up refactor PR. I would consider these helpers internal so we can make breaking changes to the function signature if/when we need to.

Thanks for thinking this through!

@phryneas
Copy link
Member

The configuration might look something like this:

removeTypenameFromVariables({
  exclude: {
    JSON: true,
    FooInput: {
      bar: true,
      deeply: {
        nested: true
      }
    }
  }
})

This also has the advantage that it looks a little more like GraphQL queries themselves.

In this example, how can I exclude the typename on FooInput from being removed, but have it removed in some of it's children?

@jerelmiller
Copy link
Member Author

@phryneas the configuration is meant to represent the fields on types that have JSON scalar as their types. FooInput here is meant to represent an input object type in the schema, not a JSON scalar itself. In other words that example represents a schema that looks like this:

scalar JSON

type Query {
  someField(foo: FooInput)
}

input FooInput {
  bar: JSON
  baz: BazInput
}

input BazInput {
  deeply: DeeplyInput
}

input DeeplyInput {
  nested: JSON
}

Leaving __typename on FooInput in this case would be a violation of the GraphQL spec since FooInput is an input type, not a JSON scalar. The only way leaving __typename on FooInput makes sense is if FooInput is a scalar itself, but the configuration should be FooInput: KEEP in that case.

The configuration was designed with input types in mind where you could tell us which fields are the JSON scalar types since we can't determine that from the query itself. In that way you should only be able to configure a KEEP at a leaf field, not within something that has a subselection.

Does that make sense?

@phryneas
Copy link
Member

Yeah, with that explanation it does. I get a feeling that this one will need a very good example in the docs, though.

@jerelmiller
Copy link
Member Author

I get a feeling that this one will need a very good example in the docs, though.

Oh absolutely. I'm willing to bet this is a very very VERY small percentage of users that will even care about keeping __typename on json scalars to begin with. I'm willing to bet 99.9% of usage will be to simply strip __typename from everything.

@github-actions
Copy link
Contributor

github-actions bot commented May 15, 2023

size-limit report 📦

Path Size
dist/apollo-client.min.cjs 35.8 KB (-0.49% 🔽)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" 45.26 KB (+0.25% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" 34.05 KB (-0.42% 🔽)
import { ApolloProvider } from "dist/react/index.js" 867 B (0%)
import { useQuery } from "dist/react/index.js" 25.42 KB (-0.59% 🔽)
import { useLazyQuery } from "dist/react/index.js" 25.75 KB (-0.62% 🔽)
import { useMutation } from "dist/react/index.js" 23.95 KB (-0.62% 🔽)
import { useSubscription } from "dist/react/index.js" 2.36 KB (0%)
import { useSuspenseQuery_experimental } from "dist/react/index.js" 3.53 KB (0%)
import { useBackgroundQuery_experimental } from "dist/react/index.js" 2.97 KB (0%)
import { useReadQuery_experimental } from "dist/react/index.js" 1.12 KB (0%)
import { useFragment_experimental } from "dist/react/index.js" 1.78 KB (0%)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants