|
| 1 | +# 設計ドキュメント: 結果一覧ページネーション |
| 2 | + |
| 3 | +## 概要 |
| 4 | + |
| 5 | +本設計は、Result_Serverの結果一覧ページ(`/results/`、`/results/confidential`、`/estimated/`)にサーバーサイドページネーションを導入する。現在の実装では`results_loader.py`がディレクトリ内の全JSONファイルを一括読み込みしているため、ファイル数が増加するとレスポンス時間とメモリ使用量が線形に増大する。 |
| 6 | + |
| 7 | +本設計では、ページネーションロジックを`results_loader.py`のローダー関数に組み込み、指定ページに該当するファイルのみをディスクから読み込む「遅延読み込み」方式を採用する。これにより、ファイル数に依存しない一定のレスポンス時間を実現する。 |
| 8 | + |
| 9 | +### 設計判断 |
| 10 | + |
| 11 | +1. **サーバーサイドページネーション**: クライアントサイドではなくサーバーサイドで実装する。理由は、1万件以上のJSONファイルを全てブラウザに送信するのは帯域・メモリの両面で非効率であるため。 |
| 12 | +2. **ファイル名ソートによる遅延読み込み**: `os.listdir()`でファイル名一覧を取得し、ファイル名の逆順ソート(新しい順)後にスライスで対象ページのファイルのみを`json.load()`する。ファイル名にタイムスタンプが含まれているため、ファイル名ソートで時系列順が保証される。 |
| 13 | +3. **フィルタはサーバーサイドに移行**: 現在のクライアントサイドドロップダウンフィルタ(SYSTEM、CODE、Exp)をクエリパラメータとしてサーバーサイドに移行する。フィルタ適用後の結果セットに対してページネーションを行うため。 |
| 14 | +4. **共通Jinja2パーシャル**: ページネーションUIは`_pagination.html`として共通化し、全対象ページで`{% include %}`する。 |
| 15 | + |
| 16 | +## アーキテクチャ |
| 17 | + |
| 18 | +```mermaid |
| 19 | +sequenceDiagram |
| 20 | + participant Browser |
| 21 | + participant Flask Route |
| 22 | + participant results_loader |
| 23 | + participant Filesystem |
| 24 | +
|
| 25 | + Browser->>Flask Route: GET /results/?page=2&per_page=100&system=X |
| 26 | + Flask Route->>results_loader: load_results_table(dir, page=2, per_page=100, system="X") |
| 27 | + results_loader->>Filesystem: os.listdir(dir) → ファイル名一覧 |
| 28 | + results_loader->>results_loader: ファイル名フィルタ(.json)+逆順ソート |
| 29 | + Note over results_loader: フィルタ適用時: 全JSONを読み込みフィルタ後にスライス<br/>フィルタなし時: ファイル名リストをスライスして対象のみ読み込み |
| 30 | + results_loader->>Filesystem: 対象ページのJSONのみ読み込み |
| 31 | + results_loader-->>Flask Route: (rows, columns, pagination_info) |
| 32 | + Flask Route-->>Browser: HTML(テーブル+ページネーションUI) |
| 33 | +``` |
| 34 | + |
| 35 | +### フィルタ適用時の処理フロー |
| 36 | + |
| 37 | +フィルタ(SYSTEM、CODE、Exp)が指定された場合、対象ファイルを特定するためにJSONの中身を確認する必要がある。ただし、全ファイルの全フィールドを読み込むのではなく、以下の最適化を行う: |
| 38 | + |
| 39 | +1. ファイル名一覧を取得・ソート |
| 40 | +2. 各JSONファイルを読み込み、フィルタ条件に一致するかチェック |
| 41 | +3. 一致した結果のみをリストに追加 |
| 42 | +4. リスト全体からスライスでページ分を抽出 |
| 43 | + |
| 44 | +> **注意**: フィルタ適用時は全ファイルの読み込みが必要になるため、フィルタなし時と比較してパフォーマンスが低下する。将来的にはインデックスファイルやキャッシュの導入で改善可能だが、本設計のスコープ外とする。 |
| 45 | +
|
| 46 | +## コンポーネントとインターフェース |
| 47 | + |
| 48 | +### 1. `results_loader.py` — ページネーション対応ローダー |
| 49 | + |
| 50 | +既存の`load_results_table()`と`load_estimated_results_table()`にページネーションパラメータを追加する。 |
| 51 | + |
| 52 | +```python |
| 53 | +def load_results_table( |
| 54 | + directory: str, |
| 55 | + public_only: bool = True, |
| 56 | + session_email: str | None = None, |
| 57 | + authenticated: bool = False, |
| 58 | + affiliations: list[str] | None = None, |
| 59 | + # --- 新規パラメータ --- |
| 60 | + page: int = 1, |
| 61 | + per_page: int = 100, |
| 62 | + filter_system: str | None = None, |
| 63 | + filter_code: str | None = None, |
| 64 | + filter_exp: str | None = None, |
| 65 | +) -> tuple[list[dict], list[tuple], dict]: |
| 66 | + """ |
| 67 | + Returns: |
| 68 | + (rows, columns, pagination_info) |
| 69 | + pagination_info = { |
| 70 | + "page": int, # 現在のページ(1始まり) |
| 71 | + "per_page": int, # 1ページあたりの件数 |
| 72 | + "total": int, # フィルタ適用後の総件数 |
| 73 | + "total_pages": int, # 総ページ数 |
| 74 | + } |
| 75 | + """ |
| 76 | +``` |
| 77 | + |
| 78 | +`load_estimated_results_table()`にも同様のシグネチャ変更を適用する。 |
| 79 | + |
| 80 | +### 2. ページネーション計算ユーティリティ |
| 81 | + |
| 82 | +`results_loader.py`内にヘルパー関数を追加する。 |
| 83 | + |
| 84 | +```python |
| 85 | +def paginate_list(items: list, page: int, per_page: int) -> tuple[list, dict]: |
| 86 | + """ |
| 87 | + リストにページネーションを適用する。 |
| 88 | +
|
| 89 | + Args: |
| 90 | + items: ページネーション対象のリスト |
| 91 | + page: ページ番号(1始まり) |
| 92 | + per_page: 1ページあたりの件数 |
| 93 | +
|
| 94 | + Returns: |
| 95 | + (paginated_items, pagination_info) |
| 96 | + """ |
| 97 | +``` |
| 98 | + |
| 99 | +### 3. Flask ルート — クエリパラメータ処理 |
| 100 | + |
| 101 | +`routes/results.py`と`routes/estimated.py`のルートハンドラでクエリパラメータを受け取り、ローダーに渡す。 |
| 102 | + |
| 103 | +```python |
| 104 | +@results_bp.route("/", strict_slashes=False) |
| 105 | +def results(): |
| 106 | + page = request.args.get("page", 1, type=int) |
| 107 | + per_page = request.args.get("per_page", 100, type=int) |
| 108 | + filter_system = request.args.get("system", None) |
| 109 | + filter_code = request.args.get("code", None) |
| 110 | + filter_exp = request.args.get("exp", None) |
| 111 | + # ... |
| 112 | +``` |
| 113 | + |
| 114 | +`per_page`の値は`[50, 100, 200]`のいずれかに制限し、範囲外の値はデフォルト(100)にフォールバックする。 |
| 115 | + |
| 116 | +### 4. Jinja2テンプレート — `_pagination.html` |
| 117 | + |
| 118 | +新規パーシャルテンプレートとして`_pagination.html`を作成する。テーブルの上部と下部の両方に`{% include "_pagination.html" %}`で挿入する。 |
| 119 | + |
| 120 | +```html |
| 121 | +<!-- templates/_pagination.html --> |
| 122 | +<div class="pagination-controls"> |
| 123 | + <span>Showing {{ pagination.total }} results</span> |
| 124 | + <a href="..." {% if pagination.page == 1 %}class="disabled"{% endif %}>First</a> |
| 125 | + <a href="..." {% if pagination.page == 1 %}class="disabled"{% endif %}>Previous</a> |
| 126 | + <span>Page {{ pagination.page }} of {{ pagination.total_pages }}</span> |
| 127 | + <a href="..." {% if pagination.page == pagination.total_pages %}class="disabled"{% endif %}>Next</a> |
| 128 | + <a href="..." {% if pagination.page == pagination.total_pages %}class="disabled"{% endif %}>Last</a> |
| 129 | + <select onchange="..."><!-- 50 / 100 / 200 --></select> |
| 130 | +</div> |
| 131 | +``` |
| 132 | + |
| 133 | +### 5. フィルタUIの変更 |
| 134 | + |
| 135 | +現在のクライアントサイドフィルタ(JavaScript `applyFilters()`)をサーバーサイドフィルタに変更する。ドロップダウンの`onchange`イベントでクエリパラメータ付きURLにリダイレクトする。 |
| 136 | + |
| 137 | +フィルタ選択肢(SYSTEM、CODE、Expの一覧)は、ローダーが全ファイル名一覧から抽出するか、別途軽量なメタデータ取得関数を用意する。 |
| 138 | + |
| 139 | +```python |
| 140 | +def get_filter_options(directory: str, public_only: bool = True, ...) -> dict: |
| 141 | + """ |
| 142 | + フィルタドロップダウンの選択肢を返す。 |
| 143 | + Returns: {"systems": [...], "codes": [...], "exps": [...]} |
| 144 | + """ |
| 145 | +``` |
| 146 | + |
| 147 | +## データモデル |
| 148 | + |
| 149 | +### pagination_info 辞書 |
| 150 | + |
| 151 | +```python |
| 152 | +pagination_info = { |
| 153 | + "page": int, # 現在のページ番号(1始まり) |
| 154 | + "per_page": int, # 1ページあたりの表示件数 |
| 155 | + "total": int, # フィルタ適用後の総結果件数 |
| 156 | + "total_pages": int, # 総ページ数 = ceil(total / per_page) |
| 157 | +} |
| 158 | +``` |
| 159 | + |
| 160 | +### クエリパラメータ |
| 161 | + |
| 162 | +| パラメータ | 型 | デフォルト | 説明 | |
| 163 | +|---|---|---|---| |
| 164 | +| `page` | int | 1 | 表示ページ番号 | |
| 165 | +| `per_page` | int | 100 | 1ページあたりの件数(50/100/200) | |
| 166 | +| `system` | str | None | SYSTEMフィルタ | |
| 167 | +| `code` | str | None | CODEフィルタ | |
| 168 | +| `exp` | str | None | Expフィルタ | |
| 169 | + |
| 170 | +### 既存データモデルへの影響 |
| 171 | + |
| 172 | +- `rows`(`list[dict]`): 構造変更なし。ページ分のみ返却される。 |
| 173 | +- `columns`(`list[tuple]`): 変更なし。 |
| 174 | +- ルートハンドラの`render_template()`呼び出しに`pagination=pagination_info`を追加。 |
| 175 | + |
| 176 | + |
| 177 | +## 正確性プロパティ (Correctness Properties) |
| 178 | + |
| 179 | +*プロパティとは、システムの全ての有効な実行において成立すべき特性や振る舞いのことである。人間が読める仕様と機械的に検証可能な正確性保証の橋渡しとなる形式的な記述である。* |
| 180 | + |
| 181 | +### Property 1: ページネーションによるリスト分割の正確性 |
| 182 | + |
| 183 | +*For any* リスト `items` と有効な `per_page` 値(50, 100, 200)に対して、全ページ(1〜total_pages)の結果を結合すると、元のリストと完全に一致する(欠落なし、重複なし、順序保持)。 |
| 184 | + |
| 185 | +**Validates: Requirements 1.1, 7.1, 7.2** |
| 186 | + |
| 187 | +### Property 2: 総ページ数の計算 |
| 188 | + |
| 189 | +*For any* 非負整数 `total` と正整数 `per_page` に対して、`total_pages` は `max(1, ceil(total / per_page))` と等しい。`total` が0の場合は `total_pages` は1となる。 |
| 190 | + |
| 191 | +**Validates: Requirements 5.3, 7.3, 7.4** |
| 192 | + |
| 193 | +### Property 3: 範囲外ページ番号のクランプ |
| 194 | + |
| 195 | +*For any* `total_pages` >= 1 に対して、`page` < 1 の場合は1に、`page` > `total_pages` の場合は `total_pages` にクランプされる。 |
| 196 | + |
| 197 | +**Validates: Requirements 1.4** |
| 198 | + |
| 199 | +### Property 4: フィルタ適用後の結果の正確性 |
| 200 | + |
| 201 | +*For any* 結果リストとフィルタ条件(system, code, exp)の組み合わせに対して、ページネーション後に返却される全ての行は、指定されたフィルタ条件に一致する。 |
| 202 | + |
| 203 | +**Validates: Requirements 4.1** |
| 204 | + |
| 205 | +### Property 5: ナビゲーションボタンの状態 |
| 206 | + |
| 207 | +*For any* 有効な `page` と `total_pages`(1 <= page <= total_pages)に対して、「Previous」ボタンは `page == 1` のとき無効、それ以外で有効。「Next」ボタンは `page == total_pages` のとき無効、それ以外で有効。 |
| 208 | + |
| 209 | +**Validates: Requirements 2.2, 2.3, 2.4, 2.5** |
| 210 | + |
| 211 | +### Property 6: ページネーションUIの必須要素 |
| 212 | + |
| 213 | +*For any* 有効な `pagination_info`(page, per_page, total, total_pages)に対して、レンダリングされたページネーションUIは「Page X of Y」テキスト、「First」リンク、「Last」リンク、およびフィルタ適用後の総件数を含む。 |
| 214 | + |
| 215 | +**Validates: Requirements 2.1, 2.6, 4.4** |
| 216 | + |
| 217 | +### Property 7: ページネーションリンクのフィルタ保持 |
| 218 | + |
| 219 | +*For any* フィルタ条件(system, code, exp)とページ番号の組み合わせに対して、ページネーションリンク(Previous, Next, First, Last)のURLは適用中のフィルタ条件をクエリパラメータとして保持する。 |
| 220 | + |
| 221 | +**Validates: Requirements 4.3** |
| 222 | + |
| 223 | +## エラーハンドリング |
| 224 | + |
| 225 | +### ページ番号の範囲外 |
| 226 | + |
| 227 | +- `page < 1`: ページ1にリダイレクト(302) |
| 228 | +- `page > total_pages`: 最終ページにリダイレクト(302) |
| 229 | +- リダイレクト時はフィルタ条件とper_pageをクエリパラメータに保持 |
| 230 | + |
| 231 | +### per_page の不正値 |
| 232 | + |
| 233 | +- 許可値 `[50, 100, 200]` 以外の値: デフォルト値100にフォールバック |
| 234 | +- 負の値や文字列: Flaskの`request.args.get()`の`type=int`で処理され、デフォルト値が使用される |
| 235 | + |
| 236 | +### ファイルシステムエラー |
| 237 | + |
| 238 | +- `os.listdir()`失敗: 既存の動作を維持(Flaskの500エラー) |
| 239 | +- 個別JSONファイルの読み込み失敗: 既存の`load_json_with_confidential_filter()`が`None`を返し、スキップされる(変更なし) |
| 240 | + |
| 241 | +### 結果0件 |
| 242 | + |
| 243 | +- `total_pages`を1として返却 |
| 244 | +- 空のテーブルとページネーションUI(「Page 1 of 1」、全ボタン無効)を表示 |
| 245 | + |
| 246 | +## テスト戦略 |
| 247 | + |
| 248 | +### テストフレームワーク |
| 249 | + |
| 250 | +- **ユニットテスト**: pytest(既存のテストスイートと同じ) |
| 251 | +- **プロパティベーステスト**: hypothesis(既存の`.hypothesis/`ディレクトリが存在し、プロジェクトで使用済み) |
| 252 | + |
| 253 | +### ユニットテスト |
| 254 | + |
| 255 | +具体的な例とエッジケースを検証する: |
| 256 | + |
| 257 | +- `paginate_list()`にデフォルトパラメータ(page=1, per_page=100)を渡した場合の動作 |
| 258 | +- 結果0件の場合に`total_pages=1`が返却される |
| 259 | +- `per_page`の不正値(例: 75)がデフォルト100にフォールバックする |
| 260 | +- 各ページ(`/results/`, `/results/confidential`, `/estimated/`)にページネーションUIが存在する |
| 261 | +- ページネーションUIがテーブルの上部と下部の両方に表示される |
| 262 | +- フィルタ変更時にページが1にリセットされる |
| 263 | +- 表示件数セレクタに50, 100, 200の選択肢がある |
| 264 | +- 既存機能(キーワード検索、Compare、認証制御)が維持される |
| 265 | + |
| 266 | +### プロパティベーステスト |
| 267 | + |
| 268 | +各プロパティテストは最低100回のイテレーションで実行する。各テストにはデザインドキュメントのプロパティ番号をタグとして付与する。 |
| 269 | + |
| 270 | +タグ形式: **Feature: results-pagination, Property {number}: {property_text}** |
| 271 | + |
| 272 | +- **Property 1**: ランダムなリストとper_page値を生成し、全ページの結合が元リストと一致することを検証 |
| 273 | +- **Property 2**: ランダムなtotalとper_page値を生成し、total_pagesの計算が`max(1, ceil(total/per_page))`と一致することを検証 |
| 274 | +- **Property 3**: ランダムな範囲外ページ番号を生成し、クランプ後の値が有効範囲内であることを検証 |
| 275 | +- **Property 4**: ランダムな結果リストとフィルタ条件を生成し、返却された全行がフィルタ条件に一致することを検証 |
| 276 | +- **Property 5**: ランダムなpage/total_pagesの組み合わせを生成し、ボタン状態が正しいことを検証 |
| 277 | +- **Property 6**: ランダムなpagination_infoを生成し、レンダリング結果に必須要素が含まれることを検証 |
| 278 | +- **Property 7**: ランダムなフィルタ条件とページ番号を生成し、ページネーションリンクにフィルタパラメータが保持されることを検証 |
| 279 | + |
| 280 | +### テスト構成 |
| 281 | + |
| 282 | +``` |
| 283 | +result_server/tests/ |
| 284 | +├── test_results_loader.py # 既存テスト(変更なし) |
| 285 | +├── test_pagination.py # ページネーション ユニットテスト |
| 286 | +└── test_pagination_properties.py # ページネーション プロパティテスト |
| 287 | +``` |
| 288 | + |
| 289 | +各プロパティテストは1つのプロパティに対して1つのテスト関数で実装する。hypothesisの`@given`デコレータと`@settings(max_examples=100)`を使用する。 |
0 commit comments