diff --git a/package.json b/package.json index c638deb0c41..8ffce95a994 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "postinstall": "patch-package && husky install", "clean": "rimraf node_modules packages/{*,plugins/*/*,presets/*,utils/*}/node_modules", "prebuild": "rimraf packages/{*,plugins/*/*,presets/*,utils/*}/dist", - "build": "tsc --project tsconfig.json && bob build", + "build": "yarn workspace @graphql-codegen/typed-document-sdk run build && tsc --project tsconfig.json && bob build", "watch-build": "npx tsc-watch --project tsconfig.json --onSuccess \"bob build\"", "test": "jest --forceExit --no-watchman", "lint": "eslint --ext .ts .", diff --git a/packages/plugins/typescript/typed-document-sdk/.gitignore b/packages/plugins/typescript/typed-document-sdk/.gitignore new file mode 100644 index 00000000000..62c40f91234 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/.gitignore @@ -0,0 +1 @@ +src/sdk-static.ts diff --git a/packages/plugins/typescript/typed-document-sdk/CHANGELOG.md b/packages/plugins/typescript/typed-document-sdk/CHANGELOG.md new file mode 100644 index 00000000000..b40de4997d1 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/CHANGELOG.md @@ -0,0 +1 @@ +# @graphql-codegen/typed-document-sdk diff --git a/packages/plugins/typescript/typed-document-sdk/jest.config.js b/packages/plugins/typescript/typed-document-sdk/jest.config.js new file mode 100644 index 00000000000..e35d633e27f --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../../jest.project')({ dirname: __dirname }); \ No newline at end of file diff --git a/packages/plugins/typescript/typed-document-sdk/package.json b/packages/plugins/typescript/typed-document-sdk/package.json new file mode 100644 index 00000000000..0ced785a18a --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/package.json @@ -0,0 +1,48 @@ +{ + "name": "@graphql-codegen/typed-document-sdk", + "version": "0.0.0", + "description": "GraphQL Code Generator plugin for generating a TypedDocumentNode builder SDK.", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-code-generator.git", + "directory": "packages/plugins/typescript/typed-document-sdk" + }, + "license": "MIT", + "scripts": { + "lint": "eslint **/*.ts", + "test": "jest --no-watchman --config ../../../../jest.config.js", + "prepack": "bob prepack", + "build": "node scripts/write-sdk.js" + }, + "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" + }, + "dependencies": { + "@graphql-codegen/plugin-helpers": "^2.3.2", + "common-tags": "^1.8.0" + }, + "main": "dist/index.js", + "module": "dist/index.mjs", + "exports": { + "./package.json": "./package.json", + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./*": { + "require": "./dist/*.js", + "import": "./dist/*.mjs" + } + }, + "typings": "dist/index.d.ts", + "typescript": { + "definition": "dist/index.d.ts" + }, + "buildOptions": { + "input": "./src/index.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + } +} diff --git a/packages/plugins/typescript/typed-document-sdk/scripts/write-sdk.js b/packages/plugins/typescript/typed-document-sdk/scripts/write-sdk.js new file mode 100644 index 00000000000..5dc94294734 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/scripts/write-sdk.js @@ -0,0 +1,25 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const sdkBasePath = path.resolve(__dirname, '..', 'src', 'sdk-base.ts'); +const sdkOutPath = path.resolve(__dirname, '..', 'src', 'sdk-static.ts'); + +const sdkBaseContents = fs.readFileSync(sdkBasePath, 'utf-8'); + +const escape = str => str.replace(/`/g, `\``); + +const [imports, body] = sdkBaseContents.split('// IMPORTS END'); + +if (body === undefined) { + throw new Error(`Missing '// IMPORTS END' that splits the body from the head within '${sdkBasePath}'.`); +} + +fs.writeFileSync( + sdkOutPath, + ` +export const importsString = ${JSON.stringify(imports)}; +export const contentsString = ${JSON.stringify(body)}; +` +); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.spec.ts new file mode 100644 index 00000000000..8ab8c440bc6 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.spec.ts @@ -0,0 +1,65 @@ +import { GraphQLBoolean, GraphQLInterfaceType, GraphQLObjectType, GraphQLSchema } from 'graphql'; +import { buildInterfaceArgumentString } from './buildInterfaceArgumentString'; + +describe('buildInterfaceArgumentString', () => { + it('single interface member', () => { + const interfaceType = new GraphQLInterfaceType({ + name: 'Foo', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + a: { + type: interfaceType, + }, + }, + }), + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + ], + }); + + expect(buildInterfaceArgumentString(schema, interfaceType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKArgumentsHee; + }>;" + `); + }); + it('multiple interface member', () => { + const interfaceType = new GraphQLInterfaceType({ + name: 'Foo', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + a: { + type: interfaceType, + }, + }, + }), + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + new GraphQLObjectType({ name: 'Hoo', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + ], + }); + + expect(buildInterfaceArgumentString(schema, interfaceType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKArgumentsHee; + \\"...Hoo\\": GeneratedSDKArgumentsHoo; + }>;" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.ts new file mode 100644 index 00000000000..d036b12aebd --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceArgumentString.ts @@ -0,0 +1,15 @@ +import { stripIndent } from 'common-tags'; +import type { GraphQLInterfaceType, GraphQLSchema } from 'graphql'; +import { buildObjectArgumentsName } from './buildObjectTypeArgumentString'; + +export const buildInterfaceArgumentString = (schema: GraphQLSchema, ttype: GraphQLInterfaceType) => { + const implementedTypes = schema + .getImplementations(ttype) + .objects.map(ttype => `"...${ttype.name}": ${buildObjectArgumentsName(ttype.name)};`); + + return stripIndent` + type ${buildObjectArgumentsName(ttype.name)} = SDKSelectionSet<{ + ${implementedTypes.join(`\n `)} + }>; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.spec.ts new file mode 100644 index 00000000000..c35da7ff291 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.spec.ts @@ -0,0 +1,65 @@ +import { GraphQLBoolean, GraphQLInterfaceType, GraphQLObjectType, GraphQLSchema } from 'graphql'; +import { buildInterfaceSelectionString } from './buildInterfaceSelectionString'; + +describe('buildInterfaceSelectionString', () => { + it('correct selection set for single interface member', () => { + const interfaceType = new GraphQLInterfaceType({ + name: 'Foo', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + a: { + type: interfaceType, + }, + }, + }), + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + ], + }); + + expect(buildInterfaceSelectionString(schema, interfaceType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKSelectionSetHee; + }>;" + `); + }); + it('correct selection set for multi interface member', () => { + const interfaceType = new GraphQLInterfaceType({ + name: 'Foo', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + a: { + type: interfaceType, + }, + }, + }), + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + new GraphQLObjectType({ name: 'Hoo', fields: { a: { type: GraphQLBoolean } }, interfaces: [interfaceType] }), + ], + }); + + expect(buildInterfaceSelectionString(schema, interfaceType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKSelectionSetHee; + \\"...Hoo\\": GeneratedSDKSelectionSetHoo; + }>;" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.ts new file mode 100644 index 00000000000..c570ca34adb --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildInterfaceSelectionString.ts @@ -0,0 +1,15 @@ +import { stripIndent } from 'common-tags'; +import type { GraphQLInterfaceType, GraphQLSchema } from 'graphql'; +import { buildSelectionSetName } from './buildObjectTypeSelectionString'; + +export const buildInterfaceSelectionString = (schema: GraphQLSchema, ttype: GraphQLInterfaceType) => { + const implementedTypes = schema + .getImplementations(ttype) + .objects.map(ttype => `"...${ttype.name}": ${buildSelectionSetName(ttype.name)};`); + + return stripIndent` + type ${buildSelectionSetName(ttype.name)} = SDKSelectionSet<{ + ${implementedTypes.join(`\n `)} + }>; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.spec.ts new file mode 100644 index 00000000000..5e7c0d73ae2 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.spec.ts @@ -0,0 +1,62 @@ +import { GraphQLInt, GraphQLObjectType } from 'graphql'; +import { buildObjectTypeArgumentString } from './buildObjectTypeArgumentString'; + +describe('buildObjectTypeArgumentString', () => { + it('primitive field', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: { + a: { + type: GraphQLInt, + }, + }, + }); + + expect(buildObjectTypeArgumentString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsHello = { + a: {}; + };" + `); + }); + it('object field', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: () => ({ + a: { + type: graphQLObjectType, + }, + }), + }); + + expect(buildObjectTypeArgumentString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsHello = { + a: GeneratedSDKArgumentsHello; + };" + `); + }); + it('primitive field with args.', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: () => ({ + a: { + type: GraphQLInt, + args: { + arg: { + type: GraphQLInt, + }, + }, + }, + }), + }); + + expect(buildObjectTypeArgumentString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsHello = { + a: {} & { + [SDKFieldArgumentSymbol]: { + arg: \\"Int\\"; + } + }; + };" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.ts new file mode 100644 index 00000000000..7dac46fedd4 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeArgumentString.ts @@ -0,0 +1,47 @@ +import { stripIndent } from 'common-tags'; +import { GraphQLObjectType, GraphQLField, getNamedType, isScalarType, isEnumType } from 'graphql'; + +export const buildObjectArgumentsName = (name: string) => `GeneratedSDKArguments${name}`; + +const buildFieldArgumentsString = (field: GraphQLField): string => { + const resultType = getNamedType(field.type); + + let fieldPartial = + isScalarType(resultType) || isEnumType(resultType) ? '{}' : buildObjectArgumentsName(resultType.name); + + if (field.args.length) { + const argumentPartials: Array = []; + + for (const arg of field.args) { + argumentPartials.push(`${arg.name}: "${arg.type.toString()}"`); + } + + fieldPartial = + fieldPartial + + ' & ' + + stripIndent` + { + [SDKFieldArgumentSymbol]: { + ${argumentPartials.join(`;\n `)}; + } + } + ` + .split(`\n`) + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join(`\n`); + } + + return `${field.name}: ${fieldPartial};`; +}; + +export const buildObjectTypeArgumentString = (objectType: GraphQLObjectType) => { + const fields: Array = []; + for (const field of Object.values(objectType.getFields())) { + fields.push(buildFieldArgumentsString(field)); + } + return stripIndent` + type ${buildObjectArgumentsName(objectType.name)} = { + ${fields.join(`\n `)} + }; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.spec.ts new file mode 100644 index 00000000000..143c32791f1 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.spec.ts @@ -0,0 +1,91 @@ +import { buildObjectTypeSelectionString } from './buildObjectTypeSelectionString'; +import { GraphQLInt, GraphQLNonNull, GraphQLObjectType } from 'graphql'; + +describe('buildObjectTypeSelectionString', () => { + it('primitive field', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: { + a: { + type: GraphQLInt, + }, + }, + }); + + expect(buildObjectTypeSelectionString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetHello = SDKSelectionSet<{ + __typename?: true; + a?: true; + }>;" + `); + }); + it('object field', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: () => ({ + a: { + type: graphQLObjectType, + }, + }), + }); + + expect(buildObjectTypeSelectionString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetHello = SDKSelectionSet<{ + __typename?: true; + a?: GeneratedSDKSelectionSetHello; + }>;" + `); + }); + it('primitive field with optional arg.', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: () => ({ + a: { + type: GraphQLInt, + args: { + arg: { + type: GraphQLInt, + }, + }, + }, + }), + }); + + expect(buildObjectTypeSelectionString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetHello = SDKSelectionSet<{ + __typename?: true; + a?: true | SDKSelectionSet<{ + [SDKFieldArgumentSymbol]?: { + arg?: string | never; + } + }>; + }>;" + `); + }); + it('primitive field with required arg.', () => { + const graphQLObjectType = new GraphQLObjectType({ + name: 'Hello', + fields: () => ({ + a: { + type: GraphQLInt, + args: { + arg: { + type: new GraphQLNonNull(GraphQLInt), + }, + }, + }, + }), + }); + + expect(buildObjectTypeSelectionString(graphQLObjectType)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetHello = SDKSelectionSet<{ + __typename?: true; + a?: SDKSelectionSet<{ + [SDKFieldArgumentSymbol]: { + arg: string | never; + } + }>; + }>;" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.ts new file mode 100644 index 00000000000..79b14418cbf --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildObjectTypeSelectionString.ts @@ -0,0 +1,70 @@ +import { stripIndent } from 'common-tags'; +import { + getNamedType, + GraphQLField, + GraphQLObjectType, + isInterfaceType, + isNonNullType, + isObjectType, + isUnionType, +} from 'graphql'; + +export const buildSelectionSetName = (name: string) => `GeneratedSDKSelectionSet${name}`; + +const buildFieldSelectionSetString = (field: GraphQLField): string => { + const resultType = getNamedType(field.type); + + let value = `true`; + + if (isObjectType(resultType) || isInterfaceType(resultType) || isUnionType(resultType)) { + value = buildSelectionSetName(resultType.name); + } + + if (field.args.length) { + let requireArguments = false; + const argumentPartials: Array = []; + + for (const arg of field.args) { + const isNonNull = isNonNullType(arg.type); + requireArguments = isNonNull === true || requireArguments; + argumentPartials.push(`${arg.name}${isNonNull ? `` : `?`}: string | never`); + } + + const args = stripIndent` + SDKSelectionSet<{ + [SDKFieldArgumentSymbol]${requireArguments ? `` : `?`}: { + ${argumentPartials.join(`;\n`)}; + } + }> + ` + .split(`\n`) + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join(`\n`); + + if (value === `true`) { + if (requireArguments) { + value = args; + } else { + value = `${value} | ${args}`; + } + } else { + value = `${value} & ${args}`; + } + } + + return `${field.name}?: ${value};`; +}; + +export const buildObjectTypeSelectionString = (objectType: GraphQLObjectType): string => { + const fields: Array = []; + for (const field of Object.values(objectType.getFields())) { + fields.push(buildFieldSelectionSetString(field)); + } + + return stripIndent` + type ${buildSelectionSetName(objectType.name)} = SDKSelectionSet<{ + __typename?: true; + ${fields.join(`\n `)} + }>; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.spec.ts new file mode 100644 index 00000000000..41d17321dab --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.spec.ts @@ -0,0 +1,106 @@ +import { GraphQLBoolean, GraphQLObjectType } from 'graphql'; +import { buildSDKObjectString } from './buildSDKObjectString'; + +describe('buildSDKObjectString', () => { + it('missing query type', () => { + expect(() => { + buildSDKObjectString(null, null, null); + }).toThrow(); + }); + it('existing query type', () => { + const objectType = new GraphQLObjectType({ + name: 'QueryRoot', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + expect(buildSDKObjectString(objectType, null, null)).toMatchInlineSnapshot(` + "export const sdk = createSDK< + GeneratedSDKInputTypes, + GeneratedSDKSelectionSetQueryRoot, + GeneratedSDKArgumentsQueryRoot, + QueryRoot, + void, + void, + void, + void, + void, + void, + >()" + `); + }); + it('existing query and mutation type', () => { + const queryType = new GraphQLObjectType({ + name: 'QueryRoot', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const mutationType = new GraphQLObjectType({ + name: 'MutationRoot', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + expect(buildSDKObjectString(queryType, mutationType, null)).toMatchInlineSnapshot(` + "export const sdk = createSDK< + GeneratedSDKInputTypes, + GeneratedSDKSelectionSetQueryRoot, + GeneratedSDKArgumentsQueryRoot, + QueryRoot, + GeneratedSDKSelectionSetMutationRoot, + GeneratedSDKArgumentsMutationRoot, + MutationRoot, + void, + void, + void, + >()" + `); + }); + it('existing query, mutation and subscription type', () => { + const queryType = new GraphQLObjectType({ + name: 'QueryRoot', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const mutationType = new GraphQLObjectType({ + name: 'MutationRoot', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + const subscriptionType = new GraphQLObjectType({ + name: 'Subscription', + fields: { + a: { + type: GraphQLBoolean, + }, + }, + }); + expect(buildSDKObjectString(queryType, mutationType, subscriptionType)).toMatchInlineSnapshot(` + "export const sdk = createSDK< + GeneratedSDKInputTypes, + GeneratedSDKSelectionSetQueryRoot, + GeneratedSDKArgumentsQueryRoot, + QueryRoot, + GeneratedSDKSelectionSetMutationRoot, + GeneratedSDKArgumentsMutationRoot, + MutationRoot, + GeneratedSDKSelectionSetSubscription, + GeneratedSDKArgumentsSubscription, + Subscription, + >()" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.ts new file mode 100644 index 00000000000..ed7ffbffb95 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildSDKObjectString.ts @@ -0,0 +1,31 @@ +import { stripIndent } from 'common-tags'; +import type { GraphQLObjectType } from 'graphql'; +import { buildSelectionSetName } from './buildObjectTypeSelectionString'; +import { buildObjectArgumentsName } from './buildObjectTypeArgumentString'; + +type Maybe = T | null | undefined; + +export const buildSDKObjectString = ( + queryType: Maybe, + mutationType: Maybe, + subscriptionType: Maybe +): string => { + if (!queryType) { + throw new TypeError('Query type is missing.'); + } + + return stripIndent` + export const sdk = createSDK< + GeneratedSDKInputTypes, + ${buildSelectionSetName(queryType.name)}, + ${buildObjectArgumentsName(queryType.name)}, + ${queryType.name}, + ${mutationType ? buildSelectionSetName(mutationType.name) : 'void'}, + ${mutationType ? buildObjectArgumentsName(mutationType.name) : 'void'}, + ${mutationType ? mutationType : 'void'}, + ${subscriptionType ? buildSelectionSetName(subscriptionType.name) : 'void'}, + ${subscriptionType ? buildObjectArgumentsName(subscriptionType.name) : 'void'}, + ${subscriptionType ? subscriptionType : 'void'}, + >() + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.spec.ts new file mode 100644 index 00000000000..f3b3362be0e --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.spec.ts @@ -0,0 +1,31 @@ +import { GraphQLBoolean, GraphQLObjectType, GraphQLUnionType } from 'graphql'; +import { buildUnionArgumentString } from './buildUnionArgumentString'; + +describe('buildUnionArgumentString', () => { + it('single union member', () => { + const unionType = new GraphQLUnionType({ + name: 'Foo', + types: [new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } } })], + }); + expect(buildUnionArgumentString(unionType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsFoo = { + \\"...Hee\\": GeneratedSDKArgumentsHee; + };" + `); + }); + it('multiple union member', () => { + const unionType = new GraphQLUnionType({ + name: 'Foo', + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } } }), + new GraphQLObjectType({ name: 'Hoo', fields: { a: { type: GraphQLBoolean } } }), + ], + }); + expect(buildUnionArgumentString(unionType)).toMatchInlineSnapshot(` + "type GeneratedSDKArgumentsFoo = { + \\"...Hee\\": GeneratedSDKArgumentsHee; + \\"...Hoo\\": GeneratedSDKArgumentsHoo; + };" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.ts new file mode 100644 index 00000000000..a6a9e31c136 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildUnionArgumentString.ts @@ -0,0 +1,15 @@ +import { stripIndent } from 'common-tags'; +import type { GraphQLUnionType } from 'graphql'; +import { buildObjectArgumentsName } from './buildObjectTypeArgumentString'; + +export const buildUnionArgumentString = (ttype: GraphQLUnionType) => { + const implementedTypes = ttype + .getTypes() + .map(ttype => `"...${ttype.name}": ${buildObjectArgumentsName(ttype.name)};`); + + return stripIndent` + type ${buildObjectArgumentsName(ttype.name)} = { + ${implementedTypes.join(`\n `)} + }; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.spec.ts new file mode 100644 index 00000000000..efac0fb8ddc --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.spec.ts @@ -0,0 +1,31 @@ +import { GraphQLBoolean, GraphQLObjectType, GraphQLUnionType } from 'graphql'; +import { buildUnionSelectionString } from './buildUnionSelectionString'; + +describe('buildUnionSelectionString', () => { + it('correct selection set for single union member', () => { + const union = new GraphQLUnionType({ + name: 'Foo', + types: [new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } } })], + }); + expect(buildUnionSelectionString(union)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKSelectionSetHee; + }>;" + `); + }); + it('correct selection set for multi union member', () => { + const union = new GraphQLUnionType({ + name: 'Foo', + types: [ + new GraphQLObjectType({ name: 'Hee', fields: { a: { type: GraphQLBoolean } } }), + new GraphQLObjectType({ name: 'Hoo', fields: { a: { type: GraphQLBoolean } } }), + ], + }); + expect(buildUnionSelectionString(union)).toMatchInlineSnapshot(` + "type GeneratedSDKSelectionSetFoo = SDKSelectionSet<{ + \\"...Hee\\": GeneratedSDKSelectionSetHee; + \\"...Hoo\\": GeneratedSDKSelectionSetHoo; + }>;" + `); + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.ts b/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.ts new file mode 100644 index 00000000000..e0f6ef58f8c --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/buildUnionSelectionString.ts @@ -0,0 +1,13 @@ +import { stripIndent } from 'common-tags'; +import type { GraphQLUnionType } from 'graphql'; +import { buildSelectionSetName } from './buildObjectTypeSelectionString'; + +export const buildUnionSelectionString = (ttype: GraphQLUnionType) => { + const implementedTypes = ttype.getTypes().map(ttype => `"...${ttype.name}": ${buildSelectionSetName(ttype.name)};`); + + return stripIndent` + type ${buildSelectionSetName(ttype.name)} = SDKSelectionSet<{ + ${implementedTypes.join(`\n `)} + }>; + `; +}; diff --git a/packages/plugins/typescript/typed-document-sdk/src/config.ts b/packages/plugins/typescript/typed-document-sdk/src/config.ts new file mode 100644 index 00000000000..6e339d355ca --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/config.ts @@ -0,0 +1 @@ +export interface TypedDocumentSDKConfig {} diff --git a/packages/plugins/typescript/typed-document-sdk/src/index.ts b/packages/plugins/typescript/typed-document-sdk/src/index.ts new file mode 100644 index 00000000000..b952c5ae5b0 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/index.ts @@ -0,0 +1,73 @@ +import { Types, PluginValidateFn, PluginFunction } from '@graphql-codegen/plugin-helpers'; +import { + GraphQLSchema, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType, +} from 'graphql'; +import { buildInterfaceArgumentString } from './buildInterfaceArgumentString'; +import { buildInterfaceSelectionString } from './buildInterfaceSelectionString'; +import { buildObjectTypeArgumentString } from './buildObjectTypeArgumentString'; +import { buildObjectTypeSelectionString } from './buildObjectTypeSelectionString'; +import { buildSDKObjectString } from './buildSDKObjectString'; +import { buildUnionArgumentString } from './buildUnionArgumentString'; +import { buildUnionSelectionString } from './buildUnionSelectionString'; +import { TypedDocumentSDKConfig } from './config'; +import { importsString, contentsString } from './sdk-static'; + +export const plugin: PluginFunction = ( + schema: GraphQLSchema, + _: Types.DocumentFile[], + _config: TypedDocumentSDKConfig +) => { + const contents: Array = [contentsString]; + const inputTypeMap: Array = []; + + for (const graphQLType of Object.values(schema.getTypeMap())) { + // selection set objects + if (isObjectType(graphQLType)) { + contents.push(buildObjectTypeSelectionString(graphQLType), buildObjectTypeArgumentString(graphQLType)); + } + + // input types + if (isScalarType(graphQLType)) { + inputTypeMap.push(` ${graphQLType.name}: Scalars['${graphQLType.name}'];`); + } + + if (isEnumType(graphQLType)) { + inputTypeMap.push(` ${graphQLType.name}: ${graphQLType.name};`); + } + + if (isInputObjectType(graphQLType)) { + inputTypeMap.push(` ${graphQLType.name}: ${graphQLType.name};`); + } + + if (isInterfaceType(graphQLType)) { + contents.push( + buildInterfaceSelectionString(schema, graphQLType), + buildInterfaceArgumentString(schema, graphQLType) + ); + } + + if (isUnionType(graphQLType)) { + contents.push(buildUnionSelectionString(graphQLType), buildUnionArgumentString(graphQLType)); + } + } + + contents.push(`type GeneratedSDKInputTypes = {\n${inputTypeMap.join('')} }`); + + // sdk object + contents.push(buildSDKObjectString(schema.getQueryType(), schema.getMutationType(), schema.getSubscriptionType())); + + return { + prepend: [importsString], + content: contents.join(`\n\n`), + }; +}; + +export const validate: PluginValidateFn = async () => {}; + +export { TypedDocumentSDKConfig }; diff --git a/packages/plugins/typescript/typed-document-sdk/src/sdk-base.spec.ts b/packages/plugins/typescript/typed-document-sdk/src/sdk-base.spec.ts new file mode 100644 index 00000000000..61336142ce6 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/sdk-base.spec.ts @@ -0,0 +1,1209 @@ +import { DocumentNode, Kind, OperationTypeNode, print } from 'graphql'; +import { + createSDK, + SDKFieldArgumentSymbol, + SDKSelectionSet, + SDKUnionResultSymbol, + SDKUnionSelectionSet, +} from './sdk-base'; + +describe('SDKLogic', () => { + it('anonymous query operation', () => { + const sdk = createSDK< + {}, + SDKSelectionSet<{ + __typename?: true; + }>, + {}, + { + __typename: 'Query'; + } + >(); + const operation = sdk.query({ + selection: { + __typename: true, + }, + }); + + expect(print(operation)).toMatchInlineSnapshot(` + "{ + __typename + }" + `); + + expect(operation).toStrictEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: undefined, + operation: OperationTypeNode.QUERY, + variableDefinitions: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ], + }, + }, + ], + }); + }); + + it('named query operation', () => { + const sdk = createSDK< + {}, + SDKSelectionSet<{ + __typename?: true; + }>, + {}, + { + __typename?: 'Query'; + } + >(); + const operation = sdk.query({ + name: 'Brrt', + selection: { + __typename: true, + }, + }); + + expect(print(operation)).toMatchInlineSnapshot(` + "query Brrt { + __typename + }" + `); + + expect(operation).toStrictEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: { + kind: Kind.NAME, + value: 'Brrt', + }, + operation: OperationTypeNode.QUERY, + variableDefinitions: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ], + }, + }, + ], + }); + }); + + it('simple mutation operation', () => { + type SelectionType = SDKSelectionSet<{ + __typename?: true; + }>; + type ArgumentType = {}; + type ResultType = { + __typename?: 'Query'; + }; + + const sdk = createSDK<{}, SelectionType, ArgumentType, ResultType, SelectionType, ArgumentType, ResultType>(); + const operation = sdk.mutation({ + selection: { + __typename: true, + }, + }); + + expect(print(operation)).toMatchInlineSnapshot(` + "mutation { + __typename + }" + `); + + expect(operation).toStrictEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: undefined, + operation: OperationTypeNode.MUTATION, + variableDefinitions: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ], + }, + }, + ], + }); + }); + + it('simple subscription operation', () => { + type SelectionType = SDKSelectionSet<{ + __typename?: true; + }>; + type ArgumentType = {}; + type ResultType = { + __typename?: 'Query'; + }; + + const sdk = createSDK< + {}, + SelectionType, + ArgumentType, + ResultType, + SelectionType, + ArgumentType, + ResultType, + SelectionType, + ArgumentType, + ResultType + >(); + const operation = sdk.subscription({ + selection: { + __typename: true, + }, + }); + + expect(print(operation)).toMatchInlineSnapshot(` + "subscription { + __typename + }" + `); + + expect(operation).toStrictEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: undefined, + operation: OperationTypeNode.SUBSCRIPTION, + variableDefinitions: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ], + }, + }, + ], + }); + }); + + it('nested operation', () => { + const sdk = createSDK< + {}, + SDKSelectionSet<{ + __typename?: true; + foo?: { + a?: true; + }; + }>, + {}, + { + __typename: 'Query'; + foo: { + a: boolean; + }; + } + >(); + + const operation = sdk.query({ + selection: { + __typename: true, + foo: { + a: true, + }, + }, + }); + + expect(print(operation)).toMatchInlineSnapshot(` + "{ + __typename + foo { + a + } + }" + `); + + expect(operation).toStrictEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: undefined, + operation: OperationTypeNode.QUERY, + variableDefinitions: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'foo', + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'a', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + + it('query with primitive variables', () => { + type InputTypes = { + String: string; + Int: number; + Boolean: number; + }; + + type SelectionType = SDKSelectionSet<{ + __typename?: boolean; + user?: SDKSelectionSet<{ + id?: boolean; + login?: boolean; + }> & { + [SDKFieldArgumentSymbol]?: { + id?: string | never; + }; + }; + string?: true; + }>; + + type ArgumentType = { + user: { + [SDKFieldArgumentSymbol]: { + id: 'String'; + }; + }; + }; + + type ResultType = { + __typename?: 'Query'; + user?: { + id?: InputTypes['String']; + login?: InputTypes['String']; + }; + }; + + const sdk = createSDK(); + + const document = sdk.query({ + name: 'UserById', + variables: { + idVariableName: 'String', + }, + selection: { + user: { + [SDKFieldArgumentSymbol]: { + id: 'idVariableName', + }, + id: true, + }, + }, + }); + + expect(print(document)).toMatchInlineSnapshot(` + "query UserById($idVariableName: String) { + user(id: $idVariableName) { + id + } + }" + `); + + const expectedDocument: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: { + kind: Kind.NAME, + value: 'UserById', + }, + operation: OperationTypeNode.QUERY, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'String', + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'user', + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'id', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(document).toStrictEqual(expectedDocument); + }); + + it('query with non nullable variables', () => { + type InputTypes = { + String: string; + Int: number; + Boolean: number; + }; + + type SelectionType = SDKSelectionSet<{ + __typename?: true; + user?: SDKSelectionSet<{ + id?: boolean; + login?: boolean; + }> & { + [SDKFieldArgumentSymbol]: { + id: string | never; + }; + }; + }>; + + type ArgumentType = { + user: { + [SDKFieldArgumentSymbol]: { + id: 'String!'; + }; + }; + }; + + type ResultType = { + __typename?: 'Query'; + user?: { + id?: InputTypes['String']; + login?: InputTypes['String']; + }; + }; + + const sdk = createSDK(); + + const document = sdk.query({ + name: 'UserById', + variables: { + idVariableName: 'String!', + }, + selection: { + user: { + [SDKFieldArgumentSymbol]: { + id: 'idVariableName', + }, + id: true, + }, + }, + }); + + expect(print(document)).toMatchInlineSnapshot(` + "query UserById($idVariableName: String!) { + user(id: $idVariableName) { + id + } + }" + `); + + const expectedDocument: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: { + kind: Kind.NAME, + value: 'UserById', + }, + operation: OperationTypeNode.QUERY, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'String', + }, + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'user', + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'id', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(document).toStrictEqual(expectedDocument); + }); + + it('query with list variables', () => { + type InputTypes = { + String: string; + Int: number; + Boolean: number; + }; + + type SelectionType = SDKSelectionSet<{ + __typename?: true; + user?: SDKSelectionSet<{ + id?: boolean; + login?: boolean; + }> & { + [SDKFieldArgumentSymbol]: { + id: string | never; + }; + }; + }>; + type ArgumentType = { + user: { + [SDKFieldArgumentSymbol]: { + id: '[String]'; + }; + }; + }; + type ResultType = { + __typename?: 'Query'; + user?: { + id?: InputTypes['String']; + login?: InputTypes['String']; + }; + }; + + const sdk = createSDK(); + + const document = sdk.query({ + name: 'UserById', + variables: { + idVariableName: '[String]', + }, + selection: { + user: { + [sdk.arguments]: { + id: 'idVariableName', + }, + id: true, + }, + }, + }); + + const expectedDocument: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: { + kind: Kind.NAME, + value: 'UserById', + }, + operation: OperationTypeNode.QUERY, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'String', + }, + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'user', + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'id', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(document).toStrictEqual(expectedDocument); + }); + + it('query with list variables (variance)', () => { + type InputTypes = { + String: string; + Int: number; + Boolean: number; + }; + + // this holds the selection set + type QuerySelectionType = SDKSelectionSet<{ + __typename?: boolean; + user?: SDKSelectionSet<{ + __typename?: boolean; + id?: boolean; + login?: boolean; + }> & { + [SDKFieldArgumentSymbol]: { + id: string | never; + number?: string | never; + }; + }; + }>; + + // this holds the actual argument types + type QueryArgumentType = { + user: { + [SDKFieldArgumentSymbol]: { + id: '[String!]'; + number?: 'Int'; + }; + }; + }; + + // this holds the result + type QueryResultType = { + __typename: 'Query'; + user: { + __typename: 'User'; + id: InputTypes['String']; + login: InputTypes['String']; + } | null; + }; + + const sdk = createSDK(); + + const document = sdk.query({ + name: 'UserById', + variables: { + idVariableName: '[String!]', + a: 'Int', + }, + selection: { + user: { + [sdk.arguments]: { + id: 'idVariableName', + number: 'a', + }, + id: true, + }, + }, + }); + + expect(print(document)).toMatchInlineSnapshot(` + "query UserById($idVariableName: [String!], $a: Int) { + user(id: $idVariableName, number: $a) { + id + } + }" + `); + + const expectedDocument: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + name: { + kind: Kind.NAME, + value: 'UserById', + }, + operation: OperationTypeNode.QUERY, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'String', + }, + }, + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'a', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'user', + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'id', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'idVariableName', + }, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'number', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'a', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + + expect(document).toStrictEqual(expectedDocument); + }); + + it('query primitive field with variables', () => { + type InputTypes = { + String: string; + }; + type GeneratedSDKSelectionSetHello = SDKSelectionSet<{ + __typename?: true; + a?: + | true + | SDKSelectionSet<{ + [SDKFieldArgumentSymbol]?: { + arg?: string | never; + }; + }>; + }>; + type GeneratedSDKArgumentsHello = { + a: GeneratedSDKArgumentsHello & { + [SDKFieldArgumentSymbol]: { + arg: 'String'; + }; + }; + }; + type GeneratedSDKResultHello = { + a: InputTypes['String']; + }; + + const sdk = createSDK< + InputTypes, + GeneratedSDKSelectionSetHello, + GeneratedSDKArgumentsHello, + GeneratedSDKResultHello + >(); + + const document = sdk.query({ + name: 'Foo', + variables: { + myString: 'String', + }, + selection: { + a: { + [sdk.arguments]: { + arg: 'myString', + }, + }, + }, + }); + + expect(print(document)).toMatchInlineSnapshot(` + "query Foo($myString: String) { + a(arg: $myString) + }" + `); + }); + + it('union types', () => { + type InputTypes = { + String: string; + ID: string; + Boolean: number; + Int: number; + }; + + type SelectionType = SDKSelectionSet<{ + __typename?: true; + user?: SDKUnionSelectionSet<{ + '...User': SDKSelectionSet<{ + __typename?: boolean; + id?: boolean; + login?: boolean; + }>; + '...Error': SDKSelectionSet<{ + __typename?: true; + reason?: boolean; + }>; + }> & { + [SDKFieldArgumentSymbol]: { + id: string | never; + number?: string | never; + }; + }; + }>; + + type ArgumentType = { + user: { + [SDKUnionResultSymbol]: true; + [SDKFieldArgumentSymbol]: { + id: 'ID!'; + number?: 'Int'; + }; + }; + }; + + type ResultType = { + __typename?: 'Query'; + user: { + [SDKUnionResultSymbol]: true; + User: { + __typename: 'User'; + id: InputTypes['String']; + login: InputTypes['String']; + }; + Error: { + __typename: 'Error'; + reason: InputTypes['String']; + }; + }; + }; + + const sdk = createSDK(); + + const document = sdk.query({ + name: 'Foo', + variables: { + id: 'ID!', + someNumber: 'Int', + }, + selection: { + user: { + [sdk.arguments]: { + id: 'id', + number: 'someNumber', + }, + '...User': { + __typename: true, + id: true, + }, + '...Error': { + __typename: true, + reason: true, + }, + }, + }, + }); + + expect(print(document)).toMatchInlineSnapshot(` + "query Foo($id: ID!, $someNumber: Int) { + user(id: $id, number: $someNumber) { + ... on User { + __typename + id + } + ... on Error { + __typename + reason + } + } + }" + `); + + const expectedDocument: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation: OperationTypeNode.QUERY, + name: { + kind: Kind.NAME, + value: 'Foo', + }, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'ID', + }, + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + }, + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'someNumber', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'user', + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'id', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'number', + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'someNumber', + }, + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'User', + }, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'id', + }, + }, + ], + }, + }, + { + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Error', + }, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'reason', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }; + expect(document).toStrictEqual(expectedDocument); + + type OperationType = ReturnType>; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + function api_test(value: OperationType) { + value; + if (value.user.__typename === 'User') { + value.user.id; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + value.user.login; + } + + if (value.user.__typename === 'Error') { + value.user.__typename; + value.user.reason; + } + } + }); +}); diff --git a/packages/plugins/typescript/typed-document-sdk/src/sdk-base.ts b/packages/plugins/typescript/typed-document-sdk/src/sdk-base.ts new file mode 100644 index 00000000000..849ae577018 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/src/sdk-base.ts @@ -0,0 +1,513 @@ +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import type { + ArgumentNode, + DocumentNode, + FieldNode, + InlineFragmentNode, + Kind, + ListTypeNode, + NamedTypeNode, + NonNullTypeNode, + OperationTypeNode, + SelectionNode, + SelectionSetNode, + TypeNode, + VariableDefinitionNode, +} from 'graphql'; +// IMPORTS END + +type Mutable = { -readonly [P in keyof T]: T[P] }; + +type Impossible = { + [P in K]: never; +}; + +/** Do not allow any other properties as the ones defined in the base type. */ +type NoExtraProperties = U & Impossible>; + +/** Require at least one property in the object defined. */ +type AtLeastOnePropertyOf = Exclude< + { + [K in keyof T]: { [L in K]-?: T[L] } & { [L in Exclude]?: T[L] }; + }[keyof T], + void +>; + +/** + * @source https://stackoverflow.com/a/56874389/4202031 + */ +type KeysMatching = { + [K in keyof T]-?: T[K] extends V ? K : never; +}[keyof T]; + +type SDKSelection = { [key: string]: any }; + +type ResultType = { [key: string]: any }; + +type SDKInlineFragmentKey = `...${T}`; + +export const SDKFieldArgumentSymbol: unique symbol = Symbol('sdk.arguments'); +export const SDKUnionResultSymbol: unique symbol = Symbol('sdk.union'); + +type SDKExtractUnionTargets, TUnionMember extends string> = Exclude< + TSelection, + null | undefined | never | { [key in `...${TUnionMember}`]?: never | undefined | void } +>; + +type SDKOperationTypeInner> = + // union narrowing + typeof SDKUnionResultSymbol extends keyof TResultType + ? { + [TUnionMember in keyof TResultType]: TUnionMember extends string + ? SDKInlineFragmentKey extends keyof SDKExtractUnionTargets // check whether all union members are covered + ? // all union members are covered + SDKOperationType< + SDKExtractUnionTargets[SDKInlineFragmentKey], + TResultType[TUnionMember] + > + : // not all union members are covered + {} + : never; + // transform result into TypeScript union + }[Exclude] + : // object with selection set + SDKOperationType; + +type SDKNonNullable = Exclude; + +type SDKSelectionKeysWithoutArguments = Exclude< + keyof TSelection, + typeof SDKFieldArgumentSymbol +>; + +type SDKOperationType = { + // check whether field in in result type + [TSelectionField in SDKSelectionKeysWithoutArguments]: TSelectionField extends keyof TResultType + ? TSelection[TSelectionField] extends boolean + ? TResultType[TSelectionField] + : null extends TResultType[TSelectionField] + ? SDKOperationTypeInner> | null + : SDKOperationTypeInner + : never; +}; + +export type SDKSelectionSet = AtLeastOnePropertyOf>; + +export type SDKUnionSelectionSet = any> = AtLeastOnePropertyOf< + NoExtraProperties +>; + +type SDKInputTypeMap = { [inputTypeName: string]: any }; + +type SDKArgumentType< + T_SDKInputTypeMap extends SDKInputTypeMap, + T_VariableDefinitions extends SDKVariableDefinitions +> = { + [T_VariableName in keyof T_VariableDefinitions]: SDKInputContainerUnwrap< + T_SDKInputTypeMap, + T_VariableDefinitions[T_VariableName] + >; +}; + +type SDKSelectionTypedDocumentNode< + T_Selection, + T_ResultType extends ResultType, + T_SDKInputTypeMap extends SDKInputTypeMap | void, + T_VariableDefinitions extends SDKVariableDefinitions< + T_SDKInputTypeMap extends void ? never : T_SDKInputTypeMap + > | void +> = TypedDocumentNode< + SDKOperationType, + T_SDKInputTypeMap extends SDKInputTypeMap + ? T_VariableDefinitions extends SDKVariableDefinitions + ? SDKArgumentType + : never + : never +>; + +type SDKInputNonNullType = `${T}!`; +type SDKInputListType = `[${T}]`; + +/** + * Poor mans implementation, this should actually be recursive as you could potentially indefinitely nest non nullable and list types... + * Right now we only allow Type, [Type], [Type]!, [Type!] and [Type!]! + */ +type SDKInputContainerType = + | TTaxonomy + | SDKInputNonNullType + | SDKInputListType + | SDKInputNonNullType> + | SDKInputListType> + | SDKInputNonNullType>>; + +/** + * Unwrap something like [Type!] to the actual runtime type. + */ +type SDKInputContainerUnwrap< + T_SDKInputTypeMap extends SDKInputTypeMap, + T_Typename extends keyof SDKVariableDefinitions +> = T_Typename extends keyof T_SDKInputTypeMap + ? T_SDKInputTypeMap[T_Typename] | null | undefined + : T_Typename extends SDKInputNonNullType + ? Exclude, null | undefined> + : T_Typename extends SDKInputListType + ? Array> + : never; + +type SDKVariableDefinitions = { + [key: string]: SDKInputContainerType>; +}; + +type SDKSelectionWithVariables< + /** GraphQLTypeName -> TS type */ + T_SDKInputTypeMap extends SDKInputTypeMap, + T_SDKPossibleSelectionSet extends SDKSelectionSet, + T_SDKUserSelectionSet extends SDKSelectionSet, + T_ArgumentType, + /** variableName -> GraphQLTypeName */ + T_VariableDefinitions extends SDKVariableDefinitions | void +> = + | { + [U_FieldName in keyof T_SDKUserSelectionSet]: U_FieldName extends typeof SDKFieldArgumentSymbol + ? T_VariableDefinitions extends SDKVariableDefinitions + ? T_ArgumentType extends { [SDKFieldArgumentSymbol]: infer U_Arguments } + ? { + // From T_VariableDefinitions we want all keys whose value matches `U_Arguments[V_ArgumentName]` + [V_ArgumentName in keyof T_SDKUserSelectionSet[U_FieldName] /* ArgumentType */]: KeysMatching< + T_VariableDefinitions, + // all legit argument values + V_ArgumentName extends keyof U_Arguments ? U_Arguments[V_ArgumentName] : never + >; + } + : never + : never + : U_FieldName extends keyof T_SDKPossibleSelectionSet + ? T_SDKUserSelectionSet[U_FieldName] extends SDKSelectionSet + ? SDKSelectionWithVariables< + T_SDKInputTypeMap, + T_SDKPossibleSelectionSet[U_FieldName], + T_SDKUserSelectionSet[U_FieldName], + U_FieldName extends keyof T_ArgumentType ? T_ArgumentType[U_FieldName] : never, + T_VariableDefinitions + > + : T_SDKUserSelectionSet[U_FieldName] + : never; + }; + +type SDK< + T_SDKInputTypeMap extends SDKInputTypeMap, + T_SDKQuerySelectionSet extends SDKSelectionSet, + T_QueryArgumentType, + T_QueryResultType extends ResultType, + T_SDKMutationSelectionSet extends SDKSelectionSet | void = void, + T_SDKMutationArgumentType = void, + T_MutationResultType extends ResultType | void = void, + T_SDKSubscriptionSelectionSet extends SDKSelectionSet | void = void, + T_SDKSubscriptionArgumentType = void, + T_SubscriptionResultType extends ResultType | void = void +> = { + arguments: typeof SDKFieldArgumentSymbol; + /** + * Build a query operation document node. + */ + query< + Q_VariableDefinitions extends SDKVariableDefinitions | void, + Q_Selection extends T_SDKQuerySelectionSet + >( + args: ( + | { + name: string; + variables?: Q_VariableDefinitions; + } + | { + name?: never; + variables?: never; + } + ) & { + selection: SDKSelectionWithVariables< + T_SDKInputTypeMap, + T_SDKQuerySelectionSet, + Q_Selection, + T_QueryArgumentType, + Q_VariableDefinitions + >; + } + ): SDKSelectionTypedDocumentNode; +} & (T_SDKMutationSelectionSet extends SDKSelectionSet + ? T_MutationResultType extends ResultType + ? { + /** + * Build a mutation operation document node. + */ + mutation< + M_VariableDefinitions extends SDKVariableDefinitions, + M_Selection extends T_SDKMutationSelectionSet + >( + args: ( + | { + name: string; + variables?: M_VariableDefinitions; + } + | { + name?: never; + variables?: never; + } + ) & { + selection: SDKSelectionWithVariables< + T_SDKInputTypeMap, + T_SDKMutationSelectionSet, + M_Selection, + T_SDKMutationArgumentType, + M_VariableDefinitions + >; + } + ): SDKSelectionTypedDocumentNode; + } + : {} + : {}) & + (T_SDKSubscriptionSelectionSet extends SDKSelectionSet + ? T_SubscriptionResultType extends ResultType + ? { + /** + * Build a subscription operation document node. + */ + subscription< + S_VariableDefinitions extends SDKVariableDefinitions, + S_Selection extends T_SDKSubscriptionSelectionSet + >( + args: ( + | { + name: string; + variables?: S_VariableDefinitions; + } + | { + name?: never; + variables?: never; + } + ) & { + selection: SDKSelectionWithVariables< + T_SDKInputTypeMap, + T_SDKSubscriptionSelectionSet, + S_Selection, + T_SDKSubscriptionArgumentType, + S_VariableDefinitions + >; + } + ): SDKSelectionTypedDocumentNode< + S_Selection, + T_SubscriptionResultType, + T_SDKInputTypeMap, + S_VariableDefinitions + >; + } + : {} + : {}); + +const getBaseDocument = ( + operation: 'query' | 'mutation' | 'subscription', + name: string | undefined, + variableDefinitions: Array, + selectionSet: SelectionSetNode +): DocumentNode => ({ + kind: 'Document' as Kind.DOCUMENT, + definitions: [ + { + kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION, + name: name + ? { + kind: 'Name' as Kind.NAME, + value: name, + } + : undefined, + operation: operation as OperationTypeNode, + variableDefinitions, + selectionSet, + }, + ], +}); + +const buildSelectionSet = (sdkSelectionSet: SDKSelectionSet>): SelectionSetNode => { + const selections: Array = []; + + for (const [fieldName, selectionValue] of Object.entries(sdkSelectionSet)) { + const fieldNode: Mutable | Mutable = fieldName.startsWith('...') + ? { + kind: 'InlineFragment' as Kind.INLINE_FRAGMENT, + typeCondition: { + kind: 'NamedType' as Kind.NAMED_TYPE, + name: { + kind: 'Name' as Kind.NAME, + value: fieldName.replace('...', ''), + }, + }, + // we lazily add this no need for adding a noop one here ok? + selectionSet: null as any, + } + : { + kind: 'Field' as Kind.FIELD, + name: { + kind: 'Name' as Kind.NAME, + value: fieldName, + }, + }; + + if (typeof selectionValue === 'object') { + fieldNode.selectionSet = buildSelectionSet(selectionValue); + + if (SDKFieldArgumentSymbol in selectionValue) { + const args: Array = []; + for (const [argumentName, variableName] of Object.entries(selectionValue[SDKFieldArgumentSymbol])) { + if (typeof variableName !== 'string') { + continue; + } + args.push({ + kind: 'Argument' as Kind.ARGUMENT, + name: { + kind: 'Name' as Kind.NAME, + value: argumentName, + }, + value: { + kind: 'Variable' as Kind.VARIABLE, + name: { + kind: 'Name' as Kind.NAME, + value: variableName, + }, + }, + }); + } + if (args.length) { + (fieldNode as Mutable).arguments = args; + } + } + } + selections.push(fieldNode); + } + + const selectionSet: SelectionSetNode = { + kind: 'SelectionSet' as Kind.SELECTION_SET, + selections, + }; + + return selectionSet; +}; + +/** + * Poor mans GraphQL `parseType` (https://github.com/graphql/graphql-js/blob/a91fdc600f2012a60e44356c373e51c5dd20ba81/src/language/parser.ts#L157-L166) + * But in a more compact way :) + */ +const buildTypeNode = (name: string): TypeNode => { + let entry: Mutable; + let previous: Mutable; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (name.endsWith('!')) { + name = name.substring(0, name.length - 1); + const current: NonNullTypeNode = { + kind: 'NonNullType' as Kind.NON_NULL_TYPE, + // Yeah... this is illegal - but we assign it in the next loop run + type: null, + }; + if (previous) { + previous.type = current; + previous = current; + } else if (!entry) { + entry = previous = current; + } + continue; + } + if (name.endsWith(']')) { + name = name.substring(1, name.length - 1); + const current: ListTypeNode = { + kind: 'ListType' as Kind.LIST_TYPE, + // Yeah... this is illegal - but we assign it in the next loop run + type: null, + }; + if (previous) { + previous.type = current; + previous = current; + } else if (!entry) { + entry = previous = current; + } + continue; + } + break; + } + + const last: NamedTypeNode = { + kind: 'NamedType' as Kind.NAMED_TYPE, + name: { + kind: 'Name' as Kind.NAME, + value: name, + }, + }; + + if (entry === undefined) { + return last; + } + + previous.type = last; + return entry; +}; + +const buildVariableDefinitions = (args: Record): Array => { + const variableDefinitions: Array = []; + for (const [variableName, inputType] of Object.entries(args)) { + variableDefinitions.push({ + kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION, + variable: { + kind: 'Variable' as Kind.VARIABLE, + name: { + kind: 'Name' as Kind.NAME, + value: variableName, + }, + }, + type: buildTypeNode(inputType), + }); + } + + return variableDefinitions; +}; + +const sdkHandler = + (operationType: 'query' | 'mutation' | 'subscription') => + (args: { name?: string; variables?: Record; selection: SDKSelection }) => { + const variableDefinitions = buildVariableDefinitions(args.variables ?? {}); + const selectionSet = buildSelectionSet(args.selection); + + const document = getBaseDocument(operationType, args.name, variableDefinitions, selectionSet); + + // type as any so the TypeScript compiler has less work to do :) + return document as any; + }; + +export function createSDK< + T_SDKInputTypeMap extends SDKInputTypeMap, + T_SDKQuerySelectionSet extends SDKSelectionSet, + T_SDKQueryArguments, + T_QueryResultType extends ResultType, + T_SDKMutationSelectionSet extends SDKSelectionSet | void = void, + T_SDKMutationArguments = void, + T_MutationResultType extends ResultType | void = void, + T_SDKSubscriptionSelectionSet extends SDKSelectionSet | void = void, + T_SDKSubscriptionArguments = void, + T_SubscriptionResultType extends ResultType | void = void +>(): SDK< + T_SDKInputTypeMap, + T_SDKQuerySelectionSet, + T_SDKQueryArguments, + T_QueryResultType, + T_SDKMutationSelectionSet, + T_SDKMutationArguments, + T_MutationResultType, + T_SDKSubscriptionSelectionSet, + T_SDKSubscriptionArguments, + T_SubscriptionResultType +> { + return { + query: sdkHandler('query'), + mutation: sdkHandler('mutation'), + subscription: sdkHandler('subscription'), + arguments: SDKFieldArgumentSymbol, + } as any; +} diff --git a/packages/plugins/typescript/typed-document-sdk/tests/typed-document-sdk.spec.ts b/packages/plugins/typescript/typed-document-sdk/tests/typed-document-sdk.spec.ts new file mode 100644 index 00000000000..d338feabec1 --- /dev/null +++ b/packages/plugins/typescript/typed-document-sdk/tests/typed-document-sdk.spec.ts @@ -0,0 +1,9 @@ +// import { Types } from '@graphql-codegen/plugin-helpers'; +// import { buildSchema, parse } from 'graphql'; +// import { plugin } from '../src'; + +describe('TypedDocumentSDK', () => { + describe('buildObjectTypePartial', () => { + test('aaa', () => {}); + }); +});