diff --git a/.changeset/twelve-windows-give.md b/.changeset/twelve-windows-give.md new file mode 100644 index 00000000000..f6165bf95ae --- /dev/null +++ b/.changeset/twelve-windows-give.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/visitor-plugin-common': minor +'@graphql-codegen/typescript-operations': minor +--- + +Add support for Apollo Client `@unmask` directive with fragment masking. diff --git a/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts b/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts index 1cc7d3b9dc4..94ed2ed07e9 100644 --- a/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts +++ b/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts @@ -11,7 +11,7 @@ import { ParsedTypesConfig, RawTypesConfig } from './base-types-visitor.js'; import { BaseVisitor } from './base-visitor.js'; import { DEFAULT_SCALARS } from './scalars.js'; import { SelectionSetToObject } from './selection-set-to-object.js'; -import { NormalizedScalarsMap } from './types.js'; +import { NormalizedScalarsMap, CustomDirectivesConfig } from './types.js'; import { buildScalarsFromConfig, DeclarationBlock, DeclarationBlockConfig, getConfigValue } from './utils.js'; import { OperationVariablesToObject } from './variables-to-object.js'; @@ -40,6 +40,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig { skipTypeNameForRoot: boolean; experimentalFragmentVariables: boolean; mergeFragmentTypes: boolean; + customDirectives: CustomDirectivesConfig; } export interface RawDocumentsConfig extends RawTypesConfig { @@ -149,6 +150,30 @@ export interface RawDocumentsConfig extends RawTypesConfig { * @ignore */ namespacedImportName?: string; + + /** + * @description Configures behavior for use with custom directives from + * various GraphQL libraries. + * @exampleMarkdown + * ```ts filename="codegen.ts" + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'path/to/file.ts': { + * plugins: ['typescript'], + * config: { + * customDirectives: { + * apolloUnmask: true + * } + * }, + * }, + * }, + * }; + * export default config; + */ + customDirectives?: CustomDirectivesConfig; } export class BaseDocumentsVisitor< @@ -180,6 +205,7 @@ export class BaseDocumentsVisitor< globalNamespace: !!rawConfig.globalNamespace, operationResultSuffix: getConfigValue(rawConfig.operationResultSuffix, ''), scalars: buildScalarsFromConfig(_schema, rawConfig, defaultScalars), + customDirectives: getConfigValue(rawConfig.customDirectives, { apolloUnmask: false }), ...((additionalConfig || {}) as any), }); diff --git a/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts b/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts index 58253118680..269eab6efc9 100644 --- a/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts +++ b/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts @@ -362,6 +362,7 @@ export interface RawConfig { * @description Whether fragment types should be inlined into other operations. * "inline" is the default behavior and will perform deep inlining fragment types within operation type definitions. * "combine" is the previous behavior that uses fragment type references without inlining the types (and might cause issues with deeply nested fragment that uses list types). + * "mask" transforms the types for use with fragment masking. Useful when masked types are needed when not using the "client" preset e.g. such as combining it with Apollo Client's data masking feature. * * @type string * @default inline diff --git a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts index 8422024ed91..34762629067 100644 --- a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts +++ b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts @@ -600,7 +600,15 @@ export class SelectionSetToObject d.name.value === 'unmask')) + ) { + continue; + } } // Handle Fragment Spreads by generating inline types. diff --git a/packages/plugins/other/visitor-plugin-common/src/types.ts b/packages/plugins/other/visitor-plugin-common/src/types.ts index a9aa76ce36d..b3792a6c7bb 100644 --- a/packages/plugins/other/visitor-plugin-common/src/types.ts +++ b/packages/plugins/other/visitor-plugin-common/src/types.ts @@ -128,6 +128,16 @@ export interface ResolversNonOptionalTypenameConfig { excludeTypes?: string[]; } +export interface CustomDirectivesConfig { + /** + * @description Adds integration with Apollo Client's `@unmask` directive + * when using Apollo Client's data masking feature. `@unmask` ensures fields + * marked by `@unmask` are available in the type definition. + * @default false + */ + apolloUnmask?: boolean; +} + export interface GenerateInternalResolversIfNeededConfig { __resolveReference?: boolean; } diff --git a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts index f37405c3b77..48690615fd7 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts @@ -7423,5 +7423,168 @@ function test(q: GetEntityBrandDataQuery): void { export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; `); }); + + it("'mask' with @unmask configured with apolloUnmask yields correct types", async () => { + const ast = parse(/* GraphQL */ ` + query { + me { + ...UserFragment @unmask + } + } + fragment UserFragment on User { + id + } + `); + const result = await plugin( + schema, + [{ location: 'test-file.ts', document: ast }], + { inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } }, + { outputFile: '' } + ); + expect(result.content).toBeSimilarStringTo(` + export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', me?: ( + { __typename?: 'User', id: string } + & { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } } + ) | null }; + + export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; + `); + }); + + it("'mask' with @unmask without apolloUnmask yields correct types", async () => { + const ast = parse(/* GraphQL */ ` + query { + me { + ...UserFragment @unmask + } + } + fragment UserFragment on User { + id + } + `); + const result = await plugin( + schema, + [{ location: 'test-file.ts', document: ast }], + { inlineFragmentTypes: 'mask' }, + { outputFile: '' } + ); + expect(result.content).toBeSimilarStringTo(` + export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', me?: ( + { __typename?: 'User' } + & { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } } + ) | null }; + + export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; + `); + }); + + it("'mask' with @unmask with apolloUnmask explicitly disabled yields correct types", async () => { + const ast = parse(/* GraphQL */ ` + query { + me { + ...UserFragment @unmask + } + } + fragment UserFragment on User { + id + } + `); + const result = await plugin( + schema, + [{ location: 'test-file.ts', document: ast }], + { inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: false } }, + { outputFile: '' } + ); + expect(result.content).toBeSimilarStringTo(` + export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', me?: ( + { __typename?: 'User' } + & { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } } + ) | null }; + + export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; + `); + }); + + it("'mask' with @unmask and masked fragments yields correct types", async () => { + const ast = parse(/* GraphQL */ ` + query { + me { + ...UserFragment @unmask + ...UserFragment2 + } + } + fragment UserFragment on User { + id + } + + fragment UserFragment2 on User { + email + } + `); + const result = await plugin( + schema, + [{ location: 'test-file.ts', document: ast }], + { inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } }, + { outputFile: '' } + ); + expect(result.content).toBeSimilarStringTo(` + export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', me?: ( + { __typename?: 'User', id: string } + & { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } } + ) | null }; + + export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; + export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' }; + `); + }); + + it("'mask' with @unmask and masked fragments on overlapping fields yields correct types", async () => { + const ast = parse(/* GraphQL */ ` + query { + me { + ...UserFragment @unmask + ...UserFragment2 + } + } + fragment UserFragment on User { + id + email + } + + fragment UserFragment2 on User { + email + } + `); + const result = await plugin( + schema, + [{ location: 'test-file.ts', document: ast }], + { inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } }, + { outputFile: '' } + ); + expect(result.content).toBeSimilarStringTo(` + export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', me?: ( + { __typename?: 'User', id: string, email: string } + & { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } } + ) | null }; + + export type UserFragmentFragment = { __typename?: 'User', id: string, email: string } & { ' $fragmentName'?: 'UserFragmentFragment' }; + export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' }; + `); + }); }); });