Skip to content

Commit 28e9223

Browse files
QueryStringEnumerable API (#33910)
* Add QueryStringEnumerable * Use QueryStringEnumerable in QueryFeature * Move decoding logic into QueryStringEnumerable * Simplify parsing logic. Add test and support for another edge case. Previously, the logic would trim leading whitespace from keys, but only for keys with a value. I don't know why we did this, why we didn't trim trailing whitespace, and why we didn't do it for keys with empty value. Also it was inconsistent - we did it in QueryHelper, but not in QueryFeature. * Make into a public API * More unit test cases * Typo * Add unit test to clarify QueryHelpers whitespace handling * Fix access modifier * CR: Remove unwanted test * Optimize: avoid allocations in common cases
1 parent 348b810 commit 28e9223

7 files changed

+362
-153
lines changed

src/Http/Http/src/Features/QueryFeature.cs

Lines changed: 7 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Buffers;
65
using System.Collections.Generic;
76
using System.Runtime.CompilerServices;
8-
using System.Runtime.InteropServices;
9-
using System.Runtime.Intrinsics;
10-
using System.Runtime.Intrinsics.X86;
117
using Microsoft.AspNetCore.Internal;
8+
using Microsoft.AspNetCore.WebUtilities;
129
using Microsoft.Extensions.Primitives;
1310

1411
namespace Microsoft.AspNetCore.Http.Features
@@ -19,7 +16,7 @@ namespace Microsoft.AspNetCore.Http.Features
1916
public class QueryFeature : IQueryFeature
2017
{
2118
// Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624
22-
private readonly static Func<IFeatureCollection, IHttpRequestFeature?> _nullRequestFeature = f => null;
19+
private static readonly Func<IFeatureCollection, IHttpRequestFeature?> _nullRequestFeature = f => null;
2320

2421
private FeatureReferences<IHttpRequestFeature> _features;
2522

@@ -113,48 +110,10 @@ public IQueryCollection Query
113110
}
114111

115112
var accumulator = new KvpAccumulator();
116-
var query = queryString.AsSpan();
117-
118-
if (query[0] == '?')
113+
var enumerable = new QueryStringEnumerable(queryString.AsSpan());
114+
foreach (var pair in enumerable)
119115
{
120-
query = query[1..];
121-
}
122-
123-
while (!query.IsEmpty)
124-
{
125-
var delimiterIndex = query.IndexOf('&');
126-
127-
var querySegment = delimiterIndex >= 0
128-
? query.Slice(0, delimiterIndex)
129-
: query;
130-
131-
var equalIndex = querySegment.IndexOf('=');
132-
133-
if (equalIndex >= 0)
134-
{
135-
var name = SpanHelper.ReplacePlusWithSpace(querySegment.Slice(0, equalIndex));
136-
var value = SpanHelper.ReplacePlusWithSpace(querySegment.Slice(equalIndex + 1));
137-
138-
accumulator.Append(
139-
Uri.UnescapeDataString(name),
140-
Uri.UnescapeDataString(value));
141-
}
142-
else
143-
{
144-
if (!querySegment.IsEmpty)
145-
{
146-
var name = SpanHelper.ReplacePlusWithSpace(querySegment);
147-
148-
accumulator.Append(Uri.UnescapeDataString(name));
149-
}
150-
}
151-
152-
if (delimiterIndex < 0)
153-
{
154-
break;
155-
}
156-
157-
query = query.Slice(delimiterIndex + 1);
116+
accumulator.Append(pair.DecodeName(), pair.DecodeValue());
158117
}
159118

160119
return accumulator.HasValues
@@ -171,8 +130,8 @@ internal struct KvpAccumulator
171130
private AdaptiveCapacityDictionary<string, StringValues> _accumulator;
172131
private AdaptiveCapacityDictionary<string, List<string>> _expandingAccumulator;
173132

174-
public void Append(ReadOnlySpan<char> key, ReadOnlySpan<char> value = default)
175-
=> Append(key.ToString(), value.IsEmpty ? string.Empty : value.ToString());
133+
public void Append(ReadOnlySpan<char> key, ReadOnlySpan<char> value)
134+
=> Append(key.ToString(), value.ToString());
176135

177136
/// <summary>
178137
/// This API supports infrastructure and is not intended to be used
@@ -263,58 +222,5 @@ public AdaptiveCapacityDictionary<string, StringValues> GetResults()
263222
return _accumulator ?? new AdaptiveCapacityDictionary<string, StringValues>(0, StringComparer.OrdinalIgnoreCase);
264223
}
265224
}
266-
267-
private static class SpanHelper
268-
{
269-
private static readonly SpanAction<char, IntPtr> s_replacePlusWithSpace = ReplacePlusWithSpaceCore;
270-
271-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
272-
public static unsafe string ReplacePlusWithSpace(ReadOnlySpan<char> span)
273-
{
274-
fixed (char* ptr = &MemoryMarshal.GetReference(span))
275-
{
276-
return string.Create(span.Length, (IntPtr)ptr, s_replacePlusWithSpace);
277-
}
278-
}
279-
280-
private static unsafe void ReplacePlusWithSpaceCore(Span<char> buffer, IntPtr state)
281-
{
282-
fixed (char* ptr = &MemoryMarshal.GetReference(buffer))
283-
{
284-
var input = (ushort*)state.ToPointer();
285-
var output = (ushort*)ptr;
286-
287-
var i = (nint)0;
288-
var n = (nint)(uint)buffer.Length;
289-
290-
if (Sse41.IsSupported && n >= Vector128<ushort>.Count)
291-
{
292-
var vecPlus = Vector128.Create((ushort)'+');
293-
var vecSpace = Vector128.Create((ushort)' ');
294-
295-
do
296-
{
297-
var vec = Sse2.LoadVector128(input + i);
298-
var mask = Sse2.CompareEqual(vec, vecPlus);
299-
var res = Sse41.BlendVariable(vec, vecSpace, mask);
300-
Sse2.Store(output + i, res);
301-
i += Vector128<ushort>.Count;
302-
} while (i <= n - Vector128<ushort>.Count);
303-
}
304-
305-
for (; i < n; ++i)
306-
{
307-
if (input[i] != '+')
308-
{
309-
output[i] = input[i];
310-
}
311-
else
312-
{
313-
output[i] = ' ';
314-
}
315-
}
316-
}
317-
}
318-
}
319225
}
320226
}

src/Http/WebUtilities/src/Microsoft.AspNetCore.WebUtilities.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<Description>ASP.NET Core utilities, such as for working with forms, multipart messages, and query strings.</Description>
55
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
66
<IsAspNetCoreApp>true</IsAspNetCoreApp>
7-
<DefineConstants>$(DefineConstants);WebEncoders_In_WebUtilities</DefineConstants>
7+
<DefineConstants>$(DefineConstants);WebEncoders_In_WebUtilities;QueryStringEnumerable_In_WebUtilities</DefineConstants>
88
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
99
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1010
<PackageTags>aspnetcore</PackageTags>
@@ -13,6 +13,7 @@
1313
</PropertyGroup>
1414

1515
<ItemGroup>
16+
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" />
1617
<Compile Include="$(SharedSourceRoot)WebEncoders\**\*.cs" />
1718
<Compile Include="$(SharedSourceRoot)UrlDecoder\**\*.cs" />
1819
</ItemGroup>

src/Http/WebUtilities/src/PublicAPI.Unshipped.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
*REMOVED*static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string! queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?
33
Microsoft.AspNetCore.WebUtilities.FileBufferingReadStream.MemoryThreshold.get -> int
44
Microsoft.AspNetCore.WebUtilities.FileBufferingWriteStream.MemoryThreshold.get -> int
5+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable
6+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair
7+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair.DecodeName() -> System.ReadOnlySpan<char>
8+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair.DecodeValue() -> System.ReadOnlySpan<char>
9+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair.EncodedName.get -> System.ReadOnlySpan<char>
10+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair.EncodedValue.get -> System.ReadOnlySpan<char>
11+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.Enumerator
12+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.Enumerator.Current.get -> Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.EncodedNameValuePair
13+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.Enumerator.MoveNext() -> bool
14+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.GetEnumerator() -> Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.Enumerator
15+
Microsoft.AspNetCore.WebUtilities.QueryStringEnumerable.QueryStringEnumerable(System.ReadOnlySpan<char> queryString) -> void
516
override Microsoft.AspNetCore.WebUtilities.BufferedReadStream.ReadAsync(System.Memory<byte> buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask<int>
617
override Microsoft.AspNetCore.WebUtilities.FileBufferingWriteStream.WriteAsync(System.ReadOnlyMemory<byte> buffer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
718
static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string? queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?

src/Http/WebUtilities/src/QueryHelpers.cs

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Linq;
77
using System.Text;
88
using System.Text.Encodings.Web;
9+
using Microsoft.AspNetCore.Internal;
910
using Microsoft.Extensions.Primitives;
1011

1112
namespace Microsoft.AspNetCore.WebUtilities
@@ -172,59 +173,11 @@ public static Dictionary<string, StringValues> ParseQuery(string? queryString)
172173
public static Dictionary<string, StringValues>? ParseNullableQuery(string? queryString)
173174
{
174175
var accumulator = new KeyValueAccumulator();
176+
var enumerable = new QueryStringEnumerable(queryString);
175177

176-
if (string.IsNullOrEmpty(queryString) || queryString == "?")
178+
foreach (var pair in enumerable)
177179
{
178-
return null;
179-
}
180-
181-
int scanIndex = 0;
182-
if (queryString[0] == '?')
183-
{
184-
scanIndex = 1;
185-
}
186-
187-
int textLength = queryString.Length;
188-
int equalIndex = queryString.IndexOf('=');
189-
if (equalIndex == -1)
190-
{
191-
equalIndex = textLength;
192-
}
193-
while (scanIndex < textLength)
194-
{
195-
int delimiterIndex = queryString.IndexOf('&', scanIndex);
196-
if (delimiterIndex == -1)
197-
{
198-
delimiterIndex = textLength;
199-
}
200-
if (equalIndex < delimiterIndex)
201-
{
202-
while (scanIndex != equalIndex && char.IsWhiteSpace(queryString[scanIndex]))
203-
{
204-
++scanIndex;
205-
}
206-
string name = queryString.Substring(scanIndex, equalIndex - scanIndex);
207-
string value = queryString.Substring(equalIndex + 1, delimiterIndex - equalIndex - 1);
208-
accumulator.Append(
209-
Uri.UnescapeDataString(name.Replace('+', ' ')),
210-
Uri.UnescapeDataString(value.Replace('+', ' ')));
211-
equalIndex = queryString.IndexOf('=', delimiterIndex);
212-
if (equalIndex == -1)
213-
{
214-
equalIndex = textLength;
215-
}
216-
}
217-
else
218-
{
219-
if (delimiterIndex > scanIndex)
220-
{
221-
string name = queryString.Substring(scanIndex, delimiterIndex - scanIndex);
222-
accumulator.Append(
223-
Uri.UnescapeDataString(name.Replace('+', ' ')),
224-
string.Empty);
225-
}
226-
}
227-
scanIndex = delimiterIndex + 1;
180+
accumulator.Append(pair.DecodeName().ToString(), pair.DecodeValue().ToString());
228181
}
229182

230183
if (!accumulator.HasValues)

0 commit comments

Comments
 (0)