Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Optional but non-nullable fields in GraphQL

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

like image 497
Matthew P Avatar asked Nov 07 '18 16:11

Matthew P


People also ask

How do you handle null values in GraphQL?

In the GraphQL type system all types are nullable by default. This means that a type like Int can take any integer ( 1 , 2 , etc.) or null which represents the absence of any value. However, the GraphQL type system allows you to make any type non-null which means that the type will never produce a null value.

Does GraphQL return null or undefined?

In JavaScript functions without an explicit return statement implicitly return undefined . So our function creates a Promise and then immediately returns undefined , causing GraphQL to return null for the field.

What is __ Typename in GraphQL?

The __typename field returns the object type's name as a String (e.g., Book or Author ). GraphQL clients use an object's __typename for many purposes, such as to determine which type was returned by a field that can return multiple types (i.e., a union or interface).

What are the three types of operations in GraphQL?

GraphQL works by sending operations to an endpoint. There are three types of operations: queries, mutations, and subscriptions.


1 Answers

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
}
like image 197
Daniel Rearden Avatar answered Oct 26 '22 19:10

Daniel Rearden