From 940c42982624cc87c70f37b3b42c695b8fa193f6 Mon Sep 17 00:00:00 2001 From: Simon Pilkington Date: Fri, 12 Oct 2018 07:59:23 -0700 Subject: [PATCH] Added strategies for decoding and encoding maps. --- Sources/XMLCoding/Decoder/XMLDecoder.swift | 70 +++++++++- Sources/XMLCoding/Encoder/XMLEncoder.swift | 88 +++++++++++++ Tests/XMLCodingTests/XMLParsingTests.swift | 142 +++++++++++++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) diff --git a/Sources/XMLCoding/Decoder/XMLDecoder.swift b/Sources/XMLCoding/Decoder/XMLDecoder.swift index 1002286..9c7aa51 100644 --- a/Sources/XMLCoding/Decoder/XMLDecoder.swift +++ b/Sources/XMLCoding/Decoder/XMLDecoder.swift @@ -115,6 +115,31 @@ open class XMLDecoder { case collapseListUsingItemTag(String) } + /// The strategy to use when decoding maps. + public enum MapDecodingStrategy { + /// Preserves the XML structure. Key and Value tags will be retained producing a + /// list of elements with these two attributes. This is the default strategy. + case preserveStructure + + /// Collapse the XML structure to avoid key and value tags. For example- + /// + /// + /// QueueType + /// Production + /// + /// + /// Owner + /// Developer123 + /// + /// + /// + /// will be decoded into + /// struct Result { + /// let tags: [String: String] + /// } + case collapseMapUsingTags(keyTag: String, valueTag: String) + } + /// The strategy to use in decoding dates. Defaults to `.secondsSince1970`. open var dateDecodingStrategy: DateDecodingStrategy = .secondsSince1970 @@ -127,6 +152,9 @@ open class XMLDecoder { /// The strategy to use in decoding lists. Defaults to `.preserveStructure`. open var listDecodingStrategy: ListDecodingStrategy = .preserveStructure + /// The strategy to use in decoding maps. Defaults to `.preserveStructure`. + open var mapDecodingStrategy: MapDecodingStrategy = .preserveStructure + /// Contextual user-provided information for use during decoding. open var userInfo: [CodingUserInfoKey : Any] = [:] @@ -136,6 +164,7 @@ open class XMLDecoder { let dataDecodingStrategy: DataDecodingStrategy let nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy let listDecodingStrategy: ListDecodingStrategy + let mapDecodingStrategy: MapDecodingStrategy let userInfo: [CodingUserInfoKey : Any] } @@ -145,6 +174,7 @@ open class XMLDecoder { dataDecodingStrategy: dataDecodingStrategy, nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy, listDecodingStrategy: listDecodingStrategy, + mapDecodingStrategy: mapDecodingStrategy, userInfo: userInfo) } @@ -216,7 +246,45 @@ internal class _XMLDecoder : Decoder { debugDescription: "Cannot get keyed decoding container -- found null value instead.")) } - guard let topContainer = self.storage.topContainer as? [String : Any] else { + let topContainer: [String : Any] + // if this is a dictionary + if let currentContainer = self.storage.topContainer as? [String : Any] { + + // if we are combining collapsing lists and maps + if case let .collapseListUsingItemTag(itemTag) = options.listDecodingStrategy, + case let .collapseMapUsingTags(keyTag: keyTag, valueTag: valueTag) = options.mapDecodingStrategy, + let itemList = currentContainer[itemTag] as? [Any] { + var newContainer: [String: Any] = [:] + + // construct a dictionary from each entry and the key and value tags + itemList.forEach { entry in + if let keyedContainer = entry as? [String : Any], + let key = keyedContainer[keyTag] as? String, + let value = keyedContainer[valueTag] { + newContainer[key] = value + } + } + + topContainer = newContainer + } else { + topContainer = currentContainer + } + // if this is a list and the mapDecodingStrategy is collapseMapUsingTags + } else if let currentContainer = self.storage.topContainer as? [Any], + case let .collapseMapUsingTags(keyTag: keyTag, valueTag: valueTag) = options.mapDecodingStrategy { + var newContainer: [String: Any] = [:] + + // construct a dictionary from each entry and the key and value tags + currentContainer.forEach { entry in + if let keyedContainer = entry as? [String : Any], + let key = keyedContainer[keyTag] as? String, + let value = keyedContainer[valueTag] { + newContainer[key] = value + } + } + + topContainer = newContainer + } else { throw DecodingError._typeMismatch(at: self.codingPath, expectation: [String : Any].self, reality: self.storage.topContainer) } diff --git a/Sources/XMLCoding/Encoder/XMLEncoder.swift b/Sources/XMLCoding/Encoder/XMLEncoder.swift index b3c2a8e..6a36a0b 100644 --- a/Sources/XMLCoding/Encoder/XMLEncoder.swift +++ b/Sources/XMLCoding/Encoder/XMLEncoder.swift @@ -187,6 +187,31 @@ open class XMLEncoder { case expandListWithItemTag(String) } + /// The strategy to use when encoding maps. + public enum MapEncodingStrategy { + /// Preserves the XML structure. Key and Value tags will be retained producing a + /// list of elements with these two attributes. This is the default strategy. + case preserveStructure + + /// Expands the XML structure to avoid key and value tags. For example- + /// + /// + /// QueueType + /// Production + /// + /// + /// Owner + /// Developer123 + /// + /// + /// + /// will be encoded from + /// struct Result { + /// let tags: [String: String] + /// } + case expandMapUsingTags(keyTag: String, valueTag: String) + } + /// The output format to produce. Defaults to `[]`. open var outputFormatting: OutputFormatting = [] @@ -211,6 +236,9 @@ open class XMLEncoder { /// The strategy to use in encoding lists. Defaults to `.preserveStructure`. open var listEncodingStrategy: ListEncodingStrategy = .preserveStructure + /// The strategy to use in encoding map. Defaults to `.preserveStructure`. + open var mapEncodingStrategy: MapEncodingStrategy = .preserveStructure + /// Contextual user-provided information for use during encoding. open var userInfo: [CodingUserInfoKey : Any] = [:] @@ -223,6 +251,7 @@ open class XMLEncoder { let attributeEncodingStrategy: AttributeEncodingStrategy let stringEncodingStrategy: StringEncodingStrategy let listEncodingStrategy: ListEncodingStrategy + let mapEncodingStrategy: MapEncodingStrategy let userInfo: [CodingUserInfoKey : Any] } @@ -235,6 +264,7 @@ open class XMLEncoder { attributeEncodingStrategy: attributeEncodingStrategy, stringEncodingStrategy: stringEncodingStrategy, listEncodingStrategy: listEncodingStrategy, + mapEncodingStrategy: mapEncodingStrategy, userInfo: userInfo) } @@ -936,6 +966,9 @@ extension _XMLEncoder { return .uint64(UInt64(int)) } else if let int = value as? UInt32 { return .uint64(UInt64(int)) + } else if let boxableEncodable = value as? BoxableEncodable { + // this type knows how to box itself + return try boxableEncodable.box(forEncoder: self) } let depth = self.storage.count @@ -950,3 +983,58 @@ extension _XMLEncoder { } } +/// Protocol for a type that knows how to box itself +protocol BoxableEncodable: Encodable { + func box(forEncoder encoder: _XMLEncoder) throws -> MutableContainer? +} + +extension Dictionary: BoxableEncodable where Key == String, Value: Encodable { + /// function to box (and potentially expand + internal func box(forEncoder encoder: _XMLEncoder) throws -> MutableContainer? { + let depth = encoder.storage.count + // if we are expanding maps + if case let .expandMapUsingTags(keyTag: keyTag, valueTag: valueTag) = encoder.options.mapEncodingStrategy { + let outerMutableContainer: (MutableContainerDictionary, String)? + // if we are expanding lists, wrap everything in a dictionary with that item + if case let .expandListWithItemTag(itemTag) = encoder.options.listEncodingStrategy { + let newMutableContainerDictionary = MutableContainerDictionary() + encoder.storage.push(container: .dictionary(newMutableContainerDictionary)) + outerMutableContainer = (newMutableContainerDictionary, itemTag) + } else { + outerMutableContainer = nil + } + + // create the expanded array + let mutableContainerArray = MutableContainerArray() + encoder.storage.push(container: .array(mutableContainerArray)) + + try self.forEach { (key, value) in + // create the dictionary for the key and value entries + let mutableContainerDictionary = MutableContainerDictionary() + encoder.storage.push(container: .dictionary(mutableContainerDictionary)) + mutableContainerDictionary[keyTag] = .string(key) + mutableContainerDictionary[valueTag] = try encoder.box(value) + + // add to the array + mutableContainerArray.append(encoder.storage.popContainer()) + } + + // add to the outer container if it is needed + if let outerMutableContainer = outerMutableContainer { + outerMutableContainer.0[outerMutableContainer.1] = encoder.storage.popContainer() + } + } else { + // otherwise just encode this array as normal + try self.encode(to: encoder) + } + + // The top container should be a new container. + guard encoder.storage.count > depth else { + return nil + } + + // return what has been created + return encoder.storage.popContainer() + } +} + diff --git a/Tests/XMLCodingTests/XMLParsingTests.swift b/Tests/XMLCodingTests/XMLParsingTests.swift index 1522deb..1998e57 100644 --- a/Tests/XMLCodingTests/XMLParsingTests.swift +++ b/Tests/XMLCodingTests/XMLParsingTests.swift @@ -18,6 +18,26 @@ let LIST_XML = """ """ +let MAP_XML = """ + + + + + key1 + value1 + + + key2 + value2 + + + key3 + value3 + + + + """ + let SINGLETON_LIST_XML = """ @@ -58,6 +78,17 @@ class XMLParsingTests: XCTestCase { } } + struct MapEntry: Codable, Equatable { + let key: String + let value: String + + + enum CodingKeys: String, CodingKey { + case key = "Key" + case value = "Value" + } + } + struct MetadataWithData: Codable, Equatable { let id: String let data: Data @@ -76,6 +107,22 @@ class XMLParsingTests: XCTestCase { } } + struct MetadataMap: Codable, Equatable { + let items: [MapEntry] + + enum CodingKeys: String, CodingKey { + case items = "item" + } + } + + struct MetadataWithCollapsedMap: Codable, Equatable { + let items: [String: String] + + enum CodingKeys: String, CodingKey { + case items = "item" + } + } + struct Response: Codable, Equatable { let result: Result let metadata: Metadata @@ -106,6 +153,36 @@ class XMLParsingTests: XCTestCase { } } + struct ResponseWithMap: Codable, Equatable { + let result: Result + let metadataMap: MetadataMap + + enum CodingKeys: String, CodingKey { + case result = "Result" + case metadataMap = "MetadataMap" + } + } + + struct ResponseWithCollapsedMap: Codable, Equatable { + let result: Result + let metadataMap: MetadataWithCollapsedMap + + enum CodingKeys: String, CodingKey { + case result = "Result" + case metadataMap = "MetadataMap" + } + } + + struct ResponseWithCollapsedListAndMap: Codable, Equatable { + let result: Result + let metadataMap: [String: String] + + enum CodingKeys: String, CodingKey { + case result = "Result" + case metadataMap = "MetadataMap" + } + } + struct ResponseWithCollapsedList: Codable, Equatable { let result: Result let metadataList: [Metadata] @@ -463,6 +540,68 @@ class XMLParsingTests: XCTestCase { XCTAssertEqual(response, response2) } + /// Test that we can decode/encode maps with the default strategy + func testMapDecodingWithDefaultStrategy() throws { + guard let inputData = MAP_XML.data(using: .utf8) else { + return XCTFail() + } + + let response = try XMLDecoder().decode(ResponseWithMap.self, from: inputData) + + XCTAssertEqual(3, response.metadataMap.items.count) + + // encode the output to make sure we get what we started with + let encoder = XMLEncoder() + let data = try encoder.encode(response, withRootKey: "Response") + + let response2 = try XMLDecoder().decode(ResponseWithMap.self, from: data) + XCTAssertEqual(response, response2) + } + + /// Test that we can decode/encode maps with collapsed maps + func testMapDecodingWithCollapsedMap() throws { + guard let inputData = MAP_XML.data(using: .utf8) else { + return XCTFail() + } + + let decoder = XMLDecoder() + decoder.mapDecodingStrategy = .collapseMapUsingTags(keyTag: "Key", valueTag: "Value") + let response = try! decoder.decode(ResponseWithCollapsedMap.self, from: inputData) + + XCTAssertEqual(3, response.metadataMap.items.count) + + // encode the output to make sure we get what we started with + let encoder = XMLEncoder() + encoder.mapEncodingStrategy = .expandMapUsingTags(keyTag: "Key", valueTag: "Value") + let data = try encoder.encode(response, withRootKey: "Response") + + let response2 = try decoder.decode(ResponseWithCollapsedMap.self, from: data) + XCTAssertEqual(response, response2) + } + + /// Test that we can decode/encode maps with collapsed lists and maps + func testMapDecodingWithCollapsedListAndMap() throws { + guard let inputData = MAP_XML.data(using: .utf8) else { + return XCTFail() + } + + let decoder = XMLDecoder() + decoder.mapDecodingStrategy = .collapseMapUsingTags(keyTag: "Key", valueTag: "Value") + decoder.listDecodingStrategy = .collapseListUsingItemTag("item") + let response = try! decoder.decode(ResponseWithCollapsedListAndMap.self, from: inputData) + + XCTAssertEqual(3, response.metadataMap.count) + + // encode the output to make sure we get what we started with + let encoder = XMLEncoder() + encoder.mapEncodingStrategy = .expandMapUsingTags(keyTag: "Key", valueTag: "Value") + encoder.listEncodingStrategy = .expandListWithItemTag("item") + let data = try encoder.encode(response, withRootKey: "Response") + + let response2 = try decoder.decode(ResponseWithCollapsedListAndMap.self, from: data) + XCTAssertEqual(response, response2) + } + /// Test that we can decode/encode single element lists with the default strategy func testSingletonListDecodingWithDefaultStrategy() throws { guard let inputData = SINGLETON_LIST_XML.data(using: .utf8) else { @@ -536,6 +675,9 @@ class XMLParsingTests: XCTestCase { ("testEmptyStructureElement", testEmptyStructureElement), ("testEmptyStructureElementNotEffectingPreviousElement", testEmptyStructureElementNotEffectingPreviousElement), ("testListDecodingWithDefaultStrategy", testListDecodingWithDefaultStrategy), + ("testMapDecodingWithDefaultStrategy", testMapDecodingWithDefaultStrategy), + ("testMapDecodingWithCollapsedMap", testMapDecodingWithCollapsedMap), + ("testMapDecodingWithCollapsedListAndMap", testMapDecodingWithCollapsedListAndMap), ("testSingletonListDecodingWithDefaultStrategy", testSingletonListDecodingWithDefaultStrategy), ("testListDecodingWithCollapseItemTagStrategy", testListDecodingWithCollapseItemTagStrategy), ("testSingletonListDecodingWithCollapseItemTagStrategy", testSingletonListDecodingWithCollapseItemTagStrategy)