Skip to content

Commit d0a3bb8

Browse files
Better support for custom directives
* Add support for directives without arguments like `@required()` * Add support for custom directive mapping Closes #422, #781
1 parent 3eb44f1 commit d0a3bb8

File tree

9 files changed

+214
-241
lines changed

9 files changed

+214
-241
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ dist
44
package-lock.json
55
.DS_Store
66
tsconfig.tsbuildinfo
7-
yarn-error.log
7+
yarn-error.log
8+
.idea

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ generates:
248248
# directive:
249249
# arg1: schemaApi
250250
# arg2: ["schemaApi2", "Hello $1"]
251+
# OR
252+
# directive: schemaApi
251253
#
252254
# See more examples in `./tests/directive.spec.ts`
253255
# https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts
@@ -290,6 +292,8 @@ generates:
290292
# directive:
291293
# arg1: schemaApi
292294
# arg2: ["schemaApi2", "Hello $1"]
295+
# OR
296+
# directive: schemaApi
293297
#
294298
# See more examples in `./tests/directive.spec.ts`
295299
# https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts
@@ -318,9 +322,60 @@ export function ExampleInputSchema(): z.ZodSchema<ExampleInput> {
318322

319323
Please see [example](https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/tree/main/example) directory.
320324

325+
#### Custom mapping functions
326+
327+
If you are using TS config you can define your own custom mapping for directives. The function will receive the arguments of the directive as an object and should return a string that will be appended to the schema.
328+
329+
```ts
330+
const config: CodegenConfig = {
331+
schema: 'http://localhost:4000/graphql',
332+
documents: ['src/**/*.tsx'],
333+
generates: {
334+
plugins: ['typescript', 'typescript-validation-schema'],
335+
config: {
336+
schema: 'zod',
337+
directives: {
338+
between: (args) => `.refine(v => v >= ${args.min} && v <= ${args.max})`,
339+
},
340+
}
341+
}
342+
}
343+
```
344+
345+
Additionally, you can define custom mapping functions for each argument, or even each argument value separately.
346+
347+
```ts
348+
const config: CodegenConfig = {
349+
schema: 'http://localhost:4000/graphql',
350+
documents: ['src/**/*.tsx'],
351+
generates: {
352+
plugins: ['typescript', 'typescript-validation-schema'],
353+
config: {
354+
schema: 'zod',
355+
directives: {
356+
// @unique()
357+
unique: () => `.refine(items => new Set(items).size === items.length)`,
358+
359+
// @array(unique: true)
360+
array: {
361+
unique: (value) => value ? `.refine(items => new Set(items).size === items.length)` : ``,
362+
},
363+
364+
// @constraint(array: "UNIQUE")
365+
constraint: {
366+
array: {
367+
UNIQUE: () => `.refine(items => new Set(items).size === items.length)`,
368+
}
369+
},
370+
},
371+
}
372+
}
373+
}
374+
```
375+
321376
## Notes
322377

323-
Their is currently a compatibility issue with the client-preset. A workaround for this is to split the generation into two (one for client-preset and one for typescript-validation-schema).
378+
There is currently a compatibility issue with the client-preset. A workaround for this is to split the generation into two (one for client-preset and one for typescript-validation-schema).
324379

325380
```yml
326381
generates:

src/config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ export type ValidationSchema = 'yup' | 'zod' | 'myzod' | 'valibot';
55
export type ValidationSchemaExportType = 'function' | 'const';
66

77
export interface DirectiveConfig {
8-
[directive: string]: {
9-
[argument: string]: string | string[] | DirectiveObjectArguments
10-
}
8+
[directive: string]: SingleDirectiveConfig | string | ((args: Record<string, any>) => string)
9+
}
10+
11+
export interface SingleDirectiveConfig {
12+
[argument: string]: string | string[] | DirectiveObjectArguments | ((argValue: any) => string)
1113
}
1214

1315
export interface DirectiveObjectArguments {
14-
[matched: string]: string | string[]
16+
[matched: string]: string | string[] | (() => string)
1517
}
1618

1719
interface ScalarSchemas {

src/directive.ts

Lines changed: 53 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,9 @@
11
import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode } from 'graphql';
22
import { Kind, valueFromASTUntyped } from 'graphql';
33

4-
import type { DirectiveConfig, DirectiveObjectArguments } from './config.js';
4+
import type { DirectiveConfig, DirectiveObjectArguments, SingleDirectiveConfig } from './config.js';
55
import { isConvertableRegexp } from './regexp.js';
66

7-
export interface FormattedDirectiveConfig {
8-
[directive: string]: FormattedDirectiveArguments
9-
}
10-
11-
export interface FormattedDirectiveArguments {
12-
[argument: string]: string[] | FormattedDirectiveObjectArguments | undefined
13-
}
14-
15-
export interface FormattedDirectiveObjectArguments {
16-
[matched: string]: string[] | undefined
17-
}
18-
19-
function isFormattedDirectiveObjectArguments(arg: FormattedDirectiveArguments[keyof FormattedDirectiveArguments]): arg is FormattedDirectiveObjectArguments {
20-
return arg !== undefined && !Array.isArray(arg)
21-
}
22-
23-
// ```yml
24-
// directives:
25-
// required:
26-
// msg: required
27-
// constraint:
28-
// minLength: min
29-
// format:
30-
// uri: url
31-
// email: email
32-
// ```
33-
//
34-
// This function convterts to like below
35-
// {
36-
// 'required': {
37-
// 'msg': ['required', '$1'],
38-
// },
39-
// 'constraint': {
40-
// 'minLength': ['min', '$1'],
41-
// 'format': {
42-
// 'uri': ['url', '$2'],
43-
// 'email': ['email', '$2'],
44-
// }
45-
// }
46-
// }
47-
export function formatDirectiveConfig(config: DirectiveConfig): FormattedDirectiveConfig {
48-
return Object.fromEntries(
49-
Object.entries(config).map(([directive, arg]) => {
50-
const formatted = Object.fromEntries(
51-
Object.entries(arg).map(([arg, val]) => {
52-
if (Array.isArray(val))
53-
return [arg, val];
54-
55-
if (typeof val === 'string')
56-
return [arg, [val, '$1']];
57-
58-
return [arg, formatDirectiveObjectArguments(val)];
59-
}),
60-
);
61-
return [directive, formatted];
62-
}),
63-
);
64-
}
65-
66-
// ```yml
67-
// format:
68-
// # For example, `@constraint(format: "uri")`. this case $1 will be "uri".
69-
// # Therefore the generator generates yup schema `.url()` followed by `uri: 'url'`
70-
// # If $1 does not match anywhere, the generator will ignore.
71-
// uri: url
72-
// email: ["email", "$2"]
73-
// ```
74-
//
75-
// This function convterts to like below
76-
// {
77-
// 'uri': ['url', '$2'],
78-
// 'email': ['email'],
79-
// }
80-
export function formatDirectiveObjectArguments(args: DirectiveObjectArguments): FormattedDirectiveObjectArguments {
81-
const formatted = Object.entries(args).map(([arg, val]) => {
82-
if (Array.isArray(val))
83-
return [arg, val];
84-
85-
return [arg, [val, '$2']];
86-
});
87-
return Object.fromEntries(formatted);
88-
}
89-
907
// This function generates `.required("message").min(100).email()`
918
//
929
// config
@@ -109,13 +26,19 @@ export function formatDirectiveObjectArguments(args: DirectiveObjectArguments):
10926
// email: String! @required(msg: "message") @constraint(minLength: 100, format: "email")
11027
// }
11128
// ```
112-
export function buildApi(config: FormattedDirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string {
29+
export function buildApi(config: DirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string {
11330
return directives
11431
.filter(directive => config[directive.name.value] !== undefined)
11532
.map((directive) => {
11633
const directiveName = directive.name.value;
117-
const argsConfig = config[directiveName];
118-
return buildApiFromDirectiveArguments(argsConfig, directive.arguments ?? []);
34+
const directiveConfig = config[directiveName];
35+
if (typeof directiveConfig === 'string') {
36+
return `.${directiveConfig}()`;
37+
}
38+
if (typeof directiveConfig === 'function') {
39+
return directiveConfig(directiveArgs(directive));
40+
}
41+
return buildApiFromDirectiveArguments(directiveConfig, directive.arguments ?? []);
11942
})
12043
.join('')
12144
}
@@ -142,20 +65,30 @@ export function buildApi(config: FormattedDirectiveConfig, directives: ReadonlyA
14265
// ```
14366
//
14467
// FIXME: v.required() is not supported yet. v.required() is classified as `Methods` and must wrap the schema. ex) `v.required(v.object({...}))`
145-
export function buildApiForValibot(config: FormattedDirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string[] {
68+
export function buildApiForValibot(config: DirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string[] {
14669
return directives
14770
.filter(directive => config[directive.name.value] !== undefined)
14871
.map((directive) => {
14972
const directiveName = directive.name.value;
150-
const argsConfig = config[directiveName];
151-
const apis = _buildApiFromDirectiveArguments(argsConfig, directive.arguments ?? []);
73+
const directiveConfig = config[directiveName];
74+
if (typeof directiveConfig === 'string') {
75+
return `.${directiveConfig}()`;
76+
}
77+
if (typeof directiveConfig === 'function') {
78+
return directiveConfig(directiveArgs(directive));
79+
}
80+
const apis = _buildApiFromDirectiveArguments(directiveConfig, directive.arguments ?? []);
15281
return apis.map(api => `v${api}`);
15382
}).flat()
15483
}
15584

156-
function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstValueNode): string {
157-
if (!validationSchema)
85+
function buildApiSchema(validationSchema: string | string[] | undefined, argValue: ConstValueNode): string {
86+
if (!validationSchema) {
15887
return '';
88+
}
89+
if (!Array.isArray(validationSchema)) {
90+
return `.${validationSchema}()`
91+
}
15992

16093
const schemaApi = validationSchema[0];
16194
const schemaApiArgs = validationSchema.slice(1).map((templateArg) => {
@@ -165,27 +98,39 @@ function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstV
16598
return `.${schemaApi}(${schemaApiArgs.join(', ')})`;
16699
}
167100

168-
function buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray<ConstArgumentNode>): string {
101+
function buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray<ConstArgumentNode>): string {
169102
return _buildApiFromDirectiveArguments(config, args).join('');
170103
}
171104

172-
function _buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray<ConstArgumentNode>): string[] {
105+
function _buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray<ConstArgumentNode>): string[] {
173106
return args
174107
.map((arg) => {
175108
const argName = arg.name.value;
176109
const validationSchema = config[argName];
177-
if (isFormattedDirectiveObjectArguments(validationSchema))
178-
return buildApiFromDirectiveObjectArguments(validationSchema, arg.value);
179-
180-
return buildApiSchema(validationSchema, arg.value);
110+
if (!validationSchema) {
111+
return ''
112+
}
113+
if (typeof validationSchema === 'function') {
114+
return validationSchema(valueFromASTUntyped(arg.value));
115+
}
116+
if (typeof validationSchema === 'string') {
117+
return buildApiSchema([validationSchema, '$1'], arg.value);
118+
}
119+
if (Array.isArray(validationSchema)) {
120+
return buildApiSchema(validationSchema, arg.value);
121+
}
122+
return buildApiFromDirectiveObjectArguments(validationSchema, arg.value);
181123
})
182124
}
183125

184-
function buildApiFromDirectiveObjectArguments(config: FormattedDirectiveObjectArguments, argValue: ConstValueNode): string {
185-
if (argValue.kind !== Kind.STRING && argValue.kind !== Kind.ENUM)
126+
function buildApiFromDirectiveObjectArguments(config: DirectiveObjectArguments, argValue: ConstValueNode): string {
127+
if (argValue.kind !== Kind.STRING && argValue.kind !== Kind.ENUM) {
186128
return '';
187-
129+
}
188130
const validationSchema = config[argValue.value];
131+
if (typeof validationSchema === 'function') {
132+
return validationSchema();
133+
}
189134
return buildApiSchema(validationSchema, argValue);
190135
}
191136

@@ -240,6 +185,13 @@ function apiArgsFromConstValueNode(value: ConstValueNode): any[] {
240185
return [val];
241186
}
242187

188+
function directiveArgs(directive: ConstDirectiveNode): Record<string, any> {
189+
if (!directive.arguments) {
190+
return {}
191+
}
192+
return Object.fromEntries(directive.arguments.map(arg => [arg.name.value, valueFromASTUntyped(arg.value)]))
193+
}
194+
243195
function tryEval(maybeValidJavaScript: string): any | undefined {
244196
try {
245197
// eslint-disable-next-line no-eval

src/myzod/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717

1818
import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers';
1919
import type { ValidationSchemaPluginConfig } from '../config.js';
20-
import { buildApi, formatDirectiveConfig } from '../directive.js';
20+
import { buildApi } from '../directive.js';
2121
import { BaseSchemaVisitor } from '../schema_visitor.js';
2222
import type { Visitor } from '../visitor.js';
2323
import {
@@ -315,8 +315,7 @@ function generateFieldTypeMyZodSchema(config: ValidationSchemaPluginConfig, visi
315315

316316
function applyDirectives(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string {
317317
if (config.directives && field.directives) {
318-
const formatted = formatDirectiveConfig(config.directives);
319-
return gen + buildApi(formatted, field.directives);
318+
return gen + buildApi(config.directives, field.directives);
320319
}
321320
return gen;
322321
}

src/valibot/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
import type { ValidationSchemaPluginConfig } from '../config.js';
1616
import { BaseSchemaVisitor } from '../schema_visitor.js';
1717
import type { Visitor } from '../visitor.js';
18-
import { buildApiForValibot, formatDirectiveConfig } from '../directive.js';
18+
import { buildApiForValibot } from '../directive.js';
1919
import {
2020
InterfaceTypeDefinitionBuilder,
2121
ObjectTypeDefinitionBuilder,
@@ -222,7 +222,7 @@ function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, vi
222222
const actions = actionsFromDirectives(config, field);
223223

224224
if (isNonNullType(parentType))
225-
return pipeSchemaAndActions(gen, actions); ;
225+
return pipeSchemaAndActions(gen, actions);
226226

227227
return `v.nullish(${pipeSchemaAndActions(gen, actions)})`;
228228
}
@@ -232,8 +232,7 @@ function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, vi
232232

233233
function actionsFromDirectives(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode): string[] {
234234
if (config.directives && field.directives) {
235-
const formatted = formatDirectiveConfig(config.directives);
236-
return buildApiForValibot(formatted, field.directives);
235+
return buildApiForValibot(config.directives, field.directives);
237236
}
238237

239238
return [];

src/yup/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717

1818
import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers';
1919
import type { ValidationSchemaPluginConfig } from '../config.js';
20-
import { buildApi, formatDirectiveConfig } from '../directive.js';
20+
import { buildApi } from '../directive.js';
2121
import { BaseSchemaVisitor } from '../schema_visitor.js';
2222
import type { Visitor } from '../visitor.js';
2323
import {
@@ -311,8 +311,7 @@ function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitio
311311
function generateFieldYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string {
312312
let gen = generateFieldTypeYupSchema(config, visitor, field.type);
313313
if (config.directives && field.directives) {
314-
const formatted = formatDirectiveConfig(config.directives);
315-
gen += buildApi(formatted, field.directives);
314+
gen += buildApi(config.directives, field.directives);
316315
}
317316
return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount);
318317
}

0 commit comments

Comments
 (0)