diff --git a/graphql/codegen/examples/example-bm25.schema.graphql b/graphql/codegen/examples/example-bm25.schema.graphql new file mode 100644 index 000000000..26148f24c --- /dev/null +++ b/graphql/codegen/examples/example-bm25.schema.graphql @@ -0,0 +1,338 @@ +"""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 + +"""Full-text search scalar type.""" +scalar FullText + +"""The root query type.""" +type Query { + """Reads and enables pagination through a set of `Article`.""" + articles( + first: Int + last: Int + offset: Int + before: Cursor + after: Cursor + orderBy: [ArticlesOrderBy!] = [PRIMARY_KEY_ASC] + filter: ArticleFilter + condition: ArticleCondition + ): ArticlesConnection + + """Reads a single `Article` using its globally unique `ID`.""" + article(id: UUID!): Article + + """Reads and enables pagination through a set of `Post`.""" + posts( + first: Int + last: Int + offset: Int + before: Cursor + after: Cursor + orderBy: [PostsOrderBy!] = [PRIMARY_KEY_ASC] + filter: PostFilter + condition: PostCondition + ): PostsConnection + + """Reads a single `Post` using its globally unique `ID`.""" + post(id: UUID!): Post +} + +"""The root mutation type.""" +type Mutation { + """Creates a single `Article`.""" + createArticle(input: CreateArticleInput!): CreateArticlePayload + + """Updates a single `Article` using its globally unique `ID`.""" + updateArticle(input: UpdateArticleInput!): UpdateArticlePayload + + """Deletes a single `Article` using its globally unique `ID`.""" + deleteArticle(input: DeleteArticleInput!): DeleteArticlePayload + + """Creates a single `Post`.""" + createPost(input: CreatePostInput!): CreatePostPayload + + """Updates a single `Post` using its globally unique `ID`.""" + updatePost(input: UpdatePostInput!): UpdatePostPayload + + """Deletes a single `Post` using its globally unique `ID`.""" + deletePost(input: DeletePostInput!): DeletePostPayload +} + +# ============================================================================ +# Entity Types +# ============================================================================ + +type Article { + id: UUID! + title: String! + body: String! + createdAt: Datetime! + updatedAt: Datetime +} + +type Post { + id: UUID! + title: String! + content: String + createdAt: Datetime! +} + +# ============================================================================ +# Enums +# ============================================================================ + +enum ArticlesOrderBy { + NATURAL + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC + ID_ASC + ID_DESC + TITLE_ASC + TITLE_DESC + CREATED_AT_ASC + CREATED_AT_DESC + """Sort by BM25 relevance score for body column (ascending = best matches first).""" + BM25_BODY_SCORE_ASC + """Sort by BM25 relevance score for body column (descending).""" + BM25_BODY_SCORE_DESC +} + +enum PostsOrderBy { + NATURAL + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC + ID_ASC + ID_DESC + TITLE_ASC + TITLE_DESC + CREATED_AT_ASC + CREATED_AT_DESC + """Sort by BM25 relevance score for content column (ascending = best matches first).""" + BM25_CONTENT_SCORE_ASC + """Sort by BM25 relevance score for content column (descending).""" + BM25_CONTENT_SCORE_DESC +} + +# ============================================================================ +# Connection Types +# ============================================================================ + +type ArticlesConnection { + nodes: [Article!]! + edges: [ArticlesEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type ArticlesEdge { + node: Article! + cursor: Cursor! +} + +type PostsConnection { + nodes: [Post!]! + edges: [PostsEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type PostsEdge { + node: Post! + cursor: Cursor! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: Cursor + endCursor: Cursor +} + +# ============================================================================ +# Filter Types +# ============================================================================ + +input ArticleFilter { + id: UUIDFilter + title: StringFilter + body: StringFilter + and: [ArticleFilter!] + or: [ArticleFilter!] + not: ArticleFilter +} + +input PostFilter { + id: UUIDFilter + title: StringFilter + and: [PostFilter!] + or: [PostFilter!] + not: PostFilter +} + +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 BM25 search fields) +# ============================================================================ + +""" +Condition type for Article table. +Includes standard column equality fields plus plugin-injected BM25 search fields. +""" +input ArticleCondition { + id: UUID + title: String + body: String + + """BM25 ranked text search on the body column (injected by Bm25SearchPlugin).""" + bm25Body: Bm25SearchInput +} + +""" +Condition type for Post table. +Includes standard column equality fields plus plugin-injected BM25 search fields. +""" +input PostCondition { + id: UUID + title: String + content: String + + """BM25 ranked text search on the content column (injected by Bm25SearchPlugin).""" + bm25Content: Bm25SearchInput +} + +""" +Input type for BM25 ranked text search. +Injected by Bm25SearchPlugin into condition types for text columns with BM25 indexes. +""" +input Bm25SearchInput { + """The search query text. Uses pg_textsearch BM25 ranking.""" + query: String! + """Maximum BM25 score threshold (negative values). Only rows with score <= threshold are returned.""" + threshold: Float +} + +# ============================================================================ +# Mutation Input Types +# ============================================================================ + +input CreateArticleInput { + clientMutationId: String + article: ArticleInput! +} + +input ArticleInput { + title: String! + body: String! +} + +input UpdateArticleInput { + clientMutationId: String + id: UUID! + patch: ArticlePatch! +} + +input ArticlePatch { + title: String + body: String +} + +input DeleteArticleInput { + clientMutationId: String + id: UUID! +} + +input CreatePostInput { + clientMutationId: String + post: PostInput! +} + +input PostInput { + title: String! + content: String +} + +input UpdatePostInput { + clientMutationId: String + id: UUID! + patch: PostPatch! +} + +input PostPatch { + title: String + content: String +} + +input DeletePostInput { + clientMutationId: String + id: UUID! +} + +# ============================================================================ +# Mutation Payload Types +# ============================================================================ + +type CreateArticlePayload { + clientMutationId: String + article: Article +} + +type UpdateArticlePayload { + clientMutationId: String + article: Article +} + +type DeleteArticlePayload { + clientMutationId: String + article: Article +} + +type CreatePostPayload { + clientMutationId: String + post: Post +} + +type UpdatePostPayload { + clientMutationId: String + post: Post +} + +type DeletePostPayload { + clientMutationId: String + post: Post +} diff --git a/graphql/codegen/src/__tests__/codegen/bm25-codegen-integration.test.ts b/graphql/codegen/src/__tests__/codegen/bm25-codegen-integration.test.ts new file mode 100644 index 000000000..9c8bedf07 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/bm25-codegen-integration.test.ts @@ -0,0 +1,209 @@ +/** + * Integration test for BM25 (pg_textsearch) codegen support. + * + * Uses the example-bm25.schema.graphql fixture which includes: + * - Plugin-injected condition fields (bm25Body, bm25Content) + * - Plugin-injected orderBy values (BM25_BODY_SCORE_ASC/DESC, BM25_CONTENT_SCORE_ASC/DESC) + * - Bm25SearchInput custom input type (query: String!, threshold?: Float) + * + * This test exercises the full codegen pipeline (schema file → introspection → + * tables + typeRegistry → generated TypeScript) to verify that BM25 search 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 { runCodegenPipeline } from '../../core/pipeline'; +import { FileSchemaSource } from '../../core/introspect/source/file'; + +const BM25_SCHEMA_PATH = path.resolve( + __dirname, + '../../..', + 'examples/example-bm25.schema.graphql', +); + +describe('bm25 codegen integration', () => { + let pipelineResult: Awaited>; + + beforeAll(async () => { + const source = new FileSchemaSource({ schemaPath: BM25_SCHEMA_PATH }); + const config = getConfigOptions({ orm: true, output: '/tmp/test-output' }); + + pipelineResult = await runCodegenPipeline({ + source, + config, + }); + }); + + // ======================================================================== + // Pipeline sanity checks + // ======================================================================== + + it('infers Article and Post tables from the BM25 schema', () => { + const tableNames = pipelineResult.tables.map((t) => t.name); + expect(tableNames).toContain('Article'); + expect(tableNames).toContain('Post'); + }); + + it('produces a typeRegistry with BM25-related types', () => { + const { typeRegistry } = pipelineResult.customOperations; + + // Condition types should be in the registry + expect(typeRegistry.has('ArticleCondition')).toBe(true); + expect(typeRegistry.has('PostCondition')).toBe(true); + + // BM25 search input type + expect(typeRegistry.has('Bm25SearchInput')).toBe(true); + + // OrderBy enums + expect(typeRegistry.has('ArticlesOrderBy')).toBe(true); + expect(typeRegistry.has('PostsOrderBy')).toBe(true); + }); + + it('ArticleCondition in typeRegistry includes bm25Body field', () => { + const { typeRegistry } = pipelineResult.customOperations; + const conditionType = typeRegistry.get('ArticleCondition'); + + expect(conditionType).toBeDefined(); + expect(conditionType!.kind).toBe('INPUT_OBJECT'); + + const fieldNames = conditionType!.inputFields?.map((f) => f.name) ?? []; + expect(fieldNames).toContain('bm25Body'); + }); + + it('PostCondition in typeRegistry includes bm25Content field', () => { + const { typeRegistry } = pipelineResult.customOperations; + const conditionType = typeRegistry.get('PostCondition'); + + expect(conditionType).toBeDefined(); + expect(conditionType!.kind).toBe('INPUT_OBJECT'); + + const fieldNames = conditionType!.inputFields?.map((f) => f.name) ?? []; + expect(fieldNames).toContain('bm25Content'); + }); + + it('ArticlesOrderBy in typeRegistry includes BM25 score values', () => { + const { typeRegistry } = pipelineResult.customOperations; + const orderByType = typeRegistry.get('ArticlesOrderBy'); + + expect(orderByType).toBeDefined(); + expect(orderByType!.kind).toBe('ENUM'); + expect(orderByType!.enumValues).toContain('BM25_BODY_SCORE_ASC'); + expect(orderByType!.enumValues).toContain('BM25_BODY_SCORE_DESC'); + }); + + it('PostsOrderBy in typeRegistry includes BM25 score values', () => { + const { typeRegistry } = pipelineResult.customOperations; + const orderByType = typeRegistry.get('PostsOrderBy'); + + expect(orderByType).toBeDefined(); + expect(orderByType!.kind).toBe('ENUM'); + expect(orderByType!.enumValues).toContain('BM25_CONTENT_SCORE_ASC'); + expect(orderByType!.enumValues).toContain('BM25_CONTENT_SCORE_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 result = generateOrm({ + tables, + customOperations, + config, + }); + + const inputTypesFile = result.files.find((f) => + f.path.includes('input-types'), + ); + expect(inputTypesFile).toBeDefined(); + inputTypesContent = inputTypesFile!.content; + }); + + // === Condition types with plugin-injected BM25 fields === + + it('generates ArticleCondition with bm25Body field', () => { + expect(inputTypesContent).toContain( + 'export interface ArticleCondition {', + ); + + // Standard column fields + expect(inputTypesContent).toMatch(/id\?.*string.*null/); + expect(inputTypesContent).toMatch(/title\?.*string.*null/); + expect(inputTypesContent).toMatch(/body\?.*string.*null/); + + // Plugin-injected BM25 search field + expect(inputTypesContent).toContain('bm25Body'); + }); + + it('generates PostCondition with bm25Content field', () => { + expect(inputTypesContent).toContain( + 'export interface PostCondition {', + ); + + // Plugin-injected BM25 search field + expect(inputTypesContent).toContain('bm25Content'); + }); + + it('generates Bm25SearchInput type', () => { + expect(inputTypesContent).toContain( + 'export interface Bm25SearchInput {', + ); + // query field should be required (NON_NULL String in schema) + expect(inputTypesContent).toMatch(/query:\s*string/); + // threshold should be optional + expect(inputTypesContent).toContain('threshold?'); + }); + + // === OrderBy types with plugin-injected BM25 score values === + + it('generates ArticlesOrderBy with BM25 score values', () => { + expect(inputTypesContent).toContain('export type ArticlesOrderBy ='); + // Standard values + expect(inputTypesContent).toContain('"PRIMARY_KEY_ASC"'); + expect(inputTypesContent).toContain('"ID_ASC"'); + expect(inputTypesContent).toContain('"TITLE_ASC"'); + // Plugin-injected BM25 score values + expect(inputTypesContent).toContain('"BM25_BODY_SCORE_ASC"'); + expect(inputTypesContent).toContain('"BM25_BODY_SCORE_DESC"'); + }); + + it('generates PostsOrderBy with BM25 content score values', () => { + expect(inputTypesContent).toContain('export type PostsOrderBy ='); + // Plugin-injected BM25 score values + expect(inputTypesContent).toContain('"BM25_CONTENT_SCORE_ASC"'); + expect(inputTypesContent).toContain('"BM25_CONTENT_SCORE_DESC"'); + }); + + // === Backwards compatibility === + + it('still generates standard CRUD input types correctly', () => { + expect(inputTypesContent).toContain( + 'export interface CreateArticleInput {', + ); + expect(inputTypesContent).toContain( + 'export interface UpdateArticleInput {', + ); + expect(inputTypesContent).toContain( + 'export interface DeleteArticleInput {', + ); + expect(inputTypesContent).toContain('export interface ArticlePatch {'); + }); + + it('still generates standard filter types correctly', () => { + expect(inputTypesContent).toContain( + 'export interface ArticleFilter {', + ); + expect(inputTypesContent).toContain( + 'export interface PostFilter {', + ); + }); + }); +});