Skip to content

fix: add pagination to nested list fields - #138

Merged
Dejmenek merged 11 commits into
mainfrom
fix/116-add-pagination-to-nested-list-fields
Sep 12, 2026
Merged

Dejmenek merged 11 commits into
mainfrom
fix/116-add-pagination-to-nested-list-fields

Conversation

@Dejmenek

Copy link
Copy Markdown
Owner

Summary

This PR refactors the GraphQL API to adopt modern best practices, improve performance, and simplify the codebase. It introduces connection-based pagination, data loaders, and modular schema configuration.

Changes

  • GraphQL schema and resolvers: Transitioned to declarative [ObjectType], [QueryType], [MutationType], and [DataLoader] attributes, replacing manually defined types.
  • ResponseModels.cs: Added ParticipantsConnection and MatchesByBracketConnection for connection-based pagination.
  • BracketDataLoaders.cs, MatchDataLoaders.cs, TournamentDataLoaders.cs: Introduced data loaders for batching and caching database queries.
  • Program.cs: Registered new services (MatchService, BracketLookupService, etc.) and updated GraphQL server configuration.
  • Integration tests: Updated to reflect schema changes, including assertions for Nodes and TotalCount.

Dejmenek and others added 11 commits September 11, 2026 20:06
…lyzers

Adds the source-generator and GreenDonut packages needed for the
upcoming HotChocolate v16 attribute-based root types, DataLoader
group discovery, and implementation-first object types. Also enables
EmitCompilerGeneratedFiles for easier inspection of generated code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
…ttern

Replace the old [ExtendObjectType(typeof(Query/Mutation))] extension
pattern with [QueryType]/[MutationType] on each feature's query and
mutation classes, discovered automatically via the
HotChocolate.Types.Analyzers source generator instead of the manual
.AddQueryType<Query>().AddTypeExtension<X>() chain.

- Delete the now-unused Query/Mutation root stub classes.
- Delete MatchQueries.GetMatchesForRound, which read Bracket.Matches
  directly via the old extension pattern; matches are reached through
  the paginated Bracket.matchesByBracket field going forward.
- Rework TournamentQueries.GetTournaments/GetTournamentById off the
  legacy UsePaging/UseProjection middleware onto UseConnection with
  GreenDonut's QueryContext<T>/PagingArguments pipeline, matching the
  pattern the rest of the schema now uses.
- Wire AddTournamentApiTypes()/AddTournamentApiDataLoaders() (source-
  generator discovery) and AddPagingArguments() into the GraphQL
  server setup, and register the new per-feature lookup/DataLoader
  services in DI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
…on-first GraphQL types

Fixes matchesByBracket, which used .UsePaging() (legacy middleware)
while its resolver body read PagingArguments/QueryContext<Match> -
arguments only the newer UseConnection middleware actually registers
- so the field always errored.

- Replace code-first BracketType/MatchType with [ObjectType<T>]
  resolver classes (BracketResolvers, MatchResolvers). Cross-entity
  navigation (Bracket.Matches, Match.Player1/Player2/Winner) is now
  resolved via explicit [Parent]-based methods instead of implicit
  bound properties, backed by DataLoaders instead of relying on
  projection push-down through nested QueryContext<T> queries.
- matchesByBracket is now a working UseConnection/UseFiltering/
  UseSorting field, batched per-bracket via MatchDataLoaders and
  exposed through MatchService.
- Add ApplicationUserDataLoaders/ApplicationUserService, a shared
  by-Id batching loader for ApplicationUser reused by Match's player
  fields now and by Tournament/TournamentParticipant in later
  commits.
- Hide IsDeleted/Version and cross-entity nav properties on Bracket
  and Match via [GraphQLIgnore] directly on the entity.
- Update the matchesByBracket query examples, response DTOs, and the
  two integration tests that queried a flat, non-paginated bracket.matches
  shape to use the connection-shaped field instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
…phQL type

- Replace code-first TournamentType with [ObjectType<Tournament>]
  resolver class (TournamentResolvers): isActive as a plain resolver
  method, owner/bracket/participants as explicit [Parent]-based
  resolvers instead of implicit bound properties.
- owner reuses the ApplicationUserService/ApplicationUserDataLoaders
  introduced for Match; delete the now-superseded
  OwnerByTournamentIdDataLoader, which the old code-first Owner field
  used instead.
- bracket is served by a new BracketDataLoaders/BracketLookupService,
  batched by TournamentId.
- participants is upgraded from an unpaginated bound list to a
  UseConnection/UseFiltering/UseSorting field via new
  ParticipantsDataLoaders/ParticipantsService, since MaxParticipants
  has no enforced upper bound.
- Hide IsDeleted, the IsActive(DateTime) helper, and cross-entity nav
  properties via [GraphQLIgnore] directly on the entity; StartDate/
  Status get [IsProjected(true)] so isActive's read of them stays
  populated regardless of what the client selects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
…on-first GraphQL type

- Replace code-first TournamentParticipantType with
  [ObjectType<TournamentParticipant>] resolver class
  (TournamentParticipantResolvers): participant/tournament as
  explicit [Parent]-based single-entity resolvers instead of
  implicit bound properties, so no filtering/sorting config applies
  to them.
- participant reuses the existing ApplicationUserService; tournament
  is served by a new TournamentDataLoaders/TournamentLookupService
  (a Tournament-by-Id batching loader, which didn't exist yet -
  TournamentQueries.GetTournamentById only did a direct IQueryable
  read, not a batching loader).
- Hide Tournament, Participant, and IsDeleted via [GraphQLIgnore]
  directly on the entity. SlotNumber stays implicit, so it's now
  exposed on the schema for the first time (harmless, no sensitive
  data).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
…hQL type

- Replace code-first ApplicationUserType with
  [ObjectType<ApplicationUser>] resolver class
  (ApplicationUserResolvers). Its Configure hook now only handles
  what's inherited from the third-party IdentityUser<int> base
  (UserName, PasswordHash, SecurityStamp, lockout fields, etc. -
  13 members ignored, plus Id's projection), since those can't be
  attributed directly. Owned members use attributes instead:
  [GraphQLIgnore] directly on the 5 nav collections
  (ParticipatedTournaments, OwnedTournaments, MatchesAsPlayer1/2,
  MatchesWon), [IsProjected(true)] directly on IsEmailPublic.
- Email keeps its original privacy-filtered logic (public, or the
  viewer's own account) verbatim, now with
  [BindMember(nameof(ApplicationUser.Email))] since HotChocolate v16
  no longer auto-projects a custom resolver's backing member.
- This was the last manual .AddType<X>() registration; all 5 GraphQL
  object types are now discovered purely via the source generator.
- Delete UserFilterInputType/UserSortInputType/
  TournamentFilterInputType/TournamentSortInputType: these lost their
  only call sites once the Owner/Participant/Tournament single-entity
  fields across the last few commits became plain resolver methods,
  which don't support filtering/sorting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
Tournament.participants became a UseConnection field when Tournament
was migrated to an implementation-first GraphQL type, but its query
examples and response DTOs were never updated to match (still a flat
list).

- Add ParticipantsConnection/ParticipantEdge to ResponseModels.cs,
  mirroring the existing MatchesByBracketConnection/MatchEdge pair;
  change TournamentNode.Participants to the connection type.
- Update the participants {...} fragments in
  Shared/QueryExamples/TournamentQueries.cs and
  Shared/MutationExamples/ParticipantMutations.cs to the paginated
  edges/node shape.
- Rewrite Shared/QueryExamples/MatchQueries.cs: its matchesForRound
  root field was deleted as dead code in an earlier commit this
  session (superseded by matchesByBracket), so its examples now reach
  matches via tournamentById { bracket { matchesByBracket(where: {
  round: { eq: $roundNumber } }) { ... } } } instead, keeping the same
  variable names so calling tests don't need their inputs changed.
  Delete the now-unused MatchesForRoundResponse DTO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
Rewrite MatchQueryTests.cs to assert through tournamentById { bracket
{ matchesByBracket } } (via the rewritten Shared/QueryExamples query)
instead of the deleted matchesForRound field, iterating .Nodes for
the player-details case.

Update TournamentQueryTests.cs and ParticipantMutationTests.cs
assertions that read Tournament.Participants as a flat list/Count to
use the connection's Nodes/TotalCount instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
The email field didn't exist on the schema at all - every query for
it (GetMe, owner/participant/player lookups, UpdateEmailVisibility)
errored with "email cannot be found in ApplicationUser".

[BindMember(nameof(ApplicationUser.Email))] on GetEmail was the wrong
tool: per HotChocolate's docs, BindMember binds a resolver to a
differently-named backing member for projection purposes (e.g. a
"brand" field backed by a BrandId property) - it doesn't establish an
override relationship for a property that shares the resolver's own
derived field name. Since Email was never ignored, the property and
the GetEmail resolver both tried to define the same "email" field,
and the schema silently dropped it.

Fix: ignore the inherited Email property directly in the Configure
hook (it can't be attributed, since it lives in the third-party
IdentityUser<int> base) and drop BindMember, matching the exact
pattern already proven for Owner/Bracket/Player1/Player2/Winner in
earlier commits this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
Both fields declared UseConnection without IncludeTotalCount = true
(unlike root GetTournaments, which sets it explicitly), so the schema
exposed a non-nullable totalCount that never got populated. Any query
requesting it failed with HC0018 "Cannot return null for non-nullable
field."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
Cost analysis defaults to MaxFieldCost = 1000, which was never
explicitly configured and rejected legitimate nested-pagination
queries, e.g. tournaments(first:10) { participants(first:10) { ... }
} with "The maximum allowed field cost was exceeded." Measured the
actual cost of that query via the GraphQL-Cost: validate header
(fieldCost 2341, typeCost 332 - well under the default) and set
MaxFieldCost = 3000, giving that shape headroom without leaving the
ceiling wide open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155M2nJpRpqNymq9T438EXb
@Dejmenek Dejmenek linked an issue Sep 12, 2026 that may be closed by this pull request
@Dejmenek
Dejmenek merged commit a0abcd3 into main Sep 12, 2026
3 checks passed
@Dejmenek
Dejmenek deleted the fix/116-add-pagination-to-nested-list-fields branch September 12, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add pagination to nested list fields

1 participant