Skip to content

Commit 791a389

Browse files
Fix duplicate key handling (#10049)
Fixes #10025 ## Problem After migrating from Newtonsoft.Json to System.Text.Json, loading template.json files with duplicate case-insensitive property keys (e.g. `"Empty"` and `"empty"`) throws an `ArgumentException`: ``` System.ArgumentException: An item with the same key has already been added. Key: empty (Parameter 'key') at System.Collections.Generic.OrderedDictionary`2.Add(TKey key, TValue value) at System.Text.Json.Nodes.JsonObject.InitializeDictionary() at System.Text.Json.Nodes.JsonObject.get_Count() ... at Microsoft.TemplateEngine.Orchestrator.RunnableProjects.ConfigModel.TemplateConfigModel..ctor(...) ``` This affected MAUI templates (`maui-blazor`, `maui-blazor-solution`) which intentionally had both `"Empty"` (PascalCase backward-compat alias) and `"empty"` (lowercase) symbol definitions. ## Root Cause In `src/Shared/JExtensions.cs`, the `NodeOptions` field was configured with `PropertyNameCaseInsensitive = true`: ```csharp private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = true }; ``` This caused `JsonObject` to use a case-insensitive internal dictionary. When any operation triggered `JsonObject.InitializeDictionary()` (e.g. accessing `.Count`, iterating via `.ToList()`), it attempted to insert both `"Empty"` and `"empty"` into the same case-insensitive dictionary, throwing on the duplicate. This setting was **redundant** — all case-insensitive property lookups in the codebase already go through the `GetPropertyCaseInsensitive()` helper method, which manually performs a case-insensitive fallback search. ## Fix **File changed:** `src/Shared/JExtensions.cs` - Removed the `NodeOptions` field entirely (it only existed to set `PropertyNameCaseInsensitive = true`) - Replaced all 6 usages of `NodeOptions` in `JsonNode.Parse()` calls with `null` (uses default `JsonNodeOptions`, which is case-sensitive) This means `JsonObject` now stores properties with their original casing and uses a case-sensitive internal dictionary — which tolerates keys like `"Empty"` and `"empty"` coexisting. Case-insensitive lookups continue to work through the existing `GetPropertyCaseInsensitive()` helper. ## Test Added **File:** `test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs` Added `CanReadTemplateWithDuplicateCaseInsensitiveSymbolKeys` — a regression test that verifies a template.json with symbols `"Empty"` and `"empty"` loads without throwing.
2 parents bbd8b09 + 4db7cd3 commit 791a389

File tree

2 files changed

+45
-7
lines changed

2 files changed

+45
-7
lines changed

src/Shared/JExtensions.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ namespace Microsoft.TemplateEngine
1717
internal static class JExtensions
1818
{
1919
private static readonly JsonDocumentOptions DocOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
20-
private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = true };
2120
private static readonly JsonSerializerOptions SerializerOptions = new()
2221
{
2322
PropertyNameCaseInsensitive = true,
@@ -387,7 +386,7 @@ internal static JsonObject ReadJObjectFromIFile(this IFile file)
387386
using Stream s = file.OpenRead();
388387
using TextReader tr = new StreamReader(s, System.Text.Encoding.UTF8, true);
389388
string json = tr.ReadToEnd();
390-
return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions)
389+
return (JsonObject?)JsonNode.Parse(json, null, DocOptions)
391390
?? throw new InvalidOperationException("Failed to parse JSON from file.");
392391
}
393392

@@ -396,7 +395,7 @@ internal static JsonObject ReadObject(this IPhysicalFileSystem fileSystem, strin
396395
using Stream fileStream = fileSystem.OpenRead(path);
397396
using var textReader = new StreamReader(fileStream, System.Text.Encoding.UTF8, true);
398397
string json = textReader.ReadToEnd();
399-
return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions)
398+
return (JsonObject?)JsonNode.Parse(json, null, DocOptions)
400399
?? throw new InvalidOperationException($"Failed to parse JSON from '{path}'.");
401400
}
402401

@@ -480,7 +479,7 @@ internal static bool TryGetValueCaseInsensitive(this JsonObject obj, string key,
480479
/// </summary>
481480
internal static JsonObject ParseJsonObject(string json)
482481
{
483-
return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions)
482+
return (JsonObject?)JsonNode.Parse(json, null, DocOptions)
484483
?? throw new InvalidOperationException("Failed to parse JSON string as JsonObject.");
485484
}
486485

@@ -489,7 +488,7 @@ internal static JsonObject ParseJsonObject(string json)
489488
/// </summary>
490489
internal static JsonNode? ParseJsonNode(string json)
491490
{
492-
return JsonNode.Parse(json, NodeOptions, DocOptions);
491+
return JsonNode.Parse(json, null, DocOptions);
493492
}
494493

495494
/// <summary>
@@ -503,7 +502,7 @@ internal static JsonObject ParseJsonObject(string json)
503502
internal static JsonObject FromObject(object obj)
504503
{
505504
string json = JsonSerializer.Serialize(obj, SerializerOptions);
506-
return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions)
505+
return (JsonObject?)JsonNode.Parse(json, null, DocOptions)
507506
?? throw new InvalidOperationException("Failed to round-trip object to JsonObject.");
508507
}
509508

@@ -512,7 +511,7 @@ internal static JsonObject FromObject(object obj)
512511
/// </summary>
513512
internal static JsonObject DeepCloneObject(this JsonObject source)
514513
{
515-
return (JsonObject?)JsonNode.Parse(source.ToJsonString(), NodeOptions, DocOptions)
514+
return (JsonObject?)JsonNode.Parse(source.ToJsonString(), null, DocOptions)
516515
?? throw new InvalidOperationException("Failed to deep clone JsonObject.");
517516
}
518517

test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,44 @@ public void CanReadTemplateFromStream()
5454
Assert.Equal(2, templateConfigModel.PrimaryOutputs.Count);
5555
Assert.Equal(new[] { "bar.cs", "bar/bar.cs" }, templateConfigModel.PrimaryOutputs.Select(po => po.Path).OrderBy(po => po));
5656
}
57+
58+
[Fact]
59+
public void CanReadTemplateWithDuplicateCaseInsensitiveSymbolKeys()
60+
{
61+
// Regression test: template.json with symbols that differ only by case
62+
// (e.g. "Empty" and "empty") should load without throwing.
63+
// See https://github.com/dotnet/templating/issues/10047
64+
string templateWithDuplicateKeys = /*lang=json*/ """
65+
{
66+
"author": "Test Asset",
67+
"classifications": [ "Test Asset" ],
68+
"name": "TemplateWithDuplicateKeys",
69+
"identity": "TestAssets.TemplateWithDuplicateKeys",
70+
"shortName": "dupkeys",
71+
"symbols": {
72+
"Empty": {
73+
"type": "parameter",
74+
"datatype": "bool",
75+
"defaultValue": "false",
76+
"description": "PascalCase variant"
77+
},
78+
"empty": {
79+
"type": "parameter",
80+
"datatype": "bool",
81+
"defaultValue": "false",
82+
"description": "lowercase variant"
83+
}
84+
}
85+
}
86+
""";
87+
88+
var exception = Record.Exception(() => TemplateConfigModel.FromString(templateWithDuplicateKeys));
89+
Assert.Null(exception);
90+
91+
TemplateConfigModel configModel = TemplateConfigModel.FromString(templateWithDuplicateKeys);
92+
Assert.Equal("TemplateWithDuplicateKeys", configModel.Name);
93+
// Both symbols should be accessible (last-in-wins for case-sensitive dict, both kept)
94+
Assert.NotEmpty(configModel.Symbols);
95+
}
5796
}
5897
}

0 commit comments

Comments
 (0)