diff --git a/CHANGELOG.md b/CHANGELOG.md index 3679fb99..d44f018e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Represents the **NuGet** versions. +## v3.25.0 +- *Enhancement:* Added new `CoreEx.Data` project/package to encapsulate all generic data-related capabilities, specifically the new `QueryFilterParser` and `QueryOrderByParser` classes. These enable a limited, explicitly supported, dynamic capability to `$filter` and `$orderby` an underlying query _similar_ to _OData_. This is **not** intended to be a replacement for the full capabilities of OData, GraphQL, etc. but to offer basic dynamic flexibility where needed. + - Added `IQueryable.Where()` and `IQueryable.OrderBy` extension method that will use the aforementioned parsers configured within the new `QueryArgsConfig` and `QueryArgs` and apply leveraging `System.Linq.Dynamic.Core`. + - Updated `HttpRequestOptions` and `WebApiRequestOptions` to support `QueryArgs` (`$filter` and `$orderby` query string arguments) similar to the existing `PagingArgs`. + - Added `QueryAttribute` to enable _Swagger/Swashbuckle_ generated documentation. +- *Fixed:* Fixed missing `IServiceCollection.AddCosmosDb` including corresponding `CosmosDbHealthCheck`. +- *Fixed:* Added `JsonIgnore` to all interfaces that have a `CompositeKey` property as _not_ intended to be serialized by default. +- *Fixed:* Fixed `ReferenceDataCollectionBase` constructor which was hiding `sortOrder` and `codeComparer` parameters. + ## v3.24.1 - *Fixed*: `CosmosDb.SelectMultiSetWithResultAsync` updated to skip items that are not considered valid; ensures same outcome as if using a `CosmosDbModelQueryBase` with respect to filtering. diff --git a/Common.targets b/Common.targets index b2ed874b..e1a1de1d 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.24.1 + 3.25.0 preview Avanade Avanade diff --git a/CoreEx.sln b/CoreEx.sln index 9eecad99..a65682d6 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -87,7 +87,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.TestFunctionIso", "t EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Test2", "tests\CoreEx.Test2\CoreEx.Test2.csproj", "{910B5894-46BC-4427-95D6-2804F06458E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database.Postgres", "src\CoreEx.Database.Postgres\CoreEx.Database.Postgres.csproj", "{C042AC2A-415D-432E-83FA-B911FD9ED378}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Database.Postgres", "src\CoreEx.Database.Postgres\CoreEx.Database.Postgres.csproj", "{C042AC2A-415D-432E-83FA-B911FD9ED378}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data", "src\CoreEx.Data\CoreEx.Data.csproj", "{B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -215,6 +217,10 @@ Global {C042AC2A-415D-432E-83FA-B911FD9ED378}.Debug|Any CPU.Build.0 = Debug|Any CPU {C042AC2A-415D-432E-83FA-B911FD9ED378}.Release|Any CPU.ActiveCfg = Release|Any CPU {C042AC2A-415D-432E-83FA-B911FD9ED378}.Release|Any CPU.Build.0 = Release|Any CPU + {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -252,6 +258,7 @@ Global {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} {910B5894-46BC-4427-95D6-2804F06458E3} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} {C042AC2A-415D-432E-83FA-B911FD9ED378} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} + {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B4566D2-9B22-4E27-9654-402BDBA6C744} diff --git a/README.md b/README.md index 032ec408..31e5a0f4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Package | Status | Source & documentation `CoreEx.AutoMapper` | [![NuGet version](https://badge.fury.io/nu/CoreEx.AutoMapper.svg)](https://badge.fury.io/nu/CoreEx.AutoMapper) | [Link](./src/CoreEx.AutoMapper) `CoreEx.Azure` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Azure.svg)](https://badge.fury.io/nu/CoreEx.Azure) | [Link](./src/CoreEx.Azure) `CoreEx.Cosmos` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Cosmos.svg)](https://badge.fury.io/nu/CoreEx.Cosmos) | [Link](./src/CoreEx.Cosmos) +`CoreEx.Data` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Data.svg)](https://badge.fury.io/nu/CoreEx.Data) | [Link](./src/CoreEx.Data) `CoreEx.Database` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.svg)](https://badge.fury.io/nu/CoreEx.Database) | [Link](./src/CoreEx.Database) `CoreEx.Database.MySql` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.MySql.svg)](https://badge.fury.io/nu/CoreEx.Database.MySql) | [Link](./src/CoreEx.Database.MySql) `CoreEx.Database.Postgres` | [![NuGet version](https://badge.fury.io/nu/CoreEx.Database.Postgres.svg)](https://badge.fury.io/nu/CoreEx.Database.Postgres) | [Link](./src/CoreEx.Database.Postgres) diff --git a/nuget-publish.ps1 b/nuget-publish.ps1 index 1313a463..b10a2174 100644 --- a/nuget-publish.ps1 +++ b/nuget-publish.ps1 @@ -53,6 +53,7 @@ param( "src\CoreEx.AutoMapper", "src\CoreEx.Azure", "src\CoreEx.Solace", + "src\CoreEx.Data", "src\CoreEx.Database", "src\CoreEx.Database.SqlServer", "src\CoreEx.Database.MySql", diff --git a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs b/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs index 3a50f06b..8f68fc0a 100644 --- a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs +++ b/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs @@ -31,8 +31,9 @@ public Task GetAsync(Guid id) [HttpGet("")] [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] [Paging] + [Query] public Task GetAllAsync() - => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Paging)); + => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); /// /// Creates a new . diff --git a/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs b/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs index 98a54fa2..59d52396 100644 --- a/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs +++ b/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs @@ -37,6 +37,5 @@ public Task GenderGetAll([FromQuery] IEnumerable? codes = [HttpGet()] [ProducesResponseType(typeof(ReferenceDataMultiDictionary), (int)HttpStatusCode.OK)] - [ApiExplorerSettings(IgnoreApi = true)] public Task GetNamed() => _webApi.GetAsync(Request, p => _orchestrator.GetNamedAsync(p.RequestOptions)); } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj b/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj index d070903d..01d604ac 100644 --- a/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj +++ b/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj @@ -4,6 +4,7 @@ net6.0 enable enable + preview diff --git a/samples/My.Hr/My.Hr.Api/Startup.cs b/samples/My.Hr/My.Hr.Api/Startup.cs index d937170d..6cbeb61c 100644 --- a/samples/My.Hr/My.Hr.Api/Startup.cs +++ b/samples/My.Hr/My.Hr.Api/Startup.cs @@ -73,7 +73,8 @@ public void ConfigureServices(IServiceCollection services) var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); options.OperationFilter(); // Needed to support AcceptsBodyAttribue where body parameter not explicitly defined. - options.OperationFilter(PagingOperationFilterFields.TokenTake); // Needed to support PagingAttribue where PagingArgs parameter not explicitly defined. + options.OperationFilter(PagingOperationFilterFields.SkipTake); // Needed to support PagingAttribute where PagingArgs parameter not explicitly defined. + options.OperationFilter(QueryOperationFilterFields.FilterAndOrderby); // Needed to support QueryAttribute where QueryArgs parameter not explicitly defined. }); } diff --git a/samples/My.Hr/My.Hr.Business/Models/UsState.cs b/samples/My.Hr/My.Hr.Business/Models/UsState.cs index 736846cc..529aae27 100644 --- a/samples/My.Hr/My.Hr.Business/Models/UsState.cs +++ b/samples/My.Hr/My.Hr.Business/Models/UsState.cs @@ -7,4 +7,4 @@ public class USState : ReferenceDataBaseEx public static implicit operator USState?(string? code) => ConvertFromCode(code); } -public class USStateCollection : ReferenceDataCollectionBase { } \ No newline at end of file +public class USStateCollection() : ReferenceDataCollectionBase(ReferenceDataSortOrder.SortOrder) { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj b/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj index db579ad5..9b4d7ce3 100644 --- a/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj +++ b/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj @@ -4,6 +4,7 @@ net6.0 enable enable + preview diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs index 27deb448..d740a5cf 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs @@ -1,7 +1,25 @@ +using CoreEx.Data.Querying; + namespace My.Hr.Business.Services; public class EmployeeService : IEmployeeService { + private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("FirstName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("StartDate") + .AddField("TerminationDate") + .AddField(nameof(Employee.Gender), c => c.WithValue(v => + { + var g = Gender.ConvertFromCode(v); + return g is not null && g.IsValid ? g : throw new FormatException("Gender is invalid."); + }))) + .WithOrderBy(orderBy => orderBy + .AddField("LastName") + .AddField("FirstName") + .WithDefault("LastName, FirstName")); + private readonly HrDbContext _dbContext; private readonly IEventPublisher _publisher; private readonly HrSettings _settings; @@ -16,8 +34,8 @@ public EmployeeService(HrDbContext dbContext, IEventPublisher publisher, HrSetti public async Task GetEmployeeAsync(Guid id) => await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id); - public Task GetAllAsync(PagingArgs? paging) - => _dbContext.Employees.OrderBy(x => x.LastName).ThenBy(x => x.FirstName).ToCollectionResultAsync(paging); + public Task GetAllAsync(QueryArgs? query, PagingArgs? paging) + => _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); public async Task AddEmployeeAsync(Employee employee) { diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs index 4e0ee281..43af40c6 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs @@ -18,7 +18,7 @@ public EmployeeService2(IHrEfDb efDb, IEventPublisher publisher, HrSettings sett public Task GetEmployeeAsync(Guid id) => _efDb.Employees.GetAsync(id); - public Task GetAllAsync(PagingArgs? paging) + public Task GetAllAsync(QueryArgs? query, PagingArgs? paging) => _efDb.Employees.Query(q => q.OrderBy(x => x.LastName).ThenBy(x => x.FirstName)).WithPaging(paging).SelectResultAsync(); public Task AddEmployeeAsync(Employee employee) => _efDb.Employees.CreateAsync(employee); diff --git a/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs index 63bfd1df..7b1b07c0 100644 --- a/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs @@ -4,7 +4,7 @@ public interface IEmployeeService { Task GetEmployeeAsync(Guid id); - Task GetAllAsync(PagingArgs? paging); + Task GetAllAsync(QueryArgs? query, PagingArgs? paging); Task AddEmployeeAsync(Employee employee); diff --git a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs b/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs index 84791a88..d1fbbbfe 100644 --- a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs +++ b/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs @@ -44,7 +44,7 @@ public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "g [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(List), Description = "Employee records")] public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees")] HttpRequest request) - => _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Paging)); + => _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); [FunctionName("Create")] [OpenApiOperation(operationId: "Create", tags: new[] { "employee" })] diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs index 4812eeff..6c5f2e05 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs @@ -116,7 +116,7 @@ public void B110_GetAll_Paging() var x = TestSetUp.Extensions; var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) }) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) .AssertOK() .GetValue(); @@ -133,7 +133,7 @@ public void B120_GetAll_PagingAndIncludeFields() using var test = ApiTester.Create(); var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2) }.Include("lastname")) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) .AssertOK() .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") .GetValue(); @@ -141,6 +141,36 @@ public void B120_GetAll_PagingAndIncludeFields() Assert.That(v!.Paging!.TotalCount, Is.Null); // No count requested. } + [Test] + public void B120_GetAll_Filter_LastName() + { + using var test = ApiTester.Create(); + + var v = test.Controller() + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create().Filter("startswith(lastname, 's')")) + .AssertOK() + .GetValue(); + + Assert.That(v?.Items, Is.Not.Null); + Assert.That(v!.Items, Has.Count.EqualTo(2)); + Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Smith", "Smithers" })); + } + + [Test] + public void B130_GetAll_Filter_StartDateAndGenders_OrderBy_FirstName() + { + using var test = ApiTester.Create(); + + var v = test.Controller() + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create().Filter("startdate ge 2010-01-01 and gender in ('m','f')").OrderBy("lastname desc")) + .AssertOK() + .GetValue(); + + Assert.That(v?.Items, Is.Not.Null); + Assert.That(v!.Items, Has.Count.EqualTo(2)); + Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Smith", "Browne" })); + } + [Test] public void C100_Create_Error() { diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs index f831fffb..8f123e2a 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs @@ -104,7 +104,7 @@ public void B110_GetAll_Paging() using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) }) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) .AssertOK() .GetValue(); @@ -124,7 +124,7 @@ public void B120_GetAll_PagingAndIncludeFields() using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2) }.Include("lastname")) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) .AssertOK() .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") .GetValue(); diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs index 108b6c05..c9f4a0ae 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs @@ -112,7 +112,7 @@ public void B110_GetAll_Paging() using var test = FunctionTester.Create(); var v = test.HttpTrigger() - .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", new CoreEx.Http.HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) }))) + .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", CoreEx.Http.HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))))) .AssertOK() .GetValue(); @@ -129,7 +129,7 @@ public void B120_GetAll_PagingAndIncludeFields() using var test = FunctionTester.Create(); var v = test.HttpTrigger() - .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", new CoreEx.Http.HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, false) }.Include("lastname")))) + .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", CoreEx.Http.HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, false)).Include("lastname")))) .AssertOK() .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") .GetValue(); diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs index 04c52443..1cf3452b 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs @@ -117,7 +117,7 @@ public void B110_GetAll_Paging() using var test = ApiTester.Create(); var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) }) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) .AssertOK() .GetValue(); @@ -137,7 +137,7 @@ public void B120_GetAll_PagingAndIncludeFields() using var test = ApiTester.Create(); var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2) }.Include("lastname")) + .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) .AssertOK() .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") .GetValue(); diff --git a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs index 40dedf34..9367ef0c 100644 --- a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs +++ b/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs @@ -9,7 +9,7 @@ namespace CoreEx.AspNetCore.WebApis { /// - /// A Swagger/Swashbuckle to add the paramaters from the specification of the . + /// A Swagger/Swashbuckle to add the parameters from the specification of the . /// /// The must be added when registering services (DI) during application startup; example as follows: /// @@ -67,7 +67,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) /// /// Create the parameter definition. /// - private static OpenApiParameter CreateParameter(string name, string description, string typeName, string? format = null) => new() + internal static OpenApiParameter CreateParameter(string name, string description, string typeName, string? format = null) => new() { Name = name, Description = description, diff --git a/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs b/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs new file mode 100644 index 00000000..5eee176a --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs @@ -0,0 +1,14 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using System; + +namespace CoreEx.AspNetCore.WebApis +{ + /// + /// An attribute that specifies that the action/operation supports (not explicitly defined as a parameter). + /// + /// The is used to enable Swagger/Swashbuckle generated documentation where the operation does not explicitly define the as a method parameter. + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class QueryAttribute : Attribute { } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs new file mode 100644 index 00000000..ea6b0a5f --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs @@ -0,0 +1,55 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Http; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Linq; + +namespace CoreEx.AspNetCore.WebApis +{ + /// + /// A Swagger/Swashbuckle to add the parameters from the specification of the . + /// + /// The must be added when registering services (DI) during application startup; example as follows: + /// + /// services.AddSwaggerGen(c => c.OperationFilter<PagingOperationFilter>(PagingOperationFilterFields.SkipTakeCount)); + /// + /// + public class QueryOperationFilter : IOperationFilter + { + /// + /// Initializes a new instance of the class with a default of . + /// + public QueryOperationFilter() { } + + /// + /// Initializes a new instance of the class with the selected . + /// + /// The . + public QueryOperationFilter(QueryOperationFilterFields fields) => Fields = fields; + + /// + /// Gets the to apply. + /// + public QueryOperationFilterFields Fields { get; } = QueryOperationFilterFields.FilterAndOrderby; + + /// + /// Applies the filter. + /// + /// The . + /// The . + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var att = context.ApiDescription.CustomAttributes().OfType().FirstOrDefault(); + if (att == null) + return; + + if (Fields.HasFlag(QueryOperationFilterFields.Filter)) + operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsFilterQueryStringName, "The basic dynamic OData-like filter specification.", "string", null)); + + if (Fields.HasFlag(QueryOperationFilterFields.OrderBy)) + operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsOrderByQueryStringName, "The basic dynamic OData-like order-by specificationswagger paramters .", "string", null)); + } + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs new file mode 100644 index 00000000..f72e7091 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs @@ -0,0 +1,30 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Http; +using System; + +namespace CoreEx.AspNetCore.WebApis +{ + /// + /// Provides the fields. + /// + [Flags] + public enum QueryOperationFilterFields + { + /// + /// Indicates to include the field (named ). + /// + Filter = 1, + + /// + /// Indicates to include the field (named ). + /// + OrderBy = 2, + + /// + /// Indicates to include both the and . + /// + FilterAndOrderby = Filter | OrderBy + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs index f9901cb3..7b0f9eaa 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs @@ -60,10 +60,15 @@ public WebApiRequestOptions(HttpRequest httpRequest) public string[]? ExcludeFields { get; private set; } /// - /// Gets or sets the . + /// Gets the . /// public PagingArgs? Paging { get; private set; } + /// + /// Gets the dynamic . + /// + public QueryArgs? Query { get; private set; } + /// /// Indicates whether to include any related texts for the item(s). /// @@ -96,6 +101,7 @@ private bool GetQueryStringOptions(IQueryCollection query) IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true")); Paging = GetPagingArgs(query); + Query = GetQueryArgs(query); return true; } @@ -104,9 +110,6 @@ private bool GetQueryStringOptions(IQueryCollection query) /// private static PagingArgs? GetPagingArgs(IQueryCollection query) { - if (query == null || query.Count == 0) - return null; - long? skip = HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsSkipQueryStringNames)); long? take = HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsTakeQueryStringNames)); long? page = skip.HasValue ? null : HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsPageQueryStringNames)); @@ -140,5 +143,15 @@ private bool GetQueryStringOptions(IQueryCollection query) var val = q.Value.FirstOrDefault(); return string.IsNullOrEmpty(val) ? defaultValue : val; } + + /// + /// Gets the from an . + /// + private static QueryArgs? GetQueryArgs(IQueryCollection query) + { + var filter = GetNamedQueryString(query, HttpConsts.QueryArgsFilterQueryStringNames); + var orderBy = GetNamedQueryString(query, HttpConsts.QueryArgsOrderByQueryStringNames); + return string.IsNullOrEmpty(filter) && string.IsNullOrEmpty(orderBy) ? null : new QueryArgs { Filter = filter, OrderBy = orderBy }; + } } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj index 02d0390f..b4409a38 100644 --- a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj +++ b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj @@ -13,10 +13,10 @@ - + diff --git a/src/CoreEx.Cosmos/CosmosDb.cs b/src/CoreEx.Cosmos/CosmosDb.cs index 8961e497..6cce0d36 100644 --- a/src/CoreEx.Cosmos/CosmosDb.cs +++ b/src/CoreEx.Cosmos/CosmosDb.cs @@ -11,10 +11,8 @@ using System.Collections.Concurrent; using System.Linq; using System.Threading.Tasks; -using System.Collections; using System.Threading; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.cs b/src/CoreEx.Cosmos/CosmosDbContainer.cs index 4d08dd97..d14dcd8c 100644 --- a/src/CoreEx.Cosmos/CosmosDbContainer.cs +++ b/src/CoreEx.Cosmos/CosmosDbContainer.cs @@ -20,7 +20,7 @@ public class CosmosDbContainer(ICosmosDb cosmosDb, string containerId, CosmosDbA public ICosmosDb CosmosDb { get; } = cosmosDb.ThrowIfNull(nameof(cosmosDb)); /// - public Container Container { get; } = cosmosDb.GetCosmosContainer(containerId); + public Container Container { get; } = cosmosDb.GetCosmosContainer(containerId.ThrowIfNullOrEmpty(nameof(containerId))); /// /// Gets or sets the Container-specific . diff --git a/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs b/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs new file mode 100644 index 00000000..f2a4aa12 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx; +using CoreEx.Cosmos; +using CoreEx.Cosmos.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods. + /// + public static class CosmosDbServiceCollectionExtensions + { + /// + /// Adds an as a singleton service. + /// + /// The . + /// The . + /// The function to create the instance. + /// Indicates whether a corresponding should be configured. + /// The to support fluent-style method-chaining. + public static IServiceCollection AddCosmosDb(this IServiceCollection services, Func create, bool healthCheck = true) where TCosmosDb : class, ICosmosDb + { + services.ThrowIfNull(nameof(services)).AddSingleton(sp => create.ThrowIfNull(nameof(create)).Invoke(sp)); + if (healthCheck) + services.AddHealthChecks().AddCosmosDbHealthCheck(); + + return services; + } + + /// + /// Adds an as a singleton service including a corresponding health check. + /// + /// The . + /// The . + /// The function to create the instance. + /// The health check name; defaults to 'cosmos-db'. + /// The to support fluent-style method-chaining. + public static IServiceCollection AddCosmosDb(this IServiceCollection services, Func create, string? healthCheckName) where TCosmosDb : class, ICosmosDb + { + services.ThrowIfNull(nameof(services)).AddSingleton(sp => create.ThrowIfNull(nameof(create)).Invoke(sp)); + services.AddHealthChecks().AddCosmosDbHealthCheck(healthCheckName); + return services; + } + + /// + /// Adds an to verify that the database is accessible by performing a read operation. + /// + /// The . + /// The . + /// The health check name; defaults to 'cosmos-db'. + /// The to support fluent-style method-chaining. + public static IHealthChecksBuilder AddCosmosDbHealthCheck(this IHealthChecksBuilder builder, string? healthCheckName = null) where TCosmosDb : class, ICosmosDb + { + builder.ThrowIfNull(nameof(builder)).AddTypeActivatedCheck>(healthCheckName ?? "cosmos-db", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30)); + return builder; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs b/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs new file mode 100644 index 00000000..030b7211 --- /dev/null +++ b/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs @@ -0,0 +1,36 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace CoreEx.Cosmos.HealthChecks +{ + /// + /// Provides a generic to verify that the database is accessible by performing a read operation. + /// + /// The . + /// The to health check. + public class CosmosDbHealthCheck(TCosmosDb cosmosDb) : IHealthCheck where TCosmosDb : class, ICosmosDb + { + private readonly TCosmosDb _cosmosDb = cosmosDb = cosmosDb.ThrowIfNull(nameof(cosmosDb)); + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var data = new Dictionary { { "database-id", _cosmosDb.Database.Id } }; + + try + { + var dr = await _cosmosDb.Database.ReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy(null, data); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, $"An unexpected CosmosDB database error has occurred: {ex.Message}", ex, data); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/CoreEx.Data.csproj b/src/CoreEx.Data/CoreEx.Data.csproj new file mode 100644 index 00000000..41dbbe4b --- /dev/null +++ b/src/CoreEx.Data/CoreEx.Data.csproj @@ -0,0 +1,22 @@ + + + + net6.0;net7.0;net8.0;netstandard2.1 + CoreEx.Data + CoreEx + CoreEx .NET Data extras. + CoreEx .NET Data extras. + coreex api data odata filter order linq + + + + + + + + + + + + + diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs new file mode 100644 index 00000000..ca0c2dec --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs @@ -0,0 +1,24 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Represents a query filter expression. + /// + /// The . + /// The originating query filter. + /// The syntax . + public sealed class QueryFilterCloseParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) + { + private QueryFilterToken _syntax; + + /// + protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; + + /// + public override void WriteToResult(QueryFilterParserResult result) => result.FilterBuilder.Append(_syntax.ToLinq(Filter)); + + /// + protected override IQueryFilterFieldConfig? GetFieldConfig() => null; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs new file mode 100644 index 00000000..8aad0792 --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs @@ -0,0 +1,80 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Provides a query filter expression. + /// + public abstract class QueryFilterExpressionBase + { + /// + /// Initlializes a new instance of the . + /// + /// The . + /// The originating query filter. + /// The first to be added. + public QueryFilterExpressionBase(QueryFilterParser parser, string filter, QueryFilterToken first) + { + Parser = parser.ThrowIfNull(nameof(parser)); + Filter = filter.ThrowIfNull(nameof(filter)); + AddToken(first); + } + + /// + /// Gets the owning . + /// + public QueryFilterParser Parser { get; } + + /// + /// Gets the originating query filter. + /// + public string Filter { get; } + + /// + /// Gets the count of tokens added. + /// + public int TokenCount { get; private set; } + + /// + /// Indicates whether the expression is considered in a complete and valid state. + /// + public virtual bool IsComplete => true; + + /// + /// Indicates whether the can be added to the expression. + /// + /// The . + /// indicates that the can and should be added; otherwise, signifies that the is for the next expression. + /// Used to determine whether the next can be added; allows an expression to support multiple complete states. + public virtual bool CanAddToken(QueryFilterToken token) => !IsComplete; + + /// + /// Adds the to the expression. + /// + /// The . + public void AddToken(QueryFilterToken token) + { + AddToken(TokenCount, token); + TokenCount++; + } + + /// + /// Adds the to the expression. + /// + /// The index. + /// The . + protected abstract void AddToken(int index, QueryFilterToken token); + + /// + /// Gets the underlying used in the expression. + /// + /// The field where applicable; otherwise, . + protected abstract IQueryFilterFieldConfig? GetFieldConfig(); + + /// + /// Converts the query filter expression into the corresponding dynamic LINQ appending to the . + /// + /// The . + public abstract void WriteToResult(QueryFilterParserResult result); + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs new file mode 100644 index 00000000..3b26c90d --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs @@ -0,0 +1,57 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Represents a query filter expression. + /// + /// The . + /// The originating query filter. + /// The logical + public class QueryFilterLogicalExpression(QueryFilterParser parser, string filter, QueryFilterToken logical) : QueryFilterExpressionBase(parser, filter, logical) + { + private QueryFilterToken _logical = QueryFilterToken.Unspecified; + private QueryFilterToken _not = QueryFilterToken.Unspecified; + private bool _isComplete = true; + + /// + public override bool IsComplete => _isComplete; + + /// + public override bool CanAddToken(QueryFilterToken token) + { + if (TokenCount == 1) + return token.Kind == QueryFilterTokenKind.Not; + + _isComplete = token.Kind == QueryFilterTokenKind.OpenParenthesis; + return _isComplete + ? false + : throw new QueryFilterParserException($"A '{_not.GetRawToken(Filter).ToString()}' expects an opening '(' to start an expression versus a syntactically incorrect '{token.GetValueToken(Filter)}' token."); + } + + /// + protected override void AddToken(int index, QueryFilterToken token) + { + if (index == 0 && token.Kind != QueryFilterTokenKind.Not) + _logical = token; + else + { + _not = token; + _isComplete = false; + } + } + + /// + public override void WriteToResult(QueryFilterParserResult result) + { + if (_logical.Kind != QueryFilterTokenKind.Unspecified) + result.Append(_logical.ToLinq(Filter)); + + if (_not.Kind != QueryFilterTokenKind.Unspecified) + result.Append(_not.ToLinq(Filter)); + } + + /// + protected override IQueryFilterFieldConfig? GetFieldConfig() => null; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs new file mode 100644 index 00000000..99c0beb4 --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs @@ -0,0 +1,24 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Represents a query filter expression. + /// + /// The . + /// The originating query filter. + /// The syntax . + public sealed class QueryFilterOpenParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) + { + private QueryFilterToken _syntax; + + /// + protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; + + /// + public override void WriteToResult(QueryFilterParserResult result) => result.Append(_syntax.ToLinq(Filter)); + + /// + protected override IQueryFilterFieldConfig? GetFieldConfig() => null; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs new file mode 100644 index 00000000..058276fd --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs @@ -0,0 +1,168 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System.Collections.Generic; + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Represents a query filter expression. + /// + /// The . + /// The originating query filter. + /// The field . + public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, string filter, QueryFilterToken field) : QueryFilterExpressionBase(parser, filter, field) + { + private bool _isComplete; + + /// + /// Gets the field . + /// + public IQueryFilterFieldConfig? FieldConfig { get; private set; } + + /// + /// Gets the field . + /// + public QueryFilterToken Field { get; private set; } + + /// + /// Gets the operator . + /// + public QueryFilterToken Operator { get; private set; } + + /// + /// Gets the constant list. + /// + public List Constants { get; } = []; + + /// + public override bool IsComplete => _isComplete; + + /// + public override bool CanAddToken(QueryFilterToken token) => !_isComplete || TokenCount == 1 && QueryFilterTokenKind.Operator.HasFlag(token.Kind); + + /// + protected override void AddToken(int index, QueryFilterToken token) + { + switch (index) + { + case 0: + Field = token; + FieldConfig = Parser.GetFieldConfig(Field, Filter); + _isComplete = FieldConfig.IsTypeBoolean; + break; + + case 1: + if (!QueryFilterTokenKind.AllStringOperators.HasFlag(token.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support '{token.GetRawToken(Filter).ToString()}' as an operator."); + + if (!FieldConfig!.SupportedKinds.HasFlag(token.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{token.GetRawToken(Filter).ToString()}' operator."); + + _isComplete = false; + Operator = token; + break; + + case 2: + if (Operator.Kind == QueryFilterTokenKind.In) + { + if (token.Kind != QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' must specify an opening '(' for the '{Operator.GetRawToken(Filter).ToString()}' operator."); + + break; + } + + if (token.Kind == QueryFilterTokenKind.Null && !QueryFilterTokenKind.EqualityOperator.HasFlag(Operator.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); + + FieldConfig!.ValidateConstant(Field, token, Filter); + Constants.Add(token); + _isComplete = true; + break; + + default: + if (index % 2 != 0) + { + if (token.Kind == QueryFilterTokenKind.CloseParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must be specified before the closing ')' for the '{Operator.GetRawToken(Filter).ToString()}' operator."); + + if (token.Kind == QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' must close ')' the '{Operator.GetRawToken(Filter).ToString()}' operator before specifying a further open '('."); + + if (token.Kind == QueryFilterTokenKind.Null) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); + + FieldConfig!.ValidateConstant(Field, token, Filter); + Constants.Add(token); + } + else + { + if (token.Kind == QueryFilterTokenKind.CloseParenthesis) + { + if (Constants.Count == 0) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' expects at least one constant value for an '{Operator.GetRawToken(Filter).ToString()}' operator."); + + _isComplete = true; + break; + } + + if (token.Kind != QueryFilterTokenKind.Comma) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' expects a ',' separator between constant values for an '{Operator.GetRawToken(Filter).ToString()}' operator."); + } + + break; + } + } + + /// + public override void WriteToResult(QueryFilterParserResult result) + { + result.Fields.Add(FieldConfig!.Field); + + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig!.IsCheckForNotNull) + { + result.Append("("); + result.FilterBuilder.Append(FieldConfig.Model); + result.FilterBuilder.Append(" != null && "); + } + + result.Append(FieldConfig!.Model); + + if (Constants.Count > 0) + { + if (FieldConfig.IsTypeString && FieldConfig.IsToUpper) + result.FilterBuilder.Append(".ToUpper()"); + + result.FilterBuilder.Append(' '); + result.FilterBuilder.Append(Operator.ToLinq(Filter)); + result.FilterBuilder.Append(' '); + + if (Operator.Kind == QueryFilterTokenKind.In) + { + result.FilterBuilder.Append('('); + for (int i = 0; i < Constants.Count; i++) + { + if (i > 0) + result.FilterBuilder.Append(", "); + + result.AppendValue(Constants[i].GetConvertedValue(Operator, Field, FieldConfig, Filter)); + } + + result.FilterBuilder.Append(')'); + } + else + { + if (Constants[0].Kind == QueryFilterTokenKind.Value || Constants[0].Kind == QueryFilterTokenKind.Literal) + result.AppendValue(Constants[0].GetConvertedValue(Operator, Field, FieldConfig, Filter)); + else + result.FilterBuilder.Append(Constants[0].ToLinq(Filter)); + } + } + + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig!.IsCheckForNotNull) + result.FilterBuilder.Append(')'); + } + + /// + protected override IQueryFilterFieldConfig? GetFieldConfig() => FieldConfig; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs new file mode 100644 index 00000000..b7109d39 --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs @@ -0,0 +1,117 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Represents a query filter expression. + /// + /// The . + /// The originating query filter. + /// The function + public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser, string filter, QueryFilterToken function) : QueryFilterExpressionBase(parser, filter, function) + { + private bool _isComplete; + + /// + /// Gets the function . + /// + public QueryFilterToken Function { get; private set; } + + /// + /// Gets the . + /// + public IQueryFilterFieldConfig? FieldConfig { get; private set; } + + /// + /// Gets the field . + /// + public QueryFilterToken Field { get; private set; } + + /// + /// Gets the constant . + /// + public QueryFilterToken Constant { get; private set; } + + /// + public override bool IsComplete => _isComplete; + + /// + public override bool CanAddToken(QueryFilterToken token) => !_isComplete; + + /// + protected override void AddToken(int index, QueryFilterToken token) + { + switch (index) + { + case 0: + Function = token; + break; + + case 1: + if (token.Kind != QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects an opening '(' not a '{token.GetValueToken(Filter)}'."); + + break; + + case 2: + Field = token; + FieldConfig = Parser.GetFieldConfig(Field, Filter); + + if (!FieldConfig!.SupportedKinds.HasFlag(Function.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{Function.GetRawToken(Filter).ToString()}' function."); + + break; + + case 3: + if (token.Kind != QueryFilterTokenKind.Comma) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects a ',' separator between the field and its constant."); + + break; + + case 4: + if (token.Kind == QueryFilterTokenKind.Null) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function references a null constant which is not supported."); + + FieldConfig!.ValidateConstant(Field, token, Filter); + Constant = token; + break; + + case 5: + if (token.Kind != QueryFilterTokenKind.CloseParenthesis) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects a closing ')' not a '{token.GetValueToken(Filter)}'."); + + _isComplete = true; + break; + } + } + + /// + public override void WriteToResult(QueryFilterParserResult result) + { + result.Fields.Add(FieldConfig!.Field); + + if (FieldConfig!.IsCheckForNotNull) + { + result.Append('('); + result.FilterBuilder.Append(FieldConfig.Model); + result.FilterBuilder.Append(" != null &&"); + } + + result.Append(FieldConfig!.Model); + if (FieldConfig.IsTypeString && FieldConfig.IsToUpper) + result.FilterBuilder.Append(".ToUpper()"); + + result.FilterBuilder.Append('.'); + result.FilterBuilder.Append(Function.ToLinq(Filter)); + result.FilterBuilder.Append('('); + result.AppendValue(Constant.GetConvertedValue(Function, Field, FieldConfig, Filter)); + result.FilterBuilder.Append(')'); + + if (FieldConfig!.IsCheckForNotNull) + result.FilterBuilder.Append(')'); + } + + /// + protected override IQueryFilterFieldConfig? GetFieldConfig() => FieldConfig; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs new file mode 100644 index 00000000..fc56cdb2 --- /dev/null +++ b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs @@ -0,0 +1,84 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Mapping.Converters; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents the base field configuration. + /// + public interface IQueryFilterFieldConfig + { + /// + /// Gets the owning . + /// + QueryFilterParser Parser { get; } + + /// + /// Gets the field type. + /// + Type Type { get; } + + /// + /// Indicates whether the field type is a . + /// + bool IsTypeString { get; } + + /// + /// Indicates whether the field type is a . + /// + bool IsTypeBoolean { get; } + + /// + /// Gets the field name. + /// + string Field { get; } + + /// + /// Gets or sets model name to be used for the dynamic LINQ expression. + /// + /// Defaults to the name. + string? Model { get; } + + /// + /// Gets the supported kinds. + /// + /// Where defaults to both and only; otherwise, defaults to . + QueryFilterTokenKind SupportedKinds { get; } + + /// + /// Indicates whether the comparison should ignore case or not; will use when selected for comparisons. + /// + /// This is only applicable where the . + bool IsToUpper { get; } + + /// + /// Indicates whether a not- check should also be performed before the comparion occurs. + /// + bool IsCheckForNotNull { get; } + + /// + /// Gets the default LINQ to be used where no filtering is specified. + /// + QueryStatement? DefaultStatement { get; } + + /// + /// Converts to the destination type using the configurations where specified. + /// + /// The operation being performed on the . + /// The field . + /// The query filter. + /// The converted value. + /// + object? ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); + + /// + /// Validate the token against the field configuration. + /// + /// The field . + /// The constant . + /// The query filter. + void ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter); + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryArgsConfig.cs b/src/CoreEx.Data/Querying/QueryArgsConfig.cs new file mode 100644 index 00000000..70cac640 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryArgsConfig.cs @@ -0,0 +1,64 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the configuration. + /// + public class QueryArgsConfig + { + private QueryFilterParser? _filterParser; + private QueryOrderByParser? _orderByParser; + + /// + /// Creates a new . + /// + /// The . + public static QueryArgsConfig Create() => new(); + + /// + /// Gets the . + /// + public QueryFilterParser FilterParser => _filterParser ??= new QueryFilterParser(); + + /// + /// Indicates whether there is a . + /// + public bool HasFilterParser => _filterParser is not null; + + /// + /// Gets the . + /// + public QueryOrderByParser OrderByParser => _orderByParser ??= new QueryOrderByParser(); + + /// + /// Indicates whether there is an . + /// + public bool HasOrderByParser => _orderByParser is not null; + + /// + /// Enables fluent-style method-chaining configuration for the . + /// + /// The . + /// The instance to support fluent-style method-chaining. + public QueryArgsConfig WithFilter(Action filter) + { + filter.ThrowIfNull(nameof(filter))(FilterParser); + return this; + } + + /// + /// Enables fluent-style method-chaining configuration for the . + /// + /// The . + /// The instance to support fluent-style method-chaining. + public QueryArgsConfig WithOrderBy(Action orderBy) + { + orderBy.ThrowIfNull(nameof(orderBy))(OrderByParser); + return this; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs new file mode 100644 index 00000000..401c47c6 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs @@ -0,0 +1,84 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx; +using CoreEx.Data.Querying; +using CoreEx.Entities; +using System.Linq; +using System.Linq.Dynamic.Core; + +namespace System.Linq +{ + /// + /// Adds additional extension methods to the . + /// + public static class QueryFilterExtensions + { + /// + /// Adds a dynamic query filter as specified by the (uses the where not ). + /// + /// The being queried. + /// The query. + /// The . + /// The . + /// The query. + public static IQueryable Where(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs) => query.Where(queryConfig, queryArgs?.Filter); + + /// + /// Adds a dynamic query (basic dynamic OData-like $filter statement). + /// + /// The being queried. + /// The query. + /// The . + /// The basic dynamic OData-like $filter statement. + /// The query. + public static IQueryable Where(this IQueryable query, QueryArgsConfig queryConfig, string? filter) + { + queryConfig.ThrowIfNull(nameof(queryConfig)); + if (!queryConfig.HasFilterParser) + throw new QueryFilterParserException("Filter statement is not currently supported."); + + var result = queryConfig.FilterParser.Parse(filter); + var linq = result.ToString(); + return string.IsNullOrEmpty(linq) ? query : query.Where(linq, [.. result.Args]); + } + + /// + /// Adds a dynamic query order by as specified by the (uses the where not ). + /// + /// The being queried. + /// The query. + /// The . + /// The . + /// The query. + /// Where the is or is , then the will be used (where also not ). + public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) + { + queryConfig.ThrowIfNull(nameof(queryConfig)); + + if (!queryConfig.HasOrderByParser) + throw new QueryOrderByParserException("OrderBy statement is not currently supported."); + + return string.IsNullOrEmpty(queryArgs?.OrderBy) + ? (queryConfig.OrderByParser.DefaultOrderBy is null ? query : query.OrderBy(queryConfig.OrderByParser.DefaultOrderBy)) + : OrderBy(query, queryConfig, queryArgs.OrderBy); + } + + /// + /// Adds a dynamic query order (basic dynamic OData-like $orderby statement). + /// + /// The being queried. + /// The query. + /// The . + /// The basic dynamic OData-like $orderby statement. + /// The query. + public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, string orderby) + { + queryConfig.ThrowIfNull(nameof(queryConfig)); + if (!queryConfig.HasOrderByParser) + throw new QueryOrderByParserException("Capability is not currently supported."); + + var linq = queryConfig.OrderByParser.Parse(orderby.ThrowIfNullOrEmpty(nameof(orderby))); + return query.OrderBy(linq); + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs new file mode 100644 index 00000000..56414362 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs @@ -0,0 +1,146 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Mapping.Converters; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the base field configuration. + /// + public abstract class QueryFilterFieldConfigBase : IQueryFilterFieldConfig + { + private readonly QueryFilterParser _parser; + private readonly Type _type; + private readonly string _field; + private readonly string? _model; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The field type. + /// The field name. + /// The model name (defaults to . + public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) + { + _parser = parser.ThrowIfNull(nameof(parser)); + _type = type.ThrowIfNull(nameof(type)); + _field = field.ThrowIfNullOrEmpty(nameof(field)); + _model = model; + + IsTypeString = type == typeof(string); + IsTypeBoolean = type == typeof(bool); + + if (IsTypeBoolean) + SupportedKinds = QueryFilterTokenKind.Equal | QueryFilterTokenKind.NotEqual; + else + SupportedKinds = QueryFilterTokenKind.Operator; + } + + /// + QueryFilterParser IQueryFilterFieldConfig.Parser => _parser; + + /// + Type IQueryFilterFieldConfig.Type => _type; + + /// + string IQueryFilterFieldConfig.Field => _field; + + /// + string? IQueryFilterFieldConfig.Model => _model ?? _field; + + /// + bool IQueryFilterFieldConfig.IsTypeString => IsTypeString; + + /// + /// Indicates whether the field type is a . + /// + protected bool IsTypeString { get; set; } + + /// + /// Indicates whether the field type is a . + /// + bool IQueryFilterFieldConfig.IsTypeBoolean => IsTypeBoolean; + + /// + /// Indicates whether the field type is a . + /// + protected bool IsTypeBoolean { get; set; } + + /// + QueryFilterTokenKind IQueryFilterFieldConfig.SupportedKinds => SupportedKinds; + + /// + /// Gets the supported kinds. + /// + /// Where defaults to both and ; otherwise, defaults to . + protected QueryFilterTokenKind SupportedKinds { get; set; } + + /// + bool IQueryFilterFieldConfig.IsToUpper => IsToUpper; + + /// + /// Indicates whether the comparison should ignore case or not (default); will use when selected for comparisons. + /// + /// This is only applicable where the . + protected bool IsToUpper { get; set; } = false; + + /// + bool IQueryFilterFieldConfig.IsCheckForNotNull => IsCheckForNotNull; + + /// + /// Indicates whether a not- check should also be performed before the comparion occurs (defaults to false). + /// + protected bool IsCheckForNotNull { get; set; } = false; + + /// + QueryStatement? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; + + /// + /// Gets the default LINQ to be used where no filtering is specified. + /// + protected QueryStatement? DefaultStatement { get; set; } + + /// + object? IQueryFilterFieldConfig.ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => ConvertToValue(operation, field, filter); + + /// + /// Converts to the destination type using the configurations where specified. + /// + /// The operation being performed on the . + /// The field . + /// The query filter. + /// The converted value. + /// Note: A converted value of is considered invalid and will result in an . + protected abstract object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); + + /// + /// Validate the token against the field configuration. + /// + /// The field . + /// The constant . + /// The query filter. + void IQueryFilterFieldConfig.ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter) + { + if (!QueryFilterTokenKind.Constant.HasFlag(constant.Kind)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not considered valid."); + + if (IsTypeString) + { + if (!(constant.Kind == QueryFilterTokenKind.Literal || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' must be specified as a {QueryFilterTokenKind.Literal} where the underlying type is a string."); + } + else if (IsTypeBoolean) + { + if (!(constant.Kind == QueryFilterTokenKind.True || constant.Kind == QueryFilterTokenKind.False || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not considered a valid boolean."); + } + else + { + if (!(constant.Kind == QueryFilterTokenKind.Value || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' must not be specified as a {QueryFilterTokenKind.Literal} where the underlying type is not a string."); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs new file mode 100644 index 00000000..e038b271 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs @@ -0,0 +1,42 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the base field configuration extending with fluent-style method-chaining capabilities. + /// + /// The self for support fluent-style method-chaining. + /// The owning . + /// The field type. + /// The field name. + /// The model name (defaults to . + public abstract class QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) + : QueryFilterFieldConfigBase(parser, type, field, model) where TSelf : QueryFilterFieldConfigBase + { + /// + /// Indicates that a not- check should also be performed before a comparion occurs. + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public TSelf AlsoCheckNotNull() + { + IsCheckForNotNull = true; + return (TSelf)this; + } + + /// + /// Sets (overrides) the default default LINQ statement to be used where no filtering is specified. + /// + /// The LINQ . + /// + /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. + /// This must be the required expression only. It will be appended as an and to the final LINQ statement. + public TSelf Default(QueryStatement? statement) + { + DefaultStatement = statement; + return (TSelf)this; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs new file mode 100644 index 00000000..544a2be7 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs @@ -0,0 +1,96 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Mapping.Converters; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the field configuration. + /// + /// The field type. + /// The owning . + /// The field name. + /// The model name (defaults to . + public class QueryFilterFieldConfig(QueryFilterParser parser, string field, string? model) : QueryFilterFieldConfigBase>(parser, typeof(T), field, model) + { + private IConverter _converter = StringToTypeConverter.Default; + private Func? _valueFunc; + + /// + /// Sets (overrides) the operator . + /// + /// The supported flags. + /// The to support fluent-style method-chaining. + /// The default is . + public QueryFilterFieldConfig Operators(QueryFilterTokenKind kinds) + { + if (((IQueryFilterFieldConfig)this).IsTypeBoolean) + throw new NotSupportedException($"{nameof(Operators)} is not supported where {nameof(IQueryFilterFieldConfig.IsTypeBoolean)}."); + + SupportedKinds = kinds; + return this; + } + + /// + /// Indicates that the operation should ignore case by performing an explicit comparison and value conversion. + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public QueryFilterFieldConfig UseUpperCase() + { + if (!((IQueryFilterFieldConfig)this).IsTypeString) + throw new ArgumentException($"A {nameof(UseUpperCase)} can only be specified where the field type is a string."); + + IsToUpper = true; + return this; + } + + /// + /// Sets (overrides) the to convert the field value from a to the field type . + /// + /// The . + /// The to support fluent-style method-chaining. + /// The is invoked before the as the resulting value is passed through to enable further conversion and/or validation where applicable. + public QueryFilterFieldConfig WithConverter(IConverter converter) + { + _converter = converter.ThrowIfNull(nameof(converter)); + return this; + } + + /// + /// Sets (overrides) the function to, a) further convert the field value to the final value that will be used in the LINQ query; and/or, b) to provide additional validation. + /// + /// The value function. + /// The final value that will be used in the LINQ query. + /// This is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. + /// This in invoked after the has been invoked. + public QueryFilterFieldConfig WithValue(Func? value) + { + _valueFunc = value; + return this; + } + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + { + // Convert from string to the underlying type and consider the upper case requirements. + T value = _converter.ConvertToDestination(field.GetValueToken(filter)); + if (typeof(T) == typeof(string)) + { + var str = value?.ToString(); + if (str is null) + return null!; + + if (IsToUpper) + str = str?.ToUpper(System.Globalization.CultureInfo.CurrentCulture); + + value = _converter.ConvertToDestination(str!); + return _valueFunc?.Invoke(value) ?? value!; + } + + // Convert the underlying type to the final value. + return _valueFunc?.Invoke(value) ?? value!; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs new file mode 100644 index 00000000..357b1e40 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs @@ -0,0 +1,24 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the null only comparison field configuration. + /// + public class QueryFilterNullFieldConfig : QueryFilterFieldConfigBase + { + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The field name. + /// The model name (defaults to . + public QueryFilterNullFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(object), field, model) => SupportedKinds = QueryFilterTokenKind.Equal | QueryFilterTokenKind.NotEqual; + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + => throw new FormatException("Only null comparisons are supported."); + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParser.cs b/src/CoreEx.Data/Querying/QueryFilterParser.cs new file mode 100644 index 00000000..7d21acd7 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterParser.cs @@ -0,0 +1,484 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Data.Querying.Expressions; +using CoreEx.RefData; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a basic query filter parser with explicitly defined field support. + /// + /// Enables basic query filtering with similar syntax to the OData $filter. + /// Support is limited to the filter tokens as specified by the . + /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. + /// Example configuration is as follows: + /// + /// private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() + /// .WithFilter(filter => filter + /// .AddField<string>(nameof(Employee.LastName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + /// .AddField<string>(nameof(Employee.FirstName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + /// .AddReferenceDataField<Gender>(nameof(Employee.Gender), nameof(EfModel.Employee.GenderCode), c => c.MustBeValid()) + /// .AddField<DateTime>(nameof(Employee.StartDate)) + /// .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))); + /// + public class QueryFilterParser() + { + private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); + private QueryStatement? _defaultStatement; + private Action? _onQuery; + + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the query filter specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, Action>? configure = null) where T : notnull => AddField(field, null, configure); + + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, string? model, Action>? configure = null) where T : notnull + { + var config = new QueryFilterFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } + + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the query filter specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddReferenceDataField(string field, Action>? configure = null) where TRef : IReferenceData, new() => AddReferenceDataField(field, null, configure); + + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddReferenceDataField(string field, string? model, Action>? configure = null) where TRef : IReferenceData, new() + { + var config = new QueryFilterReferenceDataFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } + + /// + /// Adds a to the parser using the specified as-is. + /// + /// The field name used in the query filter. + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddNullField(string field, Action? configure = null) => AddNullField(field, null, configure); + + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddNullField(string field, string? model, Action? configure = null) + { + var config = new QueryFilterNullFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } + + /// + /// Sets (overrides) the default LINQ to be used where no field filtering is specified (including defaults). + /// + public QueryFilterParser Default(QueryStatement statement) + { + _defaultStatement = statement; + return this; + } + + /// + /// Sets (overrides) the action to be invoked where the query has been successfully parsed and is ready for execution. + /// + /// The action to invoke. + /// The to support fluent-style method-chaining. + /// The can be further maintained as required. + /// Additionally, this is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. + public QueryFilterParser OnQuery(Action? onQuery) + { + _onQuery = onQuery; + return this; + } + + /// + /// Indicates that at least a single field has been configured. + /// + public bool HasFields => _fields.Count > 0; + + /// + /// Trys and gets the specified . + /// + /// The field name used in the query filter. + /// The where found. + /// where found; otherwise, . + public bool TryGetField(string field, [NotNullWhen(true)] out IQueryFilterFieldConfig? config) => _fields.TryGetValue(field, out config); + + /// + /// Gets the for the specified and automatically throws a where not found. + /// + /// The . + /// The query filter. + /// The . + public IQueryFilterFieldConfig GetFieldConfig(QueryFilterToken token, string filter) + { + if (token.Kind != QueryFilterTokenKind.Field) + throw new ArgumentException($"The token must have a Kind of {QueryFilterTokenKind.Field}.", nameof(token)); + + var name = token.GetRawToken(filter).ToString(); + return _fields.TryGetValue(name, out var config) + ? config + : throw new QueryFilterParserException($"{QueryFilterTokenKind.Field} '{name}' is not supported."); + } + + /// + /// Parses and converts the to dynamic LINQ. + /// + /// The query filter. + /// The . + /// Leverages the to perform the actual parsing. + public QueryFilterParserResult Parse(string? filter) + { + if (!string.IsNullOrEmpty(filter) && filter.Equals("help", StringComparison.OrdinalIgnoreCase)) + throw new QueryFilterParserException(ToString()); + + var result = new QueryFilterParserResult(); + + // Append all the expressions to the resulting LINQ whilst parsing. + foreach (var expression in GetExpressions(filter)) + { + WriteToResult(expression, result); + } + + // Append any default statements where no fields are in the filter. + var needsAnd = result.FilterBuilder.Length > 0; + foreach (var statement in _fields.Where(x => x.Value.DefaultStatement is not null && !result.Fields.Contains(x.Key)).Select(x => x.Value.DefaultStatement!)) + { + result.AppendStatement(statement); + } + + // Uses the default statement where no fields were specified (or defaulted). + result.Default(_defaultStatement); + + // Last chance ;-) + _onQuery?.Invoke(result); + + return result; + } + + /// + /// Parses and gets the expressions from the . + /// + /// The query filter. + /// The . + public IEnumerable GetExpressions(string? filter) + { + if (!string.IsNullOrEmpty(filter)) + { + QueryFilterExpressionBase? current = null; + int expressionCount = 0; + bool canOpenParen = true; + bool canLogical = false; + int parenDepth = 0; + + foreach (var t in GetRawTokens(filter)) + { + if (current is not null && !current.CanAddToken(t)) + { + yield return current; + expressionCount++; + current = null; + } + + if (current is not null) + current.AddToken(t); + else + { + if (t.Kind == QueryFilterTokenKind.Not && expressionCount == 0) + { + current = new QueryFilterLogicalExpression(this, filter, t); + canOpenParen = true; + canLogical = false; + } + else if (t.Kind == QueryFilterTokenKind.Field) + { + current = new QueryFilterOperatorExpression(this, filter, t); + canOpenParen = false; + canLogical = true; + } + else if (QueryFilterTokenKind.StringFunction.HasFlag(t.Kind)) + { + current = new QueryFilterStringFunctionExpression(this, filter, t); + canOpenParen = false; + canLogical = true; + } + else if (t.Kind == QueryFilterTokenKind.OpenParenthesis) + { + if (!canOpenParen) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); + + current = new QueryFilterOpenParenthesisExpression(this, filter, t); + parenDepth++; + canLogical = false; + } + else if (t.Kind == QueryFilterTokenKind.CloseParenthesis) + { + if (canOpenParen) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); + + if (parenDepth == 0) + throw new QueryFilterParserException($"There is a closing '{t.GetRawToken(filter).ToString()}' that has no matching opening '('."); + + current = new QueryFilterCloseParenthesisExpression(this, filter, t); + parenDepth--; + canOpenParen = false; + canLogical = true; + } + else if (QueryFilterTokenKind.Logical.HasFlag(t.Kind)) + { + if (!canLogical) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); + + current = new QueryFilterLogicalExpression(this, filter, t); + canOpenParen = true; + canLogical = false; + } + else + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); + } + } + + if (current is not null) + { + if (!current.IsComplete) + throw new QueryFilterParserException("The final expression is incomplete."); + + yield return current; + } + + if (parenDepth != 0) + throw new QueryFilterParserException("There is an opening '(' that has no matching closing ')'."); + + if (!canLogical) + throw new QueryFilterParserException("The final expression is incomplete."); + } + } + + /// + /// Parses and gets the raw tokens from the filter with limited validation. + /// + private IEnumerable GetRawTokens(string filter) + { + for (int i = 0; i < filter.Length; i++) + { + if (filter[i] == '(') + { + yield return new QueryFilterToken(QueryFilterTokenKind.OpenParenthesis, i, 1); + continue; + } + + if (filter[i] == ')') + { + yield return new QueryFilterToken(QueryFilterTokenKind.CloseParenthesis, i, 1); + continue; + } + + if (filter[i] == ',') + { + yield return new QueryFilterToken(QueryFilterTokenKind.Comma, i, 1); + continue; + } + + if (filter[i] == '\'') + { + var span = filter.AsSpan()[(i + 1)..]; + var j = FindEndOfLiteral(ref span); + if (j == -1) + throw new QueryFilterParserException($"A {QueryFilterTokenKind.Literal} has not been terminated."); + + yield return new QueryFilterToken(QueryFilterTokenKind.Literal, i, j + 1); + i += j; + continue; + } + + if (filter[i] != ' ') + { + var start = i; + var j = i + 1; + var backup = false; + + for (; j < filter.Length; j++) + { + if (filter[j] == ' ') + break; + + if (filter[j] == '(' || filter[j] == ')' || filter[j] == ',') + { + backup = true; + break; + } + } + + var token = filter.AsSpan()[start..j]; + var kind = QueryFilterTokenKind.Unspecified; + + // Determine the kind of token where possible. + if (token.Length <= 10) + { + Span lower = new char[token.Length]; + token.ToLowerInvariant(lower); + + kind = lower switch + { + "eq" => QueryFilterTokenKind.Equal, + "ne" => QueryFilterTokenKind.NotEqual, + "gt" => QueryFilterTokenKind.GreaterThan, + "ge" => QueryFilterTokenKind.GreaterThanOrEqual, + "lt" => QueryFilterTokenKind.LessThan, + "le" => QueryFilterTokenKind.LessThanOrEqual, + "in" => QueryFilterTokenKind.In, + "true" => QueryFilterTokenKind.True, + "false" => QueryFilterTokenKind.False, + "null" => QueryFilterTokenKind.Null, + "and" => QueryFilterTokenKind.And, + "or" => QueryFilterTokenKind.Or, + "not" => QueryFilterTokenKind.Not, + "startswith" => QueryFilterTokenKind.StartsWith, + "endswith" => QueryFilterTokenKind.EndsWith, + "contains" => QueryFilterTokenKind.Contains, + _ => QueryFilterTokenKind.Unspecified + }; + } + + if (kind == QueryFilterTokenKind.Unspecified) + kind = (token.Length == 32 && Guid.TryParse(token, out _)) + ? QueryFilterTokenKind.Value + : (char.IsLetter(token[0]) || token[0] == '_') + ? QueryFilterTokenKind.Field + : _fields.ContainsKey(token.ToString()) + ? QueryFilterTokenKind.Field + : QueryFilterTokenKind.Value; + + yield return new QueryFilterToken(kind, start, token.Length); + i = backup ? j - 1 : j; + continue; + } + } + } + + /// + /// Finds the end of a literal. + /// + private static int FindEndOfLiteral(ref ReadOnlySpan filter) + { + var inQuote = true; + var i = 0; + for (; i < filter.Length; i++) + { + if (filter[i] == '\'') + { + if (i < filter.Length - 1) + { + if (filter[i + 1] == '\'') + { + i++; + continue; + } + } + + inQuote = false; + } + else if (filter[i] == ' ' || filter[i] == '(' || filter[i] == ')' || filter[i] == ',') + { + if (!inQuote) + return i; + } + } + + return inQuote ? -1 : i; + } + + /// + /// Converts the query filter into the corresponding dynamic LINQ appending to the . + /// + /// The . + /// The . + /// Override this method to provide a custom dynamic LINQ conversion. + protected virtual void WriteToResult(QueryFilterExpressionBase expression, QueryFilterParserResult result) => expression.WriteToResult(result); + + /// + public override string ToString() + { + if (!HasFields) + return "Filter statement is not currently supported."; + + var sb = new StringBuilder("Supported field(s) are as follows:"); + foreach (var field in _fields) + { + sb.AppendLine().Append(field.Key).Append(" (Type: ").Append(field.Value.Type.Name).Append(", Operations: "); + + var first = true; + foreach (var e in Enum.GetValues(typeof(QueryFilterTokenKind))) + { + if (field.Value.SupportedKinds.HasFlag((QueryFilterTokenKind)e)) + { + var op = GetODataOperator((QueryFilterTokenKind)e); + if (op is not null) + { + if (first) + first = false; + else + sb.Append(", "); + + sb.Append(op); + } + } + } + + sb.Append(')'); + } + + return sb.ToString(); + } + + /// + /// Gets the ODATA operator. + /// + private static string? GetODataOperator(QueryFilterTokenKind kind) => kind switch + { + QueryFilterTokenKind.Equal => "EQ", + QueryFilterTokenKind.NotEqual => "NE", + QueryFilterTokenKind.GreaterThan => "GT", + QueryFilterTokenKind.GreaterThanOrEqual => "GE", + QueryFilterTokenKind.LessThan => "LT", + QueryFilterTokenKind.LessThanOrEqual => "LE", + QueryFilterTokenKind.In => "IN", + QueryFilterTokenKind.StartsWith => nameof(QueryFilterTokenKind.StartsWith), + QueryFilterTokenKind.EndsWith => nameof(QueryFilterTokenKind.EndsWith), + QueryFilterTokenKind.Contains => nameof(QueryFilterTokenKind.Contains), + _ => null + }; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParserException.cs b/src/CoreEx.Data/Querying/QueryFilterParserException.cs new file mode 100644 index 00000000..08eb3722 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterParserException.cs @@ -0,0 +1,22 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Http; +using CoreEx.Localization; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a . + /// + /// The error message. + public sealed class QueryFilterParserException(string message) + : ValidationException(MessageItem.CreateErrorMessage(HttpConsts.QueryArgsFilterQueryStringName, message), new LText(typeof(QueryFilterParserException).FullName, FallbackMessage)) + { + /// + /// Gets the + /// + internal const string FallbackMessage = "A query parsing error occurred."; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs new file mode 100644 index 00000000..6e871c8a --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs @@ -0,0 +1,105 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; +using System.Collections.Generic; +using System.Text; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents the result of a successful . + /// + public sealed class QueryFilterParserResult + { + /// + /// Gets the field names referenced within the resulting LINQ query. + /// + public HashSet Fields { get; } = []; + + /// + /// Gets the resulting dynamic LINQ filter . + /// + internal StringBuilder FilterBuilder { get; } = new StringBuilder(); + + /// + /// Gets the resulting arguments referenced by the . + /// + public List Args { get; } = []; + + /// + /// Provides the resulting dynamic LINQ filter. + public override string? ToString() => FilterBuilder.ToString(); + + /// + /// Appends a value to the as a placeholder and adds into the . + /// + /// The value. + public void AppendValue(object? value) + { + Args.Add(value); + FilterBuilder.Append($"@{Args.Count - 1}"); + } + + /// + /// Appends a to the . + /// + /// The chararater to append. + /// Also appends a space if required. + internal void Append(char @char) + { + if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') + { + if (!(@char == ')' && FilterBuilder[^1] == ')')) + FilterBuilder.Append(' '); + } + + FilterBuilder.Append(@char); + } + + /// + /// Appends a to the . + /// + /// The span. + /// Also appends a space if required. + internal void Append(ReadOnlySpan span) + { + if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') + FilterBuilder.Append(' '); + + FilterBuilder.Append(span); + } + + /// + /// Appends a to the . + /// + /// The . + /// Also appends an ' && ' (and) prior to the where neccessary. + public void AppendStatement(QueryStatement statement) + { + if (FilterBuilder.Length > 0) + FilterBuilder.Append(" && "); + + var sb = new StringBuilder(statement.ThrowIfNull(nameof(statement)).Statement); + for (int i = 0; i < statement.Args.Length; i++) + { + sb.Replace($"@{i}", $"@{Args.Count}"); + Args.Add(statement.Args[i]); + } + + FilterBuilder.Append(sb); + } + + /// + /// Defaults the with the specified where not already set. + /// + /// The . + public void Default(QueryStatement? statement) + { + if (statement is not null && FilterBuilder.Length == 0) + { + FilterBuilder.Append(statement.Statement); + Args.AddRange(statement.Args); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs new file mode 100644 index 00000000..57ade843 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs @@ -0,0 +1,84 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.RefData; +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the field configuration. + /// + /// The . + /// This will automatically set the to be only. + public class QueryFilterReferenceDataFieldConfig : QueryFilterFieldConfigBase> where TRef : IReferenceData, new() + { + private bool _useIdentifier; + private bool _mustBeValid = true; + private Func? _valueFunc; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The field name. + /// The model name (defaults to . + public QueryFilterReferenceDataFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(TRef), field, model) + { + SupportedKinds = QueryFilterTokenKind.EqualityOperator; + IsTypeString = true; + } + + /// + /// Indicates that the is to be used as the value for the query (versus the originating filter value being the ). + /// + /// The to support fluent-style method-chaining. + /// This will automatically set the to be only as other operators are nonsensical in this context. + public QueryFilterReferenceDataFieldConfig UseIdentifier() + { + _useIdentifier = true; + return this; + } + + /// + /// Indicates that the resulting converted value must be . + /// + /// indicates that an error will occur where not valid; otherwise, . + /// The to support fluent-style method-chaining. + /// Defaults to . + public QueryFilterReferenceDataFieldConfig MustBeValid(bool mustBeValid = true) + { + _mustBeValid = mustBeValid; + return this; + } + + /// + /// Sets (overrides) the function to, a) further convert the field value; and/or, b) to provide additional validation. + /// + /// The value function. + /// The final value that will be used in the LINQ query. + /// This is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. + public QueryFilterReferenceDataFieldConfig WithValue(Func? value) + { + _valueFunc = value; + return this; + } + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + { + var text = field.GetValueToken(filter); + TRef value = ReferenceDataOrchestrator.ConvertFromCode(text); + + if (_mustBeValid && !value.IsValid) + throw new FormatException($"Not a valid {typeof(TRef).Name}."); + + if (_valueFunc is not null) + value = _valueFunc.Invoke(value) ?? throw new FormatException($"Not a valid {typeof(TRef).Name}."); + + return _useIdentifier + ? (value.Id is null ? string.Empty : value.Id) + : value.Code!; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterToken.cs b/src/CoreEx.Data/Querying/QueryFilterToken.cs new file mode 100644 index 00000000..5403efe2 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterToken.cs @@ -0,0 +1,120 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a token. + /// + /// The token kind. + /// The token index. + /// The token length. + public readonly struct QueryFilterToken(QueryFilterTokenKind kind, int index, int length) + { + /// + /// Gets an unspecified . + /// + public static QueryFilterToken Unspecified { get; } = new QueryFilterToken(QueryFilterTokenKind.Unspecified, 0, 0); + + /// + /// Gets the token kind. + /// + public QueryFilterTokenKind Kind { get; } = kind; + + /// + /// Gets the token start position. + /// + public int Index { get; } = index; + + /// + /// Gets the token length. + /// + public int Length { get; } = length; + + /// + /// Gets the raw token from the . + /// + /// The query filter. + /// The raw token. + public readonly ReadOnlySpan GetRawToken(string filter) => filter.ThrowIfNull(nameof(filter)).AsSpan(Index, Length); + + /// + /// Gets the value from the token removing leading and trailing quotes, and replacing all escaped quotes where applicable. + /// + /// The query filter. + /// The value token. + public readonly string GetValueToken(string filter) + { + var raw = GetRawToken(filter); + return raw.Length >= 2 && raw[0] == '\'' && raw[^1] == '\'' ? raw[1..^1].ToString().Replace("''", "'") : raw.ToString(); + } + + /// + /// Performs a and converts using the configured . + /// + /// The operation being performed on the . + /// The field . + /// The . + /// The query filter. + /// The converted value. + public readonly object GetConvertedValue(QueryFilterToken operation, QueryFilterToken field, IQueryFilterFieldConfig config, string filter) + { + if (Kind != QueryFilterTokenKind.Value && Kind != QueryFilterTokenKind.Literal) + throw new InvalidOperationException($"A {nameof(GetConvertedValue)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."); + + try + { + return config.ConvertToValue(operation, this, filter) ?? throw new InvalidOperationException($"Field '{field.GetRawToken(filter).ToString()}' has a value '{GetValueToken(filter)}' which has been converted to null."); + } + catch (QueryFilterParserException) + { + throw; + } + catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is ValidationException) + { + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' with value '{GetValueToken(filter)}' is invalid: {ex.Message}"); + } + catch (Exception) + { + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' has a value '{GetValueToken(filter)}' that is not a valid {config.Type.Name}."); + } + } + + /// + /// Clones and updates the token with the specified . + /// + /// The overridding . + /// The new . + public readonly QueryFilterToken CloneAs(QueryFilterTokenKind kind) => new(kind, Index, Length); + + /// + /// Converts the token to the dynamic LINQ equivalent. + /// + /// The originating filter. + /// The dynamic LINQ expression. + public readonly string ToLinq(string filter) => Kind switch + { + QueryFilterTokenKind.Field => GetRawToken(filter).ToString(), + QueryFilterTokenKind.True => "true", + QueryFilterTokenKind.False => "false", + QueryFilterTokenKind.Null => "null", + QueryFilterTokenKind.And => "&&", + QueryFilterTokenKind.Or => "||", + QueryFilterTokenKind.Not => "!", + QueryFilterTokenKind.Equal => "==", + QueryFilterTokenKind.NotEqual => "!=", + QueryFilterTokenKind.GreaterThan => ">", + QueryFilterTokenKind.GreaterThanOrEqual => ">=", + QueryFilterTokenKind.LessThan => "<", + QueryFilterTokenKind.LessThanOrEqual => "<=", + QueryFilterTokenKind.In => "in", + QueryFilterTokenKind.OpenParenthesis => "(", + QueryFilterTokenKind.CloseParenthesis => ")", + QueryFilterTokenKind.StartsWith => nameof(QueryFilterTokenKind.StartsWith), + QueryFilterTokenKind.EndsWith => nameof(QueryFilterTokenKind.EndsWith), + QueryFilterTokenKind.Contains => nameof(QueryFilterTokenKind.Contains), + _ => throw new InvalidOperationException($"A {nameof(ToLinq)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."), + }; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterTokenKind.cs b/src/CoreEx.Data/Querying/QueryFilterTokenKind.cs new file mode 100644 index 00000000..60ea6b2c --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterTokenKind.cs @@ -0,0 +1,163 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the kind. + /// + [Flags] + public enum QueryFilterTokenKind + { + /// + /// An unspecified/undetermined token. + /// + Unspecified = 1, + + /// + /// The field token. + /// + Field = 2, + + /// + /// The equal operator token. + /// + Equal = 4, + + /// + /// The not equal operator token. + /// + NotEqual = 8, + + /// + /// The less than operator token. + /// + LessThan = 16, + + /// + /// The less than or equal operator token. + /// + LessThanOrEqual = 32, + + /// + /// The greater than or equal operator token. + /// + GreaterThanOrEqual = 64, + + /// + /// The greater than operator token. + /// + GreaterThan = 128, + + /// + /// The logical IN operator token. + /// + In = 256, + + /// + /// The value token. + /// + Value = 512, + + /// + /// The string literal token. + /// + Literal = 1024, + + /// + /// The token. + /// + True = 2048, + + /// + /// The token. + /// + False = 4096, + + /// + /// The token. + /// + Null = 8192, + + /// + /// The logical AND operator token. + /// + And = 16384, + + /// + /// The logical OR operator token. + /// + Or = 32768, + + /// + /// The open parenthesis token. + /// + OpenParenthesis = 65536, + + /// + /// The close parenthesis token. + /// + CloseParenthesis = 131072, + + /// + /// The comma token. + /// + Comma = 262144, + + /// + /// The starts with token. + /// + StartsWith = 524288, + + /// + /// The contains token. + /// + Contains = 1048576, + + /// + /// The ends with token. + /// + EndsWith = 2097152, + + /// + /// The logical NOT operator token. + /// + Not = 4194304, + + /// + /// An expression operator token. + /// + Operator = Equal | NotEqual | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual | In, + + /// + /// An expression equality operator token. + /// + EqualityOperator = Equal | NotEqual | In, + + /// + /// An expression constant token. + /// + Constant = Value | Literal | True | False | Null, + + /// + /// A logical operator token. + /// + Logical = And | Or, + + /// + /// A general syntax token. + /// + Syntax = OpenParenthesis | CloseParenthesis | Comma, + + /// + /// A string oriented function-based operator. + /// + StringFunction = StartsWith | EndsWith | Contains, + + /// + /// All string oriented operators. + /// + AllStringOperators = Operator | StringFunction + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByDirection.cs b/src/CoreEx.Data/Querying/QueryOrderByDirection.cs new file mode 100644 index 00000000..1609091f --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryOrderByDirection.cs @@ -0,0 +1,28 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the query order-by direction. + /// + [Flags] + public enum QueryOrderByDirection + { + /// + /// Ascending order. + /// + Ascending = 1, + + /// + /// Descending order. + /// + Descending = 2, + + /// + /// Both and order. + /// + Both = Ascending | Descending + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs new file mode 100644 index 00000000..31759ae0 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs @@ -0,0 +1,49 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying +{ + /// + /// Provides the field configuration. + /// + /// The owning . + /// The field name. + /// The model name (defaults to . + public class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, string? model) + { + private readonly string? _model = model; + + /// + /// Gets the owning . + /// + public QueryOrderByParser Parser { get; internal set; } = parser.ThrowIfNull(nameof(parser)); + + /// + /// Gets the field name. + /// + public string Field { get; } = field.ThrowIfNullOrEmpty(nameof(field)); + + /// + /// Gets or sets model name to be used for the dynamic LINQ expression. + /// + /// Defaults to the name. + public string? Model => _model ?? Field; + + /// + /// Gets the supported . + /// + /// Defaults to . + public QueryOrderByDirection SupportedDirection { get; private set; } = QueryOrderByDirection.Both; + + /// + /// Sets (overrides) the . + /// + /// The . + /// The to support fluent-style method-chaining. + /// The default is . + public QueryOrderByFieldConfig Supports(QueryOrderByDirection supportedDirection) + { + SupportedDirection = supportedDirection; + return this; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByParser.cs b/src/CoreEx.Data/Querying/QueryOrderByParser.cs new file mode 100644 index 00000000..66391876 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryOrderByParser.cs @@ -0,0 +1,143 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a basic query sort order by parser with explicitly defined field support. + /// + /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to sort an underlying query. + public sealed class QueryOrderByParser + { + private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); + private Action? _validator; + + /// + /// Gets the default order-by dynamic LINQ statement. + /// + /// To avoid unnecessary parsing this should have been specified as a valid dynamic LINQ statement. + public string? DefaultOrderBy { get; private set; } + + /// + /// Indicates that at least a single field has been configured. + /// + public bool HasFields => _fields.Count > 0; + + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the order by specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + /// To avoid unnecessary parsing this should be the valid dynamic LINQ statement. + public QueryOrderByParser AddField(string field, Action? configure = null) => AddField(field, null, configure); + + /// + /// Adds a to the parser using the specified and . + /// + /// The field name used in the query filter. + /// The model name (defaults to . + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryOrderByParser AddField(string field, string? model, Action? configure = null) + { + var config = new QueryOrderByFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } + + /// + /// Sets (overrides) the default order-by dynamic LINQ statement. + /// + /// The default order-by statement used where not explicitly specified (see .). + /// The to support fluent-style method-chaining. + /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. + public QueryOrderByParser WithDefault(string? defaultOrderBy) + { + DefaultOrderBy = defaultOrderBy.ThrowIfEmpty(nameof(defaultOrderBy)); + return this; + } + + /// + /// Adds (overrides) a that can be used to further validate the fields specified in the order by. + /// + /// The validator action. + /// The to support fluent-style method-chaining. + /// Throw a to have the validation message formatted correctly and consistently. + /// The string[] passed into the validator will contain the parsed fields (names) in the order in which they were specified. + public QueryOrderByParser Validate(Action? validator) + { + _validator = validator; + return this; + } + + /// + /// Parses and converts the to dynamic LINQ. + /// + /// The query order-by. + /// The dynamic LINQ equivalent. + public string Parse(string? orderBy) + { + if (!string.IsNullOrEmpty(orderBy) && orderBy.Equals("help", StringComparison.OrdinalIgnoreCase)) + throw new QueryOrderByParserException(ToString()); + + var fields = new List(); + var sb = new StringBuilder(); + +#if NET6_0_OR_GREATER + foreach (var sort in orderBy.ThrowIfNullOrEmpty(nameof(orderBy)).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = sort.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +#else + foreach (var sort in orderBy.ThrowIfNullOrEmpty(nameof(orderBy)).Split(',', StringSplitOptions.RemoveEmptyEntries )) + { + var parts = sort.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); +#endif + if (parts.Length == 0) + continue; + else if (parts.Length > 2) + throw new QueryOrderByParserException("Invalid syntax."); + +#if NET6_0_OR_GREATER + var field = parts[0]; +#else + var field = parts[0].Trim(); +#endif + var config = _fields.TryGetValue(field, out var fc) ? fc : throw new QueryOrderByParserException($"Field '{field}' is not supported."); + + if (sb.Length > 0) + sb.Append(", "); + + sb.Append(config.Model); + + var dir = parts.Length == 2 ? parts[1].Trim() : null; + if (dir is not null) + { + if (dir.Length > 2 && nameof(QueryOrderByDirection.Ascending).StartsWith(dir, StringComparison.OrdinalIgnoreCase)) + sb.Append(" asc"); + else if (dir.Length > 3 && nameof(QueryOrderByDirection.Descending).StartsWith(dir, StringComparison.OrdinalIgnoreCase)) + sb.Append(" desc"); + else + throw new QueryOrderByParserException($"Direction '{dir}' must be either 'asc' (ascending) or 'desc' (descending)."); + } + + if (fields.Contains(config.Field)) + throw new QueryOrderByParserException($"Field '{field}' must not be specified more than once."); + + fields.Add(config.Field); + } + + _validator?.Invoke([.. fields]); + + return sb.ToString(); + } + + /// + public override string ToString() => _fields.Count == 0 ? "OrderBy statement is not currently supported." : $"Supported field(s) are as follows: {string.Join(", ", _fields.Values.Select(x => x.Field))}."; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByParserException.cs b/src/CoreEx.Data/Querying/QueryOrderByParserException.cs new file mode 100644 index 00000000..fc3cb175 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryOrderByParserException.cs @@ -0,0 +1,15 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Http; +using CoreEx.Localization; + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a . + /// + /// The error message. + public class QueryOrderByParserException(string message) + : ValidationException(MessageItem.CreateErrorMessage(HttpConsts.QueryArgsOrderByQueryStringName, message), new LText(typeof(QueryFilterParserException).FullName, QueryFilterParserException.FallbackMessage)) { } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryStatement.cs b/src/CoreEx.Data/Querying/QueryStatement.cs new file mode 100644 index 00000000..4c27720c --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryStatement.cs @@ -0,0 +1,29 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying +{ + /// + /// Represents a dynamic LINQ statement with optional arguments. + /// + /// The LINQ may contain placeholders referencing the by its zero-based index. + /// Note: no parsing or validation is performed prior to use and as such may result in an internal error. + /// An example is as follows: + /// + /// new QueryStatement("City == @0", "Brisbane"); + /// + /// The dynamic LINQ statement. + /// The placeholder arguments. + public class QueryStatement(string statement, params object?[] args) + { + /// + /// Gets the dynamic LINQ statement. + /// + /// The dynamic LINQ statement may contain placeholders referencing the by its zero-based index. + public string Statement { get; } = statement; + + /// + /// Gets the placeholder arguments. + /// + public object?[] Args { get; } = args; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md new file mode 100644 index 00000000..7041f773 --- /dev/null +++ b/src/CoreEx.Data/README.md @@ -0,0 +1,128 @@ +# CoreEx.Data + +The `CoreEx.Data` namespace provides extended data-related capabilities. + +
+ +## Motivation + +The motivation is to simplify and improve the data access experience. + +
+ +## OData-like Querying + +It is not always possible to implement the likes of OData and/or GraphQL on an underlying data source. This could be related to the complexity of the implementation, the desire to hide the underlying data structure, and/or limit the types of operations performed to manage the underlying performance. + +However, the desire to provide a similar experience to the client remains. The `CoreEx.Data.Querying` namespace enables the client to perform OData-like queries (limited to [`$filter`](https://docs.oasis-open.org/odata/odata/v4.01/cs01/part2-url-conventions/odata-v4.01-cs01-part2-url-conventions.html#sec_SystemQueryOptionfilter) and [`$orderby`](https://docs.oasis-open.org/odata/odata/v4.01/cs01/part2-url-conventions/odata-v4.01-cs01-part2-url-conventions.html#_Toc505773299)) on an underlying data source, in a structured and controlled manner. + +_Note:_ This is **not** intended to be a replacement for [OData](https://learn.microsoft.com/en-us/odata/webapi-8/overview), [GraphQL](https://github.com/graphql-dotnet/graphql-dotnet), etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. + +
+ +### Features + +The following features are supported: + +- `$filter` - the ability to filter the underlying query based on a set of conditions. The following is supported: + - `eq` - equal to; expressed as `field eq 'value'` + - `ne` - not equal to; expressed as `field ne 'value'` + - `gt` - greater than; expressed as `field gt 'value'` + - `ge` - greater than or equal to; expressed as `field ge 'value'` + - `lt` - less than; expressed as `field lt 'value'` + - `le` - less than or equal to; expressed as `field le 'value'` + - `in` - in list; expressed as `field in('value1', 'value2', ...)` + - `startswith` - starts with; expressed as `startswith(field, 'value')` + - `endswith` - ends with; expressed as `endswith(field, 'value')` + - `contains` - contains; expressed as `contains(field, 'value')` + - `and` - logical and; expressed as `field1 eq 'value1' and field2 eq 'value2'` + - `or` - logical or; expressed as `field1 eq 'value1' or field2 eq 'value2'` + - `not` - logical not; expressed as `not field eq 'value'` + - `null` - is null; expressed as `field eq null` + - `(` and `)` - grouping; expressed as `(field1 eq 'value1' and field2 eq 'value2') or field3 eq 'value3'`)` + +- `$orderby` - the ability to order the underlying query based on a set of fields. The following is supported: + - `asc` - ascending; expressed as `field asc` + - `desc` - descending; expressed as `field desc` + - `,` - multiple fields; expressed as `field1 asc, field2 desc` + +The `'value'` is expressed as a string (must be enclosed in single quotes), number, boolean, or date, or `null` depending on the field type. + +The following are examples of supported queries: + +``` +$filter=lastname eq 'Doe' and startswith(firstname, 'a') +$filter=salary gt 100000 and salary le 200000 +$filter=(lastname eq 'Doe' and firstname eq 'John') or (lastname eq 'Smith' and firstname eq 'Jane') +$filter=state in ('CA', 'NY', 'TX') +$filter=isactive eq true +$filter=terminated eq null +$filter=startdate ge 2020-01-01 +$orderby=lastname desc, firstname +``` + +
+ +### Configuration + +The [`QueryArgsConfig`](./Querying/QueryArgsConfig.cs) provides the means to configure the desired support; the model is an _explicit_ opt-in, versus an opt-out, of the capabilities. + +This contains the following key capabilities: + +- [`FilterParser`](./Querying/QueryFilterParser.cs) - this is the `$filter` parser. +- [`OrderByParser`](./Querying/QueryOrderByParser.cs) - this is the `$orderby` parser. + +Each of these properties have the ability to _explicitly_ add fields and their corresponding configuration. An example is as follows: + +``` csharp +private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField(nameof(Employee.LastName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField(nameof(Employee.FirstName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddReferenceDataField(nameof(Employee.Gender), nameof(EfModel.Employee.GenderCode)) + .AddField(nameof(Employee.StartDate)) + .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))) + .WithOrderBy(orderby => orderby + .AddField(nameof(Employee.LastName)) + .AddField(nameof(Employee.FirstName)) + .WithDefault($"{nameof(Employee.LastName)}, {nameof(Employee.FirstName)}")); +``` + +
+ +### Usage + +The configuration is then used to parse and apply the filter and/or order-by to the underlying query using the new `IQueryable.Where` and `IQueryable.OrderBy` extension methods. + +``` csharp +var query = new QueryArgs +{ + Filter = "LastName eq 'Doe' and startswith(firstname, 'a')", + OrderBy = "LastName desc, FirstName" +}; + +return _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); +``` + +The [`QueryArgs`](../CoreEx/Entities/QueryArgs.cs), demonstrated above, is a simple class that is used to house the `Filter` and `OrderBy` properties in a consistent fashion. Additionally, the [`WebApiRequestOptions`](../CoreEx.AspNetCore/CoreEx.AspNetCore.WebApis.WebApiRequestOptions) automatically creates an instance of this class from the originating query string (i.e. `$filter` and `$orderby`). + +``` csharp +public Task GetAllAsync() + => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); +``` + +
+ +### Enablement + +The `CoreEx.Data.Querying` capabilities described above essentially parses the OData-like syntax and then translates it into the equivalent dynamic LINQ statements. This statement is then passed through the [Dynamic LINQ](https://dynamic-linq.net/) NuGet [library](https://dynamic-linq.net/). + +For example, the following OData-like filters would be translated into the equivalent dynamic LINQ statements: + +``` +$filter: code eq 'A' +LINQ: Where("Code == @0", ["A"]) +--- +$filter: startswith(firstName, 'abc'), +LINQ: Where("FirstName.ToUpper().StartsWith(@0)", ["ABC"]) +``` \ No newline at end of file diff --git a/src/CoreEx.Data/strong-name-key.snk b/src/CoreEx.Data/strong-name-key.snk new file mode 100644 index 00000000..5bced390 Binary files /dev/null and b/src/CoreEx.Data/strong-name-key.snk differ diff --git a/src/CoreEx.Database/CoreEx.Database.csproj b/src/CoreEx.Database/CoreEx.Database.csproj index 15054701..f5f267cf 100644 --- a/src/CoreEx.Database/CoreEx.Database.csproj +++ b/src/CoreEx.Database/CoreEx.Database.csproj @@ -12,6 +12,7 @@ + diff --git a/src/CoreEx.Database/DatabaseWildcard.cs b/src/CoreEx.Database/DatabaseWildcard.cs index 157b3578..455d9090 100644 --- a/src/CoreEx.Database/DatabaseWildcard.cs +++ b/src/CoreEx.Database/DatabaseWildcard.cs @@ -46,7 +46,7 @@ public class DatabaseWildcard public DatabaseWildcard(Wildcard? wildcard = null, char multiWildcard = MultiWildcardCharacter, char singleWildcard = SingleWildcardCharacter, char[]? charactersToEscape = null, string? escapeFormat = null) #pragma warning restore CS8618 { - Wildcard = wildcard ?? Wildcard.Default ?? Wildcard.MultiAll; + Wildcard = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; MultiWildcard = multiWildcard; SingleWildcard = singleWildcard; CharactersToEscape = new List(charactersToEscape ?? DefaultCharactersToEscape); @@ -98,7 +98,7 @@ public DatabaseWildcard(Wildcard? wildcard = null, char multiWildcard = MultiWil /// The SQL LIKE wildcard. public string? Replace(string? text) { - var wc = Wildcard ?? Wildcard.Default ?? Wildcard.MultiAll; + var wc = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; var wr = wc.Parse(text).ThrowOnError(); if (wr.Selection.HasFlag(WildcardSelection.None) || (wr.Selection.HasFlag(WildcardSelection.Single) && wr.Selection.HasFlag(WildcardSelection.MultiWildcard))) diff --git a/src/CoreEx.Validation/Rules/WildcardRule.cs b/src/CoreEx.Validation/Rules/WildcardRule.cs index b0d20e9d..2198c7a0 100644 --- a/src/CoreEx.Validation/Rules/WildcardRule.cs +++ b/src/CoreEx.Validation/Rules/WildcardRule.cs @@ -25,7 +25,7 @@ public class WildcardRule : ValueRuleBase where TEntit /// protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) { - var wildcard = Wildcard ?? Wildcard.Default ?? Wildcard.MultiAll; + var wildcard = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; if (wildcard != null && !wildcard.Validate(context.Value)) context.CreateErrorMessage(ErrorText ?? ValidatorStrings.WildcardFormat); diff --git a/src/CoreEx/Abstractions/IEnumerableExtensions.cs b/src/CoreEx/Abstractions/IEnumerableExtensions.cs index 402c2d5e..4cfd1897 100644 --- a/src/CoreEx/Abstractions/IEnumerableExtensions.cs +++ b/src/CoreEx/Abstractions/IEnumerableExtensions.cs @@ -115,7 +115,7 @@ public static IEnumerable WhereWildcard(this IEnumerable WhereWildcard(this IQueryable + /// Throws an if the is null or . + ///
+ /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + public static string? ThrowIfEmpty(this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (value is not null && 0 == value.Length) + throw new ArgumentException("The value cannot be an empty string.", paramName); + + return value; + } +#else + /// + /// Throws an if the is null or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + public static string? ThrowIfEmpty(this string? value, string? paramName = "value") + { + if (value is not null && 0 == value.Length) + throw new ArgumentException("The value cannot be an empty string.", paramName); + + return value; + } +#endif } } \ No newline at end of file diff --git a/src/CoreEx/Caching/ICacheKey.cs b/src/CoreEx/Caching/ICacheKey.cs index c81a40e9..88c1d572 100644 --- a/src/CoreEx/Caching/ICacheKey.cs +++ b/src/CoreEx/Caching/ICacheKey.cs @@ -2,6 +2,7 @@ using CoreEx.Abstractions; using CoreEx.Entities; +using System.Text.Json.Serialization; namespace CoreEx.Caching { @@ -13,6 +14,7 @@ public interface ICacheKey : IUniqueKey /// /// Gets the cache key. /// + [JsonIgnore] public CompositeKey CacheKey { get; } } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IEntityKey.cs b/src/CoreEx/Entities/IEntityKey.cs index 5c45f16d..24962805 100644 --- a/src/CoreEx/Entities/IEntityKey.cs +++ b/src/CoreEx/Entities/IEntityKey.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Abstractions; +using System.Text.Json.Serialization; namespace CoreEx.Entities { @@ -14,6 +15,7 @@ public interface IEntityKey : IUniqueKey /// Gets the key for the entity as a . /// /// The key represented as a . + [JsonIgnore] CompositeKey EntityKey { get; } } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IPrimaryKey.cs b/src/CoreEx/Entities/IPrimaryKey.cs index f959932c..0d0f6462 100644 --- a/src/CoreEx/Entities/IPrimaryKey.cs +++ b/src/CoreEx/Entities/IPrimaryKey.cs @@ -1,5 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using System.Text.Json.Serialization; + namespace CoreEx.Entities { /// @@ -10,9 +12,11 @@ public interface IPrimaryKey : IEntityKey /// /// Gets the primary key (represented as a ). /// + [JsonIgnore] CompositeKey PrimaryKey { get; } /// + [JsonIgnore] CompositeKey IEntityKey.EntityKey => PrimaryKey; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/QueryArgs.cs b/src/CoreEx/Entities/QueryArgs.cs new file mode 100644 index 00000000..c956a123 --- /dev/null +++ b/src/CoreEx/Entities/QueryArgs.cs @@ -0,0 +1,64 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System.Collections.Generic; + +namespace CoreEx.Entities +{ + /// + /// Represents basic dynamic query arguments. + /// + /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter and order an underlying query. + public class QueryArgs + { + /// + /// Create a new . + /// + /// The basic dynamic OData-like $filter statement. + /// The basic dynamic OData-like $orderby statement. + public static QueryArgs Create(string? filter = null, string? orderBy = null) => new() { Filter = filter, OrderBy = orderBy }; + + /// + /// Gets or sets the basic dynamic OData-like $filter statement. + /// + public string? Filter { get; set; } + + /// + /// Gets or sets the basic dynamic OData-like $orderby statement. + /// + public string? OrderBy { get; set; } + + /// + /// Gets or sets the list of included fields. + /// + /// Currently these are only used within CoreEx for JSON serialization filtering (see ). + public List? IncludeFields { get; set; } + + /// + /// Gets or sets the list of excluded fields. + /// + /// Currently these are only used within CoreEx for JSON serialization filtering (see ). + public List? ExcludeFields { get; set; } + + /// + /// Appends the to the . + /// + /// The fields to append. + /// The to support fluent-style method-chaining. + public QueryArgs Include(params string[] fields) + { + (IncludeFields ??= []).AddRange(fields); + return this; + } + + /// + /// Appends the to the . + /// + /// The fields to append. + /// The to support fluent-style method-chaining. + public QueryArgs Exclude(params string[] fields) + { + (ExcludeFields ??= []).AddRange(fields); + return this; + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpArgs.cs b/src/CoreEx/Http/HttpArgs.cs index 13765830..c9ae5736 100644 --- a/src/CoreEx/Http/HttpArgs.cs +++ b/src/CoreEx/Http/HttpArgs.cs @@ -32,7 +32,25 @@ public static class HttpArgs return requestOptions; requestOptions ??= new HttpRequestOptions(); - requestOptions.Paging = paging; + requestOptions.WithPaging(paging); + return requestOptions; + } + + /// + /// Includes the by updating . + /// + /// The (can be null). + /// The . + /// The . + /// Will create a new where the is null and the corresponding is not null; otherwise, overrides the + /// existing . + public static HttpRequestOptions? IncludeQuery(this HttpRequestOptions? requestOptions, QueryArgs? query) + { + if (requestOptions == null && query == null) + return requestOptions; + + requestOptions ??= new HttpRequestOptions(); + requestOptions.WithQuery(query); return requestOptions; } } diff --git a/src/CoreEx/Http/HttpConsts.cs b/src/CoreEx/Http/HttpConsts.cs index 07f1c098..2950cbdd 100644 --- a/src/CoreEx/Http/HttpConsts.cs +++ b/src/CoreEx/Http/HttpConsts.cs @@ -72,12 +72,12 @@ public static class HttpConsts #region QueryStringName /// - /// Gets or sets the query string name. + /// Gets or sets the query string name. /// public static string IncludeFieldsQueryStringName { get; set; } = "$fields"; /// - /// Gets or sets the query string name. + /// Gets or sets the query string name. /// public static string ExcludeFieldsQueryStringName { get; set; } = "$exclude"; @@ -111,6 +111,16 @@ public static class HttpConsts /// public static string PagingArgsCountQueryStringName { get; set; } = "$count"; + /// + /// Gets or sets the query string name. + /// + public static string QueryArgsFilterQueryStringName { get; set; } = "$filter"; + + /// + /// Gets or sets the query string name. + /// + public static string QueryArgsOrderByQueryStringName { get; set; } = "$orderby"; + /// /// Gets or sets the query string name. /// @@ -129,47 +139,57 @@ public static class HttpConsts /// /// Gets or sets the list of possible query string names. /// - public static List PagingArgsPageQueryStringNames { get; set; } = new List(new string[] { "$page", "$pageNumber", "paging-page" }); + public static List PagingArgsPageQueryStringNames { get; set; } = new List(["$page", "$pageNumber", "paging-page"]); /// /// Gets or sets the list of possible query string names. /// - public static List PagingArgsSkipQueryStringNames { get; set; } = new List(new string[] { "$skip", "$offset", "paging-skip" }); + public static List PagingArgsSkipQueryStringNames { get; set; } = new List(["$skip", "$offset", "paging-skip"]); /// /// Gets or sets the list of possible query string names. /// - public static List PagingArgsTakeQueryStringNames { get; set; } = new List(new string[] { "$take", "$top", "$size", "$pageSize", "$limit", "paging-take", "paging-size", "paging-limit" }); + public static List PagingArgsTakeQueryStringNames { get; set; } = new List(["$take", "$top", "$size", "$pageSize", "$limit", "paging-take", "paging-size", "paging-limit"]); /// /// Gets or sets the list of possible query string names. /// - public static List PagingArgsTokenQueryStringNames { get; set; } = new List(new string[] { "$token", "$after", "$cursor", "paging-token", "paging-after", "paging-cursor" }); + public static List PagingArgsTokenQueryStringNames { get; set; } = new List(["$token", "$after", "$cursor", "paging-token", "paging-after", "paging-cursor"]); /// /// Gets or sets the list of possible query string names. /// - public static List PagingArgsCountQueryStringNames { get; set; } = new List(new string[] { "$count", "$totalCount", "paging-count" }); + public static List PagingArgsCountQueryStringNames { get; set; } = new List(["$count", "$totalCount", "paging-count"]); + + /// + /// Gets or sets the list of possible query string names. + /// + public static List QueryArgsFilterQueryStringNames { get; set; } = new List(["$filter"]); + + /// + /// Gets or sets the list of possible query string names. + /// + public static List QueryArgsOrderByQueryStringNames { get; set; } = new List(["$orderby", "$order-by"]); /// - /// Gets or sets the list of possible query string names. + /// Gets or sets the list of possible query string names. /// - public static List IncludeFieldsQueryStringNames { get; set; } = new List(new string[] { "$fields", "$includeFields", "$include", "include-fields" }); + public static List IncludeFieldsQueryStringNames { get; set; } = new List(["$fields", "$includeFields", "$include", "include-fields"]); /// - /// Gets or sets the list of possible query string names. + /// Gets or sets the list of possible query string names. /// - public static List ExcludeFieldsQueryStringNames { get; set; } = new List(new string[] { "$excludeFields", "$exclude", "exclude-fields" }); + public static List ExcludeFieldsQueryStringNames { get; set; } = new List(["$excludeFields", "$exclude", "exclude-fields"]); /// /// Gets or sets the list of possible query string names. /// - public static List IncludeTextQueryStringNames { get; set; } = new List(new string[] { "$text", "$includeText", "include-text" }); + public static List IncludeTextQueryStringNames { get; set; } = new List(["$text", "$includeText", "include-text"]); /// /// Gets or sets the list of possible query string names. /// - public static List IncludeInactiveQueryStringNames { get; set; } = new List(new string[] { "$inactive", "$includeInactive", "include-inactive" }); + public static List IncludeInactiveQueryStringNames { get; set; } = new List(["$inactive", "$includeInactive", "include-inactive"]); #endregion diff --git a/src/CoreEx/Http/HttpRequestOptions.cs b/src/CoreEx/Http/HttpRequestOptions.cs index 1b661286..a5e33030 100644 --- a/src/CoreEx/Http/HttpRequestOptions.cs +++ b/src/CoreEx/Http/HttpRequestOptions.cs @@ -3,7 +3,6 @@ using CoreEx.Entities; using CoreEx.RefData; using System; -using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Net.Http; @@ -19,13 +18,20 @@ namespace CoreEx.Http public class HttpRequestOptions { /// - /// Gets or sets the query string name. + /// Creates a new instance of the class. + /// + /// The optional . + /// The . + public static HttpRequestOptions Create(PagingArgs? paging = null) => new() { Paging = paging }; + + /// + /// Gets or sets the query string name. /// /// Defaults to . public string QueryStringNameIncludeFields { get; set; } = HttpConsts.IncludeFieldsQueryStringName; /// - /// Gets or sets the query string name. + /// Gets or sets the query string name. /// /// Defaults to . public string QueryStringNameExcludeFields { get; set; } = HttpConsts.ExcludeFieldsQueryStringName; @@ -84,41 +90,84 @@ public class HttpRequestOptions public string? ETag { get; set; } /// - /// Gets or sets the list of included fields (JSON property names) to limit the serialized data payload (results in url query string: "$fields=x,y,z"). + /// Appends the to the . /// - public List? IncludeFields { get; set; } + /// The fields to append. + /// The current instance to support fluent-style method-chaining. + public HttpRequestOptions Include(params string[] fields) + { + Query ??= new QueryArgs(); + Query.Include(fields); + return this; + } /// - /// Gets or sets the list of excluded fields (JSON property names) to limit the serialized data payload (results in url query string: "$excludefields=x,y,z"). + /// Appends the to the . /// - public List? ExcludeFields { get; set; } + /// The fields to append. + /// The current instance to support fluent-style method-chaining. + public HttpRequestOptions Exclude(params string[] fields) + { + Query ??= new QueryArgs(); + Query.Exclude(fields); + return this; + } /// - /// Appends the to the . + /// Updates (overrides) the using a basic dynamic OData-like $filter statement. /// - /// The fields to append. + /// The filter. /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions Include(params string[] fields) + public HttpRequestOptions Filter(string? filter) { - (IncludeFields ??= []).AddRange(fields); + Query ??= new QueryArgs(); + Query.Filter = filter; return this; } /// - /// Appends the to the . + /// Updates (overrides) the using a basic dynamic OData-like $orderby statement. /// - /// The fields to append. + /// The order by. /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions Exclude(params string[] fields) + public HttpRequestOptions OrderBy(string orderby) + { + Query ??= new QueryArgs(); + Query.OrderBy = orderby; + return this; + } + + /// + /// Updates (overrides) the . + /// + /// The . + /// The current instance to support fluent-style method-chaining. + public HttpRequestOptions WithQuery(QueryArgs? query) + { + Query = query; + return this; + } + + /// + /// Updates (overrides) the . + /// + /// The . + /// The current instance to support fluent-style method-chaining. + public HttpRequestOptions WithPaging(PagingArgs? paging) { - (ExcludeFields ??= []).AddRange(fields); + Paging = paging; return this; } /// - /// Gets or sets the . + /// Gets the . + /// + public PagingArgs? Paging { get; private set; } + + /// + /// Gets the dynamic . /// - public PagingArgs? Paging { get; set; } + public QueryArgs? Query { get; private set; } /// /// Gets or sets the optional query string value to include within the . @@ -156,7 +205,7 @@ public HttpRequestOptions Exclude(params string[] fields) if (queryString is not null) AddNameValueCollection(sb, queryString); - if (Paging != null) + if (Paging is not null) { switch (Paging.Option) { @@ -181,11 +230,20 @@ public HttpRequestOptions Exclude(params string[] fields) AddNameValuePair(sb, QueryStringNamePagingArgsCount, "true", false); } - if (IncludeFields != null && IncludeFields.Count > 0) - AddNameValuePairs(sb, QueryStringNameIncludeFields, IncludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); + if (Query is not null) + { + if (!string.IsNullOrEmpty(Query.Filter)) + AddNameValuePair(sb, HttpConsts.QueryArgsFilterQueryStringName, Query.Filter, true); + + if (!string.IsNullOrEmpty(Query.OrderBy)) + AddNameValuePair(sb, HttpConsts.QueryArgsOrderByQueryStringName, Query.OrderBy, true); - if (ExcludeFields != null && ExcludeFields.Count > 0) - AddNameValuePairs(sb, QueryStringNameExcludeFields, ExcludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); + if (Query.IncludeFields != null && Query.IncludeFields.Count > 0) + AddNameValuePairs(sb, QueryStringNameIncludeFields, Query.IncludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); + + if (Query.ExcludeFields != null && Query.ExcludeFields.Count > 0) + AddNameValuePairs(sb, QueryStringNameExcludeFields, Query.ExcludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); + } if (IncludeText) AddNameValuePair(sb, QueryStringNameIncludeText, "true", false); diff --git a/src/CoreEx/RefData/ReferenceDataCollectionBase.cs b/src/CoreEx/RefData/ReferenceDataCollectionBase.cs index 63d3072b..f4de88f2 100644 --- a/src/CoreEx/RefData/ReferenceDataCollectionBase.cs +++ b/src/CoreEx/RefData/ReferenceDataCollectionBase.cs @@ -15,8 +15,15 @@ namespace CoreEx.RefData /// The . /// The . /// The itself. - public abstract class ReferenceDataCollectionBase : ReferenceDataCollection where TId : IComparable, IEquatable where TRef : class, IReferenceData where TSelf : ReferenceDataCollectionBase, new() + /// The . Defaults to . + /// The for comparisons. Defaults to . + public abstract class ReferenceDataCollectionBase(ReferenceDataSortOrder sortOrder, StringComparer? codeComparer = null) : ReferenceDataCollection(sortOrder, codeComparer) where TId : IComparable, IEquatable where TRef : class, IReferenceData where TSelf : ReferenceDataCollectionBase, new() { + /// + /// Initializes a new instance of the class. + /// + public ReferenceDataCollectionBase() : this(ReferenceDataSortOrder.SortOrder, null) { } + /// /// Creates an instance of and adds from the . /// diff --git a/src/CoreEx/Wildcards/Wildcard.cs b/src/CoreEx/Wildcards/Wildcard.cs index 3236c6f9..760914bf 100644 --- a/src/CoreEx/Wildcards/Wildcard.cs +++ b/src/CoreEx/Wildcards/Wildcard.cs @@ -52,7 +52,7 @@ public class Wildcard /// /// Gets or sets the default settings (defaults to . /// - public static Wildcard Default { get; set; } = MultiAll; + public static Wildcard Default { get; set; } = MultiBasic; /// /// Initializes a new instance of the class. diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs index 2f1af3f6..1c4a43e8 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs @@ -1,4 +1,7 @@ -namespace CoreEx.Cosmos.Test +using CoreEx.Data.Querying; +using CoreEx.Entities; + +namespace CoreEx.Cosmos.Test { [TestFixture] [Category("WithCosmos")] @@ -208,5 +211,19 @@ public async Task ModelQuery_Paging3() Assert.That(v[0].Value.Name, Is.EqualTo("Greg")); Assert.That(v[1].Value.Name, Is.EqualTo("Mike")); } + + [Test] + public async Task ModelQuery_WithFilter() + { + var qac = QueryArgsConfig.Create() + .WithFilter(f => f + .AddField("Name", "Value.Name", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("Birthday", "Value.Birthday")); + + var v = await _db.Persons3.ModelContainer.Query(q => q.Where(qac, QueryArgs.Create("endswith(name, 'Y')")).OrderBy(x => x.Id)).ToArrayAsync(); + Assert.That(v, Has.Length.EqualTo(2)); + Assert.That(v[0].Value.Name, Is.EqualTo("Gary")); + Assert.That(v[1].Value.Name, Is.EqualTo("Sally")); + } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs b/tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs new file mode 100644 index 00000000..df47ff68 --- /dev/null +++ b/tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs @@ -0,0 +1,247 @@ +using CoreEx.Data.Querying; +using NUnit.Framework; +using System; +using System.Linq; + +namespace CoreEx.Test.Framework.Data +{ + [TestFixture] + public class QueryFilterParserTest + { + private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).AlsoCheckNotNull()) + .AddField("FirstName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("Code") + .AddField("Birthday", "BirthDate") + .AddField("Age") + .AddField("Salary") + .AddField("IsOld")) + .WithOrderBy(order => order.WithDefault("LastName, FirstName")); + + private static void AssertFilter(string filter, string expected, params object[] expectedArgs) => AssertFilter(_queryConfig, filter, expected, expectedArgs); + + private static void AssertFilter(QueryArgsConfig config, string? filter, string expected, params object[] expectedArgs) + { + var result = config.FilterParser.Parse(filter); + Assert.Multiple(() => + { + Assert.That(result.ToString(), Is.EqualTo(expected)); + Assert.That(result.Args, Is.EquivalentTo(expectedArgs)); + }); + } + + private static void AssertException(string? filter, string expected) => AssertException(_queryConfig, filter, expected); + + private static void AssertException(QueryArgsConfig config, string? filter, string expected) + { + var ex = Assert.Throws(() => config.FilterParser.Parse(filter)); + Assert.That(ex.Messages, Is.Not.Null); + Assert.That(ex.Messages, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(ex.Messages.First().Property, Is.EqualTo("$filter")); + Assert.That(ex.Messages.First().Text, Does.StartWith(expected)); + }); + } + + [Test] + public void Parse_SimpleValid() + { + AssertFilter("lastname eq 'Smith'", "(LastName != null && LastName == @0)", "Smith"); + AssertFilter("lastname eq null", "LastName == null"); + AssertFilter("firstname eq 'Angela'", "FirstName.ToUpper() == @0", "ANGELA"); + AssertFilter("code eq 'Xyz'", "Code == @0", "Xyz"); + AssertFilter("birthday eq 1980-01-01", "BirthDate == @0", new DateTime(1980, 1, 1)); + AssertFilter("birthday ne 1980-01-01", "BirthDate != @0", new DateTime(1980, 1, 1)); + AssertFilter("age lt 100", "Age < @0", 100); + AssertFilter("age le 100", "Age <= @0", 100); + AssertFilter("age gt 100", "Age > @0", 100); + AssertFilter("age ge 100", "Age >= @0", 100); + AssertFilter("salary gt 1036.42", "Salary > @0", 1036.42); + AssertFilter("isold eq true", "IsOld == true"); + AssertFilter("IsOld ne false", "IsOld != false"); + AssertFilter("ISOLD ne null", "IsOld != null"); + AssertFilter("isold", "IsOld"); + } + + [Test] + public void Parse_In() + { + AssertFilter("code in ('abc', 'def')", "Code in (@0, @1)", "abc", "def"); + AssertFilter("age in (20, 30, 40)", "Age in (@0, @1, @2)", 20, 30, 40); + AssertFilter("age in (20)", "Age in (@0)", 20); + + AssertException("code in", "The final expression is incomplete."); + AssertException("code in ()", "Field 'code' constant must be specified before the closing ')' for the 'in' operator."); + AssertException("code in (null)", "Field 'code' constant must not be null for an 'in' operator."); + AssertException("code in ))", "Field 'code' must specify an opening '(' for the 'in' operator."); + AssertException("code in ((", "Field 'code' must close ')' the 'in' operator before specifying a further open '('."); + AssertException("code in (,)", "Field 'code' constant ',' is not considered valid."); + AssertException("age in (1 2)", "Field 'age' expects a ',' separator between constant values for an 'in' operator."); + } + + [Test] + public void Parse_ComplexValid() + { + AssertFilter("(age eq 1 or age eq 2) and isold eq true", "(Age == @0 || Age == @1) && IsOld == true", 1, 2); + AssertFilter("(age eq 1 or age eq 2 ) and isold ", "(Age == @0 || Age == @1) && IsOld", 1, 2); + AssertFilter("(age eq 1 or age eq 2) or (age eq 8 or age eq 9)", "(Age == @0 || Age == @1) || (Age == @2 || Age == @3)", 1, 2, 8, 9); + AssertFilter("((age eq 1 or age eq 2) or (age eq 8 or age eq 9))", "((Age == @0 || Age == @1) || (Age == @2 || Age == @3))", 1, 2, 8, 9); + } + + [Test] + public void Parse_Invalid() + { + AssertException("banana", "Field 'banana' is not supported."); + AssertException("banana eq", "Field 'banana' is not supported."); + AssertException("age apple", "Field 'age' does not support 'apple' as an operator."); + AssertException("age 'apple'", "Field 'age' does not support ''apple'' as an operator."); + AssertException("age eq 'apple'", "Field 'age' constant 'apple' must not be specified as a Literal where the underlying type is not a string."); + AssertException("age eq 1990-01-01", "Field 'age' has a value '1990-01-01' that is not a valid Int32."); + AssertException("null eq null", "There is a 'null' positioning that is syntactically incorrect."); + AssertException("true eq null", "There is a 'true' positioning that is syntactically incorrect."); + AssertException("false eq null", "There is a 'false' positioning that is syntactically incorrect."); + AssertException("and", "There is a 'and' positioning that is syntactically incorrect."); + AssertException("or", "There is a 'or' positioning that is syntactically incorrect."); + AssertException("and age eq 1", "There is a 'and' positioning that is syntactically incorrect."); + AssertException("or age eq 1", "There is a 'or' positioning that is syntactically incorrect."); + AssertException("age eq 1 and", "The final expression is incomplete."); + AssertException("age eq 1 or", "The final expression is incomplete."); + AssertException("isold ge true", "Field 'isold' does not support the 'ge' operator."); + AssertException("age xx 1", "Field 'age' does not support 'xx' as an operator."); + AssertException("age ge null", "Field 'age' constant must not be null for an 'ge' operator."); + + AssertException("(age eq 1", "There is an opening '(' that has no matching closing ')'."); + AssertException("age eq 1)", "There is a closing ')' that has no matching opening '('."); + AssertException("age ( 1", "Field 'age' does not support '(' as an operator."); + AssertException("age eq (", "Field 'age' constant '(' is not considered valid."); + AssertException("age eq )", "Field 'age' constant ')' is not considered valid."); + } + + [Test] + public void Parse_Literals() + { + AssertException("code eq '", "A Literal has not been terminated."); + AssertException("code eq '''", "A Literal has not been terminated."); + AssertException("code eq '''''", "A Literal has not been terminated."); + + AssertFilter("code eq ''", "Code == @0", string.Empty); + AssertFilter("code eq ''''", "Code == @0", "'"); + AssertFilter("code eq 'x''x'", "Code == @0", "x'x"); + AssertFilter("code eq 'x'''", "Code == @0", "x'"); + AssertFilter("code eq '''x'", "Code == @0", "'x"); + AssertFilter("code eq '''x'''", "Code == @0", "'x'"); + + AssertFilter("code eq 'null'", "Code == @0", "null"); + + AssertException("code eq 1", "Field 'code' constant '1' must be specified as a Literal where the underlying type is a string."); + AssertException("age eq '8'", "Field 'age' constant '8' must not be specified as a Literal where the underlying type is not a string."); + } + + [Test] + public void Parse_StringFunction() + { + AssertFilter("startswith(firstName, 'abc')", "FirstName.ToUpper().StartsWith(@0)", "ABC"); + AssertFilter("endswith(firstName, 'abc')", "FirstName.ToUpper().EndsWith(@0)", "ABC"); + AssertFilter("contains(firstName, 'abc')", "FirstName.ToUpper().Contains(@0)", "ABC"); + AssertFilter("contains(lastname, 'xyz')", "(LastName != null && LastName.Contains(@0))", "xyz"); + + AssertException("startswith(code, 'abc')", "Field 'code' does not support the 'startswith' function."); + AssertException("startswith)code, 'abc')", "A 'startswith' function expects an opening '(' not a ')'."); + AssertException("startswith(firstname( 'abc')", "A 'startswith' function expects a ',' separator between the field and its constant."); + AssertException("startswith(firstname, null)", "A 'startswith' function references a null constant which is not supported."); + AssertException("startswith(firstname, 'abc',", "A 'startswith' function expects a closing ')' not a ','."); + } + + [Test] + public void Parse_Not() + { + AssertFilter("not (age eq 1)", "!(Age == @0)", 1); + AssertFilter("age eq 1 and not (age eq 2)", "Age == @0 && !(Age == @1)", 1, 2); + + AssertException("age eq 1 and not age eq 2", "A 'not' expects an opening '(' to start an expression versus a syntactically incorrect 'age' token."); + AssertException("age eq 1 not", "There is a 'not' positioning that is syntactically incorrect."); + } + + [Test] + public void Parse_Field_Default() + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.Default(new QueryStatement("LastName == @0", "Brown"))) + .AddField("FirstName") + .Default(new QueryStatement("FirstName == @0", "Zoe"))); + + AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); + AssertFilter(config, null, "LastName == @0", "Brown"); + AssertFilter(config, "firstname eq 'Jenny'", "FirstName == @0 && LastName == @1", "Jenny", "Brown"); + } + + [Test] + public void Parse_Default() + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName") + .AddField("FirstName") + .Default(new QueryStatement("FirstName == @0", "Zoe"))); + + AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); + AssertFilter(config, "", "FirstName == @0", "Zoe"); + AssertFilter(config, null, "FirstName == @0", "Zoe"); + } + + [Test] + public void Parse_Field_OnQuery() + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName") + .AddField("FirstName") + .OnQuery(result => + { + if (!result.Fields.Contains("LastName")) + result.AppendStatement(new QueryStatement("LastName != null")); + + if (result.Fields.Count > 1) + throw new QueryFilterParserException("Only a single field filter is allowed."); + })); + + AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); + AssertFilter(config, "firstname eq 'Angela'", "FirstName == @0 && LastName != null", "Angela"); + AssertFilter(config, null, "LastName != null"); + + AssertException(config, "lastname eq 'Smith' and firstname eq 'Angela'", "Only a single field filter is allowed."); + } + + [Test] + public void Parse_Null() + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddNullField("Terminated", "TerminatedDate")); + + AssertFilter(config, "terminated eq null", "TerminatedDate == null"); + AssertFilter(config, "terminated ne null", "TerminatedDate != null"); + + AssertException(config, "terminated eq 13", "Field 'terminated' with value '13' is invalid: Only null comparisons are supported."); + AssertException(config, "terminated gt null", "Field 'terminated' does not support the 'gt' operator."); + } + + [Test] + public void ToStringHelp() + { + var s = _queryConfig.FilterParser.ToString(); + Console.WriteLine(s); + Assert.That(s, Is.EqualTo(@"Supported field(s) are as follows: +LastName (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +FirstName (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +Code (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN) +Birthday (Type: DateTime, Operations: EQ, NE, LT, LE, GE, GT, IN) +Age (Type: Int32, Operations: EQ, NE, LT, LE, GE, GT, IN) +Salary (Type: Decimal, Operations: EQ, NE, LT, LE, GE, GT, IN) +IsOld (Type: Boolean, Operations: EQ, NE)")); + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs b/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs index fe1a5689..36afd15c 100644 --- a/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs +++ b/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs @@ -32,22 +32,22 @@ public void IncludeAndExcludeFields() public void Paging() { var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { Paging = PagingArgs.CreateSkipAndTake(20, 25) }; + var ro = HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(20, 25)); hr.ApplyRequestOptions(ro); Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$skip=20&$take=25")); hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = new HttpRequestOptions() { Paging = PagingArgs.CreateSkipAndTake(20, 25, true) }; + ro = HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(20, 25, true)); hr.ApplyRequestOptions(ro); Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$skip=20&$take=25&$count=true")); hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = new HttpRequestOptions() { Paging = PagingArgs.CreatePageAndSize(2, 25) }; + ro = HttpRequestOptions.Create(PagingArgs.CreatePageAndSize(2, 25)); hr.ApplyRequestOptions(ro); Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$page=2&$size=25")); hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = new HttpRequestOptions() { Paging = PagingArgs.CreatePageAndSize(2, 25, true) }; + ro = HttpRequestOptions.Create(PagingArgs.CreatePageAndSize(2, 25, true)); hr.ApplyRequestOptions(ro); Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$page=2&$size=25&$count=true")); } diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs index e5c64e39..3e697acd 100644 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs +++ b/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs @@ -35,6 +35,13 @@ public void Convert() }); } + [Test] + public void Convert_StringToDateTime() + { + var now = DateTime.Now; + Assert.That(TypeToStringConverter.Default.ToSource.Convert(now.ToString("O")), Is.EqualTo(now)); + } + public enum TestOption { None = 0, diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs index 5d8b6c3a..c39c1a2f 100644 --- a/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs +++ b/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using CoreEx.Entities; using System.Threading.Tasks; +using CoreEx.Wildcards; namespace CoreEx.Test.Framework.Validation.Rules { @@ -26,10 +27,10 @@ public async Task ValidateWildcard() v1 = await "*xxxx*".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await "x*x".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); + v1 = await "x*x".Validate("value").Configure(c => c.Wildcard(wildcard: Wildcard.MultiAll)).ValidateAsync(); Assert.That(v1.HasErrors, Is.False); - v1 = await "x?x".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); + v1 = await "x?x".Validate("value").Configure(c => c.Wildcard(wildcard: Wildcard.MultiAll)).ValidateAsync(); Assert.Multiple(() => { Assert.That(v1.HasErrors, Is.True); diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs index 1463939e..371ba8e5 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs @@ -38,7 +38,7 @@ public void GetRequestOptions_Configured() { using var test = FunctionTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - var ro = new HttpRequestOptions { ETag = "etag-value", IncludeText = true, IncludeInactive = true, Paging = PagingArgs.CreateSkipAndTake(20, 25, true), UrlQueryString = "fruit=apples" }.Include("fielda", "fieldb").Exclude("fieldc"); + var ro = new HttpRequestOptions() { ETag = "etag-value", IncludeText = true, IncludeInactive = true, UrlQueryString = "fruit=apples" }.WithPaging(PagingArgs.CreateSkipAndTake(20, 25, true)).Include("fielda", "fieldb").Exclude("fieldc"); hr.ApplyRequestOptions(ro); Assert.That(hr.QueryString.Value, Is.EqualTo("?$skip=20&$take=25&$count=true&$fields=fielda,fieldb&$exclude=fieldc&$text=true&$inactive=true&fruit=apples")); @@ -70,7 +70,7 @@ public void GetRequestOptions_Configured_TokenPaging() { using var test = FunctionTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - var ro = new HttpRequestOptions { ETag = "etag-value", IncludeText = true, IncludeInactive = true, Paging = PagingArgs.CreateTokenAndTake("token", 25, true), UrlQueryString = "fruit=apples" }.Include("fielda", "fieldb").Exclude("fieldc"); + var ro = new HttpRequestOptions { ETag = "etag-value", IncludeText = true, IncludeInactive = true, UrlQueryString = "fruit=apples" }.WithPaging(PagingArgs.CreateTokenAndTake("token", 25, true)).Include("fielda", "fieldb").Exclude("fieldc"); hr.ApplyRequestOptions(ro); Assert.That(hr.QueryString.Value, Is.EqualTo("?$token=token&$take=25&$count=true&$fields=fielda,fieldb&$exclude=fieldc&$text=true&$inactive=true&fruit=apples")); diff --git a/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs b/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs index ddbbcc97..347e017d 100644 --- a/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs +++ b/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs @@ -333,8 +333,8 @@ public void WhereWildcard_IEnumerableExtensions() Assert.That(GetPeople().WhereWildcard(x => x.First, "*IM*", ignoreCase: false).SingleOrDefault(), Is.Null); // Regex-based: embedded. - Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N", ignoreCase: false).Select(x => x.Last).SingleOrDefault(), Is.Null); + Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N", wildcard: Wildcard.MultiAll).Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); + Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N", ignoreCase: false, wildcard: Wildcard.MultiAll).Select(x => x.Last).SingleOrDefault(), Is.Null); // Regex-based: single-char match. Assert.That(GetPeople().WhereWildcard(x => x.First, "G?RY", wildcard: Wildcard.BothAll).Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Lawson"));