diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index db9ef56..0a96a61 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -83,7 +83,7 @@ public async Task decimal_value_uses_invariant_culture() } [Fact] - public async Task list_value_with_comma_is_split_into_items() + public async Task list_value_with_comma_is_one_item() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -107,8 +107,8 @@ public async Task list_value_with_comma_is_split_into_items() var notInPeople = await queryablePeople.ApplyQueryKitFilter(notInInput).ToListAsync(); // Assert - inPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); - notInPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + inPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + notInPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); } [Theory] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index f70e44d..504db0d 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; @@ -86,12 +87,13 @@ public void decimal_value_uses_invariant_culture_in_memory() } [Theory] - [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup", "a\\b" })] - [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup" })] - [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm", "with syrup" })] - [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm", "with syrup" })] + [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup", "a\\b" })] + [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup" })] + [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm, with syrup" })] + [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm, with syrup", "Warm" })] [InlineData("Title ^^ [\" Warm \", \"with syrup \"]", new[] { "Warm", "with syrup" })] - public void list_value_with_comma_is_split_into_items(string input, string[] expectedTitles) + [InlineData("Title !^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm", "with syrup", "a\\b" })] + public void list_value_with_comma_is_one_item(string input, string[] expectedTitles) { var people = new[] { @@ -106,6 +108,26 @@ public void list_value_with_comma_is_split_into_items(string input, string[] exp result.Select(x => x.Title).Should().BeEquivalentTo(expectedTitles); } + [Theory] + [InlineData("tags ^^ [\"a\", \"b\", \"c\"]", "[a,b,c]")] + [InlineData("tags ^^ [a, b, c]", "[a,b,c]")] + [InlineData("tags ^^ [\"a\\b\"]", "[a\\b]")] + [InlineData("tags ^^ [\" a \", \"b\"]", "[ a ,b]")] + [InlineData("tags ^^ [\"Warm, with syrup\", \"Cold\"]", "[Warm, with syrup,Cold]")] + public void custom_operation_gets_list_text_without_escapes(string input, string expectedValue) + { + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Title == (string)value).HasQueryName("tags"); + }); + + var expression = FilterParser.ParseFilter(input, config); + var invocation = (InvocationExpression)expression.Body; + var received = ((ConstantExpression)invocation.Arguments[2]).Value; + + received.Should().Be(expectedValue); + } + [Fact] public void enum_list_value_is_split_into_items() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 7e9b147..b755029 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq.Expressions; using System.Reflection; +using System.Text; using Configuration; using Exceptions; using Operators; @@ -337,9 +338,52 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() from openingBracket in Parse.Char('[') from content in SquareBracketValuesParser from closingBracket in Parse.Char(']') - select new RightSideValue("[" + string.Join(",", content.Select(x => x.Value)) + "]", false, + select new RightSideValue("[" + string.Join(",", content.Select(x => EscapeListItem(x.Value))) + "]", false, content.Any(x => x.IsDotOnlyNumber) ? "" : null); + // List items are joined with ',' so quoted items that contain ',' or '\' are escaped and split back with SplitListItems, which trims each item + private static string EscapeListItem(string item) + => item.Replace(@"\", @"\\").Replace(",", @"\,"); + + private static string UnescapeListText(string list) + { + var text = new StringBuilder(list.Length); + for (var i = 0; i < list.Length; i++) + { + if (list[i] == '\\' && i + 1 < list.Length) + i++; + text.Append(list[i]); + } + + return text.ToString(); + } + + private static List SplitListItems(string list) + { + var items = new List(); + var current = new StringBuilder(); + var content = list.Substring(1, list.Length - 2); + for (var i = 0; i < content.Length; i++) + { + if (content[i] == '\\' && i + 1 < content.Length) + { + current.Append(content[++i]); + } + else if (content[i] == ',') + { + items.Add(current.ToString().Trim()); + current.Clear(); + } + else + { + current.Append(content[i]); + } + } + items.Add(current.ToString().Trim()); + + return items; + } + private static readonly Parser RightSideValueChoiceParser = Parse.String("null").Text().Select(v => new RightSideValue(v, false)) .Or(GuidFormatParser.Select(v => new RightSideValue(v, false))) @@ -592,7 +636,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { targetType = typeof(string); } - var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); + var values = SplitListItems(right); var elementType = targetType.IsArray ? targetType.GetElementType()! : targetType; var expressions = values.Select(x => @@ -684,7 +728,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (right.StartsWith("[") && right.EndsWith("]")) { - var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); + var values = SplitListItems(right); var elementType = targetType.IsArray ? targetType.GetElementType() : targetType; var expressions = values.Select(x => @@ -1662,6 +1706,10 @@ private static Expression CreateCustomOperationExpression(ParameterExpression // 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 + // A custom operation gets the list as text, so remove the escapes that EscapeListItem added + if (rightValue.StartsWith("[") && rightValue.EndsWith("]")) + rightValue = UnescapeListText(rightValue); + object? convertedValue = ConvertStringToBasicType(rightValue); // Create the parameter expressions for the custom operation diff --git a/README.md b/README.md index a708731..34754d7 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ Here's an example for the `in` operator: var input = """(Age ^^ [20, 30, 40]) && (BirthMonth ^^* ["January", "February", "March"]) || (Id ^^ ["6d623e92-d2cf-4496-a2df-f49fa77328ee"])"""; ``` +A quoted list item can hold a comma. For example, `Title ^^ ["Warm, with syrup", "Cold"]` has two items: `Warm, with syrup` and `Cold`. + ### Property List Grouping Property list grouping allows you to apply a single comparison operation across multiple properties, making it easy to search for a value in any of several fields without writing repetitive conditions.