Handling complex inputs
It’s totally fine to just take the raw input values and handle them in a plan resolver however you like. The features on this page are optional: they’re provided for more advanced use cases where they can help keep your code clean, composable, and easier to reason about.
Before diving in, it’s useful to distinguish between two patterns for handling complex input data:
- Baking — transforming input data into another form (e.g. a backend representation).
- Applying — transforming behavior by having input data modify the actions a step will take.
Both patterns work by recursing over the tree of input values (objects, lists, scalars) at runtime and invoking per-field or per-type logic as needed.
Baking (data transformations)
Baking is about data in, data out. You start with a GraphQL input object value and transform it into the representation your backend needs. For example:
input AvatarInput {
url: String!
# ...
}
input UserInput {
userId: Int!
avatar: AvatarInput
# ...
}
At runtime, you might receive:
{ "userId": 27, "avatar": { "url": "http://..." } }
and bake it into your backend representation:
{ "user_id": 27, "avatar_url": "http://..." }
Schema
Baking is defined per input object type with the
extensions.grafast.baked(input, info)
method, which is an
InputObjectTypeBakedResolver
:
type InputObjectTypeBakedResolver = (
input: Record<string, any>,
info: {
schema: GraphQLSchema;
type: GraphQLInputObjectType;
applyChildren(parent: any): void;
},
) => any;
input
is the raw GraphQL input object value.- Call
info.applyChildren(parent)
if you want to recurse with a different parent object; this uses the “applying” runtime behaviors seen below
Return value becomes the “baked” representation.
If you don’t implement baked
, the raw input passes through unchanged.
Plan resolver
You can use the fieldArgs.getBaked(path)
method to produce the baked version
of a raw input value:
const $baked = fieldArgs.getBaked(["path", "to", "input"]);
Applying (behavior transformations)
Applying is about using input values to change what a step does. Typical examples include pagination, filtering, or custom ordering.
Rather than producing a baked value, inputs are applied to a step that knows how to accept them — e.g. a step that uses a request builder to prepare the request to send to a database, URL endpoint, or any other data source.
Schema
Applying is defined per input field with a
inputField.extensions.grafast.apply(target, input, info)
method, which is an InputObjectFieldApplyResolver
:
type InputObjectFieldApplyResolver<TParent = any, TData = any, TScope = any> = (
target: TParent,
input: TData,
info: {
schema: GraphQLSchema;
fieldName: string;
field: GraphQLInputField;
scope: TScope;
},
) => any;
target
is the parent object (e.g. the request builder) passed in.input
is the raw input value for this input field
The apply method is expected to mutate target
, either directly, or by
returning a Modifier (see fan-out and fan-in below).
The apply method may return undefined
, a new parent object to use with
children when recursing, or (for list types) a factory function which is called
for each list item to produces a parent object for that item to use. Returning a
factory function (e.g. () => new Thing()
) enables patterns like OR
filters
where each entry in a list gets its own sub-condition.
Example
Simplified from postgraphile-plugin-connection-filter
:
fields["or"] = {
apply(
parent: PgCondition,
value: ReadonlyArray<LogicalOperatorInput> | null,
) {
if (value == null) return;
const orCondition = parent.orPlan();
// Each list entry is added to `orCondition` only once the entry itself is
// fully resolved - if an individual entry produces many clauses, they must be
// joined with `AND` first before being incorporated into the `OR`.
return () => orCondition.andPlan();
},
type: new GraphQLList(new GraphQLNonNull(UserFilter)),
};
Fan-out and fan-in
When you return a new object for children to use, you’ve “fanned out”: each
child field can add its own modifications in isolation. But what if you need to
fan back in afterwards — e.g. gather all child conditions, combine them with
OR
, and then apply that combined condition to the parent?
That’s where modifiers come in.
If the object you return extends the Modifier
class, Grafast will track it
during the apply process. After the entire input tree has been traversed,
Grafast goes back through the collected modifiers in reverse order and calls
their apply()
methods. This gives you a final hook to push the combined
results back up into the parent.
/** Will be applied in reverse order once fan-out is complete */
const currentModifiers: Modifier<any>[] = [];
/**
* Modifiers modify their parent (which may be another modifier or anything
* else). First they gather all the requirements from their children (if any)
* being applied to them, then they apply themselves to their parent. This
* application is done through the `apply()` method.
*/
export abstract class Modifier<TParent> {
constructor(protected readonly parent: TParent) {
currentModifiers.push(this);
}
/**
* In this method, you should apply the changes to your `this.parent` plan
*/
abstract apply(): void;
}
Using a modifier makes the OR
example cleaner: each child contributes its
conditions to the modifier; once all entries are done, the modifier’s apply()
method is called to add the combined OR
clause to its parent.
Plan resolver
FieldArgs.apply()
is how you apply input arguments to a step:
function usersPlan($query, fieldArgs) {
const $users = UsersStep.find();
// Apply all arguments to the users step
fieldArgs.apply($users);
return $users;
}
You can also target a specific argument path, and optionally provide a callback to transform the step’s value before applying:
fieldArgs.apply($target, ["filter"], (requestBuilder, inputValue) => {
// Convert the step’s value (e.g. request builder) into a filter builder object
return new FilterBuilder(requestBuilder, inputValue); // < a Modifier
});
Applyable steps
// Simplified types
type ApplyableStep = Step & {
apply($cb: Step<(arg: any) => void>): void;
};
For applying to work, the $target
you pass to fieldArgs.apply($target)
must
be an applyable step — i.e. a step that supports input-driven modifications
at runtime.
An applyable step has two responsibilities to ensure all inputs get a chance to mutate the builder before the step executes its action:
Collecting callback steps at planning time
The step must implement an apply($cb: Step<(parent: any) => void>)
method.
This should register $cb
as a unary dependency. Since multiple arguments
may apply to the same step, you should expect multiple calls to .apply()
and thus store all dependency IDs in an array:
class MyRequestStep extends Step {
applyDepIds: number[] = [];
apply($cb: Step<(parent: any) => void>) {
this.applyDepIds.push(this.addUnaryDependency($cb));
}
// ...
}
Executing the collected callbacks at runtime
In execute()
, the step should prepare its internal object (e.g. a request
builder). This object must not be a Modifier
; it should be the mutable
thing you want modified. Then iterate through the collected callbacks and
invoke them in order, passing in this object. Finally, carry out the request
using the fully-populated builder:
class MyRequestStep extends Step {
// ...
async execute(details) {
const { values, indexMap } = details;
const builder = {
//...
// Populate your request builder with the things you already know
};
// Apply the changes from all the `.apply($cb)` calls
for (const applyDepId of this.applyDepIds) {
const applyCallback = values[applyDepId].unaryValue();
applyCallback(builder);
}
// Execute the underlying request, and tie the results back
const results = await builder.execute();
return indexMap((batchIndex) => results.getResultForIndex(batchIndex));
}
}
Here's an example demonstrating how to use .apply()
to dynamically change the
order of results from a database dynamically based on user input:
import { Step, ExecutionDetails, GrafastResultsList, Maybe } from "grafast";
interface MyQueryBuilder {
orderBy(columnName: string, ascending?: boolean): void;
}
type Callback = (builder: MyQueryBuilder) => void;
class MyQueryStep extends Step {
private applyDepIds: number[] = [];
// [...]
// this.foreignKeyDepId = this.addDependency($fkey);
// [...]
// Handling `Step<Callback>` is enough for some use cases, but
// handling this combination is the most flexible.
apply($cb: Step<Maybe<Callback | ReadonlyArray<Callback>>>) {
this.applyDepIds.push(this.addUnaryDependency($cb));
}
async execute(
executionDetails: ExecutionDetails,
): Promise<GrafastResultsList<Record<string, any>>> {
const { values, indexMap } = executionDetails;
const foreignKeyEV = values[this.foreignKeyDepId];
// Create a query builder to collect together the orderBy values
const orderBys: string[] = [];
const builder: MyQueryBuilder = {
orderBy(columnName, asc = true) {
orderBys.push(`${columnName} ${asc ? "ASC" : "DESC"}`);
},
};
// For each of the `apply()` callbacks, run it against the query builder
for (const applyDepId of this.applyDepIds) {
const callback = values[applyDepId].unaryValue();
if (Array.isArray(callback)) {
callback.forEach((cb) => cb(builder));
} else if (callback != null) {
callback(builder);
}
}
// Now we can use `orderBys` to build a query:
const query = `
select *
from my_table
where foreign_key = any($1)
order by ${orderBys}
`;
// Then we can fetch the data:
const allForeignKeys = indexMap((i) => foreignKeyEV.at(i));
const rows = await runQuery(query, [allForeignKeys]);
// And return the right data to go with each input value:
return indexMap((i) => {
const foreignKey = foreignKeyEV.at(i);
return rows.filter((r) => r.foreign_key === foreignKey);
});
}
}
Under the hood
fieldArgs.apply()
uses the applyInput()
step internally. You don’t normally
call this directly, but you may see ApplyInput
nodes in plan diagrams.
applyScope
methods (experimental) can provide additional scope values to be
passed through during applying — these live on input objects or enums at
extensions.grafast.applyScope()
.
Choosing between baking and applying
- If you just need to transform data into the shape your backend expects, use baking.
- If you need to influence behavior — e.g. tell a step how to filter, sort, or paginate — use applying.
You can freely mix the two patterns in the same schema depending on what makes sense for each input.