diff --git a/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md b/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md new file mode 100644 index 000000000..96b4c6ce2 --- /dev/null +++ b/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md @@ -0,0 +1,237 @@ +# Contest-Problem Mapping Extension Plan + +## 概要 + +既存のTask テーブルのunique制約を維持しながら、複数コンテストで同一問題を扱うための拡張方針を検討。AtCoder Problems の実装を参考に、段階的な移行戦略を策定。 + +## 現状の課題 + +### 課題1: Task識別の制限 + +- **問題**: `task_id` が同じでも `contest_id` が異なるケースが多数存在 +- **制約**: Task テーブルの `task_id` unique制約は必ず維持する必要がある +- **影響**: AtCoder Problems API の Problems API で登録済みの (task_id, contest_id) の問題のみ処理可能 + +### 課題2: 影響範囲の調査 + +- 数万行のTypeScript・Svelteコード +- `map` 形式の処理が広範囲に存在 +- e2eテストがほぼ実装されていないので、手動での確認が必要 + +## 解決方針 + +### 基本アプローチ + +AtCoder Problems API を活用: + +- 問題情報: 同一の `task_id` が存在する場合は、どれか一つのコンテストに割り当て + - `https://kenkoooo.com/atcoder/resources/problems.json` +- コンテストと問題idの対応関係を網羅 + - `https://kenkoooo.com/atcoder/resources/contest-problem.json` + +### 技術的解決策 + +#### 1. 複合キーによる識別 + +**方針**: `map, TaskResult>` への移行 + +```typescript +// 型定義 +type TaskKey = `${string}:${string}`; // "contest_id:task_id" + +// ヘルパー関数 +function createTaskKey(contestId: string, taskId: string): TaskKey { + return `${contestId}:${taskId}`; +} + +// マップの型 +type TaskResultMap = Map; +``` + +**利点**: + +- 文字列連結は一般的で高速 +- TypeScriptのTuple型も利用可能だが、文字列の方が実用的 +- O(1)での参照が可能 + +#### 2. データマイグレーション戦略 + +```typescript +// 既存データの移行 +function migrateExistingData(oldMap: Map): Map { + const newMap = new Map(); + + for (const [taskId, result] of oldMap) { + const primaryContestId = result.contest_id || 'primary'; + const key = createTaskKey(primaryContestId, taskId); + newMap.set(key, result); + } + + return newMap; +} + +// 互換性レイヤー +export class TaskMapAdapter { + constructor( + private enhanced: EnhancedTaskMap, + private legacy: LegacyTaskMap, + ) {} + + get(taskId: string, contestId?: string): TaskResult | undefined { + if (contestId) { + return this.enhanced.get(createTaskKey(contestId, taskId)); + } + return this.legacy.get(taskId); + } +} +``` + +#### 3. インポート処理の分離 + +**方針**: 問題データインポートとマッピングデータインポートを別ページで処理 + +```typescript +interface ImportOptions { + includeContestMapping?: boolean; // contest-problem.json を使用 + onlyProblems?: boolean; // problems.json のみ使用 +} + +// 問題データインポート(既存制約を守る) +async function importTaskData(options: ImportOptions = {}) { + if (options.onlyProblems !== false) { + await importFromProblemsJson(); + } + + if (options.includeContestMapping) { + await importContestProblemMapping(); + } +} +``` + +**分離理由**: + +- 責務の分離(異なるAPIエンドポイント) +- エラーハンドリングの違い +- 将来のコンテストサイト固有設定への対応 + +## 実装計画 + +### Phase 1: 基盤整備 + +- [ ] 新しい型定義の作成 +- [ ] ユーティリティ関数の実装 +- [ ] 互換性レイヤーの作成 + +### Phase 2: 影響範囲調査 + +```bash +# 調査用コマンド +grep -r "taskId" src/ --include="*.ts" --include="*.svelte" +grep -r "Map; + previewMappingChanges(): Promise; + validateMappingData(): Promise; +} + +interface ImportFilter { + contestIds?: string[]; + taskIds?: string[]; + dateRange?: { from: Date; to: Date }; +} +``` + +**利点**: + +- 長期的な保守性 +- エラーハンドリングの容易さ +- 他コンテストサイト対応への拡張性 + +### ディレクトリ構成案 + +``` +src/routes/admin/import/ +├── problems/ # 問題データインポート +│ ├── +page.svelte +│ └── types.ts +├── mapping/ # コンテスト-問題マッピング +│ ├── +page.svelte +│ ├── bulk/ +│ └── selective/ +└── shared/ # 共通コンポーネント + ├── ImportStatus.svelte + └── ValidationResults.svelte +``` + +## 今後の課題 + +### 短期 + +- [ ] 影響範囲の詳細調査 +- [ ] プロトタイプの作成 +- [ ] 基本的なe2eテストの追加 + +### 中期 + +- [ ] 他コンテストサイトAPI対応 +- [ ] APIレート制限への対応 +- [ ] UI/UXの改善 + +### 長期 + +- [ ] 完全自動化システム +- [ ] 包括的なテストスイート +- [ ] パフォーマンス最適化 + +## リスク要因と対策 + +| リスク | 影響度 | 対策 | +| -------------------- | ------ | -------------------- | +| 既存機能の破綻 | 高 | 互換性レイヤーの実装 | +| データ不整合 | 中 | バリデーション強化 | +| パフォーマンス劣化 | 中 | 段階的な最適化 | +| メンテナンスコスト増 | 中 | ドキュメント整備 | + +## 決定事項 + +1. **Task テーブルのunique制約は維持** +2. **複合キー (`contest_id:task_id`) による識別** +3. **段階的移行による安全な実装** +4. **インポート機能の責務分離** +5. **Web UI による管理画面実装** + +## 参考リンク + +- [API client in AtCoder Problems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/api/APIClient.ts#L239) +- [Problems API](https://kenkoooo.com/atcoder/resources/problems.json) +- [Contest-Problem API](https://kenkoooo.com/atcoder/resources/contest-problem.json) + +--- + +**作成日**: 2025-09-17 +**最終更新**: 2025-09-17 +**ステータス**: 計画段階 diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md new file mode 100644 index 000000000..919f0c41c --- /dev/null +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -0,0 +1,264 @@ +# Contest-Task Pair Mapping 実装計画 + +## 概要 + +既存の Task テーブルの unique 制約を維持しながら、複数コンテストで同一問題を扱うための拡張実装計画。[前回計画](../../2025-09-17/contest-task-mapping/initial_plan.md)をベースに、互換性レイヤを用いた段階的移行戦略のうちデータベース設計と型定義を詳細化。 + +## 1. 命名の決定 + +- **モデル名**: `ContestTaskPair` (「ペア」であることを明示) +- **型名**: `ContestTaskPairKey` (Prisma モデルとの衝突回避) +- **マップ型**: `TaskResultMapByContestTaskPair` (キーと値の関係を明示) + +## 2. データベース設計 + +### Prisma スキーマ追加 + +```prisma +model ContestTaskPair { + id String @id @default(uuid()) + contestId String + taskTableIndex String + taskId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([contestId, taskId]) // ペアの重複を防止 + @@index([contestId]) // contestId 検索を高速化 + @@map("contesttaskpair") +} +``` + +**設計判断**: + +- id 属性追加: 将来のリレーション対応・一貫性のため +- 複合一意制約: ペアレベルでの重複防止 +- インデックス: 後から追加可能だが contestId 検索の高速化 + +### マイグレーション + +```bash +# 開発環境 +pnpm dlx prisma migrate dev --name create_contest_task_pair +``` + +### CRUD メソッドの実装 + +**ファイル**: `src/lib/services/contest_task_pairs.ts` + +```typescript +import { Prisma } from '@prisma/client'; + +import { default as db } from '$lib/server/database'; +import type { + ContestTaskPair, + ContestTaskPairs, + ContestTaskPairCreate, + ContestTaskPairUpdate, +} from '$lib/types/contest_task_pair'; + +/** + * 単一のContestTaskPairを取得 + */ +export async function getContestTaskPair( + contestId: string, + taskId: string, +): Promise { + const contestTaskPair = await db.contestTaskPair.findUnique({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + }); + + return contestTaskPair; +} + +/** + * ContestTaskPair の一覧を取得 + */ +export async function getContestTaskPairs(): Promise { + return await db.contestTaskPair.findMany(); +} + +/** + * ContestTaskPair の新規レコードを作成 + */ +export async function createContestTaskPair( + params: ContestTaskPairCreate, +): Promise { + const { contestId, taskTableIndex, taskId } = params; + + try { + const contestTaskPair = await db.contestTaskPair.create({ + data: { + contestId, + taskTableIndex, + taskId, + }, + }); + + console.log('Created ContestTaskPair:', contestTaskPair); + + return contestTaskPair; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + const existingPair = await getContestTaskPair(contestId, taskId); + + if (!existingPair) { + throw new Error('Unexpected: record exists but cannot be fetched'); + } + + return existingPair; + } + + console.error('Failed to create ContestTaskPair:', error); + throw error; + } +} + +/** + * ContestTaskPair のレコードを更新 + */ +export async function updateContestTaskPair( + params: ContestTaskPairUpdate, +): Promise { + const { contestId, taskTableIndex, taskId } = params; + + try { + const updatedContestTaskPair = await db.contestTaskPair.update({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + data: { + taskTableIndex, + }, + }); + + console.log('Updated ContestTaskPair:', updatedContestTaskPair); + + return updatedContestTaskPair; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; + console.error(errorMessage); + throw new Error(errorMessage, { cause: error as Error }); + } + + console.error('Failed to update ContestTaskPair:', error); + throw error; + } +} +``` + +**特徴**: + +- DRY原則に従い `getContestTaskPair()` を共通メソッドとして切り出し +- 複合ユニーク制約 `@@unique([contestId, taskId])` を活用 +- Prisma のエラーハンドリング(例: `P2002`, `P2025`)を活用して、事前チェックを省略し、TOCTOU (Time-of-Check to Time-of-Use)競合を回避 +- Prisma の自動生成型を使用して型安全性を確保 + +## 3. 型定義の更新 + +### Prisma型に基づく型定義 + +**ファイル**: `src/lib/types/contest_task_pair.ts` + +```typescript +import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; + +export type ContestTaskPair = ContestTaskPairOrigin; + +// Prismaの自動生成型をベースとした配列型 +export type ContestTaskPairs = ContestTaskPair[]; + +// CRUD操作用のパラメータ型 +export type ContestTaskPairCreate = { + contestId: string; + taskTableIndex: string; + taskId: string; +}; + +export type ContestTaskPairRead = ContestTaskPairCreate; + +export type ContestTaskPairUpdate = ContestTaskPairCreate; +``` + +### マッピング用の型定義 + +```typescript +import type { TaskResult } from '$lib/types/task_result'; + +// 型定義 +export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" + +// ヘルパー関数 +export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { + return `${contestId}:${taskId}`; +} + +// マップの型(明示的) +export type TaskResultMapByContestTaskPair = Map; +``` + +**設計判断**: + +- Prisma の自動生成型を活用することで型安全性を確保 +- クライアントサイドでも Prisma 型を使用可能(型定義のみでランタイムコード不要) +- CRUD パラメータ型を定義して、メソッド引数の型安全性を向上 + +## 4. 実行計画 + +- [✅] Prisma スキーマに ContestTaskPair モデル追加 +- [✅] マイグレーション実行 +- [✅] CRUD メソッドを追加 +- [✅] TypeScript 型定義追加 + +## 決定事項 + +1. **モデル名**: ContestTaskPair +2. **型名**: ContestTaskPairKey, TaskResultMapByContestTaskPair + +## 今後の課題 + +1. **移行方針**: TaskMapAdapter による段階的移行 +2. **アダプタ配置**: `src/lib/utils/task_results.ts`(影響局所化・テスト容易性・削除容易性) +3. **id 属性**: 将来性を考慮して追加 +4. **Feature Flag**: 環境変数制御、getTaskResults()のみ適用(段階1) +5. **テスト方針**: `test` + `expect to` + TypeScript strict + assert追加 +6. **監視手法**: console.log + Vercelログ(APM導入見送り) +7. **データ投入**: seed.ts優先 → 管理者画面は、その次の段階で実装 + +--- + +## 教訓と一般化 + +以下は、実装中に得られた教訓を一般化したものです。今後の開発においても参考にしてください。 + +### 1. 防御的なチェックの再考 + +- **教訓**: Prisma のエラーハンドリング(例: `P2002` や `P2025`)を活用することで、事前チェックを省略し、TOCTOU(Time-of-Check to Time-of-Use)競合を回避できる。 +- **推奨**: 事前チェックを行う代わりに、データベース操作の結果を直接利用し、エラーを適切に処理する。 + +### 2. 戻り値の活用 + +- **教訓**: CRUD メソッドで作成・更新されたレコードを返すことで、呼び出し元が追加のクエリを実行せずに結果を利用できる。 +- **推奨**: `create` や `update` メソッドでは、`void` を返すのではなく、作成・更新されたレコードを返すようにする。 + +### 3. 型定義の明確化 + +- **教訓**: Prisma の自動生成型を活用しつつ、必要に応じて独自の型を定義することで、型安全性と可読性を向上できる。 +- **推奨**: CRUD 操作用の型(例: `Create`, `Update`, `Read`)を明確に分離し、それぞれの目的に応じた型を定義する。 + +### 4. ログとエラーハンドリング + +- **教訓**: エラー発生時には、適切なログを出力し、問題の特定を容易にする。 +- **推奨**: Prisma のエラーコード(例: `P2002`, `P2025`)を活用して、エラー内容に応じた具体的なメッセージを出力する。 + +**更新日**: 2025-10-15 diff --git a/prisma/ERD.md b/prisma/ERD.md index d5f1e1ef6..57200d8d5 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -126,6 +126,16 @@ OTHERS OTHERS } + "contesttaskpair" { + String id "🗝️" + String contestId + String taskTableIndex + String taskId + DateTime createdAt + DateTime updatedAt + } + + "tag" { String id "🗝️" Boolean is_published @@ -195,28 +205,27 @@ OTHERS OTHERS } "user" o|--|| "Roles" : "enum:role" - "user" o{--}o "session" : "auth_session" - "user" o{--}o "key" : "key" - "user" o{--}o "taskanswer" : "taskAnswer" - "user" o{--}o "workbook" : "workBooks" + "user" o{--}o "session" : "" + "user" o{--}o "key" : "" + "user" o{--}o "taskanswer" : "" + "user" o{--}o "workbook" : "" "session" o|--|| "user" : "user" "key" o|--|| "user" : "user" "task" o|--|| "ContestType" : "enum:contest_type" "task" o|--|| "TaskGrade" : "enum:grade" "task" o|--|| "AtcoderProblemsDifficulty" : "enum:atcoder_problems_difficulty" - "task" o{--}o "tasktag" : "tags" - "task" o{--}o "taskanswer" : "task_answers" - "task" o{--}o "workbooktask" : "workBookTasks" - "tag" o{--}o "tasktag" : "tasks" + "task" o{--}o "tasktag" : "" + "task" o{--}o "taskanswer" : "" + "task" o{--}o "workbooktask" : "" + "tag" o{--}o "tasktag" : "" "tasktag" o|--|o "task" : "task" "tasktag" o|--|o "tag" : "tag" "taskanswer" o|--|o "task" : "task" "taskanswer" o|--|o "user" : "user" "taskanswer" o|--|o "submissionstatus" : "status" - "submissionstatus" o{--}o "taskanswer" : "task_answer" "workbook" o|--|| "WorkBookType" : "enum:workBookType" "workbook" o|--|| "user" : "user" - "workbook" o{--}o "workbooktask" : "workBookTasks" + "workbook" o{--}o "workbooktask" : "" "workbooktask" o|--|| "workbook" : "workBook" "workbooktask" o|--|| "task" : "task" ``` diff --git a/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql b/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql new file mode 100644 index 000000000..b20234887 --- /dev/null +++ b/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "public"."contesttaskpair" ( + "id" TEXT NOT NULL, + "contestId" TEXT NOT NULL, + "taskTableIndex" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contesttaskpair_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "contesttaskpair_contestId_idx" ON "public"."contesttaskpair"("contestId"); + +-- CreateIndex +CREATE UNIQUE INDEX "contesttaskpair_contestId_taskId_key" ON "public"."contesttaskpair"("contestId", "taskId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0b910c6ad..56a68ec95 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,9 +5,8 @@ // https://www.prisma.io/docs/concepts/components/prisma-schema/generators // https://www.prisma.io/docs/orm/prisma-client/deployment/edge/deploy-to-vercel generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "rhel-openssl-3.0.x"] - previewFeatures = ["driverAdapters"] + provider = "prisma-client-js" + binaryTargets = ["native", "rhel-openssl-3.0.x"] } // See: @@ -109,6 +108,20 @@ model Task { @@map("task") } +// Handling cases where the same problem is used in different contests. +model ContestTaskPair { + id String @id @default(uuid()) + contestId String + taskTableIndex String + taskId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([contestId, taskId]) // Prevent duplicate pairs of (contestId, taskId) + @@index([contestId]) // Add index on contestId to speed up queries filtering by contestId + @@map("contesttaskpair") +} + model Tag { id String @id @unique is_published Boolean @default(false) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts new file mode 100644 index 000000000..c9c23a7e9 --- /dev/null +++ b/src/lib/services/contest_task_pairs.ts @@ -0,0 +1,126 @@ +import { Prisma } from '@prisma/client'; + +import { default as db } from '$lib/server/database'; + +import type { + ContestTaskPair, + ContestTaskPairs, + ContestTaskPairCreate, + ContestTaskPairUpdate, +} from '$lib/types/contest_task_pair'; + +/** + * Retrieves all ContestTaskPair records from the database. + * + * @returns An array of ContestTaskPair objects. + */ +export async function getContestTaskPairs(): Promise { + return await db.contestTaskPair.findMany(); +} + +/** + * Retrieves a ContestTaskPair record by contestId and taskId. + * + * @param contestId: The ID of the contest. + * @param taskId: The ID of the task. + * + * @returns The ContestTaskPair if found, otherwise null. + */ +export async function getContestTaskPair( + contestId: string, + taskId: string, +): Promise { + return await db.contestTaskPair.findUnique({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + }); +} + +/** + * Creates a new ContestTaskPair record in the database. + * + * @param params - The parameters for creating a ContestTaskPair. + * + * @returns The created ContestTaskPair object or the existing one if it already exists. + * + * @throws Will throw an error if the creation fails. + */ +export async function createContestTaskPair( + params: ContestTaskPairCreate, +): Promise { + const { contestId, taskTableIndex, taskId } = params; + + try { + const contestTaskPair = await db.contestTaskPair.create({ + data: { + contestId, + taskTableIndex, + taskId, + }, + }); + + console.log('Created ContestTaskPair:', contestTaskPair); + + return contestTaskPair; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + const existingPair = await getContestTaskPair(contestId, taskId); + + if (!existingPair) { + throw new Error('Unexpected: record exists but cannot be fetched'); + } + + return existingPair; + } + + console.error('Failed to create ContestTaskPair:', error); + throw error; + } +} + +/** + * Updates an existing ContestTaskPair record in the database. + * + * @param params - The parameters for updating a ContestTaskPair. + * + * @returns The updated ContestTaskPair object. + * + * @throws Will throw an error if the update fails or if the record does not exist. + */ +export async function updateContestTaskPair( + params: ContestTaskPairUpdate, +): Promise { + const { contestId, taskTableIndex, taskId } = params; + + try { + const updatedContestTaskPair = await db.contestTaskPair.update({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + data: { + taskTableIndex, + }, + }); + + console.log('Updated ContestTaskPair:', updatedContestTaskPair); + + return updatedContestTaskPair; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; + console.error(errorMessage); + throw new Error(errorMessage, { cause: error as Error }); + } + + console.error('Failed to update ContestTaskPair:', error); + throw error; + } +} diff --git a/src/lib/types/contest_task_pair.ts b/src/lib/types/contest_task_pair.ts new file mode 100644 index 000000000..50ca5f4b0 --- /dev/null +++ b/src/lib/types/contest_task_pair.ts @@ -0,0 +1,23 @@ +import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; + +import type { TaskResult } from '$lib/types/task'; + +export type ContestTaskPair = ContestTaskPairOrigin; + +export type ContestTaskPairs = ContestTaskPair[]; + +// For CRUD operation parameter types +export type ContestTaskPairRead = ContestTaskPair; + +export type ContestTaskPairCreate = { + contestId: string; + taskTableIndex: string; + taskId: string; +}; + +export type ContestTaskPairUpdate = ContestTaskPairCreate; + +// For mapping and identification +export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" + +export type TaskResultMapByContestTaskPair = Map;