Plan resolvers
Before executing a GraphQL request, Grafast plans the included operation (query, mutation, subscription), determining the actions ("steps") it will need to perform. To do so, it traverses the operation in a breadth-first manner, building out a tree of "steps" that form the beginnings of the "operation plan." This operation plan is then optimized, and finally executed.
This same operation plan can be used for all sufficiently similar GraphQL requests in future — plan once, execute many times — so to maximize reuse, planning does not have access to the raw input values, instead representing them as steps to be populated at execution time for each request.
Field plan resolvers
// Simplified types
type FieldPlanResolver = (
$source: Step,
fieldArgs: FieldArgs,
info: FieldInfo,
) => Step;
"Field plan resolvers" are the functions responsible for detailing actions sufficient to resolve an individual field as part of the operation plan.
When calling a field's plan resolver, Grafast will pass:
- the
$source
step representing data the field is being resolved against - a
fieldArgs
object to interact with the field's arguments - an
info
object with additional planning info
The plan resolver must return a step that represents the result of the field.
Source step
The $source
step represents the data from
the object that the field is being resolved against (i.e. the GraphQL object
type that owns the field). For the root selection set, the parent object
represents either the GraphQL rootValue
or the subscription event. For all
other selection sets the source object will be derived from the parent field:
- for a list type, the "source step" will represent an item in this list
- for an object type, the "source step" is the step the parent field returned
- for an abstract type, the "source step" will be the resolved step representing the matching concrete object type
FieldArgs
// Simplified type
type FieldArgs = {
getRaw(path: string | ReadonlyArray<string | number>): Step;
// Shortcuts for getRaw for each argument:
[`$${string}`]: Step;
// -- Advanced features --
autoApply($target: Step): void;
apply(
$target: ApplyableStep,
path?: ReadonlyArray<string | number>,
getTargetFromParent?: (parent: any, inputValue: any) => object | undefined,
): void;
getBaked(path: string | ReadonlyArray<string | number>): Step;
};
The "field arguments" (fieldArgs
) is an object giving
ways of interacting with the values passed as arguments to a field.
Accessing argument values
You can retrieve a step representing a field argument's value either via the
matching $
-prefixed property of the fieldArgs
object, or via the .getRaw()
method.
Consider this schema:
input BookFilter {
author: String
publishedAfter: Int
}
type Query {
bookCount(search: String, filter: BookFilter): Int!
}
You can access the argument steps using the $
-prefixed properties:
function bookCount($parent, fieldArgs) {
const { $search, $filter } = fieldArgs;
const { $author, $publishedAfter } = $filter;
}
or, equivalently, via .getRaw()
:
function bookCount($parent, fieldArgs) {
const $search = fieldArgs.getRaw("search");
const $filter = fieldArgs.getRaw("filter");
const $author = fieldArgs.getRaw(["filter", "author"]);
const $publishedAfter = fieldArgs.getRaw(["filter", "publishedAfter"]);
}
Triggering auto-application early
ADVANCED
Arguments can have their own plan resolvers, see argument plan resolvers below. Grafast will invoke these automatically once the field plan resolver returns, but if you want to wrap a field plan resolver with a higher order function, you might want all of the arguments to have already been applied before your wrapper plan's logic continues.
You can trigger the auto-application early with the
fieldArgs.autoApply($target)
method:
const oldPlan = usersField.extensions.grafast.plan;
usersField.extensions.grafast.plan = function ($source, fieldArgs, info) {
// Call the old plan method
const $target = oldPlan($source, fieldArgs, info);
// Perform the auto-application of arguments early:
fieldArgs.autoApply($target);
// Now do whatever logic we need to do:
if (!$target.getFirst()) {
$target.setFirst(constant(10));
}
return $target;
};
Handling complex inputs
VERY ADVANCED
.getBaked()
and .apply()
are documented in the Handling complex
inputs article - these are advanced features you're
unlikely to need when writing your schema by hand, but they can be helpful
when generating schemas automatically or if you have particularly complex
input structures (advanced filters, ordering, pagination, etc).
FieldInfo
interface FieldInfo {
fieldName: string;
field: GraphQLField<any, any, any>;
schema: GraphQLSchema;
}
The info object contains information about the context in which the plan resolver is called:
fieldName
: the name of the field being resolved (not its alias)field
: the field itselfschema
: the fullGraphQLSchema
object the request is being executed against
It's very rare for hand-written plan resolvers to need this but it's useful for libraries that generate plan resolvers or when using the same plan resolver with multiple fields, as in the case of the default plan resolver.
Default plan resolver
In a pure Grafast schema (no traditional resolvers),
if a field doesn't have
a plan resolver, a default plan resolver will be used that uses
get()
to retrieve the property of the parent
object with the same name as the field:
const defaultPlanResolver: FieldPlanResolver = ($source, fieldArgs, info) =>
get($source, info.fieldName);
Example
Given the following schema fragment:
type User {
friends(limit: Int): [User!]!
}
the plan resolver for the User.friends
field might look like this:
function User_friends_plan(
$parent: ExecutableStep,
fieldArgs: FieldArgs,
): ExecutableStep {
const $limit = fieldArgs.getRaw("limit");
const $friends = $parent.getRelation("friends");
$friends.limit($limit);
return $friends;
}
By convention, when a variable represents a step the variable's name starts
with a $
; this helps remind us that this is not the actual value, but a
placeholder that represents the value that will be filled at execution time for
each request that uses this plan.
Traditional resolvers
Although Grafast uses an alternative execution model to the reference implementation (GraphQL.js), to make it easy for people to adopt Grafast it has support for emulating GraphQL.js' resolvers ("traditional resolvers") via a set of built in plan classes. This support is pretty good — sufficient to pass the integration tests of the GraphQL.js test suite — though it does have a few limitations (see Using with an existing schema for more details).
Using traditional resolvers fails to capture the benefits of Grafast (since traditional resolvers execute at execution time, we cannot optimize the operation at planning time) so it is discouraged for new schemas — if you're starting from scratch you should build a pure (plan-only) Grafast schema.
If a field has both a plan resolver and a traditional resolver, then the plan
resolver will run first, and the result of the plan resolver will be provided to
the traditional resolver as the source
argument (the first argument), giving
users the ability to port a legacy schema to Grafast on a field-by-field
basis. We recommend starting with the fields that would yield the greatest
benefit, and those that consume them.
If a field with a traditional resolver is invoked, then Grafast will enter
"resolver emulation mode" for that tree, and will remain in resolver emulation
until a field with a plan is met; the default plan resolver will not be used
in this mode, instead the traditional defaultFieldResolver
will be
emulated.
Specifying a field plan resolver
When building a GraphQL schema programatically, plan resolvers are stored into
extensions.grafast.plan
of the field; for example:
import { GraphQLSchema, GraphQLObjectType, GraphQLInt } from "graphql";
import { constant } from "grafast";
const Query = new GraphQLObjectType({
name: "Query",
fields: {
meaningOfLife: {
type: GraphQLInt,
extensions: {
grafast: {
plan() {
return constant(42);
},
},
},
},
},
});
export const schema = new GraphQLSchema({
query: Query,
});
If you are using makeGrafastSchema
then the field plan resolver for the field
fieldName
on the object type typeName
would be indicated via the
objects[typeName].plans[fieldName]
property:
import { makeGrafastSchema, constant } from "grafast";
export const schema = makeGrafastSchema({
typeDefs: /* GraphQL */ `
type Query {
meaningOfLife: Int
}
`,
objects: {
Query: {
plans: {
meaningOfLife() {
return constant(42);
},
},
},
},
});
Argument plan resolvers
You wouldn't typically use this if you're writing your schema by hand, but it can be helpful if you're using automatic schema generation as it allows arguments to handle their own logic without having to "wrap" the underlying field plan resolver.
Sometimes rather than fetching and using the raw argument value directly, you
want to apply the argument to your field plan. This allows you to keep your
argument logic separate from your field plan logic. For this, your argument
would have an applyPlan
method defined on it:
const schema = makeGrafastSchema({
typeDefs: /* GraphQL */ `
type Query {
users(first: Int, offset: Int): [User!]!
}
`,
objects: {
Query: {
plans: {
users: {
// The (simple) plan for the field
plan($query, fieldArgs) {
const $allUsers = users.find();
return $allUsers;
},
// These become `applyPlan` methods on the arguments:
args: {
// $target will be the return result of the field plan, i.e.
// `$allUsers` above
first($query, $target, val) {
const $first = val.getRaw();
$target.setFirst($first);
},
offset($query, $target, val) {
const $offset = val.getRaw();
$target.setOffset($offset);
},
},
},
},
},
},
});
Grafast will automatically call each of the arguments' plans once the field plan
resolver has returned, passing the step yielded from the field plan as the
$target
for the arguments.
If Grafast can determine statically that your argument will not be passed at
runtime (i.e. will be undefined
) then it may choose to skip calling the
applyPlan()
method for that argument.
Asserting an object type's step
Sometimes a field plan resolver expects the source step to support specific
methods. For example, imagine a Post
type that represents a row from a posts
database table. In the post list page on your website, you may want to fetch a
truncated version of the post body to include with each post; to do this
efficiently it's better to truncate in the database rather than fetch it all and
truncate in the application layer; so you might use an SQL expression, requiring
that the source step supports the ability to select SQL expressions. If an
arbitrary step were passed in instead, the plan might fail at runtime because
the method you expect doesn’t exist.
To guard against this, you can add an assertStep
to your object type
(objectType.extensions.grafast.assertStep
). The value can either be a step
class, in which case it will be asserted that each step is an instanceof
this
class, or an assertion function which will be called (and should throw an error
if the step is not acceptable).
assertStep
is optional, but highly recommended when your plan resolvers rely
on methods that only exist on a specific step class to ensure errors in plans
are caught early.
Example - step class
Here assertStep
asserts $post
is a PgSelectSingleStep
, therefore it's safe
to use .select()
:
const schema = makeGrafastSchema({
objects: {
Post: {
assertStep: PgSelectSingleStep,
plans: {
truncatedBody($post: PgSelectSingleStep) {
return $post.select(sql`left(body, 200)`, TYPES.text);
},
},
},
},
typeDefs: /* GraphQL */ `
type Post {
truncatedBody: String
}
# ...
`,
});
Example - assertion function
Using an assertion function allows you to accept a wider range of steps, perform duck typing, or throw more helpful error messages.
const schema = makeGrafastSchema({
objects: {
Post: {
assertStep($step) {
if ($step instanceof PgSelectSingleStep) return;
throw new Error(
`Type 'Post' expects PgSelectSingleStep; received ${$step.constructor.name}`,
);
},
plans: {
truncatedBody($post: PgSelectSingleStep) {
return $post.select(sql`left(body, 200)`, TYPES.text);
},
},
},
},
// ...
});
Example - extensions
If using raw GraphQL.js objects, the assertStep
method goes inside of
extensions.grafast
:
import { GraphQLObjectType, GraphQLString } from "graphql";
import { PgSelectSingleStep, sql, TYPES } from "@dataplan/pg";
const Post = new GraphQLObjectType({
name: "Post",
extensions: {
grafast: {
assertStep: PgSelectSingleStep,
},
},
fields: {
truncatedBody: {
type: GraphQLString,
extensions: {
grafast: {
plan($post: PgSelectSingleStep) {
return $post.select(sql`left(body, 200)`, TYPES.text);
},
},
},
},
},
});
Execution order & side effects
Grafast is declarative: steps form a directed acyclic graph (DAG) and only dependencies determine order. There is no implicit procedural sequencing. Two rules matter:
- Dependencies before dependents.
- Steps implicitly depend on the previous side effect step, if any.
This can be surprising during mutations: steps created before a mutation might execute after the mutation unless one of the rules above says otherwise.
Example
const $before = users.get({ id: $rowId });
const $valueBefore = $before.get("value1");
const $after = updateUser($rowId);
const $valueAfter = $after.get("value1");
const $log = sideEffect(
[$valueBefore, $valueAfter],
([before, after]) => void console.log({ before, after }),
);
Default graph (no extra side effects):
The engine may execute $after
(mutation) before $valueBefore
, because
nothing forbids it according to the two rules above.
Force “read-before-write” by marking $valueBefore
as a side effect:
const $before = users.get({ id: $rowId });
const $valueBefore = $before.get("value1");
+$valueBefore.hasSideEffect = true;
const $after = updateUser($rowId);
const $valueAfter = $after.get("value1");
const $log = sideEffect(
[$valueBefore, $valueAfter],
([before, after]) => void console.log({ before, after })
);
This adds an implicit edge from $valueBefore
to later steps (including $after
):
Now $valueBefore
must run before $after
, so your log shows the true “before” and “after”.
When to use which
- Prefer explicit data deps when possible (e.g. make the read feed the write).
- Use
hasSideEffect = true
when you need ordering without data flow (logging, metrics, authorization gates, idempotency checks). - Don’t sprinkle
hasSideEffect
on hot paths unnecessarily; it reduces reordering freedom.