diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7b6a36..6a583e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Represents the **NuGet** versions. +## v3.25.3 +- *Fixed:* Added function parameter support for `WithDefault()` to enable runtime execution of the default statement where required for the query filter capabilities. + ## v3.25.2 - *Fixed:* `HttpRequestOptions.WithQuery` fixed to ensure any previously set `Include` and `Exclude` fields are not lost (results in a merge); i.e. only the `Filter` and `OrderBy` properties are explicitly overridden. diff --git a/Common.targets b/Common.targets index 1bdf29e6..b6fc238a 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.25.2 + 3.25.3 preview Avanade Avanade diff --git a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs index ea6b0a5f..bcd4c4e5 100644 --- a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs +++ b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs @@ -46,10 +46,10 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) return; if (Fields.HasFlag(QueryOperationFilterFields.Filter)) - operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsFilterQueryStringName, "The basic dynamic OData-like filter specification.", "string", null)); + operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsFilterQueryStringName, "The basic dynamic OData-like filter statement.", "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)); + operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsOrderByQueryStringName, "The basic dynamic OData-like order-by statement.", "string", null)); } } } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs index 7b0f9eaa..88599441 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs @@ -89,19 +89,35 @@ private bool GetQueryStringOptions(IQueryCollection query) if (query == null || query.Count == 0) return false; + Paging = GetPagingArgs(query); + Query = GetQueryArgs(query); + var fields = GetNamedQueryString(query, HttpConsts.IncludeFieldsQueryStringNames); if (!string.IsNullOrEmpty(fields)) + { +#if NET6_0_OR_GREATER + IncludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +#else IncludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries); +#endif + Query ??= new QueryArgs(); + Query.Include(IncludeFields); + } fields = GetNamedQueryString(query, HttpConsts.ExcludeFieldsQueryStringNames); if (!string.IsNullOrEmpty(fields)) + { +#if NET6_0_OR_GREATER + ExcludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +#else ExcludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries); +#endif + Query ??= new QueryArgs(); + Query.Exclude(ExcludeFields); + } IncludeText = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeTextQueryStringNames)); IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true")); - - Paging = GetPagingArgs(query); - Query = GetQueryArgs(query); return true; } diff --git a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs index 4a15dfd9..90a5030a 100644 --- a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs +++ b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs @@ -66,9 +66,9 @@ public interface IQueryFilterFieldConfig bool IsCheckForNotNull { get; } /// - /// Gets the default LINQ to be used where no filtering is specified. + /// Gets the default LINQ function to be used where no filtering is specified. /// - QueryStatement? DefaultStatement { get; } + Func? DefaultStatement { get; } /// /// Gets the additional help text. diff --git a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs index ffdd8083..e602c565 100644 --- a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs +++ b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs @@ -34,7 +34,7 @@ public static class QueryFilterExtensions public static IQueryable Where(this IQueryable query, QueryArgsConfig queryConfig, string? filter) { queryConfig.ThrowIfNull(nameof(queryConfig)); - if (!queryConfig.HasFilterParser) + if (!queryConfig.HasFilterParser && !string.IsNullOrEmpty(filter)) throw new QueryFilterParserException("Filter statement is not currently supported."); var result = queryConfig.FilterParser.Parse(filter); @@ -54,8 +54,7 @@ public static IQueryable Where(this IQueryable query, QueryArgsConfig q public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) { queryConfig.ThrowIfNull(nameof(queryConfig)); - - if (!queryConfig.HasOrderByParser) + if (!queryConfig.HasOrderByParser && !string.IsNullOrEmpty(queryArgs?.OrderBy)) throw new QueryOrderByParserException("OrderBy statement is not currently supported."); return string.IsNullOrEmpty(queryArgs?.OrderBy) @@ -71,10 +70,10 @@ public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig /// The . /// The basic dynamic OData-like $orderby statement. /// The query. - public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, string orderby) + public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, string? orderby) { queryConfig.ThrowIfNull(nameof(queryConfig)); - if (!queryConfig.HasOrderByParser) + if (!queryConfig.HasOrderByParser && !string.IsNullOrEmpty(orderby)) throw new QueryOrderByParserException("OrderBy statement is not currently supported."); var linq = queryConfig.OrderByParser.Parse(orderby.ThrowIfNullOrEmpty(nameof(orderby))); diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs index df828830..98b143fc 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs @@ -120,12 +120,12 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi protected bool IsCheckForNotNull { get; set; } = false; /// - QueryStatement? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; + Func? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; /// - /// Gets or sets the default LINQ to be used where no filtering is specified. + /// Gets or sets the default LINQ function to be used where no filtering is specified. /// - protected QueryStatement? DefaultStatement { get; set; } + protected Func? DefaultStatement { get; set; } /// QueryFilterFieldResultWriter? IQueryFilterFieldConfig.ResultWriter => ResultWriter; diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs index a0067b76..0d59cc21 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs @@ -40,13 +40,22 @@ public TSelf AlsoCheckNotNull() } /// - /// Sets (overrides) the default default LINQ statement to be used where no filtering is specified. + /// Sets (overrides) the default LINQ statement to be used where no filtering is specified. /// /// The LINQ . /// The to support fluent-style method-chaining. /// 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 WithDefault(QueryStatement? statement) + public TSelf WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); + + /// + /// Sets (overrides) the default LINQ statement function to be used where no filtering is specified. + /// + /// The LINQ . + /// The to support fluent-style method-chaining. + /// 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 WithDefault(Func? statement) { DefaultStatement = statement; return (TSelf)this; diff --git a/src/CoreEx.Data/Querying/QueryFilterParser.cs b/src/CoreEx.Data/Querying/QueryFilterParser.cs index ddb56563..c8292612 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParser.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParser.cs @@ -11,7 +11,7 @@ namespace CoreEx.Data.Querying { /// - /// Represents a basic query filter parser with explicitly defined field support. + /// Represents a basic query filter parser and LINQ translator 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 . @@ -30,7 +30,7 @@ namespace CoreEx.Data.Querying public sealed class QueryFilterParser(QueryArgsConfig owner) { private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); - private QueryStatement? _defaultStatement; + private Func? _defaultStatement; private Action? _onQuery; private string? _helpText; @@ -116,7 +116,12 @@ public QueryFilterParser AddNullField(string field, string? model, Action /// Sets (overrides) the default LINQ to be used where no field filtering is specified (including defaults). /// - public QueryFilterParser Default(QueryStatement statement) + public QueryFilterParser WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); + + /// + /// Sets (overrides) the default LINQ function to be used where no field filtering is specified (including defaults). + /// + public QueryFilterParser WithDefault(Func? statement) { _defaultStatement = statement; return this; @@ -198,14 +203,15 @@ public QueryFilterParserResult Parse(string? filter) } // 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); + var stmt = statement(); + if (stmt is not null) + result.AppendStatement(stmt); } // Uses the default statement where no fields were specified (or defaulted). - result.Default(_defaultStatement); + result.UseDefault(_defaultStatement); // Last chance ;-) _onQuery?.Invoke(result); diff --git a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs index 6e871c8a..0be5c0ad 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs @@ -76,10 +76,11 @@ internal void Append(ReadOnlySpan span) /// Also appends an ' && ' (and) prior to the where neccessary. public void AppendStatement(QueryStatement statement) { + statement.ThrowIfNull(nameof(statement)); if (FilterBuilder.Length > 0) FilterBuilder.Append(" && "); - var sb = new StringBuilder(statement.ThrowIfNull(nameof(statement)).Statement); + var sb = new StringBuilder(statement.Statement); for (int i = 0; i < statement.Args.Length; i++) { sb.Replace($"@{i}", $"@{Args.Count}"); @@ -93,12 +94,22 @@ public void AppendStatement(QueryStatement statement) /// Defaults the with the specified where not already set. /// /// The . - public void Default(QueryStatement? statement) + public void UseDefault(QueryStatement? statement) => UseDefault(statement is null ? null : () => statement); + + /// + /// Defaults the with the specified function where not already set. + /// + /// The function. + public void UseDefault(Func? statement) { - if (statement is not null && FilterBuilder.Length == 0) + if (FilterBuilder.Length > 0) + return; + + var stmt = statement?.Invoke(); + if (stmt is not null) { - FilterBuilder.Append(statement.Statement); - Args.AddRange(statement.Args); + FilterBuilder.Append(stmt.Statement); + Args.AddRange(stmt.Args); } } } diff --git a/src/CoreEx.Data/Querying/QueryOrderByParser.cs b/src/CoreEx.Data/Querying/QueryOrderByParser.cs index ee14c4cc..d1f67cb2 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParser.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParser.cs @@ -2,13 +2,12 @@ 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. + /// Represents a basic query sort order by parser and LINQ translator 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. /// The owning . diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md index 87a9e2be..20ff94f7 100644 --- a/src/CoreEx.Data/README.md +++ b/src/CoreEx.Data/README.md @@ -33,7 +33,7 @@ The following features are supported: - `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', ...)` + - `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')` @@ -71,8 +71,8 @@ The [`QueryArgsConfig`](./Querying/QueryArgsConfig.cs) provides the means to con This contains the following key capabilities: -- [`FilterParser`](./Querying/QueryFilterParser.cs) - this is the `$filter` parser. -- [`OrderByParser`](./Querying/QueryOrderByParser.cs) - this is the `$orderby` parser. +- [`FilterParser`](./Querying/QueryFilterParser.cs) - this is the `$filter` parser and LINQ translator. +- [`OrderByParser`](./Querying/QueryOrderByParser.cs) - this is the `$orderby` parser and LINQ translator. Each of these properties have the ability to _explicitly_ add fields and their corresponding configuration. An example is as follows: @@ -90,6 +90,35 @@ private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() .WithDefault($"{nameof(Employee.LastName)}, {nameof(Employee.FirstName)}")); ``` +There are a number of different field configurations that can be added: + +Method | Description +- | - +`AddField()` | Adds a field of the specified type `T`. See [`QueryFilterFieldConfig`](./Querying/QueryFilterFieldConfigT.cs). +`AddNullField()` | Adds a field that only supports `null`-checking operations; limits to `EQ` and `NE`. See [`QueryFilterNullFieldConfig`](./Querying/QueryFilterNullFieldConfig.cs). +`AddReferenceDataField()` | Adds a reference data field of the specified type `TRef`. Automatically includes the requisite `IReferenceData.Code` validation, and limits operations to `EQ`, `NE` and `IN`. See [`QueryFilterReferenceDataFieldConfig`](./Querying/QueryFilterReferenceDataFieldConfig.cs). + +Each of the above methods support the following parameters: +- `field` - the name of the field that can be referenced within the `$filter`. +- `model` - the optional model name of the field to be used in the underlying LINQ operation (defaults to `field`). +- `configure` - an optional configuration action to further define the field configuration. + +Depending on the field type being added (as above), the following related configuration options are available: + +Method | Description +- | - +`AlsoCheckNotNull()` | Indicates that a not-null check should also be performed when performing the operation. +`AsNullable()` | Indicates that the field is nullable and therefore supports null equality operations. +`MustBeValid()` | Indicates that the reference data field value must exist and be considered valid; i.e. it is `IReferenceData.IsValid`. +`UseIdentifier()` | Indicates that the `IReferenceData.Id` should be used in the underlying LINQ operation instead of the `IReferenceData.Code`. +`WithConverter()` | Provides the `IConverter` to convert the filer value string to the underlying field type of `T`. +`WithDefault()` | Provides a default LINQ statement to be used for the field when no filtering is specified by the client. +`WithHelpText()` | Provides additional help text for the field to be used where help is requested. +`WithOperators()` | Overrides the supported operators for the field. See [`QueryFilterOperator`](./Querying/QueryFilterOperator.cs). +`WithResultWriter()` | Provides an opportunity to override the default result writer; i.e. LINQ expression. +`WithUpperCase()` | Indicates that the operation should be case-insensitive by performing an explicit `ToUpper()` on the field value. +`WithValue()` | Provides an opportunity to override the converted field value when the filter is applied. +
### Usage @@ -127,4 +156,32 @@ LINQ: Where("Code == @0", ["A"]) --- $filter: startswith(firstName, 'abc'), LINQ: Where("FirstName.ToUpper().StartsWith(@0)", ["ABC"]) +``` + +
+ +### Help + +To aid the consumers (clients) of the OData-like endpoints a *help* request can be issued. This is performed by using either `$filter=help` or `$orderby=help` and will result in a `400-BadRequest` with help-like contents similar to the following: + +``` json +{ + "$filter": [ + "Filter field(s) are as follows: + LastName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) + FirstName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) + Gender (Type: Gender, Null: false, Operators: EQ, NE, IN) + StartDate (Type: DateTime, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) + Termination (Type: , Null: true, Operators: EQ, NE)" + ] +} + +{ + "$orderby": [ + "Order-by field(s) are as follows: + LastName (Direction: Both) + FirstName (Direction: Both)" + ] +} + ``` \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonFilterer.cs b/src/CoreEx/Text/Json/JsonFilterer.cs index 25187565..671e02d8 100644 --- a/src/CoreEx/Text/Json/JsonFilterer.cs +++ b/src/CoreEx/Text/Json/JsonFilterer.cs @@ -136,7 +136,7 @@ public static Dictionary CreateDictionary(IEnumerable? pat public static Dictionary CreateDictionary(IEnumerable? paths, JsonPropertyFilter filter, StringComparison comparison, ref int maxDepth, bool prependRootPath) { var dict = new Dictionary(StringComparer.FromComparison(comparison)); - paths ??= Array.Empty(); + paths ??= []; // Add each 'specified' path. paths.ForEach(path => dict.TryAdd(prependRootPath ? PrependRootPath(path) : path, true)); diff --git a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs index 11359d63..e2ea0fcb 100644 --- a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs +++ b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs @@ -180,7 +180,7 @@ public void FilterParser_Field_Default() .WithFilter(filter => filter .AddField("LastName", c => c.WithDefault(new QueryStatement("LastName == @0", "Brown"))) .AddField("FirstName") - .Default(new QueryStatement("FirstName == @0", "Zoe"))); + .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); AssertFilter(config, null, "LastName == @0", "Brown"); @@ -194,7 +194,7 @@ public void FilterParser_Default() .WithFilter(filter => filter .AddField("LastName") .AddField("FirstName") - .Default(new QueryStatement("FirstName == @0", "Zoe"))); + .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); AssertFilter(config, "", "FirstName == @0", "Zoe");