From 16eb29e57de1c41608339b97331f9d3b2ab6c4b1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 12:17:13 +0700 Subject: [PATCH 1/6] refactor: deduplicate SQL temporal function expressions into SQLEscaping --- .../SQLStatementGenerator.swift | 25 +------ TablePro/Core/Database/SQLEscaping.swift | 14 ++++ .../MainContentCoordinator+SidebarSave.swift | 10 +-- .../Core/Database/SQLEscapingTests.swift | 65 +++++++++++++++++++ 4 files changed, 81 insertions(+), 33 deletions(-) diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index f79d613b..30a6680d 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -19,28 +19,6 @@ struct ParameterizedStatement { struct SQLStatementGenerator { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLStatementGenerator") - /// Known SQL function expressions that should not be quoted/parameterized - private static let sqlFunctionExpressions: Set = [ - "NOW()", - "CURRENT_TIMESTAMP()", - "CURRENT_TIMESTAMP", - "CURDATE()", - "CURTIME()", - "UTC_TIMESTAMP()", - "UTC_DATE()", - "UTC_TIME()", - "LOCALTIME()", - "LOCALTIME", - "LOCALTIMESTAMP()", - "LOCALTIMESTAMP", - "SYSDATE()", - "UNIX_TIMESTAMP()", - "CURRENT_DATE()", - "CURRENT_DATE", - "CURRENT_TIME()", - "CURRENT_TIME", - ] - let tableName: String let columns: [String] let primaryKeyColumn: String? @@ -382,7 +360,6 @@ struct SQLStatementGenerator { /// Check if a string is a SQL function expression that should not be quoted private func isSQLFunctionExpression(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: .whitespaces).uppercased() - return Self.sqlFunctionExpressions.contains(trimmed) + SQLEscaping.isTemporalFunction(value) } } diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index a074a153..60db7245 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -65,6 +65,20 @@ enum SQLEscaping { /// /// - Parameter value: The value to escape /// - Returns: The escaped value with %, _, and \ escaped + /// Known SQL temporal function expressions that should not be quoted/parameterized. + /// Canonical source — used by SQLStatementGenerator and sidebar save logic. + static let temporalFunctionExpressions: Set = [ + "NOW()", "CURRENT_TIMESTAMP()", "CURRENT_TIMESTAMP", + "CURDATE()", "CURTIME()", "UTC_TIMESTAMP()", "UTC_DATE()", "UTC_TIME()", + "LOCALTIME()", "LOCALTIME", "LOCALTIMESTAMP()", "LOCALTIMESTAMP", + "SYSDATE()", "UNIX_TIMESTAMP()", "CURRENT_DATE()", "CURRENT_DATE", + "CURRENT_TIME()", "CURRENT_TIME", + ] + + static func isTemporalFunction(_ value: String) -> Bool { + temporalFunctionExpressions.contains(value.trimmingCharacters(in: .whitespaces).uppercased()) + } + static func escapeLikeWildcards(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 403fe296..219d3044 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -159,14 +159,6 @@ extension MainContentCoordinator { } private func isSidebarSQLFunction(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: .whitespaces).uppercased() - let sqlFunctions = [ - "NOW()", "CURRENT_TIMESTAMP()", "CURRENT_TIMESTAMP", - "CURDATE()", "CURTIME()", "UTC_TIMESTAMP()", "UTC_DATE()", "UTC_TIME()", - "LOCALTIME()", "LOCALTIME", "LOCALTIMESTAMP()", "LOCALTIMESTAMP", - "SYSDATE()", "UNIX_TIMESTAMP()", "CURRENT_DATE()", "CURRENT_DATE", - "CURRENT_TIME()", "CURRENT_TIME", - ] - return sqlFunctions.contains(trimmed) + SQLEscaping.isTemporalFunction(value) } } diff --git a/TableProTests/Core/Database/SQLEscapingTests.swift b/TableProTests/Core/Database/SQLEscapingTests.swift index a08a7294..81481884 100644 --- a/TableProTests/Core/Database/SQLEscapingTests.swift +++ b/TableProTests/Core/Database/SQLEscapingTests.swift @@ -227,4 +227,69 @@ struct SQLEscapingTests { let result = SQLEscaping.escapeLikeWildcards(input) #expect(result == "\\\\\\%") } + + // MARK: - isTemporalFunction Tests + + @Test("NOW() is recognized as temporal function") + func nowIsTemporalFunction() { + #expect(SQLEscaping.isTemporalFunction("NOW()") == true) + } + + @Test("CURRENT_TIMESTAMP without parens is recognized") + func currentTimestampNoParens() { + #expect(SQLEscaping.isTemporalFunction("CURRENT_TIMESTAMP") == true) + } + + @Test("CURRENT_TIMESTAMP() with parens is recognized") + func currentTimestampWithParens() { + #expect(SQLEscaping.isTemporalFunction("CURRENT_TIMESTAMP()") == true) + } + + @Test("Case-insensitive matching") + func caseInsensitive() { + #expect(SQLEscaping.isTemporalFunction("now()") == true) + #expect(SQLEscaping.isTemporalFunction("Now()") == true) + #expect(SQLEscaping.isTemporalFunction("cUrDaTe()") == true) + } + + @Test("Leading/trailing whitespace is trimmed") + func whitespaceIsTrimmed() { + #expect(SQLEscaping.isTemporalFunction(" NOW() ") == true) + } + + @Test("Non-temporal functions are rejected") + func nonTemporalRejected() { + #expect(SQLEscaping.isTemporalFunction("COUNT(*)") == false) + #expect(SQLEscaping.isTemporalFunction("UPPER(name)") == false) + #expect(SQLEscaping.isTemporalFunction("hello") == false) + } + + @Test("Empty string is rejected") + func emptyStringRejected() { + #expect(SQLEscaping.isTemporalFunction("") == false) + } + + @Test("All 18 known temporal functions are recognized") + func allKnownFunctions() { + for function in SQLEscaping.temporalFunctionExpressions { + #expect(SQLEscaping.isTemporalFunction(function) == true) + } + } + + @Test("CURDATE, CURTIME, UTC variants recognized") + func dateTimeVariants() { + #expect(SQLEscaping.isTemporalFunction("CURDATE()") == true) + #expect(SQLEscaping.isTemporalFunction("CURTIME()") == true) + #expect(SQLEscaping.isTemporalFunction("UTC_TIMESTAMP()") == true) + #expect(SQLEscaping.isTemporalFunction("UTC_DATE()") == true) + #expect(SQLEscaping.isTemporalFunction("UTC_TIME()") == true) + } + + @Test("LOCALTIME and LOCALTIMESTAMP variants recognized") + func localVariants() { + #expect(SQLEscaping.isTemporalFunction("LOCALTIME") == true) + #expect(SQLEscaping.isTemporalFunction("LOCALTIME()") == true) + #expect(SQLEscaping.isTemporalFunction("LOCALTIMESTAMP") == true) + #expect(SQLEscaping.isTemporalFunction("LOCALTIMESTAMP()") == true) + } } From 347b147c8f6ab14d392777c489b26fad32f872e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 12:19:30 +0700 Subject: [PATCH 2/6] refactor: extract SchemaProviderRegistry from MainContentCoordinator --- TablePro/ContentView.swift | 2 +- TablePro/Core/Database/DatabaseManager.swift | 2 +- .../Services/SchemaProviderRegistry.swift | 80 +++++++++++++ .../Views/Main/MainContentCoordinator.swift | 82 ++------------ .../SchemaProviderRegistryTests.swift | 105 ++++++++++++++++++ 5 files changed, 195 insertions(+), 76 deletions(-) create mode 100644 TablePro/Core/Services/SchemaProviderRegistry.swift create mode 100644 TableProTests/Core/Services/SchemaProviderRegistryTests.swift diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index d7a05835..9abb7090 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -185,7 +185,7 @@ struct ContentView: View { tableOperationOptions: sessionTableOperationOptionsBinding, databaseType: currentSession.connection.type, connectionId: currentSession.connection.id, - schemaProvider: MainContentCoordinator.schemaProvider(for: currentSession.connection.id) + schemaProvider: SchemaProviderRegistry.shared.provider(for: currentSession.connection.id) ) } .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 18f45750..874da9bd 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -260,7 +260,7 @@ final class DatabaseManager { activeSessions.removeValue(forKey: sessionId) // Clean up shared schema cache for this connection - MainContentCoordinator.clearSharedSchema(for: sessionId) + SchemaProviderRegistry.shared.clear(for: sessionId) // Clean up shared sidebar state for this connection SharedSidebarState.removeConnection(sessionId) diff --git a/TablePro/Core/Services/SchemaProviderRegistry.swift b/TablePro/Core/Services/SchemaProviderRegistry.swift new file mode 100644 index 00000000..c18a6261 --- /dev/null +++ b/TablePro/Core/Services/SchemaProviderRegistry.swift @@ -0,0 +1,80 @@ +// +// SchemaProviderRegistry.swift +// TablePro +// +// Manages shared SQLSchemaProvider instances across connections. +// Ref-counted with grace period removal to avoid redundant schema loads. +// + +import Foundation +import os + +@MainActor +final class SchemaProviderRegistry { + private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaProviderRegistry") + + static let shared = SchemaProviderRegistry() + + private var providers: [UUID: SQLSchemaProvider] = [:] + private var refCounts: [UUID: Int] = [:] + private var removalTasks: [UUID: Task] = [:] + + init() {} + + init(forTesting: Bool) {} + + func provider(for connectionId: UUID) -> SQLSchemaProvider? { + providers[connectionId] + } + + func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { + if let existing = providers[connectionId] { + return existing + } + let provider = SQLSchemaProvider() + providers[connectionId] = provider + return provider + } + + func retain(for connectionId: UUID) { + removalTasks[connectionId]?.cancel() + removalTasks.removeValue(forKey: connectionId) + refCounts[connectionId, default: 0] += 1 + } + + func release(for connectionId: UUID) { + guard var count = refCounts[connectionId] else { return } + count -= 1 + if count <= 0 { + refCounts.removeValue(forKey: connectionId) + removalTasks[connectionId] = Task { @MainActor in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled else { return } + self.providers.removeValue(forKey: connectionId) + self.removalTasks.removeValue(forKey: connectionId) + } + } else { + refCounts[connectionId] = count + } + } + + func clear(for connectionId: UUID) { + providers.removeValue(forKey: connectionId) + refCounts.removeValue(forKey: connectionId) + removalTasks[connectionId]?.cancel() + removalTasks.removeValue(forKey: connectionId) + } + + func purgeUnused() { + let orphanedIds = providers.keys.filter { connectionId in + let count = refCounts[connectionId] ?? 0 + let hasPendingRemoval = removalTasks[connectionId] != nil + return count <= 0 && !hasPendingRemoval + } + for connectionId in orphanedIds { + Self.logger.info("Purging orphaned schema provider for connection \(connectionId)") + providers.removeValue(forKey: connectionId) + refCounts.removeValue(forKey: connectionId) + } + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3d6c4885..82681b48 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -40,17 +40,6 @@ enum ActiveSheet: Identifiable { final class MainContentCoordinator { private static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator") - /// Per-connection shared schema providers so new tabs skip redundant schema loads - private static var sharedSchemaProviders: [UUID: SQLSchemaProvider] = [:] - /// Reference counts for shared schema providers (tracks how many coordinators use each) - private static var schemaProviderRefCounts: [UUID: Int] = [:] - /// Delayed removal tasks — cancelled if a new coordinator claims the provider within the grace period - private static var schemaProviderRemovalTasks: [UUID: Task] = [:] - - static func schemaProvider(for connectionId: UUID) -> SQLSchemaProvider? { - sharedSchemaProviders[connectionId] - } - // MARK: - Dependencies let connection: DatabaseConnection @@ -153,16 +142,8 @@ final class MainContentCoordinator { self.queryBuilder = TableQueryBuilder(databaseType: connection.type) self.tabPersistence = TabPersistenceService(connectionId: connection.id) - // Reuse existing schema provider for this connection, or create a new one - if let existing = Self.sharedSchemaProviders[connection.id] { - self.schemaProvider = existing - } else { - let provider = SQLSchemaProvider() - Self.sharedSchemaProviders[connection.id] = provider - self.schemaProvider = provider - } - - Self.retainSchemaProvider(for: connection.id) + self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) + SchemaProviderRegistry.shared.retain(for: connection.id) setupURLNotificationObservers() _ = Self.registerTerminationObserver @@ -197,8 +178,8 @@ final class MainContentCoordinator { } querySortCache.removeAll() - Self.releaseSchemaProvider(for: connection.id) - Self.purgeUnusedSchemaProviders() + SchemaProviderRegistry.shared.release(for: connection.id) + SchemaProviderRegistry.shared.purgeUnused() } deinit { @@ -210,8 +191,8 @@ final class MainContentCoordinator { guard didActivate else { if !alreadyHandled { Task { @MainActor in - MainContentCoordinator.releaseSchemaProvider(for: connectionId) - MainContentCoordinator.purgeUnusedSchemaProviders() + SchemaProviderRegistry.shared.release(for: connectionId) + SchemaProviderRegistry.shared.purgeUnused() } } return @@ -224,8 +205,8 @@ final class MainContentCoordinator { if !alreadyHandled { Task { @MainActor in - MainContentCoordinator.releaseSchemaProvider(for: connectionId) - MainContentCoordinator.purgeUnusedSchemaProviders() + SchemaProviderRegistry.shared.release(for: connectionId) + SchemaProviderRegistry.shared.purgeUnused() } } } @@ -1059,53 +1040,6 @@ final class MainContentCoordinator { } } - /// Remove shared schema provider when a connection disconnects - static func clearSharedSchema(for connectionId: UUID) { - sharedSchemaProviders.removeValue(forKey: connectionId) - schemaProviderRefCounts.removeValue(forKey: connectionId) - schemaProviderRemovalTasks[connectionId]?.cancel() - schemaProviderRemovalTasks.removeValue(forKey: connectionId) - } - - /// Increment reference count for a connection's schema provider - private static func retainSchemaProvider(for connectionId: UUID) { - schemaProviderRemovalTasks[connectionId]?.cancel() - schemaProviderRemovalTasks.removeValue(forKey: connectionId) - schemaProviderRefCounts[connectionId, default: 0] += 1 - } - - /// Decrement reference count; schedule deferred removal when count reaches zero - private static func releaseSchemaProvider(for connectionId: UUID) { - guard var count = schemaProviderRefCounts[connectionId] else { return } - count -= 1 - if count <= 0 { - schemaProviderRefCounts.removeValue(forKey: connectionId) - // Grace period: keep provider alive for 5s in case a new tab opens quickly - schemaProviderRemovalTasks[connectionId] = Task { @MainActor in - try? await Task.sleep(nanoseconds: 5_000_000_000) - guard !Task.isCancelled else { return } - sharedSchemaProviders.removeValue(forKey: connectionId) - schemaProviderRemovalTasks.removeValue(forKey: connectionId) - } - } else { - schemaProviderRefCounts[connectionId] = count - } - } - - /// Remove entries with zero or missing reference counts that lack pending removal tasks. - /// Guards against unbounded growth if releaseSchemaProvider fails to execute. - private static func purgeUnusedSchemaProviders() { - let orphanedIds = sharedSchemaProviders.keys.filter { connectionId in - let count = schemaProviderRefCounts[connectionId] ?? 0 - let hasPendingRemoval = schemaProviderRemovalTasks[connectionId] != nil - return count <= 0 && !hasPendingRemoval - } - for connectionId in orphanedIds { - logger.info("Purging orphaned schema provider for connection \(connectionId)") - sharedSchemaProviders.removeValue(forKey: connectionId) - schemaProviderRefCounts.removeValue(forKey: connectionId) - } - } } // MARK: - Query Execution Helpers diff --git a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift new file mode 100644 index 00000000..154b46f6 --- /dev/null +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -0,0 +1,105 @@ +// +// SchemaProviderRegistryTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("SchemaProviderRegistry") +@MainActor +struct SchemaProviderRegistryTests { + @Test("getOrCreate returns new provider for unknown connectionId") + func getOrCreateNewProvider() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + let provider = registry.getOrCreate(for: id) + #expect(provider != nil) + } + + @Test("getOrCreate returns same provider for same connectionId") + func getOrCreateReturnsSameProvider() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + let p1 = registry.getOrCreate(for: id) + let p2 = registry.getOrCreate(for: id) + #expect(p1 === p2) + } + + @Test("provider(for:) returns nil for unknown connectionId") + func providerForUnknownReturnsNil() { + let registry = SchemaProviderRegistry(forTesting: true) + #expect(registry.provider(for: UUID()) == nil) + } + + @Test("provider(for:) returns provider after getOrCreate") + func providerForKnownReturnsProvider() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + let created = registry.getOrCreate(for: id) + #expect(registry.provider(for: id) === created) + } + + @Test("retain increments refcount, prevents purge") + func retainPreventsRemoval() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + _ = registry.getOrCreate(for: id) + registry.retain(for: id) + registry.purgeUnused() + #expect(registry.provider(for: id) != nil) + } + + @Test("release decrements refcount to zero, schedules deferred removal") + func releaseSchedulesDeferredRemoval() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + _ = registry.getOrCreate(for: id) + registry.retain(for: id) + registry.release(for: id) + #expect(registry.provider(for: id) != nil) + } + + @Test("clear removes provider, refcount, and pending removal") + func clearRemovesEverything() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + _ = registry.getOrCreate(for: id) + registry.retain(for: id) + registry.clear(for: id) + #expect(registry.provider(for: id) == nil) + } + + @Test("purgeUnused removes orphaned providers with zero refcount and no pending task") + func purgeRemovesOrphans() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + _ = registry.getOrCreate(for: id) + registry.purgeUnused() + #expect(registry.provider(for: id) == nil) + } + + @Test("purgeUnused does not remove providers with pending removal task") + func purgeKeepsProvidersWithPendingTask() { + let registry = SchemaProviderRegistry(forTesting: true) + let id = UUID() + _ = registry.getOrCreate(for: id) + registry.retain(for: id) + registry.release(for: id) + registry.purgeUnused() + #expect(registry.provider(for: id) != nil) + } + + @Test("multiple connections are independent") + func multipleConnectionsIndependent() { + let registry = SchemaProviderRegistry(forTesting: true) + let id1 = UUID(), id2 = UUID() + let p1 = registry.getOrCreate(for: id1) + let p2 = registry.getOrCreate(for: id2) + #expect(p1 !== p2) + registry.clear(for: id1) + #expect(registry.provider(for: id1) == nil) + #expect(registry.provider(for: id2) != nil) + } +} From 65a6c4f134fdc5a81631097d50ad6ee96dceccc9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 12:22:03 +0700 Subject: [PATCH 3/6] refactor: split DataGridView TableViewCoordinator into extension files --- TablePro/Views/Results/DataGridView.swift | 546 +----------------- .../Extensions/DataGridView+Click.swift | 137 +++++ .../Extensions/DataGridView+Columns.swift | 93 +++ .../Extensions/DataGridView+Editing.swift | 239 ++++++++ .../Extensions/DataGridView+Selection.swift | 39 ++ .../Extensions/DataGridView+Sort.swift | 74 +++ 6 files changed, 584 insertions(+), 544 deletions(-) create mode 100644 TablePro/Views/Results/Extensions/DataGridView+Click.swift create mode 100644 TablePro/Views/Results/Extensions/DataGridView+Columns.swift create mode 100644 TablePro/Views/Results/Extensions/DataGridView+Editing.swift create mode 100644 TablePro/Views/Results/Extensions/DataGridView+Selection.swift create mode 100644 TablePro/Views/Results/Extensions/DataGridView+Sort.swift diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 3fd63f03..d1bdf89b 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -623,7 +623,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData weak var tableView: NSTableView? let cellFactory = DataGridCellFactory() - private(set) var overlayEditor: CellOverlayEditor? + internal(set) var overlayEditor: CellOverlayEditor? // Settings observer for real-time updates fileprivate var settingsObserver: NSObjectProtocol? @@ -643,7 +643,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isWritingColumnLayout: Bool = false private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") - private static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") + static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") internal var pendingDropdownRow: Int = 0 internal var pendingDropdownColumn: Int = 0 private var rowVisualStateCache: [Int: RowVisualState] = [:] @@ -777,548 +777,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData cachedRowCount } - // MARK: - Native Sorting - - func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - guard !isSyncingSortDescriptors else { return } - - guard let sortDescriptor = tableView.sortDescriptors.first, - let key = sortDescriptor.key, - key.hasPrefix("col_"), - let columnIndex = Int(key.dropFirst(4)), - columnIndex >= 0 && columnIndex < rowProvider.columns.count else { - return - } - - let isMultiSort = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - onSort?(columnIndex, sortDescriptor.ascending, isMultiSort) - } - - // MARK: - NSMenuDelegate (Header Context Menu) - - func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - - guard let tableView = tableView, - let headerView = tableView.headerView, - let window = tableView.window else { return } - - let mouseLocation = window.mouseLocationOutsideOfEventStream - let pointInHeader = headerView.convert(mouseLocation, from: nil) - let columnIndex = headerView.column(at: pointInHeader) - - guard columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } - - let column = tableView.tableColumns[columnIndex] - if column.identifier.rawValue == "__rowNumber__" { return } - - // Derive base column name from stable identifier (avoids sort indicator in title) - let baseName: String = { - if let idx = DataGridView.columnIndex(from: column.identifier), - idx < rowProvider.columns.count { - return rowProvider.columns[idx] - } - return column.title - }() - - let copyItem = NSMenuItem(title: String(localized: "Copy Column Name"), action: #selector(copyColumnName(_:)), keyEquivalent: "") - copyItem.representedObject = baseName - copyItem.target = self - menu.addItem(copyItem) - - let filterItem = NSMenuItem(title: String(localized: "Filter with column"), action: #selector(filterWithColumn(_:)), keyEquivalent: "") - filterItem.representedObject = baseName - filterItem.target = self - menu.addItem(filterItem) - } - - @objc private func copyColumnName(_ sender: NSMenuItem) { - guard let columnName = sender.representedObject as? String else { return } - ClipboardService.shared.writeText(columnName) - } - - @objc private func filterWithColumn(_ sender: NSMenuItem) { - guard let columnName = sender.representedObject as? String else { return } - onFilterColumn?(columnName) - } - - // MARK: - NSTableViewDelegate - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard let column = tableColumn else { return nil } - - let columnId = column.identifier.rawValue - - if columnId == "__rowNumber__" { - return cellFactory.makeRowNumberCell( - tableView: tableView, - row: row, - cachedRowCount: cachedRowCount, - visualState: visualState(for: row) - ) - } - - guard columnId.hasPrefix("col_"), let columnIndex = Int(columnId.dropFirst(4)) else { return nil } - - guard row >= 0 && row < cachedRowCount, - columnIndex >= 0 && columnIndex < cachedColumnCount, - let rowData = rowProvider.row(at: row) else { - return nil - } - - let value = rowData.value(at: columnIndex) - let state = visualState(for: row) - - // Get column type for date formatting - let columnType: ColumnType? = { - guard columnIndex < rowProvider.columnTypes.count else { return nil } - return rowProvider.columnTypes[columnIndex] - }() - - let tableColumnIndex = columnIndex + 1 - let isFocused: Bool = { - guard let keyTableView = tableView as? KeyHandlingTableView, - keyTableView.focusedRow == row, - keyTableView.focusedColumn == tableColumnIndex else { return false } - return true - }() - - let isDropdown = dropdownColumns?.contains(columnIndex) == true - let isTypePicker = typePickerColumns?.contains(columnIndex) == true - - let isEnumOrSet: Bool = { - guard columnIndex < rowProvider.columnTypes.count, - columnIndex < rowProvider.columns.count else { return false } - let ct = rowProvider.columnTypes[columnIndex] - let columnName = rowProvider.columns[columnIndex] - guard ct.isEnumType || ct.isSetType else { return false } - return rowProvider.columnEnumValues[columnName]?.isEmpty == false - }() - - let isFKColumn: Bool = { - guard columnIndex < rowProvider.columns.count else { return false } - let columnName = rowProvider.columns[columnIndex] - return rowProvider.columnForeignKeys[columnName] != nil - }() - - return cellFactory.makeDataCell( - tableView: tableView, - row: row, - columnIndex: columnIndex, - value: value, - columnType: columnType, - visualState: state, - isEditable: isEditable && !state.isDeleted, - isLargeDataset: isLargeDataset, - isFocused: isFocused, - isDropdown: isEditable && (isDropdown || isTypePicker || isEnumOrSet), - isFKColumn: isFKColumn && !isDropdown && !(typePickerColumns?.contains(columnIndex) == true), - fkArrowTarget: self, - fkArrowAction: #selector(handleFKArrowClick(_:)), - delegate: self - ) - } - - func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { - let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? TableRowViewWithMenu) - ?? TableRowViewWithMenu() - rowView.identifier = Self.rowViewIdentifier - rowView.coordinator = self - rowView.rowIndex = row - return rowView - } - - // MARK: - Selection - - func tableViewColumnDidResize(_ notification: Notification) { - // Only track user-initiated resizes, not programmatic ones during column rebuilds - guard !isRebuildingColumns else { return } - hasUserResizedColumns = true - } - - func tableViewColumnDidMove(_ notification: Notification) { - guard !isRebuildingColumns else { return } - hasUserResizedColumns = true - } - - func tableViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection else { return } - guard let tableView = notification.object as? NSTableView else { return } - - let newSelection = Set(tableView.selectedRowIndexes.map { $0 }) - if newSelection != selectedRowIndices { - DispatchQueue.main.async { [weak self] in - self?.selectedRowIndices = newSelection - } - } - - if let keyTableView = tableView as? KeyHandlingTableView { - if newSelection.isEmpty { - keyTableView.focusedRow = -1 - keyTableView.focusedColumn = -1 - } - } - } - - // MARK: - Click Handlers - - @objc func handleClick(_ sender: NSTableView) { - guard isEditable else { return } - - let row = sender.clickedRow - let column = sender.clickedColumn - guard row >= 0, column > 0 else { return } - - let columnIndex = column - 1 - guard !changeManager.isRowDeleted(row) else { return } - - // Dropdown columns open on single click - if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { - showDropdownMenu(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - - // ENUM/SET columns open on single click - if columnIndex < rowProvider.columnTypes.count, - columnIndex < rowProvider.columns.count { - let ct = rowProvider.columnTypes[columnIndex] - let columnName = rowProvider.columns[columnIndex] - if ct.isEnumType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { - showEnumPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - if ct.isSetType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { - showSetPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - } - } - - @objc func handleDoubleClick(_ sender: NSTableView) { - guard isEditable else { return } - - let row = sender.clickedRow - let column = sender.clickedColumn - guard row >= 0, column > 0 else { return } - - let columnIndex = column - 1 - guard !changeManager.isRowDeleted(row) else { return } - - // MongoDB _id is immutable — block editing - if databaseType == .mongodb, - columnIndex < rowProvider.columns.count, - rowProvider.columns[columnIndex] == "_id" { - return - } - - // Dropdown columns already handled by single click - if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { - return - } - - // Type picker columns use database-specific type popover - if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) { - showTypePickerPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - - // ENUM/SET columns already handled by single click - if columnIndex < rowProvider.columnTypes.count, - columnIndex < rowProvider.columns.count { - let ct = rowProvider.columnTypes[columnIndex] - if ct.isEnumType || ct.isSetType { - let columnName = rowProvider.columns[columnIndex] - if let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { - return - } - } - } - - // FK columns use searchable dropdown popover - if columnIndex < rowProvider.columns.count { - let columnName = rowProvider.columns[columnIndex] - if let fkInfo = rowProvider.columnForeignKeys[columnName] { - showForeignKeyPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex, fkInfo: fkInfo) - return - } - } - - // Date columns use date picker popover - if columnIndex < rowProvider.columnTypes.count, - rowProvider.columnTypes[columnIndex].isDateType { - showDatePickerPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - - // JSON columns use JSON editor popover - if columnIndex < rowProvider.columnTypes.count, - rowProvider.columnTypes[columnIndex].isJsonType { - showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) - return - } - - // Multiline values use the overlay editor instead of inline field editor - if let value = rowProvider.row(at: row)?.value(at: columnIndex), - value.containsLineBreak { - showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) - return - } - - // Regular columns — start inline editing - sender.editColumn(column, row: row, with: nil, select: true) - } - - // MARK: - FK Navigation - - @objc func handleFKArrowClick(_ sender: NSButton) { - guard let button = sender as? FKArrowButton else { return } - let row = button.fkRow - let columnIndex = button.fkColumnIndex - - guard row >= 0 && row < cachedRowCount, - columnIndex >= 0 && columnIndex < rowProvider.columns.count, - let rowData = rowProvider.row(at: row) else { return } - - let columnName = rowProvider.columns[columnIndex] - guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } - - let value = rowData.value(at: columnIndex) - guard let value = value, !value.isEmpty else { return } - - onNavigateFK?(value, fkInfo) - } - - // MARK: - Editing - - func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { - guard isEditable, - let tableColumn = tableColumn else { return false } - - let columnId = tableColumn.identifier.rawValue - guard columnId != "__rowNumber__", - !changeManager.isRowDeleted(row) else { return false } - - // MongoDB _id is immutable — block editing - if databaseType == .mongodb, - columnId.hasPrefix("col_"), - let columnIndex = Int(columnId.dropFirst(4)), - columnIndex < rowProvider.columns.count, - rowProvider.columns[columnIndex] == "_id" { - return false - } - - // Popover-editor columns (date/FK/JSON) are only editable via - // double-click (handleDoubleClick). Block inline editing for them. - if columnId.hasPrefix("col_"), - let columnIndex = Int(columnId.dropFirst(4)) { - if columnIndex < rowProvider.columns.count { - let columnName = rowProvider.columns[columnIndex] - if rowProvider.columnForeignKeys[columnName] != nil { return false } - } - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] - if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType { return false } - } - if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { - return false - } - if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) { - return false - } - - // Multiline values use overlay editor — block inline field editor - if let value = rowProvider.row(at: row)?.value(at: columnIndex), - value.containsLineBreak { - let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier) - guard tableColumnIdx >= 0 else { return false } - showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value) - return false - } - } - - return true - } - - // MARK: - Overlay Editor (Multiline) - - func showOverlayEditor(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, value: String) { - if overlayEditor == nil { - overlayEditor = CellOverlayEditor() - } - guard let editor = overlayEditor else { return } - - editor.onCommit = { [weak self] row, columnIndex, newValue in - self?.commitOverlayEdit(row: row, columnIndex: columnIndex, newValue: newValue) - } - editor.onTabNavigation = { [weak self] row, column, forward in - self?.handleOverlayTabNavigation(row: row, column: column, forward: forward) - } - editor.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) - } - - private func commitOverlayEdit(row: Int, columnIndex: Int, newValue: String) { - guard let rowData = rowProvider.row(at: row) else { return } - let oldValue = rowData.value(at: columnIndex) - guard oldValue != newValue else { return } - - let columnName = rowProvider.columns[columnIndex] - changeManager.recordCellChange( - rowIndex: row, - columnIndex: columnIndex, - columnName: columnName, - oldValue: oldValue, - newValue: newValue, - originalRow: rowData.values - ) - - rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) - onCellEdit?(row, columnIndex, newValue) - - let tableColumnIndex = columnIndex + 1 - tableView?.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumnIndex)) - } - - private func handleOverlayTabNavigation(row: Int, column: Int, forward: Bool) { - guard let tableView = tableView else { return } - - var nextColumn = forward ? column + 1 : column - 1 - var nextRow = row - - if forward { - if nextColumn >= tableView.numberOfColumns { - nextColumn = 1 - nextRow += 1 - } - if nextRow >= tableView.numberOfRows { - nextRow = tableView.numberOfRows - 1 - nextColumn = tableView.numberOfColumns - 1 - } - } else { - if nextColumn < 1 { - nextColumn = tableView.numberOfColumns - 1 - nextRow -= 1 - } - if nextRow < 0 { - nextRow = 0 - nextColumn = 1 - } - } - - tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - - // Check if next cell is also multiline → open overlay there - let nextColumnIndex = nextColumn - 1 - if nextColumnIndex >= 0, nextColumnIndex < rowProvider.columns.count, - let value = rowProvider.row(at: nextRow)?.value(at: nextColumnIndex), - value.containsLineBreak { - showOverlayEditor(tableView: tableView, row: nextRow, column: nextColumn, columnIndex: nextColumnIndex, value: value) - } else { - tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) - } - } - - func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { - guard let textField = control as? NSTextField, let tableView = tableView else { return true } - - let row = tableView.row(for: textField) - let column = tableView.column(for: textField) - - guard row >= 0, column > 0 else { return true } - - let columnIndex = column - 1 - let newValue: String? = textField.stringValue - - guard let rowData = rowProvider.row(at: row) else { return true } - let oldValue = rowData.value(at: columnIndex) - - guard oldValue != newValue else { return true } - - let columnName = rowProvider.columns[columnIndex] - changeManager.recordCellChange( - rowIndex: row, - columnIndex: columnIndex, - columnName: columnName, - oldValue: oldValue, - newValue: newValue, - originalRow: rowData.values - ) - - rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) - onCellEdit?(row, columnIndex, newValue) - - DispatchQueue.main.async { - tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) - } - - (control as? CellTextField)?.restoreTruncatedDisplay() - - return true - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard let tableView = tableView else { return false } - - let currentRow = tableView.row(for: control) - let currentColumn = tableView.column(for: control) - - guard currentRow >= 0, currentColumn >= 0 else { return false } - - if commandSelector == #selector(NSResponder.insertTab(_:)) { - tableView.window?.makeFirstResponder(tableView) - - var nextColumn = currentColumn + 1 - var nextRow = currentRow - - if nextColumn >= tableView.numberOfColumns { - nextColumn = 1 - nextRow += 1 - } - if nextRow >= tableView.numberOfRows { - nextRow = tableView.numberOfRows - 1 - nextColumn = tableView.numberOfColumns - 1 - } - - DispatchQueue.main.async { - tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) - } - return true - } - - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - tableView.window?.makeFirstResponder(tableView) - - var prevColumn = currentColumn - 1 - var prevRow = currentRow - - if prevColumn < 1 { - prevColumn = tableView.numberOfColumns - 1 - prevRow -= 1 - } - if prevRow < 0 { - prevRow = 0 - prevColumn = 1 - } - - DispatchQueue.main.async { - tableView.selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) - tableView.editColumn(prevColumn, row: prevRow, with: nil, select: true) - } - return true - } - - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - tableView.window?.makeFirstResponder(tableView) - return true - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - tableView.window?.makeFirstResponder(tableView) - return true - } - - return false - } } // MARK: - Preview diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift new file mode 100644 index 00000000..42540cfe --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -0,0 +1,137 @@ +// +// DataGridView+Click.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension TableViewCoordinator { + // MARK: - Click Handlers + + @objc func handleClick(_ sender: NSTableView) { + guard isEditable else { return } + + let row = sender.clickedRow + let column = sender.clickedColumn + guard row >= 0, column > 0 else { return } + + let columnIndex = column - 1 + guard !changeManager.isRowDeleted(row) else { return } + + // Dropdown columns open on single click + if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { + showDropdownMenu(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + + // ENUM/SET columns open on single click + if columnIndex < rowProvider.columnTypes.count, + columnIndex < rowProvider.columns.count { + let ct = rowProvider.columnTypes[columnIndex] + let columnName = rowProvider.columns[columnIndex] + if ct.isEnumType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + showEnumPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + if ct.isSetType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + showSetPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + } + } + + @objc func handleDoubleClick(_ sender: NSTableView) { + guard isEditable else { return } + + let row = sender.clickedRow + let column = sender.clickedColumn + guard row >= 0, column > 0 else { return } + + let columnIndex = column - 1 + guard !changeManager.isRowDeleted(row) else { return } + + // MongoDB _id is immutable — block editing + if databaseType == .mongodb, + columnIndex < rowProvider.columns.count, + rowProvider.columns[columnIndex] == "_id" { + return + } + + // Dropdown columns already handled by single click + if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { + return + } + + // Type picker columns use database-specific type popover + if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) { + showTypePickerPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + + // ENUM/SET columns already handled by single click + if columnIndex < rowProvider.columnTypes.count, + columnIndex < rowProvider.columns.count { + let ct = rowProvider.columnTypes[columnIndex] + if ct.isEnumType || ct.isSetType { + let columnName = rowProvider.columns[columnIndex] + if let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + return + } + } + } + + // FK columns use searchable dropdown popover + if columnIndex < rowProvider.columns.count { + let columnName = rowProvider.columns[columnIndex] + if let fkInfo = rowProvider.columnForeignKeys[columnName] { + showForeignKeyPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex, fkInfo: fkInfo) + return + } + } + + // Date columns use date picker popover + if columnIndex < rowProvider.columnTypes.count, + rowProvider.columnTypes[columnIndex].isDateType { + showDatePickerPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + + // JSON columns use JSON editor popover + if columnIndex < rowProvider.columnTypes.count, + rowProvider.columnTypes[columnIndex].isJsonType { + showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + + // Multiline values use the overlay editor instead of inline field editor + if let value = rowProvider.row(at: row)?.value(at: columnIndex), + value.containsLineBreak { + showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) + return + } + + // Regular columns — start inline editing + sender.editColumn(column, row: row, with: nil, select: true) + } + + // MARK: - FK Navigation + + @objc func handleFKArrowClick(_ sender: NSButton) { + guard let button = sender as? FKArrowButton else { return } + let row = button.fkRow + let columnIndex = button.fkColumnIndex + + guard row >= 0 && row < cachedRowCount, + columnIndex >= 0 && columnIndex < rowProvider.columns.count, + let rowData = rowProvider.row(at: row) else { return } + + let columnName = rowProvider.columns[columnIndex] + guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } + + let value = rowData.value(at: columnIndex) + guard let value = value, !value.isEmpty else { return } + + onNavigateFK?(value, fkInfo) + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift new file mode 100644 index 00000000..ab7d33aa --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -0,0 +1,93 @@ +// +// DataGridView+Columns.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension TableViewCoordinator { + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let column = tableColumn else { return nil } + + let columnId = column.identifier.rawValue + + if columnId == "__rowNumber__" { + return cellFactory.makeRowNumberCell( + tableView: tableView, + row: row, + cachedRowCount: cachedRowCount, + visualState: visualState(for: row) + ) + } + + guard columnId.hasPrefix("col_"), let columnIndex = Int(columnId.dropFirst(4)) else { return nil } + + guard row >= 0 && row < cachedRowCount, + columnIndex >= 0 && columnIndex < cachedColumnCount, + let rowData = rowProvider.row(at: row) else { + return nil + } + + let value = rowData.value(at: columnIndex) + let state = visualState(for: row) + + // Get column type for date formatting + let columnType: ColumnType? = { + guard columnIndex < rowProvider.columnTypes.count else { return nil } + return rowProvider.columnTypes[columnIndex] + }() + + let tableColumnIndex = columnIndex + 1 + let isFocused: Bool = { + guard let keyTableView = tableView as? KeyHandlingTableView, + keyTableView.focusedRow == row, + keyTableView.focusedColumn == tableColumnIndex else { return false } + return true + }() + + let isDropdown = dropdownColumns?.contains(columnIndex) == true + let isTypePicker = typePickerColumns?.contains(columnIndex) == true + + let isEnumOrSet: Bool = { + guard columnIndex < rowProvider.columnTypes.count, + columnIndex < rowProvider.columns.count else { return false } + let ct = rowProvider.columnTypes[columnIndex] + let columnName = rowProvider.columns[columnIndex] + guard ct.isEnumType || ct.isSetType else { return false } + return rowProvider.columnEnumValues[columnName]?.isEmpty == false + }() + + let isFKColumn: Bool = { + guard columnIndex < rowProvider.columns.count else { return false } + let columnName = rowProvider.columns[columnIndex] + return rowProvider.columnForeignKeys[columnName] != nil + }() + + return cellFactory.makeDataCell( + tableView: tableView, + row: row, + columnIndex: columnIndex, + value: value, + columnType: columnType, + visualState: state, + isEditable: isEditable && !state.isDeleted, + isLargeDataset: isLargeDataset, + isFocused: isFocused, + isDropdown: isEditable && (isDropdown || isTypePicker || isEnumOrSet), + isFKColumn: isFKColumn && !isDropdown && !(typePickerColumns?.contains(columnIndex) == true), + fkArrowTarget: self, + fkArrowAction: #selector(handleFKArrowClick(_:)), + delegate: self + ) + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? TableRowViewWithMenu) + ?? TableRowViewWithMenu() + rowView.identifier = Self.rowViewIdentifier + rowView.coordinator = self + rowView.rowIndex = row + return rowView + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift new file mode 100644 index 00000000..0799b5e5 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -0,0 +1,239 @@ +// +// DataGridView+Editing.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension TableViewCoordinator { + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + guard isEditable, + let tableColumn = tableColumn else { return false } + + let columnId = tableColumn.identifier.rawValue + guard columnId != "__rowNumber__", + !changeManager.isRowDeleted(row) else { return false } + + // MongoDB _id is immutable — block editing + if databaseType == .mongodb, + columnId.hasPrefix("col_"), + let columnIndex = Int(columnId.dropFirst(4)), + columnIndex < rowProvider.columns.count, + rowProvider.columns[columnIndex] == "_id" { + return false + } + + // Popover-editor columns (date/FK/JSON) are only editable via + // double-click (handleDoubleClick). Block inline editing for them. + if columnId.hasPrefix("col_"), + let columnIndex = Int(columnId.dropFirst(4)) { + if columnIndex < rowProvider.columns.count { + let columnName = rowProvider.columns[columnIndex] + if rowProvider.columnForeignKeys[columnName] != nil { return false } + } + if columnIndex < rowProvider.columnTypes.count { + let ct = rowProvider.columnTypes[columnIndex] + if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType { return false } + } + if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { + return false + } + if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) { + return false + } + + // Multiline values use overlay editor — block inline field editor + if let value = rowProvider.row(at: row)?.value(at: columnIndex), + value.containsLineBreak { + let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier) + guard tableColumnIdx >= 0 else { return false } + showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value) + return false + } + } + + return true + } + + // MARK: - Overlay Editor (Multiline) + + func showOverlayEditor(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, value: String) { + if overlayEditor == nil { + overlayEditor = CellOverlayEditor() + } + guard let editor = overlayEditor else { return } + + editor.onCommit = { [weak self] row, columnIndex, newValue in + self?.commitOverlayEdit(row: row, columnIndex: columnIndex, newValue: newValue) + } + editor.onTabNavigation = { [weak self] row, column, forward in + self?.handleOverlayTabNavigation(row: row, column: column, forward: forward) + } + editor.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) + } + + func commitOverlayEdit(row: Int, columnIndex: Int, newValue: String) { + guard let rowData = rowProvider.row(at: row) else { return } + let oldValue = rowData.value(at: columnIndex) + guard oldValue != newValue else { return } + + let columnName = rowProvider.columns[columnIndex] + changeManager.recordCellChange( + rowIndex: row, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: rowData.values + ) + + rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) + onCellEdit?(row, columnIndex, newValue) + + let tableColumnIndex = columnIndex + 1 + tableView?.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumnIndex)) + } + + func handleOverlayTabNavigation(row: Int, column: Int, forward: Bool) { + guard let tableView = tableView else { return } + + var nextColumn = forward ? column + 1 : column - 1 + var nextRow = row + + if forward { + if nextColumn >= tableView.numberOfColumns { + nextColumn = 1 + nextRow += 1 + } + if nextRow >= tableView.numberOfRows { + nextRow = tableView.numberOfRows - 1 + nextColumn = tableView.numberOfColumns - 1 + } + } else { + if nextColumn < 1 { + nextColumn = tableView.numberOfColumns - 1 + nextRow -= 1 + } + if nextRow < 0 { + nextRow = 0 + nextColumn = 1 + } + } + + tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) + + // Check if next cell is also multiline → open overlay there + let nextColumnIndex = nextColumn - 1 + if nextColumnIndex >= 0, nextColumnIndex < rowProvider.columns.count, + let value = rowProvider.row(at: nextRow)?.value(at: nextColumnIndex), + value.containsLineBreak { + showOverlayEditor(tableView: tableView, row: nextRow, column: nextColumn, columnIndex: nextColumnIndex, value: value) + } else { + tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) + } + } + + func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { + guard let textField = control as? NSTextField, let tableView = tableView else { return true } + + let row = tableView.row(for: textField) + let column = tableView.column(for: textField) + + guard row >= 0, column > 0 else { return true } + + let columnIndex = column - 1 + let newValue: String? = textField.stringValue + + guard let rowData = rowProvider.row(at: row) else { return true } + let oldValue = rowData.value(at: columnIndex) + + guard oldValue != newValue else { return true } + + let columnName = rowProvider.columns[columnIndex] + changeManager.recordCellChange( + rowIndex: row, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: rowData.values + ) + + rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) + onCellEdit?(row, columnIndex, newValue) + + DispatchQueue.main.async { + tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) + } + + (control as? CellTextField)?.restoreTruncatedDisplay() + + return true + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard let tableView = tableView else { return false } + + let currentRow = tableView.row(for: control) + let currentColumn = tableView.column(for: control) + + guard currentRow >= 0, currentColumn >= 0 else { return false } + + if commandSelector == #selector(NSResponder.insertTab(_:)) { + tableView.window?.makeFirstResponder(tableView) + + var nextColumn = currentColumn + 1 + var nextRow = currentRow + + if nextColumn >= tableView.numberOfColumns { + nextColumn = 1 + nextRow += 1 + } + if nextRow >= tableView.numberOfRows { + nextRow = tableView.numberOfRows - 1 + nextColumn = tableView.numberOfColumns - 1 + } + + DispatchQueue.main.async { + tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) + tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) + } + return true + } + + if commandSelector == #selector(NSResponder.insertBacktab(_:)) { + tableView.window?.makeFirstResponder(tableView) + + var prevColumn = currentColumn - 1 + var prevRow = currentRow + + if prevColumn < 1 { + prevColumn = tableView.numberOfColumns - 1 + prevRow -= 1 + } + if prevRow < 0 { + prevRow = 0 + prevColumn = 1 + } + + DispatchQueue.main.async { + tableView.selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) + tableView.editColumn(prevColumn, row: prevRow, with: nil, select: true) + } + return true + } + + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + tableView.window?.makeFirstResponder(tableView) + return true + } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + tableView.window?.makeFirstResponder(tableView) + return true + } + + return false + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift new file mode 100644 index 00000000..a3f50e7c --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -0,0 +1,39 @@ +// +// DataGridView+Selection.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension TableViewCoordinator { + func tableViewColumnDidResize(_ notification: Notification) { + // Only track user-initiated resizes, not programmatic ones during column rebuilds + guard !isRebuildingColumns else { return } + hasUserResizedColumns = true + } + + func tableViewColumnDidMove(_ notification: Notification) { + guard !isRebuildingColumns else { return } + hasUserResizedColumns = true + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection else { return } + guard let tableView = notification.object as? NSTableView else { return } + + let newSelection = Set(tableView.selectedRowIndexes.map { $0 }) + if newSelection != selectedRowIndices { + DispatchQueue.main.async { [weak self] in + self?.selectedRowIndices = newSelection + } + } + + if let keyTableView = tableView as? KeyHandlingTableView { + if newSelection.isEmpty { + keyTableView.focusedRow = -1 + keyTableView.focusedColumn = -1 + } + } + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift new file mode 100644 index 00000000..7b819233 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -0,0 +1,74 @@ +// +// DataGridView+Sort.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension TableViewCoordinator { + // MARK: - Native Sorting + + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard !isSyncingSortDescriptors else { return } + + guard let sortDescriptor = tableView.sortDescriptors.first, + let key = sortDescriptor.key, + key.hasPrefix("col_"), + let columnIndex = Int(key.dropFirst(4)), + columnIndex >= 0 && columnIndex < rowProvider.columns.count else { + return + } + + let isMultiSort = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + onSort?(columnIndex, sortDescriptor.ascending, isMultiSort) + } + + // MARK: - NSMenuDelegate (Header Context Menu) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + + guard let tableView = tableView, + let headerView = tableView.headerView, + let window = tableView.window else { return } + + let mouseLocation = window.mouseLocationOutsideOfEventStream + let pointInHeader = headerView.convert(mouseLocation, from: nil) + let columnIndex = headerView.column(at: pointInHeader) + + guard columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } + + let column = tableView.tableColumns[columnIndex] + if column.identifier.rawValue == "__rowNumber__" { return } + + // Derive base column name from stable identifier (avoids sort indicator in title) + let baseName: String = { + if let idx = DataGridView.columnIndex(from: column.identifier), + idx < rowProvider.columns.count { + return rowProvider.columns[idx] + } + return column.title + }() + + let copyItem = NSMenuItem(title: String(localized: "Copy Column Name"), action: #selector(copyColumnName(_:)), keyEquivalent: "") + copyItem.representedObject = baseName + copyItem.target = self + menu.addItem(copyItem) + + let filterItem = NSMenuItem(title: String(localized: "Filter with column"), action: #selector(filterWithColumn(_:)), keyEquivalent: "") + filterItem.representedObject = baseName + filterItem.target = self + menu.addItem(filterItem) + } + + @objc func copyColumnName(_ sender: NSMenuItem) { + guard let columnName = sender.representedObject as? String else { return } + ClipboardService.shared.writeText(columnName) + } + + @objc func filterWithColumn(_ sender: NSMenuItem) { + guard let columnName = sender.representedObject as? String else { return } + onFilterColumn?(columnName) + } +} From ac106e9c6e8eee182a60e6a0b8307abe10547cd4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 12:26:30 +0700 Subject: [PATCH 4/6] refactor: split ExportService into per-format extension files --- .../Core/Services/ExportService+CSV.swift | 170 ++++ .../Core/Services/ExportService+JSON.swift | 201 ++++ .../Core/Services/ExportService+MQL.swift | 214 ++++ .../Core/Services/ExportService+SQL.swift | 241 +++++ .../Core/Services/ExportService+XLSX.swift | 83 ++ TablePro/Core/Services/ExportService.swift | 949 +----------------- 6 files changed, 930 insertions(+), 928 deletions(-) create mode 100644 TablePro/Core/Services/ExportService+CSV.swift create mode 100644 TablePro/Core/Services/ExportService+JSON.swift create mode 100644 TablePro/Core/Services/ExportService+MQL.swift create mode 100644 TablePro/Core/Services/ExportService+SQL.swift create mode 100644 TablePro/Core/Services/ExportService+XLSX.swift diff --git a/TablePro/Core/Services/ExportService+CSV.swift b/TablePro/Core/Services/ExportService+CSV.swift new file mode 100644 index 00000000..8f5fbbb3 --- /dev/null +++ b/TablePro/Core/Services/ExportService+CSV.swift @@ -0,0 +1,170 @@ +// +// ExportService+CSV.swift +// TablePro +// + +import Foundation + +extension ExportService { + func exportToCSV( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // Create file and get handle for streaming writes + let fileHandle = try createFileHandle(at: url) + defer { closeFileHandle(fileHandle) } + + let lineBreak = config.csvOptions.lineBreak.value + + for (index, table) in tables.enumerated() { + try checkCancellation() + + state.currentTableIndex = index + 1 + state.currentTable = table.qualifiedName + + // Add table header comment if multiple tables + // Sanitize name to prevent newlines from breaking the comment line + if tables.count > 1 { + let sanitizedName = sanitizeForSQLComment(table.qualifiedName) + try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) + } + + let batchSize = 10_000 + var offset = 0 + var isFirstBatch = true + + while true { + try checkCancellation() + try Task.checkCancellation() + + let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + + // No more rows to process + if result.rows.isEmpty { + break + } + + // Stream CSV content for this batch directly to file + // Only include headers on the first batch to avoid duplication + var batchOptions = config.csvOptions + if !isFirstBatch { + batchOptions.includeFieldNames = false + } + + try await writeCSVContentWithProgress( + columns: result.columns, + rows: result.rows, + options: batchOptions, + to: fileHandle + ) + + isFirstBatch = false + offset += batchSize + } + if index < tables.count - 1 { + try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) + } + } + + try checkCancellation() + state.progress = 1.0 + } + + func writeCSVContentWithProgress( + columns: [String], + rows: [[String?]], + options: CSVExportOptions, + to fileHandle: FileHandle + ) async throws { + let delimiter = options.delimiter.actualValue + let lineBreak = options.lineBreak.value + + // Header row + if options.includeFieldNames { + let headerLine = columns + .map { escapeCSVField($0, options: options) } + .joined(separator: delimiter) + try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data()) + } + + // Data rows with progress tracking - stream directly to file + for row in rows { + try checkCancellation() + + let rowLine = row.map { value -> String in + guard let val = value else { + return options.convertNullToEmpty ? "" : "NULL" + } + + var processed = val + + // Check for line breaks BEFORE converting them (for quote detection) + let hadLineBreaks = val.contains("\n") || val.contains("\r") + + // Convert line breaks to space + if options.convertLineBreakToSpace { + processed = processed + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } + + // Handle decimal format + if options.decimalFormat == .comma { + let range = NSRange(processed.startIndex..., in: processed) + if Self.decimalFormatRegex.firstMatch(in: processed, range: range) != nil { + processed = processed.replacingOccurrences(of: ".", with: ",") + } + } + + return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks) + }.joined(separator: delimiter) + + // Write row directly to file + try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) + + // Update progress (throttled) + await incrementProgress() + } + + // Ensure final count is shown + await finalizeTableProgress() + } + + func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { + var processed = field + + // Sanitize formula-like prefixes to prevent CSV formula injection + // Values starting with these characters can be executed as formulas in Excel/LibreOffice + if options.sanitizeFormulas { + let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"] + if let first = processed.first, dangerousPrefixes.contains(first) { + // Prefix with single quote - Excel/LibreOffice treats this as text + processed = "'" + processed + } + } + + switch options.quoteHandling { + case .always: + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + case .never: + return processed + case .asNeeded: + // Check current content for special characters, OR if original had line breaks + // (important when convertLineBreakToSpace is enabled - original line breaks + // mean the field should still be quoted even after conversion to spaces) + let needsQuotes = processed.contains(options.delimiter.actualValue) || + processed.contains("\"") || + processed.contains("\n") || + processed.contains("\r") || + originalHadLineBreaks + if needsQuotes { + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + return processed + } + } +} diff --git a/TablePro/Core/Services/ExportService+JSON.swift b/TablePro/Core/Services/ExportService+JSON.swift new file mode 100644 index 00000000..bf66a44a --- /dev/null +++ b/TablePro/Core/Services/ExportService+JSON.swift @@ -0,0 +1,201 @@ +// +// ExportService+JSON.swift +// TablePro +// + +import Foundation + +extension ExportService { + func exportToJSON( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // Stream JSON directly to file to minimize memory usage + let fileHandle = try createFileHandle(at: url) + defer { closeFileHandle(fileHandle) } + + let prettyPrint = config.jsonOptions.prettyPrint + let indent = prettyPrint ? " " : "" + let newline = prettyPrint ? "\n" : "" + + // Opening brace + try fileHandle.write(contentsOf: "{\(newline)".toUTF8Data()) + + for (tableIndex, table) in tables.enumerated() { + try checkCancellation() + + state.currentTableIndex = tableIndex + 1 + state.currentTable = table.qualifiedName + + // Write table key and opening bracket + let escapedTableName = escapeJSONString(table.qualifiedName) + try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) + + let batchSize = 1_000 + var offset = 0 + var hasWrittenRow = false + var columns: [String]? + + batchLoop: while true { + try checkCancellation() + try Task.checkCancellation() + + let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + + if result.rows.isEmpty { + break batchLoop + } + + if columns == nil { + columns = result.columns + } + + for row in result.rows { + try checkCancellation() + + // Buffer entire row into a String, then write once (SVC-10) + let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" + var rowString = "" + + // Comma/newline before every row except the first + if hasWrittenRow { + rowString += ",\(newline)" + } + + // Row prefix and opening brace + rowString += rowPrefix + rowString += "{" + + if let columns = columns { + var isFirstField = true + for (colIndex, column) in columns.enumerated() { + if colIndex < row.count { + let value = row[colIndex] + if config.jsonOptions.includeNullValues || value != nil { + if !isFirstField { + rowString += ", " + } + isFirstField = false + + let escapedKey = escapeJSONString(column) + let jsonValue = formatJSONValue( + value, + preserveAsString: config.jsonOptions.preserveAllAsStrings + ) + rowString += "\"\(escapedKey)\": \(jsonValue)" + } + } + } + } + + // Close row object + rowString += "}" + + // Single write per row instead of per field + try fileHandle.write(contentsOf: rowString.toUTF8Data()) + + hasWrittenRow = true + + // Update progress (throttled) + await incrementProgress() + } + + offset += result.rows.count + } + + // Ensure final count is shown for this table + await finalizeTableProgress() + + // Close array + if hasWrittenRow { + try fileHandle.write(contentsOf: newline.toUTF8Data()) + } + let tableSuffix = tableIndex < tables.count - 1 ? ",\(newline)" : newline + try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) + } + + // Closing brace + try fileHandle.write(contentsOf: "}".toUTF8Data()) + + try checkCancellation() + state.progress = 1.0 + } + + func escapeJSONString(_ string: String) -> String { + var utf8Result = [UInt8]() + utf8Result.reserveCapacity(string.utf8.count) + + for byte in string.utf8 { + switch byte { + case 0x22: // " + utf8Result.append(0x5C) // backslash + utf8Result.append(0x22) + case 0x5C: // backslash + utf8Result.append(0x5C) + utf8Result.append(0x5C) + case 0x0A: // \n + utf8Result.append(0x5C) + utf8Result.append(0x6E) // n + case 0x0D: // \r + utf8Result.append(0x5C) + utf8Result.append(0x72) // r + case 0x09: // \t + utf8Result.append(0x5C) + utf8Result.append(0x74) // t + case 0x08: // backspace + utf8Result.append(0x5C) + utf8Result.append(0x62) // b + case 0x0C: // form feed + utf8Result.append(0x5C) + utf8Result.append(0x66) // f + case 0x00...0x1F: + // Other control characters: emit \uXXXX + let hex = String(format: "\\u%04X", byte) + utf8Result.append(contentsOf: hex.utf8) + default: + utf8Result.append(byte) + } + } + + return String(bytes: utf8Result, encoding: .utf8) ?? string + } + + func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { + guard let val = value else { return "null" } + + // If preserving all as strings, skip type detection + if preserveAsString { + return "\"\(escapeJSONString(val))\"" + } + + // Try to detect numbers and booleans + // Note: Large integers (> 2^53-1) may lose precision in JavaScript consumers + if let intVal = Int(val) { + return String(intVal) + } + if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { + // Avoid scientific notation issues + let jsMaxSafeInteger = 9_007_199_254_740_991.0 // 2^53 - 1, JavaScript's Number.MAX_SAFE_INTEGER + + if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { + // For integral values, only convert to Int when within both Int and JS safe integer bounds + if abs(doubleVal) <= jsMaxSafeInteger, + doubleVal >= Double(Int.min), + doubleVal <= Double(Int.max) { + return String(Int(doubleVal)) + } else { + // Preserve original integral representation to avoid scientific notation / precision changes + return val + } + } + return String(doubleVal) + } + if val.lowercased() == "true" || val.lowercased() == "false" { + return val.lowercased() + } + + // String value - escape and quote + return "\"\(escapeJSONString(val))\"" + } +} diff --git a/TablePro/Core/Services/ExportService+MQL.swift b/TablePro/Core/Services/ExportService+MQL.swift new file mode 100644 index 00000000..8d9337ba --- /dev/null +++ b/TablePro/Core/Services/ExportService+MQL.swift @@ -0,0 +1,214 @@ +// +// ExportService+MQL.swift +// TablePro +// + +import Foundation + +extension ExportService { + func exportToMQL( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + let fileHandle = try createFileHandle(at: url) + defer { closeFileHandle(fileHandle) } + + let dateFormatter = ISO8601DateFormatter() + try fileHandle.write(contentsOf: "// TablePro MQL Export\n".toUTF8Data()) + try fileHandle.write(contentsOf: "// Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) + + let dbName = tables.first?.databaseName ?? "" + if !dbName.isEmpty { + try fileHandle.write(contentsOf: "// Database: \(sanitizeForJSComment(dbName))\n".toUTF8Data()) + } + try fileHandle.write(contentsOf: "\n".toUTF8Data()) + + let batchSize = config.mqlOptions.batchSize + + for (index, table) in tables.enumerated() { + try checkCancellation() + + state.currentTableIndex = index + 1 + state.currentTable = table.qualifiedName + + let mqlOpts = table.mqlOptions + let escapedCollection = escapeJSIdentifier(table.name) + let collectionAccessor: String + if escapedCollection.hasPrefix("[") { + collectionAccessor = "db\(escapedCollection)" + } else { + collectionAccessor = "db.\(escapedCollection)" + } + + try fileHandle.write(contentsOf: "// Collection: \(sanitizeForJSComment(table.name))\n".toUTF8Data()) + + if mqlOpts.includeDrop { + try fileHandle.write(contentsOf: "\(collectionAccessor).drop();\n".toUTF8Data()) + } + + if mqlOpts.includeData { + let fetchBatchSize = 5_000 + var offset = 0 + var columns: [String] = [] + var documentBatch: [String] = [] + + while true { + try checkCancellation() + try Task.checkCancellation() + + let result = try await fetchBatch(for: table, offset: offset, limit: fetchBatchSize) + + if result.rows.isEmpty { break } + + if columns.isEmpty { + columns = result.columns + } + + for row in result.rows { + try checkCancellation() + + var fields: [String] = [] + for (colIndex, column) in columns.enumerated() { + guard colIndex < row.count else { continue } + guard let value = row[colIndex] else { continue } + let jsonValue = mqlJsonValue(for: value) + fields.append("\"\(escapeJSONString(column))\": \(jsonValue)") + } + documentBatch.append(" {\(fields.joined(separator: ", "))}") + + if documentBatch.count >= batchSize { + try writeMQLInsertMany( + collection: table.name, + documents: documentBatch, + to: fileHandle + ) + documentBatch.removeAll(keepingCapacity: true) + } + + await incrementProgress() + } + + offset += fetchBatchSize + } + + if !documentBatch.isEmpty { + try writeMQLInsertMany( + collection: table.name, + documents: documentBatch, + to: fileHandle + ) + } + } + + // Indexes after data for performance + if mqlOpts.includeIndexes { + try await writeMQLIndexes( + collection: table.name, + collectionAccessor: collectionAccessor, + to: fileHandle + ) + } + + await finalizeTableProgress() + + if index < tables.count - 1 { + try fileHandle.write(contentsOf: "\n".toUTF8Data()) + } + } + + try checkCancellation() + state.progress = 1.0 + } + + func writeMQLInsertMany( + collection: String, + documents: [String], + to fileHandle: FileHandle + ) throws { + let escapedCollection = escapeJSIdentifier(collection) + var statement: String + if escapedCollection.hasPrefix("[") { + statement = "db\(escapedCollection).insertMany([\n" + } else { + statement = "db.\(escapedCollection).insertMany([\n" + } + statement += documents.joined(separator: ",\n") + statement += "\n]);\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + } + + func writeMQLIndexes( + collection: String, + collectionAccessor: String, + to fileHandle: FileHandle + ) async throws { + let ddl = try await driver.fetchTableDDL(table: collection) + + let lines = ddl.components(separatedBy: "\n") + var indexLines: [String] = [] + var foundHeader = false + + for line in lines { + if line.hasPrefix("// Collection:") { + foundHeader = true + continue + } + if foundHeader { + var processedLine = line + let escapedForDDL = collection.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + let ddlAccessor = "db[\"\(escapedForDDL)\"]" + if processedLine.hasPrefix(ddlAccessor) { + processedLine = collectionAccessor + processedLine.dropFirst(ddlAccessor.count) + } + indexLines.append(processedLine) + } + } + + let indexContent = indexLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !indexContent.isEmpty { + try fileHandle.write(contentsOf: "\(indexContent)\n".toUTF8Data()) + } + } + + func mqlJsonValue(for value: String) -> String { + if value == "true" || value == "false" { + return value + } + if value == "null" { + return "null" + } + if Int64(value) != nil { + return value + } + if Double(value) != nil, value.contains(".") { + return value + } + // JSON object or array -- pass through if valid (no unescaped control chars) + if (value.hasPrefix("{") && value.hasSuffix("}")) || + (value.hasPrefix("[") && value.hasSuffix("]")) { + let hasControlChars = value.utf8.contains(where: { $0 < 0x20 }) + if hasControlChars { + return "\"\(escapeJSONString(value))\"" + } + return value + } + return "\"\(escapeJSONString(value))\"" + } + + func escapeJSIdentifier(_ name: String) -> String { + guard let firstChar = name.first, + !firstChar.isNumber, + name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { + return "[\"\(escapeJSONString(name))\"]" + } + return name + } + + func sanitizeForJSComment(_ name: String) -> String { + var result = name + result = result.replacingOccurrences(of: "\n", with: " ") + result = result.replacingOccurrences(of: "\r", with: " ") + return result + } +} diff --git a/TablePro/Core/Services/ExportService+SQL.swift b/TablePro/Core/Services/ExportService+SQL.swift new file mode 100644 index 00000000..f9d24a6a --- /dev/null +++ b/TablePro/Core/Services/ExportService+SQL.swift @@ -0,0 +1,241 @@ +// +// ExportService+SQL.swift +// TablePro +// + +import Foundation +import os + +extension ExportService { + func exportToSQL( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // For gzip, write to temp file first then compress + // For non-gzip, stream directly to destination + let targetURL: URL + let tempFileURL: URL? + + if config.sqlOptions.compressWithGzip { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sql") + tempFileURL = tempURL + targetURL = tempURL + } else { + tempFileURL = nil + targetURL = url + } + + // Create file and get handle for streaming writes + let fileHandle = try createFileHandle(at: targetURL) + + do { + // Add header comment + let dateFormatter = ISO8601DateFormatter() + try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".toUTF8Data()) + + // Collect and emit dependent sequences and enum types (PostgreSQL) in batch + var emittedSequenceNames: Set = [] + var emittedTypeNames: Set = [] + let structureTableNames = tables.filter { $0.sqlOptions.includeStructure }.map(\.name) + + var allSequences: [String: [(name: String, ddl: String)]] = [:] + do { + allSequences = try await driver.fetchAllDependentSequences(forTables: structureTableNames) + } catch { + Self.logger.warning("Failed to fetch dependent sequences: \(error.localizedDescription)") + } + + var allEnumTypes: [String: [(name: String, labels: [String])]] = [:] + do { + allEnumTypes = try await driver.fetchAllDependentTypes(forTables: structureTableNames) + } catch { + Self.logger.warning("Failed to fetch dependent enum types: \(error.localizedDescription)") + } + + for table in tables where table.sqlOptions.includeStructure { + let sequences = allSequences[table.name] ?? [] + for seq in sequences where !emittedSequenceNames.contains(seq.name) { + emittedSequenceNames.insert(seq.name) + let quotedName = "\"\(seq.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + try fileHandle.write(contentsOf: "DROP SEQUENCE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) + try fileHandle.write(contentsOf: "\(seq.ddl)\n\n".toUTF8Data()) + } + + let enumTypes = allEnumTypes[table.name] ?? [] + for enumType in enumTypes where !emittedTypeNames.contains(enumType.name) { + emittedTypeNames.insert(enumType.name) + let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + try fileHandle.write(contentsOf: "DROP TYPE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) + let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0, databaseType: databaseType))'" } + try fileHandle.write(contentsOf: "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n\n".toUTF8Data()) + } + } + + for (index, table) in tables.enumerated() { + try checkCancellation() + + state.currentTableIndex = index + 1 + state.currentTable = table.qualifiedName + + let sqlOptions = table.sqlOptions + let tableRef = databaseType.quoteIdentifier(table.name) + + let sanitizedName = sanitizeForSQLComment(table.name) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) + + // DROP statement + if sqlOptions.includeDrop { + try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".toUTF8Data()) + } + + // CREATE TABLE (structure) + if sqlOptions.includeStructure { + do { + let ddl = try await driver.fetchTableDDL(table: table.name) + try fileHandle.write(contentsOf: ddl.toUTF8Data()) + if !ddl.hasSuffix(";") { + try fileHandle.write(contentsOf: ";".toUTF8Data()) + } + try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) + } catch { + // Track the failure for user notification + ddlFailures.append(sanitizedName) + + // Use sanitizedName (already defined above) for safe comment output + let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" + Self.logger.warning("Failed to fetch DDL for table \(sanitizedName): \(error)") + try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) + } + } + + // INSERT statements (data) - stream directly to file in batches + if sqlOptions.includeData { + let batchSize = config.sqlOptions.batchSize + var offset = 0 + var wroteAnyRows = false + + while true { + try checkCancellation() + try Task.checkCancellation() + + let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let result = try await driver.execute(query: query) + + if result.rows.isEmpty { + break + } + + try await writeInsertStatementsWithProgress( + table: table, + columns: result.columns, + rows: result.rows, + batchSize: batchSize, + to: fileHandle + ) + + wroteAnyRows = true + offset += batchSize + } + + if wroteAnyRows { + try fileHandle.write(contentsOf: "\n".toUTF8Data()) + } + } + } + + try fileHandle.close() + } catch { + closeFileHandle(fileHandle) + if let tempURL = tempFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + throw error + } + + // Handle gzip compression + if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { + state.statusMessage = "Compressing..." + await Task.yield() + + do { + defer { + // Always remove the temporary file, regardless of success or failure + try? FileManager.default.removeItem(at: tempURL) + } + + try await compressFileToFile(source: tempURL, destination: url) + } catch { + // Remove the (possibly partially written) destination file on compression failure + try? FileManager.default.removeItem(at: url) + throw error + } + } + + // Surface DDL failures to user as a warning + if !ddlFailures.isEmpty { + let failedTables = ddlFailures.joined(separator: ", ") + state.warningMessage = "Export completed with warnings: Could not fetch table structure for: \(failedTables)" + } + + state.progress = 1.0 + } + + func writeInsertStatementsWithProgress( + table: ExportTableItem, + columns: [String], + rows: [[String?]], + batchSize: Int, + to fileHandle: FileHandle + ) async throws { + // Use unqualified table name for INSERT statements (schema-agnostic export) + let tableRef = databaseType.quoteIdentifier(table.name) + let quotedColumns = columns + .map { databaseType.quoteIdentifier($0) } + .joined(separator: ", ") + + let insertPrefix = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES\n" + + // Effective batch size (<=1 means no batching, one row per INSERT) + let effectiveBatchSize = batchSize <= 1 ? 1 : batchSize + var valuesBatch: [String] = [] + valuesBatch.reserveCapacity(effectiveBatchSize) + + for row in rows { + try checkCancellation() + + let values = row.map { value -> String in + guard let val = value else { return "NULL" } + // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) + let escaped = SQLEscaping.escapeStringLiteral(val, databaseType: databaseType) + return "'\(escaped)'" + }.joined(separator: ", ") + + valuesBatch.append(" (\(values))") + + // Write batch when full + if valuesBatch.count >= effectiveBatchSize { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + valuesBatch.removeAll(keepingCapacity: true) + } + + // Update progress (throttled) + await incrementProgress() + } + + // Write remaining rows in final batch + if !valuesBatch.isEmpty { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + } + + // Ensure final count is shown + await finalizeTableProgress() + } +} diff --git a/TablePro/Core/Services/ExportService+XLSX.swift b/TablePro/Core/Services/ExportService+XLSX.swift new file mode 100644 index 00000000..26e2d157 --- /dev/null +++ b/TablePro/Core/Services/ExportService+XLSX.swift @@ -0,0 +1,83 @@ +// +// ExportService+XLSX.swift +// TablePro +// + +import AppKit +import Foundation + +extension ExportService { + func exportToXLSX( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + let writer = XLSXWriter() + let options = config.xlsxOptions + + for (index, table) in tables.enumerated() { + try checkCancellation() + + state.currentTableIndex = index + 1 + state.currentTable = table.qualifiedName + + let batchSize = 5_000 + var offset = 0 + var columns: [String] = [] + var isFirstBatch = true + + while true { + try checkCancellation() + try Task.checkCancellation() + + let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + + if result.rows.isEmpty { break } + + if isFirstBatch { + columns = result.columns + writer.beginSheet( + name: table.name, + columns: columns, + includeHeader: options.includeHeaderRow, + convertNullToEmpty: options.convertNullToEmpty + ) + isFirstBatch = false + } + + // Write this batch to the sheet XML and release batch memory + autoreleasepool { + writer.addRows(result.rows, convertNullToEmpty: options.convertNullToEmpty) + } + + // Update progress for each row in this batch + for _ in result.rows { + await incrementProgress() + } + + offset += batchSize + } + + // If we fetched at least one batch, finish the sheet + if !isFirstBatch { + writer.finishSheet() + } else { + // Table was empty - create an empty sheet with no data + writer.beginSheet( + name: table.name, + columns: [], + includeHeader: false, + convertNullToEmpty: options.convertNullToEmpty + ) + writer.finishSheet() + } + + await finalizeTableProgress() + } + + // Write XLSX on background thread to avoid blocking UI + try await Task.detached(priority: .userInitiated) { + try writer.write(to: url) + }.value + } +} diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 28c31f52..7ae83e31 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -41,7 +41,7 @@ enum ExportError: LocalizedError { // MARK: - String Extension for Safe Encoding -private extension String { +extension String { /// Safely encode string to UTF-8 data, throwing if encoding fails func toUTF8Data() throws -> Data { guard let data = self.data(using: .utf8) else { @@ -73,9 +73,9 @@ struct ExportState { /// Service responsible for exporting table data to various formats @MainActor @Observable final class ExportService { - private static let logger = Logger(subsystem: "com.TablePro", category: "ExportService") + static let logger = Logger(subsystem: "com.TablePro", category: "ExportService") // swiftlint:disable:next force_try - private static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) + static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) // MARK: - Published State var state = ExportState() @@ -83,13 +83,13 @@ final class ExportService { // MARK: - DDL Failure Tracking /// Tables that failed DDL fetch during SQL export - private var ddlFailures: [String] = [] + var ddlFailures: [String] = [] // MARK: - Cancellation - private let isCancelledLock = NSLock() - private var _isCancelled: Bool = false - private var isCancelled: Bool { + let isCancelledLock = NSLock() + var _isCancelled: Bool = false + var isCancelled: Bool { get { isCancelledLock.lock() defer { isCancelledLock.unlock() } @@ -105,14 +105,14 @@ final class ExportService { // MARK: - Progress Throttling /// Number of rows to process before updating UI - private let progressUpdateInterval: Int = 1_000 + let progressUpdateInterval: Int = 1_000 /// Internal counter for processed rows (updated every row) - private var internalProcessedRows: Int = 0 + var internalProcessedRows: Int = 0 // MARK: - Dependencies - private let driver: DatabaseDriver - private let databaseType: DatabaseType + let driver: DatabaseDriver + let databaseType: DatabaseType // MARK: - Initialization @@ -246,7 +246,7 @@ final class ExportService { } /// Check if export was cancelled and throw if so - private func checkCancellation() throws { + func checkCancellation() throws { if isCancelled { throw NSError( domain: "ExportService", @@ -259,7 +259,7 @@ final class ExportService { /// Increment processed rows with throttled UI updates /// Only updates @Published properties every `progressUpdateInterval` rows /// Uses Task.yield() to allow UI to refresh - private func incrementProgress() async { + func incrementProgress() async { internalProcessedRows += 1 // Only update UI every N rows @@ -274,7 +274,7 @@ final class ExportService { } /// Finalize progress for current table (ensures UI shows final count) - private func finalizeTableProgress() async { + func finalizeTableProgress() async { state.processedRows = internalProcessedRows if state.totalRows > 0 { state.progress = Double(internalProcessedRows) / Double(state.totalRows) @@ -286,7 +286,7 @@ final class ExportService { // MARK: - Helpers /// Build fully qualified and quoted table reference (database.table or just table) - private func qualifiedTableRef(for table: ExportTableItem) -> String { + func qualifiedTableRef(for table: ExportTableItem) -> String { if table.databaseName.isEmpty { return databaseType.quoteIdentifier(table.name) } else { @@ -296,7 +296,7 @@ final class ExportService { } } - private func fetchAllQuery(for table: ExportTableItem) -> String { + func fetchAllQuery(for table: ExportTableItem) -> String { switch databaseType { case .mongodb: let escaped = escapeJSIdentifier(table.name) @@ -311,7 +311,7 @@ final class ExportService { } } - private func fetchBatch(for table: ExportTableItem, offset: Int, limit: Int) async throws -> QueryResult { + func fetchBatch(for table: ExportTableItem, offset: Int, limit: Int) async throws -> QueryResult { let query = fetchAllQuery(for: table) return try await driver.fetchRows(query: query, offset: offset, limit: limit) } @@ -323,7 +323,7 @@ final class ExportService { /// - Comment sequences (/* */ --) /// /// Logs a warning when the name is modified. - private func sanitizeForSQLComment(_ name: String) -> String { + func sanitizeForSQLComment(_ name: String) -> String { var result = name // Replace newlines with spaces result = result.replacingOccurrences(of: "\n", with: " ") @@ -344,7 +344,7 @@ final class ExportService { // MARK: - File Helpers /// Create a file at the given URL and return a FileHandle for writing - private func createFileHandle(at url: URL) throws -> FileHandle { + func createFileHandle(at url: URL) throws -> FileHandle { guard FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil) else { throw ExportError.fileWriteFailed(url.path(percentEncoded: false)) } @@ -354,7 +354,7 @@ final class ExportService { /// Close a file handle with error logging instead of silent suppression /// /// Used in defer blocks where we can't throw but want visibility into failures. - private func closeFileHandle(_ handle: FileHandle) { + func closeFileHandle(_ handle: FileHandle) { do { try handle.close() } catch { @@ -362,916 +362,9 @@ final class ExportService { } } - // MARK: - XLSX Export - - private func exportToXLSX( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - let writer = XLSXWriter() - let options = config.xlsxOptions - - for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - let batchSize = 5_000 - var offset = 0 - var columns: [String] = [] - var isFirstBatch = true - - while true { - try checkCancellation() - try Task.checkCancellation() - - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) - - if result.rows.isEmpty { break } - - if isFirstBatch { - columns = result.columns - writer.beginSheet( - name: table.name, - columns: columns, - includeHeader: options.includeHeaderRow, - convertNullToEmpty: options.convertNullToEmpty - ) - isFirstBatch = false - } - - // Write this batch to the sheet XML and release batch memory - autoreleasepool { - writer.addRows(result.rows, convertNullToEmpty: options.convertNullToEmpty) - } - - // Update progress for each row in this batch - for _ in result.rows { - await incrementProgress() - } - - offset += batchSize - } - - // If we fetched at least one batch, finish the sheet - if !isFirstBatch { - writer.finishSheet() - } else { - // Table was empty - create an empty sheet with no data - writer.beginSheet( - name: table.name, - columns: [], - includeHeader: false, - convertNullToEmpty: options.convertNullToEmpty - ) - writer.finishSheet() - } - - await finalizeTableProgress() - } - - // Write XLSX on background thread to avoid blocking UI - try await Task.detached(priority: .userInitiated) { - try writer.write(to: url) - }.value - } - - // MARK: - CSV Export - - private func exportToCSV( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - // Create file and get handle for streaming writes - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } - - let lineBreak = config.csvOptions.lineBreak.value - - for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - // Add table header comment if multiple tables - // Sanitize name to prevent newlines from breaking the comment line - if tables.count > 1 { - let sanitizedName = sanitizeForSQLComment(table.qualifiedName) - try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) - } - - let batchSize = 10_000 - var offset = 0 - var isFirstBatch = true - - while true { - try checkCancellation() - try Task.checkCancellation() - - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) - - // No more rows to process - if result.rows.isEmpty { - break - } - - // Stream CSV content for this batch directly to file - // Only include headers on the first batch to avoid duplication - var batchOptions = config.csvOptions - if !isFirstBatch { - batchOptions.includeFieldNames = false - } - - try await writeCSVContentWithProgress( - columns: result.columns, - rows: result.rows, - options: batchOptions, - to: fileHandle - ) - - isFirstBatch = false - offset += batchSize - } - if index < tables.count - 1 { - try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) - } - } - - try checkCancellation() - state.progress = 1.0 - } - - private func writeCSVContentWithProgress( - columns: [String], - rows: [[String?]], - options: CSVExportOptions, - to fileHandle: FileHandle - ) async throws { - let delimiter = options.delimiter.actualValue - let lineBreak = options.lineBreak.value - - // Header row - if options.includeFieldNames { - let headerLine = columns - .map { escapeCSVField($0, options: options) } - .joined(separator: delimiter) - try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data()) - } - - // Data rows with progress tracking - stream directly to file - for row in rows { - try checkCancellation() - - let rowLine = row.map { value -> String in - guard let val = value else { - return options.convertNullToEmpty ? "" : "NULL" - } - - var processed = val - - // Check for line breaks BEFORE converting them (for quote detection) - let hadLineBreaks = val.contains("\n") || val.contains("\r") - - // Convert line breaks to space - if options.convertLineBreakToSpace { - processed = processed - .replacingOccurrences(of: "\r\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - .replacingOccurrences(of: "\n", with: " ") - } - - // Handle decimal format - if options.decimalFormat == .comma { - let range = NSRange(processed.startIndex..., in: processed) - if Self.decimalFormatRegex.firstMatch(in: processed, range: range) != nil { - processed = processed.replacingOccurrences(of: ".", with: ",") - } - } - - return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks) - }.joined(separator: delimiter) - - // Write row directly to file - try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) - - // Update progress (throttled) - await incrementProgress() - } - - // Ensure final count is shown - await finalizeTableProgress() - } - - /// Escape and quote a CSV field according to the specified options - /// - Parameters: - /// - field: The field value to escape - /// - options: CSV export options - /// - originalHadLineBreaks: Whether the original value had line breaks before conversion. - /// Used for proper quote detection when convertLineBreakToSpace is enabled. - private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { - var processed = field - - // Sanitize formula-like prefixes to prevent CSV formula injection - // Values starting with these characters can be executed as formulas in Excel/LibreOffice - if options.sanitizeFormulas { - let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"] - if let first = processed.first, dangerousPrefixes.contains(first) { - // Prefix with single quote - Excel/LibreOffice treats this as text - processed = "'" + processed - } - } - - switch options.quoteHandling { - case .always: - let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - case .never: - return processed - case .asNeeded: - // Check current content for special characters, OR if original had line breaks - // (important when convertLineBreakToSpace is enabled - original line breaks - // mean the field should still be quoted even after conversion to spaces) - let needsQuotes = processed.contains(options.delimiter.actualValue) || - processed.contains("\"") || - processed.contains("\n") || - processed.contains("\r") || - originalHadLineBreaks - if needsQuotes { - let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } - return processed - } - } - - // MARK: - JSON Export - - private func exportToJSON( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - // Stream JSON directly to file to minimize memory usage - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } - - let prettyPrint = config.jsonOptions.prettyPrint - let indent = prettyPrint ? " " : "" - let newline = prettyPrint ? "\n" : "" - - // Opening brace - try fileHandle.write(contentsOf: "{\(newline)".toUTF8Data()) - - for (tableIndex, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = tableIndex + 1 - state.currentTable = table.qualifiedName - - // Write table key and opening bracket - let escapedTableName = escapeJSONString(table.qualifiedName) - try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) - - let batchSize = 1_000 - var offset = 0 - var hasWrittenRow = false - var columns: [String]? - - batchLoop: while true { - try checkCancellation() - try Task.checkCancellation() - - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) - - if result.rows.isEmpty { - break batchLoop - } - - if columns == nil { - columns = result.columns - } - - for row in result.rows { - try checkCancellation() - - // Buffer entire row into a String, then write once (SVC-10) - let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" - var rowString = "" - - // Comma/newline before every row except the first - if hasWrittenRow { - rowString += ",\(newline)" - } - - // Row prefix and opening brace - rowString += rowPrefix - rowString += "{" - - if let columns = columns { - var isFirstField = true - for (colIndex, column) in columns.enumerated() { - if colIndex < row.count { - let value = row[colIndex] - if config.jsonOptions.includeNullValues || value != nil { - if !isFirstField { - rowString += ", " - } - isFirstField = false - - let escapedKey = escapeJSONString(column) - let jsonValue = formatJSONValue( - value, - preserveAsString: config.jsonOptions.preserveAllAsStrings - ) - rowString += "\"\(escapedKey)\": \(jsonValue)" - } - } - } - } - - // Close row object - rowString += "}" - - // Single write per row instead of per field - try fileHandle.write(contentsOf: rowString.toUTF8Data()) - - hasWrittenRow = true - - // Update progress (throttled) - await incrementProgress() - } - - offset += result.rows.count - } - - // Ensure final count is shown for this table - await finalizeTableProgress() - - // Close array - if hasWrittenRow { - try fileHandle.write(contentsOf: newline.toUTF8Data()) - } - let tableSuffix = tableIndex < tables.count - 1 ? ",\(newline)" : newline - try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) - } - - // Closing brace - try fileHandle.write(contentsOf: "}".toUTF8Data()) - - try checkCancellation() - state.progress = 1.0 - } - - /// Escape a string for JSON output per RFC 8259 - /// - /// Escapes: - /// - Quotation mark, backslash (required) - /// - Control characters U+0000 to U+001F (required by spec) - /// - /// Uses UTF-8 byte iteration instead of grapheme-cluster iteration for performance. - /// All JSON-special characters and control codes are single-byte ASCII, so multi-byte - /// UTF-8 sequences (which never contain bytes < 0x80) are passed through unchanged. - private func escapeJSONString(_ string: String) -> String { - var utf8Result = [UInt8]() - utf8Result.reserveCapacity(string.utf8.count) - - for byte in string.utf8 { - switch byte { - case 0x22: // " - utf8Result.append(0x5C) // backslash - utf8Result.append(0x22) - case 0x5C: // backslash - utf8Result.append(0x5C) - utf8Result.append(0x5C) - case 0x0A: // \n - utf8Result.append(0x5C) - utf8Result.append(0x6E) // n - case 0x0D: // \r - utf8Result.append(0x5C) - utf8Result.append(0x72) // r - case 0x09: // \t - utf8Result.append(0x5C) - utf8Result.append(0x74) // t - case 0x08: // backspace - utf8Result.append(0x5C) - utf8Result.append(0x62) // b - case 0x0C: // form feed - utf8Result.append(0x5C) - utf8Result.append(0x66) // f - case 0x00...0x1F: - // Other control characters: emit \uXXXX - let hex = String(format: "\\u%04X", byte) - utf8Result.append(contentsOf: hex.utf8) - default: - utf8Result.append(byte) - } - } - - return String(bytes: utf8Result, encoding: .utf8) ?? string - } - - /// Format a value for JSON output with optional type detection - /// - /// - Parameters: - /// - value: The value to format - /// - preserveAsString: If true, always output as string without type detection - /// (preserves leading zeros in ZIP codes, phone numbers, etc.) - /// - /// - Note: When type detection is enabled (preserveAsString = false), integers beyond - /// JavaScript's Number.MAX_SAFE_INTEGER (2^53-1 = 9007199254740991) may lose precision - /// when parsed by JavaScript. For large IDs or precise numeric data, enable the - /// "Preserve All Values as Strings" option in export settings. - private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { - guard let val = value else { return "null" } - - // If preserving all as strings, skip type detection - if preserveAsString { - return "\"\(escapeJSONString(val))\"" - } - - // Try to detect numbers and booleans - // Note: Large integers (> 2^53-1) may lose precision in JavaScript consumers - if let intVal = Int(val) { - return String(intVal) - } - if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { - // Avoid scientific notation issues - let jsMaxSafeInteger = 9_007_199_254_740_991.0 // 2^53 - 1, JavaScript's Number.MAX_SAFE_INTEGER - - if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { - // For integral values, only convert to Int when within both Int and JS safe integer bounds - if abs(doubleVal) <= jsMaxSafeInteger, - doubleVal >= Double(Int.min), - doubleVal <= Double(Int.max) { - return String(Int(doubleVal)) - } else { - // Preserve original integral representation to avoid scientific notation / precision changes - return val - } - } - return String(doubleVal) - } - if val.lowercased() == "true" || val.lowercased() == "false" { - return val.lowercased() - } - - // String value - escape and quote - return "\"\(escapeJSONString(val))\"" - } - - // MARK: - SQL Export - - private func exportToSQL( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - // For gzip, write to temp file first then compress - // For non-gzip, stream directly to destination - let targetURL: URL - let tempFileURL: URL? - - if config.sqlOptions.compressWithGzip { - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".sql") - tempFileURL = tempURL - targetURL = tempURL - } else { - tempFileURL = nil - targetURL = url - } - - // Create file and get handle for streaming writes - let fileHandle = try createFileHandle(at: targetURL) - - do { - // Add header comment - let dateFormatter = ISO8601DateFormatter() - try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".toUTF8Data()) - - // Collect and emit dependent sequences and enum types (PostgreSQL) in batch - var emittedSequenceNames: Set = [] - var emittedTypeNames: Set = [] - let structureTableNames = tables.filter { $0.sqlOptions.includeStructure }.map(\.name) - - var allSequences: [String: [(name: String, ddl: String)]] = [:] - do { - allSequences = try await driver.fetchAllDependentSequences(forTables: structureTableNames) - } catch { - Self.logger.warning("Failed to fetch dependent sequences: \(error.localizedDescription)") - } - - var allEnumTypes: [String: [(name: String, labels: [String])]] = [:] - do { - allEnumTypes = try await driver.fetchAllDependentTypes(forTables: structureTableNames) - } catch { - Self.logger.warning("Failed to fetch dependent enum types: \(error.localizedDescription)") - } - - for table in tables where table.sqlOptions.includeStructure { - let sequences = allSequences[table.name] ?? [] - for seq in sequences where !emittedSequenceNames.contains(seq.name) { - emittedSequenceNames.insert(seq.name) - let quotedName = "\"\(seq.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - try fileHandle.write(contentsOf: "DROP SEQUENCE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) - try fileHandle.write(contentsOf: "\(seq.ddl)\n\n".toUTF8Data()) - } - - let enumTypes = allEnumTypes[table.name] ?? [] - for enumType in enumTypes where !emittedTypeNames.contains(enumType.name) { - emittedTypeNames.insert(enumType.name) - let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - try fileHandle.write(contentsOf: "DROP TYPE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) - let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0, databaseType: databaseType))'" } - try fileHandle.write(contentsOf: "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n\n".toUTF8Data()) - } - } - - for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - let sqlOptions = table.sqlOptions - let tableRef = databaseType.quoteIdentifier(table.name) - - let sanitizedName = sanitizeForSQLComment(table.name) - try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) - - // DROP statement - if sqlOptions.includeDrop { - try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".toUTF8Data()) - } - - // CREATE TABLE (structure) - if sqlOptions.includeStructure { - do { - let ddl = try await driver.fetchTableDDL(table: table.name) - try fileHandle.write(contentsOf: ddl.toUTF8Data()) - if !ddl.hasSuffix(";") { - try fileHandle.write(contentsOf: ";".toUTF8Data()) - } - try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) - } catch { - // Track the failure for user notification - ddlFailures.append(sanitizedName) - - // Use sanitizedName (already defined above) for safe comment output - let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" - Self.logger.warning("Failed to fetch DDL for table \(sanitizedName): \(error)") - try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) - } - } - - // INSERT statements (data) - stream directly to file in batches - if sqlOptions.includeData { - let batchSize = config.sqlOptions.batchSize - var offset = 0 - var wroteAnyRows = false - - while true { - try checkCancellation() - try Task.checkCancellation() - - let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" - let result = try await driver.execute(query: query) - - if result.rows.isEmpty { - break - } - - try await writeInsertStatementsWithProgress( - table: table, - columns: result.columns, - rows: result.rows, - batchSize: batchSize, - to: fileHandle - ) - - wroteAnyRows = true - offset += batchSize - } - - if wroteAnyRows { - try fileHandle.write(contentsOf: "\n".toUTF8Data()) - } - } - } - - try fileHandle.close() - } catch { - closeFileHandle(fileHandle) - if let tempURL = tempFileURL { - try? FileManager.default.removeItem(at: tempURL) - } - throw error - } - - // Handle gzip compression - if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { - state.statusMessage = "Compressing..." - await Task.yield() - - do { - defer { - // Always remove the temporary file, regardless of success or failure - try? FileManager.default.removeItem(at: tempURL) - } - - try await compressFileToFile(source: tempURL, destination: url) - } catch { - // Remove the (possibly partially written) destination file on compression failure - try? FileManager.default.removeItem(at: url) - throw error - } - } - - // Surface DDL failures to user as a warning - if !ddlFailures.isEmpty { - let failedTables = ddlFailures.joined(separator: ", ") - state.warningMessage = "Export completed with warnings: Could not fetch table structure for: \(failedTables)" - } - - state.progress = 1.0 - } - - private func writeInsertStatementsWithProgress( - table: ExportTableItem, - columns: [String], - rows: [[String?]], - batchSize: Int, - to fileHandle: FileHandle - ) async throws { - // Use unqualified table name for INSERT statements (schema-agnostic export) - let tableRef = databaseType.quoteIdentifier(table.name) - let quotedColumns = columns - .map { databaseType.quoteIdentifier($0) } - .joined(separator: ", ") - - let insertPrefix = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES\n" - - // Effective batch size (<=1 means no batching, one row per INSERT) - let effectiveBatchSize = batchSize <= 1 ? 1 : batchSize - var valuesBatch: [String] = [] - valuesBatch.reserveCapacity(effectiveBatchSize) - - for row in rows { - try checkCancellation() - - let values = row.map { value -> String in - guard let val = value else { return "NULL" } - // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) - let escaped = SQLEscaping.escapeStringLiteral(val, databaseType: databaseType) - return "'\(escaped)'" - }.joined(separator: ", ") - - valuesBatch.append(" (\(values))") - - // Write batch when full - if valuesBatch.count >= effectiveBatchSize { - let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" - try fileHandle.write(contentsOf: statement.toUTF8Data()) - valuesBatch.removeAll(keepingCapacity: true) - } - - // Update progress (throttled) - await incrementProgress() - } - - // Write remaining rows in final batch - if !valuesBatch.isEmpty { - let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" - try fileHandle.write(contentsOf: statement.toUTF8Data()) - } - - // Ensure final count is shown - await finalizeTableProgress() - } - - // MARK: - MQL Export - - private func exportToMQL( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } - - let dateFormatter = ISO8601DateFormatter() - try fileHandle.write(contentsOf: "// TablePro MQL Export\n".toUTF8Data()) - try fileHandle.write(contentsOf: "// Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) - - let dbName = tables.first?.databaseName ?? "" - if !dbName.isEmpty { - try fileHandle.write(contentsOf: "// Database: \(sanitizeForJSComment(dbName))\n".toUTF8Data()) - } - try fileHandle.write(contentsOf: "\n".toUTF8Data()) - - let batchSize = config.mqlOptions.batchSize - - for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - let mqlOpts = table.mqlOptions - let escapedCollection = escapeJSIdentifier(table.name) - let collectionAccessor: String - if escapedCollection.hasPrefix("[") { - collectionAccessor = "db\(escapedCollection)" - } else { - collectionAccessor = "db.\(escapedCollection)" - } - - try fileHandle.write(contentsOf: "// Collection: \(sanitizeForJSComment(table.name))\n".toUTF8Data()) - - if mqlOpts.includeDrop { - try fileHandle.write(contentsOf: "\(collectionAccessor).drop();\n".toUTF8Data()) - } - - if mqlOpts.includeData { - let fetchBatchSize = 5_000 - var offset = 0 - var columns: [String] = [] - var documentBatch: [String] = [] - - while true { - try checkCancellation() - try Task.checkCancellation() - - let result = try await fetchBatch(for: table, offset: offset, limit: fetchBatchSize) - - if result.rows.isEmpty { break } - - if columns.isEmpty { - columns = result.columns - } - - for row in result.rows { - try checkCancellation() - - var fields: [String] = [] - for (colIndex, column) in columns.enumerated() { - guard colIndex < row.count else { continue } - guard let value = row[colIndex] else { continue } - let jsonValue = mqlJsonValue(for: value) - fields.append("\"\(escapeJSONString(column))\": \(jsonValue)") - } - documentBatch.append(" {\(fields.joined(separator: ", "))}") - - if documentBatch.count >= batchSize { - try writeMQLInsertMany( - collection: table.name, - documents: documentBatch, - to: fileHandle - ) - documentBatch.removeAll(keepingCapacity: true) - } - - await incrementProgress() - } - - offset += fetchBatchSize - } - - if !documentBatch.isEmpty { - try writeMQLInsertMany( - collection: table.name, - documents: documentBatch, - to: fileHandle - ) - } - } - - // Indexes after data for performance - if mqlOpts.includeIndexes { - try await writeMQLIndexes( - collection: table.name, - collectionAccessor: collectionAccessor, - to: fileHandle - ) - } - - await finalizeTableProgress() - - if index < tables.count - 1 { - try fileHandle.write(contentsOf: "\n".toUTF8Data()) - } - } - - try checkCancellation() - state.progress = 1.0 - } - - private func writeMQLInsertMany( - collection: String, - documents: [String], - to fileHandle: FileHandle - ) throws { - let escapedCollection = escapeJSIdentifier(collection) - var statement: String - if escapedCollection.hasPrefix("[") { - statement = "db\(escapedCollection).insertMany([\n" - } else { - statement = "db.\(escapedCollection).insertMany([\n" - } - statement += documents.joined(separator: ",\n") - statement += "\n]);\n" - try fileHandle.write(contentsOf: statement.toUTF8Data()) - } - - private func writeMQLIndexes( - collection: String, - collectionAccessor: String, - to fileHandle: FileHandle - ) async throws { - let ddl = try await driver.fetchTableDDL(table: collection) - - let lines = ddl.components(separatedBy: "\n") - var indexLines: [String] = [] - var foundHeader = false - - for line in lines { - if line.hasPrefix("// Collection:") { - foundHeader = true - continue - } - if foundHeader { - var processedLine = line - let escapedForDDL = collection.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - let ddlAccessor = "db[\"\(escapedForDDL)\"]" - if processedLine.hasPrefix(ddlAccessor) { - processedLine = collectionAccessor + processedLine.dropFirst(ddlAccessor.count) - } - indexLines.append(processedLine) - } - } - - let indexContent = indexLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !indexContent.isEmpty { - try fileHandle.write(contentsOf: "\(indexContent)\n".toUTF8Data()) - } - } - - /// Convert a string value to its MQL/JSON representation with auto-detected type - private func mqlJsonValue(for value: String) -> String { - if value == "true" || value == "false" { - return value - } - if value == "null" { - return "null" - } - if Int64(value) != nil { - return value - } - if Double(value) != nil, value.contains(".") { - return value - } - // JSON object or array -- pass through if valid (no unescaped control chars) - if (value.hasPrefix("{") && value.hasSuffix("}")) || - (value.hasPrefix("[") && value.hasSuffix("]")) { - let hasControlChars = value.utf8.contains(where: { $0 < 0x20 }) - if hasControlChars { - return "\"\(escapeJSONString(value))\"" - } - return value - } - return "\"\(escapeJSONString(value))\"" - } - - /// Escape a collection name for use as a JavaScript property identifier. - /// Names with special characters use bracket notation instead of dot notation. - private func escapeJSIdentifier(_ name: String) -> String { - guard let firstChar = name.first, - !firstChar.isNumber, - name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { - return "[\"\(escapeJSONString(name))\"]" - } - return name - } - - /// Sanitize a name for use in JavaScript single-line comments - private func sanitizeForJSComment(_ name: String) -> String { - var result = name - result = result.replacingOccurrences(of: "\n", with: " ") - result = result.replacingOccurrences(of: "\r", with: " ") - return result - } - // MARK: - Compression - private func compressFileToFile(source: URL, destination: URL) async throws { + func compressFileToFile(source: URL, destination: URL) async throws { // Run compression on background thread to avoid blocking main thread try await Task.detached(priority: .userInitiated) { // Pre-flight check: verify gzip is available From 284d9cda2d8107f158743b755a8ae6303e2d5701 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 12:42:33 +0700 Subject: [PATCH 5/6] fix: address review feedback on wave 3 refactoring - Fix orphaned escapeLikeWildcards docstring placement in SQLEscaping - Scope toUTF8Data() String extension as internal - Keep isCancelledLock and _isCancelled private in ExportService - Remove redundant init(forTesting:) from SchemaProviderRegistry --- TablePro/Core/Database/SQLEscaping.swift | 12 +++++------ TablePro/Core/Services/ExportService.swift | 6 +++--- .../Services/SchemaProviderRegistry.swift | 1 - .../SchemaProviderRegistryTests.swift | 20 +++++++++---------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index 60db7245..a489cf6a 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -59,12 +59,6 @@ enum SQLEscaping { } } - /// Escape wildcards in LIKE patterns while preserving intentional wildcards - /// - /// This is useful when building LIKE clauses where the search term should be treated literally. - /// - /// - Parameter value: The value to escape - /// - Returns: The escaped value with %, _, and \ escaped /// Known SQL temporal function expressions that should not be quoted/parameterized. /// Canonical source — used by SQLStatementGenerator and sidebar save logic. static let temporalFunctionExpressions: Set = [ @@ -79,6 +73,12 @@ enum SQLEscaping { temporalFunctionExpressions.contains(value.trimmingCharacters(in: .whitespaces).uppercased()) } + /// Escape wildcards in LIKE patterns while preserving intentional wildcards + /// + /// This is useful when building LIKE clauses where the search term should be treated literally. + /// + /// - Parameter value: The value to escape + /// - Returns: The escaped value with %, _, and \ escaped static func escapeLikeWildcards(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 7ae83e31..13ee3182 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -41,7 +41,7 @@ enum ExportError: LocalizedError { // MARK: - String Extension for Safe Encoding -extension String { +internal extension String { /// Safely encode string to UTF-8 data, throwing if encoding fails func toUTF8Data() throws -> Data { guard let data = self.data(using: .utf8) else { @@ -87,8 +87,8 @@ final class ExportService { // MARK: - Cancellation - let isCancelledLock = NSLock() - var _isCancelled: Bool = false + private let isCancelledLock = NSLock() + private var _isCancelled: Bool = false var isCancelled: Bool { get { isCancelledLock.lock() diff --git a/TablePro/Core/Services/SchemaProviderRegistry.swift b/TablePro/Core/Services/SchemaProviderRegistry.swift index c18a6261..3b9c09ee 100644 --- a/TablePro/Core/Services/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/SchemaProviderRegistry.swift @@ -21,7 +21,6 @@ final class SchemaProviderRegistry { init() {} - init(forTesting: Bool) {} func provider(for connectionId: UUID) -> SQLSchemaProvider? { providers[connectionId] diff --git a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift index 154b46f6..e3f9a2ca 100644 --- a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -12,7 +12,7 @@ import Testing struct SchemaProviderRegistryTests { @Test("getOrCreate returns new provider for unknown connectionId") func getOrCreateNewProvider() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() let provider = registry.getOrCreate(for: id) #expect(provider != nil) @@ -20,7 +20,7 @@ struct SchemaProviderRegistryTests { @Test("getOrCreate returns same provider for same connectionId") func getOrCreateReturnsSameProvider() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() let p1 = registry.getOrCreate(for: id) let p2 = registry.getOrCreate(for: id) @@ -29,13 +29,13 @@ struct SchemaProviderRegistryTests { @Test("provider(for:) returns nil for unknown connectionId") func providerForUnknownReturnsNil() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() #expect(registry.provider(for: UUID()) == nil) } @Test("provider(for:) returns provider after getOrCreate") func providerForKnownReturnsProvider() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() let created = registry.getOrCreate(for: id) #expect(registry.provider(for: id) === created) @@ -43,7 +43,7 @@ struct SchemaProviderRegistryTests { @Test("retain increments refcount, prevents purge") func retainPreventsRemoval() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() _ = registry.getOrCreate(for: id) registry.retain(for: id) @@ -53,7 +53,7 @@ struct SchemaProviderRegistryTests { @Test("release decrements refcount to zero, schedules deferred removal") func releaseSchedulesDeferredRemoval() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() _ = registry.getOrCreate(for: id) registry.retain(for: id) @@ -63,7 +63,7 @@ struct SchemaProviderRegistryTests { @Test("clear removes provider, refcount, and pending removal") func clearRemovesEverything() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() _ = registry.getOrCreate(for: id) registry.retain(for: id) @@ -73,7 +73,7 @@ struct SchemaProviderRegistryTests { @Test("purgeUnused removes orphaned providers with zero refcount and no pending task") func purgeRemovesOrphans() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() _ = registry.getOrCreate(for: id) registry.purgeUnused() @@ -82,7 +82,7 @@ struct SchemaProviderRegistryTests { @Test("purgeUnused does not remove providers with pending removal task") func purgeKeepsProvidersWithPendingTask() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id = UUID() _ = registry.getOrCreate(for: id) registry.retain(for: id) @@ -93,7 +93,7 @@ struct SchemaProviderRegistryTests { @Test("multiple connections are independent") func multipleConnectionsIndependent() { - let registry = SchemaProviderRegistry(forTesting: true) + let registry = SchemaProviderRegistry() let id1 = UUID(), id2 = UUID() let p1 = registry.getOrCreate(for: id1) let p2 = registry.getOrCreate(for: id2) From 36a6d70de5fc588516e70ce997ec88dd5ce45d70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 7 Mar 2026 17:38:42 +0700 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20review=20issues=20?= =?UTF-8?q?=E2=80=94=20access=20control,=20shared=20helpers,=20test=20asse?= =?UTF-8?q?rtion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Services/ExportService+CSV.swift | 4 +- .../Core/Services/ExportService+JSON.swift | 41 +------------------ .../Core/Services/ExportService+MQL.swift | 16 +++----- .../Core/Services/ExportService+SQL.swift | 2 +- TablePro/Core/Services/ExportService.swift | 39 ++++++++++++++++++ .../Services/SchemaProviderRegistry.swift | 1 - TablePro/Views/Results/DataGridView.swift | 2 +- .../SchemaProviderRegistryTests.swift | 2 +- 8 files changed, 50 insertions(+), 57 deletions(-) diff --git a/TablePro/Core/Services/ExportService+CSV.swift b/TablePro/Core/Services/ExportService+CSV.swift index 8f5fbbb3..bb94a649 100644 --- a/TablePro/Core/Services/ExportService+CSV.swift +++ b/TablePro/Core/Services/ExportService+CSV.swift @@ -71,7 +71,7 @@ extension ExportService { state.progress = 1.0 } - func writeCSVContentWithProgress( + private func writeCSVContentWithProgress( columns: [String], rows: [[String?]], options: CSVExportOptions, @@ -132,7 +132,7 @@ extension ExportService { await finalizeTableProgress() } - func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { + private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { var processed = field // Sanitize formula-like prefixes to prevent CSV formula injection diff --git a/TablePro/Core/Services/ExportService+JSON.swift b/TablePro/Core/Services/ExportService+JSON.swift index bf66a44a..daf5f92c 100644 --- a/TablePro/Core/Services/ExportService+JSON.swift +++ b/TablePro/Core/Services/ExportService+JSON.swift @@ -122,46 +122,7 @@ extension ExportService { state.progress = 1.0 } - func escapeJSONString(_ string: String) -> String { - var utf8Result = [UInt8]() - utf8Result.reserveCapacity(string.utf8.count) - - for byte in string.utf8 { - switch byte { - case 0x22: // " - utf8Result.append(0x5C) // backslash - utf8Result.append(0x22) - case 0x5C: // backslash - utf8Result.append(0x5C) - utf8Result.append(0x5C) - case 0x0A: // \n - utf8Result.append(0x5C) - utf8Result.append(0x6E) // n - case 0x0D: // \r - utf8Result.append(0x5C) - utf8Result.append(0x72) // r - case 0x09: // \t - utf8Result.append(0x5C) - utf8Result.append(0x74) // t - case 0x08: // backspace - utf8Result.append(0x5C) - utf8Result.append(0x62) // b - case 0x0C: // form feed - utf8Result.append(0x5C) - utf8Result.append(0x66) // f - case 0x00...0x1F: - // Other control characters: emit \uXXXX - let hex = String(format: "\\u%04X", byte) - utf8Result.append(contentsOf: hex.utf8) - default: - utf8Result.append(byte) - } - } - - return String(bytes: utf8Result, encoding: .utf8) ?? string - } - - func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { + private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { guard let val = value else { return "null" } // If preserving all as strings, skip type detection diff --git a/TablePro/Core/Services/ExportService+MQL.swift b/TablePro/Core/Services/ExportService+MQL.swift index 8d9337ba..9ab88d35 100644 --- a/TablePro/Core/Services/ExportService+MQL.swift +++ b/TablePro/Core/Services/ExportService+MQL.swift @@ -20,7 +20,7 @@ extension ExportService { let dbName = tables.first?.databaseName ?? "" if !dbName.isEmpty { - try fileHandle.write(contentsOf: "// Database: \(sanitizeForJSComment(dbName))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "// Database: \(sanitizeForSQLComment(dbName))\n".toUTF8Data()) } try fileHandle.write(contentsOf: "\n".toUTF8Data()) @@ -41,7 +41,7 @@ extension ExportService { collectionAccessor = "db.\(escapedCollection)" } - try fileHandle.write(contentsOf: "// Collection: \(sanitizeForJSComment(table.name))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "// Collection: \(sanitizeForSQLComment(table.name))\n".toUTF8Data()) if mqlOpts.includeDrop { try fileHandle.write(contentsOf: "\(collectionAccessor).drop();\n".toUTF8Data()) @@ -121,7 +121,7 @@ extension ExportService { state.progress = 1.0 } - func writeMQLInsertMany( + private func writeMQLInsertMany( collection: String, documents: [String], to fileHandle: FileHandle @@ -138,7 +138,7 @@ extension ExportService { try fileHandle.write(contentsOf: statement.toUTF8Data()) } - func writeMQLIndexes( + private func writeMQLIndexes( collection: String, collectionAccessor: String, to fileHandle: FileHandle @@ -171,7 +171,7 @@ extension ExportService { } } - func mqlJsonValue(for value: String) -> String { + private func mqlJsonValue(for value: String) -> String { if value == "true" || value == "false" { return value } @@ -205,10 +205,4 @@ extension ExportService { return name } - func sanitizeForJSComment(_ name: String) -> String { - var result = name - result = result.replacingOccurrences(of: "\n", with: " ") - result = result.replacingOccurrences(of: "\r", with: " ") - return result - } } diff --git a/TablePro/Core/Services/ExportService+SQL.swift b/TablePro/Core/Services/ExportService+SQL.swift index f9d24a6a..e53e6435 100644 --- a/TablePro/Core/Services/ExportService+SQL.swift +++ b/TablePro/Core/Services/ExportService+SQL.swift @@ -186,7 +186,7 @@ extension ExportService { state.progress = 1.0 } - func writeInsertStatementsWithProgress( + private func writeInsertStatementsWithProgress( table: ExportTableItem, columns: [String], rows: [[String?]], diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 13ee3182..8620821d 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -362,6 +362,45 @@ final class ExportService { } } + func escapeJSONString(_ string: String) -> String { + var utf8Result = [UInt8]() + utf8Result.reserveCapacity(string.utf8.count) + + for byte in string.utf8 { + switch byte { + case 0x22: // " + utf8Result.append(0x5C) // backslash + utf8Result.append(0x22) + case 0x5C: // backslash + utf8Result.append(0x5C) + utf8Result.append(0x5C) + case 0x0A: // \n + utf8Result.append(0x5C) + utf8Result.append(0x6E) // n + case 0x0D: // \r + utf8Result.append(0x5C) + utf8Result.append(0x72) // r + case 0x09: // \t + utf8Result.append(0x5C) + utf8Result.append(0x74) // t + case 0x08: // backspace + utf8Result.append(0x5C) + utf8Result.append(0x62) // b + case 0x0C: // form feed + utf8Result.append(0x5C) + utf8Result.append(0x66) // f + case 0x00...0x1F: + // Other control characters: emit \uXXXX + let hex = String(format: "\\u%04X", byte) + utf8Result.append(contentsOf: hex.utf8) + default: + utf8Result.append(byte) + } + } + + return String(bytes: utf8Result, encoding: .utf8) ?? string + } + // MARK: - Compression func compressFileToFile(source: URL, destination: URL) async throws { diff --git a/TablePro/Core/Services/SchemaProviderRegistry.swift b/TablePro/Core/Services/SchemaProviderRegistry.swift index 3b9c09ee..46e6cccc 100644 --- a/TablePro/Core/Services/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/SchemaProviderRegistry.swift @@ -21,7 +21,6 @@ final class SchemaProviderRegistry { init() {} - func provider(for connectionId: UUID) -> SQLSchemaProvider? { providers[connectionId] } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index d1bdf89b..44d3d653 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -623,7 +623,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData weak var tableView: NSTableView? let cellFactory = DataGridCellFactory() - internal(set) var overlayEditor: CellOverlayEditor? + var overlayEditor: CellOverlayEditor? // Settings observer for real-time updates fileprivate var settingsObserver: NSObjectProtocol? diff --git a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift index e3f9a2ca..767818fc 100644 --- a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -15,7 +15,7 @@ struct SchemaProviderRegistryTests { let registry = SchemaProviderRegistry() let id = UUID() let provider = registry.getOrCreate(for: id) - #expect(provider != nil) + #expect(registry.provider(for: id) === provider) } @Test("getOrCreate returns same provider for same connectionId")