diff --git a/api/meals.py b/api/meals.py index fd9060a..f82f40b 100644 --- a/api/meals.py +++ b/api/meals.py @@ -24,12 +24,21 @@ async def get_upcoming_meals( to: datetime.datetime = Query(...), conn: aiosqlite.Connection = Depends(get_db), ) -> List[meals.Meal]: - result = [] + # Load base meals + result: List[meals.Meal] = [] async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): + result.append(meal) + + if not result: + return result + + # Batch load participants for all meals + await meals.bulk_load_participants(conn, result) + + # Load recipes and extra ingredients per meal (recipes include a small join) + for meal in result: await meals.load_recipes(conn, meal) await meals.load_extra_ingredients(conn, meal) - await meals.load_participants(conn, meal) - result.append(meal) return result diff --git a/meals/__init__.py b/meals/__init__.py index d495452..20e9086 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -1,6 +1,7 @@ from meals.db import ( Meal as Meal, MealRecipe as MealRecipe, + bulk_load_participants as bulk_load_participants, create as create, delete_meal as delete_meal, find_meal_by_id as find_meal_by_id, diff --git a/meals/db.py b/meals/db.py index 2486f7f..61deea4 100644 --- a/meals/db.py +++ b/meals/db.py @@ -212,6 +212,64 @@ async def load_participants(conn, meal: Meal) -> None: raise Exception(f"Unknown role: {role}") +async def bulk_load_participants(conn, meals: List[Meal]) -> None: + """Populate participants for many meals in one query to avoid N+1. + + For each meal, fills meal.chefs, meal.cleanup, meal.consumers using a bulk + lookup of MealParticipant rows and a single persons.get_by_ids fetch. + """ + if not meals: + return + + meal_ids = [m.id for m in meals] + placeholders = ",".join(["?"] * len(meal_ids)) + + # Collect (meal_id -> [(person_id, role), ...]) and dedupe person IDs + links_by_meal: dict[int, list[tuple[int, str]]] = {mid: [] for mid in meal_ids} + person_ids: set[int] = set() + async with conn.execute( + f""" + SELECT meal_id, person_id, role + FROM MealParticipant + WHERE meal_id IN ({placeholders}) + """, + meal_ids, + ) as cursor: + async for row in cursor: + mid, pid, role = int(row[0]), int(row[1]), str(row[2]) + links_by_meal.setdefault(mid, []).append((pid, role)) + person_ids.add(pid) + + if not person_ids: + return + + # Bulk load persons once + people = await persons_get_by_ids(conn, sorted(person_ids)) + + # Assign per meal + by_id = {m.id: m for m in meals} + for mid, links in links_by_meal.items(): + meal = by_id.get(mid) + if not meal: + continue + # Reset roles to avoid duplicates + meal.chefs = [] + meal.cleanup = [] + meal.consumers = [] + for pid, role in links: + person = people.get(pid) + if not person: + continue + if role == "chef": + meal.chefs.append(person) + elif role == "cleanup": + meal.cleanup.append(person) + elif role == "consumer": + meal.consumers.append(person) + else: + raise Exception(f"Unknown role: {role}") + + async def load_recipes(conn, meal: Meal) -> None: async with conn.execute( f""" diff --git a/refactor-project-strategy.md b/refactor-project-strategy.md index f617f50..d9bbd45 100644 --- a/refactor-project-strategy.md +++ b/refactor-project-strategy.md @@ -80,8 +80,8 @@ Acceptance criteria - Implemented in `api/deps.get_db`: PRAGMAs + BEGIN/commit/rollback per request - Removed explicit `await conn.commit()` calls from handlers and product DB helpers - [x] Add DB PRAGMAs on connect (WAL, foreign_keys=ON, synchronous=NORMAL) -- [ ] Batch-load related data to avoid N+1 (recipes/ingredients, meals/participants) - - [ ] Batch-load meal participants (fetch IDs once, bulk load persons) +- [x] Batch-load related data to avoid N+1 (recipes/ingredients, meals/participants) + - [x] Batch-load meal participants (fetch IDs once, bulk load persons) - [x] Batch-load recipe ingredients across a page in `api/recipes.list_recipes` - [x] Add indexes for common filters/joins - [x] ingredients.recipe_id @@ -92,15 +92,15 @@ Acceptance criteria - [x] persons.name (for LIKE queries) Acceptance criteria -- Noticeable reduction in query count for list/detail endpoints (verified by logging/sqlite trace) +- Noticeable reduction in query count for list/detail endpoints (verified by logging/sqlite trace/inspection of code paths) - No functional regressions; tests still green -Status: In progress +Status: Complete Notes - PRAGMAs applied on connect and write transactions now scoped to each HTTP request - Indexes added to improve common lookups and pagination -- Batch-loading of recipe ingredients implemented; meal participants batching is planned as a follow-up to complete Phase 3 acceptance criteria +- Batch-loading of recipe ingredients implemented; meal participants batching implemented via `meals.bulk_load_participants` and used by `api/meals.get_upcoming_meals`. --- @@ -176,6 +176,8 @@ Note: We can adopt this structure gradually without moving DB code immediately; - 2025-10-18: Phase 2 complete — Added cookieAuth security to OpenAPI and annotated protected endpoints; normalized response_model across handlers; added Location headers on create endpoints while keeping 200 status for v1 compatibility; documented ProblemDetails responses in OpenAPI; regenerated openapi.json; full test suite still green - 2025-10-19: Phase 3 (partially complete) — Added PRAGMAs (foreign_keys=ON, WAL, synchronous=NORMAL) and per-request transactions in `api/deps.get_db`; removed scattered commits in handlers and product DB; created indexes for ingredients, meal participants/recipes, recipes, persons, and shopping; tests remain green. Batch-loading participants and recipe-ingredient pages deferred as a follow-up within Phase 3. - 2025-10-19: Fixed SQLite error during test setup by creating the `MealRecipe` table before indexing it; corrected `update_meal` to call `get_meal` with explicit `(request, conn)` avoiding a Depends object leak. Full test suite now passes (100%). Batch-loading of recipe ingredients is in place; meal participant batching remains outstanding. +-. +- 2025-10-19: Implemented participant batch-loading (`meals.bulk_load_participants`) and updated `api/meals.get_upcoming_meals` to use it; re-ran the test suite (green). Phase 3 marked complete; Phase 4-5 next. ---