Skip to content

Commit 2a26d55

Browse files
authored
v3.13.0 (#92)
* Changes as decribed in the log. * Further ETag testing and changes.
1 parent 7e45c7e commit 2a26d55

File tree

65 files changed

+1020
-379
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1020
-379
lines changed

CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
Represents the **NuGet** versions.
44

5+
## v3.13.0
6+
- *Enhancement*: Added `DatabaseMapperEx` enabling extended/explicit mapping where performance is critical versus existing that uses reflection and compiled expressions; can offer up to 40%+ improvement in some scenarios.
7+
- *Enhancement*: The `AddMappers<TAssembly>()` and `AddValidators<TAssembly>()` extension methods now also support two or three assembly specification overloads.
8+
- *Enhancement*: A `WorkState.UserName` has been added to enable the tracking of the user that initiated the work; this is then checked to ensure that only the initiating user can interact with their own work state.
9+
- *Fixed:* The `ReferenceDataOrchestrator.GetByTypeAsync` has had the previous sync-over-async corrected to be fully async.
10+
- *Fixed*: Validation extensions `Exists` and `ExistsAsync` which expect a non-null resultant value have been renamed to `ValueExists` and `ValueExistsAsync` to improve usability; also they are `IResult` aware and will act accordingly.
11+
- *Fixed*: The `ETag` HTTP handling has been updated to correctly output and expect the weak `W/"xxxx"` format.
12+
- *Fixed*: The `ETagGenerator` implementation has been further optimized to minimize unneccessary string allocations.
13+
- *Fixed*: The `ValueContentResult` will only generate a response header ETag (`ETagGenerator`) for a `GET` or `HEAD` request. The underlying result `IETag.ETag` is used as-is where there is no query string; otherwise, generates as assumes query string will alter result (i.e. filtering, paging, sorting, etc.). The result `IETag.ETag` is unchanged so the consumer can still use as required for a further operation.
14+
- *Fixed*: The `SettingsBase` has been optimized. The internal recursion checking has been removed and as such an endless loop (`StackOverflowException`) may occur where misconfigured; given frequency of `IConfiguration` usage the resulting performance is deemed more important. Additionally, `prefixes` are now optional.
15+
- The existing support of referencing a settings property by name (`settings.GetValue<T>("NamedProperty")`) and it using reflection to find before querying the `IConfiguration` has been removed. This was not a common, or intended usage, and was somewhat magical, and finally was non-performant.
16+
517
## v3.12.0
618
- *Enhancement*: Added new `CoreEx.Database.Postgres` project/package to support [PostgreSQL](https://www.postgresql.org/) database capabilities. Primarily encapsulates the open-source [`Npqsql`](https://www.npgsql.org/) .NET ADO database provider for PostgreSQL.
719
- Added `EncodedStringToUInt32Converter` to support PostgreSQL `xmin` column encoding as the row version/etag.

Common.targets

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>3.12.0</Version>
3+
<Version>3.13.0</Version>
44
<LangVersion>preview</LangVersion>
55
<Authors>Avanade</Authors>
66
<Company>Avanade</Company>

src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs

+58-85
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
using Microsoft.Net.Http.Headers;
1010
using System;
1111
using System.Collections;
12-
using System.Diagnostics.CodeAnalysis;
12+
using System.Collections.Generic;
1313
using System.Linq;
1414
using System.Net;
1515
using System.Net.Mime;
16-
using System.Text;
1716
using System.Threading.Tasks;
1817

1918
namespace CoreEx.AspNetCore.WebApis
@@ -70,7 +69,7 @@ public override Task ExecuteResultAsync(ActionContext context)
7069

7170
var headers = context.HttpContext.Response.GetTypedHeaders();
7271
if (ETag != null)
73-
headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag));
72+
headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag), true);
7473

7574
if (Location != null)
7675
headers.Location = Location;
@@ -149,127 +148,101 @@ public static bool TryCreateValueContentResult<T>(T value, HttpStatusCode status
149148
throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return.");
150149
}
151150

151+
// Where there is etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value.
152+
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
153+
var etag = value is IETag vetag ? vetag.ETag : null;
154+
if (etag is null)
155+
{
156+
if (isTextSerializationEnabled)
157+
ExecutionContext.Current.IsTextSerializationEnabled = false;
158+
159+
etag = ETagGenerator.Generate(jsonSerializer, value);
160+
if (value is IETag vetag2)
161+
vetag2.ETag = etag;
162+
}
163+
152164
// Where IncludeText is selected then enable before serialization occurs.
153165
if (requestOptions.IncludeText && ExecutionContext.HasCurrent)
154166
ExecutionContext.Current.IsTextSerializationEnabled = true;
155167

156-
// Serialize and generate the etag whilst also applying any filtering of the data where selected.
168+
// Serialize the value performing any filtering as per the request options.
157169
string? json = null;
158-
bool hasETag = TryGetETag(val, out var etag);
159-
160-
Action<IJsonPreFilterInspector>? inspector;
161170
if (requestOptions.IncludeFields != null && requestOptions.IncludeFields.Length > 0)
162-
{
163-
inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer);
164-
jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include, preFilterInspector: inspector);
165-
}
171+
jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include);
166172
else if (requestOptions.ExcludeFields != null && requestOptions.ExcludeFields.Length > 0)
167-
{
168-
inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer);
169-
jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude, preFilterInspector: inspector);
170-
}
173+
jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude);
171174
else
172-
{
173175
json = jsonSerializer.Serialize(val);
174-
if (!hasETag)
175-
etag = GenerateETag(requestOptions, val, json, jsonSerializer);
176-
}
176+
177+
// Generate the etag from the final JSON serialization and check for not-modified.
178+
var result = GenerateETag(requestOptions, val, json, jsonSerializer);
179+
180+
// Reset the text serialization flag.
181+
if (ExecutionContext.HasCurrent)
182+
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;
177183

178184
// Check for not-modified and return status accordingly.
179-
if (checkForNotModified && etag == requestOptions.ETag)
185+
if (checkForNotModified && result.etag == requestOptions.ETag)
180186
{
181187
primaryResult = null;
182188
alternateResult = new StatusCodeResult((int)HttpStatusCode.NotModified);
183189
return false;
184190
}
185191

186192
// Create and return the ValueContentResult.
187-
primaryResult = new ValueContentResult(json, statusCode, etag, paging, location);
193+
primaryResult = new ValueContentResult(result.json!, statusCode, result.etag ?? etag, paging, location);
188194
alternateResult = null;
189195
return true;
190196
}
191197

192198
/// <summary>
193-
/// Determines whether an <see cref="IETag.ETag"/> or <see cref="ExecutionContext.ETag"/> value exists and returns where found.
194-
/// </summary>
195-
/// <param name="value">The value.</param>
196-
/// <param name="etag">The ETag for the value where it exists.</param>
197-
/// <returns><c>true</c> indicates that the ETag value exists; otherwise, <c>false</c> to generate.</returns>
198-
internal static bool TryGetETag(object value, [NotNullWhen(true)] out string? etag)
199-
{
200-
if (value is IETag ietag && ietag.ETag != null)
201-
{
202-
etag = ietag.ETag;
203-
return true;
204-
}
205-
206-
if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null)
207-
{
208-
etag = ExecutionContext.Current.ETag;
209-
return true;
210-
}
211-
212-
etag = null;
213-
return false;
214-
}
215-
216-
/// <summary>
217-
/// Establish the ETag for the value/json.
199+
/// Establish (use existing or generate) the ETag for the value/json.
218200
/// </summary>
219201
/// <param name="requestOptions">The <see cref="WebApiRequestOptions"/>.</param>
220202
/// <param name="value">The value.</param>
221203
/// <param name="json">The value serialized to JSON.</param>
222204
/// <param name="jsonSerializer">The <see cref="IJsonSerializer"/>.</param>
223-
/// <remarks>It is expected that <see cref="TryGetETag(object, out string?)"/> is invoked prior to this to determine whether generation is required.</remarks>
224-
internal static string GenerateETag(WebApiRequestOptions requestOptions, object value, string? json, IJsonSerializer jsonSerializer)
205+
/// <returns>The etag and serialized JSON (where performed).</returns>
206+
internal static (string? etag, string? json) GenerateETag<T>(WebApiRequestOptions requestOptions, T value, string? json, IJsonSerializer jsonSerializer)
225207
{
226-
if (value is IETag etag && etag.ETag != null)
227-
return etag.ETag;
208+
// Where not a GET or HEAD then no etag is generated; just use what we have.
209+
if (!HttpMethods.IsGet(requestOptions.Request.Method) && !HttpMethods.IsHead(requestOptions.Request.Method))
210+
return (value is IETag etag ? etag.ETag : null, json);
228211

229-
if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null)
230-
return ExecutionContext.Current.ETag;
231-
232-
StringBuilder? sb = null;
233-
if (value is not string && value is IEnumerable coll)
212+
// Where no query string and there is an etag then that value should be leveraged as the fast-path.
213+
if (!requestOptions.HasQueryString)
234214
{
235-
sb = new StringBuilder();
236-
var hasEtags = true;
215+
if (value is IETag etag && etag.ETag != null)
216+
return (etag.ETag, json);
237217

238-
foreach (var item in coll)
218+
// Where there is a collection then we need to generate a hash that represents the collection.
219+
if (json is null && value is not string && value is IEnumerable coll)
239220
{
240-
if (item is IETag cetag && cetag.ETag != null)
241-
{
242-
if (sb.Length > 0)
243-
sb.Append(ETagGenerator.DividerCharacter);
221+
var hasEtags = true;
222+
var list = new List<string>();
244223

245-
sb.Append(cetag.ETag);
246-
continue;
224+
foreach (var item in coll)
225+
{
226+
if (item is IETag cetag && cetag.ETag is not null)
227+
{
228+
list.Add(cetag.ETag);
229+
continue;
230+
}
231+
232+
// No longer can fast-path as there is no ETag.
233+
hasEtags = false;
234+
break;
247235
}
248236

249-
hasEtags = false;
250-
break;
251-
}
252-
253-
if (!hasEtags)
254-
{
255-
sb.Clear();
256-
sb.Append(json ??= jsonSerializer.Serialize(value));
257-
}
258-
259-
// A GET with a collection result should include path and query with the etag.
260-
if (HttpMethods.IsGet(requestOptions.Request.Method))
261-
{
262-
sb.Append(ETagGenerator.DividerCharacter);
263-
264-
if (requestOptions.Request.Path.HasValue)
265-
sb.Append(requestOptions.Request.Path.Value);
266-
267-
sb.Append(requestOptions.Request.QueryString.ToString());
237+
// Where fast-path then return the hash for the etag list.
238+
if (hasEtags)
239+
return (ETagGenerator.GenerateHash([.. list]), json);
268240
}
269241
}
270242

271-
// Generate a hash to represent the ETag.
272-
return ETagGenerator.GenerateHash(sb != null && sb.Length > 0 ? sb.ToString() : json ?? jsonSerializer.Serialize(value));
243+
// Serialize and then generate a hash to represent the etag.
244+
json ??= jsonSerializer.Serialize(value);
245+
return (ETagGenerator.GenerateHash(requestOptions.HasQueryString ? [json, requestOptions.Request.QueryString.ToString()] : [json]), json);
273246
}
274247
}
275248
}

src/CoreEx.AspNetCore/WebApis/WebApi.cs

+17-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx
22

3+
using CoreEx.Abstractions;
34
using CoreEx.AspNetCore.Http;
45
using CoreEx.Configuration;
56
using CoreEx.Entities;
@@ -710,19 +711,29 @@ private async Task<IActionResult> PutInternalAsync<TValue>(HttpRequest request,
710711
/// </summary>
711712
private ConcurrencyException? ConcurrencyETagMatching<TValue>(WebApiParam wap, TValue getValue, TValue putValue, bool autoConcurrency)
712713
{
713-
var et = putValue as IETag;
714-
if (et != null || autoConcurrency)
714+
var ptag = putValue as IETag;
715+
if (ptag != null || autoConcurrency)
715716
{
716-
string? etag = et?.ETag ?? wap.RequestOptions.ETag;
717+
string? etag = wap.RequestOptions.ETag ?? ptag?.ETag;
717718
if (string.IsNullOrEmpty(etag))
718719
return new ConcurrencyException($"An 'If-Match' header is required for an HTTP {wap.Request.Method} where the underlying entity supports concurrency (ETag).");
719720

720721
if (etag != null)
721722
{
722-
if (!ValueContentResult.TryGetETag(getValue!, out var getEt))
723-
getEt = ValueContentResult.GenerateETag(wap.RequestOptions, getValue!, null, JsonSerializer);
723+
var gtag = getValue is IETag getag ? getag.ETag : null;
724+
if (gtag is null)
725+
{
726+
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
727+
if (isTextSerializationEnabled)
728+
ExecutionContext.Current.IsTextSerializationEnabled = false;
729+
730+
gtag = ETagGenerator.Generate(JsonSerializer, getValue);
731+
732+
if (ExecutionContext.HasCurrent)
733+
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;
734+
}
724735

725-
if (etag != getEt)
736+
if (etag != gtag)
726737
return new ConcurrencyException();
727738
}
728739
}

src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs

+16-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using CoreEx.Http;
55
using CoreEx.RefData;
66
using Microsoft.AspNetCore.Http;
7-
using Microsoft.Net.Http.Headers;
87
using System;
98
using System.Collections.Generic;
109
using System.Linq;
@@ -25,28 +24,29 @@ public class WebApiRequestOptions
2524
public WebApiRequestOptions(HttpRequest httpRequest)
2625
{
2726
Request = httpRequest.ThrowIfNull(nameof(httpRequest));
28-
GetQueryStringOptions(Request.Query);
29-
30-
if (httpRequest.Headers != null && httpRequest.Headers.Count > 0)
31-
{
32-
if (httpRequest.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var vals) || httpRequest.Headers.TryGetValue(HeaderNames.IfMatch, out vals))
33-
{
34-
var etag = vals.FirstOrDefault()?.Trim();
35-
if (!string.IsNullOrEmpty(etag))
36-
ETag = etag.Trim('\"');
37-
}
38-
}
27+
HasQueryString = GetQueryStringOptions(Request.Query);
28+
29+
// Get the raw ETag from the request headers.
30+
var rth = httpRequest.GetTypedHeaders();
31+
var etag = rth.IfNoneMatch.FirstOrDefault()?.Tag ?? rth.IfMatch.FirstOrDefault()?.Tag;
32+
if (etag.HasValue)
33+
ETag = etag.Value.Substring(1, etag.Value.Length - 2);
3934
}
4035

4136
/// <summary>
4237
/// Gets the originating <see cref="HttpRequest"/>.
4338
/// </summary>
4439
public HttpRequest Request { get; }
4540

41+
/// <summary>
42+
/// Indicates whether the <see cref="Request"/> has a query string.
43+
/// </summary>
44+
public bool HasQueryString { get; }
45+
4646
/// <summary>
4747
/// Gets or sets the entity tag that was passed as either a <c>If-None-Match</c> header where <see cref="HttpMethod.Get"/>; otherwise, an <c>If-Match</c> header.
4848
/// </summary>
49-
/// <remarks>Automatically adds quoting to be ETag format compliant.</remarks>
49+
/// <remarks>Represents the underlying ray value; i.e. is stripped of any <c>W/"xxxx"</c> formatting.</remarks>
5050
public string? ETag { get; set; }
5151

5252
/// <summary>
@@ -79,10 +79,10 @@ public WebApiRequestOptions(HttpRequest httpRequest)
7979
/// <summary>
8080
/// Gets the options from the <see cref="IQueryCollection"/>.
8181
/// </summary>
82-
private void GetQueryStringOptions(IQueryCollection query)
82+
private bool GetQueryStringOptions(IQueryCollection query)
8383
{
8484
if (query == null || query.Count == 0)
85-
return;
85+
return false;
8686

8787
var fields = GetNamedQueryString(query, HttpConsts.IncludeFieldsQueryStringNames);
8888
if (!string.IsNullOrEmpty(fields))
@@ -96,6 +96,7 @@ private void GetQueryStringOptions(IQueryCollection query)
9696
IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true"));
9797

9898
Paging = GetPagingArgs(query);
99+
return true;
99100
}
100101

101102
/// <summary>

src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs

+2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public WorkStateEntity(WorkState state) : this()
6262
TypeName = state.TypeName;
6363
Key = state.Key;
6464
CorrelationId = state.CorrelationId;
65+
UserName = state.UserName;
6566
Status = state.Status;
6667
Created = state.Created;
6768
Expiry = state.Expiry;
@@ -161,6 +162,7 @@ public WorkDataEntity(BinaryData data) : this()
161162
TypeName = er.Value.TypeName,
162163
Key = er.Value.Key,
163164
CorrelationId = er.Value.CorrelationId,
165+
UserName = er.Value.UserName,
164166
Status = er.Value.Status,
165167
Created = er.Value.Created,
166168
Expiry = er.Value.Expiry,

src/CoreEx.Database/DatabaseRecord.cs

+21
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ public class DatabaseRecord(IDatabase database, DbDataReader dataReader)
2323
/// </summary>
2424
public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(nameof(dataReader));
2525

26+
/// <summary>
27+
/// Gets the named column value.
28+
/// </summary>
29+
/// <param name="columnName">The column name.</param>
30+
/// <returns>The value.</returns>
31+
public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName))));
32+
33+
/// <summary>
34+
/// Gets the specified column value.
35+
/// </summary>
36+
/// <param name="ordinal">The ordinal index.</param>
37+
/// <returns>The value.</returns>
38+
public object? GetValue(int ordinal)
39+
{
40+
if (DataReader.IsDBNull(ordinal))
41+
return default;
42+
43+
var val = DataReader.GetValue(ordinal);
44+
return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val;
45+
}
46+
2647
/// <summary>
2748
/// Gets the named column value.
2849
/// </summary>

0 commit comments

Comments
 (0)