diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap index 2645adc8b..4e96b9c09 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap @@ -276,9 +276,10 @@ export interface PageInfo { endCursor?: string | null; } -export interface FindManyArgs { +export interface FindManyArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; orderBy?: TOrderBy[]; first?: number; last?: number; @@ -287,9 +288,10 @@ export interface FindManyArgs { offset?: number; } -export interface FindFirstArgs { +export interface FindFirstArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; } export interface CreateArgs { diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/input-types-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/input-types-generator.test.ts.snap index 713a844ee..afa9eb471 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/input-types-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/input-types-generator.test.ts.snap @@ -166,6 +166,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -648,6 +655,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -966,6 +980,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -1296,6 +1317,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -1631,6 +1659,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -2022,6 +2057,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; @@ -2409,6 +2451,13 @@ export interface InternetAddressFilter { export interface FullTextFilter { matches?: string; } +export interface VectorFilter { + isNull?: boolean; + equalTo?: number[]; + notEqualTo?: number[]; + distinctFrom?: number[]; + notDistinctFrom?: number[]; +} export interface StringListFilter { isNull?: boolean; equalTo?: string[]; diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap index 96a940f74..4f11f28fe 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap @@ -9,11 +9,11 @@ exports[`model-generator generates model with all CRUD methods 1`] = ` import { OrmClient } from "../client"; import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildFindOneDocument, buildCreateDocument, buildUpdateByPkDocument, buildDeleteByPkDocument } from "../query-builder"; import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, StrictSelect } from "../select-types"; -import type { User, UserWithRelations, UserSelect, UserFilter, UsersOrderBy, CreateUserInput, UpdateUserInput, UserPatch } from "../input-types"; +import type { User, UserWithRelations, UserSelect, UserFilter, UserCondition, UsersOrderBy, CreateUserInput, UpdateUserInput, UserPatch } from "../input-types"; import { connectionFieldsMap } from "../input-types"; export class UserModel { constructor(private client: OrmClient) {} - findMany(args: FindManyArgs & { + findMany(args: FindManyArgs & { select: S; } & StrictSelect): QueryBuilder<{ users: ConnectionResult>; @@ -23,13 +23,14 @@ export class UserModel { variables } = buildFindManyDocument("User", "users", args.select, { where: args?.where, + condition: args?.condition, orderBy: args?.orderBy as string[] | undefined, first: args?.first, last: args?.last, after: args?.after, before: args?.before, offset: args?.offset - }, "UserFilter", "UsersOrderBy", connectionFieldsMap); + }, "UserFilter", "UsersOrderBy", connectionFieldsMap, "UserCondition"); return new QueryBuilder({ client: this.client, operation: "query", @@ -39,7 +40,7 @@ export class UserModel { variables }); } - findFirst(args: FindFirstArgs & { + findFirst(args: FindFirstArgs & { select: S; } & StrictSelect): QueryBuilder<{ users: { @@ -50,8 +51,9 @@ export class UserModel { document, variables } = buildFindFirstDocument("User", "users", args.select, { - where: args?.where - }, "UserFilter", connectionFieldsMap); + where: args?.where, + condition: args?.condition + }, "UserFilter", connectionFieldsMap, "UserCondition"); return new QueryBuilder({ client: this.client, operation: "query", @@ -156,11 +158,11 @@ exports[`model-generator generates model without update/delete when not availabl import { OrmClient } from "../client"; import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildFindOneDocument, buildCreateDocument, buildUpdateByPkDocument, buildDeleteByPkDocument } from "../query-builder"; import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, StrictSelect } from "../select-types"; -import type { AuditLog, AuditLogWithRelations, AuditLogSelect, AuditLogFilter, AuditLogsOrderBy, CreateAuditLogInput, UpdateAuditLogInput, AuditLogPatch } from "../input-types"; +import type { AuditLog, AuditLogWithRelations, AuditLogSelect, AuditLogFilter, AuditLogCondition, AuditLogsOrderBy, CreateAuditLogInput, UpdateAuditLogInput, AuditLogPatch } from "../input-types"; import { connectionFieldsMap } from "../input-types"; export class AuditLogModel { constructor(private client: OrmClient) {} - findMany(args: FindManyArgs & { + findMany(args: FindManyArgs & { select: S; } & StrictSelect): QueryBuilder<{ auditLogs: ConnectionResult>; @@ -170,13 +172,14 @@ export class AuditLogModel { variables } = buildFindManyDocument("AuditLog", "auditLogs", args.select, { where: args?.where, + condition: args?.condition, orderBy: args?.orderBy as string[] | undefined, first: args?.first, last: args?.last, after: args?.after, before: args?.before, offset: args?.offset - }, "AuditLogFilter", "AuditLogsOrderBy", connectionFieldsMap); + }, "AuditLogFilter", "AuditLogsOrderBy", connectionFieldsMap, "AuditLogCondition"); return new QueryBuilder({ client: this.client, operation: "query", @@ -186,7 +189,7 @@ export class AuditLogModel { variables }); } - findFirst(args: FindFirstArgs & { + findFirst(args: FindFirstArgs & { select: S; } & StrictSelect): QueryBuilder<{ auditLogs: { @@ -197,8 +200,9 @@ export class AuditLogModel { document, variables } = buildFindFirstDocument("AuditLog", "auditLogs", args.select, { - where: args?.where - }, "AuditLogFilter", connectionFieldsMap); + where: args?.where, + condition: args?.condition + }, "AuditLogFilter", connectionFieldsMap, "AuditLogCondition"); return new QueryBuilder({ client: this.client, operation: "query", @@ -259,11 +263,11 @@ exports[`model-generator handles custom query/mutation names 1`] = ` import { OrmClient } from "../client"; import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildFindOneDocument, buildCreateDocument, buildUpdateByPkDocument, buildDeleteByPkDocument } from "../query-builder"; import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, StrictSelect } from "../select-types"; -import type { Organization, OrganizationWithRelations, OrganizationSelect, OrganizationFilter, OrganizationsOrderBy, CreateOrganizationInput, UpdateOrganizationInput, OrganizationPatch } from "../input-types"; +import type { Organization, OrganizationWithRelations, OrganizationSelect, OrganizationFilter, OrganizationCondition, OrganizationsOrderBy, CreateOrganizationInput, UpdateOrganizationInput, OrganizationPatch } from "../input-types"; import { connectionFieldsMap } from "../input-types"; export class OrganizationModel { constructor(private client: OrmClient) {} - findMany(args: FindManyArgs & { + findMany(args: FindManyArgs & { select: S; } & StrictSelect): QueryBuilder<{ allOrganizations: ConnectionResult>; @@ -273,13 +277,14 @@ export class OrganizationModel { variables } = buildFindManyDocument("Organization", "allOrganizations", args.select, { where: args?.where, + condition: args?.condition, orderBy: args?.orderBy as string[] | undefined, first: args?.first, last: args?.last, after: args?.after, before: args?.before, offset: args?.offset - }, "OrganizationFilter", "OrganizationsOrderBy", connectionFieldsMap); + }, "OrganizationFilter", "OrganizationsOrderBy", connectionFieldsMap, "OrganizationCondition"); return new QueryBuilder({ client: this.client, operation: "query", @@ -289,7 +294,7 @@ export class OrganizationModel { variables }); } - findFirst(args: FindFirstArgs & { + findFirst(args: FindFirstArgs & { select: S; } & StrictSelect): QueryBuilder<{ allOrganizations: { @@ -300,8 +305,9 @@ export class OrganizationModel { document, variables } = buildFindFirstDocument("Organization", "allOrganizations", args.select, { - where: args?.where - }, "OrganizationFilter", connectionFieldsMap); + where: args?.where, + condition: args?.condition + }, "OrganizationFilter", connectionFieldsMap, "OrganizationCondition"); return new QueryBuilder({ client: this.client, operation: "query", diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/react-query-hooks.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/react-query-hooks.test.ts.snap index 2f15ed76f..89631177b 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/react-query-hooks.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/react-query-hooks.test.ts.snap @@ -2138,7 +2138,7 @@ exports[`Schema Types Generator generateSchemaTypesFile generates schema types f * DO NOT EDIT - changes will be overwritten */ -import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export type UserRole = "ADMIN" | "USER" | "GUEST"; export interface RegisterInput { email: string; @@ -2165,7 +2165,7 @@ exports[`Schema Types Generator generateSchemaTypesFile generates schema types f * DO NOT EDIT - changes will be overwritten */ -import type { User, BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { User, BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export type UserRole = "ADMIN" | "USER" | "GUEST"; export interface RegisterInput { email: string; diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap index e7d4d48d2..4115d64a6 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap @@ -7,7 +7,7 @@ exports[`schema-types-generator generates enum types as string unions 1`] = ` * DO NOT EDIT - changes will be overwritten */ -import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export type Status = "ACTIVE" | "INACTIVE" | "PENDING"; export type Priority = "LOW" | "MEDIUM" | "HIGH";" `; @@ -19,7 +19,7 @@ exports[`schema-types-generator generates input object types as interfaces 1`] = * DO NOT EDIT - changes will be overwritten */ -import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export interface CreateUserInput { email: string; name?: string; @@ -38,7 +38,7 @@ exports[`schema-types-generator generates payload types from mutation return typ * DO NOT EDIT - changes will be overwritten */ -import type { User, BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { User, BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export interface LoginPayload { token: string; refreshToken?: string | null; @@ -53,7 +53,7 @@ exports[`schema-types-generator generates union types 1`] = ` * DO NOT EDIT - changes will be overwritten */ -import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export type SearchResult = User | Post | Comment;" `; @@ -64,6 +64,6 @@ exports[`schema-types-generator skips table types and standard scalars 1`] = ` * DO NOT EDIT - changes will be overwritten */ -import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter, VectorFilter } from "./types"; export type CustomEnum = "VALUE_A" | "VALUE_B";" `; 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..577134398 100644 --- a/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/input-types-generator.test.ts @@ -455,6 +455,18 @@ describe('scalar filter types', () => { // Float filters expect(result.content).toContain('export interface FloatFilter {'); }); + + it('includes VectorFilter for pgvector embedding fields', () => { + const result = generateInputTypesFile(new Map(), new Set(), [userTable]); + + // VectorFilter should be generated as a scalar filter type + expect(result.content).toContain('export interface VectorFilter {'); + expect(result.content).toContain('isNull?: boolean;'); + expect(result.content).toContain('equalTo?: number[];'); + expect(result.content).toContain('notEqualTo?: number[];'); + expect(result.content).toContain('distinctFrom?: number[];'); + expect(result.content).toContain('notDistinctFrom?: number[];'); + }); }); // ============================================================================ @@ -877,6 +889,229 @@ 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 {'); + + // 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', () => { + 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/__tests__/codegen/model-generator.test.ts b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts index 577710a3e..84ac95a91 100644 --- a/graphql/codegen/src/__tests__/codegen/model-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts @@ -232,4 +232,42 @@ describe('model-generator', () => { expect(result.content).toContain('UpdateProductInput'); expect(result.content).toContain('ProductPatch'); }); + + it('imports and wires Condition type for findMany and findFirst', () => { + const table = createTable({ + name: 'Contact', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + ], + query: { + all: 'contacts', + one: 'contact', + create: 'createContact', + update: 'updateContact', + delete: 'deleteContact', + }, + }); + + const result = generateModelFile(table, false); + + // Condition type should be imported + expect(result.content).toContain('ContactCondition'); + + // findMany should include condition in its args type + expect(result.content).toContain( + 'FindManyArgs', + ); + + // findFirst should include condition in its args type + expect(result.content).toContain( + 'FindFirstArgs', + ); + + // condition should be forwarded in the body args object + expect(result.content).toContain('condition: args?.condition'); + + // conditionTypeName should be passed as a string literal to the document builder + expect(result.content).toContain('"ContactCondition"'); + }); }); diff --git a/graphql/codegen/src/__tests__/codegen/query-builder.test.ts b/graphql/codegen/src/__tests__/codegen/query-builder.test.ts index c1ae2fbec..a2bba9375 100644 --- a/graphql/codegen/src/__tests__/codegen/query-builder.test.ts +++ b/graphql/codegen/src/__tests__/codegen/query-builder.test.ts @@ -38,12 +38,12 @@ function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { } function addVariable( - spec: { varName: string; argName?: string; typeName: string; value: unknown }, + spec: { varName: string; argName?: string; typeName?: string; value: unknown }, definitions: VariableDefinitionNode[], args: ArgumentNode[], variables: Record, ): void { - if (spec.value === undefined) return; + if (spec.value === undefined || !spec.typeName) return; definitions.push( t.variableDefinition({ variable: t.variable({ name: spec.varName }), @@ -123,14 +123,15 @@ function buildSelections( return fields; } -function buildFindManyDocument( +function buildFindManyDocument( operationName: string, queryField: string, select: TSelect, - args: { where?: TWhere; first?: number; orderBy?: string[] }, + args: { where?: TWhere; condition?: TCondition; first?: number; orderBy?: string[] }, filterTypeName: string, orderByTypeName: string, connectionFieldsMap?: Record>, + conditionTypeName?: string, ): { document: string; variables: Record } { const selections = select ? buildSelections( @@ -143,6 +144,16 @@ function buildFindManyDocument( const queryArgs: ArgumentNode[] = []; const variables: Record = {}; + addVariable( + { + varName: 'condition', + typeName: conditionTypeName, + value: args.condition, + }, + variableDefinitions, + queryArgs, + variables, + ); addVariable( { varName: 'where', @@ -557,6 +568,52 @@ describe('query-builder', () => { orderBy: ['NAME_ASC'], }); }); + + it('includes condition variable when conditionTypeName is provided', () => { + const { document, variables } = buildFindManyDocument( + 'Contacts', + 'contacts', + { id: true, name: true }, + { + condition: { embeddingNearby: { vector: [0.1, 0.2], metric: 'COSINE' } }, + where: { name: { equalTo: 'test' } }, + first: 5, + }, + 'ContactFilter', + 'ContactsOrderBy', + undefined, + 'ContactCondition', + ); + + // condition variable should appear in the query + expect(document).toContain('$condition: ContactCondition'); + expect(document).toContain('condition: $condition'); + // filter should still work alongside condition + expect(document).toContain('$where: ContactFilter'); + expect(document).toContain('filter: $where'); + // variables should include both + expect(variables.condition).toEqual({ + embeddingNearby: { vector: [0.1, 0.2], metric: 'COSINE' }, + }); + expect(variables.where).toEqual({ name: { equalTo: 'test' } }); + }); + + it('omits condition variable when not provided', () => { + const { document } = buildFindManyDocument( + 'Users', + 'users', + { id: true }, + { first: 10 }, + 'UserFilter', + 'UsersOrderBy', + undefined, + 'UserCondition', + ); + + // condition should NOT appear since no value was provided + expect(document).not.toContain('$condition'); + expect(document).not.toContain('condition:'); + }); }); describe('buildMutationDocument', () => { 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..4a765433b 100644 --- a/graphql/codegen/src/core/codegen/orm/input-types-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/input-types-generator.ts @@ -322,6 +322,11 @@ const SCALAR_FILTER_CONFIGS: ScalarFilterConfig[] = [ operators: ['equality', 'distinct', 'inArray', 'comparison', 'inet'], }, { name: 'FullTextFilter', tsType: 'string', operators: ['fulltext'] }, + // VectorFilter: equality/distinct operators for vector columns on Filter types. + // Similarity search uses condition types (embeddingNearby), not filters, but + // connection-filter may still generate a filter for vector columns. This ensures + // the generated type uses number[] rather than being silently omitted. + { name: 'VectorFilter', tsType: 'number[]', operators: ['equality', 'distinct'] }, // List filters (for array fields like string[], int[], uuid[]) { name: 'StringListFilter', @@ -1116,10 +1121,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 +1146,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 +1178,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 +1189,7 @@ function generateTableConditionTypes(tables: CleanTable[]): t.Statement[] { statements.push( createExportedInterface( conditionName, - buildTableConditionProperties(table), + buildTableConditionProperties(table, typeRegistry), ), ); } @@ -1165,9 +1205,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 +1224,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), @@ -1556,12 +1621,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); } @@ -1841,6 +1907,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 +2004,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 +2022,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/orm/model-generator.ts b/graphql/codegen/src/core/codegen/orm/model-generator.ts index 3d426ab5b..66c591b34 100644 --- a/graphql/codegen/src/core/codegen/orm/model-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/model-generator.ts @@ -175,6 +175,7 @@ export function generateModelFile( const selectTypeName = `${typeName}Select`; const relationTypeName = `${typeName}WithRelations`; const whereTypeName = getFilterTypeName(table); + const conditionTypeName = `${typeName}Condition`; const orderByTypeName = getOrderByTypeName(table); const createInputTypeName = `Create${typeName}Input`; const updateInputTypeName = `Update${typeName}Input`; @@ -228,6 +229,7 @@ export function generateModelFile( relationTypeName, selectTypeName, whereTypeName, + conditionTypeName, orderByTypeName, createInputTypeName, updateInputTypeName, @@ -270,6 +272,7 @@ export function generateModelFile( t.tsTypeParameterInstantiation([ sel, t.tsTypeReference(t.identifier(whereTypeName)), + t.tsTypeReference(t.identifier(conditionTypeName)), t.tsTypeReference(t.identifier(orderByTypeName)), ]), ); @@ -327,6 +330,15 @@ export function generateModelFile( true, ), ), + t.objectProperty( + t.identifier('condition'), + t.optionalMemberExpression( + t.identifier('args'), + t.identifier('condition'), + false, + true, + ), + ), t.objectProperty( t.identifier('orderBy'), t.tsAsExpression( @@ -391,6 +403,7 @@ export function generateModelFile( t.stringLiteral(whereTypeName), t.stringLiteral(orderByTypeName), t.identifier('connectionFieldsMap'), + t.stringLiteral(conditionTypeName), ]; classBody.push( createClassMethod( @@ -417,6 +430,7 @@ export function generateModelFile( t.tsTypeParameterInstantiation([ sel, t.tsTypeReference(t.identifier(whereTypeName)), + t.tsTypeReference(t.identifier(conditionTypeName)), ]), ); const retType = (sel: t.TSType) => @@ -477,9 +491,19 @@ export function generateModelFile( true, ), ), + t.objectProperty( + t.identifier('condition'), + t.optionalMemberExpression( + t.identifier('args'), + t.identifier('condition'), + false, + true, + ), + ), ]), t.stringLiteral(whereTypeName), t.identifier('connectionFieldsMap'), + t.stringLiteral(conditionTypeName), ]; classBody.push( createClassMethod( diff --git a/graphql/codegen/src/core/codegen/orm/select-types.ts b/graphql/codegen/src/core/codegen/orm/select-types.ts index 7bc20bad9..098bef89c 100644 --- a/graphql/codegen/src/core/codegen/orm/select-types.ts +++ b/graphql/codegen/src/core/codegen/orm/select-types.ts @@ -201,9 +201,10 @@ export interface PageInfo { /** * Arguments for findMany operations */ -export interface FindManyArgs { +export interface FindManyArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; orderBy?: TOrderBy[]; first?: number; last?: number; @@ -215,9 +216,10 @@ export interface FindManyArgs { /** * Arguments for findFirst/findUnique operations */ -export interface FindFirstArgs { +export interface FindFirstArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; } /** diff --git a/graphql/codegen/src/core/codegen/scalars.ts b/graphql/codegen/src/core/codegen/scalars.ts index 579eb3398..a49d33170 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) — serialized as [Float] in GraphQL + Vector: 'number[]', + // File upload Upload: 'File', }; @@ -58,6 +61,12 @@ export const SCALAR_FILTER_MAP: Record = { BigFloat: 'BigFloatFilter', BitString: 'BitStringFilter', InternetAddress: 'InternetAddressFilter', + // VectorFilter provides equality/distinct operators (isNull, equalTo, etc.) for vector + // columns on Filter types. While similarity search is done via condition types + // (e.g., embeddingNearby on ContactCondition), postgraphile-plugin-connection-filter + // may still auto-generate a filter type for vector columns. Without this mapping, + // those fields would be silently omitted from the generated SDK. + Vector: 'VectorFilter', FullText: 'FullTextFilter', Interval: 'StringFilter', }; diff --git a/graphql/codegen/src/core/codegen/templates/query-builder.ts b/graphql/codegen/src/core/codegen/templates/query-builder.ts index 5756c6713..045f86a3d 100644 --- a/graphql/codegen/src/core/codegen/templates/query-builder.ts +++ b/graphql/codegen/src/core/codegen/templates/query-builder.ts @@ -201,12 +201,13 @@ export function buildSelections( // Document Builders // ============================================================================ -export function buildFindManyDocument( +export function buildFindManyDocument( operationName: string, queryField: string, select: TSelect, args: { where?: TWhere; + condition?: TCondition; orderBy?: string[]; first?: number; last?: number; @@ -217,6 +218,7 @@ export function buildFindManyDocument( filterTypeName: string, orderByTypeName: string, connectionFieldsMap?: Record>, + conditionTypeName?: string, ): { document: string; variables: Record } { const selections = select ? buildSelections( @@ -230,6 +232,16 @@ export function buildFindManyDocument( const queryArgs: ArgumentNode[] = []; const variables: Record = {}; + addVariable( + { + varName: 'condition', + typeName: conditionTypeName, + value: args.condition, + }, + variableDefinitions, + queryArgs, + variables, + ); addVariable( { varName: 'where', @@ -308,13 +320,14 @@ export function buildFindManyDocument( return { document: print(document), variables }; } -export function buildFindFirstDocument( +export function buildFindFirstDocument( operationName: string, queryField: string, select: TSelect, - args: { where?: TWhere }, + args: { where?: TWhere; condition?: TCondition }, filterTypeName: string, connectionFieldsMap?: Record>, + conditionTypeName?: string, ): { document: string; variables: Record } { const selections = select ? buildSelections( @@ -335,6 +348,16 @@ export function buildFindFirstDocument( queryArgs, variables, ); + addVariable( + { + varName: 'condition', + typeName: conditionTypeName, + value: args.condition, + }, + variableDefinitions, + queryArgs, + variables, + ); addVariable( { varName: 'where', @@ -799,7 +822,7 @@ function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { interface VariableSpec { varName: string; argName?: string; - typeName: string; + typeName?: string; value: unknown; } @@ -850,7 +873,7 @@ function addVariable( args: ArgumentNode[], variables: Record, ): void { - if (spec.value === undefined) return; + if (spec.value === undefined || !spec.typeName) return; definitions.push( t.variableDefinition({ diff --git a/graphql/codegen/src/core/codegen/templates/select-types.ts b/graphql/codegen/src/core/codegen/templates/select-types.ts index 45a64a079..c27939da0 100644 --- a/graphql/codegen/src/core/codegen/templates/select-types.ts +++ b/graphql/codegen/src/core/codegen/templates/select-types.ts @@ -21,9 +21,10 @@ export interface PageInfo { endCursor?: string | null; } -export interface FindManyArgs { +export interface FindManyArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; orderBy?: TOrderBy[]; first?: number; last?: number; @@ -32,9 +33,10 @@ export interface FindManyArgs { offset?: number; } -export interface FindFirstArgs { +export interface FindFirstArgs { select?: TSelect; where?: TWhere; + condition?: TCondition; } export interface CreateArgs {