Skip to content

fix(operators)!: check for null in case-sensitive string operators - #145

Open
pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-string-null-guard
Open

pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-string-null-guard

Conversation

@pdevito3

@pdevito3 pdevito3 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

For later consideration in a major version. Do not merge now. #134 restored the v1.14.2 expression to keep v1.x compatible (restore commit 0abd1f8). This PR re-applies aff9638. The captain decides on this PR separately.

Summary

The case-sensitive string operators get a null check on the property, like their case-insensitive forms:

Operator v1.14.2 and main This PR
@= x.Title.Contains(v) (x.Title != null) AndAlso x.Title.Contains(v)
_= x.Title.StartsWith(v) (x.Title != null) AndAlso x.Title.StartsWith(v)
_-= x.Title.EndsWith(v) (x.Title != null) AndAlso x.Title.EndsWith(v)
!@= Not(x.Title.Contains(v)) (x.Title == null) OrElse Not(x.Title.Contains(v))
!_= Not(x.Title.StartsWith(v)) (x.Title == null) OrElse Not(x.Title.StartsWith(v))
!_-= Not(x.Title.EndsWith(v)) (x.Title == null) OrElse Not(x.Title.EndsWith(v))

The change is in QueryKit/Operators/ComparisonOperator.cs only.

v1.14.2 behavior (and main)

The case-sensitive operators call the string method on the property with no null check. On IEnumerable and on LINQ to Objects, a null property throws NullReferenceException. The case-insensitive forms (@=*, _=*, _-=* and their negations) already check for null.

New behavior

A null value does not contain, start with, or end with a value. @=, _=, and _-= skip a null property. !@=, !_=, and !_-= include a null property. In memory, no operator throws on a null property.

Example

var people = new[]
{
    new TestingPerson { Title = null, FirstName = "null" },
    new TestingPerson { Title = "lamb", FirstName = "lamb" },
    new TestingPerson { Title = "other", FirstName = "other" },
};
people.AsQueryable().ApplyQueryKitFilter("""Title _= "la" """).ToList();
people.AsQueryable().ApplyQueryKitFilter("""Title !_= "la" """).ToList();
  • v1.14.2 and main: both calls throw NullReferenceException.
  • This PR: the first call gives lamb. The second call gives null and other.

Expression text for Title _= "lam":

  • v1.14.2 and main: x => x.Title.StartsWith("lam").
  • This PR: x => ((x.Title != null) AndAlso x.Title.StartsWith("lam")).

Justification

An in-memory filter must not crash on data that has a null string. The case-sensitive and case-insensitive forms of one operator must agree on null. The in-memory result now matches the Postgres (EF Core) result, which already includes a null property for the negated operators.

Migration

  • Database queries: for @=, _=, and _-=, the result rows do not change. For the negated operators, the rows can change on a provider that does not already include null. On Postgres (EF Core), the result rows do not change. The integration test below shows this with and without the null check.
  • In-memory queries: a filter that threw NullReferenceException now returns rows. Code that caught this exception must use the result instead.
  • Code that reads the expression text (ToDisplayString() or ToString()) sees the added null check.

README

No change. The README does not document null handling for these operators.

Tests

These tests come back from main before #134:

  • Unit FilterParserTests: 8 expectations in 8 tests have the null check again: complex_with_lots_of_types, starts_with_operator, ends_with_operator, multiple_properties_and_operators, complex_filter_with_nested_parentheses, ends_with_works, contains_is_case_sensitive, not_contains_works.
  • Unit OperatorAliasTests.can_use_contains_not_case_sensitive: 1 expectation has the null check again.
  • Unit FilterParsingRegressionTests: case_sensitive_string_operator_on_null_property_throws_in_memory becomes case_sensitive_string_operator_handles_null_property (6 cases). The positive operators give lamb. The negated operators give null and other.

The integration test case_sensitive_string_operator_handles_null_property (Postgres) does not change. It passes with and without the null check.

dotnet test: 470 unit tests and 297 Postgres integration tests (Testcontainers) pass, 0 failures.

Rebase on main

This branch is rebased on current main (#169). The rebase had no conflicts. The breaking change did not change.

The case-sensitive @=, _=, _-= and their negations called the string method on the property without a null check. In memory, a null property threw NullReferenceException. The case-insensitive forms already checked for null. Add the same check: a null value does not contain, start with, or end with a value.

BREAKING CHANGE: the expression of a case-sensitive string operator now has a null check, so the expression text changes. In memory, a null property no longer throws: @=, _=, and _-= skip it, and the negations include it.
@pdevito3
pdevito3 force-pushed the fm/qk-breaking-string-null-guard branch from 1df8bf4 to 2a68372 Compare October 1, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant