Currently my GraphQL schema doesn't necessarily match the underlying runtime object schema.
I'm using Interfaces/Union types to define the GraphQL API, but from an implementation perspective there isn't a clear & obvious way to map runtime types to the appropriate Type definition at the Schema level without extracting various logic into that global resolve_type call.
Likewise, the resolve type depends on the context it's being resolved to (i.e. the same object may resolve to TypeA in UnionAB, and TypeX in UnionXY).
Suggestion:
resolve_type handler that will be attempted first, before the global handler.field :scope, ScopeUnion, resolve: -> (obj) { obj }, resolve_type: -> { obj.range? ? RangeScopeType : StandardScopeType }@rmosolgo What do you think of this? Or am I barking up the wrong tree and this doesn't make sense for some reason that I'm missing?
related issue #491
Hi, thanks for asking! A similar suggestion was made in https://github.com/rmosolgo/graphql-ruby/issues/491
What if the global resolve_type function also received the type which was being resolved, for example
resolve_type ->(obj, type, ctx) {
case type
when UnionXY
resolve_between_x_and_y(obj)
when UnionAB
resolve_between_a_and_b(obj)
else
raise "Unsupported abstract type #{type}"
end
}
This has the advantage of keeping a _single_ contact point between GraphQL and the application, but also provides enough information for the kind of type resolution you're describing.
Do you think it would work for your case?
It would certainly help - but, I feel like that removes the ability to
encapsulate these semantics within the Type definitions themselves.
In some respects, this would be similar to having to define how each field
is resolved at a global resolve_field level, rather than on the field
definition itself.
Certainly the idea of a global resolution makes sense if you think about
your Graph types as mapping to a single domain type.
But part of the benefit of GraphQL is the ability to define the graph
schema independently of the internal domain objects. In these cases, being
able to encapsulate the specifics of each types definition in the own
files/directories is beneficial especially when there is already complexity
involved because of this.
In my case, I have a domain type with a bunch of fields. But, in my graph
schema I'm defining types and unions to logically group certain subsets of
fields (I.e some fields are only semantically valid for certain values of
other fields, etc.).
Maybe I'm over complicating it, but I went into it assuming it would be
trivial to implement but it's not :-)
Yes, that's a good point about portability of types between schemas. I'm convinced, and I think we can implement it without breaking compatibility.
I was looking over code and I realized there's a problem: the current implementation caches the response of resolve_type for each object. We'd have to add a layer of caching, so we could tell "object O resolves to type T _for abstract type A_."
Why does there need to be a caching layer for runtime resolves? What if the same domain object type resolves to a different Graph type depending on some runtime property/value?
Or am I missing something and the concrete types need to be determined ahead of execution?
Here is a specific use case example of what I mean. Say you have some kind of cart system with products and services...
interface Purchasable {
isProduct: Boolean
isService: Boolean
price: Int
}
type Cart {
purchasables: Purchaseable[]
}
type CheapProduct implements Purchasable {
isProduct: Boolean
isService: Boolean
spammySalesMessage: String
price: Int
}
type ExpensiveProduct implements Purchasable {
isProduct: Boolean
isService: Boolean
seriousHipsterMessage: String
price: Int
}
type Service implements Purchasable {
isProduct: Boolean
isService: Boolean
description: String
price: Int
}
Assume the following query:
cart {
purchasables {
isService
isProduct
price
... on CheapProduct {
description: spammySalesMessage
}
... on ExpensiveProduct {
description: seriousHipsterMessage
}
... on Service {
description
}
}
}
Assume that the internal domain model of the application implements all Services and Products on the same model class. (That is, the graphql implementation should be flexible as not to necessarily force how you model your application types). eg.
class Product
attr_accessor :is_service
attr_accessor :spammy_sales_message, :serious_hipster_message, :description
attr_accessor :price
def expensive?
price > 1_000_00
end
end
class Cart
has_many :cart_products
has_many :purchasables, through: :cart_products, class_name: :Product
end
We need to implement Graph type resolution with the following logic:
PurchsableType = InterfaceType.define...
ServiceType = ObjectType.define { interfaces [PurchasableType] }
ExpensiveProductType = ObjectType.define { interfaces [PurchasableType] }
CheapProductType = ObjectType.define { interfaces [PurchasableType] }
CartType = ObjectType.define do
field :purchasables, PurchasableType[], resolve: -> (obj, *) { obj.purchasables },
resolve_type: -> (concrete, *) {
if concrete.is_service
ServiceType
elsif concrete.expensive?
ExpensiveProductType
else
CheapProductType
end
}
end
I have made up a new option called resolve_type that resolves the GraphQL type of a particular object when the field type is abstract interface or a union. As you can see there is no way to determine the intended Graph type of a runtime domain object ahead of time - it can depend on runtime state.
Having not used the source for this stuff too much in depth, I'm wondering if it makes more sense (or allows for an easier implementation) to allow the resolution on the actual UnionType definition similar to how graphql-js implements this (example). Does that make sense?
Most helpful comment
Yes, that's a good point about portability of types between schemas. I'm convinced, and I think we can implement it without breaking compatibility.