Skip to content

Commit

Permalink
Merge pull request #19 from mathiasberggren/search-with-database
Browse files Browse the repository at this point in the history
[Backend] Add search through database instead of external API's
  • Loading branch information
mathiasberggren authored Apr 29, 2024
2 parents aa4f0a9 + 2aeb415 commit b523966
Show file tree
Hide file tree
Showing 13 changed files with 483 additions and 41 deletions.
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
"db:migrate": "npx prisma migrate dev",
"db:seed": "npx prisma db seed",
"db:studio": "npx prisma studio",
"db:generate": "npx prisma generate",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json",
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --runInBand",
"test:watch": "jest --watch"
},
"lint-staged": {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// TODO: Update with real supported languages instead of this list
// TODO: Derive these from the supported languages
export const supportedLanguageLocaleCodes = ['en', 'es', 'fr', 'de', 'it', 'pt', 'se'] as const
export type Language = typeof supportedLanguageLocaleCodes[number]
59 changes: 59 additions & 0 deletions apps/api/src/database/factories/movie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Movie, MovieTitle } from '@prisma/client'
import { Factory } from 'fishery'
import { faker } from '@faker-js/faker'

import prisma from '../client'
import { supportedLanguageLocaleCodes } from '../../constants'

import { movieTitleFactory, generateMovieTitles } from './movieTitle'

export interface IMovieFactory extends Movie {
movieTitles?: MovieTitle[]
}

class MovieFactory extends Factory<IMovieFactory> {}

export const movieFactory = MovieFactory.define(({ sequence, onCreate, params }) => {
onCreate(async (movie) => {
const createdMovie = await prisma.movie.create({
data: {
id: movie.id,
director: movie.director,
genre: movie.genre,
duration: movie.duration,
subtitles: movie.subtitles,
releaseDate: movie.releaseDate
}
})

// I know this looks horrible, but thinking this is okay since it's just a factory.
const movieTitles = await Promise.all(
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
params.movieTitles
? params.movieTitles.map(async movieTitle => await movieTitleFactory.create({ movieId: movie.id, title: movieTitle.title, language: movieTitle.language }))
: Object.entries(generateMovieTitles()).map(async ([language, title]) => {
const movieTitleParams = {
movieId: movie.id,
language,
title
}

return await movieTitleFactory.create(movieTitleParams)
})
)

return {
...createdMovie,
movieTitles
}
})

return {
id: sequence,
genre: faker.music.genre(),
director: faker.person.fullName(),
duration: faker.number.int({ min: 60, max: 240 }),
subtitles: faker.helpers.arrayElements(supportedLanguageLocaleCodes, faker.number.int({ min: 0, max: 4 })),
releaseDate: faker.date.between({ from: '1980-01-01', to: '2024-01-01' })
}
})
103 changes: 103 additions & 0 deletions apps/api/src/database/factories/movieTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { MovieTitle } from '@prisma/client'
import { Factory } from 'fishery'
import { faker } from '@faker-js/faker'

import { supportedLanguageLocaleCodes, Language } from '../../constants'
import prisma from '../client'

class MovieTitleFactory extends Factory<MovieTitle> {

}

export const movieTitleFactory = MovieTitleFactory.define(({ sequence, onCreate, params }) => {
onCreate(async (movieTitle) => {
// Require movieId if creating a movieTitle in the DB
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!params.movieId) {
throw new Error('[movieTitleFactory] movieId must be provided')
}

return await prisma.movieTitle.create({
data: movieTitle
})
})

const language = (params?.language !== undefined && params.language in supportedLanguageLocaleCodes)
? params.language as Language
: faker.helpers.arrayElement(supportedLanguageLocaleCodes)

const title = params.title ?? generateMovieTitles([language])[language]

return {
movieTitleId: sequence,
movieId: params.movieId ?? sequence,
title,
language
}
})

type Words = 'adjectives' | 'nouns' | 'verbs'

// Enforce the wordlists to have the same keys as the supported locale codes
const wordLists: Record<Language, Record<Words, string[]>> = {
en: {
adjectives: ['Quick', 'Bright', 'Dark'],
nouns: ['Fox', 'Star', 'Shadow'],
verbs: ['Jumps', 'Falls', 'Rises']
},
es: {
adjectives: ['Rápido', 'Brillante', 'Oscuro'],
nouns: ['Zorro', 'Estrella', 'Sombra'],
verbs: ['Salta', 'Cae', 'Asciende']
},
pt: {
adjectives: ['Rápido', 'Brilhante', 'Escuro'],
nouns: ['Raposa', 'Estrela', 'Sombra'],
verbs: ['Salta', 'Cai', 'Sobe']
},
se: {
adjectives: ['Snabb', 'Ljus', 'Mörk'],
nouns: ['Räv', 'Stjärna', 'Skugga'],
verbs: ['Hoppa', 'Fall', 'Stiger']
},
fr: {
adjectives: ['Rapide', 'Brillant', 'Obscur'],
nouns: ['Renard', 'Étoile', 'Ombre'],
verbs: ['Saute', 'Tombe', 'Monte']
},
de: {
adjectives: ['Schnell', 'Hell', 'Dunkel'],
nouns: ['Fuchs', 'Stern', 'Schatten'],
verbs: ['Springt', 'Fällt', 'Steigt']
},
it: {
adjectives: ['Veloce', 'Luminoso', 'Oscuro'],
nouns: ['Volpe', 'Stella', 'Ombra'],
verbs: ['Salta', 'Cade', 'Sale']
}
} as const

export function generateMovieTitles (languages?: Language[]): Record<Language, string> {
const adjectiveIndex = Math.floor(Math.random() * wordLists.en.adjectives.length)
const nounIndex = Math.floor(Math.random() * wordLists.en.nouns.length)
const verbIndex = Math.floor(Math.random() * wordLists.en.verbs.length)

if (languages === null || languages === undefined || languages.length === 0) {
const keys = Object.keys(wordLists) as Array<keyof typeof wordLists>

// Pick languages with a 50% chance that the title exists on that language
languages = keys.filter(_ => Math.random() > 0.5)
}

const titles = languages.reduce((acc, language) => {
const words = wordLists[language]
const adjective = words.adjectives[adjectiveIndex]
const noun = words.nouns[nounIndex]
const verb = words.verbs[verbIndex]

return { ...acc, [language]: `${adjective} ${noun} ${verb}` }
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
}, {} as Record<Language, string>)

return titles
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
1 change: 1 addition & 0 deletions apps/api/src/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ model MovieTitle {
movieId Int @map("movie_id")
Movie Movie @relation(fields: [movieId], references: [id])
language String
@@map("movie_titles")
}

30 changes: 17 additions & 13 deletions apps/api/src/movies/dto/create-movie.dto.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
export class CreateMovieDto {
genre: string
director: string
duration: number
subtitles: string[]
releaseDate: Date
movieTitles: CreateMovieTitleDto[]
}
import { z } from 'nestjs-zod/z'
import { createZodDto } from 'nestjs-zod'

export class CreateMovieTitleDto {
language: string
title: string
}
const createMovieTitleSchema = z.object({
language: z.string(),
title: z.string()
})

const CreateMovieSchema = z.object({
genre: z.string(),
director: z.string(),
duration: z.number().int(),
subtitles: z.string().array().optional().default([]),
releaseDate: z.coerce.date(),
movieTitles: z.array(createMovieTitleSchema).nonempty()
})

export class CreateMovieDto extends createZodDto(CreateMovieSchema) {}
82 changes: 82 additions & 0 deletions apps/api/src/movies/movies-search-db/movies-search-db.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common'
import { Movie, MovieTitle } from '@prisma/client'

import { MoviesSearch } from '../interfaces/movies-search.interface'
import { PrismaService } from '../../database/prisma.service'

interface MovieWithMovieTitleRow {
id: number
genre: string
director: string
duration: number
subtitles: string[]
release_date: Date

movie_title_id: number
title: string
movie_id: number
language: string
}

// TODO: Should be possible to derive from Prisma
type MovieWithTitle = Movie & { movieTitles: MovieTitle[] }

@Injectable()
export class MoviesSearchDbService implements MoviesSearch {
constructor (
private readonly db: PrismaService
) {}

async findByTitle (searchTitle: string, queryLimit: number = 10): Promise<MovieWithTitle[]> {
const decodedSearchTitle = decodeURIComponent(searchTitle)

const queryResult = await this.db.$queryRaw<MovieWithMovieTitleRow[]>`
SELECT m.*, t.* FROM "movies" m
JOIN "movie_titles" t ON m.id = t."movie_id"
WHERE t.title % ${decodedSearchTitle}
ORDER BY similarity(t.title, ${decodedSearchTitle}) DESC
LIMIT ${queryLimit};`

const processedResult = queryResult.reduce<MovieWithTitle[]>((acc, snakeCasedRow) => {
const row = {
id: snakeCasedRow.id,
genre: snakeCasedRow.genre,
director: snakeCasedRow.director,
duration: snakeCasedRow.duration,
subtitles: snakeCasedRow.subtitles,
releaseDate: new Date(snakeCasedRow.release_date),

title: snakeCasedRow.title,
movieTitleId: snakeCasedRow.movie_title_id,
movieId: snakeCasedRow.movie_id,
language: snakeCasedRow.language

}

// Look for if we already processed the movie
const movie = acc.find(elem => elem.id === row.id)
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!movie) {
const { id, genre, director, duration, subtitles, releaseDate, ...movieTitle } = row

acc.push({
id,
genre,
director,
duration,
subtitles,
releaseDate,
movieTitles: [movieTitle]
})
// if we already processed the movie, this is the same entry but with another title
} else {
const { id, genre, director, duration, subtitles, releaseDate, ...movieTitle } = row

movie.movieTitles.push(movieTitle)
}
return acc
}, [])

return processedResult
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Test, TestingModule } from '@nestjs/testing'
import { createMock } from '@golevelup/ts-jest'

import { movieFactory } from '../../database/factories/movie'
import { PrismaService } from '../../database/prisma.service'
import { movieTitleFactory } from '../../database/factories/movieTitle'

import { MoviesSearchDbService } from './movies-search-db.service'

describe('MoviesSearchDbService', () => {
let service: MoviesSearchDbService

let module: TestingModule
let prismaService: PrismaService

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [MoviesSearchDbService, PrismaService]
})
.useMocker(createMock)
.compile()

service = module.get<MoviesSearchDbService>(MoviesSearchDbService)
prismaService = module.get<PrismaService>(PrismaService)
})

it.only('should call the database with the search title', async () => {
const queryRawSpy = jest.spyOn(prismaService, '$queryRaw').mockResolvedValueOnce([])

const title = 'Any Title'
await service.findByTitle(title)

const queryRawCalls = queryRawSpy.mock.calls[0][0]

const queryRawCallsString = (queryRawCalls as unknown as string[]).map(elem => elem.replace(/\s+/g, ' ')).join(' ')

const expectedSqlQuery = ' SELECT m.*, t.* FROM "movies" m ' +
'JOIN "movie_titles" t ON m.id = t."movie_id" ' +
'WHERE t.title % ORDER BY similarity(t.title, ) DESC LIMIT ;'
expect(queryRawSpy).toHaveBeenCalledWith(
[expect.any(String), expect.any(String), expect.any(String), expect.any(String)],
title, title, 10)

expect(queryRawCallsString).toBe(expectedSqlQuery)
})

it('should handle empty results from the database', async () => {
jest.spyOn(prismaService, '$queryRaw').mockResolvedValueOnce([])

const results = await service.findByTitle('Nonexistent Movie')

expect(results).toEqual([])
})

it('should handle database errors by throwing', async () => {
jest.spyOn(prismaService, '$queryRaw').mockRejectedValueOnce(new Error('Database error'))

await expect(service.findByTitle('The Matrix')).rejects.toThrow('Database error')
})

it('should post-process data correctly', async () => {
const mockMovieData = movieFactory.build()
const mockMovieTitleData = movieTitleFactory.build()

jest.spyOn(prismaService, '$queryRaw').mockResolvedValueOnce([{ ...mockMovieData, ...mockMovieTitleData }])

const result = await service.findByTitle('Mock Title')

expect(result).toEqual([{ ...mockMovieData, movieTitles: [mockMovieTitleData] }])
})
})
3 changes: 2 additions & 1 deletion apps/api/src/movies/movies-search.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'
import { Movie } from '@prisma/client'

import { MoviesSearch } from './interfaces/movies-search.interface'

Expand All @@ -8,5 +9,5 @@ import { MoviesSearch } from './interfaces/movies-search.interface'
export abstract class MoviesSearchService implements MoviesSearch {
// TODO: Implement Movie response interface
// eslint-disable-next-line @typescript-eslint/no-explicit-any
abstract findByTitle (title: string): Promise<any>
abstract findByTitle (title: string): Promise<Movie[]>
}
Loading

0 comments on commit b523966

Please sign in to comment.