fix: add pagination to nested list fields - #138
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
[ObjectType],[QueryType],[MutationType], and[DataLoader]attributes, replacing manually defined types.ParticipantsConnectionandMatchesByBracketConnectionfor connection-based pagination.MatchService,BracketLookupService, etc.) and updated GraphQL server configuration.NodesandTotalCount.