[ PART 12 ] Create a Twitter clone with GraphQL, Typescript, and React ( CommentsCount, retweetsCount )

[ PART 12 ] Create a Twitter clone with GraphQL, Typescript, and React ( CommentsCount, retweetsCount )

Hi everyone ;).

As a reminder, I'm doing this challenge: Tweeter challenge

Github repository

Db diagram

In Part 10, I had some issues with validating some fields 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 ignores the property skipMissingProperties. That is 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!)
  }

Feed Query result

It will be all for today.

Ciao!

Have a nice day ;)