diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff58992..9c7c592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,10 @@ name: ci on: - push: - branches: - - master pull_request: branches: - master + - 'releases/*' jobs: ubuntu-latest: diff --git a/.github/workflows/ci_publish.yml b/.github/workflows/ci_publish.yml new file mode 100644 index 0000000..3c3eeb5 --- /dev/null +++ b/.github/workflows/ci_publish.yml @@ -0,0 +1,33 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [GitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# nuke --generate-configuration GitHubActions_ci_publish --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: ci_publish + +on: + push: + branches: + - 'releases/*' + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Run './build.cmd CiPublish' + run: ./build.cmd CiPublish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 03f2823..6c8135f 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -43,6 +43,9 @@ "type": "boolean", "description": "Disables displaying the NUKE logo" }, + "NUGET_API_KEY": { + "type": "string" + }, "Plan": { "type": "boolean", "description": "Shows the execution plan (HTML)" @@ -65,9 +68,11 @@ "type": "string", "enum": [ "Ci", + "CiPublish", "Clean", "Compile", "Package", + "Publish", "Restore", "Test" ] @@ -84,9 +89,11 @@ "type": "string", "enum": [ "Ci", + "CiPublish", "Clean", "Compile", "Package", + "Publish", "Restore", "Test" ] diff --git a/GitVersion.yml b/GitVersion.yml index e69de29..e2a784c 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -0,0 +1,3 @@ +branches: + release: + mode: ContinuousDeployment \ No newline at end of file diff --git a/README.md b/README.md index 39dbe38..2df5166 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ Then you can add the configuration: "CaseSensitive": "boolean: should property names be case-sensitive? Defaults to false", "DefaultPageSize": "int number: optional number to fallback to when no page argument is given. Set <=0 to disable paging if no pageSize is specified (default).", "MaxPageSize": "int number: maximum allowed page size. Set <=0 to make infinite (default)", - "ThrowExceptions": "boolean: should Sieve throw exceptions instead of silently failing? Defaults to false" + "ThrowExceptions": "boolean: should Sieve throw exceptions instead of silently failing? Defaults to false", + "IgnoreNullsOnNotEqual": "boolean: ignore null values when filtering using is not equal operator? Default to true" } } ``` diff --git a/Sieve.Sample/appsettings.Development.json b/Sieve.Sample/appsettings.Development.json index fa8ce71..0623a3f 100644 --- a/Sieve.Sample/appsettings.Development.json +++ b/Sieve.Sample/appsettings.Development.json @@ -1,6 +1,5 @@ { "Logging": { - "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", diff --git a/Sieve.Sample/appsettings.json b/Sieve.Sample/appsettings.json index c696841..f4aec2b 100644 --- a/Sieve.Sample/appsettings.json +++ b/Sieve.Sample/appsettings.json @@ -4,10 +4,10 @@ }, "Sieve": { "CaseSensitive": false, - "DefaultPageSize": 10 + "DefaultPageSize": 10, + "IgnoreNullsOnNotEqual": true }, "Logging": { - "IncludeScopes": false, "Debug": { "LogLevel": { "Default": "Warning" diff --git a/Sieve/Models/FilterTerm.cs b/Sieve/Models/FilterTerm.cs index 77bfeec..f2fd6ee 100644 --- a/Sieve/Models/FilterTerm.cs +++ b/Sieve/Models/FilterTerm.cs @@ -9,6 +9,7 @@ namespace Sieve.Models public FilterTerm() { } private const string EscapedPipePattern = @"(? t.Trim()).ToArray(); Names = Regex.Split(filterSplits[0], EscapedPipePattern).Select(t => t.Trim()).ToArray(); - Values = filterSplits.Length > 1 ? Regex.Split(filterSplits[1], EscapedPipePattern).Select(t => t.Trim()).ToArray() : null; + Values = filterSplits.Length > 1 + ? Regex.Split(filterSplits[1], EscapedPipePattern) + .Select(t => t.Replace(PipeToEscape, "|").Trim()) + .ToArray() + : null; Operator = Array.Find(Operators, o => value.Contains(o)) ?? "=="; OperatorParsed = GetOperatorParsed(Operator); OperatorIsCaseInsensitive = Operator.EndsWith("*"); @@ -90,6 +95,5 @@ namespace Sieve.Models && Values.SequenceEqual(other.Values) && Operator == other.Operator; } - } } diff --git a/Sieve/Models/SieveModel.cs b/Sieve/Models/SieveModel.cs index a3f9818..8d43153 100644 --- a/Sieve/Models/SieveModel.cs +++ b/Sieve/Models/SieveModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/Sieve/Models/SieveOptions.cs b/Sieve/Models/SieveOptions.cs index f0059ee..226abe1 100644 --- a/Sieve/Models/SieveOptions.cs +++ b/Sieve/Models/SieveOptions.cs @@ -9,5 +9,7 @@ public int MaxPageSize { get; set; } = 0; public bool ThrowExceptions { get; set; } = false; + + public bool IgnoreNullsOnNotEqual { get; set; } = true; } -} \ No newline at end of file +} diff --git a/Sieve/Services/SieveProcessor.cs b/Sieve/Services/SieveProcessor.cs index 6496c7a..d71c09f 100644 --- a/Sieve/Services/SieveProcessor.cs +++ b/Sieve/Services/SieveProcessor.cs @@ -68,7 +68,6 @@ namespace Sieve.Services where TSortTerm : ISortTerm, new() { private const string NullFilterValue = "null"; - private readonly IOptions _options; private readonly ISieveCustomSortMethods _customSortMethods; private readonly ISieveCustomFilterMethods _customFilterMethods; private readonly SievePropertyMapper _mapper = new SievePropertyMapper(); @@ -78,7 +77,7 @@ namespace Sieve.Services ISieveCustomFilterMethods customFilterMethods) { _mapper = MapProperties(_mapper); - _options = options; + Options = options; _customSortMethods = customSortMethods; _customFilterMethods = customFilterMethods; } @@ -87,7 +86,7 @@ namespace Sieve.Services ISieveCustomSortMethods customSortMethods) { _mapper = MapProperties(_mapper); - _options = options; + Options = options; _customSortMethods = customSortMethods; } @@ -95,16 +94,18 @@ namespace Sieve.Services ISieveCustomFilterMethods customFilterMethods) { _mapper = MapProperties(_mapper); - _options = options; + Options = options; _customFilterMethods = customFilterMethods; } public SieveProcessor(IOptions options) { _mapper = MapProperties(_mapper); - _options = options; + Options = options; } + protected IOptions Options { get; } + /// /// Apply filtering, sorting, and pagination parameters found in `model` to `source` /// @@ -148,7 +149,7 @@ namespace Sieve.Services } catch (Exception ex) { - if (!_options.Value.ThrowExceptions) + if (!Options.Value.ThrowExceptions) { return result; } @@ -162,7 +163,7 @@ namespace Sieve.Services } } - private IQueryable ApplyFiltering(TSieveModel model, IQueryable result, + protected virtual IQueryable ApplyFiltering(TSieveModel model, IQueryable result, object[] dataForCustomMethods = null) { if (model?.GetFiltersParsed() == null) @@ -216,10 +217,13 @@ namespace Sieve.Services expression = Expression.Not(expression); } - var filterValueNullCheck = GetFilterValueNullCheck(parameter, fullPropertyName, isFilterTermValueNull); - if (filterValueNullCheck != null) + if (expression.NodeType != ExpressionType.NotEqual || Options.Value.IgnoreNullsOnNotEqual) { - expression = Expression.AndAlso(filterValueNullCheck, expression); + var filterValueNullCheck = GetFilterValueNullCheck(parameter, fullPropertyName, isFilterTermValueNull); + if (filterValueNullCheck != null) + { + expression = Expression.AndAlso(filterValueNullCheck, expression); + } } innerExpression = innerExpression == null @@ -253,8 +257,7 @@ namespace Sieve.Services : result.Where(Expression.Lambda>(outerExpression, parameter)); } - private static Expression GetFilterValueNullCheck(Expression parameter, string fullPropertyName, - bool isFilterTermValueNull) + private static Expression GetFilterValueNullCheck(Expression parameter, string fullPropertyName, bool isFilterTermValueNull) { var (propertyValue, nullCheck) = GetPropertyValueAndNullCheckExpression(parameter, fullPropertyName); @@ -336,15 +339,13 @@ namespace Sieve.Services } // Workaround to ensure that the filter value gets passed as a parameter in generated SQL from EF Core - // See https://github.com/aspnet/EntityFrameworkCore/issues/3361 - // Expression.Constant passed the target type to allow Nullable comparison - // See http://bradwilson.typepad.com/blog/2008/07/creating-nullab.html private static Expression GetClosureOverConstant(T constant, Type targetType) { - return Expression.Constant(constant, targetType); + Expression> hoistedConstant = () => constant; + return Expression.Convert(hoistedConstant.Body, targetType); } - private IQueryable ApplySorting(TSieveModel model, IQueryable result, + protected virtual IQueryable ApplySorting(TSieveModel model, IQueryable result, object[] dataForCustomMethods = null) { if (model?.GetSortsParsed() == null) @@ -373,11 +374,11 @@ namespace Sieve.Services return result; } - private IQueryable ApplyPagination(TSieveModel model, IQueryable result) + protected virtual IQueryable ApplyPagination(TSieveModel model, IQueryable result) { var page = model?.Page ?? 1; - var pageSize = model?.PageSize ?? _options.Value.DefaultPageSize; - var maxPageSize = _options.Value.MaxPageSize > 0 ? _options.Value.MaxPageSize : pageSize; + var pageSize = model?.PageSize ?? Options.Value.DefaultPageSize; + var maxPageSize = Options.Value.MaxPageSize > 0 ? Options.Value.MaxPageSize : pageSize; if (pageSize <= 0) { @@ -399,14 +400,14 @@ namespace Sieve.Services string name) { var property = _mapper.FindProperty(canSortRequired, canFilterRequired, name, - _options.Value.CaseSensitive); + Options.Value.CaseSensitive); if (property.Item1 != null) { return property; } var prop = FindPropertyBySieveAttribute(canSortRequired, canFilterRequired, name, - _options.Value.CaseSensitive); + Options.Value.CaseSensitive); return (prop?.Name, prop); } @@ -426,7 +427,7 @@ namespace Sieve.Services { var customMethod = parent?.GetType() .GetMethodExt(name, - _options.Value.CaseSensitive + Options.Value.CaseSensitive ? BindingFlags.Default : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, typeof(IQueryable)); @@ -437,7 +438,7 @@ namespace Sieve.Services // Find generic methods `public IQueryable Filter(IQueryable source, ...)` var genericCustomMethod = parent?.GetType() .GetMethodExt(name, - _options.Value.CaseSensitive + Options.Value.CaseSensitive ? BindingFlags.Default : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, typeof(IQueryable<>)); @@ -481,11 +482,11 @@ namespace Sieve.Services var incompatibleCustomMethods = parent? .GetType() - .GetMethods(_options.Value.CaseSensitive + .GetMethods(Options.Value.CaseSensitive ? BindingFlags.Default : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) .Where(method => string.Equals(method.Name, name, - _options.Value.CaseSensitive + Options.Value.CaseSensitive ? StringComparison.InvariantCulture : StringComparison.InvariantCultureIgnoreCase)) .ToList() diff --git a/SieveUnitTests/General.cs b/SieveUnitTests/General.cs index f12e5a3..866ece5 100644 --- a/SieveUnitTests/General.cs +++ b/SieveUnitTests/General.cs @@ -15,16 +15,24 @@ namespace SieveUnitTests { private readonly ITestOutputHelper _testOutputHelper; private readonly SieveProcessor _processor; + private readonly SieveProcessor _nullableProcessor; private readonly IQueryable _posts; private readonly IQueryable _comments; public General(ITestOutputHelper testOutputHelper) { + var nullableAccessor = new SieveOptionsAccessor(); + nullableAccessor.Value.IgnoreNullsOnNotEqual = false; + _testOutputHelper = testOutputHelper; _processor = new ApplicationSieveProcessor(new SieveOptionsAccessor(), new SieveCustomSortMethods(), new SieveCustomFilterMethods()); + _nullableProcessor = new ApplicationSieveProcessor(nullableAccessor, + new SieveCustomSortMethods(), + new SieveCustomFilterMethods()); + _posts = new List { new Post @@ -180,10 +188,27 @@ namespace SieveUnitTests }; var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); Assert.True(result.Count() == 2); + Assert.True(nullableResult.Count() == 2); } - + + [Fact] + public void CanFilterNullableIntsWithNotEqual() + { + var model = new SieveModel() + { + Filters = "CategoryId!=1" + }; + + var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); + + Assert.True(result.Count() == 1); + Assert.True(nullableResult.Count() == 2); + } + [Theory] [InlineData(@"Text@=*\,")] [InlineData(@"Text@=*\, ")] @@ -613,5 +638,61 @@ namespace SieveUnitTests Assert.Equal(1,posts[2].Id); Assert.Equal(0,posts[3].Id); } + + [Fact] + public void CanFilter_WithEscapeCharacter() + { + var comments = new List + { + new Comment + { + Id = 0, + DateCreated = DateTimeOffset.UtcNow, + Text = "Here is, a comment" + }, + new Comment + { + Id = 1, + DateCreated = DateTimeOffset.UtcNow.AddDays(-1), + Text = "Here is, another comment" + }, + }.AsQueryable(); + + var model = new SieveModel + { + Filters = "Text==Here is\\, another comment" + }; + + var result = _processor.Apply(model, comments); + Assert.Equal(1, result.Count()); + } + + [Fact] + public void OrEscapedPipeValueFilteringWorks() + { + var comments = new List + { + new Comment + { + Id = 0, + DateCreated = DateTimeOffset.UtcNow, + Text = "Here is | a comment" + }, + new Comment + { + Id = 1, + DateCreated = DateTimeOffset.UtcNow.AddDays(-1), + Text = "Here is | another comment" + }, + }.AsQueryable(); + + var model = new SieveModel() + { + Filters = "Text==Here is \\| a comment|Here is \\| another comment", + }; + + var result = _processor.Apply(model, comments); + Assert.Equal(2, result.Count()); + } } } diff --git a/SieveUnitTests/GeneralWithInterfaces.cs b/SieveUnitTests/GeneralWithInterfaces.cs index abcbaa3..f23db16 100644 --- a/SieveUnitTests/GeneralWithInterfaces.cs +++ b/SieveUnitTests/GeneralWithInterfaces.cs @@ -16,16 +16,24 @@ namespace SieveUnitTests { private readonly ITestOutputHelper _testOutputHelper; private readonly SieveProcessor _processor; + private readonly SieveProcessor _nullableProcessor; private readonly IQueryable _posts; private readonly IQueryable _comments; public GeneralWithInterfaces(ITestOutputHelper testOutputHelper) { + var nullableAccessor = new SieveOptionsAccessor(); + nullableAccessor.Value.IgnoreNullsOnNotEqual = false; + _testOutputHelper = testOutputHelper; _processor = new ApplicationSieveProcessor(new SieveOptionsAccessor(), new SieveCustomSortMethods(), new SieveCustomFilterMethods()); + _nullableProcessor = new ApplicationSieveProcessor(nullableAccessor, + new SieveCustomSortMethods(), + new SieveCustomFilterMethods()); + _posts = new List { new Post @@ -181,8 +189,25 @@ namespace SieveUnitTests }; var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); Assert.True(result.Count() == 2); + Assert.True(nullableResult.Count() == 2); + } + + [Fact] + public void CanFilterNullableIntsWithNotEqual() + { + var model = new SieveModel() + { + Filters = "CategoryId!=1" + }; + + var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); + + Assert.True(result.Count() == 1); + Assert.True(nullableResult.Count() == 2); } [Fact] diff --git a/build/Build.cs b/build/Build.cs index 38076a0..bb2de43 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,4 +1,5 @@ using System.Linq; +using GlobExpressions; using Nuke.Common; using Nuke.Common.CI; using Nuke.Common.CI.GitHubActions; @@ -8,17 +9,23 @@ using Nuke.Common.IO; using Nuke.Common.ProjectModel; using Nuke.Common.Tools.DotNet; using Nuke.Common.Tools.GitVersion; +using Nuke.Common.Utilities.Collections; using static Nuke.Common.IO.FileSystemTasks; using static Nuke.Common.Tools.DotNet.DotNetTasks; [CheckBuildProjectConfigurations] [ShutdownDotNetAfterServerBuild] [GitHubActions("ci", GitHubActionsImage.UbuntuLatest, - OnPushBranches = new[] {"master"}, - OnPullRequestBranches = new[] {"master"}, + OnPullRequestBranches = new[] {"master", "releases/*"}, AutoGenerate = true, InvokedTargets = new[] {nameof(Ci)}, CacheKeyFiles = new string[0])] +[GitHubActions("ci_publish", GitHubActionsImage.UbuntuLatest, + OnPushBranches = new[] {"releases/*"}, + AutoGenerate = true, + InvokedTargets = new[] {nameof(CiPublish)}, + CacheKeyFiles = new string[0], + ImportSecrets = new[] {"NUGET_API_KEY"})] class Build : NukeBuild { [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] @@ -30,6 +37,9 @@ class Build : NukeBuild [Solution] readonly Solution Solution; + // ReSharper disable once InconsistentNaming + [Parameter] string NUGET_API_KEY; + Project SieveProject => Solution.AllProjects.First(p => p.Name == "Sieve"); AbsolutePath OutputDirectory => RootDirectory / "output"; @@ -83,13 +93,29 @@ class Build : NukeBuild .EnableNoBuild()); }); - Target Ci => _ => _ - .DependsOn(Package); + Target Publish => _ => _ + .DependsOn(Package) + .Requires(() => IsServerBuild) + .Requires(() => NUGET_API_KEY) + .Requires(() => Configuration.Equals(Configuration.Release)) + .Executes(() => + { + Glob.Files(OutputDirectory, "*.nupkg") + .NotEmpty() + .ForEach(x => + { + DotNetNuGetPush(s => s + .SetTargetPath(OutputDirectory / x) + .SetSource("https://api.nuget.org/v3/index.json") + .SetApiKey(NUGET_API_KEY)); + }); + }); + + Target Ci => _ => _ + .DependsOn(Test); + + Target CiPublish => _ => _ + .DependsOn(Publish); - /// Support plugins are available for: - /// - JetBrains ReSharper https://nuke.build/resharper - /// - JetBrains Rider https://nuke.build/rider - /// - Microsoft VisualStudio https://nuke.build/visualstudio - /// - Microsoft VSCode https://nuke.build/vscode public static int Main() => Execute(x => x.Package); }