Skip to content

Commit f9e16f6

Browse files
Ko-Linclaude
andcommitted
Fix duplicate key crash when a resolver builds an object from context 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>
1 parent 0f1a84c commit f9e16f6

4 files changed

Lines changed: 61 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
## Fixes
1515

16+
- #553 - fix `An item with the same key has already been added` when a service field resolver builds an object from multiple context members (e.g. `(ctx, srv) => srv.Get(new Thing { A = ctx.A, B = ctx.B })`) and that field is selected below another service field
1617
- #498 - fix ExecutableDirectiveLocation naming regression introduced in beta3
1718

1819
# 6.0.0-beta3

‎src/EntityGraphQL/Compiler/Util/ExpressionReplacer.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ public ExpressionReplacer(IEnumerable<GraphQLExtractedField> expressionsToReplac
3131
{
3232
foreach (var exp in field.FieldExpressions)
3333
{
34-
this.expressionsToReplace.Add(exp, field);
34+
// The extractor deliberately emits repeats - the same instance appears twice
35+
// when a resolver builds one object from several context members.
36+
this.expressionsToReplace[exp] = field;
3537
}
3638
}
3739
}

‎src/tests/EntityGraphQL.Tests/QueryTests/ServiceFieldTests.cs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,48 @@ public void TestCollectionWithServiceUsingNewInExpression()
13761376
Assert.Equal("resolvedSettings", Enumerable.ElementAt(people[0].GetType().GetFields(), 0).Name);
13771377
}
13781378

1379+
/// <summary>
1380+
/// A resolver that builds an object from more than one context member, as an argument to the service call,
1381+
/// has both member reads credited to the enclosing new/init expression - so ExpressionExtractor records that
1382+
/// single node instance twice. Below another service field the bulk path is unavailable and ExpressionReplacer
1383+
/// is built from those expressions, which used to throw "An item with the same key has already been added".
1384+
/// </summary>
1385+
[Fact]
1386+
public void TestServiceFieldWithNewInServiceCallArgUnderServiceField()
1387+
{
1388+
var schema = SchemaBuilder.FromObject<TestDataContext>();
1389+
schema.AddType<ProjectConfig>("ProjectConfig").AddAllFields();
1390+
// outer service field - returns a User
1391+
schema.UpdateType<Project>(type => type.ReplaceField("createdBy", "Get user that created it").Resolve<UserService>((proj, users) => users.GetUserById(proj.CreatedBy)));
1392+
// inner service field - builds one object from 2 context members inside the service call
1393+
schema.Type<User>().AddField("config", "Config for the user").Resolve<ConfigService>((user, srv) => srv.Get(new Project { Id = user.Id, Name = user.Field2 }));
1394+
1395+
var gql = new QueryRequest { Query = "{ projects { name createdBy { id config { type } } } }" };
1396+
1397+
var context = new TestDataContext
1398+
{
1399+
Projects =
1400+
[
1401+
new Project
1402+
{
1403+
Id = 1,
1404+
CreatedBy = 7,
1405+
Name = "Project 1",
1406+
},
1407+
],
1408+
};
1409+
var serviceCollection = new ServiceCollection();
1410+
serviceCollection.AddSingleton(new UserService());
1411+
serviceCollection.AddSingleton(new ConfigService());
1412+
var sp = serviceCollection.BuildServiceProvider();
1413+
1414+
var res = schema.ExecuteRequestWithContext(gql, context, sp, null);
1415+
Assert.Null(res.Errors);
1416+
dynamic projects = res.Data!["projects"]!;
1417+
Assert.Equal(7, projects[0].createdBy.id);
1418+
Assert.Equal("Something", projects[0].createdBy.config.type);
1419+
}
1420+
13791421
[Fact]
13801422
public void TestRootServiceFieldBackToContext()
13811423
{

‎src/tests/EntityGraphQL.Tests/Util/ExpressionExtractorTests.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ public void ExtractLongMemberExpressionSameNameInMethod()
5050
Assert.Equal(((MethodCallExpression)expression.Body).Arguments[1], extracted.ElementAt(1).Value.First());
5151
}
5252

53+
[Fact]
54+
public void ExtractObjectBuiltFromMultipleMembersInMethodArg()
55+
{
56+
// Building an object from 2 context members as an argument to the service call - both member reads are
57+
// credited to the enclosing expression, so the same node instance is extracted twice under the one name.
58+
// ExpressionReplacer relies on tolerating that repeat - see TestServiceFieldWithNewInServiceCallArgUnderServiceField
59+
Expression<Func<User, ConfigService, ProjectConfig>> expression = (user, srv) => srv.Get(new Project { Id = user.Id, Name = user.Field2 });
60+
var extractor = new ExpressionExtractor();
61+
var extracted = extractor.Extract(expression.Body, expression.Parameters[0], false);
62+
Assert.NotNull(extracted);
63+
Assert.Single(extracted);
64+
var arg = ((MethodCallExpression)expression.Body).Arguments[0];
65+
Assert.Equal([arg, arg], extracted.First().Value);
66+
}
67+
5368
[Fact]
5469
public void ExtractExpressionInAsync()
5570
{

0 commit comments

Comments
 (0)