Step classes
A step details a particular action or transform that needs to be performed when executing a GraphQL request. Each step is an instance of a specific step class, produced during the planning of a field. Each step may depend on 0 or more other steps, and through these dependencies ultimately form a directed acyclic graph which we refer to as the execution plan. Thus the steps are the building blocks of an execution plan.
A range of standard step classes are available for you to use; but when these aren't enough you are encouraged to write your own (or pull down third party step classes from npm or similar).
Step classes extend the Step class, the only required method to
define is execute, but you may also implement the various lifecycle methods,
or add methods of your own to make it easier for you to write plan
resolvers.
/** XKCD-221 step class @ref https://xkcd.com/221/ */
class GetRandomNumberStep extends Step {
execute({ count }) {
return new Array(count).fill(4); // chosen by fair dice roll.
// guaranteed to be random.
}
}
function getRandomNumber() {
return new GetRandomNumberStep();
}
If you add any custom fields or methods to your step classes we recommend that you prefix them with your initials or organization name to avoid naming conflicts occurring.
Don't subclass steps, this will make things very confusing for you. Always
inherit directly from Step.
__) prefix is reserved for internal usageStep classes whose names start with two underscores (__) are internals and
must never be directly created by the user. Further, you must never name your
own step classes such that they begin with two underscores.
Step function
By convention, we always define a function that constructs an instance of our
class. A step function is typically named after the corresponding step class,
but with the first letter lowercased and the Step suffix omitted; for example
the AddStep step class would have a step function named add:
function add($a, $b) {
return new AddStep($a, $b);
}
There are many reasons for this indirection:
- simplicity, conciseness and readability of plan resolvers: no
newkeyword, no redundantSteptext, no harsh PascalCase identifiers; feels lighter - flexible API: the function can manipulate arguments before sending them on to a class constructor, allowing for overloads, complex generics, or tagged template literals
- caching: it's simple to add a cache at the step function level, much harder to handle that in the constructor of a step class
- branching: a function may choose to return a different step under certain circumstances, for example if it determines that the input is a constant
- evolution: a step function should express the developer's intent; how that
maps to steps under the hood is less important - e.g. a
sideEffect()step might start out by building aLambdaStepand marking it as having side effects, but later evolve to using its ownSideEffectStepclass, without consuming code needing to change
The tiny additional cost of these step functions is only incurred at plan-time, and plans are re-used for similar future requests; the cost of an additional function call during planning is negligible.
Lifecycle methods
execute
execute(details: ExecutionDetails): PromiseOrDirect<GrafastResultsList>
// These are simplified types
interface ExecutionDetails {
count: number; // Size of the batch being processed
values: [...ExecutionValue[]]; // Represents your dependencies
stream: ExecutionDetailsStream | null; // Relates to `@stream`/`subscription`
// ...
indexMap<T>(callback: (batchIndex: number) => T): ReadonlyArray<T>;
indexForEach(callback: (batchIndex: number) => any): void;
}
type ExecutionValue<TData = any> = {
isBatch: boolean;
unaryValue(): TData; // Throws if `isBatch` is true.
at(batchIndex: number): TData; // Get data entry (`0 <= batchIndex < count`)
};
interface ExecutionDetailsStream {
initialCount: number;
}
type GrafastResultsList<T = any> = ReadonlyArray<PromiseOrDirect<T>>;
The one method that your step class must define is execute. It uses a similar
approach to
DataLoader's batch function,
but Grafast steps are much more powerful thanks to the additional dependencies
and lifecycle methods.
When the step class adds a dependency (with this.addDependency($step) or
similar) a dependency index, depIndex, is returned. Dependency indexes start
at 0 and increase monotonically (a dependency can never be removed1).
At execution time the step is passed ExecutionDetails which contains
details and helpers for execution.
details.count
One key property is the size (details.count) of the batch; execute must
return a list of length count, such that each entry in this list corresponds
with the values in the batch at the same batch index. To save you from having to
loop yourself, the details.indexMap helper will call the given callback with
each index in the batch in turn; this is commonly used when returning the result
of the execute method.
details.values
details.values is also critical: an ordered array of execution values, one for
each dependency. You can retrieve the execution value for a given dependency via
its depIndex as const depEv = details.values[depIndex]. Once you have an
execution value, you can retrieve the value for a given index in the batch via
const value = depEv.at(batchIndex).
Grafast tracks which steps will always represent exactly one value (e.g. the
GraphQL context, input values passed as field arguments, constants, etc), and
which will represent a batch (e.g. values inside of a list). The former are
stored in "unary execution values", and the latter in "batch execution values".
For convenience, these both expose the .at(batchIndex) method so most of the
time you do not need to know the difference and can just think of them all as
simply "execution values".
Unary dependencies are useful for request-global concerns such as pagination
arguments, authentication credentials, database clients and the like. Should you
know that your dependency is a unary step (e.g. because you added it via
this.addUnaryDependency($step)), you may get its singular value via const value = depEv.unaryValue() (but if it wasn't unary then this will throw!).
If you have a fixed number of dependencies it common to destructure them at the top of the execute method:
class MyStep extends Step {
constructor($a, $b, $c) {
this.aDepIndex = this.addUnaryDependency($a);
this.bDepIndex = this.addDependency($b);
this.cDepIndex = this.addDependency($c);
}
execute(details) {
const { values } = details;
// Retrieve the execution values in the same order we added dependencies
const [aEv, bEv, cEv] = values;
// The unary dependency value can be shared throughout execute
const a = aEv.unaryValue();
// We must return a list of the correct length in the correct order.
return details.indexMap((batchIndex) => {
// Batch depenencies may have a different value at each index.
const b = bEv.at(batchIndex);
const c = cEv.at(batchIndex);
return a + b + c;
});
}
}
If the step has no dependencies then values will be a 0-tuple (an empty
tuple), but that doesn't mean the batch is empty or has size one, count may
be any positive integer. It's therefore recommended that you use indexMap to
generate your results in the vast majority of cases:
return indexMap((i) => 42);
You might wonder why the values input is a tuple of execution values, rather
than a list of tuples. The reason comes down to efficiency, by using a tuple of
execution values, Grafast only needs to build one new array (the tuple),
and into that array it can insert the results from previously executed steps
unmodified. Were it to provide a list of tuples instead then it would need to
build N+1 new arrays, where N was the number of values being processed, which
can easily be in the thousands.
Avoid N+1!
Critically, the execute method should never do async actions whilst looping
over the values, otherwise you introduce the N+1 problem. Most execute methods
should only perform a single async action. Typically it looks like this:
- Extract and identify the execution value for each dependency.
- Map over the indices to prepare the input for your async task.
- Execute the async task via a single
await(or promise), not in a loop. - Map over the input values again, for each index finding the value from step 3 that correlates.
Here's a hypothetical example, concentrate on the execute() method and see how
the steps outline above play out:
import { languageService } from "./services/language";
class TranslationStep extends Step {
langDepIndex: number;
textDepIndex: number;
constructor($language: Step<string>, $text: Step<string>) {
super();
// Add our dependencies
this.langDepIndex = this.addDependency($language);
this.textDepIndex = this.addDependency($text);
}
execute(details) {
// 1. Extract and identify the execution value for each dependency:
const langEv = details.values[this.langDepIndex];
const textEv = details.values[this.textDepIndex];
// 2. Map over the indices to prepare the input for our translation API:
const specs = details.indexMap((batchIndex) => {
const language = langEv.at(batchIndex);
const sourceText = textEv.at(batchIndex);
return { language, sourceText };
});
// 3. Execute the translations via a single `await`, not in a loop:
const translations = await languageService.batchTranslate(specs);
// 4. Finally, return results that correlate with the inputs:
return indexMap((batchIndex) => {
const language = langEv.at(batchIndex);
const sourceText = textEv.at(batchIndex);
const match = translations.find(
(t) => t.language === language && t.sourceText === sourceText,
);
return match?.translation;
});
}
}
Errors
If the execute method throws (or rejects), then all entries in the batch for
this step will fail with the same error.
If you want one of your entries to throw an error, but the others shouldn't,
then set the corresponding entry in the results list to flagError(error)
(where error is the error you want to use).
If it's more convenient and performance isn't a huge concern, you may return a
list of values and promises, and reject specifically the promise in that list
relating to that item. You can do this even if you don't use promises for any of
the other values, and even if your execute method is not marked as async.
isSyncAndSafeYou must not add per-value errors if you have marked your step class with
isSyncAndSafe = true; the results of doing so are undefined.
deduplicate
This method is optional.
// Where `MyStep` is the current step class
deduplicate(peers: readonly MyStep[]): readonly MyStep[]
After a field has been fully planned, Grafast will call this method on each new step when more than one step exists in the draft execution plan with the same step class and the same dependencies. These "peers" (including the step itself) will be passed in to the deduplicate method, and this method should return the list of the peers that are equivalent (or could cheaply be made equivalent) to the current step.
To cause your step class to never be deduplicated, either don't implement this
method or simply return [];.
You should not mutate your peers or yourself during this method, instead use the
deduplicatedWith method to pass any necessary information to your replacement.
deduplicatedWith
This method is optional.
deduplicatedWith(replacement: Step): void
If Grafast determines that this specific step instance should be replaced
by one of its peers (thanks to the results from deduplicate above), Grafast will call deduplicatedWith on the step that is being replaced, passing the
step that it is being replaced with as the first argument. This gives your step
a chance to pass any information to the peer that may be necessary to make the
peers truly equivalent.
It's rare to need this functionality, so let's work through a hypothetical.
Imagine step $select1 represents the SQL query SELECT id, name FROM users
and step $select2 represents SELECT id, avatar_url FROM users.
Lets further imagine that we've optimised our SQL handling step classes such
that both $select1 and $select2 return each other from their
deduplicate method (because they can "cheaply" be made equivalent).
Assuming Grafast chooses to keep $select1 and
"deduplicate" (get rid of) $select2, Grafast would then call
$select2.deduplicateWith($select1). This would give $select2 a chance to
inform $select1 that in order to be completely equivalent, it must also
select avatar_url.
In this scenario, at the end of deduplication, only $select1 would remain and
it would represent the SQL query SELECT id, name, avatar_url FROM users.
optimize
This method is optional.
optimize(options: { stream: {} | null, meta?: Record<string, unknown> }): Step
This method is called on each step during the optimize lifecycle event. It
gives the step a chance to request that its ancestors do additional work,
and/or replace itself with another step (new or old). If it does not want
to be replaced, it can simply return itself: return this;.
This one method unlocks a significant proportion of Grafast's efficiency improvements. Here are some common use cases that it can be used for:
Use case: inlining
optimize is often useful for "inlining" the requirements of this step into an
ancestor and then (optionally) replacing itself with a simple access or
remapKeys step. This reduces the number of asynchronous tasks the request
needs to execute and can enable significantly more efficient data fetching.
Use case: plan-time only steps
Another use case for optimize is to make planning-time only steps "evaporate"
by replacing them with their parent or a different step.
The loadMany step represents each record via a LoadedRecordStep instance
which can be used to .get(attr) a named attribute. This reference is then
stored, and at optimize time the LoadedRecordStep can tell the LoadStep to
request this attribute (so that the loadMany callback doesn't need to do the
equivalent of SELECT * - it can be more selective). However, since
LoadedRecordStep has no run-time behavior (only planning-time behavior) it
can simply replace itself during optimize with its parent step (typically an
__ItemStep).
The built-in each step uses optimize to replace itself with the underlying
list where possible.
Use case: simplification
Another use case is simplification.
For example the step representing access(access(access($a, 'b'), 'c'), 'd')
could be simplified down to just access($a, ['b', 'c', 'd']), reducing the
number of steps in the operation plan.
Similarly first(list([$a, $b])) can be simplified to just $a.
options.meta
If the step sets a this.optimizeMetaKey, then options.meta will be a Record
that can be mutated during optimize. This is useful for steps of the same class
to communicate with each other out of band, for example list() and object()
use it to ensure that if they replace themselves with constants, they do so with
the same constant when representing the same values.
finalize
This method is optional.
finalize(): void
This method is called on each step during the finalize lifecycle event. It gives each step a chance to prepare for execution, doing anything that needs to be done just once. A step that deals with a database might precompile its SQL, a step that transforms an object might build an optimized function to do so, there are so many other actions that this step can be used for.
super.finalize() last!It is critical that the step calls super.finalize() at the end of the
finalize() step:
finalize() {
// ... your code here ...
super.finalize();
}
Importantly during this lifecycle event the step should only worry about its own
concerns and should not attempt to communicate with its ancestors or descendents
— they may not be the steps that it remembers as they may have been
switched out during optimize! If the step needs to communicate with its
ancestors it should use the optimize method to do so.
Custom methods and conventions
Your step may implement any additional methods that it needs; however certain methods
have special meaning. For example, if your step represents an object then it should
implement the .get(key) method; and if the step represents an array/list then it
should implement the .at(index) method.
These conventions are still evolving, and more may be added as common usage patterns are detected. Some likely candidates for future reserved methods include: import, export, defer, filter, order and merge. To avoid conflicts with built in and future methods, consider using a prefix when naming custom methods.
Functions that have special meanings/expectations can be found below:
at
at(index: number): Step
Implement .at() if your step represents a list or an array. It should accept a single argument, an
integer, which represents the index within the list-like value which should be accessed.
Usage:
import { access } from "grafast";
class MyListStep extends Step {
// ...
at(index) {
// Your step may implement a more optimized solution here.
return access(this, index);
}
}
If your step implements .at(), make sure it meets the expectations: i.e. it
correctly accepts a single argument: an integer. Grafast relies on this
assumption; unanticipated behaviours may result from steps which don't adhere to
these expectations.
get
get(key: string): Step
Implement .get() if your step represents an object. It should accept a single argument, a
string, which represents an attribute to access an object-like value.
import { access } from "grafast";
class MyObjectStep extends Step {
// ...
get(key) {
// Your step may implement a more optimized solution here.
return access(this, key);
}
}
If your step implements .get(), make sure it meets the expectations:
i.e. it correctly accepts a single argument of a string.
Grafast relies on this assumption; unanticipated behaviours may result
from steps which don't adhere to these expectations.
.get(key) with per-key type safetyIf you have put effort into making your get function type safe (such that
accessing different keys returns different values), this will not work with
get($step, key) out of the box due to limitations
of TypeScript.
To work around this, you can add the __inferGet fake property to your class
which should be an object with the same keys as your get function accepts, and
the value should be the return type for that key - i.e. it's very similar to
your get method itself:
class MyStep extends Step {
__inferGet?: {
[TKey in keyof MyData]: MySpecialStep<MyData[TKey]>;
};
get<TKey extends keyof MyData>(key: TKey): MySpecialStep<MyData[TKey]> {
// ...
}
}
items
items(): Step<any[]>
Implement .items() if your step represents a collection and you want to give
users an easy way of accessing the items of your collection (as opposed to
metadata you may also wish to make available, such as pagination info). It
should accept no arguments (later we might support options related
to streaming, so do not implement arguments!) and it should expect to be called
zero or more times.
import { access } from "grafast";
class MyCollectionStep extends Step {
// ...
items() {
// Update this to access the correct property needed for the items in your
// collection; you may also choose to track that this was requested and
// thus ensure that fetches only go ahead when necessary.
return access(this, "items");
}
}
If your step implements .items(), make sure it meets the expectations:
i.e. it does not require any arguments.
Grafast relies on this assumption; unanticipated behaviours may result
from steps which don't adhere to these expectations.
apply
apply($cb: Step<(parent: any) => any): void
Implement .apply() if your step wants to allow for runtime modification of
its action based on non-trivial input values - for example, if your step
represents an SQL query it might want to allow dynamic WHERE or ORDER BY
clauses based on input arguments to a GraphQL field. .apply() will accept a
single argument, a step that represents a runtime callback function. The step
should then call this function from .execute() before running its main
action.
For more information, see handling complex inputs.
toTypename
toTypename($step: Step): Step<string>
Enables a step to return a step that yields the typename, used by the
defaultPlanType polymorphic type resolver function when the GraphQL union or
interface type does not implement the planType method. If not implemented,
this default function will fall back to get($step, '__typename').
toSpecifier
toSpecifier($step: Step): Step
This function name is reserved for convenience such that $step.toSpecifier()
should mean the same as abstractType.toSpecifier($step). Grafast does not
actually use this method (currently), but it can be convenient for users so
we reserve it for specifically this use case.
Importantly, Grafast does not require that a specifier takes a particular form, it's an agreement between the steps you're using and the polymorphic types (unions and interfaces) that you've implemented. We strongly recommend it's a plain-old JavaScript object (POJO) though!
class MyStep extends Step {
// ...
toSpecifier() {
return object({
__typename: this.get("type"),
id: this.get("id"),
});
}
}
const Animal = new GraphQLInterfaceType({
name: "Animal",
// ... fields ...
toSpecifier($step) {
// Call .toSpecifier() if it exists, otherwise use the step directly
return $step.toSpecifier?.() ?? $step;
},
planType($specifier, info) {
// Extract the property that indicates the type from above
const $__typename = get($specifier, "__typename");
return { $__typename };
},
});
const Cat = new GraphQLObjectType({
name: "Cat",
interfaces: [Animal],
// ... fields ...
extensions: {
grafast: {
planType($stepOrSpecifier) {
const $id = get($specifier, "id");
return cats.get({ id: $id });
},
},
},
});
listItem
listItem($item: __ItemStep): Step
If your step represents a list, it may implement .listItem() to wrap the
Grafast internal __ItemStep that represents items of this list in a more
useful step for child field plan resolvers to
use.
A step class that implements .listItem() is called a "list-capable step" (it
implements ListCapableStep).
Example
Here our list capable step wraps the __ItemStep in a MyListItemStep to
ensure that the child field plan resolvers have access to the methods they
expect:
class MyListStep extends Step implements ListCapableStep {
listItem($item: __ItemStep): SingleItemFromMyListStep {
return myListItem($item);
}
}
This might result in a plan diagram as such:
Built in methods
Your custom step class will have access to all the built-in methods that come
as part of Step.
addDependency
When your step requires another step's value in order to execute (which is the
case for the majority of steps!) it must add a dependency via the
this.addDependency($otherStep) method. This method will return a number,
which is the index in the execute values tuple that represents this step.
It's common to do this in the constructor, but it can be done at other stages too, for example during the optimize phase a step's descendent might ask it to do additional work, and that work might depend on another step.
In the getting started guide we saw the constructor for the AddStep step
class added two dependencies:
class AddStep extends Step {
constructor($a, $b) {
super();
this.addDependency($a); // Returns 0
this.addDependency($b); // Returns 1
}
}
You must never store an instance of another step directly (or indirectly) in your step class. Steps come and go at quite a rate during planning - being removed due to deduplicate, optimize, or tree shaking lifecycle events. Referring to a step that no longer exists in the execution plan is likely to make your program have very unexpected behaviors and/or crash.
In the exceedingly unlikely event that you need to reference another step but it
is not a dependency, use:
this.refIdx = this.addRef($step, "[ENTER REASON WHY I'M NOT USING addDependency HERE]").
You can then use const $ref = this.getRef(this.refIdx) to retrieve the step at
a later time; if it exists it may be different to the step you remember, but it
should serve the same purpose. However, it may have been deleted due to tree
shaking - if this causes a problem, then maybe that step should have been a
dependency after all?
addUnaryDependency
Sometimes you'll want to ensure that one or more of the steps your step class
depends on will have exactly one value at runtime; to do so, you can use
this.addUnaryDependency($step) rather than this.addDependency($step). This
asserts that the given dependency is a unary step (a regular step which the
system has determined will always represent exactly one value) and is primarily
useful when a parameter to a remote service request needs to be the same for
all entries in the batch; typically this will be the case for ordering,
pagination and access control.
this.addUnaryDependency($step) will raise an error during planning if the
given $step is not unary, so you should be very careful using it. If in
doubt, use this.addDependency($step) instead.
The system steps which represent request–level data (e.g. context, variable and argument values) are always unary steps, and Grafast will automatically determine which other steps are also unary steps.
It's generally intended for addUnaryDependency to be used for arguments and
their derivatives; it can also be used with context-derived values, but there
is complexity when it comes to mutations since context is mutable (whereas
input values are not).
getDep
Pass in the number of the dependency (0 for the first dependency, 1 for the
second, and so on) and Grafast will return the corresponding step. This should
only be used before or during the optimize phase.
For example in the AddStep example above we might have:
const $a = this.getDep(0);
const $b = this.getDep(1);
getDepDeep
EXPERIMENTAL
Like getDep, but skips over __ItemStep and similar built-in intermediary
steps to try and get to the original source. Typically useful if you have a
step representing an entry from a collection (e.g. a database "row") and you
want to get the step representing the entire collection (e.g. a database
SELECT statement).
toString
Pretty formatting for the step.
console.log("$a = " + $a.toString());
toStringMeta
You may override this to add additional data to the toString method (the data
that would occur between the triangular brackets).
Other properties
id
Every step is assigned a unique id by Grafast. This id may be a string, number, or symbol - treat it as opaque.
Currently this value is a number, but Grafast may change it to be a string or
symbol in a minor release so you should not rely on its data type. You may,
however, rely on String(id) being unique across an operation plan.
hasSideEffects
Set this true if the step has side effects (i.e. causes a mutation) - if this is true then Grafast will not remove this step during tree shaking, and will ensure that the step is executed even if it doesn't appear to be used in any output.
In languages like Scala/Haskell, a side effect is anything that is visible outside of the function/component itself: a network call, logging, etc. You might have heard that "a pure function has no side effects visible to any observer."
Not so with Grafast, we're not talking about pure functions here. If your step is meant to fetch data from a remote source and return it as part of the result, then that fetch is not a side effect of the step; it's the main event. If nothing ever depends on that data, then we can determine it wasn't needed and we can "tree shake" the step out of the plan such that that fetch never happens. Similarly, if two steps are doing the exact same thing, we may be able to de-duplicate them such that two fetches become one.
For Grafast, a side effect is something that should happen even if nothing ever depends on the result. It's something that must not be tree-shaken away or de-duplicated. The most common use case for this is mutations: adding, deleting, or updating state in your backend storage, triggering an event to be sent, etc.; however it may also be used for things like logging where you want the log statement to run even if nothing ever depends on the output of the step:
const $step = doSomething();
// Nothing depends on this, but because it hasSideEffects it will not be tree
// shaken away, and will be executed.
sideEffect($step, (value) => void console.log(value));
return $step;
If you're looking for the equivalent of a "pure function" in Grafast, the
closest is a step that isSyncAndSafe...
isSyncAndSafe
This is a very dangerous optimization, only use it if you're 100% sure you know what you are doing!
Setting this true is a performance optimization, but it comes with strong rules; we do not test you comply with these rules (as that would undo the performance gains) but should you break them the behaviour is undefined (and, basically, the schema may no longer be GraphQL compliant).
Do not set this true unless the following hold:
- The
executemethod must be a regular (not async) function - The
executemethod must NEVER return a promise, iterator, or similar - The values within the list returned from
executemust NEVER include promises, iterators, or similar - The result of calling
executeshould not differ after astep.hasSideEffectshas executed (i.e. it should be pure, only dependent on its deps and use no external state)
It's acceptable for the execute method to throw if it needs to, but this will
impact every batched value.
This optimisation applies to the majority of the built in plans and allows the engine to execute without needing to resolve any promises which saves precious event-loop ticks.
isOptimized
This is set true after the step has been optimized.
allowMultipleOptimizations
Set this true if your plan's optimize method can be called a second time.
In this situation it's likely that your dependencies (or their dependencies)
will not be what you expect them to be (e.g. a PgSelectSingleStep might
become an AccessStep due to having been optimized). This, and the fact
that it's rarely needed, is why it's not enabled by default.
metaKey
EXPERIMENTAL
You may optionally set this to indicate a key to use for which meta object to
be passed in to execute (typically used for caching). To make it unique to
the instance of your step, in the constructor after calling super(), set it
as this.metaKey = this.id;. If you want to share the same meta object
between all steps of a given class, that class may set metaKey to be the name
of the class. You can even set it to a shared value between multiple step
classes (a "family" of step classes) should that make sense. By default no
metaKey is set, and your class will therefore have no meta object.
The loadMany and loadOne standard steps make use of this key to optimize
value caching, you may want to look at them for more inspiration.
optimizeMetaKey
EXPERIMENTAL
Makes the meta property available on optimize options.
The list and object standard steps make use of this key for caching
constants consistently, you may want to look at them for more inspiration.