From 430f84c5aee11d35181851b6baecde9dd318c6e5 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 09:13:18 +0000 Subject: [PATCH 1/9] :bug: enhance Utils with overflow handling and list limit enforcement --- .../Internal/Decoder+ParseObject.swift | 15 +- .../QsSwift/Internal/Decoder+ParseQuery.swift | 10 +- Sources/QsSwift/Internal/Utils+Combine.swift | 51 ++++++ Sources/QsSwift/Internal/Utils+Merge.swift | 107 ++++++++++++- Sources/QsSwift/Internal/Utils+Overflow.swift | 50 ++++++ Sources/QsSwift/Internal/Utils.swift | 2 + Sources/QsSwift/Qs+Decode.swift | 26 +++ Tests/QsObjCTests/ObjCDecodeTests.swift | 8 + Tests/QsSwiftTests/DecodeTests.swift | 149 +++++++++++++----- Tests/QsSwiftTests/UtilsTests.swift | 86 ++++++++++ 10 files changed, 455 insertions(+), 49 deletions(-) create mode 100644 Sources/QsSwift/Internal/Utils+Overflow.swift diff --git a/Sources/QsSwift/Internal/Decoder+ParseObject.swift b/Sources/QsSwift/Internal/Decoder+ParseObject.swift index e26b42c2..726e21fc 100644 --- a/Sources/QsSwift/Internal/Decoder+ParseObject.swift +++ b/Sources/QsSwift/Internal/Decoder+ParseObject.swift @@ -55,16 +55,19 @@ extension QsSwift.Decoder { let obj: Any? if root == "[]" && options.parseLists { - if options.allowEmptyLists + if Utils.isOverflow(leaf) { + obj = leaf + } else if options.allowEmptyLists && ((leaf as? String) == "" || (options.strictNullHandling && leaf == nil)) { obj = [Any]() // empty list - } else if let arr = leaf as? [Any] { - obj = arr - } else if let arrOpt = leaf as? [Any?] { - obj = arrOpt.map { $0 ?? NSNull() } // normalize to non-optional } else { - obj = [leaf ?? NSNull()] // wrap scalar + let valueForCombine: Any? = { + if let arr = leaf as? [Any] { return arr } + if let arrOpt = leaf as? [Any?] { return arrOpt } + return leaf ?? NSNull() + }() + obj = Utils.combine([], valueForCombine, listLimit: options.listLimit) } } else { var mutableObj: [AnyHashable: Any] = [:] diff --git a/Sources/QsSwift/Internal/Decoder+ParseQuery.swift b/Sources/QsSwift/Internal/Decoder+ParseQuery.swift index c678d843..7cd00054 100644 --- a/Sources/QsSwift/Internal/Decoder+ParseQuery.swift +++ b/Sources/QsSwift/Internal/Decoder+ParseQuery.swift @@ -199,8 +199,14 @@ extension QsSwift.Decoder { case .combine: if exists { let prev: Any? = obj[key] ?? nil - let combined: [Any?] = Utils.combine(prev, value) - obj[key] = combined.map { $0 ?? NSNull() } // normalize optionals + let combined = Utils.combine(prev, value, listLimit: options.listLimit) + if let combinedArray = combined as? [Any?] { + obj[key] = combinedArray.map { $0 ?? NSNull() } // normalize optionals + } else if let combinedArray = combined as? [Any] { + obj[key] = combinedArray + } else { + obj[key] = combined + } } else { obj[key] = value ?? NSNull() } diff --git a/Sources/QsSwift/Internal/Utils+Combine.swift b/Sources/QsSwift/Internal/Utils+Combine.swift index aae42b96..f7003b47 100644 --- a/Sources/QsSwift/Internal/Utils+Combine.swift +++ b/Sources/QsSwift/Internal/Utils+Combine.swift @@ -26,4 +26,55 @@ extension Utils { return result } + + /// Combines two objects while honoring list limits; may return an overflow map. + /// - Returns: `[Any?]` when within limit, or `[AnyHashable: Any]` when exceeded. + @usableFromInline + static func combine(_ first: Any?, _ second: Any?, listLimit: Int) -> Any { + if let dict = first as? [AnyHashable: Any], isOverflow(dict) { + return appendOverflow(dict, value: second) + } + + var result: [Any?] = [] + appendCombineValue(first, into: &result) + appendCombineValue(second, into: &result) + + guard listLimit >= 0, result.count > listLimit else { + return result + } + + return arrayToOverflowObject(result) + } + + private static func appendCombineValue(_ value: Any?, into array: inout [Any?]) { + if let arrOpt = value as? [Any?] { + array.append(contentsOf: arrOpt) + } else if let arr = value as? [Any] { + array.append(contentsOf: arr.map(Optional.some)) + } else if let value = value { + array.append(value) + } + } + + private static func arrayToOverflowObject(_ array: [Any?]) -> [AnyHashable: Any] { + var dict: [AnyHashable: Any] = [:] + dict.reserveCapacity(array.count + 1) + for (index, value) in array.enumerated() { + dict[index] = value ?? NSNull() + } + return markOverflow(dict, maxIndex: array.count - 1) + } + + private static func appendOverflow( + _ dict: [AnyHashable: Any], + value: Any? + ) -> [AnyHashable: Any] { + guard let value = value else { return dict } + var copy = dict + let currentMax = overflowMaxIndex(copy) ?? -1 + let nextIndex = currentMax + 1 + copy[nextIndex] = value + setOverflowMaxIndex(©, nextIndex) + return copy + } } diff --git a/Sources/QsSwift/Internal/Utils+Merge.swift b/Sources/QsSwift/Internal/Utils+Merge.swift index 1e5a89de..f625e70c 100644 --- a/Sources/QsSwift/Internal/Utils+Merge.swift +++ b/Sources/QsSwift/Internal/Utils+Merge.swift @@ -18,14 +18,37 @@ extension QsSwift.Utils { if let tArr = target as? [Any?], let sDict = source as? [AnyHashable: Any] { var tDict: [AnyHashable: Any] = [:] + var maxIndex = -1 + for (idx, element) in tArr.enumerated() where !(element is Undefined) { tDict[idx] = element ?? NSNull() + if idx > maxIndex { maxIndex = idx } + } + + for (key, value) in sDict where !Utils.isOverflowKey(key) { + tDict[key] = value + if let idx = key as? Int, idx > maxIndex { + maxIndex = idx + } + } + + if Utils.isOverflow(sDict) { + Utils.setOverflowMaxIndex(&tDict, maxIndex) } - for (key, value) in sDict { tDict[key] = value } return tDict } if let tDict = target as? [AnyHashable: Any], let sArr = source as? [Any?] { + if Utils.isOverflow(tDict) { + var overflow = tDict + var maxIndex = Utils.overflowMaxIndex(overflow) ?? -1 + for element in sArr where !(element is Undefined) { + maxIndex += 1 + overflow[maxIndex] = element ?? NSNull() + } + Utils.setOverflowMaxIndex(&overflow, maxIndex) + return overflow + } var sDict: [AnyHashable: Any] = [:] for (idx, element) in sArr.enumerated() where !(element is Undefined) { sDict[idx] = element ?? NSNull() @@ -135,6 +158,24 @@ extension QsSwift.Utils { } } } else if let targetDict = target as? [AnyHashable: Any] { + if Utils.isOverflow(targetDict) { + var overflow = targetDict + var maxIndex = Utils.overflowMaxIndex(overflow) ?? -1 + + if let seq = asSequence(source) { + for item in seq where !(item is Undefined) { + maxIndex += 1 + overflow[maxIndex] = item + } + } else if !(source is Undefined) { + maxIndex += 1 + overflow[maxIndex] = source + } + + Utils.setOverflowMaxIndex(&overflow, maxIndex) + return overflow + } + var mutableTarget = targetDict if let seq = asSequence(source) { @@ -159,6 +200,43 @@ extension QsSwift.Utils { } if target == nil || !(target is [AnyHashable: Any]) { + if let sourceDict = source as? [AnyHashable: Any], Utils.isOverflow(sourceDict) { + if let targetArray = target as? [Any] { + var mutableTarget: [AnyHashable: Any] = [:] + var maxIndex = -1 + for (index, value) in targetArray.enumerated() where !(value is Undefined) { + mutableTarget[index] = value + if index > maxIndex { maxIndex = index } + } + for (key, value) in sourceDict where !Utils.isOverflowKey(key) { + mutableTarget[key] = value + if let idx = key as? Int, idx > maxIndex { + maxIndex = idx + } + } + Utils.setOverflowMaxIndex(&mutableTarget, maxIndex) + return mutableTarget + } + + var result: [AnyHashable: Any] = [:] + if let target = target { + result[0] = target + } else { + result[0] = NSNull() + } + + for (key, value) in sourceDict where !Utils.isOverflowKey(key) { + if let idx = key as? Int { + result[idx + 1] = value + } else { + result[key] = value + } + } + + let newMax = (Utils.overflowMaxIndex(sourceDict) ?? -1) + 1 + return Utils.markOverflow(result, maxIndex: newMax) + } + if let targetArray = target as? [Any] { var mutableTarget: [AnyHashable: Any] = [:] for (index, value) in targetArray.enumerated() where !(value is Undefined) { @@ -205,12 +283,37 @@ extension QsSwift.Utils { } if let sourceDict = source as? [AnyHashable: Any] { - for (key, value) in sourceDict { + let targetIsOverflow = Utils.isOverflow(mergeTarget) + let sourceIsOverflow = Utils.isOverflow(sourceDict) + var overflowMax: Int? + + if targetIsOverflow { + overflowMax = Utils.overflowMaxIndex(mergeTarget) ?? -1 + } else if sourceIsOverflow { + overflowMax = -1 + } + + for (key, value) in sourceDict where !Utils.isOverflowKey(key) { if let existingValue = mergeTarget[key] { mergeTarget[key] = merge(target: existingValue, source: value, options: options) } else { mergeTarget[key] = value } + + if let idx = key as? Int, let current = overflowMax, idx > current { + overflowMax = idx + } + } + + if sourceIsOverflow || targetIsOverflow { + if let sourceMax = Utils.overflowMaxIndex(sourceDict), + sourceMax > (overflowMax ?? -1) + { + overflowMax = sourceMax + } + if let maxIndex = overflowMax { + Utils.setOverflowMaxIndex(&mergeTarget, maxIndex) + } } } diff --git a/Sources/QsSwift/Internal/Utils+Overflow.swift b/Sources/QsSwift/Internal/Utils+Overflow.swift new file mode 100644 index 00000000..ddbf1526 --- /dev/null +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -0,0 +1,50 @@ +import Foundation + +extension Utils { + internal struct OverflowKey: Hashable, Sendable {} + + @usableFromInline + internal static let overflowKey = OverflowKey() + + @usableFromInline + internal static func isOverflow(_ value: Any?) -> Bool { + guard let dict = value as? [AnyHashable: Any] else { return false } + return dict[AnyHashable(overflowKey)] is Int + } + + @usableFromInline + internal static func overflowMaxIndex(_ dict: [AnyHashable: Any]) -> Int? { + dict[AnyHashable(overflowKey)] as? Int + } + + @usableFromInline + internal static func setOverflowMaxIndex(_ dict: inout [AnyHashable: Any], _ maxIndex: Int) { + dict[overflowKey] = maxIndex + } + + @usableFromInline + internal static func markOverflow( + _ dict: [AnyHashable: Any], + maxIndex: Int + ) -> [AnyHashable: Any] { + var copy = dict + copy[overflowKey] = maxIndex + return copy + } + + @usableFromInline + internal static func isOverflowKey(_ key: AnyHashable) -> Bool { + key == AnyHashable(overflowKey) + } + + @usableFromInline + internal static func refreshOverflowMaxIndex(_ dict: inout [AnyHashable: Any]) { + var maxIndex = -1 + for key in dict.keys where !isOverflowKey(key) { + if let idx = key as? Int, idx > maxIndex { + maxIndex = idx + } + } + dict[overflowKey] = maxIndex + } +} diff --git a/Sources/QsSwift/Internal/Utils.swift b/Sources/QsSwift/Internal/Utils.swift index 2356f3b4..c0773027 100644 --- a/Sources/QsSwift/Internal/Utils.swift +++ b/Sources/QsSwift/Internal/Utils.swift @@ -103,6 +103,7 @@ internal enum Utils { let box = DictBox() stack.append(.commitDict(box, assign)) for (keyHash, child) in dictAHOpt { + if Utils.isOverflowKey(keyHash) { continue } let keyString = String(describing: keyHash) stack.append(.build(node: child, assign: { value in box.dict[keyString] = value })) } @@ -113,6 +114,7 @@ internal enum Utils { let box = DictBox() stack.append(.commitDict(box, assign)) for (keyHash, child) in dictAH { + if Utils.isOverflowKey(keyHash) { continue } let keyString = String(describing: keyHash) stack.append(.build(node: child, assign: { value in box.dict[keyString] = value })) } diff --git a/Sources/QsSwift/Qs+Decode.swift b/Sources/QsSwift/Qs+Decode.swift index 0ab4dd74..ee59beeb 100644 --- a/Sources/QsSwift/Qs+Decode.swift +++ b/Sources/QsSwift/Qs+Decode.swift @@ -210,6 +210,19 @@ extension Qs { // Merge each parsed key structure into the final object, preserving order var obj: [String: Any] = [:] if !tmp.isEmpty { + let objectifyOverflow: ([AnyHashable: Any]) -> [String: Any] = { dict in + var out: [String: Any] = [:] + out.reserveCapacity(dict.count) + for (key, value) in dict where !Utils.isOverflowKey(key) { + if let idx = key as? Int { + out[String(idx)] = value + } else { + out[String(describing: key)] = value + } + } + return out + } + for (key, value) in tmp { let parsed = try QsSwift.Decoder.parseKeys( givenKey: key, @@ -223,6 +236,12 @@ extension Qs { obj = firstMap continue } + if obj.isEmpty, let overflow = parsed as? [AnyHashable: Any], + Utils.isOverflow(overflow) + { + obj = objectifyOverflow(overflow) + continue + } // (b) If the first parsed thing is a *list* (e.g. top-level "[]"), // objectify it as "0","1",... so tests like "[]=&a=b" pass. @@ -251,6 +270,13 @@ extension Qs { { obj = merged } + } else if let overflow = parsed as? [AnyHashable: Any], Utils.isOverflow(overflow) { + let indexed = objectifyOverflow(overflow) + if let merged = Utils.merge(target: obj, source: indexed, options: finalOptions) + as? [String: Any] + { + obj = merged + } } else if let parsed = parsed { // Non-array, non-nil fragment → merge as-is if let merged = Utils.merge(target: obj, source: parsed, options: finalOptions) diff --git a/Tests/QsObjCTests/ObjCDecodeTests.swift b/Tests/QsObjCTests/ObjCDecodeTests.swift index f81a5dbc..0d556017 100644 --- a/Tests/QsObjCTests/ObjCDecodeTests.swift +++ b/Tests/QsObjCTests/ObjCDecodeTests.swift @@ -204,6 +204,14 @@ #expect(a?["1"] as? String == "b") } + @Test("objc-decode: listLimit applies to [] notation") + func listLimitZeroEmptyBrackets() throws { + let r = decode("a[]=1&a[]=2") { o in o.listLimit = 0 } + let a = r["a"] as? NSDictionary + #expect(a?["0"] as? String == "1") + #expect(a?["1"] as? String == "2") + } + @Test("objc-decode: parseLists=false forces map") func parseListsFalse() throws { let r = decode("a[]=b") { o in o.parseLists = false } diff --git a/Tests/QsSwiftTests/DecodeTests.swift b/Tests/QsSwiftTests/DecodeTests.swift index 9f514722..907de550 100644 --- a/Tests/QsSwiftTests/DecodeTests.swift +++ b/Tests/QsSwiftTests/DecodeTests.swift @@ -556,7 +556,9 @@ struct DecodeTests { } do { let r = try Qs.decode("a[]=b&a=c", options: DecodeOptions(listLimit: 0)) - #expect(asStrings(r["a"]) == ["b", "c"]) + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "c") } do { let r = try Qs.decode("a[]=b&a=c") @@ -569,7 +571,9 @@ struct DecodeTests { } do { let r = try Qs.decode("a=b&a[]=c", options: DecodeOptions(listLimit: 0)) - #expect(asStrings(r["a"]) == ["b", "c"]) + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "c") } do { let r = try Qs.decode("a=b&a[]=c") @@ -929,11 +933,11 @@ struct DecodeTests { "a[]=b&a[]&a[]=c&a[]=", options: DecodeOptions(listLimit: 0, strictNullHandling: true) ) - let arr = r["a"] as? [Any] - #expect((arr?[0] as? String) == "b") - #expect(isNSNullValue(arr?[1])) - #expect((arr?[2] as? String) == "c") - #expect((arr?[3] as? String) == "") + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect(isNSNullValue(a?["1"])) + #expect((a?["2"] as? String) == "c") + #expect((a?["3"] as? String) == "") } do { let r = try Qs.decode( @@ -951,11 +955,11 @@ struct DecodeTests { "a[]=b&a[]=&a[]=c&a[]", options: DecodeOptions(listLimit: 0, strictNullHandling: true) ) - let arr = r["a"] as? [Any] - #expect((arr?[0] as? String) == "b") - #expect((arr?[1] as? String) == "") - #expect((arr?[2] as? String) == "c") - #expect(isNSNullValue(arr?[3])) + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "") + #expect((a?["2"] as? String) == "c") + #expect(isNSNullValue(a?["3"])) } do { let r = try Qs.decode("a[]=&a[]=b&a[]=c") @@ -1386,11 +1390,11 @@ struct DecodeTests { let r = try Qs.decode( "a[]=b&a[]&a[]=c&a[]=", options: DecodeOptions(listLimit: 0, strictNullHandling: true)) - let a = r["a"] as? [Any] - #expect((a?[0] as? String) == "b") - #expect(isNSNullValue(a?[1])) - #expect((a?[2] as? String) == "c") - #expect((a?[3] as? String) == "") + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect(isNSNullValue(a?["1"])) + #expect((a?["2"] as? String) == "c") + #expect((a?["3"] as? String) == "") } do { let r = try Qs.decode( @@ -1406,11 +1410,11 @@ struct DecodeTests { let r = try Qs.decode( "a[]=b&a[]=&a[]=c&a[]", options: DecodeOptions(listLimit: 0, strictNullHandling: true)) - let a = r["a"] as? [Any] - #expect((a?[0] as? String) == "b") - #expect((a?[1] as? String) == "") - #expect((a?[2] as? String) == "c") - #expect(isNSNullValue(a?[3])) + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "") + #expect((a?["2"] as? String) == "c") + #expect(isNSNullValue(a?["3"])) } #expect(asStrings(try Qs.decode("a[]=&a[]=b&a[]=c")["a"]) == ["", "b", "c"]) } @@ -2236,7 +2240,60 @@ struct ListLimitTests { @Test("handles list limit of zero correctly") func listLimitZero() throws { let r = try Qs.decode("a[]=1&a[]=2", options: .init(listLimit: 0)) - #expect((r["a"] as? [String]) == ["1", "2"]) + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "1") + #expect((a?["1"] as? String) == "2") + #expect(a?.count == 2) + } + + @Test("list limit applies to [] overflow") + func listLimitAppliesToEmptyBracketOverflow() throws { + let attack = Array(repeating: "a[]=x", count: 105).joined(separator: "&") + let result = try Qs.decode(attack, options: .init(listLimit: 100)) + let a = asDictString(result["a"]) + #expect(a?.count == 105) + #expect((a?["0"] as? String) == "x") + #expect((a?["104"] as? String) == "x") + } + + @Test("list limit boundary conditions for []") + func listLimitBoundaryConditions() throws { + do { + let result = try Qs.decode( + "a[]=1&a[]=2&a[]=3", + options: .init(listLimit: 3)) + #expect(asStrings(result["a"]) == ["1", "2", "3"]) + } + do { + let result = try Qs.decode( + "a[]=1&a[]=2&a[]=3&a[]=4", + options: .init(listLimit: 3)) + let a = asDictString(result["a"]) + #expect((a?["0"] as? String) == "1") + #expect((a?["3"] as? String) == "4") + #expect(a?.count == 4) + } + do { + let result = try Qs.decode( + "a[]=1&a[]=2", + options: .init(listLimit: 1)) + let a = asDictString(result["a"]) + #expect((a?["0"] as? String) == "1") + #expect((a?["1"] as? String) == "2") + #expect(a?.count == 2) + } + } + + @Test("list limit applies to duplicate keys") + func listLimitAppliesToDuplicateKeys() throws { + let under = try Qs.decode("a=b&a=c&a=d", options: .init(listLimit: 20)) + #expect(asStrings(under["a"]) == ["b", "c", "d"]) + + let over = try Qs.decode("a=b&a=c&a=d", options: .init(listLimit: 2)) + let a = asDictString(over["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["2"] as? String) == "d") + #expect(a?.count == 3) } @Test("negative list limit throws (when throwOnLimitExceeded = true)") @@ -2450,13 +2507,23 @@ extension DecodeTests { #expect((r["a"] as? [Any])?.count == 2) r = try Qs.decode("a[]=b&a=c", options: opt0) - #expect((r["a"] as? [Any])?.count == 2) + do { + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "c") + #expect(a?.count == 2) + } r = try Qs.decode("a=b&a[1]=c", options: opt20) #expect((r["a"] as? [Any])?.count == 2) r = try Qs.decode("a=b&a[]=c", options: opt0) - #expect((r["a"] as? [Any])?.count == 2) + do { + let a = asDictString(r["a"]) + #expect((a?["0"] as? String) == "b") + #expect((a?["1"] as? String) == "c") + #expect(a?.count == 2) + } } // MARK: nested arrays @@ -2759,11 +2826,11 @@ extension DecodeTests { #expect(a1?.last as? String == "") r = try Qs.decode("a[]=b&a[]&a[]=c&a[]=", options: optStrict0) - let a2 = r["a"] as? [Any] - #expect(a2?[0] as? String == "b") - #expect(a2?[1] is NSNull) - #expect(a2?[2] as? String == "c") - #expect(a2?[3] as? String == "") + let a2 = asDictString(r["a"]) + #expect(a2?["0"] as? String == "b") + #expect(isNSNullValue(a2?["1"])) + #expect(a2?["2"] as? String == "c") + #expect(a2?["3"] as? String == "") r = try Qs.decode("a[0]=b&a[1]=&a[2]=c&a[19]", options: optStrict20) let a3 = r["a"] as? [Any] @@ -2773,17 +2840,17 @@ extension DecodeTests { #expect(a3?.last is NSNull) r = try Qs.decode("a[]=b&a[]=&a[]=c&a[]", options: optStrict0) - let a4 = r["a"] as? [Any] - #expect(a4?[0] as? String == "b") - #expect(a4?[1] as? String == "") - #expect(a4?[2] as? String == "c") - #expect(a4?[3] is NSNull) + let a4 = asDictString(r["a"]) + #expect(a4?["0"] as? String == "b") + #expect(a4?["1"] as? String == "") + #expect(a4?["2"] as? String == "c") + #expect(isNSNullValue(a4?["3"])) r = try Qs.decode("a[]=&a[]=b&a[]=c", options: optStrict0) - let a5 = r["a"] as? [Any] - #expect(a5?[0] as? String == "") - #expect(a5?[1] as? String == "b") - #expect(a5?[2] as? String == "c") + let a5 = asDictString(r["a"]) + #expect(a5?["0"] as? String == "") + #expect(a5?["1"] as? String == "b") + #expect(a5?["2"] as? String == "c") } // MARK: compact arrays (no sparse) @@ -2855,6 +2922,10 @@ extension DecodeTests { #expect(r["0"] as? String == "") #expect(r["a"] as? String == "b") + r = try Qs.decode("[]=&a=b", options: DecodeOptions(listLimit: 0)) + #expect(r["0"] as? String == "") + #expect(r["a"] as? String == "b") + r = try Qs.decode("[]&a=b", options: strict) #expect(r["0"] is NSNull) #expect(r["a"] as? String == "b") diff --git a/Tests/QsSwiftTests/UtilsTests.swift b/Tests/QsSwiftTests/UtilsTests.swift index dc616cce..d45fc1ff 100644 --- a/Tests/QsSwiftTests/UtilsTests.swift +++ b/Tests/QsSwiftTests/UtilsTests.swift @@ -777,6 +777,92 @@ struct UtilsTests { #expect(result2 == [1, 2, 3]) } + @Test("Utils.combine - applies list limit and overflow tracking") + func testCombineListLimitOverflow() async throws { + let under = Utils.combine(["a", "b"], "c", listLimit: 10) + #expect((under as? [Any]) != nil) + + let exact = Utils.combine(["a", "b"], "c", listLimit: 3) + #expect((exact as? [Any]) != nil) + + let over = Utils.combine(["a", "b", "c"], "d", listLimit: 3) + let overDict = over as? [AnyHashable: Any] + #expect(overDict != nil) + #expect(Utils.isOverflow(overDict)) + if let overDict { + let cleaned = overDict.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + #expect(cleaned[AnyHashable(3)] as? String == "d") + } + + let zero = Utils.combine([], "a", listLimit: 0) + let zeroDict = zero as? [AnyHashable: Any] + #expect(zeroDict != nil) + if let zeroDict { + let cleaned = zeroDict.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + } + } + + @Test("Utils.combine - appends to overflow objects") + func testCombineAppendsToOverflow() async throws { + let overflow = Utils.combine(["a"], "b", listLimit: 1) + let overflowDict = overflow as? [AnyHashable: Any] + #expect(Utils.isOverflow(overflowDict)) + + let combined = Utils.combine(overflow, "c", listLimit: 10) + let combinedDict = combined as? [AnyHashable: Any] + #expect(Utils.isOverflow(combinedDict)) + if let combinedDict { + let cleaned = combinedDict.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + #expect(cleaned[AnyHashable(2)] as? String == "c") + } + } + + @Test("Utils.merge - handles overflow objects") + func testMergeOverflowObjects() async throws { + let overflow = Utils.combine(["a"], "b", listLimit: 1) as? [AnyHashable: Any] + #expect(Utils.isOverflow(overflow)) + + if let overflow { + let merged = Utils.merge(target: overflow, source: "c") as? [AnyHashable: Any] + #expect(Utils.isOverflow(merged)) + if let merged { + let cleaned = merged.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + #expect(cleaned[AnyHashable(2)] as? String == "c") + } + + let mergedIntoPrimitive = Utils.merge(target: "z", source: overflow) as? [AnyHashable: Any] + #expect(Utils.isOverflow(mergedIntoPrimitive)) + if let mergedIntoPrimitive { + let cleaned = mergedIntoPrimitive.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "z") + #expect(cleaned[AnyHashable(2)] as? String == "b") + } + } + } + + @Test("Utils.merge - preserves overflow max index when merging into array target") + func testMergeOverflowIntoArrayTarget() async throws { + let overflow = Utils.combine(["a"], "b", listLimit: 1) as? [AnyHashable: Any] + #expect(Utils.isOverflow(overflow)) + + if let overflow { + let merged = Utils.merge(target: ["z"], source: overflow) as? [AnyHashable: Any] + #expect(Utils.isOverflow(merged)) + + let appended = Utils.merge(target: merged, source: "c") as? [AnyHashable: Any] + #expect(Utils.isOverflow(appended)) + if let appended { + let cleaned = appended.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + #expect(cleaned[AnyHashable(2)] as? String == "c") + } + } + } + // MARK: - Utils.interpretNumericEntities tests @Test("Utils.interpretNumericEntities - returns input unchanged when there are no entities") From f2ec76443d11ac7b5faafebe4c4e53102976b8c6 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:17:08 +0000 Subject: [PATCH 2/9] :white_check_mark: add test for Utils.refreshOverflowMaxIndex to verify max numeric key recomputation --- Tests/QsSwiftTests/UtilsTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Tests/QsSwiftTests/UtilsTests.swift b/Tests/QsSwiftTests/UtilsTests.swift index d45fc1ff..4a1033e1 100644 --- a/Tests/QsSwiftTests/UtilsTests.swift +++ b/Tests/QsSwiftTests/UtilsTests.swift @@ -863,6 +863,19 @@ struct UtilsTests { } } + @Test("Utils.refreshOverflowMaxIndex - recomputes max numeric key") + func testRefreshOverflowMaxIndex() async throws { + var dict: [AnyHashable: Any] = [ + AnyHashable(0): "a", + AnyHashable(2): "b", + AnyHashable("x"): "y", + AnyHashable(Utils.overflowKey): -1 + ] + + Utils.refreshOverflowMaxIndex(&dict) + #expect(Utils.overflowMaxIndex(dict) == 2) + } + // MARK: - Utils.interpretNumericEntities tests @Test("Utils.interpretNumericEntities - returns input unchanged when there are no entities") From 24fc76df495d42bb7b9f53e58155f0b280224a6c Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:27:47 +0000 Subject: [PATCH 3/9] :bug: enhance Utils.combine to flatten arrays when appending to overflow objects and update overflow max index handling --- Sources/QsSwift/Internal/Utils+Combine.swift | 25 +++++++++++++++---- Sources/QsSwift/Internal/Utils+Overflow.swift | 4 +-- Tests/QsSwiftTests/UtilsTests.swift | 24 ++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Sources/QsSwift/Internal/Utils+Combine.swift b/Sources/QsSwift/Internal/Utils+Combine.swift index f7003b47..04bdf30d 100644 --- a/Sources/QsSwift/Internal/Utils+Combine.swift +++ b/Sources/QsSwift/Internal/Utils+Combine.swift @@ -69,12 +69,27 @@ extension Utils { _ dict: [AnyHashable: Any], value: Any? ) -> [AnyHashable: Any] { - guard let value = value else { return dict } var copy = dict - let currentMax = overflowMaxIndex(copy) ?? -1 - let nextIndex = currentMax + 1 - copy[nextIndex] = value - setOverflowMaxIndex(©, nextIndex) + var maxIndex = overflowMaxIndex(copy) ?? -1 + + func appendElement(_ element: Any?) { + maxIndex += 1 + copy[maxIndex] = element ?? NSNull() + } + + if let arrOpt = value as? [Any?] { + for element in arrOpt { + appendElement(element) + } + } else if let arr = value as? [Any] { + for element in arr { + appendElement(element) + } + } else { + appendElement(value) + } + + setOverflowMaxIndex(©, maxIndex) return copy } } diff --git a/Sources/QsSwift/Internal/Utils+Overflow.swift b/Sources/QsSwift/Internal/Utils+Overflow.swift index ddbf1526..932a12d0 100644 --- a/Sources/QsSwift/Internal/Utils+Overflow.swift +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -41,10 +41,10 @@ extension Utils { internal static func refreshOverflowMaxIndex(_ dict: inout [AnyHashable: Any]) { var maxIndex = -1 for key in dict.keys where !isOverflowKey(key) { - if let idx = key as? Int, idx > maxIndex { + if let idx = (key as? Int) ?? (key as? NSNumber)?.intValue, idx > maxIndex { maxIndex = idx } } - dict[overflowKey] = maxIndex + setOverflowMaxIndex(&dict, maxIndex) } } diff --git a/Tests/QsSwiftTests/UtilsTests.swift b/Tests/QsSwiftTests/UtilsTests.swift index 4a1033e1..41892da1 100644 --- a/Tests/QsSwiftTests/UtilsTests.swift +++ b/Tests/QsSwiftTests/UtilsTests.swift @@ -820,6 +820,30 @@ struct UtilsTests { } } + @Test("Utils.combine - flattens arrays when appending to overflow objects") + func testCombineFlattensOverflowAppend() async throws { + let overflow = Utils.combine(["a"], "b", listLimit: 1) + let combined = Utils.combine(overflow, ["c", "d"], listLimit: 10) + let combinedDict = combined as? [AnyHashable: Any] + #expect(Utils.isOverflow(combinedDict)) + if let combinedDict { + let cleaned = combinedDict.filter { !Utils.isOverflowKey($0.key) } + #expect(cleaned[AnyHashable(0)] as? String == "a") + #expect(cleaned[AnyHashable(1)] as? String == "b") + #expect(cleaned[AnyHashable(2)] as? String == "c") + #expect(cleaned[AnyHashable(3)] as? String == "d") + } + + let combinedWithNil = Utils.combine(overflow, [nil, "e"], listLimit: 10) + let combinedNilDict = combinedWithNil as? [AnyHashable: Any] + #expect(Utils.isOverflow(combinedNilDict)) + if let combinedNilDict { + let cleaned = combinedNilDict.filter { !Utils.isOverflowKey($0.key) } + #expect((cleaned[AnyHashable(2)] as AnyObject) is NSNull) + #expect(cleaned[AnyHashable(3)] as? String == "e") + } + } + @Test("Utils.merge - handles overflow objects") func testMergeOverflowObjects() async throws { let overflow = Utils.combine(["a"], "b", listLimit: 1) as? [AnyHashable: Any] From 647fb58408f489268358a6695532e85870c90125 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:31:06 +0000 Subject: [PATCH 4/9] :bug: refactor Utils to use intIndex for key conversion and update tests for overflow max index --- Sources/QsSwift/Internal/Utils+Merge.swift | 8 ++++---- Sources/QsSwift/Internal/Utils+Overflow.swift | 10 +++++++++- Tests/QsSwiftTests/UtilsTests.swift | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/QsSwift/Internal/Utils+Merge.swift b/Sources/QsSwift/Internal/Utils+Merge.swift index f625e70c..b4c357a7 100644 --- a/Sources/QsSwift/Internal/Utils+Merge.swift +++ b/Sources/QsSwift/Internal/Utils+Merge.swift @@ -27,7 +27,7 @@ extension QsSwift.Utils { for (key, value) in sDict where !Utils.isOverflowKey(key) { tDict[key] = value - if let idx = key as? Int, idx > maxIndex { + if let idx = Utils.intIndex(key), idx > maxIndex { maxIndex = idx } } @@ -210,7 +210,7 @@ extension QsSwift.Utils { } for (key, value) in sourceDict where !Utils.isOverflowKey(key) { mutableTarget[key] = value - if let idx = key as? Int, idx > maxIndex { + if let idx = Utils.intIndex(key), idx > maxIndex { maxIndex = idx } } @@ -226,7 +226,7 @@ extension QsSwift.Utils { } for (key, value) in sourceDict where !Utils.isOverflowKey(key) { - if let idx = key as? Int { + if let idx = Utils.intIndex(key) { result[idx + 1] = value } else { result[key] = value @@ -300,7 +300,7 @@ extension QsSwift.Utils { mergeTarget[key] = value } - if let idx = key as? Int, let current = overflowMax, idx > current { + if let idx = Utils.intIndex(key), let current = overflowMax, idx > current { overflowMax = idx } } diff --git a/Sources/QsSwift/Internal/Utils+Overflow.swift b/Sources/QsSwift/Internal/Utils+Overflow.swift index 932a12d0..7c2f170b 100644 --- a/Sources/QsSwift/Internal/Utils+Overflow.swift +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -6,6 +6,14 @@ extension Utils { @usableFromInline internal static let overflowKey = OverflowKey() + @inline(__always) + @usableFromInline + internal static func intIndex(_ key: AnyHashable) -> Int? { + if let intValue = key.base as? Int { return intValue } + if let number = key.base as? NSNumber { return number.intValue } + return nil + } + @usableFromInline internal static func isOverflow(_ value: Any?) -> Bool { guard let dict = value as? [AnyHashable: Any] else { return false } @@ -41,7 +49,7 @@ extension Utils { internal static func refreshOverflowMaxIndex(_ dict: inout [AnyHashable: Any]) { var maxIndex = -1 for key in dict.keys where !isOverflowKey(key) { - if let idx = (key as? Int) ?? (key as? NSNumber)?.intValue, idx > maxIndex { + if let idx = intIndex(key), idx > maxIndex { maxIndex = idx } } diff --git a/Tests/QsSwiftTests/UtilsTests.swift b/Tests/QsSwiftTests/UtilsTests.swift index 41892da1..2fc12a55 100644 --- a/Tests/QsSwiftTests/UtilsTests.swift +++ b/Tests/QsSwiftTests/UtilsTests.swift @@ -892,12 +892,13 @@ struct UtilsTests { var dict: [AnyHashable: Any] = [ AnyHashable(0): "a", AnyHashable(2): "b", + AnyHashable(NSNumber(value: 7)): "c", AnyHashable("x"): "y", AnyHashable(Utils.overflowKey): -1 ] Utils.refreshOverflowMaxIndex(&dict) - #expect(Utils.overflowMaxIndex(dict) == 2) + #expect(Utils.overflowMaxIndex(dict) == 7) } // MARK: - Utils.interpretNumericEntities tests From f467266106392a64c4f56752767e6e10de8866c1 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:34:29 +0000 Subject: [PATCH 5/9] :bug: optimize array appending in Utils.appendCombineValue to improve performance --- Sources/QsSwift/Internal/Utils+Combine.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/QsSwift/Internal/Utils+Combine.swift b/Sources/QsSwift/Internal/Utils+Combine.swift index 04bdf30d..e958879d 100644 --- a/Sources/QsSwift/Internal/Utils+Combine.swift +++ b/Sources/QsSwift/Internal/Utils+Combine.swift @@ -50,7 +50,10 @@ extension Utils { if let arrOpt = value as? [Any?] { array.append(contentsOf: arrOpt) } else if let arr = value as? [Any] { - array.append(contentsOf: arr.map(Optional.some)) + array.reserveCapacity(array.count + arr.count) + for element in arr { + array.append(element) + } } else if let value = value { array.append(value) } From ede5dbc2f6c2abd9a31031ac2437266ac1dbd72b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:35:43 +0000 Subject: [PATCH 6/9] :bug: update overflow max index handling in Utils.merge to account for source max index --- Sources/QsSwift/Internal/Utils+Merge.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/QsSwift/Internal/Utils+Merge.swift b/Sources/QsSwift/Internal/Utils+Merge.swift index b4c357a7..131701c5 100644 --- a/Sources/QsSwift/Internal/Utils+Merge.swift +++ b/Sources/QsSwift/Internal/Utils+Merge.swift @@ -33,6 +33,9 @@ extension QsSwift.Utils { } if Utils.isOverflow(sDict) { + if let sourceMax = Utils.overflowMaxIndex(sDict), sourceMax > maxIndex { + maxIndex = sourceMax + } Utils.setOverflowMaxIndex(&tDict, maxIndex) } return tDict From 20851438cf387d79a30a64e4c4f2e46219c645aa Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:47:12 +0000 Subject: [PATCH 7/9] :bug: simplify overflow key handling in Utils by removing unnecessary AnyHashable wrapping --- Sources/QsSwift/Internal/Utils+Overflow.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/QsSwift/Internal/Utils+Overflow.swift b/Sources/QsSwift/Internal/Utils+Overflow.swift index 7c2f170b..47152fe3 100644 --- a/Sources/QsSwift/Internal/Utils+Overflow.swift +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -17,12 +17,12 @@ extension Utils { @usableFromInline internal static func isOverflow(_ value: Any?) -> Bool { guard let dict = value as? [AnyHashable: Any] else { return false } - return dict[AnyHashable(overflowKey)] is Int + return dict[overflowKey] is Int } @usableFromInline internal static func overflowMaxIndex(_ dict: [AnyHashable: Any]) -> Int? { - dict[AnyHashable(overflowKey)] as? Int + dict[overflowKey] as? Int } @usableFromInline @@ -42,7 +42,7 @@ extension Utils { @usableFromInline internal static func isOverflowKey(_ key: AnyHashable) -> Bool { - key == AnyHashable(overflowKey) + key.base is OverflowKey } @usableFromInline From ef812dcd6e3a09b41c7d9b0b682f357c036b24f7 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:52:45 +0000 Subject: [PATCH 8/9] :bug: update ReerKit dependency version to 1.2.2 in Package.swift and Package@swift-5.10.swift --- Package.resolved | 2 +- Package.swift | 2 +- Package@swift-5.10.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 29cc4044..1ea0fe45 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "158ec5bc0de29c9f8d3f8f6544d4d01a6653a9a460e31b4c01eb2c7e05ccba1d", + "originHash" : "7a78b7854f30bd981d945644ab40cce14073f5fb91c7e5ee5aeb5d707bf06c97", "pins" : [ { "identity" : "swift-algorithms", diff --git a/Package.swift b/Package.swift index e229143f..b23a8edb 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ var targetDeps: [Target.Dependency] = [ .product(name: "OrderedCollections", package: "swift-collections"), ] #if os(Linux) - deps.append(.package(url: "https://github.com/reers/ReerKit.git", from: "1.1.9")) + deps.append(.package(url: "https://github.com/reers/ReerKit.git", from: "1.2.2")) targetDeps.append(.product(name: "ReerKit", package: "ReerKit")) #endif diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift index 78067214..f7ab8c1d 100644 --- a/Package@swift-5.10.swift +++ b/Package@swift-5.10.swift @@ -14,7 +14,7 @@ var targetDeps: [Target.Dependency] = [ .product(name: "OrderedCollections", package: "swift-collections"), ] #if os(Linux) - deps.append(.package(url: "https://github.com/reers/ReerKit.git", from: "1.1.9")) + deps.append(.package(url: "https://github.com/reers/ReerKit.git", from: "1.2.2")) targetDeps.append(.product(name: "ReerKit", package: "ReerKit")) #endif From 12dee9c6ac95941cdb7536ed76a30ff0520773d4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Jan 2026 10:57:28 +0000 Subject: [PATCH 9/9] :bug: add documentation for refreshOverflowMaxIndex to clarify its purpose and behavior --- Sources/QsSwift/Internal/Utils+Overflow.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/QsSwift/Internal/Utils+Overflow.swift b/Sources/QsSwift/Internal/Utils+Overflow.swift index 47152fe3..b3a37611 100644 --- a/Sources/QsSwift/Internal/Utils+Overflow.swift +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -46,6 +46,8 @@ extension Utils { } @usableFromInline + /// Scans non-overflow keys to compute the maximum integer index and stores it. + /// Sets -1 if no integer keys are present. internal static func refreshOverflowMaxIndex(_ dict: inout [AnyHashable: Any]) { var maxIndex = -1 for key in dict.keys where !isOverflowKey(key) {