Skip to content

Commit

Permalink
feat: support Gatsby-style directives in extensions (#3185)
Browse files Browse the repository at this point in the history
* feat: support Gatsby-style directives in extensions

BREAKING CHANGE: getDirectives now always return an array of DirectiveAnnotation objects

New function getDirective returns an array of args records for each use of the directive.

Note: this is true even when the directive is non-repeatable. This is because one use of this function is to throw an error if more than one directive annotation is used for a non repeatable directive!

* add changeset
  • Loading branch information
yaacovCR committed Jul 12, 2021
1 parent c5342de commit 74581cf
Show file tree
Hide file tree
Showing 11 changed files with 482 additions and 343 deletions.
16 changes: 16 additions & 0 deletions .changeset/quick-hotels-beam.md
@@ -0,0 +1,16 @@
---
'@graphql-tools/stitch': major
'@graphql-tools/stitching-directives': major
'@graphql-tools/utils': major
'@graphql-tools/wrap': major
---

fix(getDirectives): preserve order around repeatable directives

BREAKING CHANGE: getDirectives now always return an array of individual DirectiveAnnotation objects consisting of `name` and `args` properties.

New useful function `getDirective` returns an array of objects representing any args for each use of a single directive (returning the empty object `{}` when a directive is used without arguments).

Note: The `getDirective` function returns an array even when the specified directive is non-repeatable. This is because one use of this function is to throw an error if more than one directive annotation is used for a non repeatable directive!

When specifying directives in extensions, one can use either the old or new format.
@@ -1,4 +1,4 @@
import { getDirectives, MapperKind, mapSchema } from '@graphql-tools/utils';
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import { cloneSubschemaConfig, SubschemaConfig } from '@graphql-tools/delegate';

import { SubschemaConfigTransform } from '../types';
Expand All @@ -15,13 +15,13 @@ export function computedDirectiveTransformer(computedDirectiveName: string): Sub
return undefined;
}

const computed = getDirectives(schema, fieldConfig)[computedDirectiveName];
const computed = getDirective(schema, fieldConfig, computedDirectiveName)?.[0];

if (computed == null) {
return undefined;
}

const selectionSet = computed.fields != null ? `{ ${computed.fields} }` : computed.selectionSet;
const selectionSet = computed['fields'] != null ? `{ ${computed['fields']} }` : computed['selectionSet'];

if (selectionSet == null) {
return undefined;
Expand Down
38 changes: 19 additions & 19 deletions packages/stitch/tests/mergeDefinitions.test.ts
@@ -1,6 +1,6 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import { stitchSchemas } from '@graphql-tools/stitch';
import { getDirectives } from '@graphql-tools/utils';
import { getDirective } from '@graphql-tools/utils';
import { stitchingDirectives } from '@graphql-tools/stitching-directives';
import {
GraphQLObjectType,
Expand Down Expand Up @@ -282,22 +282,22 @@ describe('merge canonical types', () => {
const scalarType = gatewaySchema.getType('ProductScalar');
assertGraphQLScalerType(scalarType)

expect(getDirectives(firstSchema, queryType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, objectType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, interfaceType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, inputType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, enumType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, unionType.toConfig())['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, scalarType.toConfig())['mydir'].value).toEqual('first');

expect(getDirectives(firstSchema, queryType.getFields()['field1'])['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, queryType.getFields()['field2'])['mydir'].value).toEqual('second');
expect(getDirectives(firstSchema, objectType.getFields()['id'])['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, objectType.getFields()['url'])['mydir'].value).toEqual('second');
expect(getDirectives(firstSchema, interfaceType.getFields()['id'])['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, interfaceType.getFields()['url'])['mydir'].value).toEqual('second');
expect(getDirectives(firstSchema, inputType.getFields()['id'])['mydir'].value).toEqual('first');
expect(getDirectives(firstSchema, inputType.getFields()['url'])['mydir'].value).toEqual('second');
expect(getDirective(firstSchema, queryType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, objectType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, interfaceType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, inputType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, enumType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, unionType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, scalarType.toConfig(), 'mydir')?.[0]['value']).toEqual('first');

expect(getDirective(firstSchema, queryType.getFields()['field1'], 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, queryType.getFields()['field2'], 'mydir')?.[0]['value']).toEqual('second');
expect(getDirective(firstSchema, objectType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, objectType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second');
expect(getDirective(firstSchema, interfaceType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, interfaceType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second');
expect(getDirective(firstSchema, inputType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first');
expect(getDirective(firstSchema, inputType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second');

expect(enumType.toConfig().astNode?.values?.map(v => v.description?.value)).toEqual(['first', 'first', 'second']);
expect(enumType.toConfig().values['YES'].astNode?.description?.value).toEqual('first');
Expand All @@ -309,8 +309,8 @@ describe('merge canonical types', () => {
const objectType = gatewaySchema.getType('Product') as GraphQLObjectType;
expect(objectType.getFields()['id'].deprecationReason).toEqual('first');
expect(objectType.getFields()['url'].deprecationReason).toEqual('second');
expect(getDirectives(firstSchema, objectType.getFields()['id'])['deprecated'].reason).toEqual('first');
expect(getDirectives(firstSchema, objectType.getFields()['url'])['deprecated'].reason).toEqual('second');
expect(getDirective(firstSchema, objectType.getFields()['id'], 'deprecated')?.[0]['reason']).toEqual('first');
expect(getDirective(firstSchema, objectType.getFields()['url'], 'deprecated')?.[0]['reason']).toEqual('second');
});

it('promotes canonical root field definitions', async () => {
Expand Down
17 changes: 9 additions & 8 deletions packages/stitch/tests/typeMergingWithExtensions.test.ts
Expand Up @@ -52,9 +52,9 @@ describe('merging using type merging', () => {
},
resolve: (_root, { keys }) => keys.map((key: Record<string, any>) => users.find(u => u.id === key['id'])),
extensions: {
directives: {
merge: {},
},
directives: [{
name: 'merge',
}],
},
}
}),
Expand All @@ -68,12 +68,13 @@ describe('merging using type merging', () => {
username: { type: GraphQLString }
}),
extensions: {
directives: {
key: {
directives: [{
name: 'key',
args: {
selectionSet: '{ id }',
}
}
}
},
}],
},
});

const accountsSchema = stitchingDirectivesValidator(new GraphQLSchema({
Expand Down
148 changes: 95 additions & 53 deletions packages/stitching-directives/src/stitchingDirectivesTransformer.ts
Expand Up @@ -17,7 +17,7 @@ import {

import { cloneSubschemaConfig, SubschemaConfig, MergedTypeConfig, MergedFieldConfig } from '@graphql-tools/delegate';
import {
getDirectives,
getDirective,
getImplementingTypes,
MapperKind,
mapSchema,
Expand Down Expand Up @@ -73,45 +73,48 @@ export function stitchingDirectivesTransformer(

mapSchema(schema, {
[MapperKind.OBJECT_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (keyDirectiveName != null && directives[keyDirectiveName] != null) {
const keyDirective = directives[keyDirectiveName];
const selectionSet = parseSelectionSet(keyDirective.selectionSet, { noLocation: true });
const keyDirective = getDirective(schema, type, keyDirectiveName, pathToDirectivesInExtensions)?.[0];
if (keyDirective != null) {
const selectionSet = parseSelectionSet(keyDirective['selectionSet'], { noLocation: true });
selectionSetsByType[type.name] = selectionSet;
}

if (canonicalDirectiveName != null && directives[canonicalDirectiveName]) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];
if (canonicalDirective != null) {
setCanonicalDefinition(type.name);
}

return undefined;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions);

if (computedDirectiveName != null && directives[computedDirectiveName] != null) {
const computedDirective = directives[computedDirectiveName];
const selectionSet = parseSelectionSet(computedDirective.selectionSet, { noLocation: true });
const computedDirective = getDirective(
schema,
fieldConfig,
computedDirectiveName,
pathToDirectivesInExtensions
)?.[0];
if (computedDirective != null) {
const selectionSet = parseSelectionSet(computedDirective['selectionSet'], { noLocation: true });
if (!computedFieldSelectionSets[typeName]) {
computedFieldSelectionSets[typeName] = Object.create(null);
}
computedFieldSelectionSets[typeName][fieldName] = selectionSet;
}

if (
mergeDirectiveName != null &&
directives[mergeDirectiveName] != null &&
directives[mergeDirectiveName].keyField
) {
const mergeDirectiveKeyField = directives[mergeDirectiveName].keyField;
const mergeDirective = getDirective(schema, fieldConfig, mergeDirectiveName, pathToDirectivesInExtensions)?.[0];
if (mergeDirective?.['keyField'] != null) {
const mergeDirectiveKeyField = mergeDirective['keyField'];
const selectionSet = parseSelectionSet(`{ ${mergeDirectiveKeyField}}`, { noLocation: true });

const typeNames: Array<string> = directives[mergeDirectiveName].types;
const typeNames: Array<string> = mergeDirective['types'];

const returnType = getNamedType(fieldConfig.type);

forEachConcreteType(schema, returnType, directives[mergeDirectiveName]?.types, typeName => {
forEachConcreteType(schema, returnType, typeNames, typeName => {
if (typeNames == null || typeNames.includes(typeName)) {
const existingSelectionSet = selectionSetsByType[typeName];
selectionSetsByType[typeName] = existingSelectionSet
Expand All @@ -121,70 +124,111 @@ export function stitchingDirectivesTransformer(
});
}

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
fieldConfig,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];
if (canonicalDirective != null) {
setCanonicalDefinition(typeName, fieldName);
}

return undefined;
},
[MapperKind.INTERFACE_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective) {
setCanonicalDefinition(type.name);
}

return undefined;
},
[MapperKind.INTERFACE_FIELD]: (fieldConfig, fieldName, typeName) => {
const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName]) {
const canonicalDirective = getDirective(
schema,
fieldConfig,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective) {
setCanonicalDefinition(typeName, fieldName);
}

return undefined;
},
[MapperKind.INPUT_OBJECT_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective) {
setCanonicalDefinition(type.name);
}

return undefined;
},
[MapperKind.INPUT_OBJECT_FIELD]: (inputFieldConfig, fieldName, typeName) => {
const directives = getDirectives(schema, inputFieldConfig, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
inputFieldConfig,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective != null) {
setCanonicalDefinition(typeName, fieldName);
}

return undefined;
},
[MapperKind.UNION_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective != null) {
setCanonicalDefinition(type.name);
}

return undefined;
},
[MapperKind.ENUM_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective != null) {
setCanonicalDefinition(type.name);
}

return undefined;
},
[MapperKind.SCALAR_TYPE]: type => {
const directives = getDirectives(schema, type, pathToDirectivesInExtensions);

if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) {
const canonicalDirective = getDirective(
schema,
type,
canonicalDirectiveName,
pathToDirectivesInExtensions
)?.[0];

if (canonicalDirective != null) {
setCanonicalDefinition(type.name);
}

Expand Down Expand Up @@ -248,23 +292,21 @@ export function stitchingDirectivesTransformer(

mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName) => {
const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions);

if (mergeDirectiveName != null && directives[mergeDirectiveName] != null) {
const directiveArgumentMap = directives[mergeDirectiveName];
const mergeDirective = getDirective(schema, fieldConfig, mergeDirectiveName, pathToDirectivesInExtensions)?.[0];

if (mergeDirective != null) {
const returnType = getNullableType(fieldConfig.type);
const returnsList = isListType(returnType);
const namedType = getNamedType(returnType);

let mergeArgsExpr: string = directiveArgumentMap.argsExpr;
let mergeArgsExpr: string = mergeDirective['argsExpr'];

if (mergeArgsExpr == null) {
const key: Array<string> = directiveArgumentMap.key;
const keyField: string = directiveArgumentMap.keyField;
const key: Array<string> = mergeDirective['key'];
const keyField: string = mergeDirective['keyField'];
const keyExpr = key != null ? buildKeyExpr(key) : keyField != null ? `$key.${keyField}` : '$key';

const keyArg: string = directiveArgumentMap.keyArg;
const keyArg: string = mergeDirective['keyArg'];
const argNames = keyArg == null ? [Object.keys(fieldConfig.args ?? {})[0]] : keyArg.split('.');

const lastArgName = argNames.pop();
Expand All @@ -275,7 +317,7 @@ export function stitchingDirectivesTransformer(
}
}

const typeNames: Array<string> = directiveArgumentMap.types;
const typeNames: Array<string> = mergeDirective['types'];

forEachConcreteTypeName(namedType, schema, typeNames, typeName => {
const parsedMergeArgsExpr = parseMergeArgsExpr(
Expand All @@ -285,7 +327,7 @@ export function stitchingDirectivesTransformer(
: mergeSelectionSets(...allSelectionSetsByType[typeName])
);

const additionalArgs = directiveArgumentMap.additionalArgs;
const additionalArgs = mergeDirective['additionalArgs'];
if (additionalArgs != null) {
parsedMergeArgsExpr.args = mergeDeep(
parsedMergeArgsExpr.args,
Expand Down

0 comments on commit 74581cf

Please sign in to comment.