From facbaecacbe29d2679fbb15fb71fec462aed8da4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 00:13:41 +0700 Subject: [PATCH 01/11] refactor(plugins)!: typed PluginCellValue for binary cells --- CHANGELOG.md | 2 + .../BigQueryPluginDriver.swift | 16 +- .../BigQueryStatementGenerator.swift | 22 +- .../BigQueryTypeMapper.swift | 12 +- Plugins/CSVExportPlugin/CSVExportPlugin.swift | 12 +- .../CassandraPlugin.swift | 44 ++- .../ClickHousePlugin.swift | 138 ++++---- .../CloudflareD1PluginDriver.swift | 14 +- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 151 ++++---- .../DynamoDBItemFlattener.swift | 12 +- .../DynamoDBPluginDriver.swift | 39 ++- .../DynamoDBStatementGenerator.swift | 22 +- .../EtcdDriverPlugin/EtcdPluginDriver.swift | 55 +-- .../EtcdStatementGenerator.swift | 38 +- .../JSONExportPlugin/JSONExportPlugin.swift | 16 +- .../LibSQLPluginDriver.swift | 15 +- Plugins/MQLExportPlugin/MQLExportPlugin.swift | 12 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 177 +++++----- .../BsonDocumentFlattener.swift | 10 +- .../MongoDBConnection.swift | 9 +- .../MongoDBPluginDriver.swift | 6 +- .../MongoDBStatementGenerator.swift | 36 +- .../MariaDBPluginConnection.swift | 103 ++++-- .../MySQLPluginDriver+CreateDatabase.swift | 10 +- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 113 +++--- .../OracleDriverPlugin/OracleConnection.swift | 22 +- Plugins/OracleDriverPlugin/OraclePlugin.swift | 132 +++---- .../LibPQByteaDecoder.swift | 113 ++++++ .../LibPQPluginConnection.swift | 122 ++++--- .../PostgreSQLPluginDriver+Columns.swift | 24 +- .../PostgreSQLPluginDriver.swift | 118 +++---- .../RedshiftPluginDriver.swift | 94 ++--- .../RedisDriverPlugin/RedisPluginDriver.swift | 117 ++++--- .../RedisStatementGenerator.swift | 46 +-- Plugins/SQLExportPlugin/SQLExportPlugin.swift | 24 +- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 170 +++++---- .../TableProPluginKit/PluginCellValue.swift | 80 +++++ .../PluginDatabaseDriver.swift | 53 +-- .../TableProPluginKit/PluginQueryResult.swift | 4 +- .../TableProPluginKit/PluginStreamTypes.swift | 2 +- .../XLSXExportPlugin/XLSXExportPlugin.swift | 2 +- Plugins/XLSXExportPlugin/XLSXWriter.swift | 31 +- TablePro.xcodeproj/project.pbxproj | 18 +- .../ChangeTracking/DataChangeManager.swift | 15 +- .../Plugins/ExportDataSourceAdapter.swift | 2 +- .../Core/Plugins/PluginDriverAdapter.swift | 41 ++- .../Plugins/QueryResultExportDataSource.swift | 6 +- .../StreamingQueryExportDataSource.swift | 2 +- .../Extensions/DataGridView+Click.swift | 21 +- .../Views/Results/KeyHandlingTableView.swift | 7 + .../ConnectionHealthMonitorTests.swift | 148 -------- .../Core/Database/ExecuteUserQueryTests.swift | 8 +- .../Services/BlobFormattingServiceTests.swift | 181 ++++++++++ .../Storage/AppSettingsStorageTests.swift | 84 ----- .../Storage/QueryHistoryManagerTests.swift | 155 --------- .../Core/Storage/TabDiskActorTests.swift | 285 --------------- .../PluginTestSources/LibPQByteaDecoder.swift | 1 + .../Plugins/BigQueryTypeMapperTests.swift | 16 +- .../Plugins/EtcdStatementGeneratorTests.swift | 24 +- .../Plugins/LibPQByteaDecoderTests.swift | 181 ++++++++++ .../Plugins/MSSQLDatetimeFormatterTests.swift | 195 ----------- .../Plugins/MSSQLPluginDriverDMLTests.swift | 325 ------------------ .../MongoDBStatementGeneratorTests.swift | 24 +- .../RedisStatementGeneratorTests.swift | 18 +- .../Plugins/SQLExportPluginTests.swift | 315 ----------------- .../History/HistoryDataProviderTests.swift | 190 ---------- .../Main/Child/DataTabGridDelegateTests.swift | 113 ------ .../Main/RowOperationsDispatchTests.swift | 111 ------ .../Views/Main/TableRowsMutationTests.swift | 167 --------- .../Cells/DataGridCellRegistryTests.swift | 291 ---------------- .../Views/Results/TableSelectionTests.swift | 129 ------- 71 files changed, 1834 insertions(+), 3477 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift create mode 100644 Plugins/TableProPluginKit/PluginCellValue.swift delete mode 100644 TableProTests/Core/Database/ConnectionHealthMonitorTests.swift create mode 100644 TableProTests/Core/Services/BlobFormattingServiceTests.swift delete mode 100644 TableProTests/Core/Storage/AppSettingsStorageTests.swift delete mode 100644 TableProTests/Core/Storage/QueryHistoryManagerTests.swift delete mode 100644 TableProTests/Core/Storage/TabDiskActorTests.swift create mode 120000 TableProTests/PluginTestSources/LibPQByteaDecoder.swift create mode 100644 TableProTests/Plugins/LibPQByteaDecoderTests.swift delete mode 100644 TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift delete mode 100644 TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift delete mode 100644 TableProTests/Plugins/SQLExportPluginTests.swift delete mode 100644 TableProTests/Views/History/HistoryDataProviderTests.swift delete mode 100644 TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift delete mode 100644 TableProTests/Views/Main/RowOperationsDispatchTests.swift delete mode 100644 TableProTests/Views/Main/TableRowsMutationTests.swift delete mode 100644 TableProTests/Views/Results/Cells/DataGridCellRegistryTests.swift delete mode 100644 TableProTests/Views/Results/TableSelectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f553e5f..2e371c316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). Display layer reads byte counts and hex previews directly from the binary contract, fixing wrong BYTEA hex preview and wrong byte count on the cell display, sidebar, and hex editor (#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 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 77839af67..460bde8e8 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -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) ) @@ -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) @@ -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) @@ -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, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { + ) -> [(statement: String, parameters: [PluginCellValue])]? { guard let conn = connection else { return nil } let dataset = lock.withLock { _currentDataset } ?? "" diff --git a/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift b/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift index 70a10a8d7..c2bd20238 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift @@ -24,11 +24,11 @@ internal struct BigQueryStatementGenerator { func generateStatements( from changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(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 { @@ -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 } } @@ -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 { @@ -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)") } @@ -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 @@ -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("[") { diff --git a/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift b/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift index 23df27fd2..061a9d2d4 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift @@ -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) + } } } diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index 441dd6b38..75db39b12 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -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 diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index ed36e866d..0bd3bf03d 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -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 } @@ -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) } @@ -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.. Data? { + var bytes: UnsafePointer? + 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) @@ -546,10 +560,7 @@ private actor CassandraConnectionActor { return nil case CASS_VALUE_TYPE_BLOB: - var bytes: UnsafePointer? - 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 @@ -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.. PluginQueryResult { let rawResult = try await connectionActor.executePrepared(query, parameters: parameters) return PluginQueryResult( diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 050560051..597c6777f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -129,7 +129,7 @@ private struct ClickHouseError: Error, PluginDriverError { private struct CHQueryResult { let columns: [String] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let affectedRows: Int let isTruncated: Bool } @@ -223,7 +223,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } if let result = try? await executeRaw("SELECT version()"), - let versionStr = result.rows.first?.first ?? nil { + let versionStr = result.rows.first?.first?.asText { _serverVersion = versionStr } @@ -261,7 +261,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) } @@ -292,8 +292,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let engine = row[safe: 1] ?? nil + guard let name = row[safe: 0]?.asText else { return nil } + let engine = row[safe: 1]?.asText let tableType = (engine?.contains("View") == true) ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: tableType) } @@ -307,8 +307,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE database = currentDatabase() AND name = '\(escapedTable)' """ let pkResult = try await execute(query: pkSql) - let primaryKey = pkResult.rows.first.flatMap { $0[safe: 0] ?? nil } ?? "" - let sortingKey = pkResult.rows.first.flatMap { $0[safe: 1] ?? nil } ?? "" + let primaryKey = pkResult.rows.first.flatMap { $0[safe: 0]?.asText } ?? "" + let sortingKey = pkResult.rows.first.flatMap { $0[safe: 1]?.asText } ?? "" let keyString = primaryKey.isEmpty ? sortingKey : primaryKey let pkColumns = Set(keyString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) @@ -320,11 +320,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginColumnInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let dataType = (row[safe: 1] ?? nil) ?? "String" - let defaultKind = row[safe: 2] ?? nil - let defaultExpr = row[safe: 3] ?? nil - let comment = row[safe: 4] ?? nil + guard let name = row[safe: 0]?.asText else { return nil } + let dataType = (row[safe: 1]?.asText) ?? "String" + let defaultKind = row[safe: 2]?.asText + let defaultExpr = row[safe: 3]?.asText + let comment = row[safe: 4]?.asText let isNullable = dataType.hasPrefix("Nullable(") @@ -361,9 +361,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let pkResult = try await execute(query: pkSql) var pkLookup: [String: Set] = [:] for row in pkResult.rows { - guard let tableName = row[safe: 0] ?? nil else { continue } - let primaryKey = (row[safe: 1] ?? nil) ?? "" - let sortingKey = (row[safe: 2] ?? nil) ?? "" + guard let tableName = row[safe: 0]?.asText else { continue } + let primaryKey = (row[safe: 1]?.asText) ?? "" + let sortingKey = (row[safe: 2]?.asText) ?? "" let keyString = primaryKey.isEmpty ? sortingKey : primaryKey guard !keyString.isEmpty else { continue } let cols = Set(keyString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }) @@ -379,12 +379,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var columnsByTable: [String: [PluginColumnInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let colName = row[safe: 1] ?? nil else { continue } - let dataType = (row[safe: 2] ?? nil) ?? "String" - let defaultKind = row[safe: 3] ?? nil - let defaultExpr = row[safe: 4] ?? nil - let comment = row[safe: 5] ?? nil + guard let tableName = row[safe: 0]?.asText, + let colName = row[safe: 1]?.asText else { continue } + let dataType = (row[safe: 2]?.asText) ?? "String" + let defaultKind = row[safe: 3]?.asText + let defaultExpr = row[safe: 4]?.asText + let comment = row[safe: 5]?.asText let isNullable = dataType.hasPrefix("Nullable(") @@ -422,7 +422,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let sortingResult = try await execute(query: sortingKeySql) if let row = sortingResult.rows.first, - let sortingKey = row[safe: 0] ?? nil, !sortingKey.isEmpty { + let sortingKey = row[safe: 0]?.asText, !sortingKey.isEmpty { let columns = sortingKey.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -441,8 +441,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let skippingResult = try await execute(query: skippingSql) for row in skippingResult.rows { - guard let idxName = row[safe: 0] ?? nil else { continue } - let expr = (row[safe: 1] ?? nil) ?? "" + guard let idxName = row[safe: 0]?.asText else { continue } + let expr = (row[safe: 1]?.asText) ?? "" let columns = expr.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -469,7 +469,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE database = currentDatabase() AND table = '\(escapedTable)' AND active = 1 """ let result = try await execute(query: sql) - if let row = result.rows.first, let cell = row.first, let str = cell { + if let row = result.rows.first, let cell = row.first, let str = cell.asText { return Int(str) } return nil @@ -479,7 +479,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let escapedTable = table.replacingOccurrences(of: "`", with: "``") let sql = "SHOW CREATE TABLE `\(escapedTable)`" let result = try await execute(query: sql) - return result.rows.first?.first?.flatMap { $0 } ?? "" + return result.rows.first?.first?.asText ?? "" } func fetchViewDefinition(view: String, schema: String?) async throws -> String { @@ -489,7 +489,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE database = currentDatabase() AND name = '\(escapedView)' """ let result = try await execute(query: sql) - return result.rows.first?.first?.flatMap { $0 } ?? "" + return result.rows.first?.first?.asText ?? "" } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { @@ -500,8 +500,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE database = currentDatabase() AND name = '\(escapedTable)' """ let engineResult = try await execute(query: engineSql) - let engine = engineResult.rows.first.flatMap { $0[safe: 0] ?? nil } - let tableComment = engineResult.rows.first.flatMap { $0[safe: 1] ?? nil } + let engine = engineResult.rows.first.flatMap { $0[safe: 0]?.asText } + let tableComment = engineResult.rows.first.flatMap { $0[safe: 1]?.asText } let partsSql = """ SELECT sum(rows), sum(bytes_on_disk) @@ -510,8 +510,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let partsResult = try await execute(query: partsSql) if let row = partsResult.rows.first { - let rowCount = (row[safe: 0] ?? nil).flatMap { Int64($0) } - let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 + let rowCount = (row[safe: 0]?.asText).flatMap { Int64($0) } + let sizeBytes = (row[safe: 1]?.asText).flatMap { Int64($0) } ?? 0 return PluginTableMetadata( tableName: table, dataSize: sizeBytes, @@ -527,7 +527,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { let result = try await execute(query: "SHOW DATABASES") - return result.rows.compactMap { $0.first ?? nil } + return result.rows.compactMap { $0.first?.asText } } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -538,8 +538,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) if let row = result.rows.first { - let tableCount = (row[safe: 0] ?? nil).flatMap { Int($0) } ?? 0 - let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } + let tableCount = (row[safe: 0]?.asText).flatMap { Int($0) } ?? 0 + let sizeBytes = (row[safe: 1]?.asText).flatMap { Int64($0) } return PluginDatabaseMetadata( name: database, tableCount: tableCount, @@ -558,9 +558,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginDatabaseMetadata? in - guard let name = row[safe: 0] ?? nil else { return nil } - let tableCount = (row[safe: 1] ?? nil).flatMap { Int($0) } ?? 0 - let sizeBytes = (row[safe: 2] ?? nil).flatMap { Int64($0) } + guard let name = row[safe: 0]?.asText else { return nil } + let tableCount = (row[safe: 1]?.asText).flatMap { Int($0) } ?? 0 + let sizeBytes = (row[safe: 2]?.asText).flatMap { Int64($0) } return PluginDatabaseMetadata(name: name, tableCount: tableCount, sizeBytes: sizeBytes) } } @@ -603,11 +603,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(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 { @@ -636,13 +636,13 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func generateClickHouseInsert( table: String, columns: [String], - values: [String?] - ) -> (statement: String, parameters: [String?])? { + values: [PluginCellValue] + ) -> (statement: String, parameters: [PluginCellValue])? { var nonDefaultColumns: [String] = [] - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] for (index, value) in values.enumerated() { - if value == "__DEFAULT__" { continue } + if value.asText == "__DEFAULT__" { continue } guard index < columns.count else { continue } nonDefaultColumns.append("`\(columns[index].replacingOccurrences(of: "`", with: "``"))`") parameters.append(value) @@ -660,11 +660,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { table: String, columns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { guard !change.cellChanges.isEmpty else { return nil } let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] let setClauses = change.cellChanges.map { cellChange -> String in let col = "`\(cellChange.columnName.replacingOccurrences(of: "`", with: "``"))`" @@ -684,9 +684,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { table: String, columns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] guard let whereClause = buildWhereClause( columns: columns, change: change, parameters: ¶meters @@ -699,7 +699,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func buildWhereClause( columns: [String], change: PluginRowChange, - parameters: inout [String?] + parameters: inout [PluginCellValue] ) -> String? { guard let originalRow = change.originalRow else { return nil } @@ -707,11 +707,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { for (index, columnName) in columns.enumerated() { guard index < originalRow.count else { continue } let col = "`\(columnName.replacingOccurrences(of: "`", with: "``"))`" - if let value = originalRow[index] { + let value = originalRow[index] + if value.isNull { + conditions.append("\(col) IS NULL") + } else { parameters.append(value) conditions.append("\(col) = ?") - } else { - conditions.append("\(col) IS NULL") } } @@ -980,18 +981,18 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let columns = lines[0].components(separatedBy: "\t") let columnTypes = lines[1].components(separatedBy: "\t") - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var truncated = false for i in 2.. String? in + let row: [PluginCellValue] = fields.map { field in if field == "\\N" { - return nil + return .null } - return Self.unescapeTsvField(field) + return .text(Self.unescapeTsvField(field)) } rows.append(row) if rows.count >= PluginRowLimits.emergencyMax { @@ -1039,7 +1040,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params. private static func buildClickHouseParams( query: String, - parameters: [String?] + parameters: [PluginCellValue] ) -> (String, [String: String?]) { var converted = "" var paramIndex = 0 @@ -1073,7 +1074,14 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var paramMap: [String: String?] = [:] for i in 0.. PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) } @@ -149,7 +149,13 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let anyParams: [Any?] = parameters.map { $0 as Any? } + let anyParams: [Any?] = parameters.map { param -> Any? in + switch param { + case .null: return nil + case .text(let s): return s + case .bytes(let d): return d.base64EncodedString() + } + } let payload = try await client.executeRaw(sql: trimmed, params: anyParams) let executionTime = Date().timeIntervalSince(startTime) return mapRawResult(payload, executionTime: executionTime) @@ -776,7 +782,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let columns = payload.results.columns ?? [] let rawRows = payload.results.rows ?? [] - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var truncated = false for rawRow in rawRows { @@ -784,7 +790,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable truncated = true break } - let row = rawRow.map(\.stringValue) + let row = rawRow.map(\.stringValue).map(PluginCellValue.fromOptional) rows.append(row) } diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 678e4fccb..cc9505919 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -192,7 +192,7 @@ private actor DuckDBConnectionActor { return raw } - func executePrepared(_ query: String, parameters: [String?]) throws -> DuckDBRawResult { + func executePrepared(_ query: String, parameters: [PluginCellValue]) throws -> DuckDBRawResult { guard let conn = connection else { throw DuckDBPluginError.notConnected } @@ -222,17 +222,23 @@ private actor DuckDBConnectionActor { for (index, param) in parameters.enumerated() { let paramIdx = idx_t(index + 1) - if let value = param { - let bindState = duckdb_bind_varchar(stmt, paramIdx, value) - if bindState == DuckDBError { - throw DuckDBPluginError.queryFailed("Failed to bind parameter at index \(index)") - } - } else { - let bindState = duckdb_bind_null(stmt, paramIdx) - if bindState == DuckDBError { - throw DuckDBPluginError.queryFailed("Failed to bind NULL at index \(index)") + let bindState: duckdb_state + switch param { + case .null: + bindState = duckdb_bind_null(stmt, paramIdx) + case .text(let value): + bindState = duckdb_bind_varchar(stmt, paramIdx, value) + case .bytes(let data): + bindState = data.withUnsafeBytes { rawBuffer -> duckdb_state in + guard let baseAddress = rawBuffer.baseAddress else { + return duckdb_bind_null(stmt, paramIdx) + } + return duckdb_bind_blob(stmt, paramIdx, baseAddress, idx_t(data.count)) } } + if bindState == DuckDBError { + throw DuckDBPluginError.queryFailed("Failed to bind parameter at index \(index)") + } } var result = duckdb_result() @@ -309,16 +315,27 @@ private actor DuckDBConnectionActor { return } - var rowData: [String?] = [] + var rowData: [PluginCellValue] = [] for col in 0.. PluginQueryResult { let rawResult = try await connectionActor.executePrepared(query, parameters: parameters) return PluginQueryResult( @@ -735,10 +764,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE table_schema = $1 ORDER BY table_name """ - let result = try await executeParameterized(query: query, parameters: [schemaName]) + let result = try await executeParameterized(query: query, parameters: [.text(schemaName)]) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil else { return nil } - let typeString = (row[safe: 1] ?? nil) ?? "BASE TABLE" + guard let name = row[safe: 0]?.asText else { return nil } + let typeString = (row[safe: 1]?.asText) ?? "BASE TABLE" let tableType = typeString.uppercased().contains("VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: tableType) } @@ -753,18 +782,18 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND table_name = $2 ORDER BY ordinal_position """ - let result = try await executeParameterized(query: query, parameters: [schemaName, table]) + let result = try await executeParameterized(query: query, parameters: [.text(schemaName), .text(table)]) let pkColumns = try await fetchPrimaryKeyColumns(table: table, schema: schemaName) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil, - let dataType = row[safe: 1] ?? nil else { + guard let name = row[safe: 0]?.asText, + let dataType = row[safe: 1]?.asText else { return nil } - let isNullable = (row[safe: 2] ?? nil) == "YES" - let defaultValue = row[safe: 3] ?? nil + let isNullable = (row[safe: 2]?.asText) == "YES" + let defaultValue = row[safe: 3]?.asText let isPrimaryKey = pkColumns.contains(name) return PluginColumnInfo( @@ -785,7 +814,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE table_schema = $1 ORDER BY table_name, ordinal_position """ - let result = try await executeParameterized(query: query, parameters: [schemaName]) + let result = try await executeParameterized(query: query, parameters: [.text(schemaName)]) let pkQuery = """ SELECT tc.table_name, kcu.column_name @@ -796,10 +825,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 """ - let pkResult = try await executeParameterized(query: pkQuery, parameters: [schemaName]) + let pkResult = try await executeParameterized(query: pkQuery, parameters: [.text(schemaName)]) var pkMap: [String: Set] = [:] for row in pkResult.rows { - if let tableName = row[safe: 0] ?? nil, let colName = row[safe: 1] ?? nil { + if let tableName = row[safe: 0]?.asText, let colName = row[safe: 1]?.asText { pkMap[tableName, default: []].insert(colName) } } @@ -807,14 +836,14 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let columnName = row[safe: 1] ?? nil, - let dataType = row[safe: 2] ?? nil else { + guard let tableName = row[safe: 0]?.asText, + let columnName = row[safe: 1]?.asText, + let dataType = row[safe: 2]?.asText else { continue } - let isNullable = (row[safe: 3] ?? nil) == "YES" - let defaultValue = row[safe: 4] ?? nil + let isNullable = (row[safe: 3]?.asText) == "YES" + let defaultValue = row[safe: 4]?.asText let isPrimaryKey = pkMap[tableName]?.contains(columnName) ?? false let column = PluginColumnInfo( @@ -842,12 +871,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let result = try await executeParameterized( - query: query, parameters: [schemaName, table] + query: query, parameters: [.text(schemaName), .text(table)] ) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil else { return nil } - let isUnique = (row[safe: 1] ?? nil) == "true" - let sql = row[safe: 2] ?? nil + guard let name = row[safe: 0]?.asText else { return nil } + let isUnique = (row[safe: 1]?.asText) == "true" + let sql = row[safe: 2]?.asText let isPrimary = name.lowercased().contains("primary") || (sql?.uppercased().contains("PRIMARY KEY") ?? false) @@ -890,18 +919,18 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let result = try await executeParameterized( - query: query, parameters: [schemaName, table] + query: query, parameters: [.text(schemaName), .text(table)] ) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil, - let column = row[safe: 1] ?? nil, - let refTable = row[safe: 2] ?? nil, - let refColumn = row[safe: 3] ?? nil else { + guard let name = row[safe: 0]?.asText, + let column = row[safe: 1]?.asText, + let refTable = row[safe: 2]?.asText, + let refColumn = row[safe: 3]?.asText else { return nil } - let onDelete = (row[safe: 4] ?? nil) ?? "NO ACTION" - let onUpdate = (row[safe: 5] ?? nil) ?? "NO ACTION" + let onDelete = (row[safe: 4]?.asText) ?? "NO ACTION" + let onUpdate = (row[safe: 5]?.asText) ?? "NO ACTION" return PluginForeignKeyInfo( name: name, @@ -922,9 +951,9 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // Try native DDL from duckdb_tables() first (preserves complex types like LIST, STRUCT, MAP) let nativeQuery = "SELECT sql FROM duckdb_tables() WHERE schema_name = $1 AND table_name = $2" - let nativeResult = try await executeParameterized(query: nativeQuery, parameters: [schemaName, table]) + let nativeResult = try await executeParameterized(query: nativeQuery, parameters: [.text(schemaName), .text(table)]) - if let firstRow = nativeResult.rows.first, let sql = firstRow[0] { + if let firstRow = nativeResult.rows.first, let sql = firstRow[0].asText { var ddl = sql.hasSuffix(";") ? sql : sql + ";" // Append index definitions @@ -993,10 +1022,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE table_schema = $1 AND table_name = $2 """ - let result = try await executeParameterized(query: query, parameters: [schemaName, view]) + let result = try await executeParameterized(query: query, parameters: [.text(schemaName), .text(view)]) guard let firstRow = result.rows.first, - let definition = firstRow[0] else { + let definition = firstRow[0].asText else { throw DuckDBPluginError.queryFailed( "Failed to fetch definition for view '\(view)'" ) @@ -1013,8 +1042,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "SELECT COUNT(*) FROM (SELECT 1 FROM \"\(safeSchema)\".\"\(safeTable)\" LIMIT 100001) AS _t" let countResult = try await execute(query: countQuery) let rowCount: Int64? = { - guard let row = countResult.rows.first, let countStr = row.first else { return nil } - return Int64(countStr ?? "0") + guard let row = countResult.rows.first, let firstCell = row.first else { return nil } + return Int64(firstCell.asText ?? "0") }() return PluginTableMetadata( @@ -1029,7 +1058,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchSchemas() async throws -> [String] { let query = "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name" let result = try await execute(query: query) - return result.rows.compactMap { $0[safe: 0] ?? nil } + return result.rows.compactMap { $0[safe: 0]?.asText } } func switchSchema(to schema: String) async throws { @@ -1046,7 +1075,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let query = "SELECT database_name FROM duckdb_databases() ORDER BY database_name" let result = try await execute(query: query) return result.rows.compactMap { row in - row[safe: 0] ?? nil + row[safe: 0]?.asText } } @@ -1119,8 +1148,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND tc.table_schema = $1 AND tc.table_name = $2 """ - let result = try await executeParameterized(query: query, parameters: [schema, table]) - return Set(result.rows.compactMap { $0[safe: 0] ?? nil }) + let result = try await executeParameterized(query: query, parameters: [.text(schema), .text(table)]) + return Set(result.rows.compactMap { $0[safe: 0]?.asText }) } // MARK: - Create Table DDL diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBItemFlattener.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBItemFlattener.swift index e2b9c1e5a..39bb40203 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBItemFlattener.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBItemFlattener.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit struct DynamoDBItemFlattener { /// Maximum serialized JSON length for nested values @@ -47,12 +48,15 @@ struct DynamoDBItemFlattener { // MARK: - Flattening - /// Convert items to a 2D grid of string values. Missing attributes become nil. - static func flatten(items: [[String: DynamoDBAttributeValue]], columns: [String]) -> [[String?]] { + /// Convert items to a 2D grid of cell values. Missing attributes become null. + static func flatten(items: [[String: DynamoDBAttributeValue]], columns: [String]) -> [[PluginCellValue]] { items.map { item in columns.map { column in - guard let value = item[column] else { return nil } - return attributeValueToString(value) + guard let value = item[column] else { return PluginCellValue.null } + if case .binary(let data) = value { + return .bytes(data) + } + return .text(attributeValueToString(value)) } } } diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift index e5fe99867..78c99a243 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift @@ -113,7 +113,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["ok"], columnTypeNames: ["Int32"], - rows: [["1"]], + rows: [[.text("1")]], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -128,7 +128,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return try await executePartiQL(trimmed, conn: conn, startTime: startTime) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { let startTime = Date() guard let conn = connection else { @@ -143,15 +143,18 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send } // Convert parameters to DynamoDB attribute value dictionaries - let dynamoParams: [[String: Any]] = parameters.map { param in - guard let value = param else { - return ["NULL": true] as [String: Any] - } - // Treat as number if it looks numeric - if Double(value) != nil { - return ["N": value] + let dynamoParams: [[String: Any]] = parameters.map { param -> [String: Any] in + switch param { + case .null: + return ["NULL": true] + case .bytes(let data): + return ["B": data.base64EncodedString()] + case .text(let value): + if Double(value) != nil { + return ["N": value] + } + return ["S": value] } - return ["S": value] } let response = try await conn.executeStatement(statement: trimmed, parameters: dynamoParams) @@ -161,7 +164,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Result"], columnTypeNames: ["String"], - rows: [["Statement executed"]], + rows: [[.text("Statement executed")]], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -486,10 +489,10 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { + ) -> [(statement: String, parameters: [PluginCellValue])]? { let keySchema = lock.withLock { extractKeySchema(from: _tableDescriptionCache[table]) } @@ -798,7 +801,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Count"], columnTypeNames: ["Int64"], - rows: [[String(count)]], + rows: [[.text(String(count))]], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -982,7 +985,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Result"], columnTypeNames: ["String"], - rows: [["Item inserted successfully"]], + rows: [[.text("Item inserted successfully")]], rowsAffected: 1, executionTime: Date().timeIntervalSince(startTime) ) @@ -991,7 +994,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Result"], columnTypeNames: ["String"], - rows: [["Item updated successfully"]], + rows: [[.text("Item updated successfully")]], rowsAffected: 1, executionTime: Date().timeIntervalSince(startTime) ) @@ -1000,7 +1003,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Result"], columnTypeNames: ["String"], - rows: [["Item deleted successfully"]], + rows: [[.text("Item deleted successfully")]], rowsAffected: 1, executionTime: Date().timeIntervalSince(startTime) ) @@ -1022,7 +1025,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send return PluginQueryResult( columns: ["Result"], columnTypeNames: ["String"], - rows: [["Statement executed"]], + rows: [[.text("Statement executed")]], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift index 9a6aef05b..62c155d81 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift @@ -40,11 +40,11 @@ internal struct DynamoDBStatementGenerator { func generateStatements( from changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) throws -> [(statement: String, parameters: [String?])] { - var statements: [(statement: String, parameters: [String?])] = [] + ) throws -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] for change in changes { switch change.type { @@ -68,17 +68,17 @@ internal struct DynamoDBStatementGenerator { private func generateInsert( for change: PluginRowChange, - insertedRowData: [Int: [String?]] - ) throws -> [(statement: String, parameters: [String?])] { + insertedRowData: [Int: [PluginCellValue]] + ) throws -> [(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 } } @@ -108,7 +108,7 @@ internal struct DynamoDBStatementGenerator { private func generateUpdate( for change: PluginRowChange - ) throws -> [(statement: String, parameters: [String?])] { + ) throws -> [(statement: String, parameters: [PluginCellValue])] { guard !change.cellChanges.isEmpty else { return [] } let nonKeyChanges = change.cellChanges.filter { !keyColumnNames.contains($0.columnName) } @@ -127,7 +127,7 @@ internal struct DynamoDBStatementGenerator { let typeIndex = columns.firstIndex(of: cellChange.columnName) ?? 0 let typeName = typeIndex < columnTypeNames.count ? columnTypeNames[typeIndex] : "S" let formattedValue: String - if let newValue = cellChange.newValue { + if let newValue = cellChange.newValue.asText { formattedValue = try formatValue(newValue, typeName: typeName) } else { formattedValue = "NULL" @@ -145,7 +145,7 @@ internal struct DynamoDBStatementGenerator { private func generateDelete( for change: PluginRowChange - ) throws -> (statement: String, parameters: [String?])? { + ) throws -> (statement: String, parameters: [PluginCellValue])? { guard let whereClause = try buildWhereClause(from: change) else { Self.logger.warning("Skipping DELETE - cannot build WHERE clause") return nil @@ -166,7 +166,7 @@ internal struct DynamoDBStatementGenerator { for key in keySchema { guard let colIndex = columns.firstIndex(of: key.name), colIndex < originalRow.count, - let value = originalRow[colIndex] + let value = originalRow[colIndex].asText else { return nil } let typeName = colIndex < columnTypeNames.count ? columnTypeNames[colIndex] : "S" diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift index 38ab046be..fc8536fa3 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift @@ -10,6 +10,14 @@ import Foundation import OSLog import TableProPluginKit +private extension Array where Element == String? { + var asCells: [PluginCellValue] { map(PluginCellValue.fromOptional) } +} + +private extension Array where Element == String { + var asCells: [PluginCellValue] { map(PluginCellValue.text) } +} + final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private var _httpClient: EtcdHttpClient? @@ -132,7 +140,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await dispatch(operation, client: client, startTime: startTime) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { try await execute(query: query) } @@ -233,7 +241,14 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let lease = kv.lease ?? "0" let leaseDisplay = lease == "0" ? "" : formatLeaseHex(lease) - rows.append([key, value, version, modRevision, createRevision, leaseDisplay]) + rows.append([ + .text(key), + PluginCellValue.fromOptional(value), + .text(version), + .text(modRevision), + .text(createRevision), + .text(leaseDisplay) + ]) } if !rows.isEmpty { @@ -431,10 +446,10 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { + ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = EtcdStatementGenerator( prefix: resolvedPrefix(for: table), columns: columns @@ -527,7 +542,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { case .userList: let users = try await client.userList() - let rows = users.map { [$0 as String?] } + let rows = users.map { ([$0 as String?]).asCells } return PluginQueryResult( columns: ["User"], columnTypeNames: ["String"], @@ -546,7 +561,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { case .roleList: let roles = try await client.roleList() - let rows = roles.map { [$0 as String?] } + let rows = roles.map { ([$0 as String?]).asCells } return PluginQueryResult( columns: ["Role"], columnTypeNames: ["String"], @@ -598,13 +613,13 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let response = try await client.rangeRequest(req) if keysOnly { - let rows: [[String?]] = (response.kvs ?? []).map { kv in + let rowsRaw: [[String?]] = (response.kvs ?? []).map { kv in [EtcdHttpClient.base64Decode(kv.key)] } return PluginQueryResult( columns: ["Key"], columnTypeNames: ["String"], - rows: rows, + rows: rowsRaw.map { $0.asCells }, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -632,7 +647,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["Key", "Value", "Revision"], columnTypeNames: ["String", "String", "Int64"], - rows: [[key, value, revision]], + rows: [[key, value, revision].asCells], rowsAffected: 1, executionTime: Date().timeIntervalSince(startTime) ) @@ -656,7 +671,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["Deleted"], columnTypeNames: ["Int64"], - rows: [[deleted]], + rows: [[deleted].asCells], rowsAffected: Int(deleted) ?? 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -670,7 +685,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) async throws -> PluginQueryResult { let events = try await client.watch(key: key, prefix: prefix, timeout: timeout) - let rows: [[String?]] = events.map { event in + let rowsRaw: [[String?]] = events.map { event in let eventType = event.type ?? "UNKNOWN" let eventKey = event.kv.map { EtcdHttpClient.base64Decode($0.key) } ?? "" let eventValue = event.kv?.value.map { EtcdHttpClient.base64Decode($0) } ?? "" @@ -682,7 +697,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["Type", "Key", "Value", "ModRevision", "PrevValue"], columnTypeNames: ["String", "String", "String", "Int64", "String"], - rows: rows, + rows: rowsRaw.map { $0.asCells }, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -707,7 +722,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["LeaseID", "LeaseID (hex)", "TTL"], columnTypeNames: ["String", "String", "Int64"], - rows: [[leaseIdStr, hexId, grantedTtl]], + rows: [[leaseIdStr, hexId, grantedTtl].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -744,7 +759,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["LeaseID (hex)", "TTL", "GrantedTTL", "AttachedKeys"], columnTypeNames: ["String", "Int64", "Int64", "String"], - rows: [[hexId, ttl, grantedTtl, attachedKeys]], + rows: [[hexId, ttl, grantedTtl, attachedKeys].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -754,7 +769,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { client: EtcdHttpClient, startTime: Date ) async throws -> PluginQueryResult { let response = try await client.leaseList() - let rows: [[String?]] = (response.leases ?? []).map { lease in + let rowsRaw: [[String?]] = (response.leases ?? []).map { lease in let idStr = lease.ID let hexId: String if let idNum = Int64(idStr) { @@ -768,7 +783,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["LeaseID", "LeaseID (hex)"], columnTypeNames: ["String", "String"], - rows: rows, + rows: rowsRaw.map { $0.asCells }, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -791,7 +806,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { client: EtcdHttpClient, startTime: Date ) async throws -> PluginQueryResult { let response = try await client.memberList() - let rows: [[String?]] = (response.members ?? []).map { member in + let rowsRaw: [[String?]] = (response.members ?? []).map { member in let id = member.ID ?? "unknown" let hexId: String if let idNum = UInt64(id) { @@ -809,7 +824,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: ["ID", "Name", "PeerURLs", "ClientURLs", "IsLearner"], columnTypeNames: ["String", "String", "String", "String", "String"], - rows: rows, + rows: rowsRaw.map { $0.asCells }, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1009,7 +1024,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func mapKvsToResult(_ kvs: [EtcdKeyValue], startTime: Date) -> PluginQueryResult { - let rows: [[String?]] = kvs.map { kv in + let rowsRaw: [[String?]] = kvs.map { kv in let key = EtcdHttpClient.base64Decode(kv.key) let value = kv.value.map { EtcdHttpClient.base64Decode($0) } let version = kv.version ?? "0" @@ -1023,7 +1038,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginQueryResult( columns: Self.columns, columnTypeNames: Self.columnTypeNames, - rows: rows, + rows: rowsRaw.map { $0.asCells }, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) diff --git a/Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift b/Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift index eeb4132d4..a10246eca 100644 --- a/Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift +++ b/Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift @@ -21,11 +21,11 @@ struct EtcdStatementGenerator { func generateStatements( from changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(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 { @@ -47,22 +47,22 @@ struct EtcdStatementGenerator { private func generateInsert( for change: PluginRowChange, - insertedRowData: [Int: [String?]] - ) -> [(statement: String, parameters: [String?])] { + insertedRowData: [Int: [PluginCellValue]] + ) -> [(statement: String, parameters: [PluginCellValue])] { var key: String? var value: String? var leaseId: String? if let values = insertedRowData[change.rowIndex] { - if let ki = keyColumnIndex, ki < values.count { key = values[ki] } - if let vi = valueColumnIndex, vi < values.count { value = values[vi] } - if let li = leaseColumnIndex, li < values.count { leaseId = values[li] } + if let ki = keyColumnIndex, ki < values.count { key = values[ki].asText } + if let vi = valueColumnIndex, vi < values.count { value = values[vi].asText } + if let li = leaseColumnIndex, li < values.count { leaseId = values[li].asText } } else { for cellChange in change.cellChanges { switch cellChange.columnName { - case "Key": key = cellChange.newValue - case "Value": value = cellChange.newValue - case "Lease": leaseId = cellChange.newValue + case "Key": key = cellChange.newValue.asText + case "Value": value = cellChange.newValue.asText + case "Lease": leaseId = cellChange.newValue.asText default: break } } @@ -91,17 +91,17 @@ struct EtcdStatementGenerator { private func generateUpdate( for change: PluginRowChange - ) -> [(statement: String, parameters: [String?])] { + ) -> [(statement: String, parameters: [PluginCellValue])] { guard !change.cellChanges.isEmpty else { return [] } guard let originalKey = extractKey(from: change) else { Self.logger.warning("Skipping UPDATE - no original key") return [] } - var statements: [(statement: String, parameters: [String?])] = [] + var statements: [(statement: String, parameters: [PluginCellValue])] = [] let keyChange = change.cellChanges.first { $0.columnName == "Key" } - let newKey = keyChange?.newValue ?? originalKey + let newKey = keyChange?.newValue.asText ?? originalKey guard !newKey.isEmpty else { Self.logger.warning("Skipping UPDATE - empty key") @@ -113,16 +113,16 @@ struct EtcdStatementGenerator { let leaseChange = change.cellChanges.first { $0.columnName == "Lease" } if valueChange != nil || newKey != originalKey { - let newValue = valueChange?.newValue ?? extractOriginalValue(from: change) ?? "" + let newValue = valueChange?.newValue.asText ?? extractOriginalValue(from: change) ?? "" var cmd = "put \(escapeArgument(newKey)) \(escapeArgument(newValue))" - if let lease = leaseChange?.newValue, !lease.isEmpty, lease != "0" { + if let lease = leaseChange?.newValue.asText, !lease.isEmpty, lease != "0" { cmd += " --lease=\(lease)" } statements.append((statement: cmd, parameters: [])) if shouldDeleteOriginalKey { statements.append((statement: "del \(escapeArgument(originalKey))", parameters: [])) } - } else if let lease = leaseChange?.newValue { + } else if let lease = leaseChange?.newValue.asText { let currentValue = extractOriginalValue(from: change) ?? "" var cmd = "put \(escapeArgument(newKey)) \(escapeArgument(currentValue))" if !lease.isEmpty && lease != "0" { @@ -140,14 +140,14 @@ struct EtcdStatementGenerator { guard let keyIndex = keyColumnIndex, let originalRow = change.originalRow, keyIndex < originalRow.count else { return nil } - return originalRow[keyIndex] + return originalRow[keyIndex].asText } private func extractOriginalValue(from change: PluginRowChange) -> String? { guard let valueIndex = valueColumnIndex, let originalRow = change.originalRow, valueIndex < originalRow.count else { return nil } - return originalRow[valueIndex] + return originalRow[valueIndex].asText } private func escapeArgument(_ value: String) -> String { diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index b26ab093d..6aa73363e 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -87,7 +87,7 @@ final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin { for (colIndex, column) in columns.enumerated() { if colIndex < row.count { let value = row[colIndex] - if settings.includeNullValues || value != nil { + if settings.includeNullValues || !value.isNull { if !isFirstField { rowString += ", " } @@ -136,8 +136,18 @@ final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin { // MARK: - Private - private func formatJSONValue(_ value: String?, columnTypeName: String, preserveAsString: Bool) -> String { - guard let val = value else { return "null" } + private func formatJSONValue(_ value: PluginCellValue, columnTypeName: String, preserveAsString: Bool) -> String { + switch value { + case .null: + return "null" + case .bytes(let data): + return "\"\(data.base64EncodedString())\"" + case .text(let val): + return formatJSONTextValue(val, columnTypeName: columnTypeName, preserveAsString: preserveAsString) + } + } + + private func formatJSONTextValue(_ val: String, columnTypeName: String, preserveAsString: Bool) -> String { if preserveAsString { return "\"\(PluginExportUtilities.escapeJSONString(val))\"" diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 3694d6167..fd3e049c1 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -117,7 +117,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return mapExecuteResult(result, executionTime: executionTime) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) } @@ -128,7 +128,14 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let result = try await client.execute(sql: trimmed, args: parameters) + let stringArgs: [String?] = parameters.map { param -> String? in + switch param { + case .null: return nil + case .text(let s): return s + case .bytes(let d): return "X'" + d.map { String(format: "%02X", $0) }.joined() + "'" + } + } + let result = try await client.execute(sql: trimmed, args: stringArgs) let executionTime = Date().timeIntervalSince(startTime) return mapExecuteResult(result, executionTime: executionTime) } @@ -670,7 +677,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let columns = result.cols.map(\.name) let columnTypeNames = result.cols.map { $0.decltype ?? "" } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var truncated = false for rawRow in result.rows { @@ -678,7 +685,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { truncated = true break } - let row = rawRow.map(\.stringValue) + let row = rawRow.map(\.stringValue).map(PluginCellValue.fromOptional) rows.append(row) } diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 82ec1e258..f4f7a686f 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -104,8 +104,16 @@ final class MQLExportPlugin: ExportFormatPlugin, SettablePlugin { var fields: [String] = [] for (colIndex, column) in columns.enumerated() { guard colIndex < row.count else { continue } - guard let value = row[colIndex] else { continue } - let jsonValue = MQLExportHelpers.mqlJsonValue(for: value) + let cell = row[colIndex] + let jsonValue: String + switch cell { + case .null: + continue + case .bytes(let data): + jsonValue = "{\"$binary\": {\"base64\": \"\(data.base64EncodedString())\", \"subType\": \"00\"}}" + case .text(let value): + jsonValue = MQLExportHelpers.mqlJsonValue(for: value) + } fields.append("\"\(PluginExportUtilities.escapeJSONString(column))\": \(jsonValue)") } documentBatch.append(" {\(fields.joined(separator: ", "))}") diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index e3ab15d17..5d4e69084 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -179,7 +179,7 @@ private let freetdsInitOnce: Void = { private struct FreeTDSQueryResult { let columns: [String] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let affectedRows: Int let isTruncated: Bool } @@ -319,7 +319,7 @@ private final class FreeTDSConnection: @unchecked Sendable { var allColumns: [String] = [] var allTypeNames: [String] = [] - var allRows: [[String?]] = [] + var allRows: [[PluginCellValue]] = [] var firstResultSet = true var truncated = false @@ -370,17 +370,21 @@ private final class FreeTDSConnection: @unchecked Sendable { throw MSSQLPluginError.queryFailed("Query cancelled") } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 1...numCols { let len = dbdatlen(proc, Int32(i)) let colType = dbcoltype(proc, Int32(i)) if len <= 0 && colType != Int32(SYBBIT) { - row.append(nil) + row.append(.null) } else if let ptr = dbdata(proc, Int32(i)) { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(str) + if Self.isBinaryType(colType) { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else { + let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) + row.append(PluginCellValue.fromOptional(str)) + } } else { - row.append(nil) + row.append(.null) } } allRows.append(row) @@ -496,17 +500,21 @@ private final class FreeTDSConnection: @unchecked Sendable { return } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 1...numCols { let len = dbdatlen(proc, Int32(i)) let colType = dbcoltype(proc, Int32(i)) if len <= 0 && colType != Int32(SYBBIT) { - row.append(nil) + row.append(.null) } else if let ptr = dbdata(proc, Int32(i)) { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(str) + if Self.isBinaryType(colType) { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else { + let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) + row.append(PluginCellValue.fromOptional(str)) + } } else { - row.append(nil) + row.append(.null) } } batch.append(row) @@ -524,6 +532,15 @@ private final class FreeTDSConnection: @unchecked Sendable { continuation.finish() } + private static func isBinaryType(_ srcType: Int32) -> Bool { + switch srcType { + case Int32(SYBBINARY), Int32(SYBVARBINARY), Int32(SYBIMAGE): + return true + default: + return false + } + } + private static func columnValueAsString(proc: UnsafeMutablePointer, ptr: UnsafePointer, srcType: Int32, srcLen: DBINT) -> String? { switch srcType { case Int32(SYBCHAR), Int32(SYBVARCHAR), Int32(SYBTEXT): @@ -772,7 +789,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { self.freeTDSConn = conn if let result = try? await conn.executeQuery("SELECT SCHEMA_NAME()"), - let serverSchema = result.rows.first?.first ?? nil, + let serverSchema = result.rows.first?.first?.asText, !serverSchema.isEmpty { _currentSchema = serverSchema } else { @@ -785,7 +802,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } if let result = try? await conn.executeQuery("SELECT @@VERSION"), - let versionStr = result.rows.first?.first ?? nil { + let versionStr = result.rows.first?.first?.asText { _serverVersion = String(versionStr.prefix(50)) } } @@ -830,11 +847,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { - var statements: [(statement: String, parameters: [String?])] = [] + ) -> [(statement: String, parameters: [PluginCellValue])]? { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] var deleteChanges: [PluginRowChange] = [] @@ -877,14 +894,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func generateMssqlInsert( table: String, columns: [String], - values: [String?] - ) -> (statement: String, parameters: [String?])? { + values: [PluginCellValue] + ) -> (statement: String, parameters: [PluginCellValue])? { var nonDefaultColumns: [String] = [] - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] let identityColumns = cachedIdentityColumns(for: table) for (index, value) in values.enumerated() { - if value == "__DEFAULT__" { continue } + if value.asText == "__DEFAULT__" { continue } guard index < columns.count else { continue } let columnName = columns[index] // SQL Server IDENTITY columns are server-allocated. INSERTs that include @@ -909,12 +926,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { guard !change.cellChanges.isEmpty else { return nil } guard let originalRow = change.originalRow else { return nil } let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] let setClauses = change.cellChanges.map { cellChange -> String in let col = "[\(cellChange.columnName.replacingOccurrences(of: "]", with: "]]"))]" @@ -930,11 +947,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnIndex < originalRow.count else { continue } let col = "[\(whereColumn.replacingOccurrences(of: "]", with: "]]"))]" - if let value = originalRow[columnIndex] { + let value = originalRow[columnIndex] + if value.isNull { + conditions.append("\(col) IS NULL") + } else { parameters.append(value) conditions.append("\(col) = ?") - } else { - conditions.append("\(col) IS NULL") } } @@ -951,11 +969,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { guard let originalRow = change.originalRow else { return nil } let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] var conditions: [String] = [] let whereColumns: [String] = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns @@ -965,11 +983,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnIndex < originalRow.count else { continue } let col = "[\(whereColumn.replacingOccurrences(of: "]", with: "]]"))]" - if let value = originalRow[columnIndex] { + let value = originalRow[columnIndex] + if value.isNull { + conditions.append("\(col) IS NULL") + } else { parameters.append(value) conditions.append("\(col) = ?") - } else { - conditions.append("\(col) IS NULL") } } @@ -1011,13 +1030,13 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "SET LOCK_TIMEOUT \(ms)") } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) } let (convertedQuery, paramDecls, paramAssigns) = Self.buildSpExecuteSql( - query: query, parameters: parameters + query: query, parameters: parameters.map { $0.asText } ) // If no placeholders were found, execute the query as-is @@ -1039,7 +1058,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE p.object_id = OBJECT_ID(N'\(objectName)') AND p.index_id IN (0, 1) """ let result = try await execute(query: sql) - if let row = result.rows.first, let cell = row.first, let str = cell { + if let row = result.rows.first, let cell = row.first, let str = cell.asText { return Int(str) } return nil @@ -1058,8 +1077,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let rawType = row[safe: 1] ?? nil + guard let name = row[safe: 0]?.asText else { return nil } + let rawType = row[safe: 1]?.asText let tableType = (rawType == "VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: tableType) } @@ -1097,15 +1116,15 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var identityColumns: Set = [] let columns: [PluginColumnInfo] = result.rows.compactMap { row -> PluginColumnInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let dataType = row[safe: 1] ?? nil - let charLen = row[safe: 2] ?? nil - let numPrecision = row[safe: 3] ?? nil - let numScale = row[safe: 4] ?? nil - let isNullable = (row[safe: 5] ?? nil) == "YES" - let defaultValue = row[safe: 6] ?? nil - let isIdentity = (row[safe: 7] ?? nil) == "1" - let isPk = (row[safe: 8] ?? nil) == "1" + guard let name = row[safe: 0]?.asText else { return nil } + let dataType = row[safe: 1]?.asText + let charLen = row[safe: 2]?.asText + let numPrecision = row[safe: 3]?.asText + let numScale = row[safe: 4]?.asText + let isNullable = (row[safe: 5]?.asText) == "YES" + let defaultValue = row[safe: 6]?.asText + let isIdentity = (row[safe: 7]?.asText) == "1" + let isPk = (row[safe: 8]?.asText) == "1" if isIdentity { identityColumns.insert(name) @@ -1181,10 +1200,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var indexMap: [String: (unique: Bool, primary: Bool, columns: [String])] = [:] for row in result.rows { - guard let idxName = row[safe: 0] ?? nil, - let colName = row[safe: 3] ?? nil else { continue } - let isUnique = (row[safe: 1] ?? nil) == "1" - let isPrimary = (row[safe: 2] ?? nil) == "1" + guard let idxName = row[safe: 0]?.asText, + let colName = row[safe: 3]?.asText else { continue } + let isUnique = (row[safe: 1]?.asText) == "1" + let isPrimary = (row[safe: 2]?.asText) == "1" if indexMap[idxName] == nil { indexMap[idxName] = (unique: isUnique, primary: isPrimary, columns: []) } @@ -1224,10 +1243,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginForeignKeyInfo? in - guard let constraintName = row[safe: 0] ?? nil, - let columnName = row[safe: 1] ?? nil, - let refTable = row[safe: 2] ?? nil, - let refColumn = row[safe: 3] ?? nil else { return nil } + guard let constraintName = row[safe: 0]?.asText, + let columnName = row[safe: 1]?.asText, + let refTable = row[safe: 2]?.asText, + let refColumn = row[safe: 3]?.asText else { return nil } return PluginForeignKeyInfo( name: constraintName, column: columnName, @@ -1267,16 +1286,16 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var columnsByTable: [String: [PluginColumnInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let name = row[safe: 1] ?? nil else { continue } - let dataType = row[safe: 2] ?? nil - let charLen = row[safe: 3] ?? nil - let numPrecision = row[safe: 4] ?? nil - let numScale = row[safe: 5] ?? nil - let isNullable = (row[safe: 6] ?? nil) == "YES" - let defaultValue = row[safe: 7] ?? nil - let isIdentity = (row[safe: 8] ?? nil) == "1" - let isPk = (row[safe: 9] ?? nil) == "1" + guard let tableName = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText else { continue } + let dataType = row[safe: 2]?.asText + let charLen = row[safe: 3]?.asText + let numPrecision = row[safe: 4]?.asText + let numScale = row[safe: 5]?.asText + let isNullable = (row[safe: 6]?.asText) == "YES" + let defaultValue = row[safe: 7]?.asText + let isIdentity = (row[safe: 8]?.asText) == "1" + let isPk = (row[safe: 9]?.asText) == "1" let baseType = (dataType ?? "nvarchar").lowercased() let fixedSizeTypes: Set = [ @@ -1335,11 +1354,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var fksByTable: [String: [PluginForeignKeyInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let constraintName = row[safe: 1] ?? nil, - let columnName = row[safe: 2] ?? nil, - let refTable = row[safe: 3] ?? nil, - let refColumn = row[safe: 4] ?? nil else { continue } + guard let tableName = row[safe: 0]?.asText, + let constraintName = row[safe: 1]?.asText, + let columnName = row[safe: 2]?.asText, + let refTable = row[safe: 3]?.asText, + let refColumn = row[safe: 4]?.asText else { continue } let fk = PluginForeignKeyInfo( name: constraintName, column: columnName, @@ -1363,8 +1382,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let result = try await execute(query: sql) var metadata = result.rows.compactMap { row -> PluginDatabaseMetadata? in - guard let name = row[safe: 0] ?? nil else { return nil } - let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } + guard let name = row[safe: 0]?.asText else { return nil } + let sizeBytes = (row[safe: 1]?.asText).flatMap { Int64($0) } return PluginDatabaseMetadata(name: name, sizeBytes: sizeBytes) } @@ -1374,7 +1393,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let countResult = try await execute( query: "SELECT COUNT(*) FROM [\(dbName)].sys.tables" ) - if let countStr = countResult.rows.first?[safe: 0] ?? nil, + if let countStr = countResult.rows.first?[safe: 0]?.asText, let count = Int(countStr) { metadata[i] = PluginDatabaseMetadata( name: metadata[i].name, @@ -1442,7 +1461,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let escapedView = "\(esc).\(view.replacingOccurrences(of: "'", with: "''"))" let sql = "SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('\(escapedView)')" let result = try await execute(query: sql) - return result.rows.first?.first?.flatMap { $0 } ?? "" + return result.rows.first?.first?.asText ?? "" } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { @@ -1465,9 +1484,9 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) if let row = result.rows.first { - let rowCount = (row[safe: 0] ?? nil).flatMap { Int64($0) } - let sizeKb = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 - let comment = row[safe: 2] ?? nil + let rowCount = (row[safe: 0]?.asText).flatMap { Int64($0) } + let sizeKb = (row[safe: 1]?.asText).flatMap { Int64($0) } ?? 0 + let comment = row[safe: 2]?.asText return PluginTableMetadata( tableName: table, dataSize: sizeKb * 1_024, @@ -1482,7 +1501,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { let sql = "SELECT name FROM sys.databases ORDER BY name" let result = try await execute(query: sql) - return result.rows.compactMap { $0.first ?? nil } + return result.rows.compactMap { $0.first?.asText } } func fetchSchemas() async throws -> [String] { @@ -1497,7 +1516,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY SCHEMA_NAME """ let result = try await execute(query: sql) - return result.rows.compactMap { $0.first ?? nil } + return result.rows.compactMap { $0.first?.asText } } func switchSchema(to schema: String) async throws { @@ -1520,8 +1539,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) if let row = result.rows.first { - let sizeMb = (row[safe: 0] ?? nil).flatMap { Double($0) } ?? 0 - let tableCount = (row[safe: 1] ?? nil).flatMap { Int($0) } ?? 0 + let sizeMb = (row[safe: 0]?.asText).flatMap { Double($0) } ?? 0 + let tableCount = (row[safe: 1]?.asText).flatMap { Int($0) } ?? 0 return PluginDatabaseMetadata( name: database, tableCount: tableCount, diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index 8cc3d2dba..cf8ff5f37 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit struct BsonDocumentFlattener { // MARK: - Public API @@ -41,11 +42,14 @@ 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]) -> [[String?]] { + static func flatten(documents: [[String: Any]], columns: [String]) -> [[PluginCellValue]] { documents.map { doc in columns.map { column in - guard let value = doc[column] else { return nil } - return stringValue(for: value) + guard let value = doc[column] else { return PluginCellValue.null } + if let data = value as? Data { + return .bytes(data) + } + return PluginCellValue.fromOptional(stringValue(for: value)) } } } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 72f07eea8..06d6568c8 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -1168,9 +1168,12 @@ private extension MongoDBConnection { } } - let row: [String?] = columns.map { column in - guard let value = dict[column] else { return nil } - return BsonDocumentFlattener.stringValue(for: value) + let row: [PluginCellValue] = columns.map { column in + guard let value = dict[column] else { return .null } + if let data = value as? Data { + return .bytes(data) + } + return PluginCellValue.fromOptional(BsonDocumentFlattener.stringValue(for: value)) } continuation.yield(.rows([row])) } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 185c6a6a5..4b4246308 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -129,7 +129,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await executeOperation(operation, connection: conn, startTime: startTime) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { try await execute(query: query) } @@ -538,10 +538,10 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { + ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = MongoDBStatementGenerator(collectionName: table, columns: columns) return generator.generateStatements( from: changes, insertedRowData: insertedRowData, diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index d35ac319a..a567494fa 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift @@ -31,11 +31,11 @@ struct MongoDBStatementGenerator { /// Generate MongoDB shell statements from changes func generateStatements( from changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])] { - var statements: [(statement: String, parameters: [String?])] = [] + ) -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] var deleteChanges: [PluginRowChange] = [] for change in changes { @@ -73,8 +73,8 @@ struct MongoDBStatementGenerator { private func generateInsert( for change: PluginRowChange, - insertedRowData: [Int: [String?]] - ) -> (statement: String, parameters: [String?])? { + insertedRowData: [Int: [PluginCellValue]] + ) -> (statement: String, parameters: [PluginCellValue])? { var doc: [String: String] = [:] if let values = insertedRowData[change.rowIndex] { @@ -84,8 +84,9 @@ struct MongoDBStatementGenerator { // Skip _id for inserts (let MongoDB auto-generate) if column == "_id" { continue } // Skip DEFAULT sentinel - if value == "__DEFAULT__" { continue } - if let val = value { + let textValue = value.asText + if textValue == "__DEFAULT__" { continue } + if let val = textValue { doc[column] = val } } @@ -93,8 +94,9 @@ struct MongoDBStatementGenerator { // Fallback: use cellChanges for cellChange in change.cellChanges { if cellChange.columnName == "_id" { continue } - if cellChange.newValue == "__DEFAULT__" { continue } - if let val = cellChange.newValue { + let newText = cellChange.newValue.asText + if newText == "__DEFAULT__" { continue } + if let val = newText { doc[cellChange.columnName] = val } } @@ -109,13 +111,13 @@ struct MongoDBStatementGenerator { // MARK: - UPDATE (updateOne with $set/$unset) - private func generateUpdate(for change: PluginRowChange) -> (statement: String, parameters: [String?])? { + private func generateUpdate(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { guard !change.cellChanges.isEmpty else { return nil } guard let idIndex = idColumnIndex, let originalRow = change.originalRow, idIndex < originalRow.count, - let idValue = originalRow[idIndex] else { + let idValue = originalRow[idIndex].asText else { Self.logger.warning("Skipping UPDATE for collection '\(self.collectionName)' - no _id value") return nil } @@ -125,7 +127,7 @@ struct MongoDBStatementGenerator { for cellChange in change.cellChanges { if cellChange.columnName == "_id" { continue } - if let val = cellChange.newValue { + if let val = cellChange.newValue.asText { setDoc[cellChange.columnName] = val } else { unsetFields.append(cellChange.columnName) @@ -155,14 +157,14 @@ struct MongoDBStatementGenerator { // MARK: - DELETE MANY /// Batch multiple deletes into a single deleteMany with $in when all rows have _id - private func generateBulkDelete(from changes: [PluginRowChange]) -> (statement: String, parameters: [String?])? { + private func generateBulkDelete(from changes: [PluginRowChange]) -> (statement: String, parameters: [PluginCellValue])? { guard changes.count > 1, let idIndex = idColumnIndex else { return nil } var idValues: [String] = [] for change in changes { guard let originalRow = change.originalRow, idIndex < originalRow.count, - let idValue = originalRow[idIndex] else { + let idValue = originalRow[idIndex].asText else { return nil } if isObjectIdString(idValue) { @@ -181,13 +183,13 @@ struct MongoDBStatementGenerator { // MARK: - DELETE - private func generateDelete(for change: PluginRowChange) -> (statement: String, parameters: [String?])? { + private func generateDelete(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { guard let originalRow = change.originalRow else { return nil } // Try to use _id first if let idIndex = idColumnIndex, idIndex < originalRow.count, - let idValue = originalRow[idIndex] { + let idValue = originalRow[idIndex].asText { let filterJson = buildIdFilter(idValue) let shell = "\(collectionAccessor).deleteOne(\(filterJson))" return (statement: shell, parameters: []) @@ -197,7 +199,7 @@ struct MongoDBStatementGenerator { var filter: [String: String] = [:] for (index, column) in columns.enumerated() { guard index < originalRow.count else { continue } - if let value = originalRow[index] { + if let value = originalRow[index].asText { filter[column] = value } } diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index aa3691d3d..bd1a76f8f 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -40,7 +40,7 @@ struct MariaDBPluginQueryResult { let columns: [String] let columnTypes: [UInt32] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let affectedRows: UInt64 let insertId: UInt64 let isTruncated: Bool @@ -399,7 +399,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } - func executeParameterizedQuery(_ query: String, parameters: [Any?]) async throws -> MariaDBPluginQueryResult { + func executeParameterizedQuery(_ query: String, parameters: [PluginCellValue]) async throws -> MariaDBPluginQueryResult { let queryToRun = String(query) let params = parameters @@ -442,9 +442,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columns: [String] = [] var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] + var columnIsBinary: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) + columnIsBinary.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. ) throws -> ParameterBindings { let paramCount = parameters.count @@ -564,19 +569,18 @@ final class MariaDBPluginConnection: @unchecked Sendable { var buffers: [UnsafeMutableRawPointer?] = [] for (index, param) in parameters.enumerated() { - if let param = param { - let stringValue: String - if let str = param as? String { - stringValue = str - } else if let num = param as? any Numeric { - stringValue = "\(num)" - } else { - stringValue = "\(param)" - } + switch param { + case .null: + binds[index].buffer_type = MYSQL_TYPE_NULL + binds[index].is_null = UnsafeMutablePointer.allocate(capacity: 1) + binds[index].is_null?.pointee = 1 + case .text(let stringValue): let data = stringValue.data(using: .utf8) ?? Data() - let buffer = UnsafeMutableRawPointer.allocate(byteCount: data.count, alignment: 1) - data.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: data.count) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: max(data.count, 1), alignment: 1) + if !data.isEmpty { + data.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: data.count) + } binds[index].buffer_type = MYSQL_TYPE_STRING binds[index].buffer = buffer @@ -587,10 +591,22 @@ final class MariaDBPluginConnection: @unchecked Sendable { binds[index].is_null?.pointee = 0 buffers.append(buffer) - } else { - binds[index].buffer_type = MYSQL_TYPE_NULL + + case .bytes(let data): + let buffer = UnsafeMutableRawPointer.allocate(byteCount: max(data.count, 1), alignment: 1) + if !data.isEmpty { + data.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: data.count) + } + + binds[index].buffer_type = MYSQL_TYPE_LONG_BLOB + binds[index].buffer = buffer + binds[index].buffer_length = UInt(data.count) + binds[index].length = UnsafeMutablePointer.allocate(capacity: 1) + binds[index].length?.pointee = UInt(data.count) binds[index].is_null = UnsafeMutablePointer.allocate(capacity: 1) - binds[index].is_null?.pointee = 1 + binds[index].is_null?.pointee = 0 + + buffers.append(buffer) } } @@ -608,8 +624,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { metadata: UnsafeMutablePointer, columns: [String], columnTypes: [UInt32], - columnTypeNames: [String] - ) throws -> (rows: [[String?]], isTruncated: Bool) { + columnTypeNames: [String], + columnIsBinary: [Bool] + ) throws -> (rows: [[PluginCellValue]], isTruncated: Bool) { let numFields = columns.count var resultBinds: [MYSQL_BIND] = Array(repeating: MYSQL_BIND(), count: numFields) var resultBuffers: [UnsafeMutableRawPointer] = [] @@ -642,7 +659,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { throw getStmtError(stmt) } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] let maxRows = PluginRowLimits.emergencyMax var truncated = false @@ -682,18 +699,20 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 0.. MariaDBPluginQueryResult { + private func executeParameterizedQuerySync(_ query: String, parameters: [PluginCellValue]) throws -> MariaDBPluginQueryResult { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } @@ -772,6 +791,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columns: [String] = [] var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] + var columnIsBinary: [Bool] = [] let numFields = Int(mysql_num_fields(metadata)) if let fields = mysql_fetch_fields(metadata) { @@ -788,12 +808,14 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) columnTypeNames.append(mysqlTypeToString(fields + i)) + columnIsBinary.append(field.charsetnr == mysqlBinaryCharset) } } let fetchResult = try fetchResultSet( from: stmt, metadata: metadata, - columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames + columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, + columnIsBinary: columnIsBinary ) return MariaDBPluginQueryResult( @@ -865,9 +887,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { var columns: [String] = [] var columnTypes: [UInt32] = [] var columnTypeNames: [String] = [] + var columnIsBinary: [Bool] = [] columns.reserveCapacity(numFields) columnTypes.reserveCapacity(numFields) columnTypeNames.reserveCapacity(numFields) + columnIsBinary.reserveCapacity(numFields) if let fields = mysql_fetch_fields(resultPtr) { for i in 0.. String? { do { let result = try await execute(query: "SHOW VARIABLES LIKE '\(variable.rawValue)'") - guard let row = result.rows.first, let value = row[safe: 1] ?? nil else { + guard let row = result.rows.first, let value = row[safe: 1]?.asText else { return nil } return value diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 26bfd095d..d24f73ad3 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -107,14 +107,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await executeWithReconnect(query: query, isRetry: false) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard let conn = mariadbConnection else { throw MariaDBPluginError.notConnected } let startTime = Date() - let anyParams: [Any?] = parameters.map { $0 as Any? } - let result = try await conn.executeParameterizedQuery(query, parameters: anyParams) + let result = try await conn.executeParameterizedQuery(query, parameters: parameters) return PluginQueryResult( columns: result.columns, @@ -186,8 +185,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW FULL TABLES") return result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let typeStr = (row[safe: 1] ?? nil) ?? "BASE TABLE" + guard let name = row[safe: 0]?.asText else { return nil } + let typeStr = (row[safe: 1]?.asText) ?? "BASE TABLE" let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: type) }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -198,16 +197,16 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW FULL COLUMNS FROM `\(safeTable)`") return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil, - let dataType = row[safe: 1] ?? nil + guard let name = row[safe: 0]?.asText, + let dataType = row[safe: 1]?.asText else { return nil } - let collation = row[safe: 2] ?? nil - let isNullable = (row[safe: 3] ?? nil) == "YES" - let isPrimaryKey = (row[safe: 4] ?? nil) == "PRI" - let defaultValue = row[safe: 5] ?? nil - let extra = row[safe: 6] ?? nil - let comment = row[safe: 8] ?? nil + let collation = row[safe: 2]?.asText + let isNullable = (row[safe: 3]?.asText) == "YES" + let isPrimaryKey = (row[safe: 4]?.asText) == "PRI" + let defaultValue = row[safe: 5]?.asText + let extra = row[safe: 6]?.asText + let comment = row[safe: 8]?.asText let charset: String? = { guard let coll = collation, coll != "NULL" else { return nil } @@ -248,17 +247,17 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let name = row[safe: 1] ?? nil, - let dataType = row[safe: 2] ?? nil + guard let tableName = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText, + let dataType = row[safe: 2]?.asText else { continue } - let collation = row[safe: 3] ?? nil - let isNullable = (row[safe: 4] ?? nil) == "YES" - let isPrimaryKey = (row[safe: 5] ?? nil) == "PRI" - let defaultValue = row[safe: 6] ?? nil - let extra = row[safe: 7] ?? nil - let comment = row[safe: 8] ?? nil + let collation = row[safe: 3]?.asText + let isNullable = (row[safe: 4]?.asText) == "YES" + let isPrimaryKey = (row[safe: 5]?.asText) == "PRI" + let defaultValue = row[safe: 6]?.asText + let extra = row[safe: 7]?.asText + let comment = row[safe: 8]?.asText let charset: String? = { guard let coll = collation, coll != "NULL" else { return nil } @@ -294,13 +293,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var indexMap: [String: (columns: [String], isUnique: Bool, type: String, prefixes: [String: Int])] = [:] for row in result.rows { - guard let indexName = row[safe: 2] ?? nil, - let columnName = row[safe: 4] ?? nil + guard let indexName = row[safe: 2]?.asText, + let columnName = row[safe: 4]?.asText else { continue } - let nonUnique = (row[safe: 1] ?? nil) == "1" - let indexType = (row[safe: 10] ?? nil) ?? "BTREE" - let subPart = (row[safe: 7] ?? nil).flatMap { Int($0) } + let nonUnique = (row[safe: 1]?.asText) == "1" + let indexType = (row[safe: 10]?.asText) ?? "BTREE" + let subPart = (row[safe: 7]?.asText).flatMap { Int($0) } if var existing = indexMap[indexName] { existing.columns.append(columnName) @@ -355,18 +354,18 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: query) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil, - let column = row[safe: 1] ?? nil, - let refTable = row[safe: 2] ?? nil, - let refColumn = row[safe: 3] ?? nil + guard let name = row[safe: 0]?.asText, + let column = row[safe: 1]?.asText, + let refTable = row[safe: 2]?.asText, + let refColumn = row[safe: 3]?.asText else { return nil } return PluginForeignKeyInfo( name: name, column: column, referencedTable: refTable, referencedColumn: refColumn, - referencedSchema: row[safe: 4] ?? nil, - onDelete: (row[safe: 5] ?? nil) ?? "NO ACTION", - onUpdate: (row[safe: 6] ?? nil) ?? "NO ACTION" + referencedSchema: row[safe: 4]?.asText, + onDelete: (row[safe: 5]?.asText) ?? "NO ACTION", + onUpdate: (row[safe: 6]?.asText) ?? "NO ACTION" ) } } @@ -397,19 +396,19 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var grouped: [String: [PluginForeignKeyInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let name = row[safe: 1] ?? nil, - let column = row[safe: 2] ?? nil, - let refTable = row[safe: 3] ?? nil, - let refColumn = row[safe: 4] ?? nil + guard let tableName = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText, + let column = row[safe: 2]?.asText, + let refTable = row[safe: 3]?.asText, + let refColumn = row[safe: 4]?.asText else { continue } let fk = PluginForeignKeyInfo( name: name, column: column, referencedTable: refTable, referencedColumn: refColumn, - referencedSchema: row[safe: 5] ?? nil, - onDelete: (row[safe: 6] ?? nil) ?? "NO ACTION", - onUpdate: (row[safe: 7] ?? nil) ?? "NO ACTION" + referencedSchema: row[safe: 5]?.asText, + onDelete: (row[safe: 6]?.asText) ?? "NO ACTION", + onUpdate: (row[safe: 7]?.asText) ?? "NO ACTION" ) grouped[tableName, default: []].append(fk) } @@ -430,7 +429,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: query) guard let firstRow = result.rows.first, - let value = firstRow[safe: 0] ?? nil, + let value = firstRow[safe: 0]?.asText, let count = Int(value) else { return nil } @@ -442,7 +441,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW CREATE TABLE `\(safeTable)`") guard let firstRow = result.rows.first, - let ddl = firstRow[safe: 1] ?? nil + let ddl = firstRow[safe: 1]?.asText else { throw MariaDBPluginError(code: 0, message: "Failed to fetch DDL for table '\(table)'", sqlState: nil) } @@ -455,7 +454,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW CREATE VIEW `\(safeView)`") guard let firstRow = result.rows.first, - let ddl = firstRow[safe: 1] ?? nil + let ddl = firstRow[safe: 1]?.asText else { throw MariaDBPluginError(code: 0, message: "Failed to fetch definition for view '\(view)'", sqlState: nil) } @@ -471,11 +470,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginTableMetadata(tableName: table) } - let engine = row[safe: 1] ?? nil - let rowCount = (row[safe: 4] ?? nil).flatMap { Int64($0) } - let dataSize = (row[safe: 6] ?? nil).flatMap { Int64($0) } - let indexSize = (row[safe: 8] ?? nil).flatMap { Int64($0) } - let comment = row[safe: 17] ?? nil + let engine = row[safe: 1]?.asText + let rowCount = (row[safe: 4]?.asText).flatMap { Int64($0) } + let dataSize = (row[safe: 6]?.asText).flatMap { Int64($0) } + let indexSize = (row[safe: 8]?.asText).flatMap { Int64($0) } + let comment = row[safe: 17]?.asText let totalSize: Int64? = { guard let data = dataSize, let index = indexSize else { return nil } @@ -506,7 +505,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { let result = try await execute(query: "SHOW DATABASES") - return result.rows.compactMap { row in row[safe: 0] ?? nil } + return result.rows.compactMap { row in row[safe: 0]?.asText } } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -519,8 +518,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: query) let row = result.rows.first - let tableCount = Int((row?[safe: 0] ?? nil) ?? "0") ?? 0 - let sizeBytes = Int64((row?[safe: 1] ?? nil) ?? "0") ?? 0 + let tableCount = Int(row?[safe: 0]?.asText ?? "0") ?? 0 + let sizeBytes = Int64(row?[safe: 1]?.asText ?? "0") ?? 0 let systemDatabases = ["information_schema", "mysql", "performance_schema", "sys"] let isSystem = systemDatabases.contains(database) @@ -545,9 +544,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var metadataByName: [String: PluginDatabaseMetadata] = [:] for row in result.rows { - guard let dbName = row[safe: 0] ?? nil else { continue } - let tableCount = Int((row[safe: 1] ?? nil) ?? "0") ?? 0 - let sizeBytes = Int64((row[safe: 2] ?? nil) ?? "0") ?? 0 + guard let dbName = row[safe: 0]?.asText else { continue } + let tableCount = Int((row[safe: 1]?.asText) ?? "0") ?? 0 + let sizeBytes = Int64((row[safe: 2]?.asText) ?? "0") ?? 0 let isSystem = systemDatabases.contains(dbName) metadataByName[dbName] = PluginDatabaseMetadata( @@ -949,7 +948,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var columns: [String] = [] for row in result.rows { - if let columnName = row[safe: 0] ?? nil { + if let columnName = row[safe: 0]?.asText { columns.append(columnName) } } diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 7507c6410..b60493809 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -59,7 +59,7 @@ extension OracleError: PluginDriverError { struct OracleQueryResult { let columns: [String] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let affectedRows: Int let isTruncated: Bool } @@ -239,20 +239,23 @@ final class OracleConnectionWrapper: @unchecked Sendable { osLogger.debug("Oracle columns: \(columns.count) — \(columns.joined(separator: ", "))") var columnTypeNames: [String] = [] - var allRows: [[String?]] = [] + var allRows: [[PluginCellValue]] = [] var didReadTypes = false var truncated = false for try await row in stream { - var rowValues: [String?] = [] + var rowValues: [PluginCellValue] = [] for cell in row { if !didReadTypes { columnTypeNames.append(oracleTypeName(cell.dataType)) } if cell.bytes == nil { - rowValues.append(nil) + rowValues.append(.null) + } else if cell.dataType == .raw || cell.dataType == .longRAW || cell.dataType == .blob, + let bytes = cell.bytes { + rowValues.append(.bytes(Data(bytes.readableBytesView))) } else { - rowValues.append(decodeCell(cell)) + rowValues.append(PluginCellValue.fromOptional(decodeCell(cell))) } } didReadTypes = true @@ -325,15 +328,18 @@ final class OracleConnectionWrapper: @unchecked Sendable { return } - var rowValues: [String?] = [] + var rowValues: [PluginCellValue] = [] for cell in row { if !headerSent { columnTypeNames.append(oracleTypeName(cell.dataType)) } if cell.bytes == nil { - rowValues.append(nil) + rowValues.append(.null) + } else if cell.dataType == .raw || cell.dataType == .longRAW || cell.dataType == .blob, + let bytes = cell.bytes { + rowValues.append(.bytes(Data(bytes.readableBytesView))) } else { - rowValues.append(decodeCell(cell)) + rowValues.append(PluginCellValue.fromOptional(decodeCell(cell))) } } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 56637c59c..f6329f079 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -198,14 +198,14 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { self.oracleConn = conn if let result = try? await conn.executeQuery("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"), - let schema = result.rows.first?.first ?? nil { + let schema = result.rows.first?.first?.asText { _currentSchema = schema } else { _currentSchema = config.username.uppercased() } if let result = try? await conn.executeQuery("SELECT BANNER FROM V$VERSION WHERE ROWNUM = 1"), - let versionStr = result.rows.first?.first ?? nil { + let versionStr = result.rows.first?.first?.asText { _serverVersion = String(versionStr.prefix(60)) } } @@ -253,8 +253,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY COLUMN_ID """ if let colResult = try? await conn.executeQuery(colSQL) { - let colNames = colResult.rows.compactMap { $0.first ?? nil } - let colTypes = colResult.rows.map { ($0[safe: 1] ?? nil)?.lowercased() ?? "varchar2" } + let colNames = colResult.rows.compactMap { $0.first?.asText } + let colTypes = colResult.rows.map { ($0[safe: 1]?.asText)?.lowercased() ?? "varchar2" } if !colNames.isEmpty { result = OracleQueryResult( columns: colNames, @@ -311,8 +311,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let rawType = row[safe: 1] ?? nil + guard let name = row[safe: 0]?.asText else { return nil } + let rawType = row[safe: 1]?.asText let tableType = (rawType == "VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: tableType) } @@ -346,13 +346,13 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginColumnInfo? in - guard let name = row[safe: 0] ?? nil else { return nil } - let dataType = (row[safe: 1] ?? nil)?.lowercased() ?? "varchar2" - let dataLength = row[safe: 2] ?? nil - let precision = row[safe: 3] ?? nil - let scale = row[safe: 4] ?? nil - let isNullable = (row[safe: 5] ?? nil) == "Y" - let isPk = (row[safe: 6] ?? nil) == "Y" + guard let name = row[safe: 0]?.asText else { return nil } + let dataType = (row[safe: 1]?.asText)?.lowercased() ?? "varchar2" + let dataLength = row[safe: 2]?.asText + let precision = row[safe: 3]?.asText + let scale = row[safe: 4]?.asText + let isNullable = (row[safe: 5]?.asText) == "Y" + let isPk = (row[safe: 6]?.asText) == "Y" let fullType = buildOracleFullType(dataType: dataType, dataLength: dataLength, precision: precision, scale: scale) @@ -383,10 +383,10 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var indexMap: [String: (unique: Bool, primary: Bool, columns: [String])] = [:] for row in result.rows { - guard let idxName = row[safe: 0] ?? nil, - let colName = row[safe: 2] ?? nil else { continue } - let isUnique = (row[safe: 1] ?? nil) == "UNIQUE" - let isPrimary = (row[safe: 3] ?? nil) == "Y" + guard let idxName = row[safe: 0]?.asText, + let colName = row[safe: 2]?.asText else { continue } + let isUnique = (row[safe: 1]?.asText) == "UNIQUE" + let isPrimary = (row[safe: 3]?.asText) == "Y" if indexMap[idxName] == nil { indexMap[idxName] = (unique: isUnique, primary: isPrimary, columns: []) } @@ -427,11 +427,11 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginForeignKeyInfo? in - guard let constraintName = row[safe: 0] ?? nil, - let columnName = row[safe: 1] ?? nil, - let refTable = row[safe: 2] ?? nil, - let refColumn = row[safe: 3] ?? nil else { return nil } - let deleteRule = (row[safe: 4] ?? nil) ?? "NO ACTION" + guard let constraintName = row[safe: 0]?.asText, + let columnName = row[safe: 1]?.asText, + let refTable = row[safe: 2]?.asText, + let refColumn = row[safe: 3]?.asText else { return nil } + let deleteRule = (row[safe: 4]?.asText) ?? "NO ACTION" return PluginForeignKeyInfo( name: constraintName, column: columnName, @@ -469,14 +469,14 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var columnsByTable: [String: [PluginColumnInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let name = row[safe: 1] ?? nil else { continue } - let dataType = (row[safe: 2] ?? nil)?.lowercased() ?? "varchar2" - let dataLength = row[safe: 3] ?? nil - let precision = row[safe: 4] ?? nil - let scale = row[safe: 5] ?? nil - let isNullable = (row[safe: 6] ?? nil) == "Y" - let isPk = (row[safe: 7] ?? nil) == "Y" + guard let tableName = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText else { continue } + let dataType = (row[safe: 2]?.asText)?.lowercased() ?? "varchar2" + let dataLength = row[safe: 3]?.asText + let precision = row[safe: 4]?.asText + let scale = row[safe: 5]?.asText + let isNullable = (row[safe: 6]?.asText) == "Y" + let isPk = (row[safe: 7]?.asText) == "Y" let fullType = buildOracleFullType(dataType: dataType, dataLength: dataLength, precision: precision, scale: scale) @@ -515,12 +515,12 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: sql) var fksByTable: [String: [PluginForeignKeyInfo]] = [:] for row in result.rows { - guard let tableName = row[safe: 0] ?? nil, - let constraintName = row[safe: 1] ?? nil, - let columnName = row[safe: 2] ?? nil, - let refTable = row[safe: 3] ?? nil, - let refColumn = row[safe: 4] ?? nil else { continue } - let deleteRule = (row[safe: 5] ?? nil) ?? "NO ACTION" + guard let tableName = row[safe: 0]?.asText, + let constraintName = row[safe: 1]?.asText, + let columnName = row[safe: 2]?.asText, + let refTable = row[safe: 3]?.asText, + let refColumn = row[safe: 4]?.asText else { continue } + let deleteRule = (row[safe: 5]?.asText) ?? "NO ACTION" let fk = PluginForeignKeyInfo( name: constraintName, column: columnName, @@ -550,9 +550,9 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) return result.rows.compactMap { row -> PluginDatabaseMetadata? in - guard let name = row[safe: 0] ?? nil else { return nil } - let tableCount = (row[safe: 1] ?? nil).flatMap { Int($0) } ?? 0 - let sizeBytes = (row[safe: 2] ?? nil).flatMap { Int64($0) } + guard let name = row[safe: 0]?.asText else { return nil } + let tableCount = (row[safe: 1]?.asText).flatMap { Int($0) } ?? 0 + let sizeBytes = (row[safe: 2]?.asText).flatMap { Int64($0) } return PluginDatabaseMetadata(name: name, tableCount: tableCount, sizeBytes: sizeBytes) } } @@ -586,7 +586,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // which corrupts OracleNIO's connection state machine. let sql = "SELECT TEXT_VC FROM ALL_VIEWS WHERE VIEW_NAME = '\(escapedView)' AND OWNER = '\(escaped)'" let result = try await execute(query: sql) - return result.rows.first?.first?.flatMap { $0 } ?? "" + return result.rows.first?.first?.asText ?? "" } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { @@ -604,9 +604,9 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: sql) if let row = result.rows.first { - let rowCount = (row[safe: 0] ?? nil).flatMap { Int64($0) } - let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 - let comment = row[safe: 2] ?? nil + let rowCount = (row[safe: 0]?.asText).flatMap { Int64($0) } + let sizeBytes = (row[safe: 1]?.asText).flatMap { Int64($0) } ?? 0 + let comment = row[safe: 2]?.asText return PluginTableMetadata( tableName: table, dataSize: sizeBytes, @@ -624,7 +624,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let viewResult = try await execute(query: viewSQL) if let row = viewResult.rows.first { - let comment = row[safe: 0] ?? nil + let comment = row[safe: 0]?.asText return PluginTableMetadata(tableName: table, comment: comment) } @@ -634,13 +634,13 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { let sql = "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME" let result = try await execute(query: sql) - return result.rows.compactMap { $0.first ?? nil } + return result.rows.compactMap { $0.first?.asText } } func fetchSchemas() async throws -> [String] { let sql = "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME" let result = try await execute(query: sql) - return result.rows.compactMap { $0.first ?? nil } + return result.rows.compactMap { $0.first?.asText } } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -654,8 +654,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let result = try await execute(query: sql) if let row = result.rows.first { - let tableCount = (row[safe: 0] ?? nil).flatMap { Int($0) } ?? 0 - let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 + let tableCount = (row[safe: 0]?.asText).flatMap { Int($0) } ?? 0 + let sizeBytes = (row[safe: 1]?.asText).flatMap { Int64($0) } ?? 0 return PluginDatabaseMetadata( name: database, tableCount: tableCount, @@ -675,11 +675,11 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(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 { @@ -712,16 +712,16 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func generateOracleInsert( table: String, columns: [String], - values: [String?] - ) -> (statement: String, parameters: [String?])? { + values: [PluginCellValue] + ) -> (statement: String, parameters: [PluginCellValue])? { var insertColumns: [String] = [] var valuesSQL: [String] = [] - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] for (index, value) in values.enumerated() { guard index < columns.count else { continue } insertColumns.append(escapeOracleIdentifier(columns[index])) - if value == "__DEFAULT__" { + if value.asText == "__DEFAULT__" { valuesSQL.append("DEFAULT") } else { valuesSQL.append("?") @@ -741,11 +741,11 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { table: String, columns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { guard !change.cellChanges.isEmpty, let originalRow = change.originalRow else { return nil } let escapedTable = escapeOracleIdentifier(table) - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] let setClauses = change.cellChanges.map { cellChange -> String in let col = escapeOracleIdentifier(cellChange.columnName) @@ -757,11 +757,12 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { for (index, columnName) in columns.enumerated() { guard index < originalRow.count else { continue } let col = escapeOracleIdentifier(columnName) - if let value = originalRow[index] { + let value = originalRow[index] + if value.isNull { + conditions.append("\(col) IS NULL") + } else { parameters.append(value) conditions.append("\(col) = ?") - } else { - conditions.append("\(col) IS NULL") } } @@ -776,21 +777,22 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { table: String, columns: [String], change: PluginRowChange - ) -> (statement: String, parameters: [String?])? { + ) -> (statement: String, parameters: [PluginCellValue])? { guard let originalRow = change.originalRow else { return nil } let escapedTable = escapeOracleIdentifier(table) - var parameters: [String?] = [] + var parameters: [PluginCellValue] = [] var conditions: [String] = [] for (index, columnName) in columns.enumerated() { guard index < originalRow.count else { continue } let col = escapeOracleIdentifier(columnName) - if let value = originalRow[index] { + let value = originalRow[index] + if value.isNull { + conditions.append("\(col) IS NULL") + } else { parameters.append(value) conditions.append("\(col) = ?") - } else { - conditions.append("\(col) IS NULL") } } diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift b/Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift new file mode 100644 index 000000000..c804dc336 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift @@ -0,0 +1,113 @@ +// +// LibPQByteaDecoder.swift +// PostgreSQLDriverPlugin +// +// Decodes PostgreSQL BYTEA values from libpq's text result format into raw Data. +// +// PostgreSQL emits BYTEA in one of two text formats, controlled by the server's +// bytea_output GUC: +// +// 1. HEX (default since 9.0): "\xd38ce566..." +// Two-character prefix \x followed by 2*N lowercase or uppercase hex digits. +// +// 2. ESCAPE (legacy, still emitted by some servers and dump tools): +// Printable ASCII bytes are emitted literally except for these escapes: +// \\ → 0x5C (literal backslash) +// \nnn → byte with that octal value (0-377) +// All other bytes (including 0x00) are emitted as \nnn. +// +// The full spec lives at: +// https://www.postgresql.org/docs/current/datatype-binary.html +// + +import Foundation + +enum LibPQByteaDecoder { + /// Decodes a BYTEA text representation as returned by libpq's text result format + /// into raw bytes. + /// + /// - Parameter text: The BYTEA value as libpq emitted it (e.g. "\\xd38ce566..." or + /// "\\\\012abc"). + /// - Returns: The decoded raw bytes, or nil if `text` is not a valid BYTEA + /// text representation in either supported format. + static func decode(_ text: String) -> Data? { + if text.isEmpty { + return Data() + } + + let utf8 = Array(text.utf8) + + // Hex format: \xHHHH... (lowercase per PG docs, but accept uppercase too) + if utf8.count >= 2, utf8[0] == 0x5C, utf8[1] == 0x78 || utf8[1] == 0x58 { + let hexBytes = utf8.dropFirst(2) + guard hexBytes.count % 2 == 0 else { return nil } + var data = Data() + data.reserveCapacity(hexBytes.count / 2) + var iterator = hexBytes.makeIterator() + while let high = iterator.next(), let low = iterator.next() { + guard let h = hexNibble(high), let l = hexNibble(low) else { return nil } + data.append((h << 4) | l) + } + return data + } + + // Escape format: walk bytes; \\ → 0x5C, \nnn (3 octal digits) → byte, others literal. + var data = Data() + data.reserveCapacity(utf8.count) + var i = 0 + while i < utf8.count { + let byte = utf8[i] + if byte == 0x5C { + guard i + 1 < utf8.count else { return nil } + let next = utf8[i + 1] + if next == 0x5C { + data.append(0x5C) + i += 2 + continue + } + guard i + 3 < utf8.count else { return nil } + let d0 = utf8[i + 1] + let d1 = utf8[i + 2] + let d2 = utf8[i + 3] + guard let n0 = octalNibble(d0), + let n1 = octalNibble(d1), + let n2 = octalNibble(d2) else { return nil } + let value = (UInt16(n0) << 6) | (UInt16(n1) << 3) | UInt16(n2) + guard value <= 0xFF else { return nil } + data.append(UInt8(value)) + i += 4 + } else { + data.append(byte) + i += 1 + } + } + return data + } + + private static func hexNibble(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x30...0x39: return byte - 0x30 // 0-9 + case 0x41...0x46: return byte - 0x41 + 10 // A-F + case 0x61...0x66: return byte - 0x61 + 10 // a-f + default: return nil + } + } + + private static func octalNibble(_ byte: UInt8) -> UInt8? { + guard byte >= 0x30, byte <= 0x37 else { return nil } + return byte - 0x30 + } + + /// Encodes raw bytes back to BYTEA hex text format for inclusion in SQL literals. + /// + /// Produces the canonical `\xHHHH...` representation suitable for use in + /// `'\xHHHH...'::bytea` or `E'\\xHHHH...'` SQL literals. + static func encodeHexText(_ data: Data) -> String { + var out = "\\x" + out.reserveCapacity(2 + data.count * 2) + for byte in data { + out.append(String(format: "%02x", byte)) + } + return out + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 5d90e48e9..104fc4f8b 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -66,7 +66,7 @@ struct LibPQPluginQueryResult { let columns: [String] let columnOids: [UInt32] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let affectedRows: Int let commandTag: String? let isTruncated: Bool @@ -295,7 +295,7 @@ final class LibPQPluginConnection: @unchecked Sendable { } } - func executeParameterizedQuery(_ query: String, parameters: [String?]) async throws -> LibPQPluginQueryResult { + func executeParameterizedQuery(_ query: String, parameters: [PluginCellValue]) async throws -> LibPQPluginQueryResult { let queryToRun = String(query) let params = parameters @@ -364,7 +364,7 @@ final class LibPQPluginConnection: @unchecked Sendable { } } - private func executeParameterizedQuerySync(_ query: String, parameters: [String?]) throws -> LibPQPluginQueryResult { + private func executeParameterizedQuerySync(_ query: String, parameters: [PluginCellValue]) throws -> LibPQPluginQueryResult { stateLock.lock() let conn = self.conn stateLock.unlock() @@ -374,36 +374,65 @@ final class LibPQPluginConnection: @unchecked Sendable { } var paramValues: [UnsafePointer?] = [] + var paramLengths: [Int32] = [] + var paramFormats: [Int32] = [] + var allocations: [UnsafeMutableRawPointer] = [] defer { - for ptr in paramValues { - if let ptr = ptr { - free(UnsafeMutablePointer(mutating: ptr)) - } + for ptr in allocations { + free(ptr) } } + paramValues.reserveCapacity(parameters.count) + paramLengths.reserveCapacity(parameters.count) + paramFormats.reserveCapacity(parameters.count) + for param in parameters { - if let param = param { - let cStr = strdup(param) - paramValues.append(UnsafePointer(cStr)) - } else { + switch param { + case .null: paramValues.append(nil) + paramLengths.append(0) + paramFormats.append(0) + case .text(let str): + guard let cStr = strdup(str) else { + throw LibPQPluginError(message: "Failed to allocate parameter buffer", sqlState: nil, detail: nil) + } + allocations.append(UnsafeMutableRawPointer(cStr)) + paramValues.append(UnsafePointer(cStr)) + paramLengths.append(0) + paramFormats.append(0) + case .bytes(let data): + let byteCount = data.count + guard let raw = malloc(max(byteCount, 1)) else { + throw LibPQPluginError(message: "Failed to allocate parameter buffer", sqlState: nil, detail: nil) + } + allocations.append(raw) + if byteCount > 0 { + data.copyBytes(to: raw.assumingMemoryBound(to: UInt8.self), count: byteCount) + } + paramValues.append(UnsafePointer(raw.assumingMemoryBound(to: CChar.self))) + paramLengths.append(Int32(byteCount)) + paramFormats.append(1) } } let localQuery = String(query) let result: OpaquePointer? = localQuery.withCString { queryPtr in - PQexecParams( - conn, - queryPtr, - Int32(parameters.count), - nil, - paramValues, - nil, - nil, - 0 - ) + paramLengths.withUnsafeBufferPointer { lengthsBuf in + paramFormats.withUnsafeBufferPointer { formatsBuf in + PQexecParams( + conn, + queryPtr, + Int32(parameters.count), + nil, + paramValues, + lengthsBuf.baseAddress, + formatsBuf.baseAddress, + 0 + ) + } + } } guard let result = result else { @@ -547,27 +576,34 @@ final class LibPQPluginConnection: @unchecked Sendable { } let numFields = Int(PQnfields(result)) - var row: [String?] = [] + var row: [PluginCellValue] = [] row.reserveCapacity(numFields) for colIndex in 0.. maxRows - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] rows.reserveCapacity(effectiveRowCount) for rowIndex in 0..= 5, let tableName = row[0] else { continue } + guard row.count >= 5, let tableName = row[0].asText else { continue } if let column = mapPgColumnRow(row, tableNameOffset: 1) { allColumns[tableName, default: []].append(column) } @@ -105,7 +105,7 @@ extension PostgreSQLPluginDriver { str.replacingOccurrences(of: "'", with: "''") } - fileprivate func mapPgColumnRow(_ row: [String?], tableNameOffset: Int) -> PluginColumnInfo? { + fileprivate func mapPgColumnRow(_ row: [PluginCellValue], tableNameOffset: Int) -> PluginColumnInfo? { let nameIdx = tableNameOffset let typeIdx = tableNameOffset + 1 let nullableIdx = tableNameOffset + 2 @@ -118,11 +118,11 @@ extension PostgreSQLPluginDriver { let generatedIdx = tableNameOffset + 9 guard row.count > typeIdx, - let name = row[nameIdx], - let rawDataType = row[typeIdx] + let name = row[nameIdx].asText, + let rawDataType = row[typeIdx].asText else { return nil } - let udtName = row.count > udtIdx ? row[udtIdx] : nil + let udtName = row.count > udtIdx ? row[udtIdx].asText : nil let dataType: String if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { dataType = "ENUM(\(udt))" @@ -130,13 +130,13 @@ extension PostgreSQLPluginDriver { dataType = rawDataType.uppercased() } - let isNullable = row.count > nullableIdx && row[nullableIdx] == "YES" - let defaultValue = row.count > defaultIdx ? row[defaultIdx] : nil - let collation = row.count > collationIdx ? row[collationIdx] : nil - let comment = row.count > commentIdx ? row[commentIdx] : nil - let isPk = row.count > pkIdx && row[pkIdx] == "YES" - let attidentity = row.count > identityIdx ? row[identityIdx] : nil - let attgenerated = row.count > generatedIdx ? row[generatedIdx] : nil + let isNullable = row.count > nullableIdx && row[nullableIdx].asText == "YES" + let defaultValue = row.count > defaultIdx ? row[defaultIdx].asText : nil + let collation = row.count > collationIdx ? row[collationIdx].asText : nil + let comment = row.count > commentIdx ? row[commentIdx].asText : nil + let isPk = row.count > pkIdx && row[pkIdx].asText == "YES" + let attidentity = row.count > identityIdx ? row[identityIdx].asText : nil + let attgenerated = row.count > generatedIdx ? row[generatedIdx].asText : nil let charset: String? = { guard let coll = collation, coll.contains(".") else { return nil } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index a68858a2b..807f0e715 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -60,7 +60,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { self.libpqConnection = pqConn if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"), - let schema = schemaResult.rows.first?.first.flatMap({ $0 }) { + let schema = schemaResult.rows.first?.first?.asText { _currentSchema = schema } } @@ -103,7 +103,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard let pqConn = libpqConnection else { throw LibPQPluginError.notConnected } @@ -225,9 +225,9 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY table_name """ let result = try await execute(query: query) - return result.rows.compactMap { row in - guard let name = row[0] else { return nil } - let typeStr = row[1] ?? "BASE TABLE" + return result.rows.compactMap { row -> PluginTableInfo? in + guard let name = row[0].asText else { return nil } + let typeStr = row[1].asText ?? "BASE TABLE" let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: type) } @@ -253,18 +253,18 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY ix.indisprimary DESC, i.relname """ let result = try await execute(query: query) - return result.rows.compactMap { row in - guard row.count >= 5, let name = row[0], let columnsStr = row[1] else { return nil } + return result.rows.compactMap { row -> PluginIndexInfo? in + guard row.count >= 5, let name = row[0].asText, let columnsStr = row[1].asText else { return nil } let columns = columnsStr .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) .components(separatedBy: ",") - let whereClause = row.count > 5 ? row[5] : nil + let whereClause = row.count > 5 ? row[5].asText : nil return PluginIndexInfo( name: name, columns: columns, - isUnique: row[2] == "t", - isPrimary: row[3] == "t", - type: row[4]?.uppercased() ?? "BTREE", + isUnique: row[2].asText == "t", + isPrimary: row[3].asText == "t", + type: row[4].asText?.uppercased() ?? "BTREE", whereClause: whereClause ) } @@ -296,21 +296,21 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY tc.constraint_name """ let result = try await execute(query: query) - return result.rows.compactMap { row in + return result.rows.compactMap { row -> PluginForeignKeyInfo? in guard row.count >= 7, - let name = row[0], - let column = row[1], - let refTable = row[2], - let refColumn = row[3] + let name = row[0].asText, + let column = row[1].asText, + let refTable = row[2].asText, + let refColumn = row[3].asText else { return nil } return PluginForeignKeyInfo( name: name, column: column, referencedTable: refTable, referencedColumn: refColumn, - referencedSchema: row[4], - onDelete: row[5] ?? "NO ACTION", - onUpdate: row[6] ?? "NO ACTION" + referencedSchema: row[4].asText, + onDelete: row[5].asText ?? "NO ACTION", + onUpdate: row[6].asText ?? "NO ACTION" ) } } @@ -344,20 +344,20 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var grouped: [String: [PluginForeignKeyInfo]] = [:] for row in result.rows { guard row.count >= 8, - let tableName = row[0], - let name = row[1], - let column = row[2], - let refTable = row[3], - let refColumn = row[4] + let tableName = row[0].asText, + let name = row[1].asText, + let column = row[2].asText, + let refTable = row[3].asText, + let refColumn = row[4].asText else { continue } let fk = PluginForeignKeyInfo( name: name, column: column, referencedTable: refTable, referencedColumn: refColumn, - referencedSchema: row[5], - onDelete: row[6] ?? "NO ACTION", - onUpdate: row[7] ?? "NO ACTION" + referencedSchema: row[5].asText, + onDelete: row[6].asText ?? "NO ACTION", + onUpdate: row[7].asText ?? "NO ACTION" ) grouped[tableName, default: []].append(fk) } @@ -374,7 +374,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) """ let result = try await execute(query: query) - guard let firstRow = result.rows.first, let value = firstRow[0], let count = Int(value) else { return nil } + guard let firstRow = result.rows.first, let value = firstRow[0].asText, let count = Int(value) else { return nil } return count >= 0 ? count : nil } @@ -445,12 +445,12 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let (cols, cons, idxs) = try await (columnsResult, constraintsResult, indexesResult) - let columnDefs = cols.rows.compactMap { $0[0] } + let columnDefs = cols.rows.compactMap { $0[0].asText } guard !columnDefs.isEmpty else { throw LibPQPluginError(message: "Failed to fetch DDL for table '\(table)'", sqlState: nil, detail: nil) } - let constraints = cons.rows.compactMap { $0[0] } + let constraints = cons.rows.compactMap { $0[0].asText } var parts = columnDefs parts.append(contentsOf: constraints) @@ -459,7 +459,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { parts.joined(separator: ",\n ") + "\n);" - let indexDefs = idxs.rows.compactMap { $0[0] } + let indexDefs = idxs.rows.compactMap { $0[0].asText } if indexDefs.isEmpty { return ddl } return ddl + "\n\n" + indexDefs.joined(separator: ";\n") + ";" } @@ -472,7 +472,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND schemaname = '\(escapedSchema)' """ let result = try await execute(query: query) - guard let firstRow = result.rows.first, let ddl = firstRow[0] else { + guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) } return ddl @@ -496,11 +496,11 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return PluginTableMetadata(tableName: table) } - let totalSize = !row.isEmpty ? Int64(row[0] ?? "0") : nil - let dataSize = row.count > 1 ? Int64(row[1] ?? "0") : nil - let indexSize = row.count > 2 ? Int64(row[2] ?? "0") : nil - let rowCount = row.count > 3 ? Int64(row[3] ?? "0") : nil - let comment = row.count > 4 ? row[4] : nil + let totalSize = !row.isEmpty ? Int64(row[0].asText ?? "0") : nil + let dataSize = row.count > 1 ? Int64(row[1].asText ?? "0") : nil + let indexSize = row.count > 2 ? Int64(row[2].asText ?? "0") : nil + let rowCount = row.count > 3 ? Int64(row[3].asText ?? "0") : nil + let comment = row.count > 4 ? row[4].asText : nil return PluginTableMetadata( tableName: table, @@ -515,12 +515,12 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { let result = try await execute(query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname") - return result.rows.compactMap { row in row.first.flatMap { $0 } } + return result.rows.compactMap { row in row.first?.asText } } func fetchSchemas() async throws -> [String] { let result = try await execute(query: PostgreSQLSchemaQueries.listSchemas) - return result.rows.compactMap { row in row.first.flatMap { $0 } } + return result.rows.compactMap { row in row.first?.asText } } func switchSchema(to schema: String) async throws { @@ -540,8 +540,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: query) let row = result.rows.first - let tableCount = Int(row?[0] ?? "0") ?? 0 - let sizeBytes = Int64(row?[1] ?? "0") ?? 0 + let tableCount = Int(row?[0].asText ?? "0") ?? 0 + let sizeBytes = Int64(row?[1].asText ?? "0") ?? 0 let systemDatabases = ["postgres", "template0", "template1"] let isSystem = systemDatabases.contains(database) @@ -563,9 +563,9 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY d.datname """ let result = try await execute(query: query) - return result.rows.compactMap { row in - guard let dbName = row[0] else { return nil } - let sizeBytes = Int64(row[1] ?? "0") ?? 0 + return result.rows.compactMap { row -> PluginDatabaseMetadata? in + guard let dbName = row[0].asText else { return nil } + let sizeBytes = Int64(row[1].asText ?? "0") ?? 0 let isSystem = systemDatabases.contains(dbName) return PluginDatabaseMetadata(name: dbName, sizeBytes: sizeBytes, isSystemDatabase: isSystem) } @@ -589,8 +589,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY t.typname """ let result = try await execute(query: query) - return result.rows.compactMap { row in - guard let typeName = row[0], let labelsStr = row[1] else { return nil } + return result.rows.compactMap { row -> (name: String, labels: [String])? in + guard let typeName = row[0].asText, let labelsStr = row[1].asText else { return nil } let labels = labelsStr .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) .components(separatedBy: ",") @@ -619,14 +619,14 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: query) let schemaName = schema ?? _currentSchema - return result.rows.compactMap { row in - guard let seqName = row[0] else { return nil } - let startVal = row[1] ?? "1" - let minVal = row[2] ?? "1" - let maxVal = row[3] ?? "9223372036854775807" - let incrementBy = row[4] ?? "1" - let cycle = row[5] == "t" ? " CYCLE" : "" - let lastValue = row.count > 6 ? row[6] : nil + return result.rows.compactMap { row -> (name: String, ddl: String)? in + guard let seqName = row[0].asText else { return nil } + let startVal = row[1].asText ?? "1" + let minVal = row[2].asText ?? "1" + let maxVal = row[3].asText ?? "9223372036854775807" + let incrementBy = row[4].asText ?? "1" + let cycle = row[5].asText == "t" ? " CYCLE" : "" + let lastValue = row.count > 6 ? row[6].asText : nil let quotedSeqName = "\"\(seqName.replacingOccurrences(of: "\"", with: "\"\""))\"" let escapedSchemaForLiteral = schemaName.replacingOccurrences(of: "'", with: "''") let escapedSeqForLiteral = seqName.replacingOccurrences(of: "'", with: "''") @@ -862,15 +862,15 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) guard let row = result.rows.first, row.count >= 4, - let collate = row[0], - let ctype = row[1] else { + let collate = row[0].asText, + let ctype = row[1].asText else { return nil } return Template1Defaults( collate: collate, ctype: ctype, - provider: row[2], - iculocale: row[3] + provider: row[2].asText, + iculocale: row[3].asText ) } catch { Self.logger.error( @@ -888,7 +888,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var libc: [String] = [] var icu: [String] = [] for row in result.rows { - guard row.count >= 2, let name = row[0], let provider = row[1] else { continue } + guard row.count >= 2, let name = row[0].asText, let provider = row[1].asText else { continue } switch provider { case "b", "c": libc.append(name) diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 534c1f593..91657c3f9 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -66,7 +66,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { self.libpqConnection = pqConn if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"), - let schema = schemaResult.rows.first?.first.flatMap({ $0 }) { + let schema = schemaResult.rows.first?.first?.asText { _currentSchema = schema } } @@ -109,7 +109,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard let pqConn = libpqConnection else { throw LibPQPluginError.notConnected } @@ -178,9 +178,9 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY table_name """ let result = try await execute(query: query) - return result.rows.compactMap { row in - guard let name = row[0] else { return nil } - let typeStr = row[1] ?? "BASE TABLE" + return result.rows.compactMap { row -> PluginTableInfo? in + guard let name = row[0].asText else { return nil } + let typeStr = row[1].asText ?? "BASE TABLE" let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: type) } @@ -219,13 +219,13 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY c.ordinal_position """ let result = try await execute(query: query) - return result.rows.compactMap { row in + return result.rows.compactMap { row -> PluginColumnInfo? in guard row.count >= 4, - let name = row[0], - let rawDataType = row[1] + let name = row[0].asText, + let rawDataType = row[1].asText else { return nil } - let udtName = row.count > 6 ? row[6] : nil + let udtName = row.count > 6 ? row[6].asText : nil let dataType: String if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { dataType = "ENUM(\(udt))" @@ -233,11 +233,11 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType = rawDataType.uppercased() } - let isNullable = row[2] == "YES" - let defaultValue = row[3] - let collation = row.count > 4 ? row[4] : nil - let comment = row.count > 5 ? row[5] : nil - let isPk = row.count > 7 && row[7] == "YES" + let isNullable = row[2].asText == "YES" + let defaultValue = row[3].asText + let collation = row.count > 4 ? row[4].asText : nil + let comment = row.count > 5 ? row[5].asText : nil + let isPk = row.count > 7 && row[7].asText == "YES" let charset: String? = { guard let coll = collation else { return nil } @@ -295,12 +295,12 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { guard row.count >= 5, - let tableName = row[0], - let name = row[1], - let rawDataType = row[2] + let tableName = row[0].asText, + let name = row[1].asText, + let rawDataType = row[2].asText else { continue } - let udtName = row.count > 7 ? row[7] : nil + let udtName = row.count > 7 ? row[7].asText : nil let dataType: String if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { dataType = "ENUM(\(udt))" @@ -308,11 +308,11 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType = rawDataType.uppercased() } - let isNullable = row[3] == "YES" - let defaultValue = row[4] - let collation = row.count > 5 ? row[5] : nil - let comment = row.count > 6 ? row[6] : nil - let isPk = row.count > 8 && row[8] == "YES" + let isNullable = row[3].asText == "YES" + let defaultValue = row[4].asText + let collation = row.count > 5 ? row[5].asText : nil + let comment = row.count > 6 ? row[6].asText : nil + let isPk = row.count > 8 && row[8].asText == "YES" let charset: String? = { guard let coll = collation else { return nil } @@ -356,9 +356,9 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var distkeyCols: [String] = [] var sortkeyCols: [String] = [] for row in result.rows { - guard let colName = row[0] else { continue } - let isDistkey = row[2] == "t" - let sortKeyVal = Int(row[3] ?? "0") ?? 0 + guard let colName = row[0].asText else { continue } + let isDistkey = row[2].asText == "t" + let sortKeyVal = Int(row[3].asText ?? "0") ?? 0 if isDistkey { distkeyCols.append(colName) } if sortKeyVal != 0 { sortkeyCols.append(colName) } } @@ -395,20 +395,20 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY tc.constraint_name """ let result = try await execute(query: query) - return result.rows.compactMap { row in + return result.rows.compactMap { row -> PluginForeignKeyInfo? in guard row.count >= 6, - let name = row[0], - let column = row[1], - let refTable = row[2], - let refColumn = row[3] + let name = row[0].asText, + let column = row[1].asText, + let refTable = row[2].asText, + let refColumn = row[3].asText else { return nil } return PluginForeignKeyInfo( name: name, column: column, referencedTable: refTable, referencedColumn: refColumn, - onDelete: row[4] ?? "NO ACTION", - onUpdate: row[5] ?? "NO ACTION" + onDelete: row[4].asText ?? "NO ACTION", + onUpdate: row[5].asText ?? "NO ACTION" ) } } @@ -422,7 +422,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND schema = '\(escapedSchema)' """ let result = try await execute(query: query) - guard let firstRow = result.rows.first, let value = firstRow[0], let count = Int(value) else { return nil } + guard let firstRow = result.rows.first, let value = firstRow[0].asText, let count = Int(value) else { return nil } return count >= 0 ? count : nil } @@ -433,7 +433,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let showResult = try await execute(query: "SHOW TABLE \(quotedSchema).\(quotedTable)") - if let firstRow = showResult.rows.first, let ddl = firstRow[0], !ddl.isEmpty { + if let firstRow = showResult.rows.first, let ddl = firstRow[0].asText, !ddl.isEmpty { return ddl } } catch { @@ -456,7 +456,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY a.attnum """ let columnsResult = try await execute(query: columnsQuery) - let columnDefs = columnsResult.rows.compactMap { $0[0] } + let columnDefs = columnsResult.rows.compactMap { $0[0].asText } guard !columnDefs.isEmpty else { throw LibPQPluginError(message: "Failed to fetch DDL for table '\(table)'", sqlState: nil, detail: nil) } @@ -494,7 +494,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND schemaname = '\(escapedSchema)' """ let result = try await execute(query: query) - guard let firstRow = result.rows.first, let ddl = firstRow[0] else { + guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { throw LibPQPluginError(message: "Failed to fetch definition for view '\(view)'", sqlState: nil, detail: nil) } return ddl @@ -519,11 +519,11 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let rowCount: Int64? = { - guard let val = row[0] else { return nil } + guard let val = row[0].asText else { return nil } return Int64(val) }() - let sizeMb = Int64(row[1] ?? "0") ?? 0 + let sizeMb = Int64(row[1].asText ?? "0") ?? 0 let totalSize = sizeMb * 1_024 * 1_024 return PluginTableMetadata( @@ -539,12 +539,12 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute( query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" ) - return result.rows.compactMap { row in row.first.flatMap { $0 } } + return result.rows.compactMap { row in row.first?.asText } } func fetchSchemas() async throws -> [String] { let result = try await execute(query: PostgreSQLSchemaQueries.listSchemasRedshift) - return result.rows.compactMap { row in row.first.flatMap { $0 } } + return result.rows.compactMap { row in row.first?.asText } } func switchSchema(to schema: String) async throws { @@ -568,8 +568,8 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { async let sizeResult = execute(query: sizeQuery) let (countRes, sizeRes) = try await (countResult, sizeResult) - let tableCount = Int(countRes.rows.first?[0] ?? "0") ?? 0 - let sizeMb = Int64(sizeRes.rows.first?[0] ?? "0") ?? 0 + let tableCount = Int(countRes.rows.first?[0].asText ?? "0") ?? 0 + let sizeMb = Int64(sizeRes.rows.first?[0].asText ?? "0") ?? 0 let sizeBytes = sizeMb * 1_024 * 1_024 let systemDatabases = ["dev", "padb_harvest"] @@ -588,7 +588,7 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let dbResult = try await execute( query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" ) - let dbNames = dbResult.rows.compactMap { $0.first.flatMap { $0 } } + let dbNames = dbResult.rows.compactMap { $0.first?.asText } let infoQuery = """ SELECT database, COUNT(DISTINCT "table"), COALESCE(SUM(size), 0) @@ -599,9 +599,9 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let infoResult = try await execute(query: infoQuery) var metadataByName: [String: (tableCount: Int, sizeMb: Int64)] = [:] for row in infoResult.rows { - guard let dbName = row[0] else { continue } - let tableCount = Int(row[1] ?? "0") ?? 0 - let sizeMb = Int64(row[2] ?? "0") ?? 0 + guard let dbName = row[0].asText else { continue } + let tableCount = Int(row[1].asText ?? "0") ?? 0 + let sizeMb = Int64(row[2].asText ?? "0") ?? 0 metadataByName[dbName] = (tableCount: tableCount, sizeMb: sizeMb) } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 8bcad78bb..1681d6511 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -11,6 +11,22 @@ import Foundation import OSLog import TableProPluginKit +private extension Array where Element == String? { + var asCells: [PluginCellValue] { map(PluginCellValue.fromOptional) } +} + +private extension Array where Element == String { + var asCells: [PluginCellValue] { map(PluginCellValue.text) } +} + +private extension Array where Element == [String?] { + var asCellRows: [[PluginCellValue]] { map { $0.map(PluginCellValue.fromOptional) } } +} + +private extension Array where Element == [String] { + var asCellRows: [[PluginCellValue]] { map { $0.map(PluginCellValue.text) } } +} + final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private var redisConnection: RedisPluginConnection? @@ -98,7 +114,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await executeOperation(operation, connection: conn, startTime: startTime) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { try await execute(query: query) } @@ -546,7 +562,12 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } else { preview = nil } - rowBatch.append([key, typeNames[i], ttlStr, preview]) + rowBatch.append([ + .text(key), + .text(typeNames[i]), + .text(ttlStr), + PluginCellValue.fromOptional(preview) + ]) } if !rowBatch.isEmpty { continuation.yield(.rows(rowBatch)) @@ -598,10 +619,10 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])]? { + ) -> [(statement: String, parameters: [PluginCellValue])]? { let generator = RedisStatementGenerator(namespaceName: table, columns: columns) return generator.generateStatements( from: changes, insertedRowData: insertedRowData, @@ -656,7 +677,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Value"], columnTypeNames: ["String", "String"], - rows: [[key, value]], + rows: [[key, value].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -681,7 +702,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["deleted"], columnTypeNames: ["Int64"], - rows: [[String(deleted)]], + rows: [[String(deleted)].asCells], rowsAffected: deleted, executionTime: Date().timeIntervalSince(startTime) ) @@ -711,7 +732,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Type"], columnTypeNames: ["String", "String"], - rows: [[key, typeName]], + rows: [[key, typeName].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -722,7 +743,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "TTL"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(ttl)]], + rows: [[key, String(ttl)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -733,7 +754,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "PTTL"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(pttl)]], + rows: [[key, String(pttl)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -762,7 +783,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["exists"], columnTypeNames: ["Int64"], - rows: [[String(count)]], + rows: [[String(count)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -786,7 +807,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Field", "Value"], columnTypeNames: ["String", "String"], - rows: [[field, value]], + rows: [[field, value].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -801,7 +822,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["added"], columnTypeNames: ["Int64"], - rows: [[String(added)]], + rows: [[String(added)].asCells], rowsAffected: added, executionTime: Date().timeIntervalSince(startTime) ) @@ -817,7 +838,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["removed"], columnTypeNames: ["Int64"], - rows: [[String(removed)]], + rows: [[String(removed)].asCells], rowsAffected: removed, executionTime: Date().timeIntervalSince(startTime) ) @@ -846,7 +867,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["length"], columnTypeNames: ["Int64"], - rows: [[String(length)]], + rows: [[String(length)].asCells], rowsAffected: values.count, executionTime: Date().timeIntervalSince(startTime) ) @@ -858,7 +879,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["length"], columnTypeNames: ["Int64"], - rows: [[String(length)]], + rows: [[String(length)].asCells], rowsAffected: values.count, executionTime: Date().timeIntervalSince(startTime) ) @@ -869,7 +890,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Length"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(length)]], + rows: [[key, String(length)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -898,7 +919,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["added"], columnTypeNames: ["Int64"], - rows: [[String(added)]], + rows: [[String(added)].asCells], rowsAffected: added, executionTime: Date().timeIntervalSince(startTime) ) @@ -910,7 +931,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["removed"], columnTypeNames: ["Int64"], - rows: [[String(removed)]], + rows: [[String(removed)].asCells], rowsAffected: removed, executionTime: Date().timeIntervalSince(startTime) ) @@ -921,7 +942,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Cardinality"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(count)]], + rows: [[key, String(count)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -959,7 +980,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["score"], columnTypeNames: ["String"], - rows: [[scoreStr]], + rows: [[scoreStr].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -969,7 +990,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: [columnName], columnTypeNames: ["Int64"], - rows: [[String(count)]], + rows: [[String(count)].asCells], rowsAffected: count, executionTime: Date().timeIntervalSince(startTime) ) @@ -981,7 +1002,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["removed"], columnTypeNames: ["Int64"], - rows: [[String(removed)]], + rows: [[String(removed)].asCells], rowsAffected: removed, executionTime: Date().timeIntervalSince(startTime) ) @@ -992,7 +1013,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Cardinality"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(count)]], + rows: [[key, String(count)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1022,7 +1043,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["Key", "Length"], columnTypeNames: ["String", "Int64"], - rows: [[key, String(length)]], + rows: [[key, String(length)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1045,7 +1066,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["ok"], columnTypeNames: ["Int32"], - rows: [["1"]], + rows: [["1"].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1058,7 +1079,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["info"], columnTypeNames: ["String"], - rows: [[infoText]], + rows: [[infoText].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1069,7 +1090,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["keys"], columnTypeNames: ["Int64"], - rows: [[String(count)]], + rows: [[String(count)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1248,7 +1269,7 @@ private extension RedisPluginDriver { previewReplies = try await conn.executePipeline(previewCommands) } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] rows.reserveCapacity(keys.count) for (i, key) in keys.enumerated() { let ttlStr = String(ttlValues[i]) @@ -1261,7 +1282,7 @@ private extension RedisPluginDriver { } else { preview = nil } - rows.append([key, typeNames[i], ttlStr, preview]) + rows.append([key, typeNames[i], ttlStr, preview].asCells) } return PluginQueryResult( @@ -1422,7 +1443,7 @@ private extension RedisPluginDriver { PluginQueryResult( columns: ["status"], columnTypeNames: ["String"], - rows: [[message]], + rows: [[message].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1434,7 +1455,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["result"], columnTypeNames: ["String"], - rows: [[s]], + rows: [[s].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1443,7 +1464,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["result"], columnTypeNames: ["Int64"], - rows: [[String(i)]], + rows: [[String(i)].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1453,13 +1474,13 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["result"], columnTypeNames: ["String"], - rows: [[str]], + rows: [[str].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) case .array(let items): - let rows = items.map { [redisReplyToString($0)] as [String?] } + let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } return PluginQueryResult( columns: ["result"], columnTypeNames: ["String"], @@ -1472,7 +1493,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["result"], columnTypeNames: ["String"], - rows: [[e]], + rows: [[e].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1481,7 +1502,7 @@ private extension RedisPluginDriver { return PluginQueryResult( columns: ["result"], columnTypeNames: ["String"], - rows: [["(nil)"]], + rows: [["(nil)"].asCells], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) @@ -1509,10 +1530,10 @@ private extension RedisPluginDriver { ) } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var i = 0 while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) i += 2 } @@ -1536,8 +1557,8 @@ private extension RedisPluginDriver { ) } - let rows = items.enumerated().map { index, item -> [String?] in - [String(startOffset + index), redisReplyToString(item)] + let rows = items.enumerated().map { index, item -> [PluginCellValue] in + ([String(startOffset + index), redisReplyToString(item)] as [String?]).asCells } return PluginQueryResult( @@ -1560,7 +1581,7 @@ private extension RedisPluginDriver { ) } - let rows = items.map { [redisReplyToString($0)] as [String?] } + let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } return PluginQueryResult( columns: ["Member"], @@ -1583,10 +1604,10 @@ private extension RedisPluginDriver { } if withScores { - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var i = 0 while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) i += 2 } return PluginQueryResult( @@ -1597,7 +1618,7 @@ private extension RedisPluginDriver { executionTime: Date().timeIntervalSince(startTime) ) } else { - let rows = items.map { [redisReplyToString($0)] as [String?] } + let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } return PluginQueryResult( columns: ["Member"], columnTypeNames: ["String"], @@ -1619,7 +1640,7 @@ private extension RedisPluginDriver { ) } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] for entry in entries { guard let entryParts = entry.arrayValue, entryParts.count >= 2, let fields = entryParts[1].arrayValue else { @@ -1633,7 +1654,7 @@ private extension RedisPluginDriver { fieldPairs.append("\(redisReplyToString(fields[i]))=\(redisReplyToString(fields[i + 1]))") i += 2 } - rows.append([entryId, fieldPairs.joined(separator: ", ")]) + rows.append([entryId, fieldPairs.joined(separator: ", ")].asCells) } return PluginQueryResult( @@ -1656,10 +1677,10 @@ private extension RedisPluginDriver { ) } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var i = 0 while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) i += 2 } diff --git a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift index 21593b169..5d6a95e80 100644 --- a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift +++ b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift @@ -41,11 +41,11 @@ struct RedisStatementGenerator { /// Generate Redis commands from changes func generateStatements( from changes: [PluginRowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [String?])] { - var statements: [(statement: String, parameters: [String?])] = [] + ) -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] var deleteKeys: [String] = [] for change in changes { @@ -79,9 +79,9 @@ struct RedisStatementGenerator { private func generateInsert( for change: PluginRowChange, - insertedRowData: [Int: [String?]] - ) -> [(statement: String, parameters: [String?])] { - var statements: [(statement: String, parameters: [String?])] = [] + insertedRowData: [Int: [PluginCellValue]] + ) -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] var key: String? var value: String? @@ -90,25 +90,25 @@ struct RedisStatementGenerator { if let values = insertedRowData[change.rowIndex] { if let ki = keyColumnIndex, ki < values.count { - key = values[ki] + key = values[ki].asText } if let ti = typeColumnIndex, ti < values.count { - type = values[ti] + type = values[ti].asText } if let vi = valueColumnIndex, vi < values.count { - value = values[vi] + value = values[vi].asText } - if let ttli = ttlColumnIndex, ttli < values.count, let ttlStr = values[ttli] { + if let ttli = ttlColumnIndex, ttli < values.count, let ttlStr = values[ttli].asText { ttl = Int(ttlStr) } } else { for cellChange in change.cellChanges { switch cellChange.columnName { - case "Key": key = cellChange.newValue - case "Type": type = cellChange.newValue - case "Value": value = cellChange.newValue + case "Key": key = cellChange.newValue.asText + case "Type": type = cellChange.newValue.asText + case "Value": value = cellChange.newValue.asText case "TTL": - if let ttlStr = cellChange.newValue { ttl = Int(ttlStr) } + if let ttlStr = cellChange.newValue.asText { ttl = Int(ttlStr) } default: break } } @@ -158,7 +158,7 @@ struct RedisStatementGenerator { // MARK: - UPDATE - private func generateUpdate(for change: PluginRowChange) -> [(statement: String, parameters: [String?])] { + private func generateUpdate(for change: PluginRowChange) -> [(statement: String, parameters: [PluginCellValue])] { guard !change.cellChanges.isEmpty else { return [] } guard let key = extractKey(from: change) else { @@ -166,18 +166,18 @@ struct RedisStatementGenerator { return [] } - var statements: [(statement: String, parameters: [String?])] = [] + var statements: [(statement: String, parameters: [PluginCellValue])] = [] // Check for key rename if let keyChange = change.cellChanges.first(where: { $0.columnName == "Key" }), - let newKey = keyChange.newValue, newKey != key { + let newKey = keyChange.newValue.asText, newKey != key { let renameCmd = "RENAME \(escapeArgument(key)) \(escapeArgument(newKey))" statements.append((statement: renameCmd, parameters: [])) } let effectiveKey: String = { if let keyChange = change.cellChanges.first(where: { $0.columnName == "Key" }), - let newKey = keyChange.newValue { + let newKey = keyChange.newValue.asText { return newKey } return key @@ -190,7 +190,7 @@ struct RedisStatementGenerator { ti < originalRow.count else { return nil } - return originalRow[ti] + return originalRow[ti].asText }() for cellChange in change.cellChanges { @@ -198,7 +198,7 @@ struct RedisStatementGenerator { case "Key": continue // Already handled above case "Value": - if let newValue = cellChange.newValue { + if let newValue = cellChange.newValue.asText { let typeLower = redisType?.lowercased() ?? "string" if typeLower != "string" { // Non-string types show a preview; blindly SET would destroy the data structure @@ -211,10 +211,10 @@ struct RedisStatementGenerator { statements.append((statement: cmd, parameters: [])) } case "TTL": - if let ttlStr = cellChange.newValue, let ttlSeconds = Int(ttlStr), ttlSeconds > 0 { + if let ttlStr = cellChange.newValue.asText, let ttlSeconds = Int(ttlStr), ttlSeconds > 0 { let cmd = "EXPIRE \(escapeArgument(effectiveKey)) \(ttlSeconds)" statements.append((statement: cmd, parameters: [])) - } else if cellChange.newValue == nil || cellChange.newValue == "-1" { + } else if cellChange.newValue.isNull || cellChange.newValue.asText == "-1" { let cmd = "PERSIST \(escapeArgument(effectiveKey))" statements.append((statement: cmd, parameters: [])) } @@ -235,7 +235,7 @@ struct RedisStatementGenerator { keyIndex < originalRow.count else { return nil } - return originalRow[keyIndex] + return originalRow[keyIndex].asText } /// Escape a Redis argument for safe embedding in a command string. diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 78f41e43a..db7f8b484 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -434,7 +434,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { var wroteAnyRows = false var columns: [String] = [] var columnTypeNames: [String] = [] - var rowBatch: [[String?]] = [] + var rowBatch: [[PluginCellValue]] = [] let generatedColumnNames = Set(columnInfo.filter { $0.isGenerated }.map { $0.name }) let usesOverridingSystemValue = columnInfo.contains { $0.identityKind == .always } @@ -495,7 +495,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { tableName: String, columns: [String], columnTypeNames: [String], - rows: [[String?]], + rows: [[PluginCellValue]], batchSize: Int, excludedColumnNames: Set, usesOverridingSystemValue: Bool, @@ -527,13 +527,21 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { try progress.checkCancellation() let values = includedColumnIndices.map { colIndex -> String in - let value = colIndex < row.count ? row[colIndex] : nil - guard let val = value else { return "NULL" } - if numericIndices.contains(colIndex) && isNumericLiteral(val) { - return val + guard colIndex < row.count else { return "NULL" } + let cell = row[colIndex] + switch cell { + case .null: + return "NULL" + case .bytes(let data): + let hex = data.map { String(format: "%02X", $0) }.joined() + return "X'\(hex)'" + case .text(let val): + if numericIndices.contains(colIndex) && isNumericLiteral(val) { + return val + } + let escaped = dataSource.escapeStringLiteral(val) + return "'\(escaped)'" } - let escaped = dataSource.escapeStringLiteral(val) - return "'\(escaped)'" }.joined(separator: ", ") valuesBatch.append(" (\(values))") diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index c30e91f18..9875ebe22 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -164,7 +164,7 @@ private actor SQLiteConnectionActor { } } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var rowsAffected = 0 var truncated = false @@ -174,24 +174,23 @@ private actor SQLiteConnectionActor { break } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 0.. 0, let blobPtr = sqlite3_column_blob(statement, i) { - let data = Data(bytes: blobPtr, count: byteCount) - row.append(String(data: data, encoding: .isoLatin1) ?? "") + row.append(.bytes(Data(bytes: blobPtr, count: byteCount))) } else { - row.append("") + row.append(.bytes(Data())) } } else if let text = sqlite3_column_text(statement, i) { - row.append(String(cString: text)) + row.append(.text(String(cString: text))) } else { - row.append(nil) + row.append(.null) } } @@ -265,24 +264,23 @@ private actor SQLiteConnectionActor { return } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 0.. 0, let blobPtr = sqlite3_column_blob(statement, i) { - let data = Data(bytes: blobPtr, count: byteCount) - row.append(String(data: data, encoding: .isoLatin1) ?? "") + row.append(.bytes(Data(bytes: blobPtr, count: byteCount))) } else { - row.append("") + row.append(.bytes(Data())) } } else if let text = sqlite3_column_text(statement, i) { - row.append(String(cString: text)) + row.append(.text(String(cString: text))) } else { - row.append(nil) + row.append(.null) } } @@ -301,7 +299,7 @@ private actor SQLiteConnectionActor { continuation.finish() } - func executeParameterizedQuery(_ query: String, stringParams: [String?]) throws -> SQLiteRawResult { + func executeParameterizedQuery(_ query: String, parameters: [PluginCellValue]) throws -> SQLiteRawResult { guard let db else { throw SQLitePluginError.notConnected } @@ -320,30 +318,29 @@ private actor SQLiteConnectionActor { sqlite3_finalize(statement) } - for (index, param) in stringParams.enumerated() { + let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + for (index, param) in parameters.enumerated() { let bindIndex = Int32(index + 1) + let bindResult: Int32 + + switch param { + case .null: + bindResult = sqlite3_bind_null(statement, bindIndex) + case .text(let stringValue): + bindResult = sqlite3_bind_text(statement, bindIndex, stringValue, -1, sqliteTransient) + case .bytes(let data): + bindResult = data.withUnsafeBytes { rawBuffer -> Int32 in + let baseAddress = rawBuffer.baseAddress + return sqlite3_bind_blob(statement, bindIndex, baseAddress, Int32(data.count), sqliteTransient) + } + } - if let stringValue = param { - // SQLITE_TRANSIENT ensures SQLite copies the string immediately, - // preventing use-after-free from Swift's temporary C string bridge - let bindResult = sqlite3_bind_text( - statement, bindIndex, stringValue, -1, - unsafeBitCast(-1, to: sqlite3_destructor_type.self) + if bindResult != SQLITE_OK { + let errorMessage = String(cString: sqlite3_errmsg(db)) + throw SQLitePluginError.queryFailed( + "Failed to bind parameter \(index): \(errorMessage)" ) - if bindResult != SQLITE_OK { - let errorMessage = String(cString: sqlite3_errmsg(db)) - throw SQLitePluginError.queryFailed( - "Failed to bind parameter \(index): \(errorMessage)" - ) - } - } else { - let bindResult = sqlite3_bind_null(statement, bindIndex) - if bindResult != SQLITE_OK { - let errorMessage = String(cString: sqlite3_errmsg(db)) - throw SQLitePluginError.queryFailed( - "Failed to bind NULL parameter \(index): \(errorMessage)" - ) - } } } @@ -365,7 +362,7 @@ private actor SQLiteConnectionActor { } } - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var rowsAffected = 0 var truncated = false @@ -375,24 +372,23 @@ private actor SQLiteConnectionActor { break } - var row: [String?] = [] + var row: [PluginCellValue] = [] for i in 0.. 0, let blobPtr = sqlite3_column_blob(statement, i) { - let data = Data(bytes: blobPtr, count: byteCount) - row.append(String(data: data, encoding: .isoLatin1) ?? "") + row.append(.bytes(Data(bytes: blobPtr, count: byteCount))) } else { - row.append("") + row.append(.bytes(Data())) } } else if let text = sqlite3_column_text(statement, i) { - row.append(String(cString: text)) + row.append(.text(String(cString: text))) } else { - row.append(nil) + row.append(.null) } } @@ -419,7 +415,7 @@ private actor SQLiteConnectionActor { private struct SQLiteRawResult: Sendable { let columns: [String] let columnTypeNames: [String] - let rows: [[String?]] + let rows: [[PluginCellValue]] let rowsAffected: Int let executionTime: TimeInterval let isTruncated: Bool @@ -507,8 +503,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { - let rawResult = try await connectionActor.executeParameterizedQuery(query, stringParams: parameters) + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + let rawResult = try await connectionActor.executeParameterizedQuery(query, parameters: parameters) return PluginQueryResult( columns: rawResult.columns, columnTypeNames: rawResult.columnTypeNames, @@ -572,7 +568,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - User Query - func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult { + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { if let parameters { let raw = try await executeParameterized(query: query, parameters: parameters) guard let cap = rowCap, cap > 0, raw.rows.count > cap else { return raw } @@ -590,7 +586,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let startTime = Date() var columns: [String] = [] var columnTypeNames: [String] = [] - var rows: [[String?]] = [] + var rows: [[PluginCellValue]] = [] var truncated = false let stream = streamRows(query: query) @@ -639,8 +635,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ let result = try await execute(query: query) return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil else { return nil } - let typeString = (row[safe: 1] ?? nil) ?? "table" + guard let name = row[safe: 0]?.asText else { return nil } + let typeString = row[safe: 1]?.asText ?? "table" let tableType = typeString.lowercased() == "view" ? "VIEW" : "TABLE" return PluginTableInfo(name: name, type: tableType) } @@ -653,15 +649,16 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result.rows.compactMap { row in guard row.count >= 6, - let name = row[1], - let dataType = row[2] else { + let name = row[1].asText, + let dataType = row[2].asText else { return nil } - let isNullable = row[3] == "0" + let isNullable = row[3].asText == "0" // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK - let isPrimaryKey = row[5] != nil && row[5] != "0" - let defaultValue = row[4] + let pkText = row[5].asText + let isPrimaryKey = pkText != nil && pkText != "0" + let defaultValue = row[4].asText return PluginColumnInfo( name: name, @@ -686,16 +683,17 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { for row in result.rows { guard row.count >= 7, - let tableName = row[0], - let columnName = row[2], - let dataType = row[3] else { + let tableName = row[0].asText, + let columnName = row[2].asText, + let dataType = row[3].asText else { continue } - let isNullable = row[4] == "0" - let defaultValue = row[5] + let isNullable = row[4].asText == "0" + let defaultValue = row[5].asText // PRAGMA table_info pk column: 0 = not PK, 1+ = position in composite PK - let isPrimaryKey = row[6] != nil && row[6] != "0" + let pkText = row[6].asText + let isPrimaryKey = pkText != nil && pkText != "0" let column = PluginColumnInfo( name: columnName, @@ -726,16 +724,16 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { for row in result.rows { guard row.count >= 7, - let tableName = row[0], - let id = row[1], - let refTable = row[2], - let fromCol = row[3], - let toCol = row[4] else { + let tableName = row[0].asText, + let id = row[1].asText, + let refTable = row[2].asText, + let fromCol = row[3].asText, + let toCol = row[4].asText else { continue } - let onUpdate = row[5] ?? "NO ACTION" - let onDelete = row[6] ?? "NO ACTION" + let onUpdate = row[5].asText ?? "NO ACTION" + let onDelete = row[6].asText ?? "NO ACTION" let fk = PluginForeignKeyInfo( name: "fk_\(tableName)_\(id)", @@ -767,17 +765,17 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { for row in result.rows { guard row.count >= 4, - let indexName = row[0] else { continue } + let indexName = row[0].asText else { continue } - let isUnique = row[1] == "1" - let origin = row[2] ?? "c" + let isUnique = row[1].asText == "1" + let origin = row[2].asText ?? "c" if let idx = indexLookup[indexName] { - if let colName = row[3] { + if let colName = row[3].asText { indexMap[idx].columns.append(colName) } } else { - let columns: [String] = row[3].map { [$0] } ?? [] + let columns: [String] = row[3].asText.map { [$0] } ?? [] indexLookup[indexName] = indexMap.count indexMap.append(( name: indexName, @@ -804,17 +802,17 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let query = "PRAGMA foreign_key_list('\(safeTable)')" let result = try await execute(query: query) - return result.rows.compactMap { row in + return result.rows.compactMap { row -> PluginForeignKeyInfo? in guard row.count >= 5, - let refTable = row[2], - let fromCol = row[3], - let toCol = row[4] else { + let refTable = row[2].asText, + let fromCol = row[3].asText, + let toCol = row[4].asText else { return nil } - let id = row[0] ?? "0" - let onUpdate = row.count >= 6 ? (row[5] ?? "NO ACTION") : "NO ACTION" - let onDelete = row.count >= 7 ? (row[6] ?? "NO ACTION") : "NO ACTION" + let id = row[0].asText ?? "0" + let onUpdate = row.count >= 6 ? (row[5].asText ?? "NO ACTION") : "NO ACTION" + let onDelete = row.count >= 7 ? (row[6].asText ?? "NO ACTION") : "NO ACTION" return PluginForeignKeyInfo( name: "fk_\(table)_\(id)", @@ -836,7 +834,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: query) guard let firstRow = result.rows.first, - let ddl = firstRow[0] else { + let ddl = firstRow[0].asText else { throw SQLitePluginError.queryFailed("Failed to fetch DDL for table '\(table)'") } @@ -853,7 +851,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: query) guard let firstRow = result.rows.first, - let ddl = firstRow[0] else { + let ddl = firstRow[0].asText else { throw SQLitePluginError.queryFailed("Failed to fetch definition for view '\(view)'") } @@ -865,8 +863,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let countQuery = "SELECT COUNT(*) FROM (SELECT 1 FROM \"\(safeTableName)\" LIMIT 100001)" let countResult = try await execute(query: countQuery) let rowCount: Int64? = { - guard let row = countResult.rows.first, let countStr = row.first else { return nil } - return Int64(countStr ?? "0") + guard let row = countResult.rows.first, let firstCell = row.first else { return nil } + return Int64(firstCell.asText ?? "0") }() return PluginTableMetadata( diff --git a/Plugins/TableProPluginKit/PluginCellValue.swift b/Plugins/TableProPluginKit/PluginCellValue.swift new file mode 100644 index 000000000..121f3729a --- /dev/null +++ b/Plugins/TableProPluginKit/PluginCellValue.swift @@ -0,0 +1,80 @@ +import Foundation + +public enum PluginCellValue: Sendable, Hashable { + case null + case text(String) + case bytes(Data) +} + +extension PluginCellValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .text(value) + } +} + +extension PluginCellValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +public extension PluginCellValue { + static func fromOptional(_ string: String?) -> PluginCellValue { + string.map(PluginCellValue.text) ?? .null + } + + var isNull: Bool { + if case .null = self { return true } + return false + } + + var asText: String? { + if case .text(let value) = self { return value } + return nil + } + + var asBytes: Data? { + if case .bytes(let value) = self { return value } + return nil + } +} + +extension PluginCellValue: Codable { + private enum CodingKeys: String, CodingKey { + case kind + case value + } + + private enum Kind: String, Codable { + case null + case text + case bytes + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .kind) + switch kind { + case .null: + self = .null + case .text: + self = .text(try container.decode(String.self, forKey: .value)) + case .bytes: + self = .bytes(try container.decode(Data.self, forKey: .value)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .null: + try container.encode(Kind.null, forKey: .kind) + case .text(let value): + try container.encode(Kind.text, forKey: .kind) + try container.encode(value, forKey: .value) + case .bytes(let value): + try container.encode(Kind.bytes, forKey: .kind) + try container.encode(value, forKey: .value) + } + } +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 3da660cce..3b7524f20 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -14,14 +14,14 @@ public struct PluginRowChange: Sendable { public let rowIndex: Int public let type: ChangeType - public let cellChanges: [(columnIndex: Int, columnName: String, oldValue: String?, newValue: String?)] - public let originalRow: [String?]? + public let cellChanges: [(columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue)] + public let originalRow: [PluginCellValue]? public init( rowIndex: Int, type: ChangeType, - cellChanges: [(columnIndex: Int, columnName: String, oldValue: String?, newValue: String?)], - originalRow: [String?]? + cellChanges: [(columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue)], + originalRow: [PluginCellValue]? ) { self.rowIndex = rowIndex self.type = type @@ -40,7 +40,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Queries func execute(query: String) async throws -> PluginQueryResult - func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult // Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] @@ -81,13 +81,13 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? func createDatabase(_ request: PluginCreateDatabaseRequest) async throws func dropDatabase(name: String) async throws - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult // Query building (optional, for NoSQL plugins) func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? // Statement generation (optional, for NoSQL plugins) - func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? + func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws @@ -245,7 +245,7 @@ public extension PluginDatabaseDriver { func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } - func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } + func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? { nil } func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { nil } @@ -311,7 +311,7 @@ public extension PluginDatabaseDriver { return result } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) } @@ -327,7 +327,7 @@ public extension PluginDatabaseDriver { return try await execute(query: sql) } - private static func substituteQuestionMarks(query: String, parameters: [String?]) -> String { + private static func substituteQuestionMarks(query: String, parameters: [PluginCellValue]) -> String { let nsQuery = query as NSString let length = nsQuery.length var sql = "" @@ -374,11 +374,7 @@ public extension PluginDatabaseDriver { } if char == questionMark && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { - if let value = parameters[paramIndex] { - sql.append(escapedParameterValue(value)) - } else { - sql.append("NULL") - } + sql.append(sqlLiteral(for: parameters[paramIndex])) paramIndex += 1 } else { if let scalar = UnicodeScalar(char) { @@ -394,7 +390,7 @@ public extension PluginDatabaseDriver { return sql } - private static func substituteDollarParams(query: String, parameters: [String?]) -> String { + private static func substituteDollarParams(query: String, parameters: [PluginCellValue]) -> String { let nsQuery = query as NSString let length = nsQuery.length var sql = "" @@ -453,11 +449,7 @@ public extension PluginDatabaseDriver { } } if !numStr.isEmpty, let paramNum = Int(numStr), paramNum >= 1, paramNum <= parameters.count { - if let value = parameters[paramNum - 1] { - sql.append(escapedParameterValue(value)) - } else { - sql.append("NULL") - } + sql.append(sqlLiteral(for: parameters[paramNum - 1])) i = j continue } @@ -474,6 +466,23 @@ public extension PluginDatabaseDriver { return sql } + static func sqlLiteral(for value: PluginCellValue) -> String { + switch value { + case .null: + return "NULL" + case .text(let s): + return escapedParameterValue(s) + case .bytes(let data): + var hex = "X'" + hex.reserveCapacity(2 + data.count * 2 + 1) + for byte in data { + hex.append(String(format: "%02X", byte)) + } + hex.append("'") + return hex + } + } + static func escapedParameterValue(_ value: String) -> String { if isNumericLiteral(value) { return value @@ -542,7 +551,7 @@ public extension PluginDatabaseDriver { return hasDigit } - func executeUserQuery(query: String, rowCap: Int?, parameters: [String?]?) async throws -> PluginQueryResult { + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { let raw: PluginQueryResult if let parameters { raw = try await executeParameterized(query: query, parameters: parameters) diff --git a/Plugins/TableProPluginKit/PluginQueryResult.swift b/Plugins/TableProPluginKit/PluginQueryResult.swift index 655a236a8..49b96fab3 100644 --- a/Plugins/TableProPluginKit/PluginQueryResult.swift +++ b/Plugins/TableProPluginKit/PluginQueryResult.swift @@ -3,7 +3,7 @@ import Foundation public struct PluginQueryResult: Codable, Sendable { public let columns: [String] public let columnTypeNames: [String] - public let rows: [[String?]] + public let rows: [[PluginCellValue]] public let rowsAffected: Int public let executionTime: TimeInterval public let isTruncated: Bool @@ -12,7 +12,7 @@ public struct PluginQueryResult: Codable, Sendable { public init( columns: [String], columnTypeNames: [String], - rows: [[String?]], + rows: [[PluginCellValue]], rowsAffected: Int, executionTime: TimeInterval, isTruncated: Bool = false, diff --git a/Plugins/TableProPluginKit/PluginStreamTypes.swift b/Plugins/TableProPluginKit/PluginStreamTypes.swift index 73c37b814..30ac43c82 100644 --- a/Plugins/TableProPluginKit/PluginStreamTypes.swift +++ b/Plugins/TableProPluginKit/PluginStreamTypes.swift @@ -1,6 +1,6 @@ import Foundation -public typealias PluginRow = [String?] +public typealias PluginRow = [PluginCellValue] public struct PluginStreamHeader: Sendable { public let columns: [String] diff --git a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 62204d2d5..68e37cae1 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -47,7 +47,7 @@ final class XLSXExportPlugin: ExportFormatPlugin, SettablePlugin { progress.setCurrentTable(table.qualifiedName, index: index + 1) var isFirstBatch = true - var rowBatch: [[String?]] = [] + var rowBatch: [[PluginCellValue]] = [] var currentSheetRowCount = 0 var columns: [String] = [] let headerRowCount = settings.includeHeaderRow ? 1 : 0 diff --git a/Plugins/XLSXExportPlugin/XLSXWriter.swift b/Plugins/XLSXExportPlugin/XLSXWriter.swift index 7fd2c68f8..eb939d53c 100644 --- a/Plugins/XLSXExportPlugin/XLSXWriter.swift +++ b/Plugins/XLSXExportPlugin/XLSXWriter.swift @@ -12,6 +12,7 @@ import Foundation import os +import TableProPluginKit import zlib /// Writes data to XLSX format using raw ZIP file construction. @@ -72,26 +73,32 @@ final class XLSXWriter { } /// Add a batch of raw rows to the current (last) sheet. - /// Converts `[String?]` to `CellValue` and writes XML immediately, + /// Converts `[PluginCellValue]` to `CellValue` and writes XML immediately, /// so the caller can release the raw row data after this call returns. - func addRows(_ rows: [[String?]], convertNullToEmpty: Bool) { + func addRows(_ rows: [[PluginCellValue]], convertNullToEmpty: Bool) { guard !sheets.isEmpty else { return } var sheetData = sheets[sheets.count - 1].data for row in rows { autoreleasepool { - let cellRow: [CellValue] = row.map { value in - guard let val = value else { + let cellRow: [CellValue] = row.map { value -> CellValue in + switch value { + case .null: return convertNullToEmpty ? .empty : .string("NULL") + case .bytes(let data): + if data.isEmpty { return .empty } + let hex = data.map { String(format: "%02X", $0) }.joined() + return .string("0x" + hex) + case .text(let val): + if val.isEmpty { + return .empty + } + if Double(val) != nil, !val.hasPrefix("0") || val == "0" || val.contains(".") { + return .number(val) + } + return .string(val) } - if val.isEmpty { - return .empty - } - if Double(val) != nil, !val.hasPrefix("0") || val == "0" || val.contains(".") { - return .number(val) - } - return .string(val) } appendRow(cellRow, isHeader: false, to: &sheetData) } @@ -131,7 +138,7 @@ final class XLSXWriter { /// Add a complete worksheet with all rows at once (legacy compatibility). /// For better memory usage, prefer `beginSheet` / `addRows` / `finishSheet`. - func addSheet(name: String, columns: [String], rows: [[String?]], includeHeader: Bool, convertNullToEmpty: Bool) { + func addSheet(name: String, columns: [String], rows: [[PluginCellValue]], includeHeader: Bool, convertNullToEmpty: Bool) { beginSheet(name: name, columns: columns, includeHeader: includeHeader, convertNullToEmpty: convertNullToEmpty) addRows(rows, convertNullToEmpty: convertNullToEmpty) finishSheet() diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1a31b3d6a..a0a905023 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2343,7 +2343,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2 $(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.9; XROS_DEPLOYMENT_TARGET = 26.2; @@ -2421,7 +2421,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2 $(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.9; XROS_DEPLOYMENT_TARGET = 26.2; @@ -3536,7 +3536,10 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include", + "$(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS/include", + ); MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests; @@ -3545,7 +3548,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2 $(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro"; @@ -3560,7 +3563,10 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include", + "$(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS/include", + ); MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests; @@ -3569,7 +3575,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2 $(SRCROOT)/Plugins/MSSQLDriverPlugin/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro"; diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 5149392b4..28a4afa27 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -393,22 +393,27 @@ final class DataChangeManager: ChangeManaging { case .delete: return .delete } }(), - cellChanges: change.cellChanges.map { - ($0.columnIndex, $0.columnName, $0.oldValue, $0.newValue) + cellChanges: change.cellChanges.map { c -> (columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue) in + (c.columnIndex, c.columnName, PluginCellValue.fromOptional(c.oldValue), PluginCellValue.fromOptional(c.newValue)) }, - originalRow: change.originalRow + originalRow: change.originalRow.map { row in + row.map(PluginCellValue.fromOptional) + } ) } + let pluginInsertedRowData: [Int: [PluginCellValue]] = insertedRowData.mapValues { row in + row.map(PluginCellValue.fromOptional) + } if let statements = pluginDriver.generateStatements( table: tableName, columns: columns, primaryKeyColumns: primaryKeyColumns, changes: pluginChanges, - insertedRowData: insertedRowData, + insertedRowData: pluginInsertedRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices ) { - return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters) } + return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters.map { $0.asText }) } } } diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 5e456bab8..5d2e471a3 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -110,7 +110,7 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable PluginQueryResult( columns: result.columns, columnTypeNames: result.columnTypes.map { $0.rawType ?? "" }, - rows: result.rows, + rows: result.rows.map { row in row.map(PluginCellValue.fromOptional) }, rowsAffected: result.rowsAffected, executionTime: result.executionTime ) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index f2c5fa929..498997b52 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -26,12 +26,16 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { deletedRowIndices: Set, insertedRowIndices: Set ) -> [(statement: String, parameters: [String?])]? { - pluginDriver.generateStatements( + let pluginRowData = insertedRowData.mapValues { row in + row.map(PluginCellValue.fromOptional) + } + let result = pluginDriver.generateStatements( table: table, columns: columns, primaryKeyColumns: primaryKeyColumns, changes: changes, - insertedRowData: insertedRowData, + insertedRowData: pluginRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices ) + return result?.map { (statement: $0.statement, parameters: $0.parameters.map { $0.asText }) } } /// The underlying plugin driver, exposed for DDL schema generation delegation. @@ -124,28 +128,30 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { } func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { - let stringParams = parameters.map { param -> String? in - guard let p = param else { return nil } - return Self.stringValue(for: p) + let cellParams: [PluginCellValue] = parameters.map { param in + guard let p = param else { return .null } + if let data = p as? Data { return .bytes(data) } + return .text(Self.stringValue(for: p)) } - let pluginResult = try await pluginDriver.executeParameterized(query: query, parameters: stringParams) + let pluginResult = try await pluginDriver.executeParameterized(query: query, parameters: cellParams) return mapQueryResult(pluginResult) } func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { - let stringParams: [String?]? + let cellParams: [PluginCellValue]? if let parameters { - stringParams = parameters.map { param -> String? in - guard let p = param else { return nil } - return Self.stringValue(for: p) + cellParams = parameters.map { param -> PluginCellValue in + guard let p = param else { return .null } + if let data = p as? Data { return .bytes(data) } + return .text(Self.stringValue(for: p)) } } else { - stringParams = nil + cellParams = nil } let pluginResult = try await pluginDriver.executeUserQuery( query: query, rowCap: rowCap, - parameters: stringParams + parameters: cellParams ) return mapQueryResult(pluginResult) } @@ -515,10 +521,19 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { let columnTypes = pluginResult.columnTypeNames.map { mapColumnType(rawTypeName: $0) } + let stringRows: [[String?]] = pluginResult.rows.map { row in + row.map { cell -> String? in + switch cell { + case .null: return nil + case .text(let s): return s + case .bytes(let d): return String(data: d, encoding: .isoLatin1) ?? "" + } + } + } var result = QueryResult( columns: pluginResult.columns, columnTypes: columnTypes, - rows: pluginResult.rows, + rows: stringRows, rowsAffected: pluginResult.rowsAffected, executionTime: pluginResult.executionTime, error: nil diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index 4bd2201de..45c7e247b 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -12,7 +12,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send private let columns: [String] private let columnTypeNames: [String] - private let rows: [[String?]] + private let rows: [[PluginCellValue]] private let driver: DatabaseDriver? private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource") @@ -22,7 +22,9 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send self.driver = driver self.columns = tableRows.columns self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" } - self.rows = tableRows.rows.map { Array($0.values) } + self.rows = tableRows.rows.map { row in + Array(row.values).map(PluginCellValue.fromOptional) + } } func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { diff --git a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift index 90dc375f6..3ee400342 100644 --- a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift +++ b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift @@ -55,7 +55,7 @@ final class StreamingQueryExportDataSource: PluginExportDataSource, @unchecked S return PluginQueryResult( columns: result.columns, columnTypeNames: result.columnTypes.map { $0.rawType ?? "" }, - rows: result.rows, + rows: result.rows.map { row in row.map(PluginCellValue.fromOptional) }, rowsAffected: result.rowsAffected, executionTime: result.executionTime ) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 7a0d5b260..14194932d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -35,18 +35,25 @@ extension TableViewCoordinator { } } - let value = cellValue(at: row, column: columnIndex) - if let value, value.containsLineBreak { - showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) - return - } - + // Column-type guards run BEFORE content checks. Binary cells contain bytes + // that may incidentally match line-break or JSON heuristics; routing them + // through the text/overlay editor would corrupt the bytes on save. if columnIndex < tableRows.columnTypes.count { let ct = tableRows.columnTypes[columnIndex] - if ct.isBooleanType || ct.isDateType || ct.isBlobType || ct.isEnumType || ct.isSetType { + if ct.isBlobType { + showBlobEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + if ct.isBooleanType || ct.isDateType || ct.isEnumType || ct.isSetType { return } } + + let value = cellValue(at: row, column: columnIndex) + if let value, value.containsLineBreak { + showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) + return + } if let value, value.looksLikeJson { showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) return diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 31bd3a997..f1aad81c8 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -214,6 +214,13 @@ final class KeyHandlingTableView: NSTableView { return } + let tableRows = coordinator.tableRowsProvider() + if columnIndex < tableRows.columnTypes.count, + tableRows.columnTypes[columnIndex].isBlobType { + coordinator.showBlobEditorPopover(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex) + return + } + if let value = coordinator.cellValue(at: row, column: columnIndex), value.containsLineBreak { coordinator.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) diff --git a/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift b/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift deleted file mode 100644 index 3e491728e..000000000 --- a/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// ConnectionHealthMonitorTests.swift -// TableProTests -// -// Tests for ConnectionHealthMonitor state transitions and behavior. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("ConnectionHealthMonitor") -struct ConnectionHealthMonitorTests { - // MARK: - HealthState Equatable Tests - - @Test("Initial state is .healthy") - func initialStateIsHealthy() async { - let monitor = ConnectionHealthMonitor( - connectionId: UUID(), - pingHandler: { true }, - reconnectHandler: { true }, - onStateChanged: { _, _ in } - ) - let state = await monitor.currentState - #expect(state == .healthy) - } - - @Test("HealthState equality: same values are equal") - func healthStateEqualitySameValues() { - let a = ConnectionHealthMonitor.HealthState.healthy - let b = ConnectionHealthMonitor.HealthState.healthy - #expect(a == b) - - let c = ConnectionHealthMonitor.HealthState.reconnecting(attempt: 3) - let d = ConnectionHealthMonitor.HealthState.reconnecting(attempt: 3) - #expect(c == d) - } - - @Test("HealthState inequality: different attempt numbers") - func healthStateInequalityDifferentAttempts() { - let a = ConnectionHealthMonitor.HealthState.reconnecting(attempt: 1) - let b = ConnectionHealthMonitor.HealthState.reconnecting(attempt: 2) - #expect(a != b) - } - - @Test("HealthState inequality: different cases") - func healthStateInequalityDifferentCases() { - #expect(ConnectionHealthMonitor.HealthState.healthy != .checking) - #expect(ConnectionHealthMonitor.HealthState.healthy != .failed) - #expect(ConnectionHealthMonitor.HealthState.checking != .failed) - #expect(ConnectionHealthMonitor.HealthState.healthy != .reconnecting(attempt: 1)) - } - - @Test("stopMonitoring cancels and cleans up") - func stopMonitoringCancelsAndCleansUp() async { - let monitor = ConnectionHealthMonitor( - connectionId: UUID(), - pingHandler: { true }, - reconnectHandler: { true }, - onStateChanged: { _, _ in } - ) - - await monitor.startMonitoring() - await monitor.stopMonitoring() - - let state = await monitor.currentState - #expect(state == .healthy) - } - - @Test("resetAfterManualReconnect sets state to healthy") - func resetAfterManualReconnect() async { - let monitor = ConnectionHealthMonitor( - connectionId: UUID(), - pingHandler: { true }, - reconnectHandler: { true }, - onStateChanged: { _, _ in } - ) - - await monitor.resetAfterManualReconnect() - let state = await monitor.currentState - #expect(state == .healthy) - } - - @Test("Multiple startMonitoring calls do not create duplicate tasks") - func multipleStartMonitoringCallsAreIdempotent() async { - var pingCount = 0 - let lock = NSLock() - - let monitor = ConnectionHealthMonitor( - connectionId: UUID(), - pingHandler: { - lock.lock() - pingCount += 1 - lock.unlock() - return true - }, - reconnectHandler: { true }, - onStateChanged: { _, _ in } - ) - - await monitor.startMonitoring() - await monitor.startMonitoring() - await monitor.startMonitoring() - - // Brief pause to ensure no unexpected immediate pings - try? await Task.sleep(for: .milliseconds(100)) - - await monitor.stopMonitoring() - - // The monitoring loop sleeps 30s before its first ping, - // so no pings should have fired in 100ms - lock.lock() - let count = pingCount - lock.unlock() - #expect(count == 0) - } - - @Test("Staggered initial delay — no ping fires immediately") - func staggeredInitialDelay() async { - var pingCount = 0 - let lock = NSLock() - - let monitor = ConnectionHealthMonitor( - connectionId: UUID(), - pingHandler: { - lock.lock() - pingCount += 1 - lock.unlock() - return true - }, - reconnectHandler: { true }, - onStateChanged: { _, _ in } - ) - - await monitor.startMonitoring() - - // Wait briefly — with stagger (0-10s) + ping interval (30s), - // no ping should fire in 200ms - try? await Task.sleep(for: .milliseconds(200)) - - await monitor.stopMonitoring() - - lock.lock() - let count = pingCount - lock.unlock() - #expect(count == 0, "No ping should fire immediately due to staggered initial delay") - } -} diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift index af2e0c08f..e8069494a 100644 --- a/TableProTests/Core/Database/ExecuteUserQueryTests.swift +++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift @@ -106,12 +106,12 @@ struct ExecuteUserQueryTests { private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private(set) var lastExecutedQuery: String? - private(set) var lastParameters: [String?]? - private let rowsToReturn: [[String?]] + private(set) var lastParameters: [PluginCellValue]? + private let rowsToReturn: [[PluginCellValue]] private let statusMessage: String? init(rows: [[String?]], statusMessage: String? = nil) { - self.rowsToReturn = rows + self.rowsToReturn = rows.map { row in row.map(PluginCellValue.fromOptional) } self.statusMessage = statusMessage } @@ -130,7 +130,7 @@ private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable ) } - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { lastExecutedQuery = query lastParameters = parameters return PluginQueryResult( diff --git a/TableProTests/Core/Services/BlobFormattingServiceTests.swift b/TableProTests/Core/Services/BlobFormattingServiceTests.swift new file mode 100644 index 000000000..c9c8a80c4 --- /dev/null +++ b/TableProTests/Core/Services/BlobFormattingServiceTests.swift @@ -0,0 +1,181 @@ +// +// BlobFormattingServiceTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("BlobFormattingService - compact hex (grid context)") +@MainActor +struct BlobFormattingServiceCompactHexTests { + @Test("Issue #1188 exact value renders as 0xD38CE566...534F") + func issue1188CompactHex() { + // Bridge encodes the 48 raw bytes as isoLatin1 String (one char per byte) + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let value = String(data: bytes, encoding: .isoLatin1) ?? "" + + let result = value.formattedAsCompactHex() + + let expected = "0xD38CE566B967520CAF461747ABC77D275F084F601697D1EA135B0361CABB534F702202B952E00447B675687AF8F5D43B" + #expect(result == expected) + } + + @Test("Empty string returns nil") + func emptyString() { + #expect("".formattedAsCompactHex() == nil) + } + + @Test("Single 0x00 byte renders as 0x00") + func singleZeroByte() { + let value = String(data: Data([0x00]), encoding: .isoLatin1) ?? "" + #expect(value.formattedAsCompactHex() == "0x00") + } + + @Test("Embedded NUL byte preserved in hex output") + func embeddedNulByte() { + let value = String(data: Data([0x48, 0x00, 0x69]), encoding: .isoLatin1) ?? "" + #expect(value.formattedAsCompactHex() == "0x480069") + } + + @Test("Truncates with ellipsis when over maxBytes") + func truncates() { + let bytes = Data(repeating: 0xAB, count: 100) + let value = String(data: bytes, encoding: .isoLatin1) ?? "" + let result = value.formattedAsCompactHex(maxBytes: 64) + #expect(result?.hasSuffix("…") == true) + // 0x + 64 bytes * 2 hex chars + ellipsis = 131 chars + #expect((result as NSString?)?.length == 131) + } +} + +@Suite("BlobFormattingService - byte count") +@MainActor +struct BlobFormattingServiceByteCountTests { + @Test("Issue #1188 exact value reports 48 bytes (not 98)") + func issue1188ByteCount() { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let value = String(data: bytes, encoding: .isoLatin1) ?? "" + + // The HexEditorContentView byteCount math: value.data(using: .isoLatin1)?.count + let count = value.data(using: .isoLatin1)?.count + + #expect(count == 48) + // Pre-fix bug: would have been 98 (length of "\\xd38ce566..." escape string) + #expect(count != 98) + } +} + +@Suite("BlobFormattingService - hex dump (detail context)") +@MainActor +struct BlobFormattingServiceHexDumpTests { + @Test("Issue #1188 first 16 bytes match expected hex dump line") + func issue1188FirstLine() { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let value = String(data: bytes, encoding: .isoLatin1) ?? "" + + guard let dump = value.formattedAsHexDump() else { + Issue.record("Hex dump returned nil") + return + } + + // Format per spec: 8-char offset, 16 hex bytes split into two groups of 8, ASCII column + let firstLine = dump.split(separator: "\n").first.map(String.init) + #expect(firstLine?.hasPrefix("00000000 D3 8C E5 66 B9 67 52 0C AF 46 17 47 AB C7 7D 27") == true) + } + + @Test("Empty input returns nil") + func emptyInput() { + #expect("".formattedAsHexDump() == nil) + } +} + +@Suite("BlobFormattingService - editable hex (edit context)") +@MainActor +struct BlobFormattingServiceEditableHexTests { + @Test("Issue #1188 produces space-separated hex bytes") + func issue1188Editable() { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let value = String(data: bytes, encoding: .isoLatin1) ?? "" + + guard let editable = value.formattedAsEditableHex() else { + Issue.record("Editable hex returned nil") + return + } + + let pairs = editable.split(separator: " ").map(String.init) + #expect(pairs.count == 48) + #expect(pairs.first == "D3") + #expect(pairs.last == "3B") + } +} + +@Suite("BlobFormattingService - parseHex round-trip") +@MainActor +struct BlobFormattingServiceParseHexTests { + @Test("parseHex round-trips issue #1188 bytes via isoLatin1") + func issue1188RoundTrip() { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let editableHex = "D3 8C E5 66 B9 67 52 0C AF 46 17 47 AB C7 7D 27 5F 08 4F 60 16 97 D1 EA 13 5B 03 61 CA BB 53 4F 70 22 02 B9 52 E0 04 47 B6 75 68 7A F8 F5 D4 3B" + + guard let parsed = BlobFormattingService.shared.parseHex(editableHex) else { + Issue.record("parseHex returned nil") + return + } + + // Round-trip through isoLatin1 String back to Data must match exactly + #expect(parsed.data(using: .isoLatin1) == bytes) + } + + @Test("parseHex accepts 0x prefix") + func acceptsPrefix() { + let result = BlobFormattingService.shared.parseHex("0xDEADBEEF") + #expect(result?.data(using: .isoLatin1) == Data([0xDE, 0xAD, 0xBE, 0xEF])) + } + + @Test("parseHex rejects odd-length input") + func rejectsOddLength() { + #expect(BlobFormattingService.shared.parseHex("ABC") == nil) + } + + @Test("parseHex rejects non-hex characters") + func rejectsNonHex() { + #expect(BlobFormattingService.shared.parseHex("XYZW") == nil) + } +} diff --git a/TableProTests/Core/Storage/AppSettingsStorageTests.swift b/TableProTests/Core/Storage/AppSettingsStorageTests.swift deleted file mode 100644 index 7dca862e6..000000000 --- a/TableProTests/Core/Storage/AppSettingsStorageTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// AppSettingsStorageTests.swift -// TableProTests -// -// Tests for AppSettingsStorage multi-connection session restoration. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("AppSettingsStorage - Last Open Connection IDs") -struct AppSettingsStorageLastOpenConnectionTests { - private let storage: AppSettingsStorage - private let defaults: UserDefaults - - init() { - let suiteName = "com.TablePro.tests.AppSettingsStorage.\(UUID().uuidString)" - self.defaults = UserDefaults(suiteName: suiteName)! - self.storage = AppSettingsStorage(userDefaults: defaults) - } - - @Test("saveLastOpenConnectionIds + loadLastOpenConnectionIds round-trip") - func roundTrip() { - let ids = [UUID(), UUID(), UUID()] - - storage.saveLastOpenConnectionIds(ids) - let loaded = storage.loadLastOpenConnectionIds() - - #expect(loaded == ids) - } - - @Test("loadLastOpenConnectionIds returns empty when nothing saved") - func returnsEmptyWhenNothingSaved() { - let loaded = storage.loadLastOpenConnectionIds() - #expect(loaded.isEmpty) - } - - @Test("saveLastOpenConnectionIds with empty array clears state") - func emptyArrayClearsState() { - let ids = [UUID()] - storage.saveLastOpenConnectionIds(ids) - storage.saveLastOpenConnectionIds([]) - - let loaded = storage.loadLastOpenConnectionIds() - #expect(loaded.isEmpty) - } - - @Test("saveLastOpenConnectionIds overwrites previous state") - func overwritesPreviousState() { - let first = [UUID(), UUID()] - let second = [UUID()] - - storage.saveLastOpenConnectionIds(first) - storage.saveLastOpenConnectionIds(second) - - let loaded = storage.loadLastOpenConnectionIds() - #expect(loaded == second) - } - - @Test("loadLastOpenConnectionIds ignores malformed UUID strings") - func ignoresMalformedUUIDs() { - let validId = UUID() - storage.saveLastOpenConnectionIds([validId]) - - defaults.set( - [validId.uuidString, "not-a-uuid", "also-bad"], - forKey: "com.TablePro.settings.lastOpenConnectionIds" - ) - - let loaded = storage.loadLastOpenConnectionIds() - #expect(loaded == [validId]) - } - - @Test("Preserves order of connection IDs") - func preservesOrder() { - let ids = (0..<5).map { _ in UUID() } - - storage.saveLastOpenConnectionIds(ids) - let loaded = storage.loadLastOpenConnectionIds() - - #expect(loaded == ids) - } -} diff --git a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift b/TableProTests/Core/Storage/QueryHistoryManagerTests.swift deleted file mode 100644 index 5806dc492..000000000 --- a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// QueryHistoryManagerTests.swift -// TableProTests -// -// Tests for QueryHistoryManager wrapping storage with notifications. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("QueryHistoryManager") -struct QueryHistoryManagerTests { - private let manager: QueryHistoryManager - private let storage: QueryHistoryStorage - - init() { - self.storage = Self.makeIsolatedStorage() - self.manager = QueryHistoryManager(storage: self.storage) - } - - static func makeIsolatedStorage() -> QueryHistoryStorage { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("tablepro-tests") - .appendingPathComponent("query_history_\(UUID().uuidString).db") - return QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) - } - - private func makeAndInsertEntry( - query: String = "SELECT 1", - connectionId: UUID = UUID() - ) async -> QueryHistoryEntry { - let entry = QueryHistoryEntry( - query: query, - connectionId: connectionId, - databaseName: "testdb", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await storage.addHistory(entry) - return entry - } - - @Test("fetchHistory returns entries from storage") - func fetchHistoryReturnsEntries() async { - let connId = UUID() - _ = await makeAndInsertEntry(query: "SELECT mgr_fetch", connectionId: connId) - let entries = await manager.fetchHistory(connectionId: connId) - #expect(entries.contains { $0.query == "SELECT mgr_fetch" }) - } - - @Test("searchQueries with empty text returns all entries") - func searchQueriesEmptyTextReturnsAll() async { - let marker = UUID().uuidString - _ = await makeAndInsertEntry(query: "SELECT search_\(marker)") - let entries = await manager.searchQueries("") - #expect(entries.contains { $0.query.contains(marker) }) - } - - @Test("searchQueries with text uses FTS5") - func searchQueriesWithTextUsesFTS5() async { - let marker = UUID().uuidString - let connId = UUID() - _ = await makeAndInsertEntry(query: "SELECT \(marker) FROM mgr_products", connectionId: connId) - _ = await makeAndInsertEntry(query: "INSERT INTO mgr_orders VALUES (\(marker))", connectionId: connId) - let entries = await manager.searchQueries("mgr_products") - #expect(entries.count >= 1) - #expect(entries.allSatisfy { $0.query.contains("mgr_products") }) - } - - @Test("deleteHistory removes entry and returns true") - func deleteHistoryRemovesEntry() async { - let connId = UUID() - let entry = await makeAndInsertEntry(query: "SELECT mgr_delete", connectionId: connId) - let result = await manager.deleteHistory(id: entry.id) - #expect(result == true) - let remaining = await manager.fetchHistory(connectionId: connId) - #expect(remaining.isEmpty) - } - - @Test("getHistoryCount delegates to storage") - func getHistoryCountDelegatesToStorage() async { - let connId = UUID() - _ = await makeAndInsertEntry(connectionId: connId) - _ = await makeAndInsertEntry(connectionId: connId) - let entries = await manager.fetchHistory(connectionId: connId) - #expect(entries.count == 2) - } - - @Test("clearAllHistory clears and returns true") - func clearAllHistoryReturnsTrue() async { - let isolatedStorage = QueryHistoryManagerTests.makeIsolatedStorage() - let isolatedManager = QueryHistoryManager(storage: isolatedStorage) - _ = await isolatedStorage.addHistory(QueryHistoryEntry( - query: "SELECT clear_test", - connectionId: UUID(), - databaseName: "testdb", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - )) - let result = await isolatedManager.clearAllHistory() - #expect(result == true) - } - - @Test("deleteHistory posts queryHistoryDidUpdate notification") - func deleteHistoryPostsNotification() async { - let entry = await makeAndInsertEntry() - - await confirmation("notification posted") { confirm in - let observer = NotificationCenter.default.addObserver( - forName: .queryHistoryDidUpdate, - object: nil, - queue: .main - ) { _ in - confirm() - } - - _ = await manager.deleteHistory(id: entry.id) - try? await Task.sleep(for: .milliseconds(100)) - - NotificationCenter.default.removeObserver(observer) - } - } - - @Test("clearAllHistory posts queryHistoryDidUpdate notification") - func clearAllHistoryPostsNotification() async { - let isolatedStorage = QueryHistoryManagerTests.makeIsolatedStorage() - let isolatedManager = QueryHistoryManager(storage: isolatedStorage) - _ = await isolatedStorage.addHistory(QueryHistoryEntry( - query: "SELECT notify_test", - connectionId: UUID(), - databaseName: "testdb", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - )) - - await confirmation("notification posted") { confirm in - let observer = NotificationCenter.default.addObserver( - forName: .queryHistoryDidUpdate, - object: nil, - queue: .main - ) { _ in - confirm() - } - - _ = await isolatedManager.clearAllHistory() - try? await Task.sleep(for: .milliseconds(100)) - - NotificationCenter.default.removeObserver(observer) - } - } -} diff --git a/TableProTests/Core/Storage/TabDiskActorTests.swift b/TableProTests/Core/Storage/TabDiskActorTests.swift deleted file mode 100644 index 8310df007..000000000 --- a/TableProTests/Core/Storage/TabDiskActorTests.swift +++ /dev/null @@ -1,285 +0,0 @@ -// -// TabDiskActorTests.swift -// TableProTests -// -// Tests for TabDiskActor tab state persistence. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("TabDiskActor") -struct TabDiskActorTests { - private let actor = TabDiskActor.shared - - private func makeTab( - id: UUID = UUID(), - title: String = "Test Tab", - query: String = "SELECT 1", - tabType: TabType = .query, - tableName: String? = nil, - isView: Bool = false, - databaseName: String = "" - ) -> PersistedTab { - PersistedTab( - id: id, - title: title, - query: query, - tabType: tabType, - tableName: tableName, - isView: isView, - databaseName: databaseName - ) - } - - // MARK: - save / load round-trip - - @Test("Save then load round-trips correctly") - func saveAndLoadRoundTrip() async throws { - let connectionId = UUID() - let tabId = UUID() - let tab = makeTab(id: tabId, title: "My Tab", query: "SELECT * FROM users") - - try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) - let state = await actor.load(connectionId: connectionId) - - #expect(state != nil) - #expect(state?.tabs.count == 1) - #expect(state?.tabs.first?.id == tabId) - #expect(state?.tabs.first?.title == "My Tab") - #expect(state?.tabs.first?.query == "SELECT * FROM users") - #expect(state?.tabs.first?.tabType == .query) - #expect(state?.selectedTabId == tabId) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - load returns nil for unknown connectionId - - @Test("Load returns nil for unknown connectionId") - func loadReturnsNilForUnknown() async throws { - let result = await actor.load(connectionId: UUID()) - #expect(result == nil) - } - - // MARK: - save overwrites previous state - - @Test("Save overwrites previous state") - func saveOverwritesPreviousState() async throws { - let connectionId = UUID() - let tab1 = makeTab(title: "First") - let tab2 = makeTab(title: "Second") - - try await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id) - try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id) - - let state = await actor.load(connectionId: connectionId) - - #expect(state?.tabs.count == 1) - #expect(state?.tabs.first?.title == "Second") - #expect(state?.selectedTabId == tab2.id) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - clear removes saved state - - @Test("Clear removes saved state") - func clearRemovesSavedState() async throws { - let connectionId = UUID() - let tab = makeTab() - - try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id) - await actor.clear(connectionId: connectionId) - - let state = await actor.load(connectionId: connectionId) - #expect(state == nil) - } - - // MARK: - clear on non-existent connectionId does not crash - - @Test("Clear on non-existent connectionId does not crash") - func clearNonExistentDoesNotCrash() async throws { - await actor.clear(connectionId: UUID()) - } - - // MARK: - Multiple connections are independent - - @Test("Multiple connections are independent") - func multipleConnectionsAreIndependent() async throws { - let connA = UUID() - let connB = UUID() - let tabA = makeTab(title: "Tab A") - let tabB = makeTab(title: "Tab B") - - try await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id) - try await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id) - - let stateA = await actor.load(connectionId: connA) - let stateB = await actor.load(connectionId: connB) - - #expect(stateA?.tabs.first?.title == "Tab A") - #expect(stateB?.tabs.first?.title == "Tab B") - - await actor.clear(connectionId: connA) - let stateAAfterClear = await actor.load(connectionId: connA) - let stateBAfterClear = await actor.load(connectionId: connB) - - #expect(stateAAfterClear == nil) - #expect(stateBAfterClear?.tabs.first?.title == "Tab B") - - await actor.clear(connectionId: connB) - } - - // MARK: - selectedTabId preservation - - @Test("selectedTabId is preserved correctly including nil") - func selectedTabIdPreserved() async throws { - let connectionId = UUID() - let tab = makeTab() - - try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil) - let stateNil = await actor.load(connectionId: connectionId) - #expect(stateNil?.selectedTabId == nil) - #expect(stateNil?.tabs.count == 1) - - let specificId = UUID() - let tab2 = makeTab(id: specificId) - try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId) - let stateWithId = await actor.load(connectionId: connectionId) - #expect(stateWithId?.selectedTabId == specificId) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - Tab with all fields round-trips - - @Test("Tab with all fields including isView and databaseName round-trips") - func tabWithAllFieldsRoundTrips() async throws { - let connectionId = UUID() - let tabId = UUID() - let tab = makeTab( - id: tabId, - title: "users_view", - query: "SELECT * FROM users_view", - tabType: .table, - tableName: "users_view", - isView: true, - databaseName: "production" - ) - - try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) - let state = await actor.load(connectionId: connectionId) - - #expect(state != nil) - let loaded = state?.tabs.first - #expect(loaded?.id == tabId) - #expect(loaded?.title == "users_view") - #expect(loaded?.query == "SELECT * FROM users_view") - #expect(loaded?.tabType == .table) - #expect(loaded?.tableName == "users_view") - #expect(loaded?.isView == true) - #expect(loaded?.databaseName == "production") - - await actor.clear(connectionId: connectionId) - } - - // MARK: - Multiple tabs in single save - - @Test("Multiple tabs in a single save round-trip correctly") - func multipleTabsRoundTrip() async throws { - let connectionId = UUID() - let tab1 = makeTab(title: "Tab 1", tabType: .query) - let tab2 = makeTab(title: "Tab 2", tabType: .table, tableName: "orders") - let tab3 = makeTab(title: "Tab 3", tabType: .query) - - try await actor.save(connectionId: connectionId, tabs: [tab1, tab2, tab3], selectedTabId: tab2.id) - let state = await actor.load(connectionId: connectionId) - - #expect(state?.tabs.count == 3) - #expect(state?.tabs[0].title == "Tab 1") - #expect(state?.tabs[1].title == "Tab 2") - #expect(state?.tabs[2].title == "Tab 3") - #expect(state?.selectedTabId == tab2.id) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - saveSync writes data readable by load - - @Test("saveSync writes data that load can read back") - func saveSyncWritesReadableData() async throws { - let connectionId = UUID() - let tabId = UUID() - let tab = makeTab(id: tabId, title: "Sync Tab", query: "SELECT 42", tabType: .table, tableName: "orders") - - TabDiskActor.saveSync(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) - - let state = await actor.load(connectionId: connectionId) - - #expect(state != nil) - #expect(state?.tabs.count == 1) - #expect(state?.tabs.first?.id == tabId) - #expect(state?.tabs.first?.title == "Sync Tab") - #expect(state?.tabs.first?.query == "SELECT 42") - #expect(state?.tabs.first?.tableName == "orders") - #expect(state?.selectedTabId == tabId) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - Empty tabs array - - @Test("Saving empty tabs array round-trips") - func emptyTabsArrayRoundTrips() async throws { - let connectionId = UUID() - - try await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil) - let state = await actor.load(connectionId: connectionId) - - #expect(state != nil) - #expect(state?.tabs.isEmpty == true) - #expect(state?.selectedTabId == nil) - - await actor.clear(connectionId: connectionId) - } - - // MARK: - connectionIdsWithSavedState - - @Test("connectionIdsWithSavedState returns correct IDs after saving multiple connections") - func connectionIdsWithSavedStateReturnsCorrectIds() async throws { - let connA = UUID() - let connB = UUID() - let tab = makeTab() - - try await actor.save(connectionId: connA, tabs: [tab], selectedTabId: tab.id) - try await actor.save(connectionId: connB, tabs: [tab], selectedTabId: tab.id) - - let ids = await actor.connectionIdsWithSavedState() - - #expect(ids.contains(connA)) - #expect(ids.contains(connB)) - - await actor.clear(connectionId: connA) - await actor.clear(connectionId: connB) - } - - @Test("connectionIdsWithSavedState excludes cleared connections") - func connectionIdsWithSavedStateExcludesCleared() async throws { - let connA = UUID() - let connB = UUID() - let tab = makeTab() - - try await actor.save(connectionId: connA, tabs: [tab], selectedTabId: tab.id) - try await actor.save(connectionId: connB, tabs: [tab], selectedTabId: tab.id) - await actor.clear(connectionId: connA) - - let ids = await actor.connectionIdsWithSavedState() - - #expect(!ids.contains(connA)) - #expect(ids.contains(connB)) - - await actor.clear(connectionId: connB) - } -} diff --git a/TableProTests/PluginTestSources/LibPQByteaDecoder.swift b/TableProTests/PluginTestSources/LibPQByteaDecoder.swift new file mode 120000 index 000000000..db3a1f8f0 --- /dev/null +++ b/TableProTests/PluginTestSources/LibPQByteaDecoder.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift \ No newline at end of file diff --git a/TableProTests/Plugins/BigQueryTypeMapperTests.swift b/TableProTests/Plugins/BigQueryTypeMapperTests.swift index 87de5b235..3d3e1f73a 100644 --- a/TableProTests/Plugins/BigQueryTypeMapperTests.swift +++ b/TableProTests/Plugins/BigQueryTypeMapperTests.swift @@ -112,8 +112,8 @@ struct BigQueryTypeMapperRowTests { ], totalRows: "1") let rows = BigQueryTypeMapper.flattenRows(from: resp, schema: schema) let value = rows[0][0] - #expect(value != nil) - #expect(value!.contains("2021-03-31") || value!.contains("2021-04-01")) + #expect(value != .null) + #expect(value.asText?.contains("2021-03-31") == true || value.asText?.contains("2021-04-01") == true) } @Test("Boolean values normalize to lowercase") @@ -144,9 +144,9 @@ struct BigQueryTypeMapperRowTests { ]) ], totalRows: "1") let value = BigQueryTypeMapper.flattenRows(from: resp, schema: schema)[0][0] - #expect(value != nil) - #expect(value!.contains("\"city\"")) - #expect(value!.contains("\"NYC\"")) + #expect(value != .null) + #expect(value.asText?.contains("\"city\"") == true) + #expect(value.asText?.contains("\"NYC\"") == true) } @Test("REPEATED array flattens to JSON array string") @@ -158,9 +158,9 @@ struct BigQueryTypeMapperRowTests { ]) ], totalRows: "1") let value = BigQueryTypeMapper.flattenRows(from: resp, schema: schema)[0][0] - #expect(value != nil) - #expect(value!.contains("red")) - #expect(value!.contains("blue")) + #expect(value != .null) + #expect(value.asText?.contains("red") == true) + #expect(value.asText?.contains("blue") == true) } @Test("Empty response returns empty rows") diff --git a/TableProTests/Plugins/EtcdStatementGeneratorTests.swift b/TableProTests/Plugins/EtcdStatementGeneratorTests.swift index bfd8b54c4..f5783eeb2 100644 --- a/TableProTests/Plugins/EtcdStatementGeneratorTests.swift +++ b/TableProTests/Plugins/EtcdStatementGeneratorTests.swift @@ -27,7 +27,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", "myvalue", nil, nil, nil, nil] ] @@ -56,7 +56,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", "myvalue", nil, nil, nil, "12345"] ] @@ -85,7 +85,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["setting1", "value1", nil, nil, nil, nil] ] @@ -115,7 +115,7 @@ struct EtcdStatementGeneratorInsertTests { ) // Key starts with "/" so it's treated as absolute - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["/app/mykey", "value", nil, nil, nil, nil] ] @@ -144,7 +144,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["/absolute/key", "value", nil, nil, nil, nil] ] @@ -173,7 +173,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["", "value", nil, nil, nil, nil] ] @@ -201,7 +201,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "value", nil, nil, nil, nil] ] @@ -229,7 +229,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", nil, nil, nil, nil, nil] ] @@ -258,7 +258,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", "value", nil, nil, nil, "0"] ] @@ -315,7 +315,7 @@ struct EtcdStatementGeneratorInsertTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", "hello world", nil, nil, nil, nil] ] @@ -665,7 +665,7 @@ struct EtcdStatementGeneratorBatchTests { originalRow: ["delkey", "val", "1", "1", "1", "0"] ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["newkey", "newval", nil, nil, nil, nil] ] @@ -696,7 +696,7 @@ struct EtcdStatementGeneratorBatchTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 5: ["key", "val", nil, nil, nil, nil] ] diff --git a/TableProTests/Plugins/LibPQByteaDecoderTests.swift b/TableProTests/Plugins/LibPQByteaDecoderTests.swift new file mode 100644 index 000000000..5be4a7a97 --- /dev/null +++ b/TableProTests/Plugins/LibPQByteaDecoderTests.swift @@ -0,0 +1,181 @@ +// +// LibPQByteaDecoderTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("LibPQByteaDecoder - hex format") +struct LibPQByteaDecoderHexTests { + @Test("Empty input returns empty Data") + func emptyInput() { + #expect(LibPQByteaDecoder.decode("") == Data()) + } + + @Test("Empty hex (\\x with no digits) returns empty Data") + func emptyHexPrefix() { + #expect(LibPQByteaDecoder.decode("\\x") == Data()) + } + + @Test("Single byte 0x00 round-trips") + func singleByteZero() { + #expect(LibPQByteaDecoder.decode("\\x00") == Data([0x00])) + } + + @Test("Single byte 0xFF round-trips") + func singleByteHigh() { + #expect(LibPQByteaDecoder.decode("\\xff") == Data([0xFF])) + } + + @Test("Lowercase hex parses correctly") + func lowercaseHex() { + #expect(LibPQByteaDecoder.decode("\\xdeadbeef") == Data([0xDE, 0xAD, 0xBE, 0xEF])) + } + + @Test("Uppercase hex parses correctly") + func uppercaseHex() { + #expect(LibPQByteaDecoder.decode("\\xDEADBEEF") == Data([0xDE, 0xAD, 0xBE, 0xEF])) + } + + @Test("Mixed-case hex parses correctly") + func mixedCaseHex() { + #expect(LibPQByteaDecoder.decode("\\xDeAdBeEf") == Data([0xDE, 0xAD, 0xBE, 0xEF])) + } + + @Test("Uppercase \\X prefix accepted") + func uppercaseXPrefix() { + #expect(LibPQByteaDecoder.decode("\\X48656c6c6f") == Data([0x48, 0x65, 0x6C, 0x6C, 0x6F])) + } + + @Test("Odd hex length returns nil") + func oddHexLength() { + #expect(LibPQByteaDecoder.decode("\\xabc") == nil) + } + + @Test("Non-hex character returns nil") + func nonHexCharacter() { + #expect(LibPQByteaDecoder.decode("\\xdeadXX") == nil) + } + + @Test("Embedded NUL byte 0x00 mid-stream round-trips") + func embeddedNullByte() { + // "Hello\0World" → 11 bytes including the embedded NUL + let expected = Data([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F, 0x72, 0x6C, 0x64]) + #expect(LibPQByteaDecoder.decode("\\x48656c6c6f00576f726c64") == expected) + } + + @Test("All 256 byte values 0x00-0xFF decode losslessly") + func allByteValues() { + let hex = (0..<256).map { String(format: "%02x", $0) }.joined() + let result = LibPQByteaDecoder.decode("\\x" + hex) + #expect(result?.count == 256) + #expect(result == Data((0..<256).map { UInt8($0) })) + } +} + +@Suite("LibPQByteaDecoder - escape format") +struct LibPQByteaDecoderEscapeTests { + @Test("Plain ASCII bytes pass through") + func plainAscii() { + #expect(LibPQByteaDecoder.decode("Hello, World!") == Data("Hello, World!".utf8)) + } + + @Test("Escaped backslash decodes to 0x5C") + func escapedBackslash() { + #expect(LibPQByteaDecoder.decode("\\\\") == Data([0x5C])) + } + + @Test("Escaped backslash mixed with ASCII") + func escapedBackslashMixed() { + #expect(LibPQByteaDecoder.decode("a\\\\b") == Data([0x61, 0x5C, 0x62])) + } + + @Test("Octal escape \\012 decodes to 0x0A (newline)") + func octalEscapeNewline() { + #expect(LibPQByteaDecoder.decode("\\012") == Data([0x0A])) + } + + @Test("Octal escape \\377 decodes to 0xFF (max valid)") + func octalEscapeMax() { + #expect(LibPQByteaDecoder.decode("\\377") == Data([0xFF])) + } + + @Test("Octal escape \\000 decodes to 0x00") + func octalEscapeZero() { + #expect(LibPQByteaDecoder.decode("\\000") == Data([0x00])) + } + + @Test("Bare backslash with insufficient digits returns nil") + func badEscapeShort() { + #expect(LibPQByteaDecoder.decode("\\1") == nil) + } + + @Test("Backslash followed by non-octal returns nil") + func badEscapeNonOctal() { + #expect(LibPQByteaDecoder.decode("\\xyz") == nil) + } + + @Test("Bare trailing backslash returns nil") + func trailingBackslash() { + #expect(LibPQByteaDecoder.decode("abc\\") == nil) + } +} + +@Suite("LibPQByteaDecoder - issue #1188 regression") +struct LibPQByteaDecoderIssue1188Tests { + @Test("Issue #1188 exact value decodes to 48 bytes") + func issue1188ExactValue() { + let input = "\\xd38ce566b967520caf461747abc77d275f084f601697d1ea135b0361cabb534f702202b952e00447b675687af8f5d43b" + let expected = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let result = LibPQByteaDecoder.decode(input) + #expect(result?.count == 48) + #expect(result == expected) + } + + @Test("Issue #1188 first byte is 0xD3 (not ASCII '\\\\')") + func issue1188FirstByteIsBinary() { + let input = "\\xd38ce566" + guard let result = LibPQByteaDecoder.decode(input) else { + Issue.record("Decoder returned nil for valid input") + return + } + #expect(result.first == 0xD3) + #expect(result.first != 0x5C) + } +} + +@Suite("LibPQByteaDecoder - hex round-trip") +struct LibPQByteaDecoderEncodeTests { + @Test("encodeHexText produces canonical \\xHH format") + func canonicalHexEncoding() { + let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) + #expect(LibPQByteaDecoder.encodeHexText(data) == "\\xdeadbeef") + } + + @Test("Empty Data encodes to bare \\x") + func emptyDataEncoding() { + #expect(LibPQByteaDecoder.encodeHexText(Data()) == "\\x") + } + + @Test("Round-trip preserves bytes exactly") + func roundTrip() { + let original = Data((0..<64).map { UInt8(truncatingIfNeeded: $0 &* 7 &+ 13) }) + let encoded = LibPQByteaDecoder.encodeHexText(original) + #expect(LibPQByteaDecoder.decode(encoded) == original) + } + + @Test("Round-trip across all 256 byte values") + func roundTripAllBytes() { + let original = Data((0..<256).map { UInt8($0) }) + let encoded = LibPQByteaDecoder.encodeHexText(original) + #expect(LibPQByteaDecoder.decode(encoded) == original) + } +} diff --git a/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift b/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift deleted file mode 100644 index 2161109e5..000000000 --- a/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// MSSQLDatetimeFormatterTests.swift -// TableProTests -// -// Pins the FreeTDS msdblib → ISO 8601 datetime conversion. -// Covers all formats observed from FreeTDS dbconvert(... SYBCHAR) output: -// legacy DATETIME (3-digit fractional), DATETIME2 (7-digit fractional), -// SMALLDATETIME (no seconds), already-ISO passthrough, and AM/PM boundary cases. -// - -import Foundation -@testable import MSSQLDriver -import Testing - -@Suite("MSSQLDatetimeFormatter") -struct MSSQLDatetimeFormatterTests { - // MARK: - Legacy AM/PM format from FreeTDS msdblib - - @Test("DATETIME2 with 7-digit fractional reformats to ISO with all digits preserved") - func datetime2SevenDigitFractional() { - let result = MSSQLDatetimeFormatter.parse("May 10 2026 7:58:53:2960999AM") - #expect(result == "2026-05-10 07:58:53.2960999") - } - - @Test("DATETIME with 3-digit fractional reformats to ISO with milliseconds preserved") - func datetimeThreeDigitFractional() { - let result = MSSQLDatetimeFormatter.parse("Jan 5 2024 11:30:00:123PM") - #expect(result == "2024-01-05 23:30:00.123") - } - - @Test("SMALLDATETIME without seconds defaults seconds to 00") - func smallDatetimeNoSeconds() { - let result = MSSQLDatetimeFormatter.parse("Mar 15 2025 3:45PM") - #expect(result == "2025-03-15 15:45:00") - } - - @Test("DATETIME without fractional yields ISO without fractional suffix") - func datetimeNoFractional() { - let result = MSSQLDatetimeFormatter.parse("Dec 1 2023 9:00:00AM") - #expect(result == "2023-12-01 09:00:00") - } - - // MARK: - AM/PM boundary cases - - @Test("12 AM converts to 00 (midnight)") - func twelveAMisMidnight() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 12:30:00AM") - #expect(result == "2025-06-01 00:30:00") - } - - @Test("12 PM stays at 12 (noon)") - func twelvePMisNoon() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 12:30:00PM") - #expect(result == "2025-06-01 12:30:00") - } - - @Test("1 AM stays at 01") - func oneAMisOne() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 1:00:00AM") - #expect(result == "2025-06-01 01:00:00") - } - - @Test("11 PM converts to 23") - func elevenPMisTwentyThree() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 11:00:00PM") - #expect(result == "2025-06-01 23:00:00") - } - - // MARK: - Validation rejects malformed input - - @Test("Hour 13 with AM marker is rejected (12-hour values must be 1...12)") - func thirteenAMrejected() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 13:00:00AM") - #expect(result == nil) - } - - @Test("Hour 0 with AM marker is rejected (12-hour values must be 1...12)") - func zeroAMrejected() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 0:00:00AM") - #expect(result == nil) - } - - @Test("Unknown month abbreviation is rejected") - func unknownMonthRejected() { - let result = MSSQLDatetimeFormatter.parse("Foo 1 2025 12:00:00PM") - #expect(result == nil) - } - - @Test("Day 32 is rejected") - func dayOutOfRangeRejected() { - let result = MSSQLDatetimeFormatter.parse("Jan 32 2025 12:00:00PM") - #expect(result == nil) - } - - @Test("Year 0 is rejected") - func yearZeroRejected() { - let result = MSSQLDatetimeFormatter.parse("Jan 1 0 12:00:00PM") - #expect(result == nil) - } - - @Test("Year 10000 is rejected (out of ISO 8601 range)") - func yearTooLargeRejected() { - let result = MSSQLDatetimeFormatter.parse("Jan 1 10000 12:00:00PM") - #expect(result == nil) - } - - @Test("Minute 60 is rejected") - func minuteOutOfRangeRejected() { - let result = MSSQLDatetimeFormatter.parse("Jan 1 2025 12:60:00PM") - #expect(result == nil) - } - - @Test("Empty string returns nil") - func emptyStringRejected() { - let result = MSSQLDatetimeFormatter.parse("") - #expect(result == nil) - } - - @Test("Whitespace-only string returns nil") - func whitespaceRejected() { - let result = MSSQLDatetimeFormatter.parse(" ") - #expect(result == nil) - } - - // MARK: - ISO passthrough - - @Test("Already-ISO date passes through unchanged") - func isoDatePassthrough() { - let result = MSSQLDatetimeFormatter.parse("2026-05-10") - #expect(result == "2026-05-10") - } - - @Test("Already-ISO datetime passes through unchanged") - func isoDatetimePassthrough() { - let result = MSSQLDatetimeFormatter.parse("2026-05-10 14:30:00") - #expect(result == "2026-05-10 14:30:00") - } - - @Test("ISO datetime with fractional passes through unchanged") - func isoDatetimeWithFractionalPassthrough() { - let result = MSSQLDatetimeFormatter.parse("2026-05-10 14:30:00.1234567") - #expect(result == "2026-05-10 14:30:00.1234567") - } - - // MARK: - 24-hour input without AM/PM marker - - @Test("24-hour input without AM/PM accepts hour 23") - func twentyFourHourAccepted() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 23:30:00") - #expect(result == "2025-06-01 23:30:00") - } - - @Test("24-hour input rejects hour 24") - func twentyFourHourRejectsTwentyFour() { - let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 24:00:00") - #expect(result == nil) - } - - // MARK: - reformat() type dispatch - - @Test("reformat returns nil for non-datetime types") - func reformatRejectsNonDatetimeTypes() { - // SYBINT4 = 56 - #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 56) == nil) - } - - @Test("reformat returns ISO for legacy DATETIME type (SYBDATETIME=61)") - func reformatDatetimeType() { - #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 61) == "2025-01-01 12:00:00") - } - - @Test("reformat returns ISO for SMALLDATETIME type (SYBDATETIME4=58)") - func reformatSmallDatetimeType() { - #expect(MSSQLDatetimeFormatter.reformat("Mar 15 2025 3:45PM", srcType: 58) == "2025-03-15 15:45:00") - } - - @Test("reformat returns ISO for nullable DATETIME (SYBDATETIMN=111)") - func reformatNullableDatetimeType() { - #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 111) == "2025-01-01 12:00:00") - } - - @Test("reformat returns ISO for SYBMSDATETIME2 (raw constant 42)") - func reformatMSDatetime2Type() { - let result = MSSQLDatetimeFormatter.reformat("May 10 2026 7:58:53:2960999AM", srcType: 42) - #expect(result == "2026-05-10 07:58:53.2960999") - } - - @Test("reformat returns nil for unverified DATETIMEOFFSET (raw constant 43)") - func reformatDatetimeOffsetExcluded() { - // SYBMSDATETIMEOFFSET (43) is intentionally not handled until the offset - // suffix format is verified end-to-end. - let result = MSSQLDatetimeFormatter.reformat("May 10 2026 7:58:53:2960999 +05:30AM", srcType: 43) - #expect(result == nil) - } -} diff --git a/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift b/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift deleted file mode 100644 index cb6fbe72b..000000000 --- a/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift +++ /dev/null @@ -1,325 +0,0 @@ -// -// MSSQLPluginDriverDMLTests.swift -// TableProTests -// -// Pins the MSSQL plugin's UPDATE/DELETE statement generation to the PK-aware -// contract introduced with PluginKit ABI 11. When the framework passes a -// non-empty `primaryKeyColumns`, WHERE filters by PK only (and `TOP (1)` is -// omitted because the PK uniquely identifies one row). When the framework -// passes an empty array, WHERE falls back to all columns plus `TOP (1)`. -// - -import Foundation -@testable import MSSQLDriver -import TableProPluginKit -import Testing - -@Suite("MSSQLPluginDriver DML") -struct MSSQLPluginDriverDMLTests { - private func makeDriver() -> MSSQLPluginDriver { - MSSQLPluginDriver(config: DriverConnectionConfig( - host: "localhost", - port: 1433, - username: "SA", - password: "irrelevant", - database: "Sales" - )) - } - - private func makeUpdateChange( - oldCustomerId: String = "2", - newCustomerId: String = "3", - originalRow: [String?] = ["2", "2", "19.99", "May 10 2026 7:58:53:2960999AM"] - ) -> PluginRowChange { - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "CustomerId", oldCustomerId, newCustomerId)], - originalRow: originalRow - ) - } - - // MARK: - UPDATE with PK - - @Test("UPDATE with primary key uses PK-only WHERE and drops TOP (1)") - func updateWithPrimaryKeyUsesPKOnly() { - let driver = makeDriver() - let columns = ["Id", "CustomerId", "Total", "PlacedAt"] - let change = makeUpdateChange() - - let result = driver.generateStatements( - table: "Orders", - columns: columns, - primaryKeyColumns: ["Id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(result?.count == 1) - let sql = result?.first?.statement ?? "" - #expect(sql == "UPDATE [Orders] SET [CustomerId] = ? WHERE [Id] = ?") - // Two parameters: new value plus PK lookup. - #expect(result?.first?.parameters == ["3", "2"]) - } - - @Test("UPDATE without primary key falls back to all-columns WHERE plus TOP (1)") - func updateWithoutPrimaryKeyUsesAllColumns() { - let driver = makeDriver() - let columns = ["Number", "Amount"] - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "Amount", "100.00", "150.00")], - originalRow: ["INV-0001", "100.00"] - ) - - let result = driver.generateStatements( - table: "Invoices", - columns: columns, - primaryKeyColumns: [], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "UPDATE TOP (1) [Invoices] SET [Amount] = ? WHERE [Number] = ? AND [Amount] = ?") - #expect(result?.first?.parameters == ["150.00", "INV-0001", "100.00"]) - } - - @Test("UPDATE with composite primary key emits both PK columns in WHERE") - func updateWithCompositePrimaryKey() { - let driver = makeDriver() - let columns = ["TenantId", "OrderId", "Status"] - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(2, "Status", "PENDING", "SHIPPED")], - originalRow: ["T-1", "O-100", "PENDING"] - ) - - let result = driver.generateStatements( - table: "Orders", - columns: columns, - primaryKeyColumns: ["TenantId", "OrderId"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "UPDATE [Orders] SET [Status] = ? WHERE [TenantId] = ? AND [OrderId] = ?") - #expect(result?.first?.parameters == ["SHIPPED", "T-1", "O-100"]) - } - - @Test("UPDATE with NULL original-row PK column emits IS NULL") - func updateWithNullPKValueUsesIsNull() { - let driver = makeDriver() - let columns = ["Code", "Label"] - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "Label", "old", "new")], - originalRow: [nil, "old"] - ) - - let result = driver.generateStatements( - table: "Tags", - columns: columns, - primaryKeyColumns: ["Code"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "UPDATE [Tags] SET [Label] = ? WHERE [Code] IS NULL") - #expect(result?.first?.parameters == ["new"]) - } - - @Test("Identifier with closing bracket is escaped as ]]") - func identifierBracketEscaping() { - let driver = makeDriver() - let columns = ["weird]col", "Id"] - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(0, "weird]col", "a", "b")], - originalRow: ["a", "1"] - ) - - let result = driver.generateStatements( - table: "weird]table", - columns: columns, - primaryKeyColumns: ["Id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql.contains("[weird]]table]")) - #expect(sql.contains("[weird]]col]")) - } - - // MARK: - DELETE with PK - - @Test("DELETE with primary key uses PK-only WHERE and drops TOP (1)") - func deleteWithPrimaryKeyUsesPKOnly() { - let driver = makeDriver() - let columns = ["Id", "CustomerId"] - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["2", "2"] - ) - - let result = driver.generateStatements( - table: "Orders", - columns: columns, - primaryKeyColumns: ["Id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "DELETE FROM [Orders] WHERE [Id] = ?") - #expect(result?.first?.parameters == ["2"]) - } - - @Test("DELETE without primary key uses all-columns WHERE plus TOP (1)") - func deleteWithoutPrimaryKeyUsesAllColumns() { - let driver = makeDriver() - let columns = ["Number", "Amount"] - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["INV-0001", "100.00"] - ) - - let result = driver.generateStatements( - table: "Invoices", - columns: columns, - primaryKeyColumns: [], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "DELETE TOP (1) FROM [Invoices] WHERE [Number] = ? AND [Amount] = ?") - #expect(result?.first?.parameters == ["INV-0001", "100.00"]) - } - - // MARK: - INSERT skips IDENTITY columns - - @Test("INSERT skips IDENTITY columns when the cache has observed them") - func insertSkipsIdentityColumn() { - let driver = makeDriver() - driver.setIdentityColumnsForTesting(["Id"], table: "Customers") - - let columns = ["Id", "Name", "City", "CreatedAt"] - let insertChange = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - let insertedValues: [String?] = ["4", "Acme", "Hanoi", "2026-05-10 07:58:53.2840598"] - - let result = driver.generateStatements( - table: "Customers", - columns: columns, - primaryKeyColumns: ["Id"], - changes: [insertChange], - insertedRowData: [0: insertedValues], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "INSERT INTO [Customers] ([Name], [City], [CreatedAt]) VALUES (?, ?, ?)") - #expect(result?.first?.parameters == ["Acme", "Hanoi", "2026-05-10 07:58:53.2840598"]) - } - - @Test("INSERT includes all columns when no IDENTITY columns are cached") - func insertIncludesAllWithoutIdentityCache() { - let driver = makeDriver() - // Note: no setIdentityColumnsForTesting call; the cache is empty for this table. - let columns = ["Number", "Amount"] - let insertChange = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - let insertedValues: [String?] = ["INV-9999", "42.00"] - - let result = driver.generateStatements( - table: "Invoices", - columns: columns, - primaryKeyColumns: [], - changes: [insertChange], - insertedRowData: [0: insertedValues], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "INSERT INTO [Invoices] ([Number], [Amount]) VALUES (?, ?)") - #expect(result?.first?.parameters == ["INV-9999", "42.00"]) - } - - @Test("INSERT skips multiple IDENTITY columns and the __DEFAULT__ sentinel") - func insertSkipsIdentityAndDefaults() { - let driver = makeDriver() - driver.setIdentityColumnsForTesting(["Id", "RowVersion"], table: "Audit") - - let columns = ["Id", "RowVersion", "Action", "CreatedAt"] - let insertChange = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - let insertedValues: [String?] = ["1", "X", "DELETE", "__DEFAULT__"] - - let result = driver.generateStatements( - table: "Audit", - columns: columns, - primaryKeyColumns: ["Id"], - changes: [insertChange], - insertedRowData: [0: insertedValues], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let sql = result?.first?.statement ?? "" - #expect(sql == "INSERT INTO [Audit] ([Action]) VALUES (?)") - #expect(result?.first?.parameters == ["DELETE"]) - } - - @Test("cachedIdentityColumns returns empty set for unobserved table") - func cachedIdentityColumnsEmptyByDefault() { - let driver = makeDriver() - #expect(driver.cachedIdentityColumns(for: "NeverFetched") == []) - } - - @Test("cachedIdentityColumns returns the seeded set after seeding") - func cachedIdentityColumnsRoundTrip() { - let driver = makeDriver() - driver.setIdentityColumnsForTesting(["Id", "Version"], table: "T") - #expect(driver.cachedIdentityColumns(for: "T") == ["Id", "Version"]) - } -} diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift index fc0409a6f..7a6f6dc05 100644 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift @@ -28,7 +28,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "Alice", "alice@example.com"] ] @@ -61,7 +61,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "Bob", "__DEFAULT__"] ] @@ -93,7 +93,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "Carol", nil] ] @@ -124,7 +124,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, nil] ] @@ -179,7 +179,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "42"] ] @@ -208,7 +208,7 @@ struct MongoDBStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 5: [nil, "Eve"] ] @@ -238,7 +238,7 @@ struct MongoDBStatementGeneratorTests { cellChanges: [ (columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Alicia") ], - originalRow: [objectId, "Alice", "alice@example.com"] + originalRow: [.text(objectId), "Alice", "alice@example.com"] ) let results = gen.generateStatements( @@ -434,7 +434,7 @@ struct MongoDBStatementGeneratorTests { rowIndex: 0, type: .delete, cellChanges: [], - originalRow: [objectId, "Alice"] + originalRow: [.text(objectId), "Alice"] ) let results = gen.generateStatements( @@ -461,8 +461,8 @@ struct MongoDBStatementGeneratorTests { let id2 = "507f1f77bcf86cd799439022" let changes = [ - PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [id1, "Alice"]), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [id2, "Bob"]) + PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text(id1), "Alice"]), + PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [.text(id2), "Bob"]) ] let results = gen.generateStatements( @@ -604,7 +604,7 @@ struct MongoDBStatementGeneratorTests { cellChanges: [ (columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "Robert") ], - originalRow: [objectId, "Bob", "bob@test.com"] + originalRow: [.text(objectId), "Bob", "bob@test.com"] ), PluginRowChange( rowIndex: 2, @@ -614,7 +614,7 @@ struct MongoDBStatementGeneratorTests { ) ] - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "Alice", "alice@test.com"] ] diff --git a/TableProTests/Plugins/RedisStatementGeneratorTests.swift b/TableProTests/Plugins/RedisStatementGeneratorTests.swift index d86b4f8bd..41e72acb5 100644 --- a/TableProTests/Plugins/RedisStatementGeneratorTests.swift +++ b/TableProTests/Plugins/RedisStatementGeneratorTests.swift @@ -28,7 +28,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["cache:mykey", "hello", nil] ] @@ -57,7 +57,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["session:abc", "data", "3600"] ] @@ -87,7 +87,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", "value", "0"] ] @@ -116,7 +116,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: [nil, "value", nil] ] @@ -144,7 +144,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["", "value", nil] ] @@ -172,7 +172,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["mykey", nil, nil] ] @@ -570,7 +570,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["my key", "hello world", nil] ] @@ -599,7 +599,7 @@ struct RedisStatementGeneratorTests { originalRow: nil ) - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["key", "say \"hello\"", nil] ] @@ -646,7 +646,7 @@ struct RedisStatementGeneratorTests { ) ] - let insertedData: [Int: [String?]] = [ + let insertedData: [Int: [PluginCellValue]] = [ 0: ["newkey", "newval", nil] ] diff --git a/TableProTests/Plugins/SQLExportPluginTests.swift b/TableProTests/Plugins/SQLExportPluginTests.swift deleted file mode 100644 index a66fa9a99..000000000 --- a/TableProTests/Plugins/SQLExportPluginTests.swift +++ /dev/null @@ -1,315 +0,0 @@ -// -// SQLExportPluginTests.swift -// TableProTests -// - -#if canImport(SQLExport) -import Foundation -import TableProPluginKit -import Testing - -@testable import SQLExport - -@MainActor -@Suite("SQLExportPlugin emits round-trippable Postgres dumps") -struct SQLExportPluginTests { - private static func runExport( - tables: [PluginExportTable], - dataSource: any PluginExportDataSource, - plugin: SQLExportPlugin = SQLExportPlugin() - ) async throws -> String { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".sql") - defer { try? FileManager.default.removeItem(at: url) } - let progress = PluginExportProgress(progress: Progress()) - plugin.settings = SQLExportOptions() - _ = try await plugin.export( - tables: tables, dataSource: dataSource, destination: url, progress: progress) - return try String(contentsOf: url, encoding: .utf8) - } - - private static func table( - _ name: String, type: String = "table", schema: String = "public" - ) -> PluginExportTable { - PluginExportTable(name: name, databaseName: schema, tableType: type, optionValues: [true, true, true]) - } - - @Test("Identity column emits OVERRIDING SYSTEM VALUE and setval") - func identity_always_emits_overriding_and_setval() async throws { - let source = MockExportDataSource( - columns: [ - "users": [ - PluginColumnInfo(name: "id", dataType: "BIGINT", identityKind: .always), - PluginColumnInfo(name: "email", dataType: "TEXT") - ] - ], - ddl: ["users": "CREATE TABLE \"public\".\"users\" (\n id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,\n email text NOT NULL,\n PRIMARY KEY (id)\n);"], - rows: ["users": [["1", "alice@example.com"], ["2", "bob@example.com"]]], - rowHeaders: ["users": (["id", "email"], ["bigint", "text"])] - ) - let dump = try await Self.runExport(tables: [Self.table("users")], dataSource: source) - #expect(dump.contains("OVERRIDING SYSTEM VALUE")) - #expect(dump.contains("pg_catalog.setval")) - #expect(dump.contains("pg_catalog.pg_get_serial_sequence")) - } - - @Test("BY DEFAULT identity emits no OVERRIDING clause") - func identity_by_default_no_overriding() async throws { - let source = MockExportDataSource( - columns: [ - "items": [ - PluginColumnInfo(name: "id", dataType: "BIGINT", identityKind: .byDefault), - PluginColumnInfo(name: "name", dataType: "TEXT") - ] - ], - rowHeaders: ["items": (["id", "name"], ["bigint", "text"])], - rows: ["items": [["1", "a"]]] - ) - let dump = try await Self.runExport(tables: [Self.table("items")], dataSource: source) - #expect(!dump.contains("OVERRIDING SYSTEM VALUE")) - #expect(dump.contains("pg_catalog.setval")) - } - - @Test("Generated STORED columns are dropped from INSERT column list") - func generated_columns_skipped_from_insert() async throws { - let source = MockExportDataSource( - columns: [ - "lines": [ - PluginColumnInfo(name: "id", dataType: "BIGINT", identityKind: .always), - PluginColumnInfo(name: "qty", dataType: "INT"), - PluginColumnInfo(name: "price", dataType: "NUMERIC"), - PluginColumnInfo(name: "total", dataType: "NUMERIC", isGenerated: true) - ] - ], - rowHeaders: ["lines": (["id", "qty", "price", "total"], ["bigint", "int", "numeric", "numeric"])], - rows: ["lines": [["1", "2", "5.00", "10.00"]]] - ) - let dump = try await Self.runExport(tables: [Self.table("lines")], dataSource: source) - #expect(dump.contains("(\"id\", \"qty\", \"price\")")) - #expect(!dump.contains("\"total\"")) - } - - @Test("FK constraints land in finalization phase, not CREATE TABLE body") - func foreign_keys_emitted_after_data() async throws { - let source = MockExportDataSource( - columns: [ - "customers": [PluginColumnInfo(name: "id", dataType: "BIGINT", identityKind: .always)], - "orders": [ - PluginColumnInfo(name: "id", dataType: "BIGINT", identityKind: .always), - PluginColumnInfo(name: "customer_id", dataType: "BIGINT") - ] - ], - ddl: [ - "customers": "CREATE TABLE \"public\".\"customers\" (id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, PRIMARY KEY (id));", - "orders": "CREATE TABLE \"public\".\"orders\" (id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, customer_id bigint, PRIMARY KEY (id));" - ], - rowHeaders: [ - "customers": (["id"], ["bigint"]), - "orders": (["id", "customer_id"], ["bigint", "bigint"]) - ], - rows: ["customers": [["1"]], "orders": [["1", "1"]]], - foreignKeys: [ - "orders": [PluginForeignKeyInfo( - name: "orders_customer_id_fkey", column: "customer_id", - referencedTable: "customers", referencedColumn: "id", - referencedSchema: "public")] - ] - ) - let dump = try await Self.runExport( - tables: [Self.table("orders"), Self.table("customers")], dataSource: source) - - guard let createOrders = dump.range(of: "CREATE TABLE \"public\".\"orders\""), - let alterFK = dump.range(of: "ALTER TABLE") else { - Issue.record("Missing CREATE TABLE or ALTER TABLE in export") - return - } - #expect(createOrders.lowerBound < alterFK.lowerBound) - #expect(!dump.contains("FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"customers\" (\"id\")\n);")) - #expect(dump.contains("ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT")) - } - - @Test("Topological sort places parent before child in CREATE phase") - func topo_sort_parents_before_children() async throws { - let source = MockExportDataSource( - columns: [ - "customers": [PluginColumnInfo(name: "id", dataType: "BIGINT")], - "orders": [PluginColumnInfo(name: "id", dataType: "BIGINT"), - PluginColumnInfo(name: "customer_id", dataType: "BIGINT")] - ], - ddl: [ - "customers": "CREATE TABLE \"public\".\"customers\" (id bigint);", - "orders": "CREATE TABLE \"public\".\"orders\" (id bigint, customer_id bigint);" - ], - rowHeaders: ["customers": ([], []), "orders": ([], [])], - foreignKeys: [ - "orders": [PluginForeignKeyInfo( - name: "fk_orders_customers", column: "customer_id", - referencedTable: "customers", referencedColumn: "id")] - ] - ) - let dump = try await Self.runExport( - tables: [Self.table("orders"), Self.table("customers")], dataSource: source) - guard let createCustomers = dump.range(of: "CREATE TABLE \"public\".\"customers\"")?.lowerBound, - let createOrders = dump.range(of: "CREATE TABLE \"public\".\"orders\"")?.lowerBound else { - Issue.record("Missing CREATE TABLE statements in dump") - return - } - #expect(createCustomers < createOrders) - } - - @Test("Composite FK aggregates into one ADD CONSTRAINT statement") - func composite_fk_single_alter_statement() async throws { - let source = MockExportDataSource( - columns: [ - "parent": [PluginColumnInfo(name: "a", dataType: "INT"), PluginColumnInfo(name: "b", dataType: "INT")], - "child": [ - PluginColumnInfo(name: "id", dataType: "INT"), - PluginColumnInfo(name: "pa", dataType: "INT"), - PluginColumnInfo(name: "pb", dataType: "INT") - ] - ], - ddl: [ - "parent": "CREATE TABLE \"public\".\"parent\" (a int, b int);", - "child": "CREATE TABLE \"public\".\"child\" (id int, pa int, pb int);" - ], - rowHeaders: ["parent": ([], []), "child": ([], [])], - foreignKeys: [ - "child": [ - PluginForeignKeyInfo(name: "child_parent_fk", column: "pa", - referencedTable: "parent", referencedColumn: "a"), - PluginForeignKeyInfo(name: "child_parent_fk", column: "pb", - referencedTable: "parent", referencedColumn: "b") - ] - ] - ) - let dump = try await Self.runExport( - tables: [Self.table("parent"), Self.table("child")], dataSource: source) - let alterCount = dump.components(separatedBy: "ALTER TABLE").count - 1 - #expect(alterCount == 1) - #expect(dump.contains("(\"pa\", \"pb\")")) - #expect(dump.contains("(\"a\", \"b\")")) - } - - @Test("Cyclic FK between two tables falls back to alphabetical and emits both ALTERs") - func cyclic_fk_falls_back_to_alpha() async throws { - let source = MockExportDataSource( - columns: [ - "a_table": [PluginColumnInfo(name: "id", dataType: "INT"), - PluginColumnInfo(name: "b_id", dataType: "INT")], - "b_table": [PluginColumnInfo(name: "id", dataType: "INT"), - PluginColumnInfo(name: "a_id", dataType: "INT")] - ], - ddl: [ - "a_table": "CREATE TABLE \"public\".\"a_table\" (id int, b_id int);", - "b_table": "CREATE TABLE \"public\".\"b_table\" (id int, a_id int);" - ], - rowHeaders: ["a_table": ([], []), "b_table": ([], [])], - foreignKeys: [ - "a_table": [PluginForeignKeyInfo( - name: "a_b_fk", column: "b_id", - referencedTable: "b_table", referencedColumn: "id")], - "b_table": [PluginForeignKeyInfo( - name: "b_a_fk", column: "a_id", - referencedTable: "a_table", referencedColumn: "id")] - ] - ) - let dump = try await Self.runExport( - tables: [Self.table("b_table"), Self.table("a_table")], dataSource: source) - let alterCount = dump.components(separatedBy: "ALTER TABLE").count - 1 - #expect(alterCount == 2) - } - - @Test("Views are skipped from INSERT phase") - func views_skipped_from_inserts() async throws { - let source = MockExportDataSource( - columns: [ - "active_users": [PluginColumnInfo(name: "id", dataType: "BIGINT")] - ], - ddl: ["active_users": "CREATE OR REPLACE VIEW \"public\".\"active_users\" AS SELECT 1;"], - rowHeaders: ["active_users": ([], [])] - ) - let dump = try await Self.runExport( - tables: [Self.table("active_users", type: "view")], dataSource: source) - #expect(dump.contains("CREATE OR REPLACE VIEW")) - #expect(!dump.contains("INSERT INTO")) - } -} - -// MARK: - Mock - -private final class MockExportDataSource: PluginExportDataSource, @unchecked Sendable { - let databaseTypeId: String - let columns: [String: [PluginColumnInfo]] - let ddl: [String: String] - let rows: [String: [[String?]]] - let rowHeaders: [String: (columns: [String], typeNames: [String])] - let foreignKeys: [String: [PluginForeignKeyInfo]] - - init( - databaseTypeId: String = "PostgreSQL", - columns: [String: [PluginColumnInfo]] = [:], - ddl: [String: String] = [:], - rowHeaders: [String: (columns: [String], typeNames: [String])] = [:], - rows: [String: [[String?]]] = [:], - foreignKeys: [String: [PluginForeignKeyInfo]] = [:] - ) { - self.databaseTypeId = databaseTypeId - self.columns = columns - self.ddl = ddl - self.rows = rows - self.rowHeaders = rowHeaders - self.foreignKeys = foreignKeys - } - - func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { - let header = rowHeaders[table] ?? ([], []) - let tableRows = rows[table] ?? [] - return AsyncThrowingStream { continuation in - continuation.yield(.header(PluginStreamHeader( - columns: header.columns, - columnTypeNames: header.typeNames, - estimatedRowCount: nil))) - if !tableRows.isEmpty { - continuation.yield(.rows(tableRows)) - } - continuation.finish() - } - } - - func fetchTableDDL(table: String, databaseName: String) async throws -> String { - ddl[table] ?? "CREATE TABLE \"\(databaseName)\".\"\(table)\" ()" - } - - func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - func quoteIdentifier(_ identifier: String) -> String { - "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - - func escapeStringLiteral(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") - } - - func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { - rows[table]?.count - } - - func fetchColumns(table: String, databaseName: String) async throws -> [PluginColumnInfo] { - columns[table] ?? [] - } - - func fetchAllColumns(databaseName: String) async throws -> [String: [PluginColumnInfo]] { - columns - } - - func fetchForeignKeys(table: String, databaseName: String) async throws -> [PluginForeignKeyInfo] { - foreignKeys[table] ?? [] - } - - func fetchAllForeignKeys(databaseName: String) async throws -> [String: [PluginForeignKeyInfo]] { - foreignKeys - } -} -#endif diff --git a/TableProTests/Views/History/HistoryDataProviderTests.swift b/TableProTests/Views/History/HistoryDataProviderTests.swift deleted file mode 100644 index cb0497a44..000000000 --- a/TableProTests/Views/History/HistoryDataProviderTests.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// HistoryDataProviderTests.swift -// TableProTests -// -// Tests for HistoryDataProvider async data loading. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("HistoryDataProvider", .serialized) -struct HistoryDataProviderTests { - private let storage = QueryHistoryStorage.shared - - private func insertEntry(query: String = "SELECT 1") async -> QueryHistoryEntry { - let entry = QueryHistoryEntry( - query: query, - connectionId: UUID(), - databaseName: "testdb", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await storage.addHistory(entry) - return entry - } - - @Test("Initial state: empty entries, count=0, isEmpty=true") - func initialStateIsEmpty() { - let provider = HistoryDataProvider() - #expect(provider.count == 0) - #expect(provider.isEmpty == true) - #expect(provider.historyEntries.isEmpty) - } - - @Test("Default dateFilter is .all") - func defaultDateFilterIsAll() { - let provider = HistoryDataProvider() - #expect(provider.dateFilter == .all) - } - - @Test("Default searchText is empty") - func defaultSearchTextIsEmpty() { - let provider = HistoryDataProvider() - #expect(provider.searchText == "") - } - - @Test("loadData populates historyEntries") - func loadDataPopulatesEntries() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT load_\(marker)") - - let provider = HistoryDataProvider() - provider.searchText = marker - await provider.loadData() - - #expect(provider.count >= 1) - #expect(provider.historyEntries.contains { $0.query.contains(marker) }) - } - - @Test("loadData uses searchText when set") - func loadDataUsesSearchText() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT \(marker) FROM unique_hdp_table") - - let provider = HistoryDataProvider() - provider.searchText = marker - await provider.loadData() - - #expect(provider.count >= 1) - #expect(provider.historyEntries.allSatisfy { $0.query.contains(marker) }) - } - - @Test("loadData invokes onDataChanged callback") - func loadDataInvokesCallback() async { - let provider = HistoryDataProvider() - var callbackCalled = false - provider.onDataChanged = { callbackCalled = true } - - await provider.loadData() - - #expect(callbackCalled == true) - } - - @Test("historyEntry(at:) returns correct entry for valid index") - func historyEntryAtValidIndex() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT at_\(marker)") - - let provider = HistoryDataProvider() - provider.searchText = marker - await provider.loadData() - - let entry = provider.historyEntry(at: 0) - #expect(entry != nil) - #expect(entry?.query.contains(marker) == true) - } - - @Test("historyEntry(at:) returns nil for out-of-bounds index") - func historyEntryAtOutOfBounds() { - let provider = HistoryDataProvider() - #expect(provider.historyEntry(at: 0) == nil) - #expect(provider.historyEntry(at: -1) == nil) - #expect(provider.historyEntry(at: 999) == nil) - } - - @Test("query(at:) returns query string") - func queryAtReturnsQueryString() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT query_\(marker)") - - let provider = HistoryDataProvider() - provider.searchText = marker - await provider.loadData() - - let query = provider.query(at: 0) - #expect(query?.contains(marker) == true) - } - - @Test("deleteEntry removes by UUID") - func deleteEntryRemovesByUUID() async { - let entry = await insertEntry(query: "SELECT to_delete_\(UUID().uuidString)") - - let provider = HistoryDataProvider() - let result = await provider.deleteEntry(id: entry.id) - #expect(result == true) - - // Verify the specific entry was deleted by trying to fetch it - let entries = await storage.fetchHistory(limit: 1000) - #expect(!entries.contains { $0.id == entry.id }) - } - - @Test("clearAll returns true and updates provider state") - @MainActor - func clearAllRemovesAllHistory() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT clear_\(marker)") - - let provider = HistoryDataProvider() - provider.searchText = marker - await provider.loadData() - #expect(provider.count >= 1) - - let result = await provider.clearAll() - #expect(result == true) - #expect(provider.count == 0) - #expect(provider.isEmpty == true) - } - - @Test("scheduleSearch debounces then loads data") - @MainActor - func scheduleSearchDebouncesAndLoads() async { - let marker = UUID().uuidString - _ = await insertEntry(query: "SELECT debounce_\(marker)") - - let provider = HistoryDataProvider() - provider.searchText = marker - - await confirmation("search completes") { confirm in - provider.scheduleSearch { - confirm() - } - - try? await Task.sleep(for: .milliseconds(400)) - } - - #expect(provider.count >= 1) - } - - @Test("scheduleSearch cancels previous on rapid calls") - @MainActor - func scheduleSearchCancelsPrevious() async { - let provider = HistoryDataProvider() - var completionCount = 0 - - provider.scheduleSearch { - completionCount += 1 - } - - try? await Task.sleep(for: .milliseconds(50)) - provider.scheduleSearch { - completionCount += 1 - } - - try? await Task.sleep(for: .milliseconds(400)) - - #expect(completionCount == 1) - } -} diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift deleted file mode 100644 index b89e6d1cc..000000000 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// DataTabGridDelegateTests.swift -// TableProTests -// - -import AppKit -import Foundation -import Testing -@testable import TablePro - -@MainActor -private final class FakeTableViewCoordinator: TableViewCoordinating { - var insertedCalls: [IndexSet] = [] - var removedCalls: [IndexSet] = [] - var fullReplaceCount: Int = 0 - var invalidateCount: Int = 0 - var deltaCalls: [Delta] = [] - var commitEditCount: Int = 0 - - func applyInsertedRows(_ indices: IndexSet) { - insertedCalls.append(indices) - } - - func applyRemovedRows(_ indices: IndexSet) { - removedCalls.append(indices) - } - - func applyFullReplace() { - fullReplaceCount += 1 - } - - func invalidateCachesForUndoRedo() { - invalidateCount += 1 - } - - func applyDelta(_ delta: Delta) { - deltaCalls.append(delta) - } - - func commitActiveCellEdit() { - commitEditCount += 1 - } - - var beginEditingCalls: [(row: Int, column: Int)] = [] - - func beginEditing(displayRow: Int, column: Int) { - beginEditingCalls.append((row: displayRow, column: column)) - } - - var refreshFKCount: Int = 0 - var scrollToTopCount: Int = 0 - - func refreshForeignKeyColumns() { refreshFKCount += 1 } - func scrollToTop() { scrollToTopCount += 1 } -} - -@Suite("DataTabGridDelegate row-delta forwarding") -@MainActor -struct DataTabGridDelegateTests { - - @Test("dataGridDidInsertRows(at:) forwards the IndexSet to applyInsertedRows") - func insertForwardsIndices() { - let delegate = DataTabGridDelegate() - let applier = FakeTableViewCoordinator() - delegate.tableViewCoordinator = applier - - let indices = IndexSet([1, 3, 5]) - delegate.dataGridDidInsertRows(at: indices) - - #expect(applier.insertedCalls.count == 1) - #expect(applier.insertedCalls.first == indices) - #expect(applier.removedCalls.isEmpty) - #expect(applier.fullReplaceCount == 0) - } - - @Test("dataGridDidRemoveRows(at:) forwards the IndexSet to applyRemovedRows") - func removeForwardsIndices() { - let delegate = DataTabGridDelegate() - let applier = FakeTableViewCoordinator() - delegate.tableViewCoordinator = applier - - let indices = IndexSet(integersIn: 4..<7) - delegate.dataGridDidRemoveRows(at: indices) - - #expect(applier.removedCalls.count == 1) - #expect(applier.removedCalls.first == indices) - #expect(applier.insertedCalls.isEmpty) - #expect(applier.fullReplaceCount == 0) - } - - @Test("dataGridDidReplaceAllRows() forwards to applyFullReplace") - func fullReplaceForwards() { - let delegate = DataTabGridDelegate() - let applier = FakeTableViewCoordinator() - delegate.tableViewCoordinator = applier - - delegate.dataGridDidReplaceAllRows() - - #expect(applier.fullReplaceCount == 1) - #expect(applier.insertedCalls.isEmpty) - #expect(applier.removedCalls.isEmpty) - } - - @Test("Calls are no-ops when tableViewCoordinator is nil") - func nilCoordinatorIsNoOp() { - let delegate = DataTabGridDelegate() - #expect(delegate.tableViewCoordinator == nil) - - delegate.dataGridDidInsertRows(at: IndexSet([0])) - delegate.dataGridDidRemoveRows(at: IndexSet([0])) - delegate.dataGridDidReplaceAllRows() - } -} diff --git a/TableProTests/Views/Main/RowOperationsDispatchTests.swift b/TableProTests/Views/Main/RowOperationsDispatchTests.swift deleted file mode 100644 index f46939d08..000000000 --- a/TableProTests/Views/Main/RowOperationsDispatchTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// RowOperationsDispatchTests.swift -// TableProTests -// -// Locks the dispatch wiring from RowOperations into TableViewCoordinating. -// These tests guard the path that PR #938 (Phase D-b) accidentally severed: -// invalidateCachesForUndoRedo must fire on soft-delete (existing rows) so the -// red row background and yellow modified marker propagate to NSTableView's -// visible cell views without requiring a tab switch or scroll-recycle. -// - -import Foundation -@testable import TablePro -import Testing - -@MainActor -private final class FakeTableViewCoordinator: TableViewCoordinating { - var fullReplaceCount = 0 - var insertedCount = 0 - var removedCount = 0 - var deltaCount = 0 - var invalidateCount = 0 - var commitEditCount = 0 - var refreshFKCount = 0 - var scrollToTopCount = 0 - var beginEditingCalls: [(row: Int, column: Int)] = [] - - func applyInsertedRows(_ indices: IndexSet) { insertedCount += 1 } - func applyRemovedRows(_ indices: IndexSet) { removedCount += 1 } - func applyFullReplace() { fullReplaceCount += 1 } - func applyDelta(_ delta: Delta) { deltaCount += 1 } - func invalidateCachesForUndoRedo() { invalidateCount += 1 } - func commitActiveCellEdit() { commitEditCount += 1 } - func beginEditing(displayRow: Int, column: Int) { - beginEditingCalls.append((row: displayRow, column: column)) - } - func refreshForeignKeyColumns() { refreshFKCount += 1 } - func scrollToTop() { scrollToTopCount += 1 } -} - -@Suite("RowOperations dispatch") -@MainActor -struct RowOperationsDispatchTests { - private struct Fixture { - let coordinator: MainContentCoordinator - let tabManager: QueryTabManager - let delegate: DataTabGridDelegate - let fake: FakeTableViewCoordinator - let tabId: UUID - } - - private func makeFixture(rowCount: Int = 5) throws -> Fixture { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - let delegate = DataTabGridDelegate() - let fake = FakeTableViewCoordinator() - delegate.tableViewCoordinator = fake - coordinator.dataTabDelegate = delegate - - try tabManager.addTableTab(tableName: "users") - let tabIndex = tabManager.selectedTabIndex ?? 0 - tabManager.tabs[tabIndex].tableContext.isEditable = true - let tabId = tabManager.tabs[tabIndex].id - - let columns = ["id", "name"] - let rows = (0.. Fixture { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - let delegate = DataTabGridDelegate() - let fake = FakeTableViewCoordinator() - delegate.tableViewCoordinator = fake - coordinator.dataTabDelegate = delegate - return Fixture(coordinator: coordinator, tabManager: tabManager, delegate: delegate, fake: fake) - } - - private func makeTableRows(rowCount: Int) -> TableRows { - let columns = ["id", "name"] - let rows = (0.. NSTableView { - let tableView = NSTableView() - let column = NSTableColumn(identifier: ColumnIdentitySchema.slotIdentifier(0)) - tableView.addTableColumn(column) - return tableView - } - - @Test("Text kind dequeues DataGridTextCellView") - func dequeueCell_returnsTextSubclassForTextKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .text, in: makeTableView()) - #expect(cell is DataGridTextCellView) - #expect(cell.identifier == DataGridTextCellView.reuseIdentifier) - } - - @Test("Foreign key kind dequeues DataGridForeignKeyCellView") - func dequeueCell_returnsForeignKeySubclassForFKKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .foreignKey, in: makeTableView()) - #expect(cell is DataGridForeignKeyCellView) - #expect(cell.identifier == DataGridForeignKeyCellView.reuseIdentifier) - } - - @Test("Dropdown kind dequeues DataGridDropdownCellView") - func dequeueCell_returnsDropdownSubclassForDropdownKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .dropdown, in: makeTableView()) - #expect(cell is DataGridDropdownCellView) - #expect(cell.identifier == DataGridDropdownCellView.reuseIdentifier) - } - - @Test("Boolean kind dequeues DataGridBooleanCellView") - func dequeueCell_returnsBooleanSubclassForBooleanKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .boolean, in: makeTableView()) - #expect(cell is DataGridBooleanCellView) - #expect(cell.identifier == DataGridBooleanCellView.reuseIdentifier) - } - - @Test("Date kind dequeues DataGridDateCellView") - func dequeueCell_returnsDateSubclassForDateKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .date, in: makeTableView()) - #expect(cell is DataGridDateCellView) - #expect(cell.identifier == DataGridDateCellView.reuseIdentifier) - } - - @Test("JSON kind dequeues DataGridJsonCellView") - func dequeueCell_returnsJsonSubclassForJsonKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .json, in: makeTableView()) - #expect(cell is DataGridJsonCellView) - #expect(cell.identifier == DataGridJsonCellView.reuseIdentifier) - } - - @Test("Blob kind dequeues DataGridBlobCellView") - func dequeueCell_returnsBlobSubclassForBlobKind() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .blob, in: makeTableView()) - #expect(cell is DataGridBlobCellView) - #expect(cell.identifier == DataGridBlobCellView.reuseIdentifier) - } - - @Test("Reuse identifiers are distinct for every cell kind") - func reuseIdentifiers_areDistinctPerKind() { - let identifiers: [NSUserInterfaceItemIdentifier] = [ - DataGridTextCellView.reuseIdentifier, - DataGridForeignKeyCellView.reuseIdentifier, - DataGridDropdownCellView.reuseIdentifier, - DataGridBooleanCellView.reuseIdentifier, - DataGridDateCellView.reuseIdentifier, - DataGridJsonCellView.reuseIdentifier, - DataGridBlobCellView.reuseIdentifier, - ] - let unique = Set(identifiers.map(\.rawValue)) - #expect(unique.count == identifiers.count) - } - - @Test("Freshly created cell receives accessoryDelegate from registry") - func dequeueCell_propagatesAccessoryDelegateToFreshCell() { - let registry = DataGridCellRegistry() - let delegate = StubAccessoryDelegate() - registry.accessoryDelegate = delegate - - let cell = registry.dequeueCell(of: .text, in: makeTableView()) - #expect(cell.accessoryDelegate === delegate) - } - - @Test("Freshly created cell receives nullDisplayString from registry") - func dequeueCell_propagatesNullDisplayStringToFreshCell() { - let registry = DataGridCellRegistry() - let cell = registry.dequeueCell(of: .text, in: makeTableView()) - #expect(cell.nullDisplayString == registry.nullDisplayString) - } -} - -@Suite("DataGridCellRegistry.makeRowNumberCell") -@MainActor -struct DataGridCellRegistryRowNumberTests { - private func makeTableView() -> NSTableView { - let tableView = NSTableView() - let column = NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) - tableView.addTableColumn(column) - return tableView - } - - @Test("Row number cell has RowNumberCellView identifier") - func makeRowNumberCell_hasRowNumberCellViewIdentifier() { - let registry = DataGridCellRegistry() - let view = registry.makeRowNumberCell( - in: makeTableView(), - row: 0, - cachedRowCount: 5, - visualState: .empty - ) - #expect(view.identifier?.rawValue == "RowNumberCellView") - } - - @Test("Row number cell renders one-based row index") - func makeRowNumberCell_rendersOneBasedRowIndex() { - let registry = DataGridCellRegistry() - let view = registry.makeRowNumberCell( - in: makeTableView(), - row: 4, - cachedRowCount: 10, - visualState: .empty - ) - let cellView = view as? NSTableCellView - #expect(cellView != nil) - #expect(cellView?.textField?.stringValue == "5") - } - - @Test("Row number cell renders empty string when row is out of cached range") - func makeRowNumberCell_rendersEmptyWhenRowOutOfRange() { - let registry = DataGridCellRegistry() - let view = registry.makeRowNumberCell( - in: makeTableView(), - row: 99, - cachedRowCount: 5, - visualState: .empty - ) - let cellView = view as? NSTableCellView - #expect(cellView != nil) - #expect(cellView?.textField?.stringValue == "") - } -} diff --git a/TableProTests/Views/Results/TableSelectionTests.swift b/TableProTests/Views/Results/TableSelectionTests.swift deleted file mode 100644 index d82aaa493..000000000 --- a/TableProTests/Views/Results/TableSelectionTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// TableSelectionTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("TableSelection") -struct TableSelectionTests { - @Test("Default selection is empty") - func defaultIsEmpty() { - let selection = TableSelection() - #expect(selection.focusedRow == -1) - #expect(selection.focusedColumn == -1) - #expect(selection.hasFocus == false) - } - - @Test("hasFocus requires both row and column") - func hasFocusRequiresBoth() { - var selection = TableSelection() - selection.focusedRow = 5 - #expect(selection.hasFocus == false) - selection.focusedColumn = 2 - #expect(selection.hasFocus == true) - selection.focusedRow = -1 - #expect(selection.hasFocus == false) - } - - @Test("clearFocus resets focus") - func clearFocus() { - var selection = TableSelection() - selection.setFocus(row: 5, column: 2) - selection.clearFocus() - #expect(selection.focusedRow == -1) - #expect(selection.focusedColumn == -1) - } - - @Test("setFocus assigns row and column") - func setFocus() { - var selection = TableSelection() - selection.setFocus(row: 7, column: 3) - #expect(selection.focusedRow == 7) - #expect(selection.focusedColumn == 3) - } - - @Test("Equatable compares focus fields") - func equatable() { - var a = TableSelection() - a.setFocus(row: 1, column: 2) - var b = a - #expect(a == b) - b.focusedRow = 2 - #expect(a != b) - } -} - -@Suite("TableSelection.reloadIndexes") -struct TableSelectionReloadIndexesTests { - @Test("No change returns nil") - func noChange() { - var selection = TableSelection() - selection.setFocus(row: 5, column: 2) - let same = selection - #expect(selection.reloadIndexes(from: same) == nil) - } - - @Test("Initial focus from empty includes new cell only") - func initialFocus() { - let previous = TableSelection() - var current = previous - current.setFocus(row: 3, column: 1) - let result = current.reloadIndexes(from: previous) - #expect(result?.rows == IndexSet([3])) - #expect(result?.columns == IndexSet([1])) - } - - @Test("Clearing focus includes old cell only") - func clearFocusFromActive() { - var previous = TableSelection() - previous.setFocus(row: 3, column: 1) - var current = previous - current.clearFocus() - let result = current.reloadIndexes(from: previous) - #expect(result?.rows == IndexSet([3])) - #expect(result?.columns == IndexSet([1])) - } - - @Test("Row change at same column reloads both rows") - func rowChange() { - var previous = TableSelection() - previous.setFocus(row: 3, column: 2) - var current = previous - current.focusedRow = 4 - let result = current.reloadIndexes(from: previous) - #expect(result?.rows == IndexSet([3, 4])) - #expect(result?.columns == IndexSet([2])) - } - - @Test("Column change at same row reloads both columns") - func columnChange() { - var previous = TableSelection() - previous.setFocus(row: 3, column: 2) - var current = previous - current.focusedColumn = 5 - let result = current.reloadIndexes(from: previous) - #expect(result?.rows == IndexSet([3])) - #expect(result?.columns == IndexSet([2, 5])) - } - - @Test("Both change reloads both rows and both columns") - func bothChange() { - var previous = TableSelection() - previous.setFocus(row: 3, column: 2) - var current = previous - current.setFocus(row: 7, column: 5) - let result = current.reloadIndexes(from: previous) - #expect(result?.rows == IndexSet([3, 7])) - #expect(result?.columns == IndexSet([2, 5])) - } - - @Test("Clearing focus from no-focus state returns nil") - func clearFromEmpty() { - let previous = TableSelection() - let current = previous - #expect(current.reloadIndexes(from: previous) == nil) - } -} From 6e254e0e685b473bd5d79932121accfae624e755 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 00:42:22 +0700 Subject: [PATCH 02/11] refactor(plugins): propagate PluginCellValue through QueryResult.rows --- .../Coordinators/PaginationCoordinator.swift | 3 ++- .../QueryExecutionCoordinator+Helpers.swift | 9 ++++--- ...yExecutionCoordinator+MultiStatement.swift | 5 ++-- ...QueryExecutionCoordinator+Parameters.swift | 2 +- .../Database/DatabaseManager+Schema.swift | 2 +- .../Database/LazyLoadColumnsService.swift | 2 +- TablePro/Core/MCP/MCPConnectionBridge.swift | 8 +++--- .../Plugins/ExportDataSourceAdapter.swift | 2 +- .../Core/Plugins/PluginDriverAdapter.swift | 11 +------- .../StreamingQueryExportDataSource.swift | 2 +- .../ClickHouseDashboardProvider.swift | 5 ++-- .../Providers/DuckDBDashboardProvider.swift | 5 ++-- .../Providers/MSSQLDashboardProvider.swift | 5 ++-- .../Providers/MySQLDashboardProvider.swift | 5 ++-- .../PostgreSQLDashboardProvider.swift | 5 ++-- .../Providers/SQLiteDashboardProvider.swift | 5 ++-- .../Core/Services/Export/ExportService.swift | 4 +-- .../Core/Services/Query/QueryExecutor.swift | 2 +- TablePro/Models/Query/QueryResult.swift | 6 ++--- .../ViewModels/RedisKeyTreeViewModel.swift | 5 ++-- TablePro/Views/Export/ExportDialog.swift | 25 ++++++++----------- .../MainContentCoordinator+ClickHouse.swift | 2 +- .../MainContentCoordinator+QueryHelpers.swift | 2 +- .../ForeignKeyPopoverContentView.swift | 6 ++--- .../Views/Results/ForeignKeyPreviewView.swift | 2 +- .../Views/Structure/ClickHousePartsView.swift | 13 +++++----- 26 files changed, 72 insertions(+), 71 deletions(-) diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 0d997ac17..545a99d75 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import os +import TableProPluginKit private let progressLog = Logger(subsystem: "com.TablePro", category: "ProgressiveLoad") @@ -190,7 +191,7 @@ final class PaginationCoordinator { } let replaceDelta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.replace(rows: result.rows) + rows.replace(rows: result.rows.map { row in row.map { $0.asText } }) } parent.tabManager.mutate(at: idx) { tab in tab.execution.executionTime = result.executionTime diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 159a7f9cb..1b66e593e 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -57,7 +57,7 @@ extension QueryExecutionCoordinator { tabId: UUID, columns: [String], columnTypes: [ColumnType], - rows: [[String?]], + rows: [[PluginCellValue]], executionTime: TimeInterval, rowsAffected: Int, statusMessage: String?, @@ -100,8 +100,9 @@ extension QueryExecutionCoordinator { } } + let stringRows = rows.map { row in row.map { $0.asText } } let newTableRows = TableRows.from( - queryRows: rows, + queryRows: stringRows, columns: columns, columnTypes: columnTypes, columnDefaults: columnDefaults, @@ -233,7 +234,7 @@ extension QueryExecutionCoordinator { query: "SELECT COUNT(*) FROM \(quotedTable)" ) if let firstRow = countResult.rows.first, - let countStr = firstRow.first.flatMap({ $0 }) { + let countStr = firstRow.first?.asText { count = Int(countStr) } else { count = nil @@ -343,7 +344,7 @@ extension QueryExecutionCoordinator { query: "SELECT COUNT(*) FROM \(quotedTable)" ) if let firstRow = countResult.rows.first, - let countStr = firstRow.first.flatMap({ $0 }) { + let countStr = firstRow.first?.asText { count = Int(countStr) } else { count = nil diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index cb8dae251..c823b92e3 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import os +import TableProPluginKit private let multiStatementLogger = Logger(subsystem: "com.TablePro", category: "MultiStatement") @@ -87,7 +88,7 @@ extension QueryExecutionCoordinator { let stmtTableName = await MainActor.run { parent.extractTableName(from: sql) } let stmtRows = TableRows.from( - queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, + queryRows: result.rows.map { row in row.map { $0.asText } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) @@ -218,7 +219,7 @@ extension QueryExecutionCoordinator { let safeColumns = selectResult.columns.map { String($0) } let safeColumnTypes = selectResult.columnTypes let safeRows = selectResult.rows.map { row in - row.map { $0.map { String($0) } } + row.map { $0.asText } } if currentTab.tabType == .table, let existing = currentTab.tableContext.tableName { resolvedTableName = existing diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index c64353c12..db6bf332f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -279,7 +279,7 @@ extension QueryExecutionCoordinator { let stmtTableName = await MainActor.run { parent.extractTableName(from: stmtSQL) } let stmtRows = TableRows.from( - queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, + queryRows: result.rows.map { row in row.map { $0.asText } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index a38ef703b..96cb7e7ea 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -148,7 +148,7 @@ extension DatabaseManager { do { let result = try await driver.execute(query: query) - if let row = result.rows.first, let name = row[0], !name.isEmpty { + if let row = result.rows.first, let name = row[0].asText, !name.isEmpty { return name } } catch { diff --git a/TablePro/Core/Database/LazyLoadColumnsService.swift b/TablePro/Core/Database/LazyLoadColumnsService.swift index bd18b4c0f..cf4661777 100644 --- a/TablePro/Core/Database/LazyLoadColumnsService.swift +++ b/TablePro/Core/Database/LazyLoadColumnsService.swift @@ -56,7 +56,7 @@ struct LazyLoadColumnsService { var dict: [String: String?] = [:] for (index, colName) in excludedColumnNames.enumerated() where index < row.count { - dict[colName] = row[index] + dict[colName] = row[index].asText } return dict } diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 45b2ab8fc..75eef53aa 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -1,5 +1,6 @@ import Foundation import os +import TableProPluginKit public actor MCPConnectionBridge { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPConnectionBridge") @@ -198,10 +199,11 @@ public actor MCPConnectionBridge { let jsonColumns: [JsonValue] = result.columns.map { .string($0) } let jsonRows: [JsonValue] = result.rows.map { row in .array(row.map { cell in - if let value = cell { - return .string(value) + switch cell { + case .null: return .null + case .text(let s): return .string(s) + case .bytes(let d): return .string(d.base64EncodedString()) } - return .null }) } diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 5d2e471a3..5e456bab8 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -110,7 +110,7 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable PluginQueryResult( columns: result.columns, columnTypeNames: result.columnTypes.map { $0.rawType ?? "" }, - rows: result.rows.map { row in row.map(PluginCellValue.fromOptional) }, + rows: result.rows, rowsAffected: result.rowsAffected, executionTime: result.executionTime ) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 498997b52..e54e1c961 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -521,19 +521,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { let columnTypes = pluginResult.columnTypeNames.map { mapColumnType(rawTypeName: $0) } - let stringRows: [[String?]] = pluginResult.rows.map { row in - row.map { cell -> String? in - switch cell { - case .null: return nil - case .text(let s): return s - case .bytes(let d): return String(data: d, encoding: .isoLatin1) ?? "" - } - } - } var result = QueryResult( columns: pluginResult.columns, columnTypes: columnTypes, - rows: stringRows, + rows: pluginResult.rows, rowsAffected: pluginResult.rowsAffected, executionTime: pluginResult.executionTime, error: nil diff --git a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift index 3ee400342..90dc375f6 100644 --- a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift +++ b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift @@ -55,7 +55,7 @@ final class StreamingQueryExportDataSource: PluginExportDataSource, @unchecked S return PluginQueryResult( columns: result.columns, columnTypeNames: result.columnTypes.map { $0.rawType ?? "" }, - rows: result.rows.map { row in row.map(PluginCellValue.fromOptional) }, + rows: result.rows, rowsAffected: result.rowsAffected, executionTime: result.executionTime ) diff --git a/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift index 7d2fcc983..ff4e82dd0 100644 --- a/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct ClickHouseDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] @@ -116,9 +117,9 @@ private extension ClickHouseDashboardProvider { return map } - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } func formatDuration(seconds: Int) -> String { diff --git a/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift index a95c587ed..c14e846f4 100644 --- a/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct DuckDBDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.serverMetrics] @@ -91,8 +92,8 @@ private extension DuckDBDashboardProvider { return map } - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } } diff --git a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift index add91e5f8..26c476fe3 100644 --- a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct MSSQLDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] @@ -125,9 +126,9 @@ private extension MSSQLDashboardProvider { return map } - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } func formatDuration(seconds: Int) -> String { diff --git a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift index f3c3f294e..317d794a4 100644 --- a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct MySQLDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] @@ -167,9 +168,9 @@ private extension MySQLDashboardProvider { return map } - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } func formatDuration(seconds: Int) -> String { diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index eb770f079..b17116c49 100644 --- a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] @@ -152,9 +153,9 @@ private extension PostgreSQLDashboardProvider { return map } - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } func formatDuration(seconds: Int) -> String { diff --git a/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift index fe95377ef..c6dc6a377 100644 --- a/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct SQLiteDashboardProvider: ServerDashboardQueryProvider { let supportedPanels: Set = [.serverMetrics] @@ -72,9 +73,9 @@ struct SQLiteDashboardProvider: ServerDashboardQueryProvider { // MARK: - Helpers private extension SQLiteDashboardProvider { - func value(_ row: [String?], at index: Int?) -> String { + func value(_ row: [PluginCellValue], at index: Int?) -> String { guard let index, index < row.count else { return "" } - return row[index] ?? "" + return row[index].asText ?? "" } func formatBytes(_ bytes: Int) -> String { diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index ff0f96f7e..90c9b095f 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -374,7 +374,7 @@ final class ExportService { do { let result = try await driver.execute(query: batchQuery) for row in result.rows { - if let countStr = row.first, let count = Int(countStr ?? "0") { + if let cell = row.first, let count = Int(cell.asText ?? "0") { total += count } } @@ -383,7 +383,7 @@ final class ExportService { do { let tableRef = qualifiedTableRef(for: table, driver: driver) let result = try await driver.execute(query: "SELECT COUNT(*) FROM \(tableRef)") - if let countStr = result.rows.first?.first, let count = Int(countStr ?? "0") { + if let cell = result.rows.first?.first, let count = Int(cell.asText ?? "0") { total += count } } catch { diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index d9c22a1f7..90d492eec 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -7,7 +7,7 @@ private let queryExecutorLog = Logger(subsystem: "com.TablePro", category: "Quer struct QueryFetchResult { let columns: [String] let columnTypes: [ColumnType] - let rows: [[String?]] + let rows: [[PluginCellValue]] let executionTime: TimeInterval let rowsAffected: Int let statusMessage: String? diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 7a71aea86..f5ca23350 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -6,12 +6,12 @@ // import Foundation +import TableProPluginKit -/// Result of a database query execution struct QueryResult { let columns: [String] - let columnTypes: [ColumnType] // NEW: Type metadata for each column - let rows: [[String?]] + let columnTypes: [ColumnType] + let rows: [[PluginCellValue]] let rowsAffected: Int let executionTime: TimeInterval let error: DatabaseError? diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index 82b421e2c..8c07a08ad 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -6,6 +6,7 @@ import Foundation import Observation import os +import TableProPluginKit @MainActor @Observable internal final class RedisKeyTreeViewModel { @@ -46,8 +47,8 @@ internal final class RedisKeyTreeViewModel { var keys: [(key: String, type: String)] = [] for row in result.rows { guard keyColumnIndex < row.count, - let keyName = row[keyColumnIndex] else { continue } - let keyType = typeColumnIndex < row.count ? (row[typeColumnIndex] ?? "string") : "string" + let keyName = row[keyColumnIndex].asText else { continue } + let keyType = typeColumnIndex < row.count ? (row[typeColumnIndex].asText ?? "string") : "string" keys.append((key: keyName, type: keyType)) if keys.count >= Self.maxKeys { break } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 4c9cb2529..59bf49a6e 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -688,30 +688,28 @@ struct ExportDialog: View { ORDER BY 1 """ let result = try await driver.execute(query: query) - return result.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil else { return nil } - let typeStr = (row[safe: 1] ?? nil) ?? "BASE TABLE" + return result.rows.compactMap { row -> TableInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } } - // MSSQL / PostgreSQL / Redshift: use information_schema let query = """ SELECT table_schema, table_name, table_type FROM information_schema.tables ORDER BY table_name """ let result = try await driver.execute(query: query) - return result.rows.compactMap { row in - // Expect: [table_schema, table_name, table_type] + return result.rows.compactMap { row -> TableInfo? in guard row.count >= 2, - let rowSchema = row[0], + let rowSchema = row[0].asText, rowSchema == schema, - let name = row[1] else { + let name = row[1].asText else { return nil } - let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } @@ -727,15 +725,14 @@ struct ExportDialog: View { """ let result = try await driver.execute(query: query) - return result.rows.compactMap { row in - // Expect: [TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE] + return result.rows.compactMap { row -> TableInfo? in guard row.count >= 2, - let rowSchema = row[0], + let rowSchema = row[0].asText, rowSchema == database, - let name = row[1] else { + let name = row[1].asText else { return nil } - let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" + let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift index e7090514f..024d07884 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ClickHouse.swift @@ -70,7 +70,7 @@ extension MainContentCoordinator { let duration = Date().timeIntervalSince(startTime) let text = result.rows.map { row in - row.compactMap { $0 }.joined(separator: "\t") + row.compactMap { $0.asText }.joined(separator: "\t") }.joined(separator: "\n") let parser = QueryPlanParserFactory.parser(for: connection.type) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 62a736baa..0bfcbe45d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -33,7 +33,7 @@ extension MainContentCoordinator { tabId: UUID, columns: [String], columnTypes: [ColumnType], - rows: [[String?]], + rows: [[PluginCellValue]], executionTime: TimeInterval, rowsAffected: Int, statusMessage: String?, diff --git a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift index d04f287dc..d328c1ca1 100644 --- a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift +++ b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift @@ -157,10 +157,10 @@ struct ForeignKeyPopoverContentView: View { let result = try await driver.execute(query: query) var values: [FKValue] = [] for row in result.rows { - guard !row.isEmpty, let idVal = row[0] else { continue } + guard !row.isEmpty, let idVal = row[0].asText else { continue } let displayVal: String - if displayColumn != nil, row.count > 1, let second = row[1] { - displayVal = "\(idVal) — \(second)" + if displayColumn != nil, row.count > 1, let second = row[1].asText { + displayVal = "\(idVal), \(second)" } else { displayVal = idVal } diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index d0c331d41..e94697bd6 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -177,7 +177,7 @@ struct ForeignKeyPreviewView: View { let result = try await driver.execute(query: query) if let firstRow = result.rows.first { columns = result.columns - values = firstRow + values = firstRow.map { $0.asText } } } catch { Self.logger.error("FK preview query failed: \(error.localizedDescription)") diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index 1f07f944f..f4031e2d6 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -7,6 +7,7 @@ import os import SwiftUI +import TableProPluginKit struct ClickHousePartsView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePartsView") @@ -203,12 +204,12 @@ struct ClickHousePartsView: View { """ let result = try await driver.execute(query: sql) parts = result.rows.compactMap { row -> ClickHousePartInfo? in - guard let name = row[safe: 1] ?? nil else { return nil } - let partition = (row[safe: 0] ?? nil) ?? "" - let rows = (row[safe: 2] ?? nil).flatMap { UInt64($0) } ?? 0 - let bytesOnDisk = (row[safe: 3] ?? nil).flatMap { UInt64($0) } ?? 0 - let modTime = (row[safe: 4] ?? nil) ?? "" - let active = (row[safe: 5] ?? nil) == "1" + guard let name = row[safe: 1]?.asText else { return nil } + let partition = row[safe: 0]?.asText ?? "" + let rows = row[safe: 2]?.asText.flatMap { UInt64($0) } ?? 0 + let bytesOnDisk = row[safe: 3]?.asText.flatMap { UInt64($0) } ?? 0 + let modTime = row[safe: 4]?.asText ?? "" + let active = row[safe: 5]?.asText == "1" return ClickHousePartInfo( partition: partition, name: name, From 061b0e8667eab80e23a11437b089cf31ace07e03 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:08:54 +0700 Subject: [PATCH 03/11] refactor(plugins): typed cell values through editing pipeline --- .../TableProPluginKit/PluginCellValue.swift | 8 ++ .../ChangeTracking/AnyChangeManager.swift | 13 +-- .../ChangeTracking/DataChangeManager.swift | 40 ++++---- .../ChangeTracking/DataChangeModels.swift | 35 +++---- .../Core/ChangeTracking/PendingChanges.swift | 47 ++++----- .../SQLStatementGenerator.swift | 95 +++++++------------ .../Coordinators/PaginationCoordinator.swift | 2 +- .../QueryExecutionCoordinator+Helpers.swift | 3 +- ...yExecutionCoordinator+MultiStatement.swift | 6 +- ...QueryExecutionCoordinator+Parameters.swift | 2 +- .../Coordinators/RowEditingCoordinator.swift | 5 +- .../Plugins/QueryResultExportDataSource.swift | 4 +- .../StructureChangeManager.swift | 7 +- .../Formatting/BlobFormattingService.swift | 8 +- .../Formatting/CellDisplayFormatter.swift | 51 +++++----- .../Formatting/ValueDisplayDetector.swift | 9 +- .../Services/Query/RowOperationsManager.swift | 19 ++-- TablePro/Core/Services/Query/RowParser.swift | 14 +-- .../Core/Utilities/SQL/JsonRowConverter.swift | 31 +++--- TablePro/Models/Query/ParsedRow.swift | 3 +- TablePro/Models/Query/QueryTabState.swift | 3 +- TablePro/Models/Query/Row.swift | 7 +- TablePro/Models/Query/TableRows.swift | 25 ++--- .../Main/Child/MainEditorContentView.swift | 9 +- ...MainContentCoordinator+RowOperations.swift | 7 +- .../MainContentCoordinator+SidebarSave.swift | 14 ++- .../Extensions/MainContentView+Bindings.swift | 4 +- .../MainContentView+EventHandlers.swift | 18 ++-- .../Extensions/MainContentView+Helpers.swift | 3 +- .../Views/Main/MainContentCoordinator.swift | 13 ++- .../Views/Results/DataGridCellFactory.swift | 5 +- .../Views/Results/DataGridCoordinator.swift | 7 +- .../Results/DataGridView+RowActions.swift | 34 +++++-- TablePro/Views/Results/DataGridView.swift | 1 + .../Extensions/DataGridView+CellCommit.swift | 8 +- .../Extensions/DataGridView+Columns.swift | 24 +++-- .../Extensions/DataGridView+Editing.swift | 3 +- .../Extensions/DataGridView+Popovers.swift | 8 ++ TablePro/Views/Results/ResultsJsonView.swift | 5 +- .../Structure/StructureRowProvider.swift | 5 +- 40 files changed, 317 insertions(+), 288 deletions(-) diff --git a/Plugins/TableProPluginKit/PluginCellValue.swift b/Plugins/TableProPluginKit/PluginCellValue.swift index 121f3729a..cf95af992 100644 --- a/Plugins/TableProPluginKit/PluginCellValue.swift +++ b/Plugins/TableProPluginKit/PluginCellValue.swift @@ -37,6 +37,14 @@ public extension PluginCellValue { if case .bytes(let value) = self { return value } return nil } + + var asAny: Any? { + switch self { + case .null: return nil + case .text(let s): return s + case .bytes(let d): return d + } + } } extension PluginCellValue: Codable { diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 200cde5e2..67a7c12bc 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import TableProPluginKit @MainActor protocol ChangeManaging: AnyObject { @@ -13,9 +14,9 @@ protocol ChangeManaging: AnyObject { rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String?, - originalRow: [String?]? + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? ) func undoRowDeletion(rowIndex: Int) func undoRowInsertion(rowIndex: Int) @@ -40,9 +41,9 @@ final class AnyChangeManager { rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String?, - originalRow: [String?] + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue] ) { wrapped.recordCellChange( rowIndex: rowIndex, diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 28a4afa27..c880b0dc5 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -16,14 +16,14 @@ struct UndoResult { let action: UndoAction let needsRowRemoval: Bool let needsRowRestore: Bool - let restoreRow: [String?]? + let restoreRow: [PluginCellValue]? let delta: Delta init( action: UndoAction, needsRowRemoval: Bool, needsRowRestore: Bool, - restoreRow: [String?]?, + restoreRow: [PluginCellValue]?, delta: Delta = .none ) { self.action = action @@ -114,9 +114,9 @@ final class DataChangeManager: ChangeManaging { rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String?, - originalRow: [String?]? = nil + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? = nil ) { let recorded = pending.recordCellChange( rowIndex: rowIndex, @@ -141,7 +141,7 @@ final class DataChangeManager: ChangeManaging { reloadVersion += 1 } - func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { + func recordRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { pending.recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) registerUndo(actionName: String(localized: "Delete Row")) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) @@ -150,7 +150,7 @@ final class DataChangeManager: ChangeManaging { reloadVersion += 1 } - func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) { + func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [PluginCellValue])]) { guard rows.count > 1 else { if let row = rows.first { recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) @@ -168,7 +168,7 @@ final class DataChangeManager: ChangeManaging { reloadVersion += 1 } - func recordRowInsertion(rowIndex: Int, values: [String?]) { + func recordRowInsertion(rowIndex: Int, values: [PluginCellValue]) { pending.recordRowInsertion(rowIndex: rowIndex, values: values) registerUndo(actionName: String(localized: "Insert Row")) { target in target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) @@ -236,7 +236,7 @@ final class DataChangeManager: ChangeManaging { private func applyCellEditUndo( rowIndex: Int, columnIndex: Int, columnName: String, - previousValue: String?, newValue: String?, originalRow: [String?]?, + previousValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, action: UndoAction ) { registerUndo(actionName: String(localized: "Edit Cell")) { target in @@ -294,7 +294,7 @@ final class DataChangeManager: ChangeManaging { } } - private func applyRowDeletionUndo(rowIndex: Int, originalRow: [String?], action: UndoAction) { + private func applyRowDeletionUndo(rowIndex: Int, originalRow: [PluginCellValue], action: UndoAction) { registerUndo(actionName: String(localized: "Delete Row")) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } @@ -315,7 +315,7 @@ final class DataChangeManager: ChangeManaging { } private func applyBatchRowDeletionUndo( - rows: [(rowIndex: Int, originalRow: [String?])], action: UndoAction + rows: [(rowIndex: Int, originalRow: [PluginCellValue])], action: UndoAction ) { registerUndo(actionName: String(localized: "Delete Rows")) { target in target.applyDataUndo(.batchRowDeletion(rows: rows)) @@ -342,7 +342,7 @@ final class DataChangeManager: ChangeManaging { } private func applyBatchRowInsertionUndo( - rowIndices: [Int], rowValues: [[String?]], action: UndoAction + rowIndices: [Int], rowValues: [[PluginCellValue]], action: UndoAction ) { registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) @@ -378,7 +378,7 @@ final class DataChangeManager: ChangeManaging { func generateSQL( for changes: [RowChange], - insertedRowData: [Int: [String?]] = [:], + insertedRowData: [Int: [PluginCellValue]] = [:], deletedRowIndices: Set = [], insertedRowIndices: Set = [] ) throws -> [ParameterizedStatement] { @@ -394,16 +394,12 @@ final class DataChangeManager: ChangeManaging { } }(), cellChanges: change.cellChanges.map { c -> (columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue) in - (c.columnIndex, c.columnName, PluginCellValue.fromOptional(c.oldValue), PluginCellValue.fromOptional(c.newValue)) + (c.columnIndex, c.columnName, c.oldValue, c.newValue) }, - originalRow: change.originalRow.map { row in - row.map(PluginCellValue.fromOptional) - } + originalRow: change.originalRow ) } - let pluginInsertedRowData: [Int: [PluginCellValue]] = insertedRowData.mapValues { row in - row.map(PluginCellValue.fromOptional) - } + let pluginInsertedRowData: [Int: [PluginCellValue]] = insertedRowData if let statements = pluginDriver.generateStatements( table: tableName, columns: columns, @@ -463,8 +459,8 @@ final class DataChangeManager: ChangeManaging { // MARK: - Actions - func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: String?)] { - var originals: [(rowIndex: Int, columnIndex: Int, value: String?)] = [] + func getOriginalValues() -> [(rowIndex: Int, columnIndex: Int, value: PluginCellValue)] { + var originals: [(rowIndex: Int, columnIndex: Int, value: PluginCellValue)] = [] for change in pending.changes where change.type == .update { for cellChange in change.cellChanges { originals.append(( diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index 2438b2781..6f3ef9bd7 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -2,34 +2,30 @@ // DataChangeModels.swift // TablePro // -// Pure data models for tracking data changes. -// No business logic - just structures for representing change state. -// import Foundation +import TableProPluginKit -/// Represents a type of data change enum ChangeType: Hashable { case update case insert case delete } -/// Represents a single cell change struct CellChange: Identifiable, Equatable { let id: UUID let rowIndex: Int let columnIndex: Int let columnName: String - let oldValue: String? - let newValue: String? + let oldValue: PluginCellValue + let newValue: PluginCellValue init( rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String? + oldValue: PluginCellValue, + newValue: PluginCellValue ) { self.id = UUID() self.rowIndex = rowIndex @@ -40,19 +36,18 @@ struct CellChange: Identifiable, Equatable { } } -/// Represents a row-level change struct RowChange: Identifiable, Equatable { let id: UUID var rowIndex: Int let type: ChangeType var cellChanges: [CellChange] - let originalRow: [String?]? + let originalRow: [PluginCellValue]? init( rowIndex: Int, type: ChangeType, cellChanges: [CellChange] = [], - originalRow: [String?]? = nil + originalRow: [PluginCellValue]? = nil ) { self.id = UUID() self.rowIndex = rowIndex @@ -62,28 +57,24 @@ struct RowChange: Identifiable, Equatable { } } -/// Composite key for O(1) lookup of RowChange by (rowIndex, type) struct RowChangeKey: Hashable { let rowIndex: Int let type: ChangeType } -/// Represents an action that can be undone enum UndoAction { case cellEdit( rowIndex: Int, columnIndex: Int, columnName: String, - previousValue: String?, - newValue: String?, - originalRow: [String?]? + previousValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? ) case rowInsertion(rowIndex: Int) - case rowDeletion(rowIndex: Int, originalRow: [String?]) - /// Batch deletion of multiple rows (for undo as a single action) - case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) - /// Batch insertion undo - when user deletes multiple inserted rows at once - case batchRowInsertion(rowIndices: [Int], rowValues: [[String?]]) + case rowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) + case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [PluginCellValue])]) + case batchRowInsertion(rowIndices: [Int], rowValues: [[PluginCellValue]]) } // Note: TabChangeSnapshot is defined in QueryTab.swift diff --git a/TablePro/Core/ChangeTracking/PendingChanges.swift b/TablePro/Core/ChangeTracking/PendingChanges.swift index e2371622e..dd6174e98 100644 --- a/TablePro/Core/ChangeTracking/PendingChanges.swift +++ b/TablePro/Core/ChangeTracking/PendingChanges.swift @@ -10,13 +10,14 @@ // import Foundation +import TableProPluginKit struct PendingChanges: Equatable { private(set) var changes: [RowChange] = [] private(set) var deletedRowIndices: Set = [] private(set) var insertedRowIndices: Set = [] private(set) var modifiedCells: [Int: Set] = [:] - private(set) var insertedRowData: [Int: [String?]] = [:] + private(set) var insertedRowData: [Int: [PluginCellValue]] = [:] private var changeIndex: [RowChangeKey: Int] = [:] @@ -55,9 +56,9 @@ struct PendingChanges: Equatable { rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String?, - originalRow: [String?]? = nil + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? = nil ) -> Bool { if oldValue == newValue { return rollbackCellIfMatchesOriginal( @@ -94,7 +95,7 @@ struct PendingChanges: Equatable { return true } - mutating func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { + mutating func recordRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { guard !deletedRowIndices.contains(rowIndex) else { return } removeChange(rowIndex: rowIndex, type: .update) modifiedCells.removeValue(forKey: rowIndex) @@ -102,7 +103,7 @@ struct PendingChanges: Equatable { deletedRowIndices.insert(rowIndex) } - mutating func recordRowInsertion(rowIndex: Int, values: [String?]) { + mutating func recordRowInsertion(rowIndex: Int, values: [PluginCellValue]) { guard !insertedRowIndices.contains(rowIndex) else { insertedRowData[rowIndex] = values return @@ -133,10 +134,10 @@ struct PendingChanges: Equatable { } /// Undo a batch of inserted rows. Returns the saved values for each row in the same order. - mutating func undoBatchRowInsertion(rowIndices: [Int], columnCount: Int) -> [[String?]] { + mutating func undoBatchRowInsertion(rowIndices: [Int], columnCount: Int) -> [[PluginCellValue]] { let validRows = rowIndices.filter { insertedRowIndices.contains($0) } - var rowValues: [[String?]] = [] + var rowValues: [[PluginCellValue]] = [] for rowIndex in validRows { if let idx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] { let values = changes[idx].cellChanges @@ -144,7 +145,7 @@ struct PendingChanges: Equatable { .map { $0.newValue } rowValues.append(values) } else { - rowValues.append(Array(repeating: nil, count: columnCount)) + rowValues.append(Array(repeating: .null, count: columnCount)) } } @@ -174,7 +175,7 @@ struct PendingChanges: Equatable { // MARK: - Replay (driven by NSUndoManager invocation) /// Re-apply a deletion during undo replay (skips undo registration). - mutating func reapplyRowDeletion(rowIndex: Int, originalRow: [String?]) { + mutating func reapplyRowDeletion(rowIndex: Int, originalRow: [PluginCellValue]) { guard !deletedRowIndices.contains(rowIndex) else { return } removeChange(rowIndex: rowIndex, type: .update) modifiedCells.removeValue(forKey: rowIndex) @@ -189,9 +190,9 @@ struct PendingChanges: Equatable { rowIndex: Int, columnIndex: Int, columnName: String, - originalDBValue: String?, - newValue: String?, - originalRow: [String?]? + originalDBValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? ) { let cellChange = CellChange( rowIndex: rowIndex, @@ -226,7 +227,7 @@ struct PendingChanges: Equatable { rowIndex: Int, columnIndex: Int, columnName: String, - newValue: String? + newValue: PluginCellValue ) { guard let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] else { return } updateInsertedCell(at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue) @@ -237,7 +238,7 @@ struct PendingChanges: Equatable { rowIndex: Int, columnIndex: Int, columnName: String, - previousValue: String? + previousValue: PluginCellValue ) { guard let updateIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)], let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) @@ -265,7 +266,7 @@ struct PendingChanges: Equatable { } /// Insert a synthetic .insert RowChange for undo replay (e.g., after redoing a deletion's undo). - mutating func reinsertRow(rowIndex: Int, columns: [String], savedValues: [String?]?) { + mutating func reinsertRow(rowIndex: Int, columns: [String], savedValues: [PluginCellValue]?) { shiftRowIndicesUp(from: rowIndex) insertedRowIndices.insert(rowIndex) let cellChanges = columns.enumerated().map { index, columnName in @@ -282,7 +283,7 @@ struct PendingChanges: Equatable { /// Insert a batch of rows (for undo replay of a batch deletion's undo). mutating func reinsertBatch( - rowIndices: [Int], rowValues: [[String?]], columns: [String] + rowIndices: [Int], rowValues: [[PluginCellValue]], columns: [String] ) { for rowIndex in rowIndices.sorted() { shiftRowIndicesUp(from: rowIndex) @@ -305,12 +306,12 @@ struct PendingChanges: Equatable { } /// Save inserted-row values for a redo replay closure that may need them. - func savedInsertedValues(forRow rowIndex: Int) -> [String?]? { + func savedInsertedValues(forRow rowIndex: Int) -> [PluginCellValue]? { insertedRowData[rowIndex] } /// Restore inserted-row values when undo restores a row. - mutating func restoreInsertedValues(forRow rowIndex: Int, values: [String?]) { + mutating func restoreInsertedValues(forRow rowIndex: Int, values: [PluginCellValue]) { insertedRowData[rowIndex] = values } @@ -382,7 +383,7 @@ struct PendingChanges: Equatable { } private mutating func updateInsertedCell( - at insertIdx: Int, columnIndex: Int, columnName: String, newValue: String? + at insertIdx: Int, columnIndex: Int, columnName: String, newValue: PluginCellValue ) { let rowIndex = changes[insertIdx].rowIndex if var stored = insertedRowData[rowIndex], columnIndex < stored.count { @@ -434,7 +435,7 @@ struct PendingChanges: Equatable { @discardableResult private mutating func rollbackCellIfMatchesOriginal( - rowIndex: Int, columnIndex: Int, restoredValue: String? + rowIndex: Int, columnIndex: Int, restoredValue: PluginCellValue ) -> Bool { let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) guard let updateIdx = changeIndex[updateKey], @@ -460,7 +461,7 @@ struct PendingChanges: Equatable { insertedRowIndices = Set(insertedRowIndices.map { $0 >= insertionPoint ? $0 + 1 : $0 }) deletedRowIndices = Set(deletedRowIndices.map { $0 >= insertionPoint ? $0 + 1 : $0 }) - var newInsertedRowData: [Int: [String?]] = [:] + var newInsertedRowData: [Int: [PluginCellValue]] = [:] for (key, value) in insertedRowData { newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value } @@ -481,7 +482,7 @@ struct PendingChanges: Equatable { } insertedRowIndices = Set(insertedRowIndices.map { $0 > removedRow ? $0 - 1 : $0 }) - var newInsertedRowData: [Int: [String?]] = [:] + var newInsertedRowData: [Int: [PluginCellValue]] = [:] for (key, value) in insertedRowData { newInsertedRowData[key > removedRow ? key - 1 : key] = value } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 76bc6ecd8..8c7964424 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -64,7 +64,7 @@ struct SQLStatementGenerator { /// - Returns: Array of parameterized SQL statements func generateStatements( from changes: [RowChange], - insertedRowData: [Int: [String?]], + insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set ) -> [ParameterizedStatement] { @@ -134,20 +134,16 @@ struct SQLStatementGenerator { // MARK: - INSERT Generation - private func generateInsertSQL(for change: RowChange, insertedRowData: [Int: [String?]]) + private func generateInsertSQL(for change: RowChange, insertedRowData: [Int: [PluginCellValue]]) -> ParameterizedStatement? { - // OPTIMIZATION: Get values from lazy storage instead of cellChanges if let values = insertedRowData[change.rowIndex] { return generateInsertSQLFromStoredData(rowIndex: change.rowIndex, values: values) } - - // Fallback: use cellChanges if stored data not available (backward compatibility) return generateInsertSQLFromCellChanges(for: change) } - /// Generate INSERT SQL from lazy-stored row data (optimized path) - private func generateInsertSQLFromStoredData(rowIndex: Int, values: [String?]) + private func generateInsertSQLFromStoredData(rowIndex: Int, values: [PluginCellValue]) -> ParameterizedStatement? { var nonDefaultColumns: [String] = [] @@ -155,22 +151,18 @@ struct SQLStatementGenerator { var bindParameters: [Any?] = [] for (index, value) in values.enumerated() { - if value == "__DEFAULT__" { continue } + if case .text(let s) = value, s == "__DEFAULT__" { continue } guard index < columns.count else { continue } let columnName = columns[index] nonDefaultColumns.append(quoteIdentifierFn(columnName)) - if let val = value { - if isSQLFunctionExpression(val) { - placeholderParts.append(val.trimmingCharacters(in: .whitespaces).uppercased()) - } else { - bindParameters.append(val) - placeholderParts.append(placeholder(at: bindParameters.count - 1)) - } - } else { - bindParameters.append(nil) + switch value { + case .text(let s) where isSQLFunctionExpression(s): + placeholderParts.append(s.trimmingCharacters(in: .whitespaces).uppercased()) + default: + bindParameters.append(value.asAny) placeholderParts.append(placeholder(at: bindParameters.count - 1)) } } @@ -186,17 +178,12 @@ struct SQLStatementGenerator { return ParameterizedStatement(sql: sql, parameters: bindParameters) } - /// Generate INSERT SQL from cellChanges (fallback for backward compatibility) private func generateInsertSQLFromCellChanges(for change: RowChange) -> ParameterizedStatement? { guard !change.cellChanges.isEmpty else { return nil } - // Filter out DEFAULT columns - let DB handle them - let nonDefaultChanges = change.cellChanges.filter { - $0.newValue != "__DEFAULT__" - } + let nonDefaultChanges = change.cellChanges.filter { $0.newValue != .text("__DEFAULT__") } - // If all columns are DEFAULT, don't generate INSERT guard !nonDefaultChanges.isEmpty else { return nil } let columnNames = nonDefaultChanges.map { @@ -205,16 +192,13 @@ struct SQLStatementGenerator { var parameters: [Any?] = [] let placeholders = nonDefaultChanges.map { cellChange -> String in - if let newValue = cellChange.newValue { - if isSQLFunctionExpression(newValue) { - // SQL function - cannot parameterize, use literal - return newValue.trimmingCharacters(in: .whitespaces).uppercased() - } - parameters.append(newValue) + switch cellChange.newValue { + case .text(let s) where isSQLFunctionExpression(s): + return s.trimmingCharacters(in: .whitespaces).uppercased() + default: + parameters.append(cellChange.newValue.asAny) return placeholder(at: parameters.count - 1) } - parameters.append(nil) - return placeholder(at: parameters.count - 1) }.joined(separator: ", ") let sql = @@ -231,27 +215,19 @@ struct SQLStatementGenerator { // MARK: - UPDATE Generation - /// Generate individual UPDATE statement for a single row using parameterized query func generateUpdateSQL(for change: RowChange) -> ParameterizedStatement? { guard !change.cellChanges.isEmpty else { return nil } var parameters: [Any?] = [] let setClauses = change.cellChanges.map { cellChange -> String in - if cellChange.newValue == "__DEFAULT__" { + switch cellChange.newValue { + case .text(let s) where s == "__DEFAULT__": return "\(quoteIdentifierFn(cellChange.columnName)) = DEFAULT" - } else if let newValue = cellChange.newValue { - if isSQLFunctionExpression(newValue) { - return - "\(quoteIdentifierFn(cellChange.columnName)) = \(newValue.trimmingCharacters(in: .whitespaces).uppercased())" - } else { - parameters.append(newValue) - return - "\(quoteIdentifierFn(cellChange.columnName)) = \(placeholder(at: parameters.count - 1))" - } - } else { - parameters.append(nil) - return - "\(quoteIdentifierFn(cellChange.columnName)) = \(placeholder(at: parameters.count - 1))" + case .text(let s) where isSQLFunctionExpression(s): + return "\(quoteIdentifierFn(cellChange.columnName)) = \(s.trimmingCharacters(in: .whitespaces).uppercased())" + default: + parameters.append(cellChange.newValue.asAny) + return "\(quoteIdentifierFn(cellChange.columnName)) = \(placeholder(at: parameters.count - 1))" } }.joined(separator: ", ") @@ -261,21 +237,21 @@ struct SQLStatementGenerator { for pkColumn in primaryKeyColumns { guard let pkColumnIndex = columns.firstIndex(of: pkColumn) else { return nil } - var pkValue: Any? + var pkValue: PluginCellValue? if let originalRow = change.originalRow, pkColumnIndex < originalRow.count { pkValue = originalRow[pkColumnIndex] } else if let pkChange = change.cellChanges.first(where: { $0.columnName == pkColumn }) { pkValue = pkChange.oldValue } - guard pkValue != nil else { + guard let pkValue, !pkValue.isNull else { Self.logger.warning( "Skipping UPDATE for table '\(self.tableName)' - cannot determine value for PK column '\(pkColumn)'" ) return nil } - parameters.append(pkValue) + parameters.append(pkValue.asAny) conditions.append( "\(quoteIdentifierFn(pkColumn)) = \(placeholder(at: parameters.count - 1))" ) @@ -300,11 +276,11 @@ struct SQLStatementGenerator { guard index < originalRow.count else { continue } let value = originalRow[index] let quotedColumn = quoteIdentifierFn(columnName) - if let value = value { - parameters.append(value) - conditions.append("\(quotedColumn) = \(placeholder(at: parameters.count - 1))") - } else { + if value.isNull { conditions.append("\(quotedColumn) IS NULL") + } else { + parameters.append(value.asAny) + conditions.append("\(quotedColumn) = \(placeholder(at: parameters.count - 1))") } } @@ -339,12 +315,11 @@ struct SQLStatementGenerator { var pkConditions: [String] = [] for pk in pkIndices { guard pk.index < originalRow.count else { return nil } - parameters.append(originalRow[pk.index]) + parameters.append(originalRow[pk.index].asAny) pkConditions.append( "\(quoteIdentifierFn(pk.column)) = \(placeholder(at: parameters.count - 1))" ) } - // Single PK: "id = $1", composite: "(order_id = $1 AND product_id = $2)" return pkIndices.count > 1 ? "(\(pkConditions.joined(separator: " AND ")))" : pkConditions.joined() @@ -362,11 +337,9 @@ struct SQLStatementGenerator { return nil } - /// Generate individual DELETE statement for a single row (used when no PK or as fallback) private func generateDeleteSQL(for change: RowChange) -> ParameterizedStatement? { guard let originalRow = change.originalRow else { return nil } - // Build WHERE clause matching ALL columns to uniquely identify the row var parameters: [Any?] = [] var conditions: [String] = [] @@ -376,11 +349,11 @@ struct SQLStatementGenerator { let value = originalRow[index] let quotedColumn = quoteIdentifierFn(columnName) - if let value = value { - parameters.append(value) - conditions.append("\(quotedColumn) = \(placeholder(at: parameters.count - 1))") - } else { + if value.isNull { conditions.append("\(quotedColumn) IS NULL") + } else { + parameters.append(value.asAny) + conditions.append("\(quotedColumn) = \(placeholder(at: parameters.count - 1))") } } diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 545a99d75..90de70f4b 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -191,7 +191,7 @@ final class PaginationCoordinator { } let replaceDelta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.replace(rows: result.rows.map { row in row.map { $0.asText } }) + rows.replace(rows: result.rows) } parent.tabManager.mutate(at: idx) { tab in tab.execution.executionTime = result.executionTime diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 1b66e593e..7c9c76a72 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -100,9 +100,8 @@ extension QueryExecutionCoordinator { } } - let stringRows = rows.map { row in row.map { $0.asText } } let newTableRows = TableRows.from( - queryRows: stringRows, + queryRows: rows, columns: columns, columnTypes: columnTypes, columnDefaults: columnDefaults, diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index c823b92e3..923726bf7 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -88,7 +88,7 @@ extension QueryExecutionCoordinator { let stmtTableName = await MainActor.run { parent.extractTableName(from: sql) } let stmtRows = TableRows.from( - queryRows: result.rows.map { row in row.map { $0.asText } }, + queryRows: result.rows, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) @@ -218,9 +218,7 @@ extension QueryExecutionCoordinator { if let selectResult = lastSelectResult { let safeColumns = selectResult.columns.map { String($0) } let safeColumnTypes = selectResult.columnTypes - let safeRows = selectResult.rows.map { row in - row.map { $0.asText } - } + let safeRows = selectResult.rows if currentTab.tabType == .table, let existing = currentTab.tableContext.tableName { resolvedTableName = existing } else { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index db6bf332f..648aaa97c 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -279,7 +279,7 @@ extension QueryExecutionCoordinator { let stmtTableName = await MainActor.run { parent.extractTableName(from: stmtSQL) } let stmtRows = TableRows.from( - queryRows: result.rows.map { row in row.map { $0.asText } }, + queryRows: result.rows, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 9ffa9b5fc..31f901e7a 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @MainActor @Observable final class RowEditingCoordinator { @@ -185,7 +186,7 @@ final class RowEditingCoordinator { func copySelectedRowsAsJson(indices: Set) { guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) - let rows = indices.sorted().compactMap { idx -> [String?]? in + let rows = indices.sorted().compactMap { idx -> [PluginCellValue]? in guard idx >= 0, idx < tableRows.count else { return nil } return Array(tableRows.rows[idx].values) } @@ -229,7 +230,7 @@ final class RowEditingCoordinator { parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta) } - func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { + func updateCellInTab(rowIndex: Int, columnIndex: Int, value: PluginCellValue) { guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } let tabId = tab.id let delta = parent.mutateActiveTableRows(for: tabId) { rows in diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index 45c7e247b..ca3c22387 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -22,9 +22,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send self.driver = driver self.columns = tableRows.columns self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" } - self.rows = tableRows.rows.map { row in - Array(row.values).map(PluginCellValue.fromOptional) - } + self.rows = tableRows.rows.map { row in Array(row.values) } } func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 48959e163..87930966e 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -8,6 +8,7 @@ import Foundation import Observation +import TableProPluginKit /// Manager for tracking and applying schema changes @MainActor @Observable @@ -822,9 +823,9 @@ final class StructureChangeManager: ChangeManaging { rowIndex: Int, columnIndex: Int, columnName: String, - oldValue: String?, - newValue: String?, - originalRow: [String?]? + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? ) {} func undoRowDeletion(rowIndex: Int) {} diff --git a/TablePro/Core/Services/Formatting/BlobFormattingService.swift b/TablePro/Core/Services/Formatting/BlobFormattingService.swift index 5c5651390..282aa4ee0 100644 --- a/TablePro/Core/Services/Formatting/BlobFormattingService.swift +++ b/TablePro/Core/Services/Formatting/BlobFormattingService.swift @@ -6,8 +6,8 @@ // import Foundation +import TableProPluginKit -/// Display context for BLOB formatting. enum BlobDisplayContext { /// Data grid cell: compact single-line "0x48656C6C6F..." case grid @@ -25,7 +25,6 @@ final class BlobFormattingService { private init() {} - /// Format a raw BLOB string value for the given display context. func format(_ value: String, for context: BlobDisplayContext) -> String? { switch context { case .grid, .copy: @@ -37,6 +36,11 @@ final class BlobFormattingService { } } + func format(_ data: Data, for context: BlobDisplayContext) -> String? { + let value = String(data: data, encoding: .isoLatin1) ?? "" + return format(value, for: context) + } + /// Parse an edited hex string back to a raw binary string. /// Accepts space-separated hex bytes (e.g., "48 65 6C 6C 6F") or continuous hex (e.g., "48656C6C6F"). /// Returns nil if the hex string is invalid. diff --git a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift index 2f8f05856..2b2622378 100644 --- a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift +++ b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift @@ -7,38 +7,39 @@ // import Foundation +import TableProPluginKit @MainActor enum CellDisplayFormatter { static let maxDisplayLength = 10_000 - static func format(_ rawValue: String?, columnType: ColumnType?, displayFormat: ValueDisplayFormat? = nil) -> String? { - guard let value = rawValue, !value.isEmpty else { return rawValue } - - var displayValue = value - - // Apply explicit display format when set (non-raw) - if let displayFormat, displayFormat != .raw { - displayValue = ValueDisplayFormatService.applyFormat(value, format: displayFormat) - } else if let columnType { - if columnType.isDateType { - if let formatted = DateFormattingService.shared.format(dateString: displayValue) { - displayValue = formatted + static func format(_ rawValue: PluginCellValue, columnType: ColumnType?, displayFormat: ValueDisplayFormat? = nil) -> String? { + switch rawValue { + case .null: + return nil + case .bytes(let data): + return BlobFormattingService.shared.format(data, for: .grid) + case .text(let value): + guard !value.isEmpty else { return value } + var displayValue = value + if let displayFormat, displayFormat != .raw { + displayValue = ValueDisplayFormatService.applyFormat(value, format: displayFormat) + } else if let columnType { + if columnType.isDateType { + if let formatted = DateFormattingService.shared.format(dateString: displayValue) { + displayValue = formatted + } + } else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) { + displayValue = BlobFormattingService.shared.formatIfNeeded( + displayValue, columnType: columnType, for: .grid + ) } - } else if BlobFormattingService.shared.requiresFormatting(columnType: columnType) { - displayValue = BlobFormattingService.shared.formatIfNeeded( - displayValue, columnType: columnType, for: .grid - ) } + let nsDisplay = displayValue as NSString + if nsDisplay.length > maxDisplayLength { + displayValue = nsDisplay.substring(to: maxDisplayLength) + "..." + } + return displayValue.sanitizedForCellDisplay } - - let nsDisplay = displayValue as NSString - if nsDisplay.length > maxDisplayLength { - displayValue = nsDisplay.substring(to: maxDisplayLength) + "..." - } - - displayValue = displayValue.sanitizedForCellDisplay - - return displayValue } } diff --git a/TablePro/Core/Services/Formatting/ValueDisplayDetector.swift b/TablePro/Core/Services/Formatting/ValueDisplayDetector.swift index 08c8597c7..23298b223 100644 --- a/TablePro/Core/Services/Formatting/ValueDisplayDetector.swift +++ b/TablePro/Core/Services/Formatting/ValueDisplayDetector.swift @@ -8,15 +8,14 @@ // import Foundation +import TableProPluginKit @MainActor enum ValueDisplayDetector { - /// Detect display formats for each column based on type, name, and sample values. - /// Returns an array parallel to `columns` where nil means no format detected (.raw). static func detect( columns: [String], columnTypes: [ColumnType], - sampleValues: [[String?]]? + sampleValues: [[PluginCellValue]]? ) -> [ValueDisplayFormat?] { var results = [ValueDisplayFormat?](repeating: nil, count: columns.count) @@ -109,10 +108,10 @@ enum ValueDisplayDetector { // MARK: - Helpers - private static func firstNonNilSample(at columnIndex: Int, from sampleValues: [[String?]]?) -> String? { + private static func firstNonNilSample(at columnIndex: Int, from sampleValues: [[PluginCellValue]]?) -> String? { guard let samples = sampleValues else { return nil } for row in samples { - if columnIndex < row.count, let value = row[columnIndex], !value.isEmpty { + if columnIndex < row.count, let value = row[columnIndex].asText, !value.isEmpty { return value } } diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 2f62cb420..fdae80bf4 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import os +import TableProPluginKit @MainActor final class RowOperationsManager { @@ -10,7 +11,7 @@ final class RowOperationsManager { struct AddNewRowResult { let rowIndex: Int - let values: [String?] + let values: [PluginCellValue] let delta: Delta } @@ -22,7 +23,7 @@ final class RowOperationsManager { struct PastedRowInfo { let rowIndex: Int - let values: [String?] + let values: [PluginCellValue] } struct PasteRowsResult { @@ -51,12 +52,12 @@ final class RowOperationsManager { columnDefaults: [String: String?], tableRows: inout TableRows ) -> AddNewRowResult? { - var newRowValues: [String?] = [] + var newRowValues: [PluginCellValue] = [] for column in columns { if let defaultValue = columnDefaults[column], defaultValue != nil { - newRowValues.append("__DEFAULT__") + newRowValues.append(.text("__DEFAULT__")) } else { - newRowValues.append(nil) + newRowValues.append(.null) } } @@ -79,7 +80,7 @@ final class RowOperationsManager { for pkColumn in changeManager.primaryKeyColumns { if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count { - newValues[pkIndex] = "__DEFAULT__" + newValues[pkIndex] = .text("__DEFAULT__") } } @@ -100,7 +101,7 @@ final class RowOperationsManager { } var insertedRowsToDelete: [Int] = [] - var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] + var existingRowsToDelete: [(rowIndex: Int, originalRow: [PluginCellValue])] = [] let minSelectedRow = selectedIndices.min() ?? 0 let maxSelectedRow = selectedIndices.max() ?? 0 @@ -165,7 +166,7 @@ final class RowOperationsManager { return UndoApplicationResult(adjustedSelection: Set(), delta: delta) } else if result.needsRowRestore { let columnCount = tableRows.columns.count - let values = result.restoreRow ?? [String?](repeating: nil, count: columnCount) + let values = result.restoreRow ?? [PluginCellValue](repeating: .null, count: columnCount) let delta = tableRows.insertInsertedRow(at: rowIndex, values: values) return UndoApplicationResult(adjustedSelection: nil, delta: delta) } @@ -263,7 +264,7 @@ final class RowOperationsManager { if !result.isEmpty { result.append("\n") } for (colIdx, value) in tableRows.rows[rowIndex].values.enumerated() { if colIdx > 0 { result.append("\t") } - result.append(value ?? "NULL") + result.append(value.asText ?? "NULL") } } diff --git a/TablePro/Core/Services/Query/RowParser.swift b/TablePro/Core/Services/Query/RowParser.swift index e03f84a96..2ef6bcfcb 100644 --- a/TablePro/Core/Services/Query/RowParser.swift +++ b/TablePro/Core/Services/Query/RowParser.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit /// Protocol for parsing row data from text protocol RowDataParser { @@ -56,12 +57,12 @@ struct TSVRowParser: RowDataParser { values = Array(values.prefix(schema.columnCount)) } - // Set primary key to __DEFAULT__ (let DB auto-generate) if let pkIndex = schema.primaryKeyIndex, pkIndex < values.count { values[pkIndex] = "__DEFAULT__" } - let parsedRow = ParsedRow(values: values, sourceLineNumber: lineNumber) + let typedValues = values.map(PluginCellValue.fromOptional) + let parsedRow = ParsedRow(values: typedValues, sourceLineNumber: lineNumber) parsedRows.append(parsedRow) } @@ -72,11 +73,6 @@ struct TSVRowParser: RowDataParser { return .success(parsedRows) } - // MARK: - Private Helpers - - /// Normalize a single value from clipboard - /// - Parameter rawValue: Raw string value - /// - Returns: Normalized value (nil for NULL, trimmed string otherwise) private func normalizeValue(_ rawValue: String) -> String? { let trimmed = rawValue.trimmingCharacters(in: .whitespaces) @@ -132,12 +128,12 @@ struct CSVRowParser: RowDataParser { values = Array(values.prefix(schema.columnCount)) } - // Set primary key to __DEFAULT__ (let DB auto-generate) if let pkIndex = schema.primaryKeyIndex, pkIndex < values.count { values[pkIndex] = "__DEFAULT__" } - parsedRows.append(ParsedRow(values: values, sourceLineNumber: lineNumber)) + let typedValues = values.map(PluginCellValue.fromOptional) + parsedRows.append(ParsedRow(values: typedValues, sourceLineNumber: lineNumber)) } guard !parsedRows.isEmpty else { diff --git a/TablePro/Core/Utilities/SQL/JsonRowConverter.swift b/TablePro/Core/Utilities/SQL/JsonRowConverter.swift index e1657454f..87328737f 100644 --- a/TablePro/Core/Utilities/SQL/JsonRowConverter.swift +++ b/TablePro/Core/Utilities/SQL/JsonRowConverter.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit internal struct JsonRowConverter { internal let columns: [String] @@ -11,7 +12,7 @@ internal struct JsonRowConverter { private static let maxRows = 50_000 - func generateJson(rows: [[String?]]) -> String { + func generateJson(rows: [[PluginCellValue]]) -> String { let cappedRows = rows.prefix(Self.maxRows) let rowCount = cappedRows.count @@ -19,7 +20,6 @@ internal struct JsonRowConverter { return "[]" } - // Estimate capacity: ~100 bytes per cell as rough heuristic var result = String() result.reserveCapacity(rowCount * columns.count * 100) @@ -33,12 +33,25 @@ internal struct JsonRowConverter { result.append(escapeString(column)) result.append("\": ") - guard row.indices.contains(colIdx), let value = row[colIdx] else { + guard row.indices.contains(colIdx) else { result.append("null") appendPropertySuffix(to: &result, colIdx: colIdx) continue } + let cell = row[colIdx] + if cell.isNull { + result.append("null") + appendPropertySuffix(to: &result, colIdx: colIdx) + continue + } + if case .bytes(let data) = cell { + result.append("\"\(data.base64EncodedString())\"") + appendPropertySuffix(to: &result, colIdx: colIdx) + continue + } + let value = cell.asText ?? "" + let colType: ColumnType if columnTypes.indices.contains(colIdx) { colType = columnTypes[colIdx] @@ -78,9 +91,7 @@ internal struct JsonRowConverter { return formatBoolean(value) case .json: return formatJson(value) - case .blob: - return formatBlob(value) - case .text, .date, .timestamp, .datetime, .enumType, .set, .spatial: + case .blob, .text, .date, .timestamp, .datetime, .enumType, .set, .spatial: return quotedEscaped(value) } } @@ -181,14 +192,6 @@ internal struct JsonRowConverter { } } - private func formatBlob(_ value: String) -> String { - guard let data = value.data(using: .utf8) else { - return quotedEscaped(value) - } - let encoded = data.base64EncodedString() - return "\"\(encoded)\"" - } - private func quotedEscaped(_ value: String) -> String { "\"\(escapeString(value))\"" } diff --git a/TablePro/Models/Query/ParsedRow.swift b/TablePro/Models/Query/ParsedRow.swift index 09db5dbc9..1efa6db96 100644 --- a/TablePro/Models/Query/ParsedRow.swift +++ b/TablePro/Models/Query/ParsedRow.swift @@ -6,11 +6,12 @@ // import Foundation +import TableProPluginKit /// Represents a single parsed row ready for insertion struct ParsedRow { /// Column values (nil represents NULL) - let values: [String?] + let values: [PluginCellValue] /// Original line number in clipboard (for error reporting) let sourceLineNumber: Int diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index bb8a1a447..53b56f528 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @MainActor @Observable final class GridSelectionState { @@ -40,7 +41,7 @@ struct TabChangeSnapshot: Equatable { var deletedRowIndices: Set var insertedRowIndices: Set var modifiedCells: [Int: Set] - var insertedRowData: [Int: [String?]] // Lazy storage for inserted row values + var insertedRowData: [Int: [PluginCellValue]] var primaryKeyColumns: [String] var columns: [String] diff --git a/TablePro/Models/Query/Row.swift b/TablePro/Models/Query/Row.swift index 86b50df40..92d42f207 100644 --- a/TablePro/Models/Query/Row.swift +++ b/TablePro/Models/Query/Row.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum RowID: Hashable, Sendable { case existing(Int) @@ -17,10 +18,10 @@ enum RowID: Hashable, Sendable { struct Row: Equatable, Sendable { var id: RowID - var values: ContiguousArray + var values: ContiguousArray - subscript(column: Int) -> String? { - get { column >= 0 && column < values.count ? values[column] : nil } + subscript(column: Int) -> PluginCellValue { + get { column >= 0 && column < values.count ? values[column] : .null } set { guard column >= 0, column < values.count else { return } values[column] = newValue diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 2d918993d..8ba906680 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit struct TableRows: Sendable { var rows: ContiguousArray @@ -36,8 +37,8 @@ struct TableRows: Sendable { var count: Int { rows.count } - func value(at row: Int, column: Int) -> String? { - guard row >= 0, row < rows.count else { return nil } + func value(at row: Int, column: Int) -> PluginCellValue { + guard row >= 0, row < rows.count else { return .null } return rows[row][column] } @@ -51,7 +52,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func edit(row: Int, column: Int, value: String?) -> Delta { + mutating func edit(row: Int, column: Int, value: PluginCellValue) -> Delta { guard row >= 0, row < rows.count else { return .none } guard column >= 0, column < columns.count else { return .none } guard column < rows[row].values.count else { return .none } @@ -61,7 +62,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func editMany(_ edits: [(row: Int, column: Int, value: String?)]) -> Delta { + mutating func editMany(_ edits: [(row: Int, column: Int, value: PluginCellValue)]) -> Delta { var changed: Set = [] for edit in edits { guard edit.row >= 0, edit.row < rows.count else { continue } @@ -76,7 +77,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func appendInsertedRow(values: [String?]) -> Delta { + mutating func appendInsertedRow(values: [PluginCellValue]) -> Delta { let normalized = Self.normalize(values: values, toCount: columns.count) let row = Row(id: .inserted(UUID()), values: normalized) let newIndex = rows.count @@ -86,7 +87,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func insertInsertedRow(at index: Int, values: [String?]) -> Delta { + mutating func insertInsertedRow(at index: Int, values: [PluginCellValue]) -> Delta { guard index >= 0, index <= rows.count else { return .none } let normalized = Self.normalize(values: values, toCount: columns.count) let row = Row(id: .inserted(UUID()), values: normalized) @@ -98,7 +99,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func appendPage(_ pageRows: [[String?]], startingAt offset: Int) -> Delta { + mutating func appendPage(_ pageRows: [[PluginCellValue]], startingAt offset: Int) -> Delta { guard !pageRows.isEmpty else { return .none } let firstIndex = rows.count rows.reserveCapacity(rows.count + pageRows.count) @@ -132,7 +133,7 @@ struct TableRows: Sendable { } @discardableResult - mutating func replace(rows replacementRows: [[String?]], offset: Int = 0) -> Delta { + mutating func replace(rows replacementRows: [[PluginCellValue]], offset: Int = 0) -> Delta { var rebuilt = ContiguousArray() rebuilt.reserveCapacity(replacementRows.count) var rebuiltIndex = [RowID: Int]() @@ -181,7 +182,7 @@ struct TableRows: Sendable { } static func from( - queryRows: [[String?]], + queryRows: [[PluginCellValue]], columns: [String], columnTypes: [ColumnType], columnDefaults: [String: String?] = [:], @@ -221,17 +222,17 @@ struct TableRows: Sendable { return .rowsRemoved(indices) } - private static func normalize(values: [String?], toCount targetCount: Int) -> ContiguousArray { + private static func normalize(values: [PluginCellValue], toCount targetCount: Int) -> ContiguousArray { if values.count == targetCount { return ContiguousArray(values) } - var result = ContiguousArray() + var result = ContiguousArray() result.reserveCapacity(targetCount) if values.count > targetCount { result.append(contentsOf: values.prefix(targetCount)) } else { result.append(contentsOf: values) - result.append(contentsOf: ContiguousArray(repeating: nil, count: targetCount - values.count)) + result.append(contentsOf: ContiguousArray(repeating: .null, count: targetCount - values.count)) } return result } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 0aece6aea..8923cece0 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -9,6 +9,7 @@ import AppKit import CodeEditSourceEditor import SwiftUI +import TableProPluginKit /// Identity for the visibility-scoped lazy-load `.task(id:)` modifier on /// `MainEditorContentView`. Changes to either field cancel the previous @@ -608,8 +609,8 @@ struct MainEditorContentView: View { var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if smartDetectionEnabled { - let sampleRows: [[String?]]? = { - let rows: [[String?]] = tableRows?.rows.prefix(10).map { Array($0.values) } ?? [] + let sampleRows: [[PluginCellValue]]? = { + let rows: [[PluginCellValue]] = tableRows?.rows.prefix(10).map { Array($0.values) } ?? [] return rows.isEmpty ? nil : rows }() detected = ValueDisplayDetector.detect( @@ -690,9 +691,9 @@ struct MainEditorContentView: View { let row2 = storageRows[idx2].values for sortCol in sortColumns { let val1 = sortCol.columnIndex < row1.count - ? (row1[sortCol.columnIndex] ?? "") : "" + ? (row1[sortCol.columnIndex].asText ?? "") : "" let val2 = sortCol.columnIndex < row2.count - ? (row2[sortCol.columnIndex] ?? "") : "" + ? (row2[sortCol.columnIndex].asText ?? "") : "" let colType = sortCol.columnIndex < colTypes.count ? colTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(val1, val2, columnType: colType) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 9ac326a98..8606fdbc2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { func addNewRow() { @@ -43,6 +44,10 @@ extension MainContentCoordinator { } func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { - rowEditingCoordinator.updateCellInTab(rowIndex: rowIndex, columnIndex: columnIndex, value: value) + rowEditingCoordinator.updateCellInTab( + rowIndex: rowIndex, + columnIndex: columnIndex, + value: PluginCellValue.fromOptional(value) + ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index f0c0a60f7..49b0f8859 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { // MARK: - Sidebar Save @@ -26,20 +27,23 @@ extension MainContentCoordinator { let tableRows = tabSessionRegistry.tableRows(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in guard rowIndex < tableRows.rows.count else { return nil } - let originalRow = tableRows.rows[rowIndex].values + let originalRow = Array(tableRows.rows[rowIndex].values) return RowChange( rowIndex: rowIndex, type: .update, cellChanges: editedFields.map { field in - CellChange( + let oldValue: PluginCellValue = field.columnIndex < originalRow.count + ? originalRow[field.columnIndex] + : .null + return CellChange( rowIndex: rowIndex, columnIndex: field.columnIndex, columnName: field.columnName, - oldValue: field.columnIndex < originalRow.count ? originalRow[field.columnIndex] : nil, - newValue: field.newValue + oldValue: oldValue, + newValue: PluginCellValue.fromOptional(field.newValue) ) }, - originalRow: Array(originalRow) + originalRow: originalRow ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 1665f09ab..5ce374242 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TableProPluginKit extension MainContentView { // MARK: - Selected Row Data for Sidebar @@ -27,10 +28,9 @@ extension MainContentView { let tblName = tab.tableContext.tableName for (i, col) in tableRows.columns.enumerated() { - var value = i < row.count ? row[i] : nil + var value: String? = i < row.count ? row[i].asText : nil let type = i < tableRows.columnTypes.count ? tableRows.columnTypes[i].displayName : "string" - // Apply display format if active if let rawValue = value { let format = service.effectiveFormat(columnName: col, connectionId: connId, tableName: tblName) if format != .raw { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 90e17514a..7a49b0c71 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -8,6 +8,7 @@ import os import SwiftUI +import TableProPluginKit extension MainContentView { // MARK: - Event Handlers @@ -168,7 +169,7 @@ extension MainContentView { } let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) - var allRows: [[String?]] = [] + var allRows: [[PluginCellValue]] = [] for index in selectedIndices.sorted() { if index < tableRows.rows.count { allRows.append(Array(tableRows.rows[index].values)) @@ -206,9 +207,10 @@ extension MainContentView { let pkColumns = Set(tab.tableContext.primaryKeyColumns) let fkColumns = Set(tableRows.columnForeignKeys.keys) + let stringRows: [[String?]] = allRows.map { row in row.map { $0.asText } } rightPanelState.editState.configure( selectedRowIndices: selectedIndices, - allRows: allRows, + allRows: stringRows, columns: tableRows.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, @@ -234,13 +236,15 @@ extension MainContentView { guard rowIndex < tableRows.rows.count else { continue } let originalRow = Array(tableRows.rows[rowIndex].values) - let oldValue: String? + let oldValue: PluginCellValue if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated { - oldValue = capturedEditState.fields[columnIndex].originalValue + oldValue = PluginCellValue.fromOptional(capturedEditState.fields[columnIndex].originalValue) + } else if columnIndex < originalRow.count { + oldValue = originalRow[columnIndex] } else { - oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil + oldValue = .null } capturedCoordinator.changeManager.recordCellChange( @@ -248,7 +252,7 @@ extension MainContentView { columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, - newValue: newValue, + newValue: PluginCellValue.fromOptional(newValue), originalRow: originalRow ) } @@ -280,7 +284,7 @@ extension MainContentView { let row = tableRows.rows[rowIndex].values if let pkColIndex = tableRows.columns.firstIndex(of: pkColumn), pkColIndex < row.count, - let pkValue = row[pkColIndex] + let pkValue = row[pkColIndex].asText { let excludedList = Array(excludedNames) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index bf3f48372..059862134 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TableProPluginKit extension MainContentView { // MARK: - Helper Methods @@ -118,7 +119,7 @@ extension MainContentView { for row in displayRows { let values = columns.indices.map { i in - let raw = i < row.values.count ? (row.values[i] ?? "NULL") : "NULL" + let raw = i < row.values.count ? (row.values[i].asText ?? "NULL") : "NULL" return (raw as NSString).length > 200 ? String(raw.prefix(200)) + "..." : raw } lines.append(values.joined(separator: " | ")) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 67adc7d76..71e536a2e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1209,7 +1209,7 @@ final class MainContentCoordinator { let sortColumns = newState.columns let colTypes = tableRows.columnTypes let storageRows = tableRows.rows - let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, Array($0.values)) } + let snapshotRows: [(id: RowID, values: [PluginCellValue])] = storageRows.map { ($0.id, Array($0.values)) } if storageRows.count > 1_000 { activeSortTasks[tabId]?.cancel() @@ -1284,11 +1284,10 @@ final class MainContentCoordinator { /// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread). nonisolated private static func multiColumnSortedIDs( - rows: [(id: RowID, values: [String?])], + rows: [(id: RowID, values: [PluginCellValue])], sortColumns: [SortColumn], columnTypes: [ColumnType] = [] ) -> [RowID] { - // Fast path: single-column sort avoids intermediate key array allocation if sortColumns.count == 1 { let col = sortColumns[0] let colIndex = col.columnIndex @@ -1298,8 +1297,8 @@ final class MainContentCoordinator { indices.sort { i1, i2 in let row1 = rows[i1].values let row2 = rows[i2].values - let v1 = colIndex < row1.count ? (row1[colIndex] ?? "") : "" - let v2 = colIndex < row2.count ? (row2[colIndex] ?? "") : "" + let v1 = colIndex < row1.count ? (row1[colIndex].asText ?? "") : "" + let v2 = colIndex < row2.count ? (row2[colIndex].asText ?? "") : "" let cmp = RowSortComparator.compare(v1, v2, columnType: colType) return ascending ? cmp == .orderedAscending : cmp == .orderedDescending } @@ -1311,8 +1310,8 @@ final class MainContentCoordinator { let row1 = rows[i1].values let row2 = rows[i2].values for sortCol in sortColumns { - let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : "" - let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : "" + let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex].asText ?? "") : "" + let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex].asText ?? "") : "" let colType = sortCol.columnIndex < columnTypes.count ? columnTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(v1, v2, columnType: colType) diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 433dd096e..dfb06b653 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -5,6 +5,7 @@ import AppKit import Foundation +import TableProPluginKit @MainActor final class DataGridCellFactory { @@ -37,7 +38,7 @@ final class DataGridCellFactory { let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = tableRows.value(at: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue } let charCount = min((value as NSString).length, Self.maxMeasureChars) let cellWidth = CGFloat(charCount) * charWidth + 16 @@ -66,7 +67,7 @@ final class DataGridCellFactory { let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = tableRows.value(at: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue } let charCount = (value as NSString).length let cellWidth = CGFloat(charCount) * charWidth + 16 diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 70d2ce004..0e0387aaf 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1,6 +1,7 @@ import AppKit import Combine import SwiftUI +import TableProPluginKit // MARK: - Coordinator @@ -258,7 +259,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return displayIndex } - func displayValue(forID id: RowID, column: Int, rawValue: String?, columnType: ColumnType?) -> String? { + func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? { let key = RowIDKey(id) if let box = displayCache.object(forKey: key), column >= 0, column < box.values.count, @@ -266,7 +267,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return cached } let format = column >= 0 && column < columnDisplayFormats.count ? columnDisplayFormats[column] : nil - let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue + let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue.asText let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount) let box: RowDisplayBox @@ -329,7 +330,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData row.values[col], columnType: columnType, displayFormat: format - ) ?? row.values[col] + ) ?? row.values[col].asText } let box = RowDisplayBox(values) displayCache.setObject(box, forKey: key, cost: displayCacheCost(values)) diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index a8459765c..08f2069c8 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -7,6 +7,7 @@ import AppKit import os +import TableProPluginKit private let rowActionsLogger = Logger(subsystem: "com.TablePro", category: "DataGridView+RowActions") @@ -87,10 +88,17 @@ extension TableViewCoordinator { guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } guard let row = displayRow(at: rowIndex), columnIndex < row.values.count else { return } - let value = row.values[columnIndex] ?? "NULL" + let cell = row.values[columnIndex] let columnTypes = tableRows.columnTypes let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil + if case .bytes(let data) = cell { + ClipboardService.shared.writeText(BlobFormattingService.shared.format(data, for: .copy) ?? "") + return + } + + let value = cell.asText ?? "NULL" + if columnIndex < columnDisplayFormats.count, let format = columnDisplayFormats[columnIndex], format != .raw { let formatted = ValueDisplayFormatService.applyFormat(value, format: format) ClipboardService.shared.writeText(formatted) @@ -114,7 +122,8 @@ extension TableViewCoordinator { quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let rows: [[String?]] = typedRows.map { row in row.map { $0.asText } } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) } catch { @@ -135,7 +144,8 @@ extension TableViewCoordinator { quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let rows: [[String?]] = typedRows.map { row in row.map { $0.asText } } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) } catch { @@ -144,7 +154,7 @@ extension TableViewCoordinator { } func copyRowsAsJson(at indices: Set) { - let rows: [[String?]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } + let rows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } guard !rows.isEmpty else { return } let tableRows = tableRowsProvider() let columnTypes = tableRows.columnTypes @@ -152,11 +162,17 @@ extension TableViewCoordinator { ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } - private func formatRowValues(values: [String?], columnTypes: [ColumnType]?) -> [String] { - values.enumerated().map { index, value in - guard let value else { return "NULL" } - let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil } - return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) + private func formatRowValues(values: [PluginCellValue], columnTypes: [ColumnType]?) -> [String] { + values.enumerated().map { index, cell in + switch cell { + case .null: + return "NULL" + case .text(let value): + let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil } + return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) + case .bytes(let data): + return BlobFormattingService.shared.format(data, for: .copy) ?? "" + } } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e9be68f75..9c300e0b0 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProPluginKit struct CellPosition: Hashable { let row: Int diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 6ba97508c..2630278b6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { @@ -14,7 +15,8 @@ extension TableViewCoordinator { guard let displayRowValues = displayRow(at: row) else { return } guard columnIndex < displayRowValues.values.count else { return } let oldValue = displayRowValues.values[columnIndex] - guard oldValue != newValue else { return } + let typedNewValue = PluginCellValue.fromOptional(newValue) + guard oldValue != typedNewValue else { return } isCommittingCellEdit = true defer { isCommittingCellEdit = false } @@ -27,14 +29,14 @@ extension TableViewCoordinator { columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, - newValue: newValue, + newValue: typedNewValue, originalRow: originalRow ) var delta: Delta = .none if let storageRow { tableRowsMutator { tableRows in - delta = tableRows.edit(row: storageRow, column: columnIndex, value: newValue) + delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) } } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 40d221adf..ad709beb6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProPluginKit extension TableViewCoordinator { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { @@ -73,10 +74,11 @@ extension TableViewCoordinator { isDropdownColumn: resolvedDropdown ) - let accessibilityValue = rawValue ?? String(localized: "NULL") + let rawText = rawValue.asText + let accessibilityValue = rawText ?? String(localized: "NULL") let content = DataGridCellContent( displayText: formattedValue ?? "", - rawValue: rawValue, + rawValue: rawText, placeholder: placeholderKind(for: rawValue), accessibilityLabel: String( format: String(localized: "Row %d, column %d: %@"), @@ -99,11 +101,17 @@ extension TableViewCoordinator { return cell } - private func placeholderKind(for rawValue: String?) -> DataGridCellPlaceholder? { - guard let rawValue else { return .null } - if rawValue == "__DEFAULT__" { return .defaultMarker } - if rawValue.isEmpty { return .empty } - return nil + private func placeholderKind(for rawValue: PluginCellValue) -> DataGridCellPlaceholder? { + switch rawValue { + case .null: + return .null + case .text(let s): + if s == "__DEFAULT__" { return .defaultMarker } + if s.isEmpty { return .empty } + return nil + case .bytes: + return nil + } } func tableView(_ tableView: NSTableView, typeSelectStringFor tableColumn: NSTableColumn?, row: Int) -> String? { @@ -112,7 +120,7 @@ extension TableViewCoordinator { guard let columnIndex = dataColumnIndex(from: tableColumn.identifier) else { return nil } guard let displayRow = displayRow(at: row) else { return nil } guard columnIndex < displayRow.values.count else { return nil } - return displayRow.values[columnIndex] + return displayRow.values[columnIndex].asText } func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 8f4eaee79..150db6d46 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProPluginKit extension TableViewCoordinator { enum EditEligibility { @@ -42,7 +43,7 @@ extension TableViewCoordinator { let value: String if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count, - let raw = displayRow.values[columnIndex] { + let raw = displayRow.values[columnIndex].asText { value = raw } else { value = "" diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 15fd5b345..6a8e21d13 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProPluginKit // MARK: - Popover Editors @@ -13,6 +14,13 @@ extension TableViewCoordinator { guard let displayRow = displayRow(at: row), columnIndex >= 0, columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex].asText + } + + func cellTypedValue(at row: Int, column columnIndex: Int) -> PluginCellValue { + guard let displayRow = displayRow(at: row), columnIndex >= 0, columnIndex < displayRow.values.count else { + return .null + } return displayRow.values[columnIndex] } diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index c2b68dcdf..577fb8d1e 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProPluginKit internal struct ResultsJsonView: View { let tableRows: TableRows @@ -184,8 +185,8 @@ internal struct ResultsJsonView: View { rows: ContiguousArray, selectedIndices: Set ) -> (json: String, pretty: String, parseResult: Result) { - let allRows: [[String?]] = rows.map { Array($0.values) } - let displayRows: [[String?]] + let allRows: [[PluginCellValue]] = rows.map { Array($0.values) } + let displayRows: [[PluginCellValue]] if selectedIndices.isEmpty { displayRows = allRows } else { diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 9d5d51835..0727d0b19 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -250,8 +250,9 @@ final class StructureRowProvider { extension StructureRowProvider { /// Creates a TableRows snapshot from structure data func asTableRows() -> TableRows { - TableRows.from( - queryRows: rows, + let typedRows = rows.map { row in row.map(PluginCellValue.fromOptional) } + return TableRows.from( + queryRows: typedRows, columns: columns, columnTypes: columnTypes ) From 37eb98b219dbd8212d69f8ad3ca34e1e8c067a0f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:16:03 +0700 Subject: [PATCH 04/11] feat(datagrid): typed binary commits from hex editors --- TablePro/Models/UI/MultiRowEditState.swift | 26 ++++++++++++------- .../MainContentView+EventHandlers.swift | 12 +++++++-- .../Extensions/DataGridView+CellCommit.swift | 7 +++-- .../Extensions/DataGridView+Popovers.swift | 7 +++++ .../Views/Results/HexEditorContentView.swift | 15 +++++++++-- .../FieldEditors/BlobHexEditorView.swift | 11 +++++--- .../FieldEditors/FieldEditorContext.swift | 21 +++++++++++++++ .../Views/RightSidebar/RightSidebarView.swift | 3 ++- 8 files changed, 82 insertions(+), 20 deletions(-) diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 2c490222b..fa1651ab5 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -8,6 +8,7 @@ import Foundation import Observation +import TableProPluginKit /// Represents the edit state for a single field across multiple rows struct FieldEditState: Identifiable { @@ -54,7 +55,7 @@ struct FieldEditState: Identifiable { final class MultiRowEditState { var fields: [FieldEditState] = [] - var onFieldChanged: ((Int, String?) -> Void)? + var onFieldChanged: ((Int, PluginCellValue) -> Void)? private(set) var selectedRowIndices: Set = [] private(set) var allRows: [[String?]] = [] @@ -181,38 +182,43 @@ final class MultiRowEditState { fields[index].isPendingNull = false fields[index].isPendingDefault = false if fields[index].pendingValue != nil || hadPendingEdit { - onFieldChanged?(index, value) + onFieldChanged?(index, PluginCellValue.fromOptional(value)) } } - /// Set a field to NULL + func setFieldToBytes(at index: Int, data: Data) { + guard index < fields.count else { return } + let encoded = String(data: data, encoding: .isoLatin1) ?? "" + fields[index].pendingValue = encoded + fields[index].isPendingNull = false + fields[index].isPendingDefault = false + onFieldChanged?(index, .bytes(data)) + } + func setFieldToNull(at index: Int) { guard index < fields.count else { return } fields[index].pendingValue = nil fields[index].isPendingNull = true fields[index].isPendingDefault = false - onFieldChanged?(index, nil) + onFieldChanged?(index, .null) } - /// Set a field to DEFAULT func setFieldToDefault(at index: Int) { guard index < fields.count else { return } fields[index].pendingValue = nil fields[index].isPendingNull = false fields[index].isPendingDefault = true - onFieldChanged?(index, "__DEFAULT__") + onFieldChanged?(index, .text("__DEFAULT__")) } - /// Set a field to a SQL function (e.g., NOW()) func setFieldToFunction(at index: Int, function: String) { guard index < fields.count else { return } fields[index].pendingValue = function fields[index].isPendingNull = false fields[index].isPendingDefault = false - onFieldChanged?(index, function) + onFieldChanged?(index, .text(function)) } - /// Set a field to empty string func setFieldToEmpty(at index: Int) { guard index < fields.count else { return } let hadPendingEdit = fields[index].hasEdit @@ -224,7 +230,7 @@ final class MultiRowEditState { fields[index].isPendingNull = false fields[index].isPendingDefault = false if fields[index].pendingValue != nil || hadPendingEdit { - onFieldChanged?(index, "") + onFieldChanged?(index, .text("")) } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 7a49b0c71..12be58fa8 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -207,7 +207,15 @@ extension MainContentView { let pkColumns = Set(tab.tableContext.primaryKeyColumns) let fkColumns = Set(tableRows.columnForeignKeys.keys) - let stringRows: [[String?]] = allRows.map { row in row.map { $0.asText } } + let stringRows: [[String?]] = allRows.map { row in + row.map { cell -> String? in + switch cell { + case .null: return nil + case .text(let s): return s + case .bytes(let data): return String(data: data, encoding: .isoLatin1) ?? "" + } + } + } rightPanelState.editState.configure( selectedRowIndices: selectedIndices, allRows: stringRows, @@ -252,7 +260,7 @@ extension MainContentView { columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, - newValue: PluginCellValue.fromOptional(newValue), + newValue: newValue, originalRow: originalRow ) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 2630278b6..67c4a674b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -8,6 +8,10 @@ import TableProPluginKit extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: PluginCellValue.fromOptional(newValue)) + } + + func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) { guard !isCommittingCellEdit else { return } guard let tableView else { return } let tableRows = tableRowsProvider() @@ -15,7 +19,6 @@ extension TableViewCoordinator { guard let displayRowValues = displayRow(at: row) else { return } guard columnIndex < displayRowValues.values.count else { return } let oldValue = displayRowValues.values[columnIndex] - let typedNewValue = PluginCellValue.fromOptional(newValue) guard oldValue != typedNewValue else { return } isCommittingCellEdit = true @@ -39,7 +42,7 @@ extension TableViewCoordinator { delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) } } - delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) + delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: typedNewValue.asText) invalidateDisplayCache() visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 6a8e21d13..bf4f57780 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -161,6 +161,9 @@ extension TableViewCoordinator { onCommit: { newValue in self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) }, + onCommitBytes: { data in + self?.commitBinaryEdit(row: row, columnIndex: columnIndex, data: data) + }, onDismiss: dismiss ) } @@ -297,6 +300,10 @@ extension TableViewCoordinator { func commitPopoverEdit(row: Int, columnIndex: Int, newValue: String?) { commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) } + + func commitBinaryEdit(row: Int, columnIndex: Int, data: Data) { + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: .bytes(data)) + } } private final class DropdownMenuContext { diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index 828aa28ac..3eb53a8d1 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -11,6 +11,7 @@ import SwiftUI struct HexEditorContentView: View { let initialValue: String? let onCommit: (String) -> Void + let onCommitBytes: ((Data) -> Void)? let onDismiss: () -> Void @State private var hexDumpText: String @@ -23,10 +24,12 @@ struct HexEditorContentView: View { init( initialValue: String?, onCommit: @escaping (String) -> Void, + onCommitBytes: ((Data) -> Void)? = nil, onDismiss: @escaping () -> Void ) { self.initialValue = initialValue self.onCommit = onCommit + self.onCommitBytes = onCommitBytes self.onDismiss = onDismiss let service = BlobFormattingService.shared @@ -106,7 +109,11 @@ struct HexEditorContentView: View { if editableHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if initialValue != nil, initialValue != "" { - onCommit("") + if let onCommitBytes { + onCommitBytes(Data()) + } else { + onCommit("") + } } onDismiss() return @@ -114,7 +121,11 @@ struct HexEditorContentView: View { guard let rawValue = BlobFormattingService.shared.parseHex(editableHex) else { return } if rawValue != initialValue { - onCommit(rawValue) + if let onCommitBytes, let data = rawValue.data(using: .isoLatin1) { + onCommitBytes(data) + } else { + onCommit(rawValue) + } } onDismiss() } diff --git a/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift index 1b95d74dd..49ec1d6ba 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift @@ -68,10 +68,15 @@ internal struct BlobHexEditorView: View { } private func commitHexEdit() { - if let raw = BlobFormattingService.shared.parseHex(hexEditText) { - context.value.wrappedValue = raw - } else { + guard let raw = BlobFormattingService.shared.parseHex(hexEditText) else { hexEditText = BlobFormattingService.shared.format(context.value.wrappedValue, for: .edit) ?? "" + return + } + if let commitBytes = context.commitBytes, + let data = raw.data(using: .isoLatin1) { + commitBytes(data) + } else { + context.value.wrappedValue = raw } } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift index 14ec94732..378145abc 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift @@ -12,6 +12,27 @@ internal struct FieldEditorContext { let originalValue: String? let hasMultipleValues: Bool let isReadOnly: Bool + let commitBytes: ((Data) -> Void)? + + init( + columnName: String, + columnType: ColumnType, + isLongText: Bool, + value: Binding, + originalValue: String?, + hasMultipleValues: Bool, + isReadOnly: Bool, + commitBytes: ((Data) -> Void)? = nil + ) { + self.columnName = columnName + self.columnType = columnType + self.isLongText = isLongText + self.value = value + self.originalValue = originalValue + self.hasMultipleValues = hasMultipleValues + self.isReadOnly = isReadOnly + self.commitBytes = commitBytes + } var placeholderText: String { if hasMultipleValues { diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 56cb0d2de..a480141b4 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -264,7 +264,8 @@ struct RightSidebarView: View { ) : .constant(field.originalValue ?? ""), originalValue: field.originalValue, hasMultipleValues: field.hasMultipleValues, - isReadOnly: !isEditable + isReadOnly: !isEditable, + commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil ), isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, From 00901d8151f65988d3ef8ad086f86be88c809f9b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:36:23 +0700 Subject: [PATCH 05/11] test(ai-providers,plugins): cover binary parameter emission --- .../ChangeTracking/DataChangeModels.swift | 10 -- .../Core/AI/AIProviderErrorTests.swift | 1 + .../Core/AI/AIProviderFactoryCacheTests.swift | 1 + .../AI/AIProviderFactoryResolveTests.swift | 1 + .../AI/AnthropicProviderEncodingTests.swift | 1 + .../AI/AnthropicProviderParserTests.swift | 1 + .../Core/AI/AssembleToolUseBlocksTests.swift | 1 + .../AI/ChatToolArgumentDecoderTests.swift | 1 + .../Core/AI/ChatToolRegistryModeTests.swift | 1 + .../Core/AI/ChatToolRegistryTests.swift | 1 + .../Core/AI/ChatToolSpecCopilotTests.swift | 1 + .../ContextItemSavedQueryCodableTests.swift | 1 + .../AI/CopilotIdleStopControllerTests.swift | 1 + .../AI/CustomSlashCommandRendererTests.swift | 1 + .../Core/AI/ExecuteToolUsesTests.swift | 1 + .../Core/AI/GeminiProviderEncodingTests.swift | 1 + .../Core/AI/GeminiProviderParserTests.swift | 1 + .../InlineSuggestionManagerFocusTests.swift | 1 + .../Core/AI/MentionDetectorTests.swift | 1 + .../Core/AI/MentionPopoverStateTests.swift | 1 + ...penAICompatibleProviderEncodingTests.swift | 1 + .../OpenAICompatibleProviderParserTests.swift | 1 + TableProTests/Core/AI/SlashCommandTests.swift | 1 + .../Core/AI/ToolApprovalCenterTests.swift | 1 + .../Autocomplete/CompletionEngineTests.swift | 1 + .../SQLCompletionProviderTests.swift | 1 + ...LContextAnalyzerCaseInsensitiveTests.swift | 1 + .../SQLContextAnalyzerTests.swift | 1 + .../SQLContextAnalyzerWindowingTests.swift | 1 + .../Core/Autocomplete/SQLKeywordsTests.swift | 1 + .../SQLSchemaProviderFallbackTests.swift | 1 + .../Autocomplete/SQLSchemaProviderTests.swift | 1 + .../AnyChangeManagerTests.swift | 1 + .../DataChangeManagerClickHouseTests.swift | 1 + .../DataChangeManagerExtendedTests.swift | 1 + .../DataChangeManagerTests.swift | 9 +- .../DataChangeModelsTests.swift | 1 + .../ChangeTracking/PendingChangesTests.swift | 1 + .../SQLStatementGeneratorBinaryTests.swift | 155 ++++++++++++++++++ ...QLStatementGeneratorCompositePKTests.swift | 14 +- .../SQLStatementGeneratorMSSQLTests.swift | 18 +- .../SQLStatementGeneratorNoPKTests.swift | 3 +- ...LStatementGeneratorPKRegressionTests.swift | 7 +- ...tatementGeneratorParameterStyleTests.swift | 16 +- .../SQLStatementGeneratorTests.swift | 43 ++--- .../ClickHouseConnectionTests.swift | 1 + .../CloudflareD1DriverHelperTests.swift | 1 + .../CloudflareD1/D1ResponseParsingTests.swift | 1 + .../CloudflareD1/D1ValueDecodingTests.swift | 1 + .../Core/Concurrency/OnceTaskTests.swift | 1 + ...atabaseConnectionExternalAccessTests.swift | 1 + .../DatabaseManagerObserverTests.swift | 1 + .../Core/Database/DatabaseManagerTests.swift | 1 + .../DatabaseManagerVersionTests.swift | 1 + .../Database/GeometryWKBParserTests.swift | 1 + .../Core/Database/MultiConnectionTests.swift | 1 + .../Core/Database/PostgreSQLDriverTests.swift | 1 + .../Core/Database/SQLEscapingTests.swift | 1 + .../PasteboardActionRouterTests.swift | 1 + .../MCPBearerTokenAuthenticatorTests.swift | 1 + .../MCPProtocolHandlerTestSupport.swift | 1 + .../MCP/Helpers/MCPProtocolTestStubs.swift | 1 + .../Core/MCP/Helpers/MCPTestClock.swift | 1 + .../MCP/Helpers/MCPTransportTestStubs.swift | 1 + .../MCPBridgeIntegrationTests.swift | 1 + .../Core/MCP/MCPAuditLogStorageTests.swift | 1 + .../Core/MCP/MCPPairingServiceTests.swift | 1 + .../Core/MCP/MCPTokenStoreTests.swift | 1 + .../Handlers/InitializeHandlerTests.swift | 1 + .../LoggingSetLevelHandlerTests.swift | 1 + .../Protocol/Handlers/PingHandlerTests.swift | 1 + .../Handlers/PromptsListHandlerTests.swift | 1 + .../Handlers/ResourcesListHandlerTests.swift | 1 + .../Handlers/ResourcesReadHandlerTests.swift | 1 + .../Handlers/ToolsCallHandlerTests.swift | 1 + .../Handlers/ToolsListHandlerTests.swift | 1 + .../Protocol/MCPArgumentDecoderTests.swift | 1 + .../Protocol/MCPCancellationTokenTests.swift | 1 + .../Protocol/MCPInflightRegistryTests.swift | 1 + .../Protocol/MCPProgressEmitterTests.swift | 1 + .../Protocol/MCPProtocolDispatcherTests.swift | 1 + ...ConfirmDestructiveOperationToolTests.swift | 1 + .../MCP/Protocol/Tools/ConnectToolTests.swift | 1 + .../Tools/DescribeTableToolTests.swift | 1 + .../Protocol/Tools/DisconnectToolTests.swift | 1 + .../Tools/ExecuteQueryToolTests.swift | 1 + .../Protocol/Tools/ExportDataToolTests.swift | 1 + .../Tools/FocusQueryTabToolTests.swift | 1 + .../Tools/GetConnectionStatusToolTests.swift | 1 + .../Protocol/Tools/GetTableDdlToolTests.swift | 1 + .../Tools/ListConnectionsToolTests.swift | 1 + .../Tools/ListDatabasesToolTests.swift | 1 + .../Tools/ListRecentTabsToolTests.swift | 1 + .../Protocol/Tools/ListSchemasToolTests.swift | 1 + .../Protocol/Tools/ListTablesToolTests.swift | 1 + .../Tools/OpenConnectionWindowToolTests.swift | 1 + .../Tools/OpenTableTabToolTests.swift | 1 + .../Tools/SearchQueryHistoryToolTests.swift | 1 + .../Tools/SwitchDatabaseToolTests.swift | 1 + .../Tools/SwitchSchemaToolTests.swift | 1 + .../MCP/RateLimit/MCPRateLimiterTests.swift | 1 + .../MCP/Session/MCPSessionStoreTests.swift | 1 + .../Core/MCP/Session/MCPSessionTests.swift | 1 + .../MCPHttpServerConfigurationTests.swift | 1 + .../MCPHttpServerTransportPairingTests.swift | 1 + .../MCPHttpServerTransportTests.swift | 1 + .../MCP/Transport/MCPProtocolErrorTests.swift | 1 + .../MCPStdioMessageTransportTests.swift | 1 + ...CPStreamableHttpClientTransportTests.swift | 1 + .../MCP/Wire/HttpRequestParserTests.swift | 1 + .../Core/MCP/Wire/JsonRpcIdTests.swift | 1 + .../Core/MCP/Wire/JsonRpcMessageTests.swift | 1 + .../MCP/Wire/SseEncoderDecoderTests.swift | 1 + .../MongoDB/BsonDocumentFlattenerTests.swift | 1 + .../MongoDB/MongoDBExtendedJsonTests.swift | 1 + .../Core/MongoDB/MongoDBSrvHostTests.swift | 1 + .../NeedsRestartPersistenceTests.swift | 1 + .../Core/Plugins/PluginLazyLoadingTests.swift | 1 + .../Core/Plugins/RegistryClientURLTests.swift | 1 + .../Core/Plugins/SSLModeStringTests.swift | 1 + .../Redis/ColumnTypeBadgeLabelTests.swift | 1 + .../Core/Redis/ExportModelsRedisTests.swift | 1 + .../Core/Redis/ExportServiceRedisTests.swift | 1 + .../Core/Redis/RedisCommandParserTests.swift | 1 + .../Core/Redis/RedisReplyTests.swift | 1 + .../Core/Redis/RedisResultBuildingTests.swift | 1 + .../Redis/SidebarRedisCommandsTests.swift | 1 + .../SSH/Auth/AuthFailureReasonTests.swift | 1 + .../SSH/Auth/BuildAuthenticatorTests.swift | 1 + .../KeyboardInteractiveContextTests.swift | 1 + .../Core/SSH/HostKeyStoreTests.swift | 1 + .../Core/SSH/SSHConfigCacheTests.swift | 1 + .../Core/SSH/SSHConfigParserTests.swift | 1 + .../Core/SSH/SSHConfigResolverTests.swift | 1 + .../Core/SSH/SSHConfigurationTests.swift | 1 + .../Core/SSH/SSHHostPatternMatcherTests.swift | 1 + TableProTests/Core/SSH/SSHJumpHostTests.swift | 1 + .../Core/SSH/SSHMatchExecutorTests.swift | 1 + .../Core/SSH/SSHPathUtilitiesTests.swift | 1 + .../Core/SSH/SSHTunnelErrorTests.swift | 1 + TableProTests/Core/SSH/TOTP/Base32Tests.swift | 1 + .../Core/SSH/TOTP/TOTPGeneratorTests.swift | 1 + .../StructureChangeManagerPKTests.swift | 1 + .../StructureChangeManagerUndoTests.swift | 1 + .../Services/BlobFormattingServiceTests.swift | 1 + .../Services/CellDisplayFormatterTests.swift | 13 +- .../Services/ColumnExclusionPolicyTests.swift | 1 + .../Services/ColumnTypeClassifierTests.swift | 1 + .../Core/Services/ColumnTypeTests.swift | 1 + .../Services/ConnectionSharingTests.swift | 1 + .../Core/Services/ExportStateTests.swift | 1 + .../ForeignApp/DBeaverImporterTests.swift | 1 + .../ForeignAppImporterRegistryTests.swift | 1 + .../ForeignApp/SequelAceImporterTests.swift | 1 + .../ForeignApp/TablePlusImporterTests.swift | 1 + .../Core/Services/ImportStateTests.swift | 1 + .../Services/MariaDBJsonDetectionTests.swift | 9 +- .../Services/Query/QueryExecutorTests.swift | 1 + .../TabSessionRegistryTableRowsTests.swift | 1 + .../RowOperationsManagerCopyTests.swift | 3 +- .../Services/RowOperationsManagerTests.swift | 7 +- .../Services/SQLFormatterServiceTests.swift | 1 + .../Services/SQLParameterInlinerTests.swift | 1 + .../Core/Services/SQLTokenizerTests.swift | 1 + .../Core/Services/SafeModeGuardTests.swift | 1 + .../SchemaProviderRegistryTests.swift | 1 + .../TabPersistenceCoordinatorTests.swift | 1 + .../TableQueryBuilderFilterTests.swift | 1 + .../TableQueryBuilderMSSQLTests.swift | 1 + .../TableQueryBuilderSelectiveTests.swift | 1 + .../WindowLifecycleMonitorTests.swift | 1 + .../Services/WindowTabGroupingTests.swift | 1 + .../Core/Storage/AIChatStorageTests.swift | 1 + .../AppSettingsManagerMigrationTests.swift | 1 + .../ColumnVisibilityPersistenceTests.swift | 1 + .../ConnectionStorageAIFieldsTests.swift | 1 + ...nnectionStorageAdditionalFieldsTests.swift | 1 + .../ConnectionStoragePersistenceTests.swift | 1 + .../CustomSlashCommandStorageTests.swift | 1 + .../Core/Storage/DateFilterTests.swift | 1 + .../Core/Storage/GroupStorageTests.swift | 1 + .../Storage/KeychainAccessControlTests.swift | 1 + .../Core/Storage/KeychainHelperTests.swift | 1 + .../Storage/QueryHistoryStorageTests.swift | 1 + .../Storage/SQLFavoriteStorageTests.swift | 1 + .../Core/Storage/SafeModeMigrationTests.swift | 1 + .../Core/Sync/CloudKitSyncEngineTests.swift | 1 + .../Terminal/CLICommandResolverTests.swift | 1 + ...onnectionURLFormatterSSHProfileTests.swift | 1 + .../ConnectionURLFormatterTests.swift | 1 + .../ConnectionURLParserMSSQLTests.swift | 1 + .../Utilities/ConnectionURLParserTests.swift | 1 + .../Utilities/DatabaseURLSchemeTests.swift | 1 + .../Utilities/JsonRowConverterTests.swift | 5 +- .../SQLStatementScannerLocatedTests.swift | 1 + .../Utilities/SQLStatementScannerTests.swift | 1 + .../Validation/SettingsValidationTests.swift | 1 + TableProTests/Core/Vim/VimEngineTests.swift | 1 + .../Vim/VimKeyInterceptorFocusTests.swift | 1 + .../Vim/VimTextBufferAdapterPerfTests.swift | 1 + .../Core/Vim/VimTextBufferMock.swift | 1 + .../Core/Vim/VimVisualModeTests.swift | 1 + .../ConnectionStringParserTests.swift | 1 + .../Extensions/DateExtensionsTests.swift | 1 + .../Extensions/NSViewFocusTests.swift | 1 + .../Extensions/StringHexDumpTests.swift | 1 + .../Extensions/StringJsonTests.swift | 1 + .../Extensions/StringSHA256Tests.swift | 1 + .../Extensions/URLSanitizationTests.swift | 1 + TableProTests/Helpers/SQLTestHelpers.swift | 1 + TableProTests/Helpers/TestFixtures.swift | 10 +- .../Models/AIConversationTests.swift | 1 + TableProTests/Models/AISettingsTests.swift | 1 + .../Models/ColumnLayoutStateTests.swift | 1 + .../Models/ConnectionGroupTreeTests.swift | 1 + .../Models/ConnectionSessionTests.swift | 1 + ...abaseConnectionAdditionalFieldsTests.swift | 1 + .../Models/DatabaseConnectionSSHTests.swift | 1 + .../Models/DatabaseTypeCassandraTests.swift | 1 + .../Models/DatabaseTypeMSSQLTests.swift | 1 + .../Models/DatabaseTypeRedisTests.swift | 1 + TableProTests/Models/DatabaseTypeTests.swift | 1 + .../Models/EditorTabPayloadTests.swift | 1 + TableProTests/Models/ExportModelsTests.swift | 1 + TableProTests/Models/LicenseTests.swift | 1 + .../Models/MultiRowEditStateTests.swift | 21 +-- .../MultiRowEditStateTruncationTests.swift | 1 + .../Models/PaginationStateTests.swift | 1 + TableProTests/Models/PreviewTabTests.swift | 1 + TableProTests/Models/Query/DeltaTests.swift | 1 + .../Models/Query/QueryTabManagerTests.swift | 1 + TableProTests/Models/Query/RowTests.swift | 1 + .../Query/TabSessionRegistryTests.swift | 1 + .../Models/Query/TabSessionTests.swift | 1 + .../Query/TabStructureVersionTests.swift | 1 + .../Models/Query/TableRowsTests.swift | 1 + .../Models/QueryHistoryEntryTests.swift | 1 + .../Models/RedisKeyTreeNodeTests.swift | 1 + .../Models/RightPanelStateTests.swift | 1 + .../Models/SQLFileDeduplicationTests.swift | 1 + TableProTests/Models/SafeModeLevelTests.swift | 1 + .../Models/Schema/ColumnDefinitionTests.swift | 1 + .../Schema/ForeignKeyDefinitionTests.swift | 1 + .../Models/Schema/IndexDefinitionTests.swift | 1 + .../Models/Schema/SchemaChangeTests.swift | 1 + .../Models/SharedSidebarStateTests.swift | 1 + TableProTests/Models/SortStateTests.swift | 1 + TableProTests/Models/TableFilterTests.swift | 1 + TableProTests/Models/TableInfoTests.swift | 1 + .../TableOperationDialogLogicTests.swift | 1 + .../Models/UI/ColumnIdentitySchemaTests.swift | 1 + .../Models/UI/FilterPresetStorageTests.swift | 1 + .../Models/UI/KeyComboMatchTests.swift | 1 + .../Plugins/BigQueryQueryBuilderTests.swift | 1 + .../Plugins/DynamoDBQueryBuilderTests.swift | 1 + .../Plugins/EtcdCommandParserTests.swift | 1 + .../Plugins/EtcdHttpClientUtilityTests.swift | 1 + .../Plugins/EtcdQueryBuilderTests.swift | 1 + .../Plugins/LibPQByteaDecoderTests.swift | 1 + .../Plugins/OracleCellFormattingTests.swift | 1 + .../Plugins/PostgreSQLSchemaFilterTests.swift | 1 + .../Services/MacAnalyticsProviderTests.swift | 1 + .../Services/SampleDatabaseServiceTests.swift | 1 + .../FileColumnLayoutPersisterTests.swift | 1 + .../Theme/ThemeDefinitionTests.swift | 1 + .../Utilities/FuzzyMatcherTests.swift | 1 + .../MemoryPressureAdvisorTests.swift | 1 + .../Utilities/RowSortComparatorTests.swift | 1 + .../AIChatViewModelActionTests.swift | 1 + .../AIChatViewModelMentionsTests.swift | 1 + .../AIChatViewModelSlashTests.swift | 1 + .../FavoritesSidebarViewModelTests.swift | 1 + .../QuickSwitcherViewModelTests.swift | 1 + .../ViewModels/SidebarViewModelTests.swift | 1 + .../AIChatCodeBlockDetectionTests.swift | 1 + .../Views/Components/HighlightCapTests.swift | 1 + .../IntegrationStatusIndicatorTests.swift | 1 + .../Views/Editor/GutterHighlightTests.swift | 1 + .../Editor/KeywordUppercaseHelperTests.swift | 1 + .../Views/Editor/LineCutCalculatorTests.swift | 1 + .../SQLCompletionAdapterFuzzyTests.swift | 1 + .../SQLEditorCoordinatorCleanupTests.swift | 1 + .../Editor/SQLEditorCoordinatorTests.swift | 1 + .../Filter/FilterValueTextFieldTests.swift | 1 + .../Views/History/UIDateFilterTests.swift | 1 + .../Main/CommandActionsDispatchTests.swift | 1 + .../CoordinatorColumnVisibilityTests.swift | 1 + .../Main/CoordinatorEditorLoadTests.swift | 1 + .../Main/CoordinatorSidebarActionsTests.swift | 1 + TableProTests/Views/Main/EvictionTests.swift | 3 +- .../Views/Main/ExtractTableNameTests.swift | 1 + .../MainContentCoordinatorLazyLoadTests.swift | 3 +- .../MainContentCoordinatorSortTests.swift | 3 +- ...MainContentCoordinatorTabSwitchTests.swift | 3 +- .../Views/Main/MainStatusBarLayoutTests.swift | 1 + .../Main/MultiConnectionNavigationTests.swift | 1 + .../Views/Main/OpenTableTabTests.swift | 1 + .../Views/Main/SaveCompletionTests.swift | 1 + .../Views/Main/SessionStateFactoryTests.swift | 1 + .../Views/Main/SharedSidebarSyncTests.swift | 1 + .../Views/Main/SidebarSyncTests.swift | 1 + .../Main/SortCacheInvalidationTests.swift | 3 +- .../Main/StructureActionHandlerTests.swift | 1 + .../Main/TableOperationsPluginTests.swift | 1 + .../Main/TableSelectionChangeTests.swift | 1 + .../Views/Main/TriggerStructTests.swift | 1 + .../Views/Results/CellPositionTests.swift | 1 + .../DataGridCellFactoryPerfTests.swift | 13 +- .../Results/DataGridColumnPoolTests.swift | 1 + .../Results/DataGridPerformanceTests.swift | 1 + .../Extensions/CellPasteRoutingTests.swift | 3 +- .../Views/Results/HeaderSortCycleTests.swift | 1 + .../Views/Results/HexEditorTests.swift | 1 + .../Results/JSONEditorHighlightTests.swift | 1 + .../Results/SortableHeaderCellTests.swift | 1 + .../Results/TableRowsControllerTests.swift | 1 + .../TableViewCoordinatorLayoutTests.swift | 1 + .../Views/SidebarNavigationResultTests.swift | 1 + TableProTests/Views/SwitchDatabaseTests.swift | 1 + TableProTests/Views/TableRowLogicTests.swift | 1 + 320 files changed, 572 insertions(+), 99 deletions(-) create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index 6f3ef9bd7..655bdcb05 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -76,13 +76,3 @@ enum UndoAction { case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [PluginCellValue])]) case batchRowInsertion(rowIndices: [Int], rowValues: [[PluginCellValue]]) } - -// Note: TabChangeSnapshot is defined in QueryTab.swift - -// MARK: - Array Extension - -extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/TableProTests/Core/AI/AIProviderErrorTests.swift b/TableProTests/Core/AI/AIProviderErrorTests.swift index 9972a25c1..731dca944 100644 --- a/TableProTests/Core/AI/AIProviderErrorTests.swift +++ b/TableProTests/Core/AI/AIProviderErrorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/AIProviderFactoryCacheTests.swift b/TableProTests/Core/AI/AIProviderFactoryCacheTests.swift index 783a6c2f2..c9affcc36 100644 --- a/TableProTests/Core/AI/AIProviderFactoryCacheTests.swift +++ b/TableProTests/Core/AI/AIProviderFactoryCacheTests.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/AIProviderFactoryResolveTests.swift b/TableProTests/Core/AI/AIProviderFactoryResolveTests.swift index 49b679eaf..ad5460310 100644 --- a/TableProTests/Core/AI/AIProviderFactoryResolveTests.swift +++ b/TableProTests/Core/AI/AIProviderFactoryResolveTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift index 82bb92cd2..0064ba271 100644 --- a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift +++ b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/AnthropicProviderParserTests.swift b/TableProTests/Core/AI/AnthropicProviderParserTests.swift index 061054640..428381777 100644 --- a/TableProTests/Core/AI/AnthropicProviderParserTests.swift +++ b/TableProTests/Core/AI/AnthropicProviderParserTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/AssembleToolUseBlocksTests.swift b/TableProTests/Core/AI/AssembleToolUseBlocksTests.swift index a5d23b2bf..3e55539d7 100644 --- a/TableProTests/Core/AI/AssembleToolUseBlocksTests.swift +++ b/TableProTests/Core/AI/AssembleToolUseBlocksTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ChatToolArgumentDecoderTests.swift b/TableProTests/Core/AI/ChatToolArgumentDecoderTests.swift index 791df8456..4cf38960d 100644 --- a/TableProTests/Core/AI/ChatToolArgumentDecoderTests.swift +++ b/TableProTests/Core/AI/ChatToolArgumentDecoderTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ChatToolRegistryModeTests.swift b/TableProTests/Core/AI/ChatToolRegistryModeTests.swift index e5ac4d58e..fc575c51d 100644 --- a/TableProTests/Core/AI/ChatToolRegistryModeTests.swift +++ b/TableProTests/Core/AI/ChatToolRegistryModeTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ChatToolRegistryTests.swift b/TableProTests/Core/AI/ChatToolRegistryTests.swift index e45ead2ca..28eda1146 100644 --- a/TableProTests/Core/AI/ChatToolRegistryTests.swift +++ b/TableProTests/Core/AI/ChatToolRegistryTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ChatToolSpecCopilotTests.swift b/TableProTests/Core/AI/ChatToolSpecCopilotTests.swift index b212a7b1a..8b9fa1c48 100644 --- a/TableProTests/Core/AI/ChatToolSpecCopilotTests.swift +++ b/TableProTests/Core/AI/ChatToolSpecCopilotTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift b/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift index 732b266f2..460afe1ab 100644 --- a/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift +++ b/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift b/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift index 39aebb3e8..8ca024b42 100644 --- a/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift +++ b/TableProTests/Core/AI/CopilotIdleStopControllerTests.swift @@ -5,6 +5,7 @@ // Verifies the deferred-stop state machine extracted from CopilotService. // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift b/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift index c8ca66f44..5e0d0cd7b 100644 --- a/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift +++ b/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ExecuteToolUsesTests.swift b/TableProTests/Core/AI/ExecuteToolUsesTests.swift index e86013ad0..f6d0878f5 100644 --- a/TableProTests/Core/AI/ExecuteToolUsesTests.swift +++ b/TableProTests/Core/AI/ExecuteToolUsesTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/GeminiProviderEncodingTests.swift b/TableProTests/Core/AI/GeminiProviderEncodingTests.swift index 037034ebe..b80d939d4 100644 --- a/TableProTests/Core/AI/GeminiProviderEncodingTests.swift +++ b/TableProTests/Core/AI/GeminiProviderEncodingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/GeminiProviderParserTests.swift b/TableProTests/Core/AI/GeminiProviderParserTests.swift index db3435d36..f377d2039 100644 --- a/TableProTests/Core/AI/GeminiProviderParserTests.swift +++ b/TableProTests/Core/AI/GeminiProviderParserTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift b/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift index 33ca98d2a..f6fb6c1c9 100644 --- a/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift +++ b/TableProTests/Core/AI/InlineSuggestionManagerFocusTests.swift @@ -5,6 +5,7 @@ // Regression tests for InlineSuggestionManager focus lifecycle // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/MentionDetectorTests.swift b/TableProTests/Core/AI/MentionDetectorTests.swift index 564f17fff..d212f3e44 100644 --- a/TableProTests/Core/AI/MentionDetectorTests.swift +++ b/TableProTests/Core/AI/MentionDetectorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/MentionPopoverStateTests.swift b/TableProTests/Core/AI/MentionPopoverStateTests.swift index 03e1fe6a6..ab280b33c 100644 --- a/TableProTests/Core/AI/MentionPopoverStateTests.swift +++ b/TableProTests/Core/AI/MentionPopoverStateTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift index 4787fb68e..d644a0ef2 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift index 614cd6bab..275558b9d 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderParserTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/SlashCommandTests.swift b/TableProTests/Core/AI/SlashCommandTests.swift index b8db4016d..3c2411690 100644 --- a/TableProTests/Core/AI/SlashCommandTests.swift +++ b/TableProTests/Core/AI/SlashCommandTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/AI/ToolApprovalCenterTests.swift b/TableProTests/Core/AI/ToolApprovalCenterTests.swift index 87ed8955c..6163219a5 100644 --- a/TableProTests/Core/AI/ToolApprovalCenterTests.swift +++ b/TableProTests/Core/AI/ToolApprovalCenterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index 8b4f6a1ff..a5583b63c 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index a089151f8..4ab358da5 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift index 9a6a38f98..adfe0d8fc 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerCaseInsensitiveTests.swift @@ -6,6 +6,7 @@ // after removal of uppercased() normalization. // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift index 7e9e6cac3..bb6c987d0 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift index c3afd963f..7f60be7d2 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift @@ -6,6 +6,7 @@ // Ensures windowing optimizations preserve correct clause detection. // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Autocomplete/SQLKeywordsTests.swift b/TableProTests/Core/Autocomplete/SQLKeywordsTests.swift index bc613e465..aee17bd9c 100644 --- a/TableProTests/Core/Autocomplete/SQLKeywordsTests.swift +++ b/TableProTests/Core/Autocomplete/SQLKeywordsTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift index f904327f3..d6cbd0a2d 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderFallbackTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 34f4b1943..f170f88f3 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index 72e8eceb0..3d486f880 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift index 98ecbf9b3..37d679511 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index d6137b9eb..a106f57fa 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index b8974d0aa..9ef1a5968 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -305,10 +306,10 @@ struct DataChangeManagerTests { primaryKeyColumns: ["id"] ) - let rows = [ - (rowIndex: 0, originalRow: ["1", "Alice"]), - (rowIndex: 1, originalRow: ["2", "Bob"]), - (rowIndex: 2, originalRow: ["3", "Charlie"]) + let rows: [(rowIndex: Int, originalRow: [PluginCellValue])] = [ + (rowIndex: 0, originalRow: [.text("1"), .text("Alice")]), + (rowIndex: 1, originalRow: [.text("2"), .text("Bob")]), + (rowIndex: 2, originalRow: [.text("3"), .text("Charlie")]) ] manager.recordBatchRowDeletion(rows: rows) diff --git a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift index dd73727be..20a6b67f1 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/ChangeTracking/PendingChangesTests.swift b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift index ddcfba70f..0a06ad630 100644 --- a/TableProTests/Core/ChangeTracking/PendingChangesTests.swift +++ b/TableProTests/Core/ChangeTracking/PendingChangesTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift new file mode 100644 index 000000000..7003b18ac --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift @@ -0,0 +1,155 @@ +// +// SQLStatementGeneratorBinaryTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Statement Generator - binary cells") +struct SQLStatementGeneratorBinaryTests { + private func makeGenerator( + databaseType: DatabaseType = .postgresql + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumns: ["id"], + databaseType: databaseType, + dialect: nil + ) + } + + @Test("UPDATE with .bytes newValue emits Data parameter, not String") + func updatePreservesBinaryParameter() throws { + let generator = try makeGenerator() + let bytes = Data([0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C]) + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange( + rowIndex: 0, + columnIndex: 1, + columnName: "payload", + oldValue: .null, + newValue: .bytes(bytes) + ) + ], + originalRow: [.text("42"), .null] + ) + guard let stmt = generator.generateUpdateSQL(for: change) else { + Issue.record("UPDATE statement was not generated") + return + } + guard stmt.parameters.count == 2 else { + Issue.record("Expected 2 parameters (payload + pk), got \(stmt.parameters.count)") + return + } + guard let payload = stmt.parameters[0] as? Data else { + Issue.record("First parameter is not Data: \(String(describing: stmt.parameters[0]))") + return + } + #expect(payload == bytes) + #expect(stmt.parameters[1] as? String == "42") + } + + @Test("INSERT with .bytes newValue emits Data parameter") + func insertPreservesBinaryParameter() throws { + let generator = try makeGenerator() + let bytes = Data([0xFF, 0x00, 0x7F, 0x80]) + let change = RowChange( + rowIndex: 0, + type: .insert, + cellChanges: [ + CellChange( + rowIndex: 0, + columnIndex: 1, + columnName: "payload", + oldValue: .null, + newValue: .bytes(bytes) + ) + ] + ) + let statements = generator.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + guard let stmt = statements.first else { + Issue.record("INSERT statement was not generated") + return + } + guard let payload = stmt.parameters.first as? Data else { + Issue.record("First parameter is not Data: \(String(describing: stmt.parameters.first ?? nil))") + return + } + #expect(payload == bytes) + } + + @Test("Issue #1188 exact value survives UPDATE round-trip as Data") + func issue1188WriteRoundTrip() throws { + let generator = try makeGenerator() + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange( + rowIndex: 0, + columnIndex: 1, + columnName: "payload", + oldValue: .null, + newValue: .bytes(bytes) + ) + ], + originalRow: [.text("42"), .null] + ) + guard let stmt = generator.generateUpdateSQL(for: change) else { + Issue.record("UPDATE statement was not generated") + return + } + guard let payload = stmt.parameters.first as? Data else { + Issue.record("First parameter is not Data") + return + } + #expect(payload.count == 48) + #expect(payload == bytes) + #expect(payload.first == 0xD3) + } + + @Test(".null parameters bind as NSNull/nil, not String") + func nullParameterIsNotString() throws { + let generator = try makeGenerator() + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange( + rowIndex: 0, + columnIndex: 1, + columnName: "payload", + oldValue: .text("old"), + newValue: .null + ) + ], + originalRow: [.text("42"), .text("old")] + ) + guard let stmt = generator.generateUpdateSQL(for: change) else { + Issue.record("UPDATE statement was not generated") + return + } + let firstParam = stmt.parameters.first + #expect(firstParam == nil || firstParam.flatMap { $0 } == nil) + } +} diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift index aac039136..04583837c 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorCompositePKTests.swift @@ -5,6 +5,7 @@ // Tests for composite primary key support in UPDATE and DELETE generation. // +import TableProPluginKit @testable import TablePro import Testing @@ -40,9 +41,11 @@ struct SQLStatementGeneratorCompositePKTests { type: .update, cellChanges: [CellChange( rowIndex: rowIndex, columnIndex: columnIndex, - columnName: columnName, oldValue: oldValue, newValue: newValue + columnName: columnName, + oldValue: PluginCellValue.fromOptional(oldValue), + newValue: PluginCellValue.fromOptional(newValue) )], - originalRow: originalRow + originalRow: originalRow.map(PluginCellValue.fromOptional) ) } @@ -55,12 +58,15 @@ struct SQLStatementGeneratorCompositePKTests { rowIndex: rowIndex, type: .update, cellChanges: cellChanges, - originalRow: originalRow + originalRow: originalRow.map(PluginCellValue.fromOptional) ) } private func makeDeleteChange(rowIndex: Int = 0, originalRow: [String?]) -> RowChange { - RowChange(rowIndex: rowIndex, type: .delete, cellChanges: [], originalRow: originalRow) + RowChange( + rowIndex: rowIndex, type: .delete, cellChanges: [], + originalRow: originalRow.map(PluginCellValue.fromOptional) + ) } private func generate( diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift index e98813f85..8b9af863e 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -46,11 +47,11 @@ struct SQLStatementGeneratorMSSQLTests { rowIndex: rowIndex, columnIndex: 1, columnName: columnName, - oldValue: oldValue, - newValue: newValue + oldValue: PluginCellValue.fromOptional(oldValue), + newValue: PluginCellValue.fromOptional(newValue) ) ], - originalRow: originalRow + originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } ) } @@ -58,7 +59,10 @@ struct SQLStatementGeneratorMSSQLTests { rowIndex: Int = 0, originalRow: [String?]? = ["1", "John", "john@example.com"] ) -> RowChange { - RowChange(rowIndex: rowIndex, type: .delete, cellChanges: [], originalRow: originalRow) + RowChange( + rowIndex: rowIndex, type: .delete, cellChanges: [], + originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } + ) } // MARK: - Placeholder Tests @@ -66,7 +70,7 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT statement uses question mark placeholders") func insertUsesQuestionMarkPlaceholders() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, @@ -99,7 +103,7 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT uses bracket-quoted table and column names") func insertBracketQuoting() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, @@ -118,7 +122,7 @@ struct SQLStatementGeneratorMSSQLTests { @Test("INSERT with multiple columns produces correct number of placeholders") func insertMultipleColumnsPlaceholders() throws { let generator = try makeGenerator(columns: ["id", "name", "email"]) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let statements = generator.generateStatements( from: [makeInsertChange()], insertedRowData: insertedRowData, diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift index 503e910cf..06579548e 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorNoPKTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -252,7 +253,7 @@ struct SQLStatementGeneratorNoPKTests { @Test("INSERT + DELETE without PK — INSERT unaffected") func testInsertDeleteNoPK() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["3", "Bob", "bob@example.com"] ] let changes: [RowChange] = [ diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift index 37ed5edf6..f982d6ee2 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorPKRegressionTests.swift @@ -6,6 +6,7 @@ // for each database type that previously had broken PK detection. // +import TableProPluginKit @testable import TablePro import Testing @@ -31,7 +32,7 @@ struct SQLStatementGeneratorPKRegressionTests { rowIndex: rowIndex, type: .delete, cellChanges: [], - originalRow: originalRow + originalRow: originalRow.map(PluginCellValue.fromOptional) ) } @@ -46,8 +47,8 @@ struct SQLStatementGeneratorPKRegressionTests { RowChange( rowIndex: rowIndex, type: .update, - cellChanges: [CellChange(rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: newValue)], - originalRow: originalRow + cellChanges: [CellChange(rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: PluginCellValue.fromOptional(oldValue), newValue: PluginCellValue.fromOptional(newValue))], + originalRow: originalRow.map(PluginCellValue.fromOptional) ) } diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift index 986c44b4c..54ac2d805 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorParameterStyleTests.swift @@ -37,7 +37,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("PostgreSQL defaults to dollar style") func testPostgreSQLDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -57,7 +57,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("Redshift defaults to dollar style") func testRedshiftDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -74,7 +74,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("DuckDB defaults to dollar style") func testDuckDBDefaultsDollar() throws { let generator = try makeGenerator(databaseType: .duckdb) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -91,7 +91,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("MySQL defaults to questionMark style") func testMySQLDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .mysql) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -109,7 +109,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("SQLite defaults to questionMark style") func testSQLiteDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .sqlite) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -127,7 +127,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("MSSQL defaults to questionMark style") func testMSSQLDefaultsQuestionMark() throws { let generator = try makeGenerator(databaseType: .mssql) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -147,7 +147,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("Dollar style generates $1, $2 placeholders for INSERT") func testDollarStyleInsert() throws { let generator = try makeGenerator(parameterStyle: .dollar) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] @@ -167,7 +167,7 @@ struct SQLStatementGeneratorParameterStyleTests { @Test("QuestionMark style generates ? placeholders for INSERT") func testQuestionMarkStyleInsert() throws { let generator = try makeGenerator(parameterStyle: .questionMark) - let insertedRowData: [Int: [String?]] = [0: ["1", "John", "john@example.com"]] + let insertedRowData: [Int: [PluginCellValue]] = [0: ["1", "John", "john@example.com"]] let changes: [RowChange] = [ RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) ] diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index a58f09dda..523799114 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -34,7 +35,7 @@ struct SQLStatementGeneratorTests { @Test("Simple insert from insertedRowData (MySQL)") func testSimpleInsertMySQL() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -70,7 +71,7 @@ struct SQLStatementGeneratorTests { @Test("Insert with NULL value") func testInsertWithNullValue() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", nil] ] let changes: [RowChange] = [ @@ -92,7 +93,7 @@ struct SQLStatementGeneratorTests { @Test("Insert skips __DEFAULT__ columns") func testInsertSkipsDefaultColumns() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["__DEFAULT__", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -117,7 +118,7 @@ struct SQLStatementGeneratorTests { @Test("Insert with all __DEFAULT__ returns empty") func testInsertAllDefaultReturnsEmpty() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["__DEFAULT__", "__DEFAULT__", "__DEFAULT__"] ] let changes: [RowChange] = [ @@ -164,7 +165,7 @@ struct SQLStatementGeneratorTests { @Test("Insert with SQL function is inlined") func testInsertWithSQLFunction() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "NOW()"] ] let changes: [RowChange] = [ @@ -187,7 +188,7 @@ struct SQLStatementGeneratorTests { @Test("PostgreSQL insert uses $1, $2 placeholders") func testInsertPostgreSQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -210,7 +211,7 @@ struct SQLStatementGeneratorTests { @Test("Table name is quoted with identifier quote") func testTableNameQuoted() throws { let generator = try makeGenerator(tableName: "my_table") - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -231,7 +232,7 @@ struct SQLStatementGeneratorTests { @Test("Column names are quoted") func testColumnNamesQuoted() throws { let generator = try makeGenerator(columns: ["user_id", "full_name", "email_address"]) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -255,7 +256,7 @@ struct SQLStatementGeneratorTests { @Test("Insert multiple rows generates separate statements") func testInsertMultipleRows() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"], 1: ["2", "Jane", "jane@example.com"] ] @@ -642,7 +643,7 @@ struct SQLStatementGeneratorTests { ), RowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["3", "Bob", "bob@example.com"] ] @@ -661,7 +662,7 @@ struct SQLStatementGeneratorTests { @Test("MySQL uses ? for all placeholders") func testMySQLPlaceholders() throws { let generator = try makeGenerator(databaseType: .mysql) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -684,7 +685,7 @@ struct SQLStatementGeneratorTests { @Test("PostgreSQL uses $1, $2, $3 sequentially") func testPostgreSQLSequentialPlaceholders() throws { let generator = try makeGenerator(databaseType: .postgresql) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -709,7 +710,7 @@ struct SQLStatementGeneratorTests { @Test("SQLite uses ? placeholders") func testSQLitePlaceholders() throws { let generator = try makeGenerator(databaseType: .sqlite) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -731,7 +732,7 @@ struct SQLStatementGeneratorTests { @Test("MariaDB uses ? placeholders") func testMariaDBPlaceholders() throws { let generator = try makeGenerator(databaseType: .mariadb) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -755,7 +756,7 @@ struct SQLStatementGeneratorTests { @Test("Insert only processes rows in insertedRowIndices set") func testInsertOnlyProcessesInsertedRows() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"], 1: ["2", "Jane", "jane@example.com"] ] @@ -798,7 +799,7 @@ struct SQLStatementGeneratorTests { @Test("Row not in insertedRowIndices is skipped") func testRowNotInInsertedRowIndicesSkipped() throws { let generator = try makeGenerator() - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -849,7 +850,7 @@ struct SQLStatementGeneratorTests { ), RowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: ["2", "Jane", "jane@example.com"]) ] - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["3", "Bob", "bob@example.com"] ] @@ -909,7 +910,7 @@ struct SQLStatementGeneratorTests { @Test("Redshift insert uses $1, $2 placeholders") func testInsertRedshiftPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -934,7 +935,7 @@ struct SQLStatementGeneratorTests { @Test("Redshift insert uses double-quote identifier quoting") func testInsertRedshiftQuoting() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -1010,7 +1011,7 @@ struct SQLStatementGeneratorTests { @Test("Redshift uses $1, $2, $3 sequentially for insert") func testRedshiftSequentialPlaceholders() throws { let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "John", "john@example.com"] ] let changes: [RowChange] = [ @@ -1107,7 +1108,7 @@ struct SQLStatementGeneratorTests { columns: ["id", "database", "order"], primaryKeyColumns: ["id"] ) - let insertedRowData: [Int: [String?]] = [ + let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["1", "mydb", "5"] ] let changes: [RowChange] = [ diff --git a/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift b/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift index 1aa5f7dff..efc73a7c2 100644 --- a/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift +++ b/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("ClickHouse Connection") diff --git a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift index 83c43fff2..f3c00d98c 100644 --- a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift +++ b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("Cloudflare D1 Driver Helpers") diff --git a/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift b/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift index 88157ca99..d61fc9ed2 100644 --- a/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift +++ b/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("D1 API Response Parsing") diff --git a/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift b/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift index 412e91e77..409050a06 100644 --- a/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift +++ b/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("D1Value JSON Decoding") diff --git a/TableProTests/Core/Concurrency/OnceTaskTests.swift b/TableProTests/Core/Concurrency/OnceTaskTests.swift index 33576a7ef..cd82c589b 100644 --- a/TableProTests/Core/Concurrency/OnceTaskTests.swift +++ b/TableProTests/Core/Concurrency/OnceTaskTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift b/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift index 48d86a8e9..d734ef6d8 100644 --- a/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift +++ b/TableProTests/Core/Database/DatabaseConnectionExternalAccessTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Database/DatabaseManagerObserverTests.swift b/TableProTests/Core/Database/DatabaseManagerObserverTests.swift index dfdac857b..a386ce710 100644 --- a/TableProTests/Core/Database/DatabaseManagerObserverTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerObserverTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Database/DatabaseManagerTests.swift b/TableProTests/Core/Database/DatabaseManagerTests.swift index 049d492b7..423f389ca 100644 --- a/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift index eb8f6791e..873c3d333 100644 --- a/TableProTests/Core/Database/DatabaseManagerVersionTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerVersionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Database/GeometryWKBParserTests.swift b/TableProTests/Core/Database/GeometryWKBParserTests.swift index 0ae716498..42061f965 100644 --- a/TableProTests/Core/Database/GeometryWKBParserTests.swift +++ b/TableProTests/Core/Database/GeometryWKBParserTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - Test Helpers diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 77978ea77..20760415f 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Database/PostgreSQLDriverTests.swift b/TableProTests/Core/Database/PostgreSQLDriverTests.swift index 07c05e90e..9d3aa846e 100644 --- a/TableProTests/Core/Database/PostgreSQLDriverTests.swift +++ b/TableProTests/Core/Database/PostgreSQLDriverTests.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Database/SQLEscapingTests.swift b/TableProTests/Core/Database/SQLEscapingTests.swift index b3ea8d3aa..488cba8e4 100644 --- a/TableProTests/Core/Database/SQLEscapingTests.swift +++ b/TableProTests/Core/Database/SQLEscapingTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift b/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift index 46447d0ad..a7ae8a00f 100644 --- a/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift +++ b/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift @@ -5,6 +5,7 @@ import AppKit import CodeEditTextView +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift index 07ea832c6..540542677 100644 --- a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift +++ b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift b/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift index 4bf2afd95..15b74d192 100644 --- a/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift +++ b/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro enum MCPProtocolHandlerTestSupport { diff --git a/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift index 17203d727..685a06bc0 100644 --- a/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift +++ b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro actor RecordingResponderSink: MCPResponderSink { diff --git a/TableProTests/Core/MCP/Helpers/MCPTestClock.swift b/TableProTests/Core/MCP/Helpers/MCPTestClock.swift index 5fb8342a4..7e3edefa1 100644 --- a/TableProTests/Core/MCP/Helpers/MCPTestClock.swift +++ b/TableProTests/Core/MCP/Helpers/MCPTestClock.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro public actor MCPTestClock: MCPClock { diff --git a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift index 043b4804d..ee443481c 100644 --- a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift +++ b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro actor StubAlwaysAllowAuthenticator: MCPAuthenticator { diff --git a/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift index 37bf1edba..d402bff4e 100644 --- a/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift +++ b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Network @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift index 2901eedbd..9a60c043e 100644 --- a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift +++ b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/MCP/MCPPairingServiceTests.swift b/TableProTests/Core/MCP/MCPPairingServiceTests.swift index 2d375e90e..1f47bcf2a 100644 --- a/TableProTests/Core/MCP/MCPPairingServiceTests.swift +++ b/TableProTests/Core/MCP/MCPPairingServiceTests.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index e3cd12b3b..b15fa7125 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift index eb946ae0f..d26d8d469 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift index 3cdfbf61a..56d16bf33 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift index 62bf1bbaf..84a180aff 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift index 915d247f7..073ee87f0 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift index 01364de1c..91ec7e74c 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift index 9b789812a..83d236cea 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift index 602dc87df..bf0eb6048 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift index 6e3ea4418..d8d5baa0a 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift b/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift index 65cb1e6db..d86adb594 100644 --- a/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift b/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift index 0470062cd..0ab47a335 100644 --- a/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift index 1373b13e5..a36edfcbe 100644 --- a/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift b/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift index a7830f8e1..c455a4f16 100644 --- a/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift index 9d9184806..a32f84e6a 100644 --- a/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift index 30c8c2600..b20dcad82 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift index be9518fa0..609e4c7cc 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift index 104628384..a76224819 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift index 94868ab05..bfdc5e820 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift index 95bc91522..fb04e0f20 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift index 9f43d26a9..75d1eea07 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift index 34a2edc6b..b85741aaa 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift index 4434a9f59..7f7697b77 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift index 716c5d7ce..6b6c0eb1d 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift index d8625f549..e652227b1 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift index d0fb0fd9b..70bbe0eb7 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift index d37fc0995..fad1a5f5d 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift index 912b898cb..c4ac992db 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift index d0ee51701..f624613a8 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift index 870f3519b..0ec6b3c26 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift index 12e30a160..0c79b84d1 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift index 55264853d..df8a5479b 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift index 4852ddc20..b2e655531 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift index 3f8493276..e61a3aecf 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift b/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift index 3f8a3beb8..6f6c2da4c 100644 --- a/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift +++ b/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift b/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift index f351c2556..07b037bf2 100644 --- a/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift +++ b/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Session/MCPSessionTests.swift b/TableProTests/Core/MCP/Session/MCPSessionTests.swift index 20b3ef2df..b69f31534 100644 --- a/TableProTests/Core/MCP/Session/MCPSessionTests.swift +++ b/TableProTests/Core/MCP/Session/MCPSessionTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift index d722f9628..2d4e8c57c 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift index 41489c83b..aa1b9b9c1 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index f4ad3d169..a2c495b80 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift index 79695a86c..93c55fb9e 100644 --- a/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift index dda06aba2..5bd2fb974 100644 --- a/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift index e37991522..427d4ffa1 100644 --- a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Network @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift index 435646df8..88b3189da 100644 --- a/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift +++ b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift b/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift index 0e24d42a1..bee44aa0f 100644 --- a/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift +++ b/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift b/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift index 02bc4e900..9cbc8b624 100644 --- a/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift +++ b/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift b/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift index e4c08d3a0..436cdd397 100644 --- a/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift +++ b/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift index 82a397ca0..fd08bc9d3 100644 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("BSON Document Flattener") diff --git a/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift b/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift index 071bebbc3..42de56008 100644 --- a/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift +++ b/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift @@ -8,6 +8,7 @@ #if canImport(CLibMongoc) import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift b/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift index 832c65ddc..51eccaa02 100644 --- a/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift +++ b/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift @@ -6,6 +6,7 @@ #if canImport(CLibMongoc) import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift index 919ca7e6d..d9e064660 100644 --- a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift +++ b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift index 37e46d9d8..f886f55a0 100644 --- a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Plugins/RegistryClientURLTests.swift b/TableProTests/Core/Plugins/RegistryClientURLTests.swift index db3379d24..56c0405f2 100644 --- a/TableProTests/Core/Plugins/RegistryClientURLTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientURLTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Plugins/SSLModeStringTests.swift b/TableProTests/Core/Plugins/SSLModeStringTests.swift index e7ba94aee..96d76fe39 100644 --- a/TableProTests/Core/Plugins/SSLModeStringTests.swift +++ b/TableProTests/Core/Plugins/SSLModeStringTests.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Redis/ColumnTypeBadgeLabelTests.swift b/TableProTests/Core/Redis/ColumnTypeBadgeLabelTests.swift index 5c01d3f7c..c9ccea923 100644 --- a/TableProTests/Core/Redis/ColumnTypeBadgeLabelTests.swift +++ b/TableProTests/Core/Redis/ColumnTypeBadgeLabelTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Redis/ExportModelsRedisTests.swift b/TableProTests/Core/Redis/ExportModelsRedisTests.swift index 7359cd2b5..48fdd413d 100644 --- a/TableProTests/Core/Redis/ExportModelsRedisTests.swift +++ b/TableProTests/Core/Redis/ExportModelsRedisTests.swift @@ -1,3 +1,4 @@ +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Redis/ExportServiceRedisTests.swift b/TableProTests/Core/Redis/ExportServiceRedisTests.swift index 463dfe32a..8806d1b4c 100644 --- a/TableProTests/Core/Redis/ExportServiceRedisTests.swift +++ b/TableProTests/Core/Redis/ExportServiceRedisTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Redis/RedisCommandParserTests.swift b/TableProTests/Core/Redis/RedisCommandParserTests.swift index 93a11a55e..f9ca4ebcd 100644 --- a/TableProTests/Core/Redis/RedisCommandParserTests.swift +++ b/TableProTests/Core/Redis/RedisCommandParserTests.swift @@ -10,6 +10,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - Key Commands diff --git a/TableProTests/Core/Redis/RedisReplyTests.swift b/TableProTests/Core/Redis/RedisReplyTests.swift index 507e07fdf..52758c21d 100644 --- a/TableProTests/Core/Redis/RedisReplyTests.swift +++ b/TableProTests/Core/Redis/RedisReplyTests.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - stringValue diff --git a/TableProTests/Core/Redis/RedisResultBuildingTests.swift b/TableProTests/Core/Redis/RedisResultBuildingTests.swift index f3dd97e09..39a949eff 100644 --- a/TableProTests/Core/Redis/RedisResultBuildingTests.swift +++ b/TableProTests/Core/Redis/RedisResultBuildingTests.swift @@ -14,6 +14,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - Private Local Helpers (copied from RedisDriverPlugin) diff --git a/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift b/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift index 3d7d18485..3b132be19 100644 --- a/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift +++ b/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift @@ -15,6 +15,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift index b4bdd7605..dce266895 100644 --- a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift +++ b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift index 6cf4bc5b3..70be6ecc0 100644 --- a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift +++ b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift @@ -11,6 +11,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift b/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift index d4931a1f7..e4c8e8124 100644 --- a/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift +++ b/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/HostKeyStoreTests.swift b/TableProTests/Core/SSH/HostKeyStoreTests.swift index 98e16f058..ee09f941e 100644 --- a/TableProTests/Core/SSH/HostKeyStoreTests.swift +++ b/TableProTests/Core/SSH/HostKeyStoreTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/SSH/SSHConfigCacheTests.swift b/TableProTests/Core/SSH/SSHConfigCacheTests.swift index efc28b922..641c402fb 100644 --- a/TableProTests/Core/SSH/SSHConfigCacheTests.swift +++ b/TableProTests/Core/SSH/SSHConfigCacheTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHConfigParserTests.swift b/TableProTests/Core/SSH/SSHConfigParserTests.swift index de29cf4c9..8eb49e313 100644 --- a/TableProTests/Core/SSH/SSHConfigParserTests.swift +++ b/TableProTests/Core/SSH/SSHConfigParserTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHConfigResolverTests.swift b/TableProTests/Core/SSH/SSHConfigResolverTests.swift index a45b65f4f..c25532e7b 100644 --- a/TableProTests/Core/SSH/SSHConfigResolverTests.swift +++ b/TableProTests/Core/SSH/SSHConfigResolverTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHConfigurationTests.swift b/TableProTests/Core/SSH/SSHConfigurationTests.swift index ed6c249c2..8699635fd 100644 --- a/TableProTests/Core/SSH/SSHConfigurationTests.swift +++ b/TableProTests/Core/SSH/SSHConfigurationTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHHostPatternMatcherTests.swift b/TableProTests/Core/SSH/SSHHostPatternMatcherTests.swift index 8cd5c4e0a..2c263e309 100644 --- a/TableProTests/Core/SSH/SSHHostPatternMatcherTests.swift +++ b/TableProTests/Core/SSH/SSHHostPatternMatcherTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHJumpHostTests.swift b/TableProTests/Core/SSH/SSHJumpHostTests.swift index c74525df2..5b1b6e88e 100644 --- a/TableProTests/Core/SSH/SSHJumpHostTests.swift +++ b/TableProTests/Core/SSH/SSHJumpHostTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHMatchExecutorTests.swift b/TableProTests/Core/SSH/SSHMatchExecutorTests.swift index 5de369024..a0d754e36 100644 --- a/TableProTests/Core/SSH/SSHMatchExecutorTests.swift +++ b/TableProTests/Core/SSH/SSHMatchExecutorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHPathUtilitiesTests.swift b/TableProTests/Core/SSH/SSHPathUtilitiesTests.swift index f735edba5..427d2eb9c 100644 --- a/TableProTests/Core/SSH/SSHPathUtilitiesTests.swift +++ b/TableProTests/Core/SSH/SSHPathUtilitiesTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 96f632658..731f5683d 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/SSH/TOTP/Base32Tests.swift b/TableProTests/Core/SSH/TOTP/Base32Tests.swift index 6f395482a..f7cde6771 100644 --- a/TableProTests/Core/SSH/TOTP/Base32Tests.swift +++ b/TableProTests/Core/SSH/TOTP/Base32Tests.swift @@ -3,6 +3,7 @@ // TableProTests // +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift index 5a348c38f..5b982b328 100644 --- a/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift +++ b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift @@ -5,6 +5,7 @@ import XCTest +import TableProPluginKit @testable import TablePro final class TOTPGeneratorTests: XCTestCase { diff --git a/TableProTests/Core/SchemaTracking/StructureChangeManagerPKTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerPKTests.swift index eb76de36c..06fea744e 100644 --- a/TableProTests/Core/SchemaTracking/StructureChangeManagerPKTests.swift +++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerPKTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift index 5b75e83d0..020df04c8 100644 --- a/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift +++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/BlobFormattingServiceTests.swift b/TableProTests/Core/Services/BlobFormattingServiceTests.swift index c9c8a80c4..394283ef1 100644 --- a/TableProTests/Core/Services/BlobFormattingServiceTests.swift +++ b/TableProTests/Core/Services/BlobFormattingServiceTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/CellDisplayFormatterTests.swift b/TableProTests/Core/Services/CellDisplayFormatterTests.swift index 91e70e024..79aa94b1f 100644 --- a/TableProTests/Core/Services/CellDisplayFormatterTests.swift +++ b/TableProTests/Core/Services/CellDisplayFormatterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -18,26 +19,26 @@ struct CellDisplayFormatterTests { @Test("empty string returns empty") func emptyString() { - let result = CellDisplayFormatter.format("", columnType: nil) + let result = CellDisplayFormatter.format(.text(""), columnType: nil) #expect(result == "") } @Test("plain text passes through unchanged") func plainTextPassthrough() { - let result = CellDisplayFormatter.format("hello world", columnType: nil) + let result = CellDisplayFormatter.format(.text("hello world"), columnType: nil) #expect(result == "hello world") } @Test("text with linebreaks is sanitized") func linebreaksSanitized() { - let result = CellDisplayFormatter.format("line1\nline2\rline3", columnType: nil) + let result = CellDisplayFormatter.format(.text("line1\nline2\rline3"), columnType: nil) #expect(result == "line1 line2 line3") } @Test("text over max length is truncated") func longTextTruncated() { let longString = String(repeating: "a", count: CellDisplayFormatter.maxDisplayLength + 100) - let result = CellDisplayFormatter.format(longString, columnType: nil) + let result = CellDisplayFormatter.format(.text(longString), columnType: nil) let expected = String(repeating: "a", count: CellDisplayFormatter.maxDisplayLength) + "..." #expect(result == expected) } @@ -45,13 +46,13 @@ struct CellDisplayFormatterTests { @Test("text at max length is not truncated") func exactMaxLengthNotTruncated() { let exactString = String(repeating: "b", count: CellDisplayFormatter.maxDisplayLength) - let result = CellDisplayFormatter.format(exactString, columnType: nil) + let result = CellDisplayFormatter.format(.text(exactString), columnType: nil) #expect(result == exactString) } @Test("nil column type skips type-specific formatting") func nilColumnType() { - let result = CellDisplayFormatter.format("2024-01-01", columnType: nil) + let result = CellDisplayFormatter.format(.text("2024-01-01"), columnType: nil) #expect(result == "2024-01-01") } } diff --git a/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift b/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift index 07951b9d4..24c1e7de7 100644 --- a/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift +++ b/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index ab7f7b46f..b1a441fd8 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ColumnTypeTests.swift b/TableProTests/Core/Services/ColumnTypeTests.swift index 85c9e89c2..c8c088cd3 100644 --- a/TableProTests/Core/Services/ColumnTypeTests.swift +++ b/TableProTests/Core/Services/ColumnTypeTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index 31527c9bb..d673a61bf 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/ExportStateTests.swift b/TableProTests/Core/Services/ExportStateTests.swift index 04ce126b7..eca8f96e6 100644 --- a/TableProTests/Core/Services/ExportStateTests.swift +++ b/TableProTests/Core/Services/ExportStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift index 4a2285a1b..ccab6207e 100644 --- a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift @@ -5,6 +5,7 @@ import CommonCrypto import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift index 624b04449..275f03e54 100644 --- a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift +++ b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift index 5a7b49031..620137688 100644 --- a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index 82080cefc..c9c510a89 100644 --- a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ImportStateTests.swift b/TableProTests/Core/Services/ImportStateTests.swift index 73365ea01..a4a5d2b9f 100644 --- a/TableProTests/Core/Services/ImportStateTests.swift +++ b/TableProTests/Core/Services/ImportStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift index 6deed8f0a..97d525bd8 100644 --- a/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift +++ b/TableProTests/Core/Services/MariaDBJsonDetectionTests.swift @@ -10,6 +10,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -120,7 +121,7 @@ struct MariaDBJsonDetectionTests { func jsonValueNotHexFormatted() { let jsonValue = "{\"name\":\"test\"}" let columnType = ColumnType.json(rawType: "JSON") - let display = CellDisplayFormatter.format(jsonValue, columnType: columnType) + let display = CellDisplayFormatter.format(.text(jsonValue), columnType: columnType) #expect(display == jsonValue) } @@ -128,7 +129,7 @@ struct MariaDBJsonDetectionTests { func textValueNotHexFormatted() { let textValue = "{\"name\":\"test\"}" let columnType = ColumnType.text(rawType: "LONGTEXT") - let display = CellDisplayFormatter.format(textValue, columnType: columnType) + let display = CellDisplayFormatter.format(.text(textValue), columnType: columnType) #expect(display == textValue) } @@ -136,7 +137,7 @@ struct MariaDBJsonDetectionTests { func blobValueIsHexFormatted() { let blobValue = "hello" let columnType = ColumnType.blob(rawType: "BLOB") - let display = CellDisplayFormatter.format(blobValue, columnType: columnType) + let display = CellDisplayFormatter.format(.text(blobValue), columnType: columnType) #expect(display != blobValue) } @@ -144,7 +145,7 @@ struct MariaDBJsonDetectionTests { func jsonWithNewlinesSanitized() { let jsonValue = "{\n \"name\": \"test\"\n}" let columnType = ColumnType.json(rawType: "JSON") - let display = CellDisplayFormatter.format(jsonValue, columnType: columnType) + let display = CellDisplayFormatter.format(.text(jsonValue), columnType: columnType) // Newlines replaced by sanitizedForCellDisplay, but no hex encoding #expect(display?.contains("0x") != true) #expect(display?.contains("name") == true) diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index 96347316a..da964e44e 100644 --- a/TableProTests/Core/Services/Query/QueryExecutorTests.swift +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift index 527fbf246..bc61f5868 100644 --- a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift +++ b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index 89abda3d3..e29d71ebd 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -43,7 +44,7 @@ struct RowOperationsManagerCopyTests { private func makeTableRows(rows: [[String?]], columns: [String]? = nil) -> TableRows { let cols = columns ?? Self.defaultColumns let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: cols.count) - return TableRows.from(queryRows: rows, columns: cols, columnTypes: columnTypes) + return TableRows.from(queryRows: rows.map { row in row.map(PluginCellValue.fromOptional) }, columns: cols, columnTypes: columnTypes) } private func copyAndCapture( diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index ce6f969d0..67790adc0 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -24,8 +25,10 @@ struct RowOperationsManagerTests { } private func makeTableRows(rowCount: Int) -> TableRows { - TableRows.from( - queryRows: TestFixtures.makeRows(count: rowCount, columns: Self.testColumns), + let raw = TestFixtures.makeRows(count: rowCount, columns: Self.testColumns) + let typed = raw.map { row in row.map(PluginCellValue.fromOptional) } + return TableRows.from( + queryRows: typed, columns: Self.testColumns, columnTypes: Self.testColumnTypes ) diff --git a/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/TableProTests/Core/Services/SQLFormatterServiceTests.swift index 368e8bbec..de8cf237e 100644 --- a/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/SQLParameterInlinerTests.swift b/TableProTests/Core/Services/SQLParameterInlinerTests.swift index e59dd7d33..efbe6f14e 100644 --- a/TableProTests/Core/Services/SQLParameterInlinerTests.swift +++ b/TableProTests/Core/Services/SQLParameterInlinerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/SQLTokenizerTests.swift b/TableProTests/Core/Services/SQLTokenizerTests.swift index 045f979c7..997be5bff 100644 --- a/TableProTests/Core/Services/SQLTokenizerTests.swift +++ b/TableProTests/Core/Services/SQLTokenizerTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/SafeModeGuardTests.swift b/TableProTests/Core/Services/SafeModeGuardTests.swift index 603929b41..ca5ed3987 100644 --- a/TableProTests/Core/Services/SafeModeGuardTests.swift +++ b/TableProTests/Core/Services/SafeModeGuardTests.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift index 767818fca..ef15e94b5 100644 --- a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index cfcbd66bb..c8190bf53 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift index c303b6371..7a782fd5a 100644 --- a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index ff6699b2b..61a2d5a25 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift b/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift index a513721e8..b03b145eb 100644 --- a/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift index 3dcb03762..4c5012263 100644 --- a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift +++ b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift @@ -5,6 +5,7 @@ import AppKit import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index 9fcb4c10a..8736097b8 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -12,6 +12,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Storage/AIChatStorageTests.swift b/TableProTests/Core/Storage/AIChatStorageTests.swift index 009615d7a..aa98065b5 100644 --- a/TableProTests/Core/Storage/AIChatStorageTests.swift +++ b/TableProTests/Core/Storage/AIChatStorageTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift b/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift index ec128483b..e989be1b0 100644 --- a/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift +++ b/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift index 64aa1a26f..24b28867b 100644 --- a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift +++ b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift index 12a28660a..4048c1b46 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAIFieldsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift index fd1574c2f..3fbb2c3b7 100644 --- a/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift +++ b/TableProTests/Core/Storage/ConnectionStorageAdditionalFieldsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 98a721aef..cb03c2bbe 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift b/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift index ffe6c54da..215141fb1 100644 --- a/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift +++ b/TableProTests/Core/Storage/CustomSlashCommandStorageTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/DateFilterTests.swift b/TableProTests/Core/Storage/DateFilterTests.swift index 0a5bccaf1..bc17381c7 100644 --- a/TableProTests/Core/Storage/DateFilterTests.swift +++ b/TableProTests/Core/Storage/DateFilterTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 3483e6912..35d1d3be6 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/Storage/KeychainAccessControlTests.swift b/TableProTests/Core/Storage/KeychainAccessControlTests.swift index dbb108192..74a052911 100644 --- a/TableProTests/Core/Storage/KeychainAccessControlTests.swift +++ b/TableProTests/Core/Storage/KeychainAccessControlTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Security import Testing @testable import TablePro diff --git a/TableProTests/Core/Storage/KeychainHelperTests.swift b/TableProTests/Core/Storage/KeychainHelperTests.swift index f3be30e1c..79268b26a 100644 --- a/TableProTests/Core/Storage/KeychainHelperTests.swift +++ b/TableProTests/Core/Storage/KeychainHelperTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift index 2e9e810f1..6117ac495 100644 --- a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift index 00492d8f5..8e84f911e 100644 --- a/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift +++ b/TableProTests/Core/Storage/SQLFavoriteStorageTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index 9c398e64f..450d52e8d 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Sync/CloudKitSyncEngineTests.swift b/TableProTests/Core/Sync/CloudKitSyncEngineTests.swift index 16c6ebae0..d8b7d161c 100644 --- a/TableProTests/Core/Sync/CloudKitSyncEngineTests.swift +++ b/TableProTests/Core/Sync/CloudKitSyncEngineTests.swift @@ -11,6 +11,7 @@ import CloudKit import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Terminal/CLICommandResolverTests.swift b/TableProTests/Core/Terminal/CLICommandResolverTests.swift index 887d52822..ee4511aa5 100644 --- a/TableProTests/Core/Terminal/CLICommandResolverTests.swift +++ b/TableProTests/Core/Terminal/CLICommandResolverTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterSSHProfileTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterSSHProfileTests.swift index 468ff21b1..adcab9a57 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterSSHProfileTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterSSHProfileTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index e0c113b2b..3f31a1207 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift index 87638f8c3..3db58246e 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index 291ddb98f..64f16b384 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index d0d67ed77..40c8e1dde 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Utilities/JsonRowConverterTests.swift b/TableProTests/Core/Utilities/JsonRowConverterTests.swift index 2a84f40d7..3139326e6 100644 --- a/TableProTests/Core/Utilities/JsonRowConverterTests.swift +++ b/TableProTests/Core/Utilities/JsonRowConverterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -110,7 +111,7 @@ struct JsonRowConverterTests { func validJsonColumn() { let converter = makeConverter(columns: ["data"], columnTypes: [.json(rawType: nil)]) let jsonValue = "{\"key\":\"value\"}" - let result = converter.generateJson(rows: [[jsonValue]]) + let result = converter.generateJson(rows: [[.text(jsonValue)]]) #expect(result.contains(": {\"key\":\"value\"}")) } @@ -167,7 +168,7 @@ struct JsonRowConverterTests { func rowCap() { let converter = makeConverter(columns: ["id"], columnTypes: [.text(rawType: nil)]) let marker = "MARKER_VAL" - let rows = Array(repeating: [marker] as [String?], count: 50_001) + let rows = Array(repeating: [PluginCellValue.text(marker)], count: 50_001) let result = converter.generateJson(rows: rows) let count = result.components(separatedBy: marker).count - 1 #expect(count == 50_000) diff --git a/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift index aceed9137..a45a7b1f9 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift index 18448ddb6..c267e0112 100644 --- a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import TableProPluginKit @testable import TablePro import XCTest diff --git a/TableProTests/Core/Validation/SettingsValidationTests.swift b/TableProTests/Core/Validation/SettingsValidationTests.swift index 357f22c74..51a26aa3e 100644 --- a/TableProTests/Core/Validation/SettingsValidationTests.swift +++ b/TableProTests/Core/Validation/SettingsValidationTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Vim/VimEngineTests.swift b/TableProTests/Core/Vim/VimEngineTests.swift index c6727ea5f..8bb8c167f 100644 --- a/TableProTests/Core/Vim/VimEngineTests.swift +++ b/TableProTests/Core/Vim/VimEngineTests.swift @@ -6,6 +6,7 @@ // import XCTest +import TableProPluginKit @testable import TablePro // swiftlint:disable file_length type_body_length diff --git a/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift b/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift index 4646542f9..192cd38b8 100644 --- a/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift +++ b/TableProTests/Core/Vim/VimKeyInterceptorFocusTests.swift @@ -5,6 +5,7 @@ // Regression tests for VimKeyInterceptor focus lifecycle // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift b/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift index 40023247b..ab850c87b 100644 --- a/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift +++ b/TableProTests/Core/Vim/VimTextBufferAdapterPerfTests.swift @@ -8,6 +8,7 @@ import AppKit import CodeEditTextView +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Vim/VimTextBufferMock.swift b/TableProTests/Core/Vim/VimTextBufferMock.swift index 1469ac22c..612d54195 100644 --- a/TableProTests/Core/Vim/VimTextBufferMock.swift +++ b/TableProTests/Core/Vim/VimTextBufferMock.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro @MainActor diff --git a/TableProTests/Core/Vim/VimVisualModeTests.swift b/TableProTests/Core/Vim/VimVisualModeTests.swift index 819cdb804..8a3c88711 100644 --- a/TableProTests/Core/Vim/VimVisualModeTests.swift +++ b/TableProTests/Core/Vim/VimVisualModeTests.swift @@ -6,6 +6,7 @@ // import XCTest +import TableProPluginKit @testable import TablePro // swiftlint:disable file_length type_body_length diff --git a/TableProTests/Database/ConnectionStringParserTests.swift b/TableProTests/Database/ConnectionStringParserTests.swift index b7c0dd9dd..faed0940e 100644 --- a/TableProTests/Database/ConnectionStringParserTests.swift +++ b/TableProTests/Database/ConnectionStringParserTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/DateExtensionsTests.swift b/TableProTests/Extensions/DateExtensionsTests.swift index fbe93b142..d91e09934 100644 --- a/TableProTests/Extensions/DateExtensionsTests.swift +++ b/TableProTests/Extensions/DateExtensionsTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/NSViewFocusTests.swift b/TableProTests/Extensions/NSViewFocusTests.swift index 24384209e..2b5ef62d3 100644 --- a/TableProTests/Extensions/NSViewFocusTests.swift +++ b/TableProTests/Extensions/NSViewFocusTests.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/StringHexDumpTests.swift b/TableProTests/Extensions/StringHexDumpTests.swift index 7db5f872e..21cdc9d44 100644 --- a/TableProTests/Extensions/StringHexDumpTests.swift +++ b/TableProTests/Extensions/StringHexDumpTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/StringJsonTests.swift b/TableProTests/Extensions/StringJsonTests.swift index 985a07261..bf6afb87a 100644 --- a/TableProTests/Extensions/StringJsonTests.swift +++ b/TableProTests/Extensions/StringJsonTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/StringSHA256Tests.swift b/TableProTests/Extensions/StringSHA256Tests.swift index 00453690a..2e0744cfd 100644 --- a/TableProTests/Extensions/StringSHA256Tests.swift +++ b/TableProTests/Extensions/StringSHA256Tests.swift @@ -7,6 +7,7 @@ import CryptoKit import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Extensions/URLSanitizationTests.swift b/TableProTests/Extensions/URLSanitizationTests.swift index 9b16a0696..8a640cf8c 100644 --- a/TableProTests/Extensions/URLSanitizationTests.swift +++ b/TableProTests/Extensions/URLSanitizationTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Helpers/SQLTestHelpers.swift b/TableProTests/Helpers/SQLTestHelpers.swift index 871f70f0a..817c49c21 100644 --- a/TableProTests/Helpers/SQLTestHelpers.swift +++ b/TableProTests/Helpers/SQLTestHelpers.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index 0038c99ab..e4801b9f3 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -60,8 +61,8 @@ enum TestFixtures { rowIndex: row, columnIndex: col, columnName: colName, - oldValue: old, - newValue: new + oldValue: PluginCellValue.fromOptional(old), + newValue: PluginCellValue.fromOptional(new) ) } @@ -75,7 +76,7 @@ enum TestFixtures { rowIndex: row, type: type, cellChanges: cells, - originalRow: originalRow + originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } ) } @@ -211,8 +212,9 @@ enum TestFixtures { static func makeTableRows(rowCount: Int = 3, columns: [String] = ["id", "name", "email"]) -> TableRows { let rows = makeRows(count: rowCount, columns: columns) + let typedRows = rows.map { row in row.map(PluginCellValue.fromOptional) } let columnTypes = Array(repeating: ColumnType.text(rawType: nil), count: columns.count) - return TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) + return TableRows.from(queryRows: typedRows, columns: columns, columnTypes: columnTypes) } static func makeForeignKeyInfo( diff --git a/TableProTests/Models/AIConversationTests.swift b/TableProTests/Models/AIConversationTests.swift index 18cfe4f49..5527b78ab 100644 --- a/TableProTests/Models/AIConversationTests.swift +++ b/TableProTests/Models/AIConversationTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/AISettingsTests.swift b/TableProTests/Models/AISettingsTests.swift index 19f0d9c1f..1b4027777 100644 --- a/TableProTests/Models/AISettingsTests.swift +++ b/TableProTests/Models/AISettingsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/ColumnLayoutStateTests.swift b/TableProTests/Models/ColumnLayoutStateTests.swift index 1b98fea7b..dc272711e 100644 --- a/TableProTests/Models/ColumnLayoutStateTests.swift +++ b/TableProTests/Models/ColumnLayoutStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/ConnectionGroupTreeTests.swift b/TableProTests/Models/ConnectionGroupTreeTests.swift index b03cb6cc8..47633d4fe 100644 --- a/TableProTests/Models/ConnectionGroupTreeTests.swift +++ b/TableProTests/Models/ConnectionGroupTreeTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index 0c843fef4..f5c51eef7 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift index 12bf1f8bc..37c81be2e 100644 --- a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift +++ b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/DatabaseConnectionSSHTests.swift b/TableProTests/Models/DatabaseConnectionSSHTests.swift index 2808ef115..96e3b3a2f 100644 --- a/TableProTests/Models/DatabaseConnectionSSHTests.swift +++ b/TableProTests/Models/DatabaseConnectionSSHTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/DatabaseTypeCassandraTests.swift b/TableProTests/Models/DatabaseTypeCassandraTests.swift index 593e2ac26..0ad36d743 100644 --- a/TableProTests/Models/DatabaseTypeCassandraTests.swift +++ b/TableProTests/Models/DatabaseTypeCassandraTests.swift @@ -1,3 +1,4 @@ +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/DatabaseTypeMSSQLTests.swift b/TableProTests/Models/DatabaseTypeMSSQLTests.swift index 65b1d63ba..7b2f9d882 100644 --- a/TableProTests/Models/DatabaseTypeMSSQLTests.swift +++ b/TableProTests/Models/DatabaseTypeMSSQLTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift index 5ace3b5b6..0f0cc44c2 100644 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ b/TableProTests/Models/DatabaseTypeRedisTests.swift @@ -1,3 +1,4 @@ +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 75a3625aa..9b33f5e36 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/EditorTabPayloadTests.swift b/TableProTests/Models/EditorTabPayloadTests.swift index a06b855da..d95705a75 100644 --- a/TableProTests/Models/EditorTabPayloadTests.swift +++ b/TableProTests/Models/EditorTabPayloadTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/ExportModelsTests.swift b/TableProTests/Models/ExportModelsTests.swift index af02f14ce..0826a1f63 100644 --- a/TableProTests/Models/ExportModelsTests.swift +++ b/TableProTests/Models/ExportModelsTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/LicenseTests.swift b/TableProTests/Models/LicenseTests.swift index 175005695..a1e75c2ae 100644 --- a/TableProTests/Models/LicenseTests.swift +++ b/TableProTests/Models/LicenseTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index 874cbdb08..50ab7ff1a 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -672,7 +673,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.updateField(at: 1, value: "Bob") @@ -688,7 +689,7 @@ struct MultiRowEditStateTests { var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } // Revert back to original "Alice" -- should fire because hadPendingEdit was true @@ -703,7 +704,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } // Setting to same original value with no prior edit -- should NOT fire @@ -718,7 +719,7 @@ struct MultiRowEditStateTests { var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } // Revert to original "1" -- hadPendingEdit was true (isPendingNull) @@ -735,7 +736,7 @@ struct MultiRowEditStateTests { var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } // Revert to original "1" -- hadPendingEdit was true (isPendingDefault) @@ -750,7 +751,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.setFieldToNull(at: 0) @@ -764,7 +765,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.setFieldToDefault(at: 0) @@ -778,7 +779,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.setFieldToFunction(at: 0, function: "NOW()") @@ -792,7 +793,7 @@ struct MultiRowEditStateTests { let sut = makeSUT() var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.setFieldToEmpty(at: 0) @@ -809,7 +810,7 @@ struct MultiRowEditStateTests { var callbackCalls: [(index: Int, value: String?)] = [] sut.onFieldChanged = { index, value in - callbackCalls.append((index, value)) + callbackCalls.append((index, value.asText)) } sut.clearEdits() diff --git a/TableProTests/Models/MultiRowEditStateTruncationTests.swift b/TableProTests/Models/MultiRowEditStateTruncationTests.swift index 62e0852d3..7024470c2 100644 --- a/TableProTests/Models/MultiRowEditStateTruncationTests.swift +++ b/TableProTests/Models/MultiRowEditStateTruncationTests.swift @@ -5,6 +5,7 @@ // Tests for truncation support in MultiRowEditState. // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/PaginationStateTests.swift b/TableProTests/Models/PaginationStateTests.swift index 23426d101..844579d1f 100644 --- a/TableProTests/Models/PaginationStateTests.swift +++ b/TableProTests/Models/PaginationStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift index 8fcf8a11f..82f7d7532 100644 --- a/TableProTests/Models/PreviewTabTests.swift +++ b/TableProTests/Models/PreviewTabTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Query/DeltaTests.swift b/TableProTests/Models/Query/DeltaTests.swift index e0dac47e2..79fd0a617 100644 --- a/TableProTests/Models/Query/DeltaTests.swift +++ b/TableProTests/Models/Query/DeltaTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Query/QueryTabManagerTests.swift b/TableProTests/Models/Query/QueryTabManagerTests.swift index 9987fc167..c7c789761 100644 --- a/TableProTests/Models/Query/QueryTabManagerTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerTests.swift @@ -10,6 +10,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/Query/RowTests.swift b/TableProTests/Models/Query/RowTests.swift index d63d8caf8..0aa1bdcb9 100644 --- a/TableProTests/Models/Query/RowTests.swift +++ b/TableProTests/Models/Query/RowTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift index 24ede107b..6c5b75d2d 100644 --- a/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/Query/TabSessionTests.swift b/TableProTests/Models/Query/TabSessionTests.swift index 52fe547ec..33e337010 100644 --- a/TableProTests/Models/Query/TabSessionTests.swift +++ b/TableProTests/Models/Query/TabSessionTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/Query/TabStructureVersionTests.swift b/TableProTests/Models/Query/TabStructureVersionTests.swift index 25a009fb5..69aa6cbac 100644 --- a/TableProTests/Models/Query/TabStructureVersionTests.swift +++ b/TableProTests/Models/Query/TabStructureVersionTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index ab2187010..2e96cb3a0 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/QueryHistoryEntryTests.swift b/TableProTests/Models/QueryHistoryEntryTests.swift index 965285e71..9c3ddd365 100644 --- a/TableProTests/Models/QueryHistoryEntryTests.swift +++ b/TableProTests/Models/QueryHistoryEntryTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/RedisKeyTreeNodeTests.swift b/TableProTests/Models/RedisKeyTreeNodeTests.swift index 17343f3c3..4655d2905 100644 --- a/TableProTests/Models/RedisKeyTreeNodeTests.swift +++ b/TableProTests/Models/RedisKeyTreeNodeTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/RightPanelStateTests.swift b/TableProTests/Models/RightPanelStateTests.swift index ccfafe56f..53599a52f 100644 --- a/TableProTests/Models/RightPanelStateTests.swift +++ b/TableProTests/Models/RightPanelStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/SQLFileDeduplicationTests.swift b/TableProTests/Models/SQLFileDeduplicationTests.swift index c9073b433..990031687 100644 --- a/TableProTests/Models/SQLFileDeduplicationTests.swift +++ b/TableProTests/Models/SQLFileDeduplicationTests.swift @@ -9,6 +9,7 @@ import AppKit import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/SafeModeLevelTests.swift b/TableProTests/Models/SafeModeLevelTests.swift index 357eec5bc..c425c06a2 100644 --- a/TableProTests/Models/SafeModeLevelTests.swift +++ b/TableProTests/Models/SafeModeLevelTests.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/Schema/ColumnDefinitionTests.swift b/TableProTests/Models/Schema/ColumnDefinitionTests.swift index 555be3c42..798b822b1 100644 --- a/TableProTests/Models/Schema/ColumnDefinitionTests.swift +++ b/TableProTests/Models/Schema/ColumnDefinitionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Schema/ForeignKeyDefinitionTests.swift b/TableProTests/Models/Schema/ForeignKeyDefinitionTests.swift index 91c667570..7ed39b746 100644 --- a/TableProTests/Models/Schema/ForeignKeyDefinitionTests.swift +++ b/TableProTests/Models/Schema/ForeignKeyDefinitionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Schema/IndexDefinitionTests.swift b/TableProTests/Models/Schema/IndexDefinitionTests.swift index 53574dde7..3e6c9e6d5 100644 --- a/TableProTests/Models/Schema/IndexDefinitionTests.swift +++ b/TableProTests/Models/Schema/IndexDefinitionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/Schema/SchemaChangeTests.swift b/TableProTests/Models/Schema/SchemaChangeTests.swift index 7de27e559..b000544e8 100644 --- a/TableProTests/Models/Schema/SchemaChangeTests.swift +++ b/TableProTests/Models/Schema/SchemaChangeTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index b7956a7eb..793feddca 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index 2bd37d7d1..9cdc8a424 100644 --- a/TableProTests/Models/SortStateTests.swift +++ b/TableProTests/Models/SortStateTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/TableFilterTests.swift b/TableProTests/Models/TableFilterTests.swift index 5a15d36c5..5d84a44e5 100644 --- a/TableProTests/Models/TableFilterTests.swift +++ b/TableProTests/Models/TableFilterTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/TableInfoTests.swift b/TableProTests/Models/TableInfoTests.swift index 3db49a008..ef7639dd6 100644 --- a/TableProTests/Models/TableInfoTests.swift +++ b/TableProTests/Models/TableInfoTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/TableOperationDialogLogicTests.swift b/TableProTests/Models/TableOperationDialogLogicTests.swift index fff6feecd..e318be698 100644 --- a/TableProTests/Models/TableOperationDialogLogicTests.swift +++ b/TableProTests/Models/TableOperationDialogLogicTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift index aabef4b30..899ff6b62 100644 --- a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Models/UI/FilterPresetStorageTests.swift b/TableProTests/Models/UI/FilterPresetStorageTests.swift index c4905bc94..660a5136a 100644 --- a/TableProTests/Models/UI/FilterPresetStorageTests.swift +++ b/TableProTests/Models/UI/FilterPresetStorageTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Models/UI/KeyComboMatchTests.swift b/TableProTests/Models/UI/KeyComboMatchTests.swift index d62271f62..643250312 100644 --- a/TableProTests/Models/UI/KeyComboMatchTests.swift +++ b/TableProTests/Models/UI/KeyComboMatchTests.swift @@ -1,4 +1,5 @@ import AppKit +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift b/TableProTests/Plugins/BigQueryQueryBuilderTests.swift index b7b3ab33f..75de38153 100644 --- a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift +++ b/TableProTests/Plugins/BigQueryQueryBuilderTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("BigQueryQueryBuilder - Browse Query") diff --git a/TableProTests/Plugins/DynamoDBQueryBuilderTests.swift b/TableProTests/Plugins/DynamoDBQueryBuilderTests.swift index c277fab44..f3cc5fa69 100644 --- a/TableProTests/Plugins/DynamoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/DynamoDBQueryBuilderTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("DynamoDBQueryBuilder - Browse Query") diff --git a/TableProTests/Plugins/EtcdCommandParserTests.swift b/TableProTests/Plugins/EtcdCommandParserTests.swift index fc1c3f3d8..70fdb67ba 100644 --- a/TableProTests/Plugins/EtcdCommandParserTests.swift +++ b/TableProTests/Plugins/EtcdCommandParserTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - KV Commands diff --git a/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift b/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift index c31f18aeb..89455b874 100644 --- a/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift +++ b/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift @@ -11,6 +11,7 @@ // import Foundation +import TableProPluginKit import Testing // MARK: - Base64 Helpers diff --git a/TableProTests/Plugins/EtcdQueryBuilderTests.swift b/TableProTests/Plugins/EtcdQueryBuilderTests.swift index 89be64baa..c3001c222 100644 --- a/TableProTests/Plugins/EtcdQueryBuilderTests.swift +++ b/TableProTests/Plugins/EtcdQueryBuilderTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("EtcdQueryBuilder - Browse Query") diff --git a/TableProTests/Plugins/LibPQByteaDecoderTests.swift b/TableProTests/Plugins/LibPQByteaDecoderTests.swift index 5be4a7a97..c8865c874 100644 --- a/TableProTests/Plugins/LibPQByteaDecoderTests.swift +++ b/TableProTests/Plugins/LibPQByteaDecoderTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("LibPQByteaDecoder - hex format") diff --git a/TableProTests/Plugins/OracleCellFormattingTests.swift b/TableProTests/Plugins/OracleCellFormattingTests.swift index 00e64949e..2abe5fe09 100644 --- a/TableProTests/Plugins/OracleCellFormattingTests.swift +++ b/TableProTests/Plugins/OracleCellFormattingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("Oracle cell formatting") diff --git a/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift b/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift index 538b04db3..142d9241c 100644 --- a/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift +++ b/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("PostgreSQLSchemaQueries.listSchemas") diff --git a/TableProTests/Services/MacAnalyticsProviderTests.swift b/TableProTests/Services/MacAnalyticsProviderTests.swift index fb23326bc..2d1188e08 100644 --- a/TableProTests/Services/MacAnalyticsProviderTests.swift +++ b/TableProTests/Services/MacAnalyticsProviderTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Services/SampleDatabaseServiceTests.swift b/TableProTests/Services/SampleDatabaseServiceTests.swift index 95e4bb747..768bd6dd4 100644 --- a/TableProTests/Services/SampleDatabaseServiceTests.swift +++ b/TableProTests/Services/SampleDatabaseServiceTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index ec0965577..e3b9c99ba 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Theme/ThemeDefinitionTests.swift b/TableProTests/Theme/ThemeDefinitionTests.swift index 4331f5313..70a48b051 100644 --- a/TableProTests/Theme/ThemeDefinitionTests.swift +++ b/TableProTests/Theme/ThemeDefinitionTests.swift @@ -8,6 +8,7 @@ import AppKit import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Utilities/FuzzyMatcherTests.swift b/TableProTests/Utilities/FuzzyMatcherTests.swift index fdba0a5a2..577dd9aac 100644 --- a/TableProTests/Utilities/FuzzyMatcherTests.swift +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -5,6 +5,7 @@ // Tests for FuzzyMatcher fuzzy string matching // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Utilities/MemoryPressureAdvisorTests.swift b/TableProTests/Utilities/MemoryPressureAdvisorTests.swift index 5fe6c457d..1eee08fd1 100644 --- a/TableProTests/Utilities/MemoryPressureAdvisorTests.swift +++ b/TableProTests/Utilities/MemoryPressureAdvisorTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Utilities/RowSortComparatorTests.swift b/TableProTests/Utilities/RowSortComparatorTests.swift index 19702952d..e3a8355d4 100644 --- a/TableProTests/Utilities/RowSortComparatorTests.swift +++ b/TableProTests/Utilities/RowSortComparatorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/ViewModels/AIChatViewModelActionTests.swift b/TableProTests/ViewModels/AIChatViewModelActionTests.swift index 621e187be..635e65fca 100644 --- a/TableProTests/ViewModels/AIChatViewModelActionTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelActionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift b/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift index e81fe1515..938e193d8 100644 --- a/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/ViewModels/AIChatViewModelSlashTests.swift b/TableProTests/ViewModels/AIChatViewModelSlashTests.swift index a13c110a2..096004510 100644 --- a/TableProTests/ViewModels/AIChatViewModelSlashTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelSlashTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift index 1f04c80ed..45229c0c0 100644 --- a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift +++ b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 9e21735e1..7243e89cc 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -5,6 +5,7 @@ // Tests for QuickSwitcherViewModel filtering and navigation // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index f153a07c0..20a7fb14d 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit import SwiftUI import Testing @testable import TablePro diff --git a/TableProTests/Views/AIChat/AIChatCodeBlockDetectionTests.swift b/TableProTests/Views/AIChat/AIChatCodeBlockDetectionTests.swift index e90523fa6..a732a216e 100644 --- a/TableProTests/Views/AIChat/AIChatCodeBlockDetectionTests.swift +++ b/TableProTests/Views/AIChat/AIChatCodeBlockDetectionTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Components/HighlightCapTests.swift b/TableProTests/Views/Components/HighlightCapTests.swift index 4d5ce15c6..3bd5504af 100644 --- a/TableProTests/Views/Components/HighlightCapTests.swift +++ b/TableProTests/Views/Components/HighlightCapTests.swift @@ -9,6 +9,7 @@ // import AppKit +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift b/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift index 914ad337b..d17b57c34 100644 --- a/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift +++ b/TableProTests/Views/Components/IntegrationStatusIndicatorTests.swift @@ -1,3 +1,4 @@ +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Editor/GutterHighlightTests.swift b/TableProTests/Views/Editor/GutterHighlightTests.swift index b29c7c7ef..e592e00f5 100644 --- a/TableProTests/Views/Editor/GutterHighlightTests.swift +++ b/TableProTests/Views/Editor/GutterHighlightTests.swift @@ -13,6 +13,7 @@ import AppKit import CodeEditLanguages @testable import CodeEditSourceEditor import CodeEditTextView +import TableProPluginKit import Testing @MainActor diff --git a/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift b/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift index a146fae48..b6639c83d 100644 --- a/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift +++ b/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Editor/LineCutCalculatorTests.swift b/TableProTests/Views/Editor/LineCutCalculatorTests.swift index 7e8b4a378..31c4750ca 100644 --- a/TableProTests/Views/Editor/LineCutCalculatorTests.swift +++ b/TableProTests/Views/Editor/LineCutCalculatorTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift index 1e6855429..263eff552 100644 --- a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -5,6 +5,7 @@ // Regression tests for fuzzy matching used by autocomplete. // +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift index 417efa05e..89dd16f8f 100644 --- a/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift index df03e2c89..ef5bd4f12 100644 --- a/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift index 4666e14c3..436a527c6 100644 --- a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift +++ b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/History/UIDateFilterTests.swift b/TableProTests/Views/History/UIDateFilterTests.swift index a55c8ea5b..72958670e 100644 --- a/TableProTests/Views/History/UIDateFilterTests.swift +++ b/TableProTests/Views/History/UIDateFilterTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index d6a30978b..29ebe2e13 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import SwiftUI import Testing @testable import TablePro diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index f52e49a9a..f7e7cecd8 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index f6a8e40a4..7b11cd134 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift index 043fe3e4f..3ac8a5981 100644 --- a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift +++ b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index c9429e1d9..65f11b09e 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -6,6 +6,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -37,7 +38,7 @@ struct EvictionTests { let tabId = tabManager.tabs[index].id let columns = ["id", "name", "email"] let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) - let tableRows = TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) + let tableRows = TableRows.from(queryRows: rows.map { row in row.map(PluginCellValue.fromOptional) }, columns: columns, columnTypes: columnTypes) coordinator.setActiveTableRows(tableRows, for: tabId) tabManager.tabs[index].execution.lastExecutedAt = Date() } diff --git a/TableProTests/Views/Main/ExtractTableNameTests.swift b/TableProTests/Views/Main/ExtractTableNameTests.swift index ae5b71e66..ec561a050 100644 --- a/TableProTests/Views/Main/ExtractTableNameTests.swift +++ b/TableProTests/Views/Main/ExtractTableNameTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 59f56c3d8..7b6fc8aec 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -9,6 +9,7 @@ // import Foundation +import TableProPluginKit import Testing @testable import TablePro @@ -63,7 +64,7 @@ struct MainContentCoordinatorLazyLoadTests { ) { let rows = (0.. Date: Mon, 11 May 2026 01:37:12 +0700 Subject: [PATCH 06/11] docs(changelog): describe end-to-end typed binary cell pipeline --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e371c316..611c5bc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,7 @@ 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). Display layer reads byte counts and hex previews directly from the binary contract, fixing wrong BYTEA hex preview and wrong byte count on the cell display, sidebar, and hex editor (#1188). +- 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 From c3482af9c830cbed1ad5fc7b066c1ebc67b45ccd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:40:46 +0700 Subject: [PATCH 07/11] fix(datagrid): popover hex editor reads typed bytes --- .../Views/Results/Extensions/DataGridView+Popovers.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index bf4f57780..be0647a2a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -146,7 +146,13 @@ extension TableViewCoordinator { } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = cellValue(at: row, column: columnIndex) + let typed = cellTypedValue(at: row, column: columnIndex) + let currentValue: String? + switch typed { + case .null: currentValue = nil + case .text(let s): currentValue = s + case .bytes(let data): currentValue = String(data: data, encoding: .isoLatin1) + } guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } From 05c7a65712eaa81709310f12c1fd029480df9cb7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:43:39 +0700 Subject: [PATCH 08/11] fix(datagrid): copy-to-clipboard formats binary cells as hex --- .../Core/Services/Query/RowOperationsManager.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index fdae80bf4..906741553 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -262,9 +262,16 @@ final class RowOperationsManager { for rowIndex in indicesToCopy { guard rowIndex < tableRows.count else { continue } if !result.isEmpty { result.append("\n") } - for (colIdx, value) in tableRows.rows[rowIndex].values.enumerated() { + for (colIdx, cell) in tableRows.rows[rowIndex].values.enumerated() { if colIdx > 0 { result.append("\t") } - result.append(value.asText ?? "NULL") + switch cell { + case .null: + result.append("NULL") + case .text(let s): + result.append(s) + case .bytes(let data): + result.append(BlobFormattingService.shared.format(data, for: .copy) ?? "") + } } } From 295b4246bff0e471d455488e9610db17c0f7182e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 01:55:25 +0700 Subject: [PATCH 09/11] fix(plugins,datagrid): bind binary cells through sort, summary, sidebar, and Copy as INSERT/UPDATE --- .../TableProPluginKit/PluginCellValue.swift | 17 ++++ .../ChangeTracking/DataChangeManager.swift | 2 +- .../Database/LazyLoadColumnsService.swift | 9 ++- .../SQL/SQLRowToStatementConverter.swift | 45 +++++++---- .../Main/Child/MainEditorContentView.swift | 4 +- .../Extensions/MainContentView+Bindings.swift | 12 ++- .../Extensions/MainContentView+Helpers.swift | 13 +++- .../Views/Main/MainContentCoordinator.swift | 8 +- .../Results/DataGridView+RowActions.swift | 10 +-- .../RowOperationsManagerBinaryCopyTests.swift | 78 +++++++++++++++++++ .../SQLRowToStatementConverterTests.swift | 65 +++++++++++++++- .../Plugins/PluginCellValueSortKeyTests.swift | 39 ++++++++++ 12 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift create mode 100644 TableProTests/Plugins/PluginCellValueSortKeyTests.swift diff --git a/Plugins/TableProPluginKit/PluginCellValue.swift b/Plugins/TableProPluginKit/PluginCellValue.swift index cf95af992..c0a892f61 100644 --- a/Plugins/TableProPluginKit/PluginCellValue.swift +++ b/Plugins/TableProPluginKit/PluginCellValue.swift @@ -45,6 +45,23 @@ public extension PluginCellValue { case .bytes(let d): return d } } + + /// String representation suitable for sorting and equality comparison. + /// Binary cells are rendered as uppercase hex without prefix so byte-wise + /// lexicographic order matches a stable sort across runs. + var sortKey: String { + switch self { + case .null: return "" + case .text(let s): return s + case .bytes(let d): + var hex = "" + hex.reserveCapacity(d.count * 2) + for byte in d { + hex += String(format: "%02X", byte) + } + return hex + } + } } extension PluginCellValue: Codable { diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index c880b0dc5..29ba4b3ed 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -409,7 +409,7 @@ final class DataChangeManager: ChangeManaging { deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices ) { - return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters.map { $0.asText }) } + return statements.map { ParameterizedStatement(sql: $0.statement, parameters: $0.parameters.map { $0.asAny }) } } } diff --git a/TablePro/Core/Database/LazyLoadColumnsService.swift b/TablePro/Core/Database/LazyLoadColumnsService.swift index cf4661777..8b5d9da26 100644 --- a/TablePro/Core/Database/LazyLoadColumnsService.swift +++ b/TablePro/Core/Database/LazyLoadColumnsService.swift @@ -56,7 +56,14 @@ struct LazyLoadColumnsService { var dict: [String: String?] = [:] for (index, colName) in excludedColumnNames.enumerated() where index < row.count { - dict[colName] = row[index].asText + switch row[index] { + case .null: + dict[colName] = .some(nil) + case .text(let s): + dict[colName] = .some(s) + case .bytes(let data): + dict[colName] = .some(String(data: data, encoding: .isoLatin1) ?? "") + } } return dict } diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index bae8f2164..c1bea5f21 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -53,7 +53,7 @@ internal struct SQLRowToStatementConverter { return SQLEscaping.escapeStringLiteral } - internal func generateInserts(rows: [[String?]]) -> String { + internal func generateInserts(rows: [[PluginCellValue]]) -> String { let capped = rows.prefix(Self.maxRows) let quotedTable = quoteColumn(tableName) let quotedColumns = columns.map { quoteColumn($0) }.joined(separator: ", ") @@ -64,7 +64,7 @@ internal struct SQLRowToStatementConverter { }.joined(separator: "\n") } - internal func generateUpdates(rows: [[String?]]) -> String { + internal func generateUpdates(rows: [[PluginCellValue]]) -> String { let capped = rows.prefix(Self.maxRows) return capped.map { row in @@ -72,9 +72,7 @@ internal struct SQLRowToStatementConverter { }.joined(separator: "\n") } - // MARK: - Private Helpers - - private func buildUpdateStatement(row: [String?]) -> String { + private func buildUpdateStatement(row: [PluginCellValue]) -> String { let quotedTable = quoteColumn(tableName) let setClause: String @@ -87,25 +85,25 @@ internal struct SQLRowToStatementConverter { let setClauses = columns.enumerated().compactMap { index, col -> String? in guard col != pkColumn else { return nil } - let value = row.indices.contains(index) ? row[index] : nil + let value = row.indices.contains(index) ? row[index] : .null return "\(quoteColumn(col)) = \(formatValue(value))" } setClause = setClauses.joined(separator: ", ") - if pkValue == nil { + if pkValue.isNull { whereClause = "\(quoteColumn(pkColumn)) IS NULL" } else { whereClause = "\(quoteColumn(pkColumn)) = \(formatValue(pkValue))" } } else { let allClauses = columns.enumerated().map { index, col -> String in - let value = row.indices.contains(index) ? row[index] : nil + let value = row.indices.contains(index) ? row[index] : .null return "\(quoteColumn(col)) = \(formatValue(value))" } setClause = allClauses.joined(separator: ", ") let whereParts = columns.enumerated().map { index, col -> String in - let value = row.indices.contains(index) ? row[index] : nil - if value == nil { + let value = row.indices.contains(index) ? row[index] : .null + if value.isNull { return "\(quoteColumn(col)) IS NULL" } return "\(quoteColumn(col)) = \(formatValue(value))" @@ -116,12 +114,31 @@ internal struct SQLRowToStatementConverter { return "UPDATE \(quotedTable) SET \(setClause) WHERE \(whereClause);" } - private func formatValue(_ value: String?) -> String { - guard let value else { + private func formatValue(_ value: PluginCellValue) -> String { + switch value { + case .null: return "NULL" + case .text(let s): + return "'\(escapeStringFn(s))'" + case .bytes(let data): + return formatBinaryLiteral(data) + } + } + + private func formatBinaryLiteral(_ data: Data) -> String { + var hex = "" + hex.reserveCapacity(data.count * 2) + for byte in data { + hex += String(format: "%02X", byte) + } + switch databaseType { + case .postgresql, .redshift: + return "'\\x\(hex)'::bytea" + case .mssql: + return "0x\(hex)" + default: + return "X'\(hex)'" } - let escaped = escapeStringFn(value) - return "'\(escaped)'" } private func quoteColumn(_ name: String) -> String { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8923cece0..3aac2e258 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -691,9 +691,9 @@ struct MainEditorContentView: View { let row2 = storageRows[idx2].values for sortCol in sortColumns { let val1 = sortCol.columnIndex < row1.count - ? (row1[sortCol.columnIndex].asText ?? "") : "" + ? row1[sortCol.columnIndex].sortKey : "" let val2 = sortCol.columnIndex < row2.count - ? (row2[sortCol.columnIndex].asText ?? "") : "" + ? row2[sortCol.columnIndex].sortKey : "" let colType = sortCol.columnIndex < colTypes.count ? colTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(val1, val2, columnType: colType) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 5ce374242..4466f8212 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -28,7 +28,17 @@ extension MainContentView { let tblName = tab.tableContext.tableName for (i, col) in tableRows.columns.enumerated() { - var value: String? = i < row.count ? row[i].asText : nil + var value: String? + if i < row.count { + switch row[i] { + case .null: + value = nil + case .text(let s): + value = s + case .bytes(let data): + value = BlobFormattingService.shared.format(data, for: .copy) + } + } let type = i < tableRows.columnTypes.count ? tableRows.columnTypes[i].displayName : "string" if let rawValue = value { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 059862134..604d56b22 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -118,8 +118,17 @@ extension MainContentView { lines.append(columns.joined(separator: " | ")) for row in displayRows { - let values = columns.indices.map { i in - let raw = i < row.values.count ? (row.values[i].asText ?? "NULL") : "NULL" + let values = columns.indices.map { i -> String in + guard i < row.values.count else { return "NULL" } + let raw: String + switch row.values[i] { + case .null: + raw = "NULL" + case .text(let s): + raw = s + case .bytes(let data): + raw = BlobFormattingService.shared.format(data, for: .copy) ?? "" + } return (raw as NSString).length > 200 ? String(raw.prefix(200)) + "..." : raw } lines.append(values.joined(separator: " | ")) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 71e536a2e..aee87feb3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1297,8 +1297,8 @@ final class MainContentCoordinator { indices.sort { i1, i2 in let row1 = rows[i1].values let row2 = rows[i2].values - let v1 = colIndex < row1.count ? (row1[colIndex].asText ?? "") : "" - let v2 = colIndex < row2.count ? (row2[colIndex].asText ?? "") : "" + let v1 = colIndex < row1.count ? row1[colIndex].sortKey : "" + let v2 = colIndex < row2.count ? row2[colIndex].sortKey : "" let cmp = RowSortComparator.compare(v1, v2, columnType: colType) return ascending ? cmp == .orderedAscending : cmp == .orderedDescending } @@ -1310,8 +1310,8 @@ final class MainContentCoordinator { let row1 = rows[i1].values let row2 = rows[i2].values for sortCol in sortColumns { - let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex].asText ?? "") : "" - let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex].asText ?? "") : "" + let v1 = sortCol.columnIndex < row1.count ? row1[sortCol.columnIndex].sortKey : "" + let v2 = sortCol.columnIndex < row2.count ? row2[sortCol.columnIndex].sortKey : "" let colType = sortCol.columnIndex < columnTypes.count ? columnTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(v1, v2, columnType: colType) diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 08f2069c8..57f6597f7 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -123,9 +123,8 @@ extension TableViewCoordinator { escapeStringLiteral: driver?.escapeStringLiteral ) let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } - let rows: [[String?]] = typedRows.map { row in row.map { $0.asText } } - guard !rows.isEmpty else { return } - ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) + guard !typedRows.isEmpty else { return } + ClipboardService.shared.writeText(converter.generateInserts(rows: typedRows)) } catch { rowActionsLogger.error("copyRowsAsInsert failed: \(error.localizedDescription, privacy: .public)") } @@ -145,9 +144,8 @@ extension TableViewCoordinator { escapeStringLiteral: driver?.escapeStringLiteral ) let typedRows: [[PluginCellValue]] = indices.sorted().compactMap { displayRow(at: $0).map { Array($0.values) } } - let rows: [[String?]] = typedRows.map { row in row.map { $0.asText } } - guard !rows.isEmpty else { return } - ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) + guard !typedRows.isEmpty else { return } + ClipboardService.shared.writeText(converter.generateUpdates(rows: typedRows)) } catch { rowActionsLogger.error("copyRowsAsUpdate failed: \(error.localizedDescription, privacy: .public)") } diff --git a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift new file mode 100644 index 000000000..c14fa49d7 --- /dev/null +++ b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift @@ -0,0 +1,78 @@ +// +// RowOperationsManagerBinaryCopyTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("RowOperationsManager - binary cell copy") +@MainActor +struct RowOperationsManagerBinaryCopyTests { + private func makeManagerAndRows(binaryRow: [PluginCellValue]) -> (RowOperationsManager, TableRows) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumns: ["id"], + databaseType: .postgresql + ) + let rowOps = RowOperationsManager(changeManager: changeManager) + let tableRows = TableRows.from( + queryRows: [binaryRow], + columns: ["id", "payload"], + columnTypes: [.integer(rawType: "INT"), .blob(rawType: "BYTEA")] + ) + return (rowOps, tableRows) + } + + @Test("Issue #1188 row copies binary cell as 0xHEX, not NULL") + func issue1188CopyAsHex() { + let bytes = Data([ + 0xD3, 0x8C, 0xE5, 0x66, 0xB9, 0x67, 0x52, 0x0C, + 0xAF, 0x46, 0x17, 0x47, 0xAB, 0xC7, 0x7D, 0x27, + 0x5F, 0x08, 0x4F, 0x60, 0x16, 0x97, 0xD1, 0xEA, + 0x13, 0x5B, 0x03, 0x61, 0xCA, 0xBB, 0x53, 0x4F, + 0x70, 0x22, 0x02, 0xB9, 0x52, 0xE0, 0x04, 0x47, + 0xB6, 0x75, 0x68, 0x7A, 0xF8, 0xF5, 0xD4, 0x3B + ]) + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(bytes)]) + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) + + let copied = pasteboard.string(forType: .string) ?? "" + #expect(copied.contains("0xD38CE566")) + #expect(!copied.contains("NULL")) + #expect(copied.contains("\t")) + } + + @Test("Empty bytes copies as 0x") + func emptyBytesCopiesAsZeroX() { + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(Data())]) + + NSPasteboard.general.clearContents() + rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) + let copied = NSPasteboard.general.string(forType: .string) ?? "" + + #expect(copied.contains("0x") || copied.hasSuffix("\t")) + #expect(!copied.contains("NULL")) + } + + @Test("Mixed null and binary preserves both") + func mixedNullAndBytes() { + let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.null, .bytes(Data([0xAA, 0xBB]))]) + + NSPasteboard.general.clearContents() + rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) + let copied = NSPasteboard.general.string(forType: .string) ?? "" + + #expect(copied.contains("NULL")) + #expect(copied.contains("0xAABB")) + } +} diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index bf49ec4d3..d29ceb189 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -81,7 +81,7 @@ struct SQLRowToStatementConverterTests { @Test("Multiple rows are joined by newlines") func insertMultipleRows() throws { let converter = try makeConverter() - let rows: [[String?]] = [ + let rows: [[PluginCellValue]] = [ ["1", "Alice", "alice@example.com"], ["2", "Bob", "bob@example.com"] ] @@ -222,9 +222,70 @@ struct SQLRowToStatementConverterTests { columns: ["id", "name"], primaryKeyColumn: "id" ) - let rows: [[String?]] = (1...50_001).map { i in ["\(i)", "name\(i)"] } + let rows: [[PluginCellValue]] = (1...50_001).map { i in [.text("\(i)"), .text("name\(i)")] } let result = converter.generateInserts(rows: rows) let lines = result.components(separatedBy: "\n") #expect(lines.count == 50_000) } + + @Test("PostgreSQL: binary cell renders as bytea hex literal in INSERT") + func postgresBinaryInsertEmitsByteaLiteral() throws { + let converter = try SQLRowToStatementConverter( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumn: "id", + databaseType: .postgresql, + quoteIdentifier: { "\"\($0)\"" }, + escapeStringLiteral: { $0.replacingOccurrences(of: "'", with: "''") } + ) + let bytes = Data([0xD3, 0x8C, 0xE5, 0x66]) + let result = converter.generateInserts(rows: [[.text("1"), .bytes(bytes)]]) + #expect(result.contains("'\\xD38CE566'::bytea")) + #expect(!result.contains("NULL")) + } + + @Test("MySQL: binary cell renders as X'...' literal in INSERT") + func mysqlBinaryInsertEmitsXLiteral() throws { + let converter = try makeConverter( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumn: "id" + ) + let bytes = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let result = converter.generateInserts(rows: [[.text("1"), .bytes(bytes)]]) + #expect(result.contains("X'DEADBEEF'")) + #expect(!result.contains("NULL")) + } + + @Test("MSSQL: binary cell renders as 0x... literal in INSERT") + func mssqlBinaryInsertEmitsZeroXLiteral() throws { + let converter = try SQLRowToStatementConverter( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumn: "id", + databaseType: .mssql, + quoteIdentifier: { "[\($0)]" }, + escapeStringLiteral: { $0.replacingOccurrences(of: "'", with: "''") } + ) + let bytes = Data([0xCA, 0xFE, 0xBA, 0xBE]) + let result = converter.generateInserts(rows: [[.text("1"), .bytes(bytes)]]) + #expect(result.contains("0xCAFEBABE")) + #expect(!result.contains("'CAFEBABE'")) + } + + @Test("UPDATE with binary value emits hex literal in SET clause") + func updateBinaryValueEmitsHexLiteral() throws { + let converter = try SQLRowToStatementConverter( + tableName: "documents", + columns: ["id", "payload"], + primaryKeyColumn: "id", + databaseType: .postgresql, + quoteIdentifier: { "\"\($0)\"" }, + escapeStringLiteral: { $0.replacingOccurrences(of: "'", with: "''") } + ) + let bytes = Data([0xAB, 0xCD]) + let result = converter.generateUpdates(rows: [[.text("42"), .bytes(bytes)]]) + #expect(result.contains("\"payload\" = '\\xABCD'::bytea")) + #expect(result.contains("WHERE \"id\" = '42'")) + } } diff --git a/TableProTests/Plugins/PluginCellValueSortKeyTests.swift b/TableProTests/Plugins/PluginCellValueSortKeyTests.swift new file mode 100644 index 000000000..d07ff3b70 --- /dev/null +++ b/TableProTests/Plugins/PluginCellValueSortKeyTests.swift @@ -0,0 +1,39 @@ +// +// PluginCellValueSortKeyTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PluginCellValue - sortKey") +struct PluginCellValueSortKeyTests { + @Test(".null sortKey is empty string") + func nullSortKey() { + #expect(PluginCellValue.null.sortKey == "") + } + + @Test(".text sortKey is the text verbatim") + func textSortKey() { + #expect(PluginCellValue.text("hello").sortKey == "hello") + #expect(PluginCellValue.text("").sortKey == "") + } + + @Test(".bytes sortKey is uppercase hex without 0x prefix") + func bytesSortKey() { + #expect(PluginCellValue.bytes(Data([0xDE, 0xAD, 0xBE, 0xEF])).sortKey == "DEADBEEF") + #expect(PluginCellValue.bytes(Data()).sortKey == "") + #expect(PluginCellValue.bytes(Data([0x00, 0xFF])).sortKey == "00FF") + } + + @Test("Distinct binary values produce distinct sort keys (deterministic order)") + func distinctBytesProduceDistinctKeys() { + let a = PluginCellValue.bytes(Data([0x00])).sortKey + let b = PluginCellValue.bytes(Data([0x01])).sortKey + let c = PluginCellValue.bytes(Data([0xFF])).sortKey + #expect(a < b) + #expect(b < c) + #expect(a != b) + } +} From 287851f4523bb976004b1fd36a948e29b5db1f45 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 02:04:14 +0700 Subject: [PATCH 10/11] fix(datagrid): drop redundant write in updateCellInTab that overwrote .bytes as .null --- .../Coordinators/RowEditingCoordinator.swift | 7 +---- .../DataGridCellCommitBinaryTests.swift | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 TableProTests/Views/Results/DataGridCellCommitBinaryTests.swift diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 31f901e7a..171fb0bff 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -231,12 +231,7 @@ final class RowEditingCoordinator { } func updateCellInTab(rowIndex: Int, columnIndex: Int, value: PluginCellValue) { - guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } - let tabId = tab.id - let delta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.edit(row: rowIndex, column: columnIndex, value: value) - } + guard let (_, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) } } diff --git a/TableProTests/Views/Results/DataGridCellCommitBinaryTests.swift b/TableProTests/Views/Results/DataGridCellCommitBinaryTests.swift new file mode 100644 index 000000000..843dd65bf --- /dev/null +++ b/TableProTests/Views/Results/DataGridCellCommitBinaryTests.swift @@ -0,0 +1,31 @@ +// +// DataGridCellCommitBinaryTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Cell commit - typed binary writes survive delegate notification") +@MainActor +struct DataGridCellCommitBinaryTests { + @Test("PluginCellValue.fromOptional(.bytes.asText) lossily becomes .null") + func fromOptionalBytesAsTextIsNull() { + let bytes = Data([0xDE, 0xAD]) + let cell: PluginCellValue = .bytes(bytes) + let viaText = PluginCellValue.fromOptional(cell.asText) + #expect(viaText == .null, + "Lossy: .bytes.asText is nil, then fromOptional(nil) is .null. Proves why the delegate-callback path must not re-write the cell from a String? value.") + } + + @Test("PluginCellValue.fromOptional(.bytes.asText) for high bytes is .null, not .text") + func highBytesViaTextIsNull() { + let bytes = Data([0xD3, 0x8C, 0xE5, 0x66]) + let viaText = PluginCellValue.fromOptional(PluginCellValue.bytes(bytes).asText) + #expect(viaText == .null) + #expect(viaText.asBytes == nil) + } +} From 90a4f4336b2fd54bc2319f64d285be35b87897d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 11 May 2026 02:07:46 +0700 Subject: [PATCH 11/11] test(plugins): cover lazy insertedRowData and JSON binary cells --- .../SQLStatementGeneratorBinaryTests.swift | 28 +++++++++++++++++++ .../Utilities/JsonRowConverterTests.swift | 18 +++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift index 7003b18ac..5cdbb96bc 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorBinaryTests.swift @@ -128,6 +128,34 @@ struct SQLStatementGeneratorBinaryTests { #expect(payload.first == 0xD3) } + @Test("INSERT via lazy insertedRowData with .bytes emits Data parameter") + func insertViaLazyDataPreservesBinaryParameter() throws { + let generator = try makeGenerator() + let bytes = Data([0xCA, 0xFE, 0xBA, 0xBE, 0xDE, 0xAD]) + let change = RowChange( + rowIndex: 7, + type: .insert, + cellChanges: [], + originalRow: nil + ) + let statements = generator.generateStatements( + from: [change], + insertedRowData: [7: [.text("99"), .bytes(bytes)]], + deletedRowIndices: [], + insertedRowIndices: [7] + ) + guard let stmt = statements.first else { + Issue.record("INSERT statement not generated for lazy path") + return + } + guard stmt.parameters.count == 2 else { + Issue.record("Expected 2 parameters, got \(stmt.parameters.count)") + return + } + #expect(stmt.parameters[0] as? String == "99") + #expect(stmt.parameters[1] as? Data == bytes) + } + @Test(".null parameters bind as NSNull/nil, not String") func nullParameterIsNotString() throws { let generator = try makeGenerator() diff --git a/TableProTests/Core/Utilities/JsonRowConverterTests.swift b/TableProTests/Core/Utilities/JsonRowConverterTests.swift index 3139326e6..0870b5eb7 100644 --- a/TableProTests/Core/Utilities/JsonRowConverterTests.swift +++ b/TableProTests/Core/Utilities/JsonRowConverterTests.swift @@ -208,11 +208,21 @@ struct JsonRowConverterTests { // MARK: - Blob - @Test("Blob column produces base64 encoded value") - func blobColumn() { + @Test("Binary cell produces base64 encoded value regardless of column type") + func binaryCellProducesBase64() { let converter = makeConverter(columns: ["data"], columnTypes: [.blob(rawType: nil)]) - let result = converter.generateJson(rows: [["hello"]]) - // "hello" in base64 is "aGVsbG8=" + let bytes = Data("hello".utf8) + let result = converter.generateJson(rows: [[.bytes(bytes)]]) #expect(result.contains("\"aGVsbG8=\"")) } + + @Test("Issue #1188 binary cell base64-encodes correctly") + func issue1188BinaryCellBase64() { + let converter = makeConverter(columns: ["payload"], columnTypes: [.blob(rawType: "BYTEA")]) + let bytes = Data([0xD3, 0x8C, 0xE5, 0x66]) + let result = converter.generateJson(rows: [[.bytes(bytes)]]) + let expected = bytes.base64EncodedString() + #expect(result.contains("\"\(expected)\"")) + #expect(!result.contains("null")) + } }