From 820358e8ff52d5783c6c9123a0628570fb7c087e Mon Sep 17 00:00:00 2001 From: Nikita Prokhorov Date: Mon, 10 Jan 2022 21:29:43 +0300 Subject: [PATCH] Fixed null-ref for case-(in)sensitive null-search (#165) * Fixed null-ref for case-insensitive null-search Added null-escaping sequence (to distinguish between prop==null (as null) and prop==\null (as string)) * Added null-search case-insensitive test * Code style * Added escape-sequences description to README.md Co-authored-by: Nikita Prokhorov --- README.md | 5 +++- Sieve/Models/FilterTerm.cs | 17 ++++++++---- Sieve/Services/SieveProcessor.cs | 7 ++++- SieveUnitTests/StringFilterNullTests.cs | 36 +++++++++++++++++++------ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1e2e136..aa78c72 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,10 @@ More formally: * `pageSize` is the number of items returned per page Notes: -* You can use backslashes to escape commas and pipes within value fields +* You can use backslashes to escape special characters and sequences: + * commas: `Title@=some\,title` makes a match with "some,title" + * pipes: `Title@=some\|title` makes a match with "some|title" + * null values: `Title@=\null` will search for items with title equal to "null" (not a missing value, but "null"-string literally) * You can have spaces anywhere except *within* `{Name}` or `{Operator}` fields * If you need to look at the data before applying pagination (eg. get total count), use the optional paramters on `Apply` to defer pagination (an [example](https://github.com/Biarity/Sieve/issues/34)) * Here's a [good example on how to work with enumerables](https://github.com/Biarity/Sieve/issues/2) diff --git a/Sieve/Models/FilterTerm.cs b/Sieve/Models/FilterTerm.cs index 800e3bd..f47b81a 100644 --- a/Sieve/Models/FilterTerm.cs +++ b/Sieve/Models/FilterTerm.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -7,12 +8,16 @@ namespace Sieve.Models public class FilterTerm : IFilterTerm, IEquatable { private const string EscapedPipePattern = @"(?=|<=|>|<|@=|_=)"; private const string EscapeNegPatternForOper = @"(? _escapedSequences = new HashSet + { + @"\|", + @"\\" + }; + public string Filter { set @@ -30,8 +35,7 @@ namespace Sieve.Models } Values = Regex.Split(filterSplits[2], EscapedPipePattern) - .Select(t => t.Replace(PipeToEscape, "|").Trim()) - .Select(t => t.Replace(BackslashToEscape, "\\").Trim()) + .Select(UnEscape) .ToArray(); } @@ -40,9 +44,12 @@ namespace Sieve.Models OperatorIsCaseInsensitive = Operator.EndsWith("*"); OperatorIsNegated = OperatorParsed != FilterOperator.NotEquals && Operator.StartsWith("!"); } - } + private string UnEscape(string escapedTerm) + => _escapedSequences.Aggregate(escapedTerm, + (current, sequence) => Regex.Replace(current, $@"(\\)({sequence})", "$2")); + public string[] Names { get; private set; } public FilterOperator OperatorParsed { get; private set; } diff --git a/Sieve/Services/SieveProcessor.cs b/Sieve/Services/SieveProcessor.cs index d71c09f..5ce5407 100644 --- a/Sieve/Services/SieveProcessor.cs +++ b/Sieve/Services/SieveProcessor.cs @@ -68,6 +68,7 @@ namespace Sieve.Services where TSortTerm : ISortTerm, new() { private const string NullFilterValue = "null"; + private const char EscapeChar = '\\'; private readonly ISieveCustomSortMethods _customSortMethods; private readonly ISieveCustomFilterMethods _customFilterMethods; private readonly SievePropertyMapper _mapper = new SievePropertyMapper(); @@ -199,7 +200,7 @@ namespace Sieve.Services ? Expression.Constant(null, property.PropertyType) : ConvertStringValueToConstantExpression(filterTermValue, property, converter); - if (filterTerm.OperatorIsCaseInsensitive) + if (filterTerm.OperatorIsCaseInsensitive && !isFilterTermValueNull) { propertyValue = Expression.Call(propertyValue, typeof(string).GetMethods() @@ -311,6 +312,10 @@ namespace Sieve.Services private static Expression ConvertStringValueToConstantExpression(string value, PropertyInfo property, TypeConverter converter) { + // to allow user to distinguish between prop==null (as null) and prop==\null (as "null"-string) + value = value.Equals(EscapeChar + NullFilterValue, StringComparison.InvariantCultureIgnoreCase) + ? value.TrimStart(EscapeChar) + : value; dynamic constantVal = converter.CanConvertFrom(typeof(string)) ? converter.ConvertFrom(value) : Convert.ChangeType(value, property.PropertyType); diff --git a/SieveUnitTests/StringFilterNullTests.cs b/SieveUnitTests/StringFilterNullTests.cs index f4ccedd..577d64a 100644 --- a/SieveUnitTests/StringFilterNullTests.cs +++ b/SieveUnitTests/StringFilterNullTests.cs @@ -38,7 +38,7 @@ namespace SieveUnitTests { Id = 2, DateCreated = DateTimeOffset.UtcNow, - Text = "Regular comment without n*ll.", + Text = "Regular comment without n*ll", Author = "Mouse", }, new Comment @@ -47,24 +47,28 @@ namespace SieveUnitTests DateCreated = DateTimeOffset.UtcNow, Text = null, Author = "null", - }, + } }.AsQueryable(); } - [Fact] - public void Filter_Equals_Null() + [Theory] + [InlineData("Text==null")] + [InlineData("Text==*null")] + public void Filter_Equals_Null(string filter) { - var model = new SieveModel {Filters = "Text==null"}; + var model = new SieveModel {Filters = filter}; var result = _processor.Apply(model, _comments); Assert.Equal(100, result.Single().Id); } - [Fact] - public void Filter_NotEquals_Null() + [Theory] + [InlineData("Text!=null")] + [InlineData("Text!=*null")] + public void Filter_NotEquals_Null(string filter) { - var model = new SieveModel {Filters = "Text!=null"}; + var model = new SieveModel {Filters = filter}; var result = _processor.Apply(model, _comments); @@ -101,6 +105,22 @@ namespace SieveUnitTests Assert.Equal(expectedIds, result.Select(p => p.Id)); } + [Theory] + [InlineData(@"Author==\null", 100)] + [InlineData(@"Author==*\null", 100)] + [InlineData(@"Author==*\NuLl", 100)] + [InlineData(@"Author!=*\null", 0, 1, 2)] + [InlineData(@"Author!=*\NulL", 0, 1, 2)] + [InlineData(@"Author!=\null", 0, 1, 2)] + public void SingleFilter_Equals_NullStringEscaped(string filter, params int[] expectedIds) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(expectedIds, result.Select(p => p.Id)); + } + [Theory] [InlineData("Text_=null")] [InlineData("Text_=*null")]