Skip to content

Commit f3cf4df

Browse files
authored
[operations][client-preset] CODEGEN-500 - Support semantic non-null for clients (#10323)
1 parent f6909d1 commit f3cf4df

File tree

9 files changed

+394
-8
lines changed

9 files changed

+394
-8
lines changed

Diff for: .changeset/pink-drinks-impress.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@graphql-codegen/typescript-operations': minor
3+
'@graphql-codegen/client-preset': minor
4+
---
5+
6+
Add support for `nullability.errorHandlingClient`. This allows clients to get stronger types with [semantic nullability](https://github.com/graphql/graphql-wg/blob/main/rfcs/SemanticNullability.md)-enabled schemas.

Diff for: packages/plugins/typescript/operations/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
"tslib": "~2.6.0"
2121
},
2222
"peerDependencies": {
23-
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
23+
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
24+
"graphql-sock": "^1.0.0"
25+
},
26+
"devDependencies": {
27+
"graphql-sock": "1.0.0"
2428
},
2529
"main": "dist/cjs/index.js",
2630
"module": "dist/esm/index.js",

Diff for: packages/plugins/typescript/operations/src/config.ts

+38
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,42 @@ export interface TypeScriptDocumentsPluginConfig extends RawDocumentsConfig {
292292
*/
293293

294294
allowUndefinedQueryVariables?: boolean;
295+
296+
/**
297+
* @description Options related to handling nullability
298+
* @exampleMarkdown
299+
* ## `errorHandlingClient`
300+
* When using error handling clients, a semantic non-nullable field can never be `null`.
301+
* If a field is read and its value is `null`, there must be a respective error. The error handling client will throw in this case, so the `null` value is never read.
302+
*
303+
* To enable this option, install `graphql-sock` peer dependency:
304+
*
305+
* ```sh npm2yarn
306+
* npm install -D graphql-sock
307+
* ```
308+
*
309+
* Now, you can enable support for error handling clients:
310+
*
311+
* ```ts filename="codegen.ts"
312+
* import type { CodegenConfig } from '@graphql-codegen/cli';
313+
*
314+
* const config: CodegenConfig = {
315+
* // ...
316+
* generates: {
317+
* 'path/to/file.ts': {
318+
* plugins: ['typescript', 'typescript-operations'],
319+
* config: {
320+
* nullability: {
321+
* errorHandlingClient: true
322+
* }
323+
* },
324+
* },
325+
* },
326+
* };
327+
* export default config;
328+
* ```
329+
*/
330+
nullability?: {
331+
errorHandlingClient: boolean;
332+
};
295333
}

Diff for: packages/plugins/typescript/operations/src/index.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { TypeScriptDocumentsVisitor } from './visitor.js';
66

77
export { TypeScriptDocumentsPluginConfig } from './config.js';
88

9-
export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = (
10-
schema: GraphQLSchema,
9+
export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = async (
10+
inputSchema: GraphQLSchema,
1111
rawDocuments: Types.DocumentFile[],
1212
config: TypeScriptDocumentsPluginConfig
1313
) => {
14+
const schema = config.nullability?.errorHandlingClient ? await semanticToStrict(inputSchema) : inputSchema;
15+
1416
const documents = config.flattenGeneratedTypes
1517
? optimizeOperations(schema, rawDocuments, {
1618
includeFragments: config.flattenGeneratedTypesIncludeFragments,
@@ -64,3 +66,14 @@ export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.Compl
6466
};
6567

6668
export { TypeScriptDocumentsVisitor };
69+
70+
const semanticToStrict = async (schema: GraphQLSchema): Promise<GraphQLSchema> => {
71+
try {
72+
const sock = await import('graphql-sock');
73+
return sock.semanticToStrict(schema);
74+
} catch {
75+
throw new Error(
76+
"To use the `nullability.errorHandlingClient` option, you must install the 'graphql-sock' package."
77+
);
78+
}
79+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { buildSchema, parse } from 'graphql';
2+
import * as prettier from 'prettier';
3+
import { plugin } from '../src/index.js';
4+
5+
const schema = buildSchema(/* GraphQL */ `
6+
directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION
7+
8+
type Query {
9+
me: User
10+
}
11+
12+
type User {
13+
field: String @semanticNonNull
14+
fieldLevel0: String @semanticNonNull(levels: [0])
15+
fieldLevel1: String @semanticNonNull(levels: [1])
16+
fieldBothLevels: String @semanticNonNull(levels: [0, 1])
17+
list: [String] @semanticNonNull
18+
listLevel0: [String] @semanticNonNull(levels: [0])
19+
listLevel1: [String] @semanticNonNull(levels: [1])
20+
listBothLevels: [String] @semanticNonNull(levels: [0, 1])
21+
nonNullableList: [String]! @semanticNonNull
22+
nonNullableListLevel0: [String]! @semanticNonNull(levels: [0])
23+
nonNullableListLevel1: [String]! @semanticNonNull(levels: [1])
24+
nonNullableListBothLevels: [String]! @semanticNonNull(levels: [0, 1])
25+
listWithNonNullableItem: [String!] @semanticNonNull
26+
listWithNonNullableItemLevel0: [String!] @semanticNonNull(levels: [0])
27+
listWithNonNullableItemLevel1: [String!] @semanticNonNull(levels: [1])
28+
listWithNonNullableItemBothLevels: [String!] @semanticNonNull(levels: [0, 1])
29+
nonNullableListWithNonNullableItem: [String!]! @semanticNonNull
30+
nonNullableListWithNonNullableItemLevel0: [String!]! @semanticNonNull(levels: [0])
31+
nonNullableListWithNonNullableItemLevel1: [String!]! @semanticNonNull(levels: [1])
32+
nonNullableListWithNonNullableItemBothLevels: [String!]! @semanticNonNull(levels: [0, 1])
33+
}
34+
`);
35+
36+
const document = parse(/* GraphQL */ `
37+
query {
38+
me {
39+
field
40+
fieldLevel0
41+
fieldLevel1
42+
fieldBothLevels
43+
list
44+
listLevel0
45+
listLevel1
46+
listBothLevels
47+
nonNullableList
48+
nonNullableListLevel0
49+
nonNullableListLevel1
50+
nonNullableListBothLevels
51+
listWithNonNullableItem
52+
listWithNonNullableItemLevel0
53+
listWithNonNullableItemLevel1
54+
listWithNonNullableItemBothLevels
55+
nonNullableListWithNonNullableItem
56+
nonNullableListWithNonNullableItemLevel0
57+
nonNullableListWithNonNullableItemLevel1
58+
nonNullableListWithNonNullableItemBothLevels
59+
}
60+
}
61+
`);
62+
63+
describe('TypeScript Operations Plugin - nullability', () => {
64+
it('converts semanticNonNull to nonNull when nullability.errorHandlingClient=true', async () => {
65+
const result = await plugin(schema, [{ document }], {
66+
nullability: {
67+
errorHandlingClient: true,
68+
},
69+
});
70+
71+
const formattedContent = prettier.format(result.content, { parser: 'typescript' });
72+
expect(formattedContent).toMatchInlineSnapshot(`
73+
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;
74+
75+
export type Unnamed_1_Query = {
76+
__typename?: "Query";
77+
me?: {
78+
__typename?: "User";
79+
field: string;
80+
fieldLevel0: string;
81+
fieldLevel1?: string | null;
82+
fieldBothLevels: string;
83+
list: Array<string | null>;
84+
listLevel0: Array<string | null>;
85+
listLevel1?: Array<string> | null;
86+
listBothLevels: Array<string>;
87+
nonNullableList: Array<string | null>;
88+
nonNullableListLevel0: Array<string | null>;
89+
nonNullableListLevel1: Array<string>;
90+
nonNullableListBothLevels: Array<string>;
91+
listWithNonNullableItem: Array<string>;
92+
listWithNonNullableItemLevel0: Array<string>;
93+
listWithNonNullableItemLevel1?: Array<string> | null;
94+
listWithNonNullableItemBothLevels: Array<string>;
95+
nonNullableListWithNonNullableItem: Array<string>;
96+
nonNullableListWithNonNullableItemLevel0: Array<string>;
97+
nonNullableListWithNonNullableItemLevel1: Array<string>;
98+
nonNullableListWithNonNullableItemBothLevels: Array<string>;
99+
} | null;
100+
};
101+
"
102+
`);
103+
});
104+
105+
it('does not convert nullability to nonNull when nullability.errorHandlingClient=false', async () => {
106+
const result = await plugin(schema, [{ document }], {
107+
nullability: {
108+
errorHandlingClient: false,
109+
},
110+
});
111+
112+
const formattedContent = prettier.format(result.content, { parser: 'typescript' });
113+
expect(formattedContent).toMatchInlineSnapshot(`
114+
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;
115+
116+
export type Unnamed_1_Query = {
117+
__typename?: "Query";
118+
me?: {
119+
__typename?: "User";
120+
field?: string | null;
121+
fieldLevel0?: string | null;
122+
fieldLevel1?: string | null;
123+
fieldBothLevels?: string | null;
124+
list?: Array<string | null> | null;
125+
listLevel0?: Array<string | null> | null;
126+
listLevel1?: Array<string | null> | null;
127+
listBothLevels?: Array<string | null> | null;
128+
nonNullableList: Array<string | null>;
129+
nonNullableListLevel0: Array<string | null>;
130+
nonNullableListLevel1: Array<string | null>;
131+
nonNullableListBothLevels: Array<string | null>;
132+
listWithNonNullableItem?: Array<string> | null;
133+
listWithNonNullableItemLevel0?: Array<string> | null;
134+
listWithNonNullableItemLevel1?: Array<string> | null;
135+
listWithNonNullableItemBothLevels?: Array<string> | null;
136+
nonNullableListWithNonNullableItem: Array<string>;
137+
nonNullableListWithNonNullableItemLevel0: Array<string>;
138+
nonNullableListWithNonNullableItemLevel1: Array<string>;
139+
nonNullableListWithNonNullableItemBothLevels: Array<string>;
140+
} | null;
141+
};
142+
"
143+
`);
144+
});
145+
});

Diff for: packages/presets/client/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
},
1515
"devDependencies": {
1616
"@types/babel__helper-plugin-utils": "7.10.3",
17-
"@types/babel__template": "7.4.4"
17+
"@types/babel__template": "7.4.4",
18+
"graphql-sock": "1.0.0"
1819
},
1920
"dependencies": {
2021
"@babel/helper-plugin-utils": "^7.20.2",
@@ -32,7 +33,8 @@
3233
"tslib": "~2.6.0"
3334
},
3435
"peerDependencies": {
35-
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
36+
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
37+
"graphql-sock": "^1.0.0"
3638
},
3739
"main": "dist/cjs/index.js",
3840
"module": "dist/esm/index.js",

Diff for: packages/presets/client/src/index.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as typedDocumentNodePlugin from '@graphql-codegen/typed-document-node';
55
import * as typescriptPlugin from '@graphql-codegen/typescript';
66
import * as typescriptOperationPlugin from '@graphql-codegen/typescript-operations';
77
import { ClientSideBaseVisitor, DocumentMode } from '@graphql-codegen/visitor-plugin-common';
8-
import { DocumentNode } from 'graphql';
8+
import { parse, printSchema, type DocumentNode, type GraphQLSchema } from 'graphql';
99
import * as fragmentMaskingPlugin from './fragment-masking-plugin.js';
1010
import { generateDocumentHash, normalizeAndPrintDocumentNode } from './persisted-documents.js';
1111
import { processSources } from './process-sources.js';
@@ -101,7 +101,7 @@ const isOutputFolderLike = (baseOutputDir: string) => baseOutputDir.endsWith('/'
101101

102102
export const preset: Types.OutputPreset<ClientPresetConfig> = {
103103
prepareDocuments: (outputFilePath, outputSpecificDocuments) => [...outputSpecificDocuments, `!${outputFilePath}`],
104-
buildGeneratesSection: options => {
104+
buildGeneratesSection: async options => {
105105
if (!isOutputFolderLike(options.baseOutputDir)) {
106106
throw new Error(
107107
'[client-preset] target output should be a directory, ex: "src/gql/". Make sure you add "/" at the end of the directory path'
@@ -114,6 +114,10 @@ export const preset: Types.OutputPreset<ClientPresetConfig> = {
114114
);
115115
}
116116
const isPersistedOperations = !!options.presetConfig?.persistedDocuments;
117+
if (options.config.nullability?.errorHandlingClient) {
118+
options.schemaAst = await semanticToStrict(options.schemaAst!);
119+
options.schema = parse(printSchema(options.schemaAst));
120+
}
117121

118122
const reexports: Array<string> = [];
119123

@@ -139,7 +143,7 @@ export const preset: Types.OutputPreset<ClientPresetConfig> = {
139143
customDirectives: options.config.customDirectives,
140144
};
141145

142-
const visitor = new ClientSideBaseVisitor(options.schemaAst!, [], options.config, options.config);
146+
const visitor = new ClientSideBaseVisitor(options.schemaAst, [], options.config, options.config);
143147
let fragmentMaskingConfig: FragmentMaskingConfig | null = null;
144148

145149
if (typeof options?.presetConfig?.fragmentMasking === 'object') {
@@ -370,4 +374,15 @@ function createDeferred<T = void>(): Deferred<T> {
370374
return d;
371375
}
372376

377+
const semanticToStrict = async (schema: GraphQLSchema): Promise<GraphQLSchema> => {
378+
try {
379+
const sock = await import('graphql-sock');
380+
return sock.semanticToStrict(schema);
381+
} catch {
382+
throw new Error(
383+
"To use the `nullability.errorHandlingClient` option, you must install the 'graphql-sock' package."
384+
);
385+
}
386+
};
387+
373388
export { addTypenameSelectionDocumentTransform } from './add-typename-selection-document-transform.js';

0 commit comments

Comments
 (0)