Hi everyone ;). I hope you are well.
As a reminder, I try to do this challenge: Tweeter challenge
Today I propose you to add the possibility to like a tweet.
knex migrate:make create_likes_table -x ts
src/db/migrations/create_likes_table
import * as Knex from 'knex'
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('likes', (t) => {
t.increments('id')
t.integer('user_id').unsigned().notNullable()
t.integer('tweet_id').unsigned().notNullable()
t.unique(['user_id', 'tweet_id'])
t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
t.foreign('tweet_id').references('id').inTable('tweets').onDelete('CASCADE')
})
}
export async function down(knex: Knex): Promise<void> {
return knex.raw('DROP TABLE likes CASCADE')
}
knex migrate:latest
I add a unique constraint concerning the columns user_id and tweet_id because you can only like a tweet once ;).
src/resolvers/LikeResolver.ts
import { ApolloError } from 'apollo-server'
import {
Arg,
Authorized,
Ctx,
Int,
Mutation,
ObjectType,
Resolver,
} from 'type-graphql'
import { MyContext } from '../types/types'
@Resolver()
class LikeResolver {
@Mutation(() => String)
@Authorized()
async toggleLike(@Arg('tweet_id') tweet_id: number, @Ctx() ctx: MyContext) {
const { db, userId } = ctx
const [tweet] = await db('tweets').where('id', tweet_id)
if (!tweet) {
throw new ApolloError('Tweet not found')
}
const data = {
user_id: userId,
tweet_id: tweet_id,
}
try {
const [alreadyLiked] = await db('likes').where(data)
if (alreadyLiked) {
// Delete the like and return
await db('likes').where(data).del()
return 'Like deleted'
}
await db('likes').insert(data)
return 'Like added'
} catch (e) {
throw new ApolloError(e.message)
}
}
}
export default LikeResolver
Note that I create a single method to manage the adding or removal of a "like". So, I check if I already have a "like" and if so, I delete it. Otherwise, I add it.
I need to also add the LikeResolver to my schema method:
src/server.ts
...
export const schema = async () => {
return await buildSchema({
resolvers: [AuthResolver, TweetResolver, LikeResolver],
authChecker: authChecker,
})
}
If I launch my server, everything works properly:
Let's write some tests:
src/tests/likes.test.ts
import db from '../db/connection'
import { generateToken } from '../utils/utils'
import { createLike, createTweet, createUser } from './helpers'
import { TOGGLE_LIKE } from './queries/likes.queries'
import { ADD_TWEET, FEED, DELETE_TWEET } from './queries/tweets.queries'
import { testClient } from './setup'
describe('Likes', () => {
beforeEach(async () => {
await db.migrate.rollback()
await db.migrate.latest()
})
afterEach(async () => {
await db.migrate.rollback()
})
it('should add a like', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: TOGGLE_LIKE,
variables: {
tweet_id: tweet.id,
},
})
const [like] = await db('likes').where({
user_id: user.id,
tweet_id: tweet.id,
})
expect(like).not.toBeUndefined()
expect(res.data.toggleLike).toEqual('Like added')
expect(res.errors).toBeUndefined()
})
it('should add delete a like', async () => {
const user = await createUser()
const tweet = await createTweet(user)
await createLike(user, tweet)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: TOGGLE_LIKE,
variables: {
tweet_id: tweet.id,
},
})
const [deleted] = await db('likes').where({
user_id: user.id,
tweet_id: tweet.id,
})
expect(deleted).toBeUndefined()
expect(res.data.toggleLike).toEqual('Like deleted')
expect(res.errors).toBeUndefined()
})
it('should not authorized an anonymous user to like a tweet', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient()
const res = await mutate({
mutation: TOGGLE_LIKE,
variables: {
tweet_id: tweet.id,
},
})
const likes = await db('likes')
expect(likes.length).toEqual(0)
expect(res.errors![0].message).toEqual('Unauthorized')
})
it('should not authorized an anonymous user to delete a like', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const like = await createLike(user, tweet)
const { mutate } = await testClient()
const res = await mutate({
mutation: TOGGLE_LIKE,
variables: {
tweet_id: tweet.id,
},
})
const likes = await db('likes')
expect(likes.length).toEqual(1)
expect(res.errors![0].message).toEqual('Unauthorized')
})
})
Before finishing, it might be a good idea to try to get the number of likes for a tweet? I modified the seed file to add some random likes. I'll let you go check it out in the Github Repository ;).
src/entities/Tweet.ts
@Field()
likesCount: number
We will need a "dataloader" to avoid the n+1 problem when we add the @FieldResolver for likes.
src/dataloaders/dataloaders.ts
import DataLoader from 'dataloader'
import db from '../db/connection'
import User from '../entities/User'
export const dataloaders = {
userDataloader: new DataLoader<number, User, unknown>(async (ids) => {
const users = await db('users').whereIn('id', ids)
return ids.map((id) => users.find((u) => u.id === id))
}),
// Get the likesCount for each tweet
likesCountDataloader: new DataLoader<number, any, unknown>(async (ids) => {
const counts = await db('likes')
.whereIn('tweet_id', ids)
.count('tweet_id', { as: 'likesCount' })
.select('tweet_id')
.groupBy('tweet_id')
return ids.map((id) => counts.find((c) => c.tweet_id === id))
}),
}
We can now add a @FieldResolver in our TweetResolver:
src/resolvers/TweetResolver.ts
@FieldResolver(() => Int)
async likesCount(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
const {
dataloaders: { likesCountDataloader },
} = ctx
const count = await likesCountDataloader.load(tweet.id)
return count?.likesCount || 0
}
I do have three SQL queries that are made. One to retrieve the tweets. Another one to retrieve the associated user and the last one to retrieve the number of likes.
However, you will notice a problem. If you run the toggleLike method several times and try to refresh the feed method you will see that the likesCount property will not update. To avoid this problem, we will have to clear the cache when we want to add or remove a "like".
Clear the dataloader cache
src/resolvers/LikeResolver.ts
@Mutation(() => String)
@Authorized()
async toggleLike(@Arg('tweet_id') tweet_id: number, @Ctx() ctx: MyContext) {
const {
db,
userId,
dataloaders: { likesCountDataloader }, // I get the dataloaders from the context
} = ctx
const [tweet] = await db('tweets').where('id', tweet_id)
if (!tweet) {
throw new ApolloError('Tweet not found')
}
const data = {
user_id: userId,
tweet_id: tweet_id,
}
try {
const [alreadyLiked] = await db('likes').where(data)
if (alreadyLiked) {
// Delete the like and return
await db('likes').where(data).del()
likesCountDataloader.clear(tweet_id) // I clear the dataloader for this particular tweet_id
return 'Like deleted'
}
await db('likes').insert(data)
likesCountDataloader.clear(tweet_id) // I clear the dataloader for this particular tweet_id
return 'Like added'
} catch (e) {
throw new ApolloError(e.message)
}
}
It should now work as expected.
It will be all for today ;).
Bye and take care! ;)
Top comments (0)