diff --git a/graphql/codegen/README.md b/graphql/codegen/README.md index 8f58f0e2a..95f5d54d1 100644 --- a/graphql/codegen/README.md +++ b/graphql/codegen/README.md @@ -1,4 +1,16 @@ -# @constructive-io/graphql-sdk +# @constructive-io/graphql-codegen + +

+ +

+ +

+ + + + + +

CLI-based GraphQL SDK generator for PostGraphile endpoints. Generate type-safe React Query hooks or a Prisma-like ORM client from your GraphQL schema. @@ -39,7 +51,7 @@ CLI-based GraphQL SDK generator for PostGraphile endpoints. Generate type-safe R ## Installation ```bash -pnpm add @constructive-io/graphql-sdk +pnpm add @constructive-io/graphql-codegen ``` ## Quick Start @@ -53,7 +65,7 @@ npx graphql-sdk init Creates a `graphql-sdk.config.ts` file: ```typescript -import { defineConfig } from '@constructive-io/graphql-sdk'; +import { defineConfig } from '@constructive-io/graphql-codegen'; export default defineConfig({ endpoint: 'https://api.example.com/graphql', diff --git a/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts b/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts new file mode 100644 index 000000000..b36741fb1 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/react-query-optional.test.ts @@ -0,0 +1,335 @@ +/** + * Tests for React Query optional flag in code generators + * + * Verifies that when reactQueryEnabled is false: + * - Query generators skip React Query imports and hooks + * - Mutation generators return null (since they require React Query) + * - Standalone fetch functions are still generated for queries + */ +import { generateListQueryHook, generateSingleQueryHook, generateAllQueryHooks } from '../../cli/codegen/queries'; +import { generateCreateMutationHook, generateUpdateMutationHook, generateDeleteMutationHook, generateAllMutationHooks } from '../../cli/codegen/mutations'; +import { generateCustomQueryHook, generateAllCustomQueryHooks } from '../../cli/codegen/custom-queries'; +import { generateCustomMutationHook, generateAllCustomMutationHooks } from '../../cli/codegen/custom-mutations'; +import type { CleanTable, CleanFieldType, CleanRelations, CleanOperation, CleanTypeRef, TypeRegistry } from '../../types/schema'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const fieldTypes = { + uuid: { gqlType: 'UUID', isArray: false } as CleanFieldType, + string: { gqlType: 'String', isArray: false } as CleanFieldType, + int: { gqlType: 'Int', isArray: false } as CleanFieldType, + datetime: { gqlType: 'Datetime', isArray: false } as CleanFieldType, +}; + +const emptyRelations: CleanRelations = { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [], +}; + +function createTable(partial: Partial & { name: string }): CleanTable { + return { + name: partial.name, + fields: partial.fields ?? [], + relations: partial.relations ?? emptyRelations, + query: partial.query, + inflection: partial.inflection, + constraints: partial.constraints, + }; +} + +const userTable = createTable({ + name: 'User', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'email', type: fieldTypes.string }, + { name: 'name', type: fieldTypes.string }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'users', + one: 'user', + create: 'createUser', + update: 'updateUser', + delete: 'deleteUser', + }, +}); + +function createTypeRef(kind: CleanTypeRef['kind'], name: string | null, ofType?: CleanTypeRef): CleanTypeRef { + return { kind, name, ofType }; +} + +const sampleQueryOperation: CleanOperation = { + name: 'currentUser', + kind: 'query', + args: [], + returnType: createTypeRef('OBJECT', 'User'), + description: 'Get the current authenticated user', +}; + +const sampleMutationOperation: CleanOperation = { + name: 'login', + kind: 'mutation', + args: [ + { name: 'email', type: createTypeRef('NON_NULL', null, createTypeRef('SCALAR', 'String')) }, + { name: 'password', type: createTypeRef('NON_NULL', null, createTypeRef('SCALAR', 'String')) }, + ], + returnType: createTypeRef('OBJECT', 'LoginPayload'), + description: 'Authenticate user', +}; + +const emptyTypeRegistry: TypeRegistry = new Map(); + +// ============================================================================ +// Tests - Query Generators with reactQueryEnabled: false +// ============================================================================ + +describe('Query generators with reactQueryEnabled: false', () => { + describe('generateListQueryHook', () => { + it('should not include React Query imports when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).not.toContain('@tanstack/react-query'); + expect(result.content).not.toContain('useQuery'); + expect(result.content).not.toContain('UseQueryOptions'); + }); + + it('should not include useXxxQuery hook when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).not.toContain('export function useUsersQuery'); + }); + + it('should not include prefetch function when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).not.toContain('export async function prefetchUsersQuery'); + }); + + it('should still include standalone fetch function when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).toContain('export async function fetchUsersQuery'); + }); + + it('should still include GraphQL document when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).toContain('usersQueryDocument'); + }); + + it('should still include query key factory when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).toContain('usersQueryKey'); + }); + + it('should update file header when disabled', () => { + const result = generateListQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).toContain('List query functions for User'); + }); + }); + + describe('generateSingleQueryHook', () => { + it('should not include React Query imports when disabled', () => { + const result = generateSingleQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).not.toContain('@tanstack/react-query'); + expect(result.content).not.toContain('useQuery'); + }); + + it('should not include useXxxQuery hook when disabled', () => { + const result = generateSingleQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).not.toContain('export function useUserQuery'); + }); + + it('should still include standalone fetch function when disabled', () => { + const result = generateSingleQueryHook(userTable, { reactQueryEnabled: false }); + expect(result.content).toContain('export async function fetchUserQuery'); + }); + }); + + describe('generateAllQueryHooks', () => { + it('should generate files without React Query when disabled', () => { + const results = generateAllQueryHooks([userTable], { reactQueryEnabled: false }); + expect(results.length).toBe(2); // list + single + for (const result of results) { + expect(result.content).not.toContain('@tanstack/react-query'); + expect(result.content).not.toContain('useQuery'); + } + }); + }); +}); + +// ============================================================================ +// Tests - Query Generators with reactQueryEnabled: true (default) +// ============================================================================ + +describe('Query generators with reactQueryEnabled: true (default)', () => { + describe('generateListQueryHook', () => { + it('should include React Query imports by default', () => { + const result = generateListQueryHook(userTable); + expect(result.content).toContain('@tanstack/react-query'); + expect(result.content).toContain('useQuery'); + }); + + it('should include useXxxQuery hook by default', () => { + const result = generateListQueryHook(userTable); + expect(result.content).toContain('export function useUsersQuery'); + }); + + it('should include prefetch function by default', () => { + const result = generateListQueryHook(userTable); + expect(result.content).toContain('export async function prefetchUsersQuery'); + }); + + it('should include standalone fetch function by default', () => { + const result = generateListQueryHook(userTable); + expect(result.content).toContain('export async function fetchUsersQuery'); + }); + }); +}); + +// ============================================================================ +// Tests - Mutation Generators with reactQueryEnabled: false +// ============================================================================ + +describe('Mutation generators with reactQueryEnabled: false', () => { + describe('generateCreateMutationHook', () => { + it('should return null when disabled', () => { + const result = generateCreateMutationHook(userTable, { reactQueryEnabled: false }); + expect(result).toBeNull(); + }); + }); + + describe('generateUpdateMutationHook', () => { + it('should return null when disabled', () => { + const result = generateUpdateMutationHook(userTable, { reactQueryEnabled: false }); + expect(result).toBeNull(); + }); + }); + + describe('generateDeleteMutationHook', () => { + it('should return null when disabled', () => { + const result = generateDeleteMutationHook(userTable, { reactQueryEnabled: false }); + expect(result).toBeNull(); + }); + }); + + describe('generateAllMutationHooks', () => { + it('should return empty array when disabled', () => { + const results = generateAllMutationHooks([userTable], { reactQueryEnabled: false }); + expect(results).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Tests - Mutation Generators with reactQueryEnabled: true (default) +// ============================================================================ + +describe('Mutation generators with reactQueryEnabled: true (default)', () => { + describe('generateCreateMutationHook', () => { + it('should return mutation file by default', () => { + const result = generateCreateMutationHook(userTable); + expect(result).not.toBeNull(); + expect(result!.content).toContain('useMutation'); + }); + }); + + describe('generateAllMutationHooks', () => { + it('should return mutation files by default', () => { + const results = generateAllMutationHooks([userTable]); + expect(results.length).toBeGreaterThan(0); + }); + }); +}); + +// ============================================================================ +// Tests - Custom Query Generators with reactQueryEnabled: false +// ============================================================================ + +describe('Custom query generators with reactQueryEnabled: false', () => { + describe('generateCustomQueryHook', () => { + it('should not include React Query imports when disabled', () => { + const result = generateCustomQueryHook({ + operation: sampleQueryOperation, + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(result.content).not.toContain('@tanstack/react-query'); + expect(result.content).not.toContain('useQuery'); + }); + + it('should not include useXxxQuery hook when disabled', () => { + const result = generateCustomQueryHook({ + operation: sampleQueryOperation, + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(result.content).not.toContain('export function useCurrentUserQuery'); + }); + + it('should still include standalone fetch function when disabled', () => { + const result = generateCustomQueryHook({ + operation: sampleQueryOperation, + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(result.content).toContain('export async function fetchCurrentUserQuery'); + }); + }); + + describe('generateAllCustomQueryHooks', () => { + it('should generate files without React Query when disabled', () => { + const results = generateAllCustomQueryHooks({ + operations: [sampleQueryOperation], + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(results.length).toBe(1); + expect(results[0].content).not.toContain('@tanstack/react-query'); + }); + }); +}); + +// ============================================================================ +// Tests - Custom Mutation Generators with reactQueryEnabled: false +// ============================================================================ + +describe('Custom mutation generators with reactQueryEnabled: false', () => { + describe('generateCustomMutationHook', () => { + it('should return null when disabled', () => { + const result = generateCustomMutationHook({ + operation: sampleMutationOperation, + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(result).toBeNull(); + }); + }); + + describe('generateAllCustomMutationHooks', () => { + it('should return empty array when disabled', () => { + const results = generateAllCustomMutationHooks({ + operations: [sampleMutationOperation], + typeRegistry: emptyTypeRegistry, + reactQueryEnabled: false, + }); + expect(results).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Tests - Custom Mutation Generators with reactQueryEnabled: true (default) +// ============================================================================ + +describe('Custom mutation generators with reactQueryEnabled: true (default)', () => { + describe('generateCustomMutationHook', () => { + it('should return mutation file by default', () => { + const result = generateCustomMutationHook({ + operation: sampleMutationOperation, + typeRegistry: emptyTypeRegistry, + }); + expect(result).not.toBeNull(); + expect(result!.content).toContain('useMutation'); + }); + }); +}); diff --git a/graphql/codegen/src/cli/codegen/custom-mutations.ts b/graphql/codegen/src/cli/codegen/custom-mutations.ts index 73c3bd868..5deb921f7 100644 --- a/graphql/codegen/src/cli/codegen/custom-mutations.ts +++ b/graphql/codegen/src/cli/codegen/custom-mutations.ts @@ -51,15 +51,23 @@ export interface GenerateCustomMutationHookOptions { typeRegistry: TypeRegistry; maxDepth?: number; skipQueryField?: boolean; + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; } /** * Generate a custom mutation hook file + * When reactQueryEnabled is false, returns null since mutations require React Query */ export function generateCustomMutationHook( options: GenerateCustomMutationHookOptions -): GeneratedCustomMutationFile { - const { operation } = options; +): GeneratedCustomMutationFile | null { + const { operation, reactQueryEnabled = true } = options; + + // Mutations require React Query - skip generation when disabled + if (!reactQueryEnabled) { + return null; + } try { return generateCustomMutationHookInternal(options); @@ -229,15 +237,18 @@ export interface GenerateAllCustomMutationHooksOptions { typeRegistry: TypeRegistry; maxDepth?: number; skipQueryField?: boolean; + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; } /** * Generate all custom mutation hook files + * When reactQueryEnabled is false, returns empty array since mutations require React Query */ export function generateAllCustomMutationHooks( options: GenerateAllCustomMutationHooksOptions ): GeneratedCustomMutationFile[] { - const { operations, typeRegistry, maxDepth = 2, skipQueryField = true } = options; + const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true } = options; return operations .filter((op) => op.kind === 'mutation') @@ -247,6 +258,8 @@ export function generateAllCustomMutationHooks( typeRegistry, maxDepth, skipQueryField, + reactQueryEnabled, }) - ); + ) + .filter((result): result is GeneratedCustomMutationFile => result !== null); } diff --git a/graphql/codegen/src/cli/codegen/custom-queries.ts b/graphql/codegen/src/cli/codegen/custom-queries.ts index 567dd5d0f..05f707f19 100644 --- a/graphql/codegen/src/cli/codegen/custom-queries.ts +++ b/graphql/codegen/src/cli/codegen/custom-queries.ts @@ -53,6 +53,8 @@ export interface GenerateCustomQueryHookOptions { typeRegistry: TypeRegistry; maxDepth?: number; skipQueryField?: boolean; + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; } /** @@ -61,7 +63,7 @@ export interface GenerateCustomQueryHookOptions { export function generateCustomQueryHook( options: GenerateCustomQueryHookOptions ): GeneratedCustomQueryFile { - const { operation, typeRegistry, maxDepth = 2, skipQueryField = true } = options; + const { operation, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true } = options; const project = createProject(); const hookName = getOperationHookName(operation.name, 'query'); @@ -82,24 +84,30 @@ export function generateCustomQueryHook( const sourceFile = createSourceFile(project, fileName); // Add file header - sourceFile.insertText( - 0, - createFileHeader(`Custom query hook for ${operation.name}`) + '\n\n' - ); - - // Add imports - sourceFile.addImportDeclarations([ - createImport({ - moduleSpecifier: '@tanstack/react-query', - namedImports: ['useQuery'], - typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], - }), + const headerText = reactQueryEnabled + ? `Custom query hook for ${operation.name}` + : `Custom query functions for ${operation.name}`; + sourceFile.insertText(0, createFileHeader(headerText) + '\n\n'); + + // Add imports - conditionally include React Query imports + const imports = []; + if (reactQueryEnabled) { + imports.push( + createImport({ + moduleSpecifier: '@tanstack/react-query', + namedImports: ['useQuery'], + typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], + }) + ); + } + imports.push( createImport({ moduleSpecifier: '../client', namedImports: ['execute'], typeOnlyNamedImports: ['ExecuteOptions'], - }), - ]); + }) + ); + sourceFile.addImportDeclarations(imports); // Add query document constant sourceFile.addVariableStatement( @@ -139,18 +147,20 @@ export function generateCustomQueryHook( ); } - // Generate hook function - const hookParams = generateHookParameters(operation, variablesTypeName, resultTypeName); - const hookBody = generateHookBody(operation, documentConstName, queryKeyName, variablesTypeName, resultTypeName); - const hookDoc = generateHookDoc(operation, hookName); - - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: hookParams, - statements: hookBody, - docs: [{ description: hookDoc }], - }); + // Generate hook function (only if React Query is enabled) + if (reactQueryEnabled) { + const hookParams = generateHookParameters(operation, variablesTypeName, resultTypeName); + const hookBody = generateHookBody(operation, documentConstName, queryKeyName, variablesTypeName, resultTypeName); + const hookDoc = generateHookDoc(operation, hookName); + + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: hookParams, + statements: hookBody, + docs: [{ description: hookDoc }], + }); + } // Add standalone functions section sourceFile.addStatements('\n// ============================================================================'); @@ -173,21 +183,23 @@ export function generateCustomQueryHook( docs: [{ description: fetchDoc }], }); - // Generate prefetch function - const prefetchFnName = `prefetch${ucFirst(operation.name)}Query`; - const prefetchParams = generatePrefetchParameters(operation, variablesTypeName); - const prefetchBody = generatePrefetchBody(operation, documentConstName, queryKeyName, variablesTypeName, resultTypeName); - const prefetchDoc = generatePrefetchDoc(operation, prefetchFnName); - - sourceFile.addFunction({ - name: prefetchFnName, - isExported: true, - isAsync: true, - parameters: prefetchParams, - returnType: 'Promise', - statements: prefetchBody, - docs: [{ description: prefetchDoc }], - }); + // Generate prefetch function (only if React Query is enabled) + if (reactQueryEnabled) { + const prefetchFnName = `prefetch${ucFirst(operation.name)}Query`; + const prefetchParams = generatePrefetchParameters(operation, variablesTypeName); + const prefetchBody = generatePrefetchBody(operation, documentConstName, queryKeyName, variablesTypeName, resultTypeName); + const prefetchDoc = generatePrefetchDoc(operation, prefetchFnName); + + sourceFile.addFunction({ + name: prefetchFnName, + isExported: true, + isAsync: true, + parameters: prefetchParams, + returnType: 'Promise', + statements: prefetchBody, + docs: [{ description: prefetchDoc }], + }); + } return { fileName, @@ -482,6 +494,8 @@ export interface GenerateAllCustomQueryHooksOptions { typeRegistry: TypeRegistry; maxDepth?: number; skipQueryField?: boolean; + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; } /** @@ -490,7 +504,7 @@ export interface GenerateAllCustomQueryHooksOptions { export function generateAllCustomQueryHooks( options: GenerateAllCustomQueryHooksOptions ): GeneratedCustomQueryFile[] { - const { operations, typeRegistry, maxDepth = 2, skipQueryField = true } = options; + const { operations, typeRegistry, maxDepth = 2, skipQueryField = true, reactQueryEnabled = true } = options; return operations .filter((op) => op.kind === 'query') @@ -500,6 +514,7 @@ export function generateAllCustomQueryHooks( typeRegistry, maxDepth, skipQueryField, + reactQueryEnabled, }) ); } diff --git a/graphql/codegen/src/cli/codegen/index.ts b/graphql/codegen/src/cli/codegen/index.ts index fbcd8d96b..5a91b16dd 100644 --- a/graphql/codegen/src/cli/codegen/index.ts +++ b/graphql/codegen/src/cli/codegen/index.ts @@ -100,6 +100,7 @@ export function generate(options: GenerateOptions): GenerateResult { // Extract codegen options const maxDepth = config.codegen.maxFieldDepth; const skipQueryField = config.codegen.skipQueryField; + const reactQueryEnabled = config.reactQuery.enabled; // 1. Generate client.ts files.push({ @@ -114,7 +115,7 @@ export function generate(options: GenerateOptions): GenerateResult { }); // 3. Generate table-based query hooks (queries/*.ts) - const queryHooks = generateAllQueryHooks(tables); + const queryHooks = generateAllQueryHooks(tables, { reactQueryEnabled }); for (const hook of queryHooks) { files.push({ path: `queries/${hook.fileName}`, @@ -130,6 +131,7 @@ export function generate(options: GenerateOptions): GenerateResult { typeRegistry: customOperations.typeRegistry, maxDepth, skipQueryField, + reactQueryEnabled, }); for (const hook of customQueryHooks) { @@ -149,7 +151,7 @@ export function generate(options: GenerateOptions): GenerateResult { }); // 6. Generate table-based mutation hooks (mutations/*.ts) - const mutationHooks = generateAllMutationHooks(tables); + const mutationHooks = generateAllMutationHooks(tables, { reactQueryEnabled }); for (const hook of mutationHooks) { files.push({ path: `mutations/${hook.fileName}`, @@ -165,6 +167,7 @@ export function generate(options: GenerateOptions): GenerateResult { typeRegistry: customOperations.typeRegistry, maxDepth, skipQueryField, + reactQueryEnabled, }); for (const hook of customMutationHooks) { diff --git a/graphql/codegen/src/cli/codegen/mutations.ts b/graphql/codegen/src/cli/codegen/mutations.ts index 24090e855..9da44a62c 100644 --- a/graphql/codegen/src/cli/codegen/mutations.ts +++ b/graphql/codegen/src/cli/codegen/mutations.ts @@ -46,14 +46,29 @@ export interface GeneratedMutationFile { content: string; } +export interface MutationGeneratorOptions { + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; +} + // ============================================================================ // Create mutation hook generator // ============================================================================ /** * Generate create mutation hook file content using AST + * When reactQueryEnabled is false, returns null since mutations require React Query */ -export function generateCreateMutationHook(table: CleanTable): GeneratedMutationFile { +export function generateCreateMutationHook( + table: CleanTable, + options: MutationGeneratorOptions = {} +): GeneratedMutationFile | null { + const { reactQueryEnabled = true } = options; + + // Mutations require React Query - skip generation when disabled + if (!reactQueryEnabled) { + return null; + } const project = createProject(); const { typeName, singularName } = getTableNames(table); const hookName = getCreateMutationHookName(table); @@ -208,8 +223,19 @@ mutate({ /** * Generate update mutation hook file content using AST + * When reactQueryEnabled is false, returns null since mutations require React Query */ -export function generateUpdateMutationHook(table: CleanTable): GeneratedMutationFile | null { +export function generateUpdateMutationHook( + table: CleanTable, + options: MutationGeneratorOptions = {} +): GeneratedMutationFile | null { + const { reactQueryEnabled = true } = options; + + // Mutations require React Query - skip generation when disabled + if (!reactQueryEnabled) { + return null; + } + // Check if update mutation exists if (table.query?.update === null) { return null; @@ -369,8 +395,19 @@ mutate({ /** * Generate delete mutation hook file content using AST + * When reactQueryEnabled is false, returns null since mutations require React Query */ -export function generateDeleteMutationHook(table: CleanTable): GeneratedMutationFile | null { +export function generateDeleteMutationHook( + table: CleanTable, + options: MutationGeneratorOptions = {} +): GeneratedMutationFile | null { + const { reactQueryEnabled = true } = options; + + // Mutations require React Query - skip generation when disabled + if (!reactQueryEnabled) { + return null; + } + // Check if delete mutation exists if (table.query?.delete === null) { return null; @@ -504,19 +541,26 @@ mutate({ /** * Generate all mutation hook files for all tables + * When reactQueryEnabled is false, returns empty array since mutations require React Query */ -export function generateAllMutationHooks(tables: CleanTable[]): GeneratedMutationFile[] { +export function generateAllMutationHooks( + tables: CleanTable[], + options: MutationGeneratorOptions = {} +): GeneratedMutationFile[] { const files: GeneratedMutationFile[] = []; for (const table of tables) { - files.push(generateCreateMutationHook(table)); + const createHook = generateCreateMutationHook(table, options); + if (createHook) { + files.push(createHook); + } - const updateHook = generateUpdateMutationHook(table); + const updateHook = generateUpdateMutationHook(table, options); if (updateHook) { files.push(updateHook); } - const deleteHook = generateDeleteMutationHook(table); + const deleteHook = generateDeleteMutationHook(table, options); if (deleteHook) { files.push(deleteHook); } diff --git a/graphql/codegen/src/cli/codegen/queries.ts b/graphql/codegen/src/cli/codegen/queries.ts index 7eb30cfcb..5d3634a15 100644 --- a/graphql/codegen/src/cli/codegen/queries.ts +++ b/graphql/codegen/src/cli/codegen/queries.ts @@ -46,6 +46,11 @@ export interface GeneratedQueryFile { content: string; } +export interface QueryGeneratorOptions { + /** Whether to generate React Query hooks (default: true for backwards compatibility) */ + reactQueryEnabled?: boolean; +} + // ============================================================================ // List query hook generator // ============================================================================ @@ -53,7 +58,11 @@ export interface GeneratedQueryFile { /** * Generate list query hook file content using AST */ -export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { +export function generateListQueryHook( + table: CleanTable, + options: QueryGeneratorOptions = {} +): GeneratedQueryFile { + const { reactQueryEnabled = true } = options; const project = createProject(); const { typeName, pluralName } = getTableNames(table); const hookName = getListQueryHookName(table); @@ -69,7 +78,10 @@ export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { const sourceFile = createSourceFile(project, getListQueryFileName(table)); // Add file header as leading comment - sourceFile.insertText(0, createFileHeader(`List query hook for ${typeName}`) + '\n\n'); + const headerText = reactQueryEnabled + ? `List query hook for ${typeName}` + : `List query functions for ${typeName}`; + sourceFile.insertText(0, createFileHeader(headerText) + '\n\n'); // Collect all filter types used by this table's fields const filterTypesUsed = new Set(); @@ -80,13 +92,18 @@ export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { } } - // Add imports - sourceFile.addImportDeclarations([ - createImport({ - moduleSpecifier: '@tanstack/react-query', - namedImports: ['useQuery'], - typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], - }), + // Add imports - conditionally include React Query imports + const imports = []; + if (reactQueryEnabled) { + imports.push( + createImport({ + moduleSpecifier: '@tanstack/react-query', + namedImports: ['useQuery'], + typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], + }) + ); + } + imports.push( createImport({ moduleSpecifier: '../client', namedImports: ['execute'], @@ -95,8 +112,9 @@ export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { createImport({ moduleSpecifier: '../types', typeOnlyNamedImports: [typeName, ...Array.from(filterTypesUsed)], - }), - ]); + }) + ); + sourceFile.addImportDeclarations(imports); // Re-export entity type sourceFile.addStatements(`\n// Re-export entity type for convenience\nexport type { ${typeName} };\n`); @@ -185,28 +203,29 @@ export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { ) ); - // Add section comment - sourceFile.addStatements('\n// ============================================================================'); - sourceFile.addStatements('// Hook'); - sourceFile.addStatements('// ============================================================================\n'); - - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { - name: 'variables', - type: `${ucFirst(pluralName)}QueryVariables`, - hasQuestionToken: true, - }, - { - name: 'options', - type: `Omit, 'queryKey' | 'queryFn'>`, - hasQuestionToken: true, - }, - ], - statements: `return useQuery({ + // Add React Query hook section (only if enabled) + if (reactQueryEnabled) { + sourceFile.addStatements('\n// ============================================================================'); + sourceFile.addStatements('// Hook'); + sourceFile.addStatements('// ============================================================================\n'); + + // Hook function + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: [ + { + name: 'variables', + type: `${ucFirst(pluralName)}QueryVariables`, + hasQuestionToken: true, + }, + { + name: 'options', + type: `Omit, 'queryKey' | 'queryFn'>`, + hasQuestionToken: true, + }, + ], + statements: `return useQuery({ queryKey: ${queryName}QueryKey(variables), queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( ${queryName}QueryDocument, @@ -214,9 +233,9 @@ export function generateListQueryHook(table: CleanTable): GeneratedQueryFile { ), ...options, });`, - docs: [ - { - description: `Query hook for fetching ${typeName} list + docs: [ + { + description: `Query hook for fetching ${typeName} list @example \`\`\`tsx @@ -226,9 +245,10 @@ const { data, isLoading } = ${hookName}({ orderBy: ['CREATED_AT_DESC'], }); \`\`\``, - }, - ], - }); + }, + ], + }); + } // Add section comment for standalone functions sourceFile.addStatements('\n// ============================================================================'); @@ -277,29 +297,30 @@ const data = await queryClient.fetchQuery({ ], }); - // Prefetch function (for SSR/QueryClient) - sourceFile.addFunction({ - name: `prefetch${ucFirst(pluralName)}Query`, - isExported: true, - isAsync: true, - parameters: [ - { - name: 'queryClient', - type: 'QueryClient', - }, - { - name: 'variables', - type: `${ucFirst(pluralName)}QueryVariables`, - hasQuestionToken: true, - }, - { - name: 'options', - type: 'ExecuteOptions', - hasQuestionToken: true, - }, - ], - returnType: 'Promise', - statements: `await queryClient.prefetchQuery({ + // Prefetch function (for SSR/QueryClient) - only if React Query is enabled + if (reactQueryEnabled) { + sourceFile.addFunction({ + name: `prefetch${ucFirst(pluralName)}Query`, + isExported: true, + isAsync: true, + parameters: [ + { + name: 'queryClient', + type: 'QueryClient', + }, + { + name: 'variables', + type: `${ucFirst(pluralName)}QueryVariables`, + hasQuestionToken: true, + }, + { + name: 'options', + type: 'ExecuteOptions', + hasQuestionToken: true, + }, + ], + returnType: 'Promise', + statements: `await queryClient.prefetchQuery({ queryKey: ${queryName}QueryKey(variables), queryFn: () => execute<${ucFirst(pluralName)}QueryResult, ${ucFirst(pluralName)}QueryVariables>( ${queryName}QueryDocument, @@ -307,17 +328,18 @@ const data = await queryClient.fetchQuery({ options ), });`, - docs: [ - { - description: `Prefetch ${typeName} list for SSR or cache warming + docs: [ + { + description: `Prefetch ${typeName} list for SSR or cache warming @example \`\`\`ts await prefetch${ucFirst(pluralName)}Query(queryClient, { first: 10 }); \`\`\``, - }, - ], - }); + }, + ], + }); + } return { fileName: getListQueryFileName(table), @@ -332,7 +354,11 @@ await prefetch${ucFirst(pluralName)}Query(queryClient, { first: 10 }); /** * Generate single item query hook file content using AST */ -export function generateSingleQueryHook(table: CleanTable): GeneratedQueryFile { +export function generateSingleQueryHook( + table: CleanTable, + options: QueryGeneratorOptions = {} +): GeneratedQueryFile { + const { reactQueryEnabled = true } = options; const project = createProject(); const { typeName, singularName } = getTableNames(table); const hookName = getSingleQueryHookName(table); @@ -345,15 +371,23 @@ export function generateSingleQueryHook(table: CleanTable): GeneratedQueryFile { const sourceFile = createSourceFile(project, getSingleQueryFileName(table)); // Add file header - sourceFile.insertText(0, createFileHeader(`Single item query hook for ${typeName}`) + '\n\n'); - - // Add imports - sourceFile.addImportDeclarations([ - createImport({ - moduleSpecifier: '@tanstack/react-query', - namedImports: ['useQuery'], - typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], - }), + const headerText = reactQueryEnabled + ? `Single item query hook for ${typeName}` + : `Single item query functions for ${typeName}`; + sourceFile.insertText(0, createFileHeader(headerText) + '\n\n'); + + // Add imports - conditionally include React Query imports + const imports = []; + if (reactQueryEnabled) { + imports.push( + createImport({ + moduleSpecifier: '@tanstack/react-query', + namedImports: ['useQuery'], + typeOnlyNamedImports: ['UseQueryOptions', 'QueryClient'], + }) + ); + } + imports.push( createImport({ moduleSpecifier: '../client', namedImports: ['execute'], @@ -362,8 +396,9 @@ export function generateSingleQueryHook(table: CleanTable): GeneratedQueryFile { createImport({ moduleSpecifier: '../types', typeOnlyNamedImports: [typeName], - }), - ]); + }) + ); + sourceFile.addImportDeclarations(imports); // Re-export entity type sourceFile.addStatements(`\n// Re-export entity type for convenience\nexport type { ${typeName} };\n`); @@ -411,24 +446,25 @@ export function generateSingleQueryHook(table: CleanTable): GeneratedQueryFile { ) ); - // Add section comment - sourceFile.addStatements('\n// ============================================================================'); - sourceFile.addStatements('// Hook'); - sourceFile.addStatements('// ============================================================================\n'); - - // Hook function - sourceFile.addFunction({ - name: hookName, - isExported: true, - parameters: [ - { name: 'id', type: 'string' }, - { - name: 'options', - type: `Omit, 'queryKey' | 'queryFn'>`, - hasQuestionToken: true, - }, - ], - statements: `return useQuery({ + // Add React Query hook section (only if enabled) + if (reactQueryEnabled) { + sourceFile.addStatements('\n// ============================================================================'); + sourceFile.addStatements('// Hook'); + sourceFile.addStatements('// ============================================================================\n'); + + // Hook function + sourceFile.addFunction({ + name: hookName, + isExported: true, + parameters: [ + { name: 'id', type: 'string' }, + { + name: 'options', + type: `Omit, 'queryKey' | 'queryFn'>`, + hasQuestionToken: true, + }, + ], + statements: `return useQuery({ queryKey: ${queryName}QueryKey(id), queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( ${queryName}QueryDocument, @@ -437,9 +473,9 @@ export function generateSingleQueryHook(table: CleanTable): GeneratedQueryFile { enabled: !!id && (options?.enabled !== false), ...options, });`, - docs: [ - { - description: `Query hook for fetching a single ${typeName} by ID + docs: [ + { + description: `Query hook for fetching a single ${typeName} by ID @example \`\`\`tsx @@ -449,9 +485,10 @@ if (data?.${queryName}) { console.log(data.${queryName}.id); } \`\`\``, - }, - ], - }); + }, + ], + }); + } // Add section comment for standalone functions sourceFile.addStatements('\n// ============================================================================'); @@ -489,22 +526,23 @@ const data = await fetch${ucFirst(singularName)}Query('uuid-here'); ], }); - // Prefetch function (for SSR/QueryClient) - sourceFile.addFunction({ - name: `prefetch${ucFirst(singularName)}Query`, - isExported: true, - isAsync: true, - parameters: [ - { name: 'queryClient', type: 'QueryClient' }, - { name: 'id', type: 'string' }, - { - name: 'options', - type: 'ExecuteOptions', - hasQuestionToken: true, - }, - ], - returnType: 'Promise', - statements: `await queryClient.prefetchQuery({ + // Prefetch function (for SSR/QueryClient) - only if React Query is enabled + if (reactQueryEnabled) { + sourceFile.addFunction({ + name: `prefetch${ucFirst(singularName)}Query`, + isExported: true, + isAsync: true, + parameters: [ + { name: 'queryClient', type: 'QueryClient' }, + { name: 'id', type: 'string' }, + { + name: 'options', + type: 'ExecuteOptions', + hasQuestionToken: true, + }, + ], + returnType: 'Promise', + statements: `await queryClient.prefetchQuery({ queryKey: ${queryName}QueryKey(id), queryFn: () => execute<${ucFirst(singularName)}QueryResult, ${ucFirst(singularName)}QueryVariables>( ${queryName}QueryDocument, @@ -512,17 +550,18 @@ const data = await fetch${ucFirst(singularName)}Query('uuid-here'); options ), });`, - docs: [ - { - description: `Prefetch a single ${typeName} for SSR or cache warming + docs: [ + { + description: `Prefetch a single ${typeName} for SSR or cache warming @example \`\`\`ts await prefetch${ucFirst(singularName)}Query(queryClient, 'uuid-here'); \`\`\``, - }, - ], - }); + }, + ], + }); + } return { fileName: getSingleQueryFileName(table), @@ -537,12 +576,15 @@ await prefetch${ucFirst(singularName)}Query(queryClient, 'uuid-here'); /** * Generate all query hook files for all tables */ -export function generateAllQueryHooks(tables: CleanTable[]): GeneratedQueryFile[] { +export function generateAllQueryHooks( + tables: CleanTable[], + options: QueryGeneratorOptions = {} +): GeneratedQueryFile[] { const files: GeneratedQueryFile[] = []; for (const table of tables) { - files.push(generateListQueryHook(table)); - files.push(generateSingleQueryHook(table)); + files.push(generateListQueryHook(table, options)); + files.push(generateSingleQueryHook(table, options)); } return files; diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 872ac682a..5584559e6 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -107,6 +107,19 @@ export interface GraphQLSDKConfig { useSharedTypes?: boolean; }; + /** + * React Query integration options + * Controls whether React Query hooks are generated + */ + reactQuery?: { + /** + * Whether to generate React Query hooks (useQuery, useMutation) + * When false, only standalone fetch functions are generated (no React dependency) + * @default false + */ + enabled?: boolean; + }; + /** * Watch mode configuration (dev-only feature) * When enabled via CLI --watch flag, the CLI will poll the endpoint for schema changes @@ -160,7 +173,7 @@ export interface ResolvedWatchConfig { /** * Resolved configuration with defaults applied */ -export interface ResolvedConfig extends Required> { +export interface ResolvedConfig extends Required> { headers: Record; tables: { include: string[]; @@ -190,6 +203,9 @@ export interface ResolvedConfig extends Required = { skipQueryField: true, }, orm: null, // ORM generation disabled by default + reactQuery: { + enabled: false, // React Query hooks disabled by default + }, watch: DEFAULT_WATCH_CONFIG, }; @@ -292,6 +311,9 @@ export function resolveConfig(config: GraphQLSDKConfig): ResolvedConfig { useSharedTypes: config.orm.useSharedTypes ?? DEFAULT_ORM_CONFIG.useSharedTypes, } : null, + reactQuery: { + enabled: config.reactQuery?.enabled ?? DEFAULT_CONFIG.reactQuery.enabled, + }, watch: { pollInterval: config.watch?.pollInterval ?? DEFAULT_WATCH_CONFIG.pollInterval, debounce: config.watch?.debounce ?? DEFAULT_WATCH_CONFIG.debounce,