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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Every ClickHouse query after connecting failing with `UNKNOWN_SETTING` on 23.8.0 to 23.8.8. (#3444)
- ClickHouse users with a read-only profile unable to connect.
- ClickHouse indexes, view definitions, functions, table comments and sizes failing to load on servers before 22.3.
- A ClickHouse query that failed partway showing its partial rows and the error text as a row instead of the error.
- A ClickHouse query with a `FORMAT` clause showing garbled rows in the grid and in an export.
- ClickHouse SQL export writing the current database's table structure for a table in another database.
- Opening a ClickHouse executable function's definition showing a syntax error.
- SQL Server 2016 RTM offered a `CREATE OR ALTER VIEW` template it cannot run, and Azure SQL a drop-and-create one.

## [0.81.0] - 2026-10-16

Cloud Firestore, Firebase Realtime Database and Google Cloud Bigtable connect through new registry plugins.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,37 @@ public enum MSSQLServerBanner {

/// Matches against the whole banner rather than the first line alone, so a future layout that
/// moves the build number cannot silently return nil the way the fixed prefix did.
public static func majorVersion(from banner: String?) -> Int? {
public static func server(from banner: String?) -> MSSQLServerBuild? {
guard let banner else { return nil }
guard let regex = try? NSRegularExpression(pattern: #"(\d+)\.\d+\.\d+"#),
let match = regex.firstMatch(in: banner, range: NSRange(banner.startIndex..., in: banner)),
let range = Range(match.range(at: 1), in: banner)
guard let regex = try? NSRegularExpression(pattern: #"(\d+)\.(\d+)\.(\d+)"#),
let match = regex.firstMatch(in: banner, range: NSRange(banner.startIndex..., in: banner))
else {
return nil
}
return Int(banner[range])
let parts = (1...3).compactMap { index in
Range(match.range(at: index), in: banner).flatMap { Int(banner[$0]) }
}
guard parts.count == 3 else { return nil }
return MSSQLServerBuild(
major: parts[0],
minor: parts[1],
build: parts[2],
isAzureSQL: banner.hasPrefix("Microsoft SQL Azure")
)
}
}

/// Azure SQL Database and Managed Instance report `12.0.2000.x` whatever engine they run, so their
/// features cannot be read off the number.
public struct MSSQLServerBuild: Sendable, Equatable {
public let major: Int
public let minor: Int
public let build: Int
public let isAzureSQL: Bool

/// `CREATE OR ALTER` applies to "Azure SQL Database and SQL Server (starting with SQL Server
/// 2016 (13.x) SP1)" (CREATE VIEW, Microsoft Learn). 13.0.4001 is 2016 SP1; RTM is 13.0.1601.
public var hasCreateOrAlter: Bool {
isAzureSQL || (major, minor, build) >= (13, 0, 4_001)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ struct MSSQLServerBannerTests {

@Test("A patched server's major version survives the banner")
func patchedServerParses() {
#expect(MSSQLServerBanner.majorVersion(from: Self.sqlServer2022) == 16)
#expect(MSSQLServerBanner.server(from: Self.sqlServer2022)
== MSSQLServerBuild(major: 16, minor: 0, build: 4_275, isAzureSQL: false))
}

@Test("A fixed 50-character prefix would have lost it")
func fixedPrefixLosesTheBuild() {
#expect(MSSQLServerBanner.majorVersion(from: String(Self.sqlServer2022.prefix(50))) == nil)
#expect(MSSQLServerBanner.server(from: String(Self.sqlServer2022.prefix(50))) == nil)
}

@Test("Display text is the product line alone")
Expand All @@ -87,14 +88,29 @@ struct MSSQLServerBannerTests {
@Test("Azure SQL Database reports its version on the first line too")
func azureParses() {
let banner = "Microsoft SQL Azure (RTM) - 12.0.2000.8 \n\tOct 18 2025 12:00:00 \n\tCopyright (C) 2025 Microsoft Corporation"
#expect(MSSQLServerBanner.majorVersion(from: banner) == 12)
#expect(MSSQLServerBanner.server(from: banner)
== MSSQLServerBuild(major: 12, minor: 0, build: 2_000, isAzureSQL: true))
#expect(MSSQLServerBanner.displayText(from: banner) == "Microsoft SQL Azure (RTM) - 12.0.2000.8")
}

@Test("A banner with no build number reports no version rather than a wrong one")
func unparsableBanner() {
#expect(MSSQLServerBanner.majorVersion(from: "Microsoft SQL Server") == nil)
#expect(MSSQLServerBanner.majorVersion(from: nil) == nil)
#expect(MSSQLServerBanner.server(from: "Microsoft SQL Server") == nil)
#expect(MSSQLServerBanner.server(from: nil) == nil)
}

@Test(
"CREATE OR ALTER needs SQL Server 2016 SP1 or Azure SQL",
arguments: [
("Microsoft SQL Server 2016 (RTM) - 13.0.1601.5 (X64)", false),
("Microsoft SQL Server 2016 (SP1) (KB3182545) - 13.0.4001.0 (X64)", true),
("Microsoft SQL Server 2014 (SP3) (KB4022619) - 12.0.6024.0 (X64)", false),
("Microsoft SQL Server 2019 (RTM-CU18) (KB5017593) - 15.0.4261.1 (X64)", true),
("Microsoft SQL Azure (RTM) - 12.0.2000.8", true),
]
)
func createOrAlterGate(banner: String, expected: Bool) {
#expect(MSSQLServerBanner.server(from: banner)?.hasCreateOrAlter == expected)
}

@Test("A single-line banner is returned whole")
Expand Down
34 changes: 0 additions & 34 deletions Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift

This file was deleted.

30 changes: 24 additions & 6 deletions Plugins/ClickHouseDriverPlugin/ClickHouseHTTPChunks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import TableProPluginKit
/// rather than letting the rest of the result pile up in memory. That resume is why the body is
/// read through `forEachChunk` and not by iterating a sequence: a caller cannot forget it.
internal final class ClickHouseHTTPChunks: NSObject, URLSessionDataDelegate, @unchecked Sendable {
/// An error body is text the user reads, not a result, so it is held whole up to this and the
/// rest dropped. ClickHouse exception text runs to a few kilobytes at most.
/// An error body is text the user reads, not a result. Its last bytes are kept, because a
/// failure on 25.10 and later can arrive as HTTP 500 with the rows written so far ahead of
/// the text. ClickHouse exception text runs to a few kilobytes at most.
private static let errorBodyByteCap = 65_536

private let stream: AsyncThrowingStream<Data, Error>
Expand All @@ -27,6 +28,7 @@ internal final class ClickHouseHTTPChunks: NSObject, URLSessionDataDelegate, @un
private var isTransferSuspended = false
private var failureStatusCode: Int?
private var failureBody = Data()
private var responseHeaders: [String: String] = [:]

internal init(session: URLSession, request: URLRequest) {
(stream, continuation) = AsyncThrowingStream<Data, Error>.makeStream()
Expand All @@ -39,6 +41,15 @@ internal final class ClickHouseHTTPChunks: NSObject, URLSessionDataDelegate, @un
dataTask.resume()
}

/// Set before the first chunk is handed over, so a reader can look at it from there on.
internal var headers: [String: String] {
lock.withLock { responseHeaders }
}

internal func headerValue(_ name: String) -> String? {
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
}

/// The transfer is torn down on every way out, a thrown error and a cancellation included. A
/// reader that walks away leaves the task suspended mid-body with nothing left to resume it, so
/// without this the connection stays open until the driver disconnects.
Expand All @@ -56,17 +67,24 @@ internal final class ClickHouseHTTPChunks: NSObject, URLSessionDataDelegate, @un
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
if let statusCode = (response as? HTTPURLResponse)?.statusCode, statusCode >= 400 {
lock.withLock { failureStatusCode = statusCode }
let httpResponse = response as? HTTPURLResponse
let fields = ClickHousePluginDriver.headerFields(httpResponse)
lock.withLock {
responseHeaders = fields
if let httpResponse, ClickHousePluginDriver.isFailure(httpResponse) {
failureStatusCode = httpResponse.statusCode
}
}
completionHandler(.allow)
}

internal func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let isFailure = lock.withLock { () -> Bool in
guard failureStatusCode != nil else { return false }
let room = Self.errorBodyByteCap - failureBody.count
if room > 0 { failureBody.append(data.prefix(room)) }
failureBody.append(data)
if failureBody.count > Self.errorBodyByteCap {
failureBody = Data(failureBody.suffix(Self.errorBodyByteCap))
}
return true
}
guard !isFailure else { return }
Expand Down
Loading
Loading