diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bbcd227..cf4c4565d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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) +- Fixed ClickHouse INSERT statements always reporting 0 rows affected. (#1886) +- Fixed ClickHouse query exports writing an empty file when the query started with a comment. (#1886) - Fixed a restored window tab sometimes showing a blank name in the tab bar until you switched to it. (#1894) - Fixed the window title not updating right away after saving a query to a file or opening a favorite into the current tab. (#1894) - 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) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift index 73e547270..a9c7bb756 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseCapabilities.swift @@ -15,6 +15,10 @@ struct ClickHouseCapabilities: Sendable, Equatable { major > 19 || (major == 19 && minor >= 17) } + var hasWriteExceptionInOutputFormatSetting: Bool { + major > 23 || (major == 23 && minor >= 8) + } + static func parse(_ version: String?) -> ClickHouseCapabilities { guard let version else { return .unknown } let parts = version.split(separator: ".") diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 6e4433bfc..81dc15a5f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -149,10 +149,6 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePluginDriver") - static let selectPrefixes: Set = [ - "SELECT", "SHOW", "DESCRIBE", "DESC", "EXISTS", "EXPLAIN", "WITH" - ] - var serverVersion: String? { _serverVersion } var supportsSchemas: Bool { false } var supportsTransactions: Bool { false } @@ -604,9 +600,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if !database.isEmpty { queryItems.append(URLQueryItem(name: "database", value: database)) } - if !queryItems.isEmpty { - components.queryItems = queryItems - } + queryItems.append(URLQueryItem(name: "default_format", value: "JSONEachRow")) + components.queryItems = queryItems guard let url = components.url else { throw ClickHouseError(message: "Failed to construct request URL") @@ -622,7 +617,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { request.setValue(authorization, forHTTPHeaderField: "Authorization") } - request.httpBody = (query + " FORMAT JSONEachRow").data(using: .utf8) + request.httpBody = query.data(using: .utf8) return request } diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift index 6f091443b..ea0166c08 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift @@ -24,50 +24,7 @@ extension ClickHousePluginDriver { var request = try buildRequest(query: query, database: database, queryId: queryId) request.timeoutInterval = _queryTimeout.requestTimeoutInterval - let isSelect = Self.isSelectLikeQuery(query) - - let (data, response) = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in - let task = session.dataTask(with: request) { data, response, error in - if let error { - continuation.resume(throwing: error) - return - } - guard let data, let response else { - continuation.resume(throwing: ClickHouseError(message: "Empty response from server")) - return - } - continuation.resume(returning: (data, response)) - } - - self.lock.lock() - self.currentTask = task - self.lock.unlock() - - task.resume() - } - } onCancel: { - self.lock.lock() - self.currentTask?.cancel() - self.currentTask = nil - self.lock.unlock() - } - - lock.lock() - currentTask = nil - lock.unlock() - - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { - let body = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode): \(body)") - throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - if isSelect { - return parseTabSeparatedResponse(data) - } - - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) + return try await perform(request: request, session: session) } func executeRawWithParams(_ query: String, params: [String: String?], queryId: String? = nil) async throws -> CHQueryResult { @@ -84,9 +41,39 @@ extension ClickHousePluginDriver { var request = try buildRequest(query: query, database: database, queryId: queryId, params: params) request.timeoutInterval = _queryTimeout.requestTimeoutInterval - let isSelect = Self.isSelectLikeQuery(query) + return try await perform(request: request, session: session) + } + + private func perform(request: URLRequest, session: URLSession) async throws -> CHQueryResult { + let (data, response) = try await send(request: request, session: session) + + lock.lock() + currentTask = nil + lock.unlock() + + let httpResponse = response as? HTTPURLResponse + if let httpResponse, httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + let exceptionCode = httpResponse.value(forHTTPHeaderField: "X-ClickHouse-Exception-Code") ?? "none" + Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode) exception \(exceptionCode): \(body)") + throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + let outcome = ClickHouseResponseClassifier.classify( + headers: Self.headerFields(httpResponse), + body: data + ) + return CHQueryResult( + columns: outcome.columns, + columnTypeNames: outcome.columnTypeNames, + rows: outcome.rows, + affectedRows: outcome.affectedRows, + isTruncated: outcome.isTruncated + ) + } - let (data, response) = try await withTaskCancellationHandler { + private func send(request: URLRequest, session: URLSession) async throws -> (Data, URLResponse) { + try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in let task = session.dataTask(with: request) { data, response, error in if let error { @@ -99,9 +86,11 @@ extension ClickHousePluginDriver { } continuation.resume(returning: (data, response)) } + self.lock.lock() self.currentTask = task self.lock.unlock() + task.resume() } } onCancel: { @@ -110,22 +99,16 @@ extension ClickHousePluginDriver { self.currentTask = nil self.lock.unlock() } + } - lock.lock() - currentTask = nil - lock.unlock() - - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { - let body = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode): \(body)") - throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - if isSelect { - return parseTabSeparatedResponse(data) + private static func headerFields(_ response: HTTPURLResponse?) -> [String: String] { + guard let response else { return [:] } + var fields: [String: String] = [:] + for (key, value) in response.allHeaderFields { + guard let name = key as? String, let text = value as? String else { continue } + fields[name] = text } - - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) + return fields } func buildRequest(query: String, database: String, queryId: String? = nil, params: [String: String?]? = nil) throws -> URLRequest { @@ -145,14 +128,15 @@ extension ClickHousePluginDriver { queryItems.append(URLQueryItem(name: "query_id", value: queryId)) } queryItems.append(URLQueryItem(name: "send_progress_in_http_headers", value: "1")) + queryItems.append(contentsOf: ClickHouseResponseClassifier.transportQueryItems( + supportsWriteExceptionSetting: ClickHouseCapabilities.parse(serverVersion).hasWriteExceptionInOutputFormatSetting + )) if let params { for (key, value) in params.sorted(by: { $0.key < $1.key }) { queryItems.append(URLQueryItem(name: "param_\(key)", value: value)) } } - if !queryItems.isEmpty { - components.queryItems = queryItems - } + components.queryItems = queryItems guard let url = components.url else { throw ClickHouseError(message: "Failed to construct request URL") @@ -170,94 +154,11 @@ extension ClickHousePluginDriver { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: ";+$", with: "", options: .regularExpression) - - if Self.isSelectLikeQuery(trimmedQuery) { - request.httpBody = (trimmedQuery + " FORMAT TabSeparatedWithNamesAndTypes").data(using: .utf8) - } else { - request.httpBody = trimmedQuery.data(using: .utf8) - } + request.httpBody = trimmedQuery.data(using: .utf8) return request } - static func isSelectLikeQuery(_ query: String) -> Bool { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard let firstWord = trimmed.split(separator: " ", maxSplits: 1).first else { - return false - } - return selectPrefixes.contains(firstWord.uppercased()) - } - - func parseTabSeparatedResponse(_ data: Data) -> CHQueryResult { - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) - } - - let lines = text.components(separatedBy: "\n") - - guard lines.count >= 2 else { - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) - } - - let columns = lines[0].components(separatedBy: "\t") - let columnTypes = lines[1].components(separatedBy: "\t") - - var rows: [[PluginCellValue]] = [] - var truncated = false - for i in 2..= PluginRowLimits.emergencyMax { - truncated = true - break - } - } - - return CHQueryResult( - columns: columns, - columnTypeNames: columnTypes, - rows: rows, - affectedRows: rows.count, - isTruncated: truncated - ) - } - - static func unescapeTsvField(_ field: String) -> String { - var result = "" - result.reserveCapacity((field as NSString).length) - var iterator = field.makeIterator() - - while let char = iterator.next() { - if char == "\\" { - if let next = iterator.next() { - switch next { - case "\\": result.append("\\") - case "t": result.append("\t") - case "n": result.append("\n") - default: - result.append("\\") - result.append(next) - } - } else { - result.append("\\") - } - } else { - result.append(char) - } - } - - return result - } - /// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params. static func buildClickHouseParams( query: String, @@ -307,5 +208,4 @@ extension ClickHousePluginDriver { return (converted, paramMap) } - } diff --git a/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift b/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift new file mode 100644 index 000000000..fb60bc662 --- /dev/null +++ b/Plugins/TableProPluginKit/ClickHouseResponseClassifier.swift @@ -0,0 +1,164 @@ +import Foundation + +public enum ClickHouseResponseClassifier { + public static let requestedFormat = "TabSeparatedWithNamesAndTypes" + + public struct Outcome: Equatable, Sendable { + public let columns: [String] + public let columnTypeNames: [String] + public let rows: [[PluginCellValue]] + public let affectedRows: Int + public let isTruncated: Bool + } + + private static let formatHeaderName = "x-clickhouse-format" + private static let summaryHeaderName = "x-clickhouse-summary" + private static let rawBodyByteCap = 1_048_576 + + public static func transportQueryItems(supportsWriteExceptionSetting: Bool) -> [URLQueryItem] { + var items = [ + URLQueryItem(name: "default_format", value: requestedFormat), + URLQueryItem(name: "wait_end_of_query", value: "1") + ] + if supportsWriteExceptionSetting { + items.append(URLQueryItem(name: "http_write_exception_in_output_format", value: "0")) + } + return items + } + + public static func classify( + headers: [String: String], + body: Data, + rowLimit: Int = PluginRowLimits.emergencyMax + ) -> Outcome { + guard !body.isEmpty else { + return noResultSetOutcome(headers: headers) + } + if let format = headerValue(headers, named: formatHeaderName), format != requestedFormat { + return rawOutcome(body: body) + } + let text = decodedText(body) + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return noResultSetOutcome(headers: headers) + } + let lines = text.components(separatedBy: "\n") + guard lines.count >= 2 else { + return rawOutcome(body: body) + } + return tabSeparatedOutcome(lines: lines, rowLimit: rowLimit) + } + + public static func affectedRowsFromSummary(headers: [String: String]) -> Int { + guard let summary = headerValue(headers, named: summaryHeaderName), + let data = summary.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return 0 + } + return intValue(object["written_rows"]) ?? 0 + } + + public static func unescapeTsvField(_ field: String) -> String { + var result = "" + result.reserveCapacity((field as NSString).length) + var iterator = field.makeIterator() + + while let char = iterator.next() { + if char == "\\" { + if let next = iterator.next() { + switch next { + case "\\": result.append("\\") + case "t": result.append("\t") + case "n": result.append("\n") + default: + result.append("\\") + result.append(next) + } + } else { + result.append("\\") + } + } else { + result.append(char) + } + } + + return result + } + + private static func noResultSetOutcome(headers: [String: String]) -> Outcome { + Outcome( + columns: [], + columnTypeNames: [], + rows: [], + affectedRows: affectedRowsFromSummary(headers: headers), + isTruncated: false + ) + } + + private static func rawOutcome(body: Data) -> Outcome { + let isTruncated = body.count > rawBodyByteCap + let text = decodedText(body.prefix(rawBodyByteCap)) + return Outcome( + columns: [String(localized: "Output")], + columnTypeNames: ["String"], + rows: [[.text(text)]], + affectedRows: 1, + isTruncated: isTruncated + ) + } + + private static func tabSeparatedOutcome(lines: [String], rowLimit: Int) -> Outcome { + let columns = lines[0].components(separatedBy: "\t") + let columnTypeNames = lines[1].components(separatedBy: "\t") + + var rows: [[PluginCellValue]] = [] + var isTruncated = false + for index in 2..= rowLimit { + isTruncated = true + break + } + } + + return Outcome( + columns: columns, + columnTypeNames: columnTypeNames, + rows: rows, + affectedRows: rows.count, + isTruncated: isTruncated + ) + } + + private static func decodedText(_ data: Data) -> String { + for suffixLength in 0...3 where data.count >= suffixLength { + if let text = String(bytes: data.dropLast(suffixLength), encoding: .utf8) { + return text + } + } + return String(bytes: data, encoding: .isoLatin1) ?? "" + } + + private static func headerValue(_ headers: [String: String], named name: String) -> String? { + if let exact = headers[name] { + return exact + } + return headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } + + private static func intValue(_ value: Any?) -> Int? { + if let number = value as? NSNumber { + return number.intValue + } + if let text = value as? String { + return Int(text) + } + return nil + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index a249ff596..126d317d1 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -508,6 +508,7 @@ 5A863000E00000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "TableProTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + ClickHouseCapabilities.swift, ClickHouseCredentials.swift, ClickHouseTableOperations.swift, ); diff --git a/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift b/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift index efc73a7c2..e41d9e454 100644 --- a/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift +++ b/TableProTests/Core/ClickHouse/ClickHouseConnectionTests.swift @@ -3,7 +3,6 @@ // TableProTests // // Tests for ClickHouse TSV parsing and query escaping fixes. -// These validate the TSV unescaping logic used by the ClickHouse plugin. // import Foundation @@ -12,34 +11,8 @@ import Testing @Suite("ClickHouse Connection") struct ClickHouseConnectionTests { - - /// Local copy of the TSV unescaping logic for testing purposes. - /// The actual implementation lives in the ClickHouseDriver plugin. private static func unescapeTsvField(_ field: String) -> String { - var result = "" - result.reserveCapacity((field as NSString).length) - var iterator = field.makeIterator() - - while let char = iterator.next() { - if char == "\\" { - if let next = iterator.next() { - switch next { - case "\\": result.append("\\") - case "t": result.append("\t") - case "n": result.append("\n") - default: - result.append("\\") - result.append(next) - } - } else { - result.append("\\") - } - } else { - result.append(char) - } - } - - return result + ClickHouseResponseClassifier.unescapeTsvField(field) } // MARK: - TSV Field Unescaping diff --git a/TableProTests/Plugins/ClickHouseCapabilitiesTests.swift b/TableProTests/Plugins/ClickHouseCapabilitiesTests.swift new file mode 100644 index 000000000..5c4d75a1d --- /dev/null +++ b/TableProTests/Plugins/ClickHouseCapabilitiesTests.swift @@ -0,0 +1,25 @@ +// +// ClickHouseCapabilitiesTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("ClickHouse Capabilities") +struct ClickHouseCapabilitiesTests { + @Test("The write-exception setting needs ClickHouse 23.8 or later") + func writeExceptionSettingGate() { + #expect(!ClickHouseCapabilities.parse("23.7").hasWriteExceptionInOutputFormatSetting) + #expect(ClickHouseCapabilities.parse("23.8").hasWriteExceptionInOutputFormatSetting) + #expect(ClickHouseCapabilities.parse("23.8.1.94").hasWriteExceptionInOutputFormatSetting) + #expect(ClickHouseCapabilities.parse("24.1").hasWriteExceptionInOutputFormatSetting) + #expect(!ClickHouseCapabilities.parse("19.17").hasWriteExceptionInOutputFormatSetting) + } + + @Test("An unknown server version is treated as unsupported") + func unknownVersionIsUnsupported() { + #expect(!ClickHouseCapabilities.parse(nil).hasWriteExceptionInOutputFormatSetting) + #expect(!ClickHouseCapabilities.parse("garbage").hasWriteExceptionInOutputFormatSetting) + } +} diff --git a/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift b/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift new file mode 100644 index 000000000..0aeabd04c --- /dev/null +++ b/TableProTests/Plugins/ClickHouseResponseClassifierTests.swift @@ -0,0 +1,189 @@ +// +// ClickHouseResponseClassifierTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("ClickHouse Response Classifier") +struct ClickHouseResponseClassifierTests { + private let matchingFormatHeaders = ["X-ClickHouse-Format": "TabSeparatedWithNamesAndTypes"] + + private func classify( + headers: [String: String] = [:], + bodyText: String, + rowLimit: Int = PluginRowLimits.emergencyMax + ) -> ClickHouseResponseClassifier.Outcome { + ClickHouseResponseClassifier.classify( + headers: headers, + body: Data(bodyText.utf8), + rowLimit: rowLimit + ) + } + + // MARK: - No Result Set vs Empty Result Set + + @Test("An empty body is a statement without a result set") + func emptyBodyHasNoResultSet() { + let outcome = ClickHouseResponseClassifier.classify(headers: [:], body: Data()) + #expect(outcome.columns.isEmpty) + #expect(outcome.rows.isEmpty) + #expect(outcome.affectedRows == 0) + #expect(!outcome.isTruncated) + } + + @Test("An empty body reports written rows from the summary header") + func emptyBodyReadsWrittenRowsFromSummary() { + let headers = ["X-ClickHouse-Summary": #"{"read_rows":"0","written_rows":"42","total_rows_to_read":"0"}"#] + let outcome = ClickHouseResponseClassifier.classify(headers: headers, body: Data()) + #expect(outcome.columns.isEmpty) + #expect(outcome.affectedRows == 42) + } + + @Test("An all-zero summary reports zero affected rows") + func emptyBodyWithZeroSummary() { + let headers = ["X-ClickHouse-Summary": #"{"read_rows":"0","written_rows":"0"}"#] + let outcome = ClickHouseResponseClassifier.classify(headers: headers, body: Data()) + #expect(outcome.affectedRows == 0) + } + + @Test("Read rows never count as affected rows") + func readRowsAreNotAffectedRows() { + let headers = ["X-ClickHouse-Summary": #"{"read_rows":"5000000","written_rows":"0"}"#] + let outcome = ClickHouseResponseClassifier.classify(headers: headers, body: Data()) + #expect(outcome.affectedRows == 0) + } + + @Test("A zero-row result keeps its column headers") + func zeroRowResultKeepsColumns() { + let outcome = classify(headers: matchingFormatHeaders, bodyText: "id\ttitle\nInt32\tString\n") + #expect(outcome.columns == ["id", "title"]) + #expect(outcome.columnTypeNames == ["Int32", "String"]) + #expect(outcome.rows.isEmpty) + #expect(outcome.affectedRows == 0) + } + + // MARK: - Row-Returning Results + + @Test("A result with rows parses columns, types, and cells") + func resultWithRowsParses() { + let outcome = classify( + headers: matchingFormatHeaders, + bodyText: "id\tname\nUInt64\tString\n1\talpha\n2\tbeta\n" + ) + #expect(outcome.columns == ["id", "name"]) + #expect(outcome.columnTypeNames == ["UInt64", "String"]) + #expect(outcome.rows == [[.text("1"), .text("alpha")], [.text("2"), .text("beta")]]) + #expect(outcome.affectedRows == 2) + #expect(!outcome.isTruncated) + } + + @Test("A response without a format header still parses as TSV") + func missingFormatHeaderParsesAsTsv() { + let outcome = classify(bodyText: "x\nUInt8\n1\n") + #expect(outcome.columns == ["x"]) + #expect(outcome.rows == [[.text("1")]]) + } + + @Test("Null markers decode as null cells") + func nullMarkerDecodesAsNull() { + let outcome = classify(headers: matchingFormatHeaders, bodyText: "v\nNullable(String)\n\\N\n") + #expect(outcome.rows == [[.null]]) + } + + @Test("Escaped fields are unescaped") + func escapedFieldsAreUnescaped() { + let outcome = classify(headers: matchingFormatHeaders, bodyText: "v\nString\na\\tb\\nc\\\\d\n") + #expect(outcome.rows == [[.text("a\tb\nc\\d")]]) + } + + @Test("Rows beyond the limit are dropped and marked truncated") + func rowLimitTruncates() { + let outcome = classify( + headers: matchingFormatHeaders, + bodyText: "n\nUInt8\n1\n2\n3\n4\n5\n", + rowLimit: 3 + ) + #expect(outcome.rows.count == 3) + #expect(outcome.isTruncated) + } + + // MARK: - User-Supplied FORMAT Clause + + @Test("A different response format wraps the raw body instead of discarding it") + func mismatchedFormatWrapsRawBody() { + let body = #"{"meta":[{"name":"1","type":"UInt8"}],"data":[[1]],"rows":1}"# + let outcome = classify(headers: ["X-ClickHouse-Format": "JSON"], bodyText: body) + #expect(outcome.columns.count == 1) + #expect(outcome.columnTypeNames == ["String"]) + #expect(outcome.rows == [[.text(body)]]) + #expect(outcome.affectedRows == 1) + #expect(!outcome.isTruncated) + } + + @Test("A format header with different casing is still matched") + func formatHeaderLookupIsCaseInsensitive() { + let outcome = classify(headers: ["x-clickhouse-format": "CSV"], bodyText: "1,alpha\n") + #expect(outcome.columnTypeNames == ["String"]) + #expect(outcome.rows == [[.text("1,alpha\n")]]) + } + + @Test("An oversized raw body is byte-capped and marked truncated") + func oversizedRawBodyIsCapped() { + let body = String(repeating: "x", count: 1_048_576 + 10) + let outcome = classify(headers: ["X-ClickHouse-Format": "Pretty"], bodyText: body) + #expect(outcome.isTruncated) + let cell = outcome.rows[0][0].asText + #expect(cell?.count == 1_048_576) + } + + // MARK: - Malformed Bodies Never Vanish + + @Test("A single-line body falls back to raw output instead of empty columns") + func singleLineBodyFallsBackToRaw() { + let outcome = classify(bodyText: "1") + #expect(outcome.columns.count == 1) + #expect(outcome.rows == [[.text("1")]]) + } + + @Test("A whitespace-only body is a statement without a result set") + func whitespaceOnlyBodyHasNoResultSet() { + let outcome = classify(bodyText: "\n") + #expect(outcome.columns.isEmpty) + #expect(outcome.rows.isEmpty) + } + + // MARK: - Summary Header Parsing + + @Test("A malformed summary header reports zero without throwing") + func malformedSummaryReportsZero() { + #expect(ClickHouseResponseClassifier.affectedRowsFromSummary(headers: ["X-ClickHouse-Summary": "not json"]) == 0) + #expect(ClickHouseResponseClassifier.affectedRowsFromSummary(headers: [:]) == 0) + } + + @Test("A numeric summary value is accepted alongside the string form") + func numericSummaryValueIsAccepted() { + let headers = ["X-ClickHouse-Summary": #"{"written_rows":7}"#] + #expect(ClickHouseResponseClassifier.affectedRowsFromSummary(headers: headers) == 7) + } + + // MARK: - Transport Query Items + + @Test("Transport items pin the format and buffer the response") + func transportItemsPinFormatAndBuffer() { + let items = ClickHouseResponseClassifier.transportQueryItems(supportsWriteExceptionSetting: true) + #expect(items.contains(URLQueryItem(name: "default_format", value: "TabSeparatedWithNamesAndTypes"))) + #expect(items.contains(URLQueryItem(name: "wait_end_of_query", value: "1"))) + #expect(items.contains(URLQueryItem(name: "http_write_exception_in_output_format", value: "0"))) + } + + @Test("Older servers are not sent the write-exception setting") + func olderServersOmitWriteExceptionSetting() { + let items = ClickHouseResponseClassifier.transportQueryItems(supportsWriteExceptionSetting: false) + #expect(items.contains(URLQueryItem(name: "default_format", value: "TabSeparatedWithNamesAndTypes"))) + #expect(items.contains(URLQueryItem(name: "wait_end_of_query", value: "1"))) + #expect(!items.contains { $0.name == "http_write_exception_in_output_format" }) + } +} diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index 7505b4d82..5d8348806 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -97,3 +97,4 @@ Export query results or table data to CSV, JSON, and other formats. Import data - Structure editing supports add, modify, and drop columns, plus data-skipping indexes. Foreign keys and primary key changes are not available - UPDATE/DELETE run as asynchronous background mutations via `ALTER TABLE` - ClickHouse is designed for batch inserts, not single-row writes +- A query with its own `FORMAT` clause (for example `SELECT 1 FORMAT JSON`) shows the server's raw output in a single column instead of a parsed table