From dfd73afe9d98bf17a445b8d45e0a0a9519d3f8fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 16:26:46 +0700 Subject: [PATCH 1/3] feat: implement MSSQL cancelQuery and applyQueryTimeout --- CHANGELOG.md | 1 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 33 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 553f7417a..a00fa2cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Copy as INSERT/UPDATE SQL statements from data grid context menu - Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour +- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support ### Fixed diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 9e4dfeb86..b761a6248 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -92,6 +92,7 @@ private final class FreeTDSConnection: @unchecked Sendable { private let database: String private let lock = NSLock() private var _isConnected = false + private var _isCancelled = false var isConnected: Bool { lock.lock() @@ -172,6 +173,16 @@ private final class FreeTDSConnection: @unchecked Sendable { } } + func cancelCurrentQuery() { + lock.lock() + _isCancelled = true + let proc = dbproc + lock.unlock() + + guard let proc else { return } + dbcancel(proc) + } + func executeQuery(_ query: String) async throws -> FreeTDSQueryResult { let queryToRun = String(query) return try await pluginDispatchAsync(on: queue) { [self] in @@ -186,6 +197,10 @@ private final class FreeTDSConnection: @unchecked Sendable { _ = dbcanquery(proc) + lock.lock() + _isCancelled = false + lock.unlock() + freetdsLastError = "" if dbcmd(proc, query) == FAIL { throw MSSQLPluginError.queryFailed("Failed to prepare query") @@ -232,6 +247,14 @@ private final class FreeTDSConnection: @unchecked Sendable { if rowCode == Int32(NO_MORE_ROWS) { break } if rowCode == FAIL { break } + lock.lock() + let cancelled = _isCancelled + if cancelled { _isCancelled = false } + lock.unlock() + if cancelled { + throw MSSQLPluginError.queryFailed("Query cancelled") + } + var row: [String?] = [] for i in 1...numCols { let len = dbdatlen(proc, Int32(i)) @@ -386,6 +409,16 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + func cancelQuery() throws { + freeTDSConn?.cancelCurrentQuery() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + guard seconds > 0 else { return } + let ms = seconds * 1_000 + _ = try await execute(query: "SET LOCK_TIMEOUT \(ms)") + } + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) From f48162bc8f4bda9f4aa03faf4a03cc3d8ffb0bb0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 16:31:05 +0700 Subject: [PATCH 2/3] test: add cancelQuery and applyQueryTimeout tests for MSSQL driver --- .../Core/Database/MSSQLDriverTests.swift | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/TableProTests/Core/Database/MSSQLDriverTests.swift b/TableProTests/Core/Database/MSSQLDriverTests.swift index f271f500e..3da327f94 100644 --- a/TableProTests/Core/Database/MSSQLDriverTests.swift +++ b/TableProTests/Core/Database/MSSQLDriverTests.swift @@ -14,6 +14,10 @@ import Testing private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var schema: String? + var cancelQueryCallCount = 0 + var applyQueryTimeoutValues: [Int] = [] + var executedQueries: [String] = [] + var shouldFailExecute = true init(initialSchema: String?) { schema = initialSchema @@ -29,12 +33,25 @@ private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Send func connect() async throws {} func disconnect() {} + func cancelQuery() throws { + cancelQueryCallCount += 1 + } + + func applyQueryTimeout(_ seconds: Int) async throws { + applyQueryTimeoutValues.append(seconds) + executedQueries.append("SET LOCK_TIMEOUT \(seconds * 1_000)") + } + func execute(query: String) async throws -> PluginQueryResult { - throw NSError( - domain: "MockMSSQLPluginDriver", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Not connected"] - ) + executedQueries.append(query) + if shouldFailExecute { + throw NSError( + domain: "MockMSSQLPluginDriver", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Not connected"] + ) + } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } @@ -64,10 +81,16 @@ struct MSSQLDriverTests { } private func makeAdapter(mssqlSchema: String? = nil) -> PluginDriverAdapter { + let (adapter, _) = makeAdapterWithMock(mssqlSchema: mssqlSchema) + return adapter + } + + private func makeAdapterWithMock(mssqlSchema: String? = nil) -> (PluginDriverAdapter, MockMSSQLPluginDriver) { let conn = makeConnection(mssqlSchema: mssqlSchema) let effectiveSchema: String? = if let s = mssqlSchema, !s.isEmpty { s } else { "dbo" } - let pluginDriver = MockMSSQLPluginDriver(initialSchema: effectiveSchema) - return PluginDriverAdapter(connection: conn, pluginDriver: pluginDriver) + let mock = MockMSSQLPluginDriver(initialSchema: effectiveSchema) + let adapter = PluginDriverAdapter(connection: conn, pluginDriver: mock) + return (adapter, mock) } // MARK: - Initialization Tests @@ -147,4 +170,49 @@ struct MSSQLDriverTests { _ = try await adapter.execute(query: "SELECT 1") } } + + // MARK: - cancelQuery Tests + + @Test("cancelQuery delegates to plugin driver") + func cancelQueryDelegatesToPlugin() throws { + let (adapter, mock) = makeAdapterWithMock() + try adapter.cancelQuery() + #expect(mock.cancelQueryCallCount == 1) + } + + @Test("cancelQuery can be called multiple times") + func cancelQueryMultipleCalls() throws { + let (adapter, mock) = makeAdapterWithMock() + try adapter.cancelQuery() + try adapter.cancelQuery() + try adapter.cancelQuery() + #expect(mock.cancelQueryCallCount == 3) + } + + // MARK: - applyQueryTimeout Tests + + @Test("applyQueryTimeout delegates to plugin driver with correct value") + func applyQueryTimeoutDelegates() async throws { + let (adapter, mock) = makeAdapterWithMock() + mock.shouldFailExecute = false + try await adapter.applyQueryTimeout(30) + #expect(mock.applyQueryTimeoutValues == [30]) + } + + @Test("applyQueryTimeout with zero is handled by plugin") + func applyQueryTimeoutZero() async throws { + let (adapter, mock) = makeAdapterWithMock() + mock.shouldFailExecute = false + try await adapter.applyQueryTimeout(0) + #expect(mock.applyQueryTimeoutValues == [0]) + } + + @Test("applyQueryTimeout with different values records each call") + func applyQueryTimeoutMultipleCalls() async throws { + let (adapter, mock) = makeAdapterWithMock() + mock.shouldFailExecute = false + try await adapter.applyQueryTimeout(10) + try await adapter.applyQueryTimeout(60) + #expect(mock.applyQueryTimeoutValues == [10, 60]) + } } From b6292f90737dd027b255ee1d9e91a598a24270d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 16:33:13 +0700 Subject: [PATCH 3/3] fix: add cancellation check between MSSQL result sets --- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index b761a6248..aded08ed5 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -217,6 +217,14 @@ private final class FreeTDSConnection: @unchecked Sendable { var truncated = false while true { + lock.lock() + let cancelledBetweenResults = _isCancelled + if cancelledBetweenResults { _isCancelled = false } + lock.unlock() + if cancelledBetweenResults { + throw MSSQLPluginError.queryFailed("Query cancelled") + } + let resCode = dbresults(proc) if resCode == FAIL { throw MSSQLPluginError.queryFailed("Query execution failed")