Skip to content

Breadth-based runtime directives #5534

@gmac

Description

@gmac

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
end

Wrapping 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
end

Return 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
end

So 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions