Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Windows Authentication (Kerberos) to SQL Server now works on macOS. The bundled SQL Server driver was built without Kerberos support, so every Windows-auth connect failed as if the credentials were wrong. (#1918)
- MySQL and MariaDB queries no longer fail after about a minute when a longer query timeout is set. The client read timeout now follows the configured query timeout, so a long query or stored procedure runs for the full time you allow. (#1921)
- A dropped MySQL or MariaDB connection no longer silently re-runs a statement that changes data, so a lost connection can no longer run an insert, update, or stored procedure twice. (#1921)
- The MongoDB, Oracle, Cassandra, and Elasticsearch plugins failed to install on 0.58 with "Bundle failed to load executable". (#1917)

## [0.58.0] - 2026-07-18
Expand Down
9 changes: 6 additions & 3 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
func mysqlTypeToString(_ fieldPtr: UnsafePointer<MYSQL_FIELD>) -> String {
let field = fieldPtr.pointee
let flags = UInt(field.flags)
let length = field.length

Check warning on line 70 in Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

initialization of immutable value 'length' was never used; consider replacing with assignment to '_' or removing it

// MariaDB extended metadata: detect JSON stored as LONGTEXT.
// `MARIADB_CONST_STRING` is length-prefixed (not null-terminated), so we must read
Expand Down Expand Up @@ -159,6 +159,7 @@
private let database: String
private let sslConfig: SSLConfiguration
private let enableCleartextPlugin: Bool
private let queryTimeoutSeconds: Int

private let stateLock = NSLock()
private var _isConnected: Bool = false
Expand Down Expand Up @@ -192,7 +193,8 @@
password: String?,
database: String,
sslConfig: SSLConfiguration,
enableCleartextPlugin: Bool = false
enableCleartextPlugin: Bool = false,
queryTimeoutSeconds: Int = 0
) {
self.host = host
self.port = UInt32(port)
Expand All @@ -201,6 +203,7 @@
self.database = database
self.sslConfig = sslConfig
self.enableCleartextPlugin = enableCleartextPlugin
self.queryTimeoutSeconds = queryTimeoutSeconds
}

deinit {
Expand Down Expand Up @@ -261,10 +264,10 @@
var timeout: UInt32 = 10
mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout)

var readTimeout: UInt32 = 30
var readTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds)
mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout)

var writeTimeout: UInt32 = 30
var writeTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds)
mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout)

var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue)
Expand Down
6 changes: 4 additions & 2 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
password: config.password,
database: _activeDatabase,
sslConfig: sslConfig,
enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true"
enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true",
queryTimeoutSeconds: config.additionalFields["queryTimeoutSeconds"].flatMap { Int($0) } ?? 0
)

try await conn.connect()
Expand Down Expand Up @@ -193,7 +194,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
isTruncated: result.isTruncated,
columnMeta: result.columnMeta
)
} catch let error as MariaDBPluginError where !isRetry && isConnectionLostError(error) {
} catch let error as MariaDBPluginError
where !isRetry && isConnectionLostError(error) && mysqlStatementIsReadOnly(query) {
try await reconnect()
return try await executeWithReconnect(query: query, isRetry: true, rowCap: rowCap)
}
Expand Down
13 changes: 13 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// MySQLSocketTimeout.swift
// MySQLDriverPlugin
//

internal let mysqlSocketTimeoutGraceSeconds = 30

internal func mysqlSocketTimeoutSeconds(forQueryTimeout queryTimeoutSeconds: Int) -> UInt32 {
guard queryTimeoutSeconds > 0 else { return 0 }
let ceiling = Int(UInt32.max) - mysqlSocketTimeoutGraceSeconds
let clamped = min(queryTimeoutSeconds, ceiling)
return UInt32(clamped + mysqlSocketTimeoutGraceSeconds)
}
17 changes: 17 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// MySQLStatementClassification.swift
// MySQLDriverPlugin
//

internal func mysqlStatementIsReadOnly(_ query: String) -> Bool {
let keyword = query
.drop(while: { $0.isWhitespace })
.prefix(while: { $0.isLetter })
.uppercased()
switch keyword {
case "SELECT", "SHOW", "DESCRIBE", "DESC":
return true
default:
return false
}
}
2 changes: 2 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,8 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
MySQLQueryTimeoutStatement.swift,
MySQLSocketTimeout.swift,
MySQLStatementClassification.swift,
);
target = 5ABCC5A62F43856700EAF3FC /* TableProTests */;
};
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ enum DatabaseDriverFactory {
}
additionalFields["enableCleartextPlugin"] = "true"
}
additionalFields["queryTimeoutSeconds"] = String(AppSettingsManager.shared.general.queryTimeoutSeconds)
let config = DriverConnectionConfig(
host: connection.host,
port: connection.port,
Expand Down
35 changes: 35 additions & 0 deletions TableProTests/Plugins/MySQLSocketTimeoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// MySQLSocketTimeoutTests.swift
// TableProTests
//

import Testing

@Suite("MySQL Socket Timeout")
struct MySQLSocketTimeoutTests {
@Test("No limit maps to an infinite socket timeout")
func noLimitIsInfinite() {
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 0) == 0)
}

@Test("A negative query timeout maps to an infinite socket timeout")
func negativeIsInfinite() {
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: -5) == 0)
}

@Test("A finite query timeout adds the grace period")
func finiteAddsGrace() {
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 60) == 90)
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 600) == 630)
}

@Test("The grace period is applied on top of the query timeout")
func graceMatchesConstant() {
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 1) == UInt32(1 + mysqlSocketTimeoutGraceSeconds))
}

@Test("A very large query timeout clamps without overflowing")
func largeValueClamps() {
#expect(mysqlSocketTimeoutSeconds(forQueryTimeout: Int.max) == UInt32.max)
}
}
48 changes: 48 additions & 0 deletions TableProTests/Plugins/MySQLStatementClassificationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// MySQLStatementClassificationTests.swift
// TableProTests
//

import Testing

@Suite("MySQL Statement Classification")
struct MySQLStatementClassificationTests {
@Test("SELECT is read-only")
func selectIsReadOnly() {
#expect(mysqlStatementIsReadOnly("SELECT * FROM users"))
}

@Test("Leading whitespace and lowercase are handled")
func whitespaceAndCase() {
#expect(mysqlStatementIsReadOnly(" \n select 1"))
}

@Test("SHOW, DESCRIBE, and DESC are read-only")
func showAndDescribe() {
#expect(mysqlStatementIsReadOnly("SHOW TABLES"))
#expect(mysqlStatementIsReadOnly("DESCRIBE users"))
#expect(mysqlStatementIsReadOnly("DESC users"))
}

@Test("Mutating statements are not read-only")
func mutatingStatements() {
#expect(!mysqlStatementIsReadOnly("INSERT INTO users VALUES (1)"))
#expect(!mysqlStatementIsReadOnly("UPDATE users SET name = 'a'"))
#expect(!mysqlStatementIsReadOnly("DELETE FROM users"))
#expect(!mysqlStatementIsReadOnly("CALL do_work()"))
#expect(!mysqlStatementIsReadOnly("REPLACE INTO users VALUES (1)"))
#expect(!mysqlStatementIsReadOnly("DROP TABLE users"))
}

@Test("A statement behind a leading comment is treated conservatively")
func leadingCommentIsConservative() {
#expect(!mysqlStatementIsReadOnly("/* note */ SELECT 1"))
#expect(!mysqlStatementIsReadOnly("-- note\nSELECT 1"))
}

@Test("An empty statement is not read-only")
func emptyIsNotReadOnly() {
#expect(!mysqlStatementIsReadOnly(""))
#expect(!mysqlStatementIsReadOnly(" "))
}
}
Loading