ClientController: split LoadClientAsync into per-collection subqueries

LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.

AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).

Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
  reproducer that adds a second RedirectUri via POST then re-GETs
  the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
  .SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
  InMemory provider, one nav at a time. Pinpointed three
  problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
  on InMemory; kept as a regression net for any future shaper
  regressions on the InMemory provider (not the Postgres path).
This commit is contained in:
Paul Schneider 2026-07-12 01:15:02 +01:00
commit b12c272df7
2 changed files with 230 additions and 0 deletions

View file

@ -287,6 +287,7 @@ public partial class ClientController
private async Task<Client?> LoadClientAsync(int id)
=> await dbContext.Clients
.AsSplitQuery()
.Include(c => c.RedirectUris)
.Include(c => c.PostLogoutRedirectUris)
.Include(c => c.AllowedScopes)