From 17253a75267ac5105703e69df0b714218de57ec2 Mon Sep 17 00:00:00 2001 From: Taras Paliienko Date: Tue, 30 Jul 2024 17:20:29 +0300 Subject: [PATCH 1/2] Fix custom decoder to fully support Decodable protocol. Fixes certain crashes when using NaiveDate/Time with SwiftData. --- Sources/NaiveDate.swift | 48 +++++++++++++++++++++++++-- Tests/NaiveDateFormatterTest.swift | 4 +-- Tests/NaiveDateTests.swift | 52 ++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/Sources/NaiveDate.swift b/Sources/NaiveDate.swift index 927849d..0b6fdd0 100644 --- a/Sources/NaiveDate.swift +++ b/Sources/NaiveDate.swift @@ -6,6 +6,12 @@ import Foundation public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { public let year: Int, month: Int, day: Int + enum CodingKeys: String, CodingKey { + case year + case month + case day + } + /// Initializes the naive date with a given date components. /// - important: The naive types don't validate input components. For any /// precise manipulations with time use native `Date` and `Calendar` types. @@ -36,7 +42,16 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti // MARK: Codable public init(from decoder: Decoder) throws { - self = try _decode(from: decoder) + if let container = try? decoder.container(keyedBy: CodingKeys.self), + let year = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.year), + let month = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.month), + let day = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.day) { + self.year = year + self.month = month + self.day = day + } else { + self = try _decode(from: decoder) + } } public func encode(to encoder: Encoder) throws { @@ -56,6 +71,12 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { public let hour: Int, minute: Int, second: Int + enum CodingKeys: String, CodingKey { + case hour + case minute + case second + } + /// Initializes the naive time with a given date components. /// - important: The naive types don't validate input components. For any /// precise manipulations with time use native `Date` and `Calendar` types. @@ -98,7 +119,16 @@ public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConverti // MARK: Codable public init(from decoder: Decoder) throws { - self = try _decode(from: decoder) + if let container = try? decoder.container(keyedBy: CodingKeys.self), + let hour = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.hour), + let minute = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.minute), + let second = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.second) { + self.hour = hour + self.minute = minute + self.second = second + } else { + self = try _decode(from: decoder) + } } public func encode(to encoder: Encoder) throws { @@ -120,6 +150,11 @@ public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConv public let date: NaiveDate public let time: NaiveTime + enum CodingKeys: String, CodingKey { + case date + case time + } + /// Initializes the naive datetime with a given date components. /// - important: The naive types don't validate input components. For any /// precise manipulations with time use native `Date` and `Calendar` types. @@ -160,7 +195,14 @@ public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConv // MARK: Codable public init(from decoder: Decoder) throws { - self = try _decode(from: decoder) + if let container = try? decoder.container(keyedBy: CodingKeys.self), + let date = try? container.decodeIfPresent(NaiveDate.self, forKey: CodingKeys.date), + let time = try? container.decodeIfPresent(NaiveTime.self, forKey: CodingKeys.time) { + self.date = date + self.time = time + } else { + self = try _decode(from: decoder) + } } public func encode(to encoder: Encoder) throws { diff --git a/Tests/NaiveDateFormatterTest.swift b/Tests/NaiveDateFormatterTest.swift index bc0e1ec..39c8622 100644 --- a/Tests/NaiveDateFormatterTest.swift +++ b/Tests/NaiveDateFormatterTest.swift @@ -9,8 +9,8 @@ class NaiveDateFormatterTest: XCTestCase { $0.timeStyle = .short } - XCTAssertEqual(formatter.string(from: NaiveTime("16:10")!), "4:10 PM") - XCTAssertEqual(formatter.string(from: NaiveTime("16:10:15")!), "4:10 PM") + XCTAssertEqual(formatter.string(from: NaiveTime("16:10")!)!, "4:10 PM") + XCTAssertEqual(formatter.string(from: NaiveTime("16:10:15")!)!, "4:10 PM") } func testNaiveTimeFormatter_enGB() { diff --git a/Tests/NaiveDateTests.swift b/Tests/NaiveDateTests.swift index 333ae6c..d55f4cc 100644 --- a/Tests/NaiveDateTests.swift +++ b/Tests/NaiveDateTests.swift @@ -42,6 +42,19 @@ class NaiveDateTest: XCTestCase { XCTAssertEqual(NaiveDate("2017-10-1"), NaiveDate(year: 2017, month: 10, day: 1)) } + func testDecodableFromJson() { + let data = """ + { + "year": 2024, + "month": 7, + "day": 30 + } + """.data(using: .utf8)! + + let date = try! JSONDecoder().decode(NaiveDate.self, from: data) + XCTAssertEqual(date, NaiveDate(year: 2024, month: 07, day: 30)) + } + func testToString() { XCTAssertEqual(NaiveDate(year: 2017, month: 10, day: 1).description, "2017-10-01") } @@ -176,6 +189,19 @@ class NaiveTimeTest: XCTestCase { let data = try! JSONEncoder().encode(Wrapped(time: NaiveTime(hour: 22, minute: 15, second: 10))) XCTAssertEqual(String(data: data, encoding: .utf8), "{\"time\":\"22:15:10\"}") } + + func testDecodableFromJson() { + let data = """ + { + "hour": 22, + "minute": 15, + "second": 10 + } + """.data(using: .utf8)! + + let time = try! JSONDecoder().decode(NaiveTime.self, from: data) + XCTAssertEqual(time, NaiveTime(hour: 22, minute: 15, second: 10)) + } } @@ -353,6 +379,32 @@ class NaiveDateTimeTest: XCTestCase { XCTAssertEqual(String(data: data, encoding: .utf8), "{\"dateTime\":\"2017-02-01T10:09:08\"}") } + func testDecodableFromJson() { + let data = """ + { + "time": { + "hour": 22, + "minute": 15, + "second": 10 + }, + "date": { + "year": 2024, + "month": 7, + "day": 30 + } + } + """.data(using: .utf8)! + + let dateTime = try! JSONDecoder().decode(NaiveDateTime.self, from: data) + + let expectedDateTime = NaiveDateTime( + date: NaiveDate(year: 2024, month: 7, day: 30), + time: NaiveTime(hour: 22, minute: 15, second: 10) + ) + + XCTAssertEqual(dateTime, expectedDateTime) + } + // MARK: Date <-> NaiveDateTime func testFromDate() { From 6ebbf6f1550598a658708727c1e1262a7bfed8f9 Mon Sep 17 00:00:00 2001 From: Taras Paliienko Date: Wed, 31 Jul 2024 01:11:52 +0300 Subject: [PATCH 2/2] SwiftData encoding fix and unit test --- Sources/NaiveDate.swift | 54 ++++++++++++++---- Tests/SwiftDataTests.swift | 110 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 Tests/SwiftDataTests.swift diff --git a/Sources/NaiveDate.swift b/Sources/NaiveDate.swift index 0b6fdd0..e1c0d6e 100644 --- a/Sources/NaiveDate.swift +++ b/Sources/NaiveDate.swift @@ -43,9 +43,9 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti public init(from decoder: Decoder) throws { if let container = try? decoder.container(keyedBy: CodingKeys.self), - let year = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.year), - let month = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.month), - let day = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.day) { + let year = try? container.decodeIfPresent(Int.self, forKey: .year), + let month = try? container.decodeIfPresent(Int.self, forKey: .month), + let day = try? container.decodeIfPresent(Int.self, forKey: .day) { self.year = year self.month = month self.day = day @@ -55,7 +55,14 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti } public func encode(to encoder: Encoder) throws { - try _encode(self, to: encoder) + if String(describing: encoder) == "SwiftData.CompositeEncoder" { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(year, forKey: .year) + try container.encode(month, forKey: .month) + try container.encode(day, forKey: .day) + } else { + try _encode(self, to: encoder) + } } // MARK: _DateComponentsConvertible @@ -120,9 +127,9 @@ public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConverti public init(from decoder: Decoder) throws { if let container = try? decoder.container(keyedBy: CodingKeys.self), - let hour = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.hour), - let minute = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.minute), - let second = try? container.decodeIfPresent(Int.self, forKey: CodingKeys.second) { + let hour = try? container.decodeIfPresent(Int.self, forKey: .hour), + let minute = try? container.decodeIfPresent(Int.self, forKey: .minute), + let second = try? container.decodeIfPresent(Int.self, forKey: .second) { self.hour = hour self.minute = minute self.second = second @@ -132,7 +139,14 @@ public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConverti } public func encode(to encoder: Encoder) throws { - try _encode(self, to: encoder) + if String(describing: encoder) == "SwiftData.CompositeEncoder" { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hour, forKey: .hour) + try container.encode(minute, forKey: .minute) + try container.encode(second, forKey: .second) + } else { + try _encode(self, to: encoder) + } } // MARK: _DateComponentsConvertible @@ -196,8 +210,8 @@ public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConv public init(from decoder: Decoder) throws { if let container = try? decoder.container(keyedBy: CodingKeys.self), - let date = try? container.decodeIfPresent(NaiveDate.self, forKey: CodingKeys.date), - let time = try? container.decodeIfPresent(NaiveTime.self, forKey: CodingKeys.time) { + let date = try? container.decodeIfPresent(NaiveDate.self, forKey: .date), + let time = try? container.decodeIfPresent(NaiveTime.self, forKey: .time) { self.date = date self.time = time } else { @@ -206,7 +220,13 @@ public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConv } public func encode(to encoder: Encoder) throws { - try _encode(self, to: encoder) + if encoder.isSwiftDataComposite { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(date, forKey: .date) + try container.encode(time, forKey: .time) + } else { + try _encode(self, to: encoder) + } } // MARK: _DateComponentsConvertible @@ -296,3 +316,15 @@ private func _components(from string: String, separator: String) -> [Int]? { let components = substrings.compactMap(Int.init) return components.count == substrings.count ? components : nil } + +extension Encoder { + /// SwiftData composite encoder can't accept single value + /// as it expects to code each of attributes under the hood, which will match internal .compositeDescription + /// Otherwise null/zero data will be saved. + /// + /// So we detect it like this for now. + var isSwiftDataComposite: Bool { + // TODO: Figure out other way instead of introspection + String(describing: self) == "SwiftData.CompositeEncoder" + } +} diff --git a/Tests/SwiftDataTests.swift b/Tests/SwiftDataTests.swift new file mode 100644 index 0000000..54a250b --- /dev/null +++ b/Tests/SwiftDataTests.swift @@ -0,0 +1,110 @@ +import XCTest + +#if canImport(SwiftData) +import SwiftData +#endif + +import NaiveDate + +@available(iOS 17, *) +@Model class ModelWithDates: Codable { + var date: NaiveDate + var time: NaiveTime + var dateTime: NaiveDateTime + + enum CodingKeys: String, CodingKey { + case date + case time + case dateTime + } + + init(date: NaiveDate, time: NaiveTime, dateTime: NaiveDateTime) { + self.date = date + self.time = time + self.dateTime = dateTime + } + + required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(NaiveDate.self, forKey: .date) + time = try container.decode(NaiveTime.self, forKey: .time) + dateTime = try container.decode(NaiveDateTime.self, forKey: .dateTime) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(date, forKey: .date) + try container.encode(time, forKey: .time) + try container.encode(dateTime, forKey: .dateTime) + } +} + +@available(iOS 17, *) +class SwiftDataTests : XCTestCase { + func createModelContainer() throws -> ModelContainer { + let schema = Schema([ModelWithDates.self]) + return try ModelContainer( + for: schema, + configurations: [ + ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + ] + ) + } + + var modelContainer: ModelContainer! + + override func setUp() { + modelContainer = try! createModelContainer() + } + + @MainActor + func testSwiftDataCompositeEncodeDecode() throws { + + let data = """ + { + "dateTime":"2024-02-01T10:09:08", + "time": "11:12:13", + "date": "2025-12-24" + } + """.data(using: .utf8)! + + let model = try! JSONDecoder().decode(ModelWithDates.self, from: data) + + let expectedDateTime = NaiveDateTime(date: NaiveDate(year: 2024, month: 2, day: 1), time: NaiveTime(hour: 10, minute: 9, second: 8)) + let expectedDate = NaiveDate(year: 2025, month: 12, day: 24) + let expectedTime = NaiveTime(hour: 11, minute: 12, second: 13) + + XCTAssertEqual(model.dateTime, expectedDateTime) + XCTAssertEqual(model.date, expectedDate) + XCTAssertEqual(model.time, expectedTime) + + modelContainer.mainContext.insert(model) + + /// Save to persistent store + try modelContainer.mainContext.save() + + let exp = expectation(description: "Background task finished") + + /// Ensure we fetch object directly from persistent store and not reusing in-memory one + /// It is ensured through creating a detached task, which forces non-main actor queue + /// And so forces a ModelContext to use other thread + Task.detached { + /// Background context + let otherContext = ModelContext(self.modelContainer) + + /// Fetching our model + var fetchDescriptor = FetchDescriptor() + fetchDescriptor.fetchLimit = 1 + let fetchedModel = try otherContext.fetch(fetchDescriptor)[0] + + /// Ensuring data persisted and transformed properly + XCTAssertEqual(fetchedModel.dateTime, expectedDateTime) + XCTAssertEqual(fetchedModel.date, expectedDate) + XCTAssertEqual(fetchedModel.time, expectedTime) + + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } +}