Better Support For C# Records
TrickyCat opened this issue · 19 comments
Hello.
Currently, the library has support for C# records, at least it claims to have one. But that doesn't seem to fully work (or I may be missing something 🤦♂️).
I've seen the entities in FsCheck/Records.cs at master · fscheck/FsCheck · GitHub and tests that use them FsCheck/Arbitrary.fs at master · fscheck/FsCheck · GitHub
At first glance since the tests pass it seems that random records are being generated. But they are rather "randomly empty" all the time 😢.
Checked versions of the packages:
FsCheck.Xunit
->2.16.4
FsCheck
->2.16.4
Example
F# project that references a C# project with a Person record:
namespace CSharp {
public record Person {
public string FirstName { get; init; }
public string LastName { get; init; }
}
}
and contains a similar F# record too:
type FsPerson = { FirstName: string; LastName: string }
[<EntryPoint>]
let main args =
let csharpPersons = Arb.generate<CSharp.Person> |> sample 10 10
let fsharpPersons = Arb.generate<FsPerson> |> sample 10 10
42
Values in the program are:
The tricky part is that the library fails for "init-only C# records" (as above), but indeed generates the data if at least one property in the C# record is mutable (has a public "set").
namespace CSharp {
public record Person {
public string FirstName { get; init; }
public string LastName { get; set; }
}
}
type FsPerson = { FirstName: string; LastName: string }
[<EntryPoint>]
let main args =
let csharpPersons = Arb.generate<CSharp.Person> |> sample 10 10
let fsharpPersons = Arb.generate<FsPerson> |> sample 10 10
42
Values in the program are:
So, I wonder whether the library has support for init-only C# records since in my case I:
-
don't want to make any properties mutable in my domain
-
must stick with C# for my domain because of other developers in the team who aren't familiar with F#
-
don't want to use an awkward hack like this one (which won't work if the base type is sealed):
namespace CSharp {
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
public record ChildForTestOnly: Person {
public int DummyMutableProp { get; set; }
}
}
type FsPerson = { FirstName: string; LastName: string }
[<EntryPoint>]
let main args =
let csharpPersons = Arb.generate<CSharp.Person> |> sample 10 10
let fsharpPersons = Arb.generate<FsPerson> |> sample 10 10
let childPersons = Arb.generate<CSharp.ChildForTestOnly> |> sample 10 10
// ... upcast the "child persons" to "persons" and test them ...
42
Values in the program are:
Any ideas or suggestions?
Thanks - does seem like a bug of some sort. I'll take a look shortly.
So the problem is that the current generator for record types only generates properties that are passed into the constructor. Which is almost the same as your example, except that you'll have a constructor with two parameters instead of a parameterless ctor:
public record Person(string FirstName, string LastName) {}
FsCheck will work for this case, if you can use that as a workaround.
I'll look into fixing it later this week.
Good to know, thanks.
@kurtschelfthout
If helpful, you can think of F# as an "older framework" in this scenario, where we can define "older framework" as anything that is unaware of the c# compiler services for init-only records. In your case, "older framework" is doubly true: because you're cross-language but also because you target netstandard1.0 and net452 TFMs. Thus, the following polyfill will help you:
To determine that setter is init-only one just needs to query the existence of required modifier initialized with aforementioned IsExternalInit type - this code helper should do the trick:
public static class RecordsHelper { public static bool IsInitOnly(this PropertyInfo property) => property.CanWrite && property.SetMethod is var setter && setter.ReturnParameter.GetRequiredCustomModifiers() is var reqMods && reqMods.Length > 0 && Array.IndexOf(reqMods, typeof(System.Runtime.CompilerServices.IsExternalInit)) > -1; }-- https://nemesissoft.github.io/posts/using-records-in-older-frameworks
Cheers. The problem is not really detecting init-only properties. We can probably treat them as settable properties (and p.CanWrite returns true for them anyway).
We had some code from before C# records were even a thing, that generated objects that philosophically are immutable records - i.e. types with a single constructor and only read-only properties - as well as code to generate "DTOs" which are types with a single parameterless constructor and writeable properties.
Actual C# record types can fall somewhere in between those two - you can have a C# record type which has some ctor parameters (for which csc will also generate init-only properties) and some additional settable or init-only properties.
So it's a matter of expanding what is generated, but I need to properly look through all the possibilities to figure out what to do. My take right now is that it's probably better to see C# records as "DTOs" rather than as immutable records.
My take right now is that it's probably better to see C# records as "DTOs" rather than as immutable records.
That's how I use them. Functionally, C# records are mostly used by me when I want a "compound key" for a IDictionary<TKey, TValue> type, because it auto-generates a Equals and GetHashCode implementation that is exactly what I would write every single time. C# Records would be better implemented through higher-order type constructors rather than as a unique compiler hack. If we had that, F#/C# would both benefit with insanely powerful ways to reify generics.
100% agree - it would be awesome for people like me if the C# team would think about a high level reflection-like API. Something like FSharpType
and FSharpValue
but for the C# equivalents.
When it comes down to it, all these things are just implemented as either product or sum types and FsCheck has generators for those things of course.. But for each case we need to implement reflective reader and constructor functions which is annoying and error-prone.
Oh well! Will have to power through.
So, I wonder whether the library has support for init-only C# records since in my case I:
- don't want to make any properties mutable in my domain
- must stick with C# for my domain because of other developers in the team who aren't familiar with F#
- don't want to use an awkward hack like this one (which won't work if the base type is sealed):
The way Mark Seemann teaches people to think about immutable domain models can also apply here:
- https://blog.ploeh.dk/2014/03/11/arbitrary-version-instances-with-fscheck/
- https://blog.ploeh.dk/2017/08/21/generalised-test-data-builder/ - different approach using a generic builder pattern
- https://blog.ploeh.dk/2017/09/11/test-data-without-builders/ - modeling Value Objects using "copy and update"
with
automated expressions in F# andWithProperty
manual expressions in C#
Released in https://www.nuget.org/packages/FsCheck/2.16.5
In my use case there is a hierarchy of DTOs which need to be serialized to XML, and there are many messages like OutgoingMessage1_Orig
.
All of the outgoing messages should have a header with some information which is specific to a concrete version of the protocol (protocol version is 3.0
in the example).
Since 3.0
should be in each and every message's header there was an idea to "bake it in" to the header type - and XML serializer happily handles such objects.
namespace TestDto {
public record OutgoingMessageHeader_Orig
{
[XmlElement(ElementName = "record-type-cd")]
public string RecordTypeCode { get; init; }
[XmlElement(ElementName = "record-protocol-version")]
public string RecordProtocolVersion { get { return "3.0"; } set { } }
[XmlElement(ElementName = "record-creation-dt")]
public string RecordCreationDateTime { get; init; }
}
public abstract record OutgoingMessageBase_Orig
{
[XmlElement(ElementName = "header")]
public OutgoingMessageHeader_Orig Header { get; init; }
}
[XmlRoot(ElementName = "record")]
public record OutgoingMessage1_Orig : OutgoingMessageBase_Orig
{
public const string RecordTypeCode = "foo";
[XmlElement(ElementName = "payout-amount")]
public string Comments { get; init; }
}
///////////////////////////////////////////////////////////////////////
public record OutgoingMessageHeader_Slim
{
[XmlElement(ElementName = "record-type-cd")]
public string RecordTypeCode { get; init; }
//[XmlElement(ElementName = "record-protocol-version")]
//public string RecordProtocolVersion { get { return "3.0"; } set { } }
[XmlElement(ElementName = "record-creation-dt")]
public string RecordCreationDateTime { get; init; }
}
public abstract record OutgoingMessageBase_Slim
{
[XmlElement(ElementName = "header")]
public OutgoingMessageHeader_Slim Header { get; init; }
}
[XmlRoot(ElementName = "record")]
public record OutgoingMessage1_Slim : OutgoingMessageBase_Slim
{
public const string RecordTypeCode = "foo";
[XmlElement(ElementName = "payout-amount")]
public string Comments { get; init; }
}
///////////////////////////////////////////////////////////////////////
public record OutgoingMessageHeader_GetInit_FixedGet
{
[XmlElement(ElementName = "record-type-cd")]
public string RecordTypeCode { get; init; }
[XmlElement(ElementName = "record-protocol-version")]
public string RecordProtocolVersion { get { return "3.0"; } init { } }
[XmlElement(ElementName = "record-creation-dt")]
public string RecordCreationDateTime { get; init; }
}
public abstract record OutgoingMessageBase_GetInit_FixedGet
{
[XmlElement(ElementName = "header")]
public OutgoingMessageHeader_GetInit_FixedGet Header { get; init; }
}
[XmlRoot(ElementName = "record")]
public record OutgoingMessage1_GetInit_FixedGet : OutgoingMessageBase_GetInit_FixedGet
{
public const string RecordTypeCode = "foo";
[XmlElement(ElementName = "payout-amount")]
public string Comments { get; init; }
}
///////////////////////////////////////////////////////////////////////
public record OutgoingMessageHeader_GetInit
{
[XmlElement(ElementName = "record-type-cd")]
public string RecordTypeCode { get; init; }
[XmlElement(ElementName = "record-protocol-version")]
public string RecordProtocolVersion { get; init; }
[XmlElement(ElementName = "record-creation-dt")]
public string RecordCreationDateTime { get; init; }
}
public abstract record OutgoingMessageBase_GetInit
{
[XmlElement(ElementName = "header")]
public OutgoingMessageHeader_GetInit Header { get; init; }
}
[XmlRoot(ElementName = "record")]
public record OutgoingMessage1_GetInit : OutgoingMessageBase_GetInit
{
public const string RecordTypeCode = "foo";
[XmlElement(ElementName = "payout-amount")]
public string Comments { get; init; }
}
}
But FsCheck:
- isn't happy with
OutgoingMessage1_Orig
:Header
object has all the default values only - is happy with
OutgoingMessage1_Slim
let f<'a> () =
Arb.generate<'a>
|> sample 10 10
|> printfn "%A"
[<EntryPoint>]
let main args =
f<TestDto.OutgoingMessageHeader_Orig>()
f<TestDto.OutgoingMessage1_Orig>()
printfn ""
f<TestDto.OutgoingMessageHeader_Slim>()
f<TestDto.OutgoingMessage1_Slim>()
printfn ""
f<TestDto.OutgoingMessageHeader_GetInit_FixedGet>()
f<TestDto.OutgoingMessage1_GetInit_FixedGet>()
printfn ""
f<TestDto.OutgoingMessageHeader_GetInit>()
f<TestDto.OutgoingMessage1_GetInit>()
0
Output example:
So I wonder whether there's an option to generate OutgoingMessage1_Orig
or my only option is to reshape the Header.RecordProtocolVersion
?
🤷♂️
Confusing - can you cut down the problem to one or two types please.
You can always write a generator yourself of course, which does whatever you want.
Seems like inheritance is not fully supported:
namespace InheritRelation
{
public record BaseData
{
public int Base1 { get; init; }
public int Base2 { get; init; }
public int Base3 { get; init; }
public int Base4 { get; init; }
public int Base5 { get; init; }
}
public record A : BaseData
{
public int A_prop { get; init; }
}
}
let f<'a> () =
Arb.generate<'a>
|> sample 10 10
|> printfn "%A"
[<EntryPoint>]
let main args =
f<InheritRelation.BaseData>()
f<InheritRelation.A>()
0
This is a sizing issue - try sample 20 10
or sample 100 10
In practice when testing FsCheck will by default increase the size to 100 so you should see both small and large examples there. You can also configure this using Config.StartSize
and EndSize
.
https://github.com/fscheck/FsCheck/blob/master/src/FsCheck/Runner.fs#L361-361
Got it. Thanks for the info and for the fix
👏
Hmf. While that explanation sort of makes sense, what are the combinatoric odds of generating 60 0's in a row?
100% if the size is 0
Am I missing something, isn't the size = 10 and n = 10?
overall size is distributed to properties, so the more properties you have the smaller the size will be for those properties. Same for tuples: https://github.com/fscheck/FsCheck/blob/master/src/FsCheck/ReflectArbitrary.fs#L83
so many properties => smaller size for each of them. Try the above example with a record with one or two properties and at size 10 you'll see some non-zero values.