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';