diff --git a/Ctoss.Example/Ctoss.Example.csproj b/Ctoss.Example/Ctoss.Example.csproj new file mode 100644 index 0000000..a17122b --- /dev/null +++ b/Ctoss.Example/Ctoss.Example.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + Exe + + + + + + + diff --git a/Ctoss.Example/Program.cs b/Ctoss.Example/Program.cs new file mode 100644 index 0000000..f3c2619 --- /dev/null +++ b/Ctoss.Example/Program.cs @@ -0,0 +1,36 @@ +using Ctoss; +using Ctoss.Example; + +const string jsonString = """ + { + "tin": { + "filterType": "date", + "condition1": { + "filterType": "date", + "type": "inRange", + "dateFrom": "10/10/2002", + "dateTo": "10/12/2020" + }, + "conditions": [ + { + "filterType": "date", + "type": "inRange", + "date": "10/10/2002", + "dateTo": "10/12/2020" + } + ] + } + } + """; + +var filterBuilder = new FilterBuilder(); +var expr = filterBuilder.GetExpression(jsonString); +Console.WriteLine(expr); + +namespace Ctoss.Example +{ + class Entity + { + public DateTime Tin { get; set; } + } +} diff --git a/Ctoss.Tests/Ctoss.Tests.csproj b/Ctoss.Tests/Ctoss.Tests.csproj new file mode 100644 index 0000000..9cd9fab --- /dev/null +++ b/Ctoss.Tests/Ctoss.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/Ctoss.Tests/DateFilterTests.cs b/Ctoss.Tests/DateFilterTests.cs new file mode 100644 index 0000000..43b0548 --- /dev/null +++ b/Ctoss.Tests/DateFilterTests.cs @@ -0,0 +1,235 @@ +using Ctoss.Models; +using Ctoss.Models.Conditions; +using Ctoss.Models.Enums; +using Ctoss.Tests.Models; + +namespace Ctoss.Tests; + +public class DateFilterTests +{ + private readonly FilterBuilder _filterBuilder = new(); + + private readonly List _testEntities = + [ + new TestEntity + { + NumericProperty = 10, StringProperty = "abc", DateTimeProperty = new DateTime(2022, 1, 1) + }, + new TestEntity + { + NumericProperty = 20, StringProperty = "def", DateTimeProperty = new DateTime(2023, 2, 2) + }, + new TestEntity + { + NumericProperty = 30, StringProperty = "ghi", DateTimeProperty = new DateTime(2024, 3, 3) + } + ]; + + [Fact] + public void DateFilter_Equals_Success() + { + var condition = new DateFilterCondition + { + DateFrom = "01/01/2022", + FilterType = "date", + Type = DateFilterOptions.Equals + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(new DateTime(2022, 1, 1), result.First().DateTimeProperty); + } + + [Fact] + public void DateFilter_GreaterThen_Success() + { + var condition = new DateFilterCondition + { + DateFrom = "02/02/2023", + FilterType = "date", + Type = DateFilterOptions.GreaterThen + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(new DateTime(2024, 3, 3), result.First().DateTimeProperty); + } + + [Fact] + public void DateFilter_Blank_Success() + { + var condition = new DateFilterCondition + { + FilterType = "date", + Type = DateFilterOptions.Blank + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void DateFilter_LessThen_Success() + { + var condition = new DateFilterCondition + { + DateFrom = "01/01/2023", + FilterType = "date", + Type = DateFilterOptions.LessThen + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(new DateTime(2022, 1, 1), result.First().DateTimeProperty); + } + + [Fact] + public void DateFilter_NotBlank_Success() + { + var condition = new DateFilterCondition + { + FilterType = "date", + Type = DateFilterOptions.NotBlank + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void DateFilter_InRange_Success() + { + var condition = new DateFilterCondition + { + DateFrom = "06/06/2021", + DateTo = "09/09/2024", + FilterType = "date", + Type = DateFilterOptions.InRange + }; + + var filter = new NumberFilter + { + FilterType = "date", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void DateFilter_NotEquals_Success() + { + var condition1 = new DateFilterCondition + { + DateFrom = "01/01/2022", + FilterType = "date", + Type = DateFilterOptions.NotEquals + }; + var condition2 = new DateFilterCondition + { + DateFrom = "03/03/2024", + FilterType = "date", + Type = DateFilterOptions.NotEquals + }; + + var filter = new NumberFilter + { + FilterType = "date", + Operator = Operator.And, + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List { condition1, condition2 } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(new DateTime(2023, 02, 02), result.First().DateTimeProperty); + } + + [Fact] + public void DateFilter_Composed_Success() + { + var condition1 = new DateFilterCondition + { + DateFrom = "01/01/2022", + FilterType = "date", + Type = DateFilterOptions.NotEquals + }; + + var condition2 = new DateFilterCondition + { + DateFrom = "03/03/2024", + FilterType = "date", + Type = DateFilterOptions.LessThen + }; + + var filter = new NumberFilter + { + Operator = Operator.And, + FilterType = "date", + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List + { + condition1, condition2 + } + }; + + var expr = _filterBuilder.GetExpression("DateTimeProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(new DateTime(2023, 02, 02), result.First().DateTimeProperty); + } +} diff --git a/Ctoss.Tests/Models/TestEntity.cs b/Ctoss.Tests/Models/TestEntity.cs new file mode 100644 index 0000000..9cd7b91 --- /dev/null +++ b/Ctoss.Tests/Models/TestEntity.cs @@ -0,0 +1,8 @@ +namespace Ctoss.Tests.Models; + +public class TestEntity +{ + public string StringProperty { get; set; } = null!; + public DateTime DateTimeProperty { get; set; } + public int NumericProperty { get; set; } +} diff --git a/Ctoss.Tests/NumberFilterTests.cs b/Ctoss.Tests/NumberFilterTests.cs new file mode 100644 index 0000000..8765150 --- /dev/null +++ b/Ctoss.Tests/NumberFilterTests.cs @@ -0,0 +1,240 @@ +using Ctoss.Models; +using Ctoss.Models.Conditions; +using Ctoss.Models.Enums; +using Ctoss.Tests.Models; + +namespace Ctoss.Tests; + +public class NumberFilterTests +{ + private readonly FilterBuilder _filterBuilder = new(); + + private readonly List _testEntities = + [ + new TestEntity + { + NumericProperty = 10, StringProperty = "abc", DateTimeProperty = new DateTime(2022, 1, 1) + }, + new TestEntity + { + NumericProperty = 20, StringProperty = "def", DateTimeProperty = new DateTime(2023, 2, 2) + }, + new TestEntity + { + NumericProperty = 30, StringProperty = "ghi", DateTimeProperty = new DateTime(2024, 3, 3) + } + ]; + + [Fact] + public void NumericFilter_Equals_Success() + { + var condition = new NumberFilterCondition + { + Filter = 10, + FilterType = "number", + Type = NumberFilterOptions.Equals + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(10, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_GreaterThen_Success() + { + var condition = new NumberFilterCondition + { + Filter = 20, + FilterType = "number", + Type = NumberFilterOptions.GreaterThan + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(30, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_GreaterThenOrEquals_Success() + { + var condition = new NumberFilterCondition + { + Filter = 30, + FilterType = "number", + Type = NumberFilterOptions.GreaterThanOrEqual + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(30, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_LessThen_Success() + { + var condition = new NumberFilterCondition + { + Filter = 20, + FilterType = "number", + Type = NumberFilterOptions.LessThan + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(10, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_LessThenOrEquals_Success() + { + var condition = new NumberFilterCondition + { + Filter = 10, + FilterType = "number", + Type = NumberFilterOptions.LessThanOrEqual + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(10, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_InRange_Success() + { + var condition = new NumberFilterCondition + { + Filter = 0, + FilterTo = 12, + FilterType = "number", + Type = NumberFilterOptions.InRange + }; + + var filter = new NumberFilter + { + FilterType = "number", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(10, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_NotEquals_Success() + { + var condition1 = new NumberFilterCondition + { + Filter = 10, + FilterType = "number", + Type = NumberFilterOptions.NotEquals + }; + var condition2 = new NumberFilterCondition + { + Filter = 20, + FilterType = "number", + Type = NumberFilterOptions.NotEquals + }; + + var filter = new NumberFilter + { + FilterType = "number", + Operator = Operator.And, + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List { condition1, condition2 } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(30, result.First().NumericProperty); + } + + [Fact] + public void NumericFilter_Composed_Success() + { + var condition1 = new NumberFilterCondition + { + Filter = 25, + FilterType = "number", + Type = NumberFilterOptions.LessThan + }; + + var condition2 = new NumberFilterCondition + { + Filter = 10, + FilterType = "number", + Type = NumberFilterOptions.NotEquals + }; + + var filter = new NumberFilter + { + Operator = Operator.And, + FilterType = "number", + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List + { + condition1, condition2 + } + }; + + var expr = _filterBuilder.GetExpression("NumericProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal(20, result.First().NumericProperty); + } +} diff --git a/Ctoss.Tests/TextFilterTests.cs b/Ctoss.Tests/TextFilterTests.cs new file mode 100644 index 0000000..135f31b --- /dev/null +++ b/Ctoss.Tests/TextFilterTests.cs @@ -0,0 +1,213 @@ +using Ctoss.Models; +using Ctoss.Models.Conditions; +using Ctoss.Models.Enums; +using Ctoss.Tests.Models; + +namespace Ctoss.Tests; + +public class TextFilterTests +{ + private readonly FilterBuilder _filterBuilder = new(); + + private readonly List _testEntities = + [ + new TestEntity + { + NumericProperty = 10, StringProperty = "abc", DateTimeProperty = new DateTime(2022, 1, 1) + }, + new TestEntity + { + NumericProperty = 20, StringProperty = "def", DateTimeProperty = new DateTime(2023, 2, 2) + }, + new TestEntity + { + NumericProperty = 30, StringProperty = "ghi", DateTimeProperty = new DateTime(2024, 3, 3) + } + ]; + + [Fact] + public void TextFilter_Equals_Success() + { + var condition = new TextFilterCondition + { + Filter = "abc", + FilterType = "text", + Type = TextFilterOptions.Equals + }; + + var filter = new NumberFilter + { + FilterType = "text", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("abc", result.First().StringProperty); + } + + [Fact] + public void TextFilter_StartsWith_Success() + { + var condition = new TextFilterCondition + { + Filter = "a", + FilterType = "text", + Type = TextFilterOptions.StartsWith + }; + + var filter = new NumberFilter + { + FilterType = "text", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("abc", result.First().StringProperty); + } + + [Fact] + public void TextFilter_EndsWith_Success() + { + var condition = new TextFilterCondition + { + Filter = "c", + FilterType = "text", + Type = TextFilterOptions.EndsWith + }; + + var filter = new NumberFilter + { + FilterType = "text", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("abc", result.First().StringProperty); + } + + [Fact] + public void TextFilter_NotBlank_Success() + { + var condition = new TextFilterCondition + { + FilterType = "text", + Type = TextFilterOptions.NotBlank + }; + + var filter = new NumberFilter + { + FilterType = "text", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Equal("abc", result.First().StringProperty); + } + + [Fact] + public void TextFilter__Success() + { + var condition = new TextFilterCondition + { + Filter = "ab", + FilterType = "text", + Type = TextFilterOptions.Contains + }; + + var filter = new NumberFilter + { + FilterType = "text", + Condition1 = condition, + Conditions = new List { condition } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("abc", result.First().StringProperty); + } + + [Fact] + public void TextFilter_NotEquals_Success() + { + var condition1 = new TextFilterCondition + { + Filter = "abc", + FilterType = "text", + Type = TextFilterOptions.NotEquals + }; + var condition2 = new TextFilterCondition + { + Filter = "ghi", + FilterType = "text", + Type = TextFilterOptions.NotEquals + }; + + var filter = new NumberFilter + { + FilterType = "text", + Operator = Operator.And, + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List { condition1, condition2 } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("def", result.First().StringProperty); + } + + [Fact] + public void TextFilter_Composed_Success() + { + var condition1 = new TextFilterCondition + { + Filter = "def", + FilterType = "text", + Type = TextFilterOptions.NotEquals + }; + + var condition2 = new TextFilterCondition + { + Filter = "a", + FilterType = "text", + Type = TextFilterOptions.StartsWith + }; + + var filter = new NumberFilter + { + Operator = Operator.And, + FilterType = "text", + Condition1 = condition1, + Condition2 = condition2, + Conditions = new List + { + condition1, condition2 + } + }; + + var expr = _filterBuilder.GetExpression("StringProperty", filter)!; + var result = _testEntities.AsQueryable().Where(expr).ToList(); + + Assert.Single(result); + Assert.Equal("abc", result.First().StringProperty); + } +} diff --git a/Ctoss.sln b/Ctoss.sln new file mode 100644 index 0000000..da16eef --- /dev/null +++ b/Ctoss.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ctoss", "Ctoss\Ctoss.csproj", "{F241585F-D3A7-4573-9D21-CA3526270F05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ctoss.Example", "Ctoss.Example\Ctoss.Example.csproj", "{BF1FB2EA-7493-4CB0-9790-EDB4487D0353}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ctoss.Tests", "Ctoss.Tests\Ctoss.Tests.csproj", "{E4AB8A1B-2057-499E-BB12-28D61A5815BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F241585F-D3A7-4573-9D21-CA3526270F05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F241585F-D3A7-4573-9D21-CA3526270F05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F241585F-D3A7-4573-9D21-CA3526270F05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F241585F-D3A7-4573-9D21-CA3526270F05}.Release|Any CPU.Build.0 = Release|Any CPU + {BF1FB2EA-7493-4CB0-9790-EDB4487D0353}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF1FB2EA-7493-4CB0-9790-EDB4487D0353}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF1FB2EA-7493-4CB0-9790-EDB4487D0353}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF1FB2EA-7493-4CB0-9790-EDB4487D0353}.Release|Any CPU.Build.0 = Release|Any CPU + {E4AB8A1B-2057-499E-BB12-28D61A5815BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4AB8A1B-2057-499E-BB12-28D61A5815BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4AB8A1B-2057-499E-BB12-28D61A5815BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4AB8A1B-2057-499E-BB12-28D61A5815BE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Ctoss/Ctoss.csproj b/Ctoss/Ctoss.csproj new file mode 100644 index 0000000..5622bbb --- /dev/null +++ b/Ctoss/Ctoss.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + enable + enable + Ctoss + + + diff --git a/Ctoss/Extensions/ExpressionExtensions.cs b/Ctoss/Extensions/ExpressionExtensions.cs new file mode 100644 index 0000000..6cd5994 --- /dev/null +++ b/Ctoss/Extensions/ExpressionExtensions.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; + +namespace Ctoss.Extensions; + +/// +/// Provides extension methods for combining expressions. +/// +public static class ExpressionExtensions +{ + /// + /// Combines two expressions using a logical AND operation. + /// + /// The type of the entity. + /// The left expression. + /// The right expression. + /// An expression that represents the logical AND of the input expressions. + public static Expression> AndAlso( + this Expression> left, + Expression> right) + { + return left.Compose(right, Expression.AndAlso); + } + + /// + /// Combines two expressions using a logical OR operation. + /// + /// The type of the entity. + /// The left expression. + /// The right expression. + /// An expression that represents the logical OR of the input expressions. + public static Expression> OrElse( + this Expression> left, + Expression> right) + { + return left.Compose(right, Expression.OrElse); + } + + /// + /// Composes two expressions using a specified merge function. + /// + /// The type of the delegate. + /// The left expression. + /// The right expression. + /// The function to merge the expressions. + /// An expression that represents the merged expressions. + private static Expression Compose( + this Expression left, + Expression right, + Func merge) + { + var map = left.Parameters + .Select((expr, index) => new { Expression = expr, Parameter = right.Parameters[index] }) + .ToDictionary(p => p.Parameter, p => p.Expression); + + var rightBody = ParameterRebinder.ReplaceParameters(map, right.Body); + + return Expression.Lambda(merge(left.Body, rightBody), left.Parameters); + } +} diff --git a/Ctoss/Extensions/ParameterRebinder.cs b/Ctoss/Extensions/ParameterRebinder.cs new file mode 100644 index 0000000..6f4c171 --- /dev/null +++ b/Ctoss/Extensions/ParameterRebinder.cs @@ -0,0 +1,45 @@ +using System.Linq.Expressions; + +namespace Ctoss.Extensions; + +/// +/// Rebinds parameters in expressions to new parameters. +/// +public sealed class ParameterRebinder : ExpressionVisitor +{ + private readonly IDictionary _map; + + /// + /// Initializes a new instance of the class with the specified parameter map. + /// + /// A dictionary that maps original parameters to replacement parameters. + private ParameterRebinder(IDictionary? map) + { + _map = map ?? new Dictionary(); + } + + /// + /// Replaces the parameters in the given expression according to the specified map. + /// + /// A dictionary that maps original parameters to replacement parameters. + /// The expression in which to replace parameters. + /// An expression with the parameters replaced. + public static Expression ReplaceParameters( + IDictionary map, + Expression expression) + { + return new ParameterRebinder(map).Visit(expression); + } + + /// + /// Visits the nodes in the expression tree and replaces them according to the map. + /// + /// The parameter expression to visit. + /// The modified expression, if the parameter is replaced; otherwise, the original expression. + protected override Expression VisitParameter(ParameterExpression node) + { + if (_map.TryGetValue(node, out var replacement)) node = replacement; + + return base.VisitParameter(node); + } +} diff --git a/Ctoss/FilterBuilder.cs b/Ctoss/FilterBuilder.cs new file mode 100644 index 0000000..4ab1ed3 --- /dev/null +++ b/Ctoss/FilterBuilder.cs @@ -0,0 +1,203 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Ctoss.Extensions; +using Ctoss.Json; +using Ctoss.Models; +using Ctoss.Models.Conditions; +using Ctoss.Models.Enums; + +namespace Ctoss; + +public class FilterBuilder +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Converters = + { + new FilterConditionConverter(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter() + }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public Expression>? GetExpression(Dictionary? filters) + { + if (filters == null) + return null; + + var expressions = new List>>(); + + expressions.AddRange(filters.Select(filter => GetExpressionInternal(filter.Key, filter.Value))); + return expressions.Aggregate((acc, expr) => acc.AndAlso(expr)); + } + + public Expression>? GetExpression(string jsonFilter) + => GetExpression(JsonSerializer.Deserialize>(jsonFilter, JsonOptions)); + + public Expression>? GetExpression(string property, NumberFilter numberFilter) + => GetExpression(new Dictionary { { property, numberFilter } }); + + private Expression> GetExpressionInternal(string property, NumberFilter? filter) + { + if (filter == null) + return _ => true; + + if (filter.Operator != Operator.NoOp) + { + return filter.Conditions? + .Select(c => GetFilterExpr(property, c)) + .Aggregate((acc, expr) => filter.Operator switch + { + Operator.And => acc.AndAlso(expr), + Operator.Or => acc.OrElse(expr), + _ => throw new ArgumentOutOfRangeException() + })!; + } + + return GetFilterExpr(property, filter.Condition1); + } + + private static Expression> GetFilterExpr(string property, FilterCondition? condition) + { + return condition switch + { + TextFilterCondition textFilter => GetTextFilterExpr(property, textFilter), + NumberFilterCondition numberFilter => GetNumberFilterExpr(property, numberFilter), + DateFilterCondition dateFilter => GetDateFilterExpr(property, dateFilter), + _ => _ => true + }; + } + + private static Expression> GetTextFilterExpr(string property, TextFilterCondition condition) + { + var parameter = Expression.Parameter(typeof(T), "x"); + var propertyExpression = Expression.Property(parameter, property); + var valueExpression = Expression.Constant(condition.Filter); + + return condition.Type switch + { + TextFilterOptions.Contains => Expression.Lambda>( + Expression.Call(propertyExpression, nameof(string.Contains), null, valueExpression), parameter), + TextFilterOptions.NotContains => Expression.Lambda>( + Expression.Not( + Expression.Call(propertyExpression, nameof(string.Contains), null, valueExpression)), parameter), + TextFilterOptions.Equals => Expression.Lambda>( + Expression.Equal(propertyExpression, valueExpression), parameter), + TextFilterOptions.NotEquals => Expression.Lambda>( + Expression.NotEqual(propertyExpression, valueExpression), parameter), + TextFilterOptions.StartsWith => Expression.Lambda>( + Expression.Call(propertyExpression, nameof(string.StartsWith), null, valueExpression), parameter), + TextFilterOptions.EndsWith => Expression.Lambda>( + Expression.Call(propertyExpression, nameof(string.EndsWith), null, valueExpression), parameter), + TextFilterOptions.Blank => Expression.Lambda>( + Expression.Equal(propertyExpression, Expression.Constant(null, typeof(string))), parameter), + TextFilterOptions.NotBlank => Expression.Lambda>( + Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(string))), parameter), + _ => _ => true + }; + } + + private static Expression> GetNumberFilterExpr(string property, NumberFilterCondition numberFilter) + { + var parameter = Expression.Parameter(typeof(T), "x"); + var propertyExpression = Expression.Property(parameter, property); + + var propertyType = typeof(T).GetProperty(property)?.PropertyType; + if (propertyType == null) + throw new ArgumentException($"Property '{property}' not found on type '{typeof(T).Name}'"); + + var filterValue = Convert.ChangeType(numberFilter.Filter, propertyType); + var valueExpression = Expression.Constant(filterValue, propertyType); + + switch (numberFilter.Type) + { + case NumberFilterOptions.Equals: + return Expression.Lambda>( + Expression.Equal(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.NotEquals: + return Expression.Lambda>( + Expression.NotEqual(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.GreaterThan: + return Expression.Lambda>( + Expression.GreaterThan(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.GreaterThanOrEqual: + return Expression.Lambda>( + Expression.GreaterThanOrEqual(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.LessThan: + return Expression.Lambda>( + Expression.LessThan(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.LessThanOrEqual: + return Expression.Lambda>( + Expression.LessThanOrEqual(propertyExpression, valueExpression), parameter); + case NumberFilterOptions.InRange: + var filterToValue = Convert.ChangeType(numberFilter.FilterTo, propertyType); + var valueToExpression = Expression.Constant(filterToValue, propertyType); + var greaterThan = Expression.GreaterThan(propertyExpression, valueExpression); + var lessThan = Expression.LessThan(propertyExpression, valueToExpression); + return Expression.Lambda>( + Expression.AndAlso(greaterThan, lessThan), parameter); + case NumberFilterOptions.Blank: + return Expression.Lambda>( + Expression.Equal(propertyExpression, Expression.Constant(null, typeof(decimal?))), parameter); + case NumberFilterOptions.Empty: + case NumberFilterOptions.NotBlank: + return Expression.Lambda>( + Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(decimal?))), parameter); + default: + throw new NotSupportedException($"Number filter type '{numberFilter.Type}' is not supported."); + } + } + + private static Expression> GetDateFilterExpr(string property, DateFilterCondition dateFilter) + { + var parameter = Expression.Parameter(typeof(T), "x"); + var propertyExpression = Expression.Property(parameter, property); + + ConstantExpression dateFromExpression = null!; + + if (dateFilter.Type is not DateFilterOptions.Blank && + dateFilter.Type is not DateFilterOptions.NotBlank && + dateFilter.Type is not DateFilterOptions.Empty) + { + dateFromExpression = Expression.Constant(DateTime.Parse(dateFilter.DateFrom!), typeof(DateTime)); + } + + switch (dateFilter.Type) + { + case DateFilterOptions.Equals: + return Expression.Lambda>( + Expression.Equal(propertyExpression, dateFromExpression), parameter); + case DateFilterOptions.NotEquals: + return Expression.Lambda>( + Expression.NotEqual(propertyExpression, dateFromExpression), parameter); + case DateFilterOptions.LessThen: + return Expression.Lambda>( + Expression.LessThan(propertyExpression, dateFromExpression), parameter); + case DateFilterOptions.GreaterThen: + return Expression.Lambda>( + Expression.GreaterThan(propertyExpression, dateFromExpression), parameter); + case DateFilterOptions.InRange: + var dateToExpression = Expression.Constant(DateTime.Parse(dateFilter.DateTo!), typeof(DateTime)); + var greaterThan = Expression.GreaterThan(propertyExpression, dateFromExpression); + var lessThan = Expression.LessThan(propertyExpression, dateToExpression); + return Expression.Lambda>( + Expression.AndAlso(greaterThan, lessThan), parameter); + case DateFilterOptions.Empty: + case DateFilterOptions.Blank: + return Expression.Lambda>( + Expression.Equal( + Expression.Convert(propertyExpression, typeof(DateTime?)), + Expression.Constant(null, typeof(DateTime?))), parameter); + case DateFilterOptions.NotBlank: + return Expression.Lambda>( + Expression.NotEqual( + Expression.Convert(propertyExpression, typeof(DateTime?)), + Expression.Constant(null, typeof(DateTime?))), parameter); + default: + throw new NotSupportedException($"Date filter type '{dateFilter.Type}' is not supported."); + } + } +} diff --git a/Ctoss/Json/FilterConditionConverter.cs b/Ctoss/Json/FilterConditionConverter.cs new file mode 100644 index 0000000..48601ef --- /dev/null +++ b/Ctoss/Json/FilterConditionConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Ctoss.Models.Conditions; + +namespace Ctoss.Json; + +public class FilterConditionConverter : JsonConverter +{ + public override FilterCondition Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var filterType = root.GetProperty("filterType").GetString() + ?? throw new ArgumentException("filterType is required"); + + return (filterType switch + { + "text" => JsonSerializer.Deserialize(root.GetRawText(), options), + "number" => JsonSerializer.Deserialize(root.GetRawText(), options), + "date" => JsonSerializer.Deserialize(root.GetRawText(), options), + _ => throw new NotSupportedException($"FilterType {filterType} is not supported") + })!; + } + + public override void Write(Utf8JsonWriter writer, FilterCondition value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, (object)value, options); + } +} diff --git a/Ctoss/Json/JsonStringEnumConverter.cs b/Ctoss/Json/JsonStringEnumConverter.cs new file mode 100644 index 0000000..008cd0e --- /dev/null +++ b/Ctoss/Json/JsonStringEnumConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ctoss.Json; + +public class JsonStringEnumConverter : JsonConverter where T : struct, Enum +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException(); + } + + var enumValue = reader.GetString(); + if (Enum.TryParse(enumValue, true, out T value)) + { + return value; + } + + throw new JsonException($"Unable to convert \"{enumValue}\" to enum \"{typeof(T)}\"."); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/Ctoss/Models/Conditions/DateFilterCondition.cs b/Ctoss/Models/Conditions/DateFilterCondition.cs new file mode 100644 index 0000000..dc9a841 --- /dev/null +++ b/Ctoss/Models/Conditions/DateFilterCondition.cs @@ -0,0 +1,10 @@ +using Ctoss.Models.Enums; + +namespace Ctoss.Models.Conditions; + +public class DateFilterCondition : FilterCondition +{ + public string? DateFrom { get; set; } + public string? DateTo { get; set; } + public DateFilterOptions? Type { get; set; } +} diff --git a/Ctoss/Models/Conditions/FilterCondition.cs b/Ctoss/Models/Conditions/FilterCondition.cs new file mode 100644 index 0000000..880a76e --- /dev/null +++ b/Ctoss/Models/Conditions/FilterCondition.cs @@ -0,0 +1,6 @@ +namespace Ctoss.Models.Conditions; + +public class FilterCondition +{ + public string FilterType { get; set; } +} diff --git a/Ctoss/Models/Conditions/NumberFilterCondition.cs b/Ctoss/Models/Conditions/NumberFilterCondition.cs new file mode 100644 index 0000000..7f777b5 --- /dev/null +++ b/Ctoss/Models/Conditions/NumberFilterCondition.cs @@ -0,0 +1,10 @@ +using Ctoss.Models.Enums; + +namespace Ctoss.Models.Conditions; + +public class NumberFilterCondition : FilterCondition +{ + public decimal? Filter { get; set; } + public decimal? FilterTo { get; set; } + public NumberFilterOptions? Type { get; set; } +} diff --git a/Ctoss/Models/Conditions/TextFilterCondition.cs b/Ctoss/Models/Conditions/TextFilterCondition.cs new file mode 100644 index 0000000..7ea014d --- /dev/null +++ b/Ctoss/Models/Conditions/TextFilterCondition.cs @@ -0,0 +1,7 @@ +namespace Ctoss.Models.Conditions; + +public class TextFilterCondition : FilterCondition +{ + public string? Filter { get; set; } + public Enums.TextFilterOptions Type { get; set; } +} diff --git a/Ctoss/Models/Enums/DateFilterOptions.cs b/Ctoss/Models/Enums/DateFilterOptions.cs new file mode 100644 index 0000000..45eafaf --- /dev/null +++ b/Ctoss/Models/Enums/DateFilterOptions.cs @@ -0,0 +1,63 @@ +namespace Ctoss.Models.Enums; + +/// +/// Specifies the available options for date filtering. +/// +public enum DateFilterOptions +{ + /// + /// Provides an option to choose one of the other filter options. + /// Option Key: empty + /// Included by Default: No + /// + Empty = 0, + + /// + /// Filters for dates that are equal to the specified date. + /// Option Key: equals + /// Included by Default: Yes + /// + Equals = 1, + + /// + /// Filters for dates that are not equal to the specified date. + /// Option Key: notEqual + /// Included by Default: Yes + /// + NotEquals = 2, + + /// + /// Filters for dates that are less than the specified date. + /// Option Key: lessThan + /// Included by Default: Yes + /// + LessThen = 3, + + /// + /// Filters for dates that are greater than the specified date. + /// Option Key: greaterThan + /// Included by Default: Yes + /// + GreaterThen = 4, + + /// + /// Filters for dates that are within a specified range. + /// Option Key: inRange + /// Included by Default: Yes + /// + InRange = 5, + + /// + /// Filters for blank (null or empty) dates. + /// Option Key: blank + /// Included by Default: Yes + /// + Blank = 6, + + /// + /// Filters for dates that are not blank (not null or empty). + /// Option Key: notBlank + /// Included by Default: Yes + /// + NotBlank = 7 +} diff --git a/Ctoss/Models/Enums/NumberFilterOptions.cs b/Ctoss/Models/Enums/NumberFilterOptions.cs new file mode 100644 index 0000000..399d6d6 --- /dev/null +++ b/Ctoss/Models/Enums/NumberFilterOptions.cs @@ -0,0 +1,77 @@ +namespace Ctoss.Models.Enums; + +/// +/// Specifies the available options for number filtering. +/// +public enum NumberFilterOptions +{ + /// + /// Provides an option to choose one of the other filter options. + /// Option Key: empty + /// Included by Default: No + /// + Empty = 0, + + /// + /// Filters for numbers that are equal to the specified value. + /// Option Key: equals + /// Included by Default: Yes + /// + Equals = 1, + + /// + /// Filters for numbers that are not equal to the specified value. + /// Option Key: notEqual + /// Included by Default: Yes + /// + NotEquals = 2, + + /// + /// Filters for numbers that are greater than the specified value. + /// Option Key: greaterThan + /// Included by Default: Yes + /// + GreaterThan = 3, + + /// + /// Filters for numbers that are greater than or equal to the specified value. + /// Option Key: greaterThanOrEqual + /// Included by Default: Yes + /// + GreaterThanOrEqual = 4, + + /// + /// Filters for numbers that are less than the specified value. + /// Option Key: lessThan + /// Included by Default: Yes + /// + LessThan = 5, + + /// + /// Filters for numbers that are less than or equal to the specified value. + /// Option Key: lessThanOrEqual + /// Included by Default: Yes + /// + LessThanOrEqual = 6, + + /// + /// Filters for numbers that are within a specified range. + /// Option Key: inRange + /// Included by Default: Yes + /// + InRange = 7, + + /// + /// Filters for blank (null or empty) numbers. + /// Option Key: blank + /// Included by Default: Yes + /// + Blank = 8, + + /// + /// Filters for numbers that are not blank (not null or empty). + /// Option Key: notBlank + /// Included by Default: Yes + /// + NotBlank = 9 +} diff --git a/Ctoss/Models/Enums/Operator.cs b/Ctoss/Models/Enums/Operator.cs new file mode 100644 index 0000000..e41c0fb --- /dev/null +++ b/Ctoss/Models/Enums/Operator.cs @@ -0,0 +1,24 @@ +namespace Ctoss.Models.Enums; + +/// +/// Specifies the logical operators for combining filter conditions. +/// +public enum Operator +{ + /// + /// No logical operator applied. + /// + NoOp = 0, + + /// + /// Logical AND operator. Both conditions must be true. + /// Option Key: and + /// + And = 1, + + /// + /// Logical OR operator. At least one condition must be true. + /// Option Key: or + /// + Or = 2 +} diff --git a/Ctoss/Models/Enums/TextFilterOptions.cs b/Ctoss/Models/Enums/TextFilterOptions.cs new file mode 100644 index 0000000..83d2c2f --- /dev/null +++ b/Ctoss/Models/Enums/TextFilterOptions.cs @@ -0,0 +1,70 @@ +namespace Ctoss.Models.Enums; + +/// +/// Specifies the available options for text filtering. +/// +public enum TextFilterOptions +{ + /// + /// Provides an option to choose one of the other filter options. + /// Option Key: empty + /// Included by Default: No + /// + Empty = 0, + + /// + /// Filters for text that contains the specified value. + /// Option Key: contains + /// Included by Default: Yes + /// + Contains = 1, + + /// + /// Filters for text that does not contain the specified value. + /// Option Key: notContains + /// Included by Default: Yes + /// + NotContains = 2, + + /// + /// Filters for text that is equal to the specified value. + /// Option Key: equals + /// Included by Default: Yes + /// + Equals = 3, + + /// + /// Filters for text that is not equal to the specified value. + /// Option Key: notEqual + /// Included by Default: Yes + /// + NotEquals = 4, + + /// + /// Filters for text that starts with the specified value. + /// Option Key: startsWith + /// Included by Default: Yes + /// + StartsWith = 5, + + /// + /// Filters for text that ends with the specified value. + /// Option Key: endsWith + /// Included by Default: Yes + /// + EndsWith = 6, + + /// + /// Filters for blank (null or empty) text. + /// Option Key: blank + /// Included by Default: Yes + /// + Blank = 7, + + /// + /// Filters for text that is not blank (not null or empty). + /// Option Key: notBlank + /// Included by Default: Yes + /// + NotBlank = 8 +} diff --git a/Ctoss/Models/NumberFilter.cs b/Ctoss/Models/NumberFilter.cs new file mode 100644 index 0000000..7f0a8f6 --- /dev/null +++ b/Ctoss/Models/NumberFilter.cs @@ -0,0 +1,13 @@ +using Ctoss.Models.Conditions; +using Ctoss.Models.Enums; + +namespace Ctoss.Models; + +public class NumberFilter +{ + public string FilterType { get; set; } = null!; + public Operator Operator { get; set; } + public FilterCondition? Condition1 { get; set; } + public FilterCondition? Condition2 { get; set; } + public List? Conditions { get; set; } +}