From 01ebe054982279982e60afe7117bf985c5ca9e97 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:21:42 +0300 Subject: [PATCH] fix(operators)!: reject a null value for case-sensitive string operators Title @= null built x.Title.Contains(null). In memory, the query threw ArgumentNullException when it ran. On Postgres, the query returned no rows. The same was true for _=, _-=, and their negations. The operators now throw QueryKitParsingException when the filter is parsed, on every target. BREAKING CHANGE: a null value on the right side of @=, _=, _-=, !@=, !_=, or !_-= (and their case-insensitive forms) throws QueryKitParsingException from ApplyQueryKitFilter. A database query with this filter returned no rows before. Use == or != to compare with null. --- .../Tests/FilterParsingRegressionTests.cs | 15 ++++++++++++++ .../FilterParsingRegressionTests.cs | 16 +++++++++++++++ QueryKit/Operators/ComparisonOperator.cs | 20 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index f192c3d..baab087 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -3,6 +3,7 @@ namespace QueryKit.IntegrationTests.Tests; using System.Globalization; using System.Linq.Expressions; using Configuration; +using Exceptions; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Operators; @@ -320,4 +321,18 @@ public async Task has_returns_matching_rows(string input, string[] expectedTitle // Assert recipes.Select(x => x.Title[prefix.Length..]).Should().BeEquivalentTo(expectedTitles); } + + [Fact] + public void string_operator_with_null_value_throws_querykit_exception() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var queryablePeople = testingServiceScope.DbContext().People; + + // Act + var act = () => queryablePeople.ApplyQueryKitFilter("Title @= null"); + + // Assert + act.Should().Throw(); + } } diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..7fff583 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -286,6 +286,22 @@ public void has_returns_matching_rows(string input, string[] expectedTitles) result.Select(x => x.Title).Should().BeEquivalentTo(expectedTitles); } + [Theory] + [InlineData("Title @= null")] + [InlineData("Title _= null")] + [InlineData("Title _-= null")] + [InlineData("Title !@= null")] + [InlineData("Title !_= null")] + [InlineData("Title !_-= null")] + [InlineData("Title @=* null")] + [InlineData("Title !_-=* null")] + public void string_operator_with_null_value_throws_querykit_exception(string input) + { + var act = () => FilterParser.ParseFilter(input); + + act.Should().Throw(); + } + private static TResult WithCulture(string cultureName, Func action) { var originalCulture = CultureInfo.CurrentCulture; diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 97c959a..6dedb1f 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -365,6 +365,8 @@ public ContainsType(bool caseInsensitive = false, bool usesAll = false) : base(" public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "Contains", false, UsesAll); @@ -400,6 +402,8 @@ public StartsWithType(bool caseInsensitive = false, bool usesAll = false) : base public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "StartsWith", false, UsesAll); @@ -435,6 +439,8 @@ public EndsWithType(bool caseInsensitive = false, bool usesAll = false) : base(" public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "EndsWith", false, UsesAll); @@ -470,6 +476,8 @@ public NotContainsType(bool caseInsensitive = false, bool usesAll = false) : bas public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "Contains", true, UsesAll); @@ -505,6 +513,8 @@ public NotStartsWithType(bool caseInsensitive = false, bool usesAll = false) : b public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "StartsWith", true, UsesAll); @@ -540,6 +550,8 @@ public NotEndsWithType(bool caseInsensitive = false, bool usesAll = false) : bas public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { + EnsureStringValueIsNotNull(right, Operator()); + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return GetCollectionExpression(left, right, "EndsWith", true, UsesAll); @@ -1087,6 +1099,14 @@ private Expression GetCountExpression(Expression left, Expression right, string return (Expression)comparisonMethod.Invoke(null, new object[] { countExpression, right })!; } + private static void EnsureStringValueIsNotNull(Expression right, string op) + { + if (right is ConstantExpression { Value: null }) + { + throw new QueryKitParsingException($"The '{op}' operator does not accept a null value. Use '==' or '!=' to compare with null."); + } + } + private static (Expression left, Expression right) EnsureCompatibleExpressionTypes(Expression left, Expression right) { if (left.Type == right.Type)