diff --git a/.changeset/perfect-zebras-rest.md b/.changeset/perfect-zebras-rest.md new file mode 100644 index 0000000000000..89d0b07a3a496 --- /dev/null +++ b/.changeset/perfect-zebras-rest.md @@ -0,0 +1,7 @@ +--- +'@graphql-mesh/transform-hoist-field': minor +'@graphql-mesh/types': patch +'@graphql-mesh/website': patch +--- + +Added @graphql-mesh/transform-hoist-field package diff --git a/packages/transforms/hoist-field/package.json b/packages/transforms/hoist-field/package.json new file mode 100644 index 0000000000000..f1f7f45efc316 --- /dev/null +++ b/packages/transforms/hoist-field/package.json @@ -0,0 +1,37 @@ +{ + "name": "@graphql-mesh/transform-hoist-field", + "version": "0.0.0", + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.mjs", + "typings": "dist/index.d.ts", + "typescript": { + "definition": "dist/index.d.ts" + }, + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./*": { + "require": "./dist/*.js", + "import": "./dist/*.mjs" + } + }, + "license": "MIT", + "peerDependencies": { + "graphql": "*" + }, + "dependencies": { + "@graphql-mesh/types": "0.68.1", + "@graphql-mesh/utils": "0.31.0", + "@graphql-tools/delegate": "8.5.4", + "@graphql-tools/wrap": "8.4.6", + "@graphql-tools/utils": "8.6.3", + "@graphql-mesh/cache-inmemory-lru": "0.6.3" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + } +} diff --git a/packages/transforms/hoist-field/src/index.ts b/packages/transforms/hoist-field/src/index.ts new file mode 100644 index 0000000000000..699e7cf1d9d62 --- /dev/null +++ b/packages/transforms/hoist-field/src/index.ts @@ -0,0 +1,83 @@ +import { MeshTransform, MeshTransformOptions, YamlConfig } from '@graphql-mesh/types'; +import { applyRequestTransforms, applyResultTransforms, applySchemaTransforms } from '@graphql-mesh/utils'; +import { DelegationContext, SubschemaConfig, Transform } from '@graphql-tools/delegate'; +import { ExecutionRequest, ExecutionResult } from '@graphql-tools/utils'; +import { HoistField } from '@graphql-tools/wrap'; +import { GraphQLSchema } from 'graphql'; + +type HoistFieldCtorPathConfigItem = ConstructorParameters[1][0]; +type HoistFieldTransformFieldPathConfig = YamlConfig.HoistFieldTransformConfig['pathConfig'][0]; + +export default class MeshHoistField implements MeshTransform { + noWrap = false; + private transforms: Transform[]; + + constructor({ config }: MeshTransformOptions) { + this.transforms = config.map(({ typeName, pathConfig, newFieldName, alias, filterArgsInPath = false }) => { + const processedPathConfig = pathConfig.map(config => this.getPathConfigItem(config, filterArgsInPath)); + return new HoistField(typeName, processedPathConfig, newFieldName, alias); + }); + } + + private getPathConfigItem( + pathConfigItemFromConfig: HoistFieldTransformFieldPathConfig, + filterArgsInPath: boolean + ): HoistFieldCtorPathConfigItem { + if (typeof pathConfigItemFromConfig === 'string') { + const pathConfigItem: HoistFieldCtorPathConfigItem = { + fieldName: pathConfigItemFromConfig, + argFilter: () => filterArgsValue(filterArgsInPath), + }; + + return pathConfigItem; + } + + if (!pathConfigItemFromConfig.fieldName) { + throw new Error(`Field name is required in pathConfig item`); + } + + if (!pathConfigItemFromConfig.filterArgs) { + throw new Error(`FilterArgs is required in pathConfig item`); + } + + const filterArgsDict = (pathConfigItemFromConfig.filterArgs || []).reduce((prev, argName) => { + prev[argName] = true; + return prev; + }, {}); + + const pathConfigItem: HoistFieldCtorPathConfigItem = { + fieldName: pathConfigItemFromConfig.fieldName, + argFilter: arg => { + return filterArgsValue(filterArgsDict[arg.name]); + }, + }; + + return pathConfigItem; + } + + transformSchema( + originalWrappingSchema: GraphQLSchema, + subschemaConfig: SubschemaConfig, + transformedSchema?: GraphQLSchema + ) { + return applySchemaTransforms(originalWrappingSchema, subschemaConfig, transformedSchema, this.transforms); + } + + transformRequest( + originalRequest: ExecutionRequest, + delegationContext: DelegationContext, + transformationContext: Record + ) { + return applyRequestTransforms(originalRequest, delegationContext, transformationContext, this.transforms); + } + + transformResult(originalResult: ExecutionResult, delegationContext: DelegationContext, transformationContext: any) { + return applyResultTransforms(originalResult, delegationContext, transformationContext, this.transforms); + } +} + +// The argFilters in HoistField seem to work more like argIncludes, hence the value needs to be negated +// https://github.com/ardatan/graphql-tools/blob/af266974bf02967e0675187e9bea0391fd7fe0cf/packages/wrap/src/transforms/HoistField.ts#L44 +function filterArgsValue(filter: boolean) { + return !filter; +} diff --git a/packages/transforms/hoist-field/test/__snapshots__/transform.spec.ts.snap b/packages/transforms/hoist-field/test/__snapshots__/transform.spec.ts.snap new file mode 100644 index 0000000000000..d6c1a982f5086 --- /dev/null +++ b/packages/transforms/hoist-field/test/__snapshots__/transform.spec.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`hoist should hoist field and filter args with global flag 1`] = ` +"type Query { + users: [User!]! +} + +type UserSearchResult { + page: Int! +} + +type User { + id: ID! + name: String! +}" +`; + +exports[`hoist should hoist field and filter individual args via pathConfig 1`] = ` +"type Query { + users(page: Int): [User!]! +} + +type UserSearchResult { + page: Int! +} + +type User { + id: ID! + name: String! +}" +`; + +exports[`hoist should hoist field and filter individual args via pathConfig independent of global flag 1`] = ` +"type Query { + users(page: Int): [User!]! +} + +type UserSearchResult { + page: Int! +} + +type User { + id: ID! + name: String! +}" +`; + +exports[`hoist should hoist field with mixed pathConfig array 1`] = ` +"type Query { + users(limit: Int!, page: Int): [User!]! +} + +type UserSearchResult { + page: Int! +} + +type User { + id: ID! + name: String! +}" +`; + +exports[`hoist should hoist field with string pathConfig array 1`] = ` +"type Query { + users(limit: Int!, page: Int): [User!]! +} + +type UserSearchResult { + page: Int! +} + +type User { + id: ID! + name: String! +}" +`; diff --git a/packages/transforms/hoist-field/test/transform.spec.ts b/packages/transforms/hoist-field/test/transform.spec.ts new file mode 100644 index 0000000000000..3abb18a98fe47 --- /dev/null +++ b/packages/transforms/hoist-field/test/transform.spec.ts @@ -0,0 +1,215 @@ +import InMemoryLRUCache from '@graphql-mesh/cache-inmemory-lru'; +import { MeshPubSub } from '@graphql-mesh/types'; +import { wrapSchema } from '@graphql-tools/wrap'; +import { buildSchema, GraphQLField, GraphQLObjectType, printSchema } from 'graphql'; +import { defaultImportFn, PubSub } from '@graphql-mesh/utils'; + +import HoistFieldTransform from '../src'; + +describe('hoist', () => { + const importFn = defaultImportFn; + const schema = buildSchema(/* GraphQL */ ` + type Query { + users(limit: Int!, page: Int): UserSearchResult + } + + type UserSearchResult { + page: Int! + results: [User!]! + } + + type User { + id: ID! + name: String! + } + `); + let cache: InMemoryLRUCache; + let pubsub: MeshPubSub; + const baseDir: string = undefined; + + beforeEach(() => { + cache = new InMemoryLRUCache(); + pubsub = new PubSub(); + }); + + it('should hoist field with string pathConfig array', () => { + const newSchema = wrapSchema({ + schema, + transforms: [ + new HoistFieldTransform({ + config: [ + { + typeName: 'Query', + pathConfig: ['users', 'results'], + newFieldName: 'users', + }, + ], + apiName: '', + cache, + pubsub, + baseDir, + importFn, + }), + ], + }); + + const queryType = newSchema.getType('Query') as GraphQLObjectType; + expect(queryType).toBeDefined(); + + const fields = queryType.getFields(); + expect(fields.users).toBeDefined(); + + expect(printSchema(newSchema)).toMatchSnapshot(); + }); + + it('should hoist field with mixed pathConfig array', () => { + const newSchema = wrapSchema({ + schema, + transforms: [ + new HoistFieldTransform({ + config: [ + { + typeName: 'Query', + pathConfig: [ + { + fieldName: 'users', + filterArgs: [], + }, + 'results', + ], + newFieldName: 'users', + }, + ], + apiName: '', + cache, + pubsub, + baseDir, + importFn, + }), + ], + }); + + const queryType = newSchema.getType('Query') as GraphQLObjectType; + expect(queryType).toBeDefined(); + + const fields = queryType.getFields(); + expect(fields.users).toBeDefined(); + + expect(printSchema(newSchema)).toMatchSnapshot(); + }); + + it('should hoist field and filter args with global flag', () => { + const newSchema = wrapSchema({ + schema, + transforms: [ + new HoistFieldTransform({ + config: [ + { + typeName: 'Query', + pathConfig: ['users', 'results'], + newFieldName: 'users', + filterArgsInPath: true, + }, + ], + apiName: '', + cache, + pubsub, + baseDir, + importFn, + }), + ], + }); + + const queryType = newSchema.getType('Query') as GraphQLObjectType; + expect(queryType).toBeDefined(); + + const fields = queryType.getFields(); + expect(fields.users).toBeDefined(); + + const args = (fields.users as GraphQLField).args; + expect(args.length).toEqual(0); + + expect(printSchema(newSchema)).toMatchSnapshot(); + }); + + it('should hoist field and filter individual args via pathConfig', () => { + const newSchema = wrapSchema({ + schema, + transforms: [ + new HoistFieldTransform({ + config: [ + { + typeName: 'Query', + pathConfig: [ + { + fieldName: 'users', + filterArgs: ['limit'], + }, + 'results', + ], + newFieldName: 'users', + }, + ], + apiName: '', + cache, + pubsub, + baseDir, + importFn, + }), + ], + }); + + const queryType = newSchema.getType('Query') as GraphQLObjectType; + expect(queryType).toBeDefined(); + + const fields = queryType.getFields(); + expect(fields.users).toBeDefined(); + + const args = (fields.users as GraphQLField).args; + expect(args.length).toEqual(1); + expect(args[0].name).toEqual('page'); + + expect(printSchema(newSchema)).toMatchSnapshot(); + }); + + it('should hoist field and filter individual args via pathConfig independent of global flag', () => { + const newSchema = wrapSchema({ + schema, + transforms: [ + new HoistFieldTransform({ + config: [ + { + typeName: 'Query', + pathConfig: [ + { + fieldName: 'users', + filterArgs: ['limit'], + }, + 'results', + ], + newFieldName: 'users', + filterArgsInPath: true, + }, + ], + apiName: '', + cache, + pubsub, + baseDir, + importFn, + }), + ], + }); + + const queryType = newSchema.getType('Query') as GraphQLObjectType; + expect(queryType).toBeDefined(); + + const fields = queryType.getFields(); + expect(fields.users).toBeDefined(); + + const args = (fields.users as GraphQLField).args; + expect(args.length).toEqual(1); + expect(args[0].name).toEqual('page'); + + expect(printSchema(newSchema)).toMatchSnapshot(); + }); +}); diff --git a/packages/transforms/hoist-field/yaml-config.graphql b/packages/transforms/hoist-field/yaml-config.graphql new file mode 100644 index 0000000000000..50512c6172177 --- /dev/null +++ b/packages/transforms/hoist-field/yaml-config.graphql @@ -0,0 +1,40 @@ +extend type Transform { + """ + Transformer to hoist GraphQL fields + """ + hoistField: [HoistFieldTransformConfig!] +} + +type HoistFieldTransformConfig @md { + """ + Type name that defines where field should be hoisted to + """ + typeName: String! + """ + Array of fieldsNames to reach the field to be hoisted + """ + pathConfig: [HoistFieldTransformFieldPathConfig!]! + """ + Name the hoisted field should have when hoisted to the type specified in typeName + """ + newFieldName: String! + alias: String + + """ + Defines if args in path are filtered (default = false) + """ + filterArgsInPath: Boolean +} + +union HoistFieldTransformFieldPathConfig = String | HoistFieldTransformFieldPathConfigObject + +type HoistFieldTransformFieldPathConfigObject @md { + """ + Field name + """ + fieldName: String! + """ + Match fields based on argument, needs to implement `(arg: GraphQLArgument) => boolean`; + """ + filterArgs: [String!]! +} diff --git a/packages/types/src/config-schema.json b/packages/types/src/config-schema.json index 7f1865a41c4bc..2f44bb418d5de 100644 --- a/packages/types/src/config-schema.json +++ b/packages/types/src/config-schema.json @@ -472,6 +472,14 @@ "typeMerging": { "$ref": "#/definitions/TypeMergingConfig", "description": "[Type Merging](https://www.graphql-tools.com/docs/stitch-type-merging) Configuration" + }, + "hoistField": { + "type": "array", + "items": { + "$ref": "#/definitions/HoistFieldTransformConfig" + }, + "additionalItems": false, + "description": "Transformer to hoist GraphQL fields" } } }, @@ -3157,6 +3165,65 @@ "description": "Path to the SQL Dump file if you want to build a in-memory database" } } + }, + "HoistFieldTransformConfig": { + "additionalProperties": false, + "type": "object", + "title": "HoistFieldTransformConfig", + "properties": { + "typeName": { + "type": "string", + "description": "Type name that defines where field should be hoisted to" + }, + "pathConfig": { + "type": "array", + "items": { + "description": "Any of: String, HoistFieldTransformFieldPathConfigObject", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/HoistFieldTransformFieldPathConfigObject" + } + ] + }, + "additionalItems": false, + "description": "Array of fieldsNames to reach the field to be hoisted (Any of: String, HoistFieldTransformFieldPathConfigObject)" + }, + "newFieldName": { + "type": "string", + "description": "Name the hoisted field should have when hoisted to the type specified in typeName" + }, + "alias": { + "type": "string" + }, + "filterArgsInPath": { + "type": "boolean", + "description": "Defines if args in path are filtered (default = false)" + } + }, + "required": ["typeName", "pathConfig", "newFieldName"] + }, + "HoistFieldTransformFieldPathConfigObject": { + "additionalProperties": false, + "type": "object", + "title": "HoistFieldTransformFieldPathConfigObject", + "properties": { + "fieldName": { + "type": "string", + "description": "Field name" + }, + "filterArgs": { + "type": "array", + "items": { + "type": "string" + }, + "additionalItems": false, + "description": "Match fields based on argument, needs to implement `(arg: GraphQLArgument) => boolean`;" + } + }, + "required": ["fieldName", "filterArgs"] } }, "title": "Config", diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index c74d65fad4dce..51fca8c76645b 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -1087,6 +1087,10 @@ export interface Transform { resolversComposition?: ResolversCompositionTransform | any; snapshot?: SnapshotTransformConfig; typeMerging?: TypeMergingConfig; + /** + * Transformer to hoist GraphQL fields + */ + hoistField?: HoistFieldTransformConfig[]; [k: string]: any; } export interface CacheTransformConfig { @@ -1622,6 +1626,35 @@ export interface MergedRootFieldConfig { */ argsExpr?: string; } +export interface HoistFieldTransformConfig { + /** + * Type name that defines where field should be hoisted to + */ + typeName: string; + /** + * Array of fieldsNames to reach the field to be hoisted (Any of: String, HoistFieldTransformFieldPathConfigObject) + */ + pathConfig: (string | HoistFieldTransformFieldPathConfigObject)[]; + /** + * Name the hoisted field should have when hoisted to the type specified in typeName + */ + newFieldName: string; + alias?: string; + /** + * Defines if args in path are filtered (default = false) + */ + filterArgsInPath?: boolean; +} +export interface HoistFieldTransformFieldPathConfigObject { + /** + * Field name + */ + fieldName: string; + /** + * Match fields based on argument, needs to implement `(arg: GraphQLArgument) => boolean`; + */ + filterArgs: string[]; +} export interface AdditionalStitchingResolverObject { sourceName: string; sourceTypeName: string; diff --git a/website/docs/transforms/hoist.md b/website/docs/transforms/hoist.md new file mode 100644 index 0000000000000..8a5967442d9a5 --- /dev/null +++ b/website/docs/transforms/hoist.md @@ -0,0 +1,104 @@ +--- +id: hoist-field +title: Hoist Field Transform +sidebar_label: Hoist Field +--- + +The `hoist` transform allows you to lift a field from one object type to a 'parent' root or object type. It is currently only available as a `wrap` transform. + +``` +yarn add @graphql-mesh/transform-hoist-field +``` + +> Underneath it leverages the `HoistField` transform from the `@graphql-tools/wrap` package. + +## How to use? + +Given the following schema: +```graphql +type Query { + users(limit: Int!, page: Int): UserSearchResult +} + +type UserSearchResult { + page: Int! + results: [User!]! +} + +type User { + id: ID! +} +``` + +### Simple hoisting + +```yml +transforms: + - hoist: + - typeName: Query + pathConfig: + - users + - results + newFieldName: users +``` + +Will transform the given schema to: +```graphql +type Query { + users(limit: Int!, page: Int): [User!]! +} + +type User { + id: ID! +} +``` + +### Filtering args via a default for the entire path +```yml +transforms: + - hoist: + - typeName: Query + pathConfig: + - users + - results + newFieldName: users + filterArgsInPath: true # This flag sets the default for the entire path + +``` +Will transform the given schema to: +```graphql +type Query { + users: [User!]! +} + +type User { + id: ID! +} +``` +### Filtering args via on specific levels of the path +```yml +transforms: + - hoist: + - typeName: Query + pathConfig: + - fieldName: users + filterArgs: + - limit + - results + newFieldName: users + +``` +Will transform the given schema to: +```graphql +type Query { + users(page: Int): [User!]! +} + +type User { + id: ID! +} +``` + +## Config API Reference + +{@import ../generated-markdown/CacheTransformConfig.generated.md}