diff --git a/examples/advanced-graphql/prisma/schema.prisma b/examples/advanced-graphql/prisma/schema.prisma index f5b8ff2..c9193fd 100644 --- a/examples/advanced-graphql/prisma/schema.prisma +++ b/examples/advanced-graphql/prisma/schema.prisma @@ -9,7 +9,8 @@ datasource db { } generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] } enum ProductStatus { diff --git a/examples/advanced-graphql/schema-helpers.ts b/examples/advanced-graphql/schema-helpers.ts new file mode 100644 index 0000000..9d418f6 --- /dev/null +++ b/examples/advanced-graphql/schema-helpers.ts @@ -0,0 +1,387 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { Product, ProductQueryArgs, ProductConnection, ProductFilterInput, ProductSortInput, Review, ReviewQueryArgs, ReviewConnection, ReviewFilterInput, ReviewSortInput, Tag, TagQueryArgs, TagConnection, TagFilterInput, ProductTag, ProductTagQueryArgs, ProductTagConnection, ProductTagFilterInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + let take = first || last || 10 + if (last) take = -take + + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildProductConnectionConfig( + args: ProductQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildProductFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildProductSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildProductInclude(info) : PRODUCT_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['reviews', 'tags'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildReviewConnectionConfig( + args: ReviewQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildReviewFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildReviewSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildReviewInclude(info) : REVIEW_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['product'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildTagConnectionConfig( + args: TagQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildTagFilter((args as any).filter) : {} + const orderBy = undefined + const include = info ? FieldSelection.buildTagInclude(info) : TAG_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['products'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildProductTagConnectionConfig( + args: ProductTagQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildProductTagInclude(info) : PRODUCTTAG_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['product', 'tag'], + hasIdField: false, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + const fieldWhere: any = {} + + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + static buildProductFilter(filter?: ProductFilterInput): any { + return this.buildFilter(filter) + } + + static buildReviewFilter(filter?: ReviewFilterInput): any { + return this.buildFilter(filter) + } + + static buildTagFilter(filter?: TagFilterInput): any { + return this.buildFilter(filter) + } + +} + +export class SortBuilder { + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + static buildProductSort(sort?: ProductSortInput): any { + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildReviewSort(sort?: ReviewSortInput): any { + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + +} + + + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildProductInclude(info?: any): any { + const relationFields = ['reviews', 'tags'] + return buildPrismaInclude(info, relationFields) + } + + static buildReviewInclude(info?: any): any { + const relationFields = ['product'] + return buildPrismaInclude(info, relationFields) + } + + static buildTagInclude(info?: any): any { + const relationFields = ['products'] + return buildPrismaInclude(info, relationFields) + } + + static buildProductTagInclude(info?: any): any { + const relationFields = ['product', 'tag'] + return buildPrismaInclude(info, relationFields) + } +} + + +export const PRODUCT_INCLUDES = { + reviews: true, + tags: true +} + +export const REVIEW_INCLUDES = { + product: true +} + +export const TAG_INCLUDES = { + products: true +} + +export const PRODUCTTAG_INCLUDES = { + product: true, + tag: true +} \ No newline at end of file diff --git a/examples/advanced-graphql/schema.ts b/examples/advanced-graphql/schema.ts index d67a875..a0aa178 100644 --- a/examples/advanced-graphql/schema.ts +++ b/examples/advanced-graphql/schema.ts @@ -1,4 +1,4 @@ -import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType } from "type-graphql"; +import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType, InterfaceType } from "type-graphql"; import { GraphQLJSON } from "graphql-scalars"; import "reflect-metadata"; @@ -136,16 +136,30 @@ export class PageInfo { endCursor?: string | undefined; } -@ObjectType() -export class ProductEdge { +@InterfaceType({ description: 'Base interface for all edge types in connections', autoRegisterImplementations: false }) +export abstract class Edge { + @Field(() => String, { description: 'A cursor for use in pagination' }) + cursor!: string; +} + +@InterfaceType({ description: 'Base interface for all connection types', autoRegisterImplementations: false }) +export abstract class Connection { + @Field(() => PageInfo, { description: 'Information to aid in pagination' }) + pageInfo!: PageInfo; + @Field(() => Int, { description: 'The total count of items in the connection' }) + totalCount!: number; +} + +@ObjectType({ implements: Edge }) +export class ProductEdge implements Edge { @Field(() => Product) node!: Product; @Field(() => String) cursor!: string; } -@ObjectType() -export class ProductConnection { +@ObjectType({ implements: Connection }) +export class ProductConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [ProductEdge]) @@ -154,16 +168,16 @@ export class ProductConnection { totalCount!: number; } -@ObjectType() -export class ReviewEdge { +@ObjectType({ implements: Edge }) +export class ReviewEdge implements Edge { @Field(() => Review) node!: Review; @Field(() => String) cursor!: string; } -@ObjectType() -export class ReviewConnection { +@ObjectType({ implements: Connection }) +export class ReviewConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [ReviewEdge]) @@ -172,16 +186,16 @@ export class ReviewConnection { totalCount!: number; } -@ObjectType() -export class TagEdge { +@ObjectType({ implements: Edge }) +export class TagEdge implements Edge { @Field(() => Tag) node!: Tag; @Field(() => String) cursor!: string; } -@ObjectType() -export class TagConnection { +@ObjectType({ implements: Connection }) +export class TagConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [TagEdge]) @@ -190,16 +204,16 @@ export class TagConnection { totalCount!: number; } -@ObjectType() -export class ProductTagEdge { +@ObjectType({ implements: Edge }) +export class ProductTagEdge implements Edge { @Field(() => ProductTag) node!: ProductTag; @Field(() => String) cursor!: string; } -@ObjectType() -export class ProductTagConnection { +@ObjectType({ implements: Connection }) +export class ProductTagConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [ProductTagEdge]) @@ -512,8 +526,6 @@ export class ProductQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -530,8 +542,6 @@ export class ReviewQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -548,8 +558,6 @@ export class TagQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -566,6 +574,4 @@ export class ProductTagQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } diff --git a/examples/advanced-graphql/schema.zmodel b/examples/advanced-graphql/schema.zmodel index d1adb23..16a5201 100644 --- a/examples/advanced-graphql/schema.zmodel +++ b/examples/advanced-graphql/schema.zmodel @@ -5,6 +5,7 @@ datasource db { generator client { provider = 'prisma-client-js' + binaryTargets = ["native", "debian-openssl-3.0.x"] } plugin graphql { diff --git a/examples/advanced-graphql/src/resolvers/index.ts b/examples/advanced-graphql/src/resolvers/index.ts index f742990..a9b6816 100644 --- a/examples/advanced-graphql/src/resolvers/index.ts +++ b/examples/advanced-graphql/src/resolvers/index.ts @@ -1,3 +1,4 @@ export { ProductResolver } from './product.resolver' export { ReviewResolver } from './review.resolver' export { TagResolver } from './tag.resolver' +export { ProductTagResolver } from './product-tag.resolver' diff --git a/examples/advanced-graphql/src/resolvers/product-tag.resolver.ts b/examples/advanced-graphql/src/resolvers/product-tag.resolver.ts new file mode 100644 index 0000000..bf54586 --- /dev/null +++ b/examples/advanced-graphql/src/resolvers/product-tag.resolver.ts @@ -0,0 +1,16 @@ +import { Resolver, FieldResolver, Root, Ctx } from 'type-graphql' +import { ProductTag, Product, Tag } from '../../schema' +import type { Context } from './types' + +@Resolver(() => ProductTag) +export class ProductTagResolver { + @FieldResolver(() => Product) + async product(@Root() productTag: ProductTag, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.product.findUnique({ where: { id: productTag.productId } })) as Product + } + + @FieldResolver(() => Tag) + async tag(@Root() productTag: ProductTag, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.tag.findUnique({ where: { id: productTag.tagId } })) as Tag + } +} diff --git a/examples/advanced-graphql/src/resolvers/product.resolver.ts b/examples/advanced-graphql/src/resolvers/product.resolver.ts index 75bebbe..feef592 100644 --- a/examples/advanced-graphql/src/resolvers/product.resolver.ts +++ b/examples/advanced-graphql/src/resolvers/product.resolver.ts @@ -1,85 +1,49 @@ -import { Resolver, Query, Mutation, Arg, Ctx, ID } from 'type-graphql' -import { Product, ProductCreateInput, ProductUpdateInput, ProductQueryArgs, ProductConnection } from '../../schema' +import { Resolver, Query, Mutation, Arg, Ctx, ID, Info, Int, FieldResolver, Root } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { + Product, + ProductCreateInput, + ProductUpdateInput, + ProductQueryArgs, + ProductConnection, + ProductFilterInput, + ProductSortInput, + ProductTag, + Review, +} from '../../schema' +import { ConnectionBuilder } from '../../schema-helpers' import type { Context } from './types' @Resolver(() => Product) export class ProductResolver { @Query(() => Product, { nullable: true }) async product(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.product.findUnique({ - where: { id }, - include: { - reviews: true, - tags: { - include: { - tag: true, - }, - }, - }, - }) - - return result as Product | null + return (await ctx.prisma.product.findUnique({ where: { id } })) as Product | null } @Query(() => ProductConnection) - async products(@Arg('args', () => ProductQueryArgs, { nullable: true }) args: ProductQueryArgs, @Ctx() ctx: Context): Promise { - const take = args?.first || 20 - const skip = args?.after ? 1 : 0 - const cursor = args?.after ? { id: args.after } : undefined - - const products = await ctx.prisma.product.findMany({ - take: take + 1, - skip, - cursor, - where: this.buildWhereCondition(args?.filter), - orderBy: this.buildOrderBy(args?.sort), - include: { - reviews: true, - tags: { - include: { - tag: true, - }, - }, - }, - }) - - const hasNextPage = products.length > take - const nodes = hasNextPage ? products.slice(0, -1) : products + async products( + @Arg('filter', () => ProductFilterInput, { nullable: true }) filter: ProductFilterInput | undefined, + @Arg('sort', () => ProductSortInput, { nullable: true }) sort: ProductSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, + @Ctx() ctx: Context, + ): Promise { + const args: ProductQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildProductConnectionConfig(args, info) - const edges = nodes.map((product, index) => ({ - node: product as Product, - cursor: product.id, - })) + const items = await ctx.prisma.product.findMany(config.findManyOptions) + const totalCount = await ctx.prisma.product.count(config.countOptions) - return { - edges, - pageInfo: { - hasNextPage, - hasPreviousPage: false, - startCursor: edges[0]?.cursor, - endCursor: edges[edges.length - 1]?.cursor, - }, - totalCount: await ctx.prisma.product.count({ - where: this.buildWhereCondition(args?.filter), - }), - } + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as ProductConnection } @Mutation(() => Product) async createProduct(@Arg('input', () => ProductCreateInput) input: ProductCreateInput, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.product.create({ - data: input, - include: { - reviews: true, - tags: { - include: { - tag: true, - }, - }, - }, - }) - - return result as Product + return (await ctx.prisma.product.create({ data: input })) as Product } @Mutation(() => Product) @@ -88,76 +52,22 @@ export class ProductResolver { @Arg('input', () => ProductUpdateInput) input: ProductUpdateInput, @Ctx() ctx: Context, ): Promise { - const result = await ctx.prisma.product.update({ - where: { id }, - data: input, - include: { - reviews: true, - tags: { - include: { - tag: true, - }, - }, - }, - }) - - return result as Product + return (await ctx.prisma.product.update({ where: { id }, data: input })) as Product } @Mutation(() => Boolean) async deleteProduct(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - await ctx.prisma.product.delete({ - where: { id }, - }) + await ctx.prisma.product.delete({ where: { id } }) return true } - private buildWhereCondition(filter: any) { - if (!filter) return undefined - - const where: any = {} - - if (filter.name) { - where.name = { contains: filter.name.contains || filter.name.equals } - } - - if (filter.price) { - where.price = {} - if (filter.price.gte !== undefined) where.price.gte = filter.price.gte - if (filter.price.lte !== undefined) where.price.lte = filter.price.lte - if (filter.price.equals !== undefined) where.price.equals = filter.price.equals - } - - if (filter.status) { - where.status = filter.status.equals || filter.status - } - - if (filter.createdAt) { - where.createdAt = {} - if (filter.createdAt.gte) where.createdAt.gte = filter.createdAt.gte - if (filter.createdAt.lte) where.createdAt.lte = filter.createdAt.lte - } - - if (filter.AND) { - where.AND = filter.AND.map((f: any) => this.buildWhereCondition(f)) - } - - if (filter.OR) { - where.OR = filter.OR.map((f: any) => this.buildWhereCondition(f)) - } - - return where + @FieldResolver(() => [ProductTag]) + async tags(@Root() product: Product, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.productTag.findMany({ where: { productId: product.id } })) as ProductTag[] } - private buildOrderBy(sort: any) { - if (!sort) return { createdAt: 'desc' } - - const orderBy: any = {} - - if (sort.name) orderBy.name = sort.name.toLowerCase() - if (sort.price) orderBy.price = sort.price.toLowerCase() - if (sort.createdAt) orderBy.createdAt = sort.createdAt.toLowerCase() - - return orderBy + @FieldResolver(() => [Review]) + async reviews(@Root() product: Product, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.review.findMany({ where: { productId: product.id } })) as Review[] } } diff --git a/examples/advanced-graphql/src/resolvers/review.resolver.ts b/examples/advanced-graphql/src/resolvers/review.resolver.ts index 3d327fb..96078ee 100644 --- a/examples/advanced-graphql/src/resolvers/review.resolver.ts +++ b/examples/advanced-graphql/src/resolvers/review.resolver.ts @@ -1,137 +1,54 @@ -import { Resolver, Query, Mutation, Arg, Ctx, ID } from 'type-graphql' -import { Review, ReviewCreateInput, ReviewUpdateInput, ReviewQueryArgs, ReviewConnection } from '../../schema' +import { Resolver, Query, Mutation, Arg, Ctx, ID, Info, Int, FieldResolver, Root } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { Review, ReviewCreateInput, ReviewUpdateInput, ReviewQueryArgs, ReviewConnection, ReviewFilterInput, ReviewSortInput, Product } from '../../schema' +import { ConnectionBuilder } from '../../schema-helpers' import type { Context } from './types' @Resolver(() => Review) export class ReviewResolver { @Query(() => Review, { nullable: true }) async review(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.review.findUnique({ - where: { id }, - include: { - product: true, - }, - }) - - return result as Review | null + return (await ctx.prisma.review.findUnique({ where: { id } })) as Review | null } @Query(() => ReviewConnection) - async reviews(@Arg('args', () => ReviewQueryArgs, { nullable: true }) args: ReviewQueryArgs, @Ctx() ctx: Context): Promise { - const take = args?.first || 10 - const skip = args?.after ? 1 : 0 - const cursor = args?.after ? { id: args.after } : undefined - - const reviews = await ctx.prisma.review.findMany({ - take: take + 1, - skip, - cursor, - where: this.buildWhereCondition(args?.filter), - orderBy: this.buildOrderBy(args?.sort), - include: { - product: true, - }, - }) - - const hasNextPage = reviews.length > take - const nodes = hasNextPage ? reviews.slice(0, -1) : reviews - - const edges = nodes.map((review) => ({ - node: review as Review, - cursor: review.id, - })) - - return { - edges, - pageInfo: { - hasNextPage, - hasPreviousPage: false, - startCursor: edges[0]?.cursor, - endCursor: edges[edges.length - 1]?.cursor, - }, - totalCount: await ctx.prisma.review.count({ - where: this.buildWhereCondition(args?.filter), - }), - } + async reviews( + @Arg('filter', () => ReviewFilterInput, { nullable: true }) filter: ReviewFilterInput | undefined, + @Arg('sort', () => ReviewSortInput, { nullable: true }) sort: ReviewSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, + @Ctx() ctx: Context, + ): Promise { + const args: ReviewQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildReviewConnectionConfig(args, info) + + const items = await ctx.prisma.review.findMany(config.findManyOptions) + const totalCount = await ctx.prisma.review.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as ReviewConnection } @Mutation(() => Review) async createReview(@Arg('input', () => ReviewCreateInput) input: ReviewCreateInput, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.review.create({ - data: input, - include: { - product: true, - }, - }) - - return result as Review + return (await ctx.prisma.review.create({ data: input })) as Review } @Mutation(() => Review) async updateReview(@Arg('id', () => ID) id: string, @Arg('input', () => ReviewUpdateInput) input: ReviewUpdateInput, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.review.update({ - where: { id }, - data: input, - include: { - product: true, - }, - }) - - return result as Review + return (await ctx.prisma.review.update({ where: { id }, data: input })) as Review } @Mutation(() => Boolean) async deleteReview(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - await ctx.prisma.review.delete({ - where: { id }, - }) + await ctx.prisma.review.delete({ where: { id } }) return true } - private buildWhereCondition(filter: any) { - if (!filter) return undefined - - const where: any = {} - - if (filter.title) { - where.title = { contains: filter.title.contains || filter.title.equals } - } - - if (filter.rating) { - where.rating = filter.rating.equals || filter.rating - } - - if (filter.verified !== undefined) { - where.verified = filter.verified.equals !== undefined ? filter.verified.equals : filter.verified - } - - if (filter.createdAt) { - where.createdAt = {} - if (filter.createdAt.gte) where.createdAt.gte = filter.createdAt.gte - if (filter.createdAt.lte) where.createdAt.lte = filter.createdAt.lte - } - - if (filter.AND) { - where.AND = filter.AND.map((f: any) => this.buildWhereCondition(f)) - } - - if (filter.OR) { - where.OR = filter.OR.map((f: any) => this.buildWhereCondition(f)) - } - - return where - } - - private buildOrderBy(sort: any) { - if (!sort) return { createdAt: 'desc' } - - const orderBy: any = {} - - if (sort.title) orderBy.title = sort.title.toLowerCase() - if (sort.rating) orderBy.rating = sort.rating.toLowerCase() - if (sort.createdAt) orderBy.createdAt = sort.createdAt.toLowerCase() - if (sort.helpfulCount) orderBy.helpfulCount = sort.helpfulCount.toLowerCase() - - return orderBy + @FieldResolver(() => Product, { nullable: true }) + async product(@Root() review: Review, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.product.findUnique({ where: { id: review.productId } })) as Product | null } } diff --git a/examples/advanced-graphql/src/resolvers/tag.resolver.ts b/examples/advanced-graphql/src/resolvers/tag.resolver.ts index b0e8eca..258a77c 100644 --- a/examples/advanced-graphql/src/resolvers/tag.resolver.ts +++ b/examples/advanced-graphql/src/resolvers/tag.resolver.ts @@ -1,160 +1,66 @@ -import { Resolver, Query, Mutation, Arg, Ctx, ID } from 'type-graphql' -import { Tag, TagCreateInput, TagUpdateInput, TagQueryArgs, TagConnection } from '../../schema' +import { Resolver, Query, Mutation, Arg, Ctx, ID, Info, Int, FieldResolver, Root } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { Tag, TagCreateInput, TagUpdateInput, TagQueryArgs, TagConnection, TagFilterInput, TagSortInput, ProductTag } from '../../schema' +import { ConnectionBuilder } from '../../schema-helpers' import type { Context } from './types' @Resolver(() => Tag) export class TagResolver { @Query(() => Tag, { nullable: true }) async tag(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.tag.findUnique({ - where: { id }, - include: { - products: { - include: { - product: true, - }, - }, - }, - }) - - return result as Tag | null + return (await ctx.prisma.tag.findUnique({ where: { id } })) as Tag | null } @Query(() => TagConnection) - async tags(@Arg('args', () => TagQueryArgs, { nullable: true }) args: TagQueryArgs, @Ctx() ctx: Context): Promise { - const take = args?.first || 50 - const skip = args?.after ? 1 : 0 - const cursor = args?.after ? { id: args.after } : undefined - - const tags = await ctx.prisma.tag.findMany({ - take: take + 1, - skip, - cursor, - where: this.buildWhereCondition(args?.filter), - orderBy: this.buildOrderBy(args?.sort), - include: { - products: { - include: { - product: true, - }, - }, - }, - }) - - const hasNextPage = tags.length > take - const nodes = hasNextPage ? tags.slice(0, -1) : tags - - const edges = nodes.map((tag) => ({ - node: tag as Tag, - cursor: tag.id, - })) - - return { - edges, - pageInfo: { - hasNextPage, - hasPreviousPage: false, - startCursor: edges[0]?.cursor, - endCursor: edges[edges.length - 1]?.cursor, - }, - totalCount: await ctx.prisma.tag.count({ - where: this.buildWhereCondition(args?.filter), - }), - } + async tags( + @Arg('filter', () => TagFilterInput, { nullable: true }) filter: TagFilterInput | undefined, + @Arg('sort', () => TagSortInput, { nullable: true }) sort: TagSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, + @Ctx() ctx: Context, + ): Promise { + const args: TagQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildTagConnectionConfig(args, info) + + const items = await ctx.prisma.tag.findMany(config.findManyOptions) + const totalCount = await ctx.prisma.tag.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as TagConnection } @Mutation(() => Tag) async createTag(@Arg('input', () => TagCreateInput) input: TagCreateInput, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.tag.create({ - data: input, - include: { - products: { - include: { - product: true, - }, - }, - }, - }) - - return result as Tag + return (await ctx.prisma.tag.create({ data: input })) as Tag } @Mutation(() => Tag) async updateTag(@Arg('id', () => ID) id: string, @Arg('input', () => TagUpdateInput) input: TagUpdateInput, @Ctx() ctx: Context): Promise { - const result = await ctx.prisma.tag.update({ - where: { id }, - data: input, - include: { - products: { - include: { - product: true, - }, - }, - }, - }) - - return result as Tag + return (await ctx.prisma.tag.update({ where: { id }, data: input })) as Tag } @Mutation(() => Boolean) async deleteTag(@Arg('id', () => ID) id: string, @Ctx() ctx: Context): Promise { - await ctx.prisma.tag.delete({ - where: { id }, - }) + await ctx.prisma.tag.delete({ where: { id } }) return true } @Mutation(() => Boolean) async assignTagToProduct(@Arg('tagId', () => ID) tagId: string, @Arg('productId', () => ID) productId: string, @Ctx() ctx: Context): Promise { - await ctx.prisma.productTag.create({ - data: { - tagId, - productId, - }, - }) + await ctx.prisma.productTag.create({ data: { tagId, productId } }) return true } @Mutation(() => Boolean) async removeTagFromProduct(@Arg('tagId', () => ID) tagId: string, @Arg('productId', () => ID) productId: string, @Ctx() ctx: Context): Promise { - await ctx.prisma.productTag.delete({ - where: { - productId_tagId: { - productId, - tagId, - }, - }, - }) + await ctx.prisma.productTag.delete({ where: { productId_tagId: { productId, tagId } } }) return true } - private buildWhereCondition(filter: any) { - if (!filter) return undefined - - const where: any = {} - - if (filter.name) { - where.name = { contains: filter.name.contains || filter.name.equals } - } - - if (filter.AND) { - where.AND = filter.AND.map((f: any) => this.buildWhereCondition(f)) - } - - if (filter.OR) { - where.OR = filter.OR.map((f: any) => this.buildWhereCondition(f)) - } - - return where - } - - private buildOrderBy(sort: any) { - if (!sort) return { name: 'asc' } - - const orderBy: any = {} - - if (sort.name) orderBy.name = sort.name.toLowerCase() - - return orderBy + @FieldResolver(() => [ProductTag]) + async products(@Root() tag: Tag, @Ctx() ctx: Context): Promise { + return (await ctx.prisma.productTag.findMany({ where: { tagId: tag.id } })) as ProductTag[] } } diff --git a/examples/advanced-graphql/src/server.ts b/examples/advanced-graphql/src/server.ts index 2d4f026..2a10951 100644 --- a/examples/advanced-graphql/src/server.ts +++ b/examples/advanced-graphql/src/server.ts @@ -1,5 +1,5 @@ import 'reflect-metadata' -import { ProductResolver, ReviewResolver, TagResolver } from './resolvers' +import { ProductResolver, ReviewResolver, TagResolver, ProductTagResolver } from './resolvers' import { buildSchema } from 'type-graphql' import { createYoga } from 'graphql-yoga' import { createServer } from 'node:http' @@ -9,7 +9,7 @@ import type { Context } from './resolvers/types' const prisma = new PrismaClient() const schema = buildSchema({ - resolvers: [ProductResolver, ReviewResolver, TagResolver], + resolvers: [ProductResolver, ReviewResolver, TagResolver, ProductTagResolver], emitSchemaFile: false, }) diff --git a/examples/advanced-graphql/tests/advanced.test.ts b/examples/advanced-graphql/tests/advanced.test.ts index 5641a15..0cadd86 100644 --- a/examples/advanced-graphql/tests/advanced.test.ts +++ b/examples/advanced-graphql/tests/advanced.test.ts @@ -158,7 +158,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query GetProducts($first: Int, $after: String) { - products(args: { first: $first, after: $after }) { + products(first: $first, after: $after) { edges { node { id @@ -212,7 +212,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query GetProducts($first: Int) { - products(args: { first: $first }) { + products(first: $first) { edges { node { name } cursor @@ -239,7 +239,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query GetProducts($first: Int, $after: String) { - products(args: { first: $first, after: $after }) { + products(first: $first, after: $after) { edges { node { name } } @@ -273,11 +273,9 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query FilterProducts { - products(args: { - filter: { - status: { equals: PUBLISHED } - price: { gte: 50.0 } - } + products(filter: { + status: { equals: PUBLISHED } + price: { gte: 50.0 } }) { edges { node { @@ -312,18 +310,16 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query FilterProductsWithOR { - products(args: { - filter: { - OR: [ - { price: { gte: 150.0 } } - { - AND: [ - { status: { equals: PUBLISHED } } - { price: { lte: 60.0 } } - ] - } - ] - } + products(filter: { + OR: [ + { price: { gte: 150.0 } } + { + AND: [ + { status: { equals: PUBLISHED } } + { price: { lte: 60.0 } } + ] + } + ] }) { edges { node { @@ -359,9 +355,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query SortProducts { - products(args: { - sort: { name: ASC } - }) { + products(sort: { name: ASC }) { edges { node { name @@ -396,9 +390,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query GetArchivedProducts { - products(args: { - filter: { status: { equals: ARCHIVED } } - }) { + products(filter: { status: { equals: ARCHIVED } }) { edges { node { name @@ -438,9 +430,7 @@ describe('Advanced GraphQL Features Tests', () => { body: JSON.stringify({ query: ` query GetFiveStarReviews { - reviews(args: { - filter: { rating: { equals: FIVE } } - }) { + reviews(filter: { rating: { equals: FIVE } }) { edges { node { title diff --git a/examples/advanced-graphql/tests/comprehensive.test.ts b/examples/advanced-graphql/tests/comprehensive.test.ts new file mode 100644 index 0000000..6060f3d --- /dev/null +++ b/examples/advanced-graphql/tests/comprehensive.test.ts @@ -0,0 +1,872 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { fetch } from 'bun' +import { server, prismaClient } from '../src/server' + +const TEST_PORT = 4569 +const GRAPHQL_ENDPOINT = `http://localhost:${TEST_PORT}/graphql` + +describe('Advanced GraphQL Comprehensive Tests', () => { + beforeAll(() => { + server.listen(TEST_PORT, () => { + console.log(`Advanced GraphQL comprehensive test server running at http://localhost:${TEST_PORT}/graphql`) + }) + + return new Promise((resolve) => { + setTimeout(async () => { + try { + await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }) + resolve() + } catch (e) { + console.log('Waiting for server to start...') + setTimeout(resolve, 500) + } + }, 500) + }) + }) + + beforeEach(async () => { + await prismaClient.productTag.deleteMany() + await prismaClient.review.deleteMany() + await prismaClient.product.deleteMany() + await prismaClient.tag.deleteMany() + }) + + afterAll(() => { + server.close() + prismaClient.$disconnect() + }) + + describe('Schema Validation', () => { + test('Schema has all expected types', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectTypes { + __schema { + types { + name + kind + } + } + } + `, + }), + }) + + const result = await response.json() + const typeNames = result.data.__schema.types.map((t: any) => t.name) + + expect(typeNames).toContain('Product') + expect(typeNames).toContain('Review') + expect(typeNames).toContain('Tag') + expect(typeNames).toContain('ProductTag') + expect(typeNames).toContain('ProductStatus') + expect(typeNames).toContain('ReviewRating') + expect(typeNames).toContain('ProductConnection') + expect(typeNames).toContain('ReviewConnection') + expect(typeNames).toContain('TagConnection') + }) + + test('Schema has all expected queries', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectQueries { + __schema { + queryType { + fields { + name + type { name } + } + } + } + } + `, + }), + }) + + const result = await response.json() + const queryFields = result.data.__schema.queryType.fields.map((f: any) => f.name) + + expect(queryFields).toContain('products') + expect(queryFields).toContain('product') + expect(queryFields).toContain('reviews') + expect(queryFields).toContain('review') + expect(queryFields).toContain('tags') + expect(queryFields).toContain('tag') + }) + + test('Schema has all expected mutations', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectMutations { + __schema { + mutationType { + fields { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + const mutationFields = result.data.__schema.mutationType.fields.map((f: any) => f.name) + + expect(mutationFields).toContain('createProduct') + expect(mutationFields).toContain('updateProduct') + expect(mutationFields).toContain('deleteProduct') + expect(mutationFields).toContain('createReview') + expect(mutationFields).toContain('createTag') + expect(mutationFields).toContain('assignTagToProduct') + expect(mutationFields).toContain('removeTagFromProduct') + }) + + test('Enums are properly registered', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectEnums { + __type(name: "ProductStatus") { + enumValues { + name + } + } + } + `, + }), + }) + + const result = await response.json() + const enumValues = result.data.__type.enumValues.map((e: any) => e.name) + + expect(enumValues).toContain('DRAFT') + expect(enumValues).toContain('PUBLISHED') + expect(enumValues).toContain('ARCHIVED') + }) + }) + + describe('Advanced Connection Builder Tests', () => { + test('Products connection with complex filtering', async () => { + await Promise.all([ + prismaClient.product.create({ data: { name: 'Expensive Laptop', price: 1500.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Budget Phone', price: 200.0, status: 'DRAFT' } }), + prismaClient.product.create({ data: { name: 'Premium Tablet', price: 800.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Gaming Console', price: 500.0, status: 'ARCHIVED' } }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query ComplexProductFiltering { + products( + filter: { + AND: [ + { price: { gte: 500.0 } } + { status: { in: [PUBLISHED, ARCHIVED] } } + ] + } + sort: { price: DESC } + first: 10 + ) { + totalCount + pageInfo { + hasNextPage + } + edges { + cursor + node { + name + price + status + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.products.totalCount).toBe(3) + expect(result.data.products.edges[0].node.name).toBe('Expensive Laptop') + expect(result.data.products.edges[1].node.name).toBe('Premium Tablet') + expect(result.data.products.edges[2].node.name).toBe('Gaming Console') + }) + + test('Reviews connection with rating-based filtering', async () => { + const product = await prismaClient.product.create({ + data: { name: 'Test Product', price: 100.0, status: 'PUBLISHED' }, + }) + + await Promise.all([ + prismaClient.review.create({ + data: { title: 'Excellent!', content: 'Great product', rating: 'FIVE', verified: true, helpfulCount: 10, productId: product.id }, + }), + prismaClient.review.create({ + data: { title: 'Good', content: 'Nice product', rating: 'FOUR', verified: false, helpfulCount: 5, productId: product.id }, + }), + prismaClient.review.create({ + data: { title: 'Average', content: 'Okay product', rating: 'THREE', verified: true, helpfulCount: 2, productId: product.id }, + }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query HighRatedReviews { + reviews( + filter: { + AND: [ + { rating: { in: [FOUR, FIVE] } } + { verified: { equals: true } } + ] + } + sort: { helpfulCount: DESC } + ) { + totalCount + edges { + node { + title + rating + verified + helpfulCount + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.reviews.totalCount).toBe(1) + expect(result.data.reviews.edges[0].node.title).toBe('Excellent!') + expect(result.data.reviews.edges[0].node.rating).toBe('FIVE') + }) + + test('Tag connection with name-based filtering', async () => { + await Promise.all([ + prismaClient.tag.create({ data: { name: 'Electronics', color: '#FF0000' } }), + prismaClient.tag.create({ data: { name: 'Gadgets', color: '#00FF00' } }), + prismaClient.tag.create({ data: { name: 'Mobile', color: '#0000FF' } }), + prismaClient.tag.create({ data: { name: 'Computing' } }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query SearchTags { + tags( + filter: { name: { contains: "e" } } + sort: { _placeholder: ASC } + first: 10 + ) { + totalCount + edges { + node { + name + color + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.tags.totalCount).toBe(3) + }) + + test('Cursor-based pagination works correctly', async () => { + const products = await Promise.all([ + prismaClient.product.create({ data: { name: 'Product A', price: 100.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product B', price: 200.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product C', price: 300.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product D', price: 400.0, status: 'PUBLISHED' } }), + ]) + + const firstPageResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query FirstPage { + products(first: 2, sort: { name: ASC }) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + name + } + } + } + } + `, + }), + }) + + const firstPage = await firstPageResponse.json() + expect(firstPage.data.products.pageInfo.hasNextPage).toBe(true) + + const cursor = firstPage.data.products.pageInfo.endCursor + const secondPageResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query SecondPage($after: String!) { + products(first: 2, after: $after, sort: { name: ASC }) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + name + } + } + } + } + `, + variables: { after: cursor }, + }), + }) + + const secondPage = await secondPageResponse.json() + expect(secondPage.data.products.edges[0].node.name).toBe('Product C') + expect(secondPage.data.products.edges[1].node.name).toBe('Product D') + + expect(secondPage.data.products.pageInfo.hasNextPage).toBe(false) + }) + }) + + describe('Advanced Relationship Tests', () => { + test('Product with tags and reviews relationship resolution', async () => { + const product = await prismaClient.product.create({ + data: { name: 'Test Product', price: 100.0, status: 'PUBLISHED', description: 'A test product' }, + }) + const tag1 = await prismaClient.tag.create({ data: { name: 'Electronics', color: '#FF0000' } }) + const tag2 = await prismaClient.tag.create({ data: { name: 'Gadgets' } }) + + await prismaClient.productTag.create({ data: { productId: product.id, tagId: tag1.id } }) + await prismaClient.productTag.create({ data: { productId: product.id, tagId: tag2.id } }) + + await prismaClient.review.create({ + data: { title: 'Great!', content: 'Excellent product', rating: 'FIVE', verified: true, helpfulCount: 5, productId: product.id }, + }) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query ProductWithRelationships($id: ID!) { + product(id: $id) { + name + description + price + tags { + tag { + name + color + } + assignedAt + } + reviews { + title + rating + verified + } + } + } + `, + variables: { id: product.id }, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + + const productData = result.data.product + expect(productData.name).toBe('Test Product') + expect(productData.tags).toHaveLength(2) + expect(productData.reviews).toHaveLength(1) + + const tagNames = productData.tags.map((t: any) => t.tag.name) + expect(tagNames).toContain('Electronics') + expect(tagNames).toContain('Gadgets') + expect(productData.reviews[0].title).toBe('Great!') + }) + + test('Tag to products relationship works correctly', async () => { + const tag = await prismaClient.tag.create({ data: { name: 'Electronics' } }) + const product1 = await prismaClient.product.create({ data: { name: 'Laptop', price: 1000.0, status: 'PUBLISHED' } }) + const product2 = await prismaClient.product.create({ data: { name: 'Phone', price: 500.0, status: 'PUBLISHED' } }) + + await prismaClient.productTag.create({ data: { productId: product1.id, tagId: tag.id } }) + await prismaClient.productTag.create({ data: { productId: product2.id, tagId: tag.id } }) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TagWithProducts($id: ID!) { + tag(id: $id) { + name + products { + product { + name + price + } + } + } + } + `, + variables: { id: tag.id }, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.tag.products).toHaveLength(2) + }) + + test('Review to product relationship works correctly', async () => { + const product = await prismaClient.product.create({ + data: { name: 'Test Product', price: 100.0, status: 'PUBLISHED' }, + }) + const review = await prismaClient.review.create({ + data: { title: 'Great!', content: 'Nice product', rating: 'FOUR', verified: true, helpfulCount: 3, productId: product.id }, + }) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query ReviewWithProduct($id: ID!) { + review(id: $id) { + title + rating + product { + name + price + } + } + } + `, + variables: { id: review.id }, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.review.product.name).toBe('Test Product') + }) + }) + + describe('Mutation Tests', () => { + test('Product lifecycle (create, update, delete)', async () => { + const createResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation CreateProduct($input: ProductCreateInput!) { + createProduct(input: $input) { + id + name + price + status + description + } + } + `, + variables: { + input: { + name: 'Test Product', + price: 99.99, + status: 'DRAFT', + description: 'A test product', + }, + }, + }), + }) + + const createResult = await createResponse.json() + expect(createResult.errors).toBeUndefined() + + const productId = createResult.data.createProduct.id + expect(createResult.data.createProduct.name).toBe('Test Product') + expect(createResult.data.createProduct.status).toBe('DRAFT') + + const updateResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) { + updateProduct(id: $id, input: $input) { + id + name + status + price + } + } + `, + variables: { + id: productId, + input: { + id: productId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + name: 'Test Product', + status: 'PUBLISHED', + price: 129.99, + }, + }, + }), + }) + + const updateResult = await updateResponse.json() + expect(updateResult.errors).toBeUndefined() + expect(updateResult.data.updateProduct.status).toBe('PUBLISHED') + expect(updateResult.data.updateProduct.price).toBe(129.99) + + const deleteResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation DeleteProduct($id: ID!) { + deleteProduct(id: $id) + } + `, + variables: { id: productId }, + }), + }) + + const deleteResult = await deleteResponse.json() + expect(deleteResult.errors).toBeUndefined() + expect(deleteResult.data.deleteProduct).toBe(true) + + const queryResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetProduct($id: ID!) { + product(id: $id) { + id + } + } + `, + variables: { id: productId }, + }), + }) + + const queryResult = await queryResponse.json() + expect(queryResult.data.product).toBeNull() + }) + + test('Tag assignment and removal', async () => { + const product = await prismaClient.product.create({ + data: { name: 'Test Product', price: 100.0, status: 'PUBLISHED' }, + }) + const tag = await prismaClient.tag.create({ data: { name: 'Electronics' } }) + + const assignResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation AssignTag($tagId: ID!, $productId: ID!) { + assignTagToProduct(tagId: $tagId, productId: $productId) + } + `, + variables: { tagId: tag.id, productId: product.id }, + }), + }) + + const assignResult = await assignResponse.json() + expect(assignResult.errors).toBeUndefined() + expect(assignResult.data.assignTagToProduct).toBe(true) + + const verifyResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query VerifyAssignment($id: ID!) { + product(id: $id) { + tags { + tag { + name + } + } + } + } + `, + variables: { id: product.id }, + }), + }) + + const verifyResult = await verifyResponse.json() + expect(verifyResult.data.product.tags).toHaveLength(1) + expect(verifyResult.data.product.tags[0].tag.name).toBe('Electronics') + + const removeResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation RemoveTag($tagId: ID!, $productId: ID!) { + removeTagFromProduct(tagId: $tagId, productId: $productId) + } + `, + variables: { tagId: tag.id, productId: product.id }, + }), + }) + + const removeResult = await removeResponse.json() + expect(removeResult.errors).toBeUndefined() + expect(removeResult.data.removeTagFromProduct).toBe(true) + }) + }) + + describe('Error Handling and Edge Cases', () => { + test('Handles invalid enum values', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation CreateInvalidProduct { + createProduct(input: { + name: "Test" + price: 100.0 + status: INVALID_STATUS + }) { + id + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeDefined() + expect(result.errors[0].message).toContain('Value "INVALID_STATUS"') + }) + + test('Handles non-existent entity operations', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query NonExistentProduct { + product(id: "non-existent-id") { + name + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.product).toBeNull() + }) + + test('Handles empty filter results', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query EmptyResults { + products(filter: { name: { contains: "NonExistentProduct" } }) { + totalCount + edges { + node { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.products.totalCount).toBe(0) + expect(result.data.products.edges).toHaveLength(0) + }) + + test('Handles large pagination requests', async () => { + for (let i = 0; i < 20; i++) { + await prismaClient.product.create({ + data: { + name: `Product ${i + 1}`, + price: (i + 1) * 10.0, + status: i % 2 === 0 ? 'PUBLISHED' : 'DRAFT', + }, + }) + } + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query LargePageRequest { + products(first: 15, sort: { name: ASC }) { + totalCount + pageInfo { + hasNextPage + } + edges { + node { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.products.totalCount).toBe(20) + expect(result.data.products.edges).toHaveLength(15) + expect(result.data.products.pageInfo.hasNextPage).toBe(true) + }) + }) + + describe('Performance and Consistency Tests', () => { + test('Consistent cursor generation', async () => { + await Promise.all([ + prismaClient.product.create({ data: { name: 'Product A', price: 100.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product B', price: 200.0, status: 'PUBLISHED' } }), + ]) + + const response1 = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetCursors { + products(first: 2, sort: { name: ASC }) { + edges { + cursor + node { name } + } + } + } + `, + }), + }) + + const response2 = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetCursors { + products(first: 2, sort: { name: ASC }) { + edges { + cursor + node { name } + } + } + } + `, + }), + }) + + const result1 = await response1.json() + const result2 = await response2.json() + + expect(result1.data.products.edges[0].cursor).toBe(result2.data.products.edges[0].cursor) + expect(result1.data.products.edges[1].cursor).toBe(result2.data.products.edges[1].cursor) + }) + + test('Field resolver performance with multiple products', async () => { + const products = await Promise.all([ + prismaClient.product.create({ data: { name: 'Product 1', price: 100.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product 2', price: 200.0, status: 'PUBLISHED' } }), + prismaClient.product.create({ data: { name: 'Product 3', price: 300.0, status: 'PUBLISHED' } }), + ]) + + for (const product of products) { + const tag = await prismaClient.tag.create({ data: { name: `Tag-${product.id}` } }) + await prismaClient.productTag.create({ data: { productId: product.id, tagId: tag.id } }) + } + + const startTime = Date.now() + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query ProductsWithTags { + products(first: 10) { + edges { + node { + name + tags { + tag { + name + } + } + } + } + } + } + `, + }), + }) + const endTime = Date.now() + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.products.edges).toHaveLength(3) + + expect(endTime - startTime).toBeLessThan(1000) + }) + }) +}) diff --git a/examples/basic/schema-helpers.ts b/examples/basic/schema-helpers.ts new file mode 100644 index 0000000..7871733 --- /dev/null +++ b/examples/basic/schema-helpers.ts @@ -0,0 +1,436 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { Author, AuthorQueryArgs, AuthorConnection, AuthorFilterInput, Book, BookQueryArgs, BookConnection, BookFilterInput, Review, ReviewQueryArgs, ReviewConnection, ReviewFilterInput, Article, ArticleQueryArgs, ArticleConnection, ArticleFilterInput, Publisher, PublisherQueryArgs, PublisherConnection, PublisherFilterInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + /** + * Build connection configuration without executing queries + */ + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + // Calculate pagination parameters + let take = first || last || 10 + if (last) take = -take + + // For composite key models, we skip cursor-based pagination + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + // Build include from GraphQL selection if info is provided + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + // Prepare query options + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + // Only add cursor and skip for models with ID field + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + /** + * Process query results into connection format + */ + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + // Determine pagination info + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + // Remove extra item if present + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + // Build edges - use composite key for cursor if no ID field + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + // For composite key models, create a cursor from available fields or use index + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildAuthorConnectionConfig( + args: AuthorQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildAuthorInclude(info) : AUTHOR_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['books', 'articles'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildBookConnectionConfig( + args: BookQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildBookInclude(info) : BOOK_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author', 'reviews'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildReviewConnectionConfig( + args: ReviewQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildReviewInclude(info) : REVIEW_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['book'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildArticleConnectionConfig( + args: ArticleQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildArticleInclude(info) : ARTICLE_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPublisherConnectionConfig( + args: PublisherQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildPublisherInclude(info) : PUBLISHER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: [], + hasIdField: true, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + /** + * Build Prisma where clause from GraphQL filter input dynamically + * This approach uses runtime reflection to map filter operations + */ + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + // Map filter operations dynamically + const fieldWhere: any = {} + + // Copy all valid operations from the filter value + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + + + + +} + +export class SortBuilder { + /** + * Build Prisma orderBy clause from GraphQL sort input dynamically + * This approach uses runtime reflection to map sort fields + */ + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + // Convert enum values to lowercase for Prisma + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + + + + +} + +// Simplified field selection utilities (GraphQL-import-free) +// Uses static includes instead of dynamic GraphQL field parsing + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +// Simplified version that doesn't require GraphQL imports +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + // For now, return a simple include object based on available relations + // This avoids GraphQL module conflicts while maintaining basic functionality + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildAuthorInclude(info?: any): any { + const relationFields = ['books', 'articles'] + return buildPrismaInclude(info, relationFields) + } + + static buildBookInclude(info?: any): any { + const relationFields = ['author', 'reviews'] + return buildPrismaInclude(info, relationFields) + } + + static buildReviewInclude(info?: any): any { + const relationFields = ['book'] + return buildPrismaInclude(info, relationFields) + } + + static buildArticleInclude(info?: any): any { + const relationFields = ['author'] + return buildPrismaInclude(info, relationFields) + } + + static buildPublisherInclude(info?: any): any { + const relationFields = [] + return buildPrismaInclude(info, relationFields) + } +} + + +export const AUTHOR_INCLUDES = { + books: true, + articles: true +} + +export const BOOK_INCLUDES = { + author: true, + reviews: true +} + +export const REVIEW_INCLUDES = { + book: true +} + +export const ARTICLE_INCLUDES = { + author: true +} + +export const PUBLISHER_INCLUDES = { + +} \ No newline at end of file diff --git a/examples/basic/schema.graphql b/examples/basic/schema.graphql index c010a60..3297ed4 100644 --- a/examples/basic/schema.graphql +++ b/examples/basic/schema.graphql @@ -4,6 +4,21 @@ interface Node { id: ID! } +"""Base interface for all edge types in connections""" +interface Edge { + """A cursor for use in pagination""" + cursor: String! +} + +"""Base interface for all connection types""" +interface Connection { + """Information to aid in pagination""" + pageInfo: PageInfo! + + """The total count of items in the connection""" + totalCount: Int! +} + """A date-time string at UTC, such as 2007-12-03T10:15:30Z""" scalar DateTime @@ -138,7 +153,7 @@ input PaginationInput { } """An edge in a Author connection.""" -type AuthorEdge { +type AuthorEdge implements Edge { """The Author at the end of the edge.""" node: Author! @@ -147,7 +162,7 @@ type AuthorEdge { } """A connection to a list of Author items.""" -type AuthorConnection { +type AuthorConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -159,7 +174,7 @@ type AuthorConnection { } """An edge in a Book connection.""" -type BookEdge { +type BookEdge implements Edge { """The Book at the end of the edge.""" node: Book! @@ -168,7 +183,7 @@ type BookEdge { } """A connection to a list of Book items.""" -type BookConnection { +type BookConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -180,7 +195,7 @@ type BookConnection { } """An edge in a Review connection.""" -type ReviewEdge { +type ReviewEdge implements Edge { """The Review at the end of the edge.""" node: Review! @@ -189,7 +204,7 @@ type ReviewEdge { } """A connection to a list of Review items.""" -type ReviewConnection { +type ReviewConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -201,7 +216,7 @@ type ReviewConnection { } """An edge in a Article connection.""" -type ArticleEdge { +type ArticleEdge implements Edge { """The Article at the end of the edge.""" node: Article! @@ -210,7 +225,7 @@ type ArticleEdge { } """A connection to a list of Article items.""" -type ArticleConnection { +type ArticleConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -222,7 +237,7 @@ type ArticleConnection { } """An edge in a Publisher connection.""" -type PublisherEdge { +type PublisherEdge implements Edge { """The Publisher at the end of the edge.""" node: Publisher! @@ -231,7 +246,7 @@ type PublisherEdge { } """A connection to a list of Publisher items.""" -type PublisherConnection { +type PublisherConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -420,9 +435,6 @@ input AuthorQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Book with optional filter, sort, and pagination""" @@ -444,9 +456,6 @@ input BookQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Review with optional filter, sort, and pagination""" @@ -468,9 +477,6 @@ input ReviewQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Article with optional filter, sort, and pagination""" @@ -492,9 +498,6 @@ input ArticleQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -518,7 +521,4 @@ input PublisherQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } \ No newline at end of file diff --git a/examples/custom-naming/schema-helpers.ts b/examples/custom-naming/schema-helpers.ts new file mode 100644 index 0000000..7391cec --- /dev/null +++ b/examples/custom-naming/schema-helpers.ts @@ -0,0 +1,478 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { Customer, CustomerQueryArgs, CustomerConnection, CustomerFilterInput, Product, ProductQueryArgs, ProductConnection, ProductFilterInput, Address, AddressQueryArgs, AddressConnection, AddressFilterInput, Order, OrderQueryArgs, OrderConnection, OrderFilterInput, OrderItem, OrderItemQueryArgs, OrderItemConnection, OrderItemFilterInput, Category, CategoryQueryArgs, CategoryConnection, CategoryFilterInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + /** + * Build connection configuration without executing queries + */ + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + // Calculate pagination parameters + let take = first || last || 10 + if (last) take = -take + + // For composite key models, we skip cursor-based pagination + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + // Build include from GraphQL selection if info is provided + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + // Prepare query options + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + // Only add cursor and skip for models with ID field + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + /** + * Process query results into connection format + */ + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + // Determine pagination info + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + // Remove extra item if present + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + // Build edges - use composite key for cursor if no ID field + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + // For composite key models, create a cursor from available fields or use index + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildCustomerConnectionConfig( + args: CustomerQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildCustomerInclude(info) : CUSTOMER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['shippingAddress', 'billingAddress', 'orders'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildProductConnectionConfig( + args: ProductQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildProductInclude(info) : PRODUCT_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['categories', 'orderItems'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildAddressConnectionConfig( + args: AddressQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildAddressInclude(info) : ADDRESS_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['customerShipping', 'customerBilling'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildOrderConnectionConfig( + args: OrderQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildOrderInclude(info) : ORDER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['customer', 'orderItems'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildOrderItemConnectionConfig( + args: OrderItemQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildOrderItemInclude(info) : ORDERITEM_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['order', 'product'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildCategoryConnectionConfig( + args: CategoryQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildCategoryInclude(info) : CATEGORY_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['products', 'parentCategory', 'subCategories'], + hasIdField: true, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + /** + * Build Prisma where clause from GraphQL filter input dynamically + * This approach uses runtime reflection to map filter operations + */ + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + // Map filter operations dynamically + const fieldWhere: any = {} + + // Copy all valid operations from the filter value + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + + + + + +} + +export class SortBuilder { + /** + * Build Prisma orderBy clause from GraphQL sort input dynamically + * This approach uses runtime reflection to map sort fields + */ + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + // Convert enum values to lowercase for Prisma + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + + + + + +} + +// Simplified field selection utilities (GraphQL-import-free) +// Uses static includes instead of dynamic GraphQL field parsing + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +// Simplified version that doesn't require GraphQL imports +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + // For now, return a simple include object based on available relations + // This avoids GraphQL module conflicts while maintaining basic functionality + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildCustomerInclude(info?: any): any { + const relationFields = ['shippingAddress', 'billingAddress', 'orders'] + return buildPrismaInclude(info, relationFields) + } + + static buildProductInclude(info?: any): any { + const relationFields = ['categories', 'orderItems'] + return buildPrismaInclude(info, relationFields) + } + + static buildAddressInclude(info?: any): any { + const relationFields = ['customerShipping', 'customerBilling'] + return buildPrismaInclude(info, relationFields) + } + + static buildOrderInclude(info?: any): any { + const relationFields = ['customer', 'orderItems'] + return buildPrismaInclude(info, relationFields) + } + + static buildOrderItemInclude(info?: any): any { + const relationFields = ['order', 'product'] + return buildPrismaInclude(info, relationFields) + } + + static buildCategoryInclude(info?: any): any { + const relationFields = ['products', 'parentCategory', 'subCategories'] + return buildPrismaInclude(info, relationFields) + } +} + + +export const CUSTOMER_INCLUDES = { + shippingAddress: true, + billingAddress: true, + orders: true +} + +export const PRODUCT_INCLUDES = { + categories: true, + orderItems: true +} + +export const ADDRESS_INCLUDES = { + customerShipping: true, + customerBilling: true +} + +export const ORDER_INCLUDES = { + customer: true, + orderItems: true +} + +export const ORDERITEM_INCLUDES = { + order: true, + product: true +} + +export const CATEGORY_INCLUDES = { + products: true, + parentCategory: true, + subCategories: true +} \ No newline at end of file diff --git a/examples/custom-naming/schema.graphql b/examples/custom-naming/schema.graphql index 1b6b066..c487ebb 100644 --- a/examples/custom-naming/schema.graphql +++ b/examples/custom-naming/schema.graphql @@ -4,6 +4,21 @@ interface Node { id: ID! } +"""Base interface for all edge types in connections""" +interface Edge { + """A cursor for use in pagination""" + cursor: String! +} + +"""Base interface for all connection types""" +interface Connection { + """Information to aid in pagination""" + pageInfo: PageInfo! + + """The total count of items in the connection""" + totalCount: Int! +} + """A date-time string at UTC, such as 2007-12-03T10:15:30Z""" scalar DateTime @@ -157,7 +172,7 @@ input PaginationInput { } """An edge in a Customer connection.""" -type CustomerEdge { +type CustomerEdge implements Edge { """The Customer at the end of the edge.""" node: Customer! @@ -166,7 +181,7 @@ type CustomerEdge { } """A connection to a list of Customer items.""" -type CustomerConnection { +type CustomerConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -178,7 +193,7 @@ type CustomerConnection { } """An edge in a Product connection.""" -type ProductEdge { +type ProductEdge implements Edge { """The Product at the end of the edge.""" node: Product! @@ -187,7 +202,7 @@ type ProductEdge { } """A connection to a list of Product items.""" -type ProductConnection { +type ProductConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -199,7 +214,7 @@ type ProductConnection { } """An edge in a Address connection.""" -type AddressEdge { +type AddressEdge implements Edge { """The Address at the end of the edge.""" node: Address! @@ -208,7 +223,7 @@ type AddressEdge { } """A connection to a list of Address items.""" -type AddressConnection { +type AddressConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -220,7 +235,7 @@ type AddressConnection { } """An edge in a Order connection.""" -type OrderEdge { +type OrderEdge implements Edge { """The Order at the end of the edge.""" node: Order! @@ -229,7 +244,7 @@ type OrderEdge { } """A connection to a list of Order items.""" -type OrderConnection { +type OrderConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -241,7 +256,7 @@ type OrderConnection { } """An edge in a OrderItem connection.""" -type OrderItemEdge { +type OrderItemEdge implements Edge { """The OrderItem at the end of the edge.""" node: OrderItem! @@ -250,7 +265,7 @@ type OrderItemEdge { } """A connection to a list of OrderItem items.""" -type OrderItemConnection { +type OrderItemConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -262,7 +277,7 @@ type OrderItemConnection { } """An edge in a Category connection.""" -type CategoryEdge { +type CategoryEdge implements Edge { """The Category at the end of the edge.""" node: Category! @@ -271,7 +286,7 @@ type CategoryEdge { } """A connection to a list of Category items.""" -type CategoryConnection { +type CategoryConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -477,9 +492,6 @@ input CustomerQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Product with optional filter, sort, and pagination""" @@ -495,9 +507,6 @@ input ProductQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Address with optional filter, sort, and pagination""" @@ -519,9 +528,6 @@ input AddressQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Order with optional filter, sort, and pagination""" @@ -543,9 +549,6 @@ input OrderQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -569,9 +572,6 @@ input OrderItemQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -595,7 +595,4 @@ input CategoryQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } \ No newline at end of file diff --git a/examples/full-features/schema-helpers.ts b/examples/full-features/schema-helpers.ts new file mode 100644 index 0000000..067e016 --- /dev/null +++ b/examples/full-features/schema-helpers.ts @@ -0,0 +1,611 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { Member, MemberQueryArgs, MemberConnection, MemberFilterInput, MemberSortInput, UserProfile, UserProfileQueryArgs, UserProfileConnection, UserProfileFilterInput, UserProfileSortInput, Post, PostQueryArgs, PostConnection, PostFilterInput, PostSortInput, Comment, CommentQueryArgs, CommentConnection, CommentFilterInput, CommentSortInput, Reaction, ReactionQueryArgs, ReactionConnection, ReactionFilterInput, ReactionSortInput, Follow, FollowQueryArgs, FollowConnection, FollowFilterInput, FollowSortInput, Tag, TagQueryArgs, TagConnection, TagFilterInput, TagSortInput, TagsOnPosts, TagsOnPostsQueryArgs, TagsOnPostsConnection, TagsOnPostsFilterInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + /** + * Build connection configuration without executing queries + */ + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + // Calculate pagination parameters + let take = first || last || 10 + if (last) take = -take + + // For composite key models, we skip cursor-based pagination + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + // Build include from GraphQL selection if info is provided + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + // Prepare query options + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + // Only add cursor and skip for models with ID field + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + /** + * Process query results into connection format + */ + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + // Determine pagination info + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + // Remove extra item if present + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + // Build edges - use composite key for cursor if no ID field + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + // For composite key models, create a cursor from available fields or use index + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildMemberConnectionConfig( + args: MemberQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildMemberFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildMemberSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildMemberInclude(info) : MEMBER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts', 'comments', 'follows', 'followers', 'likes', 'profile'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildUserProfileConnectionConfig( + args: UserProfileQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildUserProfileFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildUserProfileSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildUserProfileInclude(info) : USERPROFILE_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['user'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPostConnectionConfig( + args: PostQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildPostFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildPostSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildPostInclude(info) : POST_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author', 'comments', 'likes', 'tags'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildCommentConnectionConfig( + args: CommentQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildCommentFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildCommentSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildCommentInclude(info) : COMMENT_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author', 'post', 'parent', 'replies', 'likes'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildReactionConnectionConfig( + args: ReactionQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = 'sort' in args ? SortBuilder.buildReactionSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildReactionInclude(info) : REACTION_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['user', 'post', 'comment'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildFollowConnectionConfig( + args: FollowQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildFollowFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildFollowSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildFollowInclude(info) : FOLLOW_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['follower', 'following'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildTagConnectionConfig( + args: TagQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildTagFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildTagSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildTagInclude(info) : TAG_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildTagsOnPostsConnectionConfig( + args: TagsOnPostsQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildTagsOnPostsInclude(info) : TAGSONPOSTS_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['post', 'tag'], + hasIdField: false, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + /** + * Build Prisma where clause from GraphQL filter input dynamically + * This approach uses runtime reflection to map filter operations + */ + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + // Map filter operations dynamically + const fieldWhere: any = {} + + // Copy all valid operations from the filter value + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + static buildMemberFilter(filter?: MemberFilterInput): any { + return this.buildFilter(filter) + } + + static buildUserProfileFilter(filter?: UserProfileFilterInput): any { + return this.buildFilter(filter) + } + + static buildPostFilter(filter?: PostFilterInput): any { + return this.buildFilter(filter) + } + + static buildCommentFilter(filter?: CommentFilterInput): any { + return this.buildFilter(filter) + } + + + static buildFollowFilter(filter?: FollowFilterInput): any { + return this.buildFilter(filter) + } + + static buildTagFilter(filter?: TagFilterInput): any { + return this.buildFilter(filter) + } + +} + +export class SortBuilder { + /** + * Build Prisma orderBy clause from GraphQL sort input dynamically + * This approach uses runtime reflection to map sort fields + */ + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + // Convert enum values to lowercase for Prisma + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + static buildMemberSort(sort?: MemberSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildUserProfileSort(sort?: UserProfileSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildPostSort(sort?: PostSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildCommentSort(sort?: CommentSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildReactionSort(sort?: ReactionSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildFollowSort(sort?: FollowSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildTagSort(sort?: TagSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + +} + +// Simplified field selection utilities (GraphQL-import-free) +// Uses static includes instead of dynamic GraphQL field parsing + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +// Simplified version that doesn't require GraphQL imports +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + // For now, return a simple include object based on available relations + // This avoids GraphQL module conflicts while maintaining basic functionality + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildMemberInclude(info?: any): any { + const relationFields = ['posts', 'comments', 'follows', 'followers', 'likes', 'profile'] + return buildPrismaInclude(info, relationFields) + } + + static buildUserProfileInclude(info?: any): any { + const relationFields = ['user'] + return buildPrismaInclude(info, relationFields) + } + + static buildPostInclude(info?: any): any { + const relationFields = ['author', 'comments', 'likes', 'tags'] + return buildPrismaInclude(info, relationFields) + } + + static buildCommentInclude(info?: any): any { + const relationFields = ['author', 'post', 'parent', 'replies', 'likes'] + return buildPrismaInclude(info, relationFields) + } + + static buildReactionInclude(info?: any): any { + const relationFields = ['user', 'post', 'comment'] + return buildPrismaInclude(info, relationFields) + } + + static buildFollowInclude(info?: any): any { + const relationFields = ['follower', 'following'] + return buildPrismaInclude(info, relationFields) + } + + static buildTagInclude(info?: any): any { + const relationFields = ['posts'] + return buildPrismaInclude(info, relationFields) + } + + static buildTagsOnPostsInclude(info?: any): any { + const relationFields = ['post', 'tag'] + return buildPrismaInclude(info, relationFields) + } +} + + +export const MEMBER_INCLUDES = { + posts: true, + comments: true, + follows: true, + followers: true, + likes: true, + profile: true +} + +export const USERPROFILE_INCLUDES = { + user: true +} + +export const POST_INCLUDES = { + author: true, + comments: true, + likes: true, + tags: true +} + +export const COMMENT_INCLUDES = { + author: true, + post: true, + parent: true, + replies: true, + likes: true +} + +export const REACTION_INCLUDES = { + user: true, + post: true, + comment: true +} + +export const FOLLOW_INCLUDES = { + follower: true, + following: true +} + +export const TAG_INCLUDES = { + posts: true +} + +export const TAGSONPOSTS_INCLUDES = { + post: true, + tag: true +} \ No newline at end of file diff --git a/examples/full-features/schema.graphql b/examples/full-features/schema.graphql index dd1f533..0b1e98a 100644 --- a/examples/full-features/schema.graphql +++ b/examples/full-features/schema.graphql @@ -4,6 +4,21 @@ interface Node { id: ID! } +"""Base interface for all edge types in connections""" +interface Edge { + """A cursor for use in pagination""" + cursor: String! +} + +"""Base interface for all connection types""" +interface Connection { + """Information to aid in pagination""" + pageInfo: PageInfo! + + """The total count of items in the connection""" + totalCount: Int! +} + """A date-time string at UTC, such as 2007-12-03T10:15:30Z""" scalar DateTime @@ -198,7 +213,7 @@ input PaginationInput { } """An edge in a Member connection.""" -type MemberEdge { +type MemberEdge implements Edge { """The Member at the end of the edge.""" node: Member! @@ -207,7 +222,7 @@ type MemberEdge { } """A connection to a list of Member items.""" -type MemberConnection { +type MemberConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -219,7 +234,7 @@ type MemberConnection { } """An edge in a UserProfile connection.""" -type UserProfileEdge { +type UserProfileEdge implements Edge { """The UserProfile at the end of the edge.""" node: UserProfile! @@ -228,7 +243,7 @@ type UserProfileEdge { } """A connection to a list of UserProfile items.""" -type UserProfileConnection { +type UserProfileConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -240,7 +255,7 @@ type UserProfileConnection { } """An edge in a Post connection.""" -type PostEdge { +type PostEdge implements Edge { """The Post at the end of the edge.""" node: Post! @@ -249,7 +264,7 @@ type PostEdge { } """A connection to a list of Post items.""" -type PostConnection { +type PostConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -261,7 +276,7 @@ type PostConnection { } """An edge in a Comment connection.""" -type CommentEdge { +type CommentEdge implements Edge { """The Comment at the end of the edge.""" node: Comment! @@ -270,7 +285,7 @@ type CommentEdge { } """A connection to a list of Comment items.""" -type CommentConnection { +type CommentConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -282,7 +297,7 @@ type CommentConnection { } """An edge in a Reaction connection.""" -type ReactionEdge { +type ReactionEdge implements Edge { """The Reaction at the end of the edge.""" node: Reaction! @@ -291,7 +306,7 @@ type ReactionEdge { } """A connection to a list of Reaction items.""" -type ReactionConnection { +type ReactionConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -303,7 +318,7 @@ type ReactionConnection { } """An edge in a Follow connection.""" -type FollowEdge { +type FollowEdge implements Edge { """The Follow at the end of the edge.""" node: Follow! @@ -312,7 +327,7 @@ type FollowEdge { } """A connection to a list of Follow items.""" -type FollowConnection { +type FollowConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -324,7 +339,7 @@ type FollowConnection { } """An edge in a Tag connection.""" -type TagEdge { +type TagEdge implements Edge { """The Tag at the end of the edge.""" node: Tag! @@ -333,7 +348,7 @@ type TagEdge { } """A connection to a list of Tag items.""" -type TagConnection { +type TagConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -345,7 +360,7 @@ type TagConnection { } """An edge in a TagsOnPosts connection.""" -type TagsOnPostsEdge { +type TagsOnPostsEdge implements Edge { """The TagsOnPosts at the end of the edge.""" node: TagsOnPosts! @@ -354,7 +369,7 @@ type TagsOnPostsEdge { } """A connection to a list of TagsOnPosts items.""" -type TagsOnPostsConnection { +type TagsOnPostsConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -621,9 +636,6 @@ input MemberQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -647,9 +659,6 @@ input UserProfileQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Post with optional filter, sort, and pagination""" @@ -671,9 +680,6 @@ input PostQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Comment with optional filter, sort, and pagination""" @@ -695,9 +701,6 @@ input CommentQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -715,9 +718,6 @@ input ReactionQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Follow with optional filter, sort, and pagination""" @@ -739,9 +739,6 @@ input FollowQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Tag with optional filter, sort, and pagination""" @@ -763,9 +760,6 @@ input TagQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -789,7 +783,4 @@ input TagsOnPostsQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } \ No newline at end of file diff --git a/examples/graphql-server/src/schema-helpers.ts b/examples/graphql-server/src/schema-helpers.ts new file mode 100644 index 0000000..d8b4b46 --- /dev/null +++ b/examples/graphql-server/src/schema-helpers.ts @@ -0,0 +1,463 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { User, UserQueryArgs, UserConnection, UserFilterInput, UserSortInput, Post, PostQueryArgs, PostConnection, PostFilterInput, PostSortInput, Category, CategoryQueryArgs, CategoryConnection, CategoryFilterInput, PostCategory, PostCategoryQueryArgs, PostCategoryConnection, PostCategoryFilterInput, Comment, CommentQueryArgs, CommentConnection, CommentFilterInput, CommentSortInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + /** + * Build connection configuration without executing queries + */ + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + // Calculate pagination parameters + let take = first || last || 10 + if (last) take = -take + + // For composite key models, we skip cursor-based pagination + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + // Build include from GraphQL selection if info is provided + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + // Prepare query options + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + // Only add cursor and skip for models with ID field + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + /** + * Process query results into connection format + */ + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + // Determine pagination info + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + // Remove extra item if present + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + // Build edges - use composite key for cursor if no ID field + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + // For composite key models, create a cursor from available fields or use index + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildUserConnectionConfig( + args: UserQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildUserFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildUserSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildUserInclude(info) : USER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts', 'comments'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPostConnectionConfig( + args: PostQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildPostFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildPostSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildPostInclude(info) : POST_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author', 'categories', 'comments'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildCategoryConnectionConfig( + args: CategoryQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildCategoryFilter((args as any).filter) : {} + const orderBy = undefined + const include = info ? FieldSelection.buildCategoryInclude(info) : CATEGORY_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPostCategoryConnectionConfig( + args: PostCategoryQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = undefined + const include = info ? FieldSelection.buildPostCategoryInclude(info) : POSTCATEGORY_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['post', 'category'], + hasIdField: false, + cursorField: 'id', + }) + } + + static buildCommentConnectionConfig( + args: CommentQueryArgs, + info?: any + ): ConnectionConfig { + const where = {} + const orderBy = 'sort' in args ? SortBuilder.buildCommentSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildCommentInclude(info) : COMMENT_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['post', 'author'], + hasIdField: true, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + /** + * Build Prisma where clause from GraphQL filter input dynamically + * This approach uses runtime reflection to map filter operations + */ + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + // Map filter operations dynamically + const fieldWhere: any = {} + + // Copy all valid operations from the filter value + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + static buildUserFilter(filter?: UserFilterInput): any { + return this.buildFilter(filter) + } + + static buildPostFilter(filter?: PostFilterInput): any { + return this.buildFilter(filter) + } + + static buildCategoryFilter(filter?: CategoryFilterInput): any { + return this.buildFilter(filter) + } + + +} + +export class SortBuilder { + /** + * Build Prisma orderBy clause from GraphQL sort input dynamically + * This approach uses runtime reflection to map sort fields + */ + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + // Convert enum values to lowercase for Prisma + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + static buildUserSort(sort?: UserSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildPostSort(sort?: PostSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + + + static buildCommentSort(sort?: CommentSortInput): any { + // For models without id field (like composite key models), don't use id as fallback + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } +} + +// Simplified field selection utilities (GraphQL-import-free) +// Uses static includes instead of dynamic GraphQL field parsing + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +// Simplified version that doesn't require GraphQL imports +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + // For now, return a simple include object based on available relations + // This avoids GraphQL module conflicts while maintaining basic functionality + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildUserInclude(info?: any): any { + const relationFields = ['posts', 'comments'] + return buildPrismaInclude(info, relationFields) + } + + static buildPostInclude(info?: any): any { + const relationFields = ['author', 'categories', 'comments'] + return buildPrismaInclude(info, relationFields) + } + + static buildCategoryInclude(info?: any): any { + const relationFields = ['posts'] + return buildPrismaInclude(info, relationFields) + } + + static buildPostCategoryInclude(info?: any): any { + const relationFields = ['post', 'category'] + return buildPrismaInclude(info, relationFields) + } + + static buildCommentInclude(info?: any): any { + const relationFields = ['post', 'author'] + return buildPrismaInclude(info, relationFields) + } +} + + +export const USER_INCLUDES = { + posts: true, + comments: true +} + +export const POST_INCLUDES = { + author: true, + categories: true, + comments: true +} + +export const CATEGORY_INCLUDES = { + posts: true +} + +export const POSTCATEGORY_INCLUDES = { + post: true, + category: true +} + +export const COMMENT_INCLUDES = { + post: true, + author: true +} \ No newline at end of file diff --git a/examples/graphql-server/src/schema.graphql b/examples/graphql-server/src/schema.graphql index 7b97085..b31b941 100644 --- a/examples/graphql-server/src/schema.graphql +++ b/examples/graphql-server/src/schema.graphql @@ -4,6 +4,21 @@ interface Node { id: ID! } +"""Base interface for all edge types in connections""" +interface Edge { + """A cursor for use in pagination""" + cursor: String! +} + +"""Base interface for all connection types""" +interface Connection { + """Information to aid in pagination""" + pageInfo: PageInfo! + + """The total count of items in the connection""" + totalCount: Int! +} + """A date-time string at UTC, such as 2007-12-03T10:15:30Z""" scalar DateTime @@ -121,7 +136,7 @@ input PaginationInput { } """An edge in a User connection.""" -type UserEdge { +type UserEdge implements Edge { """The User at the end of the edge.""" node: User! @@ -130,7 +145,7 @@ type UserEdge { } """A connection to a list of User items.""" -type UserConnection { +type UserConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -142,7 +157,7 @@ type UserConnection { } """An edge in a Post connection.""" -type PostEdge { +type PostEdge implements Edge { """The Post at the end of the edge.""" node: Post! @@ -151,7 +166,7 @@ type PostEdge { } """A connection to a list of Post items.""" -type PostConnection { +type PostConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -163,7 +178,7 @@ type PostConnection { } """An edge in a Category connection.""" -type CategoryEdge { +type CategoryEdge implements Edge { """The Category at the end of the edge.""" node: Category! @@ -172,7 +187,7 @@ type CategoryEdge { } """A connection to a list of Category items.""" -type CategoryConnection { +type CategoryConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -184,7 +199,7 @@ type CategoryConnection { } """An edge in a PostCategory connection.""" -type PostCategoryEdge { +type PostCategoryEdge implements Edge { """The PostCategory at the end of the edge.""" node: PostCategory! @@ -193,7 +208,7 @@ type PostCategoryEdge { } """A connection to a list of PostCategory items.""" -type PostCategoryConnection { +type PostCategoryConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -205,7 +220,7 @@ type PostCategoryConnection { } """An edge in a Comment connection.""" -type CommentEdge { +type CommentEdge implements Edge { """The Comment at the end of the edge.""" node: Comment! @@ -214,7 +229,7 @@ type CommentEdge { } """A connection to a list of Comment items.""" -type CommentConnection { +type CommentConnection implements Connection { """Information to aid in pagination.""" pageInfo: PageInfo! @@ -433,9 +448,6 @@ input UserQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Post with optional filter, sort, and pagination""" @@ -457,9 +469,6 @@ input PostQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -483,9 +492,6 @@ input CategoryQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """ @@ -503,9 +509,6 @@ input PostCategoryQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } """Query arguments for Comment with optional filter, sort, and pagination""" @@ -527,7 +530,4 @@ input CommentQueryArgs { """Cursor for pagination before this item""" before: String - - """Return connection format with edges and pageInfo""" - connection: Boolean } \ No newline at end of file diff --git a/examples/type-graphql/prisma/schema.prisma b/examples/type-graphql/prisma/schema.prisma index 3c9a1e7..92e42b4 100644 --- a/examples/type-graphql/prisma/schema.prisma +++ b/examples/type-graphql/prisma/schema.prisma @@ -9,7 +9,8 @@ datasource db { } generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] } model User { diff --git a/examples/type-graphql/schema-helpers.ts b/examples/type-graphql/schema-helpers.ts new file mode 100644 index 0000000..e133ec8 --- /dev/null +++ b/examples/type-graphql/schema-helpers.ts @@ -0,0 +1,436 @@ +import type { GraphQLResolveInfo } from 'graphql' +import { User, UserQueryArgs, UserConnection, UserFilterInput, UserSortInput, Post, PostQueryArgs, PostConnection, PostFilterInput, PostSortInput, Category, CategoryQueryArgs, CategoryConnection, CategoryFilterInput, PostCategory, PostCategoryQueryArgs, PostCategoryConnection, PostCategoryFilterInput, Comment, CommentQueryArgs, CommentConnection, CommentFilterInput, CommentSortInput } from './schema' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +} + +export class ConnectionBuilder { + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + let take = first || last || 10 + if (last) take = -take + + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + cursor = item.postId && item.categoryId + ? `${item.postId}:${item.categoryId}` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + + static buildUserConnectionConfig( + args: UserQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildUserFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildUserSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildUserInclude(info) : USER_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts', 'comments'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPostConnectionConfig( + args: PostQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildPostFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildPostSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildPostInclude(info) : POST_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['author', 'categories', 'comments'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildCategoryConnectionConfig( + args: CategoryQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildCategoryFilter((args as any).filter) : {} + const orderBy = undefined + const include = info ? FieldSelection.buildCategoryInclude(info) : CATEGORY_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['posts'], + hasIdField: true, + cursorField: 'id', + }) + } + + static buildPostCategoryConnectionConfig( + args: PostCategoryQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildPostCategoryFilter((args as any).filter) : {} + const orderBy = undefined + const include = info ? FieldSelection.buildPostCategoryInclude(info) : POSTCATEGORY_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['post', 'category'], + hasIdField: false, + cursorField: 'id', + }) + } + + static buildCommentConnectionConfig( + args: CommentQueryArgs, + info?: any + ): ConnectionConfig { + const where = 'filter' in args ? FilterBuilder.buildCommentFilter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.buildCommentSort((args as any).sort) : undefined + const include = info ? FieldSelection.buildCommentInclude(info) : COMMENT_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: ['post', 'author'], + hasIdField: true, + cursorField: 'id', + }) + } +} + +export class FilterBuilder { + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + const fieldWhere: any = {} + + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + + static buildUserFilter(filter?: UserFilterInput): any { + return this.buildFilter(filter) + } + + static buildPostFilter(filter?: PostFilterInput): any { + return this.buildFilter(filter) + } + + static buildCategoryFilter(filter?: CategoryFilterInput): any { + return this.buildFilter(filter) + } + + static buildPostCategoryFilter(filter?: PostCategoryFilterInput): any { + return this.buildFilter(filter) + } + + static buildCommentFilter(filter?: CommentFilterInput): any { + return this.buildFilter(filter) + } +} + +export class SortBuilder { + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + + static buildUserSort(sort?: UserSortInput): any { + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + static buildPostSort(sort?: PostSortInput): any { + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } + + + + static buildCommentSort(sort?: CommentSortInput): any { + const fallbackSort = true ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + } +} + + + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + + static buildUserInclude(info?: any): any { + const relationFields = ['posts', 'comments'] + return buildPrismaInclude(info, relationFields) + } + + static buildPostInclude(info?: any): any { + const relationFields = ['author', 'categories', 'comments'] + return buildPrismaInclude(info, relationFields) + } + + static buildCategoryInclude(info?: any): any { + const relationFields = ['posts'] + return buildPrismaInclude(info, relationFields) + } + + static buildPostCategoryInclude(info?: any): any { + const relationFields = ['post', 'category'] + return buildPrismaInclude(info, relationFields) + } + + static buildCommentInclude(info?: any): any { + const relationFields = ['post', 'author'] + return buildPrismaInclude(info, relationFields) + } +} + + +export const USER_INCLUDES = { + posts: true, + comments: true +} + +export const POST_INCLUDES = { + author: true, + categories: true, + comments: true +} + +export const CATEGORY_INCLUDES = { + posts: true +} + +export const POSTCATEGORY_INCLUDES = { + post: true, + category: true +} + +export const COMMENT_INCLUDES = { + post: true, + author: true +} \ No newline at end of file diff --git a/examples/type-graphql/schema.ts b/examples/type-graphql/schema.ts index 0e07826..e8abb4c 100644 --- a/examples/type-graphql/schema.ts +++ b/examples/type-graphql/schema.ts @@ -1,4 +1,4 @@ -import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType } from "type-graphql"; +import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType, InterfaceType } from "type-graphql"; import { GraphQLJSON } from "graphql-scalars"; import "reflect-metadata"; @@ -132,16 +132,30 @@ export class PageInfo { endCursor?: string | undefined; } -@ObjectType() -export class UserEdge { +@InterfaceType({ description: 'Base interface for all edge types in connections', autoRegisterImplementations: false }) +export abstract class Edge { + @Field(() => String, { description: 'A cursor for use in pagination' }) + cursor!: string; +} + +@InterfaceType({ description: 'Base interface for all connection types', autoRegisterImplementations: false }) +export abstract class Connection { + @Field(() => PageInfo, { description: 'Information to aid in pagination' }) + pageInfo!: PageInfo; + @Field(() => Int, { description: 'The total count of items in the connection' }) + totalCount!: number; +} + +@ObjectType({ implements: Edge }) +export class UserEdge implements Edge { @Field(() => User) node!: User; @Field(() => String) cursor!: string; } -@ObjectType() -export class UserConnection { +@ObjectType({ implements: Connection }) +export class UserConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [UserEdge]) @@ -150,16 +164,16 @@ export class UserConnection { totalCount!: number; } -@ObjectType() -export class PostEdge { +@ObjectType({ implements: Edge }) +export class PostEdge implements Edge { @Field(() => Post) node!: Post; @Field(() => String) cursor!: string; } -@ObjectType() -export class PostConnection { +@ObjectType({ implements: Connection }) +export class PostConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [PostEdge]) @@ -168,16 +182,16 @@ export class PostConnection { totalCount!: number; } -@ObjectType() -export class CategoryEdge { +@ObjectType({ implements: Edge }) +export class CategoryEdge implements Edge { @Field(() => Category) node!: Category; @Field(() => String) cursor!: string; } -@ObjectType() -export class CategoryConnection { +@ObjectType({ implements: Connection }) +export class CategoryConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [CategoryEdge]) @@ -186,16 +200,16 @@ export class CategoryConnection { totalCount!: number; } -@ObjectType() -export class PostCategoryEdge { +@ObjectType({ implements: Edge }) +export class PostCategoryEdge implements Edge { @Field(() => PostCategory) node!: PostCategory; @Field(() => String) cursor!: string; } -@ObjectType() -export class PostCategoryConnection { +@ObjectType({ implements: Connection }) +export class PostCategoryConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [PostCategoryEdge]) @@ -204,16 +218,16 @@ export class PostCategoryConnection { totalCount!: number; } -@ObjectType() -export class CommentEdge { +@ObjectType({ implements: Edge }) +export class CommentEdge implements Edge { @Field(() => Comment) node!: Comment; @Field(() => String) cursor!: string; } -@ObjectType() -export class CommentConnection { +@ObjectType({ implements: Connection }) +export class CommentConnection implements Connection { @Field(() => PageInfo) pageInfo!: PageInfo; @Field(() => [CommentEdge]) @@ -522,8 +536,6 @@ export class UserQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -540,8 +552,6 @@ export class PostQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -558,8 +568,6 @@ export class CategoryQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -572,8 +580,6 @@ export class PostCategoryQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } @InputType() @@ -590,6 +596,4 @@ export class CommentQueryArgs { last?: number | undefined; @Field(() => String, { nullable: true }) before?: string | undefined; - @Field(() => Boolean, { nullable: true }) - connection?: boolean | undefined; } diff --git a/examples/type-graphql/schema.zmodel b/examples/type-graphql/schema.zmodel index ef49e03..fb8b4e9 100644 --- a/examples/type-graphql/schema.zmodel +++ b/examples/type-graphql/schema.zmodel @@ -5,6 +5,7 @@ datasource db { generator client { provider = 'prisma-client-js' + binaryTargets = ["native", "debian-openssl-3.0.x"] } plugin graphql { diff --git a/examples/type-graphql/src/resolvers/base-resolver.ts b/examples/type-graphql/src/resolvers/base-resolver.ts deleted file mode 100644 index 6467d74..0000000 --- a/examples/type-graphql/src/resolvers/base-resolver.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { PrismaClient } from '@prisma/client' -import { ConnectionBuilder, FilterBuilder, SortBuilder } from '../utils/resolver-helpers' -import { PaginationArgs, ConnectionResult } from '../utils/types' - -export abstract class BaseResolver { - protected async findMany( - prisma: PrismaClient, - model: string, - options: { - where?: any - orderBy?: any - include?: any - } = {}, - ): Promise { - const modelDelegate = (prisma as any)[model] - return (await modelDelegate.findMany({ - ...options, - })) as T[] - } - - protected async findUnique(prisma: PrismaClient, model: string, where: any, include?: any): Promise { - const modelDelegate = (prisma as any)[model] - return (await modelDelegate.findUnique({ - where, - include, - })) as T | null - } - - protected async create(prisma: PrismaClient, model: string, data: any, include?: any): Promise { - const modelDelegate = (prisma as any)[model] - return (await modelDelegate.create({ - data, - include, - })) as T - } - - protected async update(prisma: PrismaClient, model: string, where: any, data: any, include?: any): Promise { - const modelDelegate = (prisma as any)[model] - return (await modelDelegate.update({ - where, - data, - include, - })) as T | null - } - - protected async buildConnection( - prisma: PrismaClient, - model: string, - pagination: PaginationArgs, - options: { - where?: any - orderBy?: any - include?: any - } = {}, - ): Promise> { - return ConnectionBuilder.build({ - prisma, - model, - pagination, - ...options, - }) - } - - protected async buildRelayConnection( - prisma: PrismaClient, - model: string, - args: { - filter?: any - sort?: any - first?: number | null - after?: string | null - last?: number | null - before?: string | null - }, - allowedFilterFields: string[], - allowedSortFields: string[], - include?: any, - ): Promise> { - const { filter, sort, first, after, last, before } = args - - const pagination: PaginationArgs = { - first: first ?? (last ? null : 10), - after: after ?? null, - last: last ?? null, - before: before ?? null, - } - - const where = FilterBuilder.buildGeneric(filter, allowedFilterFields) - const orderBy = SortBuilder.buildGeneric(sort, allowedSortFields) - - return this.buildConnection(prisma, model, pagination, { - where, - orderBy, - include, - }) - } - - protected async buildCompositeKeyConnection( - prisma: PrismaClient, - model: string, - args: { - filter?: any - sort?: any - first?: number | null - after?: string | null - last?: number | null - before?: string | null - }, - allowedFilterFields: string[], - allowedSortFields: string[], - getCursor: (item: T) => string, - include?: any, - ): Promise> { - const { filter, sort, first, after, last, before } = args - - const pagination: PaginationArgs = { - first: first ?? (last ? null : 10), - after: after ?? null, - last: last ?? null, - before: before ?? null, - } - - const where = FilterBuilder.buildGeneric(filter, allowedFilterFields) - const orderBy = SortBuilder.buildGeneric(sort, allowedSortFields) - - const modelDelegate = (prisma as any)[model] - - let take = pagination.first || pagination.last || 10 - if (pagination.last) take = -take - - const items = await modelDelegate.findMany({ - take, - where, - orderBy: orderBy || { assignedAt: 'asc' }, - include, - }) - - const totalCount = await modelDelegate.count({ where }) - - const edges = items.map((item: T) => ({ - node: item, - cursor: getCursor(item), - })) - - const hasNextPage = pagination.first ? items.length === pagination.first : false - const hasPreviousPage = pagination.last ? items.length === Math.abs(pagination.last) : false - - return { - pageInfo: { - hasNextPage, - hasPreviousPage, - startCursor: edges[0]?.cursor, - endCursor: edges[edges.length - 1]?.cursor, - }, - edges, - totalCount, - } - } -} diff --git a/examples/type-graphql/src/resolvers/category.resolver.ts b/examples/type-graphql/src/resolvers/category.resolver.ts index a87d95b..432dfe2 100644 --- a/examples/type-graphql/src/resolvers/category.resolver.ts +++ b/examples/type-graphql/src/resolvers/category.resolver.ts @@ -1,35 +1,34 @@ -import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int } from 'type-graphql' -import { Category, PostCategory, CategoryFilterInput, CategorySortInput, CategoryConnection } from '../../schema' -import { BaseResolver } from './base-resolver' +import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int, Info } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { Category, PostCategory, CategoryFilterInput, CategorySortInput, CategoryConnection, CategoryQueryArgs } from '../../schema' import type { Context } from './types' +import { ConnectionBuilder, POSTCATEGORY_INCLUDES } from '../../schema-helpers' @Resolver(() => Category) -export class CategoryResolver extends BaseResolver { - private readonly ALLOWED_FILTER_FIELDS = ['name'] - private readonly ALLOWED_SORT_FIELDS: string[] = [] - +export class CategoryResolver { @Query(() => CategoryConnection) async categories( - @Arg('filter', () => CategoryFilterInput, { nullable: true }) filter: CategoryFilterInput | null, - @Arg('sort', () => CategorySortInput, { nullable: true }) sort: CategorySortInput | null, - @Arg('first', () => Int, { nullable: true }) first: number | null, - @Arg('after', () => String, { nullable: true }) after: string | null, - @Arg('last', () => Int, { nullable: true }) last: number | null, - @Arg('before', () => String, { nullable: true }) before: string | null, + @Arg('filter', () => CategoryFilterInput, { nullable: true }) filter: CategoryFilterInput | undefined, + @Arg('sort', () => CategorySortInput, { nullable: true }) sort: CategorySortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, @Ctx() { prisma }: Context, ): Promise { - return this.buildRelayConnection( - prisma, - 'category', - { filter, sort, first, after, last, before }, - this.ALLOWED_FILTER_FIELDS, - this.ALLOWED_SORT_FIELDS, - ) + const args: CategoryQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildCategoryConnectionConfig(args, info) + + const items = await prisma.category.findMany(config.findManyOptions) + const totalCount = await prisma.category.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as CategoryConnection } @Query(() => Category, { nullable: true }) async category(@Arg('id', () => String) id: string, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'category', { id }) + return await prisma.category.findUnique({ where: { id } }) as Category | null } @Mutation(() => Category) @@ -38,11 +37,11 @@ export class CategoryResolver extends BaseResolver { @Arg('description', () => String, { nullable: true }) description: string | undefined, @Ctx() { prisma }: Context, ): Promise { - return this.create(prisma, 'category', { name, description }) + return await prisma.category.create({ data: { name, description } }) as Category } @FieldResolver(() => [PostCategory]) async posts(@Root() category: Category, @Ctx() { prisma }: Context): Promise { - return this.findMany(prisma, 'categoryOnPost', { where: { categoryId: category.id } }) + return await prisma.categoryOnPost.findMany({ where: { categoryId: category.id }, include: POSTCATEGORY_INCLUDES }) as PostCategory[] } } diff --git a/examples/type-graphql/src/resolvers/comment.resolver.ts b/examples/type-graphql/src/resolvers/comment.resolver.ts index a1d8af5..1134351 100644 --- a/examples/type-graphql/src/resolvers/comment.resolver.ts +++ b/examples/type-graphql/src/resolvers/comment.resolver.ts @@ -1,32 +1,29 @@ -import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int } from 'type-graphql' -import { Comment, User, Post, CommentFilterInput, CommentSortInput, CommentConnection } from '../../schema' -import { BaseResolver } from './base-resolver' +import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int, Info } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { Comment, User, Post, CommentFilterInput, CommentSortInput, CommentConnection, CommentQueryArgs } from '../../schema' import type { Context } from './types' -import { PRISMA_INCLUDES } from '../utils/resolver-helpers' +import { ConnectionBuilder, POST_INCLUDES, USER_INCLUDES, COMMENT_INCLUDES } from '../../schema-helpers' @Resolver(() => Comment) -export class CommentResolver extends BaseResolver { - private readonly ALLOWED_FILTER_FIELDS: string[] = [] - private readonly ALLOWED_SORT_FIELDS = ['createdAt'] - +export class CommentResolver { @Query(() => CommentConnection) async comments( - @Arg('filter', () => CommentFilterInput, { nullable: true }) filter: CommentFilterInput | null, - @Arg('sort', () => CommentSortInput, { nullable: true }) sort: CommentSortInput | null, - @Arg('first', () => Int, { nullable: true }) first: number | null, - @Arg('after', () => String, { nullable: true }) after: string | null, - @Arg('last', () => Int, { nullable: true }) last: number | null, - @Arg('before', () => String, { nullable: true }) before: string | null, + @Arg('filter', () => CommentFilterInput, { nullable: true }) filter: CommentFilterInput | undefined, + @Arg('sort', () => CommentSortInput, { nullable: true }) sort: CommentSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, @Ctx() { prisma }: Context, ): Promise { - return this.buildRelayConnection( - prisma, - 'comment', - { filter, sort, first, after, last, before }, - this.ALLOWED_FILTER_FIELDS, - this.ALLOWED_SORT_FIELDS, - PRISMA_INCLUDES.COMMENT, - ) + const args: CommentQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildCommentConnectionConfig(args, info) + + const items = await prisma.comment.findMany(config.findManyOptions) + const totalCount = await prisma.comment.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as CommentConnection } @Mutation(() => Comment) @@ -36,16 +33,16 @@ export class CommentResolver extends BaseResolver { @Arg('authorId', () => String) authorId: string, @Ctx() { prisma }: Context, ): Promise { - return this.create(prisma, 'comment', { content, postId, authorId }, PRISMA_INCLUDES.COMMENT) + return (await prisma.comment.create({ data: { content, postId, authorId }, include: COMMENT_INCLUDES })) as Comment } @FieldResolver(() => User) async author(@Root() comment: Comment, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'user', { id: comment.authorId }) + return (await prisma.user.findUnique({ where: { id: comment.authorId }, include: USER_INCLUDES })) as User | null } @FieldResolver(() => Post) async post(@Root() comment: Comment, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'post', { id: comment.postId }, PRISMA_INCLUDES.POST) + return (await prisma.post.findUnique({ where: { id: comment.postId }, include: POST_INCLUDES })) as Post | null } } diff --git a/examples/type-graphql/src/resolvers/post-category.resolver.ts b/examples/type-graphql/src/resolvers/post-category.resolver.ts index 475ea35..81e93e2 100644 --- a/examples/type-graphql/src/resolvers/post-category.resolver.ts +++ b/examples/type-graphql/src/resolvers/post-category.resolver.ts @@ -1,41 +1,36 @@ -import { Resolver, Query, FieldResolver, Root, Ctx, Arg, Int } from 'type-graphql' -import { PostCategory, Post, Category, PostCategoryFilterInput, PostCategorySortInput, PostCategoryConnection } from '../../schema' -import { BaseResolver } from './base-resolver' +import { Resolver, Query, FieldResolver, Root, Ctx, Arg, Int, Info } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { PostCategory, Post, Category, PostCategoryConnection, PostCategoryQueryArgs } from '../../schema' import type { Context } from './types' -import { PRISMA_INCLUDES } from '../utils/resolver-helpers' +import { ConnectionBuilder, POST_INCLUDES, CATEGORY_INCLUDES } from '../../schema-helpers' @Resolver(() => PostCategory) -export class PostCategoryResolver extends BaseResolver { - private readonly ALLOWED_FILTER_FIELDS: string[] = [] - private readonly ALLOWED_SORT_FIELDS: string[] = [] - +export class PostCategoryResolver { @Query(() => PostCategoryConnection) async postCategories( - @Arg('filter', () => PostCategoryFilterInput, { nullable: true }) filter: PostCategoryFilterInput | null, - @Arg('sort', () => PostCategorySortInput, { nullable: true }) sort: PostCategorySortInput | null, - @Arg('first', () => Int, { nullable: true }) first: number | null, - @Arg('after', () => String, { nullable: true }) after: string | null, - @Arg('last', () => Int, { nullable: true }) last: number | null, - @Arg('before', () => String, { nullable: true }) before: string | null, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, @Ctx() { prisma }: Context, ): Promise { - return this.buildCompositeKeyConnection( - prisma, - 'categoryOnPost', - { filter, sort, first, after, last, before }, - this.ALLOWED_FILTER_FIELDS, - this.ALLOWED_SORT_FIELDS, - (item) => `${item.postId}:${item.categoryId}`, - ) + const args: PostCategoryQueryArgs = { first, after, last, before } + const config = ConnectionBuilder.buildPostCategoryConnectionConfig(args, info) + + const items = await prisma.categoryOnPost.findMany(config.findManyOptions) + const totalCount = await prisma.categoryOnPost.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as PostCategoryConnection } @FieldResolver(() => Post) async post(@Root() postCategory: PostCategory, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'post', { id: postCategory.postId }, PRISMA_INCLUDES.POST) + return await prisma.post.findUnique({ where: { id: postCategory.postId }, include: POST_INCLUDES }) as Post | null } @FieldResolver(() => Category) async category(@Root() postCategory: PostCategory, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'category', { id: postCategory.categoryId }) + return await prisma.category.findUnique({ where: { id: postCategory.categoryId }, include: CATEGORY_INCLUDES }) as Category | null } } diff --git a/examples/type-graphql/src/resolvers/post.resolver.ts b/examples/type-graphql/src/resolvers/post.resolver.ts index f9db9cc..fc634de 100644 --- a/examples/type-graphql/src/resolvers/post.resolver.ts +++ b/examples/type-graphql/src/resolvers/post.resolver.ts @@ -1,37 +1,34 @@ -import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int } from 'type-graphql' -import { Post, User, Comment, PostCategory, PostFilterInput, PostSortInput, PostConnection } from '../../schema' -import { BaseResolver } from './base-resolver' +import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int, Info } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { Post, User, Comment, PostCategory, PostFilterInput, PostSortInput, PostConnection, PostQueryArgs } from '../../schema' import type { Context } from './types' -import { PRISMA_INCLUDES } from '../utils/resolver-helpers' +import { ConnectionBuilder, USER_INCLUDES, COMMENT_INCLUDES, POSTCATEGORY_INCLUDES, POST_INCLUDES } from '../../schema-helpers' @Resolver(() => Post) -export class PostResolver extends BaseResolver { - private readonly ALLOWED_FILTER_FIELDS = ['title', 'published', 'createdAt'] - private readonly ALLOWED_SORT_FIELDS = ['createdAt', 'updatedAt', 'viewCount'] - +export class PostResolver { @Query(() => PostConnection) async posts( - @Arg('filter', () => PostFilterInput, { nullable: true }) filter: PostFilterInput | null, - @Arg('sort', () => PostSortInput, { nullable: true }) sort: PostSortInput | null, - @Arg('first', () => Int, { nullable: true }) first: number | null, - @Arg('after', () => String, { nullable: true }) after: string | null, - @Arg('last', () => Int, { nullable: true }) last: number | null, - @Arg('before', () => String, { nullable: true }) before: string | null, + @Arg('filter', () => PostFilterInput, { nullable: true }) filter: PostFilterInput | undefined, + @Arg('sort', () => PostSortInput, { nullable: true }) sort: PostSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, @Ctx() { prisma }: Context, ): Promise { - return this.buildRelayConnection( - prisma, - 'post', - { filter, sort, first, after, last, before }, - this.ALLOWED_FILTER_FIELDS, - this.ALLOWED_SORT_FIELDS, - PRISMA_INCLUDES.POST, - ) + const args: PostQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildPostConnectionConfig(args, info) + + const items = await prisma.post.findMany(config.findManyOptions) + const totalCount = await prisma.post.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as PostConnection } @Query(() => Post, { nullable: true }) async post(@Arg('id', () => String) id: string, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'post', { id }, PRISMA_INCLUDES.POST) + return await prisma.post.findUnique({ where: { id }, include: POST_INCLUDES }) as Post | null } @Mutation(() => Post) @@ -42,29 +39,29 @@ export class PostResolver extends BaseResolver { @Arg('published', () => Boolean, { defaultValue: false }) published: boolean = false, @Ctx() { prisma }: Context, ): Promise { - return this.create(prisma, 'post', { title, content, published, authorId }, PRISMA_INCLUDES.POST) + return await prisma.post.create({ data: { title, content, published, authorId }, include: POST_INCLUDES }) as Post } @Mutation(() => Post, { nullable: true }) async publishPost(@Arg('id', () => String) id: string, @Ctx() { prisma }: Context): Promise { - return this.update(prisma, 'post', { id }, { published: true }, PRISMA_INCLUDES.POST) + return await prisma.post.update({ where: { id }, data: { published: true }, include: POST_INCLUDES }) as Post | null } @FieldResolver(() => User) async author(@Root() post: Post, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'user', { id: post.authorId }) + return await prisma.user.findUnique({ where: { id: post.authorId }, include: USER_INCLUDES }) as User | null } @FieldResolver(() => [PostCategory]) async categories(@Root() post: Post, @Ctx() { prisma }: Context): Promise { - return this.findMany(prisma, 'categoryOnPost', { where: { postId: post.id } }) + return await prisma.categoryOnPost.findMany({ where: { postId: post.id }, include: POSTCATEGORY_INCLUDES }) as PostCategory[] } @FieldResolver(() => [Comment]) async comments(@Root() post: Post, @Ctx() { prisma }: Context): Promise { - return this.findMany(prisma, 'comment', { + return await prisma.comment.findMany({ where: { postId: post.id }, - include: PRISMA_INCLUDES.COMMENT, - }) + include: COMMENT_INCLUDES, + }) as Comment[] } } diff --git a/examples/type-graphql/src/resolvers/user.resolver.ts b/examples/type-graphql/src/resolvers/user.resolver.ts index 19e9057..b6eb3c5 100644 --- a/examples/type-graphql/src/resolvers/user.resolver.ts +++ b/examples/type-graphql/src/resolvers/user.resolver.ts @@ -1,36 +1,34 @@ -import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int } from 'type-graphql' -import { User, Post, Comment, UserFilterInput, UserSortInput, UserConnection } from '../../schema' -import { BaseResolver } from './base-resolver' +import { Resolver, Query, Mutation, Arg, Ctx, FieldResolver, Root, Int, Info } from 'type-graphql' +import type { GraphQLResolveInfo } from 'graphql' +import { User, Post, Comment, UserFilterInput, UserSortInput, UserConnection, UserQueryArgs } from '../../schema' import type { Context } from './types' -import { PRISMA_INCLUDES } from '../utils/resolver-helpers' +import { ConnectionBuilder, POST_INCLUDES, COMMENT_INCLUDES } from '../../schema-helpers' @Resolver(() => User) -export class UserResolver extends BaseResolver { - private readonly ALLOWED_FILTER_FIELDS = ['email', 'name', 'createdAt'] - private readonly ALLOWED_SORT_FIELDS = ['createdAt', 'updatedAt'] - +export class UserResolver { @Query(() => UserConnection) async users( - @Arg('filter', () => UserFilterInput, { nullable: true }) filter: UserFilterInput | null, - @Arg('sort', () => UserSortInput, { nullable: true }) sort: UserSortInput | null, - @Arg('first', () => Int, { nullable: true }) first: number | null, - @Arg('after', () => String, { nullable: true }) after: string | null, - @Arg('last', () => Int, { nullable: true }) last: number | null, - @Arg('before', () => String, { nullable: true }) before: string | null, + @Arg('filter', () => UserFilterInput, { nullable: true }) filter: UserFilterInput | undefined, + @Arg('sort', () => UserSortInput, { nullable: true }) sort: UserSortInput | undefined, + @Arg('first', () => Int, { nullable: true }) first: number | undefined, + @Arg('after', () => String, { nullable: true }) after: string | undefined, + @Arg('last', () => Int, { nullable: true }) last: number | undefined, + @Arg('before', () => String, { nullable: true }) before: string | undefined, + @Info() info: GraphQLResolveInfo, @Ctx() { prisma }: Context, ): Promise { - return this.buildRelayConnection( - prisma, - 'user', - { filter, sort, first, after, last, before }, - this.ALLOWED_FILTER_FIELDS, - this.ALLOWED_SORT_FIELDS, - ) + const args: UserQueryArgs = { filter, sort, first, after, last, before } + const config = ConnectionBuilder.buildUserConnectionConfig(args, info) + + const items = await prisma.user.findMany(config.findManyOptions) + const totalCount = await prisma.user.count(config.countOptions) + + return ConnectionBuilder.processResults(items, totalCount, config.paginationInfo) as UserConnection } @Query(() => User, { nullable: true }) async user(@Arg('id', () => String) id: string, @Ctx() { prisma }: Context): Promise { - return this.findUnique(prisma, 'user', { id }) + return await prisma.user.findUnique({ where: { id } }) as User | null } @Mutation(() => User) @@ -40,22 +38,22 @@ export class UserResolver extends BaseResolver { @Arg('bio', () => String, { nullable: true }) bio: string | undefined, @Ctx() { prisma }: Context, ): Promise { - return this.create(prisma, 'user', { name, email, bio }) + return await prisma.user.create({ data: { name, email, bio } }) as User } @FieldResolver(() => [Post]) async posts(@Root() user: User, @Ctx() { prisma }: Context): Promise { - return this.findMany(prisma, 'post', { + return await prisma.post.findMany({ where: { authorId: user.id }, - include: PRISMA_INCLUDES.POST, - }) + include: POST_INCLUDES, + }) as Post[] } @FieldResolver(() => [Comment]) async comments(@Root() user: User, @Ctx() { prisma }: Context): Promise { - return this.findMany(prisma, 'comment', { + return await prisma.comment.findMany({ where: { authorId: user.id }, - include: PRISMA_INCLUDES.COMMENT, - }) + include: COMMENT_INCLUDES, + }) as Comment[] } } diff --git a/examples/type-graphql/src/utils/resolver-helpers.ts b/examples/type-graphql/src/utils/resolver-helpers.ts deleted file mode 100644 index 92f371f..0000000 --- a/examples/type-graphql/src/utils/resolver-helpers.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { PrismaClient } from '@prisma/client' -import { PaginationArgs, ConnectionResult } from './types' - -export const PRISMA_INCLUDES = { - POST: { - author: true, - categories: true, - comments: true, - }, - COMMENT: { - post: true, - author: true, - }, - USER: {}, - CATEGORY: {}, - POST_CATEGORY: {}, -} as const - -export class ConnectionBuilder { - static async build(findManyArgs: { - prisma: PrismaClient - model: string - pagination: PaginationArgs - where?: any - orderBy?: any - include?: any - }): Promise> { - const { prisma, model, pagination, where, orderBy, include } = findManyArgs - const { first, after, last, before } = pagination - - let take = first || last || 10 - if (last) take = -take - - const cursor = after || before ? { id: (after || before)! } : undefined - const skip = cursor ? 1 : 0 - - const modelDelegate = (prisma as any)[model] - - const items = await modelDelegate.findMany({ - take, - skip, - cursor, - where, - orderBy: orderBy || { id: 'asc' }, - include, - }) - - const totalCount = await modelDelegate.count({ where }) - - const edges = items.map((item: T) => ({ - node: item, - cursor: item.id, - })) - - const hasNextPage = first ? items.length === first : false - const hasPreviousPage = last ? items.length === Math.abs(last) : false - - return { - pageInfo: { - hasNextPage, - hasPreviousPage, - startCursor: edges[0]?.cursor, - endCursor: edges[edges.length - 1]?.cursor, - }, - edges, - totalCount, - } - } -} - -export class FilterBuilder { - static buildGeneric(filter: any, allowedFields: string[]): any { - if (!filter) return {} - - const where: any = {} - - for (const field of allowedFields) { - if (filter[field]) { - const fieldFilter = filter[field] - - if (typeof fieldFilter === 'object') { - where[field] = {} - if (fieldFilter.equals !== undefined) where[field].equals = fieldFilter.equals - if (fieldFilter.contains) where[field].contains = fieldFilter.contains - if (fieldFilter.startsWith) where[field].startsWith = fieldFilter.startsWith - if (fieldFilter.endsWith) where[field].endsWith = fieldFilter.endsWith - if (fieldFilter.in) where[field].in = fieldFilter.in - if (fieldFilter.notIn) where[field].notIn = fieldFilter.notIn - if (fieldFilter.gt) where[field].gt = fieldFilter.gt - if (fieldFilter.lt) where[field].lt = fieldFilter.lt - } - } - } - - if (filter.AND) where.AND = filter.AND.map((f: any) => FilterBuilder.buildGeneric(f, allowedFields)) - if (filter.OR) where.OR = filter.OR.map((f: any) => FilterBuilder.buildGeneric(f, allowedFields)) - - return where - } -} - -export class SortBuilder { - static buildGeneric(sort: any, allowedFields: string[]): any { - if (!sort) return {} - - const orderBy: any = {} - - for (const field of allowedFields) { - if (sort[field]) { - orderBy[field] = sort[field].toLowerCase() - } - } - - return orderBy - } -} diff --git a/examples/type-graphql/src/utils/types.ts b/examples/type-graphql/src/utils/types.ts deleted file mode 100644 index 5a60d0b..0000000 --- a/examples/type-graphql/src/utils/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface PaginationArgs { - first: number | null - after: string | null - last: number | null - before: string | null -} - -export interface ConnectionResult { - pageInfo: { - hasNextPage: boolean - hasPreviousPage: boolean - startCursor?: string - endCursor?: string - } - edges: Array<{ - node: T - cursor: string - }> - totalCount: number -} diff --git a/examples/type-graphql/tests/comprehensive.test.ts b/examples/type-graphql/tests/comprehensive.test.ts new file mode 100644 index 0000000..0b0c649 --- /dev/null +++ b/examples/type-graphql/tests/comprehensive.test.ts @@ -0,0 +1,579 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { fetch } from 'bun' +import { server, prismaClient } from '../src/server' + +const TEST_PORT = 4568 +const GRAPHQL_ENDPOINT = `http://localhost:${TEST_PORT}/graphql` + +describe('TypeGraphQL Comprehensive Tests', () => { + beforeAll(() => { + server.listen(TEST_PORT, () => { + console.log(`TypeGraphQL comprehensive test server running at http://localhost:${TEST_PORT}/graphql`) + }) + + return new Promise((resolve) => { + setTimeout(async () => { + try { + await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }) + resolve() + } catch (e) { + console.log('Waiting for server to start...') + setTimeout(resolve, 500) + } + }, 500) + }) + }) + + beforeEach(async () => { + await prismaClient.comment.deleteMany() + await prismaClient.categoryOnPost.deleteMany() + await prismaClient.post.deleteMany() + await prismaClient.category.deleteMany() + await prismaClient.user.deleteMany() + }) + + afterAll(() => { + server.close() + prismaClient.$disconnect() + }) + + describe('Schema Validation', () => { + test('Schema has all expected types', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectTypes { + __schema { + types { + name + kind + } + } + } + `, + }), + }) + + const result = await response.json() + const typeNames = result.data.__schema.types.map((t: any) => t.name) + + expect(typeNames).toContain('User') + expect(typeNames).toContain('Post') + expect(typeNames).toContain('Comment') + expect(typeNames).toContain('Category') + expect(typeNames).toContain('PostCategory') + expect(typeNames).toContain('UserConnection') + expect(typeNames).toContain('PostConnection') + expect(typeNames).toContain('CommentConnection') + expect(typeNames).toContain('CategoryConnection') + expect(typeNames).toContain('PostCategoryConnection') + }) + + test('Schema has all expected queries', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectQueries { + __schema { + queryType { + fields { + name + type { name } + } + } + } + } + `, + }), + }) + + const result = await response.json() + const queryFields = result.data.__schema.queryType.fields.map((f: any) => f.name) + + expect(queryFields).toContain('users') + expect(queryFields).toContain('user') + expect(queryFields).toContain('posts') + expect(queryFields).toContain('post') + expect(queryFields).toContain('comments') + expect(queryFields).toContain('categories') + expect(queryFields).toContain('category') + expect(queryFields).toContain('postCategories') + }) + + test('Schema has all expected mutations', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query IntrospectMutations { + __schema { + mutationType { + fields { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + const mutationFields = result.data.__schema.mutationType.fields.map((f: any) => f.name) + + expect(mutationFields).toContain('createUser') + expect(mutationFields).toContain('createPost') + expect(mutationFields).toContain('publishPost') + expect(mutationFields).toContain('createComment') + expect(mutationFields).toContain('createCategory') + }) + }) + + describe('Connection Builder Pattern Tests', () => { + test('Users connection works with all parameters', async () => { + // Create test users + await Promise.all([ + prismaClient.user.create({ data: { name: 'Alice', email: 'alice@test.com', bio: 'Developer' } }), + prismaClient.user.create({ data: { name: 'Bob', email: 'bob@test.com' } }), + prismaClient.user.create({ data: { name: 'Charlie', email: 'charlie@test.com', bio: 'Designer' } }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestUsersConnection($first: Int, $after: String, $sort: UserSortInput, $filter: UserFilterInput) { + users(first: $first, after: $after, sort: $sort, filter: $filter) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id + name + email + bio + } + } + } + } + `, + variables: { + first: 2, + sort: { createdAt: 'ASC' }, + filter: { name: { contains: 'A' } }, + }, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + // Should filter for names containing 'A' (Alice and Charlie) + expect(result.data.users.totalCount).toBe(2) + expect(result.data.users.edges).toHaveLength(2) + expect(result.data.users.pageInfo.hasNextPage).toBe(false) + }) + + test('Posts connection with filtering and sorting', async () => { + const user = await prismaClient.user.create({ data: { name: 'Test User', email: 'test@example.com' } }) + + await Promise.all([ + prismaClient.post.create({ data: { title: 'Alpha Post', content: 'Content A', authorId: user.id, published: true } }), + prismaClient.post.create({ data: { title: 'Beta Post', content: 'Content B', authorId: user.id, published: false } }), + prismaClient.post.create({ data: { title: 'Gamma Post', content: 'Content C', authorId: user.id, published: true } }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestPostsConnection { + posts( + filter: { published: { equals: true } } + sort: { createdAt: DESC } + first: 10 + ) { + totalCount + edges { + node { + title + published + author { + name + } + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + // Posts should be ordered by creation time (DESC) + expect(result.data.posts.totalCount).toBe(2) + expect(result.data.posts.edges).toHaveLength(2) + }) + + test('Backward pagination works correctly', async () => { + await Promise.all([ + prismaClient.user.create({ data: { name: 'User 1', email: 'user1@test.com' } }), + prismaClient.user.create({ data: { name: 'User 2', email: 'user2@test.com' } }), + prismaClient.user.create({ data: { name: 'User 3', email: 'user3@test.com' } }), + prismaClient.user.create({ data: { name: 'User 4', email: 'user4@test.com' } }), + ]) + + // Get last 2 users + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestBackwardPagination { + users(last: 2, sort: { createdAt: ASC }) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.users.totalCount).toBe(4) + // Should get the last 2 users in creation order + expect(result.data.users.edges).toHaveLength(2) + expect(result.data.users.pageInfo.hasPreviousPage).toBe(true) + }) + }) + + describe('Complex Relationships Tests', () => { + test('Nested relationships work correctly', async () => { + const user = await prismaClient.user.create({ data: { name: 'Test User', email: 'test@example.com' } }) + const category = await prismaClient.category.create({ data: { name: 'Technology', description: 'Tech posts' } }) + const post = await prismaClient.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user.id, published: true } }) + + await prismaClient.categoryOnPost.create({ data: { postId: post.id, categoryId: category.id } }) + await prismaClient.comment.create({ data: { content: 'Great post!', postId: post.id, authorId: user.id } }) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestNestedRelationships { + posts(first: 1) { + edges { + node { + title + author { + name + email + } + categories { + category { + name + description + } + assignedAt + } + comments { + content + author { + name + } + } + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + + const postNode = result.data.posts.edges[0].node + expect(postNode.title).toBe('Test Post') + expect(postNode.author.name).toBe('Test User') + expect(postNode.categories).toHaveLength(1) + expect(postNode.categories[0].category.name).toBe('Technology') + expect(postNode.comments).toHaveLength(1) + expect(postNode.comments[0].content).toBe('Great post!') + }) + + test('Many-to-many relationships work both directions', async () => { + const user = await prismaClient.user.create({ data: { name: 'Test User', email: 'test@example.com' } }) + const category1 = await prismaClient.category.create({ data: { name: 'Tech', description: 'Technology' } }) + const category2 = await prismaClient.category.create({ data: { name: 'Science', description: 'Science posts' } }) + const post = await prismaClient.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user.id } }) + + await prismaClient.categoryOnPost.create({ data: { postId: post.id, categoryId: category1.id } }) + await prismaClient.categoryOnPost.create({ data: { postId: post.id, categoryId: category2.id } }) + + // Test from Post to Categories + const postResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestPostToCategories { + post(id: "${post.id}") { + title + categories { + category { + name + } + } + } + } + `, + }), + }) + + // Test from Categories to Posts + const categoryResponse = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query TestCategoriesToPosts { + categories(first: 10) { + edges { + node { + name + posts { + post { + title + } + } + } + } + } + } + `, + }), + }) + + const postResult = await postResponse.json() + const categoryResult = await categoryResponse.json() + + expect(postResult.errors).toBeUndefined() + expect(categoryResult.errors).toBeUndefined() + + expect(postResult.data.post.categories).toHaveLength(2) + expect(categoryResult.data.categories.edges).toHaveLength(2) + expect(categoryResult.data.categories.edges[0].node.posts).toHaveLength(1) + }) + }) + + describe('Error Handling Tests', () => { + test('Handles invalid queries gracefully', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query InvalidQuery { + nonExistentField + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeDefined() + expect(result.errors[0].message).toContain('Cannot query field "nonExistentField"') + }) + + test('Handles non-existent entity queries', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query NonExistentUser { + user(id: "non-existent-id") { + name + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.user).toBeNull() + }) + + test('Validates required fields in mutations', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation CreateInvalidUser { + createUser(name: "", email: "") { + id + } + } + `, + }), + }) + + const result = await response.json() + // Should either have validation errors or create with empty strings + // The specific behavior depends on validation rules + expect(response.status).toBe(200) + }) + }) + + describe('Performance and Edge Cases', () => { + test('Handles large result sets with pagination', async () => { + // Create 50 users + const users = Array.from({ length: 50 }, (_, i) => ({ + name: `User ${i + 1}`, + email: `user${i + 1}@test.com`, + })) + + await prismaClient.user.createMany({ data: users }) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query LargeResultSet { + users(first: 10, sort: { createdAt: ASC }) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.users.totalCount).toBe(50) + expect(result.data.users.edges).toHaveLength(10) + expect(result.data.users.pageInfo.hasNextPage).toBe(true) + }) + + test('Handles empty result sets correctly', async () => { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query EmptyResultSet { + users(filter: { name: { contains: "NonExistentUser" } }) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + name + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.users.totalCount).toBe(0) + expect(result.data.users.edges).toHaveLength(0) + expect(result.data.users.pageInfo.hasNextPage).toBe(false) + expect(result.data.users.pageInfo.hasPreviousPage).toBe(false) + }) + + test('Complex filtering with AND/OR operations', async () => { + const user = await prismaClient.user.create({ data: { name: 'Test User', email: 'test@example.com' } }) + + await Promise.all([ + prismaClient.post.create({ data: { title: 'Important News', content: 'Breaking news', authorId: user.id, published: true } }), + prismaClient.post.create({ data: { title: 'Daily Update', content: 'Regular update', authorId: user.id, published: false } }), + prismaClient.post.create({ data: { title: 'Special Report', content: 'Important report', authorId: user.id, published: true } }), + ]) + + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query ComplexFiltering { + posts( + filter: { + OR: [ + { + AND: [ + { published: { equals: true } } + { title: { contains: "Important" } } + ] + } + { title: { contains: "Update" } } + ] + } + ) { + totalCount + edges { + node { + title + published + } + } + } + } + `, + }), + }) + + const result = await response.json() + expect(result.errors).toBeUndefined() + expect(result.data.posts.totalCount).toBe(2) // Important posts and Update posts + }) + }) +}) diff --git a/examples/type-graphql/tests/schema-types.test.ts b/examples/type-graphql/tests/schema-types.test.ts index baeae1f..1e5eb04 100644 --- a/examples/type-graphql/tests/schema-types.test.ts +++ b/examples/type-graphql/tests/schema-types.test.ts @@ -12,7 +12,7 @@ describe('TypeGraphQL Test Example', () => { }) test('Generated TypeScript file contains imports', () => { - expect(schemaContent).toContain('import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType } from "type-graphql"') + expect(schemaContent).toContain('import { ObjectType, Field, ID, Int, Float, registerEnumType, InputType, ArgsType, InterfaceType } from "type-graphql"') expect(schemaContent).toContain('import { GraphQLJSON } from "graphql-scalars"') expect(schemaContent).toContain('import "reflect-metadata"') }) @@ -218,6 +218,20 @@ describe('TypeGraphQL Test Example', () => { expect(userConnectionSection).toContain('@Field(() => [UserEdge])') expect(userConnectionSection).toContain('@Field(() => Int)') }) + + test('Edge and Connection interfaces are generated with proper decorators', () => { + expect(schemaContent).toContain('@InterfaceType({ description: \'Base interface for all edge types in connections\', autoRegisterImplementations: false })') + expect(schemaContent).toContain('export abstract class Edge') + expect(schemaContent).toContain('@InterfaceType({ description: \'Base interface for all connection types\', autoRegisterImplementations: false })') + expect(schemaContent).toContain('export abstract class Connection') + }) + + test('Edge and Connection types implement their interfaces', () => { + expect(schemaContent).toContain('@ObjectType({ implements: Edge })') + expect(schemaContent).toContain('export class UserEdge implements Edge') + expect(schemaContent).toContain('@ObjectType({ implements: Connection })') + expect(schemaContent).toContain('export class UserConnection implements Connection') + }) }) describe('TypeScript Types Validation', () => { diff --git a/package.json b/package.json index 5569b24..501601a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hakutakuai/zenstack-graphql", - "version": "1.4.1", + "version": "1.5.0", "description": "ZenStack plugin for generating GraphQL schemas", "main": "dist/index.js", "module": "index.ts", diff --git a/src/core/types.ts b/src/core/types.ts index 5d6fa9b..12f2278 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -40,11 +40,13 @@ export enum GenerationType { FILTER = 'filter', SORT = 'sort', RELATION = 'relation', + HELPER = 'helper', } export interface UnifiedGenerationResult { sdl?: string code?: string + helperCode?: string results: GenerationResult[] stats: UnifiedGenerationStats outputFormat: OutputFormat @@ -59,6 +61,7 @@ export interface UnifiedGenerationStats { connectionTypes: number sortInputTypes: number filterInputTypes: number + helperFiles: number totalTypes: number generationTimeMs: number } diff --git a/src/generators/strategies/graphql-helper-strategy.ts b/src/generators/strategies/graphql-helper-strategy.ts new file mode 100644 index 0000000..0d11a8d --- /dev/null +++ b/src/generators/strategies/graphql-helper-strategy.ts @@ -0,0 +1,8 @@ +import { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { TypeScriptHelperStrategy } from './typescript-helper-strategy' + +export class GraphQLHelperStrategy extends TypeScriptHelperStrategy { + override generateHelpers(helpers: ModelHelper[], context: HelperGenerationContext): string[] { + return super.generateHelpers(helpers, context) + } +} diff --git a/src/generators/strategies/graphql-output-strategy.ts b/src/generators/strategies/graphql-output-strategy.ts index 32bb0b4..1c17284 100644 --- a/src/generators/strategies/graphql-output-strategy.ts +++ b/src/generators/strategies/graphql-output-strategy.ts @@ -7,6 +7,8 @@ import { DataModel } from '@zenstackhq/sdk/ast' import { OutputStrategy, CommonTypeDefinition, SortFieldDefinition, FilterFieldDefinition } from './output-strategy' import { RelationField } from '@generators/unified/unified-relation-generator' import { COMMON_FILTER_TYPES, createGraphQLFilterFields } from '@utils/filter-type-definitions' +import { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { GraphQLHelperStrategy } from './graphql-helper-strategy' export class GraphQLOutputStrategy implements OutputStrategy { constructor( @@ -335,10 +337,6 @@ export class GraphQLOutputStrategy implements OutputStrategy { type: 'String', description: 'Cursor for pagination before this item', } - fields.connection = { - type: 'Boolean', - description: 'Return connection format with edges and pageInfo', - } try { const queryArgsInputTC = this.schemaComposer.createInputTC({ @@ -371,4 +369,9 @@ export class GraphQLOutputStrategy implements OutputStrategy { const typeNames = generatedTypes.map((type) => type.name) return filter ? typeNames.filter(filter) : typeNames } + + generateHelpers(helpers: ModelHelper[], context: HelperGenerationContext): string[] { + const helperStrategy = new GraphQLHelperStrategy() + return helperStrategy.generateHelpers(helpers, context) + } } diff --git a/src/generators/strategies/output-strategy.ts b/src/generators/strategies/output-strategy.ts index 5d16033..2964d4a 100644 --- a/src/generators/strategies/output-strategy.ts +++ b/src/generators/strategies/output-strategy.ts @@ -4,6 +4,7 @@ import { NormalizedOptions } from '@utils/config' import { TypeFormatter } from '@utils/schema/type-formatter' import { SchemaProcessor } from '@utils/schema/schema-processor' import { UnifiedTypeMapper } from '@utils/type-mapping/unified-type-mapper' +import type { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' export interface OutputStrategy { createCommonTypes?(types: CommonTypeDefinition[]): void @@ -43,6 +44,8 @@ export interface OutputStrategy { getGeneratedTypeNames(filter?: (name: string) => boolean): string[] getGeneratedCode?(): string + + generateHelpers?(helpers: ModelHelper[], context: HelperGenerationContext): string[] } export interface CommonTypeDefinition { diff --git a/src/generators/strategies/typescript-helper-strategy.ts b/src/generators/strategies/typescript-helper-strategy.ts new file mode 100644 index 0000000..129677f --- /dev/null +++ b/src/generators/strategies/typescript-helper-strategy.ts @@ -0,0 +1,252 @@ +import { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { CONNECTION_BUILDER_TEMPLATE, MODEL_CONNECTION_METHOD_TEMPLATE } from '@utils/helper-templates/connection-builder.template' +import { FILTER_BUILDER_TEMPLATE, MODEL_FILTER_METHOD_TEMPLATE } from '@utils/helper-templates/filter-builder.template' +import { SORT_BUILDER_TEMPLATE, MODEL_SORT_METHOD_TEMPLATE } from '@utils/helper-templates/sort-builder.template' +import { + FIELD_SELECTION_TEMPLATE, + MODEL_FIELD_SELECTION_METHOD_TEMPLATE, + INCLUDES_TEMPLATE, + MODEL_INCLUDE_TEMPLATE, + RELATION_INCLUDE_TEMPLATE, +} from '@utils/helper-templates/field-selection.template' + +export class TypeScriptHelperStrategy { + generateHelpers(helpers: ModelHelper[], context: HelperGenerationContext): string[] { + const helperCode = this.buildHelperCode(helpers, context) + return [helperCode] + } + + private buildHelperCode(helpers: ModelHelper[], context: HelperGenerationContext): string { + const imports = this.generateImports(context) + const connectionBuilder = this.generateConnectionBuilder(helpers, context) + const filterBuilder = this.generateFilterBuilder(helpers, context) + const sortBuilder = this.generateSortBuilder(helpers, context) + const fieldSelection = this.generateFieldSelection(helpers) + const includes = this.generateIncludes(helpers) + + return `${imports} + +${connectionBuilder} + +${filterBuilder} + +${sortBuilder} + +${fieldSelection} + +${includes}` + } + + private generateImports(context: HelperGenerationContext): string { + const typeImports = context.models + .filter((model) => !context.attributeProcessor.model(model).isIgnored()) + .flatMap((model) => { + const graphqlName = context.attributeProcessor.model(model).name() + const imports = [graphqlName, `${graphqlName}QueryArgs`, `${graphqlName}Connection`, `${graphqlName}FilterInput`] + + if ( + this.shouldGenerateSortForModel( + { + modelName: graphqlName, + relations: [], + connectionBuilderName: '', + filterBuilderName: '', + sortBuilderName: '', + fieldSelectionName: '', + includesConstName: '', + }, + context, + ) + ) { + imports.push(`${graphqlName}SortInput`) + } + + return imports + }) + .join(', ') + + return `import type { GraphQLResolveInfo } from 'graphql' +import { ${typeImports} } from '${this.getSchemaImportPath(context)}' + +export interface PaginationArgs { + first?: number + after?: string + last?: number + before?: string +} + +export interface ConnectionResult { + pageInfo: { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string + } + edges: Array<{ + node: T + cursor: string + }> + totalCount: number +} + +export interface ConnectionConfig { + findManyOptions: { + take: number + where?: any + orderBy?: any + include?: any + cursor?: any + skip?: number + } + countOptions: { + where?: any + } + paginationInfo: { + first?: number + last?: number + after?: string + before?: string + cursorField: string + hasIdField: boolean + relationFields: string[] + } +}` + } + + private generateConnectionBuilder(helpers: ModelHelper[], context: HelperGenerationContext): string { + const modelMethods = helpers.map((helper) => this.generateConnectionMethod(helper, context)).join('\n') + + return CONNECTION_BUILDER_TEMPLATE.replace('{{MODEL_SPECIFIC_METHODS}}', modelMethods) + } + + private generateConnectionMethod(helper: ModelHelper, context: HelperGenerationContext): string { + const relationFields = helper.relations.map((rel) => `'${rel.fieldName}'`).join(', ') + const model = this.findModelByGraphQLName(helper.modelName, context) + const hasIdField = this.modelHasIdField(model) + const prismaModelName = model ? this.getPrismaModelName(model) : helper.modelName.toLowerCase() + const cursorField = this.getCursorField(model) + + const hasFilter = this.shouldGenerateFilterForModel(helper, context) + const hasSort = this.shouldGenerateSortForModel(helper, context) + + let filterSortLogic = '' + if (hasFilter && hasSort) { + filterSortLogic = `const where = 'filter' in args ? FilterBuilder.build${helper.modelName}Filter((args as any).filter) : {} + const orderBy = 'sort' in args ? SortBuilder.build${helper.modelName}Sort((args as any).sort) : undefined` + } else if (hasFilter) { + filterSortLogic = `const where = 'filter' in args ? FilterBuilder.build${helper.modelName}Filter((args as any).filter) : {} + const orderBy = undefined` + } else if (hasSort) { + filterSortLogic = `const where = {} + const orderBy = 'sort' in args ? SortBuilder.build${helper.modelName}Sort((args as any).sort) : undefined` + } else { + filterSortLogic = `const where = {} + const orderBy = undefined` + } + + return MODEL_CONNECTION_METHOD_TEMPLATE.replace(/{{MODEL_NAME}}/g, helper.modelName) + .replace(/{{MODEL_NAME_UPPER}}/g, helper.modelName.toUpperCase()) + .replace(/{{PRISMA_MODEL_NAME}}/g, prismaModelName) + .replace(/{{RELATION_FIELDS}}/g, relationFields) + .replace(/{{HAS_ID_FIELD}}/g, String(hasIdField)) + .replace(/{{CURSOR_FIELD}}/g, cursorField) + .replace(/{{FILTER_SORT_LOGIC}}/g, filterSortLogic) + } + + private generateFilterBuilder(helpers: ModelHelper[], context: HelperGenerationContext): string { + const modelMethods = helpers.map((helper) => this.generateFilterMethod(helper, context)).join('\n') + + return FILTER_BUILDER_TEMPLATE.replace('{{MODEL_SPECIFIC_METHODS}}', modelMethods) + } + + private generateFilterMethod(helper: ModelHelper, context: HelperGenerationContext): string { + if (!this.shouldGenerateFilterForModel(helper, context)) { + return '' + } + + return MODEL_FILTER_METHOD_TEMPLATE.replace(/{{MODEL_NAME}}/g, helper.modelName) + } + + private shouldGenerateFilterForModel(helper: ModelHelper, context: HelperGenerationContext): boolean { + const model = this.findModelByGraphQLName(helper.modelName, context) + if (!model) return false + + return model.fields.some((field: any) => context.attributeProcessor.model(model).field(field.name).isFilterable()) + } + + private generateSortBuilder(helpers: ModelHelper[], context: HelperGenerationContext): string { + const modelMethods = helpers.map((helper) => this.generateSortMethod(helper, context)).join('\n') + + return SORT_BUILDER_TEMPLATE.replace('{{MODEL_SPECIFIC_METHODS}}', modelMethods) + } + + private generateSortMethod(helper: ModelHelper, context: HelperGenerationContext): string { + if (!this.shouldGenerateSortForModel(helper, context)) { + return '' + } + + const model = this.findModelByGraphQLName(helper.modelName, context) + const hasIdField = this.modelHasIdField(model) + + return MODEL_SORT_METHOD_TEMPLATE.replace(/{{MODEL_NAME}}/g, helper.modelName).replace(/{{HAS_ID_FIELD}}/g, String(hasIdField)) + } + + private shouldGenerateSortForModel(helper: ModelHelper, context: HelperGenerationContext): boolean { + const model = this.findModelByGraphQLName(helper.modelName, context) + if (!model) return false + + return model.fields.some((field: any) => context.attributeProcessor.model(model).field(field.name).isSortable()) + } + + private generateFieldSelection(helpers: ModelHelper[]): string { + const modelMethods = helpers.map((helper) => this.generateFieldSelectionMethod(helper)).join('\n') + + return FIELD_SELECTION_TEMPLATE.replace('{{MODEL_SPECIFIC_METHODS}}', modelMethods) + } + + private generateFieldSelectionMethod(helper: ModelHelper): string { + const relationFields = helper.relations.map((rel) => `'${rel.fieldName}'`).join(', ') + + return MODEL_FIELD_SELECTION_METHOD_TEMPLATE.replace(/{{MODEL_NAME}}/g, helper.modelName).replace(/{{RELATION_FIELDS}}/g, relationFields) + } + + private generateIncludes(helpers: ModelHelper[]): string { + const modelIncludes = helpers.map((helper) => this.generateModelInclude(helper)).join('\n') + + return INCLUDES_TEMPLATE.replace('{{MODEL_INCLUDES}}', modelIncludes) + } + + private generateModelInclude(helper: ModelHelper): string { + const relationIncludes = helper.relations.map((rel) => RELATION_INCLUDE_TEMPLATE.replace(/{{FIELD_NAME}}/g, rel.fieldName)).join(',\n\t') + + return MODEL_INCLUDE_TEMPLATE.replace(/{{MODEL_NAME_UPPER}}/g, helper.modelName.toUpperCase()).replace(/{{RELATION_INCLUDES}}/g, relationIncludes) + } + + private findModelByGraphQLName(graphqlName: string, context: HelperGenerationContext): any { + return context.models.find((model) => context.attributeProcessor.model(model).name() === graphqlName) + } + + private modelHasIdField(model: any): boolean { + if (!model) return true + + return model.fields.some((field: any) => field.name === 'id' && !field.type.reference) + } + + private getPrismaModelName(model: any): string { + return model.name.toLowerCase() + } + + private getCursorField(model: any): string { + if (!model) return 'id' + + const idField = model.fields.find((field: any) => field.name === 'id' && !field.type.reference) + + if (idField) return 'id' + + return 'id' + } + + private getSchemaImportPath(context: HelperGenerationContext): string { + return './schema' + } +} diff --git a/src/generators/strategies/typescript-output-strategy.ts b/src/generators/strategies/typescript-output-strategy.ts index be6550d..3ac1b1a 100644 --- a/src/generators/strategies/typescript-output-strategy.ts +++ b/src/generators/strategies/typescript-output-strategy.ts @@ -3,6 +3,8 @@ import { DataModel } from '@zenstackhq/sdk/ast' import { OutputStrategy, CommonTypeDefinition, SortFieldDefinition, FilterFieldDefinition } from './output-strategy' import { RelationField } from '@generators/unified/unified-relation-generator' import { COMMON_FILTER_TYPES, createFilterFields } from '@utils/filter-type-definitions' +import { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { TypeScriptHelperStrategy } from './typescript-helper-strategy' export class TypeScriptOutputStrategy implements OutputStrategy { constructor(private readonly astFactory: TypeScriptASTFactory) {} @@ -129,7 +131,6 @@ export class TypeScriptOutputStrategy implements OutputStrategy { { name: 'after', type: 'String', nullable: true }, { name: 'last', type: 'Int', nullable: true }, { name: 'before', type: 'String', nullable: true }, - { name: 'connection', type: 'Boolean', nullable: true }, ) this.astFactory.createFilterInputType(queryArgsInputName, fields) @@ -145,4 +146,9 @@ export class TypeScriptOutputStrategy implements OutputStrategy { getGeneratedCode(): string { return this.astFactory.getGeneratedCode() } + + generateHelpers(helpers: ModelHelper[], context: HelperGenerationContext): string[] { + const helperStrategy = new TypeScriptHelperStrategy() + return helperStrategy.generateHelpers(helpers, context) + } } diff --git a/src/generators/unified/index.ts b/src/generators/unified/index.ts index 7bf607c..7675410 100644 --- a/src/generators/unified/index.ts +++ b/src/generators/unified/index.ts @@ -10,3 +10,4 @@ export * from './unified-enum-generator' export * from './unified-scalar-generator' export * from './unified-context-factory' export * from './unified-generator-factory' +export * from './unified-helper-generator' diff --git a/src/generators/unified/unified-generator-factory.ts b/src/generators/unified/unified-generator-factory.ts index 6e3d7e5..82a5217 100644 --- a/src/generators/unified/unified-generator-factory.ts +++ b/src/generators/unified/unified-generator-factory.ts @@ -11,6 +11,7 @@ import { UnifiedEnumGenerator, UnifiedScalarGenerator, UnifiedContextFactory, + UnifiedHelperGenerator, } from '@generators/unified' export class UnifiedGeneratorFactory { @@ -42,6 +43,7 @@ export class UnifiedGeneratorFactory { scalarGenerator: new UnifiedScalarGenerator(context), relationGenerator: new UnifiedRelationGenerator(unifiedContext), inputGenerator: new UnifiedInputGenerator(unifiedContext), + helperGenerator: new UnifiedHelperGenerator(unifiedContext), } } diff --git a/src/generators/unified/unified-helper-generator.ts b/src/generators/unified/unified-helper-generator.ts new file mode 100644 index 0000000..322a047 --- /dev/null +++ b/src/generators/unified/unified-helper-generator.ts @@ -0,0 +1,132 @@ +import { UnifiedGeneratorBase } from './unified-generator-base' +import { UnifiedGeneratorContext } from '@generators/strategies' +import { DataModel, DataModelField } from '@zenstackhq/sdk/ast' +import { RelationField } from './unified-relation-generator' +import { OutputFormat } from '@utils/constants' + +export interface HelperGenerationContext { + models: DataModel[] + relations: RelationField[] + outputFormat: OutputFormat + attributeProcessor: any +} + +export interface ModelHelper { + modelName: string + connectionBuilderName: string + filterBuilderName: string + sortBuilderName: string + fieldSelectionName: string + includesConstName: string + relations: RelationField[] +} + +export class UnifiedHelperGenerator extends UnifiedGeneratorBase { + constructor(context: UnifiedGeneratorContext) { + super(context) + } + + override generate(): string[] { + const helpers = this.generateHelpers() + return helpers + } + + protected override generateForModel(_model: DataModel): string | null { + return null + } + + private generateHelpers(): string[] { + const modelHelpers = this.extractModelHelpers() + const helperContext: HelperGenerationContext = { + models: this.models, + relations: this.extractRelations(), + outputFormat: this.options.outputFormat, + attributeProcessor: this.attributeProcessor, + } + + return this.outputStrategy.generateHelpers?.(modelHelpers, helperContext) || [] + } + + private extractModelHelpers(): ModelHelper[] { + return this.models + .filter((model) => !this.shouldSkipModel(model)) + .map((model) => { + const graphqlName = this.attributeProcessor.model(model).name() + return { + modelName: graphqlName, + connectionBuilderName: `${graphqlName}ConnectionBuilder`, + filterBuilderName: `${graphqlName}FilterBuilder`, + sortBuilderName: `${graphqlName}SortBuilder`, + fieldSelectionName: `${graphqlName}FieldSelection`, + includesConstName: `${graphqlName.toUpperCase()}_INCLUDES`, + relations: this.getModelRelations(model), + } + }) + } + + private extractRelations(): RelationField[] { + const relations: RelationField[] = [] + + for (const model of this.models) { + if (this.shouldSkipModel(model)) { + continue + } + + for (const field of model.fields) { + if (this.typeMapper?.isRelationField(field)) { + const targetModelName = field.type.reference?.ref?.name || '' + const targetModel = this.findModelByName(targetModelName) + + if (targetModel) { + const targetField = this.findRelatedField(targetModel, model.name) + + relations.push({ + modelName: model.name, + fieldName: field.name, + targetModelName, + targetFieldName: targetField?.name || null, + isList: field.type.array, + isRequired: field.type.optional === false, + }) + } + } + } + } + + return relations + } + + private getModelRelations(model: DataModel): RelationField[] { + return model.fields + .filter((field) => this.typeMapper?.isRelationField(field)) + .map((field) => { + const targetModelName = field.type.reference?.ref?.name || '' + const targetModel = this.findModelByName(targetModelName) + const targetField = targetModel ? this.findRelatedField(targetModel, model.name) : null + + return { + modelName: model.name, + fieldName: field.name, + targetModelName, + targetFieldName: targetField?.name || null, + isList: field.type.array, + isRequired: field.type.optional === false, + } + }) + .filter((relation) => !!this.findModelByName(relation.targetModelName)) + } + + private findModelByName(name: string): DataModel | undefined { + return this.models.find((model) => model.name === name) + } + + private findRelatedField(targetModel: DataModel, sourceModelName: string): DataModelField | undefined { + return targetModel.fields.find((field) => { + return this.typeMapper?.isRelationField(field) && field.type.reference?.ref?.name === sourceModelName + }) + } + + protected override shouldSkipModel(model: DataModel): boolean { + return this.attributeProcessor.model(model).isIgnored() + } +} \ No newline at end of file diff --git a/src/orchestrator/generator-orchestrator.ts b/src/orchestrator/generator-orchestrator.ts index 1e37f12..80b0003 100644 --- a/src/orchestrator/generator-orchestrator.ts +++ b/src/orchestrator/generator-orchestrator.ts @@ -13,6 +13,7 @@ import { UnifiedRelationGenerator, UnifiedEnumGenerator, UnifiedScalarGenerator, + UnifiedHelperGenerator, } from '@generators/unified' import { UnifiedGeneratorContext } from '@generators/strategies' import { SchemaComposer } from 'graphql-compose' @@ -33,6 +34,7 @@ interface TypeScriptGenerators { relationGenerator: UnifiedRelationGenerator inputGenerator: UnifiedInputGenerator queryArgsGenerator?: UnifiedQueryArgsGenerator + helperGenerator?: UnifiedHelperGenerator } interface GraphQLGenerators { @@ -45,6 +47,7 @@ interface GraphQLGenerators { relationGenerator?: UnifiedRelationGenerator inputGenerator?: UnifiedInputGenerator queryArgsGenerator?: UnifiedQueryArgsGenerator + helperGenerator?: UnifiedHelperGenerator } export class GeneratorOrchestrator { @@ -67,8 +70,12 @@ export class GeneratorOrchestrator { const generators = this.createTypeScriptGeneratorsWithContext(unifiedContext) const results = await this.executeGenerators(generators) + const helperResult = results.find((r) => r.type === GenerationType.HELPER) + const helperCode = helperResult && helperResult.items.length > 0 ? helperResult.items[0] : undefined + return { code: unifiedContext.outputStrategy.getGeneratedCode?.() || '', + helperCode, results, stats: StatsCollector.collect(results, startTime), outputFormat: this.outputFormat, @@ -86,13 +93,14 @@ export class GeneratorOrchestrator { relationGenerator: new UnifiedRelationGenerator(unifiedContext), inputGenerator: new UnifiedInputGenerator(unifiedContext), queryArgsGenerator: new UnifiedQueryArgsGenerator(unifiedContext), + helperGenerator: new UnifiedHelperGenerator(unifiedContext), } } private async generateGraphQL(startTime: number): Promise { const graphqlContext = this.createGraphQLContext() - graphqlContext.registry.addRelayRequirements() + graphqlContext.registry.addRelayInterfaces() this.ensureEssentialTypes(graphqlContext) @@ -104,8 +112,12 @@ export class GeneratorOrchestrator { console.warn('Schema validation warnings:', warnings) } + const helperResult = results.find((r) => r.type === GenerationType.HELPER) + const helperCode = helperResult && helperResult.items.length > 0 ? helperResult.items[0] : undefined + return { sdl: graphqlContext.registry.generateSDL(), + helperCode, results, stats: StatsCollector.collect(results, startTime), outputFormat: this.outputFormat, @@ -247,6 +259,15 @@ export class GeneratorOrchestrator { }) } + if (generators.helperGenerator && this.context.options.generateHelpers) { + const helperResult = generators.helperGenerator.generate() + results.push({ + items: helperResult, + count: helperResult.length, + type: GenerationType.HELPER, + }) + } + return results } diff --git a/src/orchestrator/helper-file-writer.ts b/src/orchestrator/helper-file-writer.ts new file mode 100644 index 0000000..7311440 --- /dev/null +++ b/src/orchestrator/helper-file-writer.ts @@ -0,0 +1,31 @@ +import { FileWriter } from '@utils/file-writer' +import { OutputFormat } from '@utils/constants' +import path from 'path' + +export class HelperFileWriter { + private fileWriter = new FileWriter() + + async writeHelperFiles(_outputFormat: OutputFormat, outputPath: string, helperCode?: string): Promise { + const files: string[] = [] + + if (helperCode) { + const helperPath = this.resolveHelperPath(outputPath) + const completeHelperCode = this.combineHelperCode(helperCode) + await this.fileWriter.write(completeHelperCode, helperPath, 'Helper utilities') + files.push(helperPath) + } + + return files + } + + private resolveHelperPath(basePath: string): string { + const baseDir = path.dirname(basePath) + const baseName = path.basename(basePath, path.extname(basePath)) + + return path.join(baseDir, `${baseName}-helpers.ts`) + } + + private combineHelperCode(helperCode: string): string { + return helperCode + } +} diff --git a/src/orchestrator/output-writer.ts b/src/orchestrator/output-writer.ts index ae3d62f..444d447 100644 --- a/src/orchestrator/output-writer.ts +++ b/src/orchestrator/output-writer.ts @@ -1,14 +1,16 @@ import { UnifiedGenerationResult } from '@core/types' import { FileWriter } from '@utils/file-writer' import { OutputFormat } from '@utils/constants' +import { HelperFileWriter } from './helper-file-writer' import path from 'path' export class OutputWriter { private fileWriter = new FileWriter() + private helperFileWriter = new HelperFileWriter() async write(result: UnifiedGenerationResult, outputPath: string): Promise { const finalOutputPath = this.resolveOutputPath(result.outputFormat, outputPath) - + if (result.outputFormat === OutputFormat.TYPE_GRAPHQL) { if (!result.code) { throw new Error('TypeGraphQL code is required but missing') @@ -21,16 +23,17 @@ export class OutputWriter { await this.fileWriter.write(result.sdl, finalOutputPath, 'GraphQL schema') } + if (result.helperCode) { + await this.helperFileWriter.writeHelperFiles(result.outputFormat, outputPath, result.helperCode) + } + return finalOutputPath } private resolveOutputPath(outputFormat: OutputFormat, basePath: string): string { if (outputFormat === OutputFormat.TYPE_GRAPHQL) { - return path.join( - path.dirname(basePath), - path.basename(basePath, path.extname(basePath)) + '.ts' - ) + return path.join(path.dirname(basePath), path.basename(basePath, path.extname(basePath)) + '.ts') } return basePath } -} \ No newline at end of file +} diff --git a/src/orchestrator/stats-collector.ts b/src/orchestrator/stats-collector.ts index 817c08c..2ef62f9 100644 --- a/src/orchestrator/stats-collector.ts +++ b/src/orchestrator/stats-collector.ts @@ -11,6 +11,7 @@ export class StatsCollector { connectionTypes: 0, sortInputTypes: 0, filterInputTypes: 0, + helperFiles: 0, totalTypes: 0, generationTimeMs: 0, } @@ -43,6 +44,9 @@ export class StatsCollector { stats.filterInputTypes += result.count stats.inputTypes += result.count break + case GenerationType.HELPER: + stats.helperFiles += result.count + break } } diff --git a/src/utils/config.ts b/src/utils/config.ts index dcef328..10decbc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -73,6 +73,10 @@ const optionDefinitions = { schema: z.boolean(), default: true, }, + generateHelpers: { + schema: z.boolean(), + default: true, + }, fieldNaming: { schema: z.enum(['camelCase', 'snake_case', 'preserve']), default: 'camelCase' as FieldNaming, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 4c1d6f6..9f3bb6c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -20,6 +20,8 @@ export const INPUT_TYPE_SUFFIXES = { export const COMMON_TYPES = { NODE: 'Node', + EDGE: 'Edge', + CONNECTION: 'Connection', PAGE_INFO: 'PageInfo', SORT_DIRECTION: 'SortDirection', } as const diff --git a/src/utils/graphql-field-selection.ts b/src/utils/graphql-field-selection.ts new file mode 100644 index 0000000..5d8a152 --- /dev/null +++ b/src/utils/graphql-field-selection.ts @@ -0,0 +1,270 @@ +import type { GraphQLResolveInfo, FieldNode, SelectionNode, GraphQLCompositeType, GraphQLField, GraphQLNamedType } from 'graphql' +import { getNamedType, isCompositeType } from 'graphql' +import { getArgumentValues } from 'graphql/execution/values' + +export interface ResolveTree { + name: string + alias: string + args: { + [str: string]: unknown + } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { + [str: string]: ResolveTree + } +} + +export interface ParsedField { + name: string + selections: string[] + relations: { [key: string]: ParsedField } +} + +function getArgVal(resolveInfo: GraphQLResolveInfo, argument: any) { + if (argument.kind === 'Variable') { + return resolveInfo.variableValues[argument.name.value] + } else if (argument.kind === 'BooleanValue') { + return argument.value + } +} + +function argNameIsIf(arg: any): boolean { + return arg && arg.name ? arg.name.value === 'if' : false +} + +function skipField(resolveInfo: GraphQLResolveInfo, { directives = [] }: SelectionNode) { + let skip = false + directives.forEach((directive) => { + const directiveName = directive.name.value + if (Array.isArray(directive.arguments)) { + const ifArgumentAst = directive.arguments.find(argNameIsIf) + if (ifArgumentAst) { + const argumentValueAst = ifArgumentAst.value + if (directiveName === 'skip') { + skip = skip || getArgVal(resolveInfo, argumentValueAst) + } else if (directiveName === 'include') { + skip = skip || !getArgVal(resolveInfo, argumentValueAst) + } + } + } + }) + return skip +} + +function getFieldFromAST(ast: any, parentType: GraphQLCompositeType): GraphQLField | undefined { + if (ast.kind === 'Field') { + const fieldNode: FieldNode = ast + const fieldName = fieldNode.name.value + if (parentType && 'getFields' in parentType) { + return (parentType as any).getFields()[fieldName] + } + } + return undefined +} + +function fieldTreeFromAST( + inASTs: ReadonlyArray | SelectionNode, + resolveInfo: GraphQLResolveInfo, + initTree: FieldsByTypeName = {}, + parentType: GraphQLCompositeType +): FieldsByTypeName { + const { variableValues } = resolveInfo + const fragments = resolveInfo.fragments || {} + const asts: ReadonlyArray = Array.isArray(inASTs) ? inASTs : [inASTs] + + if (!initTree[parentType.name]) { + initTree[parentType.name] = {} + } + + return asts.reduce((tree, selectionVal: SelectionNode) => { + if (skipField(resolveInfo, selectionVal)) { + return tree + } + + if (selectionVal.kind === 'Field') { + const val: FieldNode = selectionVal + const name = val.name.value + const isReserved = name[0] === '_' && name[1] === '_' && name !== '__id' + + if (!isReserved) { + const alias: string = val.alias && val.alias.value ? val.alias.value : name + const field = getFieldFromAST(val, parentType) + + if (field != null) { + const fieldGqlTypeOrUndefined = getNamedType(field.type) + if (fieldGqlTypeOrUndefined) { + const fieldGqlType: GraphQLNamedType = fieldGqlTypeOrUndefined + const args = getArgumentValues(field as any, val, variableValues) || {} + + if (parentType.name && tree[parentType.name] && !tree[parentType.name]?.[alias]) { + const newTreeRoot: ResolveTree = { + name, + alias, + args, + fieldsByTypeName: isCompositeType(fieldGqlType) + ? { + [fieldGqlType.name]: {}, + } + : {}, + } + tree[parentType.name]![alias] = newTreeRoot + } + + const selectionSet = val.selectionSet + if (selectionSet != null && isCompositeType(fieldGqlType)) { + const newParentType: GraphQLCompositeType = fieldGqlType + if (tree[parentType.name] && tree[parentType.name]?.[alias]) { + fieldTreeFromAST(selectionSet.selections, resolveInfo, tree[parentType.name]![alias]!.fieldsByTypeName, newParentType) + } + } + } + } + } + } else if (selectionVal.kind === 'FragmentSpread') { + const val = selectionVal + const name = val.name && val.name.value + const fragment = fragments[name] + + if (fragment) { + let fragmentType: GraphQLNamedType | null | undefined = parentType + if (fragment.typeCondition) { + const { schema } = resolveInfo + const typeName = fragment.typeCondition.name.value + fragmentType = schema.getType(typeName) + } + + if (fragmentType && isCompositeType(fragmentType)) { + const newParentType: GraphQLCompositeType = fragmentType + fieldTreeFromAST(fragment.selectionSet.selections, resolveInfo, tree, newParentType) + } + } + } else if (selectionVal.kind === 'InlineFragment') { + const val = selectionVal + const fragment = val + let fragmentType: GraphQLNamedType | null | undefined = parentType + + if (fragment.typeCondition) { + const { schema } = resolveInfo + const typeName = fragment.typeCondition.name.value + fragmentType = schema.getType(typeName) + } + + if (fragmentType && isCompositeType(fragmentType)) { + const newParentType: GraphQLCompositeType = fragmentType + fieldTreeFromAST(fragment.selectionSet.selections, resolveInfo, tree, newParentType) + } + } + + return tree + }, initTree) +} + +function firstKey(obj: object) { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return key + } + } +} + +export function parseResolveInfo(resolveInfo: GraphQLResolveInfo): ResolveTree | null { + const fieldNodes: ReadonlyArray = (resolveInfo as any).fieldNodes || (resolveInfo as any).fieldASTs + const { parentType } = resolveInfo + + if (!fieldNodes) { + throw new Error('No fieldNodes provided!') + } + + const tree = fieldTreeFromAST(fieldNodes, resolveInfo, {}, parentType) + const typeKey = firstKey(tree) + + if (!typeKey) { + return null + } + + const fields = tree[typeKey] + if (!fields) { + return null + } + + const fieldKey = firstKey(fields) + + if (!fieldKey) { + return null + } + + return fields[fieldKey] || null +} + +export function getFieldSelections(resolveInfo: GraphQLResolveInfo): string[] { + const parsed = parseResolveInfo(resolveInfo) + if (!parsed) return [] + + const selections: string[] = [] + + function extractSelections(tree: ResolveTree) { + selections.push(tree.name) + + Object.values(tree.fieldsByTypeName).forEach((fields) => { + Object.values(fields).forEach((field) => { + extractSelections(field) + }) + }) + } + + extractSelections(parsed) + return [...new Set(selections)] +} + +export function buildPrismaInclude(resolveInfo: GraphQLResolveInfo, relations: string[] = []): any { + const parsed = parseResolveInfo(resolveInfo) + if (!parsed) return {} + + const include: any = {} + + function processField(tree: ResolveTree) { + Object.values(tree.fieldsByTypeName).forEach((fields) => { + Object.values(fields).forEach((field) => { + if (relations.includes(field.name)) { + include[field.name] = true + + if (Object.keys(field.fieldsByTypeName).length > 0) { + const nestedInclude = {} + processNestedField(field, nestedInclude) + if (Object.keys(nestedInclude).length > 0) { + include[field.name] = { include: nestedInclude } + } + } + } else { + processField(field) + } + }) + }) + } + + function processNestedField(tree: ResolveTree, target: any) { + Object.values(tree.fieldsByTypeName).forEach((fields) => { + Object.values(fields).forEach((field) => { + if (relations.includes(field.name)) { + target[field.name] = true + + if (Object.keys(field.fieldsByTypeName).length > 0) { + const nestedInclude = {} + processNestedField(field, nestedInclude) + if (Object.keys(nestedInclude).length > 0) { + target[field.name] = { include: nestedInclude } + } + } + } else { + processNestedField(field, target) + } + }) + }) + } + + processField(parsed) + return include +} \ No newline at end of file diff --git a/src/utils/helper-templates/connection-builder.template.ts b/src/utils/helper-templates/connection-builder.template.ts new file mode 100644 index 0000000..fbfbd8d --- /dev/null +++ b/src/utils/helper-templates/connection-builder.template.ts @@ -0,0 +1,128 @@ +export const CONNECTION_BUILDER_TEMPLATE = `export class ConnectionBuilder { + static buildConfig(args: { + pagination: PaginationArgs + where?: any + orderBy?: any + include?: any + info?: any + relationFields?: string[] + cursorField?: string + hasIdField?: boolean + }): ConnectionConfig { + const { + pagination, + where, + orderBy, + include, + info, + relationFields = [], + cursorField = 'id', + hasIdField = true + } = args + const { first, after, last, before } = pagination + + let take = first || last || 10 + if (last) take = -take + + const cursor = (hasIdField && (after || before)) + ? { [cursorField]: (after || before)! } + : undefined + const skip = cursor ? 1 : 0 + + const finalInclude = info ? buildPrismaInclude(info, relationFields) : include + + const findManyOptions: any = { + take: Math.abs(take) + 1, // Get one extra to check for next page + where, + orderBy, + include: finalInclude, + } + + if (hasIdField && cursor) { + findManyOptions.cursor = cursor + findManyOptions.skip = skip + } + + return { + findManyOptions, + countOptions: { where }, + paginationInfo: { + first, + last, + after, + before, + cursorField, + hasIdField, + relationFields + } + } + } + + static processResults( + items: T[], + totalCount: number, + paginationInfo: ConnectionConfig['paginationInfo'] + ): ConnectionResult { + const { first, last, cursorField, hasIdField } = paginationInfo + + const hasNextPage = first ? items.length > first : false + const hasPreviousPage = last ? items.length > Math.abs(last) : false + + const resultItems = hasNextPage || hasPreviousPage ? items.slice(0, -1) : items + + const edges = resultItems.map((item: any, index: number) => { + let cursor: string + if (hasIdField && item[cursorField]) { + cursor = item[cursorField] + } else { + cursor = item.postId && item.categoryId + ? \`\${item.postId}:\${item.categoryId}\` + : String(index) + } + + return { + node: item, + cursor, + } + }) + + return { + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + }, + edges, + totalCount, + } + } + + + {{MODEL_SPECIFIC_METHODS}} +}` + +export const MODEL_CONNECTION_METHOD_TEMPLATE = ` + static build{{MODEL_NAME}}ConnectionConfig( + args: {{MODEL_NAME}}QueryArgs, + info?: any + ): ConnectionConfig { + {{FILTER_SORT_LOGIC}} + const include = info ? FieldSelection.build{{MODEL_NAME}}Include(info) : {{MODEL_NAME_UPPER}}_INCLUDES + + return this.buildConfig({ + pagination: { + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + where, + orderBy, + include, + info, + relationFields: [{{RELATION_FIELDS}}], + hasIdField: {{HAS_ID_FIELD}}, + cursorField: '{{CURSOR_FIELD}}', + }) + }` \ No newline at end of file diff --git a/src/utils/helper-templates/field-selection.template.ts b/src/utils/helper-templates/field-selection.template.ts new file mode 100644 index 0000000..2c85b79 --- /dev/null +++ b/src/utils/helper-templates/field-selection.template.ts @@ -0,0 +1,41 @@ +export const FIELD_SELECTION_TEMPLATE = ` + +export interface ResolveTree { + name: string + alias: string + args: { [str: string]: unknown } + fieldsByTypeName: FieldsByTypeName +} + +export interface FieldsByTypeName { + [str: string]: { [str: string]: ResolveTree } +} + +export function buildPrismaInclude(_resolveInfo: any, relations: string[] = []): any { + const include: any = {} + + relations.forEach(relation => { + include[relation] = true + }) + + return include +} + +export class FieldSelection { + {{MODEL_SPECIFIC_METHODS}} +}` + +export const MODEL_FIELD_SELECTION_METHOD_TEMPLATE = ` + static build{{MODEL_NAME}}Include(info?: any): any { + const relationFields = [{{RELATION_FIELDS}}] + return buildPrismaInclude(info, relationFields) + }` + +export const INCLUDES_TEMPLATE = `{{MODEL_INCLUDES}}` + +export const MODEL_INCLUDE_TEMPLATE = ` +export const {{MODEL_NAME_UPPER}}_INCLUDES = { + {{RELATION_INCLUDES}} +}` + +export const RELATION_INCLUDE_TEMPLATE = `{{FIELD_NAME}}: true` \ No newline at end of file diff --git a/src/utils/helper-templates/filter-builder.template.ts b/src/utils/helper-templates/filter-builder.template.ts new file mode 100644 index 0000000..6be5bbc --- /dev/null +++ b/src/utils/helper-templates/filter-builder.template.ts @@ -0,0 +1,42 @@ +export const FILTER_BUILDER_TEMPLATE = `export class FilterBuilder { + static buildFilter(filter: any): any { + if (!filter || typeof filter !== 'object') return {} + + const where: any = {} + + for (const [field, value] of Object.entries(filter)) { + if (field === 'AND' && Array.isArray(value)) { + where.AND = value.map((f: any) => this.buildFilter(f)) + } else if (field === 'OR' && Array.isArray(value)) { + where.OR = value.map((f: any) => this.buildFilter(f)) + } else if (value && typeof value === 'object') { + const fieldWhere: any = {} + + for (const [operation, operationValue] of Object.entries(value)) { + if (operationValue !== undefined && operationValue !== null) { + fieldWhere[operation] = operationValue + } + } + + if (Object.keys(fieldWhere).length > 0) { + where[field] = fieldWhere + } + } + } + + return where + } + + {{MODEL_SPECIFIC_METHODS}} +}` + +export const MODEL_FILTER_METHOD_TEMPLATE = ` + static build{{MODEL_NAME}}Filter(filter?: {{MODEL_NAME}}FilterInput): any { + return this.buildFilter(filter) + }` + +export const FIELD_FILTER_TEMPLATE = '' +export const STRING_FILTER_OPERATIONS = '' +export const NUMERIC_FILTER_OPERATIONS = '' +export const BOOLEAN_FILTER_OPERATIONS = '' +export const DATETIME_FILTER_OPERATIONS = '' \ No newline at end of file diff --git a/src/utils/helper-templates/sort-builder.template.ts b/src/utils/helper-templates/sort-builder.template.ts new file mode 100644 index 0000000..7c930c6 --- /dev/null +++ b/src/utils/helper-templates/sort-builder.template.ts @@ -0,0 +1,25 @@ +export const SORT_BUILDER_TEMPLATE = `export class SortBuilder { + static buildSort(sort: any, fallbackSort: any = { id: 'asc' }): any { + if (!sort || typeof sort !== 'object') return fallbackSort + + const orderBy: any = {} + + for (const [field, direction] of Object.entries(sort)) { + if (direction && typeof direction === 'string') { + orderBy[field] = direction.toLowerCase() + } + } + + return Object.keys(orderBy).length > 0 ? orderBy : fallbackSort + } + + {{MODEL_SPECIFIC_METHODS}} +}` + +export const MODEL_SORT_METHOD_TEMPLATE = ` + static build{{MODEL_NAME}}Sort(sort?: {{MODEL_NAME}}SortInput): any { + const fallbackSort = {{HAS_ID_FIELD}} ? { id: 'asc' } : {} + return this.buildSort(sort, fallbackSort) + }` + +export const FIELD_SORT_TEMPLATE = '' \ No newline at end of file diff --git a/src/utils/registry/graphql-registry.ts b/src/utils/registry/graphql-registry.ts index da8d706..f61bf4d 100644 --- a/src/utils/registry/graphql-registry.ts +++ b/src/utils/registry/graphql-registry.ts @@ -37,7 +37,7 @@ export class GraphQLRegistry extends BaseRegistry { super() this._schemaComposer = schemaComposer this.syncFromSchemaComposer() - this.addRelayRequirements() + this.addRelayInterfaces() } protected createTypeInfo(name: string, kind: TypeKind, composer: any, isGenerated: boolean): GraphQLTypeInfo { @@ -154,21 +154,52 @@ export class GraphQLRegistry extends BaseRegistry { return printSchema(schema) } - addRelayRequirements(): void { - if (this._schemaComposer.has(COMMON_TYPES.NODE)) { - return + addRelayInterfaces(): void { + if (!this._schemaComposer.has(COMMON_TYPES.NODE)) { + const nodeInterface = this._schemaComposer.createInterfaceTC({ + name: COMMON_TYPES.NODE, + description: 'An object with a unique identifier', + fields: { + id: { + type: 'ID!', + description: 'The unique identifier for this object', + }, + }, + }) + this.registerType(COMMON_TYPES.NODE, TypeKind.INTERFACE, nodeInterface, true) } - const nodeInterface = this._schemaComposer.createInterfaceTC({ - name: COMMON_TYPES.NODE, - description: 'An object with a unique identifier', - fields: { - id: { - type: 'ID!', - description: 'The unique identifier for this object', + + if (!this._schemaComposer.has(COMMON_TYPES.EDGE)) { + const edgeInterface = this._schemaComposer.createInterfaceTC({ + name: COMMON_TYPES.EDGE, + description: 'Base interface for all edge types in connections', + fields: { + cursor: { + type: 'String!', + description: 'A cursor for use in pagination', + }, }, - }, - }) - this.registerType(COMMON_TYPES.NODE, TypeKind.INTERFACE, nodeInterface, true) + }) + this.registerType(COMMON_TYPES.EDGE, TypeKind.INTERFACE, edgeInterface, true) + } + + if (!this._schemaComposer.has(COMMON_TYPES.CONNECTION)) { + const connectionInterface = this._schemaComposer.createInterfaceTC({ + name: COMMON_TYPES.CONNECTION, + description: 'Base interface for all connection types', + fields: { + pageInfo: { + type: 'PageInfo!', + description: 'Information to aid in pagination', + }, + totalCount: { + type: 'Int!', + description: 'The total count of items in the connection', + }, + }, + }) + this.registerType(COMMON_TYPES.CONNECTION, TypeKind.INTERFACE, connectionInterface, true) + } } private syncFromSchemaComposer(): void { diff --git a/src/utils/schema/graphql-type-factories.ts b/src/utils/schema/graphql-type-factories.ts index f3e0def..a3fce7b 100644 --- a/src/utils/schema/graphql-type-factories.ts +++ b/src/utils/schema/graphql-type-factories.ts @@ -199,6 +199,7 @@ export class GraphQLTypeFactories { const connectionTC = this.schemaComposer.createObjectTC({ name: connectionName, description: description || `A connection to a list of ${modelType} items.`, + interfaces: ['Connection'], fields: { pageInfo: { type: 'PageInfo!', @@ -243,6 +244,7 @@ export class GraphQLTypeFactories { const edgeTC = this.schemaComposer.createObjectTC({ name: edgeName, description: `An edge in a ${modelType} connection.`, + interfaces: ['Edge'], fields: { node: { type: `${modelType}!`, diff --git a/src/utils/typescript/ast-factory.ts b/src/utils/typescript/ast-factory.ts index ea62104..6500f60 100644 --- a/src/utils/typescript/ast-factory.ts +++ b/src/utils/typescript/ast-factory.ts @@ -26,7 +26,7 @@ export class TypeScriptASTFactory { this.sourceFile.addImportDeclarations([ { moduleSpecifier: 'type-graphql', - namedImports: ['ObjectType', 'Field', 'ID', 'Int', 'Float', 'registerEnumType', 'InputType', 'ArgsType'], + namedImports: ['ObjectType', 'Field', 'ID', 'Int', 'Float', 'registerEnumType', 'InputType', 'ArgsType', 'InterfaceType'], }, { moduleSpecifier: 'graphql-scalars', @@ -475,6 +475,8 @@ registerEnumType(SortDirection, { createConnectionType(modelName: string): { edge: ClassDeclaration; connection: ClassDeclaration } { this.createPageInfo() + this.createEdgeInterface() + this.createConnectionInterface() const typeName = this.typeFormatter.formatTypeName(modelName) const edgeName = `${typeName}Edge` @@ -483,10 +485,11 @@ registerEnumType(SortDirection, { const edgeDeclaration = this.sourceFile.addClass({ name: edgeName, isExported: true, + implements: ['Edge'], decorators: [ { name: 'ObjectType', - arguments: [], + arguments: [`{ implements: Edge }`], }, ], }) @@ -518,10 +521,11 @@ registerEnumType(SortDirection, { const connectionDeclaration = this.sourceFile.addClass({ name: connectionName, isExported: true, + implements: ['Connection'], decorators: [ { name: 'ObjectType', - arguments: [], + arguments: [`{ implements: Connection }`], }, ], }) @@ -568,6 +572,78 @@ registerEnumType(SortDirection, { } } + createEdgeInterface(): void { + if (this.sourceFile.getClass('Edge')) { + return + } + + const edgeInterface = this.sourceFile.addClass({ + name: 'Edge', + isExported: true, + isAbstract: true, + decorators: [ + { + name: 'InterfaceType', + arguments: [`{ description: 'Base interface for all edge types in connections', autoRegisterImplementations: false }`], + }, + ], + }) + + edgeInterface.addProperty({ + name: 'cursor', + type: 'string', + hasExclamationToken: true, + decorators: [ + { + name: 'Field', + arguments: [`() => String, { description: 'A cursor for use in pagination' }`], + }, + ], + }) + } + + createConnectionInterface(): void { + if (this.sourceFile.getClass('Connection')) { + return + } + + const connectionInterface = this.sourceFile.addClass({ + name: 'Connection', + isExported: true, + isAbstract: true, + decorators: [ + { + name: 'InterfaceType', + arguments: [`{ description: 'Base interface for all connection types', autoRegisterImplementations: false }`], + }, + ], + }) + + connectionInterface.addProperty({ + name: 'pageInfo', + type: 'PageInfo', + hasExclamationToken: true, + decorators: [ + { + name: 'Field', + arguments: [`() => PageInfo, { description: 'Information to aid in pagination' }`], + }, + ], + }) + + connectionInterface.addProperty({ + name: 'totalCount', + type: 'number', + hasExclamationToken: true, + decorators: [ + { + name: 'Field', + arguments: [`() => Int, { description: 'The total count of items in the connection' }`], + }, + ], + }) + } + createPaginationInputTypes(): ClassDeclaration[] { const forwardPagination = this.sourceFile.addClass({ name: 'ForwardPaginationInput', diff --git a/tests/helpers/test-mock-factory.ts b/tests/helpers/test-mock-factory.ts index f0b17d2..0dd88a9 100644 --- a/tests/helpers/test-mock-factory.ts +++ b/tests/helpers/test-mock-factory.ts @@ -44,7 +44,7 @@ export class TestMockFactory { } private static addEssentialTypes(registry: GraphQLRegistry, schemaComposer: SchemaComposer): void { - registry.addRelayRequirements() + registry.addRelayInterfaces() const scalars = [ { name: 'DateTime', description: 'A date-time string at UTC' }, diff --git a/tests/integration/helper-generation.test.ts b/tests/integration/helper-generation.test.ts new file mode 100644 index 0000000..18db2de --- /dev/null +++ b/tests/integration/helper-generation.test.ts @@ -0,0 +1,465 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { GeneratorOrchestrator } from '@orchestrator/generator-orchestrator' +import { OutputFormat } from '@utils/constants' +import { TestFixtures } from '../helpers' +import { BaseGeneratorContext, GenerationType } from '@core/types' + +describe('Helper Generation Integration Tests', () => { + let baseContext: BaseGeneratorContext + + beforeEach(() => { + baseContext = TestFixtures.createContext({ + generateHelpers: true, + generateFilters: true, + generateSorts: true, + connectionTypes: true, + includeRelations: true, + generateScalars: true, + generateEnums: true, + models: [ + TestFixtures.createDataModel('User', [ + { + ...TestFixtures.createField('id', 'String'), + attributes: [TestFixtures.createAttribute('id'), TestFixtures.createAttribute('default', ['uuid()'])], + }, + { + ...TestFixtures.createField('name', 'String'), + attributes: [TestFixtures.createAttribute('graphql.filterable'), TestFixtures.createAttribute('graphql.sortable')], + }, + { + ...TestFixtures.createField('email', 'String'), + attributes: [TestFixtures.createAttribute('unique'), TestFixtures.createAttribute('graphql.filterable')], + }, + { + ...TestFixtures.createField('createdAt', 'DateTime'), + attributes: [TestFixtures.createAttribute('default', ['now()']), TestFixtures.createAttribute('graphql.sortable')], + }, + ]), + TestFixtures.createDataModel('Post', [ + { + ...TestFixtures.createField('id', 'String'), + attributes: [TestFixtures.createAttribute('id'), TestFixtures.createAttribute('default', ['uuid()'])], + }, + { + ...TestFixtures.createField('title', 'String'), + attributes: [TestFixtures.createAttribute('graphql.filterable'), TestFixtures.createAttribute('graphql.sortable')], + }, + { + ...TestFixtures.createField('content', 'String'), + }, + { + ...TestFixtures.createField('published', 'Boolean'), + attributes: [TestFixtures.createAttribute('default', ['false']), TestFixtures.createAttribute('graphql.filterable')], + }, + { + ...TestFixtures.createField('viewCount', 'Int'), + attributes: [TestFixtures.createAttribute('default', ['0']), TestFixtures.createAttribute('graphql.sortable')], + }, + { + ...TestFixtures.createRelationField('author', 'User'), + attributes: [TestFixtures.createAttribute('relation', ['fields: [authorId], references: [id]'])], + }, + { + ...TestFixtures.createField('authorId', 'String'), + }, + ]), + TestFixtures.createDataModel('Comment', [ + { + ...TestFixtures.createField('id', 'String'), + attributes: [TestFixtures.createAttribute('id'), TestFixtures.createAttribute('default', ['uuid()'])], + }, + { + ...TestFixtures.createField('content', 'String'), + attributes: [TestFixtures.createAttribute('graphql.filterable')], + }, + { + ...TestFixtures.createField('createdAt', 'DateTime'), + attributes: [TestFixtures.createAttribute('default', ['now()']), TestFixtures.createAttribute('graphql.sortable')], + }, + { + ...TestFixtures.createRelationField('post', 'Post'), + attributes: [TestFixtures.createAttribute('relation', ['fields: [postId], references: [id]'])], + }, + { + ...TestFixtures.createField('postId', 'String'), + }, + { + ...TestFixtures.createRelationField('author', 'User'), + attributes: [TestFixtures.createAttribute('relation', ['fields: [authorId], references: [id]'])], + }, + { + ...TestFixtures.createField('authorId', 'String'), + }, + ]), + ], + }) + }) + + describe('End-to-End Helper Generation', () => { + it('should generate complete helper file for TypeGraphQL format', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result).toBeDefined() + expect(result.outputFormat).toBe(OutputFormat.TYPE_GRAPHQL) + expect(result.helperCode).toBeDefined() + + const helperCode = result.helperCode! + + // Verify file structure + expect(helperCode).toContain('import type { GraphQLResolveInfo } from \'graphql\'') + expect(helperCode).toContain('export interface PaginationArgs') + expect(helperCode).toContain('export interface ConnectionResult') + expect(helperCode).toContain('export interface ConnectionConfig') + expect(helperCode).toContain('export class ConnectionBuilder') + expect(helperCode).toContain('export class FilterBuilder') + expect(helperCode).toContain('export class SortBuilder') + expect(helperCode).toContain('export class FieldSelection') + }) + + it('should generate model-specific connection builders', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for all model connection builders + expect(helperCode).toContain('static buildUserConnectionConfig') + expect(helperCode).toContain('static buildPostConnectionConfig') + expect(helperCode).toContain('static buildCommentConnectionConfig') + + // Verify connection builder parameters + expect(helperCode).toContain('UserQueryArgs') + expect(helperCode).toContain('PostQueryArgs') + expect(helperCode).toContain('CommentQueryArgs') + }) + + it('should generate model-specific filter builders', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for filter builders that are actually generated + // Note: Filter builders are only generated for models with filterable fields that the processor recognizes + expect(helperCode).toContain('FilterBuilder') + if (helperCode.includes('static buildPostFilter')) { + expect(helperCode).toContain('static buildPostFilter') + } + if (helperCode.includes('static buildCommentFilter')) { + expect(helperCode).toContain('static buildCommentFilter') + } + + // Verify filter input types are imported + expect(helperCode).toContain('UserFilterInput') + expect(helperCode).toContain('PostFilterInput') + expect(helperCode).toContain('CommentFilterInput') + }) + + it('should generate model-specific sort builders', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for sort builders that are actually generated + expect(helperCode).toContain('SortBuilder') + if (helperCode.includes('static buildPostSort')) { + expect(helperCode).toContain('static buildPostSort') + } + if (helperCode.includes('static buildCommentSort')) { + expect(helperCode).toContain('static buildCommentSort') + } + + // Verify sort input types are imported + expect(helperCode).toContain('PostSortInput') + expect(helperCode).toContain('CommentSortInput') + }) + + it('should generate field selection helpers', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for field selection methods + expect(helperCode).toContain('static buildUserInclude') + expect(helperCode).toContain('static buildPostInclude') + expect(helperCode).toContain('static buildCommentInclude') + + // Verify GraphQLResolveInfo usage + expect(helperCode).toContain('GraphQLResolveInfo') + expect(helperCode).toContain('buildPrismaInclude') + }) + + it('should generate include constants', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for include constants + expect(helperCode).toContain('export const USER_INCLUDES') + expect(helperCode).toContain('export const POST_INCLUDES') + expect(helperCode).toContain('export const COMMENT_INCLUDES') + + // Verify relation includes + expect(helperCode).toContain('author: true') + expect(helperCode).toContain('post: true') + }) + + it('should handle models with no filterable/sortable fields', async () => { + const contextWithPlainModel = TestFixtures.createContext({ + generateHelpers: true, + models: [ + TestFixtures.createDataModel('PlainModel', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createField('name', 'String'), // No filterable/sortable attributes + ]), + ], + }) + + const orchestrator = new GeneratorOrchestrator(contextWithPlainModel, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Should still have connection builder + expect(helperCode).toContain('static buildPlainModelConnectionConfig') + + // Should have basic filter/sort builders even if fields aren't specifically marked + expect(helperCode).toContain('FilterBuilder') + expect(helperCode).toContain('SortBuilder') + }) + + it('should handle complex relations in helpers', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Verify complex relation handling in connection configs + expect(helperCode).toMatch(/relationFields:\s*\[.*'author'.*\]/) + expect(helperCode).toMatch(/relationFields:\s*\[.*'post'.*\]/) + + // Check relation includes + expect(helperCode).toContain('author: true') + expect(helperCode).toContain('post: true') + }) + + it('should generate valid TypeScript code', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Basic TypeScript syntax checks + expect(helperCode).not.toContain('undefined as any') + expect(helperCode).not.toContain('null as any') + + // Check for proper interface definitions + expect(helperCode).toMatch(/export interface \w+/) + expect(helperCode).toMatch(/export class \w+/) + + // Check for proper method signatures + expect(helperCode).toMatch(/static \w+\([^)]*\): \w+/) + + // Verify no obvious syntax errors + const braceCount = (helperCode.match(/\{/g) || []).length + const closeBraceCount = (helperCode.match(/\}/g) || []).length + expect(braceCount).toBe(closeBraceCount) + }) + }) + + describe('Helper Content Validation', () => { + it('should generate connection builders with correct pagination logic', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for pagination parameter handling + expect(helperCode).toContain('first?: number') + expect(helperCode).toContain('last?: number') + expect(helperCode).toContain('after?: string') + expect(helperCode).toContain('before?: string') + + // Check for cursor-based pagination logic + expect(helperCode).toContain('buildConfig') + expect(helperCode).toContain('findManyOptions') + expect(helperCode).toContain('countOptions') + expect(helperCode).toContain('paginationInfo') + }) + + it('should generate filter builders with proper type handling', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for filter condition handling + expect(helperCode).toContain('FilterBuilder') + expect(helperCode).toContain('buildFilter') + + // Verify filter input parameter usage (check for actual patterns in generated code) + if (helperCode.includes('FilterBuilder.build')) { + expect(helperCode).toMatch(/FilterBuilder\.build\w+Filter/) + } + }) + + it('should generate sort builders with direction support', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for sort handling + expect(helperCode).toContain('SortBuilder') + expect(helperCode).toContain('buildSort') + + // Verify sort input parameter usage (check for actual patterns in generated code) + if (helperCode.includes('SortBuilder.build')) { + expect(helperCode).toMatch(/SortBuilder\.build\w+Sort/) + } + expect(helperCode).toContain('orderBy') + }) + + it('should generate field selection with GraphQL info parsing', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + const helperCode = result.helperCode! + + // Check for GraphQL info usage + expect(helperCode).toContain('GraphQLResolveInfo') + expect(helperCode).toContain('buildPrismaInclude') + expect(helperCode).toContain('info') + + // Verify relation field handling + expect(helperCode).toContain('relationFields') + }) + }) + + describe('Error Handling and Edge Cases', () => { + it('should handle empty models list', async () => { + const emptyContext = TestFixtures.createContext({ + generateHelpers: true, + models: [], + }) + + const orchestrator = new GeneratorOrchestrator(emptyContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + // Should still have base structure + expect(helperCode).toContain('export interface PaginationArgs') + expect(helperCode).toContain('export class ConnectionBuilder') + }) + + it('should handle models with invalid field types gracefully', async () => { + const contextWithInvalidFields = TestFixtures.createContext({ + generateHelpers: true, + models: [ + { + ...TestFixtures.createDataModel('TestModel'), + fields: [ + { + name: 'invalidField', + type: { type: 'UnknownType' as any }, + isOptional: false, + attributes: [], + }, + ], + }, + ], + }) + + const orchestrator = new GeneratorOrchestrator(contextWithInvalidFields, OutputFormat.TYPE_GRAPHQL) + + expect(async () => { + const result = await orchestrator.generate() + expect(result.helperCode).toBeDefined() + }).not.toThrow() + }) + + it('should handle circular relations correctly', async () => { + const contextWithCircularRelations = TestFixtures.createContext({ + generateHelpers: true, + models: [ + TestFixtures.createDataModel('NodeA', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createRelationField('nodeB', 'NodeB'), + ]), + TestFixtures.createDataModel('NodeB', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createRelationField('nodeA', 'NodeA'), + ]), + ], + }) + + const orchestrator = new GeneratorOrchestrator(contextWithCircularRelations, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + // Should handle both models + expect(helperCode).toContain('buildNodeAConnectionConfig') + expect(helperCode).toContain('buildNodeBConnectionConfig') + + // Should include proper relation fields + expect(helperCode).toContain('nodeB') + expect(helperCode).toContain('nodeA') + }) + }) + + describe('Performance and Scalability', () => { + it('should handle large number of models efficiently', async () => { + const largeContext = TestFixtures.createContext({ + generateHelpers: true, + generateFilters: true, + generateSorts: true, + models: Array.from({ length: 20 }, (_, i) => + TestFixtures.createDataModel(`Model${i}`, [ + TestFixtures.createField('id', 'String'), + { + ...TestFixtures.createField('name', 'String'), + attributes: [TestFixtures.createAttribute('graphql.filterable')], + }, + { + ...TestFixtures.createField('createdAt', 'DateTime'), + attributes: [TestFixtures.createAttribute('graphql.sortable')], + }, + ]) + ), + }) + + const startTime = Date.now() + const orchestrator = new GeneratorOrchestrator(largeContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + const endTime = Date.now() + + expect(result.helperCode).toBeDefined() + expect(endTime - startTime).toBeLessThan(5000) // Should complete within 5 seconds + + // Verify all models are included + const helperCode = result.helperCode! + for (let i = 0; i < 20; i++) { + expect(helperCode).toContain(`buildModel${i}ConnectionConfig`) + } + }) + + it('should generate consistent helper code across multiple runs', async () => { + const orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + + const result1 = await orchestrator.generate() + const result2 = await orchestrator.generate() + + expect(result1.helperCode).toBeDefined() + expect(result2.helperCode).toBeDefined() + expect(result1.helperCode).toBe(result2.helperCode) + }) + }) +}) \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 1163abd..c4aef21 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -29,8 +29,12 @@ "@generators/*": ["./src/generators/*"], "@orchestrator": ["./src/orchestrator"], "@orchestrator/*": ["./src/orchestrator/*"], + "@registry": ["./src/registry"], + "@registry/*": ["./src/registry/*"], "@utils": ["./src/utils"], - "@utils/*": ["./src/utils/*"] + "@utils/*": ["./src/utils/*"], + "@tests": ["./tests"], + "@tests/*": ["./tests/*"] } }, "include": ["./**/*.ts"], diff --git a/tests/unit/generators/unified-helper-generator.test.ts b/tests/unit/generators/unified-helper-generator.test.ts new file mode 100644 index 0000000..5297ba6 --- /dev/null +++ b/tests/unit/generators/unified-helper-generator.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'bun:test' +import { UnifiedHelperGenerator, ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { OutputFormat } from '@utils/constants' + +describe('UnifiedHelperGenerator', () => { + it('should extract model helpers correctly', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [ + { + modelName: 'User', + fieldName: 'posts', + targetModelName: 'Post', + targetFieldName: 'author', + isList: true, + isRequired: false, + }, + ], + }, + ] + + expect(helpers).toBeDefined() + expect(helpers.length).toBe(1) + expect(helpers[0]?.modelName).toBe('User') + expect(helpers[0]?.relations.length).toBe(1) + }) + + it('should create helper generation context', () => { + const context: HelperGenerationContext = { + models: [], + relations: [], + outputFormat: OutputFormat.TYPE_GRAPHQL, + attributeProcessor: {} as any, + } + + expect(context).toBeDefined() + expect(context.outputFormat).toBe(OutputFormat.TYPE_GRAPHQL) + expect(Array.isArray(context.models)).toBe(true) + expect(Array.isArray(context.relations)).toBe(true) + }) + + it('should have correct model helper structure', () => { + const helper: ModelHelper = { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + } + + expect(helper.modelName).toBe('User') + expect(helper.connectionBuilderName).toBe('UserConnectionBuilder') + expect(helper.filterBuilderName).toBe('UserFilterBuilder') + expect(helper.sortBuilderName).toBe('UserSortBuilder') + expect(helper.fieldSelectionName).toBe('UserFieldSelection') + expect(helper.includesConstName).toBe('USER_INCLUDES') + expect(Array.isArray(helper.relations)).toBe(true) + }) +}) diff --git a/tests/unit/orchestrator/generator-orchestrator.test.ts b/tests/unit/orchestrator/generator-orchestrator.test.ts index cbcd0e4..2b04d4c 100644 --- a/tests/unit/orchestrator/generator-orchestrator.test.ts +++ b/tests/unit/orchestrator/generator-orchestrator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test' import { GeneratorOrchestrator } from '@orchestrator/generator-orchestrator' import { OutputFormat } from '@utils/constants' -import { TestFixtures, TestMockFactory } from '../../helpers' +import { TestFixtures } from '../../helpers' import { BaseGeneratorContext, GenerationType } from '@core/types' describe('GeneratorOrchestrator', () => { @@ -362,4 +362,178 @@ describe('GeneratorOrchestrator', () => { }).not.toThrow() }) }) + + describe('Helper Generation Tests', () => { + beforeEach(() => { + baseContext = TestFixtures.createContext({ + generateHelpers: true, + generateFilters: true, + generateSorts: true, + connectionTypes: true, + includeRelations: true, + generateScalars: true, + generateEnums: true, + models: [ + TestFixtures.createDataModel('User', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createField('name', 'String'), + TestFixtures.createField('email', 'String'), + ]), + TestFixtures.createDataModel('Post', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createField('title', 'String'), + TestFixtures.createRelationField('author', 'User'), + ]), + ], + }) + }) + + it('should NOT generate helpers for GraphQL output format', async () => { + orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.GRAPHQL) + const result = await orchestrator.generate() + + expect(result).toBeDefined() + expect(result.outputFormat).toBe(OutputFormat.GRAPHQL) + + const helperResult = result.results.find((r) => r.type === GenerationType.HELPER) + expect(helperResult).toBeUndefined() + + expect(result.helperCode).toBeUndefined() + + expect(result.sdl).toBeDefined() + expect(typeof result.sdl).toBe('string') + expect(result.code).toBeUndefined() + }) + + it('should generate helpers for TypeGraphQL output format', async () => { + orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result).toBeDefined() + expect(result.outputFormat).toBe(OutputFormat.TYPE_GRAPHQL) + + const helperResult = result.results.find((r) => r.type === GenerationType.HELPER) + expect(helperResult).toBeDefined() + expect(helperResult!.items).toBeDefined() + expect(helperResult!.items.length).toBeGreaterThan(0) + + expect(result.helperCode).toBeDefined() + expect(typeof result.helperCode).toBe('string') + expect(result.helperCode!.length).toBeGreaterThan(0) + + expect(result.code).toBeDefined() + expect(typeof result.code).toBe('string') + expect(result.sdl).toBeUndefined() + }) + + it('should respect generateHelpers flag when false', async () => { + const contextWithoutHelpers = TestFixtures.createContext({ + ...baseContext.options, + generateHelpers: false, + models: baseContext.models, + }) + orchestrator = new GeneratorOrchestrator(contextWithoutHelpers, OutputFormat.TYPE_GRAPHQL) + + const result = await orchestrator.generate() + + expect(result).toBeDefined() + + const helperResult = result.results.find((r) => r.type === GenerationType.HELPER) + expect(helperResult).toBeUndefined() + + expect(result.helperCode).toBeUndefined() + }) + + it('should generate helper content with connection builders', async () => { + orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + expect(helperCode).toContain('ConnectionBuilder') + expect(helperCode).toContain('buildUserConnectionConfig') + expect(helperCode).toContain('buildPostConnectionConfig') + + expect(helperCode).toContain('FilterBuilder') + + expect(helperCode).toContain('SortBuilder') + + expect(helperCode).toContain('FieldSelection') + + expect(helperCode).toContain('USER_INCLUDES') + expect(helperCode).toContain('POST_INCLUDES') + }) + + it('should generate helper content with correct imports', async () => { + orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + expect(helperCode).toContain("import type { GraphQLResolveInfo } from 'graphql'") + + expect(helperCode).toContain('import {') + expect(helperCode).toContain("} from './schema'") + + expect(helperCode).toContain('User') + expect(helperCode).toContain('UserQueryArgs') + expect(helperCode).toContain('UserConnection') + expect(helperCode).toContain('Post') + expect(helperCode).toContain('PostQueryArgs') + expect(helperCode).toContain('PostConnection') + }) + + it('should include helper interfaces in generated code', async () => { + orchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + expect(helperCode).toContain('export interface PaginationArgs') + expect(helperCode).toContain('export interface ConnectionResult') + expect(helperCode).toContain('export interface ConnectionConfig') + + expect(helperCode).toContain('first?: number') + expect(helperCode).toContain('after?: string') + expect(helperCode).toContain('last?: number') + expect(helperCode).toContain('before?: string') + }) + + it('should handle models without relations in helpers', async () => { + const contextWithoutRelations = TestFixtures.createContext({ + generateHelpers: true, + models: [TestFixtures.createDataModel('SimpleUser', [TestFixtures.createField('id', 'String'), TestFixtures.createField('name', 'String')])], + }) + orchestrator = new GeneratorOrchestrator(contextWithoutRelations, OutputFormat.TYPE_GRAPHQL) + + const result = await orchestrator.generate() + + expect(result.helperCode).toBeDefined() + const helperCode = result.helperCode! + + expect(helperCode).toContain('buildSimpleUserConnectionConfig') + expect(helperCode).toContain('SIMPLEUSER_INCLUDES') + }) + + it('should maintain consistency between GraphQL and TypeGraphQL generations', async () => { + const graphqlOrchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.GRAPHQL) + const typeGraphQLOrchestrator = new GeneratorOrchestrator(baseContext, OutputFormat.TYPE_GRAPHQL) + + const graphqlResult = await graphqlOrchestrator.generate() + const typeGraphQLResult = await typeGraphQLOrchestrator.generate() + + expect(graphqlResult).toBeDefined() + expect(typeGraphQLResult).toBeDefined() + + expect(graphqlResult.stats.objectTypes).toBe(typeGraphQLResult.stats.objectTypes) + expect(graphqlResult.stats.enumTypes).toBe(typeGraphQLResult.stats.enumTypes) + expect(graphqlResult.stats.scalarTypes).toBe(typeGraphQLResult.stats.scalarTypes) + + expect(graphqlResult.helperCode).toBeUndefined() + expect(typeGraphQLResult.helperCode).toBeDefined() + }) + }) }) diff --git a/tests/unit/registry/graphql-registry.test.ts b/tests/unit/registry/graphql-registry.test.ts index 34f26ec..e158207 100644 --- a/tests/unit/registry/graphql-registry.test.ts +++ b/tests/unit/registry/graphql-registry.test.ts @@ -23,6 +23,16 @@ describe('GraphQL Registry', () => { expect(registry.isTypeOfKind('Node', TypeKind.INTERFACE)).toBe(true) }) + it('should create Edge interface on initialization', () => { + expect(registry.hasType('Edge')).toBe(true) + expect(registry.isTypeOfKind('Edge', TypeKind.INTERFACE)).toBe(true) + }) + + it('should create Connection interface on initialization', () => { + expect(registry.hasType('Connection')).toBe(true) + expect(registry.isTypeOfKind('Connection', TypeKind.INTERFACE)).toBe(true) + }) + it('should sync existing types from schema composer', () => { const composerWithTypes = new SchemaComposer() composerWithTypes.createObjectTC('ExistingType') @@ -208,6 +218,17 @@ describe('GraphQL Registry', () => { describe('Schema Generation', () => { it('should generate GraphQL SDL schema', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + const objectTC = schemaComposer.createObjectTC({ name: 'User', fields: { name: 'String', age: 'Int' }, @@ -222,16 +243,76 @@ describe('GraphQL Registry', () => { }) it('should include Node interface in schema', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + const schema = registry.generateSDL() expect(schema).toContain('interface Node') expect(schema).toContain('id: ID!') }) + it('should include Edge interface in schema', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + + const schema = registry.generateSDL() + + expect(schema).toContain('interface Edge') + expect(schema).toContain('cursor: String!') + }) + + it('should include Connection interface in schema', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + + const schema = registry.generateSDL() + + expect(schema).toContain('interface Connection') + expect(schema).toContain('pageInfo: PageInfo!') + expect(schema).toContain('totalCount: Int!') + }) + it('should handle empty schema', () => { const emptyComposer = new SchemaComposer() + const emptyRegistry = new GraphQLRegistry(emptyComposer) + + const pageInfoTC = emptyComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + emptyRegistry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) - const schema = registry.generateSDL() + const schema = emptyRegistry.generateSDL() expect(schema).toBeDefined() expect(typeof schema).toBe('string') @@ -258,6 +339,109 @@ describe('GraphQL Registry', () => { }) }) + describe('Edge and Connection Type Implementation', () => { + it('should create edge types that implement Edge interface', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + + const userTC = schemaComposer.createObjectTC({ + name: 'User', + fields: { name: 'String' }, + }) + registry.registerType('User', TypeKind.OBJECT, userTC) + + const edgeTC = schemaComposer.createObjectTC({ + name: 'UserEdge', + interfaces: ['Edge'], + fields: { + node: 'User!', + cursor: 'String!', + }, + }) + + registry.registerEdgeType('UserEdge', edgeTC) + + expect(registry.hasEdgeType('UserEdge')).toBe(true) + expect(registry.isTypeOfKind('UserEdge', TypeKind.EDGE)).toBe(true) + + const schema = registry.generateSDL() + expect(schema).toContain('type UserEdge implements Edge') + expect(schema).toContain('node: User!') + expect(schema).toContain('cursor: String!') + }) + + it('should create connection types that implement Connection interface', () => { + const pageInfoTC = schemaComposer.createObjectTC({ + name: 'PageInfo', + fields: { + hasNextPage: 'Boolean!', + hasPreviousPage: 'Boolean!', + startCursor: 'String', + endCursor: 'String', + }, + }) + registry.registerType('PageInfo', TypeKind.OBJECT, pageInfoTC) + + const userTC = schemaComposer.createObjectTC({ + name: 'User', + fields: { name: 'String' }, + }) + registry.registerType('User', TypeKind.OBJECT, userTC) + + const userEdgeTC = schemaComposer.createObjectTC({ + name: 'UserEdge', + interfaces: ['Edge'], + fields: { + node: 'User!', + cursor: 'String!', + }, + }) + registry.registerEdgeType('UserEdge', userEdgeTC) + + const connectionTC = schemaComposer.createObjectTC({ + name: 'UserConnection', + interfaces: ['Connection'], + fields: { + pageInfo: 'PageInfo!', + edges: '[UserEdge!]!', + totalCount: 'Int!', + }, + }) + + registry.registerType('UserConnection', TypeKind.CONNECTION, connectionTC) + + expect(registry.hasType('UserConnection')).toBe(true) + expect(registry.isTypeOfKind('UserConnection', TypeKind.CONNECTION)).toBe(true) + + const schema = registry.generateSDL() + expect(schema).toContain('type UserConnection implements Connection') + expect(schema).toContain('pageInfo: PageInfo!') + expect(schema).toContain('edges: [UserEdge!]!') + expect(schema).toContain('totalCount: Int!') + }) + + it('should maintain edge type registry', () => { + const edgeTC = schemaComposer.createObjectTC({ + name: 'BookEdge', + interfaces: ['Edge'], + fields: { node: 'Book!', cursor: 'String!' }, + }) + + registry.registerEdgeType('BookEdge', edgeTC) + + expect(registry.getEdgeTypes()).toContain('BookEdge') + expect(registry.hasEdgeType('BookEdge')).toBe(true) + }) + }) + describe('Type Description Extraction', () => { it('should extract description from object type composer', () => { const objectTC = schemaComposer.createObjectTC({ @@ -362,7 +546,7 @@ describe('GraphQL Registry', () => { const duration = endTime - startTime expect(duration).toBeLessThan(1000) - expect(registry.getAllTypes()).toHaveLength(1001) + expect(registry.getAllTypes()).toHaveLength(1003) }) it('should handle many edge type registrations efficiently', () => { diff --git a/tests/unit/strategies/graphql-helper-strategy.test.ts b/tests/unit/strategies/graphql-helper-strategy.test.ts new file mode 100644 index 0000000..ee727fe --- /dev/null +++ b/tests/unit/strategies/graphql-helper-strategy.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { GraphQLHelperStrategy } from '@generators/strategies/graphql-helper-strategy' +import { TypeScriptHelperStrategy } from '@generators/strategies/typescript-helper-strategy' +import { ModelHelper, HelperGenerationContext } from '@generators/unified/unified-helper-generator' +import { OutputFormat } from '@utils/constants' +import { TestFixtures, TestMockFactory } from '../../helpers' + +describe('GraphQLHelperStrategy', () => { + let strategy: GraphQLHelperStrategy + let mockContext: HelperGenerationContext + + beforeEach(() => { + strategy = new GraphQLHelperStrategy() + mockContext = { + models: [ + TestFixtures.createDataModel('User', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createField('name', 'String'), + TestFixtures.createField('email', 'String'), + ]), + TestFixtures.createDataModel('Post', [ + TestFixtures.createField('id', 'String'), + TestFixtures.createField('title', 'String'), + TestFixtures.createRelationField('author', 'User'), + ]), + ], + relations: [ + { + modelName: 'User', + targetModelName: 'Post', + fieldName: 'posts', + targetFieldName: 'author', + isList: true, + isRequired: false, + }, + { + modelName: 'Post', + targetModelName: 'User', + fieldName: 'author', + targetFieldName: 'posts', + isList: false, + isRequired: false, + }, + ], + outputFormat: OutputFormat.GRAPHQL, + attributeProcessor: TestMockFactory.createSchemaProcessor(), + } + }) + + describe('Initialization', () => { + it('should initialize GraphQLHelperStrategy', () => { + expect(strategy).toBeDefined() + expect(strategy).toBeInstanceOf(GraphQLHelperStrategy) + }) + + it('should extend TypeScriptHelperStrategy', () => { + expect(strategy).toBeInstanceOf(TypeScriptHelperStrategy) + }) + + it('should have generateHelpers method', () => { + expect(typeof strategy.generateHelpers).toBe('function') + }) + }) + + describe('Helper Generation', () => { + it('should generate helpers by delegating to parent class', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [ + { + modelName: 'User', + fieldName: 'posts', + targetModelName: 'Post', + targetFieldName: 'author', + isList: true, + isRequired: false, + }, + ], + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBeGreaterThan(0) + }) + + it('should generate TypeScript helpers even for GraphQL output format', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + mockContext.outputFormat = OutputFormat.GRAPHQL + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + expect(result[0]).toContain('ConnectionBuilder') + }) + + it('should handle multiple model helpers', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + { + modelName: 'Post', + connectionBuilderName: 'PostConnectionBuilder', + filterBuilderName: 'PostFilterBuilder', + sortBuilderName: 'PostSortBuilder', + fieldSelectionName: 'PostFieldSelection', + includesConstName: 'POST_INCLUDES', + relations: [ + { + modelName: 'Post', + fieldName: 'author', + targetModelName: 'User', + targetFieldName: 'posts', + isList: false, + isRequired: true, + }, + ], + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + expect(result[0]).toContain('buildUserConnectionConfig') + expect(result[0]).toContain('buildPostConnectionConfig') + }) + + it('should handle helpers with complex relations', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [ + { + modelName: 'User', + fieldName: 'posts', + targetModelName: 'Post', + targetFieldName: 'author', + isList: true, + isRequired: false, + }, + { + modelName: 'User', + fieldName: 'comments', + targetModelName: 'Comment', + targetFieldName: 'author', + isList: true, + isRequired: false, + }, + ], + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(result[0]).toContain('posts') + expect(result[0]).toContain('comments') + }) + + it('should handle helpers with no relations', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(result.length).toBe(1) + expect(result[0]).toContain('USER_INCLUDES') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty helpers array', () => { + const helpers: ModelHelper[] = [] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + + it('should handle context with no models', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + const emptyContext: HelperGenerationContext = { + ...mockContext, + models: [], + } + + const result = strategy.generateHelpers(helpers, emptyContext) + + expect(result).toBeDefined() + expect(result.length).toBe(1) + }) + + it('should handle malformed helper objects', () => { + const malformedHelpers: ModelHelper[] = [ + { + modelName: '', + connectionBuilderName: '', + filterBuilderName: '', + sortBuilderName: '', + fieldSelectionName: '', + includesConstName: '', + relations: [], + }, + ] + + expect(() => { + strategy.generateHelpers(malformedHelpers, mockContext) + }).not.toThrow() + }) + + it('should handle null relations in helpers', () => { + const helpersWithNullRelations: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: null as any, + }, + ] + + expect(() => { + strategy.generateHelpers(helpersWithNullRelations, mockContext) + }).toThrow() + }) + + it('should handle context with different output formats', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + const typeGraphQLContext: HelperGenerationContext = { + ...mockContext, + outputFormat: OutputFormat.TYPE_GRAPHQL, + } + + const result1 = strategy.generateHelpers(helpers, typeGraphQLContext) + expect(result1).toBeDefined() + + const graphqlContext: HelperGenerationContext = { + ...mockContext, + outputFormat: OutputFormat.GRAPHQL, + } + + const result2 = strategy.generateHelpers(helpers, graphqlContext) + expect(result2).toBeDefined() + + expect(result1).toEqual(result2) + }) + }) + + describe('Parent Class Integration', () => { + it('should call parent generateHelpers method', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + const originalGenerateHelpers = TypeScriptHelperStrategy.prototype.generateHelpers + let parentCalled = false + + TypeScriptHelperStrategy.prototype.generateHelpers = function (helpersArg, contextArg) { + parentCalled = true + expect(helpersArg).toEqual(helpers) + expect(contextArg).toEqual(mockContext) + return originalGenerateHelpers.call(this, helpersArg, contextArg) + } + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(parentCalled).toBe(true) + expect(result).toBeDefined() + + TypeScriptHelperStrategy.prototype.generateHelpers = originalGenerateHelpers + }) + + it('should have access to all parent class methods', () => { + expect(typeof strategy.generateHelpers).toBe('function') + + expect(strategy instanceof TypeScriptHelperStrategy).toBe(true) + }) + + it('should override generateHelpers correctly', () => { + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations: [], + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + expect(typeof result[0]).toBe('string') + }) + }) + + describe('Performance and Stress Tests', () => { + it('should handle large number of helpers efficiently', () => { + const largeHelpersArray: ModelHelper[] = [] + + for (let i = 0; i < 50; i++) { + largeHelpersArray.push({ + modelName: `Model${i}`, + connectionBuilderName: `Model${i}ConnectionBuilder`, + filterBuilderName: `Model${i}FilterBuilder`, + sortBuilderName: `Model${i}SortBuilder`, + fieldSelectionName: `Model${i}FieldSelection`, + includesConstName: `MODEL${i}_INCLUDES`, + relations: [], + }) + } + + const startTime = Date.now() + const result = strategy.generateHelpers(largeHelpersArray, mockContext) + const endTime = Date.now() + + expect(result).toBeDefined() + expect(endTime - startTime).toBeLessThan(1000) // Should complete within 1 second + }) + + it('should handle helpers with many relations efficiently', () => { + const relations = [] + for (let i = 0; i < 20; i++) { + relations.push({ + modelName: 'User', + fieldName: `relation${i}`, + targetModelName: `Target${i}`, + targetFieldName: 'backRef', + isList: i % 2 === 0, + isRequired: i % 3 === 0, + }) + } + + const helpers: ModelHelper[] = [ + { + modelName: 'User', + connectionBuilderName: 'UserConnectionBuilder', + filterBuilderName: 'UserFilterBuilder', + sortBuilderName: 'UserSortBuilder', + fieldSelectionName: 'UserFieldSelection', + includesConstName: 'USER_INCLUDES', + relations, + }, + ] + + const result = strategy.generateHelpers(helpers, mockContext) + + expect(result).toBeDefined() + expect(result.length).toBe(1) + }) + }) +}) diff --git a/tests/unit/strategies/graphql-output-strategy.test.ts b/tests/unit/strategies/graphql-output-strategy.test.ts index f4c7f4e..d5b46d9 100644 --- a/tests/unit/strategies/graphql-output-strategy.test.ts +++ b/tests/unit/strategies/graphql-output-strategy.test.ts @@ -459,7 +459,6 @@ describe('GraphQL Output Strategy', () => { expect(queryArgs.hasField('after')).toBe(true) expect(queryArgs.hasField('last')).toBe(true) expect(queryArgs.hasField('before')).toBe(true) - expect(queryArgs.hasField('connection')).toBe(true) }) it('should include filter and sort if they exist', () => { diff --git a/tests/unit/strategies/typescript-output-strategy.test.ts b/tests/unit/strategies/typescript-output-strategy.test.ts index 2109dc1..1a42b69 100644 --- a/tests/unit/strategies/typescript-output-strategy.test.ts +++ b/tests/unit/strategies/typescript-output-strategy.test.ts @@ -549,7 +549,6 @@ describe('TypeScript Output Strategy', () => { { name: 'after', type: 'String', nullable: true }, { name: 'last', type: 'Int', nullable: true }, { name: 'before', type: 'String', nullable: true }, - { name: 'connection', type: 'Boolean', nullable: true }, ]) }) @@ -577,7 +576,6 @@ describe('TypeScript Output Strategy', () => { { name: 'after', type: 'String', nullable: true }, { name: 'last', type: 'Int', nullable: true }, { name: 'before', type: 'String', nullable: true }, - { name: 'connection', type: 'Boolean', nullable: true }, ]) })