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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Query results can be drawn as native bar, line, area and scatter charts from the loaded rows. Date and timestamp columns plot on a real time axis. Axis and series controls stack when the pane is narrow, hover shows exact values, and the chart keeps its type and axes across a page turn, a sort and a re-run. A result past the plotting limit still draws what fits and says how much. The status bar keeps the row count and pagination in Chart mode. This is a Starter feature.
- Every database operation TablePro authorizes is written to a local execution log, including the ones the AI assistant and MCP clients ask for, with the statement stored as a digest rather than as text. Records are hash chained, so an edited, reordered or removed entry can be detected. The log stays on the Mac and is not synced.
- An administrator can set a minimum Safe Mode level for every connection through a macOS configuration profile, so a managed Mac cannot be dropped below it. A connection set stricter keeps its own level, since the policy is a floor rather than a ceiling. The control shows as managed instead of editable.
- Plugins signed by other developers can be installed. TablePro used to refuse any plugin bundle it had not signed itself, so the only way to publish a driver was through the TablePro repository. A bundle signed with a Developer ID and notarized by Apple now installs after you agree to trust that developer by name, and the prompt says plainly that a database plugin runs as part of TablePro and can read the credentials of every connection you open. Trust is recorded per developer rather than per plugin, so their updates install without asking again, and you can withdraw it. Unsigned and ad-hoc signed bundles are still refused.
Expand All @@ -19,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar.
- Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab.

### Fixed

- Timestamps written with a space before the time zone offset, or with fractional seconds, are now shown in your chosen date format instead of as raw text. PostgreSQL `timestamptz` and MySQL `DATETIME(6)` values used to slip through unformatted while the same instant written in ISO form was formatted.

## [0.66.0] - 2026-08-19

### Added
Expand Down
62 changes: 62 additions & 0 deletions TablePro/Core/Services/Formatting/DatabaseDateParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// 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.
//

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.
///
/// 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
}
}

func date(from text: String) -> Date? {
if let date = parsers[lastSuccessfulIndex].date(from: text) {
return date
}
for index in parsers.indices where index != lastSuccessfulIndex {
if let date = parsers[index].date(from: text) {
lastSuccessfulIndex = index
return date
}
}
return nil
}
}
56 changes: 7 additions & 49 deletions TablePro/Core/Services/Formatting/DateFormattingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@ final class DateFormattingService {
/// Current date format option
private(set) var currentFormat: DateFormatOption

/// Parsers for common database date formats (ISO 8601, MySQL, PostgreSQL, SQLite)
private let parsers: [DateFormatter]

/// Index of the parser that succeeded most recently. Tried first on the next parse
/// because consecutive cells in the same column share the same wire format.
private var lastSuccessfulParserIndex: Int = 0
private let parser = DatabaseDateParser()

/// Cache for formatted date strings to avoid repeated parsing
private let formatCache = NSCache<NSString, NSString>()
Expand All @@ -41,7 +36,6 @@ final class DateFormattingService {
self.formatter = Self.createFormatter(format: DateFormatOption.iso8601.formatString)
self.dateOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.dateOnlyFormatString)
self.timeOnlyFormatter = Self.createFormatter(format: DateFormatOption.iso8601.timeOnlyFormatString)
self.parsers = Self.createParsers()
formatCache.countLimit = 100_000
}

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

if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) {
let result = targetFormatter.string(from: date)
formatCache.setObject(result as NSString, forKey: cacheKey)
return result
}
for index in parsers.indices where index != lastSuccessfulParserIndex {
if let date = parsers[index].date(from: dateString) {
lastSuccessfulParserIndex = index
let result = targetFormatter.string(from: date)
formatCache.setObject(result as NSString, forKey: cacheKey)
return result
}
guard let date = parser.date(from: dateString) else {
formatCache.setObject("" as NSString, forKey: cacheKey)
return nil
}

formatCache.setObject("" as NSString, forKey: cacheKey)
return nil
let result = targetFormatter.string(from: date)
formatCache.setObject(result as NSString, forKey: cacheKey)
return result
}

private func formatter(for columnType: ColumnType?) -> DateFormatter {
Expand Down Expand Up @@ -128,31 +113,4 @@ final class DateFormattingService {
formatter.timeZone = TimeZone.current
return formatter
}

/// Create parsers for common database date formats
/// Parsers are tried in order until one successfully parses the input.
/// Formats WITHOUT explicit timezone info use the user's local timezone
/// (database values like `2024-03-01 12:00:00` are naive — display as-is).
/// Formats WITH timezone markers (`Z`, `+0000`) parse the embedded offset.
/// - Returns: Array of DateFormatters for parsing
private static func createParsers() -> [DateFormatter] {
// (format, hasTimezone) — formats with timezone markers parse UTC/offset;
// naive formats use user's local timezone so display matches the raw value.
let formats: [(String, Bool)] = [
("yyyy-MM-dd HH:mm:ss", false), // MySQL/PostgreSQL timestamp (most common)
("yyyy-MM-dd'T'HH:mm:ss", false), // ISO 8601 (no timezone)
("yyyy-MM-dd'T'HH:mm:ssZ", true), // ISO 8601 with timezone
("yyyy-MM-dd'T'HH:mm:ss.SSSZ", true), // ISO 8601 with milliseconds and timezone
("yyyy-MM-dd", false), // Date only (MySQL DATE, PostgreSQL DATE)
("HH:mm:ss", false), // Time only (MySQL TIME)
]

return formats.map { format, hasTimezone in
let parser = DateFormatter()
parser.dateFormat = format
parser.locale = Locale(identifier: "en_US_POSIX")
parser.timeZone = hasTimezone ? TimeZone(secondsFromGMT: 0) : TimeZone.current
return parser
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ extension MainSplitViewController {
/// One Find command, and the responder chain decides what finding means. A focused editor declares
/// the same selectors on its own `TextViewController`, closer to the first responder, so it searches
/// its own text without this class knowing which editor is focused. Reaching here means no editor
/// holds focus: a table tab filters its rows, and every other tab hands the find to the editor the
/// window is built around, so Cmd+F still reaches the query text while the grid or sidebar has focus.
/// holds focus: an active table result grid filters its rows, and every other view hands the find
/// to the editor the window is built around, so Cmd+F still reaches the query text while the grid
/// or sidebar has focus.
@objc func performFind(_ sender: Any?) {
guard commandActions?.isTableTab == true else {
guard commandActions?.canUseTableResultCommands == true else {
EditorEventRouter.shared.showFindPanelForKeyWindow()
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct MenuValidationContext: Equatable {
var hasSelectedWorkspace = false
var isConnected = false
var isReadOnly = false
var isTableTab = false
var canUseTableResultCommands = false
/// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection.
var isQueryTab = false
/// Export Results exports the selected tab's rows, so an empty grid has nothing to offer.
Expand All @@ -28,6 +28,9 @@ struct MenuValidationContext: Equatable {
var hasPendingChanges = false
var hasDataPendingChanges = false
var hasRowSelection = false
/// Copy with headers and copy as JSON read the result grid's columns, so they need the data
/// grid's selection specifically, not the structure grid's.
var hasDataGridRowSelection = false
var hasTableSelection = false
/// Whether the window-level `paste:` fallback would actually paste. AppKit hands a disabled
/// item its key equivalent regardless, so an item enabled over a handler that returns at its
Expand Down Expand Up @@ -144,7 +147,7 @@ extension MainSplitViewController: NSMenuItemValidation {
case #selector(truncateTable(_:)):
return context.isConnected && context.hasTableSelection && !context.isReadOnly
case #selector(performFind(_:)):
return context.hasEditorForFind || (context.isConnected && context.isTableTab)
return context.hasEditorForFind || (context.isConnected && context.canUseTableResultCommands)
case #selector(findNext(_:)), #selector(findPrevious(_:)):
return context.hasEditorForFind || context.hasActiveGridFind
case #selector(undo(_:)):
Expand All @@ -153,10 +156,11 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.canRedo
case #selector(copy(_:)):
return context.hasRowSelection || context.hasTableSelection
case #selector(copySelectedRows(_:)),
#selector(copyRowsWithHeaders(_:)),
#selector(copyRowsAsJson(_:)):
case #selector(copySelectedRows(_:)):
return context.hasRowSelection
case #selector(copyRowsWithHeaders(_:)),
#selector(copyRowsAsJson(_:)):
return context.hasDataGridRowSelection
case #selector(paste(_:)):
return context.isConnected && context.canPasteRows
case #selector(delete(_:)):
Expand Down Expand Up @@ -188,7 +192,7 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.isConnected

case #selector(toggleFilterBar(_:)):
return context.isConnected && context.isTableTab
return context.isConnected && context.canUseTableResultCommands
case #selector(pinResult(_:)):
return context.canPinResultTab
case #selector(useFlatSidebarLayout(_:)), #selector(useTreeSidebarLayout(_:)):
Expand All @@ -211,7 +215,7 @@ extension MainSplitViewController: NSMenuItemValidation {
hasSelectedWorkspace: workspaces.selectedConnectionId != nil,
isConnected: isConnected,
isReadOnly: actions.isReadOnly,
isTableTab: actions.isTableTab,
canUseTableResultCommands: actions.canUseTableResultCommands,
isQueryTab: actions.isQueryTab,
hasResultRows: actions.hasResultRows,
isCurrentTabEditable: actions.isCurrentTabEditable,
Expand All @@ -220,6 +224,7 @@ extension MainSplitViewController: NSMenuItemValidation {
hasPendingChanges: actions.hasPendingChanges,
hasDataPendingChanges: actions.hasDataPendingChanges,
hasRowSelection: actions.hasRowSelection,
hasDataGridRowSelection: actions.hasDataGridRowSelection,
hasTableSelection: actions.hasTableSelection,
canPasteRows: actions.canPasteRows,
canCloseOtherTabs: actions.canCloseOtherTabs,
Expand Down
57 changes: 57 additions & 0 deletions TablePro/Core/Services/Query/ResultChartProjection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// ResultChartProjection.swift
// TablePro
//

import Foundation

struct ResultChartProjection: Equatable, Sendable {
enum XValue: Hashable, Sendable {
case category(String)
case number(Double)
case date(Date)
}

enum SeriesValue: Equatable, Hashable, Sendable {
case value(String)
case missing

/// The legend entry and the hover callout name the same series, so they read it from here.
var displayName: String {
switch self {
case .value(let raw): return raw
case .missing: return String(localized: "No value")
}
}
}

/// A bound the projection ran into. The points already gathered are kept and drawn: a result one
/// row over a cap is a chart with a note, not an error card.
enum Limit: Equatable, Sendable {
case points(limit: Int)
case series(limit: Int)
case inspectedRows(limit: Int)
}

struct Point: Identifiable, Equatable, Sendable {
let sourceIndex: Int
let x: XValue
let y: Double
let rawX: String
let rawY: String
let series: SeriesValue?
let barGroup: Int
let lineGroup: Int

var id: Int { sourceIndex }
}

let points: [Point]
let xAxisKind: ResultChartColumn.AxisKind
let limits: [Limit]
let loadedRowCount: Int
let skippedRowCount: Int
let xAxisLabel: String
let yAxisLabel: String
let seriesLabel: String?
}
Loading
Loading