Skip to content
Open
57 changes: 57 additions & 0 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ public Parser(KnownTypeSymbols knownSymbols)
return null;
}

// When a context class is split across multiple partial declarations with
// [JsonSerializable] attributes on different partials, we only want to
// generate code once (from the canonical partial) to avoid duplicate hintNames.
if (!IsCanonicalPartialDeclaration(contextTypeSymbol, contextClassDeclaration))
{
_contextClassLocation = null;
return null;
}

ParseJsonSerializerContextAttributes(contextTypeSymbol,
out List<TypeToGenerate>? rootSerializableTypes,
out SourceGenerationOptionsSpec? options);
Expand Down Expand Up @@ -258,6 +267,54 @@ private void ParseJsonSerializerContextAttributes(
}
}

/// <summary>
/// Determines if the given class declaration is the canonical partial declaration
/// for the context type. When a context class is split across multiple partial
/// declarations with [JsonSerializable] attributes on different partials, we only
/// want to generate code once (from the canonical partial) to avoid duplicate hintNames.
/// The canonical partial is determined by picking the first syntax tree alphabetically
/// by file path among all trees that have at least one [JsonSerializable] attribute.
/// If file paths are empty or identical, comparison falls back to ordinal string order
/// which provides deterministic behavior. If no attributes are found (edge case that
/// shouldn't occur since this method is called from a context triggered by the attribute),
/// the current partial is treated as canonical.
/// </summary>
private bool IsCanonicalPartialDeclaration(INamedTypeSymbol contextTypeSymbol, ClassDeclarationSyntax contextClassDeclaration)
{
Debug.Assert(_knownSymbols.JsonSerializableAttributeType != null);

// Collect all distinct syntax trees that have [JsonSerializable] attributes for this type
SyntaxTree? canonicalTree = null;

foreach (AttributeData attributeData in contextTypeSymbol.GetAttributes())
{
if (!SymbolEqualityComparer.Default.Equals(attributeData.AttributeClass, _knownSymbols.JsonSerializableAttributeType))
{
continue;
}

SyntaxTree? attributeTree = attributeData.ApplicationSyntaxReference?.SyntaxTree;
if (attributeTree is null)
{
continue;
}

// Pick the first tree alphabetically by file path.
// Empty file paths compare as less than non-empty paths with ordinal comparison.
if (canonicalTree is null ||
string.Compare(attributeTree.FilePath, canonicalTree.FilePath, StringComparison.Ordinal) < 0)
{
canonicalTree = attributeTree;
}
}

// This partial is canonical if its syntax tree is the canonical tree.
// If canonicalTree is null (no attributes found), treat current partial as canonical.
// This is a fallback that shouldn't normally occur since this method is called
// from a context triggered by ForAttributeWithMetadataName for JsonSerializableAttribute.
return canonicalTree is null || canonicalTree == contextClassDeclaration.SyntaxTree;
}

private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(INamedTypeSymbol contextType, AttributeData attributeData)
{
JsonSourceGenerationMode? generationMode = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -991,5 +991,38 @@ public static void SupportsDisallowDuplicateProperty()
internal partial class ContextWithAllowDuplicateProperties : JsonSerializerContext
{
}

// Test for https://github.com/dotnet/runtime/issues/99669
// Verifies that partial contexts with [JsonSerializable] attributes on multiple declarations
// work correctly at runtime - all types from all partial declarations should be available.
[Fact]
public static void PartialContextWithAttributesOnMultipleDeclarations_RuntimeBehavior()
{
// Verify both types from both partial declarations are available
Assert.NotNull(MultiplePartialDeclarationsContext.Default.TypeFromPartial1);
Assert.NotNull(MultiplePartialDeclarationsContext.Default.TypeFromPartial2);

// Test serialization of type from first partial
var obj1 = new TypeFromPartial1 { Id = 42, Name = "Test" };
string json1 = JsonSerializer.Serialize(obj1, MultiplePartialDeclarationsContext.Default.TypeFromPartial1);
Assert.Contains("42", json1);
Assert.Contains("Test", json1);

// Test deserialization of type from first partial
var deserialized1 = JsonSerializer.Deserialize<TypeFromPartial1>(json1, MultiplePartialDeclarationsContext.Default.TypeFromPartial1);
Assert.Equal(42, deserialized1.Id);
Assert.Equal("Test", deserialized1.Name);

// Test serialization of type from second partial
var obj2 = new TypeFromPartial2 { Value = 3.14, IsActive = true };
string json2 = JsonSerializer.Serialize(obj2, MultiplePartialDeclarationsContext.Default.TypeFromPartial2);
Assert.Contains("3.14", json2);
Assert.Contains("true", json2);

// Test deserialization of type from second partial
var deserialized2 = JsonSerializer.Deserialize<TypeFromPartial2>(json2, MultiplePartialDeclarationsContext.Default.TypeFromPartial2);
Assert.Equal(3.14, deserialized2.Value);
Assert.True(deserialized2.IsActive);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace System.Text.Json.SourceGeneration.Tests
{
// Test for https://github.com/dotnet/runtime/issues/99669
// Types and first partial declaration for testing partial contexts with
// [JsonSerializable] attributes on multiple declarations.

public class TypeFromPartial1
{
public int Id { get; set; }
public string Name { get; set; }
}

// First partial declaration - declares TypeFromPartial1
[JsonSerializable(typeof(TypeFromPartial1))]
internal partial class MultiplePartialDeclarationsContext : JsonSerializerContext { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace System.Text.Json.SourceGeneration.Tests
{
// Second type for testing partial contexts with
// [JsonSerializable] attributes on multiple declarations.

public class TypeFromPartial2
{
public double Value { get; set; }
public bool IsActive { get; set; }
}

// Second partial declaration - declares TypeFromPartial2
[JsonSerializable(typeof(TypeFromPartial2))]
internal partial class MultiplePartialDeclarationsContext { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
<Compile Include="MetadataContextTests.cs" />
<Compile Include="MixedModeContextTests.cs" />
<Compile Include="NETStandardContextTests.cs" />
<Compile Include="PartialContextTests.Part1.cs" />
<Compile Include="PartialContextTests.Part2.cs" />
<Compile Include="RealWorldContextTests.cs" />
<Compile Include="Serialization\JsonSerializerWrapper.SourceGen.cs" />
<Compile Include="SerializationContextTests.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1035,5 +1035,87 @@ public partial class MyContext : JsonSerializerContext
Assert.Empty(errors);
}
}

[Fact]
public void PartialContextClassWithAttributesOnMultipleDeclarations()
{
// Test for https://github.com/dotnet/runtime/issues/99669
// When a JsonSerializerContext is defined across multiple partial class declarations
// with [JsonSerializable] attributes on different declarations, the generator should
// successfully generate code without duplicate hintName errors.

string source1 = """
using System.Text.Json.Serialization;

namespace HelloWorld
{
public class MyClass1
{
public int Value { get; set; }
}

public class MyClass2
{
public string Name { get; set; }
}

[JsonSerializable(typeof(MyClass1))]
internal partial class SerializerContext : JsonSerializerContext
{
}
}
""";

string source2 = """
using System.Text.Json.Serialization;

namespace HelloWorld
{
[JsonSerializable(typeof(MyClass2))]
internal partial class SerializerContext
{
}
}
""";

// Create a base compilation to get proper references (including netfx polyfill attributes).
// File paths are explicitly set to verify the canonical partial selection (first alphabetically).
// "File1.cs" comes before "File2.cs" alphabetically, so source1 declares the canonical partial.
Compilation baseCompilation = CompilationHelper.CreateCompilation("");

// Add our syntax trees with explicit file paths. Keep any existing polyfill trees from base compilation.
var polyfillTrees = baseCompilation.SyntaxTrees.Where(t => string.IsNullOrEmpty(t.FilePath) == false || !t.ToString().Contains("namespace HelloWorld"));
Compilation compilation = CSharpCompilation.Create(
"TestAssembly",
syntaxTrees: baseCompilation.SyntaxTrees.Concat(new[]
{
CSharpSyntaxTree.ParseText(source1, CompilationHelper.CreateParseOptions()).WithFilePath("File1.cs"),
CSharpSyntaxTree.ParseText(source2, CompilationHelper.CreateParseOptions()).WithFilePath("File2.cs")
}),
references: baseCompilation.References,
options: (CSharpCompilationOptions)baseCompilation.Options);

JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, logger: logger);

// Verify no errors from the source generator
var errors = result.Diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();

Assert.Empty(errors);

// Verify a single combined context was generated containing both types
// (not two separate contexts, which would cause duplicate hintName errors)
Assert.Equal(1, result.ContextGenerationSpecs.Length);
result.AssertContainsType("global::HelloWorld.MyClass1");
result.AssertContainsType("global::HelloWorld.MyClass2");

// Verify the generated code compiles without errors
var compilationErrors = result.NewCompilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();

Assert.Empty(compilationErrors);
}
}
}
Loading