Skip to content

Commit 341085f

Browse files
committed
feat: smart value display formats with auto-detection and per-column overrides
1 parent f829618 commit 341085f

14 files changed

Lines changed: 588 additions & 18 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Smart value detection: auto-render UUIDs in BINARY(16) columns and timestamps in integer columns
13+
- Per-column "Display As" override via column header context menu
1214
- iOS: safe mode (Off, Confirm Writes, Read-Only) per connection
1315

1416
## [0.27.6] - 2026-04-07

TablePro/Core/Services/Formatting/CellDisplayFormatter.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import Foundation
1212
enum CellDisplayFormatter {
1313
static let maxDisplayLength = 10_000
1414

15-
static func format(_ rawValue: String?, columnType: ColumnType?) -> String? {
15+
static func format(_ rawValue: String?, columnType: ColumnType?, displayFormat: ValueDisplayFormat? = nil) -> String? {
1616
guard let value = rawValue, !value.isEmpty else { return rawValue }
1717

1818
var displayValue = value
1919

20-
if let columnType {
20+
// Apply explicit display format when set (non-raw)
21+
if let displayFormat, displayFormat != .raw {
22+
displayValue = ValueDisplayFormatService.applyFormat(value, format: displayFormat)
23+
} else if let columnType {
2124
if columnType.isDateType {
2225
if let formatted = DateFormattingService.shared.format(dateString: displayValue) {
2326
displayValue = formatted
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// ValueDisplayDetector.swift
3+
// TablePro
4+
//
5+
// Heuristic auto-detection of semantic value formats.
6+
// Examines column types, names, and sample values to suggest
7+
// display formats like UUID or Unix timestamp.
8+
//
9+
10+
import Foundation
11+
12+
@MainActor
13+
enum ValueDisplayDetector {
14+
/// Detect display formats for each column based on type, name, and sample values.
15+
/// Returns an array parallel to `columns` where nil means no format detected (.raw).
16+
static func detect(
17+
columns: [String],
18+
columnTypes: [ColumnType],
19+
sampleValues: [[String?]]?
20+
) -> [ValueDisplayFormat?] {
21+
var results = [ValueDisplayFormat?](repeating: nil, count: columns.count)
22+
23+
for i in 0..<columns.count {
24+
let columnType = i < columnTypes.count ? columnTypes[i] : nil
25+
let columnName = columns[i]
26+
let sampleValue = firstNonNilSample(at: i, from: sampleValues)
27+
28+
if let format = detectUuid(columnType: columnType, columnName: columnName) {
29+
results[i] = format
30+
} else if let format = detectTimestamp(columnType: columnType, columnName: columnName, sampleValue: sampleValue) {
31+
results[i] = format
32+
}
33+
}
34+
35+
return results
36+
}
37+
38+
// MARK: - UUID Detection
39+
40+
private static func detectUuid(columnType: ColumnType?, columnName: String) -> ValueDisplayFormat? {
41+
guard let columnType else { return nil }
42+
let nameLower = columnName.lowercased()
43+
let nameHint = nameLower.contains("uuid") || nameLower.contains("guid")
44+
|| nameLower.hasSuffix("_id") || nameLower == "id"
45+
46+
switch columnType {
47+
case .blob(let rawType):
48+
// BINARY(16) requires name hint to avoid false positives on arbitrary 16-byte data
49+
guard let raw = rawType?.uppercased() else { return nil }
50+
if raw.contains("BINARY") && raw.contains("(16)") && nameHint {
51+
return .uuid
52+
}
53+
case .text(let rawType):
54+
guard let raw = rawType?.uppercased() else { return nil }
55+
let isCharLike = (raw.contains("CHAR") || raw.contains("VARCHAR"))
56+
&& (raw.contains("(32)") || raw.contains("(36)"))
57+
if isCharLike && (nameLower.contains("uuid") || nameLower.contains("guid")) {
58+
return .uuid
59+
}
60+
default:
61+
break
62+
}
63+
64+
return nil
65+
}
66+
67+
// MARK: - Timestamp Detection
68+
69+
private static func detectTimestamp(
70+
columnType: ColumnType?,
71+
columnName: String,
72+
sampleValue: String?
73+
) -> ValueDisplayFormat? {
74+
guard let columnType else { return nil }
75+
76+
switch columnType {
77+
case .integer:
78+
break
79+
default:
80+
return nil
81+
}
82+
83+
let nameLower = columnName.lowercased()
84+
let nameMatches = nameLower.hasSuffix("_at")
85+
|| nameLower.hasSuffix("_time")
86+
|| nameLower.hasSuffix("_timestamp")
87+
|| nameLower == "created"
88+
|| nameLower == "updated"
89+
|| nameLower == "modified"
90+
|| nameLower == "timestamp"
91+
92+
guard nameMatches else { return nil }
93+
94+
// Validate with sample value if available
95+
if let sample = sampleValue, let numericValue = Double(sample) {
96+
// Millisecond timestamps are > 10 billion
97+
if numericValue > 10_000_000_000 {
98+
let seconds = numericValue / 1_000
99+
guard seconds >= 946_684_800 && seconds <= 4_102_444_800 else { return nil }
100+
return .unixTimestampMillis
101+
}
102+
guard numericValue >= 946_684_800 && numericValue <= 4_102_444_800 else { return nil }
103+
return .unixTimestamp
104+
}
105+
106+
// No sample to validate against; default to seconds
107+
return .unixTimestamp
108+
}
109+
110+
// MARK: - Helpers
111+
112+
private static func firstNonNilSample(at columnIndex: Int, from sampleValues: [[String?]]?) -> String? {
113+
guard let samples = sampleValues else { return nil }
114+
for row in samples {
115+
if columnIndex < row.count, let value = row[columnIndex], !value.isEmpty {
116+
return value
117+
}
118+
}
119+
return nil
120+
}
121+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//
2+
// ValueDisplayFormat.swift
3+
// TablePro
4+
//
5+
// Semantic display formats for raw database values.
6+
// Enables auto-detection and per-column overrides for values like
7+
// UUIDs stored in BINARY(16) or Unix timestamps in INT columns.
8+
//
9+
10+
import Foundation
11+
12+
enum ValueDisplayFormat: String, Codable, CaseIterable, Identifiable {
13+
case raw
14+
case uuid
15+
case unixTimestamp
16+
case unixTimestampMillis
17+
18+
var id: String { rawValue }
19+
20+
var displayName: String {
21+
switch self {
22+
case .raw: return String(localized: "Raw Value")
23+
case .uuid: return String(localized: "UUID")
24+
case .unixTimestamp: return String(localized: "Unix Timestamp (seconds)")
25+
case .unixTimestampMillis: return String(localized: "Unix Timestamp (milliseconds)")
26+
}
27+
}
28+
29+
/// Column types this format can apply to.
30+
var applicableColumnTypes: Set<String> {
31+
switch self {
32+
case .raw:
33+
return []
34+
case .uuid:
35+
return ["blob", "text"]
36+
case .unixTimestamp, .unixTimestampMillis:
37+
return ["integer"]
38+
}
39+
}
40+
41+
/// Returns applicable formats for a given column type.
42+
/// Always includes `.raw` as the first option.
43+
static func applicableFormats(for columnType: ColumnType?) -> [ValueDisplayFormat] {
44+
guard let columnType else { return [.raw] }
45+
46+
let typeKey: String
47+
switch columnType {
48+
case .blob: typeKey = "blob"
49+
case .text: typeKey = "text"
50+
case .integer: typeKey = "integer"
51+
default: return [.raw]
52+
}
53+
54+
var result: [ValueDisplayFormat] = [.raw]
55+
for format in allCases where format != .raw {
56+
if format.applicableColumnTypes.contains(typeKey) {
57+
result.append(format)
58+
}
59+
}
60+
return result
61+
}
62+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//
2+
// ValueDisplayFormatService.swift
3+
// TablePro
4+
//
5+
// Applies display format transformations to raw cell values
6+
// and manages the effective format per column (auto-detected vs. user override).
7+
//
8+
9+
import Foundation
10+
import os
11+
12+
@MainActor
13+
final class ValueDisplayFormatService {
14+
static let shared = ValueDisplayFormatService()
15+
16+
private static let logger = Logger(subsystem: "com.TablePro", category: "ValueDisplayFormat")
17+
18+
/// Auto-detected formats keyed by "connectionId.tableName.columnName" for per-connection isolation.
19+
private var autoDetectedFormats: [String: ValueDisplayFormat] = [:]
20+
21+
private init() {}
22+
23+
// MARK: - Format Application
24+
25+
static func applyFormat(_ rawValue: String, format: ValueDisplayFormat) -> String {
26+
switch format {
27+
case .raw:
28+
return rawValue
29+
case .uuid:
30+
return formatAsUuid(rawValue)
31+
case .unixTimestamp:
32+
return formatAsTimestamp(rawValue, divideBy: 1)
33+
case .unixTimestampMillis:
34+
return formatAsTimestamp(rawValue, divideBy: 1_000)
35+
}
36+
}
37+
38+
// MARK: - Effective Format Resolution
39+
40+
func effectiveFormat(columnName: String, connectionId: UUID?, tableName: String?) -> ValueDisplayFormat {
41+
// Stored overrides take priority
42+
if let connId = connectionId, let table = tableName {
43+
if let overrides = ValueDisplayFormatStorage.shared.load(for: table, connectionId: connId),
44+
let format = overrides[columnName] {
45+
return format
46+
}
47+
}
48+
49+
// Then auto-detected (scoped by connection + table)
50+
let key = scopedKey(columnName: columnName, connectionId: connectionId, tableName: tableName)
51+
if let format = autoDetectedFormats[key] {
52+
return format
53+
}
54+
55+
return .raw
56+
}
57+
58+
func setAutoDetectedFormats(_ formats: [String: ValueDisplayFormat], connectionId: UUID?, tableName: String?) {
59+
// Clear previous entries for this scope
60+
let prefix = scopePrefix(connectionId: connectionId, tableName: tableName)
61+
autoDetectedFormats = autoDetectedFormats.filter { !$0.key.hasPrefix(prefix) }
62+
63+
for (columnName, format) in formats {
64+
let key = scopedKey(columnName: columnName, connectionId: connectionId, tableName: tableName)
65+
autoDetectedFormats[key] = format
66+
}
67+
}
68+
69+
func clearAutoDetectedFormats(connectionId: UUID?, tableName: String?) {
70+
let prefix = scopePrefix(connectionId: connectionId, tableName: tableName)
71+
autoDetectedFormats = autoDetectedFormats.filter { !$0.key.hasPrefix(prefix) }
72+
}
73+
74+
// MARK: - Scoping
75+
76+
private func scopePrefix(connectionId: UUID?, tableName: String?) -> String {
77+
"\(connectionId?.uuidString ?? "_").\(tableName ?? "_")."
78+
}
79+
80+
private func scopedKey(columnName: String, connectionId: UUID?, tableName: String?) -> String {
81+
"\(connectionId?.uuidString ?? "_").\(tableName ?? "_").\(columnName)"
82+
}
83+
84+
// MARK: - Override Management
85+
86+
func setOverride(
87+
_ format: ValueDisplayFormat?,
88+
columnName: String,
89+
connectionId: UUID,
90+
tableName: String
91+
) {
92+
var overrides = ValueDisplayFormatStorage.shared.load(for: tableName, connectionId: connectionId) ?? [:]
93+
94+
if let format, format != .raw {
95+
overrides[columnName] = format
96+
} else {
97+
overrides.removeValue(forKey: columnName)
98+
}
99+
100+
if overrides.isEmpty {
101+
ValueDisplayFormatStorage.shared.clear(for: tableName, connectionId: connectionId)
102+
} else {
103+
ValueDisplayFormatStorage.shared.save(overrides, for: tableName, connectionId: connectionId)
104+
}
105+
}
106+
107+
// MARK: - Private Formatting
108+
109+
private static func formatAsUuid(_ rawValue: String) -> String {
110+
// Try raw binary bytes (isoLatin1 encoding from MySQL)
111+
if let data = rawValue.data(using: .isoLatin1), data.count == 16 {
112+
let bytes = [UInt8](data)
113+
let hex = bytes.map { String(format: "%02x", $0) }.joined()
114+
return insertUuidHyphens(hex)
115+
}
116+
117+
// Try hex string (with or without 0x prefix)
118+
var hex = rawValue
119+
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
120+
hex = String(hex.dropFirst(2))
121+
}
122+
hex = hex.replacingOccurrences(of: "-", with: "")
123+
124+
guard (hex as NSString).length == 32, hex.allSatisfy({ $0.isHexDigit }) else {
125+
return rawValue
126+
}
127+
128+
return insertUuidHyphens(hex.lowercased())
129+
}
130+
131+
private static func insertUuidHyphens(_ hex: String) -> String {
132+
let ns = hex as NSString
133+
let p1 = ns.substring(with: NSRange(location: 0, length: 8))
134+
let p2 = ns.substring(with: NSRange(location: 8, length: 4))
135+
let p3 = ns.substring(with: NSRange(location: 12, length: 4))
136+
let p4 = ns.substring(with: NSRange(location: 16, length: 4))
137+
let p5 = ns.substring(with: NSRange(location: 20, length: 12))
138+
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
139+
}
140+
141+
private static func formatAsTimestamp(_ rawValue: String, divideBy divisor: Double) -> String {
142+
guard let numericValue = Double(rawValue) else { return rawValue }
143+
let seconds = numericValue / divisor
144+
let date = Date(timeIntervalSince1970: seconds)
145+
return DateFormattingService.shared.format(date)
146+
}
147+
}

0 commit comments

Comments
 (0)