Repository navigation
Fix duplicate key crash when a service resolver builds an object from context members - #553
Conversation
7769ef9 to
f9e16f6
Compare
|
The red build here is not from this change — Why it started: GHSA-2m69-gcr7-jv3q was published 2025-07-15 but updated 2026-06-18, and its vulnerable range is now 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 Locally, with |
… 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>
f9e16f6 to
efdcc84
Compare
|
Retargeted from Now based on Re-verified on
One correction to my earlier comment about CI: the |
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>
|
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 } } }
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 Your replacer change stays — it isn't redundant. A conditional argument hits the identical crash and the extractor change doesn't touch it (no 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, 1201 passed / 0 failed, plus EF 47 and AspNet 92, on net8.0. Both of your side notes are real. The |
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:
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 fieldand falls back to the per-item resolver, which then throws.Root cause
Walking
ExpressionExtractorover(user, srv) => srv.Get(new Project { Id = user.Id, Name = user.Field2 }):VisitMethodCallpushes the call, visitsnode.Object(srv), finds nothing tied to the root context, pops.ProcessPotentialLeafgets the argument. It is a construction, not a member access, so the construction itself is pushed ontocurrentExpressionand visited.VisitNew/VisitMemberInitoverride, so the base visitor descends into the constructor arguments or member bindings with the construction still on top of the stack.VisitMember(user.Id)declines to push — the stack is non-empty, andIsNullableType()is false forint(it only meansNullable<T>) — so whenVisitParameterrecognises the root context it recordscurrentExpression.Peek(): the construction, not the member.user.Field2takes the same path and derives the same name from the same construction'sToString(), so the extractor appends the identical node instance to the existing list, which is now[node A, node A].GraphQLExtractedFieldonExtractedFieldsFromServices, built once at schema-build time.BaseGraphQLField.ReplaceContextbuilds anExpressionReplacer, whose constructor calledAdd(exp, field)per expression.Expressiondoes not override equality, so the second add of the same instance is a duplicate key.ExpressionExtractoralready documents the repeat as intentional: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/VisitMemberInitoverride 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 existingTestCollectionWithServiceUsingNewInExpression. Fails on master, passes with the fix.ExpressionExtractorTests.ExtractObjectBuiltFromMultipleMembersInMethodArg— no schema; pins the extractor behaviour the fix relies on, besideExtractLongMemberExpressionSameNameInMethod. 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
ArgumentExceptionTestCollectionWithServiceUsingNewInExpression(construction is the resolver body)net9.0 is in
TargetFrameworksbut 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 restorefails —.config/dotnet-tools.jsonnames the commanddotnet-csharpier, but csharpier 1.0.0 ships it ascsharpier.dotnet teston the solution needs-p:NuGetAudit=false:SQLitePCLRaw.lib.e_sqlite3has an open advisory andEntityGraphQL.EF.Tests/examples/demopromote NuGet warnings to errors.🤖 Generated with Claude Code