From 35fd769a9212fcb022195fef706a510e2e9ccef5 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 5 Jun 2020 10:49:08 -0400 Subject: [PATCH] introduce input object field transformers: Filter/Rename/TransformInputObjectFields (#1551) --- packages/utils/src/Interfaces.ts | 6 + .../src/transforms/FilterInputObjectFields.ts | 28 +++ .../src/transforms/RenameInputObjectFields.ts | 72 +++++++ .../transforms/TransformInputObjectFields.ts | 194 ++++++++++++++++++ packages/wrap/src/transforms/index.ts | 3 + packages/wrap/src/types.ts | 27 ++- packages/wrap/tests/transforms.test.ts | 118 +++++++++++ 7 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 packages/wrap/src/transforms/FilterInputObjectFields.ts create mode 100644 packages/wrap/src/transforms/RenameInputObjectFields.ts create mode 100644 packages/wrap/src/transforms/TransformInputObjectFields.ts diff --git a/packages/utils/src/Interfaces.ts b/packages/utils/src/Interfaces.ts index c4be11147b5..a03fee5e7a6 100644 --- a/packages/utils/src/Interfaces.ts +++ b/packages/utils/src/Interfaces.ts @@ -158,6 +158,12 @@ export type FieldNodeMapper = ( export type FieldNodeMappers = Record>; +export type InputFieldFilter = ( + typeName?: string, + fieldName?: string, + inputFieldConfig?: GraphQLInputFieldConfig +) => boolean; + export type FieldFilter = ( typeName?: string, fieldName?: string, diff --git a/packages/wrap/src/transforms/FilterInputObjectFields.ts b/packages/wrap/src/transforms/FilterInputObjectFields.ts new file mode 100644 index 00000000000..e684d68642e --- /dev/null +++ b/packages/wrap/src/transforms/FilterInputObjectFields.ts @@ -0,0 +1,28 @@ +import { GraphQLSchema, GraphQLInputFieldConfig } from 'graphql'; + +import { Transform, Request, InputFieldFilter } from '@graphql-tools/utils'; + +import TransformInputObjectFields from './TransformInputObjectFields'; +import { DelegationContext } from 'packages/delegate/src'; +import { InputObjectNodeTransformer } from '../types'; + +export default class FilterInputObjectFields implements Transform { + private readonly transformer: TransformInputObjectFields; + + constructor(filter: InputFieldFilter, inputObjectNodeTransformer?: InputObjectNodeTransformer) { + this.transformer = new TransformInputObjectFields( + (typeName: string, fieldName: string, inputFieldConfig: GraphQLInputFieldConfig) => + filter(typeName, fieldName, inputFieldConfig) ? undefined : null, + undefined, + inputObjectNodeTransformer + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request, delegationContext: DelegationContext): Request { + return this.transformer.transformRequest(originalRequest, delegationContext); + } +} diff --git a/packages/wrap/src/transforms/RenameInputObjectFields.ts b/packages/wrap/src/transforms/RenameInputObjectFields.ts new file mode 100644 index 00000000000..287553ccb34 --- /dev/null +++ b/packages/wrap/src/transforms/RenameInputObjectFields.ts @@ -0,0 +1,72 @@ +import { GraphQLSchema, GraphQLInputFieldConfig, ObjectFieldNode } from 'graphql'; + +import { Transform, Request, mapSchema, MapperKind } from '@graphql-tools/utils'; + +import TransformInputObjectFields from './TransformInputObjectFields'; +import { DelegationContext } from 'packages/delegate/src'; + +export default class RenameInputObjectFields implements Transform { + private readonly renamer: (typeName: string, fieldName: string, inputFieldConfig: GraphQLInputFieldConfig) => string; + private readonly transformer: TransformInputObjectFields; + private reverseMap: Record>; + + constructor(renamer: (typeName: string, fieldName: string, inputFieldConfig: GraphQLInputFieldConfig) => string) { + this.renamer = renamer; + this.transformer = new TransformInputObjectFields( + (typeName: string, inputFieldName: string, inputFieldConfig: GraphQLInputFieldConfig) => { + const newName = renamer(typeName, inputFieldName, inputFieldConfig); + if (newName !== undefined && newName !== inputFieldName) { + return [renamer(typeName, inputFieldName, inputFieldConfig), inputFieldConfig]; + } + }, + (typeName: string, inputFieldName: string, inputFieldNode: ObjectFieldNode) => { + if (!(typeName in this.reverseMap)) { + return inputFieldNode; + } + + const inputFieldNameMap = this.reverseMap[typeName]; + if (!(inputFieldName in inputFieldNameMap)) { + return inputFieldNode; + } + + return { + ...inputFieldNode, + name: { + ...inputFieldNode.name, + value: inputFieldNameMap[inputFieldName], + }, + }; + } + ); + this.reverseMap = Object.create(null); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + mapSchema(originalSchema, { + [MapperKind.INPUT_OBJECT_FIELD]: ( + inputFieldConfig: GraphQLInputFieldConfig, + fieldName: string, + typeName + ): undefined => { + const newName = this.renamer(typeName, fieldName, inputFieldConfig); + if (newName !== undefined && newName !== fieldName) { + if (this.reverseMap[typeName] == null) { + this.reverseMap[typeName] = Object.create(null); + } + this.reverseMap[typeName][newName] = fieldName; + } + return undefined; + }, + + [MapperKind.ROOT_OBJECT]() { + return undefined; + }, + }); + + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request, delegationContext: DelegationContext): Request { + return this.transformer.transformRequest(originalRequest, delegationContext); + } +} diff --git a/packages/wrap/src/transforms/TransformInputObjectFields.ts b/packages/wrap/src/transforms/TransformInputObjectFields.ts new file mode 100644 index 00000000000..69f5e1286a1 --- /dev/null +++ b/packages/wrap/src/transforms/TransformInputObjectFields.ts @@ -0,0 +1,194 @@ +import { + GraphQLSchema, + GraphQLType, + DocumentNode, + TypeInfo, + visit, + visitWithTypeInfo, + Kind, + FragmentDefinitionNode, + GraphQLInputObjectType, + ObjectValueNode, + ObjectFieldNode, +} from 'graphql'; + +import { Transform, Request, MapperKind, mapSchema } from '@graphql-tools/utils'; +import { InputFieldTransformer, InputFieldNodeTransformer, InputObjectNodeTransformer } from '../types'; +import { DelegationContext } from 'packages/delegate/src'; + +export default class TransformInputObjectFields implements Transform { + private readonly inputFieldTransformer: InputFieldTransformer; + private readonly inputFieldNodeTransformer: InputFieldNodeTransformer; + private readonly inputObjectNodeTransformer: InputObjectNodeTransformer; + private transformedSchema: GraphQLSchema; + private mapping: Record>; + + constructor( + inputFieldTransformer: InputFieldTransformer, + inputFieldNodeTransformer?: InputFieldNodeTransformer, + inputObjectNodeTransformer?: InputObjectNodeTransformer + ) { + this.inputFieldTransformer = inputFieldTransformer; + this.inputFieldNodeTransformer = inputFieldNodeTransformer; + this.inputObjectNodeTransformer = inputObjectNodeTransformer; + this.mapping = {}; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + this.transformedSchema = mapSchema(originalSchema, { + [MapperKind.INPUT_OBJECT_TYPE]: (type: GraphQLInputObjectType) => + this.transformFields(type, this.inputFieldTransformer), + }); + + return this.transformedSchema; + } + + public transformRequest(originalRequest: Request, delegationContext: DelegationContext): Request { + const fragments = Object.create(null); + originalRequest.document.definitions + .filter(def => def.kind === Kind.FRAGMENT_DEFINITION) + .forEach(def => { + fragments[(def as FragmentDefinitionNode).name.value] = def; + }); + const document = this.transformDocument( + originalRequest.document, + this.mapping, + this.inputFieldNodeTransformer, + this.inputObjectNodeTransformer, + delegationContext, + originalRequest + ); + return { + ...originalRequest, + document, + }; + } + + private transformFields(type: GraphQLInputObjectType, inputFieldTransformer: InputFieldTransformer): any { + const config = type.toConfig(); + + const originalInputFieldConfigMap = config.fields; + const newInputFieldConfigMap = {}; + + Object.keys(originalInputFieldConfigMap).forEach(fieldName => { + const originalInputFieldConfig = originalInputFieldConfigMap[fieldName]; + const transformedField = inputFieldTransformer(type.name, fieldName, originalInputFieldConfig); + + if (transformedField === undefined) { + newInputFieldConfigMap[fieldName] = originalInputFieldConfig; + } else if (Array.isArray(transformedField)) { + const newFieldName = transformedField[0]; + const newFieldConfig = transformedField[1]; + newInputFieldConfigMap[newFieldName] = newFieldConfig; + + if (newFieldName !== fieldName) { + const typeName = type.name; + if (!(typeName in this.mapping)) { + this.mapping[typeName] = {}; + } + this.mapping[typeName][newFieldName] = fieldName; + } + } else if (transformedField != null) { + newInputFieldConfigMap[fieldName] = transformedField; + } + }); + + if (!Object.keys(newInputFieldConfigMap).length) { + return null; + } + + return new GraphQLInputObjectType({ + ...type.toConfig(), + fields: newInputFieldConfigMap, + }); + } + + private transformDocument( + document: DocumentNode, + mapping: Record>, + inputFieldNodeTransformer: InputFieldNodeTransformer, + inputObjectNodeTransformer: InputObjectNodeTransformer, + delegationContext: DelegationContext, + request: Request + ): DocumentNode { + const typeInfo = new TypeInfo(this.transformedSchema); + const newDocument: DocumentNode = visit( + document, + visitWithTypeInfo(typeInfo, { + leave: { + [Kind.OBJECT]: (node: ObjectValueNode): ObjectValueNode => { + const parentType: GraphQLType = typeInfo.getInputType() as GraphQLInputObjectType; + if (parentType != null) { + const parentTypeName = parentType.name; + const newInputFields: Array = []; + + node.fields.forEach(inputField => { + const newName = inputField.name.value; + + const transformedInputField = + inputFieldNodeTransformer != null + ? inputFieldNodeTransformer(parentTypeName, newName, inputField, delegationContext, request) + : inputField; + + if (Array.isArray(transformedInputField)) { + transformedInputField.forEach(individualTransformedInputField => { + const typeMapping = mapping[parentTypeName]; + if (typeMapping == null) { + newInputFields.push(individualTransformedInputField); + return; + } + + const oldName = typeMapping[newName]; + if (oldName == null) { + newInputFields.push(individualTransformedInputField); + return; + } + + newInputFields.push({ + ...individualTransformedInputField, + name: { + ...individualTransformedInputField.name, + value: oldName, + }, + }); + }); + return; + } + + const typeMapping = mapping[parentTypeName]; + if (typeMapping == null) { + newInputFields.push(transformedInputField); + return; + } + + const oldName = typeMapping[newName]; + if (oldName == null) { + newInputFields.push(transformedInputField); + return; + } + + newInputFields.push({ + ...transformedInputField, + name: { + ...transformedInputField.name, + value: oldName, + }, + }); + }); + + const newNode = { + ...node, + fields: newInputFields, + }; + + return inputObjectNodeTransformer != null + ? inputObjectNodeTransformer(parentTypeName, newNode, delegationContext, request) + : newNode; + } + }, + }, + }) + ); + return newDocument; + } +} diff --git a/packages/wrap/src/transforms/index.ts b/packages/wrap/src/transforms/index.ts index 9f620acbc5e..09cb95d9484 100644 --- a/packages/wrap/src/transforms/index.ts +++ b/packages/wrap/src/transforms/index.ts @@ -11,6 +11,9 @@ export { default as FilterObjectFields } from './FilterObjectFields'; export { default as TransformInterfaceFields } from './TransformInterfaceFields'; export { default as RenameInterfaceFields } from './RenameInterfaceFields'; export { default as FilterInterfaceFields } from './FilterInterfaceFields'; +export { default as TransformInputObjectFields } from './TransformInputObjectFields'; +export { default as RenameInputObjectFields } from './RenameInputObjectFields'; +export { default as FilterInputObjectFields } from './FilterInputObjectFields'; export { default as TransformQuery } from './TransformQuery'; export { default as ExtendSchema } from './ExtendSchema'; diff --git a/packages/wrap/src/types.ts b/packages/wrap/src/types.ts index 2eea98ea14a..1cc7f29bdc5 100644 --- a/packages/wrap/src/types.ts +++ b/packages/wrap/src/types.ts @@ -2,12 +2,16 @@ import { GraphQLSchema, GraphQLFieldResolver, BuildSchemaOptions, + GraphQLInputFieldConfig, GraphQLFieldConfig, FieldNode, FragmentDefinitionNode, SelectionNode, + ObjectFieldNode, + ObjectValueNode, } from 'graphql'; -import { Executor, Subscriber } from '@graphql-tools/delegate'; +import { Executor, Subscriber, DelegationContext } from '@graphql-tools/delegate'; +import { Request } from '@graphql-tools/utils'; export interface IMakeRemoteExecutableSchemaOptions { schema: GraphQLSchema | string; @@ -17,6 +21,27 @@ export interface IMakeRemoteExecutableSchemaOptions { buildSchemaOptions?: BuildSchemaOptions; } +export type InputFieldTransformer = ( + typeName: string, + fieldName: string, + inputFieldConfig: GraphQLInputFieldConfig +) => GraphQLInputFieldConfig | [string, GraphQLInputFieldConfig] | null | undefined; + +export type InputFieldNodeTransformer = ( + typeName: string, + fieldName: string, + inputFieldNode: ObjectFieldNode, + delegationContext: DelegationContext, + request: Request +) => ObjectFieldNode | Array; + +export type InputObjectNodeTransformer = ( + typeName: string, + inputObjectNode: ObjectValueNode, + delegationContext: DelegationContext, + request: Request +) => ObjectValueNode; + export type FieldTransformer = ( typeName: string, fieldName: string, diff --git a/packages/wrap/tests/transforms.test.ts b/packages/wrap/tests/transforms.test.ts index 66827f1584d..598dc74e54a 100644 --- a/packages/wrap/tests/transforms.test.ts +++ b/packages/wrap/tests/transforms.test.ts @@ -7,6 +7,8 @@ import { SelectionSetNode, print, parse, + astFromValue, + GraphQLString, } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -24,6 +26,8 @@ import { WrapQuery, ExtractField, TransformQuery, + FilterInputObjectFields, + RenameInputObjectFields, } from '@graphql-tools/wrap'; import { @@ -1368,4 +1372,118 @@ describe('replaces field with processed fragment node', () => { }, }); }); + + describe('transform input object fields', () => { + test('filtering works', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + input InputObject { + field1: String + field2: String + } + + type OutputObject { + field1: String + field2: String + } + + type Query { + test(argument: InputObject): OutputObject + } + `, + resolvers: { + Query: { + test: (_root, args) => { + return args.argument; + } + } + } + }); + + const transformedSchema = wrapSchema(schema, [ + new FilterInputObjectFields( + (typeName, fieldName) => (typeName !== 'InputObject' || fieldName !== 'field2'), + (typeName, inputObjectNode) => { + if (typeName === 'InputObject') { + return { + ...inputObjectNode, + fields: [...inputObjectNode.fields, { + kind: Kind.OBJECT_FIELD, + name: { + kind: Kind.NAME, + value: 'field2', + }, + value: astFromValue('field2', GraphQLString), + }], + }; + } + } + ) + ]); + + const query = `{ + test(argument: { + field1: "field1" + }) { + field1 + field2 + } + }`; + + const result = await graphql(transformedSchema, query); + expect(result.data.test.field1).toBe('field1'); + expect(result.data.test.field2).toBe('field2'); + }); + }); + + test('renaming works', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + input InputObject { + field1: String + field2: String + } + + type OutputObject { + field1: String + field2: String + } + + type Query { + test(argument: InputObject): OutputObject + } + `, + resolvers: { + Query: { + test: (_root, args) => { + return args.argument; + } + } + } + }); + + const transformedSchema = wrapSchema(schema, [ + new RenameInputObjectFields( + (typeName: string, fieldName: string) => { + if (typeName === 'InputObject' && fieldName === 'field2') { + return 'field3'; + } + }, + ) + ]); + + const query = `{ + test(argument: { + field1: "field1" + field3: "field2" + }) { + field1 + field2 + } + }`; + + const result = await graphql(transformedSchema, query); + expect(result.data.test.field1).toBe('field1'); + expect(result.data.test.field2).toBe('field2'); + }); });