From 0bda790694a009578014eebfdbabd5aac13084d1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 21 Mar 2026 10:08:20 +0000 Subject: [PATCH] fix: composite PK detection + filter type-safety from schema source of truth Issue 1: buildTableFilterProperties now uses the schema's filter input type as the sole source of truth (via TypeRegistry), capturing plugin-injected filter fields (bm25Body, tsvTsv, trgmName, vectorEmbedding, geom, etc.) that are not present on the entity type. Same pattern as buildOrderByValues. Adds collectFilterExtraInputTypes to discover custom filter types referenced by plugin fields. Issue 2: inferPrimaryKeyFromInputObject now returns all candidate key fields instead of null when multiple exist, enabling composite PK detection for junction tables like PostTag(postId, tagId). Prefers DeleteInput over UpdateInput as signal source (fewer non-PK fields to filter). --- .../core/codegen/orm/input-types-generator.ts | 105 +++++++++++++++++- graphql/query/src/introspect/infer-tables.ts | 37 +++--- 2 files changed, 117 insertions(+), 25 deletions(-) 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 4c0475b1b2..c732816419 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 c45b831f72..7659b0c460 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; } /**