Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ public KnownTypeSymbols(Compilation compilation)
public INamedTypeSymbol? IJsonOnSerializedType => GetOrResolveType(JsonConstants.IJsonOnSerializedFullName, ref _IJsonOnSerializedType);
private Option<INamedTypeSymbol?> _IJsonOnSerializedType;

// Runtime feature detection types
public INamedTypeSymbol? UnsafeAccessorAttributeType => GetOrResolveType("System.Runtime.CompilerServices.UnsafeAccessorAttribute", ref _UnsafeAccessorAttributeType);
private Option<INamedTypeSymbol?> _UnsafeAccessorAttributeType;

// Unsupported types
public INamedTypeSymbol? DelegateType => _DelegateType ??= Compilation.GetSpecialType(SpecialType.System_Delegate);
private INamedTypeSymbol? _DelegateType;
Expand Down
199 changes: 168 additions & 31 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public Parser(KnownTypeSymbols knownSymbols)
Namespace = contextTypeSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString() : null,
ContextClassDeclarations = classDeclarationList.ToImmutableEquatableArray(),
GeneratedOptionsSpec = options,
SupportsUnsafeAccessor = _knownSymbols.UnsafeAccessorAttributeType is not null,
};

// Clear the caches of generated metadata between the processing of context classes.
Expand Down Expand Up @@ -1627,10 +1628,17 @@ private void ProcessMember(
}

ParameterGenerationSpec? matchingConstructorParameter = GetMatchingConstructorParameter(property, constructorParameters);
bool isRequired = property.IsRequired && !constructorSetsRequiredMembers;

if (property.IsRequired || matchingConstructorParameter is null)
// Only use ParameterizedConstructor strategy for required properties.
// Init-only non-required properties can be set via the setter delegate
// using reflection, which preserves their default values when not specified in JSON.
if (isRequired || matchingConstructorParameter is not null)
{
constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor;
if (isRequired)
{
constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor;
}

var propertyInitializer = new PropertyInitializerGenerationSpec
{
Expand All @@ -1639,6 +1647,7 @@ private void ProcessMember(
MatchesConstructorParameter = matchingConstructorParameter is not null,
ParameterIndex = matchingConstructorParameter?.ParameterIndex ?? paramCount++,
IsNullable = property.PropertyType.CanBeNull && !property.IsSetterNonNullableAnnotation,
IsRequired = isRequired,
};

(propertyInitializers ??= new()).Add(propertyInitializer);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,10 @@ public sealed record ContextGenerationSpec
public required ImmutableEquatableArray<string> ContextClassDeclarations { get; init; }

public required SourceGenerationOptionsSpec? GeneratedOptionsSpec { get; init; }

/// <summary>
/// Whether the target framework supports UnsafeAccessor attribute (.NET 8+).
/// </summary>
public required bool SupportsUnsafeAccessor { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,13 @@ public sealed record PropertyInitializerGenerationSpec
public required bool MatchesConstructorParameter { get; init; }

public required bool IsNullable { get; init; }

/// <summary>
/// Indicates whether the property is a C# required member.
/// Required members must be set via object initializers,
/// while non-required init-only properties can preserve default values
/// by setting them via reflection-based setters after construction.
/// </summary>
public required bool IsRequired { get; init; }
}
}
17 changes: 15 additions & 2 deletions src/libraries/System.Text.Json/tests/Common/MetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,7 @@ public void TypeWithConstructor_JsonPropertyInfo_AssociatedParameter_MatchesCtor

[Theory]
[InlineData(typeof(ClassWithRequiredMember))]
[InlineData(typeof(ClassWithInitOnlyProperty))]
public void TypeWithRequiredOrInitMember_SourceGen_HasAssociatedParameterInfo(Type type)
public void TypeWithRequiredMember_SourceGen_HasAssociatedParameterInfo(Type type)
{
JsonTypeInfo typeInfo = Serializer.GetTypeInfo(type);
JsonPropertyInfo propertyInfo = typeInfo.Properties.Single();
Expand Down Expand Up @@ -201,6 +200,20 @@ public void TypeWithRequiredOrInitMember_SourceGen_HasAssociatedParameterInfo(Ty
}
}

[Theory]
[InlineData(typeof(ClassWithInitOnlyProperty))]
public void TypeWithInitOnlyNonRequiredMember_NoAssociatedParameterInfo(Type type)
{
// Init-only non-required properties should NOT have associated parameter info
// because they are set via reflection-based setters, not the constructor delegate.
// This preserves their default values when not specified in the JSON.
JsonTypeInfo typeInfo = Serializer.GetTypeInfo(type);
JsonPropertyInfo propertyInfo = typeInfo.Properties.Single();

JsonParameterInfo? jsonParameter = propertyInfo.AssociatedParameter;
Assert.Null(jsonParameter);
}

[Theory]
[InlineData(typeof(ClassWithDefaultCtor))]
[InlineData(typeof(StructWithDefaultCtor))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,66 @@ public RecordWithIgnoredNestedInitOnlyProperty(int foo)

public record InnerRecord(int Foo, string Bar);
}

[Fact]
public virtual async Task InitOnlyProperties_PreserveDefaultValues()
{
// Regression test for https://github.com/dotnet/runtime/issues/58770
// and https://github.com/dotnet/runtime/issues/87488
// Default values for init-only properties should be preserved when not specified in JSON.

// When no properties are specified, default values should be used
ClassWithInitOnlyPropertyDefaults obj = await Serializer.DeserializeWrapper<ClassWithInitOnlyPropertyDefaults>("{}");
Assert.Equal("DefaultName", obj.Name);
Assert.Equal(42, obj.Number);

// When only some properties are specified, unspecified ones should retain defaults
obj = await Serializer.DeserializeWrapper<ClassWithInitOnlyPropertyDefaults>(@"{""Name"":""Custom""}");
Assert.Equal("Custom", obj.Name);
Assert.Equal(42, obj.Number);

obj = await Serializer.DeserializeWrapper<ClassWithInitOnlyPropertyDefaults>(@"{""Number"":100}");
Assert.Equal("DefaultName", obj.Name);
Assert.Equal(100, obj.Number);

// When all properties are specified, they should be used
obj = await Serializer.DeserializeWrapper<ClassWithInitOnlyPropertyDefaults>(@"{""Name"":""Custom"",""Number"":100}");
Assert.Equal("Custom", obj.Name);
Assert.Equal(100, obj.Number);
}

[Fact]
public virtual async Task InitOnlyProperties_PreserveDefaultValues_Struct()
{
// Regression test for value types with init-only properties with default values

// When no properties are specified, default values should be used
StructWithInitOnlyPropertyDefaults obj = await Serializer.DeserializeWrapper<StructWithInitOnlyPropertyDefaults>("{}");
Assert.Equal("DefaultName", obj.Name);
Assert.Equal(42, obj.Number);

// When only some properties are specified, unspecified ones should retain defaults
obj = await Serializer.DeserializeWrapper<StructWithInitOnlyPropertyDefaults>(@"{""Name"":""Custom""}");
Assert.Equal("Custom", obj.Name);
Assert.Equal(42, obj.Number);

// When all properties are specified, they should be used
obj = await Serializer.DeserializeWrapper<StructWithInitOnlyPropertyDefaults>(@"{""Name"":""Custom"",""Number"":100}");
Assert.Equal("Custom", obj.Name);
Assert.Equal(100, obj.Number);
}

public class ClassWithInitOnlyPropertyDefaults
{
public string Name { get; init; } = "DefaultName";
public int Number { get; init; } = 42;
}

public struct StructWithInitOnlyPropertyDefaults
{
public StructWithInitOnlyPropertyDefaults() { }
public string Name { get; init; } = "DefaultName";
public int Number { get; init; } = 42;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ public override async Task ClassWithIgnoredAndPrivateMembers_DoesNotIncludeIgnor
[JsonSerializable(typeof(ClassWithStructProperty_IgnoreConditionWhenWritingDefault))]
[JsonSerializable(typeof(ClassWithMissingObjectProperty))]
[JsonSerializable(typeof(ClassWithInitOnlyProperty))]
[JsonSerializable(typeof(ClassWithInitOnlyPropertyDefaults))]
[JsonSerializable(typeof(StructWithInitOnlyPropertyDefaults))]
[JsonSerializable(typeof(Class_WithIgnoredInitOnlyProperty))]
[JsonSerializable(typeof(Record_WithIgnoredPropertyInCtor))]
[JsonSerializable(typeof(Class_WithIgnoredRequiredProperty))]
Expand Down Expand Up @@ -477,6 +479,8 @@ partial class DefaultContextWithGlobalIgnoreSetting : JsonSerializerContext;
[JsonSerializable(typeof(ClassWithStructProperty_IgnoreConditionWhenWritingDefault))]
[JsonSerializable(typeof(ClassWithMissingObjectProperty))]
[JsonSerializable(typeof(ClassWithInitOnlyProperty))]
[JsonSerializable(typeof(ClassWithInitOnlyPropertyDefaults))]
[JsonSerializable(typeof(StructWithInitOnlyPropertyDefaults))]
[JsonSerializable(typeof(Class_WithIgnoredInitOnlyProperty))]
[JsonSerializable(typeof(Record_WithIgnoredPropertyInCtor))]
[JsonSerializable(typeof(Class_WithIgnoredRequiredProperty))]
Expand Down
Loading