diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 6766868..bdedb93 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -3853,4 +3853,352 @@ public async Task can_filter_with_derived_property_containing_complex_conditiona pastPeople.Should().Contain(p => p.Id == fakePersonTwo.Id, "past date should match < 0"); } + [Fact] + public async Task case_insensitive_upper_mode_finds_uppercase_data_with_lowercase_filter() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"UNIQUE TITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle(uppercaseTitle) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Search with a lowercase version of the unique suffix + var lowercaseSearchValue = uniqueSuffix.ToLower(); + var input = $"""Title @=* "{lowercaseSearchValue}" """; + + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var people = await queryablePeople.ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert - Upper mode: UPPER(title) contains UPPER(searchValue) → should match + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_per_property_upper_mode_finds_uppercase_data() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"PERPROPTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle(uppercaseTitle) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var lowerSearch = uniqueSuffix.ToLower(); + var input = $"""Title @=* "{lowerSearch}" """; + + // Global mode is Lower, but Title is overridden to Upper + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower; + settings.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + }); + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var people = await queryablePeople.ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert - per-property Upper mode: UPPER(title) contains UPPER(searchValue) → should match + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_equals_operator_finds_uppercase_data() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"EQTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""Title ==* "{uppercaseTitle.ToLower()}" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_not_equals_operator_excludes_matching_record() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"NEQTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().WithTitle($"OTHER {uniqueSuffix}").Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Search with lowercase — should exclude fakePersonOne + var input = $"""Title !=* "{uppercaseTitle.ToLower()}" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Should().NotContain(p => p.Id == fakePersonOne.Id); + people.Should().Contain(p => p.Id == fakePersonTwo.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_starts_with_operator_finds_uppercase_data() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"SWTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Search with lowercase prefix + var input = $"""Title _=* "swtitle" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Count.Should().BeGreaterOrEqualTo(1); + people.Should().Contain(p => p.Id == fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_ends_with_operator_finds_uppercase_data() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"EWTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Search with lowercase suffix + var input = $"""Title _-=* "{uniqueSuffix.ToLower()}" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Count.Should().BeGreaterOrEqualTo(1); + people.Should().Contain(p => p.Id == fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_not_contains_operator_excludes_matching_record() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"NOTCONTAINS {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().WithTitle($"DIFFERENT {Guid.NewGuid():N}").Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Lowercase search — Upper mode should still find the match and therefore exclude it + var input = $"""Title !@=* "{uniqueSuffix.ToLower()}" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Should().NotContain(p => p.Id == fakePersonOne.Id); + people.Should().Contain(p => p.Id == fakePersonTwo.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_not_starts_with_operator_excludes_matching_record() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"NSWPREFIX {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().WithTitle($"DIFFERENT {Guid.NewGuid():N}").Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""Title !_=* "nswprefix" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Should().NotContain(p => p.Id == fakePersonOne.Id); + people.Should().Contain(p => p.Id == fakePersonTwo.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_not_ends_with_operator_excludes_matching_record() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"NEWSUFFIX {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().WithTitle($"DIFFERENT {Guid.NewGuid():N}").Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""Title !_-=* "{uniqueSuffix.ToLower()}" """; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Should().NotContain(p => p.Id == fakePersonOne.Id); + people.Should().Contain(p => p.Id == fakePersonTwo.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_in_operator_finds_uppercase_data() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"INTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Provide lowercase version in the list + var input = $"""Title ^^* ["{uppercaseTitle.ToLower()}"]"""; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Count.Should().BeGreaterOrEqualTo(1); + people.Should().Contain(p => p.Id == fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_upper_mode_not_in_operator_excludes_matching_record() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uppercaseTitle = $"NOTINTITLE {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(uppercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().WithTitle($"DIFFERENT {Guid.NewGuid():N}").Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Lowercase version in exclusion list — should still exclude fakePersonOne + var input = $"""Title !^^* ["{uppercaseTitle.ToLower()}"]"""; + var config = new QueryKitConfiguration(s => s.CaseInsensitiveComparison = CaseInsensitiveMode.Upper); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert + people.Should().NotContain(p => p.Id == fakePersonOne.Id); + people.Should().Contain(p => p.Id == fakePersonTwo.Id); + } + + [Fact] + public async Task case_insensitive_per_property_lower_overrides_global_upper() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueSuffix = Guid.NewGuid().ToString("N")[..8].ToLower(); + var lowercaseTitle = $"lowertitle {uniqueSuffix}"; + + var fakePersonOne = new FakeTestingPersonBuilder().WithTitle(lowercaseTitle).Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Search with uppercase — per-property Lower (lower(data) == lower(search)) should still match + var input = $"""Title @=* "{uniqueSuffix.ToUpper()}" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + settings.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Lower); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert - even though global is Upper, per-property Lower means lower(title) contains lower(search) → match + people.Count.Should().BeGreaterOrEqualTo(1); + people.Should().Contain(p => p.Id == fakePersonOne.Id); + } + + [Fact] + public async Task case_insensitive_per_property_overrides_independent_of_other_properties() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var uniqueTitleSuffix = Guid.NewGuid().ToString("N")[..8].ToUpper(); + var uniqueFirstName = $"FIRSTNAME{Guid.NewGuid().ToString("N")[..8].ToUpper()}"; + + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle($"OVERTITLE {uniqueTitleSuffix}") + .WithFirstName(uniqueFirstName) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + // Title uses per-property Upper (global is Lower); FirstName uses global Lower + var input = $"""Title @=* "{uniqueTitleSuffix.ToLower()}" && FirstName ==* "{uniqueFirstName.ToLower()}" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower; + settings.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config).ToListAsync(); + + // Assert - both conditions resolve correctly despite using different modes + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + } diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index eacb428..a4724fc 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -796,4 +796,199 @@ public void can_filter_with_has_conversion_configuration() expressionString.Should().Contain("x.Email"); expressionString.Should().Contain("test@example.com"); } + + [Fact] + public void case_insensitive_default_config_uses_to_lower() + { + // Arrange - no config, should default to ToLower + var input = """Title @=* "waffle" """; + + // Act + var filterExpression = FilterParser.ParseFilter(input); + + // Assert + filterExpression.ToString().Should() + .Be("""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))"""); + } + + [Fact] + public void case_insensitive_upper_mode_global_uses_to_upper() + { + // Arrange - global Upper mode + var input = """Title @=* "waffle" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + // Act + var filterExpression = FilterParser.ParseFilter(input, config); + + // Assert - should use ToUpper instead of ToLower + filterExpression.ToString().Should() + .Be("""x => ((x.Title != null) AndAlso x.Title.ToUpper().Contains("waffle".ToUpper()))"""); + } + + [Fact] + public void case_insensitive_per_property_upper_mode_overrides_global_lower() + { + // Arrange - global Lower (default), per-property Title set to Upper + var input = """Title @=* "waffle" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower; + settings.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + }); + + // Act + var filterExpression = FilterParser.ParseFilter(input, config); + + // Assert - Title should use ToUpper because of per-property override + filterExpression.ToString().Should() + .Be("""x => ((x.Title != null) AndAlso x.Title.ToUpper().Contains("waffle".ToUpper()))"""); + } + + [Fact] + public void case_insensitive_equals_upper_mode_uses_to_upper() + { + // Arrange + var input = """Title ==* "waffle" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + // Act + var filterExpression = FilterParser.ParseFilter(input, config); + + // Assert + filterExpression.ToString().Should() + .Contain("ToUpper"); + filterExpression.ToString().Should() + .NotContain("ToLower"); + } + + [Fact] + public void case_insensitive_not_equals_upper_mode_uses_to_upper() + { + var input = """Title !=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title == null) OrElse (x.Title.ToUpper() != \"lamb\".ToUpper()))"); + } + + [Fact] + public void case_insensitive_starts_with_upper_mode_uses_to_upper() + { + var input = """Title _=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title != null) AndAlso x.Title.ToUpper().StartsWith(\"lamb\".ToUpper()))"); + } + + [Fact] + public void case_insensitive_ends_with_upper_mode_uses_to_upper() + { + var input = """Title _-=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title != null) AndAlso x.Title.ToUpper().EndsWith(\"lamb\".ToUpper()))"); + } + + [Fact] + public void case_insensitive_not_contains_upper_mode_uses_to_upper() + { + var input = """Title !@=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().Contains(\"lamb\".ToUpper())))"); + } + + [Fact] + public void case_insensitive_not_starts_with_upper_mode_uses_to_upper() + { + var input = """Title !_=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().StartsWith(\"lamb\".ToUpper())))"); + } + + [Fact] + public void case_insensitive_not_ends_with_upper_mode_uses_to_upper() + { + var input = """Title !_-=* "lamb" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToString().Should() + .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().EndsWith(\"lamb\".ToUpper())))"); + } + + [Fact] + public void case_insensitive_in_operator_upper_mode_uses_to_upper() + { + var input = """Title ^^* ["lamb","chicken"] """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + var asString = filterExpression.ToString(); + + asString.Should().Contain("ToUpper"); + asString.Should().NotContain("ToLower"); + } + + [Fact] + public void case_insensitive_per_property_lower_overrides_global_upper() + { + // Arrange - global Upper, per-property Title set back to Lower + var input = """Title @=* "waffle" """; + var config = new QueryKitConfiguration(settings => + { + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; + settings.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Lower); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + // Title should use ToLower because per-property overrides global Upper + filterExpression.ToString().Should() + .Be("""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))"""); + } } diff --git a/QueryKit/Configuration/CaseInsensitiveMode.cs b/QueryKit/Configuration/CaseInsensitiveMode.cs new file mode 100644 index 0000000..28cb90f --- /dev/null +++ b/QueryKit/Configuration/CaseInsensitiveMode.cs @@ -0,0 +1,13 @@ +namespace QueryKit.Configuration; + +/// +/// Controls which SQL case transformation is generated for case-insensitive string operators (e.g. @=*, _=*). +/// +public enum CaseInsensitiveMode +{ + /// Default. Generates ToLower() / LOWER() comparisons. + Lower = 0, + + /// Generates ToUpper() / UPPER() comparisons. Useful when data is normalized to uppercase. + Upper = 1 +} diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 727cf06..1b867c3 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -33,6 +33,7 @@ public interface IQueryKitConfiguration public string HasOperator { get; set; } public string DoesNotHaveOperator { get; set; } public int? MaxPropertyDepth { get; set; } + public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } } public class QueryKitConfiguration : IQueryKitConfiguration @@ -68,6 +69,7 @@ public class QueryKitConfiguration : IQueryKitConfiguration public bool AllowUnknownProperties { get; set; } = false; public Type? DbContextType { get; set; } public int? MaxPropertyDepth { get; set; } + public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } public QueryKitConfiguration(Action configureSettings) { @@ -106,5 +108,6 @@ public QueryKitConfiguration(Action configureSettings) HasOperator = settings.HasOperator; DoesNotHaveOperator = settings.DoesNotHaveOperator; MaxPropertyDepth = settings.MaxPropertyDepth; + CaseInsensitiveComparison = settings.CaseInsensitiveComparison; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index a9f219c..07a1d11 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -36,6 +36,7 @@ public class QueryKitSettings public bool AllowUnknownProperties { get; set; } public Type? DbContextType { get; set; } public int? MaxPropertyDepth { get; set; } + public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; public QueryKitPropertyMapping Property(Expression>? propertySelector) { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index bf2f95a..8f83ad0 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -607,6 +607,18 @@ private static bool IsValidPropertyName(string value) value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '.'); } + private static CaseInsensitiveMode ResolveCaseMode(string? propertyPath, IQueryKitConfiguration? config) + { + if (!string.IsNullOrEmpty(propertyPath) && config?.PropertyMappings != null) + { + var propertyInfo = config.PropertyMappings.GetPropertyInfo(propertyPath) + ?? config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath); + if (propertyInfo?.CaseInsensitiveComparison.HasValue == true) + return propertyInfo.CaseInsensitiveComparison.Value; + } + return config?.CaseInsensitiveComparison ?? CaseInsensitiveMode.Lower; + } + private static Parser ComparisonExprParser(ParameterExpression parameter, IQueryKitConfiguration? config) { var comparisonOperatorParser = ComparisonOperatorParser.Token(); @@ -661,7 +673,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type); return temp.op.GetExpression(guidStringExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath), - config?.DbContextType); + config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison @@ -692,7 +704,8 @@ private static Parser ComparisonExprParser(ParameterExpression pa // Ensure compatible types for property-to-property comparison var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightPropertyExpr); - return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType); + var propToProptPath = temp.leftExpr is MemberExpression ptpMemberExpr ? GetPropertyPath(ptpMemberExpr, parameter) : null; + return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType, ResolveCaseMode(propToProptPath, config)); } } @@ -782,7 +795,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa } - return temp.op.GetExpression(leftExprForComparison, rightExpr, config?.DbContextType); + return temp.op.GetExpression(leftExprForComparison, rightExpr, config?.DbContextType, ResolveCaseMode(propertyPath, config)); }); return propertyListComparison.Or(arithmeticComparison).Or(regularComparison); @@ -1134,7 +1147,7 @@ private static Parser PropertyListComparisonExprParser( } var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, fullPropPath); - var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType); + var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators result = result == null diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index acf2d7b..a5bb8f8 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -177,7 +177,7 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi public abstract bool IsCountOperator(); public bool CaseInsensitive { get; protected set; } public bool UsesAll { get; protected set; } - public abstract Expression GetExpression(Expression left, Expression right, Type? dbContextType); + public abstract Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower); /// /// Returns true if this operator requires string comparison (e.g., Contains, StartsWith, EndsWith). @@ -203,7 +203,7 @@ public EqualsType(bool caseInsensitive = false, bool usesAll = false) : base("== public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -212,13 +212,14 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { - // null != any non-null value, so we need: left != null && left.ToLower() == right.ToLower() + // null != any non-null value, so we need: left != null && left.ToCase() == right.ToCase() + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.NotEqual(left, Expression.Constant(null, typeof(string))); - var toLowerComparison = Expression.Equal( - Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!), - Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!) + var caseComparison = Expression.Equal( + Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!), + Expression.Call(right, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!) ); - return Expression.AndAlso(nullCheck, toLowerComparison); + return Expression.AndAlso(nullCheck, caseComparison); } // for some complex derived expressions @@ -241,7 +242,7 @@ public NotEqualsType(bool caseInsensitive = false, bool usesAll = false) : base( public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -250,13 +251,14 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { - // null != any non-null value, so we need: left == null || left.ToLower() != right.ToLower() + // null != any non-null value, so we need: left == null || left.ToCase() != right.ToCase() + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.Equal(left, Expression.Constant(null, typeof(string))); - var toLowerComparison = Expression.NotEqual( - Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!), - Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!) + var caseComparison = Expression.NotEqual( + Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!), + Expression.Call(right, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!) ); - return Expression.OrElse(nullCheck, toLowerComparison); + return Expression.OrElse(nullCheck, caseComparison); } // for some complex derived expressions @@ -279,7 +281,7 @@ public GreaterThanType(bool caseInsensitive = false, bool usesAll = false) : bas public override string Operator() => Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -300,7 +302,7 @@ public LessThanType(bool caseInsensitive = false, bool usesAll = false) : base(" public override string Operator() => Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -320,7 +322,7 @@ private class GreaterThanOrEqualType : ComparisonOperator public GreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base(">=", 4, caseInsensitive, usesAll) { } - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -340,7 +342,7 @@ public LessThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : } public override string Operator() => Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -361,7 +363,7 @@ public ContainsType(bool caseInsensitive = false, bool usesAll = false) : base(" public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -370,12 +372,13 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { - // null doesn't contain anything, so we need: left != null && left.ToLower().Contains(right.ToLower()) + // null doesn't contain anything, so we need: left != null && left.ToCase().Contains(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.NotEqual(left, Expression.Constant(null, typeof(string))); var containsCall = Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("Contains", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) ); return Expression.AndAlso(nullCheck, containsCall); } @@ -392,7 +395,7 @@ public StartsWithType(bool caseInsensitive = false, bool usesAll = false) : base public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -401,12 +404,13 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { - // null doesn't start with anything, so we need: left != null && left.ToLower().StartsWith(right.ToLower()) + // null doesn't start with anything, so we need: left != null && left.ToCase().StartsWith(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.NotEqual(left, Expression.Constant(null, typeof(string))); var startsWithCall = Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) ); return Expression.AndAlso(nullCheck, startsWithCall); } @@ -423,7 +427,7 @@ public EndsWithType(bool caseInsensitive = false, bool usesAll = false) : base(" public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -432,12 +436,13 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive) { - // null doesn't end with anything, so we need: left != null && left.ToLower().EndsWith(right.ToLower()) + // null doesn't end with anything, so we need: left != null && left.ToCase().EndsWith(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.NotEqual(left, Expression.Constant(null, typeof(string))); var endsWithCall = Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) ); return Expression.AndAlso(nullCheck, endsWithCall); } @@ -454,7 +459,7 @@ public NotContainsType(bool caseInsensitive = false, bool usesAll = false) : bas public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -463,12 +468,13 @@ public override Expression GetExpression(Expression left, Expression right, T if(CaseInsensitive) { - // null doesn't contain anything, so it should be included: left == null || !left.ToLower().Contains(right.ToLower()) + // null doesn't contain anything, so it should be included: left == null || !left.ToCase().Contains(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.Equal(left, Expression.Constant(null, typeof(string))); var notContainsCall = Expression.Not(Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("Contains", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) )); return Expression.OrElse(nullCheck, notContainsCall); } @@ -485,7 +491,7 @@ public NotStartsWithType(bool caseInsensitive = false, bool usesAll = false) : b public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -494,12 +500,13 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive) { - // null doesn't start with anything, so it should be included: left == null || !left.ToLower().StartsWith(right.ToLower()) + // null doesn't start with anything, so it should be included: left == null || !left.ToCase().StartsWith(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.Equal(left, Expression.Constant(null, typeof(string))); var notStartsWithCall = Expression.Not(Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) )); return Expression.OrElse(nullCheck, notStartsWithCall); } @@ -516,7 +523,7 @@ public NotEndsWithType(bool caseInsensitive = false, bool usesAll = false) : bas public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { @@ -525,12 +532,13 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive) { - // null doesn't end with anything, so it should be included: left == null || !left.ToLower().EndsWith(right.ToLower()) + // null doesn't end with anything, so it should be included: left == null || !left.ToCase().EndsWith(right.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.Equal(left, Expression.Constant(null, typeof(string))); var notEndsWithCall = Expression.Not(Expression.Call( - Expression.Call(left, "ToLower", null), + Expression.Call(left, caseMethodName, null), typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, - Expression.Call(right, "ToLower", null) + Expression.Call(right, caseMethodName, null) )); return Expression.OrElse(nullCheck, notEndsWithCall); } @@ -547,7 +555,7 @@ public InType(bool caseInsensitive = false, bool usesAll = false) : base("^^", 1 public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { var leftType = left.Type; @@ -571,21 +579,22 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && leftType == typeof(string)) { - // null is not in any list, so: left != null && list.Contains(left.ToLower()) + // null is not in any list, so: left != null && list.Contains(left.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.NotEqual(left, Expression.Constant(null, typeof(string))); var listType = typeof(List); - var toLowerList = Activator.CreateInstance(listType); + var caseList = Activator.CreateInstance(listType); var originalList = ((ConstantExpression)right).Value as IEnumerable; foreach (var value in originalList!) { - listType.GetMethod("Add")!.Invoke(toLowerList, new[] { value.ToLower() }); + listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = Expression.Constant(toLowerList, listType); - var toLowerLeft = Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!); + right = Expression.Constant(caseList, listType); + var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); - var containsCall = Expression.Call(right, containsMethod, toLowerLeft); + var containsCall = Expression.Call(right, containsMethod, caseLeft); return Expression.AndAlso(nullCheck, containsCall); } @@ -602,7 +611,7 @@ public SoundsLikeType(bool caseInsensitive = false, bool usesAll = false) : base public override string Operator() => Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (dbContextType == null) { @@ -631,7 +640,7 @@ public DoesNotSoundLikeType(bool caseInsensitive = false, bool usesAll = false) public override string Operator() => Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (dbContextType == null) { @@ -659,7 +668,7 @@ public HasCountEqualToType(bool caseInsensitive = false, bool usesAll = false) : public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.Equal)); } @@ -673,7 +682,7 @@ public HasCountNotEqualToType(bool caseInsensitive = false, bool usesAll = false public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.NotEqual)); } @@ -687,7 +696,7 @@ public HasCountGreaterThanType(bool caseInsensitive = false, bool usesAll = fals public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.GreaterThan)); } @@ -701,7 +710,7 @@ public HasCountLessThanType(bool caseInsensitive = false, bool usesAll = false) public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.LessThan)); } @@ -715,7 +724,7 @@ public HasCountGreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.GreaterThanOrEqual)); } @@ -729,7 +738,7 @@ public HasCountLessThanOrEqualType(bool caseInsensitive = false, bool usesAll = public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => true; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { return GetCountExpression(left, right, nameof(Expression.LessThanOrEqual)); } @@ -743,7 +752,7 @@ public HasType(bool caseInsensitive = false, bool usesAll = false) : base("^$", public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && (left.Type.GetGenericTypeDefinition() == typeof(List<>) || @@ -766,7 +775,7 @@ public DoesNotHaveType(bool caseInsensitive = false, bool usesAll = false) : bas public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { if (left.Type.IsGenericType && (left.Type.GetGenericTypeDefinition() == typeof(List<>) || @@ -789,7 +798,7 @@ public NotInType(bool caseInsensitive = false, bool usesAll = false) : base("!^^ public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override bool IsCountOperator() => false; - public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { var leftType = left.Type; @@ -813,21 +822,22 @@ public override Expression GetExpression(Expression left, Expression right, T if (CaseInsensitive && leftType == typeof(string)) { - // null is not in any list, so it should be included: left == null || !list.Contains(left.ToLower()) + // null is not in any list, so it should be included: left == null || !list.Contains(left.ToCase()) + var caseMethodName = caseMode == CaseInsensitiveMode.Upper ? "ToUpper" : "ToLower"; var nullCheck = Expression.Equal(left, Expression.Constant(null, typeof(string))); var listType = typeof(List); - var toLowerList = Activator.CreateInstance(listType); + var caseList = Activator.CreateInstance(listType); var originalList = ((ConstantExpression)right).Value as IEnumerable; foreach (var value in originalList!) { - listType.GetMethod("Add")!.Invoke(toLowerList, new[] { value.ToLower() }); + listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = Expression.Constant(toLowerList, listType); - var toLowerLeft = Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)!); + right = Expression.Constant(caseList, listType); + var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); - var containsExpression = Expression.Call(right, containsMethod, toLowerLeft); + var containsExpression = Expression.Call(right, containsMethod, caseLeft); return Expression.OrElse(nullCheck, Expression.Not(containsExpression)); } diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 50e99bb..726d038 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -1,8 +1,8 @@ namespace QueryKit; using System.Linq.Expressions; -using System.Text; using System.Text.RegularExpressions; +using Configuration; using Operators; public class QueryKitPropertyMappings @@ -464,6 +464,12 @@ public QueryKitPropertyMapping HasMaxDepth(int maxDepth) _propertyInfo.MaxDepth = maxDepth; return this; } + + public QueryKitPropertyMapping HasCaseInsensitiveMode(CaseInsensitiveMode mode) + { + _propertyInfo.CaseInsensitiveComparison = mode; + return this; + } } public class QueryKitCustomOperationMapping @@ -500,4 +506,5 @@ public class QueryKitPropertyInfo internal bool UsesConversion { get; set; } internal Type? ConversionTargetType { get; set; } internal int? MaxDepth { get; set; } + public CaseInsensitiveMode? CaseInsensitiveComparison { get; set; } } \ No newline at end of file diff --git a/README.md b/README.md index 3960df3..3766aad 100644 --- a/README.md +++ b/README.md @@ -728,6 +728,32 @@ var config = new QueryKitConfiguration(config => var filterExpression = FilterParser.ParseFilter(input, config); ``` +#### Case-Insensitive Comparison Mode + +By default, QueryKit uses `ToLower()` (which EF Core translates to `LOWER()` in SQL) for case-insensitive string operators like `@=*`, `_=*`, `==*`, etc. If your data is normalized to uppercase, you can switch to `ToUpper()` / `UPPER()` to maintain index efficiency. + +```csharp +var config = new QueryKitConfiguration(settings => +{ + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper; +}); +``` + +This is particularly useful when: +- Data is stored in uppercase for consistency +- Database indexes are defined on uppercase column values (e.g., `CREATE INDEX ON people (UPPER(email))`) +- You need SARGable queries for better performance + +**Per-property override** — you can also control the mode per property, overriding the global setting: + +```csharp +var config = new QueryKitConfiguration(settings => +{ + settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower; // global default + settings.Property(x => x.Email).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); +}); +``` + #### Max Property Depth You can limit the depth of nested property access to prevent deeply nested queries. This is useful for security and performance reasons when exposing QueryKit to external consumers.