Skip to content

Commit e416c04

Browse files
authored
Merge pull request #273 from datlechin/feat/mssql-cancel-query
feat: implement MSSQL cancelQuery and applyQueryTimeout
2 parents d30fba6 + b6292f9 commit e416c04

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Copy as INSERT/UPDATE SQL statements from data grid context menu
1313
- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour
14+
- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support
1415

1516
### Fixed
1617

Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ private final class FreeTDSConnection: @unchecked Sendable {
9292
private let database: String
9393
private let lock = NSLock()
9494
private var _isConnected = false
95+
private var _isCancelled = false
9596

9697
var isConnected: Bool {
9798
lock.lock()
@@ -172,6 +173,16 @@ private final class FreeTDSConnection: @unchecked Sendable {
172173
}
173174
}
174175

176+
func cancelCurrentQuery() {
177+
lock.lock()
178+
_isCancelled = true
179+
let proc = dbproc
180+
lock.unlock()
181+
182+
guard let proc else { return }
183+
dbcancel(proc)
184+
}
185+
175186
func executeQuery(_ query: String) async throws -> FreeTDSQueryResult {
176187
let queryToRun = String(query)
177188
return try await pluginDispatchAsync(on: queue) { [self] in
@@ -186,6 +197,10 @@ private final class FreeTDSConnection: @unchecked Sendable {
186197

187198
_ = dbcanquery(proc)
188199

200+
lock.lock()
201+
_isCancelled = false
202+
lock.unlock()
203+
189204
freetdsLastError = ""
190205
if dbcmd(proc, query) == FAIL {
191206
throw MSSQLPluginError.queryFailed("Failed to prepare query")
@@ -202,6 +217,14 @@ private final class FreeTDSConnection: @unchecked Sendable {
202217
var truncated = false
203218

204219
while true {
220+
lock.lock()
221+
let cancelledBetweenResults = _isCancelled
222+
if cancelledBetweenResults { _isCancelled = false }
223+
lock.unlock()
224+
if cancelledBetweenResults {
225+
throw MSSQLPluginError.queryFailed("Query cancelled")
226+
}
227+
205228
let resCode = dbresults(proc)
206229
if resCode == FAIL {
207230
throw MSSQLPluginError.queryFailed("Query execution failed")
@@ -232,6 +255,14 @@ private final class FreeTDSConnection: @unchecked Sendable {
232255
if rowCode == Int32(NO_MORE_ROWS) { break }
233256
if rowCode == FAIL { break }
234257

258+
lock.lock()
259+
let cancelled = _isCancelled
260+
if cancelled { _isCancelled = false }
261+
lock.unlock()
262+
if cancelled {
263+
throw MSSQLPluginError.queryFailed("Query cancelled")
264+
}
265+
235266
var row: [String?] = []
236267
for i in 1...numCols {
237268
let len = dbdatlen(proc, Int32(i))
@@ -386,6 +417,16 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
386417
)
387418
}
388419

420+
func cancelQuery() throws {
421+
freeTDSConn?.cancelCurrentQuery()
422+
}
423+
424+
func applyQueryTimeout(_ seconds: Int) async throws {
425+
guard seconds > 0 else { return }
426+
let ms = seconds * 1_000
427+
_ = try await execute(query: "SET LOCK_TIMEOUT \(ms)")
428+
}
429+
389430
func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult {
390431
guard !parameters.isEmpty else {
391432
return try await execute(query: query)

TableProTests/Core/Database/MSSQLDriverTests.swift

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import Testing
1414

1515
private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
1616
private var schema: String?
17+
var cancelQueryCallCount = 0
18+
var applyQueryTimeoutValues: [Int] = []
19+
var executedQueries: [String] = []
20+
var shouldFailExecute = true
1721

1822
init(initialSchema: String?) {
1923
schema = initialSchema
@@ -29,12 +33,25 @@ private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Send
2933
func connect() async throws {}
3034
func disconnect() {}
3135

36+
func cancelQuery() throws {
37+
cancelQueryCallCount += 1
38+
}
39+
40+
func applyQueryTimeout(_ seconds: Int) async throws {
41+
applyQueryTimeoutValues.append(seconds)
42+
executedQueries.append("SET LOCK_TIMEOUT \(seconds * 1_000)")
43+
}
44+
3245
func execute(query: String) async throws -> PluginQueryResult {
33-
throw NSError(
34-
domain: "MockMSSQLPluginDriver",
35-
code: -1,
36-
userInfo: [NSLocalizedDescriptionKey: "Not connected"]
37-
)
46+
executedQueries.append(query)
47+
if shouldFailExecute {
48+
throw NSError(
49+
domain: "MockMSSQLPluginDriver",
50+
code: -1,
51+
userInfo: [NSLocalizedDescriptionKey: "Not connected"]
52+
)
53+
}
54+
return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
3855
}
3956

4057
func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] }
@@ -64,10 +81,16 @@ struct MSSQLDriverTests {
6481
}
6582

6683
private func makeAdapter(mssqlSchema: String? = nil) -> PluginDriverAdapter {
84+
let (adapter, _) = makeAdapterWithMock(mssqlSchema: mssqlSchema)
85+
return adapter
86+
}
87+
88+
private func makeAdapterWithMock(mssqlSchema: String? = nil) -> (PluginDriverAdapter, MockMSSQLPluginDriver) {
6789
let conn = makeConnection(mssqlSchema: mssqlSchema)
6890
let effectiveSchema: String? = if let s = mssqlSchema, !s.isEmpty { s } else { "dbo" }
69-
let pluginDriver = MockMSSQLPluginDriver(initialSchema: effectiveSchema)
70-
return PluginDriverAdapter(connection: conn, pluginDriver: pluginDriver)
91+
let mock = MockMSSQLPluginDriver(initialSchema: effectiveSchema)
92+
let adapter = PluginDriverAdapter(connection: conn, pluginDriver: mock)
93+
return (adapter, mock)
7194
}
7295

7396
// MARK: - Initialization Tests
@@ -147,4 +170,49 @@ struct MSSQLDriverTests {
147170
_ = try await adapter.execute(query: "SELECT 1")
148171
}
149172
}
173+
174+
// MARK: - cancelQuery Tests
175+
176+
@Test("cancelQuery delegates to plugin driver")
177+
func cancelQueryDelegatesToPlugin() throws {
178+
let (adapter, mock) = makeAdapterWithMock()
179+
try adapter.cancelQuery()
180+
#expect(mock.cancelQueryCallCount == 1)
181+
}
182+
183+
@Test("cancelQuery can be called multiple times")
184+
func cancelQueryMultipleCalls() throws {
185+
let (adapter, mock) = makeAdapterWithMock()
186+
try adapter.cancelQuery()
187+
try adapter.cancelQuery()
188+
try adapter.cancelQuery()
189+
#expect(mock.cancelQueryCallCount == 3)
190+
}
191+
192+
// MARK: - applyQueryTimeout Tests
193+
194+
@Test("applyQueryTimeout delegates to plugin driver with correct value")
195+
func applyQueryTimeoutDelegates() async throws {
196+
let (adapter, mock) = makeAdapterWithMock()
197+
mock.shouldFailExecute = false
198+
try await adapter.applyQueryTimeout(30)
199+
#expect(mock.applyQueryTimeoutValues == [30])
200+
}
201+
202+
@Test("applyQueryTimeout with zero is handled by plugin")
203+
func applyQueryTimeoutZero() async throws {
204+
let (adapter, mock) = makeAdapterWithMock()
205+
mock.shouldFailExecute = false
206+
try await adapter.applyQueryTimeout(0)
207+
#expect(mock.applyQueryTimeoutValues == [0])
208+
}
209+
210+
@Test("applyQueryTimeout with different values records each call")
211+
func applyQueryTimeoutMultipleCalls() async throws {
212+
let (adapter, mock) = makeAdapterWithMock()
213+
mock.shouldFailExecute = false
214+
try await adapter.applyQueryTimeout(10)
215+
try await adapter.applyQueryTimeout(60)
216+
#expect(mock.applyQueryTimeoutValues == [10, 60])
217+
}
150218
}

0 commit comments

Comments
 (0)