Skip to content

Commit 8024a41

Browse files
committed
fix: handle null strings in case-insensitive operators
Case-insensitive string operators (@=*, ==*, !=*, _=*, _-=*, !@=*, !_=*, !_-=*, ^^*, !^^*) threw NullReferenceException when filtering nullable string properties with null values. This occurred because ToLower() was called directly on the property without null guards. Added null checks to all affected operators: - Positive operators (contains, equals, startsWith, endsWith, in) now return false for null values: (x.Prop != null) && x.Prop.ToLower()... - Negative operators (notContains, notEquals, etc.) now return true for null values: (x.Prop == null) || !x.Prop.ToLower()... This matches expected behavior where null doesn't equal, contain, or match any non-null value. Fixes case-insensitive filtering on nullable string fields for both IQueryable (database) and IEnumerable (in-memory) scenarios. fixes #100
1 parent 70f8b5f commit 8024a41

File tree

5 files changed

+453
-33
lines changed

5 files changed

+453
-33
lines changed

QueryKit.IntegrationTests/Tests/NullFilteringTests.cs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,219 @@ public async Task can_filter_nullable_enum_not_equals_null()
539539
people[0].BirthMonth.Should().Be(BirthMonthEnum.June);
540540
}
541541

542+
[Fact]
543+
public async Task can_filter_nullable_string_with_case_insensitive_contains()
544+
{
545+
// Arrange
546+
var testingServiceScope = new TestingServiceScope();
547+
var uniqueLastName = $"CaseInsensitiveContainsTest_{Guid.NewGuid()}";
548+
var personWithNullTitle = new FakeTestingPersonBuilder()
549+
.WithTitle(null)
550+
.WithLastName(uniqueLastName)
551+
.WithFirstName("NullTitle")
552+
.Build();
553+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
554+
.WithTitle("Doctor Smith")
555+
.WithLastName(uniqueLastName)
556+
.WithFirstName("MatchingTitle")
557+
.Build();
558+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
559+
.WithTitle("Mr. Jones")
560+
.WithLastName(uniqueLastName)
561+
.WithFirstName("NonMatchingTitle")
562+
.Build();
563+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
564+
565+
var input = $"""Title @=* "doctor" && LastName == "{uniqueLastName}" """;
566+
567+
// Act
568+
var queryablePeople = testingServiceScope.DbContext().People;
569+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
570+
var people = await appliedQueryable.ToListAsync();
571+
572+
// Assert
573+
people.Count.Should().Be(1);
574+
people[0].Id.Should().Be(personWithMatchingTitle.Id);
575+
}
576+
577+
[Fact]
578+
public async Task can_filter_nullable_string_with_case_insensitive_starts_with()
579+
{
580+
// Arrange
581+
var testingServiceScope = new TestingServiceScope();
582+
var uniqueLastName = $"CaseInsensitiveStartsWithTest_{Guid.NewGuid()}";
583+
var personWithNullTitle = new FakeTestingPersonBuilder()
584+
.WithTitle(null)
585+
.WithLastName(uniqueLastName)
586+
.WithFirstName("NullTitle")
587+
.Build();
588+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
589+
.WithTitle("Doctor Smith")
590+
.WithLastName(uniqueLastName)
591+
.WithFirstName("MatchingTitle")
592+
.Build();
593+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
594+
.WithTitle("Mr. Jones")
595+
.WithLastName(uniqueLastName)
596+
.WithFirstName("NonMatchingTitle")
597+
.Build();
598+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
599+
600+
var input = $"""Title _=* "doctor" && LastName == "{uniqueLastName}" """;
601+
602+
// Act
603+
var queryablePeople = testingServiceScope.DbContext().People;
604+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
605+
var people = await appliedQueryable.ToListAsync();
606+
607+
// Assert
608+
people.Count.Should().Be(1);
609+
people[0].Id.Should().Be(personWithMatchingTitle.Id);
610+
}
611+
612+
[Fact]
613+
public async Task can_filter_nullable_string_with_case_insensitive_ends_with()
614+
{
615+
// Arrange
616+
var testingServiceScope = new TestingServiceScope();
617+
var uniqueLastName = $"CaseInsensitiveEndsWithTest_{Guid.NewGuid()}";
618+
var personWithNullTitle = new FakeTestingPersonBuilder()
619+
.WithTitle(null)
620+
.WithLastName(uniqueLastName)
621+
.WithFirstName("NullTitle")
622+
.Build();
623+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
624+
.WithTitle("Doctor Smith")
625+
.WithLastName(uniqueLastName)
626+
.WithFirstName("MatchingTitle")
627+
.Build();
628+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
629+
.WithTitle("Mr. Jones")
630+
.WithLastName(uniqueLastName)
631+
.WithFirstName("NonMatchingTitle")
632+
.Build();
633+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
634+
635+
var input = $"""Title _-=* "smith" && LastName == "{uniqueLastName}" """;
636+
637+
// Act
638+
var queryablePeople = testingServiceScope.DbContext().People;
639+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
640+
var people = await appliedQueryable.ToListAsync();
641+
642+
// Assert
643+
people.Count.Should().Be(1);
644+
people[0].Id.Should().Be(personWithMatchingTitle.Id);
645+
}
646+
647+
[Fact]
648+
public async Task can_filter_nullable_string_with_case_insensitive_equals()
649+
{
650+
// Arrange
651+
var testingServiceScope = new TestingServiceScope();
652+
var uniqueLastName = $"CaseInsensitiveEqualsTest_{Guid.NewGuid()}";
653+
var personWithNullTitle = new FakeTestingPersonBuilder()
654+
.WithTitle(null)
655+
.WithLastName(uniqueLastName)
656+
.WithFirstName("NullTitle")
657+
.Build();
658+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
659+
.WithTitle("Doctor")
660+
.WithLastName(uniqueLastName)
661+
.WithFirstName("MatchingTitle")
662+
.Build();
663+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
664+
.WithTitle("Mr.")
665+
.WithLastName(uniqueLastName)
666+
.WithFirstName("NonMatchingTitle")
667+
.Build();
668+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
669+
670+
var input = $"""Title ==* "doctor" && LastName == "{uniqueLastName}" """;
671+
672+
// Act
673+
var queryablePeople = testingServiceScope.DbContext().People;
674+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
675+
var people = await appliedQueryable.ToListAsync();
676+
677+
// Assert
678+
people.Count.Should().Be(1);
679+
people[0].Id.Should().Be(personWithMatchingTitle.Id);
680+
}
681+
682+
[Fact]
683+
public async Task can_filter_nullable_string_with_case_insensitive_not_equals()
684+
{
685+
// Arrange
686+
var testingServiceScope = new TestingServiceScope();
687+
var uniqueLastName = $"CaseInsensitiveNotEqualsTest_{Guid.NewGuid()}";
688+
var personWithNullTitle = new FakeTestingPersonBuilder()
689+
.WithTitle(null)
690+
.WithLastName(uniqueLastName)
691+
.WithFirstName("NullTitle")
692+
.Build();
693+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
694+
.WithTitle("Doctor")
695+
.WithLastName(uniqueLastName)
696+
.WithFirstName("MatchingTitle")
697+
.Build();
698+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
699+
.WithTitle("Mr.")
700+
.WithLastName(uniqueLastName)
701+
.WithFirstName("NonMatchingTitle")
702+
.Build();
703+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
704+
705+
// Title !=* "doctor" should return records where Title is not "doctor" (case-insensitive)
706+
// null values should be treated as non-matching (i.e. they don't equal "doctor")
707+
var input = $"""Title !=* "doctor" && LastName == "{uniqueLastName}" """;
708+
709+
// Act
710+
var queryablePeople = testingServiceScope.DbContext().People;
711+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
712+
var people = await appliedQueryable.ToListAsync();
713+
714+
// Assert - should return null title and "Mr." title (both are != "doctor")
715+
people.Count.Should().Be(2);
716+
people.Should().Contain(p => p.Id == personWithNullTitle.Id);
717+
people.Should().Contain(p => p.Id == personWithNonMatchingTitle.Id);
718+
}
719+
720+
[Fact]
721+
public async Task can_filter_nullable_string_with_case_insensitive_in_operator()
722+
{
723+
// Arrange
724+
var testingServiceScope = new TestingServiceScope();
725+
var uniqueLastName = $"CaseInsensitiveInTest_{Guid.NewGuid()}";
726+
var personWithNullTitle = new FakeTestingPersonBuilder()
727+
.WithTitle(null)
728+
.WithLastName(uniqueLastName)
729+
.WithFirstName("NullTitle")
730+
.Build();
731+
var personWithMatchingTitle = new FakeTestingPersonBuilder()
732+
.WithTitle("Doctor")
733+
.WithLastName(uniqueLastName)
734+
.WithFirstName("MatchingTitle")
735+
.Build();
736+
var personWithNonMatchingTitle = new FakeTestingPersonBuilder()
737+
.WithTitle("Mr.")
738+
.WithLastName(uniqueLastName)
739+
.WithFirstName("NonMatchingTitle")
740+
.Build();
741+
await testingServiceScope.InsertAsync(personWithNullTitle, personWithMatchingTitle, personWithNonMatchingTitle);
742+
743+
var input = $"""Title ^^* ["doctor", "professor"] && LastName == "{uniqueLastName}" """;
744+
745+
// Act
746+
var queryablePeople = testingServiceScope.DbContext().People;
747+
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input);
748+
var people = await appliedQueryable.ToListAsync();
749+
750+
// Assert
751+
people.Count.Should().Be(1);
752+
people[0].Id.Should().Be(personWithMatchingTitle.Id);
753+
}
754+
542755
[Fact]
543756
public async Task can_filter_with_null_in_complex_expression()
544757
{

0 commit comments

Comments
 (0)