Skip to content

Commit

Permalink
Introduce computed fields (#1989)
Browse files Browse the repository at this point in the history
* Introduces a `computeFields` attribute within the type merging configuration. Using computed field selection sets instead of regular field selection sets will cause the gateway's query planner to always to retrieve that field's dependencies from other services, even if the query originates from that subservice. This allows type merging patterns in which a given service may have to be visited twice.

* Introduces subschemaConfigTransforms that can transform configs, including a default transform that uses the `computed` directive to add computed fields and annotate them with field-level required selectionSets (or field sets).
  • Loading branch information
gmac committed Sep 13, 2020
1 parent 63a176d commit c20d686
Show file tree
Hide file tree
Showing 24 changed files with 1,270 additions and 359 deletions.
37 changes: 23 additions & 14 deletions packages/batch-delegate/src/getLoader.ts
@@ -1,12 +1,12 @@
import { getNamedType, GraphQLOutputType, GraphQLList } from 'graphql';
import { getNamedType, GraphQLOutputType, GraphQLList, GraphQLSchema } from 'graphql';

import DataLoader from 'dataloader';

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

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

const cache: DataLoaderCache = new WeakMap();
const cache1: DataLoaderCache = new WeakMap();

function createBatchFn<K = any>(options: BatchDelegateOptions) {
const argsFromKeys = options.argsFromKeys ?? ((keys: ReadonlyArray<K>) => ({ ids: keys }));
Expand All @@ -25,18 +25,27 @@ function createBatchFn<K = any>(options: BatchDelegateOptions) {
};
}

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>(options: BatchDelegateOptions): DataLoader<K, V, C> {
const cachedValue = cache.get(options.info.fieldNodes);
if (cachedValue === undefined) {
return createLoader(options);
let cache2: WeakMap<GraphQLSchema | SubschemaConfig, DataLoader<K, V, C>> = cache1.get(options.info.fieldNodes);
let loader: DataLoader<K, V, C>;

if (cache2 === undefined) {
const batchFn = createBatchFn(options);
cache2 = new WeakMap();
cache1.set(options.info.fieldNodes, cache2);
loader = new DataLoader<K, V, C>(keys => batchFn(keys), options.dataLoaderOptions);
cache2.set(options.schema, loader);
return loader;
}

loader = cache2.get(options.schema);

if (loader === undefined) {
const batchFn = createBatchFn(options);
loader = new DataLoader<K, V, C>(keys => batchFn(keys), options.dataLoaderOptions);
cache2.set(options.schema, loader);
return loader;
}

return cachedValue;
return loader;
}
9 changes: 6 additions & 3 deletions packages/batch-delegate/src/types.ts
@@ -1,10 +1,13 @@
import { FieldNode } from 'graphql';
import { FieldNode, GraphQLSchema } from 'graphql';

import DataLoader from 'dataloader';

import { IDelegateToSchemaOptions } from '@graphql-tools/delegate';
import { IDelegateToSchemaOptions, SubschemaConfig } from '@graphql-tools/delegate';

export type DataLoaderCache<K = any, V = any, C = K> = WeakMap<ReadonlyArray<FieldNode>, DataLoader<K, V, C>>;
export type DataLoaderCache<K = any, V = any, C = K> = WeakMap<
ReadonlyArray<FieldNode>,
WeakMap<GraphQLSchema | SubschemaConfig, DataLoader<K, V, C>>
>;

export type BatchDelegateFn<TContext = Record<string, any>, K = any> = (
batchDelegateOptions: BatchDelegateOptions<TContext, K>
Expand Down
69 changes: 52 additions & 17 deletions packages/batch-delegate/tests/typeMerging.example.test.ts
Expand Up @@ -3,6 +3,7 @@
// See also:
// https://github.com/ardatan/graphql-tools/issues/1697
// https://github.com/ardatan/graphql-tools/issues/1710
// https://github.com/ardatan/graphql-tools/issues/1959

import { graphql } from 'graphql';

Expand Down Expand Up @@ -67,7 +68,8 @@ describe('merging using type merging', () => {
shippingEstimate: Int
}
type Query {
_productByRepresentation(product: ProductRepresentation): Product
mostStockedProduct: Product
_products(representations: [ProductRepresentation!]!): [Product]!
}
`,
resolvers: {
Expand All @@ -80,11 +82,9 @@ describe('merging using type merging', () => {
}
},
Query: {
_productByRepresentation: (_root, { product: { upc, ...fields } }) => {
return {
...inventory.find(product => product.upc === upc),
...fields
};
mostStockedProduct: () => inventory.find(i => i.upc === '3'),
_products: (_root, { representations }) => {
return representations.map((rep: Record<string, any>) => ({ ...rep, ...inventory.find(i => i.upc === rep.upc) }));
},
},
},
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('merging using type merging', () => {
const productsSchema = makeExecutableSchema({
typeDefs: `
type Query {
topProducts(first: Int = 5): [Product]
topProducts(first: Int = 2): [Product]
_productByUpc(upc: String!): Product
_productsByUpc(upcs: [String!]!): [Product]
}
Expand Down Expand Up @@ -235,13 +235,14 @@ describe('merging using type merging', () => {
merge: {
Product: {
selectionSet: '{ upc }',
fields: {
computedFields: {
shippingEstimate: {
selectionSet: '{ price weight }',
},
},
fieldName: '_productByRepresentation',
args: ({ upc, weight, price }) => ({ product: { upc, weight, price } }),
fieldName: '_products',
key: ({ upc, weight, price }) => ({ upc, weight, price }),
argsFromKeys: (representations) => ({ representations }),
}
},
batch: true,
Expand Down Expand Up @@ -277,13 +278,14 @@ describe('merging using type merging', () => {
mergeTypes: true,
});

test('can stitch from products to inventory schema', async () => {
test('can stitch from products to inventory schema including mixture of computed and non-computed fields', async () => {
const result = await graphql(
stitchedSchema,
`
query {
topProducts {
upc
inStock
shippingEstimate
}
}
Expand All @@ -292,13 +294,17 @@ describe('merging using type merging', () => {
{},
);

const expectedResult = {
const expectedResult: ExecutionResult = {
data: {
topProducts: [
{ shippingEstimate: 50, upc: '1' },
{ shippingEstimate: 0, upc: '2' },
{ shippingEstimate: 25, upc: '3' },
],
topProducts: [{
upc: '1',
inStock: true,
shippingEstimate: 50,
}, {
upc: '2',
inStock: false,
shippingEstimate: 0,
}],
},
};

Expand Down Expand Up @@ -430,4 +436,33 @@ describe('merging using type merging', () => {

expect(result).toEqual(expectedResult);
});

test('can stitch from inventory to products and then back to inventory', async () => {
const result = await graphql(
stitchedSchema,
`
query {
mostStockedProduct {
upc
inStock
shippingEstimate
}
}
`,
undefined,
{},
);

const expectedResult: ExecutionResult = {
data: {
mostStockedProduct: {
upc: '3',
inStock: true,
shippingEstimate: 25,
},
},
};

expect(result).toEqual(expectedResult);
});
});
59 changes: 55 additions & 4 deletions packages/delegate/src/Subschema.ts
Expand Up @@ -2,7 +2,15 @@ import { GraphQLSchema } from 'graphql';

import { Transform, applySchemaTransforms } from '@graphql-tools/utils';

import { SubschemaConfig, MergedTypeConfig, CreateProxyingResolverFn, Subscriber, Executor } from './types';
import {
SubschemaConfig,
MergedTypeConfig,
CreateProxyingResolverFn,
Subscriber,
Executor,
Endpoint,
EndpointBatchingOptions,
} from './types';

import { FIELD_SUBSCHEMA_MAP_SYMBOL, OBJECT_SUBSCHEMA_SYMBOL } from './symbols';

Expand All @@ -18,28 +26,71 @@ export function setObjectSubschema(result: any, subschema: GraphQLSchema | Subsc
export function isSubschemaConfig(value: any): value is SubschemaConfig | Subschema {
return Boolean(value.schema && value.permutations === undefined);
}

export function cloneSubschemaConfig(subschemaConfig: SubschemaConfig): SubschemaConfig {
const newSubschemaConfig = {
...subschemaConfig,
transforms: subschemaConfig.transforms != null ? [...subschemaConfig.transforms] : undefined,
};

if (newSubschemaConfig.merge != null) {
newSubschemaConfig.merge = { ...subschemaConfig.merge };
Object.keys(newSubschemaConfig.merge).forEach(typeName => {
newSubschemaConfig.merge[typeName] = { ...subschemaConfig.merge[typeName] };

const fields = newSubschemaConfig.merge[typeName].fields;
if (fields != null) {
Object.keys(fields).forEach(fieldName => {
fields[fieldName] = { ...fields[fieldName] };
});
}

const computedFields = newSubschemaConfig.merge[typeName].computedFields;
if (computedFields != null) {
Object.keys(computedFields).forEach(fieldName => {
computedFields[fieldName] = { ...computedFields[fieldName] };
});
}
});
}

return newSubschemaConfig;
}

export function isSubschema(value: any): value is Subschema {
return Boolean(value.transformedSchema);
}

export class Subschema {
export class Subschema<K = any, V = any, C = K> {
public schema: GraphQLSchema;

public rootValue?: Record<string, any>;
public executor?: Executor;
public subscriber?: Subscriber;
public batch?: boolean;
public batchingOptions?: EndpointBatchingOptions<K, V, C>;
public endpoint?: Endpoint;

public createProxyingResolver?: CreateProxyingResolverFn;
public transforms: Array<Transform>;
public transformedSchema: GraphQLSchema;

public merge?: Record<string, MergedTypeConfig>;

constructor(config: SubschemaConfig) {
this.schema = config.schema;

this.rootValue = config.rootValue;
this.executor = config.executor;
this.subscriber = config.subscriber;
this.batch = config.batch;
this.batchingOptions = config.batchingOptions;
this.endpoint = config.endpoint;

this.createProxyingResolver = config.createProxyingResolver;
this.transforms = config.transforms ?? [];
this.merge = config.merge;

this.transformedSchema = applySchemaTransforms(this.schema, this.transforms);

this.merge = config.merge;
}
}
19 changes: 10 additions & 9 deletions packages/delegate/src/delegateToSchema.ts
Expand Up @@ -125,18 +125,19 @@ export function delegateRequest({
let endpoint: Endpoint;

let allTransforms: Array<Transform>;

if (isSubschemaConfig(subschemaOrSubschemaConfig)) {
subschemaConfig = subschemaOrSubschemaConfig;
const stitchingInfo: StitchingInfo = info?.schema.extensions?.stitchingInfo;
if (stitchingInfo) {
const processedSubschema = stitchingInfo.transformedSubschemaConfigs.get(subschemaOrSubschemaConfig);
subschemaConfig = processedSubschema != null ? processedSubschema : subschemaOrSubschemaConfig;
} else {
subschemaConfig = subschemaOrSubschemaConfig;
}
targetSchema = subschemaConfig.schema;
allTransforms =
subschemaOrSubschemaConfig.transforms != null
? subschemaOrSubschemaConfig.transforms.concat(transforms)
: transforms;
if (typeof subschemaConfig.endpoint === 'object') {
allTransforms = subschemaConfig.transforms != null ? subschemaConfig.transforms.concat(transforms) : transforms;
if (subschemaConfig.endpoint != null) {
endpoint = subschemaConfig.endpoint;
} else if (typeof subschemaConfig.endpoint === 'string') {
const stitchingInfo: StitchingInfo = info?.schema.extensions?.stitchingInfo;
endpoint = stitchingInfo.endpoints[subschemaConfig.endpoint];
} else {
endpoint = subschemaConfig;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/delegate/src/index.ts
Expand Up @@ -3,7 +3,7 @@ export { createRequestFromInfo, createRequest } from './createRequest';
export { defaultMergedResolver } from './defaultMergedResolver';
export { createMergedResolver } from './createMergedResolver';
export { handleResult } from './results/handleResult';
export { Subschema, isSubschema, isSubschemaConfig, getSubschema } from './Subschema';
export { Subschema, isSubschema, isSubschemaConfig, cloneSubschemaConfig, getSubschema } from './Subschema';

export * from './delegationBindings';
export * from './transforms';
Expand Down
14 changes: 12 additions & 2 deletions packages/delegate/src/results/mergeProxiedResults.ts
Expand Up @@ -4,7 +4,18 @@ import { SubschemaConfig } from '../types';
import { OBJECT_SUBSCHEMA_SYMBOL, FIELD_SUBSCHEMA_MAP_SYMBOL } from '../symbols';

export function mergeProxiedResults(target: any, ...sources: Array<any>): any {
const results = sources.filter(source => !(source instanceof Error));
const results: Array<any> = [];
const errors: Array<Error> = [];

sources.forEach(source => {
if (source instanceof Error) {
errors.push(source);
} else {
results.push(source);
errors.push(source[ERROR_SYMBOL]);
}
});

const fieldSubschemaMap = results.reduce((acc: Record<any, SubschemaConfig>, source: any) => {
const subschema = source[OBJECT_SUBSCHEMA_SYMBOL];
Object.keys(source).forEach(key => {
Expand All @@ -18,7 +29,6 @@ export function mergeProxiedResults(target: any, ...sources: Array<any>): any {
? Object.assign({}, target[FIELD_SUBSCHEMA_MAP_SYMBOL], fieldSubschemaMap)
: fieldSubschemaMap;

const errors = sources.map((source: any) => (source instanceof Error ? source : source[ERROR_SYMBOL]));
result[ERROR_SYMBOL] = target[ERROR_SYMBOL].concat(...errors);

return result;
Expand Down

0 comments on commit c20d686

Please sign in to comment.