Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pink-drinks-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@graphql-codegen/typescript-operations': minor
'@graphql-codegen/client-preset': minor
---

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.
6 changes: 5 additions & 1 deletion packages/plugins/typescript/operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
"tslib": "~2.6.0"
},
"peerDependencies": {
"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"
"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",
"graphql-sock": "^1.0.0"
},
"devDependencies": {
"graphql-sock": "1.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand Down
38 changes: 38 additions & 0 deletions packages/plugins/typescript/operations/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,4 +292,42 @@ export interface TypeScriptDocumentsPluginConfig extends RawDocumentsConfig {
*/

allowUndefinedQueryVariables?: boolean;

/**
* @description Options related to handling nullability
* @exampleMarkdown
* ## `errorHandlingClient`
* When using error handling clients, a semantic non-nullable field can never be `null`.
* 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.
*
* To enable this option, install `graphql-sock` peer dependency:
*
* ```sh npm2yarn
* npm install -D graphql-sock
* ```
*
* Now, you can enable support for error handling clients:
*
* ```ts filename="codegen.ts"
* import type { CodegenConfig } from '@graphql-codegen/cli';
*
* const config: CodegenConfig = {
* // ...
* generates: {
* 'path/to/file.ts': {
* plugins: ['typescript', 'typescript-operations'],
* config: {
* nullability: {
* errorHandlingClient: true
* }
* },
* },
* },
* };
* export default config;
* ```
*/
nullability?: {
errorHandlingClient: boolean;
};
}
17 changes: 15 additions & 2 deletions packages/plugins/typescript/operations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { TypeScriptDocumentsVisitor } from './visitor.js';

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

export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = (
schema: GraphQLSchema,
export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = async (
inputSchema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const schema = config.nullability?.errorHandlingClient ? await semanticToStrict(inputSchema) : inputSchema;

const documents = config.flattenGeneratedTypes
? optimizeOperations(schema, rawDocuments, {
includeFragments: config.flattenGeneratedTypesIncludeFragments,
Expand Down Expand Up @@ -64,3 +66,14 @@ export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.Compl
};

export { TypeScriptDocumentsVisitor };

const semanticToStrict = async (schema: GraphQLSchema): Promise<GraphQLSchema> => {
try {
const sock = await import('graphql-sock');
return sock.semanticToStrict(schema);
} catch {
throw new Error(
"To use the `nullability.errorHandlingClient` option, you must install the 'graphql-sock' package."
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { buildSchema, parse } from 'graphql';
import * as prettier from 'prettier';
import { plugin } from '../src/index.js';

const schema = buildSchema(/* GraphQL */ `
directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION

type Query {
me: User
}

type User {
field: String @semanticNonNull
fieldLevel0: String @semanticNonNull(levels: [0])
fieldLevel1: String @semanticNonNull(levels: [1])
fieldBothLevels: String @semanticNonNull(levels: [0, 1])
list: [String] @semanticNonNull
listLevel0: [String] @semanticNonNull(levels: [0])
listLevel1: [String] @semanticNonNull(levels: [1])
listBothLevels: [String] @semanticNonNull(levels: [0, 1])
nonNullableList: [String]! @semanticNonNull
nonNullableListLevel0: [String]! @semanticNonNull(levels: [0])
nonNullableListLevel1: [String]! @semanticNonNull(levels: [1])
nonNullableListBothLevels: [String]! @semanticNonNull(levels: [0, 1])
listWithNonNullableItem: [String!] @semanticNonNull
listWithNonNullableItemLevel0: [String!] @semanticNonNull(levels: [0])
listWithNonNullableItemLevel1: [String!] @semanticNonNull(levels: [1])
listWithNonNullableItemBothLevels: [String!] @semanticNonNull(levels: [0, 1])
nonNullableListWithNonNullableItem: [String!]! @semanticNonNull
nonNullableListWithNonNullableItemLevel0: [String!]! @semanticNonNull(levels: [0])
nonNullableListWithNonNullableItemLevel1: [String!]! @semanticNonNull(levels: [1])
nonNullableListWithNonNullableItemBothLevels: [String!]! @semanticNonNull(levels: [0, 1])
}
`);

const document = parse(/* GraphQL */ `
query {
me {
field
fieldLevel0
fieldLevel1
fieldBothLevels
list
listLevel0
listLevel1
listBothLevels
nonNullableList
nonNullableListLevel0
nonNullableListLevel1
nonNullableListBothLevels
listWithNonNullableItem
listWithNonNullableItemLevel0
listWithNonNullableItemLevel1
listWithNonNullableItemBothLevels
nonNullableListWithNonNullableItem
nonNullableListWithNonNullableItemLevel0
nonNullableListWithNonNullableItemLevel1
nonNullableListWithNonNullableItemBothLevels
}
}
`);

describe('TypeScript Operations Plugin - nullability', () => {
it('converts semanticNonNull to nonNull when nullability.errorHandlingClient=true', async () => {
const result = await plugin(schema, [{ document }], {
nullability: {
errorHandlingClient: true,
},
});

const formattedContent = prettier.format(result.content, { parser: 'typescript' });
expect(formattedContent).toMatchInlineSnapshot(`
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;

export type Unnamed_1_Query = {
__typename?: "Query";
me?: {
__typename?: "User";
field: string;
fieldLevel0: string;
fieldLevel1?: string | null;
fieldBothLevels: string;
list: Array<string | null>;
listLevel0: Array<string | null>;
listLevel1?: Array<string> | null;
listBothLevels: Array<string>;
nonNullableList: Array<string | null>;
nonNullableListLevel0: Array<string | null>;
nonNullableListLevel1: Array<string>;
nonNullableListBothLevels: Array<string>;
listWithNonNullableItem: Array<string>;
listWithNonNullableItemLevel0: Array<string>;
listWithNonNullableItemLevel1?: Array<string> | null;
listWithNonNullableItemBothLevels: Array<string>;
nonNullableListWithNonNullableItem: Array<string>;
nonNullableListWithNonNullableItemLevel0: Array<string>;
nonNullableListWithNonNullableItemLevel1: Array<string>;
nonNullableListWithNonNullableItemBothLevels: Array<string>;
} | null;
};
"
`);
});

it('does not convert nullability to nonNull when nullability.errorHandlingClient=false', async () => {
const result = await plugin(schema, [{ document }], {
nullability: {
errorHandlingClient: false,
},
});

const formattedContent = prettier.format(result.content, { parser: 'typescript' });
expect(formattedContent).toMatchInlineSnapshot(`
"export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never }>;

export type Unnamed_1_Query = {
__typename?: "Query";
me?: {
__typename?: "User";
field?: string | null;
fieldLevel0?: string | null;
fieldLevel1?: string | null;
fieldBothLevels?: string | null;
list?: Array<string | null> | null;
listLevel0?: Array<string | null> | null;
listLevel1?: Array<string | null> | null;
listBothLevels?: Array<string | null> | null;
nonNullableList: Array<string | null>;
nonNullableListLevel0: Array<string | null>;
nonNullableListLevel1: Array<string | null>;
nonNullableListBothLevels: Array<string | null>;
listWithNonNullableItem?: Array<string> | null;
listWithNonNullableItemLevel0?: Array<string> | null;
listWithNonNullableItemLevel1?: Array<string> | null;
listWithNonNullableItemBothLevels?: Array<string> | null;
nonNullableListWithNonNullableItem: Array<string>;
nonNullableListWithNonNullableItemLevel0: Array<string>;
nonNullableListWithNonNullableItemLevel1: Array<string>;
nonNullableListWithNonNullableItemBothLevels: Array<string>;
} | null;
};
"
`);
});
});
6 changes: 4 additions & 2 deletions packages/presets/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"devDependencies": {
"@types/babel__helper-plugin-utils": "7.10.3",
"@types/babel__template": "7.4.4"
"@types/babel__template": "7.4.4",
"graphql-sock": "1.0.0"
},
"dependencies": {
"@babel/helper-plugin-utils": "^7.20.2",
Expand All @@ -32,7 +33,8 @@
"tslib": "~2.6.0"
},
"peerDependencies": {
"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"
"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",
"graphql-sock": "^1.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand Down
21 changes: 18 additions & 3 deletions packages/presets/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as typedDocumentNodePlugin from '@graphql-codegen/typed-document-node';
import * as typescriptPlugin from '@graphql-codegen/typescript';
import * as typescriptOperationPlugin from '@graphql-codegen/typescript-operations';
import { ClientSideBaseVisitor, DocumentMode } from '@graphql-codegen/visitor-plugin-common';
import { DocumentNode } from 'graphql';
import { parse, printSchema, type DocumentNode, type GraphQLSchema } from 'graphql';
import * as fragmentMaskingPlugin from './fragment-masking-plugin.js';
import { generateDocumentHash, normalizeAndPrintDocumentNode } from './persisted-documents.js';
import { processSources } from './process-sources.js';
Expand Down Expand Up @@ -101,7 +101,7 @@ const isOutputFolderLike = (baseOutputDir: string) => baseOutputDir.endsWith('/'

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

const reexports: Array<string> = [];

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

const visitor = new ClientSideBaseVisitor(options.schemaAst!, [], options.config, options.config);
const visitor = new ClientSideBaseVisitor(options.schemaAst, [], options.config, options.config);
let fragmentMaskingConfig: FragmentMaskingConfig | null = null;

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

const semanticToStrict = async (schema: GraphQLSchema): Promise<GraphQLSchema> => {
try {
const sock = await import('graphql-sock');
return sock.semanticToStrict(schema);
} catch {
throw new Error(
"To use the `nullability.errorHandlingClient` option, you must install the 'graphql-sock' package."
);
}
};

export { addTypenameSelectionDocumentTransform } from './add-typename-selection-document-transform.js';
Loading
Loading