From fa64fbf8a44e1cee7ae17806dcd178dc7350c4ba Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 13 Nov 2024 05:10:11 -0700 Subject: [PATCH] Keep fields specified by `@unmask` for use with Apollo Client data masking (#10163) * Keep fields specified by @unmask * Update documentation for inlineFragmentTypes to mention mask option * Require apolloUnmask config to handle unmasking * Add changeset * Update packages changed in changeset * Fix prettier issue --- .changeset/twelve-windows-give.md | 6 + .../src/base-documents-visitor.ts | 28 ++- .../visitor-plugin-common/src/base-visitor.ts | 1 + .../src/selection-set-to-object.ts | 10 +- .../other/visitor-plugin-common/src/types.ts | 10 ++ .../operations/tests/ts-documents.spec.ts | 163 ++++++++++++++++++ 6 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 .changeset/twelve-windows-give.md 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' }; + `); + }); }); });