Skip to content

Commit 726ea7e

Browse files
committed
2 parents d880aff + 28acb0e commit 726ea7e

File tree

8 files changed

+1182
-324
lines changed

8 files changed

+1182
-324
lines changed

docs/dev-notes/2025-10-13/fix-n-plus-1-query-for-prisma/plan.md

Lines changed: 740 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
"@testing-library/jest-dom": "6.9.1",
4242
"@types/gtag.js": "0.0.20",
4343
"@types/jsdom": "21.1.7",
44-
"@typescript-eslint/eslint-plugin": "8.45.0",
45-
"@typescript-eslint/parser": "8.45.0",
44+
"@typescript-eslint/eslint-plugin": "8.46.1",
45+
"@typescript-eslint/parser": "8.46.1",
4646
"@vitest/coverage-v8": "3.2.4",
4747
"@vitest/ui": "3.2.4",
4848
"eslint": "9.37.0",
@@ -51,22 +51,22 @@
5151
"globals": "16.4.0",
5252
"husky": "9.1.7",
5353
"jsdom": "26.1.0",
54-
"lint-staged": "16.2.3",
54+
"lint-staged": "16.2.4",
5555
"nock": "14.0.10",
56-
"pnpm": "10.18.0",
56+
"pnpm": "10.18.3",
5757
"prettier": "3.6.2",
5858
"prettier-plugin-svelte": "3.4.0",
5959
"prettier-plugin-tailwindcss": "0.6.14",
6060
"prisma": "5.22.0",
6161
"super-sitemap": "1.0.5",
62-
"svelte": "5.39.8",
62+
"svelte": "5.40.0",
6363
"svelte-5-ui-lib": "0.12.2",
64-
"svelte-check": "4.3.2",
64+
"svelte-check": "4.3.3",
6565
"svelte-meta-tags": "4.4.1",
66-
"sveltekit-superforms": "2.27.2",
66+
"sveltekit-superforms": "2.27.3",
6767
"tslib": "2.8.1",
6868
"typescript": "5.9.3",
69-
"vite": "7.1.9",
69+
"vite": "7.1.10",
7070
"vitest": "3.2.4",
7171
"zod": "3.25.76"
7272
},
@@ -78,21 +78,21 @@
7878
"@prisma/client": "5.22.0",
7979
"@testing-library/svelte": "5.2.8",
8080
"@types/jest": "30.0.0",
81-
"@types/node": "24.6.2",
81+
"@types/node": "24.7.2",
8282
"autoprefixer": "10.4.21",
8383
"debug": "4.4.3",
8484
"embla-carousel-autoplay": "8.6.0",
8585
"embla-carousel-svelte": "8.6.0",
8686
"flowbite": "2.5.2",
8787
"lucia": "2.7.7",
88-
"lucide-svelte": "^0.544.0",
88+
"lucide-svelte": "^0.545.0",
8989
"p-queue": "^9.0.0",
9090
"playwright": "1.55.1",
9191
"prisma-erd-generator": "2.1.0",
9292
"svelte-eslint-parser": "1.3.3",
9393
"tailwind-merge": "2.6.0",
9494
"tailwindcss": "3.4.18",
95-
"vercel": "48.2.0",
95+
"vercel": "48.2.9",
9696
"xss": "1.0.15"
9797
},
9898
"packageManager": "[email protected]",

pnpm-lock.yaml

Lines changed: 287 additions & 266 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/services/answers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,37 @@ export async function getAnswers(user_id: string) {
2020
return answersMap;
2121
}
2222

23+
/**
24+
* Fetches task answers for a specific user and a list of selected task IDs.
25+
*
26+
* @param selectedTaskIds - An array of task IDs to filter the answers.
27+
* @param userId - The ID of the user whose answers are to be fetched.
28+
*
29+
* @returns A promise that resolves to an array of TaskAnswer objects.
30+
* @note conditions: task_id IN (...) AND user_id = userId
31+
*/
32+
export async function getAnswersWithSelectedTaskIds(
33+
selectedTaskIds: string[],
34+
userId: string,
35+
): Promise<Pick<TaskAnswer, 'task_id' | 'user_id' | 'status_id' | 'updated_at'>[]> {
36+
if (!selectedTaskIds?.length) {
37+
return [];
38+
}
39+
40+
return await prisma.taskAnswer.findMany({
41+
where: {
42+
task_id: { in: selectedTaskIds },
43+
user_id: userId,
44+
},
45+
select: {
46+
task_id: true,
47+
user_id: true,
48+
status_id: true,
49+
updated_at: true,
50+
},
51+
});
52+
}
53+
2354
export async function getAnswersOrderedByUpdatedDesc(user_id: string): Promise<TaskAnswer[]> {
2455
const answers_from_db = await prisma.taskAnswer.findMany({
2556
where: {

src/lib/services/task_results.ts

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
getSubmissionStatusMapWithId,
66
getSubmissionStatusMapWithName,
77
} from '$lib/services/submission_status';
8-
import { getTasks, getTask } from '$lib/services/tasks';
8+
import { getTasks, getTasksWithSelectedTaskIds, getTask } from '$lib/services/tasks';
99
import { getUser } from '$lib/services/users';
1010
import * as answer_crud from '$lib/services/answers';
1111

@@ -19,7 +19,7 @@ import type { User } from '@prisma/client';
1919
import type { TaskAnswer } from '$lib/types/answer';
2020
import type { Task } from '$lib/types/task';
2121
import type { TaskResult, TaskResults, Tasks } from '$lib/types/task';
22-
import type { WorkBookTaskBase, WorkBookTasksBase } from '$lib/types/workbook';
22+
import type { WorkBookTasksBase } from '$lib/types/workbook';
2323
import type { FloatingMessages } from '$lib/types/floating_message';
2424

2525
import { NOT_FOUND } from '$lib/constants/http-response-status-codes';
@@ -183,44 +183,90 @@ export async function getTaskResultsOnlyResultExists(
183183
}
184184

185185
// Note: 個別の問題集を参照するときのみ使用する。
186-
// Why : 未回答の問題も含めて取得するため、データ総量を抑えるためにも問題集の一覧ユーザの回答を含むを参照するときは上記のメソッドを使用する。
186+
// Why : 未回答の問題も含めて取得するため、データ総量を抑えるためにも問題集の一覧(ユーザの回答を含む)を参照するときは上記のメソッドを使用する。
187187
export async function getTaskResultsByTaskId(
188188
workBookTasks: WorkBookTasksBase,
189189
userId: string,
190190
): Promise<Map<string, TaskResult>> {
191-
const taskResultsWithTaskId = workBookTasks.map((workBookTask: WorkBookTaskBase) =>
192-
getTaskResultWithErrorHandling(workBookTask.taskId, userId).then((taskResult: TaskResult) => ({
193-
taskId: workBookTask.taskId,
194-
taskResult: taskResult,
195-
})),
196-
);
191+
const startTime = Date.now();
192+
193+
// Step 1: Extract task IDs with type-safe filtering
194+
const taskIds = workBookTasks
195+
.map((workBookTask) => workBookTask.taskId)
196+
.filter((id): id is string => id !== null && id !== undefined);
197+
198+
if (taskIds.length === 0) {
199+
return new Map();
200+
}
201+
202+
// Step 2 & 3: Bulk fetch all tasks and answers (2 query)
203+
const tasks = await getTasksWithSelectedTaskIds(taskIds);
204+
const answers = userId ? await answer_crud.getAnswersWithSelectedTaskIds(taskIds, userId) : [];
205+
206+
// Step 4: Create Maps for O(1) lookup
207+
const tasksMap = new Map(tasks.map((task: Task) => [task.task_id, task]));
208+
const answersMap = new Map(answers.map((answer) => [answer.task_id, answer]));
209+
const taskResultsMap = new Map<string, TaskResult>();
210+
211+
// Step 5: Merge in memory using mergeTaskAndAnswer
212+
for (const taskId of taskIds) {
213+
const task = tasksMap.get(taskId);
214+
215+
if (!task) {
216+
console.warn(`Not found task: ${taskId} in database`);
217+
continue;
218+
}
197219

198-
const taskResultsMap = (await Promise.all(taskResultsWithTaskId)).reduce(
199-
(map, { taskId, taskResult }: { taskId: string; taskResult: TaskResult }) =>
200-
map.set(taskId, taskResult),
201-
new Map<string, TaskResult>(),
220+
const answer = answersMap.get(taskId);
221+
const taskResult = mergeTaskAndAnswer(task, userId, answer);
222+
223+
taskResultsMap.set(taskId, taskResult);
224+
}
225+
226+
const duration = Date.now() - startTime;
227+
console.log(
228+
`[getTaskResultsByTaskId] Loaded ${taskIds.length} tasks in ${duration}ms (${answers.length} answers)`,
202229
);
203230

204231
return taskResultsMap;
205232
}
206233

207-
async function getTaskResultWithErrorHandling(taskId: string, userId: string): Promise<TaskResult> {
208-
try {
209-
return await getTaskResult(taskId, userId);
210-
} catch (error) {
211-
console.error(`Failed to get task result for taskId ${taskId}:`, error);
212-
return await handleTaskResultError(taskId, userId);
234+
/**
235+
* Merge task and answer to create TaskResult
236+
* Extracted common logic from getTaskResult (excluding DB access)
237+
*
238+
* @param task - Task object from database
239+
* @param userId - User ID for creating TaskResult
240+
* @param answer - TaskAnswer object from database (can be null or undefined)
241+
* @returns TaskResult with merged data
242+
*/
243+
function mergeTaskAndAnswer(
244+
task: Task,
245+
userId: string,
246+
answer: TaskAnswer | null | undefined,
247+
): TaskResult {
248+
const taskResult = createDefaultTaskResult(userId, task);
249+
250+
if (!answer) {
251+
return taskResult;
213252
}
214-
}
215253

216-
async function handleTaskResultError(taskId: string, userId: string): Promise<TaskResult> {
217-
try {
218-
const task: Tasks = await getTask(taskId);
219-
return await createDefaultTaskResult(userId, task[0]);
220-
} catch (innerError) {
221-
console.error(`Failed to create a default task result for taskId ${taskId}:`, innerError);
222-
throw new Error(`問題id: ${taskId} の作成に失敗しました。`);
254+
const status = statusById.get(answer.status_id);
255+
256+
if (status) {
257+
taskResult.status_id = status.id;
258+
taskResult.status_name = status.status_name;
259+
taskResult.submission_status_image_path = status.image_path;
260+
taskResult.submission_status_label_name = status.label_name;
261+
taskResult.is_ac = status.is_ac;
262+
taskResult.user_id = userId;
263+
264+
if (answer.updated_at) {
265+
taskResult.updated_at = answer.updated_at;
266+
}
223267
}
268+
269+
return taskResult;
224270
}
225271

226272
export function createDefaultTaskResult(userId: string, task: Task): TaskResult {
@@ -242,29 +288,17 @@ export function createDefaultTaskResult(userId: string, task: Task): TaskResult
242288
return taskResult;
243289
}
244290

291+
// Note: This function will be deprecated in the future in favor of bulk operations (getTaskResultsByTaskId)
245292
export async function getTaskResult(slug: string, userId: string) {
246293
const task = await getTask(slug);
247294

248295
if (!task || task.length === 0) {
249296
error(NOT_FOUND, `問題 ${slug} は見つかりませんでした。`);
250297
}
251298

252-
const taskResult = createDefaultTaskResult(userId, task[0]);
253-
const taskanswer: TaskAnswer | null = await answer_crud.getAnswer(slug, userId);
254-
255-
if (!taskanswer) {
256-
return taskResult;
257-
}
258-
259-
const status = statusById.get(taskanswer.status_id);
260-
taskResult.status_id = status.id;
261-
taskResult.status_name = status.status_name;
262-
taskResult.submission_status_image_path = status.image_path;
263-
taskResult.submission_status_label_name = status.label_name;
264-
taskResult.is_ac = status.is_ac;
265-
taskResult.user_id = userId;
299+
const taskAnswer: TaskAnswer | null = await answer_crud.getAnswer(slug, userId);
266300

267-
return taskResult;
301+
return mergeTaskAndAnswer(task[0], userId, taskAnswer);
268302
}
269303

270304
export async function updateTaskResult(taskId: string, submissionStatus: string, userId: string) {

src/lib/services/tasks.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { default as db } from '$lib/server/database';
22
import { classifyContest } from '$lib/utils/contest';
33
import type { TaskGrade } from '$lib/types/task';
4-
import type { Task } from '$lib/types/task';
4+
import type { Task, Tasks } from '$lib/types/task';
55

66
// See:
77
// https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting
@@ -11,6 +11,36 @@ export async function getTasks(): Promise<Task[]> {
1111
return tasks;
1212
}
1313

14+
/**
15+
* Fetches tasks with the specified task IDs.
16+
* @param selectedTaskIds - An array of task IDs to filter the tasks.
17+
*
18+
* @returns A promise that resolves to an array of Task objects.
19+
* @note conditions: { task_id: { in: taskIds } } for efficient filtering
20+
*/
21+
export async function getTasksWithSelectedTaskIds(
22+
selectedTaskIds: string[],
23+
): Promise<Pick<Task, 'contest_id' | 'task_table_index' | 'task_id' | 'title' | 'grade'>[]> {
24+
if (!selectedTaskIds?.length) {
25+
return [];
26+
}
27+
28+
const ids = Array.from(new Set(selectedTaskIds));
29+
30+
return await db.task.findMany({
31+
where: {
32+
task_id: { in: ids }, // SQL: WHERE task_id IN ('id1', 'id2', ...)
33+
},
34+
select: {
35+
contest_id: true,
36+
task_table_index: true,
37+
task_id: true,
38+
title: true,
39+
grade: true,
40+
},
41+
});
42+
}
43+
1444
export async function getTasksByTaskId(): Promise<Map<string, Task>> {
1545
const tasks = await db.task.findMany();
1646
const tasksMap = new Map();

src/lib/types/answer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export interface TaskAnswer {
22
task_id: string;
33
user_id: string;
44
status_id: string;
5+
updated_at: Date;
56
}

svelte.config.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ const config = {
99
preprocess: vitePreprocess(),
1010

1111
kit: {
12-
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
13-
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
14-
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
12+
// See:
13+
// https://svelte.dev/docs/kit/adapter-vercel
1514
adapter: adapter({
16-
// See: https://vercel.com/docs/edge-network/regions
15+
runtime: 'nodejs22.x',
1716
regions: ['hnd1'], // Tokyo, Japan.
17+
memory: 3008, // To avoid OOM errors on /workbooks/{slug}
18+
maxDuration: 30,
1819
}),
1920
// See:
2021
// https://kit.svelte.dev/docs/configuration#alias

0 commit comments

Comments
 (0)