diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index f192c3d..e1b8588 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -320,4 +320,35 @@ public async Task has_returns_matching_rows(string input, string[] expectedTitle // Assert recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles); } + + [Fact] + public async Task custom_operation_keeps_quoted_value_as_string() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var firstName = $"custom op {Guid.NewGuid()}"; + var fakePersonOne = new FakeTestingPersonBuilder() + .WithFirstName(firstName) + .WithTitle("001") + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithFirstName(firstName) + .WithTitle("1") + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""titleIs == "001" && {nameof(TestingPerson.FirstName)} == "{firstName}" """; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Title == (string)value) + .HasQueryName("titleIs"); + }); + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var people = await queryablePeople.ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + } } diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..05eb735 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -3,6 +3,7 @@ namespace QueryKit.UnitTests; using System.Globalization; using System.Linq.Expressions; using System.Reflection; +using Configuration; using Exceptions; using FluentAssertions; using Operators; @@ -286,6 +287,51 @@ public void has_returns_matching_rows(string input, string[] expectedTitles) result.Select(x => x.Title).Should().BeEquivalentTo(expectedTitles); } + [Theory] + [InlineData("""titleIs == "123" """, "123")] + [InlineData("""titleIs == "4.5" """, "4.5")] + [InlineData("""titleIs == "true" """, "true")] + [InlineData("""titleIs == "null" """, "null")] + [InlineData("""titleIs == "" """, "")] + public void custom_operation_keeps_quoted_value_as_string(string input, string expectedTitle) + { + var people = new[] { "123", "4.5", "true", "null", "", "other" } + .Select(title => new TestingPerson { Title = title }) + .ToArray(); + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Title == (string)value) + .HasQueryName("titleIs"); + }); + + var result = people.AsQueryable().ApplyQueryKitFilter(input, config).ToList(); + + result.Select(x => x.Title).Should().Equal(expectedTitle); + } + + [Fact] + public void custom_operation_converts_unquoted_number_and_quoted_date() + { + var people = new[] + { + new TestingPerson { Title = "match", Age = 30, SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc) }, + new TestingPerson { Title = "other", Age = 31, SpecificDateTime = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc) }, + }; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Age == (int)value) + .HasQueryName("ageIs"); + config.CustomOperation((x, op, value) => x.SpecificDateTime == (DateTime)value) + .HasQueryName("createdAt"); + }); + + var result = people.AsQueryable() + .ApplyQueryKitFilter("""ageIs == 30 && createdAt == "2024-01-15T08:00:00Z" """, config) + .ToList(); + + result.Select(x => x.Title).Should().Equal("match"); + } + private static TResult WithCulture(string cultureName, Func action) { var originalCulture = CultureInfo.CurrentCulture; diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 37bac4d..6000a70 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -805,7 +805,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa return RemovedClauseExpression.Instance; } - return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); + return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right, temp.rightIsQuotedLiteral); } if (temp.reference.Kind == PropertyReferenceKind.Unknown) @@ -1484,14 +1484,14 @@ private static Type GetWiderNumericType(Type type1, Type type2) return rank1 >= rank2 ? type1 : type2; } - private static Expression CreateCustomOperationExpression(ParameterExpression parameter, QueryKitPropertyInfo customOperationInfo, ComparisonOperator op, string rightValue) + private static Expression CreateCustomOperationExpression(ParameterExpression parameter, QueryKitPropertyInfo customOperationInfo, ComparisonOperator op, string rightValue, bool rightIsQuotedLiteral) { if (customOperationInfo.CustomOperation == null) throw new ArgumentException("Custom operation expression is null"); // For custom operations, we need to convert the string value to the appropriate basic type // instead of trying to match it to the entity type - object? convertedValue = ConvertStringToBasicType(rightValue); + object? convertedValue = ConvertStringToBasicType(rightValue, rightIsQuotedLiteral); // Create the parameter expressions for the custom operation var entityParameter = Expression.Convert(parameter, typeof(object)); @@ -1505,30 +1505,36 @@ private static Expression CreateCustomOperationExpression(ParameterExpression return invocationExpression; } - private static object? ConvertStringToBasicType(string value) + private static object? ConvertStringToBasicType(string value, bool isQuotedLiteral) { - // Handle null - if (string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.InvariantCultureIgnoreCase)) - return null; + // A quoted value is text, so it never becomes null, a boolean, or a number. + // A quoted value in the date format of the grammar or a quoted guid still converts below. + if (!isQuotedLiteral) + { + // Handle null + if (string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.InvariantCultureIgnoreCase)) + return null; - // Try boolean - if (bool.TryParse(value, out var boolValue)) - return boolValue; + // Try boolean + if (bool.TryParse(value, out var boolValue)) + return boolValue; - // Try int - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) - return intValue; + // Try int + if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue)) + return intValue; - // Try decimal - if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) - return decimalValue; + // Try decimal + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue)) + return decimalValue; - // Try double - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue)) - return doubleValue; + // Try double + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleValue)) + return doubleValue; + } // Try DateTime - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) + if ((!isQuotedLiteral || DateTimeFormatParser.End().TryParse(value).WasSuccessful) && + DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var dateTimeValue)) return dateTimeValue; // Try Guid