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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Internal: extend `AppServices` with `tagStorage`, `sshProfileStorage`, `licenseManager`, `conflictResolver`, and `syncMetadataStorage`. `SyncCoordinator` now takes `services: AppServices` in init (default `.live`); 34 raw `.shared` reads of those types inside `SyncCoordinator` are routed through `services.*`.
- Internal: Redis sidebar key tree uses SwiftUI `OutlineGroup` instead of recursive `DisclosureGroup` + `ForEach` wrapped in `AnyView`. Expansion state is now managed natively per branch identifier; the explicit `expandedPrefixes` set is gone.
- Result-grid cells render via direct `draw(_:)` on a layer-backed `NSView` instead of an `NSTableCellView` wrapping an `NSTextField` plus an `NSButton` accessory. Per cell during scroll there is no Auto Layout solving, no `NSTextField` re-layout, and no `NSButton` tracking-area work. Editing for plain-text columns now opens the overlay editor (the same surface previously used for multi-line cells) rather than an inline text field.
- Plugin contract: `PluginQueryResult.rows` carries typed `PluginCellValue` cells (`.null` / `.text(String)` / `.bytes(Data)`) instead of `String?`. Driver plugins emit `.bytes(Data)` for binary columns (PostgreSQL BYTEA, Oracle RAW/LONG_RAW/BLOB, MySQL BLOB family, SQLite BLOB, MSSQL VARBINARY/IMAGE, DuckDB BLOB, Cassandra blob, MongoDB BSON binary, DynamoDB B, BigQuery BYTES). The typed value flows end-to-end: read display, sidebar, hex editor, change tracking, SQL emission. Hex-editor saves bind raw `Data` through libpq's binary parameter format instead of UTF-8 re-encoded text. Fixes wrong BYTEA hex preview, wrong byte count, and corrupted bytes on save for high-byte binary cells (#1188).
- Double-click and Return on a binary cell now open the hex editor directly. Type-based routing runs before the line-break/JSON content heuristics so binary bytes that incidentally contain 0x0C or `{` no longer route through the multi-line text editor and corrupt the value.

### Fixed

Expand Down
16 changes: 8 additions & 8 deletions Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
return PluginQueryResult(
columns: ["ok"],
columnTypeNames: ["INT64"],
rows: [["1"]],
rows: [[.text("1")]],
rowsAffected: 0,
executionTime: Date().timeIntervalSince(startTime)
)
Expand All @@ -193,10 +193,10 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
columns: ["Metric", "Value"],
columnTypeNames: ["STRING", "STRING"],
rows: [
["Total Bytes Processed", formatBytes(bytesProcessed)],
["Total Bytes Billed", formatBytes(bytesBilled)],
["Cache Hit", cacheHit],
["Estimated Cost (USD)", estimateCost(bytesBilled)]
[.text("Total Bytes Processed"), .text(formatBytes(bytesProcessed))],
[.text("Total Bytes Billed"), .text(formatBytes(bytesBilled))],
[.text("Cache Hit"), .text(cacheHit)],
[.text("Estimated Cost (USD)"), .text(estimateCost(bytesBilled))]
],
rowsAffected: 0,
executionTime: Date().timeIntervalSince(startTime)
Expand Down Expand Up @@ -227,7 +227,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
return PluginQueryResult(
columns: ["Result"],
columnTypeNames: ["STRING"],
rows: [["Statement executed"]],
rows: [[.text("Statement executed")]],
rowsAffected: result.dmlAffectedRows,
executionTime: Date().timeIntervalSince(startTime),
statusMessage: buildCostMessage(result)
Expand Down Expand Up @@ -533,10 +533,10 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
columns: [String],
primaryKeyColumns: [String],
changes: [PluginRowChange],
insertedRowData: [Int: [String?]],
insertedRowData: [Int: [PluginCellValue]],
deletedRowIndices: Set<Int>,
insertedRowIndices: Set<Int>
) -> [(statement: String, parameters: [String?])]? {
) -> [(statement: String, parameters: [PluginCellValue])]? {
guard let conn = connection else { return nil }

let dataset = lock.withLock { _currentDataset } ?? ""
Expand Down
22 changes: 11 additions & 11 deletions Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ internal struct BigQueryStatementGenerator {

func generateStatements(
from changes: [PluginRowChange],
insertedRowData: [Int: [String?]],
insertedRowData: [Int: [PluginCellValue]],
deletedRowIndices: Set<Int>,
insertedRowIndices: Set<Int>
) -> [(statement: String, parameters: [String?])] {
var statements: [(statement: String, parameters: [String?])] = []
) -> [(statement: String, parameters: [PluginCellValue])] {
var statements: [(statement: String, parameters: [PluginCellValue])] = []

for change in changes {
switch change.type {
Expand Down Expand Up @@ -56,17 +56,17 @@ internal struct BigQueryStatementGenerator {

private func generateInsert(
for change: PluginRowChange,
insertedRowData: [Int: [String?]]
) -> (statement: String, parameters: [String?])? {
insertedRowData: [Int: [PluginCellValue]]
) -> (statement: String, parameters: [PluginCellValue])? {
var values: [String: String?] = [:]

if let rowData = insertedRowData[change.rowIndex] {
for (index, column) in columns.enumerated() where index < rowData.count {
values[column] = rowData[index]
values[column] = rowData[index].asText
}
} else {
for cellChange in change.cellChanges {
values[cellChange.columnName] = cellChange.newValue
values[cellChange.columnName] = cellChange.newValue.asText
}
}

Expand Down Expand Up @@ -95,7 +95,7 @@ internal struct BigQueryStatementGenerator {

private func generateUpdate(
for change: PluginRowChange
) -> (statement: String, parameters: [String?])? {
) -> (statement: String, parameters: [PluginCellValue])? {
guard !change.cellChanges.isEmpty else { return nil }

guard let whereClause = buildWhereClause(from: change) else {
Expand All @@ -107,7 +107,7 @@ internal struct BigQueryStatementGenerator {
for cellChange in change.cellChanges {
let typeIndex = columns.firstIndex(of: cellChange.columnName) ?? 0
let typeName = typeIndex < columnTypeNames.count ? columnTypeNames[typeIndex] : "STRING"
let formattedValue = formatValue(cellChange.newValue, typeName: typeName)
let formattedValue = formatValue(cellChange.newValue.asText, typeName: typeName)
setClauses.append("\(quoteIdentifier(cellChange.columnName)) = \(formattedValue)")
}

Expand All @@ -119,7 +119,7 @@ internal struct BigQueryStatementGenerator {

private func generateDelete(
for change: PluginRowChange
) -> (statement: String, parameters: [String?])? {
) -> (statement: String, parameters: [PluginCellValue])? {
guard let whereClause = buildWhereClause(from: change) else {
Self.logger.warning("Skipping DELETE - cannot build WHERE clause")
return nil
Expand All @@ -139,7 +139,7 @@ internal struct BigQueryStatementGenerator {
guard index < originalRow.count else { continue }
let typeName = index < columnTypeNames.count ? columnTypeNames[index] : "STRING"

if let value = originalRow[index] {
if let value = originalRow[index].asText {
// Skip complex types (STRUCT/ARRAY/RECORD) — BigQuery cannot compare with =
let trimmed = value.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") {
Expand Down
12 changes: 10 additions & 2 deletions Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ import TableProPluginKit
internal struct BigQueryTypeMapper {
// MARK: - Row Flattening

static func flattenRows(from response: BQQueryResponse, schema: BQTableSchema) -> [[String?]] {
static func flattenRows(from response: BQQueryResponse, schema: BQTableSchema) -> [[PluginCellValue]] {
guard let rows = response.rows, let fields = schema.fields else { return [] }
return rows.map { row in
flattenRow(cells: row.f ?? [], fields: fields)
let stringCells = flattenRow(cells: row.f ?? [], fields: fields)
return stringCells.enumerated().map { index, raw -> PluginCellValue in
guard let value = raw else { return .null }
let isBinary = (index < fields.count) && fields[index].type.uppercased() == "BYTES"
if isBinary, let data = Data(base64Encoded: value) {
return .bytes(data)
}
return .text(value)
}
}
}

Expand Down
12 changes: 9 additions & 3 deletions Plugins/CSVExportPlugin/CSVExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,22 @@ final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin {
// MARK: - Private

private func writeCSVRow(
_ row: [String?],
_ row: [PluginCellValue],
options: CSVExportOptions,
to fileHandle: FileHandle
) throws {
let delimiter = options.delimiter.actualValue
let lineBreak = options.lineBreak.value

let rowLine = row.map { value -> String in
guard let val = value else {
let rowLine = row.map { cell -> String in
let val: String
switch cell {
case .null:
return options.convertNullToEmpty ? "" : "NULL"
case .text(let s):
val = s
case .bytes(let d):
val = "0x" + d.map { String(format: "%02X", $0) }.joined()
}

var processed = val
Expand Down
44 changes: 30 additions & 14 deletions Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ private actor CassandraConnectionActor {
return extractResult(from: result, startTime: startTime)
}

func executePrepared(_ cql: String, parameters: [String?]) throws -> CassandraRawResult {
func executePrepared(_ cql: String, parameters: [PluginCellValue]) throws -> CassandraRawResult {
guard let session else {
throw CassandraPluginError.notConnected
}
Expand Down Expand Up @@ -413,7 +413,7 @@ private actor CassandraConnectionActor {
columnTypeNames.append(Self.cassTypeName(colType))
}

var rows: [[String?]] = []
var rows: [[PluginCellValue]] = []
let iterator = cass_iterator_from_result(result)
defer {
if let iterator { cass_iterator_free(iterator) }
Expand All @@ -437,13 +437,18 @@ private actor CassandraConnectionActor {
let row = cass_iterator_get_row(iterator)
guard let row else { continue }

var rowData: [String?] = []
var rowData: [PluginCellValue] = []
for col in 0..<colCount {
let value = cass_row_get_column(row, col)
if let value, cass_value_is_null(value) == cass_false {
rowData.append(Self.extractStringValue(value))
if cass_value_type(value) == CASS_VALUE_TYPE_BLOB,
let data = Self.extractBlobValue(value) {
rowData.append(.bytes(data))
} else {
rowData.append(PluginCellValue.fromOptional(Self.extractStringValue(value)))
}
} else {
rowData.append(nil)
rowData.append(.null)
}
}
rows.append(rowData)
Expand All @@ -461,6 +466,15 @@ private actor CassandraConnectionActor {
)
}

private static func extractBlobValue(_ value: OpaquePointer) -> Data? {
var bytes: UnsafePointer<UInt8>?
var length: Int = 0
guard cass_value_get_bytes(value, &bytes, &length) == CASS_OK, let bytes else {
return nil
}
return Data(bytes: bytes, count: length)
}

private static func extractStringValue(_ value: OpaquePointer) -> String? {
let valueType = cass_value_type(value)

Expand Down Expand Up @@ -546,10 +560,7 @@ private actor CassandraConnectionActor {
return nil

case CASS_VALUE_TYPE_BLOB:
var bytes: UnsafePointer<UInt8>?
var length: Int = 0
if cass_value_get_bytes(value, &bytes, &length) == CASS_OK, let bytes {
let data = Data(bytes: bytes, count: length)
if let data = extractBlobValue(value) {
return "0x" + data.map { String(format: "%02x", $0) }.joined()
}
return nil
Expand Down Expand Up @@ -782,13 +793,18 @@ private actor CassandraConnectionActor {
let row = cass_iterator_get_row(iterator)
guard let row else { continue }

var rowData: [String?] = []
var rowData: [PluginCellValue] = []
for col in 0..<colCount {
let value = cass_row_get_column(row, col)
if let value, cass_value_is_null(value) == cass_false {
rowData.append(Self.extractStringValue(value))
if cass_value_type(value) == CASS_VALUE_TYPE_BLOB,
let data = Self.extractBlobValue(value) {
rowData.append(.bytes(data))
} else {
rowData.append(PluginCellValue.fromOptional(Self.extractStringValue(value)))
}
} else {
rowData.append(nil)
rowData.append(.null)
}
}
continuation.yield(.rows([rowData]))
Expand Down Expand Up @@ -826,7 +842,7 @@ private actor CassandraConnectionActor {
private struct CassandraRawResult: Sendable {
let columns: [String]
let columnTypeNames: [String]
let rows: [[String?]]
let rows: [[PluginCellValue]]
let rowsAffected: Int
let executionTime: TimeInterval
}
Expand Down Expand Up @@ -938,7 +954,7 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen

func executeParameterized(
query: String,
parameters: [String?]
parameters: [PluginCellValue]
) async throws -> PluginQueryResult {
let rawResult = try await connectionActor.executePrepared(query, parameters: parameters)
return PluginQueryResult(
Expand Down
Loading
Loading