diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 45f114e..b29d84d 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1532,4 +1532,40 @@ public void Merge_MapsAndArrays() .Should() .BeEquivalentTo(new Dictionary { { "foo", "baz" }, { "bar", true } }); } + + [Fact] + public void ToStringKeyDeepNonRecursive_Converts_Nested_Lists_And_Dicts() + { + // root: { "x": [ {"a":1}, [ {"b":2}, {"c":3} ], 4 ] } + var dict1 = new Dictionary { ["a"] = 1 }; + var dict2 = new Dictionary { ["b"] = 2 }; + var dict3 = new Dictionary { ["c"] = 3 }; + + var innerList = new List { dict2, dict3 }; + var topList = new List { dict1, innerList, 4 }; + + var root = new Dictionary { ["x"] = topList }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + + result.Should().ContainKey("x"); + var outTopList = result["x"] as List; + outTopList.Should().NotBeNull(); + + var outDict1 = outTopList![0] as Dictionary; + outDict1.Should().NotBeNull(); + outDict1!["a"].Should().Be(1); + + var outInnerList = outTopList[1] as List; + outInnerList.Should().NotBeNull(); + + var outDict2 = outInnerList![0] as Dictionary; + var outDict3 = outInnerList[1] as Dictionary; + outDict2.Should().NotBeNull(); + outDict3.Should().NotBeNull(); + outDict2!["b"].Should().Be(2); + outDict3!["c"].Should().Be(3); + + outTopList[2].Should().Be(4); + } } \ No newline at end of file diff --git a/QsNet/Constants/HexTable.cs b/QsNet/Constants/HexTable.cs index 6b17e85..c0fcfb3 100644 --- a/QsNet/Constants/HexTable.cs +++ b/QsNet/Constants/HexTable.cs @@ -8,263 +8,34 @@ public static class HexTable /// /// Hex table containing percent-encoded strings for all 256 byte values /// - internal static readonly string[] Table = - [ - "%00", - "%01", - "%02", - "%03", - "%04", - "%05", - "%06", - "%07", - "%08", - "%09", - "%0A", - "%0B", - "%0C", - "%0D", - "%0E", - "%0F", - "%10", - "%11", - "%12", - "%13", - "%14", - "%15", - "%16", - "%17", - "%18", - "%19", - "%1A", - "%1B", - "%1C", - "%1D", - "%1E", - "%1F", - "%20", - "%21", - "%22", - "%23", - "%24", - "%25", - "%26", - "%27", - "%28", - "%29", - "%2A", - "%2B", - "%2C", - "%2D", - "%2E", - "%2F", - "%30", - "%31", - "%32", - "%33", - "%34", - "%35", - "%36", - "%37", - "%38", - "%39", - "%3A", - "%3B", - "%3C", - "%3D", - "%3E", - "%3F", - "%40", - "%41", - "%42", - "%43", - "%44", - "%45", - "%46", - "%47", - "%48", - "%49", - "%4A", - "%4B", - "%4C", - "%4D", - "%4E", - "%4F", - "%50", - "%51", - "%52", - "%53", - "%54", - "%55", - "%56", - "%57", - "%58", - "%59", - "%5A", - "%5B", - "%5C", - "%5D", - "%5E", - "%5F", - "%60", - "%61", - "%62", - "%63", - "%64", - "%65", - "%66", - "%67", - "%68", - "%69", - "%6A", - "%6B", - "%6C", - "%6D", - "%6E", - "%6F", - "%70", - "%71", - "%72", - "%73", - "%74", - "%75", - "%76", - "%77", - "%78", - "%79", - "%7A", - "%7B", - "%7C", - "%7D", - "%7E", - "%7F", - "%80", - "%81", - "%82", - "%83", - "%84", - "%85", - "%86", - "%87", - "%88", - "%89", - "%8A", - "%8B", - "%8C", - "%8D", - "%8E", - "%8F", - "%90", - "%91", - "%92", - "%93", - "%94", - "%95", - "%96", - "%97", - "%98", - "%99", - "%9A", - "%9B", - "%9C", - "%9D", - "%9E", - "%9F", - "%A0", - "%A1", - "%A2", - "%A3", - "%A4", - "%A5", - "%A6", - "%A7", - "%A8", - "%A9", - "%AA", - "%AB", - "%AC", - "%AD", - "%AE", - "%AF", - "%B0", - "%B1", - "%B2", - "%B3", - "%B4", - "%B5", - "%B6", - "%B7", - "%B8", - "%B9", - "%BA", - "%BB", - "%BC", - "%BD", - "%BE", - "%BF", - "%C0", - "%C1", - "%C2", - "%C3", - "%C4", - "%C5", - "%C6", - "%C7", - "%C8", - "%C9", - "%CA", - "%CB", - "%CC", - "%CD", - "%CE", - "%CF", - "%D0", - "%D1", - "%D2", - "%D3", - "%D4", - "%D5", - "%D6", - "%D7", - "%D8", - "%D9", - "%DA", - "%DB", - "%DC", - "%DD", - "%DE", - "%DF", - "%E0", - "%E1", - "%E2", - "%E3", - "%E4", - "%E5", - "%E6", - "%E7", - "%E8", - "%E9", - "%EA", - "%EB", - "%EC", - "%ED", - "%EE", - "%EF", - "%F0", - "%F1", - "%F2", - "%F3", - "%F4", - "%F5", - "%F6", - "%F7", - "%F8", - "%F9", - "%FA", - "%FB", - "%FC", - "%FD", - "%FE", - "%FF" - ]; + internal static readonly string[] Table = Create(); + + private static string[] Create() + { + var arr = new string[256]; + for (var i = 0; i < 256; i++) + { +#if NETSTANDARD2_0 + var chars = new char[3]; + chars[0] = '%'; + chars[1] = GetHexChar((i >> 4) & 0xF); + chars[2] = GetHexChar(i & 0xF); + arr[i] = new string(chars); +#else + arr[i] = string.Create(3, i, static (span, val) => + { + span[0] = '%'; + span[1] = GetHexChar((val >> 4) & 0xF); + span[2] = GetHexChar(val & 0xF); + }); +#endif + } + + return arr; + } + + private static char GetHexChar(int n) + { + return (char)(n < 10 ? '0' + n : 'A' + (n - 10)); + } } \ No newline at end of file diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index a7ae26e..ee59ddc 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -94,7 +94,6 @@ int currentListLength ) { options ??= new DecodeOptions(); - var obj = new Dictionary(); #if NETSTANDARD2_0 var cleanStr = options.IgnoreQueryPrefix ? str.TrimStart('?') : str; @@ -111,29 +110,34 @@ int currentListLength if (limit is <= 0) throw new ArgumentException("Parameter limit must be a positive integer."); + var allPartsSeq = options.Delimiter.Split(cleanStr); + var allParts = allPartsSeq as string[] ?? allPartsSeq.ToArray(); List parts; if (limit != null) { - var allParts = options.Delimiter.Split(cleanStr).ToList(); var takeCount = options.ThrowOnLimitExceeded ? limit.Value + 1 : limit.Value; - parts = allParts.Take(takeCount).ToList(); + var count = allParts.Length < takeCount ? allParts.Length : takeCount; + parts = new List(count); + for (var i = 0; i < count; i++) parts.Add(allParts[i]); + + if (options.ThrowOnLimitExceeded && allParts.Length > limit.Value) + throw new IndexOutOfRangeException( + $"Parameter limit exceeded. Only {limit} parameter{(limit == 1 ? "" : "s")} allowed." + ); } else { - parts = options.Delimiter.Split(cleanStr).ToList(); + parts = new List(allParts.Length); + parts.AddRange(allParts); } - if (options.ThrowOnLimitExceeded && limit != null && parts.Count > limit) - throw new IndexOutOfRangeException( - $"Parameter limit exceeded. Only {limit} parameter{(limit == 1 ? "" : "s")} allowed." - ); - + var obj = new Dictionary(parts.Count); var skipIndex = -1; // Keep track of where the utf8 sentinel was found var charset = options.Charset; if (options.CharsetSentinel) for (var i = 0; i < parts.Count; i++) - if (parts[i].StartsWith("utf8=")) + if (parts[i].StartsWith("utf8=", StringComparison.Ordinal)) { charset = parts[i] switch { @@ -191,27 +195,32 @@ int currentListLength && options.InterpretNumericEntities && IsLatin1(charset) ) - value = Utils.InterpretNumericEntities( - value switch - { - IEnumerable enumerable and not string => string.Join( - ",", - enumerable.Cast().Select(x => x?.ToString()) - ), - _ => value.ToString() ?? string.Empty - } - ); + { + var tmpStr = value is IEnumerable enumerable and not string + ? JoinAsCommaSeparatedStrings(enumerable) + : value.ToString() ?? string.Empty; + value = Utils.InterpretNumericEntities(tmpStr); + } - if (part.Contains("[]=")) + if (part.IndexOf("[]=", StringComparison.Ordinal) >= 0) value = value is IEnumerable and not string ? new List { value } : value; - var existing = obj.ContainsKey(key); - obj[key] = (existing, options.Duplicates) switch - { - (true, Duplicates.Combine) => Utils.Combine(obj[key], value), - (false, _) or (true, Duplicates.Last) => value, - _ => obj[key] - }; + if (obj.TryGetValue(key, out var existingVal)) + switch (options.Duplicates) + { + case Duplicates.Combine: + obj[key] = Utils.Combine(existingVal, value); + break; + case Duplicates.Last: + obj[key] = value; + break; + case Duplicates.First: + default: + // keep the first value; do nothing + break; + } + else + obj[key] = value; } return obj; @@ -241,7 +250,18 @@ bool valuesParsed #endif ) { - var parentKeyStr = string.Join("", chain.Take(chain.Count - 1)); + string parentKeyStr; + if (chain.Count > 1) + { + var sbTmp = new StringBuilder(); + for (var t = 0; t < chain.Count - 1; t++) sbTmp.Append(chain[t]); + parentKeyStr = sbTmp.ToString(); + } + else + { + parentKeyStr = string.Empty; + } + if ( int.TryParse(parentKeyStr, out var parentKey) && value is IList list @@ -255,9 +275,14 @@ bool valuesParsed if (leaf is IDictionary id and not Dictionary) { // Preserve identity for self-referencing maps - var selfRef = - id is Dictionary strDict - && strDict.Keys.Any(k => ReferenceEquals(strDict[k], strDict)); + var selfRef = false; + if (id is Dictionary strDict) + foreach (var k in strDict.Keys) + if (ReferenceEquals(strDict[k], strDict)) + { + selfRef = true; + break; + } if (!selfRef) leaf = Utils.ToObjectKeyedDictionary(id); @@ -288,9 +313,16 @@ bool valuesParsed #else var cleanRoot = root.StartsWith('[') && root.EndsWith(']') ? root[1..^1] : root; #endif + +#if NETSTANDARD2_0 + var decodedRoot = options.DecodeDotInKeys + ? ReplaceOrdinalIgnoreCase(cleanRoot, "%2E", ".") + : cleanRoot; +#else var decodedRoot = options.DecodeDotInKeys - ? cleanRoot.Replace("%2E", ".") + ? cleanRoot.Replace("%2E", ".", StringComparison.OrdinalIgnoreCase) : cleanRoot; +#endif // Bracketed numeric like "[1]"? var isPureNumeric = @@ -310,7 +342,7 @@ bool valuesParsed case true when idx >= 0 && idx <= options.ListLimit: { // Build a list up to idx (0 is allowed when ListLimit == 0) - var list = new List(); + var list = new List(idx + 1); for (var j = 0; j <= idx; j++) list.Add(j == idx ? leaf : Undefined.Instance); obj = list; @@ -455,4 +487,36 @@ private static string ReplaceOrdinalIgnoreCase(string input, string oldValue, st } } #endif + + // Helper for joining IEnumerable as comma-separated strings (avoiding LINQ) + private static string JoinAsCommaSeparatedStrings(IEnumerable enumerable) + { + var e = enumerable.GetEnumerator(); + StringBuilder? sb = null; + var first = true; + try + { + while (e.MoveNext()) + { + if (first) + { + sb = new StringBuilder(); + first = false; + } + else + { + sb!.Append(','); + } + + var s = e.Current?.ToString() ?? string.Empty; + sb!.Append(s); + } + } + finally + { + (e as IDisposable)?.Dispose(); + } + + return sb?.ToString() ?? string.Empty; + } } \ No newline at end of file diff --git a/QsNet/Internal/Encoder.cs b/QsNet/Internal/Encoder.cs index 5c983ad..db45ace 100644 --- a/QsNet/Internal/Encoder.cs +++ b/QsNet/Internal/Encoder.cs @@ -13,6 +13,8 @@ namespace QsNet.Internal; /// internal static class Encoder { + private static readonly Formatter IdentityFormatter = s => s; + /// /// Encodes the given data into a query string format. /// @@ -60,7 +62,7 @@ public static object Encode( bool addQueryPrefix = false ) { - var fmt = formatter ?? (s => s); // your Format formatter should be passed in + var fmt = formatter ?? IdentityFormatter; // avoid per-call lambda alloc var cs = charset ?? Encoding.UTF8; var gen = generateArrayPrefix ?? ListFormat.Indices.GetGenerator(); @@ -142,20 +144,29 @@ public static object Encode( if (undefined) return values; + // Detect sequence once and cache materialization for index access / counts + var isSeq = false; + List? seqList = null; + if (obj is IEnumerable seq0 and not string and not IDictionary) + { + isSeq = true; + seqList = seq0.Cast().ToList(); + } + List objKeys; if (isCommaGen && obj is IEnumerable enumerable and not string and not IDictionary) { - var list = enumerable.Cast().ToList(); - + List strings = []; if (encodeValuesOnly && encoder != null) - list = list.Select(el => - el is null ? "" : encoder(el.ToString(), null, null) as object - ) - .ToList(); + foreach (var el in enumerable) + strings.Add(el is null ? "" : encoder(el.ToString(), null, null)); + else + foreach (var el in enumerable) + strings.Add(el?.ToString() ?? ""); - if (list.Count != 0) + if (strings.Count != 0) { - var joined = string.Join(",", list.Select(el => el?.ToString() ?? "")); + var joined = string.Join(",", strings); objKeys = [ new Dictionary @@ -175,31 +186,58 @@ public static object Encode( } else { - var keys = obj switch + switch (obj) { - IDictionary map => map.Keys.Cast(), - Array arr => Enumerable.Range(0, arr.Length).Cast(), - IList list => Enumerable.Range(0, list.Count).Cast(), - IEnumerable ie and not string => ie.Cast().Select((_, i) => (object?)i), - _ => [] - }; - - objKeys = keys.ToList(); + case IDictionary map: + objKeys = map.Keys.Cast().ToList(); + break; + case Array arr: + { + objKeys = new List(arr.Length); + for (var i = 0; i < arr.Length; i++) objKeys.Add(i); + break; + } + case IList list: + { + objKeys = new List(list.Count); + for (var i = 0; i < list.Count; i++) objKeys.Add(i); + break; + } + default: + { + if (isSeq && seqList != null) + { + objKeys = new List(seqList.Count); + for (var i = 0; i < seqList.Count; i++) objKeys.Add(i); + } + else if (obj is IEnumerable ie and not string) + { + objKeys = []; + var i = 0; + foreach (var _ in ie) objKeys.Add(i++); + } + else + { + objKeys = []; + } + + break; + } + } + if (sort != null) objKeys.Sort(Comparer.Create(sort)); } + values.Capacity = Math.Max(values.Capacity, objKeys.Count); + var encodedPrefix = encodeDotInKeys ? keyPrefixStr.Replace(".", "%2E") : keyPrefixStr; var adjustedPrefix = - crt && obj is IEnumerable iter and not string && iter.Cast().Count() == 1 + crt && isSeq && seqList is { Count: 1 } ? $"{encodedPrefix}[]" : encodedPrefix; - if ( - allowEmptyLists - && obj is IEnumerable iter0 and not string - && !iter0.Cast().Any() - ) + if (allowEmptyLists && isSeq && seqList is { Count: 0 }) return $"{adjustedPrefix}[]"; for (var i = 0; i < objKeys.Count; i++) @@ -214,17 +252,51 @@ public static object Encode( switch (obj) { case IDictionary map: - if (key is not null && map.Contains(key)) - { - value = map[key]; - } - else { - value = null; - valueUndefined = true; - } + switch (obj) + { + // Fast paths for common generic dictionaries + case IDictionary dObj + when key is not null && dObj.TryGetValue(key, out var got): + value = got; + break; + case IDictionary: + value = null; + valueUndefined = true; + break; + case IDictionary dStr: + { + var ks = key as string ?? key?.ToString() ?? string.Empty; + if (dStr.TryGetValue(ks, out var got)) + { + value = got; + } + else + { + value = null; + valueUndefined = true; + } + + break; + } + default: + { + if (key is not null && map.Contains(key)) + { + value = map[key]; + } + else + { + value = null; + valueUndefined = true; + } + + break; + } + } - break; + break; + } case Array arr: { @@ -270,12 +342,11 @@ IConvertible when int.TryParse(key.ToString(), out var parsed) => var idx = key switch { int j => j, - IConvertible when int.TryParse(key.ToString(), out var parsed) => - parsed, + IConvertible when int.TryParse(key.ToString(), out var parsed) => parsed, _ => -1 }; - var list2 = ie.Cast().ToList(); - if (idx >= 0 && idx < list2.Count) + var list2 = seqList ?? ie.Cast().ToList(); + if ((uint)idx < (uint)list2.Count) { value = list2[idx]; } @@ -298,7 +369,9 @@ IConvertible when int.TryParse(key.ToString(), out var parsed) => continue; var keyStr = key?.ToString() ?? ""; - var encodedKey = allowDots && encodeDotInKeys ? keyStr.Replace(".", "%2E") : keyStr; + var encodedKey = keyStr; + if (allowDots && encodeDotInKeys && keyStr.IndexOf('.') >= 0) + encodedKey = keyStr.Replace(".", "%2E"); var keyPrefix = obj is IEnumerable and not string and not IDictionary @@ -341,7 +414,8 @@ obj is IEnumerable and not string and not IDictionary ); if (encoded is IEnumerable en and not string) - values.AddRange(en.Cast()); + foreach (var item in en) + values.Add(item); else values.Add(encoded); } diff --git a/QsNet/Internal/SideChannelFrame.cs b/QsNet/Internal/SideChannelFrame.cs index 332187d..1e19537 100644 --- a/QsNet/Internal/SideChannelFrame.cs +++ b/QsNet/Internal/SideChannelFrame.cs @@ -13,6 +13,7 @@ internal sealed class SideChannelFrame(SideChannelFrame? parent = null) private readonly ConditionalWeakTable> _map = new(); public SideChannelFrame? Parent { get; } = parent; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGet(object key, out int step) { if (_map.TryGetValue(key, out var box)) @@ -25,10 +26,13 @@ public bool TryGet(object key, out int step) return false; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Set(object key, int step) { - _map.Remove(key); - _map.Add(key, new Box(step)); + if (_map.TryGetValue(key, out var box)) + box.Value = step; + else + _map.Add(key, new Box(step)); } } @@ -40,5 +44,5 @@ public void Set(object key, int step) /// internal sealed class Box(T v) { - public readonly T Value = v; + public T Value = v; } \ No newline at end of file diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index bbac222..e604c57 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -132,8 +132,8 @@ private static Regex MyRegex1() var j = 0; foreach (var item in srcList) { - if (mutable.ContainsKey(j)) - mutable[j] = Merge(mutable[j], item, options); + if (mutable.TryGetValue(j, out var existing)) + mutable[j] = Merge(existing, item, options); else mutable.Add(j, item); @@ -146,16 +146,34 @@ private static Regex MyRegex1() } // Fallback: concat, filtering out Undefined from source - var filtered = srcList.Where(v => v is not Undefined); if (target is ISet) - return new HashSet(targetList.Concat(filtered)); - return targetList.Concat(filtered).ToList(); + { + var set = new HashSet(targetList); + foreach (var v in srcList) + if (v is not Undefined) + set.Add(v); + return set; + } + + var res = new List(targetList.Count + srcList.Count); + res.AddRange(targetList); + foreach (var v in srcList) + if (v is not Undefined) + res.Add(v); + return res; } // source is primitive -> append/merge if (target is ISet) - return new HashSet(targetList.Append(source)); - return targetList.Append(source).ToList(); + { + var set = new HashSet(targetList) { source }; + return set; + } + + var res2 = new List(targetList.Count + 1); + res2.AddRange(targetList); + res2.Add(source); + return res2; } case IDictionary targetMap: @@ -189,11 +207,12 @@ private static Regex MyRegex1() default: // target is primitive/null - if (source is IEnumerable src2) - return new[] { target } - .Concat(src2.Where(v => v is not Undefined)) - .ToList(); - return new List { target, source }; + if (source is not IEnumerable src2) return new List { target, source }; + var list = new List { target }; + foreach (var v in src2) + if (v is not Undefined) + list.Add(v); + return list; } // Source IS a map @@ -689,12 +708,20 @@ void AddOne(object? x) /// The result of applying the function, or null if the input is null. public static object? Apply(object? value, Func fn) { - return value switch + switch (value) { - IEnumerable enumerable => enumerable.Select(fn).ToList(), - T item => fn(item), - _ => value - }; + case IEnumerable enumerable: + { + var list = new List(); + foreach (var it in enumerable) + list.Add(fn(it)); + return list; + } + case T item: + return fn(item); + default: + return value; + } } /// @@ -728,11 +755,29 @@ public static bool IsEmpty(object? value) null or Undefined => true, string str => string.IsNullOrEmpty(str), IDictionary dict => dict.Count == 0, - IEnumerable enumerable => !enumerable.Cast().Any(), + IEnumerable enumerable => !HasAny(enumerable), _ => false }; } + /// + /// Checks if an IEnumerable has any elements. + /// + /// + /// + private static bool HasAny(IEnumerable enumerable) + { + var e = enumerable.GetEnumerator(); + try + { + return e.MoveNext(); + } + finally + { + (e as IDisposable)?.Dispose(); + } + } + /// /// Interpret numeric entities in a string, converting them to their Unicode characters. /// @@ -802,7 +847,7 @@ public static string InterpretNumericEntities(string str) /// internal static Dictionary ToObjectKeyedDictionary(IDictionary src) { - var dict = new Dictionary(); + var dict = new Dictionary(src.Count); foreach (DictionaryEntry de in src) dict[de.Key] = de.Value; return dict; @@ -867,8 +912,10 @@ IDictionary dictionary switch (value) { case IDictionary dict: - foreach (var key in dict.Keys.Cast().ToArray()) - dict[key] = ConvertNestedValues(dict[key], visited); + var keysCol = dict.Keys; + var keysArr = new object[dict.Count]; + keysCol.CopyTo(keysArr, 0); + foreach (var key in keysArr) dict[key] = ConvertNestedValues(dict[key], visited); return NormalizeForTarget(dict); case IList list: @@ -878,7 +925,9 @@ IDictionary dictionary case IEnumerable seq and not string: - return seq.Cast().Select(v => ConvertNestedValues(v, visited)).ToList(); + var seqList = new List(); + foreach (var v in seq) seqList.Add(ConvertNestedValues(v, visited)); + return seqList; default: return value; @@ -895,6 +944,12 @@ IDictionary dictionary return ConvertNestedDictionary(dict, new HashSet(ReferenceEqualityComparer.Instance)); } + /// + /// Converts a nested IDictionary structure to a Dictionary with string keys. + /// + /// + /// + /// private static Dictionary ConvertNestedDictionary( IDictionary dict, ISet visited @@ -973,12 +1028,13 @@ private static object NormalizeForTarget(IDictionary map) if (map is Dictionary ok) return ok; - if (map.Keys.Cast().Any(k => ReferenceEquals(map[k], map))) - return map; + foreach (DictionaryEntry de in map) + if (ReferenceEquals(de.Value, map)) + return map; var copy = new Dictionary(map.Count); - foreach (var k in map.Keys) - copy[k] = map[k]; + foreach (DictionaryEntry de in map) + copy[de.Key] = de.Value; return copy; } @@ -1005,88 +1061,119 @@ private static object NormalizeForTarget(IDictionary map) { var (src, dst) = stack.Pop(); - if (src is IDictionary sd && dst is Dictionary dd) - foreach (DictionaryEntry de in sd) - { - var key = de.Key.ToString() ?? string.Empty; - var val = de.Value; - - switch (val) + switch (src) + { + // Dictionary node ➜ Dictionary + case IDictionary sd when dst is Dictionary dd: + foreach (DictionaryEntry de in sd) { - case IDictionary child: - // Preserve identity for already string-keyed child maps - if (child is Dictionary sk) - { - dd[key] = sk; - // register so future references reuse this instance - if (!visited.ContainsKey(child)) visited[child] = sk; + var key = de.Key?.ToString() ?? string.Empty; + var val = de.Value; + + switch (val) + { + case IDictionary child: + // Preserve identity for already string-keyed child maps + if (child is Dictionary sk) + { + dd[key] = sk; + if (!visited.ContainsKey(child)) visited[child] = sk; + } + else if (visited.TryGetValue(child, out var existing)) + { + dd[key] = existing; + } + else + { + var newChild = new Dictionary(child.Count); + dd[key] = newChild; + visited[child] = newChild; + stack.Push((child, newChild)); + } + break; - } - if (visited.TryGetValue(child, out var existing)) - { - dd[key] = existing; - } - else - { - var newChild = new Dictionary(child.Count); - dd[key] = newChild; - visited[child] = newChild; - stack.Push((child, newChild)); - } + case IList list: + if (visited.TryGetValue(list, out var existingList)) + { + dd[key] = existingList; + } + else + { + var newList = new List(list.Count); + dd[key] = newList; + visited[list] = newList; + stack.Push((list, newList)); + } - break; + break; - case IList list: - if (visited.TryGetValue(list, out var existingList)) - { - dd[key] = existingList; + default: + dd[key] = val; break; - } + } + } - var newList = new List(list.Count); - dd[key] = newList; - visited[list] = newList; + break; - foreach (var item in list) - if (item is IDictionary inner) + // List node ➜ List + case IList srcList when dst is List dstList: + foreach (var item in srcList) + { + switch (item) + { + case IDictionary innerDict: + if (innerDict is Dictionary sk) { - if (inner is Dictionary innerSk) - { - newList.Add(innerSk); - if (!visited.ContainsKey(inner)) visited[inner] = innerSk; - } - else if (visited.TryGetValue(inner, out var ex)) - { - newList.Add(ex); - } - else - { - var newInner = new Dictionary(inner.Count); - newList.Add(newInner); - visited[inner] = newInner; - stack.Push((inner, newInner)); - } + dstList.Add(sk); + if (!visited.ContainsKey(innerDict)) visited[innerDict] = sk; + } + else if (visited.TryGetValue(innerDict, out var existing)) + { + dstList.Add(existing); } else { - newList.Add(item); + var newDict = new Dictionary(innerDict.Count); + dstList.Add(newDict); + visited[innerDict] = newDict; + stack.Push((innerDict, newDict)); } - break; + break; + + case IList innerList: + if (visited.TryGetValue(innerList, out var existingList)) + { + dstList.Add(existingList); + } + else + { + var newList = new List(innerList.Count); + dstList.Add(newList); + visited[innerList] = newList; + stack.Push((innerList, newList)); + } - default: - dd[key] = val; - break; + break; + + default: + dstList.Add(item); + break; + } } - } + + break; + } } return top; } } -// Reference-equality comparer used to track visited nodes without relying on value equality +/// +/// Reference-equality comparer used to track visited nodes without relying on value equality +/// internal sealed class ReferenceEqualityComparer : IEqualityComparer { public static readonly ReferenceEqualityComparer Instance = new(); diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index 52c0013..db011e2 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -56,11 +56,11 @@ public static class Qs finalOptions = opts.CopyWith(parseLists: false); // keep internal work in object-keyed maps - var obj = new Dictionary(); - if (tempObj is not { Count: > 0 }) return new Dictionary(); + var obj = new Dictionary(tempObj.Count); + #if NETSTANDARD2_0 foreach (var kv in tempObj) { @@ -137,9 +137,7 @@ public static string Encode(object? data, EncodeOptions? options = null) genericDict ), IDictionary map => Utils.ConvertDictionaryToStringKeyed(map), - IEnumerable en and not string => en.Cast() - .Select((v, i) => (Key: i.ToString(), Val: v)) - .ToDictionary(t => t.Key, t => t.Val), + IEnumerable en and not string => CreateIndexDictionary(en), _ => new Dictionary() }; @@ -177,7 +175,12 @@ public static string Encode(object? data, EncodeOptions? options = null) } // Default keys if filter didn't provide - objKeys ??= obj.Keys.Cast().ToList(); + if (objKeys is null) + { + objKeys = new List(obj.Count); + foreach (var k in obj.Keys) + objKeys.Add(k); + } // Optional sort if (opts.Sort != null) @@ -187,7 +190,7 @@ public static string Encode(object? data, EncodeOptions? options = null) var sideChannel = new SideChannelFrame(); // Collect "key=value" parts - var parts = new List(); + var parts = new List(objKeys.Count); for (var i = 0; i < objKeys.Count; i++) { @@ -196,8 +199,7 @@ public static string Encode(object? data, EncodeOptions? options = null) if (keyObj is not string key) continue; - var hasKey = obj.ContainsKey(key); - obj.TryGetValue(key, out var value); + var hasKey = obj.TryGetValue(key, out var value); if (!hasKey && opts.SkipNulls) continue; @@ -230,10 +232,12 @@ public static string Encode(object? data, EncodeOptions? options = null) switch (encoded) { case IEnumerable en and not string: - parts.AddRange( - en.Cast().Where(p => p is not null).Select(p => p!.ToString()!) - ); - break; + { + foreach (var p in en) + if (p is not null) + parts.Add(p.ToString()!); + break; + } case string { Length: > 0 } s: parts.Add(s); break; @@ -243,7 +247,7 @@ public static string Encode(object? data, EncodeOptions? options = null) var joined = string.Join(opts.Delimiter, parts); // Build final output - var sb = new StringBuilder(); + var sb = new StringBuilder(joined.Length + 16); if (opts.AddQueryPrefix) sb.Append('?'); @@ -261,5 +265,15 @@ public static string Encode(object? data, EncodeOptions? options = null) sb.Append(joined); return sb.ToString(); + + static Dictionary CreateIndexDictionary(IEnumerable en) + { + var initial = en is ICollection col ? col.Count : 0; + var dict = new Dictionary(initial); + var i = 0; + foreach (var v in en) + dict.Add(i++.ToString(), v); + return dict; + } } } \ No newline at end of file