diff --git a/example/Dto/UserDto.cs b/example/Dto/UserDto.cs index 4e1ac2d..51bdd96 100644 --- a/example/Dto/UserDto.cs +++ b/example/Dto/UserDto.cs @@ -8,6 +8,7 @@ public record UserDto public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; public int Age { get; set; } + public Gender Gender { get; set; } public bool IsEmailVerified { get; set; } public double Test { get; set; } public int? NullableInt { get; set; } diff --git a/example/Entities/User.cs b/example/Entities/User.cs index 3182437..00a294d 100644 --- a/example/Entities/User.cs +++ b/example/Entities/User.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; public record User { @@ -6,6 +7,7 @@ public record User public string Firstname { get; set; } = string.Empty; public string Lastname { get; set; } = string.Empty; public int Age { get; set; } + public Gender Gender { get; set; } public bool IsDeleted { get; set; } public bool IsEmailVerified { get; set; } public double Test { get; set; } @@ -41,4 +43,13 @@ public record Company public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public string Department { get; set; } = string.Empty; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Gender +{ + Male, + Female, + [JsonStringEnumMemberName("Alternative")] + Other } \ No newline at end of file diff --git a/example/Program.cs b/example/Program.cs index d9f9112..c172d79 100644 --- a/example/Program.cs +++ b/example/Program.cs @@ -51,6 +51,7 @@ .RuleFor(x => x.Firstname, f => f.Person.FirstName) .RuleFor(x => x.Lastname, f => f.Person.LastName) .RuleFor(x => x.Age, f => f.Random.Int(0, 100)) + .RuleFor(x => x.Gender, f => f.PickRandom()) .RuleFor(x => x.IsDeleted, f => f.Random.Bool()) .RuleFor(x => x.Test, f => f.Random.Double()) .RuleFor(x => x.NullableInt, f => f.Random.Bool() ? f.Random.Int(1, 100) : null) diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index ee38258..057976a 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Text.Json.Serialization; using FluentResults; public static class FilterEvaluator @@ -127,11 +128,6 @@ private static Result ResolvePropertyPathForCollection( return (MemberExpression)current; } - private static bool IsNullableReferenceType(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } - private static bool IsPrimitiveType(Type type) { return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || @@ -226,13 +222,13 @@ private static Result CreateConstantExpression(QueryExpressi { return literal switch { - IntegerLiteral intLit => CreateIntegerConstant(intLit.Value, expression), + IntegerLiteral intLit => CreateIntegerOrEnumConstant(intLit.Value, expression.Type), DateLiteral dateLit => Result.Ok(CreateDateConstant(dateLit, expression)), GuidLiteral guidLit => Result.Ok(Expression.Constant(guidLit.Value, expression.Type)), DecimalLiteral decLit => Result.Ok(Expression.Constant(decLit.Value, expression.Type)), FloatLiteral floatLit => Result.Ok(Expression.Constant(floatLit.Value, expression.Type)), DoubleLiteral dblLit => Result.Ok(Expression.Constant(dblLit.Value, expression.Type)), - StringLiteral strLit => Result.Ok(Expression.Constant(strLit.Value, expression.Type)), + StringLiteral strLit => CreateStringOrEnumConstant(strLit.Value, expression.Type), DateTimeLiteral dtLit => Result.Ok(Expression.Constant(dtLit.Value, expression.Type)), BooleanLiteral boolLit => Result.Ok(Expression.Constant(boolLit.Value, expression.Type)), NullLiteral _ => Result.Ok(Expression.Constant(null, expression.Type)), @@ -248,11 +244,6 @@ private static Result CreateConstantExpression(QueryExpressi return Result.Ok((constantResult.Value, property)); } - private static Result CreateIntegerConstant(int value, Expression expression) - { - return GetIntegerExpressionConstant(value, expression.Type); - } - private static ConstantExpression CreateDateConstant(DateLiteral dateLiteral, Expression expression) { if (expression.Type == typeof(DateTime?)) @@ -563,9 +554,7 @@ private static Result GetIntegerExpressionConstant(int value { try { - // Fetch the underlying type if it's nullable. - var underlyingType = Nullable.GetUnderlyingType(targetType); - var type = underlyingType ?? targetType; + var type = GetNonNullableType(targetType); object convertedValue = type switch { @@ -586,9 +575,76 @@ private static Result GetIntegerExpressionConstant(int value { return Result.Fail($"Value {value} is too large for type {targetType.Name}"); } - catch (Exception ex) + catch (Exception) + { + return Result.Fail($"Error converting {value} to {targetType.Name}"); + } + } + + private static Result CreateIntegerOrEnumConstant(int value, Type targetType) + { + var actualType = GetNonNullableType(targetType); + + if (actualType.IsEnum) + { + return ConvertIntegerToEnum(value, actualType, targetType); + } + + return GetIntegerExpressionConstant(value, targetType); + } + + private static Result ConvertIntegerToEnum(int value, Type actualType, Type targetType) + { + try + { + var enumValue = Enum.ToObject(actualType, value); + + return Result.Ok(Expression.Constant(enumValue, targetType)); + } + catch (Exception) { - return Result.Fail($"Error converting {value} to {targetType.Name}: {ex.Message}"); + return Result.Fail($"Error converting {value} to enum type {targetType.Name}"); } } -} \ No newline at end of file + + private static Result CreateStringOrEnumConstant(string value, Type targetType) + { + var actualType = GetNonNullableType(targetType); + + if (actualType.IsEnum) + { + return ConvertStringToEnum(value, actualType, targetType); + } + + return Result.Ok(Expression.Constant(value, targetType)); + } + + private static Result ConvertStringToEnum(string value, Type actualType, Type targetType) + { + try + { + var enumValue = Enum.Parse(actualType, value, true); + + return Result.Ok(Expression.Constant(enumValue, targetType)); + } + catch (Exception) + { + foreach (var field in actualType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var memberNameAttribute = field.GetCustomAttribute(); + + if (memberNameAttribute != null && memberNameAttribute.Name.Equals(value, StringComparison.Ordinal)) + { + return Result.Ok(Expression.Constant(field.GetValue(null), targetType)); + } + } + + return Result.Fail($"Value '{value}' is not a valid member of enum {actualType.Name}"); + } + } + + private static Type GetNonNullableType(Type type) + { + return Nullable.GetUnderlyingType(type) ?? type; + } +} diff --git a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs index cec70a4..721d80a 100644 --- a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs +++ b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs @@ -175,7 +175,6 @@ private static bool IsPrimitiveType(Type type) if (type.IsPrimitive || PrimitiveTypes.Contains(type)) return true; - // Handle nullable types var underlyingType = Nullable.GetUnderlyingType(type); return underlyingType != null && (underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType)); } diff --git a/src/GoatQuery/tests/Filter/FilterLexerTest.cs b/src/GoatQuery/tests/Filter/FilterLexerTest.cs index 4f6680f..43c97b5 100644 --- a/src/GoatQuery/tests/Filter/FilterLexerTest.cs +++ b/src/GoatQuery/tests/Filter/FilterLexerTest.cs @@ -523,6 +523,109 @@ public static IEnumerable Parameters() new (TokenType.RPAREN, ")"), } }; + + yield return new object[] + { + "status eq 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "0"), + } + }; + + yield return new object[] + { + "status eq 1", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "1"), + } + }; + + yield return new object[] + { + "status ne 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "ne"), + new (TokenType.INT, "0"), + } + }; + + yield return new object[] + { + "gender eq 'Male'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Male"), + } + }; + + yield return new object[] + { + "gender eq 'Female'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Female"), + } + }; + + yield return new object[] + { + "gender eq 'Alternative'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Alternative"), + } + }; + + yield return new object[] + { + "gender ne 'Male'", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "ne"), + new (TokenType.STRING, "Male"), + } + }; + + yield return new object[] + { + "gender eq null", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.NULL, "null"), + } + }; + + yield return new object[] + { + "gender eq 'Male' and status eq 0", + new KeyValuePair[] + { + new (TokenType.IDENT, "gender"), + new (TokenType.IDENT, "eq"), + new (TokenType.STRING, "Male"), + new (TokenType.IDENT, "and"), + new (TokenType.IDENT, "status"), + new (TokenType.IDENT, "eq"), + new (TokenType.INT, "0"), + } + }; } [Theory] diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index b60a764..ba233ac 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -191,7 +191,7 @@ public void Test_ParsingFilterStatementWithNestedProperty(string input, string[] [InlineData("tags/all(item: item contains 'test')", "tags", "all", "item", "item", "contains", "test")] [InlineData("categories/any(c: c eq 'electronics')", "categories", "any", "c", "c", "eq", "electronics")] [InlineData("items/all(i: i ne null)", "items", "all", "i", "i", "ne", "null")] - public void Test_ParsingQueryLambdaExpression(string input, string expectedProperty, string expectedFunction, + public void Test_ParsingQueryLambdaExpression(string input, string expectedProperty, string expectedFunction, string expectedParameter, string expectedLambdaLeft, string expectedLambdaOperator, string expectedLambdaRight) { var lexer = new QueryLexer(input); @@ -249,7 +249,7 @@ public void Test_ParsingQueryLambdaExpressionWithNestedProperty(string input, st // Verify lambda body contains nested property access var bodyExpression = lambda.Body as InfixExpression; Assert.NotNull(bodyExpression); - + var propertyPath = bodyExpression.Left as PropertyPath; Assert.NotNull(propertyPath); Assert.Equal(expectedNestedProperty, propertyPath.Segments); @@ -308,9 +308,9 @@ public void Test_ParsingComplexQueryLambdaExpression(string input, string expect // Verify lambda body contains complex expressions with logical operators var bodyExpression = lambda.Body as InfixExpression; Assert.NotNull(bodyExpression); - + // The body should have logical operators (and/or) - Assert.True(bodyExpression.Operator.Equals("and", StringComparison.OrdinalIgnoreCase) || + Assert.True(bodyExpression.Operator.Equals("and", StringComparison.OrdinalIgnoreCase) || bodyExpression.Operator.Equals("or", StringComparison.OrdinalIgnoreCase)); } @@ -331,5 +331,94 @@ public void Test_ParsingInvalidQueryLambdaExpression(string input) Assert.True(result.IsFailed); } - -} \ No newline at end of file + + [Theory] + [InlineData("status eq 0", "status", "eq", "0")] + [InlineData("status eq 1", "status", "eq", "1")] + [InlineData("status ne 0", "status", "ne", "0")] + [InlineData("status ne 1", "status", "ne", "1")] + public void Test_ParsingEnumWithIntegerValue(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("gender eq 'Male'", "gender", "eq", "Male")] + [InlineData("gender eq 'Female'", "gender", "eq", "Female")] + [InlineData("gender eq 'Alternative'", "gender", "eq", "Alternative")] + [InlineData("gender ne 'Male'", "gender", "ne", "Male")] + [InlineData("gender ne 'Female'", "gender", "ne", "Female")] + public void Test_ParsingEnumWithStringValue(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Theory] + [InlineData("gender eq null", "gender", "eq", "null")] + [InlineData("gender ne null", "gender", "ne", "null")] + public void Test_ParsingNullableEnum(string input, string expectedLeft, string expectedOperator, string expectedRight) + { + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + Assert.Equal(expectedLeft, expression.Left.TokenLiteral()); + Assert.Equal(expectedOperator, expression.Operator); + Assert.Equal(expectedRight, expression.Right.TokenLiteral()); + } + + [Fact] + public void Test_ParsingEnumWithLogicalOperators() + { + var input = "gender eq 'Male' and status eq 0"; + + var lexer = new QueryLexer(input); + var parser = new QueryParser(lexer); + + var program = parser.ParseFilter(); + + var expression = program.Value.Expression; + Assert.NotNull(expression); + + var left = expression.Left as InfixExpression; + Assert.NotNull(left); + + Assert.Equal("gender", left.Left.TokenLiteral()); + Assert.Equal("eq", left.Operator); + Assert.Equal("Male", left.Right.TokenLiteral()); + + Assert.Equal("and", expression.Operator); + + var right = expression.Right as InfixExpression; + Assert.NotNull(right); + + Assert.Equal("status", right.Left.TokenLiteral()); + Assert.Equal("eq", right.Operator); + Assert.Equal("0", right.Right.TokenLiteral()); + } + +} diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index d0ae693..eb2955c 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -350,6 +350,96 @@ public static IEnumerable Parameters() "tags/any(x: x eq 'standard')", new[] { TestData.Users["User05"] } }; + + yield return new object[] { + "status eq 0", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "status eq 1", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "status ne 0", + new[] { TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "status ne 1", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Male'", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'male'", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Female'", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "gender eq 'Alternative'", + new[] { TestData.Users["User03"] } + }; + + yield return new object[] { + "gender ne 'Male'", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "gender ne 'Female'", + new[] { TestData.Users["User01"], TestData.Users["User03"], TestData.Users["User04"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq null", + new[] { TestData.Users["User04"] } + }; + + yield return new object[] { + "gender ne null", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender eq 'Male' and age eq 25", + new[] { TestData.Users["User01"], TestData.Users["User05"] } + }; + + yield return new object[] { + "status eq 0 and age gt 25", + new[] { TestData.Users["User02"] } + }; + + yield return new object[] { + "gender eq 'Female' or status eq 1", + new[] { TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User04"] } + }; + + yield return new object[] { + "gender eq 'Male' or gender eq 'Female'", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User05"] } + }; + + yield return new object[] { + "(gender eq 'Male' and status eq 0) or age eq 30", + new[] { TestData.Users["User01"], TestData.Users["User02"], TestData.Users["User03"], TestData.Users["User05"] } + }; + + yield return new object[] { + "gender ne null and status eq 1", + new[] { TestData.Users["User03"] } + }; } [Theory] diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index a810b79..75236d1 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -13,6 +13,8 @@ public static class TestData Id = User01Id, Age = 25, Firstname = "User01", + Gender = Gender.Male, + Status = Status.Active, DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-03-15 10:30:00"), DateTimeKind.Utc), BalanceDecimal = 1500.75m, BalanceDouble = 2500.50d, @@ -57,6 +59,8 @@ public static class TestData Id = User02Id, Age = 30, Firstname = "User02", + Gender = Gender.Female, + Status = Status.Active, DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-07-20 14:00:00"), DateTimeKind.Utc), BalanceDecimal = 500.00m, BalanceDouble = null, @@ -90,6 +94,8 @@ public static class TestData Id = User03Id, Age = 30, Firstname = "User03", + Gender = Gender.Other, + Status = Status.Inactive, DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1993-11-10 09:15:00"), DateTimeKind.Utc), BalanceDecimal = null, BalanceDouble = 1000.00d, @@ -110,6 +116,8 @@ public static class TestData Id = User04Id, Age = 35, Firstname = "User04", + Gender = null, + Status = Status.Inactive, DateOfBirth = null, BalanceDecimal = null, BalanceDouble = null, @@ -125,6 +133,8 @@ public static class TestData Id = User05Id, Age = 25, Firstname = "User05", + Gender = Gender.Male, + Status = Status.Active, DateOfBirth = DateTime.SpecifyKind(DateTime.Parse("1998-12-25 18:45:00"), DateTimeKind.Utc), BalanceDecimal = 0.00m, BalanceDouble = 0.00d, diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index bb75959..931690d 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -4,6 +4,8 @@ public record User { public Guid Id { get; set; } public int Age { get; set; } + public Gender? Gender { get; set; } + public Status Status { get; set; } public string Firstname { get; set; } = string.Empty; public decimal? BalanceDecimal { get; set; } public double? BalanceDouble { get; set; } @@ -43,4 +45,20 @@ public sealed record CustomJsonPropertyUser : User { [JsonPropertyName("last_name")] public string Lastname { get; set; } = string.Empty; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Gender +{ + Male, + Female, + [JsonStringEnumMemberName("Alternative")] + Other +} + + +public enum Status +{ + Active, + Inactive, } \ No newline at end of file