diff --git a/packages/plugins/typescript/urql-graphcache/src/config.ts b/packages/plugins/typescript/urql-graphcache/src/config.ts index 2da27015d5..38d81eb7af 100644 --- a/packages/plugins/typescript/urql-graphcache/src/config.ts +++ b/packages/plugins/typescript/urql-graphcache/src/config.ts @@ -5,4 +5,9 @@ import { RawClientSideBasePluginConfig } from '@graphql-codegen/visitor-plugin-c */ export type UrqlGraphCacheConfig = RawClientSideBasePluginConfig & { offlineExchange?: boolean; + /** + * @description Optimize optimistic updater types based on actual field selections in mutations + * @default false + */ + optimizeOptimisticTypes?: boolean; }; diff --git a/packages/plugins/typescript/urql-graphcache/src/index.ts b/packages/plugins/typescript/urql-graphcache/src/index.ts index cd93ac8ce0..40c7718012 100644 --- a/packages/plugins/typescript/urql-graphcache/src/index.ts +++ b/packages/plugins/typescript/urql-graphcache/src/index.ts @@ -1,8 +1,11 @@ import { + FieldNode, + FragmentDefinitionNode, GraphQLObjectType, GraphQLSchema, GraphQLType, GraphQLWrappingType, + InlineFragmentNode, isEnumType, isInputObjectType, isInterfaceType, @@ -12,7 +15,10 @@ import { isScalarType, isUnionType, isWrappingType, + Kind, + SelectionSetNode, TypeNode, + visit, } from 'graphql'; import { PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; import { convertFactory, ConvertFn } from '@graphql-codegen/visitor-plugin-common'; @@ -64,6 +70,7 @@ function constructType( config: UrqlGraphCacheConfig, nullable = true, allowString = false, + useOutputType = false, ): string { if (isListType(typeNode)) { return nullable @@ -74,19 +81,31 @@ function constructType( config, false, allowString, + useOutputType, )}>>` - : `Array<${constructType(typeNode.ofType, schema, convertName, config, false, allowString)}>`; + : `Array<${constructType(typeNode.ofType, schema, convertName, config, false, allowString, useOutputType)}>`; } if (isNonNullType(typeNode)) { - return constructType(typeNode.ofType, schema, convertName, config, false, allowString); + return constructType( + typeNode.ofType, + schema, + convertName, + config, + false, + allowString, + useOutputType, + ); } const type = schema.getType(typeNode.name); if (isScalarType(type)) { + const scalarType = useOutputType + ? `Scalars['${type.name}']['output']` + : `Scalars['${type.name}']`; return nullable - ? `Maybe` - : `Scalars['${type.name}']${allowString ? ' | string' : ''}`; + ? `Maybe<${scalarType}${allowString ? ' | string' : ''}>` + : `${scalarType}${allowString ? ' | string' : ''}`; } const tsTypeName = convertName(typeNode.name, { @@ -248,33 +267,733 @@ function getRootUpdatersConfig( }; } +interface MutationFieldSelection { + mutationName: string; + selectionSets: SelectionSetNode[]; +} + +const EMPTY_SELECTION_SET: SelectionSetNode = { + kind: Kind.SELECTION_SET, + selections: [], +}; + +/** + * GraphQLドキュメントからFragment定義を収集します。 + */ +function collectFragmentDefinitions( + documents: Types.DocumentFile[], +): Map { + const fragments = new Map(); + + documents.forEach(doc => { + if (!doc.document) return; + + visit(doc.document, { + FragmentDefinition(node) { + fragments.set(node.name.value, node); + }, + }); + }); + + return fragments; +} + +/** + * 選択セット内の重複フィールドを統合します。 + * 同じ名前のフィールドが複数ある場合、最初の1つだけを残します。 + * ネストした選択セットがある場合は、それらもマージされます。 + */ +function deduplicateFields( + selections: Array, +): Array { + const fieldMap = new Map(); + const nonFieldSelections: Array = []; + + selections.forEach(selection => { + if (selection.kind === 'Field') { + const fieldName = selection.name.value; + const existingField = fieldMap.get(fieldName); + + if (existingField && existingField.kind === 'Field') { + // 既存のフィールドがある場合、選択セットをマージ + if (selection.selectionSet && existingField.selectionSet) { + const mergedSelections = [ + ...existingField.selectionSet.selections, + ...selection.selectionSet.selections, + ]; + const deduplicatedMerged = deduplicateFields(mergedSelections); + + fieldMap.set(fieldName, { + ...existingField, + selectionSet: { + ...existingField.selectionSet, + selections: deduplicatedMerged, + }, + }); + } else if (selection.selectionSet && !existingField.selectionSet) { + // 新しいフィールドに選択セットがあり、既存のフィールドにない場合 + fieldMap.set(fieldName, selection); + } + // それ以外の場合は既存のフィールドを保持 + } else { + // 新しいフィールドの場合 + fieldMap.set(fieldName, selection); + } + } else { + // Field以外の選択(InlineFragmentなど)はそのまま保持 + nonFieldSelections.push(selection); + } + }); + + return [...Array.from(fieldMap.values()), ...nonFieldSelections]; +} + +/** + * 選択セットを展開し、Fragment spreadとインラインFragmentを解決します。 + * インラインFragmentの場合、型条件情報も保持します。 + */ +function expandSelectionSet( + selectionSet: SelectionSetNode, + fragments: Map, + schema: GraphQLSchema, + parentTypeName?: string, + visitedFragments: Set = new Set(), +): SelectionSetNode { + const expandedSelections: Array<(typeof selectionSet.selections)[0]> = []; + + selectionSet.selections.forEach(selection => { + switch (selection.kind) { + case 'Field': { + if (selection.selectionSet) { + // ネストした選択セットも再帰的に展開 + expandedSelections.push({ + ...selection, + selectionSet: expandSelectionSet( + selection.selectionSet, + fragments, + schema, + parentTypeName, + visitedFragments, + ), + }); + } else { + expandedSelections.push(selection); + } + break; + } + case 'FragmentSpread': { + // 循環参照をチェック + if (visitedFragments.has(selection.name.value)) { + // 循環参照が検出された場合は、Fragmentの展開を停止 + break; + } + + // Fragment spreadを展開 + const fragmentDef = fragments.get(selection.name.value); + if (fragmentDef) { + const newVisitedFragments = new Set(visitedFragments); + newVisitedFragments.add(selection.name.value); + + const expandedFragment = expandSelectionSet( + fragmentDef.selectionSet, + fragments, + schema, + parentTypeName, + newVisitedFragments, + ); + expandedSelections.push(...expandedFragment.selections); + } + break; + } + case 'InlineFragment': { + // インラインFragmentはそのまま保持(型条件情報を失わないため) + const expandedInlineFragment = expandSelectionSet( + selection.selectionSet, + fragments, + schema, + selection.typeCondition?.name.value, + visitedFragments, + ); + expandedSelections.push({ + ...selection, + selectionSet: expandedInlineFragment, + }); + break; + } + } + }); + + // 重複フィールドを統合 + const deduplicatedSelections = deduplicateFields(expandedSelections); + + return { + ...selectionSet, + selections: deduplicatedSelections, + }; +} + +/** + * 選択セット内のフィールドを正規化します。 + * フィールドをスキーマ定義順でソートすることで、選択順序が異なっても同じ型として認識できるようにします。 + */ +function normalizeSelectionSet( + selectionSet: SelectionSetNode, + schema: GraphQLSchema, + baseTypeName: string, +): SelectionSetNode { + const cleanTypeName = baseTypeName.replace(/WithTypename<|>/g, '').replace(/Maybe<|>/g, ''); + const baseType = schema.getType(cleanTypeName); + + if (!baseType || !isObjectType(baseType)) { + return selectionSet; + } + + const schemaFields = baseType.getFields(); + const fieldOrder = Object.keys(schemaFields); + + const normalizedSelections = [...selectionSet.selections] + .filter(selection => selection.kind === 'Field') + .sort((a, b) => { + if (a.kind !== 'Field' || b.kind !== 'Field') return 0; + + const aIndex = fieldOrder.indexOf(a.name.value); + const bIndex = fieldOrder.indexOf(b.name.value); + + // スキーマに定義されていないフィールドは末尾に + if (aIndex === -1 && bIndex === -1) return 0; + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + + return aIndex - bIndex; + }) + .map(selection => { + if (selection.kind === 'Field' && selection.selectionSet) { + // ネストした選択セットも再帰的に正規化 + let nestedTypeName = baseTypeName; + const field = schemaFields[selection.name.value]; + if (field) { + let unwrappedType = field.type; + while (isNonNullType(unwrappedType) || isListType(unwrappedType)) { + unwrappedType = unwrappedType.ofType; + } + if (isObjectType(unwrappedType)) { + nestedTypeName = unwrappedType.name; + } + } + + return { + ...selection, + selectionSet: normalizeSelectionSet(selection.selectionSet, schema, nestedTypeName), + }; + } + return selection; + }); + + return { + ...selectionSet, + selections: normalizedSelections, + }; +} + +/** + * 選択セットから正規化されたキーを生成します。 + * このキーは選択フィールドの構造を表し、順序に依存しません。 + */ +function getSelectionSetKey( + selectionSet: SelectionSetNode, + schema: GraphQLSchema, + baseTypeName: string, +): string { + const normalizedSet = normalizeSelectionSet(selectionSet, schema, baseTypeName); + + const fieldKeys = normalizedSet.selections + .filter(selection => selection.kind === 'Field') + .map(selection => { + if (selection.kind !== 'Field') return ''; + + const fieldName = selection.name.value; + if (selection.selectionSet) { + // ネストした型名を取得 + const cleanTypeName = baseTypeName.replace(/WithTypename<|>/g, '').replace(/Maybe<|>/g, ''); + const baseType = schema.getType(cleanTypeName); + let nestedTypeName = baseTypeName; + + if (baseType && isObjectType(baseType)) { + const field = baseType.getFields()[fieldName]; + if (field) { + let unwrappedType = field.type; + while (isNonNullType(unwrappedType) || isListType(unwrappedType)) { + unwrappedType = unwrappedType.ofType; + } + if (isObjectType(unwrappedType)) { + nestedTypeName = unwrappedType.name; + } + } + } + + const nestedKey = getSelectionSetKey(selection.selectionSet, schema, nestedTypeName); + return `${fieldName}{${nestedKey}}`; + } + return fieldName; + }) + .join(','); + + return fieldKeys; +} + +/** + * GraphQLドキュメントからMutationの選択セットを抽出します。 + * + * この関数は、プロジェクト内で使用されているすべてのMutationオペレーションを解析し、 + * 各Mutationフィールドで実際に選択されているフィールド(selectionSet)を取得します。 + * 同じMutationに対して複数の異なる選択セットがある場合、それらを配列として保持します。 + * + * @example + * ```graphql + * mutation UpdateUserMutation($id: ID!, $name: String!) { + * updateUser(id: $id, name: $name) { + * id + * name + * profile { + * bio + * } + * } + * } + * ``` + * + * 上記のクエリからは以下の情報が抽出されます: + * - mutationName: "updateUser" + * - selectionSets: [{ id, name, profile { bio } }] の構造 + * + * @param documents - GraphQL Code Generatorから渡されるドキュメントファイルの配列 + * @returns Mutationフィールド名をキーとし、そのMutationの選択情報を値とするMap + */ +function extractMutationSelections( + documents: Types.DocumentFile[], + schema: GraphQLSchema, +): Map { + const mutationSelections = new Map(); + + // Fragment定義を収集 + const fragments = collectFragmentDefinitions(documents); + + documents.forEach(doc => { + if (!doc.document) return; + + visit(doc.document, { + OperationDefinition(node) { + if (node.operation !== 'mutation') return; + + node.selectionSet.selections + .filter(selection => selection.kind === 'Field') + .forEach((selection: FieldNode) => { + const mutationName = selection.name.value; + let currentSelectionSet = selection.selectionSet ?? EMPTY_SELECTION_SET; + + // Fragment展開を適用 + currentSelectionSet = expandSelectionSet(currentSelectionSet, fragments, schema); + + const existing = mutationSelections.get(mutationName); + + if (existing) { + // 既存のMutationに新しい選択セットを追加 + existing.selectionSets.push(currentSelectionSet); + } else { + // 新しいMutationを追加 + mutationSelections.set(mutationName, { + mutationName, + selectionSets: [currentSelectionSet], + }); + } + }); + }, + }); + }); + + return mutationSelections; +} + +function buildOptimisticReturnType( + selectionSet: SelectionSetNode, + baseTypeName: string, + schema: GraphQLSchema, + convertName: ConvertFn, + config: UrqlGraphCacheConfig, +): string { + if (selectionSet.selections.length === 0) { + return baseTypeName; + } + + // インラインFragmentが含まれている場合は、別の処理が必要 + const hasInlineFragments = selectionSet.selections.some( + selection => selection.kind === 'InlineFragment', + ); + + if (hasInlineFragments) { + return buildOptimisticReturnTypeWithInlineFragments( + selectionSet, + baseTypeName, + schema, + convertName, + config, + ); + } + + // 選択セットを正規化(フィールドをスキーマ定義順でソート) + const normalizedSelectionSet = normalizeSelectionSet(selectionSet, schema, baseTypeName); + const selectedFields: string[] = []; + + // 実際の型名を取得してリテラル型として使用 + const cleanTypeName = baseTypeName.replace(/WithTypename<|>/g, '').replace(/Maybe<|>/g, ''); + const literalTypeName = cleanTypeName.split(/TypeSuffix|Suffix$/)[0].replace(/^Prefix/, ''); // prefixとsuffixを除去 + + // GraphCacheでは常に__typenameが必要なので最初に追加(リテラル型として) + selectedFields.push(`__typename: '${literalTypeName}'`); + + normalizedSelectionSet.selections.forEach(selection => { + if (selection.kind === 'Field') { + const fieldName = selection.name.value; + + // __typenameは既に追加済みなのでスキップ + if (fieldName === '__typename') { + return; + } + + // フィールドの型情報を取得 + const cleanTypeName = baseTypeName.replace(/WithTypename<|>/g, '').replace(/Maybe<|>/g, ''); + const baseType = schema.getType(cleanTypeName); + if (baseType && isObjectType(baseType)) { + const field = baseType.getFields()[fieldName]; + if (field) { + // ネストした選択がある場合は再帰的に処理 + if (selection.selectionSet) { + let unwrappedType = field.type; + let isNullable = true; + let isArray = false; + + // GraphQLの型をアンラップして、リスト型とNonNull型の情報を保持 + if (isNonNullType(unwrappedType)) { + isNullable = false; + unwrappedType = unwrappedType.ofType; + } + + if (isListType(unwrappedType)) { + isArray = true; + unwrappedType = unwrappedType.ofType; + + // リスト内の要素のNonNull情報も確認 + if (isNonNullType(unwrappedType)) { + unwrappedType = unwrappedType.ofType; + } + } + + if (isObjectType(unwrappedType)) { + const nestedTypeName = convertName(unwrappedType.name, { + prefix: config.typesPrefix, + suffix: config.typesSuffix, + }); + const optimizedNestedType = buildOptimisticReturnType( + selection.selectionSet, + `WithTypename<${nestedTypeName}>`, + schema, + convertName, + config, + ); + + // リスト型の包装を適用 + let finalType = optimizedNestedType; + if (isArray) { + finalType = `Array<${finalType}>`; + } + if (isNullable) { + finalType = `Maybe<${finalType}>`; + } + + selectedFields.push(`${fieldName}: ${finalType}`); + } else { + // オブジェクト型ではない場合は通常の型生成を使用 + const fieldType = constructType( + field.type, + schema, + convertName, + config, + true, // nullableを正しく判定させる + false, + true, + ); + selectedFields.push(`${fieldName}: ${fieldType}`); + } + } else { + // 選択セットがない場合は通常の型生成を使用 + const fieldType = constructType( + field.type, + schema, + convertName, + config, + true, // nullableを正しく判定させる + false, + true, + ); + selectedFields.push(`${fieldName}: ${fieldType}`); + } + } + } + } + }); + + if (selectedFields.length === 0) { + return baseTypeName; + } + + return `{ ${selectedFields.join(', ')} }`; +} + +/** + * インラインFragmentを含む選択セットから最適化された型を生成します。 + */ +function buildOptimisticReturnTypeWithInlineFragments( + selectionSet: SelectionSetNode, + baseTypeName: string, + schema: GraphQLSchema, + convertName: ConvertFn, + config: UrqlGraphCacheConfig, +): string { + const cleanTypeName = baseTypeName.replace(/WithTypename<|>/g, '').replace(/Maybe<|>/g, ''); + const baseType = schema.getType(cleanTypeName); + + if (!baseType || (!isInterfaceType(baseType) && !isUnionType(baseType))) { + // インターフェース型やユニオン型でない場合は、インラインFragmentを無視して通常のフィールドのみ処理 + const normalSelections = selectionSet.selections.filter( + selection => selection.kind === 'Field', + ); + const normalSelectionSet: SelectionSetNode = { + ...selectionSet, + selections: normalSelections, + }; + + // 無限再帰を避けるために、直接フィールド処理を行う + const selectedFields: string[] = [`__typename: '${cleanTypeName}'`]; + + normalSelectionSet.selections.forEach(selection => { + if (selection.kind === 'Field' && selection.name.value !== '__typename') { + const fieldName = selection.name.value; + + if (isObjectType(baseType)) { + const field = baseType.getFields()[fieldName]; + if (field) { + const fieldType = constructType( + field.type, + schema, + convertName, + config, + true, + false, + true, + ); + selectedFields.push(`${fieldName}: ${fieldType}`); + } + } + } + }); + + return `{ ${selectedFields.join(', ')} }`; + } + + // 共通フィールドを収集 + const commonFields: { name: string; type: string }[] = []; + selectionSet.selections.forEach(selection => { + if (selection.kind === 'Field') { + const fieldName = selection.name.value; + if (fieldName === '__typename') return; + + // インターフェース型の場合のみ共通フィールドを処理 + if (isInterfaceType(baseType)) { + const field = baseType.getFields()[fieldName]; + if (field) { + const fieldType = constructType( + field.type, + schema, + convertName, + config, + true, + false, + true, + ); + commonFields.push({ name: fieldName, type: fieldType }); + } + } + } + }); + + // インラインFragmentごとに具体的な型を生成 + const inlineFragments = selectionSet.selections.filter( + selection => selection.kind === 'InlineFragment', + ) as InlineFragmentNode[]; + const typeSpecificVariants: string[] = []; + + for (const inlineFragment of inlineFragments) { + if (inlineFragment.typeCondition) { + const typeName = inlineFragment.typeCondition.name.value; + const concreteType = schema.getType(typeName); + + if (concreteType && isObjectType(concreteType)) { + const typeFields: string[] = []; + typeFields.push(`__typename: '${typeName}'`); + + // 共通フィールドを追加 + commonFields.forEach(field => { + typeFields.push(`${field.name}: ${field.type}`); + }); + + // インラインFragment内のフィールドを追加 + inlineFragment.selectionSet.selections.forEach(selection => { + if (selection.kind === 'Field') { + const fieldName = selection.name.value; + if (fieldName === '__typename') return; + + const field = concreteType.getFields()[fieldName]; + if (field) { + const fieldType = constructType( + field.type, + schema, + convertName, + config, + true, + false, + true, + ); + typeFields.push(`${fieldName}: ${fieldType}`); + } + } + }); + + typeSpecificVariants.push(`{ ${typeFields.join(', ')} }`); + } + } + } + + if (typeSpecificVariants.length === 0) { + return baseTypeName; + } + + return typeSpecificVariants.join(' | '); +} + +function buildOptimisticUnionType( + selectionSets: SelectionSetNode[], + baseTypeName: string, + schema: GraphQLSchema, + convertName: ConvertFn, + config: UrqlGraphCacheConfig, +): string { + if (selectionSets.length === 0) { + return baseTypeName; + } + + if (selectionSets.length === 1) { + return buildOptimisticReturnType(selectionSets[0], baseTypeName, schema, convertName, config); + } + + // 正規化されたキーを使用して重複する選択セットを除去 + const uniqueSelectionSets = new Map(); + + selectionSets.forEach(selectionSet => { + const key = getSelectionSetKey(selectionSet, schema, baseTypeName); + if (!uniqueSelectionSets.has(key)) { + uniqueSelectionSets.set(key, selectionSet); + } + }); + + // 一意な選択セットから型を生成 + const types = Array.from(uniqueSelectionSets.values()).map(selectionSet => + buildOptimisticReturnType(selectionSet, baseTypeName, schema, convertName, config), + ); + + if (types.length === 1) { + return types[0]; + } + + return types.map(type => `| ${type}`).join(' '); +} + function getOptimisticUpdatersConfig( schema: GraphQLSchema, + documents: Types.DocumentFile[], convertName: ConvertFn, config: UrqlGraphCacheConfig, ): string[] | null { const mutationType = schema.getMutationType(); - if (mutationType) { - const optimistic: string[] = []; + if (!mutationType) return null; - Object.values(mutationType.getFields()).forEach(field => { - const argsName = field.args.length - ? convertName(`${capitalize(mutationType.name)}${capitalize(field.name)}Args`, { + const optimistic: string[] = []; + + // 型の最適化が有効な場合のみMutation選択セットを抽出 + const mutationSelections = config.optimizeOptimisticTypes + ? extractMutationSelections(documents, schema) + : new Map(); + + // すべてのMutationフィールドを処理 + Object.values(mutationType.getFields()).forEach(field => { + const argsName = field.args.length + ? convertName(`${capitalize(mutationType.name)}${capitalize(field.name)}Args`, { + prefix: config.typesPrefix, + suffix: config.typesSuffix, + }) + : 'Record'; + + let outputType = constructType(field.type, schema, convertName, config); + + // 型の最適化が有効で、選択情報がある場合 + if (config.optimizeOptimisticTypes) { + const selection = mutationSelections.get(field.name); + + if (selection && selection.selectionSets.length > 0) { + let unwrappedType = field.type; + // GraphQLの型をアンラップして実際の型を取得 + while (isNonNullType(unwrappedType) || isListType(unwrappedType)) { + unwrappedType = unwrappedType.ofType; + } + + if ( + isObjectType(unwrappedType) || + isInterfaceType(unwrappedType) || + isUnionType(unwrappedType) + ) { + const baseTypeName = convertName(unwrappedType.name, { prefix: config.typesPrefix, suffix: config.typesSuffix, - }) - : 'Record'; - const outputType = constructType(field.type, schema, convertName, config); - optimistic.push( - `${field.name}?: GraphCacheOptimisticMutationResolver<` + - `${argsName}, ` + - `${outputType}>`, - ); - }); + }); - return optimistic; - } - return null; + const partialType = buildOptimisticUnionType( + selection.selectionSets, + `WithTypename<${baseTypeName}>`, + schema, + convertName, + config, + ); + + // NonNullやListの包装を維持 + if (isNonNullType(field.type)) { + if (isListType(field.type.ofType)) { + outputType = `Array<${partialType}>`; + } else { + outputType = partialType; + } + } else if (isListType(field.type)) { + outputType = `Maybe>`; + } else { + outputType = `Maybe<${partialType}>`; + } + } + } + } + + optimistic.push( + `${field.name}?: GraphCacheOptimisticMutationResolver<` + `${argsName}, ` + `${outputType}>`, + ); + }); + + return optimistic.length > 0 ? optimistic : null; } function getImports(config: UrqlGraphCacheConfig): string { @@ -290,7 +1009,7 @@ function getImports(config: UrqlGraphCacheConfig): string { export const plugin: PluginFunction = ( schema: GraphQLSchema, - _documents, + documents, config, ) => { const convertName = convertFactory(config); @@ -299,7 +1018,7 @@ export const plugin: PluginFunction { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + createPost(title: String!, content: String!): Post! + } + + type User { + id: ID! + name: String! + profile: Profile + } + + type Profile { + bio: String + } + + type Post { + id: ID! + title: String! + } + `); + + const updateUserMutation = parse(` + mutation UpdateUserMutation($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + profile { + bio + } + } + } + `); + + const createPostMutation = parse(` + mutation CreatePostMutation($title: String!, $content: String!) { + createPost(title: $title, content: $content) { + id + title + } + } + `); + + const documents = [ + { location: 'updateUser.graphql', document: updateUserMutation }, + { location: 'createPost.graphql', document: createPostMutation }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver }> }>", + ); + expect(result).toContain( + "createPost?: GraphCacheOptimisticMutationResolver", + ); + }); + + it('Should use full types when optimizeOptimisticTypes is disabled', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + } + `); + + const updateUserMutation = parse(` + mutation UpdateUserMutation($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `); + + const documents = [{ location: 'updateUser.graphql', document: updateUserMutation }]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: false }), + ]); + + expect(result).toContain( + 'updateUser?: GraphCacheOptimisticMutationResolver>', + ); + }); + + it('Should handle mutations without documents when optimizeOptimisticTypes is enabled', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + } + `); + + const result = mergeOutputs([await plugin(schema, [], { optimizeOptimisticTypes: true })]); + + expect(result).toContain( + 'updateUser?: GraphCacheOptimisticMutationResolver>', + ); + }); + + it('Should handle multiple documents with different selections for the same mutation', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + email: String! + age: Int + profile: Profile + } + + type Profile { + bio: String + avatar: String + } + `); + + const updateUserMutation1 = parse(` + mutation UpdateUserMutation1($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `); + + const updateUserMutation2 = parse(` + mutation UpdateUserMutation2($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + email + profile { + bio + avatar + } + } + } + `); + + const updateUserMutation3 = parse(` + mutation UpdateUserMutation3($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + age + } + } + `); + + const documents = [ + { location: 'updateUser1.graphql', document: updateUserMutation1 }, + { location: 'updateUser2.graphql', document: updateUserMutation2 }, + { location: 'updateUser3.graphql', document: updateUserMutation3 }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver, avatar: Maybe }> } | { __typename: 'User', id: Scalars['ID']['output'], age: Maybe }>", + ); + }); + + it('Should deduplicate identical selections from different documents for the same mutation', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + email: String! + } + `); + + const updateUserMutation1 = parse(` + mutation UpdateUserMutation1($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `); + + const updateUserMutation2 = parse(` + mutation UpdateUserMutation2($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `); + + const updateUserMutation3 = parse(` + mutation UpdateUserMutation3($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + email + } + } + `); + + const documents = [ + { location: 'updateUser1.graphql', document: updateUserMutation1 }, + { location: 'updateUser2.graphql', document: updateUserMutation2 }, + { location: 'updateUser3.graphql', document: updateUserMutation3 }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver", + ); + expect(result).not.toContain( + "| { __typename: 'User', id: Scalars['ID']['output'], name: Scalars['String']['output'] } | { __typename: 'User', id: Scalars['ID']['output'], name: Scalars['String']['output'] }", + ); + }); + + it('Should deduplicate selections with different field orders', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + } + `); + + const updateUserMutation1 = parse(` + mutation UpdateUserMutation1($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + } + } + `); + + const updateUserMutation2 = parse(` + mutation UpdateUserMutation2($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + name + id + } + } + `); + + const documents = [ + { location: 'updateUser1.graphql', document: updateUserMutation1 }, + { location: 'updateUser2.graphql', document: updateUserMutation2 }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver", + ); + expect(result).not.toContain( + "| { __typename: 'User', id: Scalars['ID']['output'], name: Scalars['String']['output'] } | { __typename: 'User', name: Scalars['String']['output'], id: Scalars['ID']['output'] }", + ); + }); + + it('Should handle list type fields correctly in optimized types', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUserWithPosts(id: ID!): User! + updateTeam(id: ID!): Team! + } + + type User { + id: ID! + name: String! + posts: [Post!]! + tags: [String!] + optionalPosts: [Post] + } + + type Post { + id: ID! + title: String! + content: String + } + + type Team { + id: ID! + name: String! + members: [User!]! + projects: [String] + } + `); + + const updateUserMutation = parse(` + mutation UpdateUserMutation($id: ID!) { + updateUserWithPosts(id: $id) { + id + name + posts { + id + title + } + tags + optionalPosts { + id + content + } + } + } + `); + + const updateTeamMutation = parse(` + mutation UpdateTeamMutation($id: ID!) { + updateTeam(id: $id) { + id + name + members { + id + name + } + projects + } + } + `); + + const documents = [ + { location: 'updateUser.graphql', document: updateUserMutation }, + { location: 'updateTeam.graphql', document: updateTeamMutation }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // Non-null list of non-null objects + expect(result).toContain( + "updateUserWithPosts?: GraphCacheOptimisticMutationResolver, tags: Maybe>, optionalPosts: Maybe }>> }>", + ); + + // Non-null list of non-null objects with nested selection + expect(result).toContain( + "updateTeam?: GraphCacheOptimisticMutationResolver, projects: Maybe> }>", + ); + }); + + it('Should handle nested list selections correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateCompany(id: ID!): Company! + } + + type Company { + id: ID! + name: String! + departments: [Department!]! + } + + type Department { + id: ID! + name: String! + employees: [Employee] + projects: [Project!] + } + + type Employee { + id: ID! + name: String! + skills: [String!]! + } + + type Project { + id: ID! + title: String! + tags: [String] + } + `); + + const updateCompanyMutation = parse(` + mutation UpdateCompanyMutation($id: ID!) { + updateCompany(id: $id) { + id + name + departments { + id + name + employees { + id + name + skills + } + projects { + id + title + tags + } + } + } + } + `); + + const documents = [{ location: 'updateCompany.graphql', document: updateCompanyMutation }]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // 深いネストのリスト型が正しく処理されることを確認 + expect(result).toContain( + "updateCompany?: GraphCacheOptimisticMutationResolver }>>, projects: Maybe> }>> }> }>", + ); + }); + + it('Should handle union types in list fields correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateFeed(id: ID!): Feed! + } + + type Feed { + id: ID! + name: String! + items: [FeedItem!]! + } + + union FeedItem = Post | Video | Image + + type Post { + id: ID! + title: String! + content: String + } + + type Video { + id: ID! + title: String! + duration: Int + } + + type Image { + id: ID! + title: String! + url: String! + } + `); + + const updateFeedMutation = parse(` + mutation UpdateFeedMutation($id: ID!) { + updateFeed(id: $id) { + id + name + items { + ... on Post { + id + title + content + } + ... on Video { + id + title + duration + } + ... on Image { + id + title + url + } + } + } + } + `); + + const documents = [{ location: 'updateFeed.graphql', document: updateFeedMutation }]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // ユニオン型を含むリストが正しく処理されることを確認 + // 注意: この場合、ユニオン型の選択は現在の実装では完全に最適化されない可能性があります + expect(result).toContain('items: Array>'); + }); + + it('Should use literal types for __typename fields', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + createPost(title: String!): Post! + } + + type User { + id: ID! + name: String! + profile: Profile + } + + type Profile { + bio: String + } + + type Post { + id: ID! + title: String! + } + `); + + const updateUserMutation = parse(` + mutation UpdateUserMutation($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + id + name + profile { + bio + } + } + } + `); + + const createPostMutation = parse(` + mutation CreatePostMutation($title: String!) { + createPost(title: $title) { + id + title + } + } + `); + + const documents = [ + { location: 'updateUser.graphql', document: updateUserMutation }, + { location: 'createPost.graphql', document: createPostMutation }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // __typename should be literal types, not string + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver }> }>", + ); + expect(result).toContain( + "createPost?: GraphCacheOptimisticMutationResolver", + ); + }); + + it('Should handle fragments in mutation selections correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + createPost(authorId: ID!, title: String!): Post! + } + + type User { + id: ID! + name: String! + email: String! + profile: Profile + } + + type Profile { + bio: String + avatar: String + website: String + } + + type Post { + id: ID! + title: String! + content: String + author: User! + } + `); + + const mutationWithFragments = parse(` + fragment UserBasicInfo on User { + id + name + email + } + + fragment ProfileInfo on Profile { + bio + avatar + } + + fragment PostInfo on Post { + id + title + content + } + + mutation UpdateUserMutation($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + ...UserBasicInfo + profile { + ...ProfileInfo + website + } + } + } + + mutation CreatePostMutation($authorId: ID!, $title: String!) { + createPost(authorId: $authorId, title: $title) { + ...PostInfo + author { + ...UserBasicInfo + } + } + } + `); + + const documents = [{ location: 'mutations.graphql', document: mutationWithFragments }]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // Fragmentが展開されて最適化された型が生成されることを確認 + expect(result).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver, avatar: Maybe, website: Maybe }> }>", + ); + expect(result).toContain( + "createPost?: GraphCacheOptimisticMutationResolver, author: { __typename: 'User', id: Scalars['ID']['output'], name: Scalars['String']['output'], email: Scalars['String']['output'] } }>", + ); + }); + + it('Should handle inline fragments correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, role: String!): User! + } + + interface User { + id: ID! + name: String! + email: String! + } + + type AdminUser implements User { + id: ID! + name: String! + email: String! + adminLevel: Int! + permissions: [String!]! + } + + type RegularUser implements User { + id: ID! + name: String! + email: String! + subscriptionLevel: String + } + `); + + const mutationWithInlineFragments = parse(` + mutation UpdateUserMutation($id: ID!, $role: String!) { + updateUser(id: $id, role: $role) { + id + name + email + ... on AdminUser { + adminLevel + permissions + } + ... on RegularUser { + subscriptionLevel + } + } + } + `); + + const documents = [{ location: 'updateUser.graphql', document: mutationWithInlineFragments }]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // インラインFragmentが正しく処理されることを確認 + expect(result).toContain( + 'updateUser?: GraphCacheOptimisticMutationResolver } | { __typename: 'RegularUser', id: Scalars['ID']['output'], name: Scalars['String']['output'], email: Scalars['String']['output'], subscriptionLevel: Maybe }", + ); + }); + + it('Should handle nested fragments correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateCompany(id: ID!): Company! + } + + type Company { + id: ID! + name: String! + departments: [Department!]! + } + + type Department { + id: ID! + name: String! + manager: Employee + employees: [Employee!]! + } + + type Employee { + id: ID! + name: String! + email: String! + position: String + profile: EmployeeProfile + } + + type EmployeeProfile { + bio: String + skills: [String!]! + experience: Int + } + `); + + const mutationWithNestedFragments = parse(` + fragment EmployeeProfileInfo on EmployeeProfile { + bio + skills + experience + } + + fragment EmployeeBasicInfo on Employee { + id + name + email + position + } + + fragment EmployeeFullInfo on Employee { + ...EmployeeBasicInfo + profile { + ...EmployeeProfileInfo + } + } + + fragment DepartmentInfo on Department { + id + name + manager { + ...EmployeeBasicInfo + } + employees { + ...EmployeeFullInfo + } + } + + mutation UpdateCompanyMutation($id: ID!) { + updateCompany(id: $id) { + id + name + departments { + ...DepartmentInfo + } + } + } + `); + + const documents = [ + { location: 'updateCompany.graphql', document: mutationWithNestedFragments }, + ]; + + const result = mergeOutputs([ + await plugin(schema, documents, { optimizeOptimisticTypes: true }), + ]); + + // ネストしたFragmentが正しく展開されて最適化された型が生成されることを確認 + expect(result).toContain( + "updateCompany?: GraphCacheOptimisticMutationResolver }>, employees: Array<{ __typename: 'Employee', id: Scalars['ID']['output'], name: Scalars['String']['output'], email: Scalars['String']['output'], position: Maybe, profile: Maybe<{ __typename: 'EmployeeProfile', bio: Maybe, skills: Array, experience: Maybe }> }> }> }>", + ); + }); + + it('Should handle circular fragment references without stack overflow', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!): User! + } + + type User { + id: ID! + name: String! + friends: [User!]! + } + `); + + // 循環Fragment参照を含むクエリ + const mutationWithCircularFragments = parse(` + fragment UserInfo on User { + id + name + friends { + ...UserInfo + } + } + + mutation UpdateUserMutation($id: ID!) { + updateUser(id: $id) { + ...UserInfo + } + } + `); + + const documents = [{ location: 'updateUser.graphql', document: mutationWithCircularFragments }]; + + // スタックオーバーフローを起こさずに処理されることを確認 + const result = await plugin(schema, documents, { optimizeOptimisticTypes: true }); + expect(result).toBeDefined(); + }); + + it('Should handle mutual circular fragment references without stack overflow', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!): User! + } + + type User { + id: ID! + name: String! + friends: [User!]! + posts: [Post!]! + } + + type Post { + id: ID! + title: String! + author: User! + } + `); + + // 相互循環Fragment参照を含むクエリ(User ↔ Post ↔ User) + const mutationWithMutualCircularFragments = parse(` + fragment UserInfo on User { + id + name + posts { + ...PostInfo + } + } + + fragment PostInfo on Post { + id + title + author { + ...UserInfo + } + } + + mutation UpdateUserMutation($id: ID!) { + updateUser(id: $id) { + ...UserInfo + } + } + `); + + const documents = [ + { location: 'updateUser.graphql', document: mutationWithMutualCircularFragments }, + ]; + + // スタックオーバーフローを起こさずに処理されることを確認 + const result = await plugin(schema, documents, { optimizeOptimisticTypes: true }); + expect(result).toBeDefined(); + expect(result.content).toContain('updateUser?: GraphCacheOptimisticMutationResolver'); + }); + + it('Should handle complex multi-fragment circular references', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateCompany(id: ID!): Company! + } + + type Company { + id: ID! + name: String! + departments: [Department!]! + } + + type Department { + id: ID! + name: String! + company: Company! + employees: [Employee!]! + } + + type Employee { + id: ID! + name: String! + department: Department! + colleagues: [Employee!]! + } + `); + + // 3つのFragmentが複雑に相互参照するクエリ(Company → Department → Employee → Department → Company) + const mutationWithComplexCircularFragments = parse(` + fragment CompanyInfo on Company { + id + name + departments { + ...DepartmentInfo + } + } + + fragment DepartmentInfo on Department { + id + name + company { + id + name + } + employees { + ...EmployeeInfo + } + } + + fragment EmployeeInfo on Employee { + id + name + department { + ...DepartmentInfo + } + colleagues { + id + name + department { + id + name + } + } + } + + mutation UpdateCompanyMutation($id: ID!) { + updateCompany(id: $id) { + ...CompanyInfo + } + } + `); + + const documents = [ + { location: 'updateCompany.graphql', document: mutationWithComplexCircularFragments }, + ]; + + // スタックオーバーフローを起こさずに処理されることを確認 + const result = await plugin(schema, documents, { optimizeOptimisticTypes: true }); + expect(result).toBeDefined(); + expect(result.content).toContain('updateCompany?: GraphCacheOptimisticMutationResolver'); + }); + + it('Should not generate duplicate properties when fragments and direct fields overlap', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!, name: String!): User! + } + + type User { + id: ID! + name: String! + email: String! + profile: Profile + } + + type Profile { + bio: String + avatar: String + } + `); + + const documents = [ + { + location: 'test-mutation.graphql', + document: parse(/* GraphQL */ ` + fragment UserBasicInfo on User { + id + name + email + } + + mutation UpdateUserMutation($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { + ...UserBasicInfo + id + name + profile { + bio + } + } + } + `), + }, + ]; + + const result = await plugin(schema, documents, { optimizeOptimisticTypes: true }); + + // 期待される型定義:重複するフィールドが統合されること + expect(result.content).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver }> }>", + ); + }); + + it('Should handle nested field deduplication correctly when fragments and direct fields overlap', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Mutation { + updateUser(id: ID!): User! + } + + type User { + id: ID! + profile: Profile + } + + type Profile { + bio: String + avatar: String + } + `); + + const documents = [ + { + location: 'test-nested-mutation.graphql', + document: parse(/* GraphQL */ ` + fragment UserProfileInfo on User { + profile { + bio + avatar + } + } + + mutation UpdateUserMutation($id: ID!) { + updateUser(id: $id) { + id + ...UserProfileInfo + profile { + bio + } + } + } + `), + }, + ]; + + const result = await plugin(schema, documents, { optimizeOptimisticTypes: true }); + + // 期待される型定義:ネストされたフィールドでもprofile.bioが重複せずに統合されること + expect(result.content).toContain( + "updateUser?: GraphCacheOptimisticMutationResolver, avatar: Maybe }> }>", + ); + }); });