ruby-on-railsgraphqlgraphql-ruby

Using a List in a GraphQL Union type (in Ruby)


The GraphQL Ruby documentation shows how to define a union type:

class Types::CommentSubject < Types::BaseUnion
  description "Objects which may be commented on"
  possible_types Types::Post, Types::Image

  # Optional: if this method is defined, it will override `Schema.resolve_type`
  def self.resolve_type(object, context)
    if object.is_a?(BlogPost)
      Types::Post
    else
      Types::Image
    end
  end
end

and it shows how to declare that a field is a list outside of a union:

# A field returning a list type:
# Equivalent to `aliases: [String!]` above
field :aliases, [String]

# An argument which accepts a list type:
argument :categories, [Types::PostCategory], required: false

but I can't for the life of me figure out how to use a list as a possible type that a union member could be.

My code looks something like this:

class Types::ArgumentValueType < Types::BaseUnion
  possible_types GraphQL::Types::String, GraphQL::Types::Boolean, GraphQL::Types::Int

  def self.resolve_type(object, _context)
    if object.is_a?(String)
      GraphQL::Types::String
    elsif object.is_a?(Array)
      [GraphQL::Types::String]
    elsif object.is_a?(FalseClass)
      GraphQL::Types::Boolean
    elsif object.is_a?(TrueClass)
      GraphQL::Types::Boolean
    elsif object.is_a?(Integer)
      GraphQL::Types::Int
    end
  end
end

… which sort of works, except that when it's an array, this value comes back as a string. In GraphiQL it looks like this (we're looking at the value field):

{
  "name": "top_box",
  "type": "Array",
  "description": "The chosen values of the scale which should be combined",
  "position": 2,
  "optional": false,
  "value": "[\"8\", \"9\", \"10\"]"
}

We could potentially parse that in the client but ideally I'd like it to be an array of strings, like this:

{
  "name": "top_box",
  "type": "Array",
  "description": "The chosen values of the scale which should be combined",
  "position": 2,
  "optional": false,
  "value": [
    "8",
    "9",
    "10"
  ]
},

But I can't see how to define that and the only information I could find anywhere is a brief comment in this answer to ‘GraphQL Union within Union’ which seems to suggest that it may not be possible.

Errors

If I try adding [GraphQL::Types::String] to possible_types, I get

undefined method `graphql_name' for [GraphQL::Types::String]:Array

If I try adding GraphQL::Schema::List.new(GraphQL::Types::String) to possible_types, I get

undefined method `directives' for #<GraphQL::Schema::List:0x000000010f690950 @of_type=GraphQL::Types::String>

and if I try replacing [GraphQL::Types::String] (under elsif object.is_a?(Array)) with GraphQL::Schema::List.new(GraphQL::Types::String), then I get

.resolve_type should return a type definition, but got #<GraphQL::Schema::List:0x000000010f9fd6b0
@of_type=GraphQL::Types::String> (GraphQL::Schema::List) from `resolve_type(Types::ArgumentValueType,
[\"8\", \"9\", \"10\"], #<GraphQL::Query::Context:0x000000010f8ed018>)`

Update

I managed to make an improvement by adding a wrapper class:

# frozen_string_literal: true

module Types
  class ListOfStringsType < Types::BaseObject
    field :values, [String]
  end
end

Then, with a GraphQL query that looks a bit like this

arguments {
  name
  type
  description
  position
  optional
  value {
    ... on ListOfStrings {
      values
    }
  }
}

It produces output like this

"arguments": [
  {
    "name": "top_box",
    "type": "Array",
    "description": "The chosen values of the scale which should be combined",
    "position": 2,
    "optional": false,
    "value": {
      "values": [
        "8",
        "9",
        "10"
      ]
    }
  },
  {
    "name": "measure",
    "type": "Measure",
    "description": "The name of the measure to be \"top-boxed\"",
    "position": 1,
    "optional": false,
    "value": "unique_and_different"
  }
]

This is kind of okay, except that it has one extra level of indirection which I would prefer to avoid.


Solution

  • A colleague has pointed me at the following code, which works, but I'm afraid I still don't fully understand everything that's going on. But I will do my best to explain.

    Use a Scalar

    As I understand it, the idea with scalar types is that if you have your own fundamental type (normally not a complex object but just a single datum) which is none of the basic built-ins, you can define that as a scalar. (You can get a sense of examples of scalars from the graphql-scalars project). Ultimately everything is a string over the wire, of course, but in your backend you will define how to serialize your type, and in the frontend you will define how to unserialize it, and vice versa for writes of course.

    So, you replace the contents of your argument_value_type.rb file up there with the following.

    class Types::ArgumentValueType < Types::BaseScalar
      description "A value"
    
      def self.coerce_input(input_value, _context)
        input_value
      end
    
      def self.coerce_result(ruby_value, _context)
        ruby_value
      end
    end
    

    As we can see from the Scalars reference:

    • self.coerce_input takes a GraphQL input and converts it into a Ruby value
    • self.coerce_result takes the return value of a field and prepares it for the GraphQL response JSON

    … in other words, no conversion either way.

    Then you can shove anything in there and it just comes out as-is. Assuming it's representable in JSON.

    GraphQL Query

    Your query is super-simple:

    arguments {
      name
      type
      description
      position
      optional
      value       // <-- this is the relevant bit
    }
    

    and you just get all the values back in whatever type they may be. Your front-end will have to handle it!

    Future Improvements

    Probably this could be improved to narrow it down a bit as to where it will break when an unexpected type is encountered.