From b68856b22b78e2ad24a5e7fdcf687462e239c98a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 11 Jun 2026 09:11:06 +0000 Subject: [PATCH] fix(codegen): emit INPUT_OBJECT types nested inside CRUD entity inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a table's Create/Patch entity input contains fields referencing non-scalar INPUT_OBJECT types (e.g. requiredConfigs: [FunctionRequirementInput]), the ORM input-types generator would inline the type name as a string but never generate its interface definition. This caused TypeScript build failures in downstream SDK packages. Root cause: collectInputTypeNames only scanned operation args and table field args, not the fields inside CRUD entity input types (CreateXInput → XInput → nested input types). Fix: adds collectCrudNestedInputTypes() which traverses Create/Patch entity input fields recursively and collects any referenced INPUT_OBJECT types, merging them into the custom input types generation pass. Adds two tests covering the collection and full output generation. --- .../codegen/input-types-generator.test.ts | 141 ++++++++++++++++++ .../core/codegen/orm/input-types-generator.ts | 55 +++++++ 2 files changed, 196 insertions(+) diff --git a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts index 6c6038ba4..f267bef10 100644 --- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts @@ -7,6 +7,7 @@ */ // Jest globals - no import needed import { + collectCrudNestedInputTypes, collectInputTypeNames, collectPayloadTypeNames, generateInputTypesFile, @@ -1095,3 +1096,143 @@ describe('table field argument input types', () => { expect(withTables.has('FooBulkUploadFileInput')).toBe(true); }); }); + +describe('CRUD nested input types', () => { + it('collects INPUT_OBJECT types referenced by Create entity input fields', () => { + const table = createTable({ + name: 'PlatformFunctionDefinition', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + ], + query: { + all: 'platformFunctionDefinitions', + one: 'platformFunctionDefinition', + create: 'createPlatformFunctionDefinition', + update: 'updatePlatformFunctionDefinition', + delete: 'deletePlatformFunctionDefinition', + }, + }); + + const registry = createTypeRegistry({ + CreatePlatformFunctionDefinitionInput: { + kind: 'INPUT_OBJECT', + name: 'CreatePlatformFunctionDefinitionInput', + inputFields: [ + { + name: 'platformFunctionDefinition', + type: createNonNull(createTypeRef('INPUT_OBJECT', 'PlatformFunctionDefinitionInput')), + }, + ], + }, + PlatformFunctionDefinitionInput: { + kind: 'INPUT_OBJECT', + name: 'PlatformFunctionDefinitionInput', + inputFields: [ + { + name: 'name', + type: createTypeRef('SCALAR', 'String'), + }, + { + name: 'requiredConfigs', + type: createList(createTypeRef('INPUT_OBJECT', 'FunctionRequirementInput')), + }, + ], + }, + FunctionRequirementInput: { + kind: 'INPUT_OBJECT', + name: 'FunctionRequirementInput', + inputFields: [ + { + name: 'name', + type: createTypeRef('SCALAR', 'String'), + }, + { + name: 'required', + type: createTypeRef('SCALAR', 'Boolean'), + }, + ], + }, + }); + + const nested = collectCrudNestedInputTypes([table], registry); + expect(nested.has('FunctionRequirementInput')).toBe(true); + }); + + it('generates FunctionRequirementInput interface in input-types output', () => { + const table = createTable({ + name: 'PlatformFunctionDefinition', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + ], + query: { + all: 'platformFunctionDefinitions', + one: 'platformFunctionDefinition', + create: 'createPlatformFunctionDefinition', + update: 'updatePlatformFunctionDefinition', + delete: 'deletePlatformFunctionDefinition', + }, + }); + + const registry = createTypeRegistry({ + CreatePlatformFunctionDefinitionInput: { + kind: 'INPUT_OBJECT', + name: 'CreatePlatformFunctionDefinitionInput', + inputFields: [ + { + name: 'platformFunctionDefinition', + type: createNonNull(createTypeRef('INPUT_OBJECT', 'PlatformFunctionDefinitionInput')), + }, + ], + }, + PlatformFunctionDefinitionInput: { + kind: 'INPUT_OBJECT', + name: 'PlatformFunctionDefinitionInput', + inputFields: [ + { + name: 'name', + type: createTypeRef('SCALAR', 'String'), + }, + { + name: 'requiredConfigs', + type: createList(createTypeRef('INPUT_OBJECT', 'FunctionRequirementInput')), + }, + ], + }, + PlatformFunctionDefinitionPatch: { + kind: 'INPUT_OBJECT', + name: 'PlatformFunctionDefinitionPatch', + inputFields: [ + { + name: 'name', + type: createTypeRef('SCALAR', 'String'), + }, + { + name: 'requiredConfigs', + type: createList(createTypeRef('INPUT_OBJECT', 'FunctionRequirementInput')), + }, + ], + }, + FunctionRequirementInput: { + kind: 'INPUT_OBJECT', + name: 'FunctionRequirementInput', + inputFields: [ + { + name: 'name', + type: createTypeRef('SCALAR', 'String'), + }, + { + name: 'required', + type: createTypeRef('SCALAR', 'Boolean'), + }, + ], + }, + }); + + const result = generateInputTypesFile(registry, new Set(), [table]); + expect(result.content).toContain('export interface FunctionRequirementInput {'); + expect(result.content).toContain('name?: string;'); + expect(result.content).toContain('required?: boolean;'); + }); +}); diff --git a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts index 99539e6c4..ea1292110 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -1598,6 +1598,55 @@ export function collectInputTypeNames( return inputTypes; } +/** + * Collect input type names referenced by CRUD entity input fields. + * + * When a table's Create/Patch input contains fields that reference non-scalar + * input types (e.g. `requiredConfigs: [FunctionRequirementInput]`), those + * types must be generated. The CRUD generator inlines the field types as + * strings but doesn't track which input types they reference. + */ +export function collectCrudNestedInputTypes( + tables: Table[], + typeRegistry: TypeRegistry, +): Set { + const nestedTypes = new Set(); + + for (const table of tables) { + // Check Create entity input + const createInputTypeName = getCreateInputTypeName(table); + collectNestedInputTypesFromInput(createInputTypeName, typeRegistry, nestedTypes); + + // Check Patch entity input + const patchTypeName = getPatchTypeName(table); + collectNestedInputTypesFromInput(patchTypeName, typeRegistry, nestedTypes); + } + + return nestedTypes; +} + +function collectNestedInputTypesFromInput( + inputTypeName: string, + typeRegistry: TypeRegistry, + collected: Set, +): void { + const inputType = typeRegistry.get(inputTypeName); + if (!inputType || inputType.kind !== 'INPUT_OBJECT' || !inputType.inputFields) return; + + for (const field of inputType.inputFields) { + const baseName = getTypeBaseName(field.type); + if (!baseName) continue; + if (SCALAR_NAMES.has(baseName)) continue; + if (collected.has(baseName)) continue; + + const refType = typeRegistry.get(baseName); + if (refType?.kind === 'INPUT_OBJECT') { + collected.add(baseName); + collectNestedInputTypesFromInput(baseName, typeRegistry, collected); + } + } +} + /** * Build a set of exact table CRUD input type names to skip * These are generated by generateAllCrudInputTypes, so we don't need to regenerate them @@ -2084,6 +2133,12 @@ export function generateInputTypesFile( for (const typeName of fieldArgTypes) { mergedUsedInputTypes.add(typeName); } + // Collect input types nested inside CRUD entity inputs (e.g. FunctionRequirementInput + // referenced by Create/Patch fields) that the CRUD generator inlines as type strings + const crudNestedTypes = collectCrudNestedInputTypes(tablesList, typeRegistry); + for (const typeName of crudNestedTypes) { + mergedUsedInputTypes.add(typeName); + } } const tableCrudTypes = tables ? buildTableCrudTypeNames(tables) : undefined; // Pass customScalarTypes + enumTypes as already-generated to avoid duplicate declarations