Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3.25.3 #120

Merged
merged 1 commit into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading