Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 semanticNonNull.errorHandlingClient
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 `@semanticNonNull` directive
* @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: {
* semanticNonNull: {
* errorHandlingClient: true
* }
* },
* },
* },
* };
* export default config;
* ```
*/
semanticNonNull?: {
Copy link
Copy Markdown
Contributor

@benjie benjie Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, and this is just a suggestion, rename this to "semantic nullability" as a more generic term. If we were to accept a solution that used a document directive to enable the syntax, this is the likely name that we would use for that directive, and it doesn't tie the solution to one specific implementation. (GraphQL-SOCK can be updated to work with other solutions without any effort on your part.)

Suggested change
semanticNonNull?: {
semanticNullability?: {

errorHandlingClient: boolean;
};
Copy link
Copy Markdown
Collaborator Author

@eddeee888 eddeee888 Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking this object can be extended to handle other related semanticNonNull functionalities e.g. @catch?
(I'm not entirely sure how it'd work yet, but I imagine there could be options we turn on/off here 😅 )

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see @catch as mainly orthogonal to @semanticNonNull. Sure they make a lot of sense together but you can have an error handling client that uses @catch(to: RESULT) or @catch(to: THROW) without semantic nullability on the server.

That client will not get the semantic nullability information but could still use @catch to modify the shape of the generated code.

I would keep it simple/yagni with just a plain Boolean option:

semanticNonNull?: boolean

If really you want to bundle nullability options together, maybe something like so:

nullability?: {
  semanticNonNull: boolean;
  catch: boolean;
}

}
19 changes: 16 additions & 3 deletions packages/plugins/typescript/operations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { TypeScriptDocumentsVisitor } from './visitor.js';

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

export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = (
export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.ComplexPluginOutput> = async (
schema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const transformedSchema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(schema) : schema;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would rename the schema variable to inputSchema so that schema, the variable you'd use by default, is the correct variable to use:

Suggested change
schema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const transformedSchema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(schema) : schema;
inputSchema: GraphQLSchema,
rawDocuments: Types.DocumentFile[],
config: TypeScriptDocumentsPluginConfig
) => {
const schema = config.semanticNonNull?.errorHandlingClient ? await semanticToStrict(inputSchema) : inputSchema;

(This would also mean no other changes were needed in the function.)


const documents = config.flattenGeneratedTypes
? optimizeOperations(schema, rawDocuments, {
? optimizeOperations(transformedSchema, rawDocuments, {
includeFragments: config.flattenGeneratedTypesIncludeFragments,
})
: rawDocuments;
Expand All @@ -30,7 +32,7 @@ export const plugin: PluginFunction<TypeScriptDocumentsPluginConfig, Types.Compl
...(config.externalFragments || []),
];

const visitor = new TypeScriptDocumentsVisitor(schema, config, allFragments);
const visitor = new TypeScriptDocumentsVisitor(transformedSchema, config, allFragments);

const visitorResult = oldVisit(allAst, {
leave: visitor,
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 `customDirective.semanticNonNull` option, you must install the 'graphql-sock' package."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"To use the `customDirective.semanticNonNull` option, you must install the 'graphql-sock' package."
"To use the `semanticNonNull.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 - semanticNonNull', () => {
it('converts semanticNonNull to nonNull when semanticNonNull.errorHandlingClient=true', async () => {
const result = await plugin(schema, [{ document }], {
semanticNonNull: {
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 semanticNonNull to nonNull when semanticNonNull.errorHandlingClient=false', async () => {
const result = await plugin(schema, [{ document }], {
semanticNonNull: {
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 type { DocumentNode, 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.semanticNonNull?.errorHandlingClient) {
options.schemaAst = await semanticToStrict(options.schemaAst!);
options.schema = options.schemaAst as any; // FIXME: `schemaAst` _seems_ to be able to used as `schema`, but not sure if it has any side effects?
Copy link
Copy Markdown
Collaborator Author

@eddeee888 eddeee888 Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit unsure about this:

  • schema is a DocumentNode
  • shemaAst is a GraphQLSchema
  • We can forcefully override schema with schemaAst ... and it works 🤔

Maybe when loading schema in each generates block, we do a check? That's my best guess though, and if anyone has more context, please let me know!

EDIT: Looks like it's somewhere here 👀

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe what you want here is this?

Suggested change
options.schema = options.schemaAst as any; // FIXME: `schemaAst` _seems_ to be able to used as `schema`, but not sure if it has any side effects?
options.schema = parse(printSchema(options.schemaAst));

Just guessing from the types you mention, I'm not familiar with this codebase.

}

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 `customDirective.semanticNonNull` option, you must install the 'graphql-sock' package."
);
}
};

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