Skip to content

Commit 46550ce

Browse files
authored
Added date encoding support for clients that may need to use a different formatting than ISO. (#53)
* Added the ability to customize encoding and decoding dates when parking or retrieving Codable objects. * Split out SwiftCodableTests into separate files to significantly reduce file size.
1 parent 865c9ae commit 46550ce

File tree

8 files changed

+396
-192
lines changed

8 files changed

+396
-192
lines changed

Sources/GarageStorage/Garage+Codable.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ extension Garage {
4747

4848
let decoder = JSONDecoder()
4949
decoder.userInfo[.garage] = self
50-
decoder.dateDecodingStrategy = .custom(decodeTransformableDate)
50+
decoder.dateDecodingStrategy = self.dateDecodingStrategy
5151

5252
return try decoder.decode(T.self, from: data)
5353
}
@@ -57,7 +57,7 @@ extension Garage {
5757
internal func encodeData<T: Encodable>(_ object: T) throws -> String {
5858
let encoder = JSONEncoder()
5959
encoder.userInfo[.garage] = self
60-
encoder.dateEncodingStrategy = .formatted(Date.isoFormatter)
60+
encoder.dateEncodingStrategy = self.dateEncodingStrategy
6161
let data = try encoder.encode(object)
6262

6363
return try encrypt(data)

Sources/GarageStorage/Garage+Migratable.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ internal func decodeTransformableDate(_ decoder: Decoder) throws -> Date {
2828

2929
// Swift Codable encodes the date as a string directly
3030
// Objective-C MappableObject encodes a dictionary of transformable type
31-
if let string = try? container.decode(String.self) {
32-
date = Date.isoDate(for: string)!
31+
if let string = try? container.decode(String.self),
32+
let isoDate = Date.isoDate(for: string) {
33+
date = isoDate
3334
} else if let transformableDate = try? container.decode(__TransformableDateObjC.self) {
3435
date = transformableDate.date
3536
} else {

Sources/GarageStorage/Garage.swift

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,22 @@ public class Garage: NSObject {
3333

3434
/// Autosave is set to true by default, for every operation that causes a change to the underlying Core Data Managed Object Context.
3535
///
36-
/// When set to true, the garage will be saved after any operation that causes a change to the underlying Core Data Managed Object Context, including `park()`, `setSyncStatus()`, and `delete()`. When set to false, `save()` must be called instead, in order to persist those changes. You might want to set this to false to perform batch changes to many objects before saving them all, to optimize performance.
36+
/// When set to true, the garage will be saved after any operation that causes a change to the underlying Core Data Managed Object Context, including `park()`, `setSyncStatus()`, and `delete()`. When set to false, `save()` must be called instead, in order to persist those changes. You might want to use `withAutosaveDisabled(:)` to set this to false, in order to perform batch changes to many objects before saving them all, optimizing performance.
3737
@objc(autosaveEnabled)
3838
public var isAutosaveEnabled = true
3939

40+
/// The date decoding strategy used by JSONDecoder when decoding objects.
41+
///
42+
/// By default, this is set to a custom strategy that uses an internal `decodeTransformableDate` function that uses `Date.isoFormatter `for Codable objects or a wrapper conversion check for backward compatibility with Objective-C `MappableObject`.
43+
/// You can modify this to use a different date decoding strategy by wrapping a `retrieve()` call `withDateDecodingStrategy() { }`.
44+
public private(set) var dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .custom(decodeTransformableDate)
45+
46+
/// The date encoding strategy used by JSONEncoder when encoding objects.
47+
///
48+
/// By default, this is set to use ISO 8601 formatted dates via `Date.isoFormatter`.
49+
/// You can modify this to use a different date encoding strategy by wrapping a `park()` call using `withDateEncodingStrategy() { }`.
50+
public private(set) var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .formatted(Date.isoFormatter)
51+
4052
/// The domain for errors generated by GarageStorage.
4153
@objc
4254
public static let errorDomain = "GSErrorDomain"
@@ -157,6 +169,52 @@ public class Garage: NSObject {
157169
}
158170
return try closure()
159171
}
172+
173+
/// Temporarily changes the date decoding strategy while executing the provided closure, then restores the previous strategy.
174+
///
175+
/// This is useful when you need to retrieve objects that were encoded with a different date strategy, or when you need to temporarily use a different decoding strategy. The previous strategy is automatically restored even if the closure throws an error.
176+
///
177+
/// - parameter strategy: The temporary date decoding strategy to use.
178+
/// - parameter closure: A closure to execute with the temporary date decoding strategy. If the closure throws, the error is propagated after restoring the previous strategy.
179+
/// - throws: Rethrows any error thrown by the closure.
180+
///
181+
/// Example usage:
182+
/// ```swift
183+
/// let person = try garage.withDateDecodingStrategy(.iso8601) {
184+
/// try garage.retrieve(Person.self, identifier: "123")
185+
/// }
186+
/// ```
187+
public func withDateDecodingStrategy<Result>(_ strategy: JSONDecoder.DateDecodingStrategy, _ closure: () throws -> Result) rethrows -> Result {
188+
let previousStrategy = dateDecodingStrategy
189+
dateDecodingStrategy = strategy
190+
defer {
191+
dateDecodingStrategy = previousStrategy
192+
}
193+
return try closure()
194+
}
195+
196+
/// Temporarily changes the date encoding strategy while executing the provided closure, then restores the previous strategy.
197+
///
198+
/// This is useful when you need to park objects with a different date encoding strategy, or when you need to temporarily use a different encoding strategy. The previous strategy is automatically restored even if the closure throws an error.
199+
///
200+
/// - parameter strategy: The temporary date encoding strategy to use.
201+
/// - parameter closure: A closure to execute with the temporary date encoding strategy. If the closure throws, the error is propagated after restoring the previous strategy.
202+
/// - throws: Rethrows any error thrown by the closure.
203+
///
204+
/// Example usage:
205+
/// ```swift
206+
/// try garage.withDateEncodingStrategy(.iso8601) {
207+
/// try garage.park(person)
208+
/// }
209+
/// ```
210+
public func withDateEncodingStrategy<Result>(_ strategy: JSONEncoder.DateEncodingStrategy, _ closure: () throws -> Result) rethrows -> Result {
211+
let previousStrategy = dateEncodingStrategy
212+
dateEncodingStrategy = strategy
213+
defer {
214+
dateEncodingStrategy = previousStrategy
215+
}
216+
return try closure()
217+
}
160218

161219
/// Encrypts the data using the dataEncryptionDelegate, if specified, otherwise converts the data to a plain string.
162220
internal func encrypt(_ data: Data) throws -> String {

Tests/GarageStorageTests/RawStorageTests.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,5 @@ struct RawStorageTests {
3131
// we want to catch the error, and verify it
3232
#expect(error.localizedDescription.contains("failed to retrieve gs_data"))
3333
}
34-
3534
}
36-
3735
}

Tests/GarageStorageTests/SwiftCodableTests.swift

Lines changed: 2 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import Testing
1010
import GarageStorage
11-
import CoreData
11+
import Foundation
1212

1313
// This set of tests checks Codable (hence "Swift-y") types.
1414
@Suite("Swift Codable Tests")
@@ -38,7 +38,7 @@ struct SwiftCodableTests {
3838
#expect(retrievedSam.siblings.count == 2, "expected 2 siblings")
3939
}
4040

41-
@Test("Mappable non-string identifiers work correctly")
41+
@Test("Mappable with Identifiable non-string identifiers work correctly")
4242
func mappableNonString() throws {
4343
let garage = makeTestGarage()
4444

@@ -157,133 +157,6 @@ struct SwiftCodableTests {
157157
#expect(addresses[0].city == "Boston", "all addresses should be based in Boston")
158158
}
159159

160-
@Test("Deleting all objects from garage")
161-
func deletingAllObjects() throws {
162-
let garage = makeTestGarage()
163-
164-
// Park heterogeneous objects
165-
let sam = swiftPerson()
166-
let nick = swiftPerson2()
167-
let emily = swiftPerson3()
168-
169-
let oldAddress = swiftAddress()
170-
let newAddress = swiftAddress2()
171-
try garage.parkAll([nick, emily, sam])
172-
try garage.parkAll([oldAddress, newAddress])
173-
174-
// Delete everything
175-
garage.deleteAllObjects()
176-
177-
// Confirm that there are no persons
178-
let persons = try garage.retrieveAll(SwiftPerson.self)
179-
#expect(persons.count == 0, "Should not be any Persons")
180-
181-
let addresses = try garage.retrieveAll(SwiftAddress.self)
182-
#expect(addresses.count == 0, "Should not be any Addresses")
183-
184-
// Delete everything again (hits the no-op case, for code coverage)
185-
garage.deleteAll(SwiftPerson.self)
186-
garage.deleteAllObjects()
187-
}
188-
189-
@Test("Managing sync status of objects")
190-
func syncStatus() throws {
191-
let garage = makeTestGarage()
192-
193-
let sam = swiftPerson()
194-
let nick = swiftPerson2()
195-
let emily = swiftPerson3()
196-
197-
let oldAddress = swiftAddress()
198-
let newAddress = swiftAddress2()
199-
200-
let pet = swiftPet()
201-
202-
// Park heterogeneous objects
203-
try garage.parkAll([nick, emily, sam])
204-
try garage.parkAll([oldAddress, newAddress])
205-
try garage.parkAll([pet])
206-
207-
// Validate sync status of Persons
208-
// WARNING: This will succeed by accident, because there is only one object of type SwiftPerson syncing,
209-
// but if there was another object of a different type syncing, then this would fail.
210-
// Use retrieveAll(_ objectClass: T, withStatus: SyncStatus) instead to focus on the specific type.
211-
let syncing: [SwiftPerson] = try garage.retrieveAll(withStatus: .syncing)
212-
213-
#expect(syncing.count == 1, "1 item should be syncing")
214-
215-
// TODO: heterogeneous Codable subtype arrays
216-
//let undetermined: [?] = try garage.retrieveAll(withStatus: .undetermined)
217-
//#expect(undetermined.count == 4, "4 items should be undetermined")
218-
219-
// This will throw because there are different types of objects not synced.
220-
// Don't use retrieveAll without an objectClass if you work with heterogeneous objects that may have the same sync status.
221-
// let notSynced: [SwiftPerson] = try garage.retrieveAll(withStatus: .notSynced))
222-
223-
// This is the correct way to fetch all of a specific type that are not synced
224-
let notSynced: [SwiftPerson] = try garage.retrieveAll(SwiftPerson.self, withStatus: .notSynced)
225-
#expect(notSynced.count == 0, "no items should be not synced")
226-
227-
// Change Sam's sync status and validate that it changed
228-
try garage.setSyncStatus(.notSynced, for: sam)
229-
230-
// Add in an unrelated object type, to ensure that retrieveAll below works on just one type
231-
try garage.setSyncStatus(.notSynced, for: pet)
232-
233-
let syncingAfterChange: [SwiftPerson] = try garage.retrieveAll(SwiftPerson.self, withStatus: .syncing)
234-
#expect(syncingAfterChange.count == 0, "no items should be syncing")
235-
236-
let notSyncedAfterChange: [SwiftPerson] = try garage.retrieveAll(SwiftPerson.self, withStatus: .notSynced)
237-
#expect(notSyncedAfterChange.count == 1, "1 item should be not synced")
238-
239-
// Test setting sync status for a collection
240-
try garage.setSyncStatus(.undetermined, for: [nick, sam])
241-
let nickStatus = try garage.syncStatus(for: nick)
242-
#expect(nickStatus == .undetermined, "Nick should have undetermined sync status")
243-
}
244-
245-
@Test("Setting sync status for unparked object throws error")
246-
func invalidSyncStatus() throws {
247-
let garage = makeTestGarage()
248-
249-
// Create, but don't park, sam
250-
let sam = swiftPerson()
251-
252-
// Verify that setting sync status on an unparked object throws an error
253-
#expect(throws: Error.self) {
254-
try garage.setSyncStatus(.notSynced, for: sam)
255-
}
256-
257-
#expect(throws: Error.self) {
258-
try garage.setSyncStatus(.notSynced, for: [sam])
259-
}
260-
}
261-
262-
@Test("Date encoding and decoding")
263-
func dates() throws {
264-
let garage = makeTestGarage()
265-
266-
let sam = swiftPerson()
267-
268-
// Set sam's birthdate to 1950/01/01 04:00:00
269-
let timeZone = TestSetup.timeZone
270-
var dateComponents = DateComponents()
271-
dateComponents.day = 1
272-
dateComponents.month = 1
273-
dateComponents.year = 1950
274-
dateComponents.timeZone = timeZone
275-
276-
var calendar = Calendar.current
277-
calendar.timeZone = timeZone
278-
sam.birthdate = calendar.date(from: dateComponents)!
279-
#expect(sam.birthdate.timeIntervalSinceReferenceDate == -1609459200.0, "Making assumption about the test")
280-
281-
try garage.park(sam)
282-
283-
let retrievedSam = try #require(try garage.retrieve(SwiftPerson.self, identifier: "Sam"))
284-
#expect(retrievedSam.birthdate.timeIntervalSinceReferenceDate == -1609459200.0, "Reconstituted date failed")
285-
}
286-
287160
@Test("Identifiable references are preserved")
288161
func identifiableReferences() throws {
289162
let garage = makeTestGarage()
@@ -334,10 +207,6 @@ struct SwiftCodableTests {
334207
let sam = swiftPerson()
335208
let data = try encoder.encode(sam)
336209

337-
// For debugging:
338-
//let string = String(data: data, encoding: .utf8)!
339-
//print(string)
340-
341210
let decoder = JSONDecoder()
342211
let decodedSam = try decoder.decode(SwiftPerson.self, from: data)
343212
#expect(decodedSam.name == "Sam", "name")
@@ -358,65 +227,13 @@ struct SwiftCodableTests {
358227

359228
let data = try encoder.encode(child)
360229

361-
// For debugging:
362-
//let string = String(data: data, encoding: .utf8)!
363-
//print(string)
364-
365230
let decoder = JSONDecoder()
366231
let decodedChild = try decoder.decode(SwiftPersonWithParent.self, from: data)
367232
#expect(decodedChild.name == "Child", "child name")
368233
#expect(decodedChild.parent.name == "Nick", "parent name")
369234
#expect(decodedChild.parent.age == 26, "parent age")
370235
}
371236

372-
@Test("Autosave disabled requires manual save")
373-
func autosaveDisabled() throws {
374-
let garage = makeTestGarage()
375-
376-
// Verify autosave is enabled by default
377-
#expect(garage.isAutosaveEnabled == true, "Autosave should be enabled by default")
378-
379-
// Create and park people using withAutosaveDisabled
380-
try garage.withAutosaveDisabled {
381-
let nick = swiftPerson2()
382-
let emily = swiftPerson3()
383-
try garage.park(nick)
384-
try garage.park(emily)
385-
}
386-
387-
// Verify autosave is re-enabled after the closure
388-
#expect(garage.isAutosaveEnabled == true, "Autosave should be re-enabled after closure")
389-
390-
// Now manually save the garage
391-
garage.save()
392-
393-
// Retrieve the objects to verify they were saved
394-
let retrievedNick = try? garage.retrieve(SwiftPerson.self, identifier: "Nick")
395-
#expect(retrievedNick != nil, "Object should be retrievable after manual save")
396-
#expect(retrievedNick?.name == "Nick", "Retrieved object should have correct name")
397-
398-
let retrievedEmily = try? garage.retrieve(SwiftPerson.self, identifier: "Emily")
399-
#expect(retrievedEmily != nil, "Emily should be retrievable after manual save")
400-
#expect(retrievedEmily?.name == "Emily", "Retrieved Emily should have correct name")
401-
}
402-
403-
@Test("Convenience initializer creates garage with named store")
404-
func convenienceInitializer() throws {
405-
// Use the convenience initializer that creates a garage with a named store
406-
let garage = makeTestGarage()
407-
408-
// Verify the garage is functional by parking and retrieving an object
409-
let sam = swiftPerson()
410-
try garage.park(sam)
411-
412-
let retrievedSam = try #require(try garage.retrieve(SwiftPerson.self, identifier: "Sam"))
413-
#expect(retrievedSam.name == "Sam", "Expected Sam to be Sam")
414-
#expect(retrievedSam.importantDates.count == 3, "Expected 3 important dates")
415-
416-
// Clean up
417-
garage.deleteAllObjects()
418-
}
419-
420237
@Test("Hashable park and delete")
421238
func hashableParkAndDelete() throws {
422239
let garage = makeTestGarage()

0 commit comments

Comments
 (0)