swift-foundation icon indicating copy to clipboard operation
swift-foundation copied to clipboard

ISO8601FormatStyle produces unexpected results when including fractional seconds

Open arthurcro opened this issue 1 year ago • 12 comments

While working on https://github.com/apple/swift-openapi-generator/issues/637, I noticed ISO8601FormatStyle formattes dates with fractional seconds incorreclty. The fractional seconds field is off by one. Also, when parsing a formatted date with fractional seconds, the result is not equal to the initial formatted date.

You can reproduce the issue with the following code snippet.

import Foundation

let initialDate = Date(timeIntervalSince1970: 1_674_036_251.123) 
let formatStyle = Date.ISO8601FormatStyle.iso8601
    .year()
    .month()
    .day()
    .time(includingFractionalSeconds: true)

let encoded = initialDate.formatted(formatStyle)
print(encoded) // prints 2023-01-18T10:04:11.122 instead of 2023-01-18T10:04:11.123
print(initialDate == (try! formatStyle.parse(encoded)) // prints false

arthurcro avatar Oct 06 '24 10:10 arthurcro

Hi, could not replicate, copy/pasting your snippet... on what versions are you? does the issue still exist?

$ swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

$ uname -a
Darwin JPXNGP6J95 23.6.0 Darwin Kernel Version 23.6.0: Wed Jul 31 20:48:52 PDT 2024; root:xnu-10063.141.1.700.5~1/RELEASE_ARM64_T6020 arm64

LordBurtz avatar Oct 08 '24 09:10 LordBurtz

Hey! Thank you for checking! The issue still exists, I'm on the following:

$ swift --version
swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx15.0

$ uname -a
Darwin arthurs-mbp.home 24.0.0 Darwin Kernel Version 24.0.0: Mon Aug 12 20:51:54 PDT 2024; root:xnu-11215.1.10~2/RELEASE_ARM64_T6000 arm64

arthurcro avatar Oct 08 '24 10:10 arthurcro

I had the same issue in my own project (not swift-openapi) when I updated my own iPhone, I first opened a thread on the Swift forums: https://forums.swift.org/t/rounding-error-in-milliseconds-using-iso8601formatstyle-instead-of-iso8601dateformatter/75206

I also opened this case on the feedback assistant: FB15418195 (Rounding error in milliseconds using ISO8601FormatStyle on iOS 18.0)

import Testing
import UIKit

struct DateFormatterTests {
    @Test func testDateEncodingDecoding() async throws {
        let originalIsoString = "2024-10-05T17:08:34.650Z"
        
        let iso8601DateFormatter = ISO8601DateFormatter()
        iso8601DateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        
        let testDate = iso8601DateFormatter.date(from: originalIsoString)!
        let oldFormatterString = iso8601DateFormatter.string(from: testDate)
        
        #expect(oldFormatterString == originalIsoString)
        
        let isoDateFormat: Date.ISO8601FormatStyle = .iso8601
            .year()
            .month()
            .day()
            .timeZone(separator: .omitted)
            .time(includingFractionalSeconds: true)
            .timeSeparator(.colon)
        
        let date = try isoDateFormat.parse(originalIsoString)
        
        let newFormatterString = isoDateFormat.format(date)
        
        #expect(newFormatterString == originalIsoString)
    }
}

It happens when using iOS 18. I have no idea how the implementation of this works, is it baked inside iOS? Could it be due to this change perhaps: https://github.com/swiftlang/swift-foundation/pull/453/commits/337a22e08704c1024aa8d32c4608e83fedd41174 ?

jvdvleuten avatar Oct 11 '24 06:10 jvdvleuten

seems to be a problem of 6.0 vs 5.10 which matches the date of the change.. ill have a look over the weekend

LordBurtz avatar Oct 11 '24 11:10 LordBurtz

@LordBurtz isn't this baked in the iOS version? How could I test this myself?

jvdvleuten avatar Oct 14 '24 10:10 jvdvleuten

@parkera is this something you could help with when you get a chance? 🙇🏻

arthurcro avatar Oct 16 '24 12:10 arthurcro

@stephentyrone was looking into this

parkera avatar Oct 18 '24 15:10 parkera

We encountered the exact same issue. In some cases it's converted correctly, in others it's off by one.

Any indication when this will be fixed? It causes several issues in operations that require accuracy, especially when the value needs to be serialized and sent to a backend.

theoks avatar Dec 03 '24 22:12 theoks

After the last comment I thought stephentyrone was taking care of this but i'll investigate it this weekend probably

LordBurtz avatar Dec 03 '24 23:12 LordBurtz

In other parts of swift, we have control over both the precision and the rounding. Perhaps the default should be standard rounding, but then also allow for control over the rounding with options. My use case is simple though. If I had milliseconds of .127 in the date, and asked for .secondFraction(.fractional(2)) in the formatting so that times are only shown to the hundredths of a second, I'd be satisfied with simple rounding to .13 in the output string. As far as I know I don't need all of the other rounding rules.

let formatter = ISOISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let timestamp = formatter.date(from: "2025-01-21T12:34:45.127Z") let dateTimeFormat = Date.FormatStyle() .hour() .minute() .second() .secondFraction(.fractional(2)) let timestampString = timestamp.formatted(dateTimeFormat) // wrong 7:34:45.12, when it should be 7:34:45.13

breynolds avatar Jan 05 '25 09:01 breynolds

@stephentyrone and @parkera can speak more to this, but my understanding is that it is inherently an issue with how Date is stored and what Foundation promises to do with Date -- We use DateComponents throughout the API and implementation, which promises to support up to nanoseconds, but Date is stored as a Double (a diff to timeIntervalSinceReferenceDate), and it is simply impossible to store all values to the nanosecond accuracy.

ISO8601 only guarantees up to milliseconds though, so it is unfortunate.

itingliu avatar Jan 06 '25 18:01 itingliu

The behavior was introduced in an attempt to avoid something like this:

Time: 12:59:59.9999 Format: 3 digits of ms, rounding nearest Result: 12:59:59.000

Now, we could say that it should round the next field up instead. But of course that would be 12:60, which is nonsense, so actually it's 13:00. But doing this requires consulting with the time zone again to see if that's a valid time (e.g., on a boundary). So I think we may need to plumb some kind of rounding rule into the calculation of the date components themselves at the beginning of the formatting.

parkera avatar Jan 31 '25 21:01 parkera