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 @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083)
- Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086)

### Changed

- Mobile keeps remote connections open when you switch apps.
- MongoDB shows a standard binary UUID as `UUID("...")` everywhere, including in exports.
- Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way.
- An imported connection link now shows the startup SQL and driver options it carries, before you add it.

Expand All @@ -38,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- MongoDB no longer prints a binary field nested inside a document as a UUID when it is not one, or labels a UUID with the wrong byte order. (#2086)
- Deleting a MongoDB document with a binary `_id` deletes that document. It used to match on the other fields and could remove the wrong one.
- TablePro Mobile no longer gets killed by iOS when you leave the app with a DuckDB file open.
- Mobile sends the client certificate and key on MySQL and PostgreSQL connections that use mutual TLS. (#2083)
- Editing a connection on mobile no longer wipes its SSL settings and per-database options, which then synced the loss back to the Mac. (#2083)
- A connection whose certificate is missing now says so, instead of connecting without it while still demanding server verification. (#2083)
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ These have caused real bugs when violated:

**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017).

**Decoding a MongoDB binary UUID is a per-column decision, and the column's type name is load-bearing**: BSON binary subtype 3 is the legacy UUID format, and the Java, C# and Python drivers each wrote it with a different byte order with nothing in the stored bytes to say which. `MongoDBUuidCodec` therefore decodes subtype 3 only when the connection names one (`mongoUuidRepresentation`); subtype 4 is unambiguous and always decodes. The choice is made once per column from `BsonDocumentFlattener.columnKinds`' majority vote, never per value, because a decoded cell is `.text` and an undecoded one is `.bytes`, and `CellDisplayFormatter` runs blob formatting over a `.text` cell whenever its column type is BLOB. One UUID decoded inside a column the app still types `BLOB` renders as `0x4c65676163...`. For the same reason `BsonDocumentFlattener.typeName` must return exactly `"BLOB"` for undecoded binary: `ColumnTypeClassifier` maps any name containing `BLOB` to `.blob`, and that classification is the only thing keeping a binary cell out of the inline editor. Once a column does decode, both edit guards (`isBlobType` and `asBytes != nil`) fall together, so every write path must parse the wrapper back to `$binary`: `MongoDBStatementGenerator.jsonValue` and `idValueJson`, `MongoDBQueryBuilder.jsonValue` plus its `=`, `!=` and `IN` arms (a case-insensitive regex can never match a binary field), and `MQLExportHelpers.mqlJsonValue`. An `_id` filter left as wrapper text matches zero documents while the UI reports the save succeeded. (#2086)

**A MongoDB update or delete is anchored on `_id` or it does not run**: `generateDelete` used to fall back to a filter built from the remaining columns, which silently dropped every value it could not stringify (all binary) and then `deleteOne`d the first partial match, so a document with a binary `_id` could delete a different document. Both paths now skip with a logged warning instead, matching what `generateUpdate` already did.

### Main Coordinator Pattern

`MainContentCoordinator` is the central coordinator, split across 7+ extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file.
Expand Down
3 changes: 3 additions & 0 deletions Plugins/MQLExportPlugin/MQLExportHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ enum MQLExportHelpers {
if Double(value) != nil, value.contains(".") {
return value
}
if let binary = MongoDBUuidCodec.extendedJsonFromWrapper(value) {
return binary
}
if (value.hasPrefix("{") && value.hasSuffix("}")) ||
(value.hasPrefix("[") && value.hasSuffix("]")) {
if let data = value.data(using: .utf8),
Expand Down
182 changes: 125 additions & 57 deletions Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@
import Foundation
import TableProPluginKit

enum BsonValueKind {
case double
case string
case document
case array
case binary
case boolean
case date
case null
case int32
case int64
case uuid
case legacyUuid

var isUuid: Bool {
switch self {
case .uuid, .legacyUuid: return true
default: return false
}
}
}

struct BsonDocumentFlattener {
// MARK: - Public API

Expand Down Expand Up @@ -42,29 +64,75 @@ struct BsonDocumentFlattener {

/// Flatten documents into a grid. Missing fields become nil cells.
/// Nested objects/arrays are serialized as compact JSON strings.
static func flatten(documents: [[String: Any]], columns: [String]) -> [[PluginCellValue]] {
static func flatten(
documents: [[String: Any]],
columns: [String],
kinds: [BsonValueKind],
representation: MongoDBUuidRepresentation
) -> [[PluginCellValue]] {
documents.map { doc in
columns.map { column in
columns.enumerated().map { index, column in
guard let value = doc[column] else { return PluginCellValue.null }
if let data = value as? Data {
return .bytes(data)
}
return PluginCellValue.fromOptional(stringValue(for: value))
let kind = index < kinds.count ? kinds[index] : .string
return cellValue(for: value, kind: kind, representation: representation)
}
}
}

/// Infer ColumnType for each column by majority-vote over document values.
static func columnTypes(for columns: [String], documents: [[String: Any]]) -> [Int32] {
/// Infer the dominant value kind for each column by majority-vote over document values.
static func columnKinds(
for columns: [String],
documents: [[String: Any]],
representation: MongoDBUuidRepresentation
) -> [BsonValueKind] {
columns.map { column in
inferBsonType(for: column, in: documents)
inferValueKind(for: column, in: documents, representation: representation)
}
}

static func cellValue(
for value: Any,
kind: BsonValueKind,
representation: MongoDBUuidRepresentation
) -> PluginCellValue {
if let binary = value as? MongoDBBinaryValue {
guard kind.isUuid,
let text = MongoDBUuidCodec.decodedText(for: binary, representation: representation) else {
return .bytes(binary.data)
}
return .text(text)
}
if let data = value as? Data {
return .bytes(data)
}
return PluginCellValue.fromOptional(stringValue(for: value, representation: representation))
}

static func typeName(for kind: BsonValueKind, representation: MongoDBUuidRepresentation) -> String {
switch kind {
case .double: return "FLOAT"
case .string, .null: return "VARCHAR"
case .document, .array: return "JSON"
case .binary: return "BLOB"
case .boolean: return "BOOLEAN"
case .date: return "TIMESTAMP"
case .int32: return "INTEGER"
case .int64: return "BIGINT"
case .uuid:
return MongoDBUuidCodec.wrapperTag(
forSubtype: MongoDBUuidCodec.standardUuidSubtype, representation: representation
) ?? "BLOB"
case .legacyUuid:
return MongoDBUuidCodec.wrapperTag(
forSubtype: MongoDBUuidCodec.legacyUuidSubtype, representation: representation
) ?? "BLOB"
}
}

// MARK: - Value Serialization

/// Serialize a single value to its display string representation
static func stringValue(for value: Any?) -> String? {
static func stringValue(for value: Any?, representation: MongoDBUuidRepresentation) -> String? {
guard let value = value else { return nil }

if value is NSNull { return nil }
Expand All @@ -76,37 +144,47 @@ struct BsonDocumentFlattener {
return displayString(for: num)
case let date as Date:
return iso8601Formatter.string(from: date)
case let binary as MongoDBBinaryValue:
return binaryString(for: binary, representation: representation)
case let data as Data:
return formatBinaryData(data)
return MongoDBUuidCodec.binaryText(for: MongoDBBinaryValue(data: data, subtype: 0))
case let dict as [String: Any]:
// Code type: {"$code": "function() {...}"}
if let code = dict["$code"] as? String {
if let scope = dict["$scope"] as? [String: Any] {
return "Code(\"\(code)\", \(serializeToJson(scope)))"
return "Code(\"\(code)\", \(serializeToJson(scope, representation: representation)))"
}
return "Code(\"\(code)\")"
}
// DBRef convention: {"$ref": "collection", "$id": "..."}
if let ref = dict["$ref"] as? String, let id = dict["$id"] {
let idStr = stringValue(for: id) ?? String(describing: id)
let idStr = stringValue(for: id, representation: representation) ?? String(describing: id)
if let db = dict["$db"] as? String {
return "DBRef(\"\(ref)\", \(idStr), \"\(db)\")"
}
return "DBRef(\"\(ref)\", \(idStr))"
}
return serializeToJson(dict)
return serializeToJson(dict, representation: representation)
case let array as [Any]:
return serializeToJson(array)
return serializeToJson(array, representation: representation)
default:
return String(describing: value)
}
}

private static func binaryString(
for binary: MongoDBBinaryValue,
representation: MongoDBUuidRepresentation
) -> String {
MongoDBUuidCodec.decodedText(for: binary, representation: representation)
?? MongoDBUuidCodec.binaryText(for: binary)
}

// MARK: - JSON Serialization

/// Serialize a dictionary or array to compact JSON string
static func serializeToJson(_ value: Any) -> String {
let sanitized = sanitizeForJson(value)
static func serializeToJson(_ value: Any, representation: MongoDBUuidRepresentation) -> String {
let sanitized = sanitizeForJson(value, representation: representation)
guard JSONSerialization.isValidJSONObject(sanitized),
let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]),
let json = String(data: data, encoding: .utf8) else {
Expand All @@ -120,14 +198,16 @@ struct BsonDocumentFlattener {
}

/// Recursively convert every value into a JSON-safe representation
static func sanitizeForJson(_ value: Any) -> Any {
static func sanitizeForJson(_ value: Any, representation: MongoDBUuidRepresentation) -> Any {
switch value {
case let dict as [String: Any]:
return dict.mapValues { sanitizeForJson($0) }
return dict.mapValues { sanitizeForJson($0, representation: representation) }
case let array as [Any]:
return array.map { sanitizeForJson($0) }
return array.map { sanitizeForJson($0, representation: representation) }
case let binary as MongoDBBinaryValue:
return binaryString(for: binary, representation: representation)
case let data as Data:
return formatBinaryData(data)
return MongoDBUuidCodec.binaryText(for: MongoDBBinaryValue(data: data, subtype: 0))
case let date as Date:
return iso8601Formatter.string(from: date)
case is NSNull:
Expand Down Expand Up @@ -173,66 +253,54 @@ struct BsonDocumentFlattener {
return value > 0 ? "Infinity" : "-Infinity"
}

/// Format binary data: 16-byte values as UUID, otherwise as hex string
private static func formatBinaryData(_ data: Data) -> String {
if data.count == 16 {
let uuid = UUID(uuid: (
data[0], data[1], data[2], data[3],
data[4], data[5], data[6], data[7],
data[8], data[9], data[10], data[11],
data[12], data[13], data[14], data[15]
))
return "UUID(\"\(uuid.uuidString.lowercased())\")"
}
return "BinData(\(data.count), \"\(data.base64EncodedString())\")"
}

// MARK: - Type Inference

/// Infer the most common BSON type code for a field across all documents.
/// Returns BSON type integer: 1=Double, 2=String, 3=Document, 4=Array,
/// 5=Binary, 7=ObjectId, 8=Boolean, 9=Date, 10=Null, 16=Int32, 18=Int64
private static func inferBsonType(for field: String, in documents: [[String: Any]]) -> Int32 {
var typeCounts: [Int32: Int] = [:]
private static func inferValueKind(
for field: String,
in documents: [[String: Any]],
representation: MongoDBUuidRepresentation
) -> BsonValueKind {
var counts: [BsonValueKind: Int] = [:]

for doc in documents {
guard let value = doc[field] else { continue }
if value is NSNull { continue }

let type = bsonTypeCode(for: value)
typeCounts[type, default: 0] += 1
counts[valueKind(for: value, representation: representation), default: 0] += 1
}

// Return most common type, default to String (2) if no values found
return typeCounts.max(by: { $0.value < $1.value })?.key ?? 2
return counts.max(by: { $0.value < $1.value })?.key ?? .string
}

/// Map a Swift value to its approximate BSON type code
private static func bsonTypeCode(for value: Any) -> Int32 {
if value is NSNull { return 10 } // Null
private static func valueKind(for value: Any, representation: MongoDBUuidRepresentation) -> BsonValueKind {
if value is NSNull { return .null }

switch value {
case let num as NSNumber:
if isBoolean(num) {
return 8 // Boolean
return .boolean
}
if isFloatingPoint(num) {
return 1 // Double
return .double
}
let objCType = String(cString: num.objCType)
return objCType == "q" || objCType == "l" ? 18 : 16 // Int64 : Int32
return objCType == "q" || objCType == "l" ? .int64 : .int32
case is String:
return 2 // String
return .string
case is Date:
return 9 // Date
return .date
case let binary as MongoDBBinaryValue:
guard MongoDBUuidCodec.isDecodableUuid(binary, representation: representation) else {
return .binary
}
return binary.subtype == MongoDBUuidCodec.standardUuidSubtype ? .uuid : .legacyUuid
case is Data:
return 5 // Binary
return .binary
case is [String: Any]:
return 3 // Document
return .document
case is [Any]:
return 4 // Array
return .array
default:
return 2 // Default to String
return .string
}
}
}
36 changes: 11 additions & 25 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection+SyncHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,15 @@ extension MongoDBConnection {
continuation: AsyncThrowingStream<PluginStreamElement, Error>.Continuation
) -> MongoStreamProjection {
let columns = BsonDocumentFlattener.unionColumns(from: sample)
let columnTypeNames = BsonDocumentFlattener
.columnTypes(for: columns, documents: sample)
.map { bsonTypeToStreamString($0) }
let projection = MongoStreamProjection(columns: columns, columnTypeNames: columnTypeNames)
let kinds = BsonDocumentFlattener.columnKinds(
for: columns, documents: sample, representation: uuidRepresentation
)
let columnTypeNames = kinds.map {
BsonDocumentFlattener.typeName(for: $0, representation: uuidRepresentation)
}
let projection = MongoStreamProjection(
columns: columns, columnTypeNames: columnTypeNames, kinds: kinds
)

continuation.yield(.header(projection.header))

Expand All @@ -496,11 +501,8 @@ extension MongoDBConnection {
return projection
}

private func streamCellValue(_ value: Any) -> PluginCellValue {
if let data = value as? Data {
return .bytes(data)
}
return PluginCellValue.fromOptional(BsonDocumentFlattener.stringValue(for: value))
private func streamCellValue(_ value: Any, kind: BsonValueKind) -> PluginCellValue {
BsonDocumentFlattener.cellValue(for: value, kind: kind, representation: uuidRepresentation)
}

private func cleanup(_ state: MongoStreamState) {
Expand All @@ -517,21 +519,5 @@ extension MongoDBConnection {
if let col { mongoc_collection_destroy(col) }
}

private func bsonTypeToStreamString(_ type: Int32) -> String {
switch type {
case 1: return "FLOAT"
case 2: return "VARCHAR"
case 3: return "JSON"
case 4: return "JSON"
case 5: return "BLOB"
case 7: return "VARCHAR"
case 8: return "BOOLEAN"
case 9: return "TIMESTAMP"
case 10: return "VARCHAR"
case 16: return "INTEGER"
case 18: return "BIGINT"
default: return "VARCHAR"
}
}
}
#endif
Loading
Loading