Skip to content

Commit 117041b

Browse files
parkeraitingliu
andauthored
ISO8601ComponentStyle proposal (#1211)
* Add ISO8601ComponentStyle proposal * Update Proposals/NNNN-ISO8601ComponentsStyle.md * Update Proposals/NNNN-ISO8601ComponentsStyle.md * Rename NNNN-ISO8601ComponentsStyle.md to 0021-ISO8601ComponentsStyle.md * Update Proposals/0021-ISO8601ComponentsStyle.md * Update Proposals/0021-ISO8601ComponentsStyle.md --------- Co-authored-by: Tina L <[email protected]>
1 parent b951998 commit 117041b

File tree

1 file changed

+212
-0
lines changed

1 file changed

+212
-0
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# ISO8601 Components Formatting and Parsing
2+
3+
* Proposal: SF-0021
4+
* Author(s): Tony Parker <[email protected]>
5+
* Status: **Review: March 17, 2025...March 24, 2025**
6+
* Intended Release: _Swift 6.2_
7+
* Review: ([pitch](https://forums.swift.org/t/pitch-iso8601-components-format-style/77990))
8+
*_Related issues_*
9+
10+
* https://github.com/swiftlang/swift-foundation/issues/323
11+
* https://github.com/swiftlang/swift-foundation/issues/967
12+
* https://github.com/swiftlang/swift-foundation/issues/1159
13+
14+
## Revision history
15+
16+
* **v1** Initial version
17+
18+
## Introduction
19+
20+
Based upon feedback from adoption of `ISO8601FormatStyle`, we propose two changes and one addition to the API:
21+
22+
- Change the behavior of the `includingFractionalSeconds` flag with respect to parsing. `ISO8601FormatStyle` will now always allow fractional seconds regardless of the setting.
23+
- Change the behavior of the time zone flag with respect to parsing. `ISO8601FormatStyle` will now always allow hours-only time zone offsets.
24+
- Add a _components_ style, which formats `DateComponents` into ISO8601 and parses ISO8601-formatted `String`s into `DateComponents`.
25+
26+
## Motivation
27+
28+
The existing `Date.ISO8601FormatStyle` type has one property for controlling fractional seconds, and it is also settable in the initializer. The existing behavior is that parsing _requires_ presence of fractional seconds if set, and _requires_ absence of fractional seconds if not set.
29+
30+
If the caller does not know if the string contains the fractional seconds or not, they are forced to parse the string twice:
31+
32+
```swift
33+
let str = "2022-01-28T15:35:46Z"
34+
var result = try? Date.ISO8601FormatStyle(includingFractionalSeconds: false).parse(str)
35+
if result == nil {
36+
result = try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(str)
37+
}
38+
```
39+
40+
In most cases, the caller simply does not care if the fractional seconds are present or not. Therefore, we propose changing the behavior of the parser to **always** allow fractional seconds, regardless of the setting of the `includeFractionalSeconds` flag. The flag is still used for formatting.
41+
42+
With respect to time zone offsets, the parser has always allowed the optional presence of seconds, as well the optional presence of `:`. We propose extending this behavior to allow optional minutes as well. The following are considered well-formed by the parser:
43+
44+
```
45+
2022-01-28T15:35:46 +08
46+
2022-01-28T15:35:46 +0800
47+
2022-01-28T15:35:46 +080000
48+
2022-01-28T15:35:46 +08
49+
2022-01-28T15:35:46 +08:00
50+
2022-01-28T15:35:46 +08:00:00
51+
```
52+
53+
In order to provide an alternative for cases where strict parsing is required, a new parser is provided that returns the _components_ of the parsed date instead of the resolved `Date` itself. This new parser also provides a mechanism to retrieve the time zone from an ISO8601-formatted string. Following parsing of the components, the caller can resolve them into a `Date` using the regular `Calendar` and `DateComponents` API.
54+
55+
## Proposed solution and example
56+
57+
In addition to the behavior change above, we propose introducing a new `DateComponents.ISO8601FormatStyle`. The API surface is nearly identical to `Date.ISO8601FormatStyle`, with the exception of the output type. It reuses the same inner types, and they share a common implementation. The full API surface is in the detailed design, below.
58+
59+
Formatting ISO8601 components is just as straightforward as formatting a `Date`.
60+
61+
```swift
62+
let components = DateComponents(year: 1999, month: 12, day: 31, hour: 23, minute: 59, second: 59)
63+
let formatted = components.formatted(.iso8601Components)
64+
print(formatted) // 1999-12-31T23:59:59Z
65+
```
66+
67+
Parsing ISO8601 components follows the same pattern as other parse strategies:
68+
69+
```swift
70+
let components = try DateComponents.ISO8601FormatStyle().parse("2022-01-28T15:35:46Z")
71+
// components are: DateComponents(timeZone: .gmt, year: 2022, month: 1, day: 28, hour: 15, minute: 35, second: 46))
72+
```
73+
74+
If further conversion to a `Date` is required, the existing `Calendar` API can be used:
75+
76+
```swift
77+
let date = components.date // optional result, date may be invalid
78+
```
79+
80+
## Detailed design
81+
82+
The full API surface of the new style is:
83+
84+
```swift
85+
@available(FoundationPreview 6.2, *)
86+
extension DateComponents {
87+
/// Options for generating and parsing string representations of dates following the ISO 8601 standard.
88+
public struct ISO8601FormatStyle : Sendable, Codable, Hashable {
89+
public var timeSeparator: Date.ISO8601FormatStyle.TimeSeparator { get }
90+
/// If set, fractional seconds will be present in formatted output. Fractional seconds may be present in parsing regardless of the setting of this property.
91+
public var includingFractionalSeconds: Bool { get }
92+
public var timeZoneSeparator: Date.ISO8601FormatStyle.TimeZoneSeparator { get }
93+
public var dateSeparator: Date.ISO8601FormatStyle.DateSeparator { get }
94+
public var dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator { get }
95+
96+
public init(from decoder: any Decoder) throws
97+
public func encode(to encoder: any Encoder) throws
98+
99+
public func hash(into hasher: inout Hasher)
100+
101+
public static func ==(lhs: ISO8601FormatStyle, rhs: ISO8601FormatStyle) -> Bool
102+
public var timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!
103+
104+
// The default is the format of RFC 3339 with no fractional seconds: "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"
105+
public init(dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash, dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator = .standard, timeSeparator: Date.ISO8601FormatStyle.TimeSeparator = .colon, timeZoneSeparator: Date.ISO8601FormatStyle.TimeZoneSeparator = .omitted, includingFractionalSeconds: Bool = false, timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!)
106+
}
107+
}
108+
109+
@available(FoundationPreview 6.2, *)
110+
extension DateComponents.ISO8601FormatStyle {
111+
public func year() -> Self
112+
public func weekOfYear() -> Self
113+
public func month() -> Self
114+
public func day() -> Self
115+
public func time(includingFractionalSeconds: Bool) -> Self
116+
public func timeZone(separator: Date.ISO8601FormatStyle.TimeZoneSeparator) -> Self
117+
public func dateSeparator(_ separator: Date.ISO8601FormatStyle.DateSeparator) -> Self
118+
public func dateTimeSeparator(_ separator: Date.ISO8601FormatStyle.DateTimeSeparator) -> Self
119+
public func timeSeparator(_ separator: Date.ISO8601FormatStyle.TimeSeparator) -> Self
120+
public func timeZoneSeparator(_ separator: Date.ISO8601FormatStyle.TimeZoneSeparator) -> Self
121+
}
122+
123+
@available(FoundationPreview 6.2, *)
124+
extension DateComponents.ISO8601FormatStyle : FormatStyle {
125+
public func format(_ value: DateComponents) -> String
126+
}
127+
128+
@available(FoundationPreview 6.2, *)
129+
public extension FormatStyle where Self == DateComponents.ISO8601FormatStyle {
130+
static var iso8601Components: Self
131+
}
132+
133+
@available(FoundationPreview 6.2, *)
134+
public extension ParseableFormatStyle where Self == DateComponents.ISO8601FormatStyle {
135+
static var iso8601Components: Self
136+
}
137+
138+
@available(FoundationPreview 6.2, *)
139+
public extension ParseStrategy where Self == DateComponents.ISO8601FormatStyle {
140+
@_disfavoredOverload
141+
static var iso8601Components: Self
142+
}
143+
144+
@available(FoundationPreview 6.2, *)
145+
extension DateComponents.ISO8601FormatStyle : ParseStrategy {
146+
public func parse(_ value: String) throws -> DateComponents
147+
}
148+
149+
@available(FoundationPreview 6.2, *)
150+
extension DateComponents.ISO8601FormatStyle: ParseableFormatStyle {
151+
public var parseStrategy: Self
152+
}
153+
154+
@available(FoundationPreview 6.2, *)
155+
extension DateComponents.ISO8601FormatStyle : CustomConsumingRegexComponent {
156+
public typealias RegexOutput = DateComponents
157+
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: DateComponents)?
158+
}
159+
160+
@available(FoundationPreview 6.2, *)
161+
extension RegexComponent where Self == DateComponents.ISO8601FormatStyle {
162+
@_disfavoredOverload
163+
public static var iso8601Components: DateComponents.ISO8601FormatStyle
164+
165+
public static func iso8601ComponentsWithTimeZone(includingFractionalSeconds: Bool = false, dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash, dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator = .standard, timeSeparator: Date.ISO8601FormatStyle.TimeSeparator = .colon, timeZoneSeparator: Date.ISO8601FormatStyle.TimeZoneSeparator = .omitted) -> Self
166+
167+
public static func iso8601Components(timeZone: TimeZone, includingFractionalSeconds: Bool = false, dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash, dateTimeSeparator: Date.ISO8601FormatStyle.DateTimeSeparator = .standard, timeSeparator: Date.ISO8601FormatStyle.TimeSeparator = .colon) -> Self
168+
169+
public static func iso8601Components(timeZone: TimeZone, dateSeparator: Date.ISO8601FormatStyle.DateSeparator = .dash) -> Self
170+
}
171+
```
172+
173+
Unlike the `Date` format style, formatting with a `DateComponents` style can have a mismatch between the specified output fields and the contents of the `DateComponents` struct. In the case where the input `DateComponents` is missing required values, then the formatter will fill in default values to ensure correct output.
174+
175+
```swift
176+
let components = DateComponents(year: 1999, month: 12, day: 31)
177+
let formatted = components.formatted(.iso8601Components) // 1999-12-31T00:00:00Z
178+
```
179+
180+
## Impact on existing code
181+
182+
The change to always allow fractional seconds will affect existing code. As described above, we believe the improvement in the API surface is worth the risk of introducing unexpected behavior for the rare case that a parser truly needs to specify the exact presence or absence of frational seconds.
183+
184+
If code depending on this new behavior must be backdeployed before Swift 6.2, then Swift's `if #available` checks may be used to parse twice on older releases of the OS or Swift.
185+
186+
## Alternatives considered
187+
188+
### "Allowing" Option
189+
190+
We considered adding a new flag to `Date.ISO8601FormatStyle` to control the optional parsing of fractional seconds. However, the truth table quickly became confusing:
191+
192+
#### Formatting
193+
194+
| `includingFractionalSeconds` | `allowingFractionalSeconds` | Fractional Seconds |
195+
| -- | -- | -- |
196+
| `true` | `true` | Included |
197+
| `true` | `false` | Included |
198+
| `false` | `true` | Excluded |
199+
| `false` | `true` | Excluded |
200+
201+
#### Parsing
202+
203+
| `includingFractionalSeconds` | `allowingFractionalSeconds` | Fractional Seconds |
204+
| -- | -- | -- |
205+
| `true` | `true` | Required Present |
206+
| `true` | `false` | Required Present |
207+
| `false` | `true` | ? |
208+
| `false` | `true` | Allow Present or Missing |
209+
210+
In addition, all the initializers needed to be duplicated to add the new option.
211+
212+
In practice, the additional complexity did not seem worth the tradeoff with the potential for a compatibility issue with the existing style. This does require callers to be aware that the behavior has changed in the release in which this feature ships. Therefore, we will be clear in the documentation about the change.

0 commit comments

Comments
 (0)