Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions QsNet.Tests/UtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1354,36 +1354,6 @@ public void ToDictionary_Returns_Same_Instance_When_Already_ObjectKeyed()
res.Should().BeSameAs(map);
}

[Fact]
public void ToStringKeyedDictionary_Converts_Keys_To_String()
{
var src = new OrderedDictionary
{
{ "a", 1 }, // string key
{ 2, "b" }, // int key
{ "", 3 } // null key
};

var dict =
(Dictionary<string, object?>)
typeof(Utils)
.GetMethod(
"ToStringKeyedDictionary",
BindingFlags.NonPublic | BindingFlags.Static
)!
.Invoke(null, [src])!;

dict.Should()
.BeEquivalentTo(
new Dictionary<string, object?>
{
["a"] = 1,
["2"] = "b", // int key → "2"
[""] = 3 // null key → ""
}
);
}

[Fact]
public void ConvertDictionaryToStringKeyed_Does_Not_Copy_If_Already_StringKeyed()
{
Expand Down
11 changes: 11 additions & 0 deletions QsNet/Compat/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#if NETSTANDARD2_0
namespace System.Runtime.CompilerServices
{
/// <summary>
/// Polyfill for init-only setters on netstandard2.0.
/// </summary>
internal static class IsExternalInit
{
}
}
#endif
13 changes: 9 additions & 4 deletions QsNet/Enums/ListFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public enum ListFormat
/// </summary>
public static class ListFormatExtensions
{
private static readonly ListFormatGenerator BracketsGen = (p, _) => $"{p}[]";
private static readonly ListFormatGenerator CommaGen = (p, _) => p;
private static readonly ListFormatGenerator RepeatGen = (p, _) => p;
private static readonly ListFormatGenerator IndicesGen = (p, k) => $"{p}[{k}]";

/// <summary>
/// Gets the generator function for the specified list format.
/// </summary>
Expand All @@ -50,10 +55,10 @@ public static ListFormatGenerator GetGenerator(this ListFormat format)
{
return format switch
{
ListFormat.Brackets => (prefix, _) => $"{prefix}[]",
ListFormat.Comma => (prefix, _) => prefix,
ListFormat.Repeat => (prefix, _) => prefix,
ListFormat.Indices => (prefix, key) => $"{prefix}[{key}]",
ListFormat.Brackets => BracketsGen,
ListFormat.Comma => CommaGen,
ListFormat.Repeat => RepeatGen,
ListFormat.Indices => IndicesGen,
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
Expand Down
103 changes: 98 additions & 5 deletions QsNet/Internal/Decoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,42 @@ namespace QsNet.Internal;
/// <summary>
/// A helper class for decoding query strings into structured data.
/// </summary>
#if NETSTANDARD2_0
internal static class Decoder
#else
internal static partial class Decoder
#endif
{
/// <summary>
/// Regular expression to match dots followed by non-dot and non-bracket characters.
/// This is used to replace dots in keys with brackets for parsing.
/// </summary>
private static readonly Regex DotToBracket = MyRegex();

#if NETSTANDARD2_0
private static readonly Regex MyRegexInstance = new(@"\.([^.\[]+)", RegexOptions.Compiled);
private static Regex MyRegex()
{
return MyRegexInstance;
}
#else
[GeneratedRegex(@"\.([^.\[]+)", RegexOptions.Compiled)]
private static partial Regex MyRegex();
#endif

private static Encoding Latin1Encoding =>
#if NETSTANDARD2_0
Encoding.GetEncoding(28591);
#else
Encoding.Latin1;
#endif

private static bool IsLatin1(Encoding e) =>
#if NETSTANDARD2_0
e is { CodePage: 28591 };
#else
Equals(e, Encoding.Latin1);
#endif

/// <summary>
/// Parses a list value from a string or any other type, applying the options provided.
Expand Down Expand Up @@ -70,9 +96,15 @@ int currentListLength
options ??= new DecodeOptions();
var obj = new Dictionary<string, object?>();

#if NETSTANDARD2_0
var cleanStr = options.IgnoreQueryPrefix ? str.TrimStart('?') : str;
cleanStr = ReplaceOrdinalIgnoreCase(cleanStr, "%5B", "[");
cleanStr = ReplaceOrdinalIgnoreCase(cleanStr, "%5D", "]");
#else
var cleanStr = (options.IgnoreQueryPrefix ? str.TrimStart('?') : str)
.Replace("%5B", "[", StringComparison.OrdinalIgnoreCase)
.Replace("%5D", "]", StringComparison.OrdinalIgnoreCase);
#endif

var limit = options.ParameterLimit == int.MaxValue ? (int?)null : options.ParameterLimit;

Expand Down Expand Up @@ -106,7 +138,7 @@ int currentListLength
charset = parts[i] switch
{
var p when p == Sentinel.Charset.GetEncoded() => Encoding.UTF8,
var p when p == Sentinel.Iso.GetEncoded() => Encoding.Latin1,
var p when p == Sentinel.Iso.GetEncoded() => Latin1Encoding,
_ => charset
};
skipIndex = i;
Expand All @@ -132,21 +164,32 @@ int currentListLength
}
else
{
#if NETSTANDARD2_0
key = options.GetDecoder(part.Substring(0, pos), charset)?.ToString() ?? string.Empty;
#else
key = options.GetDecoder(part[..pos], charset)?.ToString() ?? string.Empty;
#endif
var currentLength =
obj.TryGetValue(key, out var val) && val is IList<object?> list ? list.Count : 0;

#if NETSTANDARD2_0
value = Utils.Apply<object?>(
ParseListValue(part.Substring(pos + 1), options, currentLength),
v => options.GetDecoder(v?.ToString(), charset)
);
#else
value = Utils.Apply<object?>(
ParseListValue(part[(pos + 1)..], options, currentLength),
v => options.GetDecoder(v?.ToString(), charset)
);
#endif
}

if (
value != null
&& !Utils.IsEmpty(value)
&& options.InterpretNumericEntities
&& Equals(charset, Encoding.Latin1)
&& IsLatin1(charset)
)
value = Utils.InterpretNumericEntities(
value switch
Expand Down Expand Up @@ -190,7 +233,13 @@ bool valuesParsed
)
{
var currentListLength = 0;
if (chain.Count > 0 && chain[^1] == "[]")
if (chain.Count > 0 &&
#if NETSTANDARD2_0
chain[chain.Count - 1] == "[]"
#else
chain[^1] == "[]"
#endif
)
{
var parentKeyStr = string.Join("", chain.Take(chain.Count - 1));
if (
Expand Down Expand Up @@ -232,7 +281,13 @@ bool valuesParsed
else
{
// Unwrap [ ... ] and (optionally) decode %2E -> .
#if NETSTANDARD2_0
var cleanRoot = root.StartsWith("[") && root.EndsWith("]")
? root.Substring(1, root.Length - 2)
: root;
#else
var cleanRoot = root.StartsWith('[') && root.EndsWith(']') ? root[1..^1] : root;
#endif
var decodedRoot = options.DecodeDotInKeys
? cleanRoot.Replace("%2E", ".")
: cleanRoot;
Expand Down Expand Up @@ -298,7 +353,7 @@ bool valuesParsed
return null;

var segments = SplitKeyIntoSegments(
givenKey,
givenKey!,
options.AllowDots,
options.Depth,
options.StrictDepth
Expand Down Expand Up @@ -335,7 +390,11 @@ bool strictDepth
var segments = new List<string>();

var first = key.IndexOf('[');
#if NETSTANDARD2_0
var parent = first >= 0 ? key.Substring(0, first) : key;
#else
var parent = first >= 0 ? key[..first] : key;
#endif
if (!string.IsNullOrEmpty(parent))
segments.Add(parent);

Expand All @@ -346,7 +405,11 @@ bool strictDepth
var close = key.IndexOf(']', open + 1);
if (close < 0)
break;
#if NETSTANDARD2_0
segments.Add(key.Substring(open, close + 1 - open)); // e.g. "[p]" or "[]"
#else
segments.Add(key[open..(close + 1)]); // e.g. "[p]" or "[]"
#endif
depth++;
open = key.IndexOf('[', close + 1);
}
Expand All @@ -357,9 +420,39 @@ bool strictDepth
throw new IndexOutOfRangeException(
$"Input depth exceeded depth option of {maxDepth} and strictDepth is true"
);
// Stash the remainder as a single segment.
#if NETSTANDARD2_0
segments.Add("[" + key.Substring(open) + "]");
#else
segments.Add("[" + key[open..] + "]");
#endif

return segments;
}

#if NETSTANDARD2_0
// Efficient case-insensitive ordinal string replace for NETSTANDARD2_0 (no regex, no allocations beyond matches)
private static string ReplaceOrdinalIgnoreCase(string input, string oldValue, string newValue)
{
if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(oldValue))
return input;

var startIndex = 0;
StringBuilder? sb = null;
while (true)
{
var idx = input.IndexOf(oldValue, startIndex, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
if (sb == null) return input;
sb.Append(input, startIndex, input.Length - startIndex);
return sb.ToString();
}

sb ??= new StringBuilder(input.Length);
sb.Append(input, startIndex, idx - startIndex);
sb.Append(newValue);
startIndex = idx + oldValue.Length;
}
}
#endif
}
Loading
Loading