Skip to content

Commit

Permalink
PT-7293: Add a new Liquid filter 'where' to enable filtering collecti…
Browse files Browse the repository at this point in the history
…ons (#149)

Co-authored-by: artem-dudarev <ad@virtoway.com>
  • Loading branch information
cgenesoni01 and artem-dudarev authored Dec 6, 2023
1 parent d551c07 commit 1c1adac
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using VirtoCommerce.Platform.Core.Common;

namespace VirtoCommerce.NotificationsModule.LiquidRenderer.Filters
{
public static class ArrayFilter
{
/// <summary>
/// Filter the elements of an array by a given condition
/// {% assign filtered = items | where: 'propertyName' '==' 'propertyValue' %}
/// </summary>
/// <param name="input"></param>
/// <param name="propertyName"></param>
/// <param name="operationName"></param>
/// <param name="propertyValue"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static object Where(object input, string propertyName, string operationName, string propertyValue)
{
if (input is not IEnumerable enumerable)
{
throw new ArgumentException("Input is not a collection", nameof(input));
}

var elementType = GetEnumerableElementType(enumerable.GetType());
var property = elementType.GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);

if (property is null)
{
throw new ArgumentException($"Unknown property '{propertyName}'", nameof(propertyName));
}

var parameterX = Expression.Parameter(elementType, "x");
var left = Expression.Property(parameterX, property);
var value = ParsePropertyValue(propertyValue, left.Type);
var right = Expression.Constant(value, left.Type);

BinaryExpression operation;

if (operationName.EqualsInvariant("=="))
{
operation = Expression.Equal(left, right);
}
else if (operationName.EqualsInvariant("!="))
{
operation = Expression.NotEqual(left, right);
}
else if (operationName.EqualsInvariant(">"))
{
operation = Expression.GreaterThan(left, right);
}
else if (operationName.EqualsInvariant(">="))
{
operation = Expression.GreaterThanOrEqual(left, right);
}
else if (operationName.EqualsInvariant("<"))
{
operation = Expression.LessThan(left, right);
}
else if (operationName.EqualsInvariant("<="))
{
operation = Expression.LessThanOrEqual(left, right);
}
else if (operationName.EqualsInvariant("contains"))
{
Expression expression;

if (property.PropertyType == typeof(string))
{
var containsMethod = typeof(string).GetMethods().First(x => x.Name == "Contains");
expression = Expression.Call(left, containsMethod, right);
}
else
{
var containsMethod = typeof(Enumerable).GetMethods()
.First(x => x.Name == "Contains" && x.GetParameters().Length == 2)
.MakeGenericMethod(value.GetType());

expression = Expression.Call(containsMethod, left, right);
}

operation = Expression.Equal(expression, Expression.Constant(true));
}
else
{
throw new ArgumentException($"Unknown operation '{operationName}'", nameof(operationName));
}

var delegateType = typeof(Func<,>).MakeGenericType(elementType, typeof(bool));

// Construct expression: Func<T, bool> = (x) => x.propertyName == propertyValue
var lambda = Expression.Lambda(delegateType, operation, parameterX);

// Find Queryable.Where(Expression<Func<TSource, bool>>) method
var whereMethod = typeof(Queryable).GetMethods()
.Where(x => x.Name == "Where")
.Select(x => new { M = x, P = x.GetParameters() })
.Where(x => x.P.Length == 2 &&
x.P[0].ParameterType.IsGenericType &&
x.P[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) &&
x.P[1].ParameterType.IsGenericType &&
x.P[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>))
.Select(x => new { x.M, A = x.P[1].ParameterType.GetGenericArguments() })
.Where(x => x.A[0].IsGenericType &&
x.A[0].GetGenericTypeDefinition() == typeof(Func<,>))
.Select(x => new { x.M, A = x.A[0].GetGenericArguments() })
.Where(x => x.A[0].IsGenericParameter &&
x.A[1] == typeof(bool))
.Select(x => x.M)
.SingleOrDefault();

var result = whereMethod?.MakeGenericMethod(elementType).Invoke(null, new object[] { enumerable.AsQueryable(), lambda });

return result;
}


private static object ParsePropertyValue(string value, Type valueType)
{
if ((valueType == typeof(int) || valueType == typeof(int?)) && int.TryParse(value, out var intValue))
{
return intValue;
}

if (valueType == typeof(double) && double.TryParse(value, out var doubleValue))
{
return doubleValue;
}

if (valueType == typeof(decimal) && decimal.TryParse(value, out var decimalValue))
{
return decimalValue;
}

if (valueType == typeof(TimeSpan) && TimeSpan.TryParse(value, out var timespan))
{
return timespan;
}

if (valueType == typeof(DateTime) && DateTime.TryParse(value, out var dateTime))
{
return dateTime;
}

if (valueType == typeof(char) && char.TryParse(value, out var charValue))
{
return charValue;
}

if (valueType == typeof(bool) && bool.TryParse(value, out var boolValue))
{
return boolValue;
}

return value;
}

private static Type GetEnumerableElementType(Type type)
{
return type.GetInterfaces()
.Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>))
.Select(x => x.GetGenericArguments()[0])
.FirstOrDefault();
}
}
}
1 change: 1 addition & 0 deletions src/VirtoCommerce.NotificationsModule.Web/Module.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ public void Initialize(IServiceCollection serviceCollection)
{
builder.AddCustomLiquidFilterType(typeof(TranslationFilter));
builder.AddCustomLiquidFilterType(typeof(UrlFilters));
builder.AddCustomLiquidFilterType(typeof(ArrayFilter));
builder.SetRendererLoopLimit(Configuration["Notifications:LiquidRenderOptions:LoopLimit"].TryParse(ModuleConstants.DefaultLiquidRendererLoopLimit));
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public LiquidTemplateRendererUnitTests()
_notificationLayoutSearchService.Setup(x => x.SearchAsync(It.IsAny<NotificationLayoutSearchCriteria>(), It.IsAny<bool>())).ReturnsAsync(notificationLayoutSearchResult);

Func<ITemplateLoader> factory = () => new LayoutTemplateLoader(_notificationLayoutServiceMock.Object);
_liquidTemplateRenderer = new LiquidTemplateRenderer(Options.Create(new LiquidRenderOptions() { CustomFilterTypes = new HashSet<Type> { typeof(UrlFilters), typeof(TranslationFilter) } }), factory, _notificationLayoutSearchService.Object);
_liquidTemplateRenderer = new LiquidTemplateRenderer(Options.Create(new LiquidRenderOptions() { CustomFilterTypes = new HashSet<Type> { typeof(UrlFilters), typeof(TranslationFilter), typeof(ArrayFilter) } }), factory, _notificationLayoutSearchService.Object);

//TODO
if (!AbstractTypeFactory<NotificationScriptObject>.AllTypeInfos.SelectMany(x => x.AllSubclasses).Contains(typeof(NotificationScriptObject)))
Expand Down Expand Up @@ -152,6 +152,43 @@ public async Task RenderAsync_ContextHasLayoutId_LayoutRendered()
Assert.Equal("header test_content footer", result);
}

[Theory]
[InlineData("Group", "==", "Group2", "Item2", "Item4")]
[InlineData("Group", "!=", "Group22", "Item1", "Item4")]
[InlineData("Value", ">", "2", "Item3", "Item5")]
[InlineData("Value", ">=", "2", "Item2", "Item5")]
[InlineData("Value", "<", "4", "Item1", "Item3")]
[InlineData("Value", "<=", "4", "Item1", "Item4")]
[InlineData("Group", "contains", "2", "Item2", "Item5")]
public async Task RenderAsync_FilterArray(string propertyName, string operationName, string propertyValue, string expectedFirstName, string expectedLastName)
{
var filter = $"'{propertyName}' '{operationName}' '{propertyValue}'";

var context = new NotificationRenderContext
{
Template = "{{ filtered = items | where: " + filter + " }}" +
"{{ first_item = filtered | array.first }}" +
"{{ last_item = filtered | array.last }}" +
"First: {{ first_item.name }}, Last: {{ last_item.name }}",
Model = new
{
Items = new[]
{
new { Group = "Group1", Name = "Item1", Value = 1 },
new { Group = "Group2", Name = "Item2", Value = 2 },
new { Group = "Group2", Name = "Item3", Value = 3 },
new { Group = "Group2", Name = "Item4", Value = 4 },
new { Group = "Group22", Name = "Item5", Value = 5 },
}
},
};

var expectedResult = $"First: {expectedFirstName}, Last: {expectedLastName}";

var result = await _liquidTemplateRenderer.RenderAsync(context);
Assert.Equal(expectedResult, result);
}

public static IEnumerable<object[]> TranslateData
{
get
Expand Down

0 comments on commit 1c1adac

Please sign in to comment.