From f9f78850934e13a02507c73612dc90e675f49f3d Mon Sep 17 00:00:00 2001 From: ArthurFreeb <32460970+ArthurFreeb@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:24:33 +0100 Subject: [PATCH] feat: added enums --- src/GoatQuery/src/Ast/Literals.cs | 12 +- .../src/Evaluator/FilterEvaluator.cs | 78 +++++++++++- src/GoatQuery/src/Parser/Parser.cs | 3 +- .../src/Utilities/PropertyMappingTree.cs | 12 +- .../tests/Filter/FilterParserTest.cs | 2 + src/GoatQuery/tests/Filter/FilterTest.cs | 114 ++++++++++++++++++ src/GoatQuery/tests/TestData.cs | 18 ++- src/GoatQuery/tests/User.cs | 9 ++ 8 files changed, 240 insertions(+), 8 deletions(-) diff --git a/src/GoatQuery/src/Ast/Literals.cs b/src/GoatQuery/src/Ast/Literals.cs index 455b32d..21f228f 100644 --- a/src/GoatQuery/src/Ast/Literals.cs +++ b/src/GoatQuery/src/Ast/Literals.cs @@ -95,4 +95,14 @@ public BooleanLiteral(Token token, bool value) : base(token) { Value = value; } -} \ No newline at end of file +} + +public sealed class EnumSymbolLiteral : QueryExpression +{ + public string Value { get; set; } + + public EnumSymbolLiteral(Token token, string value) : base(token) + { + Value = value; + } +} diff --git a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs index ee38258..aad5818 100644 --- a/src/GoatQuery/src/Evaluator/FilterEvaluator.cs +++ b/src/GoatQuery/src/Evaluator/FilterEvaluator.cs @@ -224,6 +224,22 @@ private static Result CreateComparisonExpression(string operatorKeyw private static Result CreateConstantExpression(QueryExpression literal, Expression expression) { + if (IsEnumOrNullableEnum(expression.Type)) + { + if (literal is StringLiteral enumString) + { + return CreateEnumConstantFromString(enumString.Value, expression.Type); + } + if (literal is IntegerLiteral enumInt) + { + return CreateEnumConstantFromInteger(enumInt.Value, expression.Type); + } + if (literal is EnumSymbolLiteral enumSymbol) + { + return CreateEnumConstantFromString(enumSymbol.Value, expression.Type); + } + } + return literal switch { IntegerLiteral intLit => CreateIntegerConstant(intLit.Value, expression), @@ -236,6 +252,7 @@ private static Result CreateConstantExpression(QueryExpressi 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)), + EnumSymbolLiteral enumSym => Result.Fail("Unquoted identifiers are only allowed for enum values"), _ => Result.Fail($"Unsupported literal type: {literal.GetType().Name}") }; } @@ -563,6 +580,11 @@ private static Result GetIntegerExpressionConstant(int value { try { + if (IsEnumOrNullableEnum(targetType)) + { + return CreateEnumConstantFromInteger(value, targetType); + } + // Fetch the underlying type if it's nullable. var underlyingType = Nullable.GetUnderlyingType(targetType); var type = underlyingType ?? targetType; @@ -591,4 +613,58 @@ private static Result GetIntegerExpressionConstant(int value return Result.Fail($"Error converting {value} to {targetType.Name}: {ex.Message}"); } } -} \ No newline at end of file + + private static bool IsEnumOrNullableEnum(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return underlying.IsEnum; + } + + private static Result CreateEnumConstantFromString(string value, Type targetType) + { + var isNullable = Nullable.GetUnderlyingType(targetType) != null; + var enumType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try + { + var enumValue = Enum.Parse(enumType, value, ignoreCase: true); + + if (isNullable) + { + var nullableType = typeof(Nullable<>).MakeGenericType(enumType); + var boxedNullable = Activator.CreateInstance(nullableType, enumValue); + return Expression.Constant(boxedNullable, targetType); + } + + return Expression.Constant(enumValue, targetType); + } + catch (ArgumentException) + { + return Result.Fail($"'{value}' is not a valid value for enum type {enumType.Name}"); + } + } + + private static Result CreateEnumConstantFromInteger(int intValue, Type targetType) + { + var isNullable = Nullable.GetUnderlyingType(targetType) != null; + var enumType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try + { + var enumValue = Enum.ToObject(enumType, intValue); + + if (isNullable) + { + var nullableType = typeof(Nullable<>).MakeGenericType(enumType); + var boxedNullable = Activator.CreateInstance(nullableType, enumValue); + return Expression.Constant(boxedNullable, targetType); + } + + return Expression.Constant(enumValue, targetType); + } + catch (Exception ex) + { + return Result.Fail($"Error converting integer {intValue} to enum type {enumType.Name}: {ex.Message}"); + } + } +} diff --git a/src/GoatQuery/src/Parser/Parser.cs b/src/GoatQuery/src/Parser/Parser.cs index 984665b..02289b1 100644 --- a/src/GoatQuery/src/Parser/Parser.cs +++ b/src/GoatQuery/src/Parser/Parser.cs @@ -190,7 +190,7 @@ private Result ParseFilterStatement() var statement = new InfixExpression(_currentToken, leftExpression, _currentToken.Literal); - if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL, TokenType.BOOLEAN)) + if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL, TokenType.BOOLEAN, TokenType.IDENT)) { return Result.Fail("Invalid value type within filter"); } @@ -295,6 +295,7 @@ private QueryExpression ParseLiteral(Token token) TokenType.BOOLEAN => bool.TryParse(token.Literal, out var boolValue) ? new BooleanLiteral(token, boolValue) : null, + TokenType.IDENT => new EnumSymbolLiteral(token, token.Literal), _ => null }; } diff --git a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs index cec70a4..beab085 100644 --- a/src/GoatQuery/src/Utilities/PropertyMappingTree.cs +++ b/src/GoatQuery/src/Utilities/PropertyMappingTree.cs @@ -172,11 +172,19 @@ private static bool ShouldCreateNestedMapping(Type type) private static bool IsPrimitiveType(Type type) { + if (type.IsEnum) + return true; + if (type.IsPrimitive || PrimitiveTypes.Contains(type)) return true; - // Handle nullable types var underlyingType = Nullable.GetUnderlyingType(type); - return underlyingType != null && (underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType)); + if (underlyingType == null) + return false; + + if (underlyingType.IsEnum) + return true; + + return underlyingType.IsPrimitive || PrimitiveTypes.Contains(underlyingType); } } \ No newline at end of file diff --git a/src/GoatQuery/tests/Filter/FilterParserTest.cs b/src/GoatQuery/tests/Filter/FilterParserTest.cs index b60a764..1eeabd6 100644 --- a/src/GoatQuery/tests/Filter/FilterParserTest.cs +++ b/src/GoatQuery/tests/Filter/FilterParserTest.cs @@ -23,6 +23,7 @@ public sealed class FilterParserTest [InlineData("dateOfBirth gte 2000-01-01", "dateOfBirth", "gte", "2000-01-01")] [InlineData("dateOfBirth eq 2023-01-30T09:29:55.1750906Z", "dateOfBirth", "eq", "2023-01-30T09:29:55.1750906Z")] [InlineData("balance eq null", "balance", "eq", "null")] + [InlineData("status eq Active", "status", "eq", "Active")] [InlineData("balance ne null", "balance", "ne", "null")] [InlineData("name eq NULL", "name", "eq", "NULL")] public void Test_ParsingFilterStatement(string input, string expectedLeft, string expectedOperator, string expectedRight) @@ -168,6 +169,7 @@ public void Test_ParsingFilterStatementWithAndAndOr() [Theory] [InlineData("manager/firstName eq 'John'", new string[] { "manager", "firstName" }, "eq", "John")] [InlineData("manager/manager/firstName eq 'John'", new string[] { "manager", "manager", "firstName" }, "eq", "John")] + [InlineData("manager/status eq Active", new string[] { "manager", "status" }, "eq", "Active")] public void Test_ParsingFilterStatementWithNestedProperty(string input, string[] expectedLeft, string expectedOperator, string expectedRight) { var lexer = new QueryLexer(input); diff --git a/src/GoatQuery/tests/Filter/FilterTest.cs b/src/GoatQuery/tests/Filter/FilterTest.cs index 0bdd7d2..3ceccc6 100644 --- a/src/GoatQuery/tests/Filter/FilterTest.cs +++ b/src/GoatQuery/tests/Filter/FilterTest.cs @@ -497,6 +497,119 @@ public static IEnumerable Parameters() "tags/all(x: x eq 'premium')", new[] { TestData.Users["Egg"] } }; + + // Status enum tests + yield return new object[] { + "status eq 'Active'", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "status eq 'Inactive'", + new[] { TestData.Users["Jane"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status eq 'Suspended'", + new[] { TestData.Users["Harry"] } + }; + + yield return new object[] { + "status ne 'Active'", + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status ne 'Inactive'", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "status ne 'Suspended'", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + }; + + // Status combined with other properties + yield return new object[] { + "status eq 'Active' and age eq 1", + new[] { TestData.Users["Apple"], TestData.Users["Doe"] } + }; + + yield return new object[] { + "status eq 'Inactive' or age eq 33", + new[] { TestData.Users["Jane"], TestData.Users["Egg"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status eq 'Active' and isEmailVerified eq true", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"] } + }; + + yield return new object[] { + "status ne 'Active' and age lt 10", + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["NullUser"] } + }; + + // Manager status tests + yield return new object[] { + "manager/status eq 'Active'", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager ne null and manager/status eq 'Active'", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "status eq Active", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "status eq Inactive", + new[] { TestData.Users["Jane"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status eq Suspended", + new[] { TestData.Users["Harry"] } + }; + + yield return new object[] { + "status ne Active", + new[] { TestData.Users["Jane"], TestData.Users["Harry"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status ne Inactive", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Harry"], TestData.Users["Doe"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "status ne Suspended", + new[] { TestData.Users["John"], TestData.Users["Jane"], TestData.Users["Apple"], TestData.Users["Doe"], TestData.Users["Egg"], TestData.Users["NullUser"] } + }; + + yield return new object[] { + "status eq Active and age eq 1", + new[] { TestData.Users["Apple"], TestData.Users["Doe"] } + }; + + yield return new object[] { + "status eq Active and isEmailVerified eq true", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Doe"] } + }; + + yield return new object[] { + "manager/status eq Active", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; + + yield return new object[] { + "manager ne null and manager/status eq Active", + new[] { TestData.Users["John"], TestData.Users["Apple"], TestData.Users["Egg"] } + }; } [Theory] @@ -521,6 +634,7 @@ public void Test_Filter(string filter, IEnumerable expected) [InlineData("addresses/any(addr: addr/nonExistentProperty eq 'test')")] [InlineData("addresses/invalid(addr: addr/city/name eq 'test')")] [InlineData("nonExistentCollection/any(item: item eq 'test')")] + [InlineData("firstname eq John")] // Unquoted RHS on non-enum should error public void Test_InvalidFilterReturnsError(string filter) { var query = new Query diff --git a/src/GoatQuery/tests/TestData.cs b/src/GoatQuery/tests/TestData.cs index e04c1f0..b8a3b39 100644 --- a/src/GoatQuery/tests/TestData.cs +++ b/src/GoatQuery/tests/TestData.cs @@ -9,6 +9,7 @@ public static class TestData DateOfBirth = DateTime.Parse("2004-01-31 23:59:59").ToUniversalTime(), BalanceDecimal = 1.50m, IsEmailVerified = true, + Status = Status.Active, Addresses = new[] { new Address @@ -28,7 +29,8 @@ public static class TestData Firstname = "Manager 01", DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDecimal = 2.00m, - IsEmailVerified = false + IsEmailVerified = false, + Status = Status.Active } }, ["Jane"] = new User @@ -38,6 +40,7 @@ public static class TestData DateOfBirth = DateTime.Parse("2020-05-09 15:30:00").ToUniversalTime(), BalanceDecimal = 0, IsEmailVerified = false, + Status = Status.Inactive, Addresses = new[] { new Address @@ -59,6 +62,7 @@ public static class TestData DateOfBirth = DateTime.Parse("1980-12-31 00:00:01").ToUniversalTime(), BalanceFloat = 1204050.98f, IsEmailVerified = true, + Status = Status.Active, Addresses = new[] { new Address @@ -78,7 +82,8 @@ public static class TestData Firstname = "Manager 01", DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDecimal = 2.00m, - IsEmailVerified = true + IsEmailVerified = true, + Status = Status.Active }, Tags = ["vip", "premium"] }, @@ -89,6 +94,7 @@ public static class TestData DateOfBirth = DateTime.Parse("2002-08-01").ToUniversalTime(), BalanceDecimal = 0.5372958205929493m, IsEmailVerified = false, + Status = Status.Suspended, Addresses = Array.Empty
() }, ["Doe"] = new User @@ -98,6 +104,7 @@ public static class TestData DateOfBirth = DateTime.Parse("2023-07-26 12:00:30").ToUniversalTime(), BalanceDecimal = null, IsEmailVerified = true, + Status = Status.Active, Addresses = new[] { new Address @@ -114,6 +121,7 @@ public static class TestData DateOfBirth = DateTime.Parse("2000-01-01 00:00:00").ToUniversalTime(), BalanceDouble = 1334534453453433.33435443343231235652d, IsEmailVerified = false, + Status = Status.Active, Addresses = new[] { new Address @@ -134,6 +142,7 @@ public static class TestData DateOfBirth = DateTime.Parse("1999-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 19.00m, IsEmailVerified = true, + Status = Status.Active, Manager = new User { Age = 30, @@ -141,13 +150,15 @@ public static class TestData DateOfBirth = DateTime.Parse("1993-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 29.00m, IsEmailVerified = true, + Status = Status.Active, Manager = new User { Age = 40, Firstname = "Manager 04", DateOfBirth = DateTime.Parse("1983-04-21 00:00:00").ToUniversalTime(), BalanceDecimal = 39.00m, - IsEmailVerified = true + IsEmailVerified = true, + Status = Status.Active }, Company = new Company { @@ -167,6 +178,7 @@ public static class TestData BalanceDouble = null, BalanceFloat = null, IsEmailVerified = true, + Status = Status.Inactive, Addresses = Array.Empty
() }, }; diff --git a/src/GoatQuery/tests/User.cs b/src/GoatQuery/tests/User.cs index 6021c6d..b98dd55 100644 --- a/src/GoatQuery/tests/User.cs +++ b/src/GoatQuery/tests/User.cs @@ -10,6 +10,7 @@ public record User public float? BalanceFloat { get; set; } public DateTime? DateOfBirth { get; set; } public bool IsEmailVerified { get; set; } + public Status Status { get; set; } public Company? Company { get; set; } public User? Manager { get; set; } public IEnumerable
Addresses { get; set; } = Array.Empty
(); @@ -41,4 +42,12 @@ 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 Status +{ + Active, + Inactive, + Suspended } \ No newline at end of file