diff --git a/README.md b/README.md index 7bb3134..b49304e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,145 @@ Adds `__typename` property to mock data Changes enums to TypeScript string union types +### generateOperationFactories (`boolean`, default: `true`) + +When the codegen `documents` config is non-empty, the plugin emits one factory per named operation, shaped to that operation's selection-set type. This is useful with [msw](https://mswjs.io/) or any tooling that needs operation-shaped data without hand-rolling the response literal. + +Requires `typesFile` to be set. Set `generateOperationFactories: false` to disable the feature. + +Assume the schema and operations below for the rest of this section: + +```graphql +interface Node { + id: ID! +} + +type User implements Node { + id: ID! + name: String! + friends: [User!]! +} + +type Document implements Node { + id: ID! + title: String! +} + +union SearchHit = User | Document + +type Query { + user(id: ID!): User + node(id: ID!): Node + search(q: String!): [SearchHit!]! +} +``` + +```graphql +query GetUser($id: ID!) { + user(id: $id) { + id + name + friends { + id + name + } + } +} + +query GetNode($id: ID!) { + node(id: $id) { + __typename + ... on User { + id + name + } + ... on Document { + id + title + } + } +} + +query Search($q: String!) { + search(q: $q) { + __typename + ... on User { + id + name + } + ... on Document { + id + title + } + } +} +``` + +#### Basic usage + +The factory returns a fully-populated response shaped to the operation's selection set. Pass a partial override to customize specific fields: + +```ts +import { aGetUserQueryResponse } from './mocks.generated'; + +const response = aGetUserQueryResponse({ + user: { name: 'Alice' }, +}); +// response.user.id is auto-generated; response.user.name is 'Alice' +// response.user.friends is populated with default-shaped User entries +``` + +#### Lists + +Arrays of object/scalar elements take either a literal array or a `(make) => Element[]` callback. The callback's `make` argument produces one default-populated element per call, and accepts a partial override: + +```ts +aGetUserQueryResponse({ + user: { + friends: (make) => [ + make(), // default User + make({ name: 'Bob' }), // overridden name + make({ id: 'pinned-friend' }), + ], + }, +}); +``` + +A literal array replaces the defaults entirely. Use this form when you already have fully-built objects. + +#### Union and interface fields + +When an operation's union/interface field selects `__typename` and two or more concrete branches via inline fragments, the override slot accepts a branch-keyed callback. Each key is a concrete type name; each value is a factory for that branch's operation-shaped subset: + +```ts +import { aGetNodeQueryResponse } from './mocks.generated'; + +// Default branch: alphabetically-first concrete type (Document here). +aGetNodeQueryResponse(); + +// Object override targets the default branch. +aGetNodeQueryResponse({ + node: { title: 'Quarterly Report' }, +}); + +// Branch callback picks a non-default branch with full type inference. +aGetNodeQueryResponse({ + node: ({ User }) => User({ name: 'Alice' }), +}); +``` + +For lists of a union/interface, the array slot likewise accepts a branch-keyed callback. Build the array directly by calling the per-branch factories: + +```ts +import { aSearchQueryResponse } from './mocks.generated'; + +aSearchQueryResponse({ + search: ({ User, Document }) => [User({ name: 'Alice' }), Document({ title: 'Q3 Report' }), User()], +}); +``` + +If the operation does **not** select `__typename`, branch dispatch isn't generated for that field. The override slot falls back to a partial of the alphabetically-first branch. Add `__typename` to the operation if you want callback-style branch overrides. + ### includedTypes (`string[]`, defaultValue: `undefined`) Specifies an array of types to **include** in the mock generation. When provided, only the types listed in this array will have mock data generated. diff --git a/src/index.ts b/src/index.ts index 7503e64..4bd97ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { sentenceCase } from 'sentence-case'; import a from 'indefinite'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { setupFunctionTokens, setupMockValueGenerator } from './mockValueGenerator'; +import { buildOperationFactories } from './operationFactories'; type NamingConvention = 'change-case-all#pascalCase' | 'keep' | string; @@ -446,7 +447,7 @@ const getNamedType = (opts: Options): } }; -const generateMockValue = (opts: Options): string | number | boolean => { +export const generateMockValue = (opts: Options): string | number | boolean => { switch (opts.currentType.kind) { case 'NamedType': return getNamedType({ @@ -542,6 +543,7 @@ const getImportTypes = ({ enumsAsTypes, useTypeImports, typeNamesMapping, + extraTypeImports, }: { typeNamesConvention: NamingConvention; definitions: any; @@ -553,6 +555,7 @@ const getImportTypes = ({ enumsAsTypes: boolean; useTypeImports: boolean; typeNamesMapping?: Record; + extraTypeImports?: string[]; }) => { const typenameConverter = createNameConverter(typeNamesConvention, transformUnderscore); const typeImports = typesPrefix?.endsWith('.') @@ -571,6 +574,10 @@ const getImportTypes = ({ renamedTypeImports.push(...enumTypes); } + if (extraTypeImports && extraTypeImports.length > 0) { + renamedTypeImports.push(...extraTypeImports); + } + function onlyUnique(value, index, self) { return self.indexOf(value) === index; } @@ -635,6 +642,7 @@ export interface TypescriptMocksPluginConfig { typeNamesMapping?: Record; includedTypes?: string[]; excludedTypes?: string[]; + generateOperationFactories?: boolean; } interface TypeItem { @@ -912,6 +920,29 @@ export const plugin: PluginFunction = (schema, docu ); const typesFile = config.typesFile ? config.typesFile.replace(/\.[\w]+$/, '') : null; + // Generate operation factories first to collect operation type imports + let operationOutput = ''; + let operationTypeImports: string[] = []; + const generateOps = config.generateOperationFactories !== false && documents && documents.length > 0; + if (generateOps) { + if (!config.typesFile) { + throw new Error( + 'Plugin "typescript-mock-data" requires `typesFile` to be set when generating operation factories. ' + + 'Either set `typesFile: ` or `generateOperationFactories: false`.', + ); + } + const { output, operationTypeImports: opTypeImports } = buildOperationFactories({ + schema, + documents, + typesFile: config.typesFile, + listElementCount, + prefix: config.prefix, + sharedGenerateMockOpts, + }); + operationOutput = output; + operationTypeImports = opTypeImports; + } + const typesFileImport = getImportTypes({ typeNamesConvention, definitions, @@ -923,6 +954,7 @@ export const plugin: PluginFunction = (schema, docu useTypeImports: config.useTypeImports, enumsAsTypes, typeNamesMapping, + extraTypeImports: operationTypeImports, }); // Function that will generate the mocks. // We generate it after having visited because we need to distinct types from enums @@ -940,5 +972,7 @@ export const plugin: PluginFunction = (schema, docu mockFile += mockFns; if (dynamicValues) mockFile += `\n\n${functionTokens.seedFunction}`; mockFile += '\n'; + mockFile += operationOutput; + return mockFile; }; diff --git a/src/operationFactories.ts b/src/operationFactories.ts new file mode 100644 index 0000000..d765ac1 --- /dev/null +++ b/src/operationFactories.ts @@ -0,0 +1,497 @@ +// src/operationFactories.ts +import { + GraphQLSchema, + OperationDefinitionNode, + Kind, + SelectionSetNode, + GraphQLObjectType, + GraphQLOutputType, + GraphQLNamedType, + isScalarType, + isEnumType, + getNamedType, + FieldNode, + isListType, + isNonNullType, + isObjectType, + isInterfaceType, + isUnionType, + TypeNode, + NamedTypeNode, + ListTypeNode, + FragmentDefinitionNode, +} from 'graphql'; +import { Types } from '@graphql-codegen/plugin-helpers'; +import { pascalCase } from 'change-case-all'; +import { sentenceCase } from 'sentence-case'; +import a from 'indefinite'; +import { RUNTIME_HELPERS } from './operationRuntime'; +import { generateMockValue } from './index'; + +export interface BuildOperationFactoriesArgs { + schema: GraphQLSchema; + documents: Types.DocumentFile[]; + typesFile: string; + listElementCount: number; + prefix: string | undefined; + sharedGenerateMockOpts: any; +} + +export interface BuildOperationFactoriesResult { + output: string; + operationTypeImports: string[]; +} + +const operationLocation = (op: OperationDefinitionNode): string => { + const src = op.loc?.source.name ?? ''; + const line = op.loc?.startToken.line ?? '?'; + return `${src}:${line}`; +}; + +const operationTypeSuffix = (op: OperationDefinitionNode): string => { + switch (op.operation) { + case 'query': + return 'Query'; + case 'mutation': + return 'Mutation'; + case 'subscription': + return 'Subscription'; + default: + throw new Error(`Unknown operation type: ${op.operation}`); + } +}; + +const operationTypeName = (op: OperationDefinitionNode): string => + `${pascalCase(op.name!.value)}${operationTypeSuffix(op)}`; + +const factoryName = (op: OperationDefinitionNode, prefix: string | undefined): string => { + const tn = operationTypeName(op); + const article = prefix !== undefined ? prefix : a(sentenceCase(tn).split(' ')[0], { articleOnly: true }); + return `${article}${tn}Response`; +}; + +const collectOperations = (documents: Types.DocumentFile[]): OperationDefinitionNode[] => { + const seen = new Map(); + const ops: OperationDefinitionNode[] = []; + for (const file of documents) { + for (const def of file.document.definitions) { + if (def.kind !== Kind.OPERATION_DEFINITION) continue; + if (!def.name) continue; + const name = def.name.value; + const prior = seen.get(name); + if (prior) { + throw new Error( + `Plugin "typescript-mock-data" found two operations named "${name}":\n` + + ` - ${operationLocation(prior)}\n` + + ` - ${operationLocation(def)}\n` + + `Each named operation must have a unique name.`, + ); + } + seen.set(name, def); + ops.push(def); + } + } + return ops; +}; + +const collectFragments = (documents: Types.DocumentFile[]): Map => { + const map = new Map(); + for (const file of documents) { + for (const def of file.document.definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + map.set(def.name.value, def); + } + } + } + return map; +}; + +type LeafGenerator = (typeName: string, fieldName: string, gqlType: GraphQLOutputType) => string; + +interface WalkContext { + schema: GraphQLSchema; + listElementCount: number; + closures: string[]; + closureIdSeq: { n: number }; + generateLeaf: LeafGenerator; + fragments: Map; +} + +const unwrap = (t: GraphQLOutputType): GraphQLNamedType => getNamedType(t) as GraphQLNamedType; + +const pickBranch = ( + schema: GraphQLSchema, + parentType: GraphQLNamedType, + selectionSet: SelectionSetNode, +): GraphQLObjectType | null => { + if (isObjectType(parentType)) return parentType; + + const candidateNames = new Set(); + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.INLINE_FRAGMENT && sel.typeCondition) { + candidateNames.add(sel.typeCondition.name.value); + } + } + + if (candidateNames.size === 0) { + // No inline fragments. Pick from possible types. + if (isUnionType(parentType)) { + const types = parentType + .getTypes() + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + if (isInterfaceType(parentType)) { + const types = schema + .getImplementations(parentType) + .objects.slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + return null; + } + + const sorted = Array.from(candidateNames).sort((a, b) => a.localeCompare(b)); + const chosen = schema.getType(sorted[0]); + if (isObjectType(chosen)) return chosen; + if (isInterfaceType(chosen)) { + const types = schema + .getImplementations(chosen) + .objects.slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + if (isUnionType(chosen)) { + const types = chosen + .getTypes() + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return types[0] ?? null; + } + return null; +}; + +const toTypeNode = (t: GraphQLOutputType): TypeNode => { + if (isNonNullType(t)) { + return { kind: Kind.NON_NULL_TYPE, type: toTypeNode(t.ofType) as NamedTypeNode | ListTypeNode }; + } + if (isListType(t)) { + return { kind: Kind.LIST_TYPE, type: toTypeNode(t.ofType) }; + } + return { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: (t as any).name }, + }; +}; + +const nextClosureName = (ctx: WalkContext, hint: string): string => { + const id = ++ctx.closureIdSeq.n; + return `make${pascalCase(hint)}_${id}`; +}; + +const selectsTypename = ( + selectionSet: SelectionSetNode, + fragments: Map, + seen: Set = new Set(), +): boolean => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.FIELD && sel.name.value === '__typename') return true; + if (sel.kind === Kind.INLINE_FRAGMENT && selectsTypename(sel.selectionSet, fragments, seen)) return true; + if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seen.has(sel.name.value)) continue; + const frag = fragments.get(sel.name.value); + if (!frag) continue; + seen.add(sel.name.value); + if (selectsTypename(frag.selectionSet, fragments, seen)) return true; + } + } + return false; +}; + +const collectInlineFragmentTypeNames = ( + schema: GraphQLSchema, + selectionSet: SelectionSetNode, + fragments: Map, + names: Set, + seen: Set, +): void => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.INLINE_FRAGMENT && sel.typeCondition) { + const t = schema.getType(sel.typeCondition.name.value); + if (isObjectType(t)) { + names.add(t.name); + } else if (isInterfaceType(t)) { + for (const impl of schema.getImplementations(t).objects) names.add(impl.name); + } else if (isUnionType(t)) { + for (const member of t.getTypes()) names.add(member.name); + } + } else if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seen.has(sel.name.value)) continue; + const frag = fragments.get(sel.name.value); + if (!frag) continue; + seen.add(sel.name.value); + collectInlineFragmentTypeNames(schema, frag.selectionSet, fragments, names, seen); + } + } +}; + +const selectedBranches = ( + schema: GraphQLSchema, + parentType: GraphQLNamedType, + selectionSet: SelectionSetNode, + fragments: Map, +): GraphQLObjectType[] => { + const names = new Set(); + collectInlineFragmentTypeNames(schema, selectionSet, fragments, names, new Set()); + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map((n) => schema.getType(n)) + .filter(isObjectType) as GraphQLObjectType[]; +}; + +const emitCallbackDispatch = ( + selectionSet: SelectionSetNode, + parentType: GraphQLNamedType, + ctx: WalkContext, + overrideAccess: string, +): string => { + const branches = selectedBranches(ctx.schema, parentType, selectionSet, ctx.fragments); + const branchEntries: string[] = []; + for (const branch of branches) { + const literal = walkSelectionSet(selectionSet, branch, ctx, '_o'); + const closureName = nextClosureName(ctx, branch.name); + ctx.closures.push(` + const ${closureName} = (_o?: any): any => { + const _defaults = ${literal}; + return mergeOverrides(_defaults, _o); + };`); + branchEntries.push(`'${branch.name}': ${closureName}`); + } + return `pickByCallback({ ${branchEntries.join(', ')} }, '${branches[0].name}', ${overrideAccess})`; +}; + +const walkField = ( + field: FieldNode, + parentType: GraphQLObjectType, + ctx: WalkContext, + overrideAccess: string, +): string => { + const fieldName = field.name.value; + const aliasOrName = field.alias?.value ?? fieldName; + if (fieldName === '__typename') { + return `${aliasOrName}: '${parentType.name}' as const`; + } + const fieldDef = parentType.getFields()[fieldName]; + if (!fieldDef) return `${aliasOrName}: null`; + + const childOverride = `${overrideAccess}?.${aliasOrName}`; + + let t = fieldDef.type; + if (isNonNullType(t)) t = t.ofType; + const fieldIsList = isListType(t); + + if (fieldIsList) { + let inner = isListType(t) ? t.ofType : t; + if (isNonNullType(inner)) inner = inner.ofType; + const innerNamed = getNamedType(inner); + const makeName = nextClosureName(ctx, aliasOrName); + + const isAbstract = isInterfaceType(innerNamed) || isUnionType(innerNamed); + if (isAbstract && field.selectionSet && selectsTypename(field.selectionSet, ctx.fragments)) { + const branches = selectedBranches(ctx.schema, innerNamed, field.selectionSet, ctx.fragments); + if (branches.length >= 2) { + const branchEntries: string[] = []; + for (const branch of branches) { + const literal = walkSelectionSet(field.selectionSet, branch, ctx, '_o'); + const closureName = nextClosureName(ctx, branch.name); + ctx.closures.push(` + const ${closureName} = (_o?: any): any => { + const _defaults = ${literal}; + return mergeOverrides(_defaults, _o); + };`); + branchEntries.push(`'${branch.name}': ${closureName}`); + } + return `${aliasOrName}: applyBranchedArrayOverride({ ${branchEntries.join(', ')} }, '${ + branches[0].name + }', ${childOverride}, ${ctx.listElementCount})`; + } + } + + let elemBody: string; + if (isScalarType(innerNamed) || isEnumType(innerNamed)) { + elemBody = ctx.generateLeaf(parentType.name, field.name.value, inner); + } else if (isObjectType(innerNamed) && field.selectionSet) { + elemBody = walkSelectionSet(field.selectionSet, innerNamed, ctx, '_o'); + } else if (isAbstract && field.selectionSet) { + elemBody = walkSelectionSet(field.selectionSet, innerNamed, ctx, '_o'); + } else { + elemBody = 'null'; + } + + ctx.closures.push(` + const ${makeName} = (_o?: any): any => { + const _defaults = ${elemBody}; + return mergeOverrides(_defaults, _o); + };`); + + return `${aliasOrName}: applyArrayOverride(${makeName}, ${childOverride}, ${ctx.listElementCount})`; + } + + const named = unwrap(fieldDef.type); + if (isScalarType(named) || isEnumType(named)) { + return `${aliasOrName}: ${ctx.generateLeaf(parentType.name, fieldName, fieldDef.type)}`; + } + if (isObjectType(named) && field.selectionSet) { + return `${aliasOrName}: ${walkSelectionSet(field.selectionSet, named, ctx, childOverride)}`; + } + if ((isInterfaceType(named) || isUnionType(named)) && field.selectionSet) { + if (selectsTypename(field.selectionSet, ctx.fragments)) { + const branches = selectedBranches(ctx.schema, named, field.selectionSet, ctx.fragments); + if (branches.length >= 2) { + return `${aliasOrName}: ${emitCallbackDispatch(field.selectionSet, named, ctx, childOverride)}`; + } + } + // Cast the override to any when descending into a single concrete branch of an + // interface/union. typescript-operations emits the full implementer list as the + // static type, but we only walk one branch, so descendants need to reach + // branch-specific fields without TS narrowing. + return `${aliasOrName}: ${walkSelectionSet(field.selectionSet, named, ctx, `(${childOverride} as any)`)}`; + } + return `${aliasOrName}: null`; +}; + +const walkSelectionSet = ( + selectionSet: SelectionSetNode, + parentType: GraphQLNamedType, + ctx: WalkContext, + overrideAccess: string, +): string => { + let concrete: GraphQLObjectType; + if (isObjectType(parentType)) { + concrete = parentType; + } else { + const picked = pickBranch(ctx.schema, parentType, selectionSet); + if (!picked) return '{}'; + concrete = picked; + } + + const entries: string[] = []; + const seenFragments = new Set(); + const seenFields = new Set(); + collectFieldSelections(selectionSet, concrete, ctx, overrideAccess, entries, seenFragments, seenFields); + return `{\n ${entries.join(',\n ')},\n }`; +}; + +const collectFieldSelections = ( + selectionSet: SelectionSetNode, + concrete: GraphQLObjectType, + ctx: WalkContext, + overrideAccess: string, + entries: string[], + seenFragments: Set, + seenFields: Set, +): void => { + for (const sel of selectionSet.selections) { + if (sel.kind === Kind.FIELD) { + const key = sel.alias?.value ?? sel.name.value; + if (seenFields.has(key)) continue; + seenFields.add(key); + entries.push(walkField(sel, concrete, ctx, overrideAccess)); + } else if (sel.kind === Kind.INLINE_FRAGMENT) { + const cond = sel.typeCondition?.name.value; + if (cond && !typeConditionMatches(ctx.schema, concrete, cond)) continue; + collectFieldSelections(sel.selectionSet, concrete, ctx, overrideAccess, entries, seenFragments, seenFields); + } else if (sel.kind === Kind.FRAGMENT_SPREAD) { + if (seenFragments.has(sel.name.value)) continue; + const frag = ctx.fragments.get(sel.name.value); + if (!frag) continue; + const fragCond = frag.typeCondition.name.value; + if (!typeConditionMatches(ctx.schema, concrete, fragCond)) continue; + seenFragments.add(sel.name.value); + collectFieldSelections( + frag.selectionSet, + concrete, + ctx, + overrideAccess, + entries, + seenFragments, + seenFields, + ); + } + } +}; + +const typeConditionMatches = (schema: GraphQLSchema, concrete: GraphQLObjectType, conditionName: string): boolean => { + if (concrete.name === conditionName) return true; + const conditionType = schema.getType(conditionName); + if (!conditionType) return false; + if (isInterfaceType(conditionType)) { + return concrete.getInterfaces().some((i) => i.name === conditionType.name); + } + if (isUnionType(conditionType)) { + return conditionType.getTypes().some((t) => t.name === concrete.name); + } + return false; +}; + +const buildFactory = ( + op: OperationDefinitionNode, + schema: GraphQLSchema, + listElementCount: number, + prefix: string | undefined, + generateLeaf: LeafGenerator, + fragments: Map, +): string => { + const opType = schema.getRootType(op.operation); + if (!opType) return ''; + const typeName = operationTypeName(op); + const fnName = factoryName(op, prefix); + const ctx: WalkContext = { + schema, + listElementCount, + closures: [], + closureIdSeq: { n: 0 }, + generateLeaf, + fragments, + }; + const literal = walkSelectionSet(op.selectionSet, opType, ctx, 'overrides'); + const closures = ctx.closures.join('\n'); + return ` +export const ${fnName} = ( + overrides?: DeepPartial<${typeName}>, +): ${typeName} => {${closures} + const defaults: ${typeName} = ${literal}; + return mergeOverrides(defaults, overrides); +}; +`; +}; + +export const buildOperationFactories = (args: BuildOperationFactoriesArgs): BuildOperationFactoriesResult => { + const ops = collectOperations(args.documents); + if (ops.length === 0) return { output: '', operationTypeImports: [] }; + + const fragments = collectFragments(args.documents); + + const generateLeaf: LeafGenerator = (typeName, fieldName, gqlType) => { + const typeNode = toTypeNode(gqlType); + return String( + generateMockValue({ + typeName, + fieldName, + generatorMode: 'output', + currentType: typeNode, + ...args.sharedGenerateMockOpts, + }), + ); + }; + + const factories = ops + .map((op) => buildFactory(op, args.schema, args.listElementCount, args.prefix, generateLeaf, fragments)) + .join('\n'); + const imports = ops.map((op) => operationTypeName(op)); + return { + output: `\n${RUNTIME_HELPERS}\n${factories}`, + operationTypeImports: imports, + }; +}; diff --git a/src/operationRuntime.ts b/src/operationRuntime.ts new file mode 100644 index 0000000..ff22bfe --- /dev/null +++ b/src/operationRuntime.ts @@ -0,0 +1,109 @@ +// src/operationRuntime.ts +export const RUNTIME_HELPERS = ` +type _BranchKeys = T extends { __typename: infer K extends string } ? K : never; +// True when the union has two or more distinct __typename values. Distributive +// instead of recursive so it scales to large interface unions. +type _HasMultipleBranches> = + [K] extends [never] ? false : + K extends K + ? [Exclude<_BranchKeys, K>] extends [never] ? false : true + : false; +type Branches = { + [P in _BranchKeys]: (o?: DeepPartial>) => Extract; +}; + +// Distribute over union members so each branch's keys are preserved. Taking keyof +// on the union directly would only yield the keys common to every branch. +type _PerBranchPartial = T extends object ? { [K in keyof T]?: _Field } : T; + +type _Field = [T] extends [never] + ? T + : _HasMultipleBranches> extends true + ? _PerBranchPartial> | ((b: Branches>) => NonNullable) + : DeepPartial; + +type DeepPartial = [T] extends [never] + ? T + : [T] extends [Array] + ? _HasMultipleBranches> extends true + ? T | ((b: Branches>) => U[]) + : Array> | ((make: (o?: DeepPartial) => U) => U[]) + : T extends object ? { [K in keyof T]?: _Field } : T; + +// NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides +// from being used to infer T, so T is anchored to the defaults parameter only. +type _NoInfer = [T][T extends any ? 0 : never]; + +function mergeOverrides(defaults: T, overrides: _NoInfer> | undefined): T { + if (overrides === undefined) return defaults; + if (overrides === null) return overrides as unknown as T; + // Callback overrides are consumed at the callsite that knows how to dispatch them; + // by the time we get here, the dispatched value is already in defaults. Skip. + if (typeof overrides === 'function') return defaults; + // Arrays were already constructed by applyArrayOverride, which consumes function-typed + // and per-element overrides. The outer mergeOverrides shouldn't replace them. + if (Array.isArray(defaults) && Array.isArray(overrides)) { + return defaults; + } + if (Array.isArray(defaults) || Array.isArray(overrides)) { + return overrides as unknown as T; + } + if (typeof defaults !== 'object' || defaults === null) { + return overrides as unknown as T; + } + if (typeof overrides !== 'object') { + return overrides as unknown as T; + } + const out: any = { ...defaults }; + for (const key of Object.keys(overrides)) { + const ov = (overrides as any)[key]; + if (ov === undefined) continue; + out[key] = mergeOverrides((defaults as any)[key], ov); + } + return out; +} + +function applyArrayOverride( + makeDefault: (o?: DeepPartial) => T, + override: Array> | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(makeDefault()); + return arr; + } + if (typeof override === 'function') { + return (override as (m: (o?: DeepPartial) => T) => T[])(makeDefault); + } + return (override as any[]).map((el) => makeDefault(el)); +} + +function pickByCallback( + branches: Record T>, + defaultBranch: string, + override: T | ((b: Record T>) => T) | undefined | null, +): T { + if (typeof override === 'function') { + return (override as (b: Record T>) => T)(branches); + } + return branches[defaultBranch](override ?? undefined); +} + +function applyBranchedArrayOverride( + branches: Record T>, + defaultBranch: string, + override: T[] | ((b: Record T>) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(branches[defaultBranch]()); + return arr; + } + if (typeof override === 'function') { + return (override as (b: Record T>) => T[])(branches); + } + return override; +} +`; diff --git a/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap new file mode 100644 index 0000000..5297c13 --- /dev/null +++ b/tests/operationFactories/__snapshots__/snapshot.spec.ts.snap @@ -0,0 +1,249 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`snapshot matches snapshot for a representative operation set 1`] = ` +"import { Node, User, Avatar, Document, Query, Mutation, Subscription, Status, GetUserQuery, AllUsersQuery, RenameMutation, SearchQuery } from './types'; + +export const aNode = (overrides?: Partial): Node => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : '8b23789f-fc2b-42cb-a56c-665574fdcbfe', + }; +}; + +export const aUser = (overrides?: Partial): User => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: overrides && overrides.hasOwnProperty('name') ? overrides.name! : 'supellex', + email: overrides && overrides.hasOwnProperty('email') ? overrides.email! : 'natus', + status: overrides && overrides.hasOwnProperty('status') ? overrides.status! : Status.Active, + avatar: overrides && overrides.hasOwnProperty('avatar') ? overrides.avatar! : anAvatar(), + friends: overrides && overrides.hasOwnProperty('friends') ? overrides.friends! : [aUser(), aUser()], + }; +}; + +export const anAvatar = (overrides?: Partial): Avatar => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : '15f9c394-c8fc-46bc-a882-b3b853f28c03', + url: overrides && overrides.hasOwnProperty('url') ? overrides.url! : 'consectetur', + }; +}; + +export const aDocument = (overrides?: Partial): Document => { + return { + id: overrides && overrides.hasOwnProperty('id') ? overrides.id! : 'ab777987-09a3-448f-a2b6-4c0ecc3b45da', + title: overrides && overrides.hasOwnProperty('title') ? overrides.title! : 'suasoria', + url: overrides && overrides.hasOwnProperty('url') ? overrides.url! : 'tutamen', + }; +}; + +export const aQuery = (overrides?: Partial): Query => { + return { + user: overrides && overrides.hasOwnProperty('user') ? overrides.user! : aUser(), + node: overrides && overrides.hasOwnProperty('node') ? overrides.node! : aNode(), + search: overrides && overrides.hasOwnProperty('search') ? overrides.search! : [aUser(), aUser()], + users: overrides && overrides.hasOwnProperty('users') ? overrides.users! : [aUser(), aUser()], + }; +}; + +export const aMutation = (overrides?: Partial): Mutation => { + return { + renameUser: overrides && overrides.hasOwnProperty('renameUser') ? overrides.renameUser! : aUser(), + }; +}; + +export const aSubscription = (overrides?: Partial): Subscription => { + return { + userUpdated: overrides && overrides.hasOwnProperty('userUpdated') ? overrides.userUpdated! : aUser(), + }; +}; + + +type _BranchKeys = T extends { __typename: infer K extends string } ? K : never; +// True when the union has two or more distinct __typename values. Distributive +// instead of recursive so it scales to large interface unions. +type _HasMultipleBranches> = + [K] extends [never] ? false : + K extends K + ? [Exclude<_BranchKeys, K>] extends [never] ? false : true + : false; +type Branches = { + [P in _BranchKeys]: (o?: DeepPartial>) => Extract; +}; + +// Distribute over union members so each branch's keys are preserved. Taking keyof +// on the union directly would only yield the keys common to every branch. +type _PerBranchPartial = T extends object ? { [K in keyof T]?: _Field } : T; + +type _Field = [T] extends [never] + ? T + : _HasMultipleBranches> extends true + ? _PerBranchPartial> | ((b: Branches>) => NonNullable) + : DeepPartial; + +type DeepPartial = [T] extends [never] + ? T + : [T] extends [Array] + ? _HasMultipleBranches> extends true + ? T | ((b: Branches>) => U[]) + : Array> | ((make: (o?: DeepPartial) => U) => U[]) + : T extends object ? { [K in keyof T]?: _Field } : T; + +// NoInfer-like helper for TS <5.4. Prevents the second parameter of mergeOverrides +// from being used to infer T, so T is anchored to the defaults parameter only. +type _NoInfer = [T][T extends any ? 0 : never]; + +function mergeOverrides(defaults: T, overrides: _NoInfer> | undefined): T { + if (overrides === undefined) return defaults; + if (overrides === null) return overrides as unknown as T; + // Callback overrides are consumed at the callsite that knows how to dispatch them; + // by the time we get here, the dispatched value is already in defaults. Skip. + if (typeof overrides === 'function') return defaults; + // Arrays were already constructed by applyArrayOverride, which consumes function-typed + // and per-element overrides. The outer mergeOverrides shouldn't replace them. + if (Array.isArray(defaults) && Array.isArray(overrides)) { + return defaults; + } + if (Array.isArray(defaults) || Array.isArray(overrides)) { + return overrides as unknown as T; + } + if (typeof defaults !== 'object' || defaults === null) { + return overrides as unknown as T; + } + if (typeof overrides !== 'object') { + return overrides as unknown as T; + } + const out: any = { ...defaults }; + for (const key of Object.keys(overrides)) { + const ov = (overrides as any)[key]; + if (ov === undefined) continue; + out[key] = mergeOverrides((defaults as any)[key], ov); + } + return out; +} + +function applyArrayOverride( + makeDefault: (o?: DeepPartial) => T, + override: Array> | ((m: (o?: DeepPartial) => T) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(makeDefault()); + return arr; + } + if (typeof override === 'function') { + return (override as (m: (o?: DeepPartial) => T) => T[])(makeDefault); + } + return (override as any[]).map((el) => makeDefault(el)); +} + +function pickByCallback( + branches: Record T>, + defaultBranch: string, + override: T | ((b: Record T>) => T) | undefined | null, +): T { + if (typeof override === 'function') { + return (override as (b: Record T>) => T)(branches); + } + return branches[defaultBranch](override ?? undefined); +} + +function applyBranchedArrayOverride( + branches: Record T>, + defaultBranch: string, + override: T[] | ((b: Record T>) => T[]) | null | undefined, + count: number, +): T[] { + if (override === null || override === undefined) { + const arr: T[] = []; + for (let i = 0; i < count; i++) arr.push(branches[defaultBranch]()); + return arr; + } + if (typeof override === 'function') { + return (override as (b: Record T>) => T[])(branches); + } + return override; +} + + +export const aGetUserQueryResponse = ( + overrides?: DeepPartial, +): GetUserQuery => { + const defaults: GetUserQuery = { + user: { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + email: 'natus', + avatar: { + id: '15f9c394-c8fc-46bc-a882-b3b853f28c03', + url: 'consectetur', + }, + }, + }; + return mergeOverrides(defaults, overrides); +}; + + +export const anAllUsersQueryResponse = ( + overrides?: DeepPartial, +): AllUsersQuery => { + const makeFriends_2 = (_o?: any): any => { + const _defaults = { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + }; + return mergeOverrides(_defaults, _o); + }; + + const makeUsers_1 = (_o?: any): any => { + const _defaults = { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + friends: applyArrayOverride(makeFriends_2, _o?.friends, 2), + }; + return mergeOverrides(_defaults, _o); + }; + const defaults: AllUsersQuery = { + users: applyArrayOverride(makeUsers_1, overrides?.users, 2), + }; + return mergeOverrides(defaults, overrides); +}; + + +export const aRenameMutationResponse = ( + overrides?: DeepPartial, +): RenameMutation => { + const defaults: RenameMutation = { + renameUser: { + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + name: 'supellex', + }, + }; + return mergeOverrides(defaults, overrides); +}; + + +export const aSearchQueryResponse = ( + overrides?: DeepPartial, +): SearchQuery => { + const makeDocument_2 = (_o?: any): any => { + const _defaults = { + __typename: 'Document' as const, + id: 'ab777987-09a3-448f-a2b6-4c0ecc3b45da', + title: 'suasoria', + }; + return mergeOverrides(_defaults, _o); + }; + + const makeUser_3 = (_o?: any): any => { + const _defaults = { + __typename: 'User' as const, + id: 'b7605a2a-ad1e-4667-801e-5e47e5de933b', + }; + return mergeOverrides(_defaults, _o); + }; + const defaults: SearchQuery = { + search: applyBranchedArrayOverride({ 'Document': makeDocument_2, 'User': makeUser_3 }, 'Document', overrides?.search, 2), + }; + return mergeOverrides(defaults, overrides); +}; +" +`; diff --git a/tests/operationFactories/activation.spec.ts b/tests/operationFactories/activation.spec.ts new file mode 100644 index 0000000..5c2808d --- /dev/null +++ b/tests/operationFactories/activation.spec.ts @@ -0,0 +1,28 @@ +// tests/operationFactories/activation.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('operation factory activation', () => { + it('emits nothing extra when no documents are provided', async () => { + const result = await plugin(opSchema, [], { typesFile: './types.ts' }); + expect(result).not.toContain('QueryResponse'); + expect(result).not.toContain('mergeOverrides'); + }); + + it('throws when documents are present but typesFile is missing', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + await expect(async () => { + await plugin(opSchema, docs, {}); + }).rejects.toThrow(/requires `typesFile` to be set/); + }); + + it('does nothing when generateOperationFactories is false', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + generateOperationFactories: false, + }); + expect(result).not.toContain('GetUserQueryResponse'); + }); +}); diff --git a/tests/operationFactories/aliasesAndTypename.spec.ts b/tests/operationFactories/aliasesAndTypename.spec.ts new file mode 100644 index 0000000..11b9d9e --- /dev/null +++ b/tests/operationFactories/aliasesAndTypename.spec.ts @@ -0,0 +1,32 @@ +// tests/operationFactories/aliasesAndTypename.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('aliases and __typename', () => { + it('uses the alias as the literal key', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { uid: id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factory = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(factory).toContain('uid:'); + // The factory should not have an unaliased "id:" key when alias is used. + // We test by ensuring we don't see two distinct id keys in the user object. + expect(factory.match(/id:/g)?.length ?? 0).toBeLessThanOrEqual(1); + }); + + it('emits __typename only when selected', async () => { + const withTypename = await plugin( + opSchema, + makeDocs([`query GetUser($id: ID!) { user(id: $id) { __typename id } }`]), + { typesFile: './types.ts' }, + ); + const withFactory = String(withTypename).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(withFactory).toMatch(/__typename:\s*'User'/); + + const without = await plugin(opSchema, makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]), { + typesFile: './types.ts', + }); + const withoutFactory = String(without).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(withoutFactory).not.toContain('__typename'); + }); +}); diff --git a/tests/operationFactories/callbackBranches.spec.ts b/tests/operationFactories/callbackBranches.spec.ts new file mode 100644 index 0000000..7b500d2 --- /dev/null +++ b/tests/operationFactories/callbackBranches.spec.ts @@ -0,0 +1,81 @@ +// tests/operationFactories/callbackBranches.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +const factoryFor = async (source: string, name: string): Promise => { + const result = await plugin(opSchema, makeDocs([source]), { typesFile: './types.ts' }); + const matched = String(result).match(new RegExp(`export const ${name}[\\s\\S]+?^};$`, 'm')); + if (!matched) throw new Error(`factory ${name} not found`); + return matched[0]; +}; + +describe('callback branch overrides for union/interface fields', () => { + it('emits a per-branch defaults closure for each concrete type selected on an interface field', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + // One closure per branch, named after the concrete type. + expect(factory).toMatch( + /const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'[\s\S]+title:/, + ); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'[\s\S]+name:/); + // The field's value is built by pickByCallback wiring those closures by typename. + expect(factory).toMatch( + /node: pickByCallback\(\s*\{[^}]*'Document':\s*makeDocument_\d+[^}]*'User':\s*makeUser_\d+[^}]*\}/, + ); + }); + + it('emits per-branch closures and applyBranchedArrayOverride for a list of a union with __typename', async () => { + const factory = await factoryFor( + `query Search { search(q: "hi") { __typename ... on User { id name } ... on Document { id title } } }`, + 'aSearchQueryResponse', + ); + expect(factory).toMatch(/const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'/); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'/); + // List of a union dispatches via applyBranchedArrayOverride, which receives the branch + // factories and the override directly, with no intermediate make closure. + expect(factory).toMatch( + /search: applyBranchedArrayOverride\(\s*\{[^}]*'Document':\s*makeDocument_\d+[^}]*'User':\s*makeUser_\d+[^}]*\}\s*,\s*'Document'\s*,\s*overrides\?\.search\s*,/, + ); + // The flat make+pickByCallback combo from the previous design is gone. + expect(factory).not.toMatch(/const makeSearch_\d+ = \(_o\?: any\): any => pickByCallback\(/); + }); + + it('points the pickByCallback default at the alphabetically-first branch', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).toMatch(/pickByCallback\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('does not emit branch dispatch when only one inline fragment is present', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).not.toContain('pickByCallback'); + }); + + it('does not emit branch dispatch when __typename is not selected', async () => { + const factory = await factoryFor( + `query GetNode($id: ID!) { node(id: $id) { ... on User { id name } ... on Document { id title } } }`, + 'aGetNodeQueryResponse', + ); + expect(factory).not.toContain('pickByCallback'); + }); + + it('finds inline-fragment branches reached through a named fragment spread', async () => { + const factory = await factoryFor( + `fragment Hit on SearchHit { __typename ... on User { id name } ... on Document { id title } } + query Search { search(q: "hi") { ...Hit } }`, + 'aSearchQueryResponse', + ); + // Both branch closures should be emitted, not just the alphabetically-first one. + expect(factory).toMatch(/const makeDocument_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'Document'/); + expect(factory).toMatch(/const makeUser_\d+ = \(_o\?: any\): any => \{[\s\S]+__typename: 'User'/); + expect(factory).toMatch(/applyBranchedArrayOverride\(\s*\{[^}]*'Document':[^}]*'User':/); + }); +}); diff --git a/tests/operationFactories/collection.spec.ts b/tests/operationFactories/collection.spec.ts new file mode 100644 index 0000000..1dfb818 --- /dev/null +++ b/tests/operationFactories/collection.spec.ts @@ -0,0 +1,22 @@ +// tests/operationFactories/collection.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('operation collection', () => { + it('throws on duplicate operation names with both source locations', async () => { + const docs = makeDocs([ + `query GetUser($id: ID!) { user(id: $id) { id } }`, + `query GetUser($id: ID!) { user(id: $id) { name } }`, + ]); + await expect(async () => { + await plugin(opSchema, docs, { typesFile: './types.ts' }); + }).rejects.toThrow(/two operations named "GetUser"/); + }); + + it('silently skips anonymous operations', async () => { + const docs = makeDocs([`{ user(id: "1") { id } }`, `query Named($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('aNamedQueryResponse'); + }); +}); diff --git a/tests/operationFactories/fixtures.ts b/tests/operationFactories/fixtures.ts new file mode 100644 index 0000000..5dccadf --- /dev/null +++ b/tests/operationFactories/fixtures.ts @@ -0,0 +1,62 @@ +// tests/operationFactories/fixtures.ts +import { buildSchema, parse, Source } from 'graphql'; +import { Types } from '@graphql-codegen/plugin-helpers'; + +export const opSchema = buildSchema(/* GraphQL */ ` + scalar Date + + enum Status { + ACTIVE + INACTIVE + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + email: String! + status: Status! + avatar: Avatar + friends: [User!]! + } + + type Avatar { + id: ID! + url: String! + } + + type Document implements Node { + id: ID! + title: String! + url: String! + } + + union SearchHit = User | Document + + type Query { + user(id: ID!): User + node(id: ID!): Node + search(q: String!): [SearchHit!]! + users: [User!]! + } + + type Mutation { + renameUser(id: ID!, name: String!): User! + } + + type Subscription { + userUpdated(id: ID!): User! + } +`); + +export const makeDocs = (sources: string[]): Types.DocumentFile[] => + sources.map((source, i) => { + const location = `tests/operationFactories/fixtures.graphql#${i}`; + return { + document: parse(new Source(source, location)), + location, + }; + }); diff --git a/tests/operationFactories/fragments.spec.ts b/tests/operationFactories/fragments.spec.ts new file mode 100644 index 0000000..20cbc20 --- /dev/null +++ b/tests/operationFactories/fragments.spec.ts @@ -0,0 +1,47 @@ +// tests/operationFactories/fragments.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('named fragment spreads', () => { + it('inlines fragment selections into the operation literal', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name } + query GetUser($id: ID!) { user(id: $id) { ...UserCore email } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // Scope assertions to the operation factory body (avoid false positives from + // schema-level type factories that also contain these field names). + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + expect(factory).toContain('name:'); + expect(factory).toContain('email:'); + expect(result).not.toContain('aUserCoreFragment'); // no fragment factory + }); + + it('resolves fragments defined in a separate document', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name }`, + `query GetUser($id: ID!) { user(id: $id) { ...UserCore } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + expect(factoryMatch![0]).toContain('name:'); + }); + + it('deduplicates fields selected directly and via a spread', async () => { + const docs = makeDocs([ + `fragment UserBits on User { __typename name } + query GetUser($id: ID!) { user(id: $id) { __typename name ...UserBits } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetUserQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory.match(/__typename:\s*'User'/g)?.length).toBe(1); + expect(factory.match(/\bname:/g)?.length).toBe(1); + }); +}); diff --git a/tests/operationFactories/leafValues.spec.ts b/tests/operationFactories/leafValues.spec.ts new file mode 100644 index 0000000..d4e90ba --- /dev/null +++ b/tests/operationFactories/leafValues.spec.ts @@ -0,0 +1,25 @@ +// tests/operationFactories/leafValues.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('leaf value generation', () => { + it('honors custom scalars config', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + scalars: { ID: 'string.uuid' }, + }); + // The factory's id default should look like a UUID call from faker. + expect(result).toMatch(/id:\s*'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/); + }); + + it('honors fieldGeneration config', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { email } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + fieldGeneration: { User: { email: 'internet.email' } }, + }); + expect(result).toMatch(/email:\s*'\S+@\S+'/); + }); +}); diff --git a/tests/operationFactories/lists.spec.ts b/tests/operationFactories/lists.spec.ts new file mode 100644 index 0000000..17c8223 --- /dev/null +++ b/tests/operationFactories/lists.spec.ts @@ -0,0 +1,32 @@ +// tests/operationFactories/lists.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('list selections', () => { + it('emits applyArrayOverride and a per-element factory', async () => { + const docs = makeDocs([`query AllUsers { users { id name } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('applyArrayOverride('); + expect(result).toMatch(/const make[A-Z]\w* = \(/); + }); + + it('honors listElementCount config', async () => { + const docs = makeDocs([`query AllUsers { users { id } }`]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + listElementCount: 3, + }); + expect(result).toContain('applyArrayOverride('); + expect(result).toContain(', 3)'); + }); + + it('handles nested lists (friends inside user)', async () => { + const docs = makeDocs([`query AllUsers { users { id friends { id name } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // Should have at least two make* factories (top-level users and nested friends) + const output = typeof result === 'string' ? result : result.content; + const matches = output.match(/const make[A-Z]\w* = \(/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/operationFactories/nested.spec.ts b/tests/operationFactories/nested.spec.ts new file mode 100644 index 0000000..ed74034 --- /dev/null +++ b/tests/operationFactories/nested.spec.ts @@ -0,0 +1,19 @@ +// tests/operationFactories/nested.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('nested object selections', () => { + it('emits nested literals for object subfields', async () => { + const docs = makeDocs([`query Profile($id: ID!) { user(id: $id) { id avatar { id url } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/avatar:\s*\{[^}]*id:[^}]*url:[^}]*\}/); + }); + + it('handles a null branch on nullable object fields', async () => { + const docs = makeDocs([`query Profile($id: ID!) { user(id: $id) { id avatar { url } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + // avatar is nullable; default should be the object, not null + expect(result).not.toMatch(/avatar:\s*null/); + }); +}); diff --git a/tests/operationFactories/runtime.spec.ts b/tests/operationFactories/runtime.spec.ts new file mode 100644 index 0000000..7eb51e0 --- /dev/null +++ b/tests/operationFactories/runtime.spec.ts @@ -0,0 +1,19 @@ +// tests/operationFactories/runtime.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('runtime helpers', () => { + it('emits DeepPartial, mergeOverrides, applyArrayOverride when operations are generated', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('type DeepPartial<'); + expect(result).toContain('function mergeOverrides<'); + expect(result).toContain('function applyArrayOverride<'); + }); + + it('does not emit runtime helpers when no operations exist', async () => { + const result = await plugin(opSchema, [], { typesFile: './types.ts' }); + expect(result).not.toContain('mergeOverrides'); + }); +}); diff --git a/tests/operationFactories/simple.spec.ts b/tests/operationFactories/simple.spec.ts new file mode 100644 index 0000000..29bb3f0 --- /dev/null +++ b/tests/operationFactories/simple.spec.ts @@ -0,0 +1,48 @@ +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('simple scalar operation factory', () => { + it('emits a factory whose name is articleNameOperation+Response', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('export const aGetUserQueryResponse = ('); + }); + + it('annotates return type with the operation TS type', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('): GetUserQuery =>'); + }); + + it('imports the operation type from typesFile', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/import\s*\{[^}]*GetUserQuery[^}]*\}\s*from\s*['"]\.\/types['"]/); + }); + + it('emits an inline literal matching the selection set', async () => { + const docs = makeDocs([`query GetUser($id: ID!) { user(id: $id) { id name email } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('user:'); + expect(result).toContain('id:'); + expect(result).toContain('name:'); + expect(result).toContain('email:'); + }); + + it('emits an `an`-prefixed factory when operation type starts with a vowel', async () => { + const docs = makeDocs([`query Audit { user(id: "1") { id } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('export const anAuditQueryResponse = ('); + }); + + it('emits factories for mutations and subscriptions too', async () => { + const docs = makeDocs([ + `mutation Rename($id: ID!, $name: String!) { renameUser(id: $id, name: $name) { id name } }`, + `subscription Watch($id: ID!) { userUpdated(id: $id) { id name } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toContain('aRenameMutationResponse'); + expect(result).toContain('aWatchSubscriptionResponse'); + }); +}); diff --git a/tests/operationFactories/snapshot.spec.ts b/tests/operationFactories/snapshot.spec.ts new file mode 100644 index 0000000..338440b --- /dev/null +++ b/tests/operationFactories/snapshot.spec.ts @@ -0,0 +1,21 @@ +// tests/operationFactories/snapshot.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('snapshot', () => { + it('matches snapshot for a representative operation set', async () => { + const docs = makeDocs([ + `fragment UserCore on User { id name email }`, + `query GetUser($id: ID!) { user(id: $id) { ...UserCore avatar { id url } } }`, + `query AllUsers { users { id friends { id name } } }`, + `mutation Rename($id: ID!, $name: String!) { renameUser(id: $id, name: $name) { id name } }`, + `query Search { search(q: "x") { __typename ... on User { id } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { + typesFile: './types.ts', + listElementCount: 2, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/tests/operationFactories/typeCheck.spec.ts b/tests/operationFactories/typeCheck.spec.ts new file mode 100644 index 0000000..bf85edb --- /dev/null +++ b/tests/operationFactories/typeCheck.spec.ts @@ -0,0 +1,144 @@ +// tests/operationFactories/typeCheck.spec.ts +import '@graphql-codegen/testing'; +import * as ts from 'typescript'; +import { buildSchema } from 'graphql'; +import { plugin } from '../../src'; +import { makeDocs } from './fixtures'; + +const listUsersSchema = buildSchema(/* GraphQL */ ` + type Query { + users: UserConnection! + } + + type UserConnection { + nodes: [User!]! + totalCount: Int! + } + + type User { + id: ID! + name: String! + email: String! + } +`); + +const listUsersTypes = ` +export type User = { id: string; name: string; email: string }; +export type UserConnection = { nodes: User[]; totalCount: number }; +export type Query = { users: UserConnection }; +export type ListUsersQuery = { + users: { + nodes: User[]; + totalCount: number; + }; +}; +`; + +const compile = (files: Record): readonly ts.Diagnostic[] => { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2018, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + strict: true, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + }; + const host = ts.createCompilerHost(options); + const realGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + if (Object.prototype.hasOwnProperty.call(files, fileName)) { + return ts.createSourceFile(fileName, files[fileName], languageVersion, true); + } + return realGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); + }; + host.fileExists = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) || ts.sys.fileExists(fileName); + host.readFile = (fileName) => + Object.prototype.hasOwnProperty.call(files, fileName) ? files[fileName] : ts.sys.readFile(fileName); + host.writeFile = () => undefined; + + const program = ts.createProgram(Object.keys(files), options, host); + return ts.getPreEmitDiagnostics(program); +}; + +const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]): string => + diagnostics + .map((d) => { + const message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); + if (d.file && d.start !== undefined) { + const { line, character } = d.file.getLineAndCharacterOfPosition(d.start); + return `${d.file.fileName}:${line + 1}:${character + 1} ${message}`; + } + return message; + }) + .join('\n'); + +describe('generated factories type-check', () => { + it('accepts partial element overrides for object lists', async () => { + const docs = makeDocs([`query ListUsers { users { nodes { id name email } totalCount } }`]); + const generated = await plugin(listUsersSchema, docs, { typesFile: './types.ts' }); + const mockFile = typeof generated === 'string' ? generated : generated.content; + + const usage = ` +import { aListUsersQueryResponse } from './mocks.generated'; + +const partialElement = aListUsersQueryResponse({ + users: { nodes: [{ id: 'u1' }] }, +}); + +const partialElementWithCount = aListUsersQueryResponse({ + users: { + nodes: [{ id: 'u1' }, { name: 'Alice' }], + totalCount: 2, + }, +}); + +const callbackForm = aListUsersQueryResponse({ + users: { + nodes: (make) => [make({ id: 'u1' }), make({ name: 'Alice' })], + }, +}); + +const noOverride = aListUsersQueryResponse(); + +void partialElement; +void partialElementWithCount; +void callbackForm; +void noOverride; +`; + + const diagnostics = compile({ + '/types.ts': listUsersTypes, + '/mocks.generated.ts': mockFile, + '/usage.ts': usage, + }); + + expect(formatDiagnostics(diagnostics)).toBe(''); + }); + + it('still rejects element overrides with extra unknown properties', async () => { + const docs = makeDocs([`query ListUsers { users { nodes { id name email } totalCount } }`]); + const generated = await plugin(listUsersSchema, docs, { typesFile: './types.ts' }); + const mockFile = typeof generated === 'string' ? generated : generated.content; + + const usage = ` +import { aListUsersQueryResponse } from './mocks.generated'; + +const bogus = aListUsersQueryResponse({ + users: { nodes: [{ notAField: 'oops' }] }, +}); + +void bogus; +`; + + const diagnostics = compile({ + '/types.ts': listUsersTypes, + '/mocks.generated.ts': mockFile, + '/usage.ts': usage, + }); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(formatDiagnostics(diagnostics)).toMatch(/notAField/); + }); +}); diff --git a/tests/operationFactories/unions.spec.ts b/tests/operationFactories/unions.spec.ts new file mode 100644 index 0000000..0217374 --- /dev/null +++ b/tests/operationFactories/unions.spec.ts @@ -0,0 +1,79 @@ +// tests/operationFactories/unions.spec.ts +import '@graphql-codegen/testing'; +import { plugin } from '../../src'; +import { opSchema, makeDocs } from './fixtures'; + +describe('unions and interfaces', () => { + it('picks the alphabetically first concrete branch as the default for a list of a union (with __typename)', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { __typename ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + // Document < User alphabetically, so the list dispatch default is 'Document'. + expect(factoryMatch![0]).toMatch(/applyBranchedArrayOverride\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('picks the alphabetically first concrete branch as the default for an interface narrowing (with __typename)', async () => { + const docs = makeDocs([ + `query GetNode($id: ID!) { node(id: $id) { __typename ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetNodeQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + expect(factoryMatch![0]).toMatch(/pickByCallback\(\s*\{[^}]+\}\s*,\s*'Document'/); + }); + + it('does not emit callback dispatch when __typename is not selected', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { ... on User { id name } ... on Document { id title } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factory = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m)?.[0] ?? ''; + expect(factory).not.toContain('pickByCallback'); + }); + + it('emits __typename when the operation selects it on a union branch', async () => { + const docs = makeDocs([ + `query Search { search(q: "hi") { __typename ... on User { id } ... on Document { id } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + expect(result).toMatch(/__typename:\s*'Document'/); + }); + + it('inlines fields from an inline fragment whose type condition is an interface implemented by the chosen concrete type', async () => { + const docs = makeDocs([`query GetNode($id: ID!) { node(id: $id) { ... on Node { id } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aGetNodeQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + // Must not produce empty object literal (which surfaces as `{ , }` after our join). + expect(factory).not.toMatch(/\{\s*,\s*\}/); + }); + + it('inlines fragment spreads nested inside an inline fragment matching the chosen branch', async () => { + const docs = makeDocs([ + // Only the Document branch's inline fragment is present, and it spreads a fragment. + `fragment DocBits on Document { id title } + query Search { search(q: "hi") { ... on Document { ...DocBits } } }`, + ]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aSearchQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('title:'); + expect(factory).not.toMatch(/\{\s*,\s*\}/); + }); + + it('inlines interface-typed inline fragments selected on a concrete object field (e.g. payload errors lists)', async () => { + const docs = makeDocs([`query Friends { users { friends { ... on Node { id } } } }`]); + const result = await plugin(opSchema, docs, { typesFile: './types.ts' }); + const factoryMatch = String(result).match(/export const aFriendsQueryResponse[\s\S]+?^};$/m); + expect(factoryMatch).toBeTruthy(); + const factory = factoryMatch![0]; + expect(factory).toContain('id:'); + expect(factory).not.toMatch(/_defaults\s*=\s*\{\s*,?\s*\};?/); + }); +});