From bf6061dddfeb0a32d66bc1cd0b5a75a2ae87a7f5 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 13 May 2026 01:04:33 +0000 Subject: [PATCH 1/2] feat: add codegen support for bulk mutations (ORM methods, React Query hooks, CLI commands) Phase 6 of the bulk mutations feature: - Extend introspection to detect bulkCreate/bulkUpsert/bulkUpdate/bulkDelete mutations from the GraphQL schema (infer-tables.ts, transform-schema.ts) - Add bulk document builders to query-builder template (buildBulkInsertDocument, buildBulkUpsertDocument, etc.) - Add bulk arg types and BulkMutationResult to select-types template - Generate ORM bulk methods on model classes (bulkCreate, bulkUpsert, bulkUpdate, bulkDelete) via Babel AST in model-generator.ts - Generate React Query mutation hooks (useBulkCreateX, useBulkUpsertX, useBulkUpdateX, useBulkDeleteX) in mutations.ts - Generate CLI subcommands (bulk-create, bulk-upsert, bulk-update, bulk-delete) in table-command-generator.ts - Add bulk mutation keys to mutation-keys.ts for tracking in-flight mutations - Add bulk naming helper functions to utils.ts --- .../codegen/cli/table-command-generator.ts | 287 +++++++++++++++ .../codegen/src/core/codegen/mutation-keys.ts | 21 ++ graphql/codegen/src/core/codegen/mutations.ts | 336 ++++++++++++++++++ .../src/core/codegen/orm/model-generator.ts | 296 +++++++++++++++ .../core/codegen/templates/query-builder.ts | 158 ++++++++ .../core/codegen/templates/select-types.ts | 28 ++ graphql/codegen/src/core/codegen/utils.ts | 40 +++ graphql/codegen/src/types/schema.ts | 8 + graphql/query/src/introspect/infer-tables.ts | 39 +- .../query/src/introspect/transform-schema.ts | 10 + graphql/query/src/types/schema.ts | 8 + 11 files changed, 1230 insertions(+), 1 deletion(-) diff --git a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts index 89d26a3d68..51ff386266 100644 --- a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts @@ -1322,6 +1322,275 @@ function buildMutationHandler( ); } +type BulkCliOp = 'bulk-create' | 'bulk-upsert' | 'bulk-update' | 'bulk-delete'; + +function buildBulkMutationHandler( + table: Table, + operation: BulkCliOp, + targetName?: string, +): t.FunctionDeclaration { + const { singularName } = getTableNames(table); + const selectObj = buildSelectObject(table); + + // Map CLI op name to ORM method name + const ormMethod = (() => { + switch (operation) { + case 'bulk-create': return 'bulkCreate'; + case 'bulk-upsert': return 'bulkUpsert'; + case 'bulk-update': return 'bulkUpdate'; + case 'bulk-delete': return 'bulkDelete'; + } + })(); + + const tryBody: t.Statement[] = []; + + if (operation === 'bulk-create' || operation === 'bulk-upsert') { + // Parse --data (JSON array) from argv + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('dataRaw'), + t.memberExpression(t.identifier('argv'), t.identifier('data')), + ), + ]), + ); + tryBody.push( + t.ifStatement( + t.unaryExpression('!', t.identifier('dataRaw')), + t.blockStatement([ + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('console'), t.identifier('error')), + [t.stringLiteral(`--data is required for ${operation}. Provide a JSON array.`)], + ), + ), + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('process'), t.identifier('exit')), + [t.numericLiteral(1)], + ), + ), + ]), + ), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('data'), + t.callExpression( + t.memberExpression(t.identifier('JSON'), t.identifier('parse')), + [t.tsAsExpression(t.identifier('dataRaw'), t.tsStringKeyword())], + ), + ), + ]), + ); + + let ormArgs: t.ObjectExpression; + if (operation === 'bulk-upsert') { + // Also parse --on-conflict + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('onConflictRaw'), + t.memberExpression(t.identifier('argv'), t.identifier('on-conflict')), + ), + ]), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('onConflict'), + t.conditionalExpression( + t.identifier('onConflictRaw'), + t.callExpression( + t.memberExpression(t.identifier('JSON'), t.identifier('parse')), + [t.tsAsExpression(t.identifier('onConflictRaw'), t.tsStringKeyword())], + ), + t.objectExpression([]), + ), + ), + ]), + ); + ormArgs = t.objectExpression([ + t.objectProperty(t.identifier('data'), t.identifier('data')), + t.objectProperty(t.identifier('onConflict'), t.identifier('onConflict')), + t.objectProperty(t.identifier('select'), selectObj), + ]); + } else { + ormArgs = t.objectExpression([ + t.objectProperty(t.identifier('data'), t.identifier('data')), + t.objectProperty(t.identifier('select'), selectObj), + ]); + } + + tryBody.push(buildGetClientStatement(targetName)); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('result'), + t.awaitExpression(buildOrmCall(singularName, ormMethod, ormArgs)), + ), + ]), + ); + } else if (operation === 'bulk-update') { + // Parse --where (JSON) and --data (JSON) + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('whereRaw'), + t.memberExpression(t.identifier('argv'), t.identifier('where')), + ), + ]), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('dataRaw'), + t.memberExpression(t.identifier('argv'), t.identifier('data')), + ), + ]), + ); + tryBody.push( + t.ifStatement( + t.logicalExpression( + '||', + t.unaryExpression('!', t.identifier('whereRaw')), + t.unaryExpression('!', t.identifier('dataRaw')), + ), + t.blockStatement([ + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('console'), t.identifier('error')), + [t.stringLiteral('--where and --data are required for bulk-update. Provide JSON objects.')], + ), + ), + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('process'), t.identifier('exit')), + [t.numericLiteral(1)], + ), + ), + ]), + ), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('where'), + t.callExpression( + t.memberExpression(t.identifier('JSON'), t.identifier('parse')), + [t.tsAsExpression(t.identifier('whereRaw'), t.tsStringKeyword())], + ), + ), + ]), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('data'), + t.callExpression( + t.memberExpression(t.identifier('JSON'), t.identifier('parse')), + [t.tsAsExpression(t.identifier('dataRaw'), t.tsStringKeyword())], + ), + ), + ]), + ); + tryBody.push(buildGetClientStatement(targetName)); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('result'), + t.awaitExpression( + buildOrmCall(singularName, ormMethod, t.objectExpression([ + t.objectProperty(t.identifier('where'), t.identifier('where')), + t.objectProperty(t.identifier('data'), t.identifier('data')), + t.objectProperty(t.identifier('select'), selectObj), + ])), + ), + ), + ]), + ); + } else { + // bulk-delete: parse --where (JSON) + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('whereRaw'), + t.memberExpression(t.identifier('argv'), t.identifier('where')), + ), + ]), + ); + tryBody.push( + t.ifStatement( + t.unaryExpression('!', t.identifier('whereRaw')), + t.blockStatement([ + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('console'), t.identifier('error')), + [t.stringLiteral('--where is required for bulk-delete. Provide a JSON object.')], + ), + ), + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('process'), t.identifier('exit')), + [t.numericLiteral(1)], + ), + ), + ]), + ), + ); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('where'), + t.callExpression( + t.memberExpression(t.identifier('JSON'), t.identifier('parse')), + [t.tsAsExpression(t.identifier('whereRaw'), t.tsStringKeyword())], + ), + ), + ]), + ); + tryBody.push(buildGetClientStatement(targetName)); + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('result'), + t.awaitExpression( + buildOrmCall(singularName, ormMethod, t.objectExpression([ + t.objectProperty(t.identifier('where'), t.identifier('where')), + t.objectProperty(t.identifier('select'), selectObj), + ])), + ), + ), + ]), + ); + } + + tryBody.push(buildJsonLog(t.identifier('result'))); + + const argvParam = t.identifier('argv'); + argvParam.typeAnnotation = buildArgvType(); + const prompterParam = t.identifier('prompter'); + prompterParam.typeAnnotation = t.tsTypeAnnotation( + t.tsTypeReference(t.identifier('Inquirerer')), + ); + + const handlerName = `handle${toPascalCase(operation)}`; + + return t.functionDeclaration( + t.identifier(handlerName), + [argvParam, prompterParam], + t.blockStatement([ + t.tryStatement( + t.blockStatement(tryBody), + buildErrorCatch(`Failed to ${operation}.`), + ), + ]), + false, + true, + ); +} + export interface TableCommandOptions { targetName?: string; executorImportPath?: string; @@ -1439,12 +1708,22 @@ export function generateTableCommand(table: Table, options?: TableCommandOptions ); } + // Detect bulk mutations + const hasBulkCreate = !!table.query?.bulkInsert; + const hasBulkUpsert = !!table.query?.bulkUpsert; + const hasBulkUpdate = !!table.query?.bulkUpdate; + const hasBulkDelete = !!table.query?.bulkDelete; + const subcommands: string[] = ['list', 'find-first']; if (hasSearchFields) subcommands.push('search'); if (hasGet) subcommands.push('get'); subcommands.push('create'); if (hasUpdate) subcommands.push('update'); if (hasDelete) subcommands.push('delete'); + if (hasBulkCreate) subcommands.push('bulk-create'); + if (hasBulkUpsert) subcommands.push('bulk-upsert'); + if (hasBulkUpdate) subcommands.push('bulk-update'); + if (hasBulkDelete) subcommands.push('bulk-delete'); const usageLines = [ '', @@ -1466,6 +1745,10 @@ export function generateTableCommand(table: Table, options?: TableCommandOptions ); } if (hasDelete) usageLines.push(` delete Delete a ${singularName}`); + if (hasBulkCreate) usageLines.push(` bulk-create Bulk create ${singularName} records`); + if (hasBulkUpsert) usageLines.push(` bulk-upsert Bulk upsert ${singularName} records`); + if (hasBulkUpdate) usageLines.push(` bulk-update Bulk update ${singularName} records`); + if (hasBulkDelete) usageLines.push(` bulk-delete Bulk delete ${singularName} records`); usageLines.push( '', 'List Options:', @@ -1684,6 +1967,10 @@ export function generateTableCommand(table: Table, options?: TableCommandOptions statements.push(buildMutationHandler(table, 'create', vectorFieldNames, tn, options?.typeRegistry, ormTypes)); if (hasUpdate) statements.push(buildMutationHandler(table, 'update', vectorFieldNames, tn, options?.typeRegistry, ormTypes)); if (hasDelete) statements.push(buildMutationHandler(table, 'delete', vectorFieldNames, tn, options?.typeRegistry, ormTypes)); + if (hasBulkCreate) statements.push(buildBulkMutationHandler(table, 'bulk-create', tn)); + if (hasBulkUpsert) statements.push(buildBulkMutationHandler(table, 'bulk-upsert', tn)); + if (hasBulkUpdate) statements.push(buildBulkMutationHandler(table, 'bulk-update', tn)); + if (hasBulkDelete) statements.push(buildBulkMutationHandler(table, 'bulk-delete', tn)); const header = getGeneratedFileHeader(`CLI commands for ${table.name}`); const code = generateCode(statements); diff --git a/graphql/codegen/src/core/codegen/mutation-keys.ts b/graphql/codegen/src/core/codegen/mutation-keys.ts index 2af740e6c9..f78807fed2 100644 --- a/graphql/codegen/src/core/codegen/mutation-keys.ts +++ b/graphql/codegen/src/core/codegen/mutation-keys.ts @@ -140,6 +140,27 @@ function generateEntityMutationKeysDeclaration( addJSDocComment(deleteProp, [`Delete ${singularName} mutation key`]); properties.push(deleteProp); + // Bulk mutation keys (only if table has bulk operations) + const bulkOps: Array<{ key: string; queryField: string | null | undefined }> = [ + { key: 'bulkCreate', queryField: table.query?.bulkInsert }, + { key: 'bulkUpsert', queryField: table.query?.bulkUpsert }, + { key: 'bulkUpdate', queryField: table.query?.bulkUpdate }, + { key: 'bulkDelete', queryField: table.query?.bulkDelete }, + ]; + for (const { key, queryField } of bulkOps) { + if (!queryField) continue; + const arrowFn = t.arrowFunctionExpression( + [], + constArray([ + t.stringLiteral('mutation'), + t.stringLiteral(entityKey), + t.stringLiteral(key), + ]), + ); + const prop = t.objectProperty(t.identifier(key), arrowFn); + properties.push(prop); + } + return t.exportNamedDeclaration( t.variableDeclaration('const', [ t.variableDeclarator( diff --git a/graphql/codegen/src/core/codegen/mutations.ts b/graphql/codegen/src/core/codegen/mutations.ts index feb45b73d0..25b57a7b7e 100644 --- a/graphql/codegen/src/core/codegen/mutations.ts +++ b/graphql/codegen/src/core/codegen/mutations.ts @@ -39,6 +39,14 @@ import { voidStatement, } from './hooks-ast'; import { + getBulkCreateMutationFileName, + getBulkCreateMutationHookName, + getBulkDeleteMutationFileName, + getBulkDeleteMutationHookName, + getBulkUpdateMutationFileName, + getBulkUpdateMutationHookName, + getBulkUpsertMutationFileName, + getBulkUpsertMutationHookName, getCreateMutationFileName, getCreateMutationHookName, getCreateMutationName, @@ -827,6 +835,325 @@ export function generateDeleteMutationHook( }; } +// ============================================================================ +// Bulk Mutation Hook Generators +// ============================================================================ + +type BulkOp = 'bulkCreate' | 'bulkUpsert' | 'bulkUpdate' | 'bulkDelete'; + +function generateBulkMutationHook( + table: Table, + op: BulkOp, + options: MutationGeneratorOptions = {}, +): GeneratedMutationFile | null { + const { reactQueryEnabled = true, useCentralizedKeys = true } = options; + if (!reactQueryEnabled) return null; + + const mutationFieldName = (() => { + switch (op) { + case 'bulkCreate': return table.query?.bulkInsert; + case 'bulkUpsert': return table.query?.bulkUpsert; + case 'bulkUpdate': return table.query?.bulkUpdate; + case 'bulkDelete': return table.query?.bulkDelete; + } + })(); + if (!mutationFieldName) return null; + + const { typeName, singularName } = getTableNames(table); + const hookName = (() => { + switch (op) { + case 'bulkCreate': return getBulkCreateMutationHookName(table); + case 'bulkUpsert': return getBulkUpsertMutationHookName(table); + case 'bulkUpdate': return getBulkUpdateMutationHookName(table); + case 'bulkDelete': return getBulkDeleteMutationHookName(table); + } + })(); + const fileName = (() => { + switch (op) { + case 'bulkCreate': return getBulkCreateMutationFileName(table); + case 'bulkUpsert': return getBulkUpsertMutationFileName(table); + case 'bulkUpdate': return getBulkUpdateMutationFileName(table); + case 'bulkDelete': return getBulkDeleteMutationFileName(table); + } + })(); + const keysName = `${lcFirst(typeName)}Keys`; + const mutationKeysName = `${lcFirst(typeName)}MutationKeys`; + const selectTypeName = `${typeName}Select`; + const relationTypeName = `${typeName}WithRelations`; + const createInputTypeName = `Create${typeName}Input`; + const patchTypeName = `${typeName}Patch`; + const filterTypeName = `${typeName}Filter`; + + const statements: t.Statement[] = []; + + // Imports + statements.push( + createImportDeclaration('@tanstack/react-query', [ + 'useMutation', + 'useQueryClient', + ]), + ); + statements.push( + createImportDeclaration( + '@tanstack/react-query', + ['UseMutationOptions', 'UseMutationResult'], + true, + ), + ); + statements.push(createImportDeclaration('../client', ['getClient'])); + statements.push( + createImportDeclaration('../selection', ['buildSelectionArgs']), + ); + statements.push( + createImportDeclaration('../selection', ['SelectionConfig'], true), + ); + + if (useCentralizedKeys) { + statements.push(createImportDeclaration('../query-keys', [keysName])); + statements.push( + createImportDeclaration('../mutation-keys', [mutationKeysName]), + ); + } + + // Determine which types to import + const typeImports = [selectTypeName, relationTypeName]; + if (op === 'bulkCreate' || op === 'bulkUpsert') { + typeImports.push(createInputTypeName); + } + if (op === 'bulkUpdate') { + typeImports.push(patchTypeName); + typeImports.push(filterTypeName); + } + if (op === 'bulkDelete') { + typeImports.push(filterTypeName); + } + statements.push( + createImportDeclaration('../../orm/input-types', typeImports, true), + ); + statements.push( + createImportDeclaration( + '../../orm/select-types', + ['InferSelectResult', 'BulkMutationResult', 'HookStrictSelect'], + true, + ), + ); + + // Re-exports + statements.push(createTypeReExport(typeImports, '../../orm/input-types')); + + // Build the variable type for the mutationFn parameter + const varType = (() => { + switch (op) { + case 'bulkCreate': + return t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier('data'), + t.tsTypeAnnotation( + t.tsArrayType( + t.tsIndexedAccessType( + typeRef(createInputTypeName), + t.tsLiteralType(t.stringLiteral(singularName)), + ), + ), + ), + ), + (() => { + const p = t.tsPropertySignature( + t.identifier('onConflict'), + t.tsTypeAnnotation(t.tsUnknownKeyword()), + ); + p.optional = true; + return p; + })(), + ]); + case 'bulkUpsert': + return t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier('data'), + t.tsTypeAnnotation( + t.tsArrayType( + t.tsIndexedAccessType( + typeRef(createInputTypeName), + t.tsLiteralType(t.stringLiteral(singularName)), + ), + ), + ), + ), + t.tsPropertySignature( + t.identifier('onConflict'), + t.tsTypeAnnotation(t.tsUnknownKeyword()), + ), + ]); + case 'bulkUpdate': + return t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier('where'), + t.tsTypeAnnotation(typeRef(filterTypeName)), + ), + t.tsPropertySignature( + t.identifier('data'), + t.tsTypeAnnotation(typeRef(patchTypeName)), + ), + ]); + case 'bulkDelete': + return t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier('where'), + t.tsTypeAnnotation(typeRef(filterTypeName)), + ), + ]); + } + })(); + + // Result type: BulkMutationResult> + const bulkResultType = (sel: t.TSType) => + typeRef('BulkMutationResult', [inferSelectResultType(relationTypeName, sel)]); + + // Overload with fields + const o1ParamType = t.tsIntersectionType([ + t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier('selection'), + t.tsTypeAnnotation(buildFieldsSelectionType(sRef(), selectTypeName)), + ), + ]), + useMutationOptionsType(bulkResultType(sRef()), varType), + ]); + const o1 = exportDeclareFunction( + hookName, + createSTypeParam(selectTypeName), + [createFunctionParam('params', o1ParamType)], + useMutationResultType(bulkResultType(sRef()), varType), + ); + addJSDocComment(o1, [ + table.description || `Bulk ${op.replace('bulk', '').toLowerCase()} mutation hook for ${typeName}`, + ]); + statements.push(o1); + + // Implementation + const implSelProp = t.tsPropertySignature( + t.identifier('selection'), + t.tsTypeAnnotation(selectionConfigType(typeRef(selectTypeName))), + ); + const implParamType = t.tsIntersectionType([ + t.tsTypeLiteral([implSelProp]), + omitType( + typeRef('UseMutationOptions', [ + t.tsAnyKeyword(), + typeRef('Error'), + varType, + ]), + ['mutationFn'], + ), + ]); + + const body: t.Statement[] = []; + body.push(buildSelectionArgsCall(selectTypeName)); + body.push(destructureParamsWithSelection('mutationOptions')); + body.push(voidStatement('_selection')); + body.push(constDecl('queryClient', callExpr('useQueryClient', []))); + + const mutationKeyExpr = useCentralizedKeys + ? callExpr( + t.memberExpression( + t.identifier(mutationKeysName), + t.identifier(op), + ), + [], + ) + : undefined; + + // Build the ORM method call depending on the operation + const ormMethodName = op; + const mutationFnArgs = (() => { + switch (op) { + case 'bulkCreate': + return t.objectExpression([ + shorthandProp('data'), + objectProp('onConflict', t.memberExpression(t.identifier('vars'), t.identifier('onConflict'))), + objectProp('select', t.memberExpression(t.identifier('args'), t.identifier('select'))), + ]); + case 'bulkUpsert': + return t.objectExpression([ + shorthandProp('data'), + objectProp('onConflict', t.memberExpression(t.identifier('vars'), t.identifier('onConflict'))), + objectProp('select', t.memberExpression(t.identifier('args'), t.identifier('select'))), + ]); + case 'bulkUpdate': + return t.objectExpression([ + objectProp('where', t.memberExpression(t.identifier('vars'), t.identifier('where'))), + shorthandProp('data'), + objectProp('select', t.memberExpression(t.identifier('args'), t.identifier('select'))), + ]); + case 'bulkDelete': + return t.objectExpression([ + objectProp('where', t.memberExpression(t.identifier('vars'), t.identifier('where'))), + objectProp('select', t.memberExpression(t.identifier('args'), t.identifier('select'))), + ]); + } + })(); + + const varsParam = createFunctionParam('vars', varType); + const mutationFnExpr = t.arrowFunctionExpression( + [varsParam], + getClientCallUnwrap(singularName, ormMethodName, mutationFnArgs), + ); + + // onSuccess: invalidate lists + const listKeyExpr = useCentralizedKeys + ? callExpr( + t.memberExpression(t.identifier(keysName), t.identifier('lists')), + [], + ) + : t.arrayExpression([ + t.stringLiteral(typeName.toLowerCase()), + t.stringLiteral('list'), + ]); + + const onSuccessFn = t.arrowFunctionExpression( + [], + t.blockStatement([ + t.expressionStatement( + callExpr( + t.memberExpression( + t.identifier('queryClient'), + t.identifier('invalidateQueries'), + ), + [t.objectExpression([objectProp('queryKey', listKeyExpr)])], + ), + ), + ]), + ); + + body.push( + returnUseMutation( + mutationFnExpr, + [ + objectProp('onSuccess', onSuccessFn), + spreadObj(t.identifier('mutationOptions')), + ], + mutationKeyExpr, + ), + ); + + statements.push( + exportFunction( + hookName, + null, + [createFunctionParam('params', implParamType)], + body, + ), + ); + + return { + fileName, + content: generateHookFileCode( + table.description || `Bulk ${op.replace('bulk', '').toLowerCase()} mutation hook for ${typeName}`, + statements, + ), + }; +} + export function generateAllMutationHooks( tables: Table[], options: MutationGeneratorOptions = {}, @@ -848,6 +1175,15 @@ export function generateAllMutationHooks( if (deleteHook) { files.push(deleteHook); } + + // Bulk mutation hooks + const bulkOps: BulkOp[] = ['bulkCreate', 'bulkUpsert', 'bulkUpdate', 'bulkDelete']; + for (const op of bulkOps) { + const hook = generateBulkMutationHook(table, op, options); + if (hook) { + files.push(hook); + } + } } return files; diff --git a/graphql/codegen/src/core/codegen/orm/model-generator.ts b/graphql/codegen/src/core/codegen/orm/model-generator.ts index 8346c97b26..255a8cb7a3 100644 --- a/graphql/codegen/src/core/codegen/orm/model-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/model-generator.ts @@ -212,6 +212,13 @@ export function generateModelFile( return jt?.query?.delete != null; }); + // Detect which bulk mutations are available for this table + const bulkInsertMutationName = table.query?.bulkInsert ?? null; + const bulkUpsertMutationName = table.query?.bulkUpsert ?? null; + const bulkUpdateMutationName = table.query?.bulkUpdate ?? null; + const bulkDeleteMutationName = table.query?.bulkDelete ?? null; + const hasBulk = !!(bulkInsertMutationName || bulkUpsertMutationName || bulkUpdateMutationName || bulkDeleteMutationName); + const queryBuilderImports = [ 'QueryBuilder', 'buildFindManyDocument', @@ -221,6 +228,10 @@ export function generateModelFile( 'buildUpdateByPkDocument', 'buildDeleteByPkDocument', ...(needsJunctionRemove ? ['buildJunctionRemoveDocument'] : []), + ...(bulkInsertMutationName ? ['buildBulkInsertDocument'] : []), + ...(bulkUpsertMutationName ? ['buildBulkUpsertDocument'] : []), + ...(bulkUpdateMutationName ? ['buildBulkUpdateDocument'] : []), + ...(bulkDeleteMutationName ? ['buildBulkDeleteDocument'] : []), ]; statements.push( createImportDeclaration('../query-builder', queryBuilderImports), @@ -235,6 +246,7 @@ export function generateModelFile( 'CreateArgs', 'UpdateArgs', 'DeleteArgs', + ...(hasBulk ? ['BulkInsertArgs', 'BulkUpsertArgs', 'BulkUpdateArgs', 'BulkDeleteArgs', 'BulkMutationResult'] : []), 'InferSelectResult', 'StrictSelect', ], @@ -970,6 +982,290 @@ export function generateModelFile( ); } + // ── bulkCreate ────────────────────────────────────────────────────────── + if (bulkInsertMutationName) { + const bulkInsertInputTypeName = `BulkCreate${typeName}Input`; + const dataType = () => + t.tsIndexedAccessType( + t.tsTypeReference(t.identifier(createInputTypeName)), + t.tsLiteralType(t.stringLiteral(singularName)), + ); + const argsType = (sel: t.TSType) => + t.tsTypeReference( + t.identifier('BulkInsertArgs'), + t.tsTypeParameterInstantiation([sel, dataType()]), + ); + const retType = (sel: t.TSType) => + t.tsTypeAnnotation( + t.tsTypeReference( + t.identifier('QueryBuilder'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('BulkMutationResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('InferSelectResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier(relationTypeName)), + sel, + ]), + ), + ]), + ), + ]), + ), + ); + + const implParam = t.identifier('args'); + implParam.typeAnnotation = t.tsTypeAnnotation( + t.tsIntersectionType([ + argsType(sRef()), + t.tsTypeLiteral([requiredSelectProp()]), + strictSelectGuard(selectTypeName), + ]), + ); + const selectExpr = t.memberExpression( + t.identifier('args'), + t.identifier('select'), + ); + const bodyArgs = [ + t.stringLiteral(typeName), + t.stringLiteral(bulkInsertMutationName), + selectExpr, + t.memberExpression(t.identifier('args'), t.identifier('data')), + t.stringLiteral(bulkInsertInputTypeName), + t.memberExpression(t.identifier('args'), t.identifier('onConflict')), + t.identifier('connectionFieldsMap'), + ]; + classBody.push( + createClassMethod( + 'bulkCreate', + createTypeParam(selectTypeName), + [implParam], + retType(sRef()), + buildMethodBody( + 'buildBulkInsertDocument', + bodyArgs, + 'mutation', + typeName, + bulkInsertMutationName, + ), + ), + ); + } + + // ── bulkUpsert ───────────────────────────────────────────────────────── + if (bulkUpsertMutationName) { + const bulkUpsertInputTypeName = `BulkUpsert${typeName}Input`; + const dataType = () => + t.tsIndexedAccessType( + t.tsTypeReference(t.identifier(createInputTypeName)), + t.tsLiteralType(t.stringLiteral(singularName)), + ); + const argsType = (sel: t.TSType) => + t.tsTypeReference( + t.identifier('BulkUpsertArgs'), + t.tsTypeParameterInstantiation([sel, dataType()]), + ); + const retType = (sel: t.TSType) => + t.tsTypeAnnotation( + t.tsTypeReference( + t.identifier('QueryBuilder'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('BulkMutationResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('InferSelectResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier(relationTypeName)), + sel, + ]), + ), + ]), + ), + ]), + ), + ); + + const implParam = t.identifier('args'); + implParam.typeAnnotation = t.tsTypeAnnotation( + t.tsIntersectionType([ + argsType(sRef()), + t.tsTypeLiteral([requiredSelectProp()]), + strictSelectGuard(selectTypeName), + ]), + ); + const selectExpr = t.memberExpression( + t.identifier('args'), + t.identifier('select'), + ); + const bodyArgs = [ + t.stringLiteral(typeName), + t.stringLiteral(bulkUpsertMutationName), + selectExpr, + t.memberExpression(t.identifier('args'), t.identifier('data')), + t.stringLiteral(bulkUpsertInputTypeName), + t.memberExpression(t.identifier('args'), t.identifier('onConflict')), + t.identifier('connectionFieldsMap'), + ]; + classBody.push( + createClassMethod( + 'bulkUpsert', + createTypeParam(selectTypeName), + [implParam], + retType(sRef()), + buildMethodBody( + 'buildBulkUpsertDocument', + bodyArgs, + 'mutation', + typeName, + bulkUpsertMutationName, + ), + ), + ); + } + + // ── bulkUpdate ───────────────────────────────────────────────────────── + if (bulkUpdateMutationName) { + const bulkUpdateInputTypeName = `BulkUpdate${typeName}Input`; + const argsType = (sel: t.TSType) => + t.tsTypeReference( + t.identifier('BulkUpdateArgs'), + t.tsTypeParameterInstantiation([ + sel, + t.tsTypeReference(t.identifier(whereTypeName)), + t.tsTypeReference(t.identifier(patchTypeName)), + ]), + ); + const retType = (sel: t.TSType) => + t.tsTypeAnnotation( + t.tsTypeReference( + t.identifier('QueryBuilder'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('BulkMutationResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('InferSelectResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier(relationTypeName)), + sel, + ]), + ), + ]), + ), + ]), + ), + ); + + const implParam = t.identifier('args'); + implParam.typeAnnotation = t.tsTypeAnnotation( + t.tsIntersectionType([ + argsType(sRef()), + t.tsTypeLiteral([requiredSelectProp()]), + strictSelectGuard(selectTypeName), + ]), + ); + const selectExpr = t.memberExpression( + t.identifier('args'), + t.identifier('select'), + ); + const bodyArgs = [ + t.stringLiteral(typeName), + t.stringLiteral(bulkUpdateMutationName), + selectExpr, + t.memberExpression(t.identifier('args'), t.identifier('where')), + t.memberExpression(t.identifier('args'), t.identifier('data')), + t.stringLiteral(bulkUpdateInputTypeName), + t.identifier('connectionFieldsMap'), + ]; + classBody.push( + createClassMethod( + 'bulkUpdate', + createTypeParam(selectTypeName), + [implParam], + retType(sRef()), + buildMethodBody( + 'buildBulkUpdateDocument', + bodyArgs, + 'mutation', + typeName, + bulkUpdateMutationName, + ), + ), + ); + } + + // ── bulkDelete ───────────────────────────────────────────────────────── + if (bulkDeleteMutationName) { + const bulkDeleteInputTypeName = `BulkDelete${typeName}Input`; + const argsType = (sel: t.TSType) => + t.tsTypeReference( + t.identifier('BulkDeleteArgs'), + t.tsTypeParameterInstantiation([ + sel, + t.tsTypeReference(t.identifier(whereTypeName)), + ]), + ); + const retType = (sel: t.TSType) => + t.tsTypeAnnotation( + t.tsTypeReference( + t.identifier('QueryBuilder'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('BulkMutationResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference( + t.identifier('InferSelectResult'), + t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier(relationTypeName)), + sel, + ]), + ), + ]), + ), + ]), + ), + ); + + const implParam = t.identifier('args'); + implParam.typeAnnotation = t.tsTypeAnnotation( + t.tsIntersectionType([ + argsType(sRef()), + t.tsTypeLiteral([requiredSelectProp()]), + strictSelectGuard(selectTypeName), + ]), + ); + const selectExpr = t.memberExpression( + t.identifier('args'), + t.identifier('select'), + ); + const bodyArgs = [ + t.stringLiteral(typeName), + t.stringLiteral(bulkDeleteMutationName), + selectExpr, + t.memberExpression(t.identifier('args'), t.identifier('where')), + t.stringLiteral(bulkDeleteInputTypeName), + t.identifier('connectionFieldsMap'), + ]; + classBody.push( + createClassMethod( + 'bulkDelete', + createTypeParam(selectTypeName), + [implParam], + retType(sRef()), + buildMethodBody( + 'buildBulkDeleteDocument', + bodyArgs, + 'mutation', + typeName, + bulkDeleteMutationName, + ), + ), + ); + } + // ── M:N add/remove methods ──────────────────────────────────────────── for (const rel of m2nRels) { if (!rel.fieldName) continue; diff --git a/graphql/codegen/src/core/codegen/templates/query-builder.ts b/graphql/codegen/src/core/codegen/templates/query-builder.ts index 71025cf1fb..9b3e6a5b0c 100644 --- a/graphql/codegen/src/core/codegen/templates/query-builder.ts +++ b/graphql/codegen/src/core/codegen/templates/query-builder.ts @@ -962,3 +962,161 @@ function buildValueAst( throw new Error('Unsupported value type: ' + typeof value); } + +// ============================================================================ +// Bulk Mutation Document Builders +// ============================================================================ + +export function buildBulkInsertDocument( + operationName: string, + mutationField: string, + select: TSelect, + data: TData[], + inputTypeName: string, + onConflict?: unknown, + connectionFieldsMap?: Record>, +): { document: string; variables: Record } { + const selections = select + ? buildSelections( + select as Record, + connectionFieldsMap, + operationName, + ) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ name: 'affectedCount' }), + t.field({ + name: 'returning', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + values: data, + ...(onConflict ? { onConflict } : {}), + }, + }, + }; +} + +export function buildBulkUpsertDocument( + operationName: string, + mutationField: string, + select: TSelect, + data: TData[], + inputTypeName: string, + onConflict: unknown, + connectionFieldsMap?: Record>, +): { document: string; variables: Record } { + const selections = select + ? buildSelections( + select as Record, + connectionFieldsMap, + operationName, + ) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ name: 'affectedCount' }), + t.field({ + name: 'returning', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + values: data, + onConflict, + }, + }, + }; +} + +export function buildBulkUpdateDocument( + operationName: string, + mutationField: string, + select: TSelect, + where: TWhere, + data: TData, + inputTypeName: string, + connectionFieldsMap?: Record>, +): { document: string; variables: Record } { + const selections = select + ? buildSelections( + select as Record, + connectionFieldsMap, + operationName, + ) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ name: 'affectedCount' }), + t.field({ + name: 'returning', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + where, + patch: data, + }, + }, + }; +} + +export function buildBulkDeleteDocument( + operationName: string, + mutationField: string, + select: TSelect, + where: TWhere, + inputTypeName: string, + connectionFieldsMap?: Record>, +): { document: string; variables: Record } { + const selections = select + ? buildSelections( + select as Record, + connectionFieldsMap, + operationName, + ) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ name: 'affectedCount' }), + t.field({ + name: 'returning', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + where, + }, + }, + }; +} diff --git a/graphql/codegen/src/core/codegen/templates/select-types.ts b/graphql/codegen/src/core/codegen/templates/select-types.ts index be6375b2f3..4b07f61702 100644 --- a/graphql/codegen/src/core/codegen/templates/select-types.ts +++ b/graphql/codegen/src/core/codegen/templates/select-types.ts @@ -61,6 +61,34 @@ export interface DeleteArgs { select?: TSelect; } +export interface BulkInsertArgs { + data: TData[]; + select?: TSelect; + onConflict?: TOnConflict; +} + +export interface BulkUpsertArgs { + data: TData[]; + select?: TSelect; + onConflict: TOnConflict; +} + +export interface BulkUpdateArgs { + where: TWhere; + data: TData; + select?: TSelect; +} + +export interface BulkDeleteArgs { + where: TWhere; + select?: TSelect; +} + +export interface BulkMutationResult { + affectedCount: number; + returning: T[]; +} + type DepthLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; type DecrementDepth = { 0: 0; diff --git a/graphql/codegen/src/core/codegen/utils.ts b/graphql/codegen/src/core/codegen/utils.ts index 02fc72639f..ef7ff30c46 100644 --- a/graphql/codegen/src/core/codegen/utils.ts +++ b/graphql/codegen/src/core/codegen/utils.ts @@ -210,6 +210,46 @@ export function getDeleteMutationName(table: Table): string { return table.query?.delete || `delete${table.name}`; } +// ============================================================================ +// Bulk mutation naming helpers +// ============================================================================ + +export function getBulkCreateMutationHookName(table: Table): string { + const { typeName } = getTableNames(table); + return `useBulkCreate${typeName}Mutation`; +} + +export function getBulkUpsertMutationHookName(table: Table): string { + const { typeName } = getTableNames(table); + return `useBulkUpsert${typeName}Mutation`; +} + +export function getBulkUpdateMutationHookName(table: Table): string { + const { typeName } = getTableNames(table); + return `useBulkUpdate${typeName}Mutation`; +} + +export function getBulkDeleteMutationHookName(table: Table): string { + const { typeName } = getTableNames(table); + return `useBulkDelete${typeName}Mutation`; +} + +export function getBulkCreateMutationFileName(table: Table): string { + return `${getBulkCreateMutationHookName(table)}.ts`; +} + +export function getBulkUpsertMutationFileName(table: Table): string { + return `${getBulkUpsertMutationHookName(table)}.ts`; +} + +export function getBulkUpdateMutationFileName(table: Table): string { + return `${getBulkUpdateMutationHookName(table)}.ts`; +} + +export function getBulkDeleteMutationFileName(table: Table): string { + return `${getBulkDeleteMutationHookName(table)}.ts`; +} + // ============================================================================ // Type names // ============================================================================ diff --git a/graphql/codegen/src/types/schema.ts b/graphql/codegen/src/types/schema.ts index cb84188614..6c569953ea 100644 --- a/graphql/codegen/src/types/schema.ts +++ b/graphql/codegen/src/types/schema.ts @@ -88,6 +88,14 @@ export interface TableQueryNames { delete: string | null; /** Patch field name in update mutation input (e.g., "userPatch" for UpdateUserInput) */ patchFieldName?: string; + /** Bulk insert mutation name (e.g., "bulkCreateUsers") */ + bulkInsert?: string | null; + /** Bulk upsert mutation name (e.g., "bulkUpsertUsers") */ + bulkUpsert?: string | null; + /** Bulk update mutation name (e.g., "bulkUpdateUsers") */ + bulkUpdate?: string | null; + /** Bulk delete mutation name (e.g., "bulkDeleteUsers") */ + bulkDelete?: string | null; } /** diff --git a/graphql/query/src/introspect/infer-tables.ts b/graphql/query/src/introspect/infer-tables.ts index c78f84cb84..e5b78652c4 100644 --- a/graphql/query/src/introspect/infer-tables.ts +++ b/graphql/query/src/introspect/infer-tables.ts @@ -319,6 +319,10 @@ function buildCleanTable( update: mutationOps.update, delete: mutationOps.delete, patchFieldName, + bulkInsert: mutationOps.bulkInsert, + bulkUpsert: mutationOps.bulkUpsert, + bulkUpdate: mutationOps.bulkUpdate, + bulkDelete: mutationOps.bulkDelete, }; // Extract description from entity type (PostgreSQL COMMENT), strip smart comments @@ -689,6 +693,10 @@ interface MutationOperations { create: string | null; update: string | null; delete: string | null; + bulkInsert: string | null; + bulkUpsert: string | null; + bulkUpdate: string | null; + bulkDelete: string | null; } /** @@ -698,6 +706,10 @@ interface MutationOperations { * - create{EntityName} * - update{EntityName} or update{EntityName}ById * - delete{EntityName} or delete{EntityName}ById + * - bulkCreate{PluralName} (bulk insert) + * - bulkUpsert{PluralName} (bulk upsert) + * - bulkUpdate{PluralName} (bulk update) + * - bulkDelete{PluralName} (bulk delete) */ function matchMutationOperations( entityName: string, @@ -706,11 +718,22 @@ function matchMutationOperations( let create: string | null = null; let update: string | null = null; let del: string | null = null; + let bulkInsert: string | null = null; + let bulkUpsert: string | null = null; + let bulkUpdate: string | null = null; + let bulkDelete: string | null = null; const expectedCreate = `create${entityName}`; const expectedUpdate = `update${entityName}`; const expectedDelete = `delete${entityName}`; + // Bulk mutation patterns use plural form: bulkCreate{Plural} + const pluralName = pluralize(entityName); + const expectedBulkInsert = `bulkCreate${pluralName}`; + const expectedBulkUpsert = `bulkUpsert${pluralName}`; + const expectedBulkUpdate = `bulkUpdate${pluralName}`; + const expectedBulkDelete = `bulkDelete${pluralName}`; + for (const field of mutationFields) { // Exact match for create if (field.name === expectedCreate) { @@ -738,9 +761,23 @@ function matchMutationOperations( ) { del = field.name; } + + // Bulk mutations + if (field.name === expectedBulkInsert) { + bulkInsert = field.name; + } + if (field.name === expectedBulkUpsert) { + bulkUpsert = field.name; + } + if (field.name === expectedBulkUpdate) { + bulkUpdate = field.name; + } + if (field.name === expectedBulkDelete) { + bulkDelete = field.name; + } } - return { create, update, delete: del }; + return { create, update, delete: del, bulkInsert, bulkUpsert, bulkUpdate, bulkDelete }; } // ============================================================================ diff --git a/graphql/query/src/introspect/transform-schema.ts b/graphql/query/src/introspect/transform-schema.ts index 4bffb2dd31..c0bc501d7a 100644 --- a/graphql/query/src/introspect/transform-schema.ts +++ b/graphql/query/src/introspect/transform-schema.ts @@ -347,6 +347,10 @@ export function getTableOperationNames( create: string; update: string | null; delete: string | null; + bulkInsert?: string | null; + bulkUpsert?: string | null; + bulkUpdate?: string | null; + bulkDelete?: string | null; }; }>, ): TableOperationNames { @@ -365,6 +369,12 @@ export function getTableOperationNames( mutations.add(table.query.create); if (table.query.update) mutations.add(table.query.update); if (table.query.delete) mutations.add(table.query.delete); + + // Add bulk mutation names + if (table.query.bulkInsert) mutations.add(table.query.bulkInsert); + if (table.query.bulkUpsert) mutations.add(table.query.bulkUpsert); + if (table.query.bulkUpdate) mutations.add(table.query.bulkUpdate); + if (table.query.bulkDelete) mutations.add(table.query.bulkDelete); } } diff --git a/graphql/query/src/types/schema.ts b/graphql/query/src/types/schema.ts index cb84188614..6c569953ea 100644 --- a/graphql/query/src/types/schema.ts +++ b/graphql/query/src/types/schema.ts @@ -88,6 +88,14 @@ export interface TableQueryNames { delete: string | null; /** Patch field name in update mutation input (e.g., "userPatch" for UpdateUserInput) */ patchFieldName?: string; + /** Bulk insert mutation name (e.g., "bulkCreateUsers") */ + bulkInsert?: string | null; + /** Bulk upsert mutation name (e.g., "bulkUpsertUsers") */ + bulkUpsert?: string | null; + /** Bulk update mutation name (e.g., "bulkUpdateUsers") */ + bulkUpdate?: string | null; + /** Bulk delete mutation name (e.g., "bulkDeleteUsers") */ + bulkDelete?: string | null; } /** From da5ddc94ff6ba862c0c0583bef51cb11da74406d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 13 May 2026 01:12:00 +0000 Subject: [PATCH 2/2] test: update select-types snapshot to include bulk mutation types --- .../client-generator.test.ts.snap | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap index 41c85c7c33..ab3a75438a 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap @@ -426,6 +426,34 @@ export interface DeleteArgs { select?: TSelect; } +export interface BulkInsertArgs { + data: TData[]; + select?: TSelect; + onConflict?: TOnConflict; +} + +export interface BulkUpsertArgs { + data: TData[]; + select?: TSelect; + onConflict: TOnConflict; +} + +export interface BulkUpdateArgs { + where: TWhere; + data: TData; + select?: TSelect; +} + +export interface BulkDeleteArgs { + where: TWhere; + select?: TSelect; +} + +export interface BulkMutationResult { + affectedCount: number; + returning: T[]; +} + type DepthLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; type DecrementDepth = { 0: 0;