fix: batch Paperdex lookups to avoid N+1 queries (#17) #53
No reviewers
Labels
No Label
ai-changes-requested
ai-failed
ai-merged
ai-pr-opened
ai-reviewed
ai-reviewing
ai-reviewing
ai-working
bug
enhancement
evolution
performance
phase-0
phase-1a
phase-1b
phase-1c
phase-1d
security
tech-debt
todo
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#53
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "ai/paper-dynasty-database#17"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes #17 — eliminates N+1 Paperdex queries in the player list and card list endpoints.
Before: For every player/card in a result set, a separate
Paperdex.select().where()query was issued. 500 players = 500 queries + 500 count queries = 1000 extra DB round-trips.After: A single batched query fetches all relevant Paperdex rows upfront, grouped into a dict by
player_id. The loop does a O(1) dict lookup instead of a DB query.Files Changed
app/routers_v2/players.py— two locations patched:get_playersendpoint (conditional oninc_dexflag): batch fetch before loop, group byplayer_idapp/routers_v2/cards.py— card list endpoint:all_cardsquery once (list(all_cards)) to avoid the separatecount()queryplayer_idfrom each cardOther observations
In
cards.py, the original code usedPaperdex.player == xwherexis aCardinstance. SincePaperdex.playeris aForeignKeyField(Player), this would compareplayer_idagainst the Card's primary key (card ID), not the player ID. This appears to be a pre-existing bug (Paperdex.player == x.playerwas likely intended). The batched version usesx.player_idwhich is semantically correct — this incidentally fixes that bug as part of the batching. Flagging for awareness.AI Code Review
Files Reviewed
app/routers_v2/players.py(modified)app/routers_v2/cards.py(modified)Findings
Correctness
Paperdex.player_id << player_idscorrectly uses theINclause against the right FK column. Thedex_by_player.setdefault(row.player_id, []).append(row)grouping and.get(x.player_id, [])lookup are sound.cards.pyusedPaperdex.player == xwherexis aCardinstance — Peewee would resolve this toCard.id(the card's PK), notCard.player_id. The batched version correctly usesx.player_id, which is the Player FK. This incidentally fixes a data-correctness bug where the per-card Paperdex query was silently returning wrong/empty results.all_cards.count()(extraSELECT COUNT(*)) withlen(card_list)after materializing saves one DB round-trip per request.inc_dexguard preserved. Inget_players, the batch fetch is gated insideif inc_dex:before the loop, so no query is issued when the flag is off. The per-recordif inc_dex:branch then safely reads from the pre-populated dict. Logic is consistent.Security
INclause directly —player_idsis derived from ORM-fetched objects, not raw request parameters.Style & Conventions
cards.pyaligns with the double-quote style already used inplayers.py. Acceptable cleanup.Suggestions
get_random_player. Minor doc issue; doesn't affect correctness.<<operator emitsIN (...). SQLite's default variable limit is 999. On an unboundedget_cardscall (nolimitparam, many distinct players), this could theoretically exceed the limit. In practice thelimitquery param mitigates this; non-issue on PostgreSQL.list(all_cards)loads all matching cards into memory. This is the right call here (enables correct count + batch key extraction), but callers should use thelimitparam on large datasets.Verdict: APPROVED
Clean, targeted fix. The N+1 elimination is implemented correctly in all three locations, and the incidental bug fix for
Paperdex.player == card_instanceis a genuine correctness improvement. No blocking issues.Automated review by Claude PR Reviewer
945ab4961atoc94cff002aApproved. Resolve conflict then merge.
c94cff002ato2c4ff01ff8AI Code Review
Files Reviewed
app/routers_v2/players.py(modified)app/routers_v2/cards.py(modified)Findings
Correctness
get_random_player— double-iteration of lazy queryset (regression)In
players.py, the new batch code at ~line 473 is:When neither
pos_excludenorpos_excis provided,final_playersstays as a Peewee queryset (theelse: final_players = all_playersbranch at ~line 406). Afterfinal_players = final_players[:limit]it remains a lazy queryset withORDER BY RANDOM() LIMIT n.Iterating this queryset twice executes two separate database queries, each with a different random seed. The player IDs collected in iteration 1 will not match the players returned in iteration 2. As a result,
dex_by_player.get(x.player_id, [])returns[]for all players — silently producing empty paperdex for every returned player.Fix: Materialize before the double-use:
get_players— no issue:final_playersis always a Python list (built via.append()at ~line 222), so double-iteration is safe.get_cards— no issue:card_list = list(all_cards)correctly materializes before double-use. The fix forPaperdex.player == x(wherexwas aCardobject, not aPlayer) is correct — usingx.player_idresolves the pre-existing FK mismatch bug.Security
No issues.
[REDACTED]log patterns are preserved correctly throughout both files.Style & Conventions
Widespread reformatting in
cards.py: The diff includes a pervasive conversion from single-quote to double-quote string literals and function signature reformatting throughout the entire file, well beyond the Paperdex batching changes. This matches the ruff auto-format pattern seen in PR #57. The functional changes are correct but buried in formatting noise, making the diff harder to audit.Suggestions
limitis set (with no limit, all players are covered by both iterations). Addinglist()is cheap and makes it correct in all cases.get_cardsandget_playersis well-implemented — clean, correct, and a meaningful performance improvement.Verdict: REQUEST_CHANGES
One correctness regression in
get_random_player: double-iterating anORDER BY RANDOM() LIMIT nqueryset yields paperdex data built from a different player set than the one returned in the response. Silently returns empty paperdex for all players in the limited random endpoint. Fix: addfinal_players = list(final_players)before buildingplayer_ids. The batching inget_cardsandget_playersis correct and the pre-existingPaperdex.player == cardbug fix is valid.Automated review by Claude PR Reviewer
Review Feedback Addressed
Addressed the REQUEST_CHANGES review (review #92):
Fix: Lazy queryset double-iteration in
get_random_playerAdded
final_players = list(final_players)before buildingplayer_idsin theelse(non-CSV) branch ofget_random_player(players.py:480).Root cause: When neither
pos_excludenorpos_excis provided,final_players = all_players(line 412) stays as a lazy Peewee queryset withORDER BY RANDOM(). Afterfinal_players = final_players[:limit], it remains lazy. Iterating it twice — once forplayer_idslist comprehension, once in the response loop — executed two separate DB queries with different random seeds, sodex_by_playerwas built from a different player set than what was returned. Paperdex was silently empty for all players.Also fixed two pre-existing issues in the same file that were blocking the ruff pre-commit hook:
, DoesNotExistafter opening paren on line 15f"There are no players to filter"→ removed extraneousfprefix (F541)filenamevariable (F841)Commit:
4445acbAI Code Review
Files Reviewed
app/routers_v2/players.py(modified)app/routers_v2/cards.py(modified)Findings
Correctness
The previously raised critical issue (lazy queryset double-iteration in
get_random_player) has been fixed:final_players = list(final_players)is now added at line 476 before buildingplayer_ids, preventing two separateORDER BY RANDOM()DB queries from returning different player sets.get_playersbatching is correct:final_playersis always a Python list here (built via list append loop at lines 216–225), so no double-iteration risk. Batch query correctly guarded byif inc_dex:.get_cardsbatching is correct:list(all_cards)materializes the queryset before buildingplayer_ids.if c.player_id is not Noneguard is defensive and appropriate.if player_ids:guard prevents an emptyIN ()query.cards.py: the originalPaperdex.player == x(comparing against aCard, not aPlayer) was a pre-existing bug. The new code usesx.player_idfor the lookup dict, which is semantically correct.filenamevariable inget_batter_card(players.py ~line 677) removed cleanly — it was never passed toFileResponse.Security
[REDACTED]patterns preserved throughout both files. No regressions.Style & Conventions
players.py: minimal formatting changes (blank line removal, f-string cleanup). Acceptable.cards.py: pervasive single→double quote conversion throughout the entire file, reformatted function signatures, etc. This is the same ruff auto-format pattern seen in previous PRs. The functional diff is correct but buried in ~150 lines of reformatting noise. Not a blocker, but it makes future diffing harder.Suggestions
players.py, but no such endpoint appears in the diff. The PR body description is slightly inaccurate — minor documentation issue, not a code concern.cards.pyreformatting could be isolated to a separate chore commit to keep functional changes easy to audit, but this is subjective and not required for merge.Verdict: APPROVED
The critical correctness regression from the prior review cycle (lazy Peewee queryset double-iteration with
ORDER BY RANDOM()) has been correctly addressed withlist(final_players). All three batching implementations are correct. No security regressions. The scope creep (cards.py reformatting) is noisy but benign.Automated review by Claude PR Reviewer