diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..05a9782 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Request + url: https://github.com/biarity/sieve/discussions/new + about: Share your ideas on how to make Sieve better. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c7c592 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [GitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# nuke --generate-configuration GitHubActions_ci --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: ci + +on: + pull_request: + branches: + - master + - 'releases/*' + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Run './build.cmd Ci' + run: ./build.cmd Ci diff --git a/.github/workflows/ci_publish.yml b/.github/workflows/ci_publish.yml new file mode 100644 index 0000000..1ff5730 --- /dev/null +++ b/.github/workflows/ci_publish.yml @@ -0,0 +1,35 @@ +# ------------------------------------------------------------------------------ +# +# +# 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/*' + tags: + - 'v*' + +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/.gitignore b/.gitignore index da4d8f3..c365f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -260,4 +260,10 @@ paket-files/ # Python Tools for Visual Studio (PTVS) __pycache__/ -*.pyc \ No newline at end of file +*.pyc + +# Nuke output +/output + +# Sample database +Sieve.Sample/Sieve.db diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json new file mode 100644 index 0000000..6c8135f --- /dev/null +++ b/.nuke/build.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Build Schema", + "$ref": "#/definitions/build", + "definitions": { + "build": { + "type": "object", + "properties": { + "Configuration": { + "type": "string", + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", + "enum": [ + "Debug", + "Release" + ] + }, + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "type": "string", + "description": "Host for execution. Default is 'automatic'", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI" + ] + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "NUGET_API_KEY": { + "type": "string" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "type": "string", + "enum": [ + "Ci", + "CiPublish", + "Clean", + "Compile", + "Package", + "Publish", + "Restore", + "Test" + ] + } + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "type": "string", + "enum": [ + "Ci", + "CiPublish", + "Clean", + "Compile", + "Package", + "Publish", + "Restore", + "Test" + ] + } + }, + "Verbosity": { + "type": "string", + "description": "Logging verbosity during build execution. Default is 'Normal'", + "enum": [ + "Minimal", + "Normal", + "Quiet", + "Verbose" + ] + } + } + } + } +} \ No newline at end of file diff --git a/.nuke/parameters.json b/.nuke/parameters.json new file mode 100644 index 0000000..94e005a --- /dev/null +++ b/.nuke/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "Sieve.sln" +} \ No newline at end of file diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..e2a784c --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,3 @@ +branches: + release: + mode: ContinuousDeployment \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..2dca77f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,192 +1,4 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] + Copyright 2018 Biarity, 2021 Ashish Patel and Kevin Sommer Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index cbd4efa..8049fad 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ ⚗️ Sieve is a simple, clean, and extensible framework for .NET Core that **adds sorting, filtering, and pagination functionality out of the box**. Most common use case would be for serving ASP.NET Core GET queries. -[![NuGet Release](https://img.shields.io/nuget/v/Sieve.svg?style=flat-square)](https://www.nuget.org/packages/Sieve) +[![NuGet Release](https://img.shields.io/nuget/v/Sieve?style=for-the-badge)](https://www.nuget.org/packages/Sieve) +[![NuGet Pre-Release](https://img.shields.io/nuget/vpre/Sieve?style=for-the-badge)](https://www.nuget.org/packages/Sieve) [Get Sieve on nuget](https://www.nuget.org/packages/Sieve/) @@ -74,7 +75,7 @@ Where `SieveCustomSortMethodsOfPosts` for example is: ```C# public class SieveCustomSortMethods : ISieveCustomSortMethods { - public IQueryable Popularity(IQueryable source, bool useThenBy, bool desc) // The method is given an indicator of weather to use ThenBy(), and if the query is descending + public IQueryable Popularity(IQueryable source, bool useThenBy, bool desc) // The method is given an indicator of whether to use ThenBy(), and if the query is descending { var result = useThenBy ? ((IOrderedQueryable)source).ThenBy(p => p.LikeCount) : // ThenBy only works on IOrderedQueryable @@ -127,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" } } ``` @@ -157,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) @@ -258,13 +263,78 @@ public class ApplicationSieveProcessor : SieveProcessor } ``` + + Now you should inject the new class instead: ```C# services.AddScoped(); ``` - Find More on Sieve's Fluent API [here](https://github.com/Biarity/Sieve/issues/4#issuecomment-364629048). +### Modular Fluent API configuration +Adding all fluent mappings directly in the processor can become unwieldy on larger projects. +It can also clash with vertical architectures. +To enable functional grouping of mappings the `ISieveConfiguration` interface was created together with extensions to the default mapper. +```C# +public class SieveConfigurationForPost : ISieveConfiguration +{ + protected override SievePropertyMapper Configure(SievePropertyMapper mapper) + { + mapper.Property(p => p.Title) + .CanFilter() + .HasName("a_different_query_name_here"); + + mapper.Property(p => p.CommentCount) + .CanSort(); + + mapper.Property(p => p.DateCreated) + .CanSort() + .CanFilter() + .HasName("created_on"); + + return mapper; + } +} +``` +With the processor simplified to: +```C# +public class ApplicationSieveProcessor : SieveProcessor +{ + public ApplicationSieveProcessor( + IOptions options, + ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) + : base(options, customSortMethods, customFilterMethods) + { + } + + protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) + { + return mapper + .ApplyConfiguration() + .ApplyConfiguration(); + } +} +``` +There is also the option to scan and add all configurations for a given assembly +```C# +public class ApplicationSieveProcessor : SieveProcessor +{ + public ApplicationSieveProcessor( + IOptions options, + ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) + : base(options, customSortMethods, customFilterMethods) + { + } + + protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) + { + return mapper.ApplyConfigurationForAssembly(typeof(ApplicationSieveProcessor).Assembly); + } +} +``` + ## Upgrading to v2.2.0 2.2.0 introduced OR logic for filter values. This means your custom filters will need to accept multiple values rather than just the one. diff --git a/SieveTests/Controllers/PostsController.cs b/Sieve.Sample/Controllers/PostsController.cs similarity index 95% rename from SieveTests/Controllers/PostsController.cs rename to Sieve.Sample/Controllers/PostsController.cs index 6ab09fa..3f142de 100644 --- a/SieveTests/Controllers/PostsController.cs +++ b/Sieve.Sample/Controllers/PostsController.cs @@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Sieve.Models; +using Sieve.Sample.Entities; using Sieve.Services; -using SieveTests.Entities; -namespace SieveTests.Controllers +namespace Sieve.Sample.Controllers { [Route("api/[controller]/[action]")] public class PostsController : Controller diff --git a/SieveTests/Entities/ApplicationDbContext.cs b/Sieve.Sample/Entities/ApplicationDbContext.cs similarity index 91% rename from SieveTests/Entities/ApplicationDbContext.cs rename to Sieve.Sample/Entities/ApplicationDbContext.cs index 6c745e7..aa053e6 100644 --- a/SieveTests/Entities/ApplicationDbContext.cs +++ b/Sieve.Sample/Entities/ApplicationDbContext.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -namespace SieveTests.Entities +namespace Sieve.Sample.Entities { public class ApplicationDbContext : DbContext { diff --git a/SieveTests/Entities/Post.cs b/Sieve.Sample/Entities/Post.cs similarity index 92% rename from SieveTests/Entities/Post.cs rename to Sieve.Sample/Entities/Post.cs index e275c3c..25f6201 100644 --- a/SieveTests/Entities/Post.cs +++ b/Sieve.Sample/Entities/Post.cs @@ -2,14 +2,14 @@ using System.ComponentModel.DataAnnotations.Schema; using Sieve.Attributes; -namespace SieveTests.Entities +namespace Sieve.Sample.Entities { public class Post { public int Id { get; set; } [Sieve(CanFilter = true, CanSort = true)] - public string Title { get; set; } = Guid.NewGuid().ToString().Replace("-", string.Empty).Substring(0, 8); + public string Title { get; set; } = Guid.NewGuid().ToString().Replace("-", string.Empty)[..8]; [Sieve(CanFilter = true, CanSort = true)] public int LikeCount { get; set; } = new Random().Next(0, 1000); diff --git a/Sieve.Sample/Entities/SieveConfigurationForPost.cs b/Sieve.Sample/Entities/SieveConfigurationForPost.cs new file mode 100644 index 0000000..0455322 --- /dev/null +++ b/Sieve.Sample/Entities/SieveConfigurationForPost.cs @@ -0,0 +1,15 @@ +using Sieve.Services; + +namespace Sieve.Sample.Entities +{ + public class SieveConfigurationForPost : ISieveConfiguration + { + public void Configure(SievePropertyMapper mapper) + { + mapper.Property(p => p.Title) + .CanSort() + .CanFilter() + .HasName("CustomTitleName"); + } + } +} diff --git a/Sieve.Sample/Migrations/20210513114647_Initial.Designer.cs b/Sieve.Sample/Migrations/20210513114647_Initial.Designer.cs new file mode 100644 index 0000000..7a26181 --- /dev/null +++ b/Sieve.Sample/Migrations/20210513114647_Initial.Designer.cs @@ -0,0 +1,52 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sieve.Sample.Entities; + +namespace Sieve.Sample.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20210513114647_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.14"); + + modelBuilder.Entity("Sieve.Sample.Entities.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CommentCount") + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastViewed") + .HasColumnType("datetime"); + + b.Property("LikeCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SieveTests/Migrations/20180127005347_Init.cs b/Sieve.Sample/Migrations/20210513114647_Initial.cs similarity index 70% rename from SieveTests/Migrations/20180127005347_Init.cs rename to Sieve.Sample/Migrations/20210513114647_Initial.cs index c69e330..276c0ad 100644 --- a/SieveTests/Migrations/20180127005347_Init.cs +++ b/Sieve.Sample/Migrations/20210513114647_Initial.cs @@ -1,10 +1,9 @@ using System; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; -namespace SieveTests.Migrations +namespace Sieve.Sample.Migrations { - public partial class Init : Migration + public partial class Initial : Migration { protected override void Up(MigrationBuilder migrationBuilder) { @@ -13,14 +12,18 @@ namespace SieveTests.Migrations columns: table => new { Id = table.Column(nullable: false) - .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(nullable: true), + LikeCount = table.Column(nullable: false), CommentCount = table.Column(nullable: false), DateCreated = table.Column(nullable: false), - LikeCount = table.Column(nullable: false), - Title = table.Column(nullable: true), + DateLastViewed = table.Column(type: "datetime", nullable: false), CategoryId = table.Column(nullable: true) }, - constraints: table => table.PrimaryKey("PK_Posts", x => x.Id)); + constraints: table => + { + table.PrimaryKey("PK_Posts", x => x.Id); + }); } protected override void Down(MigrationBuilder migrationBuilder) diff --git a/Sieve.Sample/Migrations/ApplicationDbContextModelSnapshot.cs b/Sieve.Sample/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..b33a790 --- /dev/null +++ b/Sieve.Sample/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,50 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sieve.Sample.Entities; + +namespace Sieve.Sample.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.14"); + + modelBuilder.Entity("Sieve.Sample.Entities.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CommentCount") + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastViewed") + .HasColumnType("datetime"); + + b.Property("LikeCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SieveTests/Program.cs b/Sieve.Sample/Program.cs similarity index 94% rename from SieveTests/Program.cs rename to Sieve.Sample/Program.cs index 255e0c6..7b5dc68 100644 --- a/SieveTests/Program.cs +++ b/Sieve.Sample/Program.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; -namespace SieveTests +namespace Sieve.Sample { public static class Program { diff --git a/SieveTests/Properties/launchSettings.json b/Sieve.Sample/Properties/launchSettings.json similarity index 100% rename from SieveTests/Properties/launchSettings.json rename to Sieve.Sample/Properties/launchSettings.json diff --git a/SieveTests/Services/ApplicationSieveProcessor.cs b/Sieve.Sample/Services/ApplicationSieveProcessor.cs similarity index 60% rename from SieveTests/Services/ApplicationSieveProcessor.cs rename to Sieve.Sample/Services/ApplicationSieveProcessor.cs index 039352a..eddbea6 100644 --- a/SieveTests/Services/ApplicationSieveProcessor.cs +++ b/Sieve.Sample/Services/ApplicationSieveProcessor.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Options; using Sieve.Models; +using Sieve.Sample.Entities; using Sieve.Services; -using SieveTests.Entities; -namespace SieveTests.Services +namespace Sieve.Sample.Services { public class ApplicationSieveProcessor : SieveProcessor { @@ -13,11 +13,18 @@ namespace SieveTests.Services protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) { + // Option 1: Map all properties centrally mapper.Property(p => p.Title) .CanSort() .CanFilter() .HasName("CustomTitleName"); + // Option 2: Manually apply functionally grouped mapping configurations + //mapper.ApplyConfiguration(); + + // Option 3: Scan and apply all configurations + //mapper.ApplyConfigurationsFromAssembly(typeof(ApplicationSieveProcessor).Assembly); + return mapper; } } diff --git a/SieveTests/Services/SieveCustomFilterMethods.cs b/Sieve.Sample/Services/SieveCustomFilterMethods.cs similarity index 82% rename from SieveTests/Services/SieveCustomFilterMethods.cs rename to Sieve.Sample/Services/SieveCustomFilterMethods.cs index 2a366ea..a2fc673 100644 --- a/SieveTests/Services/SieveCustomFilterMethods.cs +++ b/Sieve.Sample/Services/SieveCustomFilterMethods.cs @@ -1,8 +1,8 @@ using System.Linq; +using Sieve.Sample.Entities; using Sieve.Services; -using SieveTests.Entities; -namespace SieveTests.Services +namespace Sieve.Sample.Services { public class SieveCustomFilterMethods : ISieveCustomFilterMethods { diff --git a/SieveTests/Services/SieveCustomSortMethods.cs b/Sieve.Sample/Services/SieveCustomSortMethods.cs similarity index 87% rename from SieveTests/Services/SieveCustomSortMethods.cs rename to Sieve.Sample/Services/SieveCustomSortMethods.cs index 98f1244..5b89745 100644 --- a/SieveTests/Services/SieveCustomSortMethods.cs +++ b/Sieve.Sample/Services/SieveCustomSortMethods.cs @@ -1,8 +1,8 @@ using System.Linq; +using Sieve.Sample.Entities; using Sieve.Services; -using SieveTests.Entities; -namespace SieveTests.Services +namespace Sieve.Sample.Services { public class SieveCustomSortMethods : ISieveCustomSortMethods { diff --git a/Sieve.Sample/Sieve.Sample.csproj b/Sieve.Sample/Sieve.Sample.csproj new file mode 100644 index 0000000..8aca6b9 --- /dev/null +++ b/Sieve.Sample/Sieve.Sample.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Sieve.Sample/Startup.cs b/Sieve.Sample/Startup.cs new file mode 100644 index 0000000..06c36b3 --- /dev/null +++ b/Sieve.Sample/Startup.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Sieve.Models; +using Sieve.Sample.Entities; +using Sieve.Sample.Services; +using Sieve.Services; + +namespace Sieve.Sample +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc(opts => + { + opts.EnableEndpointRouting = false; + }); + + services.AddDbContext(options => + options.UseSqlite("Data Source=.\\sieve.db")); + + services.Configure(Configuration.GetSection("Sieve")); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + PrepareDatabase(app); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseMvc(); + } + + private static void PrepareDatabase(IApplicationBuilder app) + { + using var scope = app.ApplicationServices.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureDeleted(); + dbContext.Database.Migrate(); + } + } +} diff --git a/SieveTests/appsettings.Development.json b/Sieve.Sample/appsettings.Development.json similarity index 83% rename from SieveTests/appsettings.Development.json rename to Sieve.Sample/appsettings.Development.json index fa8ce71..0623a3f 100644 --- a/SieveTests/appsettings.Development.json +++ b/Sieve.Sample/appsettings.Development.json @@ -1,6 +1,5 @@ { "Logging": { - "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", diff --git a/SieveTests/appsettings.json b/Sieve.Sample/appsettings.json similarity index 84% rename from SieveTests/appsettings.json rename to Sieve.Sample/appsettings.json index c696841..f4aec2b 100644 --- a/SieveTests/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/SieveTests/pyprofile.py b/Sieve.Sample/pyprofile.py similarity index 100% rename from SieveTests/pyprofile.py rename to Sieve.Sample/pyprofile.py diff --git a/Sieve.sln b/Sieve.sln index acf6862..8a36907 100644 --- a/Sieve.sln +++ b/Sieve.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 15.0.27130.2027 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sieve", "Sieve\Sieve.csproj", "{B32B8B33-94B0-40E3-8FE5-D54602222717}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SieveTests", "SieveTests\SieveTests.csproj", "{8043D264-42A0-4275-97A1-46400C02E37E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sieve.Sample", "Sieve.Sample\Sieve.Sample.csproj", "{8043D264-42A0-4275-97A1-46400C02E37E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SieveUnitTests", "SieveUnitTests\SieveUnitTests.csproj", "{21C3082D-F40E-457F-BE2E-AA099E19E199}" EndProject @@ -15,12 +15,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{C8829BE9-BC1E-4C67-ACEA-EC5DD3633EE2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C8829BE9-BC1E-4C67-ACEA-EC5DD3633EE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8829BE9-BC1E-4C67-ACEA-EC5DD3633EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU {B32B8B33-94B0-40E3-8FE5-D54602222717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B32B8B33-94B0-40E3-8FE5-D54602222717}.Debug|Any CPU.Build.0 = Debug|Any CPU {B32B8B33-94B0-40E3-8FE5-D54602222717}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Sieve/Models/FilterTerm.cs b/Sieve/Models/FilterTerm.cs index 77bfeec..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; @@ -6,45 +7,49 @@ namespace Sieve.Models { public class FilterTerm : IFilterTerm, IEquatable { - public FilterTerm() { } + private const string EscapedPipePattern = @"(?=|<=|>|<|@=|_=)"; + private const string EscapeNegPatternForOper = @"(?=", - "<=", - ">", - "<", - "@=", - "_=" + private static readonly HashSet _escapedSequences = new HashSet + { + @"\|", + @"\\" }; public string Filter { set { - var filterSplits = value.Split(Operators, StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()).ToArray(); + var filterSplits = Regex.Split(value,EscapeNegPatternForOper).Select(t => 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; - Operator = Array.Find(Operators, o => value.Contains(o)) ?? "=="; + + if (filterSplits.Length > 2) + { + foreach (var match in Regex.Matches(filterSplits[2],EscapePosPatternForOper)) + { + var matchStr = match.ToString(); + filterSplits[2] = filterSplits[2].Replace('\\' + matchStr, matchStr); + } + + Values = Regex.Split(filterSplits[2], EscapedPipePattern) + .Select(UnEscape) + .ToArray(); + } + + Operator = Regex.Match(value,EscapeNegPatternForOper).Value; OperatorParsed = GetOperatorParsed(Operator); 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; } @@ -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 a773392..8d43153 100644 --- a/Sieve/Models/SieveModel.cs +++ b/Sieve/Models/SieveModel.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Runtime.Serialization; @@ -13,7 +14,15 @@ namespace Sieve.Models where TFilterTerm : IFilterTerm, new() where TSortTerm : ISortTerm, new() { + /// + /// Pattern used to split filters and sorts by comma. + /// private const string EscapedCommaPattern = @"(? + /// Escaped comma e.g. used in filter filter string. + /// + private const string EscapedComma = @"\,"; [DataMember] public string Filters { get; set; } @@ -34,15 +43,20 @@ namespace Sieve.Models var value = new List(); foreach (var filter in Regex.Split(Filters, EscapedCommaPattern)) { - if (string.IsNullOrWhiteSpace(filter)) continue; + if (string.IsNullOrWhiteSpace(filter)) + { + continue; + } + + var filterValue = filter.Replace(EscapedComma, ","); if (filter.StartsWith("(")) { - var filterOpAndVal = filter.Substring(filter.LastIndexOf(")") + 1); - var subfilters = filter.Replace(filterOpAndVal, "").Replace("(", "").Replace(")", ""); + var filterOpAndVal = filterValue[(filterValue.LastIndexOf(")", StringComparison.Ordinal) + 1)..]; + var subFilters = filterValue.Replace(filterOpAndVal, "").Replace("(", "").Replace(")", ""); var filterTerm = new TFilterTerm { - Filter = subfilters + filterOpAndVal + Filter = subFilters + filterOpAndVal }; value.Add(filterTerm); } @@ -50,7 +64,7 @@ namespace Sieve.Models { var filterTerm = new TFilterTerm { - Filter = filter + Filter = filterValue }; value.Add(filterTerm); } @@ -65,29 +79,28 @@ namespace Sieve.Models public List GetSortsParsed() { - if (Sorts != null) - { - var value = new List(); - foreach (var sort in Regex.Split(Sorts, EscapedCommaPattern)) - { - if (string.IsNullOrWhiteSpace(sort)) continue; - - var sortTerm = new TSortTerm() - { - Sort = sort - }; - if (!value.Any(s => s.Name == sortTerm.Name)) - { - value.Add(sortTerm); - } - } - return value; - } - else + if (Sorts == null) { return null; } + var value = new List(); + foreach (var sort in Regex.Split(Sorts, EscapedCommaPattern)) + { + if (string.IsNullOrWhiteSpace(sort)) continue; + + var sortTerm = new TSortTerm + { + Sort = sort + }; + + if (value.All(s => s.Name != sortTerm.Name)) + { + value.Add(sortTerm); + } + } + + return value; } } } diff --git a/Sieve/Models/SieveOptions.cs b/Sieve/Models/SieveOptions.cs index a207958..ead79f9 100644 --- a/Sieve/Models/SieveOptions.cs +++ b/Sieve/Models/SieveOptions.cs @@ -1,4 +1,4 @@ -namespace Sieve.Models +namespace Sieve.Models { public class SieveOptions { @@ -10,6 +10,8 @@ public bool ThrowExceptions { get; set; } = false; + public bool IgnoreNullsOnNotEqual { get; set; } = true; + public string CultureNameOfTypeConversion { get; set; } = "en"; } } diff --git a/Sieve/Services/ISieveConfiguration.cs b/Sieve/Services/ISieveConfiguration.cs new file mode 100644 index 0000000..22f3402 --- /dev/null +++ b/Sieve/Services/ISieveConfiguration.cs @@ -0,0 +1,70 @@ +#nullable enable +using System; +using System.Linq; +using System.Reflection; + +namespace Sieve.Services +{ + /// + /// Use this interface to create SieveConfiguration (just like EntityTypeConfigurations are defined for EF) + /// + public interface ISieveConfiguration + { + /// + /// Configures sieve property mappings. + /// + /// The mapper used to configure the sieve properties on. + void Configure(SievePropertyMapper mapper); + } + + /// + /// Configuration extensions to the + /// + public static class SieveConfigurationExtensions + { + /// + /// Applies configuration that is defined in an instance. + /// + /// The mapper to apply the configuration on. + /// The configuration to be applied. + /// + /// The same instance so that additional configuration calls can be chained. + /// + public static SievePropertyMapper ApplyConfiguration(this SievePropertyMapper mapper) where T : ISieveConfiguration, new() + { + var configuration = new T(); + configuration.Configure(mapper); + return mapper; + } + + /// + /// Applies configuration from all + /// instances that are defined in provided assembly. + /// + /// The mapper to apply the configuration on. + /// The assembly to scan. + /// + /// The same instance so that additional configuration calls can be chained. + /// + public static SievePropertyMapper ApplyConfigurationsFromAssembly(this SievePropertyMapper mapper, Assembly assembly) + { + foreach (var type in assembly.GetTypes().Where(t => !t.IsAbstract && !t.IsGenericTypeDefinition)) + { + // Only accept types that contain a parameterless constructor, are not abstract. + var noArgConstructor = type.GetConstructor(Type.EmptyTypes); + if (noArgConstructor is null) + { + continue; + } + + if (type.GetInterfaces().Any(t => t == typeof(ISieveConfiguration))) + { + var configuration = (ISieveConfiguration)noArgConstructor.Invoke(new object?[] { }); + configuration.Configure(mapper); + } + } + + return mapper; + } + } +} diff --git a/Sieve/Services/SieveProcessor.cs b/Sieve/Services/SieveProcessor.cs index b872edb..89af09f 100644 --- a/Sieve/Services/SieveProcessor.cs +++ b/Sieve/Services/SieveProcessor.cs @@ -15,40 +15,50 @@ namespace Sieve.Services { public class SieveProcessor : SieveProcessor, ISieveProcessor { - public SieveProcessor(IOptions options) : base(options) + public SieveProcessor(IOptions options) + : base(options) { } - public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) : base(options, customSortMethods) + public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) + : base(options, customSortMethods) { } - public SieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) : base(options, customFilterMethods) + public SieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) + : base(options, customFilterMethods) { } - public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods, ISieveCustomFilterMethods customFilterMethods) : base(options, customSortMethods, customFilterMethods) + public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) : base(options, customSortMethods, customFilterMethods) { } } - public class SieveProcessor : SieveProcessor, TFilterTerm, TSortTerm>, ISieveProcessor + public class SieveProcessor : + SieveProcessor, TFilterTerm, TSortTerm>, ISieveProcessor where TFilterTerm : IFilterTerm, new() where TSortTerm : ISortTerm, new() { - public SieveProcessor(IOptions options) : base(options) + public SieveProcessor(IOptions options) + : base(options) { } - public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) : base(options, customSortMethods) + public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) + : base(options, customSortMethods) { } - public SieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) : base(options, customFilterMethods) + public SieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) + : base(options, customFilterMethods) { } - public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods, ISieveCustomFilterMethods customFilterMethods) : base(options, customSortMethods, customFilterMethods) + public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) + : base(options, customSortMethods, customFilterMethods) { } } @@ -58,18 +68,18 @@ namespace Sieve.Services where TFilterTerm : IFilterTerm, new() where TSortTerm : ISortTerm, new() { - private const string nullFilterValue = "null"; - private readonly IOptions _options; + private const string NullFilterValue = "null"; + private const char EscapeChar = '\\'; private readonly ISieveCustomSortMethods _customSortMethods; private readonly ISieveCustomFilterMethods _customFilterMethods; - private readonly SievePropertyMapper mapper = new SievePropertyMapper(); + private readonly SievePropertyMapper _mapper = new SievePropertyMapper(); public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods, ISieveCustomFilterMethods customFilterMethods) { - mapper = MapProperties(mapper); - _options = options; + _mapper = MapProperties(_mapper); + Options = options; _customSortMethods = customSortMethods; _customFilterMethods = customFilterMethods; } @@ -77,25 +87,27 @@ namespace Sieve.Services public SieveProcessor(IOptions options, ISieveCustomSortMethods customSortMethods) { - mapper = MapProperties(mapper); - _options = options; + _mapper = MapProperties(_mapper); + Options = options; _customSortMethods = customSortMethods; } public SieveProcessor(IOptions options, ISieveCustomFilterMethods customFilterMethods) { - mapper = MapProperties(mapper); - _options = options; + _mapper = MapProperties(_mapper); + Options = options; _customFilterMethods = customFilterMethods; } public SieveProcessor(IOptions options) { - mapper = MapProperties(mapper); - _options = options; + _mapper = MapProperties(_mapper); + Options = options; } + protected IOptions Options { get; } + /// /// Apply filtering, sorting, and pagination parameters found in `model` to `source` /// @@ -107,12 +119,8 @@ namespace Sieve.Services /// Should the data be sorted? Defaults to true. /// Should the data be paginated? Defaults to true. /// Returns a transformed version of `source` - public IQueryable Apply( - TSieveModel model, - IQueryable source, - object[] dataForCustomMethods = null, - bool applyFiltering = true, - bool applySorting = true, + public IQueryable Apply(TSieveModel model, IQueryable source, + object[] dataForCustomMethods = null, bool applyFiltering = true, bool applySorting = true, bool applyPagination = true) { var result = source; @@ -124,19 +132,16 @@ namespace Sieve.Services try { - // Filter if (applyFiltering) { result = ApplyFiltering(model, result, dataForCustomMethods); } - // Sort if (applySorting) { result = ApplySorting(model, result, dataForCustomMethods); } - // Paginate if (applyPagination) { result = ApplyPagination(model, result); @@ -146,25 +151,21 @@ namespace Sieve.Services } catch (Exception ex) { - if (_options.Value.ThrowExceptions) - { - if (ex is SieveException) - { - throw; - } - - throw new SieveException(ex.Message, ex); - } - else + if (!Options.Value.ThrowExceptions) { return result; } + + if (ex is SieveException) + { + throw; + } + + throw new SieveException(ex.Message, ex); } } - private IQueryable ApplyFiltering( - TSieveModel model, - IQueryable result, + protected virtual IQueryable ApplyFiltering(TSieveModel model, IQueryable result, object[] dataForCustomMethods = null) { if (model?.GetFiltersParsed() == null) @@ -184,38 +185,33 @@ namespace Sieve.Services var (fullPropertyName, property) = GetSieveProperty(false, true, filterTermName); if (property != null) { - Expression propertyValue = parameter; - Expression nullCheck = null; - var names = fullPropertyName.Split('.'); - for (var i = 0; i < names.Length; i++) + if (filterTerm.Values == null) { - propertyValue = Expression.PropertyOrField(propertyValue, names[i]); - - if (i != names.Length - 1 && propertyValue.Type.IsNullable()) - { - nullCheck = GenerateFilterNullCheckExpression(propertyValue, nullCheck); - } + continue; } - if (filterTerm.Values == null) continue; - var converter = TypeDescriptor.GetConverter(property.PropertyType); foreach (var filterTermValue in filterTerm.Values) { - var isFilterTermValueNull = filterTermValue.ToLower() == nullFilterValue; + var (propertyValue, nullCheck) = + GetPropertyValueAndNullCheckExpression(parameter, fullPropertyName); + + var isFilterTermValueNull = + IsFilterTermValueNull(propertyValue, filterTerm, filterTermValue); + var filterValue = isFilterTermValueNull ? Expression.Constant(null, property.PropertyType) : ConvertStringValueToConstantExpression(filterTermValue, property, converter, cultureInfoToUseForTypeConversion); - if (filterTerm.OperatorIsCaseInsensitive) + if (filterTerm.OperatorIsCaseInsensitive && !isFilterTermValueNull) { propertyValue = Expression.Call(propertyValue, typeof(string).GetMethods() - .First(m => m.Name == "ToUpper" && m.GetParameters().Length == 0)); + .First(m => m.Name == "ToUpper" && m.GetParameters().Length == 0)); filterValue = Expression.Call(filterValue, typeof(string).GetMethods() - .First(m => m.Name == "ToUpper" && m.GetParameters().Length == 0)); + .First(m => m.Name == "ToUpper" && m.GetParameters().Length == 0)); } var expression = GetExpression(filterTerm, filterValue, propertyValue); @@ -225,61 +221,103 @@ namespace Sieve.Services expression = Expression.Not(expression); } - var filterValueNullCheck = !isFilterTermValueNull && propertyValue.Type.IsNullable() - ? GenerateFilterNullCheckExpression(propertyValue, nullCheck) - : nullCheck; - - 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); + } } - if (innerExpression == null) - { - innerExpression = expression; - } - else - { - innerExpression = Expression.OrElse(innerExpression, expression); - } + innerExpression = innerExpression == null + ? expression + : Expression.OrElse(innerExpression, expression); } } else { result = ApplyCustomMethod(result, filterTermName, _customFilterMethods, - new object[] { - result, - filterTerm.Operator, - filterTerm.Values - }, dataForCustomMethods); - + new object[] {result, filterTerm.Operator, filterTerm.Values}, dataForCustomMethods); } } + if (outerExpression == null) { outerExpression = innerExpression; continue; } + if (innerExpression == null) { continue; } + outerExpression = Expression.AndAlso(outerExpression, innerExpression); } + return outerExpression == null ? result : result.Where(Expression.Lambda>(outerExpression, parameter)); } - private static Expression GenerateFilterNullCheckExpression(Expression propertyValue, Expression nullCheckExpression) + private static Expression GetFilterValueNullCheck(Expression parameter, string fullPropertyName, bool isFilterTermValueNull) + { + var (propertyValue, nullCheck) = GetPropertyValueAndNullCheckExpression(parameter, fullPropertyName); + + if (!isFilterTermValueNull && propertyValue.Type.IsNullable()) + { + return GenerateFilterNullCheckExpression(propertyValue, nullCheck); + } + + return nullCheck; + } + + private static bool IsFilterTermValueNull(Expression propertyValue, TFilterTerm filterTerm, + string filterTermValue) + { + var isNotString = propertyValue.Type != typeof(string); + + var isValidStringNullOperation = filterTerm.OperatorParsed == FilterOperator.Equals || + filterTerm.OperatorParsed == FilterOperator.NotEquals; + + return filterTermValue.ToLower() == NullFilterValue && (isNotString || isValidStringNullOperation); + } + + private static (Expression propertyValue, Expression nullCheck) GetPropertyValueAndNullCheckExpression( + Expression parameter, string fullPropertyName) + { + var propertyValue = parameter; + Expression nullCheck = null; + var names = fullPropertyName.Split('.'); + for (var i = 0; i < names.Length; i++) + { + propertyValue = Expression.PropertyOrField(propertyValue, names[i]); + + if (i != names.Length - 1 && propertyValue.Type.IsNullable()) + { + nullCheck = GenerateFilterNullCheckExpression(propertyValue, nullCheck); + } + } + + return (propertyValue, nullCheck); + } + + private static Expression GenerateFilterNullCheckExpression(Expression propertyValue, + Expression nullCheckExpression) { return nullCheckExpression == null ? Expression.NotEqual(propertyValue, Expression.Default(propertyValue.Type)) - : Expression.AndAlso(nullCheckExpression, Expression.NotEqual(propertyValue, Expression.Default(propertyValue.Type))); + : Expression.AndAlso(nullCheckExpression, + Expression.NotEqual(propertyValue, Expression.Default(propertyValue.Type))); } - private Expression ConvertStringValueToConstantExpression(string value, PropertyInfo property, TypeConverter converter, CultureInfo cultureInfo) + private static Expression ConvertStringValueToConstantExpression(string value, PropertyInfo property, TypeConverter converter, CultureInfo cultureInfo) { + // 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(null, cultureInfo, value) : Convert.ChangeType(value, property.PropertyType); @@ -289,47 +327,32 @@ namespace Sieve.Services private static Expression GetExpression(TFilterTerm filterTerm, dynamic filterValue, dynamic propertyValue) { - switch (filterTerm.OperatorParsed) + return filterTerm.OperatorParsed switch { - case FilterOperator.Equals: - return Expression.Equal(propertyValue, filterValue); - case FilterOperator.NotEquals: - return Expression.NotEqual(propertyValue, filterValue); - case FilterOperator.GreaterThan: - return Expression.GreaterThan(propertyValue, filterValue); - case FilterOperator.LessThan: - return Expression.LessThan(propertyValue, filterValue); - case FilterOperator.GreaterThanOrEqualTo: - return Expression.GreaterThanOrEqual(propertyValue, filterValue); - case FilterOperator.LessThanOrEqualTo: - return Expression.LessThanOrEqual(propertyValue, filterValue); - case FilterOperator.Contains: - return Expression.Call(propertyValue, - typeof(string).GetMethods() - .First(m => m.Name == "Contains" && m.GetParameters().Length == 1), - filterValue); - case FilterOperator.StartsWith: - return Expression.Call(propertyValue, - typeof(string).GetMethods() - .First(m => m.Name == "StartsWith" && m.GetParameters().Length == 1), - filterValue); - default: - return Expression.Equal(propertyValue, filterValue); - } + FilterOperator.Equals => Expression.Equal(propertyValue, filterValue), + FilterOperator.NotEquals => Expression.NotEqual(propertyValue, filterValue), + FilterOperator.GreaterThan => Expression.GreaterThan(propertyValue, filterValue), + FilterOperator.LessThan => Expression.LessThan(propertyValue, filterValue), + FilterOperator.GreaterThanOrEqualTo => Expression.GreaterThanOrEqual(propertyValue, filterValue), + FilterOperator.LessThanOrEqualTo => Expression.LessThanOrEqual(propertyValue, filterValue), + FilterOperator.Contains => Expression.Call(propertyValue, + typeof(string).GetMethods().First(m => m.Name == "Contains" && m.GetParameters().Length == 1), + filterValue), + FilterOperator.StartsWith => Expression.Call(propertyValue, + typeof(string).GetMethods().First(m => m.Name == "StartsWith" && m.GetParameters().Length == 1), + filterValue), + _ => Expression.Equal(propertyValue, filterValue) + }; } // 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 Expression GetClosureOverConstant(T constant, Type targetType) + 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) @@ -349,33 +372,29 @@ namespace Sieve.Services else { result = ApplyCustomMethod(result, sortTerm.Name, _customSortMethods, - new object[] - { - result, - useThenBy, - sortTerm.Descending - }, dataForCustomMethods); + new object[] {result, useThenBy, sortTerm.Descending}, dataForCustomMethods); } + useThenBy = true; } 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) + if (pageSize <= 0) { - result = result.Skip((page - 1) * pageSize); - result = result.Take(Math.Min(pageSize, maxPageSize)); + return result; } + result = result.Skip((page - 1) * pageSize); + result = result.Take(Math.Min(pageSize, maxPageSize)); + return result; } @@ -384,51 +403,52 @@ namespace Sieve.Services return mapper; } - private (string, PropertyInfo) GetSieveProperty( - bool canSortRequired, - bool canFilterRequired, + private (string, PropertyInfo) GetSieveProperty(bool canSortRequired, bool canFilterRequired, string name) { - var property = mapper.FindProperty(canSortRequired, canFilterRequired, name, _options.Value.CaseSensitive); - if (property.Item1 == null) + var property = _mapper.FindProperty(canSortRequired, canFilterRequired, name, + Options.Value.CaseSensitive); + if (property.Item1 != null) { - var prop = FindPropertyBySieveAttribute(canSortRequired, canFilterRequired, name, _options.Value.CaseSensitive); - return (prop?.Name, prop); + return property; } - return property; + var prop = FindPropertyBySieveAttribute(canSortRequired, canFilterRequired, name, + Options.Value.CaseSensitive); + return (prop?.Name, prop); } - private PropertyInfo FindPropertyBySieveAttribute( - bool canSortRequired, - bool canFilterRequired, - string name, - bool isCaseSensitive) + private static PropertyInfo FindPropertyBySieveAttribute(bool canSortRequired, bool canFilterRequired, + string name, bool isCaseSensitive) { - return Array.Find(typeof(TEntity).GetProperties(), p => - { - return p.GetCustomAttribute(typeof(SieveAttribute)) is SieveAttribute sieveAttribute - && (!canSortRequired || sieveAttribute.CanSort) - && (!canFilterRequired || sieveAttribute.CanFilter) - && (sieveAttribute.Name ?? p.Name).Equals(name, isCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); - }); + return Array.Find(typeof(TEntity).GetProperties(), + p => p.GetCustomAttribute(typeof(SieveAttribute)) is SieveAttribute SieveAttribute + && (!canSortRequired || SieveAttribute.CanSort) + && (!canFilterRequired || SieveAttribute.CanFilter) + && (SieveAttribute.Name ?? p.Name).Equals(name, + isCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)); } - private IQueryable ApplyCustomMethod(IQueryable result, string name, object parent, object[] parameters, object[] optionalParameters = null) + private IQueryable ApplyCustomMethod(IQueryable result, string name, object parent, + object[] parameters, object[] optionalParameters = null) { var customMethod = parent?.GetType() .GetMethodExt(name, - _options.Value.CaseSensitive ? BindingFlags.Default : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, - typeof(IQueryable)); + Options.Value.CaseSensitive + ? BindingFlags.Default + : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, + typeof(IQueryable)); if (customMethod == null) { // Find generic methods `public IQueryable Filter(IQueryable source, ...)` var genericCustomMethod = parent?.GetType() - .GetMethodExt(name, - _options.Value.CaseSensitive ? BindingFlags.Default : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, - typeof(IQueryable<>)); + .GetMethodExt(name, + Options.Value.CaseSensitive + ? BindingFlags.Default + : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance, + typeof(IQueryable<>)); if (genericCustomMethod != null && genericCustomMethod.ReturnType.IsGenericType && @@ -436,7 +456,8 @@ namespace Sieve.Services { var genericBaseType = genericCustomMethod.ReturnType.GenericTypeArguments[0]; var constraints = genericBaseType.GetGenericParameterConstraints(); - if (constraints == null || constraints.Length == 0 || constraints.All((t) => t.IsAssignableFrom(typeof(TEntity)))) + if (constraints == null || constraints.Length == 0 || + constraints.All((t) => t.IsAssignableFrom(typeof(TEntity)))) { customMethod = genericCustomMethod.MakeGenericMethod(typeof(TEntity)); } @@ -465,40 +486,34 @@ namespace Sieve.Services } else { - var incompatibleCustomMethods = parent? - .GetType() - .GetMethods - ( - _options.Value.CaseSensitive - ? BindingFlags.Default - : BindingFlags.IgnoreCase | BindingFlags.Public | - BindingFlags.Instance - ) - .Where(method => string.Equals(method.Name, name, - _options.Value.CaseSensitive - ? StringComparison.InvariantCulture - : StringComparison.InvariantCultureIgnoreCase)) - .ToList() - ?? - new List(); + var incompatibleCustomMethods = + parent? + .GetType() + .GetMethods(Options.Value.CaseSensitive + ? BindingFlags.Default + : BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) + .Where(method => string.Equals(method.Name, name, + Options.Value.CaseSensitive + ? StringComparison.InvariantCulture + : StringComparison.InvariantCultureIgnoreCase)) + .ToList() + ?? new List(); - if (incompatibleCustomMethods.Any()) - { - var incompatibles = - from incompatibleCustomMethod in incompatibleCustomMethods - let expected = typeof(IQueryable) - let actual = incompatibleCustomMethod.ReturnType - select new SieveIncompatibleMethodException(name, expected, actual, - $"{name} failed. Expected a custom method for type {expected} but only found for type {actual}"); - - var aggregate = new AggregateException(incompatibles); - - throw new SieveIncompatibleMethodException(aggregate.Message, aggregate); - } - else + if (!incompatibleCustomMethods.Any()) { throw new SieveMethodNotFoundException(name, $"{name} not found."); } + + var incompatibles = + from incompatibleCustomMethod in incompatibleCustomMethods + let expected = typeof(IQueryable) + let actual = incompatibleCustomMethod.ReturnType + select new SieveIncompatibleMethodException(name, expected, actual, + $"{name} failed. Expected a custom method for type {expected} but only found for type {actual}"); + + var aggregate = new AggregateException(incompatibles); + + throw new SieveIncompatibleMethodException(aggregate.Message, aggregate); } return result; diff --git a/Sieve/Sieve.csproj b/Sieve/Sieve.csproj index 344c5af..78b51b0 100644 --- a/Sieve/Sieve.csproj +++ b/Sieve/Sieve.csproj @@ -1,22 +1,24 @@  - netstandard2.0 - 2.3.3 - Sieve is a simple, clean, and extensible framework for .NET Core that adds sorting, filtering, and pagination functionality out of the box. Most common use case would be for serving ASP.NET Core GET queries. Documentation available on GitHub: https://github.com/Biarity/Sieve/ - - Copyright 2018 - https://github.com/Biarity/Sieve/blob/master/LICENSE + netstandard2.1 + Sieve is a simple, clean, and extensible framework for .NET Core that adds sorting, filtering, and pagination functionality out of the box. Most common use case would be for serving ASP.NET Core GET queries. Documentation available on GitHub: https://github.com/Biarity/Sieve/ + Biarity;Ashish Patel;Kevin Sommer + + Filter;Sort;Page;Paging; https://github.com/Biarity/Sieve - https://emojipedia-us.s3.amazonaws.com/thumbs/240/twitter/120/alembic_2697.png - - Only Skip when pageSize > 0 (#63) -Added support for generic filter and sort methods (#60) -Don't process when filterTerm.Values is null (#59) - - true + Apache-2.0 true - Biarity + + https://github.com/Biarity/Sieve + git + + + true + + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb diff --git a/Sieve/nuget.exe b/Sieve/nuget.exe deleted file mode 100644 index 7473556..0000000 Binary files a/Sieve/nuget.exe and /dev/null differ diff --git a/SieveTests/Migrations/20180127005347_Init.Designer.cs b/SieveTests/Migrations/20180127005347_Init.Designer.cs deleted file mode 100644 index 741e3af..0000000 --- a/SieveTests/Migrations/20180127005347_Init.Designer.cs +++ /dev/null @@ -1,44 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.Internal; -using SieveTests.Entities; -using System; - -namespace SieveTests.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20180127005347_Init")] - partial class Init - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.1-rtm-125") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("SieveTests.Entities.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("CommentCount"); - - b.Property("DateCreated"); - - b.Property("LikeCount"); - - b.Property("Title"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.Designer.cs b/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.Designer.cs deleted file mode 100644 index 052009c..0000000 --- a/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.Designer.cs +++ /dev/null @@ -1,47 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.Internal; -using SieveTests.Entities; -using System; - -namespace SieveTests.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20180522013323_AddDateLastViewedColumn")] - partial class AddDateLastViewedColumn - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.1-rtm-125") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("SieveTests.Entities.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("CommentCount"); - - b.Property("DateCreated"); - - b.Property("DateLastViewed") - .HasColumnType("datetime"); - - b.Property("LikeCount"); - - b.Property("Title"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.cs b/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.cs deleted file mode 100644 index b42ab3e..0000000 --- a/SieveTests/Migrations/20180522013323_AddDateLastViewedColumn.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace SieveTests.Migrations -{ - public partial class AddDateLastViewedColumn : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DateLastViewed", - table: "Posts", - type: "datetime", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DateLastViewed", - table: "Posts"); - } - } -} diff --git a/SieveTests/Migrations/ApplicationDbContextModelSnapshot.cs b/SieveTests/Migrations/ApplicationDbContextModelSnapshot.cs deleted file mode 100644 index f2a7904..0000000 --- a/SieveTests/Migrations/ApplicationDbContextModelSnapshot.cs +++ /dev/null @@ -1,48 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Storage.Internal; -using SieveTests.Entities; -using System; - -namespace SieveTests.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - partial class ApplicationDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.1-rtm-125") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("SieveTests.Entities.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("CommentCount"); - - b.Property("DateCreated"); - - b.Property("DateLastViewed") - .HasColumnType("datetime"); - - b.Property("LikeCount"); - - b.Property("CategoryId"); - - b.Property("Title"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SieveTests/SieveTests.csproj b/SieveTests/SieveTests.csproj deleted file mode 100644 index 1374e21..0000000 --- a/SieveTests/SieveTests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - netcoreapp2.0 - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SieveTests/Startup.cs b/SieveTests/Startup.cs deleted file mode 100644 index 415abd9..0000000 --- a/SieveTests/Startup.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Sieve.Models; -using Sieve.Services; -using SieveTests.Entities; -using SieveTests.Services; - -namespace SieveTests -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddMvc(); - - services.AddDbContext(options => - options.UseSqlServer(Configuration.GetConnectionString("TestSqlServer"))); - - services.Configure(Configuration.GetSection("Sieve")); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IHostingEnvironment env) - { - // TIME MEASUREMENT - var times = new List(); - app.Use(async (context, next) => - { - var sw = new Stopwatch(); - sw.Start(); - await next.Invoke(); - sw.Stop(); - times.Add(sw.ElapsedMilliseconds); - var text = $"AVG: {(int)times.Average()}ms; AT {sw.ElapsedMilliseconds}; COUNT: {times.Count()}"; - Console.WriteLine(text); - await context.Response.WriteAsync($""); - }); - - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseMvc(); - } - } -} diff --git a/SieveUnitTests/Abstractions/Entity/SieveConfigurationForIPost.cs b/SieveUnitTests/Abstractions/Entity/SieveConfigurationForIPost.cs new file mode 100644 index 0000000..f6270ed --- /dev/null +++ b/SieveUnitTests/Abstractions/Entity/SieveConfigurationForIPost.cs @@ -0,0 +1,37 @@ +using Sieve.Services; + +namespace SieveUnitTests.Abstractions.Entity +{ + public class SieveConfigurationForIPost : ISieveConfiguration + { + public void Configure(SievePropertyMapper mapper) + { + mapper.Property(p => p.ThisHasNoAttributeButIsAccessible) + .CanSort() + .CanFilter() + .HasName("shortname"); + + mapper.Property(p => p.TopComment.Text) + .CanFilter(); + + mapper.Property(p => p.TopComment.Id) + .CanSort(); + + mapper.Property(p => p.OnlySortableViaFluentApi) + .CanSort(); + + mapper.Property(p => p.TopComment.Text) + .CanFilter() + .HasName("topc"); + + mapper.Property(p => p.FeaturedComment.Text) + .CanFilter() + .HasName("featc"); + + mapper + .Property(p => p.DateCreated) + .CanSort() + .HasName("CreateDate"); + } + } +} diff --git a/SieveUnitTests/Entities/Comment.cs b/SieveUnitTests/Entities/Comment.cs index b892796..4a04ff7 100644 --- a/SieveUnitTests/Entities/Comment.cs +++ b/SieveUnitTests/Entities/Comment.cs @@ -7,5 +7,8 @@ namespace SieveUnitTests.Entities { [Sieve(CanFilter = true)] public string Text { get; set; } + + [Sieve(CanFilter = true)] + public string Author { get; set; } } } diff --git a/SieveUnitTests/Entities/SieveConfigurationForPost.cs b/SieveUnitTests/Entities/SieveConfigurationForPost.cs new file mode 100644 index 0000000..8a02178 --- /dev/null +++ b/SieveUnitTests/Entities/SieveConfigurationForPost.cs @@ -0,0 +1,37 @@ +using Sieve.Services; + +namespace SieveUnitTests.Entities +{ + public class SieveConfigurationForPost : ISieveConfiguration + { + public void Configure(SievePropertyMapper mapper) + { + mapper.Property(p => p.ThisHasNoAttributeButIsAccessible) + .CanSort() + .CanFilter() + .HasName("shortname"); + + mapper.Property(p => p.TopComment.Text) + .CanFilter(); + + mapper.Property(p => p.TopComment.Id) + .CanSort(); + + mapper.Property(p => p.OnlySortableViaFluentApi) + .CanSort(); + + mapper.Property(p => p.TopComment.Text) + .CanFilter() + .HasName("topc"); + + mapper.Property(p => p.FeaturedComment.Text) + .CanFilter() + .HasName("featc"); + + mapper + .Property(p => p.DateCreated) + .CanSort() + .HasName("CreateDate"); + } + } +} diff --git a/SieveUnitTests/General.cs b/SieveUnitTests/General.cs index 5379825..80b28ce 100644 --- a/SieveUnitTests/General.cs +++ b/SieveUnitTests/General.cs @@ -1,32 +1,42 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Sieve.Exceptions; using Sieve.Models; using Sieve.Services; -using SieveUnitTests.Abstractions.Entity; using SieveUnitTests.Entities; using SieveUnitTests.Services; +using Xunit; +using Xunit.Abstractions; namespace SieveUnitTests { - [TestClass] public class General { + private readonly ITestOutputHelper _testOutputHelper; private readonly SieveProcessor _processor; + private readonly SieveProcessor _nullableProcessor; private readonly IQueryable _posts; private readonly IQueryable _comments; - public General() + 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() { + new Post + { Id = 0, Title = "A", LikeCount = 100, @@ -35,7 +45,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 0, Text = "A1" }, FeaturedComment = new Comment { Id = 4, Text = "A2" } }, - new Post() { + new Post + { Id = 1, Title = "B", LikeCount = 50, @@ -44,7 +55,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 3, Text = "B1" }, FeaturedComment = new Comment { Id = 5, Text = "B2" } }, - new Post() { + new Post + { Id = 2, Title = "C", LikeCount = 0, @@ -52,7 +64,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 2, Text = "C1" }, FeaturedComment = new Comment { Id = 6, Text = "C2" } }, - new Post() { + new Post + { Id = 3, Title = "D", LikeCount = 3, @@ -60,286 +73,323 @@ namespace SieveUnitTests CategoryId = 2, TopComment = new Comment { Id = 1, Text = "D1" }, FeaturedComment = new Comment { Id = 7, Text = "D2" } - }, + } }.AsQueryable(); _comments = new List { - new Comment() { + new Comment + { Id = 0, DateCreated = DateTimeOffset.UtcNow.AddDays(-20), Text = "This is an old comment." }, - new Comment() { + new Comment + { Id = 1, DateCreated = DateTimeOffset.UtcNow.AddDays(-1), Text = "This is a fairly new comment." }, - new Comment() { + new Comment + { Id = 2, DateCreated = DateTimeOffset.UtcNow, - Text = "This is a brand new comment. (Text in braces)" + Text = "This is a brand new comment. (Text in braces, comma separated)" }, }.AsQueryable(); } - [TestMethod] + [Fact] public void ContainsCanBeCaseInsensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title@=*a" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 0); - Assert.IsTrue(result.Count() == 1); + Assert.Equal(0, result.First().Id); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void NotEqualsCanBeCaseInsensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title!=*a" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 1); - Assert.IsTrue(result.Count() == 3); + Assert.Equal(1, result.First().Id); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void ContainsIsCaseSensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title@=a", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 0); + Assert.True(!result.Any()); } - [TestMethod] + [Fact] public void NotContainsWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title!@=D", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 3); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void CanFilterBools() { - var model = new SieveModel() + var model = new SieveModel { Filters = "IsDraft==false" }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 2); + Assert.True(result.Count() == 2); } - [TestMethod] + [Fact] public void CanSortBools() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "-IsDraft" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 0); + Assert.Equal(0, result.First().Id); } - [TestMethod] + [Fact] public void CanFilterNullableInts() { - var model = new SieveModel() + var model = new SieveModel { Filters = "CategoryId==1" }; var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 2); + Assert.True(result.Count() == 2); + Assert.True(nullableResult.Count() == 2); } - [TestMethod] - public void EqualsDoesntFailWithNonStringTypes() + [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@=*\, ")] + [InlineData(@"Text@=*braces\,")] + [InlineData(@"Text@=*braces\, comma")] + public void CanFilterWithEscapedComma(string filter) + { + var model = new SieveModel + { + Filters = filter + }; + + var result = _processor.Apply(model, _comments); + + Assert.True(result.Count() == 1); + } + + [Fact] + public void EqualsDoesntFailWithNonStringTypes() + { + var model = new SieveModel { Filters = "LikeCount==50", }; - Console.WriteLine(model.GetFiltersParsed()[0].Values); - Console.WriteLine(model.GetFiltersParsed()[0].Operator); - Console.WriteLine(model.GetFiltersParsed()[0].OperatorParsed); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].Values.ToString()); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].Operator); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].OperatorParsed.ToString()); var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 1); - Assert.IsTrue(result.Count() == 1); + Assert.Equal(1, result.First().Id); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Isnew", }; var result = _processor.Apply(model, _posts); - Assert.IsFalse(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 3); + Assert.False(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void CustomGenericFiltersWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Latest", }; var result = _processor.Apply(model, _comments); - Assert.IsFalse(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 2); + Assert.False(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 2); } - [TestMethod] + [Fact] public void CustomFiltersWithOperatorsWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "HasInTitle==A", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersMixedWithUsualWork1() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Isnew,CategoryId==2", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 3)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 3)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersMixedWithUsualWork2() { - var model = new SieveModel() + var model = new SieveModel { Filters = "CategoryId==2,Isnew", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 3)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 3)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersOnDifferentSourcesCanShareName() { - var postModel = new SieveModel() + var postModel = new SieveModel { Filters = "CategoryId==2,Isnew", }; var postResult = _processor.Apply(postModel, _posts); - Assert.IsTrue(postResult.Any(p => p.Id == 3)); - Assert.AreEqual(1, postResult.Count()); + Assert.True(postResult.Any(p => p.Id == 3)); + Assert.Equal(1, postResult.Count()); - var commentModel = new SieveModel() + var commentModel = new SieveModel { Filters = "Isnew", }; var commentResult = _processor.Apply(commentModel, _comments); - Assert.IsTrue(commentResult.Any(c => c.Id == 2)); - Assert.AreEqual(2, commentResult.Count()); + Assert.True(commentResult.Any(c => c.Id == 2)); + Assert.Equal(2, commentResult.Count()); } - [TestMethod] + [Fact] public void CustomSortsWork() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "Popularity", }; var result = _processor.Apply(model, _posts); - Assert.IsFalse(result.First().Id == 0); + Assert.False(result.First().Id == 0); } - [TestMethod] + [Fact] public void CustomGenericSortsWork() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "Oldest", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Last().Id == 0); + Assert.True(result.Last().Id == 0); } - [TestMethod] + [Fact] public void MethodNotFoundExceptionWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "does not exist", }; - Assert.ThrowsException(() => _processor.Apply(model, _posts)); + Assert.Throws(() => _processor.Apply(model, _posts)); } - [TestMethod] + [Fact] public void IncompatibleMethodExceptionsWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "TestComment", }; - Assert.ThrowsException(() => _processor.Apply(model, _posts)); + Assert.Throws(() => _processor.Apply(model, _posts)); } - [TestMethod] + [Fact] public void OrNameFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "(Title|LikeCount)==3", }; @@ -348,17 +398,17 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); - Assert.AreEqual(3, entry.Id); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + Assert.Equal(3, entry.Id); } - [DataTestMethod] - [DataRow("CategoryId==1,(CategoryId|LikeCount)==50")] - [DataRow("(CategoryId|LikeCount)==50,CategoryId==1")] + [Theory] + [InlineData("CategoryId==1,(CategoryId|LikeCount)==50")] + [InlineData("(CategoryId|LikeCount)==50,CategoryId==1")] public void CombinedAndOrFilterIndependentOfOrder(string filter) { - var model = new SieveModel() + var model = new SieveModel { Filters = filter, }; @@ -367,14 +417,14 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); } - [TestMethod] + [Fact] public void CombinedAndOrWithSpaceFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title==D, (Title|LikeCount)==3", }; @@ -383,97 +433,98 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); - Assert.AreEqual(3, entry.Id); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + Assert.Equal(3, entry.Id); } - [TestMethod] + [Fact] public void OrValueFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title==C|D", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(2, result.Count()); - Assert.IsTrue(result.Any(p => p.Id == 2)); - Assert.IsTrue(result.Any(p => p.Id == 3)); + Assert.Equal(2, result.Count()); + Assert.True(result.Any(p => p.Id == 2)); + Assert.True(result.Any(p => p.Id == 3)); } - [TestMethod] + [Fact] public void OrValueFilteringWorks2() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Text@=(|)", }; var result = _processor.Apply(model, _comments); - Assert.AreEqual(1, result.Count()); - Assert.AreEqual(2, result.FirstOrDefault().Id); + Assert.Equal(1, result.Count()); + Assert.Equal(2, result.FirstOrDefault()?.Id); } - [TestMethod] + [Fact] public void NestedFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "TopComment.Text!@=A", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(3, result.Count()); + Assert.Equal(3, result.Count()); var posts = result.ToList(); - Assert.IsTrue(posts[0].TopComment.Text.Contains("B")); - Assert.IsTrue(posts[1].TopComment.Text.Contains("C")); - Assert.IsTrue(posts[2].TopComment.Text.Contains("D")); + Assert.Contains("B", posts[0].TopComment.Text); + Assert.Contains("C", posts[1].TopComment.Text); + Assert.Contains("D", posts[2].TopComment.Text); } - [TestMethod] + [Fact] public void NestedSortingWorks() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "TopComment.Id", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); var posts = result.ToList(); - Assert.AreEqual(posts[0].Id, 0); - Assert.AreEqual(posts[1].Id, 3); - Assert.AreEqual(posts[2].Id, 2); - Assert.AreEqual(posts[3].Id, 1); + Assert.Equal(0, posts[0].Id); + Assert.Equal(3, posts[1].Id); + Assert.Equal(2, posts[2].Id); + Assert.Equal(1, posts[3].Id); } - [TestMethod] + [Fact] public void NestedFilteringWithIdenticTypesWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "(topc|featc)@=*2", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); - model = new SieveModel() + model = new SieveModel { Filters = "(topc|featc)@=*B", }; result = _processor.Apply(model, _posts); - Assert.AreEqual(1, result.Count()); + Assert.Equal(1, result.Count()); } - [TestMethod] + [Fact] public void FilteringNullsWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -484,21 +535,22 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Filters = "FeaturedComment.Text!@=Some value", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(0, result.Count()); + Assert.Equal(0, result.Count()); } - [TestMethod] + [Fact] public void SortingNullsWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -507,7 +559,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 1 }, FeaturedComment = null }, - new Post() { + new Post + { Id = 2, Title = null, LikeCount = 0, @@ -518,24 +571,25 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Sorts = "TopComment.Id", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(2, result.Count()); + Assert.Equal(2, result.Count()); var sortedPosts = result.ToList(); - Assert.AreEqual(sortedPosts[0].Id, 2); - Assert.AreEqual(sortedPosts[1].Id, 1); + Assert.Equal(2, sortedPosts[0].Id); + Assert.Equal(1, sortedPosts[1].Id); } - [TestMethod] + [Fact] public void FilteringOnNullWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -544,7 +598,8 @@ namespace SieveUnitTests TopComment = null, FeaturedComment = null }, - new Post() { + new Post + { Id = 2, Title = null, LikeCount = 0, @@ -555,33 +610,190 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Filters = "FeaturedComment.Text==null", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(1, result.Count()); + Assert.Equal(1, result.Count()); var filteredPosts = result.ToList(); - Assert.AreEqual(filteredPosts[0].Id, 2); + Assert.Equal(2, filteredPosts[0].Id); } - [TestMethod] + [Fact] public void BaseDefinedPropertyMappingSortingWorks_WithCustomName() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "-CreateDate" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); var posts = result.ToList(); - Assert.AreEqual(posts[0].Id, 3); - Assert.AreEqual(posts[1].Id, 2); - Assert.AreEqual(posts[2].Id, 1); - Assert.AreEqual(posts[3].Id, 0); + Assert.Equal(3,posts[0].Id); + Assert.Equal(2,posts[1].Id); + 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" + }, + new Comment + { + Id = 2, + DateCreated = DateTimeOffset.UtcNow.AddDays(-1), + Text = @"Here is \| another comment(1)" + } + }.AsQueryable(); + + var model = new SieveModel + { + Filters = @"Text==Here is \| a comment|Here is \| another comment|Here is \\\| another comment(1)", + }; + + var result = _processor.Apply(model, comments); + Assert.Equal(3, result.Count()); + } + + [Theory] + [InlineData("CategoryId==1,(CategoryId|LikeCount)==50")] + [InlineData("(CategoryId|LikeCount)==50,CategoryId==1")] + public void CanFilterWithEscape(string filter) + { + var model = new SieveModel + { + Filters = filter + }; + + var result = _processor.Apply(model, _posts); + var entry = result.FirstOrDefault(); + var resultCount = result.Count(); + + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + } + + [Theory] + [InlineData(@"Title@=\\")] + public void CanFilterWithEscapedBackSlash(string filter) + { + var posts = new List + { + new Post + { + Id = 1, + Title = "E\\", + LikeCount = 4, + IsDraft = true, + CategoryId = 1, + TopComment = new Comment { Id = 1, Text = "E1" }, + FeaturedComment = new Comment { Id = 7, Text = "E2" } + } + }.AsQueryable(); + + var model = new SieveModel + { + Filters = filter + }; + + var result = _processor.Apply(model, posts); + var entry = result.FirstOrDefault(); + var resultCount = result.Count(); + + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + } + + [Theory] + [InlineData(@"Title@=\== ")] + [InlineData(@"Title@=\!= ")] + [InlineData(@"Title@=\> ")] + [InlineData(@"Title@=\< ")] + [InlineData(@"Title@=\<= ")] + [InlineData(@"Title@=\>= ")] + [InlineData(@"Title@=\@= ")] + [InlineData(@"Title@=\_= ")] + [InlineData(@"Title@=!\@= ")] + [InlineData(@"Title@=!\_= ")] + [InlineData(@"Title@=\@=* ")] + [InlineData(@"Title@=\_=* ")] + [InlineData(@"Title@=\==* ")] + [InlineData(@"Title@=\!=* ")] + [InlineData(@"Title@=!\@=* ")] + public void CanFilterWithEscapedOperators(string filter) + { + var posts = new List + { + new Post + { + Id = 1, + Title = @"Operators: == != > < >= <= @= _= !@= !_= @=* _=* ==* !=* !@=* !_=* ", + LikeCount = 1, + IsDraft = true, + CategoryId = 1, + TopComment = new Comment { Id = 1, Text = "F1" }, + FeaturedComment = new Comment { Id = 7, Text = "F2" } + } + }.AsQueryable(); + + var model = new SieveModel + { + Filters = filter, + }; + + var result = _processor.Apply(model, posts); + var entry = result.FirstOrDefault(); + var resultCount = result.Count(); + + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + } + } } diff --git a/SieveUnitTests/GeneralWithInterfaces.cs b/SieveUnitTests/GeneralWithInterfaces.cs index 45f6c9f..f23db16 100644 --- a/SieveUnitTests/GeneralWithInterfaces.cs +++ b/SieveUnitTests/GeneralWithInterfaces.cs @@ -1,33 +1,43 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Sieve.Exceptions; using Sieve.Models; using Sieve.Services; using SieveUnitTests.Abstractions.Entity; using SieveUnitTests.Entities; using SieveUnitTests.Services; +using Xunit; +using Xunit.Abstractions; namespace SieveUnitTests { - [TestClass] public class GeneralWithInterfaces { + private readonly ITestOutputHelper _testOutputHelper; private readonly SieveProcessor _processor; + private readonly SieveProcessor _nullableProcessor; private readonly IQueryable _posts; private readonly IQueryable _comments; - public GeneralWithInterfaces() + 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() { + new Post + { Id = 0, Title = "A", LikeCount = 100, @@ -36,7 +46,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 0, Text = "A1" }, FeaturedComment = new Comment { Id = 4, Text = "A2" } }, - new Post() { + new Post + { Id = 1, Title = "B", LikeCount = 50, @@ -45,7 +56,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 3, Text = "B1" }, FeaturedComment = new Comment { Id = 5, Text = "B2" } }, - new Post() { + new Post + { Id = 2, Title = "C", LikeCount = 0, @@ -53,7 +65,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 2, Text = "C1" }, FeaturedComment = new Comment { Id = 6, Text = "C2" } }, - new Post() { + new Post + { Id = 3, Title = "D", LikeCount = 3, @@ -66,17 +79,20 @@ namespace SieveUnitTests _comments = new List { - new Comment() { + new Comment + { Id = 0, DateCreated = DateTimeOffset.UtcNow.AddDays(-20), Text = "This is an old comment." }, - new Comment() { + new Comment + { Id = 1, DateCreated = DateTimeOffset.UtcNow.AddDays(-1), Text = "This is a fairly new comment." }, - new Comment() { + new Comment + { Id = 2, DateCreated = DateTimeOffset.UtcNow, Text = "This is a brand new comment. (Text in braces)" @@ -84,263 +100,280 @@ namespace SieveUnitTests }.AsQueryable(); } - [TestMethod] + [Fact] public void ContainsCanBeCaseInsensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title@=*a" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 0); - Assert.IsTrue(result.Count() == 1); + Assert.Equal(0, result.First().Id); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void NotEqualsCanBeCaseInsensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title!=*a" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 1); - Assert.IsTrue(result.Count() == 3); + Assert.Equal(1, result.First().Id); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void ContainsIsCaseSensitive() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title@=a", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 0); + Assert.True(!result.Any()); } - [TestMethod] + [Fact] public void NotContainsWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title!@=D", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 3); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void CanFilterBools() { - var model = new SieveModel() + var model = new SieveModel { Filters = "IsDraft==false" }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 2); + Assert.True(result.Count() == 2); } - [TestMethod] + [Fact] public void CanSortBools() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "-IsDraft" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 0); + Assert.Equal(0, result.First().Id); } - [TestMethod] + [Fact] public void CanFilterNullableInts() { - var model = new SieveModel() + var model = new SieveModel { Filters = "CategoryId==1" }; var result = _processor.Apply(model, _posts); + var nullableResult = _nullableProcessor.Apply(model, _posts); - Assert.IsTrue(result.Count() == 2); + Assert.True(result.Count() == 2); + Assert.True(nullableResult.Count() == 2); } - [TestMethod] - public void EqualsDoesntFailWithNonStringTypes() + [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] + public void EqualsDoesntFailWithNonStringTypes() + { + var model = new SieveModel { Filters = "LikeCount==50", }; - Console.WriteLine(model.GetFiltersParsed()[0].Values); - Console.WriteLine(model.GetFiltersParsed()[0].Operator); - Console.WriteLine(model.GetFiltersParsed()[0].OperatorParsed); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].Values.ToString()); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].Operator); + _testOutputHelper.WriteLine(model.GetFiltersParsed()[0].OperatorParsed.ToString()); var result = _processor.Apply(model, _posts); - Assert.AreEqual(result.First().Id, 1); - Assert.IsTrue(result.Count() == 1); + Assert.Equal(1, result.First().Id); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Isnew", }; var result = _processor.Apply(model, _posts); - Assert.IsFalse(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 3); + Assert.False(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 3); } - [TestMethod] + [Fact] public void CustomGenericFiltersWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Latest", }; var result = _processor.Apply(model, _comments); - Assert.IsFalse(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 2); + Assert.False(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 2); } - [TestMethod] + [Fact] public void CustomFiltersWithOperatorsWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "HasInTitle==A", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 0)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 0)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersMixedWithUsualWork1() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Isnew,CategoryId==2", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 3)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 3)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersMixedWithUsualWork2() { - var model = new SieveModel() + var model = new SieveModel { Filters = "CategoryId==2,Isnew", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Any(p => p.Id == 3)); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Any(p => p.Id == 3)); + Assert.True(result.Count() == 1); } - [TestMethod] + [Fact] public void CustomFiltersOnDifferentSourcesCanShareName() { - var postModel = new SieveModel() + var postModel = new SieveModel { Filters = "CategoryId==2,Isnew", }; var postResult = _processor.Apply(postModel, _posts); - Assert.IsTrue(postResult.Any(p => p.Id == 3)); - Assert.AreEqual(1, postResult.Count()); + Assert.True(postResult.Any(p => p.Id == 3)); + Assert.Equal(1, postResult.Count()); - var commentModel = new SieveModel() + var commentModel = new SieveModel { Filters = "Isnew", }; var commentResult = _processor.Apply(commentModel, _comments); - Assert.IsTrue(commentResult.Any(c => c.Id == 2)); - Assert.AreEqual(2, commentResult.Count()); + Assert.True(commentResult.Any(c => c.Id == 2)); + Assert.Equal(2, commentResult.Count()); } - [TestMethod] + [Fact] public void CustomSortsWork() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "Popularity", }; var result = _processor.Apply(model, _posts); - Assert.IsFalse(result.First().Id == 0); + Assert.False(result.First().Id == 0); } - [TestMethod] + [Fact] public void CustomGenericSortsWork() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "Oldest", }; var result = _processor.Apply(model, _posts); - Assert.IsTrue(result.Last().Id == 0); + Assert.True(result.Last().Id == 0); } - [TestMethod] + [Fact] public void MethodNotFoundExceptionWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "does not exist", }; - Assert.ThrowsException(() => _processor.Apply(model, _posts)); + Assert.Throws(() => _processor.Apply(model, _posts)); } - [TestMethod] + [Fact] public void IncompatibleMethodExceptionsWork() { - var model = new SieveModel() + var model = new SieveModel { Filters = "TestComment", }; - Assert.ThrowsException(() => _processor.Apply(model, _posts)); + Assert.Throws(() => _processor.Apply(model, _posts)); } - [TestMethod] + [Fact] public void OrNameFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "(Title|LikeCount)==3", }; @@ -349,17 +382,17 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); - Assert.AreEqual(3, entry.Id); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + Assert.Equal(3, entry.Id); } - [DataTestMethod] - [DataRow("CategoryId==1,(CategoryId|LikeCount)==50")] - [DataRow("(CategoryId|LikeCount)==50,CategoryId==1")] + [Theory] + [InlineData("CategoryId==1,(CategoryId|LikeCount)==50")] + [InlineData("(CategoryId|LikeCount)==50,CategoryId==1")] public void CombinedAndOrFilterIndependentOfOrder(string filter) { - var model = new SieveModel() + var model = new SieveModel { Filters = filter, }; @@ -368,14 +401,14 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); } - [TestMethod] + [Fact] public void CombinedAndOrWithSpaceFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title==D, (Title|LikeCount)==3", }; @@ -384,97 +417,98 @@ namespace SieveUnitTests var entry = result.FirstOrDefault(); var resultCount = result.Count(); - Assert.IsNotNull(entry); - Assert.AreEqual(1, resultCount); - Assert.AreEqual(3, entry.Id); + Assert.NotNull(entry); + Assert.Equal(1, resultCount); + Assert.Equal(3, entry.Id); } - [TestMethod] + [Fact] public void OrValueFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Title==C|D", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(2, result.Count()); - Assert.IsTrue(result.Any(p => p.Id == 2)); - Assert.IsTrue(result.Any(p => p.Id == 3)); + Assert.Equal(2, result.Count()); + Assert.True(result.Any(p => p.Id == 2)); + Assert.True(result.Any(p => p.Id == 3)); } - [TestMethod] + [Fact] public void OrValueFilteringWorks2() { - var model = new SieveModel() + var model = new SieveModel { Filters = "Text@=(|)", }; var result = _processor.Apply(model, _comments); - Assert.AreEqual(1, result.Count()); - Assert.AreEqual(2, result.FirstOrDefault().Id); + Assert.Equal(1, result.Count()); + Assert.Equal(2, result.FirstOrDefault()?.Id); } - [TestMethod] + [Fact] public void NestedFilteringWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "TopComment.Text!@=A", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(3, result.Count()); + Assert.Equal(3, result.Count()); var posts = result.ToList(); - Assert.IsTrue(posts[0].TopComment.Text.Contains("B")); - Assert.IsTrue(posts[1].TopComment.Text.Contains("C")); - Assert.IsTrue(posts[2].TopComment.Text.Contains("D")); + Assert.Contains("B", posts[0].TopComment.Text); + Assert.Contains("C", posts[1].TopComment.Text); + Assert.Contains("D", posts[2].TopComment.Text); } - [TestMethod] + [Fact] public void NestedSortingWorks() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "TopComment.Id", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); var posts = result.ToList(); - Assert.AreEqual(posts[0].Id, 0); - Assert.AreEqual(posts[1].Id, 3); - Assert.AreEqual(posts[2].Id, 2); - Assert.AreEqual(posts[3].Id, 1); + Assert.Equal(0, posts[0].Id); + Assert.Equal(3, posts[1].Id); + Assert.Equal(2, posts[2].Id); + Assert.Equal(1, posts[3].Id); } - [TestMethod] + [Fact] public void NestedFilteringWithIdenticTypesWorks() { - var model = new SieveModel() + var model = new SieveModel { Filters = "(topc|featc)@=*2", }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); - model = new SieveModel() + model = new SieveModel { Filters = "(topc|featc)@=*B", }; result = _processor.Apply(model, _posts); - Assert.AreEqual(1, result.Count()); + Assert.Equal(1, result.Count()); } - [TestMethod] + [Fact] public void FilteringNullsWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -485,21 +519,22 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Filters = "FeaturedComment.Text!@=Some value", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(0, result.Count()); + Assert.Equal(0, result.Count()); } - [TestMethod] + [Fact] public void SortingNullsWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -508,7 +543,8 @@ namespace SieveUnitTests TopComment = new Comment { Id = 1 }, FeaturedComment = null }, - new Post() { + new Post + { Id = 2, Title = null, LikeCount = 0, @@ -519,24 +555,25 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Sorts = "TopComment.Id", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(2, result.Count()); + Assert.Equal(2, result.Count()); var sortedPosts = result.ToList(); - Assert.AreEqual(sortedPosts[0].Id, 2); - Assert.AreEqual(sortedPosts[1].Id, 1); + Assert.Equal(2, sortedPosts[0].Id); + Assert.Equal(1, sortedPosts[1].Id); } - [TestMethod] + [Fact] public void FilteringOnNullWorks() { var posts = new List { - new Post() { + new Post + { Id = 1, Title = null, LikeCount = 0, @@ -545,7 +582,8 @@ namespace SieveUnitTests TopComment = null, FeaturedComment = null }, - new Post() { + new Post + { Id = 2, Title = null, LikeCount = 0, @@ -556,33 +594,33 @@ namespace SieveUnitTests }, }.AsQueryable(); - var model = new SieveModel() + var model = new SieveModel { Filters = "FeaturedComment.Text==null", }; var result = _processor.Apply(model, posts); - Assert.AreEqual(1, result.Count()); + Assert.Equal(1, result.Count()); var filteredPosts = result.ToList(); - Assert.AreEqual(filteredPosts[0].Id, 2); + Assert.Equal(2, filteredPosts[0].Id); } - [TestMethod] + [Fact] public void BaseDefinedPropertyMappingSortingWorks_WithCustomName() { - var model = new SieveModel() + var model = new SieveModel { Sorts = "-CreateDate" }; var result = _processor.Apply(model, _posts); - Assert.AreEqual(4, result.Count()); + Assert.Equal(4, result.Count()); var posts = result.ToList(); - Assert.AreEqual(posts[0].Id, 3); - Assert.AreEqual(posts[1].Id, 2); - Assert.AreEqual(posts[2].Id, 1); - Assert.AreEqual(posts[3].Id, 0); + Assert.Equal(3,posts[0].Id); + Assert.Equal(2,posts[1].Id); + Assert.Equal(1,posts[2].Id); + Assert.Equal(0,posts[3].Id); } } } diff --git a/SieveUnitTests/Mapper.cs b/SieveUnitTests/Mapper.cs index 7da20a4..32861cc 100644 --- a/SieveUnitTests/Mapper.cs +++ b/SieveUnitTests/Mapper.cs @@ -1,40 +1,38 @@ using System.Collections.Generic; using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Sieve.Exceptions; using Sieve.Models; +using Sieve.Services; using SieveUnitTests.Entities; using SieveUnitTests.Services; +using Xunit; namespace SieveUnitTests { - [TestClass] public class Mapper { - private readonly ApplicationSieveProcessor _processor; private readonly IQueryable _posts; public Mapper() { - _processor = new ApplicationSieveProcessor(new SieveOptionsAccessor(), - new SieveCustomSortMethods(), - new SieveCustomFilterMethods()); - _posts = new List { - new Post() { + new Post + { Id = 1, ThisHasNoAttributeButIsAccessible = "A", ThisHasNoAttribute = "A", OnlySortableViaFluentApi = 100 }, - new Post() { + new Post + { Id = 2, ThisHasNoAttributeButIsAccessible = "B", ThisHasNoAttribute = "B", OnlySortableViaFluentApi = 50 }, - new Post() { + new Post + { Id = 3, ThisHasNoAttributeButIsAccessible = "C", ThisHasNoAttribute = "C", @@ -43,43 +41,63 @@ namespace SieveUnitTests }.AsQueryable(); } - [TestMethod] - public void MapperWorks() + /// + /// Processors with the same mappings but configured via a different method. + /// + /// + public static IEnumerable GetProcessors() { - var model = new SieveModel() + yield return new object[] { + new ApplicationSieveProcessor( + new SieveOptionsAccessor(), + new SieveCustomSortMethods(), + new SieveCustomFilterMethods())}; + yield return new object[] { + new ModularConfigurationSieveProcessor( + new SieveOptionsAccessor(), + new SieveCustomSortMethods(), + new SieveCustomFilterMethods())}; + yield return new object[] { + new ModularConfigurationWithScanSieveProcessor( + new SieveOptionsAccessor(), + new SieveCustomSortMethods(), + new SieveCustomFilterMethods())}; + } + + + [Theory] + [MemberData(nameof(GetProcessors))] + public void MapperWorks(ISieveProcessor processor) + { + var model = new SieveModel { Filters = "shortname@=A", }; - var result = _processor.Apply(model, _posts); + var result = processor.Apply(model, _posts); - Assert.AreEqual(result.First().ThisHasNoAttributeButIsAccessible, "A"); + Assert.Equal("A", result.First().ThisHasNoAttributeButIsAccessible); - Assert.IsTrue(result.Count() == 1); + Assert.True(result.Count() == 1); } - [TestMethod] - public void MapperSortOnlyWorks() + [Theory] + [MemberData(nameof(GetProcessors))] + public void MapperSortOnlyWorks(ISieveProcessor processor) { - var model = new SieveModel() + var model = new SieveModel { Filters = "OnlySortableViaFluentApi@=50", Sorts = "OnlySortableViaFluentApi" }; - var result = _processor.Apply(model, _posts, applyFiltering: false, applyPagination: false); + var result = processor.Apply(model, _posts, applyFiltering: false, applyPagination: false); - Assert.ThrowsException(() => _processor.Apply(model, _posts)); + Assert.Throws(() => processor.Apply(model, _posts)); - Assert.AreEqual(result.First().Id, 3); + Assert.Equal(3, result.First().Id); - Assert.IsTrue(result.Count() == 3); + Assert.True(result.Count() == 3); } } } - -// -//Sorts = "LikeCount", -//Page = 1, -//PageSize = 10 -// \ No newline at end of file diff --git a/SieveUnitTests/Services/ModularConfigurationSieveProcessor.cs b/SieveUnitTests/Services/ModularConfigurationSieveProcessor.cs new file mode 100644 index 0000000..df78f66 --- /dev/null +++ b/SieveUnitTests/Services/ModularConfigurationSieveProcessor.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; +using SieveUnitTests.Abstractions.Entity; +using SieveUnitTests.Entities; + +namespace SieveUnitTests.Services +{ + public class ModularConfigurationSieveProcessor : SieveProcessor + { + public ModularConfigurationSieveProcessor( + IOptions options, + ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) + : base(options, customSortMethods, customFilterMethods) + { + } + + protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) + { + return mapper + .ApplyConfiguration() + .ApplyConfiguration(); + } + } +} diff --git a/SieveUnitTests/Services/ModularConfigurationWithScanSieveProcessor.cs b/SieveUnitTests/Services/ModularConfigurationWithScanSieveProcessor.cs new file mode 100644 index 0000000..07b3030 --- /dev/null +++ b/SieveUnitTests/Services/ModularConfigurationWithScanSieveProcessor.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; +using Sieve.Models; +using Sieve.Services; + +namespace SieveUnitTests.Services +{ + public class ModularConfigurationWithScanSieveProcessor : SieveProcessor + { + public ModularConfigurationWithScanSieveProcessor( + IOptions options, + ISieveCustomSortMethods customSortMethods, + ISieveCustomFilterMethods customFilterMethods) + : base(options, customSortMethods, customFilterMethods) + { + } + + protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper) => + mapper.ApplyConfigurationsFromAssembly(typeof(ModularConfigurationWithScanSieveProcessor).Assembly); + } +} diff --git a/SieveUnitTests/SieveUnitTests.csproj b/SieveUnitTests/SieveUnitTests.csproj index 772896a..1c26e54 100644 --- a/SieveUnitTests/SieveUnitTests.csproj +++ b/SieveUnitTests/SieveUnitTests.csproj @@ -1,15 +1,22 @@ - netcoreapp2.0 - + netcoreapp3.1 false - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/SieveUnitTests/StringFilterNullTests.cs b/SieveUnitTests/StringFilterNullTests.cs new file mode 100644 index 0000000..577d64a --- /dev/null +++ b/SieveUnitTests/StringFilterNullTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Sieve.Models; +using Sieve.Services; +using SieveUnitTests.Entities; +using SieveUnitTests.Services; +using Xunit; + +namespace SieveUnitTests +{ + public class StringFilterNullTests + { + private readonly IQueryable _comments; + private readonly SieveProcessor _processor; + + public StringFilterNullTests() + { + _processor = new SieveProcessor(new SieveOptionsAccessor()); + + _comments = new List + { + new Comment + { + Id = 0, + DateCreated = DateTimeOffset.UtcNow, + Text = "This text contains null somewhere in the middle of a string", + Author = "Dog", + }, + new Comment + { + Id = 1, + DateCreated = DateTimeOffset.UtcNow, + Text = "null is here in the text", + Author = "Cat", + }, + new Comment + { + Id = 2, + DateCreated = DateTimeOffset.UtcNow, + Text = "Regular comment without n*ll", + Author = "Mouse", + }, + new Comment + { + Id = 100, + DateCreated = DateTimeOffset.UtcNow, + Text = null, + Author = "null", + } + }.AsQueryable(); + } + + [Theory] + [InlineData("Text==null")] + [InlineData("Text==*null")] + public void Filter_Equals_Null(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(100, result.Single().Id); + } + + [Theory] + [InlineData("Text!=null")] + [InlineData("Text!=*null")] + public void Filter_NotEquals_Null(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(new[] {0, 1, 2}, result.Select(p => p.Id)); + } + + [Theory] + [InlineData("Text@=null")] + [InlineData("Text@=*null")] + [InlineData("Text@=*NULL")] + [InlineData("Text@=*NulL")] + [InlineData("Text@=*null|text")] + public void Filter_Contains_NullString(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(new[] {0, 1}, result.Select(p => p.Id)); + } + + [Theory] + [InlineData("Text|Author==null", 100)] + [InlineData("Text|Author@=null", 0, 1, 100)] + [InlineData("Text|Author@=*null", 0, 1, 100)] + [InlineData("Text|Author_=null", 1, 100)] + [InlineData("Text|Author_=*null", 1, 100)] + public void MultiFilter_Contains_NullString(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(@"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")] + [InlineData("Text_=*NULL")] + [InlineData("Text_=*NulL")] + [InlineData("Text_=*null|text")] + public void Filter_StartsWith_NullString(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(new[] {1}, result.Select(p => p.Id)); + } + + [Theory] + [InlineData("Text!@=null")] + [InlineData("Text!@=*null")] + [InlineData("Text!@=*NULL")] + [InlineData("Text!@=*NulL")] + [InlineData("Text!@=*null|text")] + public void Filter_DoesNotContain_NullString(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(new[] {2}, result.Select(p => p.Id)); + } + + [Theory] + [InlineData("Text!_=null")] + [InlineData("Text!_=*null")] + [InlineData("Text!_=*NULL")] + [InlineData("Text!_=*NulL")] + public void Filter_DoesNotStartsWith_NullString(string filter) + { + var model = new SieveModel {Filters = filter}; + + var result = _processor.Apply(model, _comments); + + Assert.Equal(new[] {0, 2}, result.Select(p => p.Id)); + } + } +} diff --git a/build.cmd b/build.cmd new file mode 100755 index 0000000..b08cc59 --- /dev/null +++ b/build.cmd @@ -0,0 +1,7 @@ +:; set -eo pipefail +:; SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) +:; ${SCRIPT_DIR}/build.sh "$@" +:; exit $? + +@ECHO OFF +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..bbaa118 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +Param( + [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] + [string[]]$BuildArguments +) + +Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" + +Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } +$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent + +########################################################################### +# CONFIGURATION +########################################################################### + +$BuildProjectFile = "$PSScriptRoot\build\_build.csproj" +$TempDirectory = "$PSScriptRoot\\.nuke\temp" + +$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" +$DotNetChannel = "Current" + +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = 1 +$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 +$env:DOTNET_MULTILEVEL_LOOKUP = 0 + +########################################################################### +# EXECUTION +########################################################################### + +function ExecSafe([scriptblock] $cmd) { + & $cmd + if ($LASTEXITCODE) { exit $LASTEXITCODE } +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` + $(dotnet --version) -and $LASTEXITCODE -eq 0) { + $env:DOTNET_EXE = (Get-Command "dotnet").Path +} +else { + # Download install script + $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) + + # If global.json exists, load expected version + if (Test-Path $DotNetGlobalFile) { + $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) + if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { + $DotNetVersion = $DotNetGlobal.sdk.version + } + } + + # Install by channel or version + $DotNetDirectory = "$TempDirectory\dotnet-win" + if (!(Test-Path variable:DotNetVersion)) { + ExecSafe { & $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } + } else { + ExecSafe { & $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } + } + $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" +} + +Write-Output "Microsoft (R) .NET Core SDK version $(& $env:DOTNET_EXE --version)" + +ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } +ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e8961f9 --- /dev/null +++ b/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +bash --version 2>&1 | head -n 1 + +set -eo pipefail +SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) + +########################################################################### +# CONFIGURATION +########################################################################### + +BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj" +TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" + +DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" +DOTNET_CHANNEL="Current" + +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +export DOTNET_MULTILEVEL_LOOKUP=0 + +########################################################################### +# EXECUTION +########################################################################### + +function FirstJsonValue { + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then + export DOTNET_EXE="$(command -v dotnet)" +else + # Download install script + DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" + mkdir -p "$TEMP_DIRECTORY" + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" + + # If global.json exists, load expected version + if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then + DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")") + if [[ "$DOTNET_VERSION" == "" ]]; then + unset DOTNET_VERSION + fi + fi + + # Install by channel or version + DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" + if [[ -z ${DOTNET_VERSION+x} ]]; then + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path + else + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path + fi + export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" +fi + +echo "Microsoft (R) .NET Core SDK version $("$DOTNET_EXE" --version)" + +"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/build/.editorconfig b/build/.editorconfig new file mode 100644 index 0000000..31e43dc --- /dev/null +++ b/build/.editorconfig @@ -0,0 +1,11 @@ +[*.cs] +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_require_accessibility_modifiers = never:warning + +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..3146ffc --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,123 @@ +using System.Linq; +using GlobExpressions; +using Nuke.Common; +using Nuke.Common.CI; +using Nuke.Common.CI.GitHubActions; +using Nuke.Common.Execution; +using Nuke.Common.Git; +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, + OnPullRequestBranches = new[] {"master", "releases/*"}, + AutoGenerate = true, + InvokedTargets = new[] {nameof(Ci)}, + CacheKeyFiles = new string[0])] +[GitHubActions("ci_publish", GitHubActionsImage.UbuntuLatest, + OnPushBranches = new[] { "releases/*" }, + OnPushTags = new[] { "v*" }, + 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)")] + readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + + [GitRepository] readonly GitRepository GitRepository; + + [GitVersion(Framework = "netcoreapp3.1")] readonly GitVersion GitVersion; + + [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"; + + Target Clean => _ => _ + .Executes(() => + { + DotNetClean(); + EnsureCleanDirectory(OutputDirectory); + }); + + Target Restore => _ => _ + .DependsOn(Clean) + .Executes(() => + { + DotNetRestore(s => s + .SetProjectFile(Solution)); + }); + + Target Compile => _ => _ + .DependsOn(Restore) + .Executes(() => + { + DotNetBuild(s => s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .SetAssemblyVersion(GitVersion.AssemblySemVer) + .SetFileVersion(GitVersion.AssemblySemFileVer) + .SetInformationalVersion(GitVersion.InformationalVersion) + .EnableNoRestore()); + }); + + Target Test => _ => _ + .DependsOn(Compile) + .Executes(() => + { + DotNetTest(s => s + .SetProjectFile(Solution)); + }); + + Target Package => _ => _ + .DependsOn(Test) + .Executes(() => + + { + DotNetPack(s => s + .SetProject(SieveProject) + .SetConfiguration(Configuration) + .SetOutputDirectory(OutputDirectory) + .SetVersion(GitVersion.NuGetVersionV2) + .EnableNoRestore() + .EnableNoBuild()); + }); + + 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); + + public static int Main() => Execute(x => x.Package); +} diff --git a/build/Configuration.cs b/build/Configuration.cs new file mode 100644 index 0000000..9c08b1a --- /dev/null +++ b/build/Configuration.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Nuke.Common.Tooling; + +[TypeConverter(typeof(TypeConverter))] +public class Configuration : Enumeration +{ + public static Configuration Debug = new Configuration { Value = nameof(Debug) }; + public static Configuration Release = new Configuration { Value = nameof(Release) }; + + public static implicit operator string(Configuration configuration) + { + return configuration.Value; + } +} diff --git a/build/Directory.Build.props b/build/Directory.Build.props new file mode 100644 index 0000000..e147d63 --- /dev/null +++ b/build/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets new file mode 100644 index 0000000..2532609 --- /dev/null +++ b/build/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..c432dd2 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,17 @@ + + + + Exe + net5.0 + + CS0649;CS0169 + .. + .. + + + + + + + + diff --git a/build/_build.csproj.DotSettings b/build/_build.csproj.DotSettings new file mode 100644 index 0000000..7bc2848 --- /dev/null +++ b/build/_build.csproj.DotSettings @@ -0,0 +1,27 @@ + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + Implicit + Implicit + ExpressionBody + 0 + NEXT_LINE + True + False + 120 + IF_OWNER_IS_SINGLE_LINE + WRAP_IF_LONG + False + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + True + True + True + True + True + True + True + True