fix(csharp): push pagination to database level via ListAsync#406
fix(csharp): push pagination to database level via ListAsync#406
Conversation
Previously GetAllAsync loaded all rows into memory and pagination was applied in the controller with Skip/Take in C#. For large datasets this causes full-table fetches on every list request. Introduce ILampRepository.ListAsync(limit, offset) implemented with EF Core Skip/Take so the LIMIT/OFFSET is emitted in SQL. The controller now requests pageSize+1 rows to detect a next page without a separate COUNT(*) query. GetAllAsync is retained for internal use (parameterless ListLampsAsync). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves the C# LampControl API list endpoint by pushing pagination down into the repository layer so PostgreSQL can apply LIMIT/OFFSET, reducing network transfer and in-memory processing for list requests.
Changes:
- Add
ILampRepository.ListAsync(limit, offset)and implement it for PostgreSQL (EF CoreSkip/Take) and the in-memory repository. - Update
LampControllerImplementation.ListLampsAsync(cursor, pageSize)to use repository paging and computehasMoreby fetchingpageSize + 1rows. - Add unit/integration tests covering repository paging, ordering, and soft-delete behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/csharp/LampControlApi/Services/PostgresLampRepository.cs | Adds DB-level paginated listing via EF Core Skip/Take. |
| src/csharp/LampControlApi/Services/LampControllerImplementation.cs | Switches list endpoint to repository paging and hasMore via pageSize+1. |
| src/csharp/LampControlApi/Services/InMemoryLampRepository.cs | Adds in-memory implementation of ListAsync with ordering and paging. |
| src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs | Extends repository contract with ListAsync(limit, offset). |
| src/csharp/LampControlApi.Tests/Infrastructure/PostgresLampRepositoryTests.cs | Adds integration tests for ListAsync paging/ordering/soft-delete. |
| src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs | Adds unit tests for ListAsync paging/ordering/empty-offset. |
| .OrderBy(l => l.CreatedAt) | ||
| .ThenBy(l => l.Id) | ||
| .Skip(offset) | ||
| .Take(limit) | ||
| .AsNoTracking() |
There was a problem hiding this comment.
limit/offset are used directly in Skip/Take with no validation. Negative values (or overflowed values from callers) can cause EF Core to throw or produce unexpected SQL, and different implementations may behave inconsistently. Add guard clauses to enforce limit >= 0 and offset >= 0 (and consider capping limit), and consider aligning GetAllAsync ordering to CreatedAt + Id to keep list ordering deterministic across methods.
| var page = _lamps.Values | ||
| .OrderBy(l => l.CreatedAt) | ||
| .ThenBy(l => l.Id) | ||
| .Skip(offset) | ||
| .Take(limit) |
There was a problem hiding this comment.
limit/offset are not validated before applying Skip/Take. In LINQ-to-Objects, negative values can behave differently than EF Core (e.g., Skip(-1) effectively acts like Skip(0)), which can hide bugs and diverge from production behavior. Add guard clauses to enforce non-negative inputs (and optionally a max limit) for consistency with PostgresLampRepository.
| /// <param name="limit">Maximum number of lamps to return.</param> | ||
| /// <param name="offset">Number of lamps to skip.</param> |
There was a problem hiding this comment.
The contract for limit/offset doesn’t specify whether negative values are allowed; current implementations may throw (EF Core) or behave differently (LINQ-to-Objects). Document the expected constraints (e.g., limit >= 0, offset >= 0, and whether a max limit applies) so callers and implementations are consistent.
| /// <param name="limit">Maximum number of lamps to return.</param> | |
| /// <param name="offset">Number of lamps to skip.</param> | |
| /// <param name="limit"> | |
| /// Maximum number of lamps to return. Must be greater than or equal to 0. | |
| /// A value of 0 results in an empty collection being returned. Implementations | |
| /// may enforce an upper bound on this value; callers should pass a value within | |
| /// the allowed range for the specific implementation. | |
| /// </param> | |
| /// <param name="offset"> | |
| /// Number of lamps to skip before returning results. Must be greater than or equal to 0. | |
| /// </param> |
| .ToList(); | ||
| // Fetch one extra row to determine whether a next page exists, | ||
| // avoiding a separate COUNT(*) query. | ||
| var entities = await _lampRepository.ListAsync(pageSize + 1, offset); |
There was a problem hiding this comment.
pageSize + 1 can overflow (e.g., pageSize == int.MaxValue), resulting in a negative limit being passed to ListAsync and potentially causing runtime exceptions or incorrect paging. Clamp pageSize to a reasonable maximum and/or use checked arithmetic (or a wider type) before adding 1.
| var entities = await _lampRepository.ListAsync(pageSize + 1, offset); | ||
|
|
||
| var page = lamps.Skip(start).Take(pageSize).ToList(); | ||
| var hasMore = start + pageSize < lamps.Count; | ||
| var nextCursor = hasMore ? (start + pageSize).ToString() : string.Empty; | ||
| var hasMore = entities.Count > pageSize; | ||
| var page = entities.Take(pageSize).Select(LampMapper.ToApiModel).ToList(); | ||
| var nextCursor = hasMore ? (offset + pageSize).ToString() : string.Empty; |
There was a problem hiding this comment.
The new paging/hasMore/nextCursor behavior in ListLampsAsync(cursor, pageSize) is untested. Add unit tests that verify (1) stable ordering across pages, (2) HasMore toggles correctly when requesting pageSize+1, and (3) NextCursor matches the returned page size/offset.
| // Fetch all lamps (no pagination) via the repository, then map to API models. | ||
| var entities = await _lampRepository.GetAllAsync(); |
There was a problem hiding this comment.
ListLampsAsync() now returns GetAllAsync() results without enforcing any ordering. This is nondeterministic for InMemoryLampRepository (dictionary value order) and can diverge from the paginated endpoint’s ordering; consider sorting here (e.g., by CreatedAt then Id) or reusing ListAsync with a deterministic order.
| // Fetch all lamps (no pagination) via the repository, then map to API models. | |
| var entities = await _lampRepository.GetAllAsync(); | |
| // Fetch all lamps (no pagination) using the same deterministic ordering as the paginated endpoint, | |
| // then map to API models. | |
| var entities = await _lampRepository.ListAsync(int.MaxValue, 0); |
- Add ArgumentOutOfRangeException guards for limit < 0 and offset < 0 in both PostgresLampRepository and InMemoryLampRepository so behavior is consistent across implementations - Expand ILampRepository XML docs to document limit/offset constraints and the ArgumentOutOfRangeException contract - Cap pageSize at MaxPageSize (1000) in the controller before adding +1, eliminating any overflow risk; extract DefaultPageSize constant (25) - Fix parameterless ListLampsAsync() to use ListAsync(int.MaxValue, 0) so ordering matches the paginated endpoint (deterministic for in-memory) - Add controller unit tests: first page HasMore=true, last page HasMore=false, cursor offset propagation, default page size fallback, and MaxPageSize clamping; update existing mocks from GetAllAsync to ListAsync to match the new parameterless overload implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
GetAllAsyncfetched all rows from PostgreSQL (SELECT ... FROM lamps WHERE deleted_at IS NULL ORDER BY created_at) with noLIMIT/OFFSET, thenLampControllerImplementationpaginated the result in application memory using.Skip().Take()— O(n) in both memory and network on every list requestILampRepository.ListAsync(int limit, int offset)implemented inPostgresLampRepositorywith EF Core's.Skip(offset).Take(limit), which translates directly toLIMIT/OFFSETin SQLhasMorewithout COUNT: The controller now requestspageSize + 1rows; if more thanpageSizeare returned, there is a next page — no extraCOUNT(*)query neededGetAllAsyncis retained and used only by the parameterlessListLampsAsync()overloadSQL before
SQL after
Test plan
ILampRepositoryinterface extended withListAsyncPostgresLampRepository.ListAsyncimplemented (EF Core Skip/Take → SQL LIMIT/OFFSET)InMemoryLampRepository.ListAsyncimplemented (ordered Skip/Take in memory)InMemoryLampRepositoryTests)PostgresLampRepositoryTests)dotnet testgreen)dotnet format --verify-no-changespasses,dotnet build0 errors🤖 Generated with Claude Code