diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac4ff8b214..409c3c4261a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ the use of the `RenameRootFields` transform with subscriptions, pull request [#1104](https://github.com/apollographql/graphql-tools/pull/1104), fixes [#997](https://github.com/apollographql/graphql-tools/issues/997).
+* Add transformers to rename, filter, and arbitrarily transform object fields.
+ Fixes [#819](https://github.com/apollographql/graphql-tools/issues/819). ### 4.0.4 diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 3102105c9a0..389368f51f7 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -170,6 +170,54 @@ RenameRootFields( ) ``` +### Modifying object fields + +* `TransformObjectFields(objectFieldTransformer: ObjectFieldTransformer, fieldNodeTransformer?: FieldNodeTransformer))`: Given an object field transformer, arbitrarily transform fields. The `objectFieldTransformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. The optional `fieldNodeTransformer`, if specified, is called upon any field of that type in the request; result transformation can be specified by wrapping the resolve function within the `objectFieldTransformer`. In this way, a field can be fully arbitrarily modified in place. + +```ts +TransformObjectFields(objectFieldTransformer: ObjectFieldTransformer, fieldNodeTransformer: FieldNodeTransformer) + +type ObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | void; + +type FieldNodeTransformer = ( + typeName: string, + fieldName: string, + fieldNode: FieldNode +) => FieldNode; +``` + +* `FilterObjectFields(filter: ObjectFilter)`: Removes object fields for which the `filter` function returns `false`. + +```ts +FilterObjectFields(filter: ObjectFilter) + +type ObjectFilter = ( + typeName: string, + fieldName: string, + field: GraphQLField, +) => boolean; +``` + +* `RenameObjectFields(renamer)`: Rename object fields, by applying the `renamer` function to their names. + +```ts +RenameObjectFields( + renamer: ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => string, +) +``` + ### Other * `ExtractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index a7f4b3a422a..8b4c4deef24 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -7,6 +7,9 @@ import { ExecutionResult, subscribe, parse, + GraphQLField, + GraphQLNamedType, + FieldNode } from 'graphql'; import mergeSchemas from '../stitching/mergeSchemas'; import { @@ -14,6 +17,9 @@ import { FilterRootFields, RenameTypes, RenameRootFields, + RenameObjectFields, + FilterObjectFields, + TransformObjectFields, } from '../transforms'; import { propertySchema, @@ -23,6 +29,7 @@ import { subscriptionPubSubTrigger, } from './testingSchemas'; import { forAwaitEach } from 'iterall'; +import { createResolveType, fieldToFieldConfig } from '../stitching/schemaRecreation'; let linkSchema = ` """ @@ -79,7 +86,7 @@ describe('merge schemas through transforms', () => { 'Query.properties' === `${operation}.${rootField}`, ), new RenameTypes((name: string) => `Properties_${name}`), - new RenameRootFields((name: string) => `Properties_${name}`), + new RenameRootFields((operation: string, name: string) => `Properties_${name}`), ]); const transformedBookingSchema = transformSchema(bookingSchema, [ new FilterRootFields( @@ -87,9 +94,7 @@ describe('merge schemas through transforms', () => { 'Query.bookings' === `${operation}.${rootField}`, ), new RenameTypes((name: string) => `Bookings_${name}`), - new RenameRootFields( - (operation: string, name: string) => `Bookings_${name}`, - ), + new RenameRootFields((operation: string, name: string) => `Bookings_${name}`), ]); const transformedSubscriptionSchema = transformSchema(subscriptionSchema, [ new FilterRootFields( @@ -296,6 +301,121 @@ describe('merge schemas through transforms', () => { }); }); +describe('transform object fields', () => { + let transformedPropertySchema: GraphQLSchema; + + before(async () => { + const resolveType = createResolveType((name: string, type: GraphQLNamedType): GraphQLNamedType => type); + transformedPropertySchema = transformSchema(propertySchema, [ + new TransformObjectFields( + (typeName: string, fieldName: string, field: GraphQLField) => { + const fieldConfig = fieldToFieldConfig(field, resolveType, true); + if (typeName !== 'Property' || fieldName !== 'name') { + return fieldConfig; + } + fieldConfig.resolve = () => 'test'; + return fieldConfig; + }, + (typeName: string, fieldName: string, fieldNode: FieldNode) => { + if (typeName !== 'Property' || fieldName !== 'name') { + return fieldNode; + } + const newFieldNode = { + ...fieldNode, + name: { + ...fieldNode.name, + value: 'id' + } + }; + return newFieldNode; + } + ) + ]); + }); + + it('should work', async () => { + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + id + name + location { + name + } + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + expect(result).to.deep.equal({ + data: { + propertyById: { + id: 'p1', + name: 'test', + location: { + name: 'Helsinki', + }, + }, + }, + }); + }); +}); + +describe('filter and rename object fields', () => { + let transformedPropertySchema: GraphQLSchema; + + before(async () => { + transformedPropertySchema = transformSchema(propertySchema, [ + new RenameTypes((name: string) => `New_${name}`), + new FilterObjectFields((typeName: string, fieldName: string) => + (typeName !== 'NewProperty' || fieldName === 'id' || fieldName === 'name' || fieldName === 'location') + ), + new RenameObjectFields((typeName: string, fieldName: string) => (typeName === 'New_Property' ? `new_${fieldName}` : fieldName)) + ]); + }); + + it('should work', async () => { + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + new_id + new_name + new_location { + name + } + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + expect(result).to.deep.equal({ + data: { + propertyById: { + new_id: 'p1', + new_name: 'Super great hotel', + new_location: { + name: 'Helsinki', + }, + }, + }, + }); + }); +}); + describe('interface resolver inheritance', () => { const testSchemaWithInterfaceResolvers = ` interface Node { diff --git a/src/transforms/FilterObjectFields.ts b/src/transforms/FilterObjectFields.ts new file mode 100644 index 00000000000..cd0ca806f94 --- /dev/null +++ b/src/transforms/FilterObjectFields.ts @@ -0,0 +1,25 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; +import { Transform } from './transforms'; +import TransformObjectFields from './TransformObjectFields'; + +export type ObjectFilter = (typeName: string, fieldName: string, field: GraphQLField) => boolean; + +export default class FilterObjectFields implements Transform { + private transformer: TransformObjectFields; + + constructor(filter: ObjectFilter) { + this.transformer = new TransformObjectFields( + (typeName: string, fieldName: string, field: GraphQLField) => { + if (filter(typeName, fieldName, field)) { + return undefined; + } else { + return null; + } + } + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } +} diff --git a/src/transforms/RenameObjectFields.ts b/src/transforms/RenameObjectFields.ts new file mode 100644 index 00000000000..01426897e05 --- /dev/null +++ b/src/transforms/RenameObjectFields.ts @@ -0,0 +1,29 @@ +import { GraphQLNamedType, GraphQLField, GraphQLSchema } from 'graphql'; +import { Transform } from './transforms'; +import { createResolveType, fieldToFieldConfig } from '../stitching/schemaRecreation'; +import { Request } from '../Interfaces'; +import TransformObjectFields from './TransformObjectFields'; + +export default class RenameObjectFields implements Transform { + private transformer: TransformObjectFields; + + constructor(renamer: (typeName: string, fieldName: string, field: GraphQLField) => string) { + const resolveType = createResolveType((name: string, type: GraphQLNamedType): GraphQLNamedType => type); + this.transformer = new TransformObjectFields( + (typeName: string, fieldName: string, field: GraphQLField) => { + return { + name: renamer(typeName, fieldName, field), + field: fieldToFieldConfig(field, resolveType, true) + }; + } + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/transforms/TransformObjectFields.ts b/src/transforms/TransformObjectFields.ts new file mode 100644 index 00000000000..08dbc0af9fc --- /dev/null +++ b/src/transforms/TransformObjectFields.ts @@ -0,0 +1,168 @@ +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLNamedType, + GraphQLField, + GraphQLFieldConfig, + GraphQLType, + DocumentNode, + FieldNode, + TypeInfo, + visit, + visitWithTypeInfo, + Kind +} from 'graphql'; +import isEmptyObject from '../isEmptyObject'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; +import { visitSchema, VisitSchemaKind } from './visitSchema'; +import { createResolveType, fieldToFieldConfig } from '../stitching/schemaRecreation'; + +export type ObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField +) => GraphQLFieldConfig | { name: string; field: GraphQLFieldConfig } | null | undefined; + +export type FieldNodeTransformer = ( + typeName: string, + fieldName: string, + fieldNode: FieldNode +) => FieldNode; + +type FieldMapping = { + [typeName: string]: { + [newFieldName: string]: string; + }; +}; + +export default class TransformObjectFields implements Transform { + private objectFieldTransformer: ObjectFieldTransformer; + private fieldNodeTransformer: FieldNodeTransformer; + private schema: GraphQLSchema; + private mapping: FieldMapping; + + constructor(objectFieldTransformer: ObjectFieldTransformer, fieldNodeTransformer?: FieldNodeTransformer) { + this.objectFieldTransformer = objectFieldTransformer; + this.fieldNodeTransformer = fieldNodeTransformer; + this.mapping = {}; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + this.schema = originalSchema; + return visitSchema(originalSchema, { + [VisitSchemaKind.ROOT_OBJECT]: () => { + return undefined; + }, + [VisitSchemaKind.OBJECT_TYPE]: (type: GraphQLObjectType) => { + return this.transformFields(type, this.objectFieldTransformer); + } + }); + } + + public transformRequest(originalRequest: Request): Request { + const document = this.reverseMapping(originalRequest.document, this.mapping, this.fieldNodeTransformer); + return { + ...originalRequest, + document + }; + } + + private transformFields( + type: GraphQLObjectType, + objectFieldTransformer: ObjectFieldTransformer + ): GraphQLObjectType { + const resolveType = createResolveType( + (name: string, originalType: GraphQLNamedType): GraphQLNamedType => originalType + ); + const fields = type.getFields(); + const newFields = {}; + + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const transformedField = objectFieldTransformer(type.name, fieldName, field); + + if (typeof transformedField === 'undefined') { + newFields[fieldName] = fieldToFieldConfig(field, resolveType, true); + } else if (transformedField !== null) { + const newName = (transformedField as { name: string; field: GraphQLFieldConfig }).name; + + if (newName) { + newFields[newName] = (transformedField as { + name: string; + field: GraphQLFieldConfig; + }).field; + if (newName !== fieldName) { + const typeName = type.name; + if (!this.mapping[typeName]) { + this.mapping[typeName] = {}; + } + this.mapping[typeName][newName] = fieldName; + + const originalResolver = (transformedField as { + name: string; + field: GraphQLFieldConfig; + }).field.resolve; + (newFields[newName] as GraphQLFieldConfig).resolve = (parent, args, context, info) => + originalResolver(parent, args, context, { + ...info, + fieldName + }); + } + } else { + newFields[fieldName] = transformedField; + } + } + }); + if (isEmptyObject(newFields)) { + return null; + } else { + return new GraphQLObjectType({ + name: type.name, + description: type.description, + astNode: type.astNode, + isTypeOf: type.isTypeOf, + fields: newFields, + interfaces: () => type.getInterfaces().map(iface => resolveType(iface)) + }); + } + } + + private reverseMapping( + document: DocumentNode, + mapping: FieldMapping, + fieldNodeTransformer?: FieldNodeTransformer + ): DocumentNode { + const typeInfo = new TypeInfo(this.schema); + const newDocument: DocumentNode = visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.FIELD](node: FieldNode): FieldNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + if (parentType) { + const parentTypeName = parentType.name; + const newName = node.name.value; + const transformedNode = fieldNodeTransformer + ? fieldNodeTransformer(parentTypeName, newName, node) + : node; + let transformedName = transformedNode.name.value; + if (mapping[parentTypeName]) { + const originalName = mapping[parentTypeName][newName]; + if (originalName) { + transformedName = originalName; + } + } + return { + ...transformedNode, + name: { + ...node.name, + value: transformedName + } + }; + } + } + }) + ); + return newDocument; + } +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts index db76efbc221..8ffe9f238f2 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -17,6 +17,9 @@ export { default as FilterTypes } from './FilterTypes'; export { default as TransformRootFields } from './TransformRootFields'; export { default as RenameRootFields } from './RenameRootFields'; export { default as FilterRootFields } from './FilterRootFields'; +export { default as TransformObjectFields } from './TransformObjectFields'; +export { default as RenameObjectFields } from './RenameObjectFields'; +export { default as FilterObjectFields } from './FilterObjectFields'; export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; export { default as ExtractField } from './ExtractField'; export { default as WrapQuery } from './WrapQuery';