Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(batchDelegate): add tests, docs #1856

Merged
merged 1 commit into from
Jul 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/batch-delegate/src/batchDelegateToSchema.ts
@@ -1,10 +1,8 @@
import { BatchDelegateOptions, DataLoaderCache } from './types';
import { BatchDelegateOptions } from './types';

import { getLoader } from './getLoader';

export function batchDelegateToSchema<K = any, V = any, C = K>(options: BatchDelegateOptions): any {
let cache: DataLoaderCache<K, V, C>;

const loader = getLoader(cache, options);
export function batchDelegateToSchema(options: BatchDelegateOptions): any {
const loader = getLoader(options);
return loader.load(options.key);
}
23 changes: 10 additions & 13 deletions packages/batch-delegate/src/createBatchDelegateFn.ts
Expand Up @@ -4,34 +4,31 @@ import {
CreateBatchDelegateFnOptions,
BatchDelegateOptionsFn,
BatchDelegateFn,
BatchDelegateArgsFn,
BatchDelegateResultsFn,
DataLoaderCache,
BatchDelegateMapKeysFn,
BatchDelegateMapResultsFn,
} from './types';

import { getLoader } from './getLoader';

export function createBatchDelegateFn<K = any, V = any, C = K>(
optionsOrArgsFn: CreateBatchDelegateFnOptions | BatchDelegateArgsFn,
optionsOrMapKeysFn: CreateBatchDelegateFnOptions | BatchDelegateMapKeysFn,
optionsFn?: BatchDelegateOptionsFn,
dataLoaderOptions?: DataLoader.Options<K, V, C>,
resultsFn?: BatchDelegateResultsFn
mapResultsFn?: BatchDelegateMapResultsFn
): BatchDelegateFn<K> {
return typeof optionsOrArgsFn === 'function'
return typeof optionsOrMapKeysFn === 'function'
? createBatchDelegateFnImpl({
argsFn: optionsOrArgsFn,
mapKeysFn: optionsOrMapKeysFn,
optionsFn,
dataLoaderOptions,
resultsFn,
mapResultsFn,
})
: createBatchDelegateFnImpl(optionsOrArgsFn);
: createBatchDelegateFnImpl(optionsOrMapKeysFn);
}

function createBatchDelegateFnImpl<K = any, V = any, C = K>(options: CreateBatchDelegateFnOptions): BatchDelegateFn<K> {
let cache: DataLoaderCache<K, V, C>;

function createBatchDelegateFnImpl<K = any>(options: CreateBatchDelegateFnOptions): BatchDelegateFn<K> {
return batchDelegateOptions => {
const loader = getLoader(cache, {
const loader = getLoader({
...options,
...batchDelegateOptions,
});
Expand Down
29 changes: 10 additions & 19 deletions packages/batch-delegate/src/getLoader.ts
Expand Up @@ -6,47 +6,38 @@ import { delegateToSchema } from '@graphql-tools/delegate';

import { BatchDelegateOptions, DataLoaderCache } from './types';

const cache: DataLoaderCache = new WeakMap();

function createBatchFn<K = any>(options: BatchDelegateOptions) {
const argsFn = options.argsFn ?? ((keys: ReadonlyArray<K>) => ({ ids: keys }));
const { resultsFn, optionsFn } = options;
const mapKeysFn = options.mapKeysFn ?? ((keys: ReadonlyArray<K>) => ({ ids: keys }));
const { mapResultsFn, optionsFn } = options;

return async (keys: ReadonlyArray<K>) => {
let results = await delegateToSchema({
returnType: new GraphQLList(getNamedType(options.info.returnType) as GraphQLOutputType),
args: argsFn(keys),
args: mapKeysFn(keys),
...(optionsFn != null ? optionsFn(options) : options),
});

if (resultsFn != null) {
results = resultsFn(results, keys);
if (mapResultsFn != null) {
results = mapResultsFn(results, keys);
}

return Array.isArray(results) ? results : keys.map(() => results);
};
}

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

export function getLoader<K = any, V = any, C = K>(
cache: DataLoaderCache,
options: BatchDelegateOptions
): DataLoader<K, V, C> {
if (!cache) {
cache = new WeakMap();
return createLoader(cache, options);
}

export function getLoader<K = any, V = any, C = K>(options: BatchDelegateOptions): DataLoader<K, V, C> {
const cachedValue = cache.get(options.info.fieldNodes);
if (cachedValue === undefined) {
return createLoader(cache, options);
return createLoader(options);
}

return cachedValue;
Expand Down
22 changes: 7 additions & 15 deletions packages/batch-delegate/src/types.ts
Expand Up @@ -14,31 +14,23 @@ export type BatchDelegateOptionsFn<TContext = Record<string, any>, K = any> = (
batchDelegateOptions: BatchDelegateOptions<TContext, K>
) => IDelegateToSchemaOptions<TContext>;

export type BatchDelegateArgsFn<K = any> = (keys: ReadonlyArray<K>) => Record<string, any>;
export type BatchDelegateMapKeysFn<K = any> = (keys: ReadonlyArray<K>) => Record<string, any>;

export type BatchDelegateResultsFn<K = any, V = any> = (results: any, keys: ReadonlyArray<K>) => Array<V>;
export type BatchDelegateMapResultsFn<K = any, V = any> = (results: any, keys: ReadonlyArray<K>) => Array<V>;

export interface BatchDelegateOptions<TContext = Record<string, any>, K = any, V = any, C = K>
extends Omit<IDelegateToSchemaOptions<TContext>, 'args'> {
dataLoaderOptions?: DataLoader.Options<K, V, C>;
key: K;
argsFn?: BatchDelegateArgsFn;
mapKeysFn?: BatchDelegateMapKeysFn;
mapResultsFn?: BatchDelegateMapResultsFn;
optionsFn?: BatchDelegateOptionsFn;
dataLoaderOptions?: DataLoader.Options<K, V, C>;
resultsFn?: BatchDelegateResultsFn;
}

export interface CreateBatchDelegateFnOptions<TContext = Record<string, any>, K = any, V = any, C = K>
extends Partial<Omit<IDelegateToSchemaOptions<TContext>, 'args' | 'info'>> {
dataLoaderOptions?: DataLoader.Options<K, V, C>;
argsFn?: BatchDelegateArgsFn;
resultsFn?: BatchDelegateResultsFn;
optionsFn?: BatchDelegateOptionsFn;
}

export interface BatchDelegateToSchema<TContext = Record<string, any>, K = any, V = any, C = K>
extends Omit<IDelegateToSchemaOptions<TContext>, 'schema' | 'args'> {
dataLoaderOptions?: DataLoader.Options<K, V, C>;
argsFn?: BatchDelegateArgsFn;
resultsFn?: BatchDelegateResultsFn;
mapKeysFn?: BatchDelegateMapKeysFn;
mapResultsFn?: BatchDelegateMapResultsFn;
optionsFn?: BatchDelegateOptionsFn;
}
94 changes: 94 additions & 0 deletions packages/batch-delegate/tests/basic.example.test.ts
@@ -0,0 +1,94 @@
import { graphql } from 'graphql';

import { makeExecutableSchema } from '@graphql-tools/schema';
import { batchDelegateToSchema } from '@graphql-tools/batch-delegate';
import { stitchSchemas } from '@graphql-tools/stitch';

describe('batch delegation within basic stitching example', () => {
test('works', async () => {
let numCalls = 0;

const chirpSchema = makeExecutableSchema({
typeDefs: `
type Chirp {
chirpedAtUserId: ID!
}

type Query {
trendingChirps: [Chirp]
}
`,
resolvers: {
Query: {
trendingChirps: () => [{ chirpedAtUserId: 1 }, { chirpedAtUserId: 2 }]
}
}
});

// Mocked author schema
const authorSchema = makeExecutableSchema({
typeDefs: `
type User {
email: String
}

type Query {
usersByIds(ids: [ID!]): [User]
}
`,
resolvers: {
Query: {
usersByIds: (_root, args) => {
numCalls++;
return args.ids.map((id: string) => ({ email: `${id}@test.com`}));
}
}
}
});

const linkTypeDefs = `
extend type Chirp {
chirpedAtUser: User
}
`;

const stitchedSchema = stitchSchemas({
subschemas: [chirpSchema, authorSchema],
typeDefs: linkTypeDefs,
resolvers: {
Chirp: {
chirpedAtUser: {
selectionSet: `{ chirpedAtUserId }`,
resolve(chirp, _args, context, info) {
return batchDelegateToSchema({
schema: authorSchema,
operation: 'query',
fieldName: 'usersByIds',
key: chirp.chirpedAtUserId,
mapKeysFn: (ids) => ({ ids }),
context,
info,
});
},
},
},
},
});

const query = `
query {
trendingChirps {
chirpedAtUser {
email
}
}
}
`;

const result = await graphql(stitchedSchema, query);

expect(numCalls).toEqual(1);
expect(result.errors).toBeUndefined();
expect(result.data.trendingChirps[0].chirpedAtUser.email).not.toBe(null);
});
});
6 changes: 3 additions & 3 deletions packages/delegate/src/types.ts
Expand Up @@ -136,9 +136,9 @@ export interface MergedTypeConfig<K = any, V = any> {
resolve?: MergedTypeResolver;
fieldName?: string;
args?: (originalResult: any) => Record<string, any>;
key?: (originalResult: any) => any;
argsFn?: (keys: ReadonlyArray<K>) => Record<string, any>;
resultsFn?: (results: any, keys: ReadonlyArray<K>) => Array<V>;
key?: (originalResult: any) => K;
mapKeysFn?: (keys: ReadonlyArray<K>) => Record<string, any>;
mapResultsFn?: (results: any, keys: ReadonlyArray<K>) => Array<V>;
}

export type MergedTypeResolver = (
Expand Down
4 changes: 2 additions & 2 deletions packages/stitch/src/stitchingInfo.ts
Expand Up @@ -125,9 +125,9 @@ function createMergedTypes(
schema: subschema,
operation: 'query',
fieldName: mergedTypeConfig.fieldName,
argsFn: mergedTypeConfig.argsFn ?? mergedTypeConfig.args,
resultsFn: mergedTypeConfig.resultsFn,
key: mergedTypeConfig.key(originalResult),
mapKeysFn: mergedTypeConfig.mapKeysFn ?? mergedTypeConfig.args,
mapResultsFn: mergedTypeConfig.mapResultsFn,
selectionSet,
context,
info,
Expand Down
82 changes: 82 additions & 0 deletions website/docs/schema-stitching.md
Expand Up @@ -240,6 +240,88 @@ forwardArgsToSelectionSet('{ id chirpIds }', { chirpIds: ['since'] })

Note that a dynamic `selectionSet` is simply a function that recieves a GraphQL `FieldNode` (the gateway field) and returns a `SelectionSetNode`. This dynamic capability can support a wide range of custom stitching configurations.

### Batch Delegation

Suppose there was an additional root field within the schema for chirps called `trendingChirps` that returned a list of the current most popular chirps, as well as an additonal field on the `Chirp` type called `chirpedAtUserId` that described the target of an individual chirp. Imagine as well that we used the above stitching strategy to add an additional new field on the `Chirp` type called `chirpedAtUser` so that we could write the following query:

```graphql
query {
trendingChirps {
id
text
chirpedAtUser {
id
email
}
}
}
```

The implementation could be something like this:

```js
const schema = stitchSchemas({
subschemas: [chirpSchema, authorSchema],
typeDefs: linkTypeDefs,
resolvers: {
// ...
Chirp: {
chirpedAtUser: {
selectionSet: `{ chirpedAtUserId }`,
resolve(chirp, _args, context, info) {
return delegateToSchema({
schema: authorSchema,
operation: 'query',
fieldName: 'userById',
args: {
id: chirp.chirpedAtUserId,
},
context,
info,
});
},
},
},
// ...
},
});
```

The above query as written would cause the gateway to fire an additional query to our author schema for each trending chirp, with the exact same arguments and selection set!

Imagine, however, that the author schema had an additional root field `usersByIds` besides just `userById`. Because we know that for each member of a list, the arguments and selection set will always match, we can utilize batch delegation using the [DataLoader](https://www.npmjs.com/package/dataloader) pattern to combine the individual queries from the gateway into one batch to the `userByIds` root field instead of `userById`. The implementation would look very similar:

```js
const { batchDelegateToSchema } from '@graphql-tools/batchDelegate';

const schema = stitchSchemas({
subschemas: [chirpSchema, authorSchema],
typeDefs: linkTypeDefs,
resolvers: {
// ...
Chirp: {
chirpedAtUser: {
selectionSet: `{ chirpedAtUserId }`,
resolve(chirp, _args, context, info) {
return batchDelegateToSchema({
schema: authorSchema,
operation: 'query',
fieldName: 'usersByIds',
key: chirp.chirpedAtUserId,
mapKeysFn: (ids) => ({ ids }),
context,
info,
});
},
},
},
// ...
},
});
```

Batch delegation may be preferable over plain delegation whenever possible, as it reduces the number of requests significantly whenever the parent object type appears in a list!

## Using with Transforms

Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](/docs/schema-transforms/) with schema stitching, we can easily tweak the subschemas before merging them together. (In earlier versions of graphql-tools, this required an additional round of delegation prior to merging, but transforms can now be specifying directly when merging using the new subschema configuration objects.)
Expand Down