Optional but non-nullable fields in GraphQL
Asked Answered
L

1

14

In an update to our GraphQL API only the models _id field is required hence the ! in the below SDL language code. Other fields such as name don't have to be included on an update but also cannot have null value. Currently, excluding the ! from the name field allows the end user to not have to pass a name in an update but it allows them to pass a null value for the name in, which cannot be allowed.

A null value lets us know that a field needs to be removed from the database.

Below is an example of a model where this would cause a problem - the Name custom scalar doesn't allow null values but GraphQL still allows them through:

type language {
  _id: ObjectId
  iso: Language_ISO
  auto_translate: Boolean
  name: Name
  updated_at: Date_time
  created_at: Date_time
}
input language_create {
  iso: Language_ISO!
  auto_translate: Boolean
  name: Name!
}
input language_update {
  _id: ObjectId!
  iso: Language_ISO!
  auto_translate: Boolean
  name: Name
}

When a null value is passed in it bypasses our Scalars so we cannot throw a user input validation error if null isn't an allowed value.

I am aware that ! means non-nullable and that the lack of the ! means the field is nullable however it is frustrating that, as far as I can see, we cannot specify the exact values for a field if a field is not required / optional. This issue only occurs on updates.

Are there any ways to work around this issue through custom Scalars without having to start hardcoding logic into each update resolver which seems cumbersome?

EXAMPLE MUTATION THAT SHOULD FAIL

mutation tests_language_create( $input: language_update! ) { language_update( input: $input ) { name  }}

Variables

input: {
  _id: "1234",
  name: null
}

UPDATE 9/11/18: for reference, I can't find a way around this as there are issues with using custom scalars, custom directives and validation rules. I've opened an issue on GitHub here: https://github.com/apollographql/apollo-server/issues/1942

Latinity answered 7/11, 2018 at 16:49 Comment(4)
Have you tried schema directives ? [link] (apollographql.com/docs/graphql-tools/schema-directives.html)Mise
@AmitBhoyar Good shout - I'll try it now and get back - should have thought about directives! Thanks!Latinity
Trying the directives route but it seems there is an issue with input fields - github.com/apollographql/graphql-tools/issues/858Latinity
@MatthewP I've updated my answer with an alternative solution. It's not as nice as using a directive on the input fields themselves, but it should be a functional workaround.Flippant
F
8

What you're effectively looking for is custom validation logic. You can add any validation rules you want on top of the "default" set that is normally included when you build a schema. Here's a rough example of how to add a rule that checks for null values on specific types or scalars when they are used as arguments:

const { specifiedRules } = require('graphql/validation')
const { GraphQLError } = require('graphql/error')

const typesToValidate = ['Foo', 'Bar']

// This returns a "Visitor" whose properties get called for
// each node in the document that matches the property's name
function CustomInputFieldsNonNull(context) {
  return {
    Argument(node) {
      const argDef = context.getArgument();
      const checkType = typesToValidate.includes(argDef.astNode.type.name.value)
      if (checkType && node.value.kind === 'NullValue') {
        context.reportError(
          new GraphQLError(
            `Type ${argDef.astNode.type.name.value} cannot be null`,
            node,
          ),
        )
      }
    },
  }
}

// We're going to override the validation rules, so we want to grab
// the existing set of rules and just add on to it
const validationRules = specifiedRules.concat(CustomInputFieldsNonNull)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules,
})

EDIT: The above only works if you're not using variables, which isn't going to be very helpful in most cases. As a workaround, I was able to utilize a FIELD_DEFINITION directive to achieve the desired behavior. There's probably a number of ways you could approach this, but here's a basic example:

class NonNullInputDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field
    const { args: { paths } } = this
    field.resolve = async function (...resolverArgs) {
      const fieldArgs = resolverArgs[1]
      for (const path of paths) {
        if (_.get(fieldArgs, path) === null) {
          throw new Error(`${path} cannot be null`)
        }
      }
      return resolve.apply(this, resolverArgs)
    }
  }
}

Then in your schema:

directive @nonNullInput(paths: [String!]!) on FIELD_DEFINITION

input FooInput {
  foo: String
  bar: String
}

type Query {
  foo (input: FooInput!): String @nonNullInput(paths: ["input.foo"])
}

Assuming that the "non null" input fields are the same each time the input is used in the schema, you could map each input's name to an array of field names that should be validated. So you could do something like this as well:

const nonNullFieldMap = {
  FooInput: ['foo'],
}

class NonNullInputDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field
    const visitedTypeArgs = this.visitedType.args
    field.resolve = async function (...resolverArgs) {
      const fieldArgs = resolverArgs[1]
      visitedTypeArgs.forEach(arg => {
        const argType = arg.type.toString().replace("!", "")
        const nonNullFields = nonNullFieldMap[argType]
        nonNullFields.forEach(nonNullField => {
          const path = `${arg.name}.${nonNullField}`
          if (_.get(fieldArgs, path) === null) {
            throw new Error(`${path} cannot be null`)
          }
        })
      })      

      return resolve.apply(this, resolverArgs)
    }
  }
}

And then in your schema:

directive @nonNullInput on FIELD_DEFINITION

type Query {
  foo (input: FooInput!): String @nonNullInput
}
Flippant answered 8/11, 2018 at 2:23 Comment(4)
This is a great idea - thanks! I'm having one issue with argDef.astNode.type.name.value as it always returns "Variable" - I think this might be to do with the mutations using variables (see example added to question). If I could get the value of the field that is being passed in I could mod the above to what I need but for the life of me I can't find it in the context or node variables. Really appreciate the help!Latinity
Just to add a bit more context we are actually using Scalars that can specifically be null i.e. "NAME_or_Null" - "String_or_Null". If it doesn't include "_or_Null" then the value passed in can't be null, or at least this seems a sensible approach from an external dev viewpointLatinity
@MatthewP So I didn't realize variable values are actually validated on execution, which means I don't think there's any way a validation rule can access them. That makes this solution a no-go for your specific case. Will have to think about it some more, but off the top of my head, a custom scalar that would replace your entire input type might be more viable.Flippant
ah ok! I've tried the custom scalar route but null values seem to bypass the scalars... bit of a pain! Thanks for your time in any case!Latinity

© 2022 - 2024 — McMap. All rights reserved.