[ PART 12 ] Creating a Twitter clone with GraphQL, Typescript, and React ( CommentsCount, retweetsCount )
ips-coding-challenge
Posted on January 15, 2021
Hi everyone ;).
As a reminder, I'm doing this challenge: Tweeter challenge
In Part 10, I had some issues with validating some field conditionally. To fix the issue, I had to set this option to the server file:
src/server.ts
export const schema = async () => {
return await buildSchema({
resolvers: [AuthResolver, TweetResolver, LikeResolver],
authChecker: authChecker,
validate: {
skipMissingProperties: false, // This one
},
})
}
However, I found that there was another option, so I changed what I did previously. It's not a big thing, but I didn't like to change the option globally. Let's see what I modified:
src/entities/AddTweetPayload
import {
IsDefined,
IsIn,
IsNotEmpty,
MinLength,
ValidateIf,
} from 'class-validator'
import { Field, InputType, Int } from 'type-graphql'
import { TweetTypeEnum } from '../entities/Tweet'
@InputType()
class AddTweetPayload {
@Field()
@IsNotEmpty()
@MinLength(2)
body: string
@Field(() => Int, { nullable: true })
@ValidateIf((o) => o.type !== undefined)
@IsDefined()
parent_id?: number
@Field(() => String, { nullable: true })
@ValidateIf((o) => o.parent_id !== undefined)
@IsDefined()
@IsIn([TweetTypeEnum.COMMENT, TweetTypeEnum.RETWEET])
type?: TweetTypeEnum
@Field(() => String, { nullable: true })
visibility?: string
}
export default AddTweetPayload
According to the documentation, the IsDefined() annotation ignore the property skipMissingProperties. That exactly what I needed ;). I rewrote some tests too because the error was not the same. I can finally remove the option from my server file:
src/server.ts
export const schema = async () => {
return await buildSchema({
resolvers: [AuthResolver, TweetResolver, LikeResolver],
authChecker: authChecker
})
}
CommentsCount && RetweetsCount
As we already add the likesCount, it will be easy to do the same for the comments and retweets.
src/entities/Tweet.ts
@Field()
retweetsCount: number
@Field()
commentsCount: number
src/dataloaders/dataloaders.ts
retweetsCountDataloader: new DataLoader<number, any, unknown>(async (ids) => {
const counts = await db('tweets')
.whereIn('parent_id', ids)
.andWhere('type', TweetTypeEnum.RETWEET)
.count('parent_id', { as: 'retweetsCount' })
.select('parent_id')
.groupBy('parent_id')
return ids.map((id) => counts.find((c) => c.parent_id === id))
}),
commentsCountDataloader: new DataLoader<number, any, unknown>(async (ids) => {
const counts = await db('tweets')
.whereIn('parent_id', ids)
.andWhere('type', TweetTypeEnum.COMMENT)
.count('parent_id', { as: 'commentsCount' })
.select('parent_id')
.groupBy('parent_id')
return ids.map((id) => counts.find((c) => c.parent_id === id))
}),
src/resolvers/TweetResolver.ts
@FieldResolver(() => Int)
async retweetsCount(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
const {
dataloaders: { retweetsCountDataloader },
} = ctx
const count = await retweetsCountDataloader.load(tweet.id)
return count?.retweetsCount || 0
}
@FieldResolver(() => Int)
async commentsCount(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
const {
dataloaders: { commentsCountDataloader },
} = ctx
const count = await commentsCountDataloader.load(tweet.id)
return count?.commentsCount || 0
}
I also have to clear the cache if a comment/retweet is added or if a tweet is deleted.
src/resolvers/TweetResolver.ts
@Mutation(() => Tweet)
@Authorized()
async addTweet(
@Arg('payload') payload: AddTweetPayload,
@Ctx() ctx: MyContext
) {
const {
db,
userId,
dataloaders: { retweetsCountDataloader, commentsCountDataloader },
} = ctx
const { body, type, parent_id } = payload
// Maybe I should add a mutation to handle the retweet?
// For the comment, we can comment as much as we want so I could
// still add the comment here.
// Feel free to share your opinion ;)
if (type === TweetTypeEnum.RETWEET && parent_id) {
const [alreadyRetweeted] = await db('tweets').where({
parent_id: parent_id,
type: TweetTypeEnum.RETWEET,
user_id: userId,
})
if (alreadyRetweeted) {
throw new ApolloError('You already retweeted that tweet')
}
}
if (parent_id) {
const [tweetExists] = await db('tweets').where('id', parent_id)
if (!tweetExists) {
throw new ApolloError('Tweet not found')
}
}
try {
const [tweet] = await db('tweets')
.insert({
...payload,
user_id: userId,
})
.returning('*')
// Needed to clear the cache
if (type === TweetTypeEnum.RETWEET) {
retweetsCountDataloader.clear(tweet.parent_id)
} else if (type === TweetTypeEnum.COMMENT) {
commentsCountDataloader.clear(tweet.parent_id)
}
return tweet
} catch (e) {
throw new ApolloError(e.message)
}
}
@Mutation(() => Int)
@Authorized()
async deleteTweet(@Arg('id') id: number, @Ctx() ctx: MyContext) {
const {
db,
userId,
dataloaders: { retweetsCountDataloader, commentsCountDataloader },
} = ctx
try {
const [tweet] = await db('tweets').where({
id,
user_id: userId,
})
if (!tweet) {
throw new ApolloError('Tweet not found')
}
// Needed to clear the cache
if (tweet.parent_id) {
if (tweet.type === TweetTypeEnum.COMMENT) {
commentsCountDataloader.clear(tweet.parent_id)
} else if (tweet.type === TweetTypeEnum.RETWEET) {
retweetsCountDataloader.clear(tweet.parent_id)
}
}
// Return the number of affected rows
return await db('tweets').where({ id, user_id: userId }).del()
} catch (e) {
throw new ApolloError(e.message)
}
}
It should work as expected ;)
Comment Query
I will add another query to fetch the comments for a tweet.
src/resolvers/TweetResolver.ts
@Query(() => [Tweet])
async comments(@Arg('parent_id') parent_id: number, @Ctx() ctx: MyContext) {
const { db } = ctx
const comments = await db('tweets').where({
parent_id,
type: TweetTypeEnum.COMMENT,
})
return comments
}
Nothing particular here. We should also need a way to retrieve the parent of a comment as a Tweet.
src/entities/Tweet.ts
@Field(() => Tweet, { nullable: true })
parent?: Tweet
And we will add a dataloader too:
src/dataloaders/dataloaders
parentTweetDataloader: new DataLoader<number, Tweet, unknown>(async (ids) => {
const parents = await db('tweets').whereIn('id', ids)
return ids.map((id) => parents.find((p) => p.id === id))
}),
We just need to add the @FieldResolver
src/resolvers/TweetResolver.ts
@FieldResolver(() => Tweet, { nullable: true })
async parent(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
const {
dataloaders: { parentTweetDataloader },
} = ctx
if (!tweet.parent_id) return null
return await parentTweetDataloader.load(tweet.parent_id!)
}
It will be all for today.
Ciao!
Have a nice day ;)
Posted on January 15, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.