Skip to content

Commit

Permalink
Updating property wrappers
Browse files Browse the repository at this point in the history
  • Loading branch information
drekka committed Jan 15, 2024
1 parent c41e686 commit 95afc0b
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 61 deletions.
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git",
"state" : {
"revision" : "a23ded2c91df9156628a6996ab4f347526f17b6b",
"version" : "2.1.2"
"revision" : "dc9af4781f2afdd1e68e90f80b8603be73ea7abc",
"version" : "2.2.0"
}
},
{
Expand Down
56 changes: 43 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

(A friendly API for working with dates without the time of day)

Developers regularly need to refer to a date without needing to know a specific point in time. For example, a person's date of birth is often needed, but not the exact time they were born. The same goes for many other things. The dates of a person's leave, religious holidays, sales, festivals, etc.
Swift provides the excellent date support through `Date`, `Calendar`, `TimeZone` and other types. However these are all designed to work with specific points in time, rather than the generalisations that people often refer to. For example, a person's date of birth is often used without any reference to the exact time they were born. The same goes for a variety of other dates, an employee's person's leave, various religious holidays, retail sales, festivals, etc.

Swift provides the excellent `Date`, `Calendar` and `TimeZone` types for details with specific points in time. But when it comes to the generalisation that is a date they can become a lot harder to work with. As a result developers often find themselves stripping the time components from Swift's `Date` to try and make them work as dates. Add in the complexities of time zone calculations and this can become quite fragile and prone to bugs.
As a result developers often find themselves stripping time components from Swift's `Date` to force it to act like a date, often with mixed results as there are many technical issues to consider when coercing a specific point to such a generalisation. Especially with the complexities of time zones and sometime questionable input from external sources.

`DayType` sets out to address these issues by providing `Day` which represents a general 24 hours period instead of a specific point in time. ie. no hours, minutes, etc. This allows date code to be simpler because it no longer needs to sanitise time components and removes the angst of accidental bugs as well as making date based calculations simpler.
`DayType` sets out to simplify date handling by providing a new `Day` type which represents a general 24 hours period instead of a specific point in time. ie. no hours, minutes, etc and no time zones. This allows date only code to be simpler because it no longer needs to sanitise time components which in turn removes the angst of accidental bugs as well as making date based calculations simpler.

## Installation

Expand All @@ -31,15 +31,15 @@ init(year: Int, month: Int, day: Int)

`Day` is fully `Codable`.

The actual value it reads and write is an `Int` representing the number of days since 1970 and you can access it via the `.daysSince1970` property.
It's base value is an `Int` representing the number of days since 1 January 1970 which can accessed via the `.daysSince1970` property.

# Properties

## .daysSince1970

Literally the number of days since Swift's base date of 00:00:00 UTC on 1 January 1970.

_Note that will match the number of days produced by:_
_Note that matches the number of days produced by this Apple API based code:_

```swift
let fromDate = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 0))
Expand All @@ -51,9 +51,9 @@ let numberOfDays = Calendar.current.dateComponents([.day], from: fromDate, to: t

`Day`'s internal value isn't something that external APIs are typically aware of. So to support the typical values that external APIs tend to use `DayType` provides a variety of property wrappers implementing `Codable` to automatically handle the conversions.

_Note: All of these wrappers support both `Day` and `Day?` properties through the use of the `DayCodable` protocol which is applied to both. Technically this protocol could be added to other types to make then covertable to `Day`._
_Note: All of these wrappers support both `Day` and `Day?` properties through the use of the `DayCodable` protocol which is applied to both. Technically this protocol could be added to other types to make them convertible to `Day`._

## @EpochDay
## @CodableAsEpochSeconds

Converts [epoch timestamps](https://www.epochconverter.com) to `Day`. For example the JSON data structure:

Expand All @@ -67,11 +67,29 @@ Can be read by:

```swift
struct MyType: Codable {
@EpochDay var dob: Day // or Day?
@CodableAsEpochSeconds var dob: Day // or Day?
}
```

## @ISO8601Day
## @CodableAsEpochMilliseconds

Essentially the same as `@CodableAsEpochSeconds` but expects the epoch time to be in millisecond [epoch timestamps](https://www.epochconverter.com). For example the JSON data structure:

```json
{
"dob":856616400123
}
```

Can be read by:

```swift
struct MyType: Codable {
@CodableAsEpochMilliseconds var dob: Day // or Day?
}
```

## @CodableAsISO8601

Converts [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date strings to `Day`. For example:

Expand All @@ -85,11 +103,11 @@ Can be read by:

```swift
struct MyType: Codable {
@ISO8601Day var dob: Day // or Day?
@CodableAsISO8601 var dob: Day // or Day?
}
```

## @CustomISO8601Day<T, Configurator>
## @CodableAsConfiguredISO8601<T, Configurator>

Where `T: DayCodable` and `Configurator: ISO8601Configurator`.

Expand All @@ -114,14 +132,26 @@ enum MinimalFormat: ISO8601Configurator {
}

struct MyType: Codable {
@CustomISO8601Day<Day, MinimalFormat> var dob: Day
@CodableAsConfiguredISO8601<Day, MinimalFormat> var dob: Day
// or ...
@CustomISO8601Day<Day?, MinimalFormat> var dob: Day?
@CodableAsConfiguredISO8601<Day?, MinimalFormat> var dob: Day?
}
```

The `ISO8601Configurator` protocol specifies only a single function which is `static`. That function is used to configure the formatter used to read and write the date strings.

_Note that because Swift does not current support specifying a default type for a generic argument, `@CodableAsConfiguredISO8601<T, Configurator>` requires you to specify the `DayCodable` type (`Day` or `Day?`) which must match the type of the property._

## Supplied ISO8601 configurators

### ISO8601Config.Default

This configurator does not change the formatter. It's main purpose is to support the `@CodableAsISO8601` property wrapper.

### ISO8601Config.SansTimeZone

This configurator is for the common situation where the ISO8601 string does not have the time zone specified. For example `"1997-02-22T13:00:00"`.

# Manipulating Day types

## Operators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

/// Identifies a ``Day`` property that reads and writes from an ISO8601 formatted string.
@propertyWrapper
public struct CustomISO8601Day<T, Configurator>: Codable where T: ISO8601Codable, Configurator: ISO8601Configurator {
public struct CodableAsConfiguredISO8601<T, Configurator>: Codable where T: ISO8601Codable, Configurator: ISO8601Configurator {

public var wrappedValue: T

Expand Down
27 changes: 27 additions & 0 deletions Sources/Property wrappers/CodableAsEpochMilliseconds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// EpochDay.swift
//
//
// Created by Derek Clarkson on 9/1/2024.
//

import Foundation

/// Identifies a ``Day`` property that reads and writes from an epoch time value expressed in seconds.
@propertyWrapper
public struct CodableAsEpochMilliseconds<T>: Codable where T: EpochCodable {

public var wrappedValue: T

public init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}

public init(from decoder: Decoder) throws {
wrappedValue = try T(epochDecoder: decoder, factor: 0.001)
}

public func encode(to encoder: Encoder) throws {
try wrappedValue.encode(epochEncoder: encoder, factor: 0.001)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

import Foundation

/// Identifies a ``Day`` property that reads and writes from an epoch time value.
/// Identifies a ``Day`` property that reads and writes from an epoch time value expressed in seconds.
@propertyWrapper
public struct EpochDay<T>: Codable where T: EpochCodable {
public struct CodableAsEpochSeconds<T>: Codable where T: EpochCodable {

public var wrappedValue: T

Expand All @@ -18,10 +18,10 @@ public struct EpochDay<T>: Codable where T: EpochCodable {
}

public init(from decoder: Decoder) throws {
wrappedValue = try T(epochDecoder: decoder)
wrappedValue = try T(epochDecoder: decoder, factor: 1.0)
}

public func encode(to encoder: Encoder) throws {
try wrappedValue.encode(epochEncoder: encoder)
try wrappedValue.encode(epochEncoder: encoder, factor: 1.0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

/// Identifies a ``Day`` property that reads and writes from an ISO8601 formatted string.
@propertyWrapper
public struct ISO8601Day<T>: Codable where T: ISO8601Codable {
public struct CodableAsISO8601<T>: Codable where T: ISO8601Codable {

public var wrappedValue: T

Expand All @@ -18,10 +18,10 @@ public struct ISO8601Day<T>: Codable where T: ISO8601Codable {
}

public init(from decoder: Decoder) throws {
wrappedValue = try T(iso8601Decoder: decoder, configurator: ISO8601DefaultConfigurator.self)
wrappedValue = try T(iso8601Decoder: decoder, configurator: ISO8601Config.Default.self)
}

public func encode(to encoder: Encoder) throws {
try wrappedValue.encode(iso8601Encoder: encoder, configurator: ISO8601DefaultConfigurator.self)
try wrappedValue.encode(iso8601Encoder: encoder, configurator: ISO8601Config.Default.self)
}
}
20 changes: 10 additions & 10 deletions Sources/Property wrappers/Day+EpochCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,38 @@ import Foundation
/// By using this protocols for property wrappers we can reduce the number of wrappers needed because
/// it erases the optional aspect of the values.
public protocol EpochCodable {
init(epochDecoder decoder: Decoder) throws
func encode(epochEncoder encoder: Encoder) throws
init(epochDecoder decoder: Decoder, factor: Double) throws
func encode(epochEncoder encoder: Encoder, factor: Double) throws
}

extension Day: EpochCodable {

public init(epochDecoder decoder: Decoder) throws {
public init(epochDecoder decoder: Decoder, factor: Double) throws {
let container = try decoder.singleValueContainer()
guard let epochTime = try? container.decode(TimeInterval.self) else {
let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to read a Day value, expected an epoch.")
throw DecodingError.dataCorrupted(context)
}
self = Day(date: Date(timeIntervalSince1970: epochTime))
self = Day(date: Date(timeIntervalSince1970: epochTime * factor))
}

public func encode(epochEncoder encoder: Encoder) throws {
public func encode(epochEncoder encoder: Encoder, factor: Double) throws {
var container = encoder.singleValueContainer()
try container.encode(date().timeIntervalSince1970)
try container.encode(date().timeIntervalSince1970 / factor)
}
}

/// `Day?` support which mostly just handles `nil` before calling the main ``Day`` codable code.
extension Day?: EpochCodable {

public init(epochDecoder decoder: Decoder) throws {
public init(epochDecoder decoder: Decoder, factor: Double) throws {
let container = try decoder.singleValueContainer()
self = container.decodeNil() ? nil : try Day(epochDecoder: decoder)
self = container.decodeNil() ? nil : try Day(epochDecoder: decoder, factor: factor)
}

public func encode(epochEncoder encoder: Encoder) throws {
public func encode(epochEncoder encoder: Encoder, factor: Double) throws {
if let self {
try self.encode(epochEncoder: encoder)
try self.encode(epochEncoder: encoder, factor: factor)
} else {
var container = encoder.singleValueContainer()
try container.encodeNil()
Expand Down
19 changes: 15 additions & 4 deletions Sources/Property wrappers/ISO8601Configurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,19 @@ public protocol ISO8601Configurator {
static func configure(formatter: ISO8601DateFormatter)
}

/// A default implementation of a ``ISO8601CodingStrategy`` that's used
/// in the default property wrappers.
public enum ISO8601DefaultConfigurator: ISO8601Configurator {
public static func configure(formatter _: ISO8601DateFormatter) {}
/// USeful common configurations of ISO8601 formatters.
public enum ISO8601Config {

/// A default implementation that leaves the formatted untouched from it's defaults.
/// in the default property wrappers.
public enum Default: ISO8601Configurator {
public static func configure(formatter _: ISO8601DateFormatter) {}
}

/// Removes the time zone element from the string.
public enum SansTimeZone: ISO8601Configurator {
public static func configure(formatter: ISO8601DateFormatter) {
formatter.formatOptions.remove(.withTimeZone)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,25 @@ import XCTest

// MARK: - ISO8601 Decoding

private enum SansTimeZone: ISO8601Configurator {
static func configure(formatter: ISO8601DateFormatter) {
formatter.formatOptions.remove(.withTimeZone)
}
}

private struct ISO8601CustomContainer<Configurator>: Codable where Configurator: ISO8601Configurator {
@CustomISO8601Day<Day, Configurator> var d1: Day
@CodableAsConfiguredISO8601<Day, Configurator> var d1: Day
init(d1: Day) {
self.d1 = d1
}
}

private struct ISO8601CustomOptionalContainer<Configurator>: Codable where Configurator: ISO8601Configurator {
@CustomISO8601Day<Day?, Configurator> var d1: Day?
@CodableAsConfiguredISO8601<Day?, Configurator> var d1: Day?
init(d1: Day?) {
self.d1 = d1
}
}

class CustomISO8601DayDecodingTests: XCTestCase {
class CodableAsConfiguredISO8601Tests: XCTestCase {

func testDecodingSansTimeZone() throws {
let json = #"{"d1": "2012-02-02T13:33:23"}"#
let result = try JSONDecoder().decode(ISO8601CustomContainer<SansTimeZone>.self, from: json.data(using: .utf8)!)
let result = try JSONDecoder().decode(ISO8601CustomContainer<ISO8601Config.SansTimeZone>.self, from: json.data(using: .utf8)!)
expect(result.d1) == Day(2012, 02, 03)
}

Expand Down Expand Up @@ -81,21 +75,21 @@ class CustomISO8601OptionalDayDecodingTests: XCTestCase {

func testDecodingSansTimeZone() throws {
let json = #"{"d1": "2012-02-02T13:33:23"}"#
let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer<SansTimeZone>.self, from: json.data(using: .utf8)!)
let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer<ISO8601Config.SansTimeZone>.self, from: json.data(using: .utf8)!)
expect(result.d1) == Day(2012, 02, 03)
}

func testDecodingSansTimeZoneWithNil() throws {
let json = #"{"d1":null}"#
let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer<SansTimeZone>.self, from: json.data(using: .utf8)!)
let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer<ISO8601Config.SansTimeZone>.self, from: json.data(using: .utf8)!)
expect(result.d1).to(beNil())
}
}

class ISO8601CustomDayEncodingTests: XCTestCase {

func testEncoding() throws {
let instance = ISO8601CustomContainer<SansTimeZone>(d1: Day(2012, 02, 03))
let instance = ISO8601CustomContainer<ISO8601Config.SansTimeZone>(d1: Day(2012, 02, 03))
let result = try JSONEncoder().encode(instance)
expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00"}"#
}
Expand All @@ -104,13 +98,13 @@ class ISO8601CustomDayEncodingTests: XCTestCase {
class CustomISO8601OptionalDayEncodingTests: XCTestCase {

func testEncoding() throws {
let instance = ISO8601CustomOptionalContainer<SansTimeZone>(d1: Day(2012, 02, 03))
let instance = ISO8601CustomOptionalContainer<ISO8601Config.SansTimeZone>(d1: Day(2012, 02, 03))
let result = try JSONEncoder().encode(instance)
expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00"}"#
}

func testEncodingNil() throws {
let instance = ISO8601CustomOptionalContainer<SansTimeZone>(d1: nil)
let instance = ISO8601CustomOptionalContainer<ISO8601Config.SansTimeZone>(d1: nil)
let result = try JSONEncoder().encode(instance)
expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"#
}
Expand Down
Loading

0 comments on commit 95afc0b

Please sign in to comment.