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 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..e958879d 100644 --- a/Sources/QsSwift/Internal/Utils+Combine.swift +++ b/Sources/QsSwift/Internal/Utils+Combine.swift @@ -26,4 +26,73 @@ 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.reserveCapacity(array.count + arr.count) + for element in arr { + array.append(element) + } + } 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] { + var copy = dict + 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+Merge.swift b/Sources/QsSwift/Internal/Utils+Merge.swift index 1e5a89de..131701c5 100644 --- a/Sources/QsSwift/Internal/Utils+Merge.swift +++ b/Sources/QsSwift/Internal/Utils+Merge.swift @@ -18,14 +18,40 @@ 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 = Utils.intIndex(key), idx > maxIndex { + maxIndex = idx + } + } + + if Utils.isOverflow(sDict) { + if let sourceMax = Utils.overflowMaxIndex(sDict), sourceMax > maxIndex { + maxIndex = sourceMax + } + 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 +161,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 +203,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 = Utils.intIndex(key), 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 = Utils.intIndex(key) { + 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 +286,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 = Utils.intIndex(key), 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..b3a37611 --- /dev/null +++ b/Sources/QsSwift/Internal/Utils+Overflow.swift @@ -0,0 +1,60 @@ +import Foundation + +extension Utils { + internal struct OverflowKey: Hashable, Sendable {} + + @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 } + return dict[overflowKey] is Int + } + + @usableFromInline + internal static func overflowMaxIndex(_ dict: [AnyHashable: Any]) -> Int? { + dict[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.base is OverflowKey + } + + @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) { + if let idx = intIndex(key), idx > maxIndex { + maxIndex = idx + } + } + setOverflowMaxIndex(&dict, 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..2fc12a55 100644 --- a/Tests/QsSwiftTests/UtilsTests.swift +++ b/Tests/QsSwiftTests/UtilsTests.swift @@ -777,6 +777,130 @@ 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.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] + #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") + } + } + } + + @Test("Utils.refreshOverflowMaxIndex - recomputes max numeric key") + func testRefreshOverflowMaxIndex() async throws { + 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) == 7) + } + // MARK: - Utils.interpretNumericEntities tests @Test("Utils.interpretNumericEntities - returns input unchanged when there are no entities")