various extensions to improve the property wrapper's versatility and unit tests for the extensions
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user