diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 4ac8641..45f114e 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -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) - typeof(Utils) - .GetMethod( - "ToStringKeyedDictionary", - BindingFlags.NonPublic | BindingFlags.Static - )! - .Invoke(null, [src])!; - - dict.Should() - .BeEquivalentTo( - new Dictionary - { - ["a"] = 1, - ["2"] = "b", // int key → "2" - [""] = 3 // null key → "" - } - ); - } - [Fact] public void ConvertDictionaryToStringKeyed_Does_Not_Copy_If_Already_StringKeyed() { diff --git a/QsNet/Compat/IsExternalInit.cs b/QsNet/Compat/IsExternalInit.cs new file mode 100644 index 0000000..e84c317 --- /dev/null +++ b/QsNet/Compat/IsExternalInit.cs @@ -0,0 +1,11 @@ +#if NETSTANDARD2_0 +namespace System.Runtime.CompilerServices +{ + /// + /// Polyfill for init-only setters on netstandard2.0. + /// + internal static class IsExternalInit + { + } +} +#endif \ No newline at end of file diff --git a/QsNet/Enums/ListFormat.cs b/QsNet/Enums/ListFormat.cs index 32ad62e..aa91abb 100644 --- a/QsNet/Enums/ListFormat.cs +++ b/QsNet/Enums/ListFormat.cs @@ -41,6 +41,11 @@ public enum ListFormat /// 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}]"; + /// /// Gets the generator function for the specified list format. /// @@ -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)) }; } diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index bc60700..a7ae26e 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -12,7 +12,11 @@ namespace QsNet.Internal; /// /// A helper class for decoding query strings into structured data. /// +#if NETSTANDARD2_0 +internal static class Decoder +#else internal static partial class Decoder +#endif { /// /// Regular expression to match dots followed by non-dot and non-bracket characters. @@ -20,8 +24,30 @@ internal static partial class Decoder /// 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 /// /// Parses a list value from a string or any other type, applying the options provided. @@ -70,9 +96,15 @@ int currentListLength options ??= new DecodeOptions(); var obj = new Dictionary(); +#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; @@ -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; @@ -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 list ? list.Count : 0; +#if NETSTANDARD2_0 + value = Utils.Apply( + ParseListValue(part.Substring(pos + 1), options, currentLength), + v => options.GetDecoder(v?.ToString(), charset) + ); +#else value = Utils.Apply( 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 @@ -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 ( @@ -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; @@ -298,7 +353,7 @@ bool valuesParsed return null; var segments = SplitKeyIntoSegments( - givenKey, + givenKey!, options.AllowDots, options.Depth, options.StrictDepth @@ -335,7 +390,11 @@ bool strictDepth var segments = new List(); 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); @@ -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); } @@ -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 } \ No newline at end of file diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index 469fa02..bbac222 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using System.Web; @@ -15,7 +16,11 @@ namespace QsNet.Internal; /// /// A collection of utility methods used by the library. /// +#if NETSTANDARD2_0 +internal static class Utils +#else internal static partial class Utils +#endif { /// /// The maximum length of a segment to encode in a single pass. @@ -25,14 +30,32 @@ internal static partial class Utils /// /// A regex to match percent-encoded characters in the format %XX. /// +#if NETSTANDARD2_0 + private static readonly Regex MyRegexInstance = new("%[0-9a-f]{2}", RegexOptions.IgnoreCase); + + private static Regex MyRegex() + { + return MyRegexInstance; + } +#else [GeneratedRegex("%[0-9a-f]{2}", RegexOptions.IgnoreCase, "en-GB")] private static partial Regex MyRegex(); +#endif /// /// A regex to match Unicode percent-encoded characters in the format %uXXXX. /// +#if NETSTANDARD2_0 + private static readonly Regex MyRegex1Instance = new("%u[0-9a-f]{4}", RegexOptions.IgnoreCase); + + private static Regex MyRegex1() + { + return MyRegex1Instance; + } +#else [GeneratedRegex("%u[0-9a-f]{4}", RegexOptions.IgnoreCase, "en-GB")] private static partial Regex MyRegex1(); +#endif /// /// Merges two objects, where the source object overrides the target object. If the source is a @@ -93,9 +116,12 @@ internal static partial class Utils // Otherwise: both sides are iterables / primitives if (source is IEnumerable srcIt) { + // Materialize once to avoid multiple enumeration of a potentially lazy sequence + var srcList = srcIt as IList ?? srcIt.ToList(); + // If both sequences are maps-or-Undefined only, fold by index merging var targetAllMaps = targetList.All(v => v is IDictionary or Undefined); - var srcAllMaps = srcIt.All(v => v is IDictionary or Undefined); + var srcAllMaps = srcList.All(v => v is IDictionary or Undefined); if (targetAllMaps && srcAllMaps) { @@ -104,10 +130,12 @@ internal static partial class Utils mutable[i] = targetList[i]; var j = 0; - foreach (var item in srcIt) + foreach (var item in srcList) { - if (!mutable.TryAdd(j, item)) + if (mutable.ContainsKey(j)) mutable[j] = Merge(mutable[j], item, options); + else + mutable.Add(j, item); j++; } @@ -118,7 +146,7 @@ internal static partial class Utils } // Fallback: concat, filtering out Undefined from source - var filtered = srcIt.Where(v => v is not Undefined); + var filtered = srcList.Where(v => v is not Undefined); if (target is ISet) return new HashSet(targetList.Concat(filtered)); return targetList.Concat(filtered).ToList(); @@ -274,15 +302,27 @@ public static string Unescape(string str) { if (i + 1 < str.Length && str[i + 1] == 'u') { +#if NETSTANDARD2_0 if ( - i + 6 <= str.Length - && int.TryParse( + i + 6 <= str.Length && + int.TryParse( + str.Substring(i + 2, 4), + NumberStyles.HexNumber, + null, + out var code + ) + ) +#else + if ( + i + 6 <= str.Length && + int.TryParse( str.AsSpan(i + 2, 4), NumberStyles.HexNumber, null, out var code ) ) +#endif { sb.Append((char)code); i += 6; @@ -291,7 +331,11 @@ out var code } else if ( i + 3 <= str.Length +#if NETSTANDARD2_0 + && int.TryParse(str.Substring(i + 1, 2), NumberStyles.HexNumber, null, out var b) +#else && int.TryParse(str.AsSpan(i + 1, 2), NumberStyles.HexNumber, null, out var b) +#endif ) { sb.Append((char)b); @@ -318,6 +362,7 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo { encoding ??= Encoding.UTF8; format ??= Format.Rfc3986; + var fmt = format.GetValueOrDefault(); // These cannot be encoded if (value is IEnumerable and not string and not byte[] or IDictionary or Undefined) @@ -332,16 +377,21 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo if (string.IsNullOrEmpty(str)) return string.Empty; + var nonNullStr = str!; if (Equals(encoding, Encoding.GetEncoding("ISO-8859-1"))) { #pragma warning disable CS0618 // Type or member is obsolete return MyRegex1() .Replace( - Escape(str, format.Value), + Escape(str!, fmt), match => { +#if NETSTANDARD2_0 + var code = int.Parse(match.Value.Substring(2), NumberStyles.HexNumber); +#else var code = int.Parse(match.Value[2..], NumberStyles.HexNumber); +#endif return $"%26%23{code}%3B"; } ); @@ -351,12 +401,12 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo var buffer = new StringBuilder(); var j = 0; - while (j < str.Length) + while (j < nonNullStr.Length) { var segment = - str.Length >= SegmentLimit - ? str.Substring(j, Math.Min(SegmentLimit, str.Length - j)) - : str; + nonNullStr.Length >= SegmentLimit + ? nonNullStr.Substring(j, Math.Min(SegmentLimit, nonNullStr.Length - j)) + : nonNullStr; var i = 0; while (i < segment.Length) @@ -369,7 +419,7 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo case >= 0x30 and <= 0x39: case >= 0x41 and <= 0x5A: case >= 0x61 and <= 0x7A: - case 0x28 or 0x29 when format == Format.Rfc1738: + case 0x28 or 0x29 when fmt == Format.Rfc1738: buffer.Append(segment[i]); i++; continue; @@ -774,22 +824,6 @@ public static string InterpretNumericEntities(string str) return dict; } - /// - /// string-keyed view - /// - /// - /// - private static Dictionary ToStringKeyedDictionary(IDictionary src) - { - if (src is Dictionary strDict) - return strDict; - - var dict = new Dictionary(src.Count); - foreach (DictionaryEntry de in src) - dict[de.Key.ToString() ?? string.Empty] = de.Value; - return dict; - } - /// /// Helper to convert an IDictionary to Dictionary<string, object?>. /// @@ -876,7 +910,7 @@ ISet visited // Fallback: make a shallow string-keyed view without descending var shallow = new Dictionary(dict.Count); foreach (DictionaryEntry de in dict) - shallow[de.Key?.ToString() ?? string.Empty] = de.Value; + shallow[de.Key.ToString() ?? string.Empty] = de.Value; return shallow; } @@ -884,7 +918,7 @@ ISet visited foreach (DictionaryEntry entry in dict) { - var key = entry.Key?.ToString() ?? string.Empty; + var key = entry.Key.ToString() ?? string.Empty; var item = entry.Value; switch (item) @@ -974,7 +1008,7 @@ private static object NormalizeForTarget(IDictionary map) if (src is IDictionary sd && dst is Dictionary dd) foreach (DictionaryEntry de in sd) { - var key = de.Key?.ToString() ?? string.Empty; + var key = de.Key.ToString() ?? string.Empty; var val = de.Value; switch (val) @@ -1014,9 +1048,7 @@ private static object NormalizeForTarget(IDictionary map) dd[key] = newList; visited[list] = newList; - for (var i = 0; i < list.Count; i++) - { - var item = list[i]; + foreach (var item in list) if (item is IDictionary inner) { if (inner is Dictionary innerSk) @@ -1040,7 +1072,6 @@ private static object NormalizeForTarget(IDictionary map) { newList.Add(item); } - } break; @@ -1053,4 +1084,24 @@ private static object NormalizeForTarget(IDictionary map) return top; } +} + +// Reference-equality comparer used to track visited nodes without relying on value equality +internal sealed class ReferenceEqualityComparer : IEqualityComparer +{ + public static readonly ReferenceEqualityComparer Instance = new(); + + private ReferenceEqualityComparer() + { + } + + public new bool Equals(object? x, object? y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(object obj) + { + return RuntimeHelpers.GetHashCode(obj); + } } \ No newline at end of file diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 8769d0b..35bf183 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -28,8 +28,14 @@ public sealed class DecodeOptions /// public DecodeOptions() { - // Validation - if (!Equals(Charset, Encoding.UTF8) && !Equals(Charset, Encoding.Latin1)) + if ( + !Equals(Charset, Encoding.UTF8) && +#if NETSTANDARD2_0 + Charset.CodePage != 28591 +#else + !Equals(Charset, Encoding.Latin1) +#endif + ) throw new ArgumentException("Invalid charset"); if (ParameterLimit <= 0) diff --git a/QsNet/Models/Delimiter.cs b/QsNet/Models/Delimiter.cs index 445383c..04f995a 100644 --- a/QsNet/Models/Delimiter.cs +++ b/QsNet/Models/Delimiter.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -30,7 +31,11 @@ public sealed record StringDelimiter(string Value) : IDelimiter /// A list of split strings public IEnumerable Split(string input) { +#if NETSTANDARD2_0 + return Value.Length == 1 ? input.Split(Value[0]) : input.Split([Value], StringSplitOptions.None); +#else return input.Split(Value); +#endif } } diff --git a/QsNet/Models/EncodeOptions.cs b/QsNet/Models/EncodeOptions.cs index 11b23e8..9fd100a 100644 --- a/QsNet/Models/EncodeOptions.cs +++ b/QsNet/Models/EncodeOptions.cs @@ -43,8 +43,14 @@ public EncodeOptions(ListFormat? listFormat = null) { ListFormat = listFormat; - // Validate charset - if (!Equals(Charset, Encoding.UTF8) && !Equals(Charset, Encoding.Latin1)) + if ( + !Equals(Charset, Encoding.UTF8) && +#if NETSTANDARD2_0 + Charset.CodePage != 28591 +#else + !Equals(Charset, Encoding.Latin1) +#endif + ) throw new ArgumentException("Invalid charset"); } diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index c779585..52c0013 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -61,6 +61,32 @@ public static class Qs if (tempObj is not { Count: > 0 }) return new Dictionary(); +#if NETSTANDARD2_0 + foreach (var kv in tempObj) + { + var key = kv.Key; + var value = kv.Value; + + var parsed = Decoder.ParseKeys(key, value, finalOptions, input is string); + if (parsed is null) + continue; + + if (obj.Count == 0 && parsed is IDictionary first) + { + obj = Utils.ToObjectKeyedDictionary(first); + continue; + } + + var merged = Utils.Merge(obj, parsed, finalOptions) ?? obj; + + obj = merged switch + { + Dictionary d => d, + IDictionary id => Utils.ToObjectKeyedDictionary(id), + _ => obj + }; + } +#else foreach (var (key, value) in tempObj) { var parsed = Decoder.ParseKeys(key, value, finalOptions, input is string); @@ -82,6 +108,7 @@ public static class Qs _ => obj }; } +#endif // compact (still object-keyed), then convert the whole tree to string-keyed var compacted = Utils.Compact(obj, opts.AllowSparseLists); diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index ce40fcc..82dc91b 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -1,6 +1,6 @@  - net8.0 + net8.0;netstandard2.0 enable latest true