From 4f5e52ff15e3a1978a4a63ea8ff5eeae53674707 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 12 Mar 2026 01:17:14 +0000 Subject: [PATCH 1/2] test(codegen): add vector schema integration test and fix transitive type resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add example-vector.schema.graphql fixture with Contact/Document entities, Vector scalar, VectorNearbyInput, VectorMetric enum, plugin-injected condition fields (embeddingNearby, contentEmbeddingNearby), and plugin-injected orderBy values (EMBEDDING_DISTANCE_ASC/DESC) - Add vector-codegen-integration.test.ts that exercises the full codegen pipeline (FileSchemaSource → runCodegenPipeline → generateOrm) and verifies: - Tables are inferred correctly from vector schema - TypeRegistry includes vector-related types - Generated output maps Vector scalar to number[] - Condition types include plugin-injected fields - OrderBy types include distance-based sorting values - VectorNearbyInput and VectorMetric types are generated - Standard CRUD and filter types are still generated correctly - Fix transitive type resolution in generateCustomInputTypes: follow all non-scalar types referenced by input fields (not just types ending with 'Input'), so enum types like VectorMetric referenced by VectorNearbyInput are correctly included in generated output --- .../examples/example-vector.schema.graphql | 356 ++++++++++++++++++ .../vector-codegen-integration.test.ts | 217 +++++++++++ .../core/codegen/orm/input-types-generator.ts | 7 +- 3 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 graphql/codegen/examples/example-vector.schema.graphql create mode 100644 graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts diff --git a/graphql/codegen/examples/example-vector.schema.graphql b/graphql/codegen/examples/example-vector.schema.graphql new file mode 100644 index 000000000..e3d6bc4af --- /dev/null +++ b/graphql/codegen/examples/example-vector.schema.graphql @@ -0,0 +1,356 @@ +"""A universally unique identifier as defined by [RFC 4122](https://tools.ietf.org/html/rfc4122).""" +scalar UUID + +""" +A point in time as described by the [ISO +8601](https://en.wikipedia.org/wiki/ISO_8601) standard. May or may not include a timezone. +""" +scalar Datetime + +"""A location as described by the [GeoJSON](https://geojson.org/) format.""" +scalar JSON + +"""A string representing a cursor for pagination.""" +scalar Cursor + +"""A vector embedding type from pgvector.""" +scalar Vector + +"""The root query type.""" +type Query { + """Reads and enables pagination through a set of `Contact`.""" + contacts( + first: Int + last: Int + offset: Int + before: Cursor + after: Cursor + orderBy: [ContactsOrderBy!] = [PRIMARY_KEY_ASC] + filter: ContactFilter + condition: ContactCondition + ): ContactsConnection + + """Reads a single `Contact` using its globally unique `ID`.""" + contact(id: UUID!): Contact + + """Reads and enables pagination through a set of `Document`.""" + documents( + first: Int + last: Int + offset: Int + before: Cursor + after: Cursor + orderBy: [DocumentsOrderBy!] = [PRIMARY_KEY_ASC] + filter: DocumentFilter + condition: DocumentCondition + ): DocumentsConnection + + """Reads a single `Document` using its globally unique `ID`.""" + document(id: UUID!): Document +} + +"""The root mutation type.""" +type Mutation { + """Creates a single `Contact`.""" + createContact(input: CreateContactInput!): CreateContactPayload + + """Updates a single `Contact` using its globally unique `ID`.""" + updateContact(input: UpdateContactInput!): UpdateContactPayload + + """Deletes a single `Contact` using its globally unique `ID`.""" + deleteContact(input: DeleteContactInput!): DeleteContactPayload + + """Creates a single `Document`.""" + createDocument(input: CreateDocumentInput!): CreateDocumentPayload + + """Updates a single `Document` using its globally unique `ID`.""" + updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload + + """Deletes a single `Document` using its globally unique `ID`.""" + deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload +} + +# ============================================================================ +# Entity Types +# ============================================================================ + +type Contact { + id: UUID! + name: String! + email: String! + embedding: Vector + createdAt: Datetime! + updatedAt: Datetime +} + +type Document { + id: UUID! + title: String! + content: String + contentEmbedding: Vector + createdAt: Datetime! +} + +# ============================================================================ +# Enums +# ============================================================================ + +"""Metric used for vector distance calculations.""" +enum VectorMetric { + L2 + INNER_PRODUCT + COSINE +} + +enum ContactsOrderBy { + NATURAL + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC + ID_ASC + ID_DESC + NAME_ASC + NAME_DESC + EMAIL_ASC + EMAIL_DESC + CREATED_AT_ASC + CREATED_AT_DESC + """Sort by vector distance to a reference embedding (ascending).""" + EMBEDDING_DISTANCE_ASC + """Sort by vector distance to a reference embedding (descending).""" + EMBEDDING_DISTANCE_DESC +} + +enum DocumentsOrderBy { + NATURAL + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC + ID_ASC + ID_DESC + TITLE_ASC + TITLE_DESC + CREATED_AT_ASC + CREATED_AT_DESC + """Sort by content embedding distance (ascending).""" + CONTENT_EMBEDDING_DISTANCE_ASC + """Sort by content embedding distance (descending).""" + CONTENT_EMBEDDING_DISTANCE_DESC +} + +# ============================================================================ +# Connection Types +# ============================================================================ + +type ContactsConnection { + nodes: [Contact!]! + edges: [ContactsEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type ContactsEdge { + node: Contact! + cursor: Cursor! +} + +type DocumentsConnection { + nodes: [Document!]! + edges: [DocumentsEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type DocumentsEdge { + node: Document! + cursor: Cursor! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: Cursor + endCursor: Cursor +} + +# ============================================================================ +# Filter Types +# ============================================================================ + +input ContactFilter { + id: UUIDFilter + name: StringFilter + email: StringFilter + and: [ContactFilter!] + or: [ContactFilter!] + not: ContactFilter +} + +input DocumentFilter { + id: UUIDFilter + title: StringFilter + and: [DocumentFilter!] + or: [DocumentFilter!] + not: DocumentFilter +} + +input UUIDFilter { + equalTo: UUID + notEqualTo: UUID + in: [UUID!] + notIn: [UUID!] + isNull: Boolean +} + +input StringFilter { + equalTo: String + notEqualTo: String + in: [String!] + notIn: [String!] + includes: String + startsWith: String + endsWith: String + isNull: Boolean +} + +input BooleanFilter { + equalTo: Boolean + notEqualTo: Boolean + isNull: Boolean +} + +# ============================================================================ +# Condition Types (includes plugin-injected vector search fields) +# ============================================================================ + +""" +Condition type for Contact table. +Includes standard column equality fields plus plugin-injected vector search fields. +""" +input ContactCondition { + id: UUID + name: String + email: String + embedding: Vector + + """Find contacts whose embedding is near the given vector (injected by VectorSearchPlugin).""" + embeddingNearby: VectorNearbyInput +} + +""" +Condition type for Document table. +Includes standard column equality fields plus plugin-injected vector search fields. +""" +input DocumentCondition { + id: UUID + title: String + contentEmbedding: Vector + + """Find documents whose content embedding is near the given vector (injected by VectorSearchPlugin).""" + contentEmbeddingNearby: VectorNearbyInput +} + +""" +Input type for vector similarity search. +Injected by VectorSearchPlugin into condition types for vector columns. +""" +input VectorNearbyInput { + """The reference vector to compare against.""" + vector: Vector! + """The distance metric to use for comparison.""" + metric: VectorMetric + """Maximum distance threshold.""" + threshold: Float +} + +# ============================================================================ +# Mutation Input Types +# ============================================================================ + +input CreateContactInput { + clientMutationId: String + contact: ContactInput! +} + +input ContactInput { + name: String! + email: String! + embedding: Vector +} + +input UpdateContactInput { + clientMutationId: String + id: UUID! + patch: ContactPatch! +} + +input ContactPatch { + name: String + email: String + embedding: Vector +} + +input DeleteContactInput { + clientMutationId: String + id: UUID! +} + +input CreateDocumentInput { + clientMutationId: String + document: DocumentInput! +} + +input DocumentInput { + title: String! + content: String + contentEmbedding: Vector +} + +input UpdateDocumentInput { + clientMutationId: String + id: UUID! + patch: DocumentPatch! +} + +input DocumentPatch { + title: String + content: String + contentEmbedding: Vector +} + +input DeleteDocumentInput { + clientMutationId: String + id: UUID! +} + +# ============================================================================ +# Mutation Payload Types +# ============================================================================ + +type CreateContactPayload { + clientMutationId: String + contact: Contact +} + +type UpdateContactPayload { + clientMutationId: String + contact: Contact +} + +type DeleteContactPayload { + clientMutationId: String + contact: Contact +} + +type CreateDocumentPayload { + clientMutationId: String + document: Document +} + +type UpdateDocumentPayload { + clientMutationId: String + document: Document +} + +type DeleteDocumentPayload { + clientMutationId: String + document: Document +} diff --git a/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts b/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts new file mode 100644 index 000000000..635a9a9d9 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts @@ -0,0 +1,217 @@ +/** + * Integration test for vector (pgvector) codegen support. + * + * Uses the example-vector.schema.graphql fixture which includes: + * - Vector scalar type + * - Plugin-injected condition fields (embeddingNearby, contentEmbeddingNearby) + * - Plugin-injected orderBy values (EMBEDDING_DISTANCE_ASC/DESC) + * - VectorNearbyInput and VectorMetric types + * + * This test exercises the full codegen pipeline (schema file → introspection → + * tables + typeRegistry → generated TypeScript) to verify that vector types + * are correctly included in the generated output. + */ +import path from 'node:path'; + +import { getConfigOptions } from '../../types/config'; +import { generateOrm } from '../../core/codegen/orm'; +import { + collectInputTypeNames, + collectPayloadTypeNames, +} from '../../core/codegen/orm/input-types-generator'; +import { runCodegenPipeline } from '../../core/pipeline'; +import { FileSchemaSource } from '../../core/introspect/source/file'; + +const VECTOR_SCHEMA_PATH = path.resolve( + __dirname, + '../../..', + 'examples/example-vector.schema.graphql', +); + +describe('vector codegen integration', () => { + let pipelineResult: Awaited>; + + beforeAll(async () => { + const source = new FileSchemaSource({ schemaPath: VECTOR_SCHEMA_PATH }); + const config = getConfigOptions({ orm: true, output: '/tmp/test-output' }); + + pipelineResult = await runCodegenPipeline({ + source, + config, + }); + }); + + // ======================================================================== + // Pipeline sanity checks + // ======================================================================== + + it('infers Contact and Document tables from the vector schema', () => { + const tableNames = pipelineResult.tables.map((t) => t.name); + expect(tableNames).toContain('Contact'); + expect(tableNames).toContain('Document'); + }); + + it('produces a typeRegistry with vector-related types', () => { + const { typeRegistry } = pipelineResult.customOperations; + + // Condition types should be in the registry + expect(typeRegistry.has('ContactCondition')).toBe(true); + expect(typeRegistry.has('DocumentCondition')).toBe(true); + + // Vector search types + expect(typeRegistry.has('VectorNearbyInput')).toBe(true); + expect(typeRegistry.has('VectorMetric')).toBe(true); + + // OrderBy enums + expect(typeRegistry.has('ContactsOrderBy')).toBe(true); + expect(typeRegistry.has('DocumentsOrderBy')).toBe(true); + }); + + it('ContactCondition in typeRegistry includes embeddingNearby field', () => { + const { typeRegistry } = pipelineResult.customOperations; + const conditionType = typeRegistry.get('ContactCondition'); + + expect(conditionType).toBeDefined(); + expect(conditionType!.kind).toBe('INPUT_OBJECT'); + + const fieldNames = conditionType!.inputFields?.map((f) => f.name) ?? []; + expect(fieldNames).toContain('embeddingNearby'); + }); + + it('ContactsOrderBy in typeRegistry includes distance values', () => { + const { typeRegistry } = pipelineResult.customOperations; + const orderByType = typeRegistry.get('ContactsOrderBy'); + + expect(orderByType).toBeDefined(); + expect(orderByType!.kind).toBe('ENUM'); + expect(orderByType!.enumValues).toContain('EMBEDDING_DISTANCE_ASC'); + expect(orderByType!.enumValues).toContain('EMBEDDING_DISTANCE_DESC'); + }); + + // ======================================================================== + // Full ORM generation + // ======================================================================== + + describe('generated ORM output', () => { + let inputTypesContent: string; + + beforeAll(() => { + const { tables, customOperations } = pipelineResult; + const config = getConfigOptions({ orm: true, output: '/tmp/test-output' }); + + const allOps = [ + ...customOperations.queries, + ...customOperations.mutations, + ]; + const result = generateOrm({ + tables, + customOperations, + config, + }); + + const inputTypesFile = result.files.find((f) => + f.path.includes('input-types'), + ); + expect(inputTypesFile).toBeDefined(); + inputTypesContent = inputTypesFile!.content; + }); + + // === Vector scalar mapping === + + it('maps Vector scalar to number[] in entity types', () => { + // Contact entity should have embedding as number[] + expect(inputTypesContent).toContain('embedding?: number[] | null;'); + }); + + // === Condition types with plugin-injected fields === + + it('generates ContactCondition with embeddingNearby field', () => { + expect(inputTypesContent).toContain( + 'export interface ContactCondition {', + ); + + // Standard column fields + expect(inputTypesContent).toMatch(/id\?.*string.*null/); + expect(inputTypesContent).toMatch(/name\?.*string.*null/); + expect(inputTypesContent).toMatch(/email\?.*string.*null/); + + // Plugin-injected field + expect(inputTypesContent).toContain('embeddingNearby'); + }); + + it('generates DocumentCondition with contentEmbeddingNearby field', () => { + expect(inputTypesContent).toContain( + 'export interface DocumentCondition {', + ); + + // Plugin-injected field + expect(inputTypesContent).toContain('contentEmbeddingNearby'); + }); + + it('generates VectorNearbyInput type', () => { + expect(inputTypesContent).toContain( + 'export interface VectorNearbyInput {', + ); + // vector field should be required (NON_NULL in schema) + expect(inputTypesContent).toMatch(/vector:\s*number\[\]/); + // metric and threshold should be optional + expect(inputTypesContent).toContain('metric?'); + expect(inputTypesContent).toContain('threshold?'); + }); + + it('generates VectorMetric enum type', () => { + expect(inputTypesContent).toContain('VectorMetric'); + expect(inputTypesContent).toContain('"L2"'); + expect(inputTypesContent).toContain('"INNER_PRODUCT"'); + expect(inputTypesContent).toContain('"COSINE"'); + }); + + // === OrderBy types with plugin-injected values === + + it('generates ContactsOrderBy with distance values', () => { + expect(inputTypesContent).toContain('export type ContactsOrderBy ='); + // Standard values + expect(inputTypesContent).toContain('"PRIMARY_KEY_ASC"'); + expect(inputTypesContent).toContain('"ID_ASC"'); + expect(inputTypesContent).toContain('"NAME_ASC"'); + // Plugin-injected distance values + expect(inputTypesContent).toContain('"EMBEDDING_DISTANCE_ASC"'); + expect(inputTypesContent).toContain('"EMBEDDING_DISTANCE_DESC"'); + }); + + it('generates DocumentsOrderBy with content embedding distance values', () => { + expect(inputTypesContent).toContain('export type DocumentsOrderBy ='); + // Plugin-injected distance values + expect(inputTypesContent).toContain( + '"CONTENT_EMBEDDING_DISTANCE_ASC"', + ); + expect(inputTypesContent).toContain( + '"CONTENT_EMBEDDING_DISTANCE_DESC"', + ); + }); + + // === Backwards compatibility === + + it('still generates standard CRUD input types correctly', () => { + expect(inputTypesContent).toContain( + 'export interface CreateContactInput {', + ); + expect(inputTypesContent).toContain( + 'export interface UpdateContactInput {', + ); + expect(inputTypesContent).toContain( + 'export interface DeleteContactInput {', + ); + expect(inputTypesContent).toContain('export interface ContactPatch {'); + }); + + it('still generates standard filter types correctly', () => { + expect(inputTypesContent).toContain( + 'export interface ContactFilter {', + ); + expect(inputTypesContent).toContain( + 'export interface DocumentFilter {', + ); + }); + }); +}); 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); } From 5546edf571315c1c3da020b148cfe41559fa140f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 12 Mar 2026 01:18:34 +0000 Subject: [PATCH 2/2] chore: remove unused imports and variable in vector integration test --- .../__tests__/codegen/vector-codegen-integration.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts b/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts index 635a9a9d9..ae2bbf4d0 100644 --- a/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts +++ b/graphql/codegen/src/__tests__/codegen/vector-codegen-integration.test.ts @@ -15,10 +15,6 @@ import path from 'node:path'; import { getConfigOptions } from '../../types/config'; import { generateOrm } from '../../core/codegen/orm'; -import { - collectInputTypeNames, - collectPayloadTypeNames, -} from '../../core/codegen/orm/input-types-generator'; import { runCodegenPipeline } from '../../core/pipeline'; import { FileSchemaSource } from '../../core/introspect/source/file'; @@ -99,10 +95,6 @@ describe('vector codegen integration', () => { const { tables, customOperations } = pipelineResult; const config = getConfigOptions({ orm: true, output: '/tmp/test-output' }); - const allOps = [ - ...customOperations.queries, - ...customOperations.mutations, - ]; const result = generateOrm({ tables, customOperations,