vapor / mysql-nio

🐬 Non-blocking, event-driven Swift client for MySQL.
MIT License
90 stars 27 forks source link

Replace tm() by DateComponents #97

Closed RafaelPlantard closed 1 year ago

RafaelPlantard commented 1 year ago

The TIME implementation was always getting nil, because time doesn't have year, month, day.

gwynne commented 1 year ago

Unfortunately, the dates resulting from this code don't make a great deal of sense. For example:

  1> import Foundation
  2> DateComponents(calendar: .init(identifier: .gregorian), timeZone: .init(secondsFromGMT: 0)!, hour: 1, minute: 0, second: 0).date
$R0: Foundation.Date? = 0000-12-30 01:00:00 UTC

The Date type is not intended to be able to represent a time independently of a calendar date. Nor is it intended by MySQLNIO that the MySQLData.date property be used for TIME columns; the slightly lower-level MySQLData.time property is provided for that purpose (and other related uses).

However, even with all of that aside, it's not a change I can approve regardless - it changes the behavior of an existing public API, one whose behavior is (no matter how undesirably) relied upon in a variety of ways by a lot of somewhat... tricky... code up in Fluent.

P.S.: Please don't take this as a discouragement of your effort; contributions are welcome and appreciated, even when we have to reject them!

RafaelPlantard commented 1 year ago

I understand, could you please help me to get a TIME value from DB in Fluent? Because when I try to do something like that:

@Field(key: "column_in_db")
var time: Date?

it's not working and after debugging quite a time I just discovered that because of this guard let:

guard
            let year = self.year,
            let month = self.month,
            let day = self.day
        else {
            return nil
        }

a TIME when represented as a Date object is always nil, could you give a hint to solve this?

0xTim commented 1 year ago

cc @gwynne

gwynne commented 1 year ago

@RafaelPlantard To use such a column, you'd need to do something like this:

import Foundation
import FluentKit
import MySQLNIO

// Unfortunately, `TimestampFormat` requires its values to be `Codable`, even though the
// conformance won't be used, and `MySQLTime` does not include a `Codable` conformance,
// so we must provide it here:
extension MySQLNIO.MySQLTime: Codable {
    private enum CodingKeys: String, CodingKey { case year,month,day,hour,minute,second,microsecond }
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Self.CodingKeys.self)
        self.init(
            year: try container.decodeIfPresent(UInt16.self, forKey: .year),
            month: try container.decodeIfPresent(UInt16.self, forKey: .month),
            day: try container.decodeIfPresent(UInt16.self, forKey: .day),
            hour: try container.decodeIfPresent(UInt16.self, forKey: .hour),
            minute: try container.decodeIfPresent(UInt16.self, forKey: .minute),
            second: try container.decodeIfPresent(UInt16.self, forKey: .second),
            microsecond: try container.decodeIfPresent(UInt32.self, forKey: .microsecond)
        )
    }
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: Self.CodingKeys.self)
        try container.encodeIfPresent(self.year, forKey: .year)
        try container.encodeIfPresent(self.month, forKey: .month)
        try container.encodeIfPresent(self.day, forKey: .day)
        try container.encodeIfPresent(self.hour, forKey: .hour)
        try container.encodeIfPresent(self.minute, forKey: .minute)
        try container.encodeIfPresent(self.second, forKey: .second)
        try container.encodeIfPresent(self.microsecond, forKey: .microsecond)
    }
}

/// A `TimestampFormat` which translates between a `MySQLTime` and a `Date` using a
/// "forgiving" algorithm which does not refuse to parse if the "date" components are missing.
/// Can be used with columns of type `TIMESTAMP`, `DATETIME`, `DATE`, and `TIME`.
///
/// `MySQLTime` is the type used to represent the various MySQL date/time-related column
/// types in parameter bindings and result rows. Normally, a time structure is converted to a
/// Foundation `Date` via the C API `timegm(3)`. However, `timegm(3)` will refuse to return a
/// result if the `year`, `month`, and `day` components are not provided. This format uses
/// Foundation's `DateComponents` type to do the conversion instead; `DateComponents` is 3-5x
/// slower than `timegm(3)`, but it will try to come up with _some_ kind of result for almost
/// any combination (or lack) of components. Be aware that the result may not always make
/// much sense; for example, a "pure time" value such as "01:00:00" results in the date
/// value `"0000-12-30 01:00:00 UTC"`.
///
/// > Note: This does not work for `YEAR` columns; use a `UInt16` field instead.
public struct ForgivingMySQLTimestampFormat: FluentKit.TimestampFormat {
    public typealias Value = MySQLNIO.MySQLTime

    private let calendar = Calendar(identifier: .gregorian)
    private let timeZone = TimeZone(identifier: "UTC")!

    public func parse(_ value: MySQLNIO.MySQLTime) -> Date? {
        var components = DateComponents(
              calendar: self.calendar,
              timeZone: self.timeZone,
                  year: value.year.map(Int.init(_:)),
                 month: value.month.map(Int.init(_:)),
                   day: value.day.map(Int.init(_:)),
                  hour: value.hour.map(Int.init(_:)),
                minute: value.minute.map(Int.init(_:)),
                second: value.second.map(Int.init(_:)),
            nanosecond: value.microsecond.map { Int($0) * 1000 }
        )
        return components.date
    }

    public func serialize(_ date: Date) -> MySQLNIO.MySQLTime? {
        .init(date: date) // no need to be fancy when going in the other direction
    }
}

extension FluentKit.TimestampFormatFactory {
    public static var forgivingMySQL: FluentKit.TimestampFormatFactory<ForgivingMySQLTimestampFormat> {
        .init { ForgivingMySQLTimestampFormat() }
    }
}

You can then write your model's property this way:

    @Timestamp(key: "column_in_db", on: .none, format: .forgivingMySQL)
    var time: Date?

I know this is a ridiculous amount of work to do something that seems so simple; I wish I had something better to suggest.

RafaelPlantard commented 1 year ago

Thank you so much. I'll try this!