Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix custom to fully support Codable protocol when using SwiftData #5

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions Sources/NaiveDate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -36,11 +42,27 @@ 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: .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
} else {
self = try _decode(from: decoder)
}
}

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
Expand All @@ -56,6 +78,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.
Expand Down Expand Up @@ -98,11 +126,27 @@ 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: .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
} else {
self = try _decode(from: decoder)
}
}

public func encode(to encoder: Encoder) throws {
try _encode(self, to: encoder)
if String(describing: encoder) == "SwiftData.CompositeEncoder" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to implement it without hardcoding "SwiftData.CompositeEncoder"?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I didn't have a time to find a way just yet

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
Expand All @@ -120,6 +164,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.
Expand Down Expand Up @@ -160,11 +209,24 @@ 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: .date),
let time = try? container.decodeIfPresent(NaiveTime.self, forKey: .time) {
self.date = date
self.time = time
} else {
self = try _decode(from: decoder)
}
}

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
Expand Down Expand Up @@ -254,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"
}
}
4 changes: 2 additions & 2 deletions Tests/NaiveDateFormatterTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:10PM")
XCTAssertEqual(formatter.string(from: NaiveTime("16:10:15")!)!, "4:10PM")
}

func testNaiveTimeFormatter_enGB() {
Expand Down
52 changes: 52 additions & 0 deletions Tests/NaiveDateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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))
}
}


Expand Down Expand Up @@ -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() {
Expand Down
110 changes: 110 additions & 0 deletions Tests/SwiftDataTests.swift
Original file line number Diff line number Diff line change
@@ -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<ModelWithDates>()
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)
}
}