Skip to content

Commit e1fcbb2

Browse files
authored
fix(plugin-clickhouse): show the result grid whenever the server returns rows (#1896)
1 parent 3a51275 commit e1fcbb2

10 files changed

Lines changed: 439 additions & 184 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Fixed
2020

21+
- Fixed ClickHouse queries showing only a success message with no result table. TablePro guessed whether a query returns data from its first word, which failed for queries starting with a comment or with SELECT on its own line. It now reads what the server actually returned, so any query that produces a result set shows the grid, including empty results and queries with their own FORMAT clause. (#1886)
22+
- Fixed ClickHouse INSERT statements always reporting 0 rows affected. (#1886)
23+
- Fixed ClickHouse query exports writing an empty file when the query started with a comment. (#1886)
2124
- Fixed a restored window tab sometimes showing a blank name in the tab bar until you switched to it. (#1894)
2225
- Fixed the window title not updating right away after saving a query to a file or opening a favorite into the current tab. (#1894)
2326
- Fixed the SQL editor in a new tab losing keyboard focus after the first couple of characters, which stopped further typing until you clicked back into the editor. (#1885)

Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ struct ClickHouseCapabilities: Sendable, Equatable {
1515
major > 19 || (major == 19 && minor >= 17)
1616
}
1717

18+
var hasWriteExceptionInOutputFormatSetting: Bool {
19+
major > 23 || (major == 23 && minor >= 8)
20+
}
21+
1822
static func parse(_ version: String?) -> ClickHouseCapabilities {
1923
guard let version else { return .unknown }
2024
let parts = version.split(separator: ".")

Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,6 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
149149

150150
static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePluginDriver")
151151

152-
static let selectPrefixes: Set<String> = [
153-
"SELECT", "SHOW", "DESCRIBE", "DESC", "EXISTS", "EXPLAIN", "WITH"
154-
]
155-
156152
var serverVersion: String? { _serverVersion }
157153
var supportsSchemas: Bool { false }
158154
var supportsTransactions: Bool { false }
@@ -604,9 +600,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
604600
if !database.isEmpty {
605601
queryItems.append(URLQueryItem(name: "database", value: database))
606602
}
607-
if !queryItems.isEmpty {
608-
components.queryItems = queryItems
609-
}
603+
queryItems.append(URLQueryItem(name: "default_format", value: "JSONEachRow"))
604+
components.queryItems = queryItems
610605

611606
guard let url = components.url else {
612607
throw ClickHouseError(message: "Failed to construct request URL")
@@ -622,7 +617,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
622617
request.setValue(authorization, forHTTPHeaderField: "Authorization")
623618
}
624619

625-
request.httpBody = (query + " FORMAT JSONEachRow").data(using: .utf8)
620+
request.httpBody = query.data(using: .utf8)
626621
return request
627622
}
628623

Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift

Lines changed: 48 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -24,50 +24,7 @@ extension ClickHousePluginDriver {
2424

2525
var request = try buildRequest(query: query, database: database, queryId: queryId)
2626
request.timeoutInterval = _queryTimeout.requestTimeoutInterval
27-
let isSelect = Self.isSelectLikeQuery(query)
28-
29-
let (data, response) = try await withTaskCancellationHandler {
30-
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in
31-
let task = session.dataTask(with: request) { data, response, error in
32-
if let error {
33-
continuation.resume(throwing: error)
34-
return
35-
}
36-
guard let data, let response else {
37-
continuation.resume(throwing: ClickHouseError(message: "Empty response from server"))
38-
return
39-
}
40-
continuation.resume(returning: (data, response))
41-
}
42-
43-
self.lock.lock()
44-
self.currentTask = task
45-
self.lock.unlock()
46-
47-
task.resume()
48-
}
49-
} onCancel: {
50-
self.lock.lock()
51-
self.currentTask?.cancel()
52-
self.currentTask = nil
53-
self.lock.unlock()
54-
}
55-
56-
lock.lock()
57-
currentTask = nil
58-
lock.unlock()
59-
60-
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 {
61-
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
62-
Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode): \(body)")
63-
throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines))
64-
}
65-
66-
if isSelect {
67-
return parseTabSeparatedResponse(data)
68-
}
69-
70-
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false)
27+
return try await perform(request: request, session: session)
7128
}
7229

7330
func executeRawWithParams(_ query: String, params: [String: String?], queryId: String? = nil) async throws -> CHQueryResult {
@@ -84,9 +41,39 @@ extension ClickHousePluginDriver {
8441

8542
var request = try buildRequest(query: query, database: database, queryId: queryId, params: params)
8643
request.timeoutInterval = _queryTimeout.requestTimeoutInterval
87-
let isSelect = Self.isSelectLikeQuery(query)
44+
return try await perform(request: request, session: session)
45+
}
46+
47+
private func perform(request: URLRequest, session: URLSession) async throws -> CHQueryResult {
48+
let (data, response) = try await send(request: request, session: session)
49+
50+
lock.lock()
51+
currentTask = nil
52+
lock.unlock()
53+
54+
let httpResponse = response as? HTTPURLResponse
55+
if let httpResponse, httpResponse.statusCode >= 400 {
56+
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
57+
let exceptionCode = httpResponse.value(forHTTPHeaderField: "X-ClickHouse-Exception-Code") ?? "none"
58+
Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode) exception \(exceptionCode): \(body)")
59+
throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines))
60+
}
61+
62+
let outcome = ClickHouseResponseClassifier.classify(
63+
headers: Self.headerFields(httpResponse),
64+
body: data
65+
)
66+
return CHQueryResult(
67+
columns: outcome.columns,
68+
columnTypeNames: outcome.columnTypeNames,
69+
rows: outcome.rows,
70+
affectedRows: outcome.affectedRows,
71+
isTruncated: outcome.isTruncated
72+
)
73+
}
8874

89-
let (data, response) = try await withTaskCancellationHandler {
75+
private func send(request: URLRequest, session: URLSession) async throws -> (Data, URLResponse) {
76+
try await withTaskCancellationHandler {
9077
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in
9178
let task = session.dataTask(with: request) { data, response, error in
9279
if let error {
@@ -99,9 +86,11 @@ extension ClickHousePluginDriver {
9986
}
10087
continuation.resume(returning: (data, response))
10188
}
89+
10290
self.lock.lock()
10391
self.currentTask = task
10492
self.lock.unlock()
93+
10594
task.resume()
10695
}
10796
} onCancel: {
@@ -110,22 +99,16 @@ extension ClickHousePluginDriver {
11099
self.currentTask = nil
111100
self.lock.unlock()
112101
}
102+
}
113103

114-
lock.lock()
115-
currentTask = nil
116-
lock.unlock()
117-
118-
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 {
119-
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
120-
Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode): \(body)")
121-
throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines))
122-
}
123-
124-
if isSelect {
125-
return parseTabSeparatedResponse(data)
104+
private static func headerFields(_ response: HTTPURLResponse?) -> [String: String] {
105+
guard let response else { return [:] }
106+
var fields: [String: String] = [:]
107+
for (key, value) in response.allHeaderFields {
108+
guard let name = key as? String, let text = value as? String else { continue }
109+
fields[name] = text
126110
}
127-
128-
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false)
111+
return fields
129112
}
130113

131114
func buildRequest(query: String, database: String, queryId: String? = nil, params: [String: String?]? = nil) throws -> URLRequest {
@@ -145,14 +128,15 @@ extension ClickHousePluginDriver {
145128
queryItems.append(URLQueryItem(name: "query_id", value: queryId))
146129
}
147130
queryItems.append(URLQueryItem(name: "send_progress_in_http_headers", value: "1"))
131+
queryItems.append(contentsOf: ClickHouseResponseClassifier.transportQueryItems(
132+
supportsWriteExceptionSetting: ClickHouseCapabilities.parse(serverVersion).hasWriteExceptionInOutputFormatSetting
133+
))
148134
if let params {
149135
for (key, value) in params.sorted(by: { $0.key < $1.key }) {
150136
queryItems.append(URLQueryItem(name: "param_\(key)", value: value))
151137
}
152138
}
153-
if !queryItems.isEmpty {
154-
components.queryItems = queryItems
155-
}
139+
components.queryItems = queryItems
156140

157141
guard let url = components.url else {
158142
throw ClickHouseError(message: "Failed to construct request URL")
@@ -170,94 +154,11 @@ extension ClickHousePluginDriver {
170154

171155
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
172156
.replacingOccurrences(of: ";+$", with: "", options: .regularExpression)
173-
174-
if Self.isSelectLikeQuery(trimmedQuery) {
175-
request.httpBody = (trimmedQuery + " FORMAT TabSeparatedWithNamesAndTypes").data(using: .utf8)
176-
} else {
177-
request.httpBody = trimmedQuery.data(using: .utf8)
178-
}
157+
request.httpBody = trimmedQuery.data(using: .utf8)
179158

180159
return request
181160
}
182161

183-
static func isSelectLikeQuery(_ query: String) -> Bool {
184-
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
185-
guard let firstWord = trimmed.split(separator: " ", maxSplits: 1).first else {
186-
return false
187-
}
188-
return selectPrefixes.contains(firstWord.uppercased())
189-
}
190-
191-
func parseTabSeparatedResponse(_ data: Data) -> CHQueryResult {
192-
guard let text = String(data: data, encoding: .utf8), !text.isEmpty else {
193-
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false)
194-
}
195-
196-
let lines = text.components(separatedBy: "\n")
197-
198-
guard lines.count >= 2 else {
199-
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false)
200-
}
201-
202-
let columns = lines[0].components(separatedBy: "\t")
203-
let columnTypes = lines[1].components(separatedBy: "\t")
204-
205-
var rows: [[PluginCellValue]] = []
206-
var truncated = false
207-
for i in 2..<lines.count {
208-
let line = lines[i]
209-
if line.isEmpty { continue }
210-
211-
let fields = line.components(separatedBy: "\t")
212-
let row: [PluginCellValue] = fields.map { field in
213-
if field == "\\N" {
214-
return .null
215-
}
216-
return .text(Self.unescapeTsvField(field))
217-
}
218-
rows.append(row)
219-
if rows.count >= PluginRowLimits.emergencyMax {
220-
truncated = true
221-
break
222-
}
223-
}
224-
225-
return CHQueryResult(
226-
columns: columns,
227-
columnTypeNames: columnTypes,
228-
rows: rows,
229-
affectedRows: rows.count,
230-
isTruncated: truncated
231-
)
232-
}
233-
234-
static func unescapeTsvField(_ field: String) -> String {
235-
var result = ""
236-
result.reserveCapacity((field as NSString).length)
237-
var iterator = field.makeIterator()
238-
239-
while let char = iterator.next() {
240-
if char == "\\" {
241-
if let next = iterator.next() {
242-
switch next {
243-
case "\\": result.append("\\")
244-
case "t": result.append("\t")
245-
case "n": result.append("\n")
246-
default:
247-
result.append("\\")
248-
result.append(next)
249-
}
250-
} else {
251-
result.append("\\")
252-
}
253-
} else {
254-
result.append(char)
255-
}
256-
}
257-
258-
return result
259-
}
260-
261162
/// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params.
262163
static func buildClickHouseParams(
263164
query: String,
@@ -307,5 +208,4 @@ extension ClickHousePluginDriver {
307208

308209
return (converted, paramMap)
309210
}
310-
311211
}

0 commit comments

Comments
 (0)