Skip to content

Fix duplicate key crash when a service resolver builds an object from context members - #553

Merged
lukemurray merged 2 commits into
EntityGraphQL:mainfrom
Ko-Lin:fix/expression-replacer-duplicate-extracted-expression
Sep 8, 2026
Merged

lukemurray merged 2 commits into
EntityGraphQL:mainfrom
Ko-Lin:fix/expression-replacer-duplicate-extracted-expression

Conversation

@Ko-Lin

@Ko-Lin Ko-Lin commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The bug

A service field whose resolver builds an object from two or more context members — as an argument to the service call — fails at compile time when that field is selected beneath another service field:

System.ArgumentException: An item with the same key has already been added.
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at EntityGraphQL.Compiler.Util.ExpressionReplacer..ctor(...)
   at EntityGraphQL.Compiler.BaseGraphQLField.ReplaceContext(...)

Surfaced to the caller as Field 'projects' - An item with the same key has already been added. Nothing is queried — the request fails during compilation. Originally hit on 6.2.1 in a production schema; reproduces on master.

Being below another service field is the precondition, not incidental: it is the only route that reaches the throwing line. Where the inner field also has a bulk resolver, EntityGraphQL logs Bulk resolver for field '…' was not used - it is selected below another service field and falls back to the per-item resolver, which then throws.

Root cause

Walking ExpressionExtractor over (user, srv) => srv.Get(new Project { Id = user.Id, Name = user.Field2 }):

  1. VisitMethodCall pushes the call, visits node.Object (srv), finds nothing tied to the root context, pops.
  2. ProcessPotentialLeaf gets the argument. It is a construction, not a member access, so the construction itself is pushed onto currentExpression and visited.
  3. There is no VisitNew/VisitMemberInit override, so the base visitor descends into the constructor arguments or member bindings with the construction still on top of the stack.
  4. VisitMember(user.Id) declines to push — the stack is non-empty, and IsNullableType() is false for int (it only means Nullable<T>) — so when VisitParameter recognises the root context it records currentExpression.Peek(): the construction, not the member.
  5. user.Field2 takes the same path and derives the same name from the same construction's ToString(), so the extractor appends the identical node instance to the existing list, which is now [node A, node A].
  6. That becomes one GraphQLExtractedField on ExtractedFieldsFromServices, built once at schema-build time.
  7. Below another service field the bulk path is unavailable, so BaseGraphQLField.ReplaceContext builds an ExpressionReplacer, whose constructor called Add(exp, field) per expression. Expression does not override equality, so the second add of the same instance is a duplicate key.

ExpressionExtractor already documents the repeat as intentional:

We extract all expression - which may repeat - and we then replace them by matching the expression object

The replacer was the side that did not tolerate it.

The fix

One line — Add(exp, field) → [exp] = field. The replacer only ever reads this dictionary by identity, so a repeated entry is redundant rather than lost information.

Fixing it here rather than in the extractor matches the extractor's documented contract and has the smaller blast radius. A VisitNew/VisitMemberInit override in the extractor would change what every construction extracts, including the bulk key selectors and resolver bodies that work correctly today. Happy to take that route instead if you would prefer it.

Tests

Two, each in the file that owns its layer, both built from fixtures already in the suite (TestDataContext, UserService, ConfigService, Project, ProjectConfig) — no new types:

  • ServiceFieldTests.TestServiceFieldWithNewInServiceCallArgUnderServiceField — end-to-end through the schema, beside the existing TestCollectionWithServiceUsingNewInExpression. Fails on master, passes with the fix.
  • ExpressionExtractorTests.ExtractObjectBuiltFromMultipleMembersInMethodArg — no schema; pins the extractor behaviour the fix relies on, beside ExtractLongMemberExpressionSameNameInMethod. Passes either way by design, so that a later change to the extractor shows up here rather than quietly making the fix dead code.

Verification

Check Result
End-to-end test on master, unpatched reproduces the ArgumentException
Object initializer vs. a two-arg constructor both crash identically — the trap is the nesting, not the syntax
Both tests with the fix pass on net8.0 and net10.0
Full suite with the fix 1016 passed, 0 failed, 5 skipped on net8.0 and net10.0
TestCollectionWithServiceUsingNewInExpression (construction is the resolver body) unaffected — walked with an empty stack, so each read credits itself

net9.0 is in TargetFrameworks but that runtime is not installed here, so that TFM was not run locally.

Two unrelated notes from setting this up, happy to file separately or fix in this PR if useful:

  • dotnet tool restore fails — .config/dotnet-tools.json names the command dotnet-csharpier, but csharpier 1.0.0 ships it as csharpier.
  • dotnet test on the solution needs -p:NuGetAudit=false: SQLitePCLRaw.lib.e_sqlite3 has an open advisory and EntityGraphQL.EF.Tests / examples/demo promote NuGet warnings to errors.

🤖 Generated with Claude Code

@Ko-Lin
Ko-Lin force-pushed the fix/expression-replacer-duplicate-extracted-expression branch from 7769ef9 to f9e16f6 Compare September 8, 2026 07:54
@Ko-Lin

Ko-Lin commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

The red build here is not from this change — dotnet restore fails on the solution before anything is compiled, and it fails on src/examples/demo/demo.csproj and src/tests/EntityGraphQL.EF.Tests/EntityGraphQL.EF.Tests.csproj, neither of which this PR touches:

demo.csproj : error NU1903: Warning As Error: Package 'SQLitePCLRaw.lib.e_sqlite3' 2.1.11
has a known high severity vulnerability, GHSA-2m69-gcr7-jv3q

Why it started: GHSA-2m69-gcr7-jv3q was published 2025-07-15 but updated 2026-06-18, and its vulnerable range is now <= 2.1.11 — which covers the SQLitePCLRaw.lib.e_sqlite3 that Microsoft.EntityFrameworkCore.Sqlite 10.0.0 pulls in. Master's last green run was 0f1a84c on 2026-04-22, before that update, so the same base commit would fail today with no changes at all.

Note the advisory lists no patched version for any of the three affected packages, so this cannot be resolved by bumping EF Core — the options are an audit suppression, e.g.

<ItemGroup>
  <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-2m69-gcr7-jv3q" />
</ItemGroup>

in the two affected projects (or in a Directory.Build.props), or scoping NuGetAuditMode / relaxing TreatWarningsAsErrors for them. That is your call on risk, so I have deliberately left it out of this PR rather than bundling a security suppression with a compiler fix — happy to send it as a separate PR if you tell me which way you want it, and this PR should go green once it lands.

Locally, with -p:NuGetAudit=false to get past restore, the full suite is 1016 passed / 0 failed / 5 skipped on net8.0 and net10.0 with this fix applied.

… members

ExpressionExtractor credits both context member reads of a nested
construction to the construction node itself, so the same Expression
instance is extracted twice under the one name. Its own comment says that
is expected: "we extract all expression - which may repeat - and we then
replace them by matching the expression object".

ExpressionReplacer's constructor used Dictionary.Add per expression, and
Expression does not override equality, so the second add of that same
instance threw "An item with the same key has already been added". It
fires when the service field is selected below another service field,
where the bulk path is unavailable and ReplaceContext builds an
ExpressionReplacer - nothing is queried, the request fails during
compilation.

Use the indexer instead. The replacer only ever reads this dictionary by
identity, so a repeated entry is redundant rather than lost information,
and fixing it here rather than in the extractor leaves the construction
shapes that extract correctly today untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ko-Lin
Ko-Lin changed the base branch from master to main September 8, 2026 08:19
@Ko-Lin
Ko-Lin force-pushed the fix/expression-replacer-duplicate-extracted-expression branch from f9e16f6 to efdcc84 Compare September 8, 2026 08:19
@Ko-Lin

Ko-Lin commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Retargeted from master to main and rebased — my mistake on the original base. main is the default branch, sits at 6.2.2, and is the line the released 6.2.x packages come from; master was last committed 2026-04-22 at 6.0.0-beta4, so merging there would have fixed a branch that does not ship. The bug is present on both — identical .Add line.

Now based on main @ b02bf07, same 4 files / 61 insertions. The CHANGELOG entry moved to the 6.2.2 → Fixes section and is written in the prose style the recent entries use rather than the older - #NNN - … form.

Re-verified on main:

  • TestServiceFieldWithNewInServiceCallArgUnderServiceField fails on unpatched main, passes with the fix
  • Full solution with the fix: 1337 passed, 0 failed, 4 skipped, identical on net8.0 and net10.0 — EntityGraphQL.Tests 1198, EntityGraphQL.AspNet.Tests 92, EntityGraphQL.EF.Tests 47

One correction to my earlier comment about CI: the NU1903 / SQLitePCLRaw.lib.e_sqlite3 restore failure is a master problem only. main restores cleanly with no audit flag, so that caveat does not apply to this PR any more — please disregard it for review purposes. It does still mean master's CI is red for anything targeting it, if that is useful to know separately.

The replacer fix alone left `srv.Get(new Key(ctx.A, ctx.B))` broken on a plain
service field with nothing above it - it turned "An item with the same key has
already been added" into "Argument types do not match", because the extracted
expression is still the whole construction.

Walk through New/MemberInit in ExpressionExtractor.ProcessPotentialLeaf so each
member read credits itself, which is what the class doc-comment already
describes. Keep the ExpressionReplacer change: the extractor still emits a
repeat for a conditional argument, which hits the identical crash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lukemurray

Copy link
Copy Markdown
Collaborator

Thanks — the fix is right but it doesn't cover the whole bug, so I've pushed a follow-up commit rather than sending it back.

"below another service field" is not the precondition. Same resolver on a plain top-level field:

schema.Type<User>().AddField("config", "c")
      .Resolve<ConfigService>((user, srv) => srv.Get(new Project { Id = user.Id, Name = user.Field2 }));
// { users { id config { type } } }
result
base b02bf07b Field 'users' - An item with the same key has already been added
this PR Field 'users' - Argument types do not match

One crash for another — the extracted expression is still the whole construction, so the second pass can't rebuild the call.

So I took the extractor route as well, in ProcessPotentialLeaf: walk through New/MemberInit instead of pushing them, so each member read credits itself. Blast radius turned out to be nil — with your replacer change reverted, all four projects pass on net8.0 including TestCollectionWithServiceUsingNewInExpression and the bulk key selectors. It's also what the class doc-comment already says the extractor does.

Your replacer change stays — it isn't redundant. A conditional argument hits the identical crash and the extractor change doesn't touch it (no VisitConditional override, so both branch reads are credited to the conditional):

srv.Get(user.Id > 1 ? user.Id : user.Field2.Length)
// without the replacer fix: An item with the same key has already been added. Key: IIF((user.Id > 1), user.Id, user.Field2.Length)

Also in the follow-up: top-level tests for both shapes, ExtractObjectBuiltFromMultipleMembersInMethodArg flipped to assert the two separate extractions, an ExtractConditionalInMethodArg pinning the repeat the replacer relies on, corrected CHANGELOG under a new # 6.2.3 (6.2.2 is out), and the package version bumped.

1201 passed / 0 failed, plus EF 47 and AspNet 92, on net8.0.

Both of your side notes are real. The dotnet-csharpier → csharpier command name in .config/dotnet-tools.json is worth its own PR if you want to send one — I left it out of here to keep the diff on the bug.

@lukemurray
lukemurray merged commit 90246f5 into EntityGraphQL:main Sep 8, 2026
1 check passed
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.

2 participants