Skip to content

Commit

Permalink
Allow anonymous object for selecting fields/properties at Exclude a…
Browse files Browse the repository at this point in the history
…nd `Include` (fluentassertions#2488)
  • Loading branch information
IT-VBFK authored Nov 11, 2024
1 parent e125412 commit f8700b2
Show file tree
Hide file tree
Showing 9 changed files with 602 additions and 21 deletions.
62 changes: 49 additions & 13 deletions Src/FluentAssertions/Common/ExpressionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,22 @@ private static MemberInfo AttemptToGetMemberInfoFromExpression<T, TValue>(Expres
(((expression.Body as UnaryExpression)?.Operand ?? expression.Body) as MemberExpression)?.Member;

/// <summary>
/// Gets a dotted path of property names representing the property expression, including the declaring type.
/// Gets one or more dotted paths of property names representing the property expression, including the declaring type.
/// </summary>
/// <example>
/// E.g. Parent.Child.Sibling.Name.
/// E.g. ["Parent.Child.Sibling.Name"] or ["A.Dotted.Path1", "A.Dotted.Path2"].
/// </example>
/// <exception cref="ArgumentNullException"><paramref name="expression"/> is <see langword="null"/>.</exception>
#pragma warning disable MA0051
public static MemberPath GetMemberPath<TDeclaringType, TPropertyType>(
public static IEnumerable<MemberPath> GetMemberPaths<TDeclaringType, TPropertyType>(
this Expression<Func<TDeclaringType, TPropertyType>> expression)
#pragma warning restore MA0051
{
Guard.ThrowIfArgumentIsNull(expression, nameof(expression), "Expected an expression, but found <null>.");

var segments = new List<string>();
var declaringTypes = new List<Type>();
string singlePath = null;
List<string> selectors = [];
List<Type> declaringTypes = [];
Expression node = expression;

while (node is not null)
Expand All @@ -68,7 +69,7 @@ public static MemberPath GetMemberPath<TDeclaringType, TPropertyType>(
var memberExpression = (MemberExpression)node;
node = memberExpression.Expression;

segments.Add(memberExpression.Member.Name);
singlePath = $"{memberExpression.Member.Name}.{singlePath}";
declaringTypes.Add(memberExpression.Member.DeclaringType);
break;

Expand All @@ -77,7 +78,7 @@ public static MemberPath GetMemberPath<TDeclaringType, TPropertyType>(
var indexExpression = (ConstantExpression)binaryExpression.Right;
node = binaryExpression.Left;

segments.Add($"[{indexExpression.Value}]");
singlePath = $"[{indexExpression.Value}].{singlePath}";
break;

case ExpressionType.Parameter:
Expand All @@ -87,29 +88,64 @@ public static MemberPath GetMemberPath<TDeclaringType, TPropertyType>(
case ExpressionType.Call:
var methodCallExpression = (MethodCallExpression)node;

if (methodCallExpression is not { Method.Name: "get_Item", Arguments: [ConstantExpression argumentExpression] })
if (methodCallExpression is not
{ Method.Name: "get_Item", Arguments: [ConstantExpression argumentExpression] })
{
throw new ArgumentException(GetUnsupportedExpressionMessage(expression.Body), nameof(expression));
}

node = methodCallExpression.Object;
segments.Add($"[{argumentExpression.Value}]");
singlePath = $"[{argumentExpression.Value}].{singlePath}";
break;
case ExpressionType.New:
var newExpression = (NewExpression)node;

foreach (Expression member in newExpression.Arguments)
{
var expr = member.ToString();
selectors.Add(expr[expr.IndexOf('.', StringComparison.Ordinal)..]);
declaringTypes.Add(((MemberExpression)member).Member.DeclaringType);
}

node = null;
break;

default:
throw new ArgumentException(GetUnsupportedExpressionMessage(expression.Body), nameof(expression));
}
}

// If any members were accessed in the expression, the first one found is the last member.
Type declaringType = declaringTypes.FirstOrDefault() ?? typeof(TDeclaringType);

IEnumerable<string> reversedSegments = segments.AsEnumerable().Reverse();
string segmentPath = string.Join(".", reversedSegments);
if (singlePath is null)
{
#if NET47 || NETSTANDARD2_0
return selectors.Select(selector =>
GetNewInstance<TDeclaringType>(declaringType, selector)).ToList();
#else
return selectors.ConvertAll(selector =>
GetNewInstance<TDeclaringType>(declaringType, selector));
#endif
}

return new MemberPath(typeof(TDeclaringType), declaringType, segmentPath.Replace(".[", "[", StringComparison.Ordinal));
return [GetNewInstance<TDeclaringType>(declaringType, singlePath)];
}

private static MemberPath GetNewInstance<TReflectedType>(Type declaringType, string dottedPath) =>
new(typeof(TReflectedType), declaringType, dottedPath.Trim('.').Replace(".[", "[", StringComparison.Ordinal));

/// <summary>
/// Gets the first dotted path of property names collected by <see cref="GetMemberPaths{TDeclaringType,TPropertyType}"/>
/// from a given property expression, including the declaring type.
/// </summary>
/// <example>
/// E.g. Parent.Child.Sibling.Name.
/// </example>
/// <exception cref="ArgumentNullException"><paramref name="expression"/> is <see langword="null"/>.</exception>
public static MemberPath GetMemberPath<TDeclaringType, TPropertyType>(
this Expression<Func<TDeclaringType, TPropertyType>> expression) =>
expression.GetMemberPaths().First();

/// <summary>
/// Validates that the expression can be used to construct a <see cref="MemberPath"/>.
/// </summary>
Expand Down
19 changes: 14 additions & 5 deletions Src/FluentAssertions/Equivalency/EquivalencyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public EquivalencyOptions(IEquivalencyOptions defaults)
/// </summary>
public EquivalencyOptions<TExpectation> Excluding(Expression<Func<TExpectation, object>> expression)
{
AddSelectionRule(new ExcludeMemberByPathSelectionRule(expression.GetMemberPath()));
foreach (var memberPath in expression.GetMemberPaths())
{
AddSelectionRule(new ExcludeMemberByPathSelectionRule(memberPath));
}

return this;
}

Expand All @@ -40,9 +44,8 @@ public EquivalencyOptions<TExpectation> Excluding(Expression<Func<TExpectation,
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);
return new NestedExclusionOptionBuilder<TExpectation, TNext>(
this, new ExcludeMemberByPathSelectionRule(expression.GetMemberPath()));
}

/// <summary>
Expand All @@ -53,7 +56,11 @@ public NestedExclusionOptionBuilder<TExpectation, TNext> For<TNext>(
/// </remarks>
public EquivalencyOptions<TExpectation> Including(Expression<Func<TExpectation, object>> expression)
{
AddSelectionRule(new IncludeMemberByPathSelectionRule(expression.GetMemberPath()));
foreach (var memberPath in expression.GetMemberPaths())
{
AddSelectionRule(new IncludeMemberByPathSelectionRule(memberPath));
}

return this;
}

Expand All @@ -77,10 +84,12 @@ public EquivalencyOptions<TExpectation> WithoutStrictOrderingFor(
Expression<Func<TExpectation, object>> expression)
{
string expressionMemberPath = expression.GetMemberPath().ToString();

OrderingRules.Add(new PathBasedOrderingRule(expressionMemberPath)
{
Invert = true
});

return this;
}

Expand Down
10 changes: 8 additions & 2 deletions Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ internal NestedExclusionOptionBuilder(EquivalencyOptions<TExpectation> capturedO
/// </summary>
public EquivalencyOptions<TExpectation> Exclude(Expression<Func<TCurrent, object>> expression)
{
var nextPath = expression.GetMemberPath();
currentPathSelectionRule.AppendPath(nextPath);
var currentSelectionPath = currentPathSelectionRule.CurrentPath;

foreach (var path in expression.GetMemberPaths())
{
var newPath = currentSelectionPath.AsParentCollectionOf(path);
capturedOptions.AddSelectionRule(new ExcludeMemberByPathSelectionRule(newPath));
}

return capturedOptions;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public void AppendPath(MemberPath nextPath)
memberToExclude = memberToExclude.AsParentCollectionOf(nextPath);
}

public MemberPath CurrentPath => memberToExclude;

public override string ToString()
{
return "Exclude member " + memberToExclude;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,7 @@ private void RemoveSelectionRule<T>()
selectionRules.RemoveAll(selectionRule => selectionRule is T);
}

protected TSelf AddSelectionRule(IMemberSelectionRule selectionRule)
protected internal TSelf AddSelectionRule(IMemberSelectionRule selectionRule)
{
selectionRules.Add(selectionRule);
return (TSelf)this;
Expand Down
Loading

0 comments on commit f8700b2

Please sign in to comment.