Skip to content

Commit

Permalink
Add For/Exclude to allow exclusion of members inside a collection (
Browse files Browse the repository at this point in the history
  • Loading branch information
whymatter committed Apr 20, 2022
1 parent 0aa7417 commit 37a241b
Show file tree
Hide file tree
Showing 15 changed files with 558 additions and 9 deletions.
24 changes: 18 additions & 6 deletions Src/FluentAssertions/Common/MemberPath.cs
Expand Up @@ -17,6 +17,8 @@ internal class MemberPath

private string[] segments;

private static readonly MemberPathSegmentEqualityComparer MemberPathSegmentEqualityComparer = new();

public MemberPath(IMember member, string parentPath)
: this(member.ReflectedType, member.DeclaringType, parentPath.Combine(member.Name))
{
Expand Down Expand Up @@ -53,7 +55,7 @@ public bool IsSameAs(MemberPath candidate)
{
string[] candidateSegments = candidate.Segments;

return candidateSegments.SequenceEqual(Segments);
return candidateSegments.SequenceEqual(Segments, MemberPathSegmentEqualityComparer);
}

return false;
Expand All @@ -64,15 +66,22 @@ private bool IsParentOf(MemberPath candidate)
string[] candidateSegments = candidate.Segments;

return candidateSegments.Length > Segments.Length &&
candidateSegments.Take(Segments.Length).SequenceEqual(Segments);
candidateSegments.Take(Segments.Length).SequenceEqual(Segments, MemberPathSegmentEqualityComparer);
}

private bool IsChildOf(MemberPath candidate)
{
string[] candidateSegments = candidate.Segments;

return candidateSegments.Length < Segments.Length
&& candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length));
&& candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length),
MemberPathSegmentEqualityComparer);
}

public MemberPath AsParentCollectionOf(MemberPath nextPath)
{
var extendedDottedPath = dottedPath.Combine(nextPath.dottedPath, "[]");
return new MemberPath(declaringType, nextPath.reflectedType, extendedDottedPath);
}

/// <summary>
Expand All @@ -86,7 +95,7 @@ public bool IsEquivalentTo(string path)
public bool HasSameParentAs(MemberPath path)
{
return Segments.Length == path.Segments.Length
&& GetParentSegments().SequenceEqual(path.GetParentSegments());
&& GetParentSegments().SequenceEqual(path.GetParentSegments(), MemberPathSegmentEqualityComparer);
}

private IEnumerable<string> GetParentSegments() => Segments.Take(Segments.Length - 1);
Expand All @@ -96,6 +105,11 @@ public bool HasSameParentAs(MemberPath path)
/// </summary>
public bool GetContainsSpecificCollectionIndex() => dottedPath.ContainsSpecificCollectionIndex();

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

/// <summary>
/// Returns a copy of the current object as if it represented an un-indexed item in a collection.
/// </summary>
Expand All @@ -104,8 +118,6 @@ public MemberPath WithCollectionAsRoot()
return new MemberPath(reflectedType, declaringType, "[]." + dottedPath);
}

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>
Expand Down
49 changes: 49 additions & 0 deletions Src/FluentAssertions/Common/MemberPathSegmentEqualityComparer.cs
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace FluentAssertions.Common
{
/// <summary>
/// Compares two segments of a <see cref="MemberPath"/>.
/// Sets the <see cref="AnyIndexQualifier"/> equal with any numeric index qualifier.
/// All other comparisons are default string equality.
/// </summary>
internal class MemberPathSegmentEqualityComparer : IEqualityComparer<string>
{
private const string AnyIndexQualifier = "*";
private static readonly Regex IndexQualifierRegex = new(@"^\d+$");

/// <summary>
/// Compares two segments of a <see cref="MemberPath"/>.
/// </summary>
/// <param name="x">Left part of the comparison.</param>
/// <param name="y">Right part of the comparison.</param>
/// <returns>True if segments are equal, false if not.</returns>
public bool Equals(string x, string y)
{
if (x == AnyIndexQualifier)
{
return IsIndexQualifier(y);
}

if (y == AnyIndexQualifier)
{
return IsIndexQualifier(x);
}

return x == y;
}

private static bool IsIndexQualifier(string segment)
=> segment == AnyIndexQualifier || IndexQualifierRegex.IsMatch(segment);

public int GetHashCode(string obj)
{
#if NETCOREAPP2_1_OR_GREATER
return obj.GetHashCode(System.StringComparison.Ordinal);
#else
return obj.GetHashCode();
#endif
}
}
}
12 changes: 11 additions & 1 deletion Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs
Expand Up @@ -2,7 +2,6 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using FluentAssertions.Common;
using FluentAssertions.Equivalency.Execution;
Expand Down Expand Up @@ -40,6 +39,17 @@ public EquivalencyAssertionOptions<TExpectation> Excluding(Expression<Func<TExpe
return this;
}

/// <summary>
/// Selects a collection to define exclusions at.
/// Allows to navigate deeper by using <see cref="For{TNext}"/>.
/// </summary>
public NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(Expression<Func<TExpectation, IEnumerable<TNext>>> expression)
{
var selectionRule = new ExcludeMemberByPathSelectionRule(expression.GetMemberPath());
AddSelectionRule(selectionRule);
return new NestedExclusionOptionBuilder<TExpectation, TNext>(this, selectionRule);
}

/// <summary>
/// Includes the specified member in the equality check.
/// </summary>
Expand Down
46 changes: 46 additions & 0 deletions Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using FluentAssertions.Common;
using FluentAssertions.Equivalency.Selection;

namespace FluentAssertions.Equivalency
{
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
/// <summary>
/// The selected path starting at the first <see cref="EquivalencyAssertionOptions{TExpectation}.For{TNext}"/>.
/// </summary>
private readonly ExcludeMemberByPathSelectionRule currentPathSelectionRule;

private readonly EquivalencyAssertionOptions<TExpectation> capturedAssertionOptions;

internal NestedExclusionOptionBuilder(EquivalencyAssertionOptions<TExpectation> capturedAssertionOptions,
ExcludeMemberByPathSelectionRule currentPathSelectionRule)
{
this.capturedAssertionOptions = capturedAssertionOptions;
this.currentPathSelectionRule = currentPathSelectionRule;
}

/// <summary>
/// Selects a nested property to exclude. This ends the <see cref="For{TNext}"/> chain.
/// </summary>
public EquivalencyAssertionOptions<TExpectation> Exclude(Expression<Func<TCurrent, object>> expression)
{
var nextPath = expression.GetMemberPath();
currentPathSelectionRule.AppendPath(nextPath);
return capturedAssertionOptions;
}

/// <summary>
/// Adds the selected collection to the <see cref="For{TNext}"/> chain.
/// </summary>
public NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(
Expression<Func<TCurrent, IEnumerable<TNext>>> expression)
{
var nextPath = expression.GetMemberPath();
currentPathSelectionRule.AppendPath(nextPath);
return new NestedExclusionOptionBuilder<TExpectation, TNext>(capturedAssertionOptions, currentPathSelectionRule);
}
}
}
Expand Up @@ -8,7 +8,7 @@ namespace FluentAssertions.Equivalency.Selection
/// </summary>
internal class ExcludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule
{
private readonly MemberPath memberToExclude;
private MemberPath memberToExclude;

public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude)
: base(pathToExclude.ToString())
Expand All @@ -23,6 +23,12 @@ public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude)
memberToExclude.IsSameAs(new MemberPath(member, parentPath)));
}

public void AppendPath(MemberPath nextPath)
{
memberToExclude = memberToExclude.AsParentCollectionOf(nextPath);
SetSelectedPath(memberToExclude.ToString());
}

public override string ToString()
{
return "Exclude member " + memberToExclude;
Expand Down
Expand Up @@ -7,7 +7,7 @@ namespace FluentAssertions.Equivalency.Selection
{
internal abstract class SelectMemberByPathSelectionRule : IMemberSelectionRule
{
private readonly string selectedPath;
private string selectedPath;

protected SelectMemberByPathSelectionRule(string selectedPath)
{
Expand All @@ -16,6 +16,11 @@ protected SelectMemberByPathSelectionRule(string selectedPath)

public virtual bool IncludesMembers => false;

protected void SetSelectedPath(string path)
{
this.selectedPath = path;
}

public IEnumerable<IMember> SelectMembers(INode currentNode, IEnumerable<IMember> selectedMembers,
MemberSelectionContext context)
{
Expand Down
Expand Up @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency
public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<System.Collections.Generic.IEnumerable<TExpectation>> AsCollection() { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Excluding(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TExpectation, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Including(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping(string expectationMemberPath, string subjectMemberPath) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expectationMemberPath, System.Linq.Expressions.Expression<System.Func<TSubject, object>> subjectMemberPath) { }
Expand Down Expand Up @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency
Internal = 1,
Public = 2,
}
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Exclude(System.Linq.Expressions.Expression<System.Func<TCurrent, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TCurrent, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
}
public class Node : FluentAssertions.Equivalency.INode
{
public Node() { }
Expand Down
Expand Up @@ -803,6 +803,7 @@ namespace FluentAssertions.Equivalency
public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<System.Collections.Generic.IEnumerable<TExpectation>> AsCollection() { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Excluding(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TExpectation, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Including(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping(string expectationMemberPath, string subjectMemberPath) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expectationMemberPath, System.Linq.Expressions.Expression<System.Func<TSubject, object>> subjectMemberPath) { }
Expand Down Expand Up @@ -971,6 +972,11 @@ namespace FluentAssertions.Equivalency
Internal = 1,
Public = 2,
}
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Exclude(System.Linq.Expressions.Expression<System.Func<TCurrent, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TCurrent, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
}
public class Node : FluentAssertions.Equivalency.INode
{
public Node() { }
Expand Down
Expand Up @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency
public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<System.Collections.Generic.IEnumerable<TExpectation>> AsCollection() { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Excluding(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TExpectation, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Including(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping(string expectationMemberPath, string subjectMemberPath) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expectationMemberPath, System.Linq.Expressions.Expression<System.Func<TSubject, object>> subjectMemberPath) { }
Expand Down Expand Up @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency
Internal = 1,
Public = 2,
}
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Exclude(System.Linq.Expressions.Expression<System.Func<TCurrent, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TCurrent, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
}
public class Node : FluentAssertions.Equivalency.INode
{
public Node() { }
Expand Down
Expand Up @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency
public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<System.Collections.Generic.IEnumerable<TExpectation>> AsCollection() { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Excluding(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TExpectation, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Including(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping(string expectationMemberPath, string subjectMemberPath) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expectationMemberPath, System.Linq.Expressions.Expression<System.Func<TSubject, object>> subjectMemberPath) { }
Expand Down Expand Up @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency
Internal = 1,
Public = 2,
}
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Exclude(System.Linq.Expressions.Expression<System.Func<TCurrent, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TCurrent, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
}
public class Node : FluentAssertions.Equivalency.INode
{
public Node() { }
Expand Down
Expand Up @@ -784,6 +784,7 @@ namespace FluentAssertions.Equivalency
public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<System.Collections.Generic.IEnumerable<TExpectation>> AsCollection() { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Excluding(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TExpectation, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Including(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expression) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping(string expectationMemberPath, string subjectMemberPath) { }
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> WithMapping<TSubject>(System.Linq.Expressions.Expression<System.Func<TExpectation, object>> expectationMemberPath, System.Linq.Expressions.Expression<System.Func<TSubject, object>> subjectMemberPath) { }
Expand Down Expand Up @@ -952,6 +953,11 @@ namespace FluentAssertions.Equivalency
Internal = 1,
Public = 2,
}
public class NestedExclusionOptionBuilder<TExpectation, TCurrent>
{
public FluentAssertions.Equivalency.EquivalencyAssertionOptions<TExpectation> Exclude(System.Linq.Expressions.Expression<System.Func<TCurrent, object>> expression) { }
public FluentAssertions.Equivalency.NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(System.Linq.Expressions.Expression<System.Func<TCurrent, System.Collections.Generic.IEnumerable<TNext>>> expression) { }
}
public class Node : FluentAssertions.Equivalency.INode
{
public Node() { }
Expand Down

0 comments on commit 37a241b

Please sign in to comment.