This post demonstrates a way to perform semantic equality for complex object graphs taking advantage of SemanticComparer including Structural Types, Entities, Value Objects, as well as Primitive Types.
The equality algorithm for ComplexType
should use the default equality for record
, number
, text
, version
and value
, while it should use custom equality for os
and entity
:
type ComplexType(entity, value, record, number, text, version, os) =
member this.Entity = entity
member this.Value = value
member this.Record = record
member this.Number = number
member this.Text = text
member this.Version = version
member this.OS = os
The record
is a simple aggregate of named values (an F# Record type) with an explicit implementation of Equals
and no auto-generated comparisons:
[<CustomEquality; NoComparison>]
type StructuralType =
{ Value: int;
Other: string }
override this.Equals(y) =
match y with
| :? StructuralType as other -> (this.Value = other.Value)
| _ -> false
override x.GetHashCode() = hash x.Value
The value
follows value semantics and has no conceptual identity:
type ValueObject(x: int, y: int) =
member this.X = x
member this.Y = y
override this.Equals(other) =
match other with
| :? ValueObject as other ->
this.X = other.X &&
this.Y = other.Y
| _ -> Object.Equals(this, other)
override this.GetHashCode() =
hash this.X ^^^
hash this.Y
The entity
type has a conceptual identity as the following (rather incomplete) implementation demonstrates:
type Entity(name: string) =
member this.Name = name
member this.Id = Guid.NewGuid()
override this.Equals(other) =
match other with
| :? Entity as other -> this.Id = other.Id
| _ -> Object.Equals(this, other)
override this.GetHashCode() = hash this.Id
The remaining types are defined in BCL: version
overrides its Equals method using value semantics while os
represents instances of the OperatingSystem
type which uses its default reference equality.
All the following tests are parameterized with xUnit.net’s [<PropertyData>]
attribute which means that the test data is coming from a property.
The property below yields 3 tests cases:
let RecursiveComparisonTestCases : seq<obj[]> =
seq {
yield
[|
ComplexType(
Entity("abc"),
ValueObject(1, 2),
{ Value = 1;
Other = "foo" },
1,
"Anonymous Text",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Unix,
Version(3, 9, 8)))
ComplexType(
Entity("abc"),
ValueObject(1, 2),
{ Value = 1;
Other = "bar" }, // Difference
1,
"Anonymous Text",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Xbox, // Difference
Version(3, 9, 8)))
true // Expected result
|]
yield
[|
ComplexType(
Entity("abc"),
ValueObject(1, 2),
{ Value = 2;
Other = "foo" },
1,
"123",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Unix,
Version(3, 9, 8)))
ComplexType(
Entity("ABC"), // Difference
ValueObject(1, 2),
{ Value = 2;
Other = "foo" },
1,
"123",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Xbox, // Difference
Version(3, 9, 8)))
true // Expected result
|]
yield
[|
ComplexType(
Entity("abc"),
ValueObject(1, 2),
{ Value = 3;
Other = "foo" },
1,
"Anonymous Text",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Unix,
Version(3, 9, 8)))
ComplexType(
Entity("abc"),
ValueObject(1, 2),
{ Value = 4; // Difference
Other = "foo" },
1,
"Anonymous Text",
Version(4, 0, 0),
OperatingSystem(
PlatformID.Xbox, // Difference
Version(0, 0, 0))) // Difference
false // Expected result
|] }
Semantic equality can be modeled with SemanticComparer, as the following parameterized xUnit.net test demonstrates:
[<Theory; PropertyData("RecursiveComparisonTestCases")>]
let ``Equals returns correct result for ComplexType`` value other expected =
// Fixture setup
let valueObjectComparer() = {
new IMemberComparer with
member this.IsSatisfiedBy(request: PropertyInfo) = true
member this.IsSatisfiedBy(request: FieldInfo) = true
member this.GetHashCode(obj) = hash obj
member this.Equals(x, y) = x.Equals(y) }
let entityComparer() = {
new IMemberComparer with
member this.IsSatisfiedBy(request: PropertyInfo) =
request.PropertyType = typedefof<Entity>
member this.IsSatisfiedBy(request: FieldInfo) =
request.FieldType = typedefof<Entity>
member this.GetHashCode(obj) = hash obj
member this.Equals(x, y) =
StringComparer.OrdinalIgnoreCase.Equals(
(x :?> Entity).Name,
(y :?> Entity).Name) }
let osComparer() = {
new IMemberComparer with
member this.IsSatisfiedBy(request: PropertyInfo) =
request.PropertyType = typedefof<OperatingSystem>
member this.IsSatisfiedBy(request: FieldInfo) =
request.FieldType = typedefof<OperatingSystem>
member this.GetHashCode(obj) = hash obj
member this.Equals(x, y) =
(x :?> OperatingSystem).Version.Equals(
(y :?> OperatingSystem).Version) }
let sut =
SemanticComparer<ComplexType>(
valueObjectComparer(),
entityComparer(),
osComparer())
// Exercise system
let actual = sut.Equals(value, other)
// Verify outcome
Assert.Equal(expected, actual)
// Teardown
SemanticComparer<T>
is a boolean ‘AND’ composite over IMemberComparer instances.valueObjectComparer
for everything except entity
(where it uses entityComparer
) and os
(where it uses osComparer
).IsSatisfiedBy
method of the appropriate IMemberComparer
instance, and then invokes its Equals
method.The described behavior can be also packed into a Custom Assertion.
The idiomatic way of turning a Custom Assertion into a test-specific override of an object’s equality method is called Resemblance.
A Resemblance can be emitted dynamically as the following test demonstrates:
[<Theory; PropertyData("RecursiveComparisonTestCases")>]
let ``Likeness returns correct result for ComplexType`` value other expected =
// (Same setup code as above.)
let likeness =
Likeness<ComplexType>(
value,
SemanticComparer<ComplexType>(
valueObjectComparer(),
entityComparer(),
osComparer()))
let sut = likeness.ToResemblance()
// Exercise system
let actual = sut.Equals(other)
// Verify outcome
Assert.Equal(expected, actual)
// Teardown
The tests require SemanticComparison and xUnit.net data theories. Both can be installed through NuGet:
PM> Install-Package SemanticComparison
PM> Install-Package Xunit.Extensions
For added convinience all the above code is also stored in a Gist.