From 804971d22d5b45eacfddc350eeafa12632eb501d Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 26 Nov 2024 23:10:24 -0500 Subject: [PATCH 1/4] Implement reference coalescing when decoding documents with anchors into reference types. --- Sources/Yams/AliasDereferencingStrategy.swift | 29 ++ Sources/Yams/Decoder.swift | 108 +++++- Tests/YamsTests/AnchorCodingTests.swift | 2 +- .../ClassReferenceDecodingTests.swift | 332 ++++++++++++++++++ Tests/YamsTests/EncoderTests.swift | 10 +- Tests/YamsTests/TagCodingTests.swift | 2 +- 6 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 Sources/Yams/AliasDereferencingStrategy.swift create mode 100644 Tests/YamsTests/ClassReferenceDecodingTests.swift diff --git a/Sources/Yams/AliasDereferencingStrategy.swift b/Sources/Yams/AliasDereferencingStrategy.swift new file mode 100644 index 00000000..35547d5a --- /dev/null +++ b/Sources/Yams/AliasDereferencingStrategy.swift @@ -0,0 +1,29 @@ +// +// AliasDereferencingStrategy.swift +// Yams +// +// Created by Adora Lynch on 8/9/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import Foundation + +public protocol AliasDereferencingStrategy: AnyObject { + + subscript(_ key: Anchor) -> Any? { get set } +} + +public class BasicAliasDereferencingStrategy: AliasDereferencingStrategy { + public init() {} + + private var map: [Anchor: Any] = .init() + + public subscript(_ key: Anchor) -> Any? { + get { map[key] } + set { map[key] = newValue } + } +} + +extension CodingUserInfoKey { + internal static let aliasDereferencingStrategy = Self(rawValue: "aliasDereferencingStrategy")! +} diff --git a/Sources/Yams/Decoder.swift b/Sources/Yams/Decoder.swift index 859c4583..3f34ab2f 100644 --- a/Sources/Yams/Decoder.swift +++ b/Sources/Yams/Decoder.swift @@ -11,12 +11,34 @@ import Foundation /// `Codable`-style `Decoder` that can be used to decode a `Decodable` type from a given `String` and optional /// user info mapping. Similar to `Foundation.JSONDecoder`. public class YAMLDecoder { + /// Options to use when decoding from YAML. + public struct Options { + public init(encoding: Parser.Encoding = .default, + aliasDereferencingStrategy: AliasDereferencingStrategy? = nil) { + self.encoding = encoding + self.aliasDereferencingStrategy = aliasDereferencingStrategy + } + + /// Encoding + public var encoding: Parser.Encoding = .default + + /// Alias dereferencing strategy to use when decoding. Defaults to nil + public var aliasDereferencingStrategy: AliasDereferencingStrategy? + } + + /// Options to use when decoding from YAML. + public var options = Options() + /// Creates a `YAMLDecoder` instance. /// - /// - parameter encoding: Encoding, `.default` if omitted. - public init(encoding: Parser.Encoding = .default) { - self.encoding = encoding + /// - parameter encoding: String encoding, + public convenience init(encoding: Parser.Encoding) { + self.init() + self.options.encoding = encoding } + + /// Creates a `YAMLDecoder` instance. + public init() {} /// Decode a `Decodable` type from a given `Node` and optional user info mapping. /// @@ -30,7 +52,12 @@ public class YAMLDecoder { public func decode(_ type: T.Type = T.self, from node: Node, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable { - let decoder = _Decoder(referencing: node, userInfo: userInfo) + var finalUserInfo = userInfo + if let dealiasingStrategy = options.aliasDereferencingStrategy { + finalUserInfo[.aliasDereferencingStrategy] = dealiasingStrategy + } + + let decoder = _Decoder(referencing: node, userInfo: finalUserInfo) let container = try decoder.singleValueContainer() return try container.decode(type) } @@ -48,7 +75,7 @@ public class YAMLDecoder { from yaml: String, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable { do { - let parser = try Parser(yaml: yaml, resolver: Resolver([.merge]), encoding: encoding) + let parser = try Parser(yaml: yaml, resolver: Resolver([.merge]), encoding: options.encoding) // ^ the parser holds the references to Anchors while parsing, return try withExtendedLifetime(parser) { // ^ so we hold an explicit reference to the parser during decoding @@ -80,15 +107,18 @@ public class YAMLDecoder { public func decode(_ type: T.Type = T.self, from yamlData: Data, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable { - guard let yamlString = String(data: yamlData, encoding: encoding.swiftStringEncoding) else { - throw YamlError.dataCouldNotBeDecoded(encoding: encoding.swiftStringEncoding) + guard let yamlString = String(data: yamlData, encoding: options.encoding.swiftStringEncoding) else { + throw YamlError.dataCouldNotBeDecoded(encoding: options.encoding.swiftStringEncoding) } return try decode(type, from: yamlString, userInfo: userInfo) } /// Encoding - public var encoding: Parser.Encoding + @available(*, deprecated, renamed: "options.encoding") + public var encoding: Parser.Encoding { + options.encoding + } } private struct _Decoder: Decoder { @@ -295,29 +325,71 @@ extension _Decoder: SingleValueDecodingContainer { // MARK: - Swift.SingleValueDecodingContainer Methods func decodeNil() -> Bool { return node.null == NSNull() } - func decode(_ type: T.Type) throws -> T where T: Decodable & ScalarConstructible { return try construct(type) } - func decode(_ type: T.Type) throws -> T where T: Decodable {return try construct(type) ?? type.init(from: self) } + func decode(_ type: T.Type) throws -> T where T: Decodable & ScalarConstructible { return try _decode(type) } + func decode(_ type: T.Type) throws -> T where T: Decodable {return try _decode(type) } // MARK: - + private func _decode(_ type: T.Type) throws -> T { + if let dereferenced = dereferenceAnchor(type) { + return dereferenced + } + + let constructed = try _construct(type) + + recordAnchor(constructed) + + return constructed + } + + private func _construct(_ type: T.Type) throws -> T { + if let constructibleType = type as? ScalarConstructible.Type { + let scalarConstructed = try constructScalar(constructibleType) + guard let t = scalarConstructed as? T else { + throw _typeMismatch(at: codingPath, expectation: type, reality: scalarConstructed) + } + return t + } + // not scalar constructable, initialize as Decodable + return try type.init(from: self) + } + /// constuct `T` from `node` - private func construct(_ type: T.Type) throws -> T { + private func constructScalar(_ type: T.Type) throws -> T { let scalar = try self.scalar() guard let constructed = type.construct(from: scalar) else { throw _typeMismatch(at: codingPath, expectation: type, reality: scalar) } return constructed } - - private func construct(_ type: T.Type) throws -> T? { - guard let constructibleType = type as? ScalarConstructible.Type else { + + + private func dereferenceAnchor(_ type: T.Type) -> T? { + guard let anchor = self.node.anchor else { return nil } - let scalar = try self.scalar() - guard let value = constructibleType.construct(from: scalar) else { - throw _valueNotFound(at: codingPath, type, "Expected \(type) value but found \(scalar) instead.") + + guard let strategy = userInfo[.aliasDereferencingStrategy] as? any AliasDereferencingStrategy else { + return nil + } + + guard let existing = strategy[anchor] as? T else { + return nil + } + + return existing + } + + private func recordAnchor(_ constructed: T) { + guard let anchor = self.node.anchor else { + return + } + + guard let strategy = userInfo[.aliasDereferencingStrategy] as? any AliasDereferencingStrategy else { + return } - return value as? T + + return strategy[anchor] = constructed } } diff --git a/Tests/YamsTests/AnchorCodingTests.swift b/Tests/YamsTests/AnchorCodingTests.swift index 997ced71..a14ef575 100644 --- a/Tests/YamsTests/AnchorCodingTests.swift +++ b/Tests/YamsTests/AnchorCodingTests.swift @@ -79,7 +79,7 @@ class AnchorCodingTests: XCTestCase { stringValue: it's a value intValue: 52 - """.data(using: decoder.encoding.swiftStringEncoding)! + """.data(using: decoder.options.encoding.swiftStringEncoding)! let decodedStruct = try decoder.decode(SimpleWithStringTypeAnchorName.self, from: data) diff --git a/Tests/YamsTests/ClassReferenceDecodingTests.swift b/Tests/YamsTests/ClassReferenceDecodingTests.swift new file mode 100644 index 00000000..64069375 --- /dev/null +++ b/Tests/YamsTests/ClassReferenceDecodingTests.swift @@ -0,0 +1,332 @@ +// +// ClassReferenceDecodingTests.swift +// Yams +// +// Created by Adora Lynch on 8/9/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import XCTest +import Yams + +class ClassReferenceDecodingTests: XCTestCase { + + /// If types conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_duplicateAnchor() throws { + let simpleStruct = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: duplicatedStructArray, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + - &simple + nested: + stringValue: it's a value + intValue: 52 + - *simple + + """ ) + + guard let decoded else { return } + + XCTAssertTrue(decoded[0] === decoded[1], "Class reference not unique") + } + + /// If types conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_duplicateAnchor_objectCoalescing() throws { + let simpleStruct1 = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let simpleStruct2 = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + + let sameTypeOneAnchorPair = SimplePair(first: simpleStruct1, second: simpleStruct2) + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: sameTypeOneAnchorPair, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + first: &simple + nested: + stringValue: it's a value + intValue: 52 + second: *simple + + """ ) + + guard let decoded else { return } + + XCTAssertTrue(decoded.first === decoded.first, "Class reference not unique") + } + + /// If types do NOT conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_noAnchors() throws { + let simpleStruct = SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] // zero specified anchor + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: duplicatedStructArray, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + - &2 + nested: + stringValue: it's a value + intValue: 52 + - *2 + + """ ) + + guard let decoded else { return } + + XCTAssertTrue(decoded[0] === decoded[1], "Class reference not unique") + } + + /// If types conform to YamlAnchorProviding and are NOT Hashable-Equal then + /// HashableAliasingStrategy does not alias them even though their members may still be + /// Hashable-Equal and therefor maybe aliased. + func testEncoderAutoAlias_Hashable_uniqueAnchor() throws { + let differentTypesOneAnchors = SimplePair(first: + SimpleWithAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: differentTypesOneAnchors, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + first: &simple + nested: &2 + stringValue: it's a value + intValue: &4 52 + second: + nested: *2 + intValue: *4 + + """ ) + + guard let decoded else { return } + + XCTAssertTrue(decoded.first.nested === decoded.second.nested, "Class reference not unique") + } + + /// If types conform to YamlAnchorProviding and are NOT Hashable-Equal then + /// HashableAliasingStrategy does not alias them even though their members may still be + /// Hashable-Equal and therefor maybe aliased. + /// Note particularly that the to Simple* values here have exactly the same encoded representation, + /// they're just different types and thus not Hashable-Equal + func testEncoderAutoAlias_Hashable_noAnchor() throws { + let differentTypesNoAnchors = SimplePair(first: + SimpleWithoutAnchor2(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: differentTypesNoAnchors, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + first: + nested: &3 + stringValue: it's a value + intValue: &5 52 + second: + nested: *3 + intValue: *5 + + """ ) + + guard let decoded else { return } + + XCTAssertTrue(decoded.first.nested === decoded.second.nested, "Class reference not unique") + } + + /// If types conform to YamlAnchorProviding and have exactly the same encoded representation then + /// StrictEncodableAliasingStrategy alias them even though they are encoded and decoded from + /// different types. + func testEncoderAutoAlias_StrictEncodable_NoAnchors() throws { + let differentTypesNoAnchors = SimplePair(first: + SimpleWithoutAnchor2(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: StrictEncodableAliasingStrategy()) + let decodingOptions = YAMLDecoder.Options(aliasDereferencingStrategy: BasicAliasDereferencingStrategy()) + let decoded = + _testRoundTrip(of: differentTypesNoAnchors, + with: encodingOptions, + decodingOptions: decodingOptions, + expectedYAML: """ + first: &2 + nested: + stringValue: it's a value + intValue: 52 + second: *2 + + """ ) + + guard let decoded else { return } + + /// It is expected and rational behavior that if an aliased value is decoded into two different types + /// that those types cannot share object identity (a memory address) + XCTAssertTrue(decoded.first !== decoded.second, "Class reference is unique") + + + /// It would be nice, + /// if objects contained within aliased values which are decoded different types could still identify and preserve the + /// object identity of those contained objects. (If ivars of different types could share reference to common data) + /// but is asking too much.... + XCTAssertFalse(decoded.first.nested === decoded.second.nested, "You fixed it!") + + + /// The reality of the behavior is that if you declared to decode an aliased value into two different classes, + /// you forfeit the possibility of down-graph reference sharing. + XCTAssertTrue(decoded.first.nested !== decoded.second.nested, "Class reference is unique") + } + + /// A type used to contain values used during testing + private struct SimplePair: Hashable, Codable { + let first: First + let second: Second + } + +} + +// MARK: - Types used for Anchor encoding tests. + +private class NestedStruct: Codable, Hashable { + let stringValue: String + + init(stringValue: String) { + self.stringValue = stringValue + } + + static func == (lhs: NestedStruct, rhs: NestedStruct) -> Bool { + lhs.stringValue == rhs.stringValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(stringValue) + } +} +private protocol SimpleProtocol: Codable, Hashable { + // swiftlint:disable unused_declaration + var nested: NestedStruct { get } + // swiftlint:disable unused_declaration + var intValue: Int { get } +} + +private class SimpleWithAnchor: SimpleProtocol, YamlAnchorProviding { + + let nested: NestedStruct + let intValue: Int + let yamlAnchor: Anchor? + + init(nested: NestedStruct, intValue: Int, yamlAnchor: Anchor? = "simple") { + self.nested = nested + self.intValue = intValue + self.yamlAnchor = yamlAnchor + } + + static func == (lhs: SimpleWithAnchor, rhs: SimpleWithAnchor) -> Bool { + lhs.nested == rhs.nested && + lhs.intValue == rhs.intValue && + lhs.yamlAnchor == rhs.yamlAnchor + } + + func hash(into hasher: inout Hasher) { + hasher.combine(nested) + hasher.combine(intValue) + hasher.combine(yamlAnchor) + } +} + +private class SimpleWithoutAnchor: SimpleProtocol { + let nested: NestedStruct + let intValue: Int + + init(nested: NestedStruct, intValue: Int) { + self.nested = nested + self.intValue = intValue + } + + static func == (lhs: SimpleWithoutAnchor, rhs: SimpleWithoutAnchor) -> Bool { + lhs.nested == rhs.nested && + lhs.intValue == rhs.intValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(nested) + hasher.combine(intValue) + } +} + +private class SimpleWithoutAnchor2: SimpleProtocol { + + let nested: NestedStruct + let intValue: Int + // swiftlint:disable unused_declaration + let unrelatedValue: String? + + init(nested: NestedStruct, intValue: Int, unrelatedValue: String? = nil) { + self.nested = nested + self.intValue = intValue + self.unrelatedValue = unrelatedValue + } + + + static func == (lhs: SimpleWithoutAnchor2, rhs: SimpleWithoutAnchor2) -> Bool { + lhs.nested == rhs.nested && + lhs.intValue == rhs.intValue && + lhs.unrelatedValue == rhs.unrelatedValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(nested) + hasher.combine(intValue) + hasher.combine(unrelatedValue) + } + +} + +private class SimpleWithStringTypeAnchorName: SimpleProtocol { + + let nested: NestedStruct + let intValue: Int + let yamlAnchor: String? + + init(nested: NestedStruct, intValue: Int, yamlAnchor: String? = "StringTypeAnchor") { + self.nested = nested + self.intValue = intValue + self.yamlAnchor = yamlAnchor + } + + static func == (lhs: SimpleWithStringTypeAnchorName, rhs: SimpleWithStringTypeAnchorName) -> Bool { + lhs.nested == rhs.nested && + lhs.intValue == rhs.intValue && + lhs.yamlAnchor == rhs.yamlAnchor + } + + func hash(into hasher: inout Hasher) { + hasher.combine(nested) + hasher.combine(intValue) + hasher.combine(yamlAnchor) + } +} diff --git a/Tests/YamsTests/EncoderTests.swift b/Tests/YamsTests/EncoderTests.swift index 0d85a3a2..4d85fb2d 100644 --- a/Tests/YamsTests/EncoderTests.swift +++ b/Tests/YamsTests/EncoderTests.swift @@ -439,11 +439,14 @@ class EncoderTests: XCTestCase { // swiftlint:disable:this type_body_length } } +/// returns the decoded instance of T +@discardableResult internal func _testRoundTrip(of value: T, with options: YAMLEncoder.Options = .init(), + decodingOptions: YAMLDecoder.Options = .init(), expectedYAML yamlString: String? = nil, file: StaticString = #file, - line: UInt = #line) + line: UInt = #line) -> T? where T: Codable, T: Equatable { do { let encoder = YAMLEncoder() @@ -456,9 +459,12 @@ where T: Codable, T: Equatable { } let decoder = YAMLDecoder() + decoder.options = decodingOptions let decoded = try decoder.decode(T.self, from: producedYAML) XCTAssertEqual(decoded, value, "\(T.self) did not round-trip to an equal value.", file: (file), line: line) + + return decoded } catch let error as EncodingError { XCTFail("Failed to encode \(T.self) from YAML by error: \(error)", file: (file), line: line) @@ -467,6 +473,8 @@ where T: Codable, T: Equatable { } catch { XCTFail("Rout trip test of \(T.self) failed with error: \(error)", file: (file), line: line) } + + return nil } // MARK: - Helper Global Functions diff --git a/Tests/YamsTests/TagCodingTests.swift b/Tests/YamsTests/TagCodingTests.swift index 6f85828d..4a6ba967 100644 --- a/Tests/YamsTests/TagCodingTests.swift +++ b/Tests/YamsTests/TagCodingTests.swift @@ -77,7 +77,7 @@ class TagCodingTests: XCTestCase { stringValue: it's a value intValue: 52 - """.data(using: decoder.encoding.swiftStringEncoding)! + """.data(using: decoder.options.encoding.swiftStringEncoding)! let decodedStruct = try decoder.decode(SimpleWithStringTypeTagName.self, from: data) From f030158ffe57bebc97af9502511e4922727f3233 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 26 Nov 2024 23:16:09 -0500 Subject: [PATCH 2/4] Address CI feedback --- Sources/Yams/AliasDereferencingStrategy.swift | 26 +++++-- Sources/Yams/CMakeLists.txt | 1 + Sources/Yams/Decoder.swift | 42 +++++----- Tests/YamsTests/CMakeLists.txt | 1 + .../ClassReferenceDecodingTests.swift | 77 +++++++++---------- Tests/YamsTests/EncoderTests.swift | 4 +- Yams.xcodeproj/project.pbxproj | 8 ++ 7 files changed, 91 insertions(+), 68 deletions(-) diff --git a/Sources/Yams/AliasDereferencingStrategy.swift b/Sources/Yams/AliasDereferencingStrategy.swift index 35547d5a..2ce8d1ec 100644 --- a/Sources/Yams/AliasDereferencingStrategy.swift +++ b/Sources/Yams/AliasDereferencingStrategy.swift @@ -6,18 +6,34 @@ // Copyright (c) 2024 Yams. All rights reserved. // -import Foundation - +/// A class-bound protocol which implements a strategy for dereferencing aliases (or dealiasing) values during +/// YAML document decoding. YAML documents which do not contain anchors will not benefit from the use of +/// an AliasDereferencingStrategy in any way. The main use-case for dereferencing aliases in a YML document +/// is when decoding into class types. If the yaml document is large and contains many references +/// (perhaps it is a representation of a dense graph) then, decoding into structs will require the of large amounts +/// of system memory to represent highly redundant (duplicated) data structures. +/// However, if the same document is decoded into class types and the decoding uses +/// an `AliasDereferencingStrategy` such as `BasicAliasDereferencingStrategy` then the emitted value will have its +/// class references coalesced. No duplicate objects will be initialized (unless identical objects have multiple +/// distinct anchors in the YAML document). In some scenarios this may significantly reduce the memory footprint of +/// the decoded type. public protocol AliasDereferencingStrategy: AnyObject { - + + /// get and set cached references, keyed bo an Anchor subscript(_ key: Anchor) -> Any? { get set } } +/// A AliasDereferencingStrategy which caches all values (even value-type values) in a Dictionary, keyed by their Anchor. +/// For reference types, this strategy achieves reference coalescing +/// For value types, this strategy achieves short-cutting the decoding process when dereferencing aliases. +/// if the aliased structure is large, this may result in a time savings public class BasicAliasDereferencingStrategy: AliasDereferencingStrategy { + /// Create a new BasicAliasDereferencingStrategy public init() {} - + private var map: [Anchor: Any] = .init() - + + /// get and set cached references, keyed bo an Anchor public subscript(_ key: Anchor) -> Any? { get { map[key] } set { map[key] = newValue } diff --git a/Sources/Yams/CMakeLists.txt b/Sources/Yams/CMakeLists.txt index 0bababc9..ac25b581 100644 --- a/Sources/Yams/CMakeLists.txt +++ b/Sources/Yams/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(Yams + AliasDereferencingStrategy.swift Anchor.swift Constructor.swift Decoder.swift diff --git a/Sources/Yams/Decoder.swift b/Sources/Yams/Decoder.swift index 3f34ab2f..aeeb528f 100644 --- a/Sources/Yams/Decoder.swift +++ b/Sources/Yams/Decoder.swift @@ -13,22 +13,23 @@ import Foundation public class YAMLDecoder { /// Options to use when decoding from YAML. public struct Options { + /// Create `YAMLDecoder.Options` with the specified values. public init(encoding: Parser.Encoding = .default, aliasDereferencingStrategy: AliasDereferencingStrategy? = nil) { self.encoding = encoding self.aliasDereferencingStrategy = aliasDereferencingStrategy } - + /// Encoding public var encoding: Parser.Encoding = .default - + /// Alias dereferencing strategy to use when decoding. Defaults to nil public var aliasDereferencingStrategy: AliasDereferencingStrategy? } - + /// Options to use when decoding from YAML. public var options = Options() - + /// Creates a `YAMLDecoder` instance. /// /// - parameter encoding: String encoding, @@ -36,7 +37,7 @@ public class YAMLDecoder { self.init() self.options.encoding = encoding } - + /// Creates a `YAMLDecoder` instance. public init() {} @@ -56,7 +57,7 @@ public class YAMLDecoder { if let dealiasingStrategy = options.aliasDereferencingStrategy { finalUserInfo[.aliasDereferencingStrategy] = dealiasingStrategy } - + let decoder = _Decoder(referencing: node, userInfo: finalUserInfo) let container = try decoder.singleValueContainer() return try container.decode(type) @@ -334,26 +335,26 @@ extension _Decoder: SingleValueDecodingContainer { if let dereferenced = dereferenceAnchor(type) { return dereferenced } - + let constructed = try _construct(type) - + recordAnchor(constructed) - + return constructed } - + private func _construct(_ type: T.Type) throws -> T { if let constructibleType = type as? ScalarConstructible.Type { let scalarConstructed = try constructScalar(constructibleType) - guard let t = scalarConstructed as? T else { + guard let scalarT = scalarConstructed as? T else { throw _typeMismatch(at: codingPath, expectation: type, reality: scalarConstructed) } - return t + return scalarT } // not scalar constructable, initialize as Decodable return try type.init(from: self) } - + /// constuct `T` from `node` private func constructScalar(_ type: T.Type) throws -> T { let scalar = try self.scalar() @@ -362,33 +363,32 @@ extension _Decoder: SingleValueDecodingContainer { } return constructed } - - + private func dereferenceAnchor(_ type: T.Type) -> T? { guard let anchor = self.node.anchor else { return nil } - + guard let strategy = userInfo[.aliasDereferencingStrategy] as? any AliasDereferencingStrategy else { return nil } - + guard let existing = strategy[anchor] as? T else { return nil } - + return existing } - + private func recordAnchor(_ constructed: T) { guard let anchor = self.node.anchor else { return } - + guard let strategy = userInfo[.aliasDereferencingStrategy] as? any AliasDereferencingStrategy else { return } - + return strategy[anchor] = constructed } } diff --git a/Tests/YamsTests/CMakeLists.txt b/Tests/YamsTests/CMakeLists.txt index 8b223cac..932d019c 100644 --- a/Tests/YamsTests/CMakeLists.txt +++ b/Tests/YamsTests/CMakeLists.txt @@ -1,6 +1,7 @@ add_library(YamsTests AnchorCodingTests.swift AnchorTolerancesTests.swift + ClassReferenceDecodingTests.swift ConstructorTests.swift EmitterTests.swift EncoderTests.swift diff --git a/Tests/YamsTests/ClassReferenceDecodingTests.swift b/Tests/YamsTests/ClassReferenceDecodingTests.swift index 64069375..2d84f6b1 100644 --- a/Tests/YamsTests/ClassReferenceDecodingTests.swift +++ b/Tests/YamsTests/ClassReferenceDecodingTests.swift @@ -30,17 +30,17 @@ class ClassReferenceDecodingTests: XCTestCase { - *simple """ ) - + guard let decoded else { return } - + XCTAssertTrue(decoded[0] === decoded[1], "Class reference not unique") } - + /// If types conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them func testEncoderAutoAlias_Hashable_duplicateAnchor_objectCoalescing() throws { let simpleStruct1 = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) let simpleStruct2 = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) - + let sameTypeOneAnchorPair = SimplePair(first: simpleStruct1, second: simpleStruct2) let encodingOptions = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) @@ -57,9 +57,9 @@ class ClassReferenceDecodingTests: XCTestCase { second: *simple """ ) - + guard let decoded else { return } - + XCTAssertTrue(decoded.first === decoded.first, "Class reference not unique") } @@ -82,9 +82,9 @@ class ClassReferenceDecodingTests: XCTestCase { - *2 """ ) - + guard let decoded else { return } - + XCTAssertTrue(decoded[0] === decoded[1], "Class reference not unique") } @@ -115,9 +115,9 @@ class ClassReferenceDecodingTests: XCTestCase { intValue: *4 """ ) - + guard let decoded else { return } - + XCTAssertTrue(decoded.first.nested === decoded.second.nested, "Class reference not unique") } @@ -150,9 +150,9 @@ class ClassReferenceDecodingTests: XCTestCase { intValue: *5 """ ) - + guard let decoded else { return } - + XCTAssertTrue(decoded.first.nested === decoded.second.nested, "Class reference not unique") } @@ -181,21 +181,20 @@ class ClassReferenceDecodingTests: XCTestCase { second: *2 """ ) - + guard let decoded else { return } - + /// It is expected and rational behavior that if an aliased value is decoded into two different types /// that those types cannot share object identity (a memory address) XCTAssertTrue(decoded.first !== decoded.second, "Class reference is unique") - - + /// It would be nice, - /// if objects contained within aliased values which are decoded different types could still identify and preserve the - /// object identity of those contained objects. (If ivars of different types could share reference to common data) - /// but is asking too much.... + /// if objects contained within aliased values which are decoded different types could still identify and + /// preserve the object identity of those contained objects. + /// (If ivars of different types could share reference to common data) + /// but is asking too much.... XCTAssertFalse(decoded.first.nested === decoded.second.nested, "You fixed it!") - - + /// The reality of the behavior is that if you declared to decode an aliased value into two different classes, /// you forfeit the possibility of down-graph reference sharing. XCTAssertTrue(decoded.first.nested !== decoded.second.nested, "Class reference is unique") @@ -213,15 +212,15 @@ class ClassReferenceDecodingTests: XCTestCase { private class NestedStruct: Codable, Hashable { let stringValue: String - + init(stringValue: String) { self.stringValue = stringValue } - + static func == (lhs: NestedStruct, rhs: NestedStruct) -> Bool { lhs.stringValue == rhs.stringValue } - + func hash(into hasher: inout Hasher) { hasher.combine(stringValue) } @@ -238,19 +237,19 @@ private class SimpleWithAnchor: SimpleProtocol, YamlAnchorProviding { let nested: NestedStruct let intValue: Int let yamlAnchor: Anchor? - + init(nested: NestedStruct, intValue: Int, yamlAnchor: Anchor? = "simple") { self.nested = nested self.intValue = intValue self.yamlAnchor = yamlAnchor } - + static func == (lhs: SimpleWithAnchor, rhs: SimpleWithAnchor) -> Bool { lhs.nested == rhs.nested && lhs.intValue == rhs.intValue && lhs.yamlAnchor == rhs.yamlAnchor } - + func hash(into hasher: inout Hasher) { hasher.combine(nested) hasher.combine(intValue) @@ -261,17 +260,17 @@ private class SimpleWithAnchor: SimpleProtocol, YamlAnchorProviding { private class SimpleWithoutAnchor: SimpleProtocol { let nested: NestedStruct let intValue: Int - + init(nested: NestedStruct, intValue: Int) { self.nested = nested self.intValue = intValue } - + static func == (lhs: SimpleWithoutAnchor, rhs: SimpleWithoutAnchor) -> Bool { lhs.nested == rhs.nested && lhs.intValue == rhs.intValue } - + func hash(into hasher: inout Hasher) { hasher.combine(nested) hasher.combine(intValue) @@ -279,51 +278,49 @@ private class SimpleWithoutAnchor: SimpleProtocol { } private class SimpleWithoutAnchor2: SimpleProtocol { - + let nested: NestedStruct let intValue: Int - // swiftlint:disable unused_declaration let unrelatedValue: String? - + init(nested: NestedStruct, intValue: Int, unrelatedValue: String? = nil) { self.nested = nested self.intValue = intValue self.unrelatedValue = unrelatedValue } - static func == (lhs: SimpleWithoutAnchor2, rhs: SimpleWithoutAnchor2) -> Bool { lhs.nested == rhs.nested && lhs.intValue == rhs.intValue && lhs.unrelatedValue == rhs.unrelatedValue } - + func hash(into hasher: inout Hasher) { hasher.combine(nested) hasher.combine(intValue) hasher.combine(unrelatedValue) } - + } private class SimpleWithStringTypeAnchorName: SimpleProtocol { - + let nested: NestedStruct let intValue: Int let yamlAnchor: String? - + init(nested: NestedStruct, intValue: Int, yamlAnchor: String? = "StringTypeAnchor") { self.nested = nested self.intValue = intValue self.yamlAnchor = yamlAnchor } - + static func == (lhs: SimpleWithStringTypeAnchorName, rhs: SimpleWithStringTypeAnchorName) -> Bool { lhs.nested == rhs.nested && lhs.intValue == rhs.intValue && lhs.yamlAnchor == rhs.yamlAnchor } - + func hash(into hasher: inout Hasher) { hasher.combine(nested) hasher.combine(intValue) diff --git a/Tests/YamsTests/EncoderTests.swift b/Tests/YamsTests/EncoderTests.swift index 4d85fb2d..0b7dd39b 100644 --- a/Tests/YamsTests/EncoderTests.swift +++ b/Tests/YamsTests/EncoderTests.swift @@ -463,7 +463,7 @@ where T: Codable, T: Equatable { let decoded = try decoder.decode(T.self, from: producedYAML) XCTAssertEqual(decoded, value, "\(T.self) did not round-trip to an equal value.", file: (file), line: line) - + return decoded } catch let error as EncodingError { @@ -473,7 +473,7 @@ where T: Codable, T: Equatable { } catch { XCTFail("Rout trip test of \(T.self) failed with error: \(error)", file: (file), line: line) } - + return nil } diff --git a/Yams.xcodeproj/project.pbxproj b/Yams.xcodeproj/project.pbxproj index f6cc6ca1..cb66bde8 100644 --- a/Yams.xcodeproj/project.pbxproj +++ b/Yams.xcodeproj/project.pbxproj @@ -44,6 +44,8 @@ 8FBD7F8B2CB70CFB00271BB9 /* AnchorTolerancesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F852CB70CFB00271BB9 /* AnchorTolerancesTests.swift */; }; 8FBD7F8C2CB70CFB00271BB9 /* TagTolerancesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F882CB70CFB00271BB9 /* TagTolerancesTests.swift */; }; 8FBD7F8D2CB70CFB00271BB9 /* TagCodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F872CB70CFB00271BB9 /* TagCodingTests.swift */; }; + 8FCB2E202CF6D82700550869 /* ClassReferenceDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FCB2E1F2CF6D82700550869 /* ClassReferenceDecodingTests.swift */; }; + 8FCB2E222CF6D83F00550869 /* AliasDereferencingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FCB2E212CF6D83F00550869 /* AliasDereferencingStrategy.swift */; }; E8EDB8851DE2181B0062268D /* api.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* api.c */; }; E8EDB8871DE2181B0062268D /* emitter.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* emitter.c */; }; E8EDB8891DE2181B0062268D /* parser.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_14 /* parser.c */; }; @@ -107,6 +109,8 @@ 8FBD7F862CB70CFB00271BB9 /* NodeDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDecoderTests.swift; sourceTree = ""; }; 8FBD7F872CB70CFB00271BB9 /* TagCodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCodingTests.swift; sourceTree = ""; }; 8FBD7F882CB70CFB00271BB9 /* TagTolerancesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTolerancesTests.swift; sourceTree = ""; }; + 8FCB2E1F2CF6D82700550869 /* ClassReferenceDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassReferenceDecodingTests.swift; sourceTree = ""; }; + 8FCB2E212CF6D83F00550869 /* AliasDereferencingStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AliasDereferencingStrategy.swift; sourceTree = ""; }; OBJ_10 /* api.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = api.c; sourceTree = ""; }; OBJ_12 /* emitter.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = emitter.c; sourceTree = ""; }; OBJ_14 /* parser.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = parser.c; sourceTree = ""; }; @@ -151,6 +155,7 @@ OBJ_21 /* Yams */ = { isa = PBXGroup; children = ( + 8FCB2E212CF6D83F00550869 /* AliasDereferencingStrategy.swift */, 8FBD7F7A2CB70C8900271BB9 /* Anchor.swift */, 6C0D2A351E0A934B00C45545 /* Constructor.swift */, 6C4AF31E1EBE14A1008775BC /* Decoder.swift */, @@ -191,6 +196,7 @@ children = ( 8FBD7F842CB70CFB00271BB9 /* AnchorCodingTests.swift */, 8FBD7F852CB70CFB00271BB9 /* AnchorTolerancesTests.swift */, + 8FCB2E1F2CF6D82700550869 /* ClassReferenceDecodingTests.swift */, 6C0488ED1E0CBD56006F9F80 /* ConstructorTests.swift */, 6C0A00D41E152D6200222704 /* EmitterTests.swift */, 6C788A001EB87232005386F0 /* EncoderTests.swift */, @@ -385,6 +391,7 @@ 6C0409AA1E6033DF00C95D83 /* Node.Sequence.swift in Sources */, 6C0409A81E602E9A00C95D83 /* Node.Mapping.swift in Sources */, E8EDB88C1DE2181B0062268D /* writer.c in Sources */, + 8FCB2E222CF6D83F00550869 /* AliasDereferencingStrategy.swift in Sources */, E8EDB88B1DE2181B0062268D /* scanner.c in Sources */, 6C4AF3201EBE1705008775BC /* Decoder.swift in Sources */, 6C0409AC1E607E9900C95D83 /* Node.Scalar.swift in Sources */, @@ -414,6 +421,7 @@ 8FBD7F892CB70CFB00271BB9 /* NodeDecoderTests.swift in Sources */, 8FBD7F8A2CB70CFB00271BB9 /* AnchorCodingTests.swift in Sources */, 8FBD7F8B2CB70CFB00271BB9 /* AnchorTolerancesTests.swift in Sources */, + 8FCB2E202CF6D82700550869 /* ClassReferenceDecodingTests.swift in Sources */, 8FBD7F8C2CB70CFB00271BB9 /* TagTolerancesTests.swift in Sources */, 8FBD7F8D2CB70CFB00271BB9 /* TagCodingTests.swift in Sources */, 6C78C5651E29B27D0096215F /* RepresenterTests.swift in Sources */, From 68b4af2c64cd535495ec844faa75bf095b1bf48a Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 27 Nov 2024 00:05:55 -0500 Subject: [PATCH 3/4] Last touches. --- Sources/Yams/AliasDereferencingStrategy.swift | 12 +++++++----- Sources/Yams/Decoder.swift | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Yams/AliasDereferencingStrategy.swift b/Sources/Yams/AliasDereferencingStrategy.swift index 2ce8d1ec..d8701d32 100644 --- a/Sources/Yams/AliasDereferencingStrategy.swift +++ b/Sources/Yams/AliasDereferencingStrategy.swift @@ -18,12 +18,14 @@ /// distinct anchors in the YAML document). In some scenarios this may significantly reduce the memory footprint of /// the decoded type. public protocol AliasDereferencingStrategy: AnyObject { - + /// The stored exestential type of all AliasDereferencingStrategys + typealias Value = (any Decodable) /// get and set cached references, keyed bo an Anchor - subscript(_ key: Anchor) -> Any? { get set } + subscript(_ key: Anchor) -> Value? { get set } } -/// A AliasDereferencingStrategy which caches all values (even value-type values) in a Dictionary, keyed by their Anchor. +/// A AliasDereferencingStrategy which caches all values (even value-type values) in a Dictionary, +/// keyed by their Anchor. /// For reference types, this strategy achieves reference coalescing /// For value types, this strategy achieves short-cutting the decoding process when dereferencing aliases. /// if the aliased structure is large, this may result in a time savings @@ -31,10 +33,10 @@ public class BasicAliasDereferencingStrategy: AliasDereferencingStrategy { /// Create a new BasicAliasDereferencingStrategy public init() {} - private var map: [Anchor: Any] = .init() + private var map: [Anchor: Value] = .init() /// get and set cached references, keyed bo an Anchor - public subscript(_ key: Anchor) -> Any? { + public subscript(_ key: Anchor) -> Value? { get { map[key] } set { map[key] = newValue } } diff --git a/Sources/Yams/Decoder.swift b/Sources/Yams/Decoder.swift index aeeb528f..270f4412 100644 --- a/Sources/Yams/Decoder.swift +++ b/Sources/Yams/Decoder.swift @@ -380,7 +380,7 @@ extension _Decoder: SingleValueDecodingContainer { return existing } - private func recordAnchor(_ constructed: T) { + private func recordAnchor(_ constructed: T) { guard let anchor = self.node.anchor else { return } From 89af35f6738471c56cc0aa4d7f7bca1a3ac21920 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Sun, 1 Dec 2024 22:27:34 -0500 Subject: [PATCH 4/4] Add CHANGELOG entry --- CHANGELOG.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e477d0a7..bc4fdd70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ ##### Enhancements +* Yams is able to coalesce references to objects decoded with YAML anchors. + [Adora Lynch](https://github.com/lynchsft) + +##### Bug Fixes + +* None. + +## 5.3.0 + +##### Breaking + +* None. + +##### Enhancements + * Yams is able to encode and decode Anchors via YamlAnchorProviding, and YamlAnchorCoding. [Adora Lynch](https://github.com/lynchsft) @@ -16,7 +31,7 @@ [Adora Lynch](https://github.com/lynchsft) [#265](https://github.com/jpsim/Yams/issues/265) -* Yams is able to detect redundant structes and automaticaly +* Yams is able to detect redundant structs and automatically alias them during encoding via RedundancyAliasingStrategy [Adora Lynch](https://github.com/lynchsft)