Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow mutable struct record support (as auto-tuple) #1000

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"sdk": {
"version": "7.0.100",
"rollForward": "latestMajor",
"allowPrerelease": false
}
}
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<DefaultLanguage>en-US</DefaultLanguage>
<IncludeSymbols>false</IncludeSymbols>
<IsTestProject>$(MSBuildProjectName.Contains('Test'))</IsTestProject>
<LangVersion>9.0</LangVersion>
<LangVersion>11</LangVersion>
<CodeAnalysisRuleset>$(MSBuildThisFileDirectory)Shared.ruleset</CodeAnalysisRuleset>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release' or '$(Configuration)'=='VS'">
Expand Down
69 changes: 69 additions & 0 deletions src/protobuf-net.Test/RecordTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using ProtoBuf.Meta;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Xunit;

namespace ProtoBuf.Test;

public class RecordTests
{
[Theory]
[InlineData(typeof(ReadWriteClassRecord))]
[InlineData(typeof(ReadWriteStructRecord))]
[InlineData(typeof(ReadOnlyStructRecord))]
public void TestSchema(Type type)
{
var schema = RuntimeTypeModel.Default.GetSchema(type);
Assert.Equal($@"syntax = ""proto3"";
package ProtoBuf.Test;

message {type.Name} {{
int32 Id = 1;
string Name = 2;
}}
", schema, ignoreLineEndingDifferences: true);
}

[Fact]
public void TestRoundTrip_ReadOnlyStructRecord() => TestRoundTrip(new ReadOnlyStructRecord(12, "abc"));

[Fact]
public void TestRoundTrip_ReadWriteStructRecord() => TestRoundTrip(new ReadWriteStructRecord(12, "abc"));

[Fact]
public void TestRoundTrip_ReadWriteClassRecord() => TestRoundTrip(new ReadWriteClassRecord(12, "abc"));

[System.Diagnostics.CodeAnalysis.SuppressMessage("Assertions", "xUnit2005:Do not use identity check on value type", Justification = "Ref-type check included")]
private void TestRoundTrip<T>(T value) where T : IRecord
{
Assert.Equal(12, value.Id);
Assert.Equal("abc", value.Name);

using var ms = new MemoryStream();
Serializer.Serialize(ms, value);
if (!ms.TryGetBuffer(out var buffer)) buffer = new(ms.ToArray());
var hex = BitConverter.ToString(buffer.Array, buffer.Offset, buffer.Count);
Assert.Equal("08-0C-12-03-61-62-63", hex);

ms.Position = 0;
var clone = Serializer.Deserialize<T>(ms);
if (!typeof(T).IsValueType)
{
Assert.NotSame(value, clone);
}
Assert.Equal(12, clone.Id);
Assert.Equal("abc", clone.Name);
}

public interface IRecord
{
int Id { get; }
string Name { get; }
}

record class ReadWriteClassRecord(int Id, string Name) : IRecord;
record struct ReadWriteStructRecord(int Id, string Name) : IRecord;
readonly record struct ReadOnlyStructRecord(int Id, string Name) : IRecord;
}
38 changes: 32 additions & 6 deletions src/protobuf-net/Meta/MetaType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -986,23 +986,49 @@ internal static ConstructorInfo ResolveTupleConstructor(Type type, out MemberInf
// for most types we'll enforce that you need readonly, because that is what protobuf-net
// always did historically; but: if you smell so much like a Tuple that it is *in your name*,
// we'll let you past that
bool demandReadOnly = type.Name.IndexOf("Tuple", StringComparison.OrdinalIgnoreCase) < 0;
bool checkForDisallowedSetters = type.Name.IndexOf("Tuple", StringComparison.OrdinalIgnoreCase) < 0,
isPossibleStructRecord = type.IsValueType && IsSelfEquatable(type); // not much to go on

static bool IsSelfEquatable(Type type) // T : IEquatable<T>
{
foreach(var iType in type.GetInterfaces())
{
if (iType.IsGenericType && iType.GetGenericTypeDefinition() == typeof(IEquatable<>)
&& iType.GetGenericArguments()[0] == type)
{
return true;
}
}
return false;
}

for (int i = 0; i < fieldsPropsUnfiltered.Length; i++)
{
if (fieldsPropsUnfiltered[i] is PropertyInfo prop)
{
if (!prop.CanRead) return null; // no use if can't read
if (demandReadOnly && prop.CanWrite && IsPublicSetter(Helpers.GetSetMethod(prop, false, false)))
if (checkForDisallowedSetters && prop.CanWrite && IsDisallowedSetter(isPossibleStructRecord, Helpers.GetSetMethod(prop, false, false)))
{
// don't allow a public set (need to allow non-public to handle Mono's KeyValuePair<,>)
// (unless it is an "init-only" set)
return null;
}
memberList.Add(prop);

static bool IsPublicSetter(MethodInfo method)
static bool IsDisallowedSetter(bool isValueType, MethodInfo method)
{
// don't allow a public set (need to allow non-public to handle Mono's KeyValuePair<,>)
if (method is null) return false;

if (isValueType)
{
// for value-types, allow [CompilerGenerated], to allow
// mutable struct records
foreach(var attrib in method.GetCustomAttributesData())
{
if (attrib.AttributeType is { FullName: "System.Runtime.CompilerServices.CompilerGeneratedAttribute" }) return false;
}
}

// (unless it is an "init-only" set)
foreach (Type modreq in method.ReturnParameter?.GetRequiredCustomModifiers() ?? Type.EmptyTypes)
{
if (modreq?.FullName == "System.Runtime.CompilerServices.IsExternalInit") return false;
Expand All @@ -1014,7 +1040,7 @@ static bool IsPublicSetter(MethodInfo method)
{
if (fieldsPropsUnfiltered[i] is FieldInfo field)
{
if (demandReadOnly && !field.IsInitOnly) return null; // all public fields must be readonly to be counted a tuple
if (checkForDisallowedSetters && !field.IsInitOnly) return null; // all public fields must be readonly to be counted a tuple
memberList.Add(field);
}
}
Expand Down