Skip to content

Commit

Permalink
Introduce batch delegation (#1735)
Browse files Browse the repository at this point in the history
* fix memoization

= memoize info using info.fieldNodes, which is memoized by upstream graphql-js

= refactor StitchingInfo type and handleObject and supporting methods to use consistent keys to enable memoization

* add batchDelegate package

Add key option within type merging config to enable batch loading for lists.

This minimal version of batching uses cached dataLoaders to create a separate batch for each list rather than for every similar query within the resolution tree.

This is because a new dataloader is created for every new info.fieldNodes object, which is memoized upstream by graphql-js within resolution of a given list to allow the loader to be used for items of that list.

A future version could provide an option to batch by similar target fieldName/selectionSet, but this version may hit the sweet spot in terms of code complexity and batching behavior.

see:
#1710

* reorganize delegate files

remove unused isProxiedResult function

move unwrapResult and dehoistResult into createMergedResolver

WrapFields and HoistField transforms now use their own unwrapping and dehoisting logic, so these functions should be located only to the file that used them
  • Loading branch information
yaacovCR committed Jul 8, 2020
1 parent d463d77 commit d319d27
Show file tree
Hide file tree
Showing 18 changed files with 375 additions and 186 deletions.
34 changes: 34 additions & 0 deletions packages/batch-delegate/package.json
@@ -0,0 +1,34 @@
{
"name": "@graphql-tools/batch-delegate",
"version": "6.0.12",
"description": "A set of utils for faster development of GraphQL tools",
"repository": "git@github.com:ardatan/graphql-tools.git",
"license": "MIT",
"sideEffects": false,
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0"
},
"buildOptions": {
"input": "./src/index.ts"
},
"dependencies": {
"@graphql-tools/delegate": "6.0.12",
"dataloader": "2.0.0",
"tslib": "~2.0.0"
},
"devDependencies": {
"@graphql-tools/schema": "6.0.12",
"@graphql-tools/stitch": "6.0.12",
"@graphql-tools/utils": "6.0.12"
},
"publishConfig": {
"access": "public",
"directory": "dist"
}
}
51 changes: 51 additions & 0 deletions packages/batch-delegate/src/createBatchDelegateFn.ts
@@ -0,0 +1,51 @@
import { FieldNode, getNamedType, GraphQLOutputType, GraphQLList } from 'graphql';

import DataLoader from 'dataloader';

import { delegateToSchema } from '@graphql-tools/delegate';

import { BatchDelegateOptionsFn, BatchDelegateFn, BatchDelegateOptions } from './types';

export function createBatchDelegateFn<K = any, V = any, C = K>(
argFn: (args: ReadonlyArray<K>) => Record<string, any>,
batchDelegateOptionsFn: BatchDelegateOptionsFn,
dataLoaderOptions?: DataLoader.Options<K, V, C>
): BatchDelegateFn<K> {
let cache: WeakMap<ReadonlyArray<FieldNode>, DataLoader<K, V, C>>;

function createBatchFn(options: BatchDelegateOptions) {
return async (keys: ReadonlyArray<K>) => {
const results = await delegateToSchema({
returnType: new GraphQLList(getNamedType(options.info.returnType) as GraphQLOutputType),
args: argFn(keys),
...batchDelegateOptionsFn(options),
});
return Array.isArray(results) ? results : keys.map(() => results);
};
}

function getLoader(options: BatchDelegateOptions) {
if (!cache) {
cache = new WeakMap();
const batchFn = createBatchFn(options);
const newValue = new DataLoader<K, V, C>(keys => batchFn(keys), dataLoaderOptions);
cache.set(options.info.fieldNodes, newValue);
return newValue;
}

const cachedValue = cache.get(options.info.fieldNodes);
if (cachedValue === undefined) {
const batchFn = createBatchFn(options);
const newValue = new DataLoader<K, V, C>(keys => batchFn(keys), dataLoaderOptions);
cache.set(options.info.fieldNodes, newValue);
return newValue;
}

return cachedValue;
}

return options => {
const loader = getLoader(options);
return loader.load(options.key);
};
}
3 changes: 3 additions & 0 deletions packages/batch-delegate/src/index.ts
@@ -0,0 +1,3 @@
export { createBatchDelegateFn } from './createBatchDelegateFn';

export * from './types';
14 changes: 14 additions & 0 deletions packages/batch-delegate/src/types.ts
@@ -0,0 +1,14 @@
import { IDelegateToSchemaOptions } from '@graphql-tools/delegate';

export type BatchDelegateFn<TContext = Record<string, any>, K = any> = (
batchDelegateOptions: BatchDelegateOptions<TContext, K>
) => any;

export type BatchDelegateOptionsFn<TContext = Record<string, any>, K = any> = (
batchDelegateOptions: BatchDelegateOptions<TContext, K>
) => IDelegateToSchemaOptions<TContext>;

export interface BatchDelegateOptions<TContext = Record<string, any>, K = any>
extends Omit<IDelegateToSchemaOptions<TContext>, 'args'> {
key: K;
}
@@ -1,16 +1,16 @@
// Conversion of Apollo Federation demo from https://github.com/apollographql/federation-demo.
// See: https://github.com/ardatan/graphql-tools/issues/1697
// Conversion of Apollo Federation demo
// Compare: https://github.com/apollographql/federation-demo
// See also:
// https://github.com/ardatan/graphql-tools/issues/1697
// https://github.com/ardatan/graphql-tools/issues/1710

import { graphql } from 'graphql';

import { makeExecutableSchema } from '@graphql-tools/schema';

import { ExecutionResult } from '@graphql-tools/utils';

import { stitchSchemas } from '../src/stitchSchemas';
import { stitchSchemas } from '@graphql-tools/stitch';

describe('merging using type merging', () => {

const users = [
{
id: '1',
Expand All @@ -31,6 +31,7 @@ describe('merging using type merging', () => {
type Query {
me: User
_userById(id: ID!): User
_usersById(ids: [ID!]!): [User]
}
type User {
id: ID!
Expand All @@ -42,6 +43,7 @@ describe('merging using type merging', () => {
Query: {
me: () => users[0],
_userById: (_root, { id }) => users.find(user => user.id === id),
_usersById: (_root, { ids }) => ids.map((id: any) => users.find(user => user.id === id)),
},
},
});
Expand Down Expand Up @@ -111,6 +113,7 @@ describe('merging using type merging', () => {
type Query {
topProducts(first: Int = 5): [Product]
_productByUpc(upc: String!): Product
_productsByUpc(upcs: [String!]!): [Product]
}
type Product {
upc: String!
Expand All @@ -123,6 +126,7 @@ describe('merging using type merging', () => {
Query: {
topProducts: (_root, args) => products.slice(0, args.first),
_productByUpc: (_root, { upc }) => products.find(product => product.upc === upc),
_productsByUpc: (_root, { upcs }) => upcs.map((upc: any) => products.find(product => product.upc === upc)),
}
},
});
Expand Down Expand Up @@ -179,8 +183,10 @@ describe('merging using type merging', () => {
}
type Query {
_userById(id: ID!): User
_usersById(ids: [ID!]!): [User]
_reviewById(id: ID!): Review
_productByUpc(upc: String!): Product
_productsByUpc(upcs: [String!]!): [Product]
}
`,
resolvers: {
Expand All @@ -201,7 +207,9 @@ describe('merging using type merging', () => {
Query: {
_reviewById: (_root, { id }) => reviews.find(review => review.id === id),
_userById: (_root, { id }) => ({ id }),
_usersById: (_root, { ids }) => ids.map((id: string) => ({ id })),
_productByUpc: (_, { upc }) => ({ upc }),
_productsByUpc: (_, { upcs }) => upcs.map((upc: string) => ({ upc })),
},
}
});
Expand All @@ -212,8 +220,8 @@ describe('merging using type merging', () => {
schema: accountsSchema,
merge: {
User: {
fieldName: '_userById',
selectionSet: '{ id }',
fieldName: '_userById',
args: ({ id }) => ({ id })
}
}
Expand All @@ -222,8 +230,8 @@ describe('merging using type merging', () => {
schema: inventorySchema,
merge: {
Product: {
fieldName: '_productByUpc',
selectionSet: '{ upc weight price }',
fieldName: '_productByUpc',
args: ({ upc, weight, price }) => ({ upc, weight, price }),
}
}
Expand All @@ -232,8 +240,8 @@ describe('merging using type merging', () => {
schema: productsSchema,
merge: {
Product: {
fieldName: '_productByUpc',
selectionSet: '{ upc }',
fieldName: '_productByUpc',
args: ({ upc }) => ({ upc }),
}
}
Expand All @@ -242,13 +250,14 @@ describe('merging using type merging', () => {
schema: reviewsSchema,
merge: {
User: {
fieldName: '_userById',
selectionSet: '{ id }',
args: ({ id }) => ({ id }),
fieldName: '_usersById',
args: (ids) => ({ ids }),
key: ({ id }) => id,
},
Product: {
fieldName: '_productByUpc',
selectionSet: '{ upc }',
fieldName: '_productByUpc',
args: ({ upc }) => ({ upc }),
},
}
Expand Down
66 changes: 64 additions & 2 deletions packages/delegate/src/createMergedResolver.ts
@@ -1,8 +1,70 @@
import { IFieldResolver } from '@graphql-tools/utils';
import { GraphQLError } from 'graphql';

import { IFieldResolver, getErrors, setErrors, relocatedError, ERROR_SYMBOL } from '@graphql-tools/utils';

import { OBJECT_SUBSCHEMA_SYMBOL } from './symbols';

import { getSubschema, setObjectSubschema } from './Subschema';

import { unwrapResult, dehoistResult } from './proxiedResult';
import { defaultMergedResolver } from './defaultMergedResolver';

import { handleNull } from './results/handleNull';

function unwrapResult(parent: any, path: Array<string>): any {
let newParent: any = parent;
const pathLength = path.length;
for (let i = 0; i < pathLength; i++) {
const responseKey = path[i];
const errors = getErrors(newParent, responseKey);
const subschema = getSubschema(newParent, responseKey);

const object = newParent[responseKey];
if (object == null) {
return handleNull(errors);
}

setErrors(
object,
errors.map(error => relocatedError(error, error.path != null ? error.path.slice(1) : undefined))
);
setObjectSubschema(object, subschema);

newParent = object;
}

return newParent;
}

function dehoistResult(parent: any, delimeter = '__gqltf__'): any {
const result = Object.create(null);

Object.keys(parent).forEach(alias => {
let obj = result;

const fieldNames = alias.split(delimeter);
const fieldName = fieldNames.pop();
fieldNames.forEach(key => {
obj = obj[key] = obj[key] || Object.create(null);
});
obj[fieldName] = parent[alias];
});

result[ERROR_SYMBOL] = parent[ERROR_SYMBOL].map((error: GraphQLError) => {
if (error.path != null) {
const path = error.path.slice();
const pathSegment = path.shift();
const expandedPathSegment: Array<string | number> = (pathSegment as string).split(delimeter);
return relocatedError(error, expandedPathSegment.concat(path));
}

return error;
});

result[OBJECT_SUBSCHEMA_SYMBOL] = parent[OBJECT_SUBSCHEMA_SYMBOL];

return result;
}

export function createMergedResolver({
fromPath,
dehoist,
Expand Down
89 changes: 0 additions & 89 deletions packages/delegate/src/proxiedResult.ts

This file was deleted.

0 comments on commit d319d27

Please sign in to comment.