diff --git a/src/routes/graphql/index.ts b/src/routes/graphql/index.ts index bb974d9c8..476cc7f32 100644 --- a/src/routes/graphql/index.ts +++ b/src/routes/graphql/index.ts @@ -1,6 +1,15 @@ import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; import { createGqlResponseSchema, gqlResponseSchema } from './schemas.js'; -import { graphql } from 'graphql'; +import { graphql, validate, specifiedRules, parse, GraphQLSchema } from 'graphql'; +import { RootQueryType } from './resolvers/queries.js'; +import { Mutations } from './resolvers/mutations.js'; +import depthLimit from 'graphql-depth-limit'; +import { createLoaders } from './loaders.js'; + +const schema = new GraphQLSchema({ + query: RootQueryType, + mutation: Mutations, +}); const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const { prisma } = fastify; @@ -15,7 +24,31 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, }, async handler(req) { - // return graphql(); + const document = parse(req.body.query); + const validationErrors = validate(schema, document, [ + ...specifiedRules, + depthLimit(5), + ]); + + if (validationErrors.length > 0) { + return { + errors: validationErrors, + }; + } + + const loaders = createLoaders(prisma); + + const result = await graphql({ + schema, + source: req.body.query, + variableValues: req.body.variables, + rootValue: {}, + contextValue: { prisma, loaders }, + fieldResolver: undefined, + typeResolver: undefined, + }); + + return result; }, }); }; diff --git a/src/routes/graphql/loaders.ts b/src/routes/graphql/loaders.ts new file mode 100644 index 000000000..585ad5198 --- /dev/null +++ b/src/routes/graphql/loaders.ts @@ -0,0 +1,106 @@ +import DataLoader from 'dataloader'; +import type { PrismaClient } from '@prisma/client'; + +export const createLoaders = (prisma: PrismaClient) => { + const userLoader = new DataLoader< + string, + Awaited> + >(async (ids) => { + const users = await prisma.user.findMany({ + where: { + id: { in: Array.from(ids) }, + }, + }); + const userMap = new Map(users.map((user) => [user.id, user])); + return ids.map((id) => userMap.get(id) ?? null); + }); + + const postLoader = new DataLoader< + string, + Awaited> + >(async (authorIds) => { + const posts = await prisma.post.findMany({ + where: { + authorId: { in: Array.from(authorIds) }, + }, + }); + const postsByAuthor = new Map(); + for (const post of posts) { + const existing = postsByAuthor.get(post.authorId) ?? []; + existing.push(post); + postsByAuthor.set(post.authorId, existing); + } + return authorIds.map((authorId) => postsByAuthor.get(authorId) ?? []); + }); + + const profileLoader = new DataLoader< + string, + Awaited> + >(async (userIds) => { + const profiles = await prisma.profile.findMany({ + where: { + userId: { in: Array.from(userIds) }, + }, + }); + const profileMap = new Map(profiles.map((profile) => [profile.userId, profile])); + return userIds.map((userId) => profileMap.get(userId) ?? null); + }); + + const memberTypeLoader = new DataLoader< + string, + Awaited> + >(async (ids) => { + const memberTypes = await prisma.memberType.findMany({ + where: { + id: { in: Array.from(ids) }, + }, + }); + const memberTypeMap = new Map( + memberTypes.map((memberType) => [memberType.id, memberType]), + ); + return ids.map((id) => memberTypeMap.get(id) ?? null); + }); + + const userSubscribedToLoader = new DataLoader(async (userIds) => { + const subscriptions = await prisma.subscribersOnAuthors.findMany({ + where: { + subscriberId: { in: Array.from(userIds) }, + }, + select: { subscriberId: true, authorId: true }, + }); + const subscriptionsByUser = new Map(); + for (const sub of subscriptions) { + const existing = subscriptionsByUser.get(sub.subscriberId) ?? []; + existing.push(sub.authorId); + subscriptionsByUser.set(sub.subscriberId, existing); + } + return userIds.map((userId) => subscriptionsByUser.get(userId) ?? []); + }); + + const subscribedToUserLoader = new DataLoader(async (userIds) => { + const subscriptions = await prisma.subscribersOnAuthors.findMany({ + where: { + authorId: { in: Array.from(userIds) }, + }, + select: { authorId: true, subscriberId: true }, + }); + const subscriptionsByUser = new Map(); + for (const sub of subscriptions) { + const existing = subscriptionsByUser.get(sub.authorId) ?? []; + existing.push(sub.subscriberId); + subscriptionsByUser.set(sub.authorId, existing); + } + return userIds.map((userId) => subscriptionsByUser.get(userId) ?? []); + }); + + return { + userLoader, + postLoader, + profileLoader, + memberTypeLoader, + userSubscribedToLoader, + subscribedToUserLoader, + }; +}; + +export type Loaders = ReturnType; diff --git a/src/routes/graphql/resolvers/mutations.ts b/src/routes/graphql/resolvers/mutations.ts new file mode 100644 index 000000000..d44d3ac93 --- /dev/null +++ b/src/routes/graphql/resolvers/mutations.ts @@ -0,0 +1,240 @@ +import type { Loaders } from '../loaders.js'; + +import { GraphQLObjectType, GraphQLString, GraphQLNonNull } from 'graphql'; +import { UUIDType } from '../types/uuid.js'; +import { Post, CreatePostInput, ChangePostInput } from '../types/post.js'; +import { Profile, CreateProfileInput, ChangeProfileInput } from '../types/profile.js'; +import { User, CreateUserInput, ChangeUserInput } from '../types/user.js'; +import { PrismaClient } from '@prisma/client'; + +interface Context { + prisma: PrismaClient; + loaders: Loaders; +} + +export const Mutations = new GraphQLObjectType({ + name: 'Mutations', + fields: () => ({ + createUser: { + type: new GraphQLNonNull(User), + args: { + dto: { + type: new GraphQLNonNull(CreateUserInput), + }, + }, + resolve: ( + _: unknown, + { dto }: { dto: { name: string; balance: number } }, + { prisma }: Context, + ) => { + return prisma.user.create({ + data: dto, + }); + }, + }, + createProfile: { + type: new GraphQLNonNull(Profile), + args: { + dto: { + type: new GraphQLNonNull(CreateProfileInput), + }, + }, + resolve: ( + _: unknown, + { + dto, + }: { + dto: { + isMale: boolean; + yearOfBirth: number; + userId: string; + memberTypeId: string; + }; + }, + { prisma }: Context, + ) => { + return prisma.profile.create({ + data: dto, + }); + }, + }, + createPost: { + type: new GraphQLNonNull(Post), + args: { + dto: { + type: new GraphQLNonNull(CreatePostInput), + }, + }, + resolve: ( + _: unknown, + { dto }: { dto: { title: string; content: string; authorId: string } }, + { prisma }: Context, + ) => { + return prisma.post.create({ + data: dto, + }); + }, + }, + changePost: { + type: new GraphQLNonNull(Post), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + dto: { + type: new GraphQLNonNull(ChangePostInput), + }, + }, + resolve: ( + _: unknown, + { id, dto }: { id: string; dto: { title?: string; content?: string } }, + { prisma }: Context, + ) => { + return prisma.post.update({ + where: { id }, + data: dto, + }); + }, + }, + changeProfile: { + type: new GraphQLNonNull(Profile), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + dto: { + type: new GraphQLNonNull(ChangeProfileInput), + }, + }, + resolve: ( + _: unknown, + { + id, + dto, + }: { + id: string; + dto: { isMale?: boolean; yearOfBirth?: number; memberTypeId?: string }; + }, + { prisma }: Context, + ) => { + return prisma.profile.update({ + where: { id }, + data: dto, + }); + }, + }, + changeUser: { + type: new GraphQLNonNull(User), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + dto: { + type: new GraphQLNonNull(ChangeUserInput), + }, + }, + resolve: ( + _: unknown, + { id, dto }: { id: string; dto: { name?: string; balance?: number } }, + { prisma }: Context, + ) => { + return prisma.user.update({ + where: { id }, + data: dto, + }); + }, + }, + deleteUser: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: async (_: unknown, { id }: { id: string }, { prisma }: Context) => { + await prisma.user.delete({ + where: { id }, + }); + return id; + }, + }, + deletePost: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: async (_: unknown, { id }: { id: string }, { prisma }: Context) => { + await prisma.post.delete({ + where: { id }, + }); + return id; + }, + }, + deleteProfile: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: async (_: unknown, { id }: { id: string }, { prisma }: Context) => { + await prisma.profile.delete({ + where: { id }, + }); + return id; + }, + }, + subscribeTo: { + type: new GraphQLNonNull(GraphQLString), + args: { + userId: { + type: new GraphQLNonNull(UUIDType), + }, + authorId: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: async ( + _: unknown, + { userId, authorId }: { userId: string; authorId: string }, + { prisma }: Context, + ) => { + await prisma.subscribersOnAuthors.create({ + data: { + subscriberId: userId, + authorId, + }, + }); + return userId; + }, + }, + unsubscribeFrom: { + type: new GraphQLNonNull(GraphQLString), + args: { + userId: { + type: new GraphQLNonNull(UUIDType), + }, + authorId: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: async ( + _: unknown, + { userId, authorId }: { userId: string; authorId: string }, + { prisma }: Context, + ) => { + await prisma.subscribersOnAuthors.delete({ + where: { + subscriberId_authorId: { + subscriberId: userId, + authorId, + }, + }, + }); + return userId; + }, + }, + }), +}); diff --git a/src/routes/graphql/resolvers/queries.ts b/src/routes/graphql/resolvers/queries.ts new file mode 100644 index 000000000..b596e792a --- /dev/null +++ b/src/routes/graphql/resolvers/queries.ts @@ -0,0 +1,150 @@ +import type { Loaders } from '../loaders.js'; +import type { GraphQLResolveInfo } from 'graphql'; + +import { GraphQLObjectType, GraphQLNonNull, GraphQLList } from 'graphql'; +import { UUIDType } from '../types/uuid.js'; +import { MemberType, MemberTypeIdEnum } from '../types/member-type.js'; +import { Post } from '../types/post.js'; +import { Profile } from '../types/profile.js'; +import { User } from '../types/user.js'; +import { PrismaClient } from '@prisma/client'; +import { parseResolveInfo } from 'graphql-parse-resolve-info'; + +interface Context { + prisma: PrismaClient; + loaders: Loaders; +} + +export const RootQueryType = new GraphQLObjectType({ + name: 'RootQueryType', + fields: () => ({ + memberTypes: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(MemberType))), + resolve: (_: unknown, __: unknown, { prisma }: Context) => { + return prisma.memberType.findMany(); + }, + }, + memberType: { + type: MemberType, + args: { + id: { + type: new GraphQLNonNull(MemberTypeIdEnum), + }, + }, + resolve: (_: unknown, { id }: { id: string }, { prisma }: Context) => { + return prisma.memberType.findUnique({ + where: { id }, + }); + }, + }, + users: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(User))), + resolve: async ( + _: unknown, + __: unknown, + { prisma, loaders }: Context, + info: GraphQLResolveInfo, + ) => { + const parsedInfo = parseResolveInfo(info) as { + fieldsByTypeName?: { User?: Record }; + } | null; + const fields = parsedInfo?.fieldsByTypeName?.User; + + const needsUserSubscribedTo = Boolean(fields?.userSubscribedTo); + const needsSubscribedToUser = Boolean(fields?.subscribedToUser); + + const include: { + userSubscribedTo?: boolean; + subscribedToUser?: boolean; + } = {}; + + if (needsUserSubscribedTo) { + include.userSubscribedTo = true; + } + if (needsSubscribedToUser) { + include.subscribedToUser = true; + } + + const users = await prisma.user.findMany({ + include: Object.keys(include).length > 0 ? include : undefined, + }); + + // Prime the DataLoader cache + for (const user of users) { + loaders.userLoader.prime(user.id, user); + } + + // Prime subscription loaders if relations were included + if (needsUserSubscribedTo || needsSubscribedToUser) { + for (const user of users) { + const userWithRelations = user as typeof user & { + userSubscribedTo?: Array<{ authorId: string }>; + subscribedToUser?: Array<{ subscriberId: string }>; + }; + if (needsUserSubscribedTo && userWithRelations.userSubscribedTo) { + const authorIds = userWithRelations.userSubscribedTo.map((sub) => sub.authorId); + loaders.userSubscribedToLoader.prime(user.id, authorIds); + } + if (needsSubscribedToUser && userWithRelations.subscribedToUser) { + const subscriberIds = userWithRelations.subscribedToUser.map((sub) => sub.subscriberId); + loaders.subscribedToUserLoader.prime(user.id, subscriberIds); + } + } + } + + return users; + }, + }, + user: { + type: User as GraphQLObjectType, + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: (_: unknown, { id }: { id: string }, { prisma }: Context) => { + return prisma.user.findUnique({ + where: { id }, + }); + }, + }, + posts: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Post))), + resolve: (_: unknown, __: unknown, { prisma }: Context) => { + return prisma.post.findMany(); + }, + }, + post: { + type: Post, + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: (_: unknown, { id }: { id: string }, { prisma }: Context) => { + return prisma.post.findUnique({ + where: { id }, + }); + }, + }, + profiles: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Profile))), + resolve: (_: unknown, __: unknown, { prisma }: Context) => { + return prisma.profile.findMany(); + }, + }, + profile: { + type: Profile, + args: { + id: { + type: new GraphQLNonNull(UUIDType), + }, + }, + resolve: (_: unknown, { id }: { id: string }, { prisma }: Context) => { + return prisma.profile.findUnique({ + where: { id }, + }); + }, + }, + }), +}); diff --git a/src/routes/graphql/types/member-type.ts b/src/routes/graphql/types/member-type.ts new file mode 100644 index 000000000..fbfe1c577 --- /dev/null +++ b/src/routes/graphql/types/member-type.ts @@ -0,0 +1,30 @@ +import { + GraphQLEnumType, + GraphQLObjectType, + GraphQLFloat, + GraphQLInt, + GraphQLNonNull, +} from 'graphql'; + +export const MemberTypeIdEnum = new GraphQLEnumType({ + name: 'MemberTypeId', + values: { + BASIC: { value: 'BASIC' }, + BUSINESS: { value: 'BUSINESS' }, + }, +}); + +export const MemberType = new GraphQLObjectType({ + name: 'MemberType', + fields: () => ({ + id: { + type: new GraphQLNonNull(MemberTypeIdEnum), + }, + discount: { + type: new GraphQLNonNull(GraphQLFloat), + }, + postsLimitPerMonth: { + type: new GraphQLNonNull(GraphQLInt), + }, + }), +}); diff --git a/src/routes/graphql/types/post.ts b/src/routes/graphql/types/post.ts new file mode 100644 index 000000000..9bb52f2b4 --- /dev/null +++ b/src/routes/graphql/types/post.ts @@ -0,0 +1,49 @@ +import { + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLString, + GraphQLNonNull, +} from 'graphql'; +import { UUIDType } from './uuid.js'; + +export const Post = new GraphQLObjectType({ + name: 'Post', + fields: () => ({ + id: { + type: new GraphQLNonNull(UUIDType), + }, + title: { + type: new GraphQLNonNull(GraphQLString), + }, + content: { + type: new GraphQLNonNull(GraphQLString), + }, + }), +}); + +export const CreatePostInput = new GraphQLInputObjectType({ + name: 'CreatePostInput', + fields: () => ({ + title: { + type: new GraphQLNonNull(GraphQLString), + }, + content: { + type: new GraphQLNonNull(GraphQLString), + }, + authorId: { + type: new GraphQLNonNull(UUIDType), + }, + }), +}); + +export const ChangePostInput = new GraphQLInputObjectType({ + name: 'ChangePostInput', + fields: () => ({ + title: { + type: GraphQLString, + }, + content: { + type: GraphQLString, + }, + }), +}); diff --git a/src/routes/graphql/types/profile.ts b/src/routes/graphql/types/profile.ts new file mode 100644 index 000000000..24851dfd7 --- /dev/null +++ b/src/routes/graphql/types/profile.ts @@ -0,0 +1,71 @@ +import type { PrismaClient } from '@prisma/client'; +import type { Loaders } from '../loaders.js'; + +import { + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLBoolean, + GraphQLInt, + GraphQLNonNull, +} from 'graphql'; +import { UUIDType } from './uuid.js'; +import { MemberType, MemberTypeIdEnum } from './member-type.js'; + +interface Context { + prisma: PrismaClient; + loaders: Loaders; +} + +export const Profile = new GraphQLObjectType({ + name: 'Profile', + fields: () => ({ + id: { + type: new GraphQLNonNull(UUIDType), + }, + isMale: { + type: new GraphQLNonNull(GraphQLBoolean), + }, + yearOfBirth: { + type: new GraphQLNonNull(GraphQLInt), + }, + memberType: { + type: new GraphQLNonNull(MemberType), + resolve: (parent: { memberTypeId: string }, _: unknown, { loaders }: Context) => { + return loaders.memberTypeLoader.load(parent.memberTypeId); + }, + }, + }), +}); + +export const CreateProfileInput = new GraphQLInputObjectType({ + name: 'CreateProfileInput', + fields: () => ({ + isMale: { + type: new GraphQLNonNull(GraphQLBoolean), + }, + yearOfBirth: { + type: new GraphQLNonNull(GraphQLInt), + }, + userId: { + type: new GraphQLNonNull(UUIDType), + }, + memberTypeId: { + type: new GraphQLNonNull(MemberTypeIdEnum), + }, + }), +}); + +export const ChangeProfileInput = new GraphQLInputObjectType({ + name: 'ChangeProfileInput', + fields: () => ({ + isMale: { + type: GraphQLBoolean, + }, + yearOfBirth: { + type: GraphQLInt, + }, + memberTypeId: { + type: MemberTypeIdEnum, + }, + }), +}); diff --git a/src/routes/graphql/types/user.ts b/src/routes/graphql/types/user.ts new file mode 100644 index 000000000..dfd13d391 --- /dev/null +++ b/src/routes/graphql/types/user.ts @@ -0,0 +1,104 @@ +import type { PrismaClient } from '@prisma/client'; +import type { Loaders } from '../loaders.js'; + +import { + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLString, + GraphQLFloat, + GraphQLNonNull, + GraphQLList, +} from 'graphql'; +import { UUIDType } from './uuid.js'; +import { Post } from './post.js'; +import { Profile } from './profile.js'; + +interface Context { + prisma: PrismaClient; + loaders: Loaders; +} + +export const User = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + id: { + type: new GraphQLNonNull(UUIDType), + }, + name: { + type: new GraphQLNonNull(GraphQLString), + }, + balance: { + type: new GraphQLNonNull(GraphQLFloat), + }, + profile: { + type: Profile, + resolve: (parent: { id: string }, _: unknown, { loaders }: Context) => { + return loaders.profileLoader.load(parent.id); + }, + }, + posts: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Post))), + resolve: (parent: { id: string }, _: unknown, { loaders }: Context) => { + return loaders.postLoader.load(parent.id); + }, + }, + userSubscribedTo: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(User))), + resolve: async ( + parent: { id: string }, + _: unknown, + { loaders }: Context, + ) => { + const authorIds = await loaders.userSubscribedToLoader.load(parent.id); + if (authorIds.length === 0) { + return []; + } + return Promise.all(authorIds.map((id) => loaders.userLoader.load(id))).then( + (users) => + users.filter((user): user is NonNullable => user !== null), + ); + }, + }, + subscribedToUser: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(User))), + resolve: async ( + parent: { id: string }, + _: unknown, + { loaders }: Context, + ) => { + const subscriberIds = await loaders.subscribedToUserLoader.load(parent.id); + if (subscriberIds.length === 0) { + return []; + } + return Promise.all(subscriberIds.map((id) => loaders.userLoader.load(id))).then( + (users) => + users.filter((user): user is NonNullable => user !== null), + ); + }, + }, + }), +}); + +export const CreateUserInput = new GraphQLInputObjectType({ + name: 'CreateUserInput', + fields: () => ({ + name: { + type: new GraphQLNonNull(GraphQLString), + }, + balance: { + type: new GraphQLNonNull(GraphQLFloat), + }, + }), +}); + +export const ChangeUserInput = new GraphQLInputObjectType({ + name: 'ChangeUserInput', + fields: () => ({ + name: { + type: GraphQLString, + }, + balance: { + type: GraphQLFloat, + }, + }), +});