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 mapping properties and/or fields with different names #1742

Merged
Merged
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
39 changes: 37 additions & 2 deletions Src/FluentAssertions/Common/MemberPath.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using FluentAssertions.Equivalency;

namespace FluentAssertions.Common
Expand All @@ -23,9 +23,18 @@ public MemberPath(IMember member, string parentPath)
}

public MemberPath(Type reflectedType, Type declaringType, string dottedPath)
: this(dottedPath)
{
this.reflectedType = reflectedType;
this.declaringType = declaringType;
}

public MemberPath(string dottedPath)
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
{
Guard.ThrowIfArgumentIsNullOrEmpty(
dottedPath, nameof(dottedPath),
"A member path cannot be null or empty");

this.dottedPath = dottedPath;
}

Expand All @@ -40,7 +49,7 @@ public bool IsParentOrChildOf(MemberPath candidate)

public bool IsSameAs(MemberPath candidate)
{
if ((declaringType == candidate.declaringType) || declaringType.IsAssignableFrom(candidate.reflectedType))
if ((declaringType == candidate.declaringType) || declaringType?.IsAssignableFrom(candidate.reflectedType) == true)
{
string[] candidateSegments = candidate.Segments;

Expand All @@ -66,8 +75,34 @@ private bool IsChildOf(MemberPath candidate)
&& candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length));
}

/// <summary>
/// Determines whether the current path is the same as <paramref name="path"/> when ignoring any specific indexes.
/// </summary>
public bool IsEquivalentTo(string path)
{
return path.WithoutSpecificCollectionIndices() == dottedPath.WithoutSpecificCollectionIndices();
}

public bool HasSameParentAs(MemberPath path)
{
return Segments.Length == path.Segments.Length
&& GetParentSegments().SequenceEqual(path.GetParentSegments());
}

private IEnumerable<string> GetParentSegments() => Segments.Take(Segments.Length - 1);

/// <summary>
/// Gets a value indicating whether the current path contains an indexer like `[1]` instead of `[]`.
/// </summary>
public bool GetContainsSpecificCollectionIndex() => dottedPath.ContainsSpecificCollectionIndex();

private string[] Segments => segments ??= dottedPath.Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries);

/// <summary>
/// Returns the name of the member the current path points to without its parent path.
/// </summary>
public string MemberName => Segments.Last();

public override string ToString()
{
return dottedPath;
Expand Down
17 changes: 17 additions & 0 deletions Src/FluentAssertions/Common/StringExtensions.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using FluentAssertions.Formatting;

namespace FluentAssertions.Common
Expand Down Expand Up @@ -41,6 +42,22 @@ public static string IndexedSegmentAt(this string value, int index)
return $"{formattedString} (index {index})".EscapePlaceholders();
}

/// <summary>
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
/// Replaces all numeric indices from a path like "property[0].nested" and returns "property[].nested"
/// </summary>
public static string WithoutSpecificCollectionIndices(this string indexedPath)
{
return Regex.Replace(indexedPath, @"\[\d+\]", "[]");
}

/// <summary>
/// Determines whether a string contains a specific index like `[0]` instead of just `[]`.
/// </summary>
public static bool ContainsSpecificCollectionIndex(this string indexedPath)
{
return Regex.IsMatch(indexedPath, @"\[\d+\]");
}

/// <summary>
/// Replaces all characters that might conflict with formatting placeholders with their escaped counterparts.
/// </summary>
Expand Down
91 changes: 91 additions & 0 deletions Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs
Expand Up @@ -2,9 +2,11 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using FluentAssertions.Common;
using FluentAssertions.Equivalency.Execution;
using FluentAssertions.Equivalency.Matching;
using FluentAssertions.Equivalency.Ordering;
using FluentAssertions.Equivalency.Selection;

Expand Down Expand Up @@ -70,6 +72,95 @@ public EquivalencyAssertionOptions<IEnumerable<TExpectation>> AsCollection()
return new EquivalencyAssertionOptions<IEnumerable<TExpectation>>(
new CollectionMemberAssertionOptionsDecorator(this));
}

/// <summary>
/// Maps a (nested) property or field of type <typeparamref name="TExpectation"/> to
/// a (nested) property or field of <typeparamref name="TSubject"/> using lambda expressions.
/// </summary>
/// <param name="expectationMemberPath">A field or property expression indicating the (nested) member to map from.</param>
/// <param name="subjectMemberPath">A field or property expression indicating the (nested) member to map to.</param>
/// <remarks>
/// The members of the subject and the expectation must have the same parent. Also, indexes in collections are ignored.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(
Expression<Func<TExpectation, object>> expectationMemberPath,
Expression<Func<TSubject, object>> subjectMemberPath)
{
return WithMapping(
expectationMemberPath.GetMemberPath().ToString().WithoutSpecificCollectionIndices(),
subjectMemberPath.GetMemberPath().ToString().WithoutSpecificCollectionIndices());
}

/// <summary>
/// Maps a (nested) property or field of the expectation to a (nested) property or field of the subject using a path string.
/// </summary>
/// <param name="expectationMemberPath">
/// A field or property path indicating the (nested) member to map from in the format <c>Parent.Child.Collection[].Member</c>.
/// </param>
/// <param name="subjectMemberPath">
/// A field or property path indicating the (nested) member to map to in the format <c>Parent.Child.Collection[].Member</c>.
/// </param>
/// <remarks>
/// The members of the subject and the expectation must have the same parent. Also, indexes in collections are not allowed
/// and must be written as "[]". If the types of the members are different, the usual logic applies depending or not
/// if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping(
string expectationMemberPath,
string subjectMemberPath)
{
AddMatchingRule(new MappedPathMatchingRule(expectationMemberPath, subjectMemberPath));

return this;
}

/// <summary>
/// Maps a direct property or field of type <typeparamref name="TNestedExpectation"/> to
/// a direct property or field of <typeparamref name="TNestedSubject"/> using lambda expressions.
/// </summary>
/// <param name="expectationMember">A field or property expression indicating the member to map from.</param>
/// <param name="subjectMember">A field or property expression indicating the member to map to.</param>
/// <remarks>
/// Only direct members of <typeparamref name="TNestedExpectation"/> and <typeparamref name="TNestedSubject"/> can be
/// mapped to each other. Those types can appear anywhere in the object graphs that are being compared.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TNestedExpectation, TNestedSubject>(
Expression<Func<TNestedExpectation, object>> expectationMember,
Expression<Func<TNestedSubject, object>> subjectMember)
{
return WithMapping<TNestedExpectation, TNestedSubject>(
expectationMember.GetMemberPath().ToString(),
subjectMember.GetMemberPath().ToString());
}

/// <summary>
/// Maps a direct property or field of type <typeparamref name="TNestedExpectation"/> to
/// a direct property or field of <typeparamref name="TNestedSubject"/> using member names.
/// </summary>
/// <param name="expectationMemberName">A field or property name indicating the member to map from.</param>
/// <param name="subjectMemberName">A field or property name indicating the member to map to.</param>
/// <remarks>
/// Only direct members of <typeparamref name="TNestedExpectation"/> and <typeparamref name="TNestedSubject"/> can be
/// mapped to each other, so no <c>.</c> or <c>[]</c> are allowed.
/// Those types can appear anywhere in the object graphs that are being compared.
/// If the types of the members are different, the usual logic applies depending or not if conversion options were specified.
/// Fields can be mapped to properties and vice-versa.
/// </remarks>
public EquivalencyAssertionOptions<TExpectation> WithMapping<TNestedExpectation, TNestedSubject>(
string expectationMemberName,
string subjectMemberName)
{
AddMatchingRule(new MappedMemberMatchingRule<TNestedExpectation, TNestedSubject>(
expectationMemberName,
subjectMemberName));

return this;
}
}

/// <summary>
Expand Down
14 changes: 13 additions & 1 deletion Src/FluentAssertions/Equivalency/INode.cs
Expand Up @@ -16,7 +16,10 @@ public interface INode
/// <summary>
/// Gets the name of this node.
/// </summary>
string Name { get; }
/// <example>
/// "Property2"
/// </example>
string Name { get; set; }
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Gets the type of this node.
Expand All @@ -26,11 +29,17 @@ public interface INode
/// <summary>
/// Gets the path from the root object UNTIL the current node, separated by dots or index/key brackets.
/// </summary>
/// <example>
/// "Parent[0].Property2"
/// </example>
string Path { get; }

/// <summary>
/// Gets the full path from the root object up to and including the name of the node.
/// </summary>
/// <example>
/// "Parent[0]"
/// </example>
string PathAndName { get; }

/// <summary>
Expand All @@ -41,6 +50,9 @@ public interface INode
/// <summary>
/// Gets the path including the description of the subject.
/// </summary>
/// <example>
/// "property subject.Parent[0].Property2"
/// </example>
string Description { get; }

/// <summary>
Expand Down
@@ -0,0 +1,52 @@
using System;
using System.Text.RegularExpressions;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency.Matching
{
/// <summary>
/// Allows mapping a member (property or field) of the expectation to a differently named member
/// of the subject-under-test using a member name and the target type.
/// </summary>
internal class MappedMemberMatchingRule<TExpectation, TSubject> : IMemberMatchingRule
{
private readonly string expectationMemberName;
private readonly string subjectMemberName;

public MappedMemberMatchingRule(string expectationMemberName, string subjectMemberName)
{
if (Regex.IsMatch(expectationMemberName, @"[\.\[\]]"))
{
throw new ArgumentException("The expectation's member name cannot be a nested path");
}

if (Regex.IsMatch(subjectMemberName, $@"[\.\[\]]"))
{
throw new ArgumentException("The subject's member name cannot be a nested path");
}

this.expectationMemberName = expectationMemberName;
this.subjectMemberName = subjectMemberName;
}

public IMember Match(IMember expectedMember, object subject, INode parent, IEquivalencyAssertionOptions options)
{
if (parent.Type.IsSameOrInherits(typeof(TExpectation)) && subject is TSubject)
{
if (expectedMember.Name == expectationMemberName)
{
var member = MemberFactory.Find(subject, subjectMemberName, expectedMember.Type, parent);
if (member is null)
{
throw new ArgumentException(
$"Subject of type {typeof(TSubject)} does not have member {subjectMemberName}");
}

return member;
}
}

return null;
}
}
}
@@ -0,0 +1,48 @@
using System;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency.Matching
{
/// <summary>
/// Allows mapping a member (property or field) of the expectation to a differently named member
/// of the subject-under-test using a nested member path in the form of "Parent.NestedCollection[].Member"
/// </summary>
internal class MappedPathMatchingRule : IMemberMatchingRule
{
private readonly MemberPath expectationPath;
private readonly MemberPath subjectPath;

public MappedPathMatchingRule(string expectationMemberPath, string subjectMemberPath)
{
expectationPath = new MemberPath(expectationMemberPath);
subjectPath = new MemberPath(subjectMemberPath);

if (expectationPath.GetContainsSpecificCollectionIndex() || subjectPath.GetContainsSpecificCollectionIndex())
{
throw new ArgumentException("Mapping properties containing a collection index must use the [] format without specific index.");
}

if (!expectationPath.HasSameParentAs(subjectPath))
{
throw new ArgumentException("The member paths must have the same parent.");
}
}

public IMember Match(IMember expectedMember, object subject, INode parent, IEquivalencyAssertionOptions options)
{
if (expectationPath.IsEquivalentTo(expectedMember.PathAndName))
{
var member = MemberFactory.Find(subject, subjectPath.MemberName, expectedMember.Type, parent);
if (member is null)
{
throw new ArgumentException(
$"Subject of type {subject?.GetType().Name} does not have member {subjectPath.MemberName}");
dennisdoomen marked this conversation as resolved.
Show resolved Hide resolved
}

return member;
}

return null;
}
}
}
13 changes: 13 additions & 0 deletions Src/FluentAssertions/Equivalency/MemberFactory.cs
@@ -1,5 +1,6 @@
using System;
using System.Reflection;
using FluentAssertions.Common;

namespace FluentAssertions.Equivalency
{
Expand All @@ -19,5 +20,17 @@ public static IMember Create(MemberInfo memberInfo, INode parent)

throw new NotSupportedException($"Don't know how to deal with a {memberInfo.MemberType}");
}

internal static IMember Find(object target, string memberName, Type preferredMemberType, INode parent)
{
PropertyInfo property = target.GetType().FindProperty(memberName, preferredMemberType);
jnyrup marked this conversation as resolved.
Show resolved Hide resolved
if ((property is not null) && !property.IsIndexer())
{
return new Property(property, parent);
}

FieldInfo field = target.GetType().FindField(memberName, preferredMemberType);
return (field is not null) ? new Field(field, parent) : null;
}
}
}
2 changes: 1 addition & 1 deletion Src/FluentAssertions/Equivalency/Node.cs
Expand Up @@ -21,7 +21,7 @@ public class Node : INode

public string PathAndName => Path.Combine(Name);

public string Name { get; protected set; }
public string Name { get; set; }

public virtual string Description => $"{GetSubjectId().Combine(PathAndName)}";

Expand Down
Expand Up @@ -822,7 +822,7 @@ protected TSelf AddSelectionRule(IMemberSelectionRule selectionRule)
return (TSelf)this;
}

private TSelf AddMatchingRule(IMemberMatchingRule matchingRule)
protected TSelf AddMatchingRule(IMemberMatchingRule matchingRule)
{
matchingRules.Insert(0, matchingRule);
return (TSelf)this;
Expand Down