diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f41757..c68093ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix MySQL/MariaDB getting `BEGIN` instead of `START TRANSACTION` in table operations and SQL preview - Replace `.onTapGesture` with `Button` in color pickers, section headers, group headers, and connection switcher for VoiceOver accessibility - Fix data race on `isAppTerminating` static var in `MainContentCoordinator` using `OSAllocatedUnfairLock` - Fix `MainActor.assumeIsolated` crash risk in `VimKeyInterceptor` notification observer diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 71ffa142..93cfbeed 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -652,6 +652,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = LocalPackages/CodeEditLanguages; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 5ACE00012F4F000000000001 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; @@ -679,13 +686,6 @@ }; /* End XCRemoteSwiftPackageReference section */ -/* Begin XCLocalSwiftPackageReference section */ - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = LocalPackages/CodeEditLanguages; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ 5ACE00012F4F000000000002 /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 8fecd993..ed4729b9 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -33,4 +33,33 @@ enum AIProviderFactory { ) } } + + static func resolveProvider( + for feature: AIFeature, + settings: AISettings + ) -> (AIProviderConfig, String?)? { + if let route = settings.featureRouting[feature.rawValue], + let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) { + let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) + return (config, apiKey) + } + + guard let config = settings.providers.first(where: { $0.isEnabled }) else { + return nil + } + + let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) + return (config, apiKey) + } + + static func resolveModel( + for feature: AIFeature, + config: AIProviderConfig, + settings: AISettings + ) -> String { + if let route = settings.featureRouting[feature.rawValue], !route.model.isEmpty { + return route.model + } + return config.model + } } diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index 9713a5e4..7d5eb9e1 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -204,11 +204,11 @@ final class InlineSuggestionManager { private func fetchSuggestion(textBefore: String, fullQuery: String) async throws -> String { let settings = AppSettingsManager.shared.ai - guard let (config, apiKey) = resolveProvider(for: .inlineSuggest, settings: settings) else { + guard let (config, apiKey) = AIProviderFactory.resolveProvider(for: .inlineSuggest, settings: settings) else { throw AIProviderError.networkError("No AI provider configured") } - let model = resolveModel(for: .inlineSuggest, config: config, settings: settings) + let model = AIProviderFactory.resolveModel(for: .inlineSuggest, config: config, settings: settings) let provider = AIProviderFactory.createProvider(for: config, apiKey: apiKey) let userMessage = AIPromptTemplates.inlineSuggest(textBefore: textBefore, fullQuery: fullQuery) @@ -247,39 +247,6 @@ final class InlineSuggestionManager { return accumulated } - // MARK: - Provider Resolution (mirrors AIChatViewModel) - - private func resolveProvider( - for feature: AIFeature, - settings: AISettings - ) -> (AIProviderConfig, String?)? { - // Check feature routing first - if let route = settings.featureRouting[feature.rawValue], - let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) { - let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - return (config, apiKey) - } - - // Fall back to first enabled provider - guard let config = settings.providers.first(where: { $0.isEnabled }) else { - return nil - } - - let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - return (config, apiKey) - } - - private func resolveModel( - for feature: AIFeature, - config: AIProviderConfig, - settings: AISettings - ) -> String { - if let route = settings.featureRouting[feature.rawValue], !route.model.isEmpty { - return route.model - } - return config.model - } - /// Clean the AI suggestion: strip thinking blocks, leading newlines, /// and trailing whitespace, but preserve leading spaces. private func cleanSuggestion(_ raw: String) -> String { diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 663a6988..ce8473ac 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -303,9 +303,11 @@ final class SQLContextAnalyzer { let safePosition = min(cursorPosition, nsQuery.length) // Extract the current statement for multi-statement queries - let (currentStatement, statementOffset) = extractCurrentStatement( - from: nsQuery, cursorPosition: safePosition + let located = SQLStatementScanner.locatedStatementAtCursor( + in: nsQuery as String, cursorPosition: safePosition ) + let currentStatement = located.sql + let statementOffset = located.offset let adjustedPosition = safePosition - statementOffset let nsStatement = currentStatement as NSString @@ -402,78 +404,6 @@ final class SQLContextAnalyzer { ) } - // MARK: - Multi-Statement Support - - /// Extract the current SQL statement containing the cursor. - /// Uses NSString UTF-16 character access for O(1) per character instead of - /// O(n) Swift String.index(offsetBy:). - private func extractCurrentStatement( - from nsQuery: NSString, - cursorPosition: Int - ) -> (statement: String, offset: Int) { - let length = nsQuery.length - guard length > 0 else { return ("", 0) } - - // Scan through to find semicolons not inside strings/comments - var statementStart = 0 - var inString = false - var inComment = false - var prevChar: UInt16 = 0 - - // Track the statement that contains the cursor - var foundStatement: String? - var foundOffset = 0 - - for i in 0..= statementStart && cursorPosition < stmtEnd { - foundStatement = nsQuery.substring(with: stmtRange) - foundOffset = statementStart - break - } - statementStart = stmtEnd - } - - prevChar = ch - } - - // If found during scan, return it - if let stmt = foundStatement { - return (stmt, foundOffset) - } - - // Check the last statement (may not end with ;) - if statementStart < length { - let stmtRange = NSRange(location: statementStart, length: length - statementStart) - if cursorPosition >= statementStart { - return (nsQuery.substring(with: stmtRange), statementStart) - } - } - - // Fallback: return entire query - return (nsQuery as String, 0) - } - // MARK: - CTE Support /// Extract CTE (Common Table Expression) names from the query diff --git a/TablePro/Core/Database/ConnectionHealthMonitor.swift b/TablePro/Core/Database/ConnectionHealthMonitor.swift index a5b4d358..2248b9f2 100644 --- a/TablePro/Core/Database/ConnectionHealthMonitor.swift +++ b/TablePro/Core/Database/ConnectionHealthMonitor.swift @@ -21,14 +21,6 @@ extension ConnectionHealthMonitor { } } -// MARK: - Notification - -extension Notification.Name { - /// Posted when a connection's health state changes. - /// userInfo: ["connectionId": UUID, "state": ConnectionHealthMonitor.HealthState] - static let connectionHealthStateChanged = Notification.Name("connectionHealthStateChanged") -} - // MARK: - ConnectionHealthMonitor /// Monitors a single database connection's health via periodic pings and diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 13e1e2b5..d830be20 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -286,21 +286,7 @@ extension DatabaseDriver { /// Default transaction implementation using database-specific SQL func beginTransaction() async throws { - let sql: String - switch connection.type { - case .mysql, .mariadb: - sql = "START TRANSACTION" - case .postgresql, .redshift: - sql = "BEGIN" - case .sqlite: - sql = "BEGIN" - case .mongodb: - sql = "" // MongoDB transactions not supported in default impl - case .redis: - sql = "" // Redis transactions handled by RedisDriver directly - case .mssql: - sql = "BEGIN TRANSACTION" - } + let sql = connection.type.beginTransactionSQL guard !sql.isEmpty else { return } _ = try await execute(query: sql) } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 30bbc674..18f45750 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -9,10 +9,6 @@ import Foundation import Observation import os -extension Notification.Name { - static let databaseDidConnect = Notification.Name("databaseDidConnect") -} - /// Manages database connections and active drivers @MainActor @Observable final class DatabaseManager { diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 7471557c..ebdb21fc 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -8,10 +8,6 @@ import Foundation import os -extension Notification.Name { - static let sshTunnelDied = Notification.Name("sshTunnelDied") -} - /// Error types for SSH tunnel operations enum SSHTunnelError: Error, LocalizedError { case tunnelCreationFailed(String) diff --git a/TablePro/Core/Services/AppNotifications.swift b/TablePro/Core/Services/AppNotifications.swift new file mode 100644 index 00000000..ced823cf --- /dev/null +++ b/TablePro/Core/Services/AppNotifications.swift @@ -0,0 +1,36 @@ +// +// AppNotifications.swift +// TablePro +// +// Centralized notification names used across the app. +// Domain-specific collections remain in OpenTableApp.swift +// and SettingsNotifications.swift. +// + +import Foundation + +extension Notification.Name { + // MARK: - Query Editor + + static let formatQueryRequested = Notification.Name("formatQueryRequested") + static let sendAIPrompt = Notification.Name("sendAIPrompt") + static let aiFixError = Notification.Name("aiFixError") + static let aiExplainSelection = Notification.Name("aiExplainSelection") + static let aiOptimizeSelection = Notification.Name("aiOptimizeSelection") + + // MARK: - Query History + + static let queryHistoryDidUpdate = Notification.Name("queryHistoryDidUpdate") + static let loadQueryIntoEditor = Notification.Name("loadQueryIntoEditor") + static let insertQueryFromAI = Notification.Name("insertQueryFromAI") + + // MARK: - Connections + + static let connectionUpdated = Notification.Name("connectionUpdated") + static let databaseDidConnect = Notification.Name("databaseDidConnect") + static let connectionHealthStateChanged = Notification.Name("connectionHealthStateChanged") + + // MARK: - SSH + + static let sshTunnelDied = Notification.Name("sshTunnelDied") +} diff --git a/TablePro/Core/Services/ImportService.swift b/TablePro/Core/Services/ImportService.swift index 41b33385..a7f74092 100644 --- a/TablePro/Core/Services/ImportService.swift +++ b/TablePro/Core/Services/ImportService.swift @@ -132,7 +132,7 @@ final class ImportService { // 5. Begin transaction (if enabled) if config.wrapInTransaction { - let beginStmt = beginTransactionStatement(for: connection.type) + let beginStmt = connection.type.beginTransactionSQL if !beginStmt.isEmpty { _ = try await driver.execute(query: beginStmt) } @@ -311,19 +311,6 @@ final class ImportService { } } - private func beginTransactionStatement(for dbType: DatabaseType) -> String { - switch dbType { - case .mysql, .mariadb: - return "START TRANSACTION" - case .postgresql, .redshift, .sqlite: - return "BEGIN" - case .mssql: - return "BEGIN TRANSACTION" - case .mongodb, .redis: - return "" - } - } - private func commitStatement(for dbType: DatabaseType) -> String { switch dbType { case .mongodb, .redis: diff --git a/TablePro/Core/Storage/QueryHistoryManager.swift b/TablePro/Core/Storage/QueryHistoryManager.swift index 64a4055e..5d61a187 100644 --- a/TablePro/Core/Storage/QueryHistoryManager.swift +++ b/TablePro/Core/Storage/QueryHistoryManager.swift @@ -9,24 +9,23 @@ import Combine import Foundation -/// Notification names for query history updates -extension Notification.Name { - static let queryHistoryDidUpdate = Notification.Name("queryHistoryDidUpdate") - static let loadQueryIntoEditor = Notification.Name("loadQueryIntoEditor") - static let insertQueryFromAI = Notification.Name("insertQueryFromAI") -} - /// Thread-safe manager for query history /// NOT an ObservableObject - uses NotificationCenter for UI communication final class QueryHistoryManager { static let shared = QueryHistoryManager() - private let storage = QueryHistoryStorage.shared + private let storage: QueryHistoryStorage // Settings observer for immediate cleanup when settings change private var settingsObserver: AnyCancellable? + /// Creates an isolated manager with its own storage. For testing only. + init(isolatedStorage: QueryHistoryStorage) { + self.storage = isolatedStorage + } + private init() { + self.storage = QueryHistoryStorage.shared // Subscribe to history settings changes for immediate cleanup settingsObserver = NotificationCenter.default.publisher(for: .historySettingsDidChange) .receive(on: DispatchQueue.main) diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 862dd3ad..f0aca39c 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -61,6 +61,16 @@ final class QueryHistoryStorage { } } + /// Creates an isolated instance with a unique database file. For testing only. + init(isolatedForTesting: Bool) { + testDatabaseSuffix = isolatedForTesting ? "_\(UUID().uuidString)" : nil + queue.sync { + setupDatabase() + } + } + + private var testDatabaseSuffix: String? + private var dbPath: String? deinit { @@ -118,8 +128,9 @@ final class QueryHistoryStorage { // Create directory if needed try? fileManager.createDirectory(at: TableProDir, withIntermediateDirectories: true) + let suffix = testDatabaseSuffix ?? "" let dbFileName = Self.isRunningTests - ? "query_history_test_\(ProcessInfo.processInfo.processIdentifier).db" + ? "query_history_test_\(ProcessInfo.processInfo.processIdentifier)\(suffix).db" : "query_history.db" let dbPath = TableProDir.appendingPathComponent(dbFileName).path(percentEncoded: false) diff --git a/TablePro/Core/Utilities/SQLStatementScanner.swift b/TablePro/Core/Utilities/SQLStatementScanner.swift new file mode 100644 index 00000000..12222443 --- /dev/null +++ b/TablePro/Core/Utilities/SQLStatementScanner.swift @@ -0,0 +1,159 @@ +// +// SQLStatementScanner.swift +// TablePro +// + +import Foundation + +enum SQLStatementScanner { + struct LocatedStatement { + let sql: String + let offset: Int + } + + static func allStatements(in sql: String) -> [String] { + var results: [String] = [] + scan(sql: sql, cursorPosition: nil) { rawSQL, _ in + var trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if !trimmed.isEmpty { + results.append(trimmed) + } + return true + } + return results + } + + static func statementAtCursor(in sql: String, cursorPosition: Int) -> String { + var result = locatedStatementAtCursor(in: sql, cursorPosition: cursorPosition) + .sql + .trimmingCharacters(in: .whitespacesAndNewlines) + if result.hasSuffix(";") { + result = String(result.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return result + } + + static func locatedStatementAtCursor(in sql: String, cursorPosition: Int) -> LocatedStatement { + var result = LocatedStatement(sql: "", offset: 0) + scan(sql: sql, cursorPosition: cursorPosition) { rawSQL, offset in + result = LocatedStatement(sql: rawSQL, offset: offset) + return false + } + return result + } + + // MARK: - Private + + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let semicolonChar = UInt16(UnicodeScalar(";").value) + private static let dash = UInt16(UnicodeScalar("-").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let star = UInt16(UnicodeScalar("*").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let backslash = UInt16(UnicodeScalar("\\").value) + + private static func scan( + sql: String, + cursorPosition: Int?, + onStatement: (_ rawSQL: String, _ offset: Int) -> Bool + ) { + let nsQuery = sql as NSString + let length = nsQuery.length + guard length > 0 else { return } + + guard nsQuery.range(of: ";").location != NSNotFound else { + _ = onStatement(sql, 0) + return + } + + let safePosition = cursorPosition.map { min(max(0, $0), length) } + + var currentStart = 0 + var inString = false + var stringCharVal: UInt16 = 0 + var inLineComment = false + var inBlockComment = false + var i = 0 + + while i < length { + let ch = nsQuery.character(at: i) + + if inLineComment { + if ch == newline { inLineComment = false } + i += 1 + continue + } + + if inBlockComment { + if ch == star && i + 1 < length && nsQuery.character(at: i + 1) == slash { + inBlockComment = false + i += 2 + continue + } + i += 1 + continue + } + + if !inString && ch == dash && i + 1 < length && nsQuery.character(at: i + 1) == dash { + inLineComment = true + i += 2 + continue + } + + if !inString && ch == slash && i + 1 < length && nsQuery.character(at: i + 1) == star { + inBlockComment = true + i += 2 + continue + } + + if inString && ch == backslash && i + 1 < length { + i += 2 + continue + } + + if ch == singleQuote || ch == doubleQuote || ch == backtick { + if !inString { + inString = true + stringCharVal = ch + } else if ch == stringCharVal { + if i + 1 < length && nsQuery.character(at: i + 1) == stringCharVal { + i += 1 + } else { + inString = false + } + } + } + + if ch == semicolonChar && !inString { + let stmtEnd = i + 1 + + if let cursor = safePosition { + if cursor >= currentStart && cursor <= stmtEnd { + let stmtRange = NSRange(location: currentStart, length: stmtEnd - currentStart) + _ = onStatement(nsQuery.substring(with: stmtRange), currentStart) + return + } + } else { + let stmtRange = NSRange(location: currentStart, length: stmtEnd - currentStart) + if !onStatement(nsQuery.substring(with: stmtRange), currentStart) { return } + } + + currentStart = stmtEnd + } + + i += 1 + } + + if currentStart < length { + let stmtRange = NSRange(location: currentStart, length: length - currentStart) + _ = onStatement(nsQuery.substring(with: stmtRange), currentStart) + } + } +} diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 86f296dc..2a0f04e9 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -164,6 +164,15 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { } } + var beginTransactionSQL: String { + switch self { + case .mysql, .mariadb: return "START TRANSACTION" + case .postgresql, .redshift, .sqlite: return "BEGIN" + case .mssql: return "BEGIN TRANSACTION" + case .mongodb, .redis: return "" + } + } + /// Whether this database type supports SQL-based schema editing (ALTER TABLE etc.) var supportsSchemaEditing: Bool { switch self { diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 7578e143..7654a4d5 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -252,7 +252,7 @@ final class AIChatViewModel { let settings = AppSettingsManager.shared.ai // Resolve provider from feature routing or use first enabled provider - guard let (config, apiKey) = resolveProvider(for: feature, settings: settings) else { + guard let (config, apiKey) = AIProviderFactory.resolveProvider(for: feature, settings: settings) else { errorMessage = String(localized: "No AI provider configured. Go to Settings > AI to add one.") return } @@ -276,7 +276,7 @@ final class AIChatViewModel { } let provider = AIProviderFactory.createProvider(for: config, apiKey: apiKey) - let model = resolveModel(for: feature, config: config, settings: settings) + let model = AIProviderFactory.resolveModel(for: feature, config: config, settings: settings) let systemPrompt = buildSystemPrompt(settings: settings) // Create assistant message placeholder @@ -335,39 +335,6 @@ final class AIChatViewModel { } } - private func resolveProvider( - for feature: AIFeature, - settings: AISettings - ) -> (AIProviderConfig, String?)? { - // Check feature routing first - if let route = settings.featureRouting[feature.rawValue], - let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) { - let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - return (config, apiKey) - } - - // Fall back to first enabled provider - guard let config = settings.providers.first(where: { $0.isEnabled }) else { - return nil - } - - let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - return (config, apiKey) - } - - private func resolveModel( - for feature: AIFeature, - config: AIProviderConfig, - settings: AISettings - ) -> String { - // Use feature-specific model if routed - if let route = settings.featureRouting[feature.rawValue], !route.model.isEmpty { - return route.model - } - // Fall back to provider's default model - return config.model - } - private func resolveConnectionPolicy(settings: AISettings) -> AIConnectionPolicy? { let policy = connection?.aiPolicy ?? settings.defaultConnectionPolicy diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9f94155d..1d6b948a 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -901,12 +901,6 @@ struct ConnectionFormView: View { } } -// MARK: - Notification Names - -extension Notification.Name { - static let connectionUpdated = Notification.Name("connectionUpdated") -} - #Preview("New Connection") { ConnectionFormView(connectionId: nil) } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index f63ee1fe..031d2ff6 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -9,14 +9,6 @@ import CodeEditSourceEditor import os import SwiftUI -extension Notification.Name { - static let formatQueryRequested = Notification.Name("formatQueryRequested") - static let sendAIPrompt = Notification.Name("sendAIPrompt") - static let aiFixError = Notification.Name("aiFixError") - static let aiExplainSelection = Notification.Name("aiExplainSelection") - static let aiOptimizeSelection = Notification.Name("aiOptimizeSelection") -} - /// SQL query editor view with execute button struct QueryEditorView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index e1368b65..ef6dfb0f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -19,17 +19,7 @@ extension MainContentCoordinator { let dbType = connection.type var allStatements: [String] = [] - // Add database-specific BEGIN / START TRANSACTION - let beginStatement: String - switch dbType { - case .mysql, .mariadb: - beginStatement = "START TRANSACTION" - case .mssql: - beginStatement = "BEGIN TRANSACTION" - default: - beginStatement = "BEGIN" - } - allStatements.append(beginStatement) + allStatements.append(dbType.beginTransactionSQL) // Add user statements allStatements.append(contentsOf: statements) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index cace0599..0042b3ff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -3,124 +3,13 @@ // TablePro // // Multi-statement SQL execution support for MainContentCoordinator. -// Splits SQL text on semicolons (respecting strings/comments) and -// executes each statement sequentially, stopping on first error. +// Executes each statement sequentially, stopping on first error. // import AppKit import Foundation extension MainContentCoordinator { - // MARK: - Statement Splitting - - /// Split SQL text into individual statements, respecting strings, comments, and backticks. - /// Uses the same parsing logic as `extractQueryAtCursor` but collects all statements. - func splitStatements(from sql: String) -> [String] { - let nsQuery = sql as NSString - let length = nsQuery.length - guard length > 0 else { return [] } - - // Fast check: if no semicolons, return the full query trimmed - guard nsQuery.range(of: ";").location != NSNotFound else { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? [] : [trimmed] - } - - let singleQuote = UInt16(UnicodeScalar("'").value) - let doubleQuote = UInt16(UnicodeScalar("\"").value) - let backtick = UInt16(UnicodeScalar("`").value) - let semicolonChar = UInt16(UnicodeScalar(";").value) - let dash = UInt16(UnicodeScalar("-").value) - let slash = UInt16(UnicodeScalar("/").value) - let star = UInt16(UnicodeScalar("*").value) - let newline = UInt16(UnicodeScalar("\n").value) - let backslash = UInt16(UnicodeScalar("\\").value) - - var statements: [String] = [] - var currentStart = 0 - var inString = false - var stringCharVal: UInt16 = 0 - var inLineComment = false - var inBlockComment = false - var i = 0 - - while i < length { - let ch = nsQuery.character(at: i) - - if inLineComment { - if ch == newline { inLineComment = false } - i += 1 - continue - } - - if inBlockComment { - if ch == star && i + 1 < length && nsQuery.character(at: i + 1) == slash { - inBlockComment = false - i += 2 - continue - } - i += 1 - continue - } - - if !inString && ch == dash && i + 1 < length && nsQuery.character(at: i + 1) == dash { - inLineComment = true - i += 2 - continue - } - - if !inString && ch == slash && i + 1 < length && nsQuery.character(at: i + 1) == star { - inBlockComment = true - i += 2 - continue - } - - // Handle backslash escapes inside strings (e.g., \' \" \\) - if inString && ch == backslash && i + 1 < length { - i += 2 // Skip the backslash and the escaped character - continue - } - - if ch == singleQuote || ch == doubleQuote || ch == backtick { - if !inString { - inString = true - stringCharVal = ch - } else if ch == stringCharVal { - // Handle doubled (escaped) quotes: '' "" `` - if i + 1 < length && nsQuery.character(at: i + 1) == stringCharVal { - i += 1 // Skip the escaped quote - } else { - inString = false - } - } - } - - if ch == semicolonChar && !inString { - let stmtRange = NSRange(location: currentStart, length: i - currentStart) - let stmt = nsQuery.substring(with: stmtRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !stmt.isEmpty { - statements.append(stmt) - } - currentStart = i + 1 - } - - i += 1 - } - - // Last statement (no trailing semicolon) - if currentStart < length { - let stmtRange = NSRange(location: currentStart, length: length - currentStart) - let stmt = nsQuery.substring(with: stmtRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !stmt.isEmpty { - statements.append(stmt) - } - } - - return statements - } - // MARK: - Multi-Statement Execution /// Execute multiple SQL statements sequentially within a transaction, @@ -160,14 +49,7 @@ extension MainContentCoordinator { } // Wrap in a transaction for atomicity - let beginSQL: String - switch dbType { - case .mysql, .mariadb: - beginSQL = "START TRANSACTION" - default: - beginSQL = "BEGIN" - } - _ = try await driver.execute(query: beginSQL) + _ = try await driver.execute(query: dbType.beginTransactionSQL) for (stmtIndex, sql) in statements.enumerated() { guard !Task.isCancelled else { break } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index f4858b26..22edfe10 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -77,7 +77,7 @@ extension MainContentCoordinator { // Wrap all operations in a single transaction when we have multiple operations let needsTransaction = hasEditedCells && hasPendingTableOps if needsTransaction { - let beginSQL = dbType == .mssql ? "BEGIN TRANSACTION" : "BEGIN" + let beginSQL = dbType.beginTransactionSQL allStatements.append(ParameterizedStatement(sql: beginSQL, parameters: [])) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 4ea9e276..4cdaffef 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -45,7 +45,7 @@ extension MainContentCoordinator { // Wrap in transaction for atomicity let needsTransaction = wrapInTransaction && (sortedTruncates.count + sortedDeletes.count) > 1 if needsTransaction { - statements.append(dbType == .mssql ? "BEGIN TRANSACTION" : "BEGIN") + statements.append(dbType.beginTransactionSQL) } for tableName in sortedTruncates { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 58541f1e..3d6c4885 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -337,9 +337,9 @@ final class MainContentCoordinator { sql = nsQuery.substring(with: clampedRange) .trimmingCharacters(in: .whitespacesAndNewlines) } else { - sql = extractQueryAtCursor( - from: fullQuery, - at: cursorPositions.first?.range.location ?? 0 + sql = SQLStatementScanner.statementAtCursor( + in: fullQuery, + cursorPosition: cursorPositions.first?.range.location ?? 0 ) } @@ -348,7 +348,7 @@ final class MainContentCoordinator { } // Split into individual statements for multi-statement support - let statements = splitStatements(from: sql) + let statements = SQLStatementScanner.allStatements(in: sql) guard !statements.isEmpty else { return } // Block write queries in read-only mode @@ -415,9 +415,9 @@ final class MainContentCoordinator { sql = nsQuery.substring(with: clampedRange) .trimmingCharacters(in: .whitespacesAndNewlines) } else { - sql = extractQueryAtCursor( - from: fullQuery, - at: cursorPositions.first?.range.location ?? 0 + sql = SQLStatementScanner.statementAtCursor( + in: fullQuery, + cursorPosition: cursorPositions.first?.range.location ?? 0 ) } @@ -425,7 +425,7 @@ final class MainContentCoordinator { guard !trimmed.isEmpty else { return } // Use first statement only (EXPLAIN on a single statement) - let statements = splitStatements(from: trimmed) + let statements = SQLStatementScanner.allStatements(in: trimmed) guard let stmt = statements.first else { return } // Build database-specific EXPLAIN prefix @@ -719,117 +719,6 @@ final class MainContentCoordinator { return nil } - private func extractQueryAtCursor(from fullQuery: String, at position: Int) -> String { - let nsQuery = fullQuery as NSString - let length = nsQuery.length - guard length > 0 else { return "" } - - // Fast check: if no semicolons, return the full query trimmed. - // Uses NSString range search (C-level speed) instead of Swift String.contains. - guard nsQuery.range(of: ";").location != NSNotFound else { - return fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) - } - - let singleQuote = UInt16(UnicodeScalar("'").value) - let doubleQuote = UInt16(UnicodeScalar("\"").value) - let backtick = UInt16(UnicodeScalar("`").value) - let semicolonChar = UInt16(UnicodeScalar(";").value) - let dash = UInt16(UnicodeScalar("-").value) - let slash = UInt16(UnicodeScalar("/").value) - let star = UInt16(UnicodeScalar("*").value) - let newline = UInt16(UnicodeScalar("\n").value) - let backslash = UInt16(UnicodeScalar("\\").value) - - let safePosition = min(max(0, position), length) - var currentStart = 0 - var inString = false - var stringCharVal: UInt16 = 0 - var inLineComment = false - var inBlockComment = false - var i = 0 - - // Scan through characters, stopping as soon as we find the statement - // containing the cursor. Avoids scanning the entire file. - while i < length { - let ch = nsQuery.character(at: i) - - // Handle line comment end - if inLineComment { - if ch == newline { inLineComment = false } - i += 1 - continue - } - - // Handle block comment end - if inBlockComment { - if ch == star && i + 1 < length && nsQuery.character(at: i + 1) == slash { - inBlockComment = false - i += 2 - continue - } - i += 1 - continue - } - - // Detect line comment start (--) - if !inString && ch == dash && i + 1 < length && nsQuery.character(at: i + 1) == dash { - inLineComment = true - i += 2 - continue - } - - // Detect block comment start (/*) - if !inString && ch == slash && i + 1 < length && nsQuery.character(at: i + 1) == star { - inBlockComment = true - i += 2 - continue - } - - // Handle backslash escapes inside strings (e.g., \' \" \\) - if inString && ch == backslash && i + 1 < length { - i += 2 - continue - } - - // Track string/identifier literals - if ch == singleQuote || ch == doubleQuote || ch == backtick { - if !inString { - inString = true - stringCharVal = ch - } else if ch == stringCharVal { - // Handle doubled (escaped) quotes: '' "" `` - if i + 1 < length && nsQuery.character(at: i + 1) == stringCharVal { - i += 1 // Skip the escaped quote - } else { - inString = false - } - } - } - - // Statement delimiter - if ch == semicolonChar && !inString { - let stmtEnd = i + 1 - if safePosition >= currentStart && safePosition <= stmtEnd { - let stmtRange = NSRange(location: currentStart, length: i - currentStart) - return nsQuery.substring(with: stmtRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - currentStart = stmtEnd - } - - i += 1 - } - - // Cursor is in the last statement (no trailing semicolon) - if currentStart < length { - let stmtRange = NSRange(location: currentStart, length: length - currentStart) - return nsQuery.substring(with: stmtRange) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - return fullQuery.trimmingCharacters(in: .whitespacesAndNewlines) - } - // MARK: - Sorting func handleSort(columnIndex: Int, ascending: Bool, isMultiSort: Bool = false, selectedRowIndices: inout Set) { diff --git a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift b/TableProTests/Core/Storage/QueryHistoryManagerTests.swift index 0b108d51..38e9644a 100644 --- a/TableProTests/Core/Storage/QueryHistoryManagerTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryManagerTests.swift @@ -78,8 +78,17 @@ struct QueryHistoryManagerTests { @Test("clearAllHistory clears and returns true") func clearAllHistoryReturnsTrue() async { - _ = await makeAndInsertEntry() - let result = await manager.clearAllHistory() + let isolatedStorage = QueryHistoryStorage(isolatedForTesting: true) + let isolatedManager = QueryHistoryManager(isolatedStorage: isolatedStorage) + _ = await isolatedStorage.addHistory(QueryHistoryEntry( + query: "SELECT clear_test", + connectionId: UUID(), + databaseName: "testdb", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + )) + let result = await isolatedManager.clearAllHistory() #expect(result == true) } @@ -105,7 +114,16 @@ struct QueryHistoryManagerTests { @Test("clearAllHistory posts queryHistoryDidUpdate notification") func clearAllHistoryPostsNotification() async { - _ = await makeAndInsertEntry() + let isolatedStorage = QueryHistoryStorage(isolatedForTesting: true) + let isolatedManager = QueryHistoryManager(isolatedStorage: isolatedStorage) + _ = await isolatedStorage.addHistory(QueryHistoryEntry( + query: "SELECT notify_test", + connectionId: UUID(), + databaseName: "testdb", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true + )) await confirmation("notification posted") { confirm in let observer = NotificationCenter.default.addObserver( @@ -116,7 +134,7 @@ struct QueryHistoryManagerTests { confirm() } - _ = await manager.clearAllHistory() + _ = await isolatedManager.clearAllHistory() try? await Task.sleep(for: .milliseconds(100)) NotificationCenter.default.removeObserver(observer) diff --git a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift index 72bc79d7..086a66a8 100644 --- a/TableProTests/Core/Storage/QueryHistoryStorageTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryStorageTests.swift @@ -178,12 +178,12 @@ struct QueryHistoryStorageTests { @Test("clearAllHistory removes all entries") func clearAllHistoryRemovesAll() async { - // Insert then clear — verify count goes to 0 - _ = await storage.addHistory(makeEntry(query: "SELECT clear_test")) - let result = await storage.clearAllHistory() + let isolated = QueryHistoryStorage(isolatedForTesting: true) + _ = await isolated.addHistory(makeEntry(query: "SELECT clear_test")) + let result = await isolated.clearAllHistory() #expect(result == true) - // Count may be 0 or may have entries from other parallel processes - // Just verify the operation succeeded (returns true) + let remaining = await isolated.fetchHistory(limit: 100) + #expect(remaining.isEmpty) } @Test("Combined connectionId + dateFilter works") diff --git a/TableProTests/Core/Utilities/SQLStatementScannerTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift new file mode 100644 index 00000000..f0eed725 --- /dev/null +++ b/TableProTests/Core/Utilities/SQLStatementScannerTests.swift @@ -0,0 +1,186 @@ +// +// SQLStatementScannerTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +final class SQLStatementScannerTests: XCTestCase { + // MARK: - allStatements + + func testEmptyInput() { + XCTAssertEqual(SQLStatementScanner.allStatements(in: ""), []) + } + + func testSingleStatement() { + XCTAssertEqual( + SQLStatementScanner.allStatements(in: "SELECT 1"), + ["SELECT 1"] + ) + } + + func testSingleStatementWithTrailingSemicolon() { + XCTAssertEqual( + SQLStatementScanner.allStatements(in: "SELECT 1;"), + ["SELECT 1"] + ) + } + + func testMultipleStatements() { + let sql = "SELECT 1; SELECT 2; SELECT 3" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 1", "SELECT 2", "SELECT 3"] + ) + } + + func testSemicolonInsideSingleQuotes() { + let sql = "SELECT 'a;b'; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 'a;b'", "SELECT 2"] + ) + } + + func testSemicolonInsideDoubleQuotes() { + let sql = "SELECT \"a;b\"; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT \"a;b\"", "SELECT 2"] + ) + } + + func testSemicolonInsideBackticks() { + let sql = "SELECT `a;b`; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT `a;b`", "SELECT 2"] + ) + } + + func testSemicolonInsideLineComment() { + let sql = "SELECT 1 -- comment; still comment\n; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 1 -- comment; still comment", "SELECT 2"] + ) + } + + func testSemicolonInsideBlockComment() { + let sql = "SELECT 1 /* comment; */ ; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 1 /* comment; */", "SELECT 2"] + ) + } + + func testBackslashEscape() { + let sql = "SELECT 'it\\'s'; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 'it\\'s'", "SELECT 2"] + ) + } + + func testDoubledQuoteEscape() { + let sql = "SELECT 'it''s'; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 'it''s'", "SELECT 2"] + ) + } + + func testWhitespaceOnlyStatements() { + let sql = "SELECT 1; ; \n ; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 1", "SELECT 2"] + ) + } + + func testNestedBlockComment() { + // SQL block comments don't nest — first */ closes + let sql = "SELECT 1 /* outer /* inner */ ; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.allStatements(in: sql), + ["SELECT 1 /* outer /* inner */", "SELECT 2"] + ) + } + + // MARK: - statementAtCursor + + func testCursorInFirstStatement() { + let sql = "SELECT 1; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 3), + "SELECT 1" + ) + } + + func testCursorInSecondStatement() { + let sql = "SELECT 1; SELECT 2" + // cursor at position 10 = 'S' of "SELECT 2" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 10), + "SELECT 2" + ) + } + + func testCursorInLastStatementNoSemicolon() { + let sql = "SELECT 1; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 15), + "SELECT 2" + ) + } + + func testCursorAtSemicolon() { + let sql = "SELECT 1; SELECT 2" + // cursor at position 8 (the ';') should belong to first statement + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 8), + "SELECT 1" + ) + } + + func testCursorAtZero() { + let sql = "SELECT 1; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 0), + "SELECT 1" + ) + } + + func testCursorBeyondEnd() { + let sql = "SELECT 1; SELECT 2" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 999), + "SELECT 2" + ) + } + + func testNoSemicolonsFastPath() { + let sql = "SELECT * FROM users" + XCTAssertEqual( + SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 5), + "SELECT * FROM users" + ) + } + + // MARK: - locatedStatementAtCursor + + func testLocatedStatementOffset() { + let sql = "SELECT 1; SELECT 2" + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 3) + XCTAssertEqual(located.sql, "SELECT 1;") + XCTAssertEqual(located.offset, 0) + } + + func testLocatedStatementOffsetSecondStatement() { + let sql = "SELECT 1; SELECT 2" + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 12) + XCTAssertEqual(located.sql, " SELECT 2") + XCTAssertEqual(located.offset, 9) + } +} diff --git a/TableProTests/Views/History/HistoryDataProviderTests.swift b/TableProTests/Views/History/HistoryDataProviderTests.swift index 6dcc914f..cb0497a4 100644 --- a/TableProTests/Views/History/HistoryDataProviderTests.swift +++ b/TableProTests/Views/History/HistoryDataProviderTests.swift @@ -131,13 +131,21 @@ struct HistoryDataProviderTests { #expect(!entries.contains { $0.id == entry.id }) } - @Test("clearAll returns true") + @Test("clearAll returns true and updates provider state") + @MainActor func clearAllRemovesAllHistory() async { - _ = await insertEntry() + let marker = UUID().uuidString + _ = await insertEntry(query: "SELECT clear_\(marker)") let provider = HistoryDataProvider() + provider.searchText = marker + await provider.loadData() + #expect(provider.count >= 1) + let result = await provider.clearAll() #expect(result == true) + #expect(provider.count == 0) + #expect(provider.isEmpty == true) } @Test("scheduleSearch debounces then loads data")