svelte: Improve TS types for GraphQL mocks (#61047)

Before this commit the "mock types" had been generated by simply
applying `DeepPartial` (which is like `Partial` but applies recursively)
to the existing GraphQL types.

This commit generates the TS types "from scratch". This allows us to
customize how TS types for mocks are defined. Concrete differences (so
far):

- Mock types for interfaces have a `__typename` field that is a union of
  the type names of all implementations of that interface. This should
  make it easier to define mocks of concreate types for fields that
  accept an interface.
- Fields that take an enum are now defined as string union, which allows
  speciying the enum value as string literal (with completion support),
  instead of having to import the actual enum.

Example of generated code:

```
export interface AggregationModeAvailabilityMock {
  __typename?: 'AggregationModeAvailability',
  /**
   * Boolean indicating if the mode is available
   */
  available?: Scalars['Boolean']['output'],
  /**
   * The SearchAggregationMode
   */
  mode?: 'AUTHOR' | 'CAPTURE_GROUP' | 'PATH' | 'REPO' | 'REPO_METADATA',
  /**
   * If the mode is unavailable the reason why
   */
  reasonUnavailable?: Scalars['String']['output'] | null,
}
```

Note that this only affect type mocks, not operation mocks. Those
require more effort to process. I might do that in a future PR.
This commit is contained in:
Felix Kling 2024-03-13 10:09:01 +01:00 committed by GitHub
parent 15ef80ea6d
commit a4a3ef6e6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 9 deletions

View File

@ -1,6 +1,15 @@
// @ts-check
const { isObjectType, visit, concatAST } = require('graphql')
const { isScalarType } = require('graphql')
const {
visit,
concatAST,
isObjectType,
isNonNullType,
isListType,
isInterfaceType,
isUnionType,
isEnumType,
isScalarType,
} = require('graphql')
const logger = require('signale')
/**
@ -17,26 +26,123 @@ function documentsToAST(documents) {
return concatAST(documentNodes)
}
/**
* @param {import('graphql').GraphQLNamedType} type
* @returns {string}
*/
function getMockTypeName(type) {
return type.name + 'Mock'
}
/**
* @param {string[]} lines
* @param {string} indent
* @returns {string}
*/
function formatLines(lines, indent = '') {
return lines.map(line => indent + line).join('\n')
}
/**
* @param {import('graphql').GraphQLSchema} schema
* @param {import('graphql').GraphQLNamedType} type
* @returns {string}
*/
function generateObjectTypeFields(schema, type, indent = '') {
if (!isObjectType(type) && !isInterfaceType(type)) {
throw new Error('Unsupported type ' + type)
}
let lines = []
if (isObjectType(type)) {
lines.push(`__typename?: '${type.name}',`)
}
if (isInterfaceType(type)) {
lines.push(
`__typename?: ${schema
.getImplementations(type)
.objects.map(type => `'${type.name}'`)
.join(' | ')},`
)
}
Object.entries(type.getFields()).forEach(([fieldName, field]) => {
if (field.description || field.deprecationReason) {
lines.push('/**')
if (field.description) {
field.description.split('\n').forEach(line => lines.push(` * ${line}`))
}
if (field.deprecationReason) {
lines.push(` * @deprecated ${field.deprecationReason}`)
}
lines.push(' */')
}
lines.push(`${fieldName}?: ${generateTSTypeForNullableGraphQLType(field.type, indent)},`)
})
return formatLines(lines, indent)
}
/**
* @param {import('graphql').GraphQLType} type
* @param {string} indent
* @returns {string}
*/
function generateTSTypeForNullableGraphQLType(type, indent = '') {
if (isNonNullType(type)) {
return generateTSTypeForGraphQLType(type.ofType, indent)
}
return generateTSTypeForGraphQLType(type, indent) + ' | null'
}
/**
* @param {import('graphql').GraphQLType} type
* @returns {string}
*/
function generateTSTypeForGraphQLType(type, indent = '') {
if (isListType(type)) {
// Using Array<...> instead of ...[] to avoid having to wrap some inner types in parentheses
return `Array<${generateTSTypeForNullableGraphQLType(type.ofType, indent)}>`
}
if (isUnionType(type)) {
return type
.getTypes()
.map(type => generateTSTypeForGraphQLType(type, indent))
.join(' | ')
}
if (isObjectType(type) || isInterfaceType(type)) {
return getMockTypeName(type)
}
if (isEnumType(type)) {
return type
.getValues()
.map(value => `'${value.name}'`)
.join(' | ')
}
if (isScalarType(type)) {
return `Scalars['${type.name}']['output']`
}
throw new Error('Unsupported type ' + type)
}
/**
*
* @param {import('graphql').GraphQLSchema} schema
* @param {import('@graphql-codegen/plugin-helpers').Types.DocumentFile[]} documents
* @param {{typesImport: string, mockInterfaceName?: string, operationResultSuffix?: string}} config
* @param {{mockInterfaceName?: string, operationResultSuffix?: string}} config
* @returns {import('@graphql-codegen/plugin-helpers').Types.PluginOutput}
*/
const plugin = (schema, documents, config) => {
const { mockInterfaceName = 'TypeMocks', typesImport, operationResultSuffix = '' } = config
const { mockInterfaceName = 'TypeMocks', operationResultSuffix = '' } = config
const interfaceFields = Object.values(schema.getTypeMap())
.filter(value => !value.name.startsWith('__') && (isScalarType(value) || isObjectType(value)))
.map(
value =>
`${value.name}?: MockFunction<${
isScalarType(value) ? `Scalars['${value.name}']['output']` : `DeepPartial<${value.name}Mock>`
isScalarType(value) ? `Scalars['${value.name}']['output']` : getMockTypeName(value)
}>`
)
const objectTypes = Object.values(schema.getTypeMap()).filter(
value => !value.name.startsWith('__') && isObjectType(value)
value => !value.name.startsWith('__') && (isObjectType(value) || isInterfaceType(value))
)
const operations = []
@ -53,14 +159,16 @@ const plugin = (schema, documents, config) => {
return {
prepend: [
`import type { GraphQLResolveInfo } from 'graphql'`,
`import type * as Types from '${typesImport}'`,
'type DeepPartial<T> = T extends object ? {',
' [P in keyof T]?: DeepPartial<T[P]>',
'} : T',
'type MockFunction<Return> = (info: GraphQLResolveInfo) => Return',
],
content: [
...objectTypes.map(type => `export type ${type.name}Mock = DeepPartial<Types.${type.name}>`),
...objectTypes.map(
type =>
`export interface ${getMockTypeName(type)} {\n${generateObjectTypeFields(schema, type, ' ')}\n}\n`
),
`export interface ${mockInterfaceName} {`,
` [key: string]: MockFunction<any>|undefined`,
` ${interfaceFields.join('\n ')}`,
@ -74,7 +182,11 @@ const plugin = (schema, documents, config) => {
)
.join('\n ')}`,
`}`,
'export type ObjectMock = ' + objectTypes.map(type => `${type.name}Mock`).join(' | '),
'export type ObjectMock = ' +
objectTypes
.filter(type => !isInterfaceType(type))
.map(type => getMockTypeName(type))
.join(' | '),
].join('\n'),
}
}

View File

@ -194,6 +194,8 @@ class Sourcegraph {
}
public fixture(fixtures: (ObjectMock & { __typename: NonNullable<ObjectMock['__typename']> })[]): void {
// @ts-expect-error - Unclear how to type this correctly. ObjectMock is missing string index signature
// which is required by addFixtures
this.graphqlMock.addFixtures(fixtures)
}