Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Opening the date picker on an empty date or timestamp cell now starts at your own clock and writes your own time. It used to read the picked instant as UTC, so the value written was your clock shifted by your time zone offset, and for the hours after local midnight a date column got the previous day. (#2241)
- A time value that carries a UTC offset, such as a PostgreSQL `time with time zone` column, now follows your chosen date format instead of showing as raw text. The grid and the cell editor disagreed about which spellings counted as a date, so the same value was editable as a date but never formatted as one. (#2241)
- **Open in New Tab** opens a second tab for a table that is already open. It reselected the existing tab and said nothing, so there was no way to browse one table under two different filters. (#2235)
- Following a foreign key into a new tab no longer overwrites the filters on a tab already showing that table. The jump reused the open tab, replaced its filters with the foreign key's, and left the grid on the rows it had before, so the filters were gone and the rows did not match what the panel said.
- Opening a table from the sidebar while the object list is still loading opens it. The click was dropped with no tab, no error, and no retry when the load finished, which was most visible right after relaunching with tabs restored.
Expand Down
180 changes: 133 additions & 47 deletions TablePro/Core/Services/Formatting/DatabaseDateParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,147 @@
// DatabaseDateParser.swift
// TablePro
//
// The date spellings TablePro's drivers put on the wire, in one place. Cells arrive as text, so
// both the grid's display formatting and the chart's temporal axis have to recover a Date from
// the same strings.
// The one grammar for the date spellings TablePro's drivers put on the wire. Cells arrive as text,
// so the grid's display formatting, the chart's temporal axis and the cell editor all recover a
// value from the same strings. A second grammar beside this one drifts from it.
//

import Foundation

/// Not thread safe: `DateFormatter` is not `Sendable`, so each consumer owns an instance inside its
/// own isolation domain rather than sharing one.
final class DatabaseDateParser {
/// Tried in order until one succeeds. Formats without a zone marker are read in the user's
/// time zone, because a database value like `2024-03-01 12:00:00` is naive and must display
/// as written. Formats with a zone marker carry their own offset.
/// The shape of the text a value was written in, kept so an edit can be written back the same way
/// instead of normalised into one house style.
struct TemporalLayout: Equatable {
let hasDate: Bool
let hasTime: Bool
let dateTimeSeparator: String
let fractionalSeconds: String?
let timeZoneSuffix: String?
}

/// The instant, the zone it was written in, and the spelling it arrived as.
struct ParsedTemporalValue: Equatable {
let date: Date
let timeZone: TimeZone
let layout: TemporalLayout
}

enum DatabaseDateParser {
/// One expression rather than a list of `DateFormatter` patterns: it accepts every spelling
/// MySQL, PostgreSQL, SQLite and SQL Server produce, and it reports the structure of the match,
/// which a formatter cannot. `NSRegularExpression` is immutable and safe to match from any
/// thread, so this needs no per-consumer instance.
///
/// Coverage is measured, not assumed: `Z` accepts `Z`, `+0700` and `+07:00` when parsing, and
/// `SSSSSS` accepts any number of fractional digits, so nine patterns cover every spelling
/// MySQL, PostgreSQL, SQLite and SQL Server produce.
private static let formats: [(pattern: String, hasTimeZone: Bool)] = [
("yyyy-MM-dd HH:mm:ss", false),
("yyyy-MM-dd'T'HH:mm:ss", false),
("yyyy-MM-dd'T'HH:mm:ssZ", true),
("yyyy-MM-dd'T'HH:mm:ss.SSSZ", true),
("yyyy-MM-dd", false),
("HH:mm:ss", false),
("yyyy-MM-dd HH:mm:ssXXXXX", true),
("yyyy-MM-dd HH:mm:ss.SSSSSSXXXXX", true),
("yyyy-MM-dd HH:mm:ss.SSSSSS", false),
]

private let parsers: [DateFormatter]

/// Consecutive cells in one column share a wire format, so the last winner is tried first.
private var lastSuccessfulIndex = 0

init() {
parsers = Self.formats.map { format in
let parser = DateFormatter()
parser.dateFormat = format.pattern
parser.locale = Locale(identifier: "en_US_POSIX")
parser.calendar = Calendar(identifier: .gregorian)
parser.timeZone = format.hasTimeZone ? TimeZone(secondsFromGMT: 0) : TimeZone.current
return parser
}
}
/// Month, day and hour take one or two digits because `DateFormatter` accepted an unpadded
/// `2024-9-1` and dropping that would stop such a value rendering as a date at all.
private static let pattern =
#"^(?:(\d{4})-(\d{1,2})-(\d{1,2}))?(?:([ T])?(\d{1,2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"#

private static let matcher = try? NSRegularExpression(pattern: pattern)

func date(from text: String) -> Date? {
if let date = parsers[lastSuccessfulIndex].date(from: text) {
return date
private static let referenceDateComponents = (year: 2_000, month: 1, day: 1)

/// A value carrying no offset is naive: `2024-03-01 12:00:00` names a wall clock, not an
/// instant. It resolves in the reader's own zone so the grid, the chart's axis and the picker
/// all show it as written, and the zone travels with the value so a write-back reproduces the
/// same text.
private static var naiveTimeZone: TimeZone { .current }

static func parse(_ rawValue: String?) -> ParsedTemporalValue? {
guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else {
return nil
}
for index in parsers.indices where index != lastSuccessfulIndex {
if let date = parsers[index].date(from: text) {
lastSuccessfulIndex = index
return date
let range = NSRange(raw.startIndex..., in: raw)
guard let match = matcher.firstMatch(in: raw, range: range) else { return nil }

func group(_ index: Int) -> String? {
let groupRange = match.range(at: index)
guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else {
return nil
}
return String(raw[swiftRange])
}

let year = group(1).flatMap(Int.init)
let month = group(2).flatMap(Int.init)
let day = group(3).flatMap(Int.init)
let hour = group(5).flatMap(Int.init)
let minute = group(6).flatMap(Int.init)
let second = group(7).flatMap(Int.init)

let hasDate = year != nil && month != nil && day != nil
let hasTime = hour != nil && minute != nil && second != nil
guard hasDate || hasTime else { return nil }

let timeZoneSuffix = group(9)
let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? naiveTimeZone
let fractionalSeconds = group(8)

var components = DateComponents()
components.year = hasDate ? year : referenceDateComponents.year
components.month = hasDate ? month : referenceDateComponents.month
components.day = hasDate ? day : referenceDateComponents.day
components.hour = hasTime ? hour : 0
components.minute = hasTime ? minute : 0
components.second = hasTime ? second : 0
components.nanosecond = nanoseconds(from: fractionalSeconds)

guard isInRange(components) else { return nil }

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
guard let date = calendar.date(from: components), keepsItsDay(components, in: calendar, at: date) else {
return nil
}
return nil

let separator = group(4) ?? (hasDate && hasTime ? " " : "")
let layout = TemporalLayout(
hasDate: hasDate,
hasTime: hasTime,
dateTimeSeparator: separator,
fractionalSeconds: fractionalSeconds,
timeZoneSuffix: timeZoneSuffix
)
return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout)
}

static func date(from text: String) -> Date? {
parse(text)?.date
}

/// `Calendar` rolls an out-of-range component over instead of refusing it, which would turn
/// MySQL's `0000-00-00 00:00:00` into a plausible `0002-11-30`. `DateFormatter` refused those,
/// and a value that is not a date has to keep rendering as the text it is.
private static func isInRange(_ components: DateComponents) -> Bool {
guard let month = components.month, let day = components.day,
let hour = components.hour, let minute = components.minute, let second = components.second
else { return false }
return (1 ... 12).contains(month) && (1 ... 31).contains(day)
&& (0 ... 23).contains(hour) && (0 ... 59).contains(minute) && (0 ... 59).contains(second)
}

/// Catches the day a range check cannot, such as `2024-02-30`, which `Calendar` moves into
/// March. Only the date is compared: a naive wall clock inside a spring-forward gap is legally
/// shifted by an hour, and that value is still the day it says it is.
private static func keepsItsDay(_ components: DateComponents, in calendar: Calendar, at date: Date) -> Bool {
let rebuilt = calendar.dateComponents([.year, .month, .day], from: date)
return rebuilt.year == components.year && rebuilt.month == components.month
&& rebuilt.day == components.day
}

/// Sub-second precision reaches the `Date` so a chart can separate points inside one second.
/// The original text is kept in the layout as well, because rebuilding it from a `Double` would
/// lose digits a database round-trip has to preserve.
private static func nanoseconds(from fractionalSeconds: String?) -> Int {
guard let fractionalSeconds, let fraction = Double(fractionalSeconds) else { return 0 }
return Int((fraction * 1_000_000_000).rounded())
}

static func timeZone(fromSuffix suffix: String) -> TimeZone {
if suffix == "Z" { return .gmt }
let sign = suffix.hasPrefix("-") ? -1 : 1
let digits = suffix.dropFirst().filter(\.isNumber)
let hours = Int(digits.prefix(2)) ?? 0
let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0
return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt
}
}
98 changes: 11 additions & 87 deletions TablePro/Core/Services/Formatting/DateEditingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,93 +2,20 @@
// DateEditingService.swift
// TablePro
//
// Parses a database date/time string for editing and writes the edited value
// back in the same shape. Distinct from DateFormattingService, which formats
// for display using the user's locale and format preference.
// Writes an edited date/time value back in the shape it arrived in. The grammar that recognises
// that shape belongs to DatabaseDateParser; this is only the write side. Distinct from
// DateFormattingService, which formats for display using the user's locale and format preference.
//

import Foundation

struct TemporalLayout: Equatable {
let hasDate: Bool
let hasTime: Bool
let dateTimeSeparator: String
let fractionalSeconds: String?
let timeZoneSuffix: String?
}

struct ParsedTemporalValue: Equatable {
let date: Date
let timeZone: TimeZone
let layout: TemporalLayout
}

enum TemporalComponents: Equatable {
case dateOnly
case timeOnly
case dateAndTime
}

enum DateEditingService {
private static let pattern =
#"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"#

private static let matcher = try? NSRegularExpression(pattern: pattern)

private static let referenceDateComponents = (year: 2_000, month: 1, day: 1)

static func parse(_ rawValue: String?) -> ParsedTemporalValue? {
guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else {
return nil
}
let range = NSRange(raw.startIndex..., in: raw)
guard let match = matcher.firstMatch(in: raw, range: range) else { return nil }

func group(_ index: Int) -> String? {
let groupRange = match.range(at: index)
guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else {
return nil
}
return String(raw[swiftRange])
}

let year = group(1).flatMap(Int.init)
let month = group(2).flatMap(Int.init)
let day = group(3).flatMap(Int.init)
let hour = group(5).flatMap(Int.init)
let minute = group(6).flatMap(Int.init)
let second = group(7).flatMap(Int.init)

let hasDate = year != nil && month != nil && day != nil
let hasTime = hour != nil && minute != nil && second != nil
guard hasDate || hasTime else { return nil }

let timeZoneSuffix = group(9)
let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? .gmt

var components = DateComponents()
components.year = hasDate ? year : referenceDateComponents.year
components.month = hasDate ? month : referenceDateComponents.month
components.day = hasDate ? day : referenceDateComponents.day
components.hour = hasTime ? hour : 0
components.minute = hasTime ? minute : 0
components.second = hasTime ? second : 0

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
guard let date = calendar.date(from: components) else { return nil }

let separator = group(4) ?? (hasDate && hasTime ? " " : "")
let layout = TemporalLayout(
hasDate: hasDate,
hasTime: hasTime,
dateTimeSeparator: separator,
fractionalSeconds: group(8),
timeZoneSuffix: timeZoneSuffix
)
return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout)
}

static func string(from date: Date, like parsed: ParsedTemporalValue) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = parsed.timeZone
Expand All @@ -112,9 +39,12 @@ enum DateEditingService {
return result
}

static func defaultString(from date: Date, columnType: ColumnType) -> String {
/// An empty cell has no spelling to imitate, so the value is written in the user's own zone.
/// Reading the picked instant in GMT instead wrote the UTC wall clock, which is the user's
/// clock shifted by their offset and, for the hours after local midnight, the previous day.
static func defaultString(from date: Date, columnType: ColumnType, timeZone: TimeZone = defaultTimeZone) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
calendar.timeZone = timeZone
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)

if case .date = columnType {
Expand All @@ -126,6 +56,9 @@ enum DateEditingService {
return dateString(from: components) + " " + timeString(from: components)
}

/// The zone the picker opens in when the cell holds no value to read one from.
static var defaultTimeZone: TimeZone { .current }

static func components(for columnType: ColumnType) -> TemporalComponents {
if case .date = columnType { return .dateOnly }
if columnType.isTimeOnly { return .timeOnly }
Expand All @@ -139,13 +72,4 @@ enum DateEditingService {
private static func timeString(from components: DateComponents) -> String {
String(format: "%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0)
}

private static func timeZone(fromSuffix suffix: String) -> TimeZone {
if suffix == "Z" { return .gmt }
let sign = suffix.hasPrefix("-") ? -1 : 1
let digits = suffix.dropFirst().filter(\.isNumber)
let hours = Int(digits.prefix(2)) ?? 0
let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0
return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ final class DateFormattingService {
/// Current date format option
private(set) var currentFormat: DateFormatOption

private let parser = DatabaseDateParser()

/// Cache for formatted date strings to avoid repeated parsing
private let formatCache = NSCache<NSString, NSString>()

Expand Down Expand Up @@ -70,7 +68,7 @@ final class DateFormattingService {
return cached.length == 0 ? nil : cached as String
}

guard let date = parser.date(from: dateString) else {
guard let date = DatabaseDateParser.date(from: dateString) else {
formatCache.setObject("" as NSString, forKey: cacheKey)
return nil
}
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Core/Services/Query/ResultChartProjector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ actor ResultChartProjector {
static let maximumLabelLength = 512
static let maximumNumericLength = 256

private let dateParser = DatabaseDateParser()

private enum XOccurrenceKey: Hashable {
case category(String)
Expand Down Expand Up @@ -194,7 +193,7 @@ actor ResultChartProjector {
case .date:
guard case .text(let raw) = cell,
raw.utf8.count <= Self.maximumLabelLength,
let date = dateParser.date(from: raw.trimmingCharacters(in: .whitespacesAndNewlines))
let date = DatabaseDateParser.date(from: raw.trimmingCharacters(in: .whitespacesAndNewlines))
else {
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Results/Extensions/DataGridView+Popovers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ extension TableViewCoordinator {
guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return }

let columnType = tableRows.columnTypes[columnIndex]
let parsed = DateEditingService.parse(cellValue(at: row, column: columnIndex))
let parsed = DatabaseDateParser.parse(cellValue(at: row, column: columnIndex))
let initialDate = parsed?.date ?? Date()
let timeZone = parsed?.timeZone ?? .gmt
let timeZone = parsed?.timeZone ?? DateEditingService.defaultTimeZone
let components = DateEditingService.components(for: columnType)

let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column))
Expand Down
Loading
Loading