-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Some food for thought on supporting runtime directives in the new execution module. Here's docs on how we did it...
Runtime directives
Breadth supports runtime directive actions applied to the QUERY | MUTATION | FIELD locations. While a schema may define runtime directives in other document locations, these are for AST reference only and provide no execution hooks.
This is an operation-level directive (QUERY | MUTATION locations):
query @inContext(lang: EN) {
myField
}These are field-level directives (FIELD location):
query {
thing @language(lang: EN) {
title
child @language(lang: FR) {
title
}
}
}To implement a runtime directive, set up a BreadthExec::DirectiveResolver and assign it to the directive class:
class LanguageDirectiveResolver < DirectiveResolver
def resolve(exec_directive, context, current_field: nil)
current_field&.attributes[:lang] = exec_directive.arguments[:lang]
end
end
class Language < GraphQL::Schema::Directive
extend GraphQL::BreadthExec::HasBreadthResolver::Directive
graphql_name("language")
argument :lang, String, required: true
locations QUERY, MUTATION, FIELD
self.breadth_resolver = LanguageDirectiveResolver.new
endWrapping directives
Directive resolvers can be configured as block wrappers around all of GraphQL execution (QUERY / MUTATION), or around the execution of a field (FIELD). Wrapping is disabled by default because it adds overhead. To enable wrapping for a specific directive, enable it for the resolver and include a yield in its resolver, or pass the resolver &block forward:
class InContextDirectiveResolver < DirectiveResolver
def initialize
super(wraps: true)
end
def resolve(exec_directive, context, current_field: nil, &block)
MyI18N.with_context(exec_directive.arguments[:lang], &block) # << must yield
end
endReturn note: wrapping directives must return their block result; non-wrapping directives have no return expectations.
Lazy loading note: fields are only wrapped by directives during their primary execution pass. If a wrapped field defers to a lazy loader, it must pass any directive state as an argument to the loader. This both preserves the state and assures the field doesn't batch with other fields of different state. Wrapping at the root operation level assigns global execution state that is consistent across both eager and lazy field executions.
Cascading directives
Breadth runs field resolvers via flat queuing rather than recursively, which changes conventional expectations around tree nesting slightly. Consider this example:
query {
a @language(lang: EN) {
title
b {
title
}
c @language(lang: FR) {
title
}
}
}We expect a to assign a base language of EN that b inherits, and then c overrides with a more specific setting. Breadth achieves this by marking directives as cascading. A cascading directive will be passed down to all of its child fields within a stacking queue. A field execution then runs all directives that it inherited in the order they were queued, followed by any directives defined on the field itself.
class LanguageDirectiveResolver < DirectiveResolver
def initialize
super(cascades: true)
end
def resolve(exec_directive, context, current_field: nil)
# repeatedly write each cascading directive's value onto the field; last one wins...
current_field&.attributes[:lang] = exec_directive.arguments[:lang]
end
endSo basically, fields just track a stack of directives that are their own prepended by those that cascaded onto them, ie:
[<@language(lang:"EN")>, <@language(lang:"FR")>]Then, every field simply calls through its stack of directives before executing itself. That means cascading directives run repeatedly on every field rather than once at specific points within the subtree hierarchy. You could argue this adds overhead, but it also encourages more judicious directive implementations. We support an attributes notepad on all execution elements, which would allow this sort of pattern to cheaply write state onto a field that it could then use while resolving itself. It also encourages fields to take ownership of their resolved directive state going into a lazy batching sequence where tree-based directive state would be lost anyway.