Skip to content

Commit

Permalink
Merge pull request #1 from drekka/develop
Browse files Browse the repository at this point in the history
Updates to property wrappers.
  • Loading branch information
drekka committed Jan 15, 2024
2 parents 61fefd2 + 95afc0b commit 7067c5b
Show file tree
Hide file tree
Showing 15 changed files with 303 additions and 106 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ jobs:
timezoneLinux: "Australia/Melbourne"
timezoneMacos: "Australia/Melbourne"
timezoneWindows: "Australia/Melbourne"
- uses: swift-actions/setup-swift@v1
- uses: BinaryBirds/swift-test-report@0.0.1
#- name: Build
# run: swift build -v
- name: Run tests
run: swift test -v
run: |
# sudo xcode-select -s /Applications/Xcode_15.0.app
swift test -v
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
71 changes: 51 additions & 20 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 @@ -49,13 +49,13 @@ let numberOfDays = Calendar.current.dateComponents([.day], from: fromDate, to: t

# Property wrappers

`Day`'s internal value isn't something that external APIs are typically aware of. So to support the typical range of values that external APIs tend to use, `DayType` provides a range of property wrappers that can handle the processing. Each of which provide the relevant `Codable` support for reading `Day` values from JSON and other sources.
`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 `DayCoable` protocol which is applied to both._
_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

Reads and writes `Day` types as epoch integers. For example the JSON data structure:
Converts [epoch timestamps](https://www.epochconverter.com) to `Day`. For example the JSON data structure:

```json
{
Expand All @@ -67,13 +67,31 @@ Can be read by:

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

## @ISO8601Day
## @CodableAsEpochMilliseconds

Reads and writes `Day` types as [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date strings. For example:
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:

```json
{
Expand All @@ -85,15 +103,15 @@ 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`.

Internally `DayType` uses an `ISO8601DateFormatter` to read and write [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) strings. As there are a variety of ISO8601 formats, this property wrapper allows you to pre-configure the formatter before it is used.
Internally `DayType` uses an `ISO8601DateFormatter` to read and write [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) strings. As there are a variety of ISO8601 formats, this property wrapper allows you to pre-configure the formatter before it processes the string.

For example:

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 Expand Up @@ -157,16 +187,17 @@ Similar to the way `Date` has a matching `DateComponents`, `Day` has a matching

Using a passed `Calendar` and `TimeZone`, this function coverts a `Day` to a Swift `Date` with the `Day`'s year, month and day, and a time of `00:00` (midnight). With no arguments this function uses the current calendar and time zone.


## .formatted(_:) -> String

Wrapping `Date.formatted(date:time:)` this function formats a day using the standard formatting specified by the `Date.FormatStyle.DateStyle` styles. The time component of `Date.formatted(date:time:)` is omitted.

# References and thanks

* Can't thank [Howard Hinnant](http://howardhinnant.github.io) enough. His calculations are what I based this framework on.
* Can't thank [Howard Hinnant](http://howardhinnant.github.io) enough. Using his math instead of Apple's APIs produced a significant speed boost when converting to and from years, months and days.
* Quick thank you to the guys behind the excellent [Nimble test assertion framework](https://github.com/Quick/Nimble).

# Future additions

Obviously there are a large number of useful functions that can be added to this API, many of which could come from various other calculations on [http://howardhinnant.github.io/date_algorithms.html#weekday_from_days](). However I will add these as requested rather than trying to re-implement a large number of possibilities that may not ben needed. So please feel free to request things you need.
Obviously there are a large number of useful functions that can be added to this API, many of which could come from various other calculations on [http://howardhinnant.github.io/date_algorithms.html#weekday_from_days](). However I plan to add these as it becomes clear they will provide a useful addition rather than re-implementing a large number of functions that may not ben needed.

So please feel free to drop a request for thing you'd like added.
37 changes: 26 additions & 11 deletions Sources/Day.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ public typealias DayInterval = Int
/// day is consistent for that timezone.
public struct Day {

private static let daysInEra = 146_097
private static let negativeEraAdjustment = 146_096
private static let unixTimeShift = 719_468

public let daysSince1970: Int

// MARK: - Initialisers
Expand Down Expand Up @@ -52,8 +48,7 @@ public struct Day {
/// Initializer that accepts a known date and calendar.
///
/// The calendar and it's timezone is them used to extract the year, month and day values before passing to the
/// ``init(date:usingCalendar:)`` initializer. Other components such as the time components will be ignoreed, or effectively
/// dropped.
/// ``init(date:usingCalendar:)`` initializer. Time components will be ignored.
///
/// - parameter date: The date to read.
/// - parameter calendar: The calender which will be used to extract the date components.
Expand Down Expand Up @@ -84,14 +79,23 @@ public struct Day {
///
/// Note that this ``Day`` is Epoch based. ie. UTC. Also note that the passed values are rolling. So for example, passing a day of 45 for a month that has 30 days, will produce a date that is the 15th of the next month.
///
/// The math here has been sourced from the very detailed formulas defined here: [http://howardhinnant.github.io/date_algorithms.html]()
/// The math here has been sourced from the very detailed formulas defined at [http://howardhinnant.github.io/date_algorithms.html](http://howardhinnant.github.io/date_algorithms.html)
///
/// - parameter year: The year in the passed timezone.
/// - parameter month: The month in the passed timezone.
/// - parameter day: The day number in the passed timezone.
public init(year: Int, month: Int, day: Int) {

// Note: These calculations made use of the way Ints round fractional parts down.
// Also note that whilst this code produces the same results as using Apple's APIs like this
//
// let calendar = Calendar.current
// let components = DateComponents(calendar: calendar, year: year, month: month, day: day)
// let fromDate = calendar.startOfDay(for: Date(timeIntervalSince1970: 0))
// let toDate = calendar.startOfDay(for: components.date!)
// daysSince1970 = calendar.dateComponents([.day], from: fromDate, to: toDate).day!
//
// it is significantly faster. ie. The unit test for a million iterations takes 2 seconds instead of over 30.

// The nature of calendars is that there is no such thing as year zero. Only year 1 AD or BC.
let year = year == 0 ? 1 : year
Expand All @@ -108,19 +112,30 @@ public struct Day {
// Calculate the day of the era adjusting for leap years and centuries.
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear // Range: 0 -> 146096

daysSince1970 = era * Day.daysInEra + dayOfEra - Day.unixTimeShift
daysSince1970 = era * 146_097 + dayOfEra - 719_468
}

/// Returns the day components.
public func dayComponents() -> DayComponents {

// Note that whilst this code produces the same results as using Apple's APIs like this
//
// let secondsSince1970 = TimeInterval(daysSince1970 * 60 * 60 * 24)
// let date = Date(timeIntervalSince1970: secondsSince1970)
// var calendar = Calendar.current
// calendar.timeZone = TimeZone(secondsFromGMT: 0)!
// let components = calendar.dateComponents([.year, .month, .day], from: date)
// return DayComponents(year: components.year!, month: components.month!, day: components.day!)
//
// it is significantly faster.

// Shift the epoch from 1970-01-01 to 0000-03-01
let daysSinceZero = daysSince1970 + Day.unixTimeShift
let daysSinceZero = daysSince1970 + 719_468

// Recalcate the era allowing for negative dates.
let era = (daysSinceZero >= 0 ? daysSinceZero : daysSinceZero - Day.negativeEraAdjustment) / Day.daysInEra
let era = (daysSinceZero >= 0 ? daysSinceZero : daysSinceZero - 146_096) / 146_097

let dayOfEra = daysSinceZero - era * Day.daysInEra // Range: 0-> 146096
let dayOfEra = daysSinceZero - era * 146_097 // Range: 0-> 146096

// This accounts for the variations in numbers of days during the early parts of an era. See doco for details.
let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146_096) / 365 // Range: 0 -> 399
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: DayCodable, 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: DayCodable {
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: DayCodable {
}

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: DayCodable {
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: DayCodable {
}

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)
}
}
52 changes: 52 additions & 0 deletions Sources/Property wrappers/Day+EpochCodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// DayCodable.swift
//
//
// Created by Derek Clarkson on 9/1/2024.
//

import Foundation

/// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``.
///
/// 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, factor: Double) throws
func encode(epochEncoder encoder: Encoder, factor: Double) throws
}

extension Day: EpochCodable {

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 * factor))
}

public func encode(epochEncoder encoder: Encoder, factor: Double) throws {
var container = encoder.singleValueContainer()
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, factor: Double) throws {
let container = try decoder.singleValueContainer()
self = container.decodeNil() ? nil : try Day(epochDecoder: decoder, factor: factor)
}

public func encode(epochEncoder encoder: Encoder, factor: Double) throws {
if let self {
try self.encode(epochEncoder: encoder, factor: factor)
} else {
var container = encoder.singleValueContainer()
try container.encodeNil()
}
}
}
Loading

0 comments on commit 7067c5b

Please sign in to comment.