apollographql/apollo-client

@client on arguments

Sachanski opened this issue · 9 comments

Hello everyone,

I was wondering if it is possible (or planned to be implemented) to apply the @client directive to a field argument.

My use case is the following:

  • My server returns a model that has a name field (i.e. full name)
  • On most places I would like to display only the first and last name, so I have a type policy that splits the string and returns the relevant parts
  • However on some places I would like to display the full name (as stored in apollo's cache)

What I think would ultimately be the cleanest, is to be able to do something like:

gql(`
  query GetModel {
    getModel {
      name(full: true @client)
    }
  }
`)

with a type policy like so:

Model: {
  fields: {
    name: (name: string | undefined, opts) {
      if (!name || opts.args?.full) {
        return name
      }
      const split = name.split(' ')
      if (split.length === 1) {
        return name
      }

      return `${split[0]} ${split.at(-1)}`
    }
  }
}

Some alternatives that I have tried (and they work) are:

  • a custom hook that fetches the string directly from the cache
function useModelFullName(id: number) {
  return useApolloClient().cache.extract()[`Model:${id}`].name
}

but this seems a bit hacky.

  • extending the schema with
extend type Model {
  shortName: String!
}

but this will require to replace all references to

name

with

name
shortName @client

Notice that I still need to fetch name since otherwise the field won't be populated in the cache at all


If there is anything else that will serve this use case that I have not considered I am happy to hear any suggestions.

Hey @Sachanski 👋

The @client argument is an interesting idea and not sure we've considered that before, so short answer, no we don't have something like that planned at the moment.

If you want a more "official" way to use what you're trying to do with useModelFullName, try implementing that hook with useFragment which will be a bit more efficient since instead of reading the cache on every render, it will only rerender when a cache write happens that would change the value of that field.

Using that would look like this:

const fragment: TypedDocumentNode<{ name: string }> = gql`
  fragment ModelFullName on Model {
    name
  }
`
function useModelFullName(id: number) {
  const { data, complete } = useFragment({ 
    fragment, 
    from: { __typename: 'Model', id } 
  });

  return complete ? data.name : '';
}

Here the fragment acts as a sort of "selector" on the cache. Using it this way also doesn't require you to use it in a query, you can use it just to pluck that value out of the cache.

Note: you'll need to be using @apollo/client v3.8.0 in order to use this. Try this out and see how this works for you!

Thank you for the quick reply!

Using useFragment seems to be going through the type policy and again returns the shortened name. I guess I could pass in a variable inside the useFragment options argument and differentiate on that, but I am not sure if that is the correct way of using the variables in this case.

I was looking into the Document transformers and I was able to remove the argument (full: true) from being sent to the server, but it is sadly also not present under the args key of the type policy read function. Do you think it is worth investing more time there or there is no way to pass a field argument only to the type policy without sending it to the server?

Ah right, you're subject to how the value is read out of the cache with useFragment.

Document transforms unfortunately won't work super well here because they are run early in the lifecycle of a request, so the cache will only see the transformed document, not the original one.

Something you could also consider is creating a custom link that strips the argument out before the query is sent to the server. This way the argument is available to your type policy, but you still avoid sending an argument that you don't expect. This approach would also allow you to scale this to other fields that may need this kind of behavior:

import { visit } from 'graphql';

const stripClientArgsLink = new ApolloLink((operation, forward) => {
  const { query } = operation

  const transformedQuery = visit(query, {
    // ...logic here to strip out arguments with `@client` directives
  });

  return forward({ ...operation, query: transformedQuery });
})

const link = stripClientArgsLink.concat(yourOtherLink);

Its a bit more complex, but should do the trick. Let me know if this works for you!

If that doesn't seem feasible, then I think you may have to switch to something like useFragment everywhere you need to use the name and move the logic from your type policy into your custom hook instead:

const fragment: TypedDocumentNode<{ name: string }> = gql`
  fragment ModelFullName on Model {
    name
  }
`;

function useModelName(id: number, { fullName = true }: { fullName?: boolean } = {}) {
  const { data, complete } = useFragment({ 
    fragment, 
    from: { __typename: 'Model', id } 
  });

  if (!complete || !fullName ) {
    return data.name;
  }

  const { name } = data;

  const split = name.split(' ')
  if (split.length === 1) {
    return name
  }

  return `${split[0]} ${split.at(-1)}`;
}

Let me know if either of these work for you!

Oh, and if you'd like to see @client supported for arguments, do you mind opening a feature request that way we can track it separately?

Thank you! The link solution worked like a charm (I only had to make a minor change by not spreading the operation, since otherwise a this gets lost I assume)! I am leaving my setup here for anyone that might require a similar solution:

schema.graphql

directive @omitArgs(names: [String!]!) on FIELD # declare the directive that we will use in our GraphQL documents to omit sending certain arguments to the server

extend type Model {
  name(full: Boolean): String! # replace an existing name: String! property with one that accepts an optional argument
}

codegen.ts

import { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: `${process.env.VITE_SERVER_BASE_URL}/graphql`,
  ...
  generates: {
    'src/gql/': {
      schema: './schema.graphql', // point to the file above
  },
}

export default config

some-graphql-document

// using gql generated by @graphql-codegen/cli
const GET_MODELS = gql(`
  query GetModels {
    models {
      name(full: true) @omitArgs(names: ["full"]) // use the custom directive we defined previously and our extended model
    }
  }
`)

strip-client-args.ts

import { ApolloLink } from '@apollo/client'
import { ASTNode, FieldNode, Kind, visit } from 'graphql'

const OMIT_ARGS_DIRECTIVE_NAME = 'omitArgs'

function isField(node: unknown): node is FieldNode {
  return (node as ASTNode | undefined)?.kind === Kind.FIELD
}

// check if the field has our custom directive applied and return the names passed to it
function getOmittedArgs(field: FieldNode) {
  const toOmit = field.directives
    ?.find(d => d.name.value === OMIT_ARGS_DIRECTIVE_NAME)
    ?.arguments?.find(arg => arg.name.value === 'names')
  if (toOmit?.value.kind === Kind.LIST) {
    return toOmit.value.values
  }
}

export const stripClientArgs = new ApolloLink((operation, forward) => {
  operation.query = visit(operation.query, {
    // we want to modify (remove part of) an argument definition before sending it to the server
    Argument(node, _key, _parent, _path, ancestors) {
      const maybeField = ancestors.at(-1)
      // check if the argument was on a field
      if (isField(maybeField)) {
        const toOmit = getOmittedArgs(maybeField)
        const shouldOmit = toOmit?.some(f => {
          if (f.kind === Kind.STRING) {
            return f.value === node.name.value
          }
        })
        // if some of the argument names provided to @omitArgs matches the current node name we return null in order to strip it from the GraphQL document sent to the server
        if (shouldOmit) {
          return null
        }
      }
    },
    Directive(node) {
      // also remove the custom directive from the request
      if (node.name.value === OMIT_ARGS_DIRECTIVE_NAME) {
        return null
      }
    },
  })
  return forward(operation)
})

type-policies.ts

import { TypePolicies } from '@apollo/client'

export const typePolicies: TypePolicies = {
  Model: {
    fields: {
      name: (name: string | undefined, { args }) => {
        // in case we passed the full: true argument to our field on some document it is available here
        if (!name || args?.full) {
          return name
        }
        const split = name.split(' ')
        if (split.length === 1) {
          return name
        }

        return `${split[0]} ${split.at(-1)}`
      },
    }
  }
}

Do you have any feedback for the maintainers? Please tell us by taking a one-minute survey. Your responses will help us understand Apollo Client usage and allow us to serve you better.

Love it! Thanks for posting the code sample here for others to find!

Oh - and the time spent on the document transformers wasn't a waste since I pretty much copy pasted the logic into the custom link :)