diff --git a/.changeset/afraid-buckets-fly.md b/.changeset/afraid-buckets-fly.md new file mode 100644 index 0000000000..03d9d680f8 --- /dev/null +++ b/.changeset/afraid-buckets-fly.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/near-operation-file-preset': minor +--- + +Add `filePerOperation` config to generate filename based on named operation or fragment, instead of source filename diff --git a/.vscode/settings.json b/.vscode/settings.json index da75431998..a115c697a1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,7 +17,7 @@ "coverage": true, "npm": true }, - "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.tsdk.path": "node_modules/typescript/lib", "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[typescript]": { diff --git a/packages/presets/near-operation-file/src/fragment-resolver.ts b/packages/presets/near-operation-file/src/fragment-resolver.ts index be73984c92..8516778aa0 100644 --- a/packages/presets/near-operation-file/src/fragment-resolver.ts +++ b/packages/presets/near-operation-file/src/fragment-resolver.ts @@ -89,7 +89,7 @@ function buildFragmentRegistry( const registry = documents.reduce((prev: FragmentRegistry, documentRecord) => { const fragments: FragmentDefinitionNode[] = documentRecord.document.definitions.filter( d => d.kind === Kind.FRAGMENT_DEFINITION, - ) as FragmentDefinitionNode[]; + ); for (const fragment of fragments) { const schemaType = schemaObject.getType(fragment.typeCondition.name.value); @@ -100,8 +100,12 @@ function buildFragmentRegistry( ); } + const filePath = generateFilePath({ + location: documentRecord.location, + meta: { operations: [], fragments: [fragment] }, + }); + const fragmentName = fragment.name.value; - const filePath = generateFilePath(documentRecord.location); const possibleTypes = getPossibleTypes(schemaObject, schemaType); const possibleTypeNames = possibleTypes.map(t => t.name); const imports = createFragmentImports(baseVisitor, fragment.name.value, possibleTypeNames); diff --git a/packages/presets/near-operation-file/src/index.ts b/packages/presets/near-operation-file/src/index.ts index ba826666b1..8d669ded90 100644 --- a/packages/presets/near-operation-file/src/index.ts +++ b/packages/presets/near-operation-file/src/index.ts @@ -177,7 +177,7 @@ export type NearOperationFileConfig = { folder?: string; /** * @description Optional, override the name of the import namespace used to import from the `baseTypesPath` file. - * @default Types + * @default Types (if `baseTypesPath` is set) * * @exampleMarkdown * ```ts filename="codegen.ts" {11} @@ -200,6 +200,30 @@ export type NearOperationFileConfig = { * ``` */ importTypesNamespace?: string; + + /** + * @description Optional, generates one file per operation, using the operation name as the filename. Note: if your documents are in `.graphql` files and there are multiple operations or fragments in a single file, the generated filename will be based on the first operation or fragment found. + * @default false + * + * @exampleMarkdown + * ```ts filename="codegen.ts" {11} + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * const config: CodegenConfig = { + * // ... + * generates: { + * 'path/to/file.ts': { + * preset: 'near-operation-file', + * presetConfig: { + * filePerOperation: true + * }, + * plugins: ['typescript-operations'], + * }, + * }, + * }; + * export default config; + * ``` + */ + filePerOperation?: boolean; }; export type FragmentNameToFile = { @@ -226,6 +250,7 @@ export const preset: Types.OutputPreset = { (options.presetConfig.baseTypesPath ? 'Types' : undefined); // When there is `baseTypesPath`, we assume there'd be a type import, so we default `importTypesNamespace` value to `Types` for convenience. const importAllFragmentsFrom: FragmentImportFromFn | string | null = options.presetConfig.importAllFragmentsFrom || null; + const filePerOperation = options.presetConfig.filePerOperation || false; const shouldAbsolute = !baseTypesPath.startsWith('~'); @@ -239,10 +264,20 @@ export const preset: Types.OutputPreset = { schemaObject, { baseDir, - generateFilePath(location: string) { + generateFilePath({ location, meta }) { const newFilePath = defineFilepathSubfolder(location, folder); - return appendFileNameToFilePath(newFilePath, fileName, extension); + let customFilename = fileName; + if (filePerOperation) { + // Note: If a file only has unnamed operations (i.e. undefined operation name) or fragment (i.e. undefined fragment name). + // In such case, the generated filename will be based on the source document file. + customFilename = + meta['operations'][0]?.name?.value || + meta['fragments'][0]?.name?.value || + customFilename; + } + + return appendFileNameToFilePath(newFilePath, customFilename, extension); }, schemaTypesSource: { path: shouldAbsolute ? join(options.baseOutputDir, baseTypesPath) : baseTypesPath, diff --git a/packages/presets/near-operation-file/src/resolve-document-imports.ts b/packages/presets/near-operation-file/src/resolve-document-imports.ts index af942ab782..4a27b647c4 100644 --- a/packages/presets/near-operation-file/src/resolve-document-imports.ts +++ b/packages/presets/near-operation-file/src/resolve-document-imports.ts @@ -1,4 +1,9 @@ -import { FragmentDefinitionNode, GraphQLSchema } from 'graphql'; +import { + GraphQLSchema, + Kind, + type FragmentDefinitionNode, + type OperationDefinitionNode, +} from 'graphql'; import { isUsingTypes, Types } from '@graphql-codegen/plugin-helpers'; import { FragmentImport, @@ -25,7 +30,13 @@ export type DocumentImportResolverOptions = { /** * Generates a target file path from the source `document.location` */ - generateFilePath: (location: string) => string; + generateFilePath: (params: { + location: string; + meta: { + operations: OperationDefinitionNode[]; + fragments: FragmentDefinitionNode[]; + }; + }) => string; /** * Schema base types source */ @@ -69,7 +80,22 @@ export function resolveDocumentImports( return documents.map(documentFile => { try { - const generatedFilePath = generateFilePath(documentFile.location); + const meta: { + operations: OperationDefinitionNode[]; + fragments: FragmentDefinitionNode[]; + } = { + operations: [], + fragments: [], + }; + for (const definition of documentFile.document.definitions) { + if (definition.kind === Kind.OPERATION_DEFINITION) { + meta.operations.push(definition); + } else if (definition.kind === Kind.FRAGMENT_DEFINITION) { + meta.fragments.push(definition); + } + } + const generatedFilePath = generateFilePath({ location: documentFile.location, meta }); + const importStatements: string[] = []; const { externalFragments, fragmentImports } = resolveFragments( generatedFilePath, diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.1.graphql.ts b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.1.graphql.ts new file mode 100644 index 0000000000..9b27303e90 --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.1.graphql.ts @@ -0,0 +1,23 @@ +export const user1a = /* GraphQL */ ` + query User1a { + user1a: user { + id + } + } +`; +export const user1b = /* GraphQL */ ` + query User1b { + user1b: user { + id + name + } + } +`; + +export const anon = /* GraphQL */ ` + query { + anon: user { + __typename + } + } +`; diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.2.graphql.ts b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.2.graphql.ts new file mode 100644 index 0000000000..07888a1b35 --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.2.graphql.ts @@ -0,0 +1,7 @@ +export const user2 = /* GraphQL */ ` + query User2 { + user2: user { + ...UserFragment3 + } + } +`; diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.3.graphql.ts b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.3.graphql.ts new file mode 100644 index 0000000000..42f9935afd --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.3.graphql.ts @@ -0,0 +1,5 @@ +export const userFragment3 = /* GraphQL */ ` + fragment UserFragment3 on User { + id + } +`; diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.4.graphql b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.4.graphql new file mode 100644 index 0000000000..c46d087117 --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.4.graphql @@ -0,0 +1,5 @@ +query User4($id: ID!) { + user4: user { + name + } +} diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.5.graphql.ts b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.5.graphql.ts new file mode 100644 index 0000000000..14e0308b4d --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.5.graphql.ts @@ -0,0 +1,13 @@ +export const userFragment5a = /* GraphQL */ ` + fragment UserFragment5a on User { + id + } +`; + +export const user5b = /* GraphQL */ ` + query User5b { + user1a: user { + id + } + } +`; diff --git a/packages/presets/near-operation-file/tests/fixtures/file-per-operation.6.graphql b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.6.graphql new file mode 100644 index 0000000000..b63a147f74 --- /dev/null +++ b/packages/presets/near-operation-file/tests/fixtures/file-per-operation.6.graphql @@ -0,0 +1,8 @@ +fragment UserFragment6 on User { + id +} +query User6 { + user6: user { + id + } +} diff --git a/packages/presets/near-operation-file/tests/near-operation-file.spec.ts b/packages/presets/near-operation-file/tests/near-operation-file.spec.ts index 2d03e2843f..577e4c988b 100644 --- a/packages/presets/near-operation-file/tests/near-operation-file.spec.ts +++ b/packages/presets/near-operation-file/tests/near-operation-file.spec.ts @@ -1413,6 +1413,114 @@ describe('near-operation-file preset', () => { " `); }); + + it('generates a file per operation (instead of file) when using filePerOperation:true', async () => { + const { result } = await executeCodegen({ + schema: /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: ID! + name: String! + } + `, + documents: path.join(__dirname, 'fixtures/file-per-operation.*.graphql*'), + generates: { + [__dirname]: { + preset, + presetConfig: { + filePerOperation: true, + }, + plugins: ['typescript-operations'], + }, + }, + }); + + expect(result.length).toBe(9); + + const file1a = result.find(file => file.filename.endsWith('User1a.generated.ts')); + expect(file1a.content).toMatchInlineSnapshot(` + "export type User1aQueryVariables = Exact<{ [key: string]: never; }>; + + + export type User1aQuery = { __typename?: 'Query', user1a?: { __typename?: 'User', id: string } | null }; + " + `); + + const file1b = result.find(file => file.filename.endsWith('User1b.generated.ts')); + expect(file1b.content).toMatchInlineSnapshot(` + "export type User1bQueryVariables = Exact<{ [key: string]: never; }>; + + + export type User1bQuery = { __typename?: 'Query', user1b?: { __typename?: 'User', id: string, name: string } | null }; + " + `); + + // Unnamed operations falls back to source doc filename + const file1c = result.find(file => + file.filename.endsWith('file-per-operation.1.graphql.generated.ts'), + ); + expect(file1c.content).toMatchInlineSnapshot(` + "export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>; + + + export type Unnamed_1_Query = { __typename?: 'Query', anon?: { __typename: 'User' } | null }; + " + `); + + const file2 = result.find(file => file.filename.endsWith('User2.generated.ts')); + expect(file2.content).toMatchInlineSnapshot(` + "export type User2QueryVariables = Exact<{ [key: string]: never; }>; + + + export type User2Query = { __typename?: 'Query', user2?: { __typename?: 'User', id: string } | null }; + " + `); + + const file3 = result.find(file => file.filename.endsWith('UserFragment3.generated.ts')); + expect(file3.content).toMatchInlineSnapshot(` + "export type UserFragment3Fragment = { __typename?: 'User', id: string }; + " + `); + + const file4 = result.find(file => file.filename.endsWith('User4.generated.ts')); + expect(file4.content).toMatchInlineSnapshot(` + "export type User4QueryVariables = Exact<{ + id: Scalars['ID']['input']; + }>; + + + export type User4Query = { __typename?: 'Query', user4?: { __typename?: 'User', name: string } | null }; + " + `); + + const file5a = result.find(file => file.filename.endsWith('UserFragment5a.generated.ts')); + expect(file5a.content).toMatchInlineSnapshot(` + "export type UserFragment5aFragment = { __typename?: 'User', id: string }; + " + `); + const file5b = result.find(file => file.filename.endsWith('User5b.generated.ts')); + expect(file5b.content).toMatchInlineSnapshot(` + "export type User5bQueryVariables = Exact<{ [key: string]: never; }>; + + + export type User5bQuery = { __typename?: 'Query', user1a?: { __typename?: 'User', id: string } | null }; + " + `); + + const file6 = result.find(file => file.filename.endsWith('User6.generated.ts')); + expect(file6.content).toMatchInlineSnapshot(` + "export type UserFragment6Fragment = { __typename?: 'User', id: string }; + + export type User6QueryVariables = Exact<{ [key: string]: never; }>; + + + export type User6Query = { __typename?: 'Query', user6?: { __typename?: 'User', id: string } | null }; + " + `); + }); }); const getFragmentImportsFromResult = (result: Types.GenerateOptions[], index = 0) =>