various extensions to improve the property wrapper's versatility and unit tests for the extensions

This commit is contained in:
2025-10-19 21:44:09 +01:00
parent 3a8f58461b
commit b96c64034f
2 changed files with 258 additions and 4 deletions

View File

@@ -1,8 +1,8 @@
// //
// ThreadSafe.swift // ThreadSafe.swift
// ThreadSafe // ThreadSafe
// // https://github.com/yeungkaho/ThreadSafe.git
// Created by kahoyeung on 13/09/2025. // Created by Kaho Yeung on 13/09/2025.
// //
import Foundation import Foundation
@@ -117,3 +117,73 @@ final class RWLock {
return body() 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<T: Decodable & ExpressibleByNilLiteral>(
_ type: ThreadSafe<T>.Type,
forKey key: Key
) throws -> ThreadSafe<T> {
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<T: Encodable & ExpressibleByNilLiteral>(_ value: ThreadSafe<T>, forKey key: KeyedEncodingContainer<K>.Key) throws {
let mirror = Mirror(reflecting: value.wrappedValue)
guard mirror.displayStyle != .optional || !mirror.children.isEmpty else {
return
}
try encodeIfPresent(value, forKey: key)
}
}

View File

@@ -1,6 +1,6 @@
import Testing import Testing
import Foundation import Foundation
@testable import ThreadSafe import ThreadSafe
class ThreadSafetyTest: @unchecked Sendable { class ThreadSafetyTest: @unchecked Sendable {
@@ -46,7 +46,7 @@ class ThreadSafetyTest: @unchecked Sendable {
// same as above // same as above
@ThreadSafe(valueTypeCopyOnWrite: false) var anotherNSString: NSMutableString = "Hello" @ThreadSafe(valueTypeCopyOnWrite: false) var anotherNSString: NSMutableString = "Hello"
} }
let original = Example() let original = Example()
var copy = original var copy = original
@@ -66,3 +66,187 @@ class ThreadSafetyTest: @unchecked Sendable {
#expect(original.anotherNSString == "Hello, World!") #expect(original.anotherNSString == "Hello, World!")
#expect(copy.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<Value: Equatable>: 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)
}
}