From 3c4a91a85ab2290fd2aa54c3359a701c3db8f23e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 12 Mar 2026 01:04:27 +0000 Subject: [PATCH 1/2] fix(codegen): include plugin-injected fields in condition and orderBy types Previously, buildTableConditionProperties and buildOrderByValues only iterated table.fields (database columns), missing any fields injected by Graphile plugins (e.g., VectorSearchPlugin's embeddingNearby condition field and EMBEDDING_DISTANCE_ASC/DESC orderBy values). Changes: - Add Vector scalar type mapping (number[]) to SCALAR_TS_MAP - Modify buildTableConditionProperties to merge plugin-added fields from the TypeRegistry's condition type - Modify buildOrderByValues to merge plugin-added enum values from the TypeRegistry's orderBy type - Add collectConditionExtraInputTypes to discover and generate referenced input types (e.g., VectorNearbyInput, VectorMetric) - Pass typeRegistry through to condition/orderBy generators - Add comprehensive tests for plugin-injected condition and orderBy types Fixes: constructive-io/constructive-planning#663 --- .../codegen/input-types-generator.test.ts | 166 ++++++++++++++++++ .../core/codegen/orm/input-types-generator.ts | 146 +++++++++++++-- graphql/codegen/src/core/codegen/scalars.ts | 3 + 3 files changed, 304 insertions(+), 11 deletions(-) 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 8e0c7d8de..25c8f136e 100644 --- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts @@ -877,6 +877,172 @@ describe('collectPayloadTypeNames', () => { }); }); +// ============================================================================ +// Tests - Plugin-Injected Condition Fields (e.g., VectorSearchPlugin) +// ============================================================================ + +describe('plugin-injected condition fields', () => { + /** + * Simulates a table with a vector embedding column. + * The VectorSearchPlugin adds extra condition fields (e.g., embeddingNearby) + * that are NOT derived from the table's own columns, but are injected into + * the GraphQL schema's condition type via plugin hooks. + */ + const contactTable = createTable({ + name: 'Contact', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + { name: 'email', type: fieldTypes.string }, + { + name: 'embedding', + type: { gqlType: 'Vector', isArray: false } as CleanFieldType, + }, + ], + query: { + all: 'contacts', + one: 'contact', + create: 'createContact', + update: 'updateContact', + delete: 'deleteContact', + }, + }); + + it('includes plugin-injected condition fields from TypeRegistry', () => { + // Registry simulates what PostGraphile + VectorSearchPlugin produce: + // The ContactCondition type has the regular columns PLUS an extra + // "embeddingNearby" field of type VectorNearbyInput injected by the plugin. + const registry = createTypeRegistry({ + ContactCondition: { + kind: 'INPUT_OBJECT', + name: 'ContactCondition', + inputFields: [ + { name: 'id', type: createTypeRef('SCALAR', 'UUID') }, + { name: 'name', type: createTypeRef('SCALAR', 'String') }, + { name: 'email', type: createTypeRef('SCALAR', 'String') }, + { name: 'embedding', type: createTypeRef('SCALAR', 'Vector') }, + { + name: 'embeddingNearby', + type: createTypeRef('INPUT_OBJECT', 'VectorNearbyInput'), + description: 'Find contacts near a vector embedding', + }, + ], + }, + VectorNearbyInput: { + kind: 'INPUT_OBJECT', + name: 'VectorNearbyInput', + inputFields: [ + { + name: 'vector', + type: createNonNull(createTypeRef('SCALAR', 'Vector')), + }, + { + name: 'metric', + type: createTypeRef('ENUM', 'VectorMetric'), + }, + { + name: 'threshold', + type: createTypeRef('SCALAR', 'Float'), + }, + ], + }, + VectorMetric: { + kind: 'ENUM', + name: 'VectorMetric', + enumValues: ['L2', 'INNER_PRODUCT', 'COSINE'], + }, + }); + + const result = generateInputTypesFile(registry, new Set(), [contactTable]); + + // Regular table column fields should still be present + expect(result.content).toContain('export interface ContactCondition {'); + expect(result.content).toContain('id?: string | null;'); + expect(result.content).toContain('name?: string | null;'); + expect(result.content).toContain('email?: string | null;'); + + // Plugin-injected field should also be present + expect(result.content).toContain('embeddingNearby?: VectorNearbyInput'); + + // The referenced VectorNearbyInput type should be generated as a custom input type + expect(result.content).toContain('export interface VectorNearbyInput {'); + }); + + it('does not duplicate fields already derived from table columns', () => { + const registry = createTypeRegistry({ + ContactCondition: { + kind: 'INPUT_OBJECT', + name: 'ContactCondition', + inputFields: [ + { name: 'id', type: createTypeRef('SCALAR', 'UUID') }, + { name: 'name', type: createTypeRef('SCALAR', 'String') }, + { name: 'email', type: createTypeRef('SCALAR', 'String') }, + { name: 'embedding', type: createTypeRef('SCALAR', 'Vector') }, + ], + }, + }); + + const result = generateInputTypesFile(registry, new Set(), [contactTable]); + + // Count occurrences of 'id?' in the ContactCondition interface + const conditionMatch = result.content.match( + /export interface ContactCondition \{([^}]*)\}/s, + ); + expect(conditionMatch).toBeTruthy(); + const conditionBody = conditionMatch![1]; + + // Each field should appear only once + const idOccurrences = (conditionBody.match(/\bid\?/g) || []).length; + expect(idOccurrences).toBe(1); + }); + + it('includes plugin-injected orderBy values from TypeRegistry', () => { + const registry = createTypeRegistry({ + ContactsOrderBy: { + kind: 'ENUM', + name: 'ContactsOrderBy', + enumValues: [ + 'PRIMARY_KEY_ASC', + 'PRIMARY_KEY_DESC', + 'NATURAL', + 'ID_ASC', + 'ID_DESC', + 'NAME_ASC', + 'NAME_DESC', + 'EMAIL_ASC', + 'EMAIL_DESC', + 'EMBEDDING_ASC', + 'EMBEDDING_DESC', + // Plugin-injected values from VectorSearchPlugin + 'EMBEDDING_DISTANCE_ASC', + 'EMBEDDING_DISTANCE_DESC', + ], + }, + }); + + const result = generateInputTypesFile(registry, new Set(), [contactTable]); + + expect(result.content).toContain('export type ContactsOrderBy ='); + // Standard column-derived values + expect(result.content).toContain('"ID_ASC"'); + expect(result.content).toContain('"NAME_DESC"'); + // Plugin-injected values + expect(result.content).toContain('"EMBEDDING_DISTANCE_ASC"'); + expect(result.content).toContain('"EMBEDDING_DISTANCE_DESC"'); + }); + + it('works without typeRegistry (backwards compatible)', () => { + // When no typeRegistry has the condition type, only table columns are used + const result = generateInputTypesFile(new Map(), new Set(), [contactTable]); + + expect(result.content).toContain('export interface ContactCondition {'); + expect(result.content).toContain('id?: string | null;'); + expect(result.content).toContain('name?: string | null;'); + // No plugin-injected fields + expect(result.content).not.toContain('embeddingNearby'); + }); +}); + // ============================================================================ // Tests - Edge Cases // ============================================================================ 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 6d19a7977..8717c7ddb 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -1116,10 +1116,18 @@ function generateTableFilterTypes(tables: CleanTable[]): t.Statement[] { /** * Build properties for a table condition interface - * Condition types are simpler than Filter types - they use direct value equality + * Condition types are simpler than Filter types - they use direct value equality. + * + * Also merges any extra fields from the GraphQL schema's condition type + * (e.g., plugin-injected fields like vectorEmbedding from VectorSearchPlugin) + * that are not derived from the table's own columns. */ -function buildTableConditionProperties(table: CleanTable): InterfaceProperty[] { +function buildTableConditionProperties( + table: CleanTable, + typeRegistry?: TypeRegistry, +): InterfaceProperty[] { const properties: InterfaceProperty[] = []; + const generatedFieldNames = new Set(); for (const field of table.fields) { const fieldType = @@ -1133,6 +1141,30 @@ function buildTableConditionProperties(table: CleanTable): InterfaceProperty[] { type: `${tsType} | null`, optional: true, }); + generatedFieldNames.add(field.name); + } + + // Merge any additional fields from the schema's condition type + // (e.g., plugin-added fields like vectorEmbedding from VectorSearchPlugin) + if (typeRegistry) { + const conditionTypeName = getConditionTypeName(table); + const conditionType = typeRegistry.get(conditionTypeName); + if ( + conditionType?.kind === 'INPUT_OBJECT' && + conditionType.inputFields + ) { + for (const field of conditionType.inputFields) { + if (generatedFieldNames.has(field.name)) continue; + + const tsType = typeRefToTs(field.type); + properties.push({ + name: field.name, + type: tsType, + optional: true, + description: stripSmartComments(field.description, true), + }); + } + } } return properties; @@ -1141,7 +1173,10 @@ function buildTableConditionProperties(table: CleanTable): InterfaceProperty[] { /** * Generate table condition type statements */ -function generateTableConditionTypes(tables: CleanTable[]): t.Statement[] { +function generateTableConditionTypes( + tables: CleanTable[], + typeRegistry?: TypeRegistry, +): t.Statement[] { const statements: t.Statement[] = []; for (const table of tables) { @@ -1149,7 +1184,7 @@ function generateTableConditionTypes(tables: CleanTable[]): t.Statement[] { statements.push( createExportedInterface( conditionName, - buildTableConditionProperties(table), + buildTableConditionProperties(table, typeRegistry), ), ); } @@ -1165,9 +1200,16 @@ function generateTableConditionTypes(tables: CleanTable[]): t.Statement[] { // ============================================================================ /** - * Build OrderBy union type values + * Build OrderBy union type values. + * + * Also merges any extra values from the GraphQL schema's orderBy enum + * (e.g., plugin-injected values like EMBEDDING_DISTANCE_ASC/DESC + * from VectorSearchPlugin). */ -function buildOrderByValues(table: CleanTable): string[] { +function buildOrderByValues( + table: CleanTable, + typeRegistry?: TypeRegistry, +): string[] { const values: string[] = ['PRIMARY_KEY_ASC', 'PRIMARY_KEY_DESC', 'NATURAL']; for (const field of table.fields) { @@ -1177,18 +1219,36 @@ function buildOrderByValues(table: CleanTable): string[] { values.push(`${upperSnake}_DESC`); } + // Merge any additional values from the schema's orderBy enum type + // (e.g., plugin-added values like EMBEDDING_DISTANCE_ASC/DESC) + if (typeRegistry) { + const orderByTypeName = getOrderByTypeName(table); + const orderByType = typeRegistry.get(orderByTypeName); + if (orderByType?.kind === 'ENUM' && orderByType.enumValues) { + const existingValues = new Set(values); + for (const value of orderByType.enumValues) { + if (!existingValues.has(value)) { + values.push(value); + } + } + } + } + return values; } /** * Generate OrderBy type statements */ -function generateOrderByTypes(tables: CleanTable[]): t.Statement[] { +function generateOrderByTypes( + tables: CleanTable[], + typeRegistry?: TypeRegistry, +): t.Statement[] { const statements: t.Statement[] = []; for (const table of tables) { const enumName = getOrderByTypeName(table); - const values = buildOrderByValues(table); + const values = buildOrderByValues(table, typeRegistry); const unionType = createStringLiteralUnion(values); const typeAlias = t.tsTypeAliasDeclaration( t.identifier(enumName), @@ -1841,6 +1901,55 @@ function generateConnectionFieldsMap( return statements; } +// ============================================================================ +// Plugin-Injected Type Collector +// ============================================================================ + +/** + * Collect extra input type names referenced by plugin-injected condition fields. + * + * When plugins (like VectorSearchPlugin) inject fields into condition types, + * they reference types (like VectorNearbyInput, VectorMetric) that also need + * to be generated. This function discovers those types by comparing the + * schema's condition type fields against the table's own columns. + */ +function collectConditionExtraInputTypes( + tables: CleanTable[], + typeRegistry: TypeRegistry, +): Set { + const extraTypes = new Set(); + + for (const table of tables) { + const conditionTypeName = getConditionTypeName(table); + const conditionType = typeRegistry.get(conditionTypeName); + if ( + !conditionType || + conditionType.kind !== 'INPUT_OBJECT' || + !conditionType.inputFields + ) { + continue; + } + + const tableFieldNames = new Set( + table.fields + .filter((f) => !isRelationField(f.name, table)) + .map((f) => f.name), + ); + + for (const field of conditionType.inputFields) { + if (tableFieldNames.has(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; +} + // ============================================================================ // Main Generator (AST-based) // ============================================================================ @@ -1889,10 +1998,14 @@ export function generateInputTypesFile( statements.push(...generateTableFilterTypes(tablesList)); // 4b. Table condition types (simple equality filter) - statements.push(...generateTableConditionTypes(tablesList)); + // Pass typeRegistry to merge plugin-injected condition fields + // (e.g., vectorEmbedding from VectorSearchPlugin) + statements.push(...generateTableConditionTypes(tablesList, typeRegistry)); // 5. OrderBy types - statements.push(...generateOrderByTypes(tablesList)); + // Pass typeRegistry to merge plugin-injected orderBy values + // (e.g., EMBEDDING_DISTANCE_ASC/DESC from VectorSearchPlugin) + statements.push(...generateOrderByTypes(tablesList, typeRegistry)); // 6. CRUD input types statements.push(...generateAllCrudInputTypes(tablesList, typeRegistry)); @@ -1903,9 +2016,20 @@ 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 + const mergedUsedInputTypes = new Set(usedInputTypes); + if (hasTables) { + const conditionExtraTypes = collectConditionExtraInputTypes( + tablesList, + typeRegistry, + ); + for (const typeName of conditionExtraTypes) { + mergedUsedInputTypes.add(typeName); + } + } const tableCrudTypes = tables ? buildTableCrudTypeNames(tables) : undefined; statements.push( - ...generateCustomInputTypes(typeRegistry, usedInputTypes, tableCrudTypes, comments), + ...generateCustomInputTypes(typeRegistry, mergedUsedInputTypes, tableCrudTypes, comments), ); // 8. Payload/return types for custom operations diff --git a/graphql/codegen/src/core/codegen/scalars.ts b/graphql/codegen/src/core/codegen/scalars.ts index 579eb3398..95db6052d 100644 --- a/graphql/codegen/src/core/codegen/scalars.ts +++ b/graphql/codegen/src/core/codegen/scalars.ts @@ -39,6 +39,9 @@ export const SCALAR_TS_MAP: Record = { TsVector: 'string', TsQuery: 'string', + // Vector types (pgvector) + Vector: 'number[]', + // File upload Upload: 'File', }; From 0ffda8eb9f6d56d5a3e34142b6372aa23b1abf23 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 12 Mar 2026 01:23:16 +0000 Subject: [PATCH 2/2] fix(codegen): resolve transitive enum types referenced by input fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, generateCustomInputTypes only followed nested types whose names ended with 'Input'. This meant enum types like VectorMetric (referenced by VectorNearbyInput.metric) were silently dropped from generated output, producing TypeScript with undefined type references. Now follows all non-scalar types that exist in the typeRegistry, including enums and other type kinds. Added unit test verifying transitive enum resolution through VectorNearbyInput → VectorMetric. --- .../codegen/input-types-generator.test.ts | 57 +++++++++++++++++++ .../core/codegen/orm/input-types-generator.ts | 7 ++- 2 files changed, 61 insertions(+), 3 deletions(-) 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 25c8f136e..3746c73fd 100644 --- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts @@ -966,6 +966,63 @@ describe('plugin-injected condition fields', () => { // The referenced VectorNearbyInput type should be generated as a custom input type expect(result.content).toContain('export interface VectorNearbyInput {'); + + // Transitively referenced enum type (VectorMetric) should also be generated + expect(result.content).toContain('VectorMetric'); + expect(result.content).toContain('"L2"'); + expect(result.content).toContain('"INNER_PRODUCT"'); + expect(result.content).toContain('"COSINE"'); + }); + + it('generates transitively referenced enum types from input fields', () => { + // This specifically tests that enum types referenced by input object fields + // are followed and generated, not just types ending with "Input". + // VectorNearbyInput.metric references VectorMetric (an ENUM), + // which must be included in the output. + const registry = createTypeRegistry({ + ContactCondition: { + kind: 'INPUT_OBJECT', + name: 'ContactCondition', + inputFields: [ + { name: 'id', type: createTypeRef('SCALAR', 'UUID') }, + { name: 'name', type: createTypeRef('SCALAR', 'String') }, + { + name: 'embeddingNearby', + type: createTypeRef('INPUT_OBJECT', 'VectorNearbyInput'), + }, + ], + }, + VectorNearbyInput: { + kind: 'INPUT_OBJECT', + name: 'VectorNearbyInput', + inputFields: [ + { + name: 'vector', + type: createNonNull(createTypeRef('SCALAR', 'Vector')), + }, + { + name: 'metric', + type: createTypeRef('ENUM', 'VectorMetric'), + }, + ], + }, + VectorMetric: { + kind: 'ENUM', + name: 'VectorMetric', + enumValues: ['L2', 'INNER_PRODUCT', 'COSINE'], + }, + }); + + const result = generateInputTypesFile(registry, new Set(), [contactTable]); + + // VectorNearbyInput should be generated (follows *Input pattern) + expect(result.content).toContain('export interface VectorNearbyInput {'); + + // VectorMetric enum should ALSO be generated (transitive enum resolution) + expect(result.content).toMatch(/export type VectorMetric\s*=/); + expect(result.content).toContain('"L2"'); + expect(result.content).toContain('"INNER_PRODUCT"'); + expect(result.content).toContain('"COSINE"'); }); it('does not duplicate fields already derived from table columns', () => { 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 8717c7ddb..09b218ad9 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -1616,12 +1616,13 @@ function generateCustomInputTypes( description: stripSmartComments(field.description, comments), }); - // Follow nested Input types + // Follow nested types (Input objects, Enums, etc.) that exist in the registry const baseType = getTypeBaseName(field.type); if ( baseType && - baseType.endsWith('Input') && - !generatedTypes.has(baseType) + !SCALAR_NAMES.has(baseType) && + !generatedTypes.has(baseType) && + typeRegistry.has(baseType) ) { typesToGenerate.add(baseType); }