From d8249569020e4e5069a1772f7f63ac9dd24e5ddf Mon Sep 17 00:00:00 2001 From: "Yeyang (Justin) Sun" Date: Fri, 20 Oct 2023 07:08:04 -0500 Subject: [PATCH] Save questions to test to avoid re-query database in each test (#52) * docs(readme): remove `db:generate` * fix(db): drizzle push * feat: save questions to test to avoid re-query database in each test --- README.md | 12 ++- app/_game/helpers/getQuestions.ts | 120 ++++++++++++++++++++++++++++++ app/page.tsx | 79 ++++---------------- db/index.ts | 3 + db/schema.ts | 35 +++++++++ drizzle.config.ts | 2 + package.json | 2 +- pnpm-lock.yaml | 14 ++-- 8 files changed, 189 insertions(+), 78 deletions(-) create mode 100644 app/_game/helpers/getQuestions.ts diff --git a/README.md b/README.md index fbad681..2dd074a 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,13 @@ pnpm i ``` -2. Add `.env` file +2. Pull `.env` file from vercel - 1. Copy `.env.example` and rename it to `.env` - 2. Edit `.env`. - -3. Initialize database +```bash +vercel env pull .env +``` -> **Note** -> If your database is not empty, just run `pnpm run db:generate` to generate types +3. (Optional) Initialize database ```bash pnpm run db:push diff --git a/app/_game/helpers/getQuestions.ts b/app/_game/helpers/getQuestions.ts new file mode 100644 index 0000000..606667c --- /dev/null +++ b/app/_game/helpers/getQuestions.ts @@ -0,0 +1,120 @@ +import { desc, eq, inArray } from 'drizzle-orm' + +import { db } from '~/db' +import { fetchQuestionCount } from '~/db/queries' +import { questions, questionsToTests, tests } from '~/db/schema' +import { env } from '~/env.mjs' +import dayjs from '~/lib/dayjs' +import { Random } from '~/lib/random' + +const generateRandomSeed = ({ now }: { now: Date }) => { + const nowDayjs = dayjs(now) + const dateString = nowDayjs.format('YYYYMMDD') + const idFlag = String( + Math.floor(nowDayjs.hour() / env.NEXT_PUBLIC_REFRESH_INTERVAL_HOURS) + ).padStart(2, '0') + return dateString + idFlag +} + +const generateRandomArray = ({ + length, + max, + seed, +}: { + length: number + max: number + seed: string +}) => { + const random = new Random(Number(seed)) + const randomArray: number[] = [] + const getRandomNumber = (): number => { + const randomNumber = random.range(0, max) + // Random array should be unrepeated when `length` is not bigger than `max` + if (length <= max) { + if (randomArray.includes(randomNumber)) { + return getRandomNumber() + } + } + return randomNumber + } + for (let index = 0; index < length; index++) { + randomArray.push(getRandomNumber()) + } + return randomArray +} + +const generateRandomQuestions = async ({ + seed, + testId, +}: { + seed: string + testId: number +}) => { + // Generate random indexes + const questionTotal = await fetchQuestionCount() + const randomIndexes = generateRandomArray({ + length: env.NEXT_PUBLIC_QUESTIONS_PER_CHALLENGE, + max: Math.min(env.NEXT_PUBLIC_ACTIVE_QUESTIONS_LIMIT, questionTotal), + seed, + }) + // Indexes -> Question IDs + const activeQuestionsIds = ( + await db + .select({ id: questions.id }) + .from(questions) + .orderBy(desc(questions.id)) + .limit(env.NEXT_PUBLIC_ACTIVE_QUESTIONS_LIMIT - 1) + ).map(({ id }) => id) + const randomIds = randomIndexes.map( + (index) => activeQuestionsIds[index] || -1 + ) + // Save IDs to test + await db + .insert(tests) + .values({ id: testId }) + .onDuplicateKeyUpdate({ set: { id: testId } }) + await db + .delete(questionsToTests) + .where(inArray(questionsToTests.questionId, randomIds)) + await db + .insert(questionsToTests) + .values(randomIds.map((questionId) => ({ questionId, testId }))) + // Get image from question IDs + const images = ( + await db + .select({ image: questions.image }) + .from(questions) + .where(inArray(questions.id, randomIds)) + ).map(({ image }) => image) + return images +} + +const getQuestionFromTest = async ({ testId }: { testId: number }) => { + const questionRows = await db + .select() + .from(questions) + .innerJoin(questionsToTests, eq(questionsToTests.questionId, questions.id)) + .where(eq(questionsToTests.testId, testId)) + return questionRows.map(({ questions: { image } }) => image) +} + +type GetQuestionsParams = { + length: number + now: Date + testId: number +} +export const getQuestions = async ({ + length, + now, + testId, +}: GetQuestionsParams) => { + const existedTestQuestions = await getQuestionFromTest({ testId }) + if (existedTestQuestions.length >= length) { + return existedTestQuestions.slice(0, length) + } + const generatedQuestions = await generateRandomQuestions({ + seed: generateRandomSeed({ now }), + testId, + }) + return generatedQuestions.slice(0, length) +} diff --git a/app/page.tsx b/app/page.tsx index 2eb5c6b..75f3267 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,14 @@ import { currentUser } from '@clerk/nextjs' -import { desc, eq, inArray } from 'drizzle-orm' +import { desc, eq } from 'drizzle-orm' import { db } from '~/db' -import { fetchQuestionCount } from '~/db/queries' -import { questions, userScores } from '~/db/schema' +import { userScores } from '~/db/schema' import { env } from '~/env.mjs' -import dayjs from '~/lib/dayjs' -import { Random } from '~/lib/random' import { Game } from './_game/Game' import { filterUser } from './_game/helpers/filterUser' import { getNextTestStartTime } from './_game/helpers/getNextTestStartTime' +import { getQuestions } from './_game/helpers/getQuestions' import { getTestId } from './_game/helpers/getTestId' const fetchUser = async () => { @@ -22,61 +20,13 @@ const fetchUser = async () => { } } -const getTestSeed = (now: Date) => { - const nowDayjs = dayjs(now) - const dateString = nowDayjs.format('YYYYMMDD') - const idFlag = String( - Math.floor(nowDayjs.hour() / env.NEXT_PUBLIC_REFRESH_INTERVAL_HOURS) - ).padStart(2, '0') - return dateString + idFlag -} - -const generateRandomArray = (length: number, max: number, seed: string) => { - const random = new Random(Number(seed)) - const randomArray: number[] = [] - const getRandomNumber = (): number => { - const randomNumber = random.range(0, max) - // Random array should be unrepeated when `length` is not bigger than `max` - if (length <= max) { - if (randomArray.includes(randomNumber)) { - return getRandomNumber() - } - } - return randomNumber - } - for (let index = 0; index < length; index++) { - randomArray.push(getRandomNumber()) - } - return randomArray -} - -const fetchRandomQuestions = async (isTrial: boolean, seed: string) => { - const questionCount = await fetchQuestionCount() - const randomIndexes = generateRandomArray( - isTrial ? 1 : env.NEXT_PUBLIC_QUESTIONS_PER_CHALLENGE, - Math.min(env.NEXT_PUBLIC_ACTIVE_QUESTIONS_LIMIT, questionCount), - seed - ) - const activeQuestionsIds = ( - await db - .select({ id: questions.id }) - .from(questions) - .orderBy(desc(questions.id)) - .limit(env.NEXT_PUBLIC_ACTIVE_QUESTIONS_LIMIT - 1) - ).map(({ id }) => id) - const randomIds = randomIndexes.map( - (index) => activeQuestionsIds[index] || -1 - ) - const images = ( - await db - .select({ image: questions.image }) - .from(questions) - .where(inArray(questions.id, randomIds)) - ).map(({ image }) => image) - return images -} - -const fetchUserScoreInCurrentTest = async (userId: string, testId: number) => { +const fetchUserScoreInCurrentTest = async ({ + userId, + testId, +}: { + userId: string + testId: number +}) => { const [latestUserScoreRow] = await db .select() .from(userScores) @@ -93,7 +43,6 @@ const fetchUserScoreInCurrentTest = async (userId: string, testId: number) => { export default async function Home() { const now = new Date() - const testSeed = getTestSeed(now) const testId = getTestId({ date: now }) const nextTestStartTime = getNextTestStartTime({ now, @@ -102,12 +51,16 @@ export default async function Home() { const user = await fetchUser() const userScoreInCurrentTest = user - ? await fetchUserScoreInCurrentTest(user.userId, testId) + ? await fetchUserScoreInCurrentTest({ userId: user.userId, testId }) : null const images = userScoreInCurrentTest ? [] - : await fetchRandomQuestions(user === null, testSeed) + : await getQuestions({ + length: user === null ? 1 : env.NEXT_PUBLIC_QUESTIONS_PER_CHALLENGE, + testId, + now, + }) return ( ({ + questionsToTests: many(questionsToTests), +})) + +export const tests = mysqlTable('tests', { id: int('id').primaryKey() }) + +export const testsRelations = relations(tests, ({ many }) => ({ + questionsToTests: many(questionsToTests), +})) + +export const questionsToTests = mysqlTable( + 'questions_to_tests', + { + questionId: int('question_id').notNull(), + testId: int('test_id').notNull(), + }, + (t) => ({ pk: primaryKey(t.questionId, t.testId) }) +) + +export const questionsToTestsRelations = relations( + questionsToTests, + ({ one }) => ({ + question: one(questions, { + fields: [questionsToTests.questionId], + references: [questions.id], + }), + test: one(tests, { + fields: [questionsToTests.testId], + references: [tests.id], + }), + }) +) + export const userScores = mysqlTable('userScores', { id: serial('id').primaryKey(), userId: varchar('user_id', { length: 191 }).notNull(), diff --git a/drizzle.config.ts b/drizzle.config.ts index 629c43c..d2badd9 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,3 +1,5 @@ +import 'dotenv/config' + import type { Config } from 'drizzle-kit' export default { diff --git a/package.json b/package.json index 3077ab9..85b6b59 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "@clerk/nextjs": "^4.25.3", "@headlessui/react": "^1.7.16", - "@planetscale/database": "^1.10.0", + "@planetscale/database": "^1.11.0", "@t3-oss/env-nextjs": "^0.6.0", "@tremor/react": "^3.6.0", "@upstash/redis": "^1.23.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e9a8ed..44eb857 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ dependencies: specifier: ^1.7.16 version: 1.7.16(react-dom@18.2.0)(react@18.2.0) '@planetscale/database': - specifier: ^1.10.0 - version: 1.10.0 + specifier: ^1.11.0 + version: 1.11.0 '@t3-oss/env-nextjs': specifier: ^0.6.0 version: 0.6.0(typescript@5.1.6)(zod@3.21.4) @@ -37,7 +37,7 @@ dependencies: version: 1.11.10 drizzle-orm: specifier: ^0.28.6 - version: 0.28.6(@planetscale/database@1.10.0) + version: 0.28.6(@planetscale/database@1.11.0) eslint-config-next: specifier: 13.2.4 version: 13.2.4(eslint@8.37.0)(typescript@5.1.6) @@ -1533,8 +1533,8 @@ packages: picocolors: 1.0.0 tslib: 2.6.1 - /@planetscale/database@1.10.0: - resolution: {integrity: sha512-XMfNRjIPgGTga6g1YpGr7E21CcnHZcHZdyhRUIiZ/AlpD+ts65UF2B3wKjcu7MKMynmmcOGs6R9kAT6D1OTlZQ==} + /@planetscale/database@1.11.0: + resolution: {integrity: sha512-aWbU+D/IRHoDE9975y+Q4c+EwwAWxCPwFId+N1AhQVFXzbeJMkj6KN2iQtoi03elcLMRdfT+V3i9Z4WRw+/oIA==} engines: {node: '>=16'} dev: false @@ -3801,7 +3801,7 @@ packages: - supports-color dev: true - /drizzle-orm@0.28.6(@planetscale/database@1.10.0): + /drizzle-orm@0.28.6(@planetscale/database@1.11.0): resolution: {integrity: sha512-yBe+F9htrlYER7uXgDJUQsTHFoIrI5yMm5A0bg0GiZ/kY5jNXTWoEy4KQtg35cE27sw1VbgzoMWHAgCckUUUww==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' @@ -3863,7 +3863,7 @@ packages: sqlite3: optional: true dependencies: - '@planetscale/database': 1.10.0 + '@planetscale/database': 1.11.0 dev: false /electron-to-chromium@1.4.477: