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 4c0475b1b..c73281641 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -1077,10 +1077,38 @@ function getFilterTypeForField(fieldType: string, isArray = false): string { } /** - * Build properties for a table filter interface + * Build properties for a table filter interface. + * + * When typeRegistry is available, uses the schema's filter input type as + * the sole source of truth — this captures plugin-injected filter fields + * (e.g., bm25Body, tsvTsv, trgmName, vectorEmbedding, geom) that are not + * present on the entity type itself. Same pattern as buildOrderByValues(). */ -function buildTableFilterProperties(table: Table): InterfaceProperty[] { +function buildTableFilterProperties( + table: Table, + typeRegistry?: TypeRegistry, +): InterfaceProperty[] { const filterName = getFilterTypeName(table); + + // When the schema's filter type is available, use it as the source of truth + if (typeRegistry) { + const filterType = typeRegistry.get(filterName); + if (filterType?.kind === 'INPUT_OBJECT' && filterType.inputFields) { + const properties: InterfaceProperty[] = []; + for (const field of filterType.inputFields) { + const tsType = typeRefToTs(field.type); + properties.push({ + name: field.name, + type: tsType, + optional: true, + description: stripSmartComments(field.description, true), + }); + } + return properties; + } + } + + // Fallback: derive from table fields when schema filter type is not available const properties: InterfaceProperty[] = []; for (const field of table.fields) { @@ -1105,13 +1133,19 @@ function buildTableFilterProperties(table: Table): InterfaceProperty[] { /** * Generate table filter type statements */ -function generateTableFilterTypes(tables: Table[]): t.Statement[] { +function generateTableFilterTypes( + tables: Table[], + typeRegistry?: TypeRegistry, +): t.Statement[] { const statements: t.Statement[] = []; for (const table of tables) { const filterName = getFilterTypeName(table); statements.push( - createExportedInterface(filterName, buildTableFilterProperties(table)), + createExportedInterface( + filterName, + buildTableFilterProperties(table, typeRegistry), + ), ); } @@ -1956,6 +1990,54 @@ function generateConnectionFieldsMap( // Plugin-Injected Type Collector // ============================================================================ +/** + * Collect extra input type names referenced by plugin-injected filter fields. + * + * When the schema's filter type is used as source of truth, plugin-injected + * fields reference custom filter types (e.g., Bm25BodyFilter, TsvectorFilter, + * GeometryFilter) that also need to be generated. This function discovers + * those types by comparing the schema's filter type fields against the + * standard scalar filter types. + */ +function collectFilterExtraInputTypes( + tables: Table[], + typeRegistry: TypeRegistry, +): Set { + const extraTypes = new Set(); + + for (const table of tables) { + const filterTypeName = getFilterTypeName(table); + const filterType = typeRegistry.get(filterTypeName); + if ( + !filterType || + filterType.kind !== 'INPUT_OBJECT' || + !filterType.inputFields + ) { + continue; + } + + const tableFieldNames = new Set( + table.fields + .filter((f) => !isRelationField(f.name, table)) + .map((f) => f.name), + ); + + for (const field of filterType.inputFields) { + // Skip standard column-derived fields and logical operators + if (tableFieldNames.has(field.name)) continue; + if (['and', 'or', 'not'].includes(field.name)) continue; + + // Collect the base type name of this extra field + const baseName = getTypeBaseName(field.type); + if (baseName && !SCALAR_NAMES.has(baseName)) { + extraTypes.add(baseName); + } + } + } + + return extraTypes; +} + /** * Collect extra input type names referenced by plugin-injected condition fields. * @@ -2048,7 +2130,9 @@ export function generateInputTypesFile( statements.push(...generateEntitySelectTypes(tablesList, tableByName)); // 4. Table filter types - statements.push(...generateTableFilterTypes(tablesList)); + // Pass typeRegistry to use schema's filter type as source of truth, + // capturing plugin-injected filter fields (e.g., bm25, tsvector, trgm, vector, geom) + statements.push(...generateTableFilterTypes(tablesList, typeRegistry)); // 4b. Table condition types (simple equality filter) // Pass typeRegistry to merge plugin-injected condition fields @@ -2071,8 +2155,17 @@ export function generateInputTypesFile( statements.push(...generateConnectionFieldsMap(tablesList, tableByName)); // 7. Custom input types from TypeRegistry - // Also include any extra types referenced by plugin-injected condition fields + // Also include any extra types referenced by plugin-injected filter/condition fields const mergedUsedInputTypes = new Set(usedInputTypes); + if (hasTables) { + const filterExtraTypes = collectFilterExtraInputTypes( + tablesList, + typeRegistry, + ); + for (const typeName of filterExtraTypes) { + mergedUsedInputTypes.add(typeName); + } + } if (hasTables && conditionEnabled) { const conditionExtraTypes = collectConditionExtraInputTypes( tablesList, diff --git a/graphql/query/src/introspect/infer-tables.ts b/graphql/query/src/introspect/infer-tables.ts index c45b831f7..7659b0c46 100644 --- a/graphql/query/src/introspect/infer-tables.ts +++ b/graphql/query/src/introspect/infer-tables.ts @@ -723,19 +723,19 @@ function inferConstraints( const updateInput = typeMap.get(updateInputName); const deleteInput = typeMap.get(deleteInputName); - const keyInputField = - inferPrimaryKeyFromInputObject(updateInput) || - inferPrimaryKeyFromInputObject(deleteInput); + // Prefer Delete input (fewer non-PK fields) over Update input + const keyFields = + inferPrimaryKeyFromInputObject(deleteInput).length > 0 + ? inferPrimaryKeyFromInputObject(deleteInput) + : inferPrimaryKeyFromInputObject(updateInput); - if (keyInputField) { + if (keyFields.length > 0) { primaryKey.push({ name: 'primary', - fields: [ - { - name: keyInputField.name, - type: convertToCleanFieldType(keyInputField.type), - }, - ], + fields: keyFields.map((f) => ({ + name: f.name, + type: convertToCleanFieldType(f.type), + })), }); } @@ -769,27 +769,28 @@ function inferConstraints( } /** - * Infer a single-row lookup key from an Update/Delete input object. + * Infer primary key fields from an Update/Delete input object. * * Priority: * 1. Canonical keys: id, nodeId, rowId - * 2. Single non-patch/non-clientMutationId scalar-ish field + * 2. All non-patch/non-clientMutationId fields (supports composite keys) * - * If multiple possible key fields remain, return null to avoid guessing. + * Returns all candidate key fields, enabling composite PK detection + * for junction tables like PostTag(postId, tagId). */ function inferPrimaryKeyFromInputObject( inputType: IntrospectionType | undefined, -): IntrospectionInputValue | null { +): IntrospectionInputValue[] { const inputFields = inputType?.inputFields ?? []; - if (inputFields.length === 0) return null; + if (inputFields.length === 0) return []; const canonicalKey = inputFields.find( (field) => field.name === 'id' || field.name === 'nodeId' || field.name === 'rowId', ); - if (canonicalKey) return canonicalKey; + if (canonicalKey) return [canonicalKey]; - const candidates = inputFields.filter((field) => { + return inputFields.filter((field) => { if (field.name === 'clientMutationId') return false; const baseTypeName = getBaseTypeName(field.type); @@ -801,8 +802,6 @@ function inferPrimaryKeyFromInputObject( return true; }); - - return candidates.length === 1 ? candidates[0] : null; } /**