From b96c64034f80df41467aeb00325cf19a96a46824 Mon Sep 17 00:00:00 2001 From: Kaho Yeung Date: Sun, 19 Oct 2025 21:44:09 +0100 Subject: [PATCH] various extensions to improve the property wrapper's versatility and unit tests for the extensions --- Sources/ThreadSafe/ThreadSafe.swift | 74 +++++++- Tests/ThreadSafeTests/ThreadSafeTests.swift | 188 +++++++++++++++++++- 2 files changed, 258 insertions(+), 4 deletions(-) diff --git a/Sources/ThreadSafe/ThreadSafe.swift b/Sources/ThreadSafe/ThreadSafe.swift index 13b1730..420c70f 100644 --- a/Sources/ThreadSafe/ThreadSafe.swift +++ b/Sources/ThreadSafe/ThreadSafe.swift @@ -1,8 +1,8 @@ // // ThreadSafe.swift // ThreadSafe -// -// Created by kahoyeung on 13/09/2025. +// https://github.com/yeungkaho/ThreadSafe.git +// Created by Kaho Yeung on 13/09/2025. // import Foundation @@ -117,3 +117,73 @@ final class RWLock { return body() } } + +// conform to various protocols so using the property wrapper won't break existing behaviours + +extension ThreadSafe: CustomStringConvertible where T: CustomStringConvertible { + public var description: String { + wrappedValue.description + } +} + +extension ThreadSafe: CustomDebugStringConvertible where T: CustomDebugStringConvertible { + public var debugDescription: String { + wrappedValue.debugDescription + } +} + +extension ThreadSafe: CustomReflectable where T: CustomReflectable { + public var customMirror: Mirror { + Mirror(reflecting: wrappedValue) + } +} + +extension ThreadSafe: Equatable where T: Equatable { + public static func == (lhs: ThreadSafe, rhs: ThreadSafe) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension ThreadSafe: @unchecked Sendable where T: Sendable {} + +extension ThreadSafe: Encodable where T: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +extension ThreadSafe: Decodable where T: Decodable { + public init(from decoder: any Decoder) throws { + do { + let value = try T(from: decoder) + self.init(wrappedValue: value) + } catch { + throw error + } + } +} + +extension KeyedDecodingContainer { + // This extension fixes the keyNotFound error when a optional value is absent in the data to be decoded + public func decode( + _ type: ThreadSafe.Type, + forKey key: Key + ) throws -> ThreadSafe { + guard let value = try self.decodeIfPresent(type, forKey: key) else { + return ThreadSafe(wrappedValue: nil) + } + return value + } +} + +extension KeyedEncodingContainer { + // This extension allows nil values to be ignored by the encoder + public mutating func encode(_ value: ThreadSafe, forKey key: KeyedEncodingContainer.Key) throws { + let mirror = Mirror(reflecting: value.wrappedValue) + guard mirror.displayStyle != .optional || !mirror.children.isEmpty else { + return + } + try encodeIfPresent(value, forKey: key) + } +} diff --git a/Tests/ThreadSafeTests/ThreadSafeTests.swift b/Tests/ThreadSafeTests/ThreadSafeTests.swift index 7caee83..ea1dcea 100644 --- a/Tests/ThreadSafeTests/ThreadSafeTests.swift +++ b/Tests/ThreadSafeTests/ThreadSafeTests.swift @@ -1,6 +1,6 @@ import Testing import Foundation -@testable import ThreadSafe +import ThreadSafe class ThreadSafetyTest: @unchecked Sendable { @@ -46,7 +46,7 @@ class ThreadSafetyTest: @unchecked Sendable { // same as above @ThreadSafe(valueTypeCopyOnWrite: false) var anotherNSString: NSMutableString = "Hello" } - + let original = Example() var copy = original @@ -66,3 +66,187 @@ class ThreadSafetyTest: @unchecked Sendable { #expect(original.anotherNSString == "Hello, World!") #expect(copy.anotherNSString == "Hello, World!") } + +struct StringConvertibleTest { + @Test func testStringInterpolation() { + struct TestStruct { + @ThreadSafe var text: String = "Hello" + } + + let testStruct = TestStruct() + + #expect("\(testStruct)" == #"TestStruct(_text: "Hello")"#) + // without the extension "\(wrapped)" will show the type instead of the value + #expect("\(testStruct)" != "TestStruct(_text: Swift.String)") + } + + @Test func testStringConvertible() { + struct TestStruct: CustomStringConvertible, CustomDebugStringConvertible { + @ThreadSafe var text: String = "Hello" + var description: String { _text.description } + var debugDescription: String { _text.debugDescription } + } + let testStruct = TestStruct() + #expect(testStruct.debugDescription == testStruct.text.debugDescription) + #expect(testStruct.description == testStruct.text.description) + } +} + +struct TestEquatable { + // without the extension this code will simply fail to compile + struct TestStruct: Equatable { + @ThreadSafe var value: Value + } + + @Test func testIntEquatable() { + + let int1 = TestStruct(value: 1) + let int2 = TestStruct(value: 1) + let int3 = TestStruct(value: 2) + + #expect(int1 == int2) + #expect(int1 != int3) + + let string1 = TestStruct(value: "foo") + let string2 = TestStruct(value: "foo") + let string3 = TestStruct(value: "bar") + + #expect(string1 == string2) + #expect(string1 != string3) + } +} + +struct TestCustomReflectable { + struct Custom: CustomReflectable, Equatable { + var a = 1 + var b = 2 + var customMirror: Mirror { + Mirror(self, children: ["A": a, "B": b]) + } + } + + struct WrappedCustom { + @ThreadSafe var value = Custom() + } + + @Test func testCustomReflectablePreserved() throws { + + let mirror = Mirror(reflecting: WrappedCustom()) + + try #require(mirror.children.count == 1) + guard let child = mirror.children.first else { + fatalError() + } + + // label of wrapped value will have an underscore prefix + #expect(child.label == "_value") + + let childMirror = Mirror(reflecting: child.value) + let labels = childMirror.children.compactMap { $0.label } + + #expect(labels == ["A", "B"]) + } + +} + +struct TestSendable { + + // normally we don't have to do anything if all the members already conform to Sendable. + struct SendableStruct: Sendable { + var a: Int = 0 + } + + // when the property wrapper is used without the extension this code won't compile. + struct SendableStructWrapped: Sendable { + @ThreadSafe var a: Int = 0 + } + + // @unchecked Sendable puts the responsibility on the dev to ensure thread safety + // without @ThreadSafe this class will compile but may cause issues + class SenableObj: @unchecked Sendable { + @ThreadSafe var a: Int = 0 + } + + @Test func testSendable() async { + let sendable = SendableStruct() + await doThingWithSendable(sendable) + let sendableWrapped = SendableStructWrapped() + await doThingWithSendable(sendableWrapped) + } + + func doThingWithSendable(_ senable: Sendable) async { + _ = await Task { + senable + }.value + } +} + +struct TestCodable { + struct TestStruct: Codable, Equatable { + struct SubStruct : Codable, Equatable { + @ThreadSafe var text: String + } + @ThreadSafe var text: String + @ThreadSafe var number: Int + @ThreadSafe var bool: Bool + @ThreadSafe var numberArray: [Int] + @ThreadSafe var optionalText: String? + @ThreadSafe var optionalText2: String? + @ThreadSafe var optionalText3: String? + @ThreadSafe var sub: SubStruct? + } + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + @Test func testDecodeAndEncode() throws { + let testJson = """ + { + "text": "Hello", + "number": 42, + "bool": false, + "numberArray": [0, 1, 2], + "optionalText": "not null", + "optionalText2": null, + "sub": { + "text": "World" + } + } + """ + let decoded = try decoder.decode(TestStruct.self, from: Data(testJson.utf8)) + + #expect(decoded.text == "Hello") + #expect(decoded.number == 42) + #expect(!decoded.bool) + #expect(decoded.numberArray == [0, 1, 2]) + #expect(decoded.optionalText == "not null") + #expect(decoded.optionalText2 == nil) + #expect(decoded.optionalText3 == nil) + #expect(decoded.sub?.text == "World") + + let encoded = try encoder.encode(decoded) + let encodedString = String(data: encoded, encoding: .utf8)! + + // nil values won't be encoded into the data + #expect(!encodedString.contains("optionalText2")) + #expect(!encodedString.contains("optionalText3")) + } + + @Test func testEncodeThenDecodeAgain() throws { + let aStruct = TestStruct( + text: "Hello", + number: 42, + bool: false, + numberArray: [0, 1, 2], + optionalText: "not null", + optionalText2: nil, + optionalText3: "optionalText4", + sub: TestStruct.SubStruct(text: "World") + ) + + let data = try JSONEncoder().encode(aStruct) + let decoded = try JSONDecoder().decode(TestStruct.self, from: data) + + #expect(decoded == aStruct) + } +}