Skip to content

Commit

Permalink
## v3.25.3 (#120)
Browse files Browse the repository at this point in the history
- *Fixed:* Added function parameter support for `WithDefault()` to enable runtime execution of the default statement where required for the query filter capabilities.
  • Loading branch information
chullybun authored Sep 18, 2024
1 parent ad60021 commit 31d144e
Show file tree
Hide file tree
Showing 14 changed files with 137 additions and 37 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.25.2</Version>
<Version>3.25.3</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
4 changes: 2 additions & 2 deletions src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
22 changes: 19 additions & 3 deletions src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ public interface IQueryFilterFieldConfig
bool IsCheckForNotNull { get; }

/// <summary>
/// Gets the default LINQ <see cref="QueryStatement"/> to be used where no filtering is specified.
/// Gets the default LINQ <see cref="QueryStatement"/> function to be used where no filtering is specified.
/// </summary>
QueryStatement? DefaultStatement { get; }
Func<QueryStatement>? DefaultStatement { get; }

/// <summary>
/// Gets the additional help text.
Expand Down
9 changes: 4 additions & 5 deletions src/CoreEx.Data/Querying/QueryFilterExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static class QueryFilterExtensions
public static IQueryable<T> Where<T>(this IQueryable<T> 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);
Expand All @@ -54,8 +54,7 @@ public static IQueryable<T> Where<T>(this IQueryable<T> query, QueryArgsConfig q
public static IQueryable<T> OrderBy<T>(this IQueryable<T> 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)
Expand All @@ -71,10 +70,10 @@ public static IQueryable<T> OrderBy<T>(this IQueryable<T> query, QueryArgsConfig
/// <param name="queryConfig">The <see cref="QueryArgsConfig"/>.</param>
/// <param name="orderby">The basic dynamic <i>OData-like</i> <c>$orderby</c> statement.</param>
/// <returns>The query.</returns>
public static IQueryable<T> OrderBy<T>(this IQueryable<T> query, QueryArgsConfig queryConfig, string orderby)
public static IQueryable<T> OrderBy<T>(this IQueryable<T> 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)));
Expand Down
6 changes: 3 additions & 3 deletions src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,12 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi
protected bool IsCheckForNotNull { get; set; } = false;

/// <inheritdoc/>
QueryStatement? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement;
Func<QueryStatement>? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement;

/// <summary>
/// Gets or sets the default LINQ <see cref="QueryStatement"/> to be used where no filtering is specified.
/// Gets or sets the default LINQ <see cref="QueryStatement"/> function to be used where no filtering is specified.
/// </summary>
protected QueryStatement? DefaultStatement { get; set; }
protected Func<QueryStatement>? DefaultStatement { get; set; }

/// <inheritdoc/>
QueryFilterFieldResultWriter? IQueryFilterFieldConfig.ResultWriter => ResultWriter;
Expand Down
13 changes: 11 additions & 2 deletions src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,22 @@ public TSelf AlsoCheckNotNull()
}

/// <summary>
/// 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.
/// </summary>
/// <param name="statement">The LINQ <see cref="QueryStatement"/>.</param>
/// <returns>The <typeparamref name="TSelf"/> to support fluent-style method-chaining.</returns>
/// <remarks>To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement.
/// <para>This must be the required expression <b>only</b>. It will be appended as an <i>and</i> to the final LINQ statement.</para></remarks>
public TSelf WithDefault(QueryStatement? statement)
public TSelf WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement);

/// <summary>
/// Sets (overrides) the default LINQ statement function to be used where no filtering is specified.
/// </summary>
/// <param name="statement">The LINQ <see cref="QueryStatement"/>.</param>
/// <returns>The <typeparamref name="TSelf"/> to support fluent-style method-chaining.</returns>
/// <remarks>To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement.
/// <para>This must be the required expression <b>only</b>. It will be appended as an <i>and</i> to the final LINQ statement.</para></remarks>
public TSelf WithDefault(Func<QueryStatement>? statement)
{
DefaultStatement = statement;
return (TSelf)this;
Expand Down
18 changes: 12 additions & 6 deletions src/CoreEx.Data/Querying/QueryFilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
namespace CoreEx.Data.Querying
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>Enables basic query filtering with similar syntax to the OData <c><see href="https://docs.oasis-open.org/odata/odata/v4.01/cs01/part2-url-conventions/odata-v4.01-cs01-part2-url-conventions.html#sec_SystemQueryOptionfilter">$filter</see></c>.
/// Support is limited to the filter tokens as specified by the <see cref="QueryFilterTokenKind"/>.
Expand All @@ -30,7 +30,7 @@ namespace CoreEx.Data.Querying
public sealed class QueryFilterParser(QueryArgsConfig owner)
{
private readonly Dictionary<string, IQueryFilterFieldConfig> _fields = new(StringComparer.OrdinalIgnoreCase);
private QueryStatement? _defaultStatement;
private Func<QueryStatement>? _defaultStatement;
private Action<QueryFilterParserResult>? _onQuery;
private string? _helpText;

Expand Down Expand Up @@ -116,7 +116,12 @@ public QueryFilterParser AddNullField(string field, string? model, Action<QueryF
/// <summary>
/// Sets (overrides) the default LINQ <see cref="QueryStatement"/> to be used where no field filtering is specified (including defaults).
/// </summary>
public QueryFilterParser Default(QueryStatement statement)
public QueryFilterParser WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement);

/// <summary>
/// Sets (overrides) the default LINQ <see cref="QueryStatement"/> function to be used where no field filtering is specified (including defaults).
/// </summary>
public QueryFilterParser WithDefault(Func<QueryStatement>? statement)
{
_defaultStatement = statement;
return this;
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 16 additions & 5 deletions src/CoreEx.Data/Querying/QueryFilterParserResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@ internal void Append(ReadOnlySpan<char> span)
/// <remarks>Also appends an '<c> &amp;&amp; </c>' (and) prior to the <paramref name="statement"/> where neccessary.</remarks>
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}");
Expand All @@ -93,12 +94,22 @@ public void AppendStatement(QueryStatement statement)
/// Defaults the <see cref="FilterBuilder"/> with the specified <paramref name="statement"/> where not already set.
/// </summary>
/// <param name="statement">The <see cref="QueryStatement"/>.</param>
public void Default(QueryStatement? statement)
public void UseDefault(QueryStatement? statement) => UseDefault(statement is null ? null : () => statement);

/// <summary>
/// Defaults the <see cref="FilterBuilder"/> with the specified <paramref name="statement"/> function where not already set.
/// </summary>
/// <param name="statement">The <see cref="QueryStatement"/> function.</param>
public void UseDefault(Func<QueryStatement>? 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);
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/CoreEx.Data/Querying/QueryOrderByParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CoreEx.Data.Querying
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>This is <b>not</b> intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to sort an underlying query.</remarks>
/// <param name="owner">The owning <see cref="QueryArgsConfig"/>.</param>
Expand Down
63 changes: 60 additions & 3 deletions src/CoreEx.Data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')`
Expand Down Expand Up @@ -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:

Expand All @@ -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<T>()` | Adds a field of the specified type `T`. See [`QueryFilterFieldConfig<T>`](./Querying/QueryFilterFieldConfigT.cs).
`AddNullField()` | Adds a field that only supports `null`-checking operations; limits to `EQ` and `NE`. See [`QueryFilterNullFieldConfig`](./Querying/QueryFilterNullFieldConfig.cs).
`AddReferenceDataField<TRef>()` | 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<TRef>`](./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<string, T>` 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.

<br/>

### Usage
Expand Down Expand Up @@ -127,4 +156,32 @@ LINQ: Where("Code == @0", ["A"])
---
$filter: startswith(firstName, 'abc'),
LINQ: Where("FirstName.ToUpper().StartsWith(@0)", ["ABC"])
```

<br/>

### 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: <none>, Null: true, Operators: EQ, NE)"
]
}

{
"$orderby": [
"Order-by field(s) are as follows:
LastName (Direction: Both)
FirstName (Direction: Both)"
]
}

```
2 changes: 1 addition & 1 deletion src/CoreEx/Text/Json/JsonFilterer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public static Dictionary<string, bool> CreateDictionary(IEnumerable<string>? pat
public static Dictionary<string, bool> CreateDictionary(IEnumerable<string>? paths, JsonPropertyFilter filter, StringComparison comparison, ref int maxDepth, bool prependRootPath)
{
var dict = new Dictionary<string, bool>(StringComparer.FromComparison(comparison));
paths ??= Array.Empty<string>();
paths ??= [];

// Add each 'specified' path.
paths.ForEach(path => dict.TryAdd(prependRootPath ? PrependRootPath(path) : path, true));
Expand Down
Loading

0 comments on commit 31d144e

Please sign in to comment.