From e0d21899ee42279111cd0241aa4c0d74f22acd37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:34:48 -0400 Subject: [PATCH 1/5] Package Dependencies: Bump Bogus from 35.4.0 to 35.6.1 (#67) Bumps [Bogus](https://github.com/bchavez/Bogus) from 35.4.0 to 35.6.1. - [Release notes](https://github.com/bchavez/Bogus/releases) - [Changelog](https://github.com/bchavez/Bogus/blob/master/HISTORY.md) - [Commits](https://github.com/bchavez/Bogus/compare/v35.4.0...v35.6.1) --- updated-dependencies: - dependency-name: Bogus dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 39f743b014fe18c2aebc8b9ca8774c80520cbe4d Mon Sep 17 00:00:00 2001 From: Alex Mariano Date: Wed, 18 Feb 2026 17:29:58 -0300 Subject: [PATCH 2/5] Add configurable case-insensitive mode for string filters Introduces the CaseInsensitiveMode enum to control whether case-insensitive string comparisons use ToLower/LOWER or ToUpper/UPPER. Adds global and per-property configuration for this mode in QueryKitConfiguration, QueryKitSettings, and QueryKitPropertyMapping. Refactors all relevant string comparison logic and operators to honor the selected case transformation, enabling consistent and customizable case-insensitive behavior for various data normalization scenarios. --- QueryKit/Configuration/CaseInsensitiveMode.cs | 13 ++ .../Configuration/QueryKitConfiguration.cs | 3 + QueryKit/Configuration/QueryKitSettings.cs | 1 + QueryKit/FilterParser.cs | 21 ++- QueryKit/Operators/ComparisonOperator.cs | 140 ++++++++++-------- QueryKit/QueryKitPropertyMappings.cs | 9 +- 6 files changed, 117 insertions(+), 70 deletions(-) create mode 100644 QueryKit/Configuration/CaseInsensitiveMode.cs 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 From 8727ca377b5bd6cae2090e789e4ee3561b00b544 Mon Sep 17 00:00:00 2001 From: Alex Mariano Date: Wed, 18 Feb 2026 17:30:16 -0300 Subject: [PATCH 3/5] Add tests for case-insensitive filter parsing modes Added unit tests to verify QueryKit's handling of case-insensitive string comparisons. Tests cover default, global, and per-property case-insensitive modes, ensuring correct use of ToLower or ToUpper in generated filter expressions for all relevant operators. Per-property settings are confirmed to override global configuration. --- QueryKit.UnitTests/FilterParserTests.cs | 195 ++++++++++++++++++++++++ 1 file changed, 195 insertions(+) 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()))"""); + } } From 72fda3b262f4d36effbd4fcc591a0e5ff8d8e5d7 Mon Sep 17 00:00:00 2001 From: Alex Mariano Date: Wed, 18 Feb 2026 17:33:10 -0300 Subject: [PATCH 4/5] Add tests for case-insensitive filtering with Upper mode Expanded DatabaseFilteringTests to cover case-insensitive string filtering using QueryKit with CaseInsensitiveMode.Upper and per-property overrides. Tests include all relevant operators and verify correct behavior for uppercase data, mixed-case queries, and property-specific settings. Increases reliability and coverage for filtering logic. --- .../Tests/DatabaseFilteringTests.cs | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) 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); + } + } From 01510a3244414131d2a47a0078619c2c6e54e632 Mon Sep 17 00:00:00 2001 From: Alex Mariano Date: Wed, 18 Feb 2026 17:35:58 -0300 Subject: [PATCH 5/5] Add docs for case-insensitive comparison mode config Document new CaseInsensitiveMode option in QueryKit, including usage examples for global and per-property settings. Explains benefits for uppercase-normalized data and index efficiency. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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.