loadMany
Similar to DataLoader's load method, uses the given callback function to
read many results from your business logic layer. To load just one, see
loadOne
.
Simple usage
In our plan resolver we might load a user's friends like this:
function User_friends($user) {
const $userId = get($user, "id");
return loadMany($userId, friendsByUserId);
}
Where our friendsByUserId
loader might be the same one that we would use with
DataLoader; here's a fictional example of how it might look:
import { db } from "./db"; // Assume this is your database client
// This could be the same callback you use with DataLoader!
async function friendsByUserId(userIds) {
const rows = await db.query(sql`
select u.id as _user_id, f.*
from users u
inner join friendships on (friendships.user_id = u.id)
inner join users f on (f.id = friendships.friend_id)
where u.id = any(${sql.value(userIds)})
`);
// Return an array of arrays, where each inner array contains the friends
// for the respective userId we were passed.
return userIds.map((id) => rows.filter((r) => r._user_id === id));
}
loader
inlineThe loader
function acts as a gateway between the Grafast plan execution and
your business logic; you should keep it in a centralized location so that it may
be used by multiple plan resolvers easily. This also allows for equivalent calls
to this same loader to be deduplicated for increased performance.
Enhancements over DataLoader
Thanks to the planning system in Grafast, loadMany
can expose features that
are not possible in DataLoader.
Only requesting the required attributes
One such feature is the ability to only request the attributes that are read
by our downstream consumers. The list of requested attributes are automatically
passed to your load
callback via attributes
property of the info
object
passed as the second argument to your load callback.
Here's the previous example modified so we only request the needed attributes:
import { db } from "./db"; // Assume this is your database client
async function friendsByUserId(userIds, info) {
// Only request the required columns (and avoid SQL injection!)
const columns = info.attributes.map((attr) => sql.identifier("f", attr));
const rows = await db.query(sql`
select u.id as _user_id, ${sql.join(columns, ",")}
from users u
inner join friendships on (friendships.user_id = u.id)
inner join users f on (f.id = friendships.friend_id)
where u.id = any(${sql.value(userIds)})
`);
return userIds.map((id) => rows.filter((r) => r._user_id === id));
}
Setting custom params
Another feature that we can do easily with Grafast is to pass parameters to
our loader via .setParam(key, value)
(where value
may be a unary step,
or a static value) in order to handle common concerns such as filtering,
ordering, pagination and so on:
function User_friends($user, fieldArgs) {
const $userId = get($user, "id");
const $friends = loadMany($userId, friendsByUserId);
// Appears in info.params.includeArchived
$friends.setParam("includeArchived", fieldArgs.$includeArchived);
return $friends;
}
Your loader can access these params via the info.params
object:
import { db } from "./db"; // Assume this is your database client
async function friendsByUserId(userIds, info) {
const columns = info.attributes.map((attr) => sql.identifier("f", attr));
const rows = await db.query(sql`
select u.id as _user_id, ${sql.join(columns, ",")}
from users u
inner join friendships on (friendships.user_id = u.id)
inner join users f on (f.id = friendships.friend_id)
where u.id = any(${sql.value(userIds)})
and friendships.archived = ${sql.value(info.params.includeArchived ?? false)}
`);
return userIds.map((id) => rows.filter((r) => r._user_id === id));
}
Shared step usage
You could use params to pass through things like a database client, user
credentials, etc - however since things like this are always needed by your
loader, having to set them in each plan resolver is a chore. Instead, it makes
sense to centralize them alongside your loader. To do so, we can change our load
callback into a loader object, and use the shared
callback to retrieve the
database client from the GraphQL context:
const friendsByUserId = {
name: "friendsByUserId",
// Load the request-specific database client from the GraphQL context
shared: () => context().get("dbClient"),
async load(userIds, info) {
const db = info.shared;
const columns = info.attributes.map((attr) => sql.identifier("f", attr));
const rows = await db.query(sql`
select u.id as _user_id, ${sql.join(columns, ",")}
from users u
inner join friendships on (friendships.user_id = u.id)
inner join users f on (f.id = friendships.friend_id)
where u.id = any(${sql.value(userIds)})
and friendships.archived = ${sql.value(info.params.includeArchived ?? false)}
`);
return userIds.map((id) => rows.filter((r) => r._user_id === id));
},
};
Input/output equivalence
Another attribute you may add to the loader object is ioEquivalence
. This
allows you to declare which output fields correspond to which input(s). This
allows children to start immediately when they only depend on those equivalent
outputs, rather than having to wait for the parent step to finish loading.
Imagine you're loading the users within a given organization:
{
usersByOrganizationId(id: Int!) {
id
name
organization {
id
name
}
}
}
You might have plan resolvers such as:
const objects = {
Query: {
plans: {
usersByOrganizationId(_, { $id }) {
return loadMany($id, batchGetUsersByOrganizationId);
},
},
},
User: {
plans: {
organization($user) {
const $orgId = $user.get("organization_id");
return loadOne($orgId, batchGetOrganizationById);
},
},
},
};
const batchGetUsersByOrganizationId = {
async load(organizationIds) {
/* your fetch logic here */
},
};
In its current state the system doesn't know that the
$user.get("organization_id")
is equivalent to the id
argument to our
usersByOrganizationId
field, so this would result in a chained fetch:
However, we can indicate that the output of the loadMany
step's records'
organization_id
property ($user.get("organization_id")
) is equivalent to its input
($id
):
const batchGetUsersByOrganizationId = {
ioEquivalence: "organization_id",
async load(organizationIds) {
/* your fetch logic here */
},
};
Now the access to $user.get("organization_id")
will return a step equivalent
to the Query.usersByOrganizationId(id:)
argument (the id
argument on the
usersByOrganizationId
field on the Query
type); thus Grafast does not need
to load the users in order to fetch their organization - it can fetch both in
parallel:
Usage
Called as loadMany($lookup, loader)
; $lookup
is a step (or multistep)
representing a "lookup" value (identifying the records to look up) and loader
is responsible for loading the related records.
Here's a simplified form of the type signature:
/**
* @template TLookup - type used to identify the record to look up (typically an
* string/UUID/integer, but composite types are supported).
* @template TItem - The type of each individual record returned.
* @template TData - The type of the collection for one lookup (array or async
* iterable of `TItem`, including nullability concerns).
* @template TParams - The shape of the `params` object available in the `info`
* argument - add params using `$loadMany.setParam(...)`, see below.
* @template TShared - Optional shared data, typically API or database clients,
* current user or session information, or other values shared by all entries in
* the batch.
*/
function loadMany<TLookup, TItem, TData, TParams, TShared>(
lookup: Step<TLookup> | Multistep,
loader:
| LoadManyLoader<TLookup, TItem, TData, TParams, TShared>
| LoadManyCallback<TLookup, TItem, TData, TParams, undefined>,
): LoadManyStep<TLookup, TItem, TData, TParams, TShared>;
interface LoadManyLoader<TLookup, TItem, TData, TParams, TShared> {
load: LoadManyCallback<TLookup, TItem, TData, TParams, TShared>;
name?: string;
shared?: Thunk<Step<TShared>>;
ioEquivalence?: IOEquivalence<TLookup>;
paginationSupport?: PaginationFeatures; // see "Pagination interop"
}
type LoadManyCallback<TLookup, TItem, TData, TParams, TShared> = (
lookups: ReadonlyArray<TLookup>,
info: LoadManyInfo<TItem, TParams, TShared>,
) => PromiseOrDirect<ReadonlyArray<TData>>;
interface LoadManyInfo<TItem, TParams, TShared> {
attributes: ReadonlyArray<keyof TItem>;
params: Partial<TParams>;
shared: TShared;
}
Load callback
Whether passed directly or specified in a loader object, the load
callback
will be passed two arguments: lookups
and info
, and it must return
one result collection per lookup value. Each
collection may be an array or an async iterable; items may be null
:
PromiseOrDirect<ReadonlyArray<Maybe<ReadonlyArrayOrAsyncIterable<Maybe<TItem>>>>>
.
The lookups argument is a readonly array of resolved lookup values.
The info
argument contains additional metadata about the request:
attributes
: the set of accessed keys (keyof TItem
) that our children needparams
: a map of params set via.setParam(...)
(used to indicate pagination, filtering, etc)shared
: the resolved value fromloader.shared
(typically API/DB clients, current user/session details, etc) - can only be populated if specified via a loader object
Loader object
const loader = {
// Purely cosmetic, for plan diagrams/debugging.
name: "myLoaderName",
// Optimization: if you know that parts of the output will be equivalent to
// parts of the input
ioEquivalence: null,
// Get access to any shared values your loader will need
shared: () => context().get("db"),
// Only set this if you actually support these features!
// paginationSupport: { cursor: true, offset: true, reverse: true },
async load(lookups, info) {
// lookups: readonly array of resolved lookup values
// info.attributes: readonly array of accessed keys (keyof TItem)
const attributes = info.attributes;
// info.params: Partial<TParams> including any `.setParam(...)` and pagination params
// Extract `paginationSupport`-related parameters:
const { reverse, limit, offset, after } = info.params;
// info.shared: resolved shared value(s)
const db = info.shared;
const resultsByLookup = await db.lookUpTheThings(lookups, {
attributes,
pagination: {
reverse,
limit,
offset,
after,
},
});
return lookups.map((lookup) => resultsByLookup.get(lookup));
},
};
ioEquivalence
type IOEquivalence<TSpec> =
| null
| string
| { [key in Exclude<keyof TSpec, keyof any[]>]?: string | null };
The ioEquivalence
optional parameter can accept the following values:
null
to indicate no input/output equivalence- a string to indicate that the same named property on the output is equivalent to the lookup value
- if the lookup was an array, an array containing a list of keys (or null for no relation) on the output that are equivalent to the same entry in the input
- if the lookup was an object, an object that maps between the attributes of the object and the key(s) in the output that are equivalent to the given entry on the input
const $posts = loadMany($userId, friendshipsByUserId);
const friendshipsByUserId = {
load: batchGetFriendshipsByUserId,
// States that $post.get('user_id') should return $userId directly, since it
// will have the same value.
ioEquivalence: "user_id",
};
const $posts = loadMany(
[$organizationId, $userId],
memberPostsByOrganizationIdAndUserId,
);
const memberPostsByOrganizationIdAndUserId = {
load: batchGetMemberPostsByOrganizationIdAndUserId,
// States that:
// - $post.get('organization_id') should return $organizationId directly, and
// - $post.get('user_id') should return $userId directly
ioEquivalence: ["organization_id", "user_id"],
};
const $posts = loadMany(
{ oid: $organizationId, uid: $userId },
memberPostsByOrganizationIdAndUserId,
);
const memberPostsByOrganizationIdAndUserId = {
load: batchGetMemberPostsByOrganizationIdAndUserId,
// States that:
// - $post.get('organization_id') should return $organizationId directly (the value for the `oid` input), and
// - $post.get('user_id') should return $userId directly (the value for the `uid` input
ioEquivalence: { oid: "organization_id", uid: "user_id" },
};
Pagination interop with connection()
You can combine loadMany
with connection()
:
function User_friends($user, fieldArgs) {
const $userId = $user.get("id");
const $friends = loadMany($userId, friendsByUserId);
return connection($friends, { fieldArgs });
}
- If your loader object includes
paginationSupport
(even{}
), theLoadManyStep
exposes GraphQL pagination to yourload
viainfo.params
, thereby setting:info.params.limit
: number of records to fetch, or null for no limitinfo.params.offset
: number of records to skip past, or null to not skip (applied afterafter
when cursors are used)info.params.after
: exclusive lower bound cursor normally, or exclusive upper bound cursor in reverse mode, if specifiedinfo.params.reverse
: whether the other parameters should be applied backwards from the end rather than forwards from the start of the collection
- If you advertise
cursor: true
inpaginationSupport
, each returned item must include a stablecursor: string
attribute which will be used verbatim byconnection()
to populateedges { cursor }
andpageInfo { startCursor endCursor }
. fieldArgs
inconnection($step, { fieldArgs })
is thefieldArgs
parameter from your plan resolver, and is assumed to represent arguments including thefirst
,last
,before
,after
andoffset
pagination arguments. This saves you from manually calling$connection.setFirst(fieldArgs.getRaw('first'))
for each of the pagination arguments in turn.
paginationSupport
, the entire list will be fetchedIf you don’t set paginationSupport
, connection()
will handle cursors and
pagination for you, which requires the entire collection to be downloaded.
Attribute merging
LoadManyStep
s that share the same load
function and identical param
signatures automatically merge their attributes
sets before execution to maximize
cache reuse (even if it means some requests will need to fetch more attributes
than strictly required).