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 the 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 test 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 also be 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 convenience, all the above code is also stored in a Gist.