From 6738a92259fda3c8b7e41467ef2279fbbbe7f9fb Mon Sep 17 00:00:00 2001 From: "Eric Sibly [chullybun]" Date: Fri, 11 Oct 2024 15:32:27 -0700 Subject: [PATCH] v3.27.0 (#125) - *Fixed:* The `ValueContentResult.TryCreateValueContentResult` would return `NotModified` where the request `ETag` was `null`; this has been corrected to return `OK` with the resulting `value`. - *Fixed:* The `ValueContentResult.TryCreateValueContentResult` now returns `ExtendedStatusCodeResult` versus `StatusCodeResult` as this offers additional capabilities where required. - *Enhancement:* The `ExtendedStatusCodeResult` and `ExtendedContentResult` now implement `IExtendedActionResult` to standardize access to the `BeforeExtension` and `AfterExtension` functions. - *Enhancement:* Added `WebApiParam.CreateActionResult` helper methods to enable execution of the underlying `ValueContentResult.CreateValueContentResult` (which is no longer public as this was always intended as internal only). - *Fixed:* `PostgresDatabase.OnDbException` corrected to use `PostgresException.MessageText` versus `Message` as it does not include the `SQLSTATE` code. - *Enhancement:* Improve debugging insights by adding `ILogger.LogDebug` start/stop/elapsed for the `InvokerArgs`. - *Fixed*: Updated `System.Text.Json` package depenedency to latest (including related); resolve [Microsoft Security Advisory CVE-2024-43485](https://github.com/advisories/GHSA-8g4q-xg66-9fp4). --- CHANGELOG.md | 9 ++ Common.targets | 2 +- .../My.Hr.Database/My.Hr.Database.csproj | 2 +- .../My.Hr.Functions/My.Hr.Functions.csproj | 2 +- .../My.Hr.UnitTest/My.Hr.UnitTest.csproj | 6 +- .../CoreEx.AspNetCore.csproj | 2 +- .../WebApis/ExtendedContentResult.cs | 10 +- .../WebApis/ExtendedStatusCodeResult.cs | 18 ++- .../WebApis/IExtendedActionResult.cs | 28 ++++ .../WebApis/ValueContentResult.cs | 12 +- src/CoreEx.AspNetCore/WebApis/WebApiBase.cs | 8 +- .../WebApis/WebApiInvoker.cs | 12 -- src/CoreEx.AspNetCore/WebApis/WebApiParam.cs | 21 +++ src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs | 1 + src/CoreEx.Azure/CoreEx.Azure.csproj | 6 +- src/CoreEx.Cosmos/CoreEx.Cosmos.csproj | 2 +- src/CoreEx.Data/Querying/QueryArgsConfig.cs | 41 ++++++ .../Querying/QueryArgsParseResult.cs | 31 ++++ .../Querying/QueryFilterExtensions.cs | 60 ++++++-- src/CoreEx.Data/Querying/QueryFilterParser.cs | 50 ++++--- .../Querying/QueryFilterParserResult.cs | 14 +- .../Querying/QueryOrderByParser.cs | 29 ++-- .../Querying/QueryOrderByParserResult.cs | 23 +++ .../PostgresDatabase.cs | 50 ++++++- .../PostgresDatabaseColumns.cs | 21 +++ .../SqlServerDatabase.cs | 8 +- .../CoreEx.EntityFrameworkCore.csproj | 2 +- .../CoreEx.UnitTesting.NUnit.csproj | 2 +- .../CoreEx.UnitTesting.csproj | 2 +- .../ValueValidationConfiguration.cs | 13 +- src/CoreEx.Validation/ValueValidator.cs | 3 +- src/CoreEx/CoreEx.csproj | 24 +-- src/CoreEx/Entities/CompositeKey.cs | 3 + src/CoreEx/Invokers/InvokeArgs.cs | 138 ++++++++++++++---- src/CoreEx/Invokers/Invoker.cs | 8 +- .../RefData/ReferenceDataOrchestrator.cs | 26 ++-- .../CoreEx.Cosmos.Test.csproj | 10 +- .../CoreEx.Solace.Test.csproj | 6 +- tests/CoreEx.Test/CoreEx.Test.csproj | 8 +- .../Framework/Data/QueryArgsConfigTest.cs | 32 +++- .../Framework/WebApis/WebApiWithResultTest.cs | 45 ++++++ tests/CoreEx.Test2/CoreEx.Test2.csproj | 4 +- .../CoreEx.TestFunction.csproj | 2 +- .../CoreEx.TestFunctionIso.csproj | 2 +- 44 files changed, 614 insertions(+), 184 deletions(-) create mode 100644 src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs create mode 100644 src/CoreEx.Data/Querying/QueryArgsParseResult.cs create mode 100644 src/CoreEx.Data/Querying/QueryOrderByParserResult.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2299df42..573a63b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Represents the **NuGet** versions. +## v3.27.0 +- *Fixed:* The `ValueContentResult.TryCreateValueContentResult` would return `NotModified` where the request `ETag` was `null`; this has been corrected to return `OK` with the resulting `value`. +- *Fixed:* The `ValueContentResult.TryCreateValueContentResult` now returns `ExtendedStatusCodeResult` versus `StatusCodeResult` as this offers additional capabilities where required. +- *Enhancement:* The `ExtendedStatusCodeResult` and `ExtendedContentResult` now implement `IExtendedActionResult` to standardize access to the `BeforeExtension` and `AfterExtension` functions. +- *Enhancement:* Added `WebApiParam.CreateActionResult` helper methods to enable execution of the underlying `ValueContentResult.CreateValueContentResult` (which is no longer public as this was always intended as internal only). +- *Fixed:* `PostgresDatabase.OnDbException` corrected to use `PostgresException.MessageText` versus `Message` as it does not include the `SQLSTATE` code. +- *Enhancement:* Improve debugging insights by adding `ILogger.LogDebug` start/stop/elapsed for the `InvokerArgs`. +- *Fixed*: Updated `System.Text.Json` package depenedency to latest (including related); resolve [Microsoft Security Advisory CVE-2024-43485](https://github.com/advisories/GHSA-8g4q-xg66-9fp4). + ## v3.26.0 - *Enhancement:* Enable JSON serialization of database parameter values; added `DatabaseParameterCollection.AddJsonParameter` method and associated `JsonParam`, `JsonParamWhen` and `JsonParamWith` extension methods. - *Enhancement:* Updated (simplified) `EventOutboxEnqueueBase` to pass events to the underlying stored procedures as JSON versus existing TVP removing database dependency on a UDT (user-defined type). diff --git a/Common.targets b/Common.targets index 02f4ffc4..ddfd497f 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.26.0 + 3.27.0 preview Avanade Avanade diff --git a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj b/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj index d3e64b60..aee41a46 100644 --- a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj +++ b/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj @@ -22,7 +22,7 @@ - + diff --git a/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj b/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj index b6771276..d7c07fe5 100644 --- a/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj +++ b/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj @@ -7,7 +7,7 @@ - + diff --git a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj index a904604c..ef1f3d9f 100644 --- a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj +++ b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj @@ -20,9 +20,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj index 7dd3d7af..8f4ebcae 100644 --- a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj +++ b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs index 28017b25..d79010de 100644 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs @@ -11,17 +11,13 @@ namespace CoreEx.AspNetCore.WebApis /// /// Represents an extended that enables customization of the . /// - public class ExtendedContentResult : ContentResult + public class ExtendedContentResult : ContentResult, IExtendedActionResult { - /// - /// Gets or sets the function to perform the extended customization. - /// + /// [JsonIgnore] public Func? BeforeExtension { get; set; } - /// - /// Gets or sets the function to perform the extended customization. - /// + /// [JsonIgnore] public Func? AfterExtension { get; set; } diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs index 1b40a6ce..55b7e386 100644 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs @@ -12,23 +12,25 @@ namespace CoreEx.AspNetCore.WebApis /// /// Represents an extended that enables customization of the . /// - /// The . - public class ExtendedStatusCodeResult(HttpStatusCode statusCode) : StatusCodeResult((int)statusCode) + /// The status code value. + public class ExtendedStatusCodeResult(int statusCode) : StatusCodeResult(statusCode), IExtendedActionResult { /// - /// Gets or sets the . + /// Initializes a new instance of the class with the specified . /// - public Uri? Location { get; set; } + /// The . + public ExtendedStatusCodeResult(HttpStatusCode statusCode) : this((int)statusCode) { } /// - /// Gets or sets the function to perform the extended customization. + /// Gets or sets the . /// + public Uri? Location { get; set; } + + /// [JsonIgnore] public Func? BeforeExtension { get; set; } - /// - /// Gets or sets the function to perform the extended customization. - /// + /// [JsonIgnore] public Func? AfterExtension { get; set; } diff --git a/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs b/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs new file mode 100644 index 00000000..0c1fc618 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs @@ -0,0 +1,28 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace CoreEx.AspNetCore.WebApis +{ + /// + /// Extends an to enable customization of the resulting using the and functions. + /// + public interface IExtendedActionResult : IActionResult + { + /// + /// Gets or sets the function to perform the extended customization. + /// + [JsonIgnore] + Func? BeforeExtension { get; set; } + + /// + /// Gets or sets the function to perform the extended customization. + /// + [JsonIgnore] + Func? AfterExtension { get; set; } + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs index ca8d4e71..405be475 100644 --- a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs @@ -81,7 +81,7 @@ public override Task ExecuteResultAsync(ActionContext context) } /// - /// Creates the as either or as per ; unless is an instance of which will return as-is. + /// Creates the as either or as per ; unless is an instance of which will return as-is. /// /// The value. /// The primary status code where there is a value. @@ -91,11 +91,11 @@ public override Task ExecuteResultAsync(ActionContext context) /// Indicates whether to check for by comparing request and response values. /// The . /// The . - public static IActionResult CreateResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location) + internal static IActionResult CreateResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location) => TryCreateValueContentResult(value, statusCode, alternateStatusCode, jsonSerializer, requestOptions, checkForNotModified, location, out var pr, out var ar) ? pr! : ar!; /// - /// Try and create an as either or as per ; unless is an instance of which will return as-is. + /// Try and create an as either or as per ; unless is an instance of which will return as-is. /// /// The value. /// The primary status code where there is a value. @@ -107,7 +107,7 @@ public static IActionResult CreateResult(T value, HttpStatusCode statusCode, /// The where created. /// The alternate result where no . /// true indicates that the was created; otherwise, false for creation. - public static bool TryCreateValueContentResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location, out IActionResult? primaryResult, out IActionResult? alternateResult) + internal static bool TryCreateValueContentResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location, out IActionResult? primaryResult, out IActionResult? alternateResult) { if (value is Results.IResult) throw new ArgumentException($"The {nameof(value)} must not implement {nameof(Results.IResult)}; the underlying {nameof(Results.IResult.Value)} must be unwrapped before invoking.", nameof(value)); @@ -182,10 +182,10 @@ public static bool TryCreateValueContentResult(T value, HttpStatusCode status ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled; // Check for not-modified and return status accordingly. - if (checkForNotModified && result.etag == requestOptions.ETag) + if (checkForNotModified && requestOptions.ETag is not null && result.etag == requestOptions.ETag) { primaryResult = null; - alternateResult = new StatusCodeResult((int)HttpStatusCode.NotModified); + alternateResult = new ExtendedStatusCodeResult(HttpStatusCode.NotModified); return false; } diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs b/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs index 4b625e20..b69eddff 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs @@ -61,7 +61,7 @@ public abstract class WebApiBase(ExecutionContext executionContext, SettingsBase /// Gets or sets the list of secondary correlation identifier names. /// /// Searches the for or one of the other to determine the (uses first value found in sequence). - public IEnumerable SecondaryCorrelationIdNames { get; set; } = new string[] { "x-ms-client-tracking-id" }; + public IEnumerable SecondaryCorrelationIdNames { get; set; } = ["x-ms-client-tracking-id"]; /// /// Gets the list of correlation identifier names, being and (inclusive). @@ -69,7 +69,7 @@ public abstract class WebApiBase(ExecutionContext executionContext, SettingsBase /// The list of correlation identifier names. public virtual IEnumerable GetCorrelationIdNames() { - var list = new List(new string[] { HttpConsts.CorrelationIdHeaderName }); + var list = new List([HttpConsts.CorrelationIdHeaderName]); list.AddRange(SecondaryCorrelationIdNames); return list; } @@ -173,7 +173,9 @@ public static async Task CreateActionResultFromExceptionAsync(Web if (owner is not null && !owner.Invoker.CatchAndHandleExceptions) throw exception; - logger.LogDebug("WebApi error: {Error} [{Type}]", exception.Message, exception.GetType().Name); + // Also check for an inner IExtendedException where the outer is an AggregateException; if so, then use. + if (exception is AggregateException aex && aex.InnerException is not null && aex.InnerException is IExtendedException) + exception = aex.InnerException; IActionResult? ar = null; if (exception is IExtendedException eex) diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs b/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs index 72de888f..45cfd480 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Primitives; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -55,9 +54,6 @@ protected async override Task OnInvokeAsync(InvokeArgs invokeA // Start logging scope and begin work. using (owner.Logger.BeginScope(new Dictionary() { { HttpConsts.CorrelationIdHeaderName, owner.ExecutionContext.CorrelationId } })) { - owner.Logger.LogDebug("WebApi started."); - var stopwatch = owner.Logger.IsEnabled(LogLevel.Debug) ? Stopwatch.StartNew() : null; - try { return await func(invokeArgs, cancellationToken).ConfigureAwait(false); @@ -71,14 +67,6 @@ protected async override Task OnInvokeAsync(InvokeArgs invokeA owner.Logger.LogDebug("WebApi unhandled exception: {Error} [{Type}]", ex.Message, ex.GetType().Name); throw; } - finally - { - if (stopwatch is not null) - { - stopwatch.Stop(); - owner.Logger.LogDebug("WebApi elapsed: {Elapsed}ms.", stopwatch.Elapsed.TotalMilliseconds); - } - } } } } diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs b/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs index 4c003d3c..f5b999ad 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs @@ -2,7 +2,9 @@ using CoreEx.Entities; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using System; +using System.Net; namespace CoreEx.AspNetCore.WebApis { @@ -12,6 +14,7 @@ namespace CoreEx.AspNetCore.WebApis /// The parent instance. /// The . /// The . + /// This enables access to the corresponding , , , etc. public class WebApiParam(WebApiBase webApi, WebApiRequestOptions requestOptions, OperationType operationType = OperationType.Unspecified) { /// @@ -55,5 +58,23 @@ public T InspectValue(T value) return value; } + + /// + /// Creates the as either or () as per ; unless is an instance of which will return as-is. + /// + /// The value. + /// The primary status code where there is a value. + /// The alternate status code where there is not a value (i.e. null). + /// Indicates whether to check for by comparing request and response values. + /// The . + /// The . + public IActionResult CreateActionResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode = null, bool checkForNotModified = true, Uri? location = null) + => ValueContentResult.CreateResult(value, statusCode, alternateStatusCode, WebApi.JsonSerializer, RequestOptions, checkForNotModified, location); + + /// + /// Creates the as a (). + /// + /// The status code. + public static IActionResult CreateActionResult(HttpStatusCode statusCode) => new ExtendedStatusCodeResult(statusCode); } } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs b/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs index d18e7539..28d024e5 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs @@ -5,6 +5,7 @@ namespace CoreEx.AspNetCore.WebApis /// /// Represents a parameter with a request . /// + /// This enables access to the corresponding , , , deserialized , etc. public class WebApiParam : WebApiParam { /// diff --git a/src/CoreEx.Azure/CoreEx.Azure.csproj b/src/CoreEx.Azure/CoreEx.Azure.csproj index e099bb24..b1d74da9 100644 --- a/src/CoreEx.Azure/CoreEx.Azure.csproj +++ b/src/CoreEx.Azure/CoreEx.Azure.csproj @@ -15,12 +15,12 @@ - - + + - + diff --git a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj index e2498db6..37fcb0df 100644 --- a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj +++ b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/CoreEx.Data/Querying/QueryArgsConfig.cs b/src/CoreEx.Data/Querying/QueryArgsConfig.cs index c412fa00..71c13c1c 100644 --- a/src/CoreEx.Data/Querying/QueryArgsConfig.cs +++ b/src/CoreEx.Data/Querying/QueryArgsConfig.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Entities; +using CoreEx.Results; using System; namespace CoreEx.Data.Querying @@ -60,5 +61,45 @@ public QueryArgsConfig WithOrderBy(Action orderBy) orderBy.ThrowIfNull(nameof(orderBy))(OrderByParser); return this; } + + /// + /// Parses and converst the and to dynamic LINQ. + /// + /// The . + /// The . + public Result Parse(QueryArgs? queryArgs) + { + if (queryArgs is null) + return new QueryArgsParseResult(); + + Result filterParserResult = default; + Result orderByParserResult = default; + + if (!string.IsNullOrEmpty(queryArgs.Filter)) + { + if (HasFilterParser) + { + filterParserResult = FilterParser.Parse(queryArgs.Filter); + if (filterParserResult.IsFailure) + return filterParserResult.AsResult(); + } + else + return new QueryFilterParserException("Filter statement is not currently supported."); + } + + if (!string.IsNullOrEmpty(queryArgs.OrderBy)) + { + if (HasOrderByParser) + { + orderByParserResult = OrderByParser.Parse(queryArgs.OrderBy); + if (orderByParserResult.IsFailure) + return orderByParserResult.AsResult(); + } + else + return new QueryOrderByParserException("OrderBy statement is not currently supported."); + } + + return new QueryArgsParseResult(filterParserResult, orderByParserResult); + } } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryArgsParseResult.cs b/src/CoreEx.Data/Querying/QueryArgsParseResult.cs new file mode 100644 index 00000000..2cb0b31e --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryArgsParseResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying +{ + /// + /// Represents the result. + /// + public sealed class QueryArgsParseResult + { + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + internal QueryArgsParseResult(QueryFilterParserResult? filterResult = null, QueryOrderByParserResult? orderByResult = null) + { + FilterResult = filterResult; + OrderByResult = orderByResult; + } + + /// + /// Gets the . + /// + public QueryFilterParserResult? FilterResult { get; } + + /// + /// Gets the . + /// + public QueryOrderByParserResult? OrderByResult { get; } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs index e602c565..7a06d7b4 100644 --- a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs +++ b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs @@ -13,6 +13,25 @@ namespace System.Linq /// public static class QueryFilterExtensions { + /// + /// Adds a dynamic query filter as specified by the (uses the where not ). + /// + /// The being queried. + /// The query. + /// The . + /// The query. + public static IQueryable Where(this IQueryable query, QueryArgsParseResult result) + { + result.ThrowIfNull(nameof(result)); + if (result.FilterResult is not null) + { + var linq = result.FilterResult.ToLinqString(); + query = string.IsNullOrEmpty(linq) ? query : query.Where(linq, [.. result.FilterResult.Args]); + } + + return query; + } + /// /// Adds a dynamic query filter as specified by the (uses the where not ). /// @@ -37,9 +56,28 @@ public static IQueryable Where(this IQueryable query, QueryArgsConfig q if (!queryConfig.HasFilterParser && !string.IsNullOrEmpty(filter)) 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]); + var result = queryConfig.FilterParser.Parse(filter).ThrowOnError(); + var linq = result.Value.ToLinqString(); + return string.IsNullOrEmpty(linq) ? query : query.Where(linq, [.. result.Value.Args]); + } + + /// + /// Adds a dynamic query order by as specified by the (uses the where not ). + /// + /// The being queried. + /// The query. + /// The . + /// The query. + public static IQueryable OrderBy(this IQueryable query, QueryArgsParseResult result) + { + result.ThrowIfNull(nameof(result)); + if (result.OrderByResult is not null) + { + var linq = result.OrderByResult.ToLinqString(); + query = string.IsNullOrEmpty(linq) ? query : query.OrderBy(linq); + } + + return query; } /// @@ -51,16 +89,7 @@ public static IQueryable Where(this IQueryable query, QueryArgsConfig q /// 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 && !string.IsNullOrEmpty(queryArgs?.OrderBy)) - 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); - } + public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) => query.OrderBy(queryConfig, queryArgs?.OrderBy); /// /// Adds a dynamic query order (basic dynamic OData-like $orderby statement). @@ -76,8 +105,9 @@ public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig if (!queryConfig.HasOrderByParser && !string.IsNullOrEmpty(orderby)) throw new QueryOrderByParserException("OrderBy statement is not currently supported."); - var linq = queryConfig.OrderByParser.Parse(orderby.ThrowIfNullOrEmpty(nameof(orderby))); - return query.OrderBy(linq); + var result = queryConfig.OrderByParser.Parse(orderby).ThrowOnError(); + var linq = result.Value.ToLinqString(); + return string.IsNullOrEmpty(linq) ? query : query.OrderBy(linq); } } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParser.cs b/src/CoreEx.Data/Querying/QueryFilterParser.cs index c8292612..da69fe7e 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParser.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParser.cs @@ -2,6 +2,7 @@ using CoreEx.Data.Querying.Expressions; using CoreEx.RefData; +using CoreEx.Results; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -182,40 +183,47 @@ public IQueryFilterFieldConfig GetFieldConfig(QueryFilterToken token, string fil /// The query filter. /// The . /// Leverages the to perform the actual parsing. - public QueryFilterParserResult Parse(string? filter) + public Result Parse(string? filter) { if (!string.IsNullOrEmpty(filter) && filter.Equals("help", StringComparison.OrdinalIgnoreCase)) - throw new QueryFilterParserException(ToString()); + return new QueryFilterParserException(ToString()); var result = new QueryFilterParserResult(); - // Append all the expressions to the resulting LINQ whilst parsing. - foreach (var expression in GetExpressions(filter)) + try { - if (expression is IQueryFilterFieldStatementExpression fse) + // Append all the expressions to the resulting LINQ whilst parsing. + foreach (var expression in GetExpressions(filter)) { - result.Fields.Add(fse.FieldConfig.Field); - if (fse.FieldConfig.ResultWriter is not null && fse.FieldConfig.ResultWriter.Invoke(fse, result)) - continue; + if (expression is IQueryFilterFieldStatementExpression fse) + { + result.Fields.Add(fse.FieldConfig.Field); + if (fse.FieldConfig.ResultWriter is not null && fse.FieldConfig.ResultWriter.Invoke(fse, result)) + continue; + } + + expression.WriteToResult(result); } - expression.WriteToResult(result); - } + // Append any default statements where no fields are in the filter. + foreach (var statement in _fields.Where(x => x.Value.DefaultStatement is not null && !result.Fields.Contains(x.Key)).Select(x => x.Value.DefaultStatement!)) + { + var stmt = statement(); + if (stmt is not null) + result.AppendStatement(stmt); + } + + // Uses the default statement where no fields were specified (or defaulted). + result.UseDefault(_defaultStatement); - // Append any default statements where no fields are in the filter. - foreach (var statement in _fields.Where(x => x.Value.DefaultStatement is not null && !result.Fields.Contains(x.Key)).Select(x => x.Value.DefaultStatement!)) + // Last chance ;-) + _onQuery?.Invoke(result); + } + catch (QueryFilterParserException qfpex) { - var stmt = statement(); - if (stmt is not null) - result.AppendStatement(stmt); + return qfpex; } - // Uses the default statement where no fields were specified (or defaulted). - result.UseDefault(_defaultStatement); - - // Last chance ;-) - _onQuery?.Invoke(result); - return result; } diff --git a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs index 0be5c0ad..cc616c82 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs @@ -7,10 +7,15 @@ namespace CoreEx.Data.Querying { /// - /// Represents the result of a successful . + /// Represents the result of a . /// public sealed class QueryFilterParserResult { + /// + /// Initializes a new instance of the that is a success. + /// + internal QueryFilterParserResult() { } + /// /// Gets the field names referenced within the resulting LINQ query. /// @@ -26,9 +31,10 @@ public sealed class QueryFilterParserResult /// public List Args { get; } = []; - /// - /// Provides the resulting dynamic LINQ filter. - public override string? ToString() => FilterBuilder.ToString(); + /// + /// Provides the resulting dynamic LINQ filter. + /// + public string? ToLinqString() => FilterBuilder.ToString(); /// /// Appends a value to the as a placeholder and adds into the . diff --git a/src/CoreEx.Data/Querying/QueryOrderByParser.cs b/src/CoreEx.Data/Querying/QueryOrderByParser.cs index d1f67cb2..c7c792ae 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParser.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParser.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Results; using System; using System.Collections.Generic; using System.Text; @@ -97,17 +98,20 @@ public QueryOrderByParser WithValidator(Action? validator) /// Parses and converts the to dynamic LINQ. /// /// The query order-by. - /// The dynamic LINQ equivalent. - public string Parse(string? orderBy) + /// The . + public Result Parse(string? orderBy) { if (!string.IsNullOrEmpty(orderBy) && orderBy.Equals("help", StringComparison.OrdinalIgnoreCase)) - throw new QueryOrderByParserException(ToString()); + return new QueryOrderByParserException(ToString()); + + if (string.IsNullOrEmpty(orderBy)) + return new QueryOrderByParserResult(DefaultOrderBy); 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)) + foreach (var sort in orderBy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var parts = sort.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); #else @@ -142,21 +146,28 @@ public string Parse(string? orderBy) direction = QueryOrderByDirection.Descending; } else if (!(dir.Length > 2 && nameof(QueryOrderByDirection.Ascending).StartsWith(dir, StringComparison.OrdinalIgnoreCase))) - throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); + return new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); if (!config.Direction.HasFlag(direction)) - throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; not supported."); + return new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; not supported."); } if (fields.Contains(config.Field)) - throw new QueryOrderByParserException($"Field '{field}' must not be specified more than once."); + return new QueryOrderByParserException($"Field '{field}' must not be specified more than once."); fields.Add(config.Field); } - _validator?.Invoke([.. fields]); + try + { + _validator?.Invoke([.. fields]); + } + catch (QueryOrderByParserException qobpex) + { + return qobpex; + } - return sb.ToString(); + return new QueryOrderByParserResult(sb.ToString()); } /// diff --git a/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs b/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs new file mode 100644 index 00000000..009e7500 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs @@ -0,0 +1,23 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying +{ + /// + /// Represents the result of . + /// + public sealed class QueryOrderByParserResult + { + private readonly string? _orderByStatement; + + /// + /// Initializes a new instance of the class. + /// + /// The resulting dynamic LINQ order by statement. + internal QueryOrderByParserResult(string? orderByStatement) => _orderByStatement = orderByStatement; + + /// + /// Provides the resulting dynamic LINQ order by. + /// + public string? ToLinqString() => _orderByStatement; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.Postgres/PostgresDatabase.cs b/src/CoreEx.Database.Postgres/PostgresDatabase.cs index 48563c4a..0acc31bf 100644 --- a/src/CoreEx.Database.Postgres/PostgresDatabase.cs +++ b/src/CoreEx.Database.Postgres/PostgresDatabase.cs @@ -7,6 +7,8 @@ using System; using System.Linq; using System.Data.Common; +using System.Threading.Tasks; +using System.Threading; namespace CoreEx.Database.Postgres { @@ -32,6 +34,12 @@ public class PostgresDatabase(Func create, ILoggerDo not update the default properties directly as a shared static instance is used (unless this is the desired behaviour); create a new instance for overridding. public new PostgresDatabaseColumns DatabaseColumns { get; set; } = _defaultColumns; + /// + /// Gets or sets the stored procedure name used by . + /// + /// Defaults to '"public"."sp_set_session_context"'. + public string SessionContextStoredProcedure { get; set; } = "\"public\".\"sp_set_session_context\""; + /// public override IConverter RowVersionConverter => EncodedStringToUInt32Converter.Default; @@ -52,12 +60,52 @@ public class PostgresDatabase(Func create, ILoggerOverrides the . public string[]? DuplicateErrorNumbers { get; set; } + /// + /// Sets the PostgreSQL context using the specified values by invoking the using parameters named , + /// , and . + /// + /// The username (where null the value will default to ). + /// The timestamp (where null the value will default to ). + /// The tenant identifer (where null the value will not be used). + /// The unique user identifier (where null the value will not be used). + /// The . + /// See . + public Task SetPostgresSessionContextAsync(string? username, DateTime? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(SessionContextStoredProcedure)) + throw new InvalidOperationException("The SessionContextStoredProcedure property must have a value."); + + return Invoker.InvokeAsync(this, username, timestamp, tenantId, userId, async (_, username, timestamp, tenantId, userId, ct) => + { + return await StoredProcedure(SessionContextStoredProcedure) + .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) + .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? ExecutionContext.SystemTime.UtcNow) + .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") + .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") + .NonQueryAsync(ct).ConfigureAwait(false); + }, cancellationToken, nameof(SetPostgresSessionContextAsync)); + } + + /// + /// Sets the PostgreSQL session context using the . + /// + /// The . Defaults to . + /// The . + /// See for more information. + public Task SetPostgresSessionContextAsync(ExecutionContext? executionContext = null, CancellationToken cancellationToken = default) + { + var ec = executionContext ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current : null); + return (ec == null) + ? SetPostgresSessionContextAsync(null!, null, cancellationToken: cancellationToken) + : SetPostgresSessionContextAsync(ec.UserName, ec.Timestamp, ec.TenantId, ec.UserId, cancellationToken); + } + /// protected override Result? OnDbException(DbException dbex) { if (ThrowTransformedException && dbex is PostgresException pex) { - var msg = pex.Message?.TrimEnd(); + var msg = pex.MessageText?.TrimEnd(); if (string.IsNullOrEmpty(msg)) msg = null; diff --git a/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs b/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs index 4014f2e1..5732bf01 100644 --- a/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs +++ b/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs @@ -1,6 +1,7 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Database.Extended; +using CoreEx.Entities; namespace CoreEx.Database.Postgres { @@ -11,6 +12,26 @@ namespace CoreEx.Database.Postgres /// and for more information. public class PostgresDatabaseColumns : DatabaseColumns { + /// + /// Gets or sets the session context 'Username' column name. + /// + public string SessionContextUsernameName { get; set; } = "Username"; + + /// + /// Gets or sets the session context 'Timestamp' column name. + /// + public string SessionContextTimestampName { get; set; } = "Timestamp"; + + /// + /// Gets or sets the column name. + /// + public string SessionContextTenantIdName { get; set; } = "TenantId"; + + /// + /// Gets or sets the session context 'UserId' column name. + /// + public string SessionContextUserIdName { get; set; } = "UserId"; + /// /// Initializes a new instance of the class. /// diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs index 31533d6f..ba44cff3 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs @@ -39,7 +39,7 @@ public class SqlServerDatabase(Func create, ILogger StringToBase64Converter.Default; /// - /// Gets or sets the stored procedure name used by . + /// Gets or sets the stored procedure name used by . /// /// Defaults to '[dbo].[spSetSessionContext]'. public string SessionContextStoredProcedure { get; set; } = "[dbo].[spSetSessionContext]"; @@ -65,13 +65,13 @@ public class SqlServerDatabase(Func create, ILogger using parameters named , /// , and . /// - /// The username. + /// The username (where null the value will default to ). /// The timestamp (where null the value will default to ). /// The tenant identifer (where null the value will not be used). /// The unique user identifier (where null the value will not be used). /// The . /// See . - public Task SetSqlSessionContextAsync(string username, DateTime? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) + public Task SetSqlSessionContextAsync(string? username, DateTime? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(SessionContextStoredProcedure)) throw new InvalidOperationException("The SessionContextStoredProcedure property must have a value."); @@ -80,7 +80,7 @@ public Task SetSqlSessionContextAsync(string username, DateTime? timestamp, stri { return await StoredProcedure(SessionContextStoredProcedure) .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) - .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? Entities.Cleaner.Clean(DateTime.UtcNow)) + .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? ExecutionContext.SystemTime.UtcNow) .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") .NonQueryAsync(ct).ConfigureAwait(false); diff --git a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj index 952c163d..a41528c6 100644 --- a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj +++ b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj b/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj index 6cc4655f..f2f8e8ba 100644 --- a/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj +++ b/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj index 04b394c5..e7be84e9 100644 --- a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj +++ b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/CoreEx.Validation/ValueValidationConfiguration.cs b/src/CoreEx.Validation/ValueValidationConfiguration.cs index 208ddb83..6bbb7f3c 100644 --- a/src/CoreEx.Validation/ValueValidationConfiguration.cs +++ b/src/CoreEx.Validation/ValueValidationConfiguration.cs @@ -20,19 +20,16 @@ public class ValueValidatorConfiguration : PropertyRuleBase - /// + /// Performs the underlying validation. /// /// The . /// The . /// The . - internal Task, T>> ValidateAsync(ValidationValue validationValue, CancellationToken cancellationToken) + internal async Task, T>> ValidateAsync(ValidationValue validationValue, CancellationToken cancellationToken) { - return ValidationInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - var ctx = new PropertyContext, T>(new ValidationContext>(validationValue, new ValidationArgs()), validationValue.Value, Name, JsonName, Text); - await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); - return new ValueValidatorResult, T>(ctx); - }, cancellationToken); + var ctx = new PropertyContext, T>(new ValidationContext>(validationValue, new ValidationArgs()), validationValue.Value, Name, JsonName, Text); + await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); + return new ValueValidatorResult, T>(ctx); } } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValueValidator.cs b/src/CoreEx.Validation/ValueValidator.cs index bbef87b4..1176b1ee 100644 --- a/src/CoreEx.Validation/ValueValidator.cs +++ b/src/CoreEx.Validation/ValueValidator.cs @@ -49,7 +49,8 @@ public ValueValidator Configure(Action>? valid /// /// The . /// The . - public Task, T>> ValidateAsync(CancellationToken cancellationToken = default) => _configuration.ValidateAsync(_validationValue, cancellationToken); + public Task, T>> ValidateAsync(CancellationToken cancellationToken = default) + => ValidationInvoker.Current.InvokeAsync(this, (_, cancellationToken) => _configuration.ValidateAsync(_validationValue, cancellationToken), cancellationToken); /// /// Validates the . diff --git a/src/CoreEx/CoreEx.csproj b/src/CoreEx/CoreEx.csproj index f9700a56..f026c4a2 100644 --- a/src/CoreEx/CoreEx.csproj +++ b/src/CoreEx/CoreEx.csproj @@ -13,16 +13,16 @@ - + - - - - + + + + - - + + @@ -35,11 +35,11 @@ - + - + @@ -48,7 +48,7 @@ - + @@ -61,13 +61,13 @@ - + - + diff --git a/src/CoreEx/Entities/CompositeKey.cs b/src/CoreEx/Entities/CompositeKey.cs index facc538f..d8e70071 100644 --- a/src/CoreEx/Entities/CompositeKey.cs +++ b/src/CoreEx/Entities/CompositeKey.cs @@ -73,6 +73,8 @@ public CompositeKey(params object?[] args) return; } +#if !RELEASE + // Validate supported types in Debug-mode only; fast-path for Release. object? temp; for (int idx = 0; idx < args.Length; idx++) { @@ -94,6 +96,7 @@ public CompositeKey(params object?[] args) + "string, char, short, int, long, ushort, uint, ulong, Guid, DateTime and DateTimeOffset.") }; } +#endif #if NET8_0_OR_GREATER _args = [.. args]; diff --git a/src/CoreEx/Invokers/InvokeArgs.cs b/src/CoreEx/Invokers/InvokeArgs.cs index a96c9259..36e38ae9 100644 --- a/src/CoreEx/Invokers/InvokeArgs.cs +++ b/src/CoreEx/Invokers/InvokeArgs.cs @@ -3,6 +3,7 @@ using CoreEx.Configuration; using CoreEx.Results; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; using System.Diagnostics; @@ -14,7 +15,7 @@ namespace CoreEx.Invokers /// public struct InvokeArgs { - private static readonly ConcurrentDictionary _invokerOptions = new(); + private static readonly ConcurrentDictionary _invokerOptions = new(); private const string NullName = "null"; private const string InvokerType = "invoker.type"; @@ -29,6 +30,35 @@ public struct InvokeArgs private bool _isComplete; + /// + /// Determines whether tracing is enabled for the . + /// + private static bool IsTracingEnabled(Type invokerType) + { + var settings = ExecutionContext.GetService() ?? new DefaultSettings(ExecutionContext.GetService()); + if (settings.Configuration is null) + return true; + + return settings.GetValue($"Invokers:{invokerType.FullName}:TracingEnabled") ?? settings.GetValue("Invokers:Default:TracingEnabled") ?? true; + } + + /// + /// Determines whether logging is enabled for the . + /// + private static bool IsLoggingEnabled(Type invokerType) + { + var settings = ExecutionContext.GetService() ?? new DefaultSettings(ExecutionContext.GetService()); + if (settings.Configuration is null) + return true; + + return settings.GetValue($"Invokers:{invokerType.FullName}:LoggingEnabled") ?? settings.GetValue("Invokers:Default:LoggingEnabled") ?? true; + } + + /// + /// Initializes a new instance of the struct. + /// + public InvokeArgs() => Type = typeof(Invoker); + /// /// Initializes a new instance of the struct. /// @@ -40,42 +70,62 @@ public struct InvokeArgs /// meant to represent the fully-qualified member/method name. public InvokeArgs(Type invokerType, Type? ownerType, string? memberName, InvokeArgs? invokeArgs) { + Type = invokerType; + OwnerType = ownerType; + MemberName = memberName; + try { - var options = _invokerOptions.GetOrAdd(invokerType, type => (new ActivitySource(type.FullName ?? NullName), IsTracingEnabled(invokerType))); - if (!options.IsTracingEnabled) - return; - - Activity = options.ActivitySource.CreateActivity(ownerType is null ? memberName ?? NullName : $"{ownerType.FullName} -> {memberName ?? NullName}", ActivityKind.Internal); - if (Activity is not null) + var options = _invokerOptions.GetOrAdd(invokerType, type => (new ActivitySource(type.FullName ?? NullName), IsTracingEnabled(invokerType), IsLoggingEnabled(invokerType))); + if (options.IsTracingEnabled) { - if (invokeArgs.HasValue && invokeArgs.Value.Activity is not null) - Activity.SetParentId(invokeArgs.Value.Activity!.TraceId, invokeArgs.Value.Activity.SpanId, invokeArgs.Value.Activity.ActivityTraceFlags); + Activity = options.ActivitySource.CreateActivity(ownerType is null ? memberName ?? NullName : $"{ownerType}->{memberName ?? NullName}", ActivityKind.Internal); + if (Activity is not null) + { + if (invokeArgs.HasValue && invokeArgs.Value.Activity is not null) + Activity.SetParentId(invokeArgs.Value.Activity!.TraceId, invokeArgs.Value.Activity.SpanId, invokeArgs.Value.Activity.ActivityTraceFlags); + + Activity.SetTag(InvokerType, options.ActivitySource!.Name); + Activity.SetTag(InvokerOwner, ownerType?.FullName); + Activity.SetTag(InvokerMember, memberName); + Activity.Start(); + } + } - Activity.SetTag(InvokerType, options.ActivitySource!.Name); - Activity.SetTag(InvokerOwner, ownerType?.FullName); - Activity.SetTag(InvokerMember, memberName); - Activity.Start(); + if (options.IsLoggingEnabled) + { + Logger = ExecutionContext.GetService>(); + if (Logger is null || !Logger.IsEnabled(LogLevel.Debug)) + Logger = null; + else + { + Logger.LogDebug("{InvokerType}: Start {InvokerCaller}.", invokerType.ToString(), FormatCaller()); + Stopwatch = Stopwatch.StartNew(); + } } } catch { - // Continue; do not allow tracing to impact the execution. + // Continue; do not allow tracing/logging to impact the execution. Activity = null; + Logger = null; } } /// - /// Determines whether tracing is enabled for the . + /// Gets the invoker . /// - private static bool IsTracingEnabled(Type invokerType) - { - var settings = ExecutionContext.GetService() ?? new DefaultSettings(ExecutionContext.GetService()); - if (settings.Configuration is null) - return true; + public Type Type { get; } - return settings.GetValue($"Invokers:{invokerType.FullName}:TracingEnabled") ?? settings.GetValue("Invokers:Default:TracingEnabled") ?? true; - } + /// + /// Gets the invoking (owner) + /// + public Type? OwnerType { get; } + + /// + /// Gets the calling member name. + /// + public string? MemberName { get; } /// /// Gets the leveraged for standardized (open-telemetry) tracing. @@ -83,6 +133,21 @@ private static bool IsTracingEnabled(Type invokerType) /// Will be null where tracing is not enabled. public Activity? Activity { get; } + /// + /// Gets the leveraged for standardized invoker logging. + /// + public ILogger? Logger { get; } + + /// + /// Gets the leveraged for standardized invoker timing. + /// + public Stopwatch? Stopwatch { get; } + + /// + /// Formats the caller. + /// + private readonly string FormatCaller() => OwnerType is null ? MemberName ?? NullName : $"{OwnerType}->{MemberName ?? NullName}"; + /// /// Adds the result outcome to the (where started). /// @@ -97,8 +162,18 @@ public TResult TraceResult(TResult result) var ir = result as IResult; Activity.SetTag(InvokerResult, ir is null ? CompleteState : (ir.IsSuccess ? SuccessState : FailureState)); if (ir is not null && ir.IsFailure) - Activity.SetTag(InvokerFailure, $"{ir.Error.Message} [{ir.Error.GetType().Name}]"); + Activity.SetTag(InvokerFailure, $"{ir.Error.Message} ({ir.Error.GetType()})"); + + _isComplete = true; + } + if (Logger is not null) + { + Stopwatch!.Stop(); + var ir = result as IResult; + Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller}{InvokerFailure} [{Elapsed}ms].", + Type.ToString(), ir is null ? CompleteState : (ir.IsSuccess ? SuccessState : FailureState), FormatCaller(), (ir is not null && ir.IsFailure) ? $" {ir.Error.Message} ({ir.Error.GetType()})" : string.Empty, Stopwatch.Elapsed.TotalMilliseconds); + _isComplete = true; } @@ -109,12 +184,19 @@ public TResult TraceResult(TResult result) /// Completes the tracing (where started) recording the with the and capturing the corresponding . /// /// The . - public void TraceException(Exception ex) + public void TraceException(Exception ex) { if (Activity is not null && ex is not null) { Activity.SetTag(InvokerResult, ExceptionState); - Activity.SetTag(InvokerFailure, $"{ex.Message} [{ex.GetType().Name}]"); + Activity.SetTag(InvokerFailure, $"{ex.Message} ({ex.GetType()})"); + _isComplete = true; + } + + if (Logger is not null && ex is not null) + { + Stopwatch!.Stop(); + Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller}{InvokerFailure} [{Elapsed}ms].", Type.ToString(), ExceptionState, FormatCaller(), $" {ex.Message} ({ex.GetType()})", Stopwatch.Elapsed.TotalMilliseconds); _isComplete = true; } } @@ -133,6 +215,12 @@ public readonly void TraceComplete() Activity.Stop(); } + + if (Logger is not null && !_isComplete) + { + Stopwatch!.Stop(); + Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller} [{Elapsed}ms].", Type.ToString(), ExceptionState, FormatCaller(), Stopwatch.Elapsed.TotalMilliseconds); + } } /// diff --git a/src/CoreEx/Invokers/Invoker.cs b/src/CoreEx/Invokers/Invoker.cs index b5e05fd0..8db4b485 100644 --- a/src/CoreEx/Invokers/Invoker.cs +++ b/src/CoreEx/Invokers/Invoker.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Results; using System; using System.Threading; using System.Threading.Tasks; @@ -10,7 +9,7 @@ namespace CoreEx.Invokers /// /// Provides invoking capabilities including and to execute an async synchronously. /// - public static class Invoker + public class Invoker { private static readonly TaskFactory _taskFactory = new(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default); @@ -45,5 +44,10 @@ public static class Invoker /// The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many articles /// written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. This implementation has been inspired by . public static T RunSync(Func> task) => _taskFactory.StartNew(task.ThrowIfNull(nameof(task))).Unwrap().GetAwaiter().GetResult(); + + /// + /// Private constructor. + /// + private Invoker() { } } } \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs index bc4accb9..671371bc 100644 --- a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs +++ b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs @@ -266,11 +266,11 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere var coll = await OnGetOrCreateAsync(type, (t, ct) => { - return ReferenceDataOrchestratorInvoker.Current.Invoke(this, async ia => + return ReferenceDataOrchestratorInvoker.Current.InvokeAsync(this, async (ia, ct) => { if (ia.Activity is not null) { - ia.Activity.AddTag(InvokerCacheType, type.FullName); + ia.Activity.AddTag(InvokerCacheType, type.ToString()); ia.Activity.AddTag(InvokerCacheState, "TaskRun"); } @@ -282,11 +282,17 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere Task task; using (System.Threading.ExecutionContext.SuppressFlow()) { - task = Task.Run(() => GetByTypeInternalAsync(rdo, ec, scope, t, providerType, ia, ct)); + task = Task.Run(async () => await GetByTypeInNewScopeAsync(rdo, ec, scope, t, providerType, ia, ct).ConfigureAwait(false)); } - return await task.ConfigureAwait(false); - }, nameof(GetByTypeAsync)); +#if NET6_0_OR_GREATER + return await task.WaitAsync(ct).ConfigureAwait(false); +#else + task.Wait(ct); + await Task.CompletedTask.ConfigureAwait(false); + return task.Result; +#endif + }, cancellationToken, nameof(GetByTypeAsync)); }, cancellationToken).ConfigureAwait(false); return coll ?? throw new InvalidOperationException($"The {nameof(IReferenceDataCollection)} returned for Type '{type.FullName}' from Provider '{providerType.FullName}' must not be null."); @@ -295,7 +301,7 @@ public ReferenceDataOrchestrator Register() where TProvider : IRefere /// /// Performs the actual reference data load in a new thread context / scope. /// - private async Task GetByTypeInternalAsync(ReferenceDataOrchestrator? rdo, ExecutionContext executionContext, IServiceScope scope, Type type, Type providerType, InvokeArgs invokeArgs, CancellationToken cancellationToken) + private async Task GetByTypeInNewScopeAsync(ReferenceDataOrchestrator? rdo, ExecutionContext executionContext, IServiceScope scope, Type type, Type providerType, InvokeArgs invokeArgs, CancellationToken cancellationToken) { _asyncLocal.Value = rdo; @@ -303,21 +309,21 @@ private async Task GetByTypeInternalAsync(ReferenceDat ExecutionContext.SetCurrent(executionContext); // Start related activity as this "work" is occuring on an unrelated different thread (by design to ensure complete separation). - var ria = invokeArgs.StartNewRelated(typeof(ReferenceDataOrchestratorInvoker), typeof(ReferenceDataOrchestrator), nameof(GetByTypeAsync)); + var ria = invokeArgs.StartNewRelated(typeof(ReferenceDataOrchestratorInvoker), typeof(ReferenceDataOrchestrator), nameof(GetByTypeInNewScopeAsync)); try { if (ria.Activity is not null) { - ria.Activity.AddTag(InvokerCacheType, type.FullName); + ria.Activity.AddTag(InvokerCacheType, type.ToString()); ria.Activity.AddTag(InvokerCacheState, "TaskWorker"); } var sw = Stopwatch.StartNew(); var provider = (IReferenceDataProvider)scope.ServiceProvider.GetRequiredService(providerType); - var coll = (await provider.GetAsync(type, cancellationToken).ConfigureAwait(false)).Value; + var coll = (await provider.GetAsync(type, cancellationToken).ConfigureAwait(false)).Value!; sw.Stop(); - Logger.LogInformation("Reference data type {RefDataType} cache load finish: {ItemCount} items cached [{Elapsed}ms]", type.FullName, coll.Count, sw.Elapsed.TotalMilliseconds); + Logger.LogInformation("Reference data type {RefDataType} cache load finish: {ItemCount} items cached [{Elapsed}ms]", type.ToString(), coll.Count, sw.Elapsed.TotalMilliseconds); ria.Activity?.AddTag(InvokerCacheCount, coll.Count); return ria.TraceResult(coll); diff --git a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj index d22881fc..bc790c99 100644 --- a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj +++ b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj @@ -18,11 +18,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -34,7 +34,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj b/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj index d5bb792f..7461ef4e 100644 --- a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj +++ b/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj @@ -13,10 +13,10 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CoreEx.Test/CoreEx.Test.csproj b/tests/CoreEx.Test/CoreEx.Test.csproj index 212a7142..7e607c3d 100644 --- a/tests/CoreEx.Test/CoreEx.Test.csproj +++ b/tests/CoreEx.Test/CoreEx.Test.csproj @@ -8,9 +8,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,7 +23,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs index e2ea0fcb..57abe1e8 100644 --- a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs +++ b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs @@ -32,10 +32,11 @@ public class QueryArgsConfigTest private static void AssertFilter(QueryArgsConfig config, string? filter, string expected, params object[] expectedArgs) { var result = config.FilterParser.Parse(filter); + Assert.That(result.IsSuccess, Is.True); Assert.Multiple(() => { - Assert.That(result.ToString(), Is.EqualTo(expected)); - Assert.That(result.Args, Is.EquivalentTo(expectedArgs)); + Assert.That(result.Value.ToLinqString(), Is.EqualTo(expected)); + Assert.That(result.Value.Args, Is.EquivalentTo(expectedArgs)); }); } @@ -43,7 +44,12 @@ private static void AssertFilter(QueryArgsConfig config, string? filter, string private static void AssertException(QueryArgsConfig config, string? filter, string expected) { - var ex = Assert.Throws(() => config.FilterParser.Parse(filter)); + var result = config.FilterParser.Parse(filter); + Assert.That(result.IsFailure, Is.True); + Assert.That(result.Error, Is.TypeOf()); + + var ex = (QueryFilterParserException)result.Error; + Assert.That(ex.Messages, Is.Not.Null); Assert.That(ex.Messages, Has.Count.EqualTo(1)); Assert.Multiple(() => @@ -284,8 +290,9 @@ public void OrderByParser_Valid() { Assert.Multiple(() => { - Assert.That(_queryConfig.OrderByParser.Parse("firstname, birthday desc"), Is.EqualTo("FirstName, BirthDate desc")); - Assert.That(_queryConfig.OrderByParser.Parse("lastname asc, birthday desc"), Is.EqualTo("LastName, BirthDate desc")); + Assert.That(_queryConfig.OrderByParser.Parse("firstname, birthday desc").Value.ToLinqString(), Is.EqualTo("FirstName, BirthDate desc")); + Assert.That(_queryConfig.OrderByParser.Parse("lastname asc, birthday desc").Value.ToLinqString(), Is.EqualTo("LastName, BirthDate desc")); + Assert.That(_queryConfig.OrderByParser.Parse(null).Value.ToLinqString(), Is.EqualTo("LastName, FirstName")); }); } @@ -294,7 +301,7 @@ public void OrderByParser_Invalid() { void AssertException(string? orderBy, string expected) { - var ex = Assert.Throws(() => _queryConfig.OrderByParser.Parse(orderBy)); + var ex = Assert.Throws(() => _queryConfig.OrderByParser.Parse(orderBy).ThrowOnError()); Assert.That(ex.Messages, Is.Not.Null); Assert.That(ex.Messages, Has.Count.EqualTo(1)); Assert.Multiple(() => @@ -323,5 +330,18 @@ public void OrderByParser_ToString() --- Note: The OData-like ordering is awesome!")); } + + [Test] + public void QueryArgsParse_Success() + { + var r = _queryConfig.Parse(new CoreEx.Entities.QueryArgs { Filter = "lastname eq 'Smith'", OrderBy = "firstname, birthday desc" }); + Assert.That(r.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(r.Value!.FilterResult!.ToLinqString(), Is.EqualTo("(LastName != null && LastName == @0)")); + Assert.That(r.Value.FilterResult.Args, Is.EquivalentTo(new object[] { "Smith" })); + Assert.That(r.Value.OrderByResult!.ToLinqString(), Is.EqualTo("FirstName, BirthDate desc")); + }); + } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs index a5566ac7..50dcab2b 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs @@ -6,6 +6,7 @@ using CoreEx.TestFunction; using CoreEx.TestFunction.Models; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using NUnit.Framework; using System; @@ -228,6 +229,37 @@ public void GetWithResultAsync_WithCollection_FieldsExclude() .AssertValue(new PersonCollection { new Person { Id = 1 } }); } + [Test] + public void GetWithResultAsync_OverrideIActionResult_OK() + { + static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); + + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); + req.ApplyETag("1JkYXLLxYY4Zw02qg/K2yVGH+J+oGshN7M/BlIH20/0="); + + test.Type() + .Run(f => f.GetWithResultAsync(req, Success)) + .ToActionResultAssertor() + .AssertOK() + .AssertContent("It works!"); + } + + [Test] + public void GetWithResultAsync_OverrideIActionResult_NotModified() + { + static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); + + using var test = FunctionTester.Create(); + var req = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); + req.ApplyETag("1JkYXLLxYY4Zw02qg/J2yVGH+J+oGshN7M/BlIH20/0="); + + test.Type() + .Run(f => f.GetWithResultAsync(req, Success)) + .ToActionResultAssertor() + .AssertNotModified(); + } + [Test] public void PostWithResultAsync_NoValueNoResult() { @@ -274,6 +306,19 @@ public void PostWithResultAsync_WithValueWithResult() .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.01m }); } + [Test] + public void PostWithResultAsync_OverrideIActionResult() + { + static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); + + using var test = FunctionTester.Create(); + test.Type() + .Run(f => f.PostWithResultAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), Success)) + .ToActionResultAssertor() + .AssertOK() + .AssertContent("It works!"); + } + [Test] public void PutWithResultAsync_WithValueNoResult() { diff --git a/tests/CoreEx.Test2/CoreEx.Test2.csproj b/tests/CoreEx.Test2/CoreEx.Test2.csproj index 37f68248..fe6f7c4a 100644 --- a/tests/CoreEx.Test2/CoreEx.Test2.csproj +++ b/tests/CoreEx.Test2/CoreEx.Test2.csproj @@ -10,9 +10,9 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj index 7c7d4292..f678482d 100644 --- a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj +++ b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj @@ -7,7 +7,7 @@ - + diff --git a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj b/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj index 96ed51d1..dd8790e8 100644 --- a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj +++ b/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj @@ -15,7 +15,7 @@ - +