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

Treat record structs as records #2009

Merged
merged 16 commits into from Jan 11, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
29 changes: 23 additions & 6 deletions Src/FluentAssertions/Common/TypeExtensions.cs
Expand Up @@ -584,12 +584,29 @@ private static bool IsAnonymousType(this Type type)
public static bool IsRecord(this Type type)
{
return TypeIsRecordCache.GetOrAdd(type, static t =>
t.GetMethod("<Clone>$") is not null &&
t.GetTypeInfo()
.DeclaredProperties
.FirstOrDefault(p => p.Name == "EqualityContract")?
.GetMethod?
.GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null);
{
var isRecord = t.GetMethod("<Clone>$") is not null &&
t.GetTypeInfo()
.DeclaredProperties
.FirstOrDefault(p => p.Name == "EqualityContract")?
.GetMethod?
.GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null;

// As noted here: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#open-questions
// recognizing record structs from metadata is an open point. The following check is based on common sense
// and heuristic testing, apparently giving good results but not supported by official documentation.
var isRecordStruct = t.BaseType == typeof(ValueType) &&
t.GetMethods()
.Where(m => m.Name == "op_Inequality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any() &&
t.GetMethods()
.Where(m => m.Name == "op_Equality")
.SelectMany(m => m.GetCustomAttributes(typeof(CompilerGeneratedAttribute)))
.Any();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is probably the best we can currently do, I wonder a bit if this is still too broad a check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid of this too. This is backed by MSDN too (see comment). I've tried my best to "crack" this test and was not able to, but please feel free to add counter-examples.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for one operator should be enough.

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { t, t }, null)?
                        .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is not null;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had another look at creating a heuristic to identify record structs with few false positives.

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#printing-members-printmembers-and-tostring-methods

specifies that a record struct contains a synthesized (read has [CompilerGenerated])

private bool PrintMembers(System.Text.StringBuilder builder)

The name PrintMembers is also a public part of Roslyn.

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null)?
                        .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is { };

What do you think about this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That may be a good catch. I'll look into it!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand you here 🤔
As a record struct currently doesn't have any unspeakable parts, it's always possible to write a struct in plain C# that looks like a record struct.

E.g. this is valid C# code, SharpLab

public struct A
{
    [CompilerGenerated]
    public static bool operator ==(A x, A y) => true;
    
    [CompilerGenerated]
    public static bool operator !=(A x, A y) => false 
}

I tried another thing.

I made a record struct Auto {} and copied its output back into regular C# code.
The only thing I wasn't allowed to was using [IsReadOnly] as that gave a compiler error.

error CS8335: Do not use 'System.Runtime.CompilerServices.IsReadOnlyAttribute'. This is reserved for compiler usage.

PrintMembers is annotated with [IsReadOnly]

So what do you think about checking for this?

var isRecordStruct = t.BaseType == typeof(ValueType) &&
                     t.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null)?
                        .GetCustomAttribute(typeof(IsReadOnlyAttribute)) is { };

I don't know if an arbitrary source generator is allowed to use [IsReadOnly].
IL weaving would most likely be able to circumvent CS8335, but we aren't looking for a bullet proof solution.

SharpLab

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! I didn't know one could annotate with [CompilerGenerated] in source code; it looks a little silly to me that you can, but it is what it is I suppose :)
In this case, I definitely like your proposal with PrintMembers and IsReadOnly more. Let me try to give a couple of final touches and I'll be back with a new commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jnyrup I have just tried the last proposal checking for a PrintMembers annotated with IsReadOnly. IsReadOnly is not available on NET47 and NETSTANDARD2_0, so it does not compile on those target, but here the problem is just my ignorance on properly handling multi-target.
The major problem for me is that the check fails for the MyReadOnlyRecordStruct and MyRecordStructWithCustomPrintMembers tests, for which PrintMembers has not the IsReadOnly attribute.
For this reason, at the end of the day (literally, for me 😄 ) I believe the lesser evil for now is checking for compiler generated equality, knowing that we can have a false positive if someone uses the [CompilerGenerated] attribute in source code (I have added a test to document this).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha!
I hadn't thought about the case in MyRecordStructWithCustomPrintMembers where one implements PrintMembers themselves such that the compiler doesn't create one.
Well found 👍

So it seems there will always be a PrintMembers, but we don't know who wrote it.

This code handles all your test cases and the false positive.

public static bool IsRecord(this Type type)
{
    return TypeIsRecordCache.GetOrAdd(type, static t => t.IsRecordClass() || t.IsRecordStruct());
}

private static bool IsRecordClass(this Type type)
{
    return type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) is { } &&
            type.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)?
                .GetMethod?.IsDecoratedWith<CompilerGeneratedAttribute>() == true;
}

private static bool IsRecordStruct(this Type type)
{
    // As noted here: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#open-questions
    // recognizing record structs from metadata is an open point. The following check is based on common sense
    // and heuristic testing, apparently giving good results but not supported by official documentation.
    return type.BaseType == typeof(ValueType) &&
            type.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null) is { } &&
            type.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { type, type }, null)?
                .IsDecoratedWith<CompilerGeneratedAttribute>() == true;
}

I would be confident enough with this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea: this way we rely on the two documented behaviors of record structs!
There is still a small possibility of a false positive, where one both implements a PrintMember and annotates equality with [CompilerGenerated] (I have added a test for reference), but I'd consider such code as deliberate sabotage 😄


return isRecord || isRecordStruct;
});
}

private static bool IsKeyValuePair(Type type)
Expand Down
15 changes: 15 additions & 0 deletions Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs
Expand Up @@ -16,6 +16,16 @@ public void When_the_subject_is_a_record_it_should_compare_it_by_its_members()
actual.Should().BeEquivalentTo(expected);
}

[Fact]
public void When_the_subject_is_a_readonly_record_struct_it_should_compare_it_by_its_members()
jnyrup marked this conversation as resolved.
Show resolved Hide resolved
{
var actual = new MyReadonlyRecordStruct("foo", new[] { "bar", "zip", "foo" });
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved

var expected = new MyReadonlyRecordStruct("foo", new[] { "bar", "zip", "foo" });

actual.Should().BeEquivalentTo(expected);
}

[Fact]
public void When_the_subject_is_a_record_it_should_mention_that_in_the_configuration_output()
{
Expand Down Expand Up @@ -90,4 +100,9 @@ private record MyRecord

public string[] CollectionProperty { get; init; }
}

private readonly record struct MyReadonlyRecordStruct(string StringField, string[] CollectionProperty)
{
public readonly string StringField = StringField;
}
}
66 changes: 66 additions & 0 deletions Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs
@@ -1,4 +1,5 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using FluentAssertions.Common;
Expand Down Expand Up @@ -124,6 +125,33 @@ public void When_getting_fake_implicit_conversion_operator_from_a_type_with_fake
result.Should().NotBeNull();
}

[Theory]
[InlineData(typeof(MyRecord), true)]
[InlineData(typeof(MyRecordStruct), true)]
[InlineData(typeof(MyRecordStructWithOverriddenEquality), true)]
[InlineData(typeof(MyReadonlyRecordStruct), true)]
[InlineData(typeof(MyStruct), false)]
[InlineData(typeof(MyStructWithOverriddenEquality), false)]
[InlineData(typeof(MyClass), false)]
[InlineData(typeof(int), false)]
[InlineData(typeof(string), false)]
public void IsRecord_should_detect_records_correctly(Type type, bool expected)
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
{
type.IsRecord().Should().Be(expected);
}

[Fact]
public void When_checking_if_anonymous_type_is_record_it_should_return_false()
{
new { Value = 42 }.GetType().IsRecord().Should().BeFalse();
}

[Fact]
public void When_checking_if_class_with_multiple_equality_methods_is_record_it_should_return_false()
{
typeof(ImmutableArray<int>).IsRecord().Should().BeFalse();
}

private static MethodInfo GetFakeConversionOperator(Type type, string name, BindingFlags bindingAttr, Type returnType)
{
MethodInfo[] methods = type.GetMethods(bindingAttr);
Expand Down Expand Up @@ -153,4 +181,42 @@ private TypeWithFakeConversionOperators(int value)
public static byte op_Explicit(TypeWithFakeConversionOperators typeWithFakeConversionOperators) => (byte)typeWithFakeConversionOperators.value;
#pragma warning restore SA1300, IDE1006
}

private record MyRecord(int Value);

private record struct MyRecordStruct(int Value);

private record struct MyRecordStructWithOverriddenEquality(int Value)
{
public bool Equals(MyRecordStructWithOverriddenEquality other) => Value == other.Value;

public override int GetHashCode() => Value;
}

private readonly record struct MyReadonlyRecordStruct(int Value);

private struct MyStruct
{
public int Value { get; set; }
}

private struct MyStructWithOverriddenEquality : IEquatable<MyStructWithOverriddenEquality>
{
public int Value { get; set; }

public bool Equals(MyStructWithOverriddenEquality other) => Value == other.Value;

public override bool Equals(object obj) => obj is MyStructWithOverriddenEquality other && Equals(other);

public override int GetHashCode() => Value;

public static bool operator ==(MyStructWithOverriddenEquality left, MyStructWithOverriddenEquality right) => left.Equals(right);

public static bool operator !=(MyStructWithOverriddenEquality left, MyStructWithOverriddenEquality right) => !left.Equals(right);
}

private class MyClass
{
public int Value { get; set; }
}
}
4 changes: 2 additions & 2 deletions docs/_pages/objectgraphs.md
Expand Up @@ -39,7 +39,7 @@ orderDto.Should().BeEquivalentTo(order, options =>

### Value Types

To determine whether Fluent Assertions should recurs into an object's properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides `Object.Equals` as an object that was designed to have value semantics. Anonymous types, records and tuples also override this method, but because the community proved us that they use them quite often in equivalency comparisons, we decided to always compare them by their members.
To determine whether Fluent Assertions should recurs into an object's properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides `Object.Equals` as an object that was designed to have value semantics. Anonymous types, records, record structs, readonly record structs and tuples also override this method, but because the community proved us that they use them quite often in equivalency comparisons, we decided to always compare them by their members.
jnyrup marked this conversation as resolved.
Show resolved Hide resolved

You can easily override this by using the `ComparingByValue<T>`, `ComparingByMembers<T>`, `ComparingRecordsByValue` and `ComparingRecordsByMembers` options for individual assertions:

Expand All @@ -48,7 +48,7 @@ subject.Should().BeEquivalentTo(expected,
options => options.ComparingByValue<IPAddress>());
```

For records, this works like this:
For records, record structs and readonly record structs this works like this:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For records, record structs and readonly record structs this works like this:
For records and record structs this works like this:


```csharp
actual.Should().BeEquivalentTo(expected, options => options
Expand Down
1 change: 1 addition & 0 deletions docs/_pages/releases.md
Expand Up @@ -25,6 +25,7 @@ sidebar:

### Enhancements
* Included the time difference in the error message of `BeCloseTo` - [#2013](https://github.com/fluentassertions/fluentassertions/pull/2013)
* Treated record structs and readonly record structs as records, thus comparing them by member by default - [#2009](https://github.com/fluentassertions/fluentassertions/pull/2009)
jnyrup marked this conversation as resolved.
Show resolved Hide resolved

## 6.7.0

Expand Down