From 4d856892c8c86d296cc610b4e037c2c7e405ade1 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:57:10 +0300 Subject: [PATCH] fix(operators)!: make does-not-have exclude collections with the value `Tags !^$ "sweet"` built `Tags.Any(z => z != "sweet")`. This matched every collection with any other value, so it returned rows that have the value and left out empty collections. On Postgres, EF could not translate it and threw InvalidOperationException. The operator now builds the negation of has: `!Tags.Any(z => z == "sweet")`. This is the same pattern as the other negated collection operators. BREAKING CHANGE: `!^$` returns only collections without the value, including empty collections. It no longer returns a collection that has the value and another value. `%!^$` is now the negation of `%^$`. Use a custom operation to get the old meaning. --- .../Tests/FilterParsingRegressionTests.cs | 5 ++++- QueryKit.UnitTests/FilterParserTests.cs | 4 ++-- QueryKit.UnitTests/FilterParsingRegressionTests.cs | 5 ++++- QueryKit/Operators/ComparisonOperator.cs | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index f192c3d..9151f72 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -297,9 +297,12 @@ public async Task sort_direction_after_double_space_is_read() [Theory] [InlineData("""Tags ^$ "sweet" """, new[] { "pancakes" })] + [InlineData("""Tags !^$ "sweet" """, new[] { "stew", "bread", "water" })] [InlineData("""Tags ^$* "WINNER" """, new[] { "bread" })] + [InlineData("""Tags !^$* "WINNER" """, new[] { "pancakes", "stew", "water" })] [InlineData("""Tags %^$ "dinner" """, new[] { "stew", "water" })] - public async Task has_returns_matching_rows(string input, string[] expectedTitles) + [InlineData("""Tags %!^$ "dinner" """, new[] { "pancakes", "bread" })] + public async Task has_and_does_not_have_return_matching_rows(string input, string[] expectedTitles) { // Arrange var testingServiceScope = new TestingServiceScope(); diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 420ec16..1e04e5a 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -749,7 +749,7 @@ public void primitive_collection_does_not_have() var input = """Tags !^$ "winner" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => x.Tags.Any(z => (z != "winner"))""""); + .Be(""""x => Not(x.Tags.Any(z => (z == "winner")))""""); } [Fact] @@ -767,7 +767,7 @@ public void primitive_collection_does_not_have_case_insensitive() var input = """Tags !^$* "winner" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => x.Tags.Any(z => (z.ToLower() != "winner".ToLower()))""""); + .Be(""""x => Not(x.Tags.Any(z => (z.ToLower() == "winner".ToLower())))""""); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..4f90344 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -269,9 +269,12 @@ public void sort_direction_after_extra_white_space_is_read(string input) [Theory] [InlineData("""Tags ^$ "sweet" """, new[] { "pancakes" })] + [InlineData("""Tags !^$ "sweet" """, new[] { "stew", "bread", "water" })] [InlineData("""Tags ^$* "WINNER" """, new[] { "bread" })] + [InlineData("""Tags !^$* "WINNER" """, new[] { "pancakes", "stew", "water" })] [InlineData("""Tags %^$ "dinner" """, new[] { "stew", "water" })] - public void has_returns_matching_rows(string input, string[] expectedTitles) + [InlineData("""Tags %!^$ "dinner" """, new[] { "pancakes", "bread" })] + public void has_and_does_not_have_return_matching_rows(string input, string[] expectedTitles) { var recipes = new[] { diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 97c959a..f60071b 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -803,7 +803,7 @@ public override Expression GetExpression(Expression left, Expression right, T left.Type.GetGenericTypeDefinition() == typeof(IList<>) || typeof(IEnumerable<>).IsAssignableFrom(left.Type.GetGenericTypeDefinition()))) { - return GetCollectionExpression(left, right, Expression.NotEqual, UsesAll); + return Expression.Not(GetCollectionExpression(left, right, Expression.Equal, UsesAll)); } throw new QueryKitParsingException("DoesNotHaveType is only supported for collections");