diff --git a/src/VirtoCommerce.NotificationsModule.LiquidRenderer/Filters/ArrayFilter.cs b/src/VirtoCommerce.NotificationsModule.LiquidRenderer/Filters/ArrayFilter.cs new file mode 100644 index 0000000..3e1b603 --- /dev/null +++ b/src/VirtoCommerce.NotificationsModule.LiquidRenderer/Filters/ArrayFilter.cs @@ -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 + { + /// + /// Filter the elements of an array by a given condition + /// {% assign filtered = items | where: 'propertyName' '==' 'propertyValue' %} + /// + /// + /// + /// + /// + /// + /// + 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 = (x) => x.propertyName == propertyValue + var lambda = Expression.Lambda(delegateType, operation, parameterX); + + // Find Queryable.Where(Expression>) 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(); + } + } +} diff --git a/src/VirtoCommerce.NotificationsModule.Web/Module.cs b/src/VirtoCommerce.NotificationsModule.Web/Module.cs index 5c8d76c..39ab039 100644 --- a/src/VirtoCommerce.NotificationsModule.Web/Module.cs +++ b/src/VirtoCommerce.NotificationsModule.Web/Module.cs @@ -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)); }); } diff --git a/tests/VirtoCommerce.NotificationsModule.Tests/UnitTests/LiquidTemplateRendererUnitTests.cs b/tests/VirtoCommerce.NotificationsModule.Tests/UnitTests/LiquidTemplateRendererUnitTests.cs index 2ffae7a..0c33d21 100644 --- a/tests/VirtoCommerce.NotificationsModule.Tests/UnitTests/LiquidTemplateRendererUnitTests.cs +++ b/tests/VirtoCommerce.NotificationsModule.Tests/UnitTests/LiquidTemplateRendererUnitTests.cs @@ -38,7 +38,7 @@ public LiquidTemplateRendererUnitTests() _notificationLayoutSearchService.Setup(x => x.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(notificationLayoutSearchResult); Func factory = () => new LayoutTemplateLoader(_notificationLayoutServiceMock.Object); - _liquidTemplateRenderer = new LiquidTemplateRenderer(Options.Create(new LiquidRenderOptions() { CustomFilterTypes = new HashSet { typeof(UrlFilters), typeof(TranslationFilter) } }), factory, _notificationLayoutSearchService.Object); + _liquidTemplateRenderer = new LiquidTemplateRenderer(Options.Create(new LiquidRenderOptions() { CustomFilterTypes = new HashSet { typeof(UrlFilters), typeof(TranslationFilter), typeof(ArrayFilter) } }), factory, _notificationLayoutSearchService.Object); //TODO if (!AbstractTypeFactory.AllTypeInfos.SelectMany(x => x.AllSubclasses).Contains(typeof(NotificationScriptObject))) @@ -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 TranslateData { get