From 1f1cc8fd113aca8f04edac1d5b85216020ca0119 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 18:21:41 +0700 Subject: [PATCH 1/2] fix(clickhouse): write values too long for the URL into the statement Values stay param_ URL items within the server's 128 KiB field and 1 MiB URL limits, every URL item is percent-encoded (a + arrived as a space), and a body over max_query_size raises it for that request. --- .../PluginParameterEscapingTests.swift | 11 ++ .../ClickHouseParameterBinding.swift | 166 ++++++++++++++---- .../ClickHousePlugin.swift | 29 +-- .../ClickHousePluginDriver+Http.swift | 42 ++++- .../ClickHouseServerProfile.swift | 27 ++- .../ClickHouseParameterBindingTests.swift | 84 +++++++++ .../ClickHouseRequestTests.swift | 92 +++++++++- .../ClickHouseServerProfileTests.swift | 14 ++ .../PluginDatabaseDriver.swift | 25 +-- .../ChangeTracking/SQLWriteBatchBudget.swift | 4 +- .../Core/Compare/CompareSQLLiteral.swift | 5 + .../SQLWriteBatchBudgetTests.swift | 11 ++ .../Core/Compare/CompareDataScopeTests.swift | 19 ++ .../probes/check-clickhouse-long-values.sh | 123 +++++++++++++ 14 files changed, 585 insertions(+), 67 deletions(-) create mode 100755 apple/scripts/probes/check-clickhouse-long-values.sh diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginParameterEscapingTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginParameterEscapingTests.swift index 8715c02a64..9364e0ca04 100644 --- a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginParameterEscapingTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/PluginParameterEscapingTests.swift @@ -144,6 +144,12 @@ struct EscapedParameterValueTests { #expect(driver.escapedParameterValue("a\u{1A}b") == "'a\\Zb'") } + @Test("A quote or backslash a combining mark follows is still escaped") + func combiningMarkAfterQuoteEscaped() { + #expect(driver.escapedParameterValue("x'\u{301} OR 1=1 --") == "'x''\u{301} OR 1=1 --'") + #expect(driver.escapedParameterValue(#"x\"# + "\u{301}") == #"'x\\"# + "\u{301}'") + } + @Test("SQL injection attempt quoted") func sqlInjectionQuoted() { #expect(driver.escapedParameterValue("1 OR 1=1") == "'1 OR 1=1'") @@ -178,6 +184,11 @@ struct SqlStandardEscapeTests { #expect(driver.escapedParameterValue("O'Brien") == "'O''Brien'") } + @Test("A quote a combining mark follows is still doubled") + func combiningMarkAfterQuoteDoubled() { + #expect(driver.escapedParameterValue("x'\u{301} OR 1=1 --") == "'x''\u{301} OR 1=1 --'") + } + @Test("NUL bytes still stripped") func nulBytesStripped() { #expect(driver.escapedParameterValue("a\0b") == "'ab'") diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift index d349976222..1640a00925 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseParameterBinding.swift @@ -7,66 +7,98 @@ import Foundation import TableProPluginKit nonisolated internal enum ClickHouseParameterBinding { - struct Bound { + struct Bound: Equatable { let query: String let params: [String: String?] } + // Measured on 25.8: a `param_` item over 131,072 bytes once percent-encoded, or a URL over 1,048,576 bytes, is + // refused before the query runs (http_max_field_value_size, http_max_uri_size, both from the server's profile). + // Within those the values ride as parameters, which the server keeps out of the SQL text and out of + // max_query_size, so a statement that ran before still runs for a user who may not raise that setting. 4 KiB + // stays for the URL's other items. + static let maxParameterBytes = 131_072 + static let maxParametersBytes = 1_048_576 - 4_096 + // A `{p1:String}` parameter is text, so bytes are inlined as `unhex('...')` instead of compared as hex characters, - // and NULL, which a String parameter cannot hold, is inlined as NULL. + // and NULL, which a String parameter cannot hold, is inlined as NULL. Text too long for the URL is written into the + // statement, which travels in the POST body. static func bind(query: String, parameters: [PluginCellValue]) -> Bound { + let escaped = parameters.map { value -> String? in + guard case .text(let text) = value else { return nil } + return escapedParameterValue(text) + } + let sizes = escaped.compactMap { $0.map(urlEncodedByteCount) } + // Each item also carries `¶m_pN=`, which is most of the URL when a wide batch holds short values. + let itemBytes = sizes.indices.reduce(0) { $0 + "¶m_p\($1 + 1)=".utf8.count } + let fitsURL = sizes.allSatisfy { $0 <= maxParameterBytes } && sizes.reduce(itemBytes, +) <= maxParametersBytes + + // Scalars, not Characters: a combining mark after a quote would merge with it into one Character. + let scalars = Array(query.unicodeScalars) var converted = String.UnicodeScalarView() converted.reserveCapacity(query.utf8.count) var paramMap: [String: String?] = [:] var consumed = 0 var namedCount = 0 - // Scalars, not Characters: a combining mark after a quote would merge with it into one Character. - var openQuote: Unicode.Scalar? - var isEscaped = false + var index = 0 + var firstWord: String? + var isInsertData = false - for scalar in query.unicodeScalars { - if isEscaped { - isEscaped = false - converted.append(scalar) + while index < scalars.count { + let skipped = skippedEnd(in: scalars, from: index) + if skipped > index { + converted.append(contentsOf: scalars[index.. String { + switch value { + case .null: + return "NULL" + case .bytes(let data): + return hexLiteral(data) + case .text(let text): + let quoted = "'\(ClickHousePluginDriver.escapeStringLiteral(text))'" + return isInsertData ? "CAST(\(quoted) AS String)" : quoted + } + } + // The server reads a parameter in its escaped text format: a backslash starts an escape and a raw tab ends the value. static func escapedParameterValue(_ text: String) -> String { var escaped = String.UnicodeScalarView() @@ -83,8 +115,82 @@ nonisolated internal enum ClickHouseParameterBinding { return String(escaped) } + static func urlEncodedByteCount(_ value: String) -> Int { + value.utf8.reduce(0) { total, byte in + total + (ClickHousePluginDriver.isUnreservedURLByte(byte) ? 1 : 3) + } + } + static func hexLiteral(_ data: Data) -> String { let hex = data.map { String(format: "%02X", $0) }.joined() return "unhex('\(hex)')" } + + // The spans SQLParameterExtractor skips when it writes the `?`s, so a `?` or a quote inside a string, a name, a + // comment or a dollar-quoted body is never taken for a placeholder. + private static func skippedEnd(in scalars: [Unicode.Scalar], from start: Int) -> Int { + let next = start + 1 < scalars.count ? scalars[start + 1] : nil + switch scalars[start] { + case "'", "\"", "`": + return quotedEnd(in: scalars, from: start) + case "-" where next == "-": + return scalars[start...].firstIndex(of: "\n") ?? scalars.count + case "/" where next == "*": + return blockCommentEnd(in: scalars, from: start) + case "$": + return dollarQuotedEnd(in: scalars, from: start) ?? start + default: + return start + } + } + + // ClickHouse reads backslash escapes inside all three quote forms. A doubled quote closes the span and opens the next. + private static func quotedEnd(in scalars: [Unicode.Scalar], from start: Int) -> Int { + let quote = scalars[start] + var index = start + 1 + while index < scalars.count { + if scalars[index] == "\\" { + index += 2 + continue + } + if scalars[index] == quote { + return index + 1 + } + index += 1 + } + return scalars.count + } + + private static func blockCommentEnd(in scalars: [Unicode.Scalar], from start: Int) -> Int { + var index = start + 2 + while index + 1 < scalars.count { + if scalars[index] == "*" && scalars[index + 1] == "/" { + return index + 2 + } + index += 1 + } + return scalars.count + } + + // `$$...$$` or `$tag$...$tag$`, the tag made of letters, digits and underscores. An unclosed one runs to the end. + private static func dollarQuotedEnd(in scalars: [Unicode.Scalar], from start: Int) -> Int? { + var tagEnd = start + 1 + while tagEnd < scalars.count, isWordScalar(scalars[tagEnd]) { + tagEnd += 1 + } + guard tagEnd < scalars.count, scalars[tagEnd] == "$" else { return nil } + let tag = Array(scalars[start...tagEnd]) + var index = tagEnd + 1 + while index + tag.count <= scalars.count { + if scalars[index] == "$", Array(scalars[index.. Bool { + scalar == "_" || ("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar) || ("0"..."9").contains(scalar) + } } diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index a5a9d8b7bf..ecafedbda7 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -220,19 +220,24 @@ nonisolated final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Self.escapeStringLiteral(value) } - /// The backslash goes first, or every escape written after it is escaped again. + // Only escapes ClickHouse decodes: it reads an unknown one such as MySQL's `\Z` as a backslash and the letter. static func escapeStringLiteral(_ value: String) -> String { - var result = value - result = result.replacingOccurrences(of: "\\", with: "\\\\", options: .literal) - result = result.replacingOccurrences(of: "'", with: "''", options: .literal) - result = result.replacingOccurrences(of: "\n", with: "\\n", options: .literal) - result = result.replacingOccurrences(of: "\r", with: "\\r", options: .literal) - result = result.replacingOccurrences(of: "\t", with: "\\t", options: .literal) - result = result.replacingOccurrences(of: "\0", with: "\\0", options: .literal) - result = result.replacingOccurrences(of: "\u{08}", with: "\\b", options: .literal) - result = result.replacingOccurrences(of: "\u{0C}", with: "\\f", options: .literal) - result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z", options: .literal) - return result + var escaped = String.UnicodeScalarView() + escaped.reserveCapacity(value.utf8.count) + for scalar in value.unicodeScalars { + switch scalar { + case "\\": escaped.append(contentsOf: #"\\"#.unicodeScalars) + case "'": escaped.append(contentsOf: "''".unicodeScalars) + case "\n": escaped.append(contentsOf: #"\n"#.unicodeScalars) + case "\r": escaped.append(contentsOf: #"\r"#.unicodeScalars) + case "\t": escaped.append(contentsOf: #"\t"#.unicodeScalars) + case "\0": escaped.append(contentsOf: #"\0"#.unicodeScalars) + case "\u{08}": escaped.append(contentsOf: #"\b"#.unicodeScalars) + case "\u{0C}": escaped.append(contentsOf: #"\f"#.unicodeScalars) + default: escaped.append(scalar) + } + } + return String(escaped) } func beginTransaction() async throws {} func commitTransaction() async throws {} diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift index c52eea432d..a0c432ad4c 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift @@ -158,18 +158,20 @@ nonisolated extension ClickHousePluginDriver { executionTimeLimit: Int?, shape: ClickHouseResponseShape ) throws -> URLRequest { + let body = Data(Self.withoutTrailingSemicolons(query).utf8) var components = URLComponents() components.scheme = config.ssl.isEnabled ? "https" : "http" components.host = config.host components.port = config.port components.path = "/" - components.queryItems = Self.queryItems( + components.percentEncodedQueryItems = Self.queryItems( database: database, queryId: queryId, executionTimeLimit: executionTimeLimit, shape: shape, - profile: serverProfile - ) + profile: serverProfile, + statementBytes: body.count + ).map { URLQueryItem(name: Self.percentEncoded($0.name), value: $0.value.map(Self.percentEncoded)) } guard let url = components.url else { throw ClickHouseError(message: "Failed to construct request URL") @@ -190,7 +192,7 @@ nonisolated extension ClickHousePluginDriver { request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") } - request.httpBody = Self.withoutTrailingSemicolons(query).data(using: .utf8) + request.httpBody = body return request } @@ -199,7 +201,8 @@ nonisolated extension ClickHousePluginDriver { queryId: String?, executionTimeLimit: Int?, shape: ClickHouseResponseShape, - profile: ClickHouseServerProfile + profile: ClickHouseServerProfile, + statementBytes: Int = 0 ) -> [URLQueryItem] { var items = [URLQueryItem]() if !database.isEmpty { @@ -242,9 +245,38 @@ nonisolated extension ClickHousePluginDriver { items.append(item) } } + // The server parses at most max_query_size bytes of a statement, and a long bound value is written into it. + if statementBytes > profile.maxQuerySize, let item = profile.queryItem(.maxQuerySize, String(statementBytes)) { + items.append(item) + } return items } + // URLComponents leaves `+` as it is, and the server's form decoding reads it as a space: `a+b` arrived as `a b`. + static func percentEncoded(_ text: String) -> String { + let hex = Array("0123456789ABCDEF".utf8) + var bytes: [UInt8] = [] + bytes.reserveCapacity(text.utf8.count) + for byte in text.utf8 { + if isUnreservedURLByte(byte) { + bytes.append(byte) + } else { + bytes.append(contentsOf: [UInt8(ascii: "%"), hex[Int(byte >> 4)], hex[Int(byte & 0x0F)]]) + } + } + return String(decoding: bytes, as: UTF8.self) + } + + static func isUnreservedURLByte(_ byte: UInt8) -> Bool { + switch byte { + case UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"): + return true + default: + return false + } + } + static func withoutTrailingSemicolons(_ query: String) -> String { var trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) while trimmed.hasSuffix(";") { diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerProfile.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerProfile.swift index b697b7d636..2b079699eb 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerProfile.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerProfile.swift @@ -16,6 +16,10 @@ nonisolated struct ClickHouseServerProfile: Sendable, Equatable { /// The settings this user may change, each with the bounds a settings constraint puts on it. let settingLimits: [ClickHouseURLSetting: ClickHouseSettingLimits] let catalog: Set + // The bytes of a statement the parser reads. A value written into the statement counts; as a URL parameter it did not. + let maxQuerySize: Int + + static let documentedMaxQuerySize = 262_144 /// Before the probes answer, a request carries nothing the server could refuse, so a /// `readonly = 1` user can still connect. @@ -43,22 +47,26 @@ nonisolated struct ClickHouseServerProfile: Sendable, Equatable { init( version: ClickHouseServerVersion?, settingLimits: [ClickHouseURLSetting: ClickHouseSettingLimits], - catalog: Set + catalog: Set, + maxQuerySize: Int = Self.documentedMaxQuerySize ) { self.version = version self.settingLimits = settingLimits self.catalog = catalog + self.maxQuerySize = maxQuerySize } init( version: ClickHouseServerVersion?, writableSettings: Set, - catalog: Set + catalog: Set, + maxQuerySize: Int = Self.documentedMaxQuerySize ) { self.init( version: version, settingLimits: Dictionary(uniqueKeysWithValues: writableSettings.map { ($0, ClickHouseSettingLimits()) }), - catalog: catalog + catalog: catalog, + maxQuerySize: maxQuerySize ) } @@ -73,10 +81,20 @@ nonisolated struct ClickHouseServerProfile: Sendable, Equatable { self.init( version: versionText.flatMap(ClickHouseServerVersion.init), settingLimits: settings.map { Self.settingLimits(columns: $0.columns, rows: $0.rows) } ?? [:], - catalog: catalogRow.map(Self.catalog(row:)) ?? Set(ClickHouseCatalogFeature.allCases) + catalog: catalogRow.map(Self.catalog(row:)) ?? Set(ClickHouseCatalogFeature.allCases), + maxQuerySize: settings.flatMap { Self.maxQuerySize(columns: $0.columns, rows: $0.rows) } + ?? Self.documentedMaxQuerySize ) } + static func maxQuerySize(columns: [String], rows: [[PluginCellValue]]) -> Int? { + guard let nameIndex = columns.firstIndex(of: "name"), let valueIndex = columns.firstIndex(of: "value") else { + return nil + } + let row = rows.first { $0[safe: nameIndex]?.asText == ClickHouseURLSetting.maxQuerySize.rawValue } + return row?[safe: valueIndex]?.asText.flatMap { Int($0) } + } + var writableSettings: Set { Set(settingLimits.keys) } /// The URL item for `value`, or nil when this user may not set it to that. @@ -204,6 +222,7 @@ nonisolated enum ClickHouseURLSetting: String, CaseIterable, Sendable { case resultOverflowMode = "result_overflow_mode" case cancelHTTPReadonlyQueriesOnClientClose = "cancel_http_readonly_queries_on_client_close" case mutationsSync = "mutations_sync" + case maxQuerySize = "max_query_size" } /// System catalog parts the metadata reads use that older servers lack, measured: 19.16 and 20.3 diff --git a/apple/Plugins/ClickHouseDriverTests/ClickHouseParameterBindingTests.swift b/apple/Plugins/ClickHouseDriverTests/ClickHouseParameterBindingTests.swift index 70702bf5dc..1c1a72ccfd 100644 --- a/apple/Plugins/ClickHouseDriverTests/ClickHouseParameterBindingTests.swift +++ b/apple/Plugins/ClickHouseDriverTests/ClickHouseParameterBindingTests.swift @@ -136,4 +136,88 @@ struct ClickHouseParameterBindingTests { let literal = ClickHouseParameterBinding.hexLiteral(Data([0x27, 0x5C, 0x00, 0xFF])) #expect(literal == "unhex('275C00FF')") } + + // #3777, measured on 25.8: a `param_` item over 131,072 encoded bytes got HTTP 500 and a URL over 1 MiB HTTP 400. + @Test("A value too long for the URL writes every value of the statement into it") + func longValueIsWrittenIntoTheStatement() { + let long = String(repeating: "x", count: ClickHouseParameterBinding.maxParameterBytes + 1) + let bound = ClickHouseParameterBinding.bind( + query: "ALTER TABLE t UPDATE s = ?, n = ? WHERE id = ?", + parameters: [.text(long), .null, .text("it's")] + ) + #expect(bound.query == "ALTER TABLE t UPDATE s = '\(long)', n = NULL WHERE id = 'it''s'") + #expect(bound.params.isEmpty) + } + + @Test("Values that fit one by one but not together go into the statement") + func manyValuesOverTheURLBudgetAreWrittenIn() { + let value = String(repeating: "y", count: 120_000) + let parameters = [PluginCellValue](repeating: .text(value), count: 9) + let bound = ClickHouseParameterBinding.bind( + query: "SELECT " + Array(repeating: "?", count: 9).joined(separator: ", "), parameters: parameters + ) + #expect(bound.params.isEmpty) + #expect(bound.query.hasPrefix("SELECT '\(value)', ")) + } + + @Test("The URL budget counts each item's name and separators, which a wide batch of short values is mostly made of") + func urlBudgetCountsItemNames() { + let count = 70_000 + let bound = ClickHouseParameterBinding.bind( + query: "INSERT INTO t VALUES (" + Array(repeating: "?", count: count).joined(separator: "), (") + ")", + parameters: Array(repeating: .text("&"), count: count) + ) + #expect(bound.params.isEmpty) + #expect(bound.query.hasPrefix("INSERT INTO t VALUES (CAST('&' AS String)), ")) + } + + // Percent-encoding triples every byte outside A-Z, a-z, 0-9 and `-._~`, and the server counts the encoded form. + @Test("The URL budget counts percent-encoded bytes") + func urlBudgetCountsEncodedBytes() { + let euros = String(repeating: "\u{20AC}", count: 15_000) + #expect(ClickHouseParameterBinding.urlEncodedByteCount(euros) == 135_000) + #expect(ClickHouseParameterBinding.urlEncodedByteCount("a+b c") == 9) + #expect(ClickHouseParameterBinding.bind(query: "SELECT ?", parameters: [.text(euros)]).params.isEmpty) + } + + // Measured on 25.8: a braced UUID as a plain literal in VALUES was stored as another UUID; the cast refused it. + @Test("Long text in INSERT data is a String cast, and only there") + func longInsertDataIsCast() { + let long = String(repeating: "z", count: 140_000) + let insert = ClickHouseParameterBinding.bind( + query: "INSERT INTO `t` (`a`, `b`, `c`) VALUES (?, ?, ?)", + parameters: [.text(long), .bytes(Data([0x01])), .text("{6f9619ff-8b86-d011-b42d-00c04fc964ff}")] + ) + let identified = ClickHouseParameterBinding.bind(query: "CREATE USER u IDENTIFIED BY ?", parameters: [.text(long)]) + let insertSelect = ClickHouseParameterBinding.bind( + query: "/* copy */ insert into t select * from s where s = ?", parameters: [.text(long)] + ) + #expect(insert.query == "INSERT INTO `t` (`a`, `b`, `c`) VALUES (CAST('\(long)' AS String), unhex('01'), " + + "CAST('{6f9619ff-8b86-d011-b42d-00c04fc964ff}' AS String))") + #expect(identified.query == "CREATE USER u IDENTIFIED BY '\(long)'") + #expect(insertSelect.query == "/* copy */ insert into t select * from s where s = '\(long)'") + } + + // Measured on 25.8: each of these reads back as the text that was bound. + @Test("A quote, backslash or control character cannot end a written-in literal") + func writtenInLiteralsAreEscaped() { + let padding = String(repeating: "p", count: 140_000) + let bound = ClickHouseParameterBinding.bind( + query: "SELECT ?, ?, ?, ?", + parameters: [.text(padding), .text(#"C:\new\table"#), .text("a\tb\nc\rd\u{0}e\u{08}f\u{0C}g\u{1A}"), .text("'\u{301}) OR 1 = 1 --")] + ) + let expected = [padding, #"C:\\new\\table"#, #"a\tb\nc\rd\0e\bf\fg"# + "\u{1A}", "''\u{301}) OR 1 = 1 --"] + #expect(bound.query == "SELECT " + expected.map { "'\($0)'" }.joined(separator: ", ")) + } + + // SQLParameterExtractor writes no `?` inside these, so a `?` or a quote in one is the user's own text. + @Test("A quote or question mark in a comment or a dollar-quoted body is not a placeholder") + func commentsAndDollarQuotesAreSkipped() { + let bound = ClickHouseParameterBinding.bind( + query: "-- customer's orders?\nSELECT /* why? it's */ $$it's ?$$, $t$?$t$, $1 FROM t WHERE id = ?", + parameters: [.text("7")] + ) + #expect(bound.query == "-- customer's orders?\nSELECT /* why? it's */ $$it's ?$$, $t$?$t$, $1 FROM t WHERE id = {p1:String}") + #expect(bound.params == ["p1": "7"]) + } } diff --git a/apple/Plugins/ClickHouseDriverTests/ClickHouseRequestTests.swift b/apple/Plugins/ClickHouseDriverTests/ClickHouseRequestTests.swift index 914a528047..7b8367003d 100644 --- a/apple/Plugins/ClickHouseDriverTests/ClickHouseRequestTests.swift +++ b/apple/Plugins/ClickHouseDriverTests/ClickHouseRequestTests.swift @@ -24,14 +24,16 @@ struct ClickHouseRequestTests { private static func items( _ shape: ClickHouseResponseShape, _ profile: ClickHouseServerProfile, - executionTimeLimit: Int? = nil + executionTimeLimit: Int? = nil, + statementBytes: Int = 0 ) -> [String: String] { let items = ClickHousePluginDriver.queryItems( database: "default", queryId: "q1", executionTimeLimit: executionTimeLimit, shape: shape, - profile: profile + profile: profile, + statementBytes: statementBytes ) return Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { _, last in last }) } @@ -131,6 +133,92 @@ struct ClickHouseRequestTests { #expect(items["param_b"] == "2") } + @Test("A statement longer than max_query_size gets room for exactly itself") + func longStatementGetsItsOwnQuerySize() { + let profile = ClickHouseServerProfile.testing(writableSettings: [.maxQuerySize]) + #expect(Self.items(.buffered(parameters: nil), profile, statementBytes: 300_080)["max_query_size"] == "300080") + #expect(Self.items(.streamed(rowCap: nil), profile, statementBytes: 300_080)["max_query_size"] == "300080") + #expect(Self.items(.buffered(parameters: nil), profile, statementBytes: 262_144)["max_query_size"] == nil) + #expect(Self.items(.buffered(parameters: nil), profile)["max_query_size"] == nil) + } + + @Test("The user's own max_query_size decides when a statement needs more") + func statementComparesWithTheUsersLimit() { + let lowered = ClickHouseServerProfile( + version: nil, writableSettings: [.maxQuerySize], catalog: [], maxQuerySize: 100_000 + ) + let raised = ClickHouseServerProfile( + version: nil, writableSettings: [.maxQuerySize], catalog: [], maxQuerySize: 10_485_760 + ) + #expect(Self.items(.buffered(parameters: nil), lowered, statementBytes: 150_000)["max_query_size"] == "150000") + #expect(Self.items(.buffered(parameters: nil), raised, statementBytes: 1_000_000)["max_query_size"] == nil) + } + + @Test("max_query_size is not sent to a user who may not set it, or above a constraint's max") + func querySizeFollowsTheProfile() { + let constrained = ClickHouseServerProfile( + version: nil, + settingLimits: [.maxQuerySize: ClickHouseSettingLimits(max: 500_000)], + catalog: [] + ) + let buffered = ClickHouseResponseShape.buffered(parameters: nil) + #expect(Self.items(buffered, Self.readonlyUser, statementBytes: 300_000)["max_query_size"] == nil) + #expect(Self.items(buffered, constrained, statementBytes: 600_000)["max_query_size"] == nil) + #expect(Self.items(buffered, constrained, statementBytes: 400_000)["max_query_size"] == "400000") + } + + // #3777: as `param_` items the server refused a value over 128 KiB and a URL over 1 MiB. + @Test("A long bound value travels in the body, never the URL, and the statement says how long it is") + func longBoundValueTravelsInTheBody() async throws { + let server = ClickHouseStubServer() + let driver = server.connectedDriver() + driver.serverProfile = ClickHouseServerProfile.testing(writableSettings: [.maxQuerySize]) + let short = String(repeating: "a", count: 200_000) + let long = String(repeating: "b", count: 300_000) + + _ = try await driver.executeParameterized(query: "INSERT INTO t (s) VALUES (?)", parameters: [.text(short)]) + _ = try await driver.executeParameterized( + query: "ALTER TABLE t UPDATE s = ? WHERE id = ?", parameters: [.text(long), .text("1")] + ) + + let requests = server.requests + #expect(requests.count == 2) + for request in requests { + #expect(!request.queryItems.keys.contains { $0.hasPrefix("param_") }) + } + #expect(requests.first?.body == "INSERT INTO t (s) VALUES (CAST('\(short)' AS String))") + #expect(requests.first?.item("max_query_size") == nil) + let update = "ALTER TABLE t UPDATE s = '\(long)' WHERE id = '1'" + #expect(requests.last?.body == update) + #expect(requests.last?.item("max_query_size") == String(update.utf8.count)) + } + + // A data sync writes its values into the statements it runs, so a long one needs the same room. + @Test("A statement without bound values that is longer than max_query_size gets room too") + func plainStatementGetsItsOwnQuerySize() async throws { + let server = ClickHouseStubServer() + let driver = server.connectedDriver() + driver.serverProfile = ClickHouseServerProfile.testing(writableSettings: [.maxQuerySize]) + let statement = "ALTER TABLE t UPDATE s = '\(String(repeating: "c", count: 300_000))' WHERE id = 1" + + _ = try await driver.execute(query: statement + ";") + + #expect(server.requests.first?.item("max_query_size") == String(statement.utf8.count)) + } + + // Measured on 25.8: `param_p=a+b` arrived as `a b`, since the server decodes a form-encoded `+` as a space. + @Test("A plus sign in a URL value is percent-encoded") + func plusSignIsEncoded() throws { + let driver = ClickHouseStubServer().connectedDriver() + let request = try driver.buildRequest( + query: "SELECT {p1:String}", database: "a+b", queryId: nil, executionTimeLimit: nil, + shape: .buffered(parameters: ["p1": "+1 555 & x=y"]) + ) + let query = try #require(request.url?.absoluteString) + #expect(query.contains("database=a%2Bb")) + #expect(query.contains("param_p1=%2B1%20555%20%26%20x%3Dy")) + } + @Test("An accepted execution time limit rides on both shapes") func executionTimeLimitRidesOnBothShapes() { #expect(Self.items(.buffered(parameters: nil), .bootstrap, executionTimeLimit: 30)["max_execution_time"] == "30") diff --git a/apple/Plugins/ClickHouseDriverTests/ClickHouseServerProfileTests.swift b/apple/Plugins/ClickHouseDriverTests/ClickHouseServerProfileTests.swift index b2e897dd42..e2e8c20b1c 100644 --- a/apple/Plugins/ClickHouseDriverTests/ClickHouseServerProfileTests.swift +++ b/apple/Plugins/ClickHouseDriverTests/ClickHouseServerProfileTests.swift @@ -128,6 +128,20 @@ struct ClickHouseServerProfileTests { #expect(ClickHouseServerProfile.writableSettings(columns: columns, rows: rows).isEmpty) } + @Test("max_query_size is read from the settings probe, and the documented default stands without it") + func maxQuerySizeIsProbed() { + let columns = ClickHouseSettingsRows.columns20 + let rows: [[PluginCellValue]] = [ + [.text("max_query_size"), .text("1048576"), .text("1"), .text(""), .null, .null, .text("0")], + ] + let probed = ClickHouseServerProfile(versionText: "25.8.33.6", settings: (columns, rows), catalogRow: nil) + let unanswered = ClickHouseServerProfile(versionText: "25.8.33.6", settings: nil, catalogRow: nil) + #expect(probed.maxQuerySize == 1_048_576) + #expect(probed.writableSettings.contains(.maxQuerySize)) + #expect(unanswered.maxQuerySize == 262_144) + #expect(ClickHouseServerProfile.bootstrap.maxQuerySize == 262_144) + } + @Test("A settings answer with no name column yields nothing") func settingsWithoutNameColumn() { #expect(ClickHouseServerProfile.writableSettings(columns: ["value"], rows: [[.text("0")]]).isEmpty) diff --git a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 2add9a77e6..ed59378451 100644 --- a/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -1653,32 +1653,33 @@ public extension PluginDatabaseDriver { if Self.isNumericLiteral(value) { return value } - var escaped = "" - escaped.reserveCapacity(value.count + 2) + var escaped = String.UnicodeScalarView() escaped.append("'") let escapeBackslashes = requiresBackslashEscapingInLiterals - for char in value { - switch char { + // Scalars, not Characters: a quote with a combining mark after it is one Character no case matched, so it + // went out single and ended the literal. + for scalar in value.unicodeScalars { + switch scalar { case "'": - escaped.append("''") + escaped.append(contentsOf: "''".unicodeScalars) case "\0": continue case "\\" where escapeBackslashes: - escaped.append("\\\\") + escaped.append(contentsOf: #"\\"#.unicodeScalars) case "\n" where escapeBackslashes: - escaped.append("\\n") + escaped.append(contentsOf: #"\n"#.unicodeScalars) case "\r" where escapeBackslashes: - escaped.append("\\r") + escaped.append(contentsOf: #"\r"#.unicodeScalars) case "\t" where escapeBackslashes: - escaped.append("\\t") + escaped.append(contentsOf: #"\t"#.unicodeScalars) case "\u{1A}" where escapeBackslashes: - escaped.append("\\Z") + escaped.append(contentsOf: #"\Z"#.unicodeScalars) default: - escaped.append(char) + escaped.append(scalar) } } escaped.append("'") - return escaped + return String(escaped) } static func isNumericLiteral(_ value: String) -> Bool { diff --git a/apple/TablePro/Core/ChangeTracking/SQLWriteBatchBudget.swift b/apple/TablePro/Core/ChangeTracking/SQLWriteBatchBudget.swift index 89ebcccbda..054904d2b9 100644 --- a/apple/TablePro/Core/ChangeTracking/SQLWriteBatchBudget.swift +++ b/apple/TablePro/Core/ChangeTracking/SQLWriteBatchBudget.swift @@ -46,9 +46,9 @@ internal struct SQLWriteBatchBudget: Sendable, Equatable { self.rendersValuesAsLiterals = rendersValuesAsLiterals } - /// The engines whose driver inlines a parameter rather than binding it. + // ClickHouse writes the values of a statement too long for the URL into it as escaped literals, binary as hex (#3777). internal static func rendersValuesAsLiterals(_ databaseType: DatabaseType) -> Bool { - databaseType == .databend + databaseType == .databend || databaseType == .clickhouse } /// The engine's bind-parameter ceiling over the row's width, clamped by its multi-row `VALUES` diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index 5dd6b32873..e34f80616d 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -45,6 +45,11 @@ internal enum CompareSQLLiteral { if let columnType, ColumnTypeSQLQuoting.isNumericLiteral(text, for: columnType) { return text } + // ClickHouse reads backslash escapes in every literal. The default left them single, so `C:\new` synced as a + // newline and a value ending in `\'` closed the literal early (#3777). + if databaseType == .clickhouse { + return "'\(driver.escapeStringLiteral(text))'" + } let rendered = driver.sqlLiteral(for: .text(text)) /// A driver that passed the value through unquoted answered for a number, so the value holds /// no quote to escape. Escaping anyway is what keeps that true if a driver ever renders a diff --git a/apple/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift b/apple/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift index cb384b23e4..c4cb992903 100644 --- a/apple/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift +++ b/apple/TableProTests/Core/ChangeTracking/SQLWriteBatchBudgetTests.swift @@ -127,6 +127,17 @@ struct SQLWriteBatchBudgetTests { "two of these exceed a mebibyte of rendered SQL, so the second must close the batch") } + // #3777: 800 rows of an id and 1,100 bytes of text fit the parameter rate, and went out as a 1.2 MB URL. + @Test + func clickHouseIsChargedForTheLiteralsItWrites() throws { + let clickhouse = try SQLWriteBatchBudget(columnCount: 2, generator: generator(.clickhouse)) + let row = [PluginCellValue.text("800"), .text(String(repeating: "'", count: 1_100))] + + #expect(clickhouse.rendersValuesAsLiterals) + #expect(clickhouse.byteCount(of: row) == 12 + 6 + 12 + 2_200) + #expect(SQLWriteBatchBudget.maximumBytes / clickhouse.byteCount(of: row) < 800) + } + /// A multi-byte value is charged its UTF-8 length, not its character count: the packet the /// server measures carries bytes. @Test diff --git a/apple/TableProTests/Core/Compare/CompareDataScopeTests.swift b/apple/TableProTests/Core/Compare/CompareDataScopeTests.swift index d21b5b7088..a1ebedcac0 100644 --- a/apple/TableProTests/Core/Compare/CompareDataScopeTests.swift +++ b/apple/TableProTests/Core/Compare/CompareDataScopeTests.swift @@ -688,6 +688,25 @@ struct CompareSQLLiteralTextTests { for: .text("007"), columnType: .text(rawType: "CHAR(3)"), databaseType: .mysql, driver: driver ) == "'007'") } + + // #3777: the PluginKit default left a backslash single, which ClickHouse reads as an escape. + @Test + func clickHouseTextGoesThroughTheDriversOwnEscaper() { + let clickHouse = BackslashEscapingDriver() + + #expect(CompareSQLLiteral.textLiteral(#"C:\new"#, columnType: nil, databaseType: .clickhouse, driver: clickHouse) + == #"'C:\\new'"#) + #expect(CompareSQLLiteral.textLiteral(#"x\' OR 1=1 --"#, columnType: nil, databaseType: .clickhouse, driver: clickHouse) + == #"'x\\'' OR 1=1 --'"#) + #expect(CompareSQLLiteral.textLiteral("42", columnType: .integer(rawType: "UInt8"), databaseType: .clickhouse, driver: clickHouse) + == "42") + } +} + +private final class BackslashEscapingDriver: PluginDriverStub, @unchecked Sendable { + func escapeStringLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "''") + } } struct DataCompareOptionsDecodingTests { diff --git a/apple/scripts/probes/check-clickhouse-long-values.sh b/apple/scripts/probes/check-clickhouse-long-values.sh new file mode 100755 index 0000000000..22862124e8 --- /dev/null +++ b/apple/scripts/probes/check-clickhouse-long-values.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Check how a ClickHouse server takes long values over HTTP, which decides how the driver sends bound values (#3777). +# +# ClickHouseParameterBinding sends short values as `param_` URL items and writes a statement's values into it once +# they no longer fit, cast to String inside INSERT data; the statement travels in the POST body. A `param_` item over +# http_max_field_value_size (128 KiB, counted percent-encoded) is refused, in the URL and in a multipart form alike, +# and both URL limits come from the server's own profile. A value in the statement counts against max_query_size, +# which the driver raises for that request; INSERT data is not parsed against it. In VALUES a bare literal goes to the +# column's own parser, which stores a braced UUID as a different one, so the cast keeps a String parameter's meaning +# there. A `+` left unencoded in the URL arrives as a space. ClickHousePluginDriver.escapeStringLiteral writes only the +# escapes the server decodes. This checks each of those on a throwaway table. +# +# Usage: +# scripts/probes/check-clickhouse-long-values.sh [url] [user] +# +# Needs curl 7.87 or later. The password, if any, comes from CLICKHOUSE_PASSWORD. Exits 0 when the server behaves as +# the driver expects, 1 when it does not, and 3 when the check could not run. + +set -uo pipefail + +URL="${1:-http://127.0.0.1:8123}" +USER_NAME="${2:-default}" +TABLE="tablepro_probe_long_values" + +CREDENTIALS="$USER_NAME:${CLICKHOUSE_PASSWORD:-}" +CREDENTIALS="${CREDENTIALS//\\/\\\\}" +CREDENTIALS="${CREDENTIALS//\"/\\\"}" +ask() { + printf 'user = "%s"\n' "$CREDENTIALS" | curl -sS --max-time 120 --config - "$@" +} +status_of() { + ask -o /dev/null -w '%{http_code}' "$@" +} + +VERSION="$(ask "$URL/" --data-binary "SELECT version()")" || { + echo "no ClickHouse at $URL for $USER_NAME" >&2 + exit 3 +} + +WORK="$(mktemp -d)" || exit 3 +cleanup() { + ask "$URL/" --data-binary "DROP TABLE IF EXISTS $TABLE" > /dev/null 2>&1 + rm -rf "$WORK" +} +trap cleanup EXIT +cleanup +mkdir -p "$WORK" + +ask "$URL/" --data-binary "CREATE TABLE $TABLE (id UInt8, u UInt64, dt DateTime, arr Array(UInt8), ni Nullable(Int32), s String, uid UUID) ENGINE = MergeTree ORDER BY id" > /dev/null || exit 3 + +repeat() { + head -c "$2" /dev/zero | tr '\0' "$1" +} + +repeat x 200000 > "$WORK/value" +URL_PARAM="$(status_of -G "$URL/" --data-urlencode "query=SELECT length({v:String})" --data-urlencode "param_v@$WORK/value")" +FORM_PARAM="$(status_of "$URL/?query=SELECT%20length(%7Bv:String%7D)" -F "param_v=<$WORK/value")" + +{ printf "INSERT INTO %s (id, s) VALUES (1, '" "$TABLE"; repeat y 2000000; printf "')"; } > "$WORK/insert" +ask "$URL/" --data-binary "@$WORK/insert" > /dev/null +INSERTED="$(ask "$URL/" --data-binary "SELECT length(s) FROM $TABLE WHERE id = 1")" + +{ printf "ALTER TABLE %s UPDATE s = '" "$TABLE"; repeat z 300000; printf "' WHERE id = 1 SETTINGS mutations_sync = 1"; } > "$WORK/update" +UPDATE_BYTES="$(wc -c < "$WORK/update" | tr -d ' ')" +DEFAULT_LIMIT="$(status_of "$URL/" --data-binary "@$WORK/update")" +SIZED="$(status_of "$URL/?max_query_size=$UPDATE_BYTES" --data-binary "@$WORK/update")" +ONE_SHORT="$(status_of "$URL/?max_query_size=$((UPDATE_BYTES - 1))" --data-binary "@$WORK/update")" +UPDATED="$(ask "$URL/" --data-binary "SELECT length(s) FROM $TABLE WHERE id = 1")" + +SUB_ESCAPE="$(ask "$URL/" --data-binary "SELECT hex('\\Z')")" +ESCAPES="$(ask "$URL/" --data-binary "SELECT hex('a''b\\\\c\\0\\b\\f\\n\\r\\t')")" + +cast() { + printf "CAST('%s' AS String)" "$1" +} +ROW="$(cast 18446744073709551615), $(cast '2024-02-29 12:34:56'), $(cast '[1,2,3]'), $(cast 7), $(cast "it''s")" +ask "$URL/" --data-binary "INSERT INTO $TABLE (id, u, dt, arr, ni, s) VALUES (2, $ROW), (3, $ROW)" > /dev/null +PARAMS="{u:String}, {dt:String}, {arr:String}, {ni:String}, {s:String}" +ask "$URL/" --url-query "param_u=18446744073709551615" --url-query "param_dt=2024-02-29 12:34:56" \ + --url-query "param_arr=[1,2,3]" --url-query "param_ni=7" --url-query "param_s=it's" \ + --data-binary "INSERT INTO $TABLE (id, u, dt, arr, ni, s) VALUES (4, $PARAMS), (5, $PARAMS)" > /dev/null +DISTINCT="$(ask "$URL/" --data-binary "SELECT count(DISTINCT (u, dt, arr, ni, s)), count() FROM $TABLE WHERE id BETWEEN 2 AND 5 FORMAT CSV")" + +PLUS_RAW="$(ask "$URL/?param_v=a+b" --data-binary "SELECT {v:String}")" +PLUS_ENCODED="$(ask "$URL/?param_v=a%2Bb" --data-binary "SELECT {v:String}")" +ENCODED_LIMIT="$(status_of "$URL/" --url-query "param_v=$(repeat x 40000 | sed 's/x/€/g')" --data-binary "SELECT length({v:String})")" + +BRACED="{6f9619ff-8b86-d011-b42d-00c04fc964ff}" +BARE_UUID="$(status_of "$URL/" --data-binary "INSERT INTO $TABLE (id, uid) VALUES (6, '$BRACED')")" +CAST_UUID="$(status_of "$URL/" --data-binary "INSERT INTO $TABLE (id, uid) VALUES (7, $(cast "$BRACED"))")" +PARAM_UUID="$(status_of "$URL/" --url-query "param_v=$BRACED" --data-binary "INSERT INTO $TABLE (id, uid) VALUES (8, {v:String})")" + +echo "server: $VERSION" +echo "200,000-byte param_ in the URL: HTTP $URL_PARAM (driver expects 500, Field value too long)" +echo "200,000-byte param_ as a form field: HTTP $FORM_PARAM (driver expects 500, Field value too long)" +echo "2,000,000-byte literal in INSERT VALUES at the default max_query_size: length $INSERTED (driver expects 2000000)" +echo "$UPDATE_BYTES-byte ALTER UPDATE: default HTTP $DEFAULT_LIMIT, max_query_size=$UPDATE_BYTES HTTP $SIZED, one byte less HTTP $ONE_SHORT (driver expects 400, 200, 400)" +echo "value after the sized update: length $UPDATED (driver expects 300000)" +echo "hex of '\\Z': $SUB_ESCAPE (driver expects 5C5A, no escape)" +echo "hex of the escapes the driver writes: $ESCAPES (driver expects 6127625C6300080C0A0D09)" +echo "cast rows and String parameter rows: distinct,count $DISTINCT (driver expects 1,4)" +echo "param_v=a+b reads back as: $PLUS_RAW; a%2Bb as: $PLUS_ENCODED (driver expects a b and a+b)" +echo "40,000 euro signs (120,000 bytes, 360,000 encoded) as a parameter: HTTP $ENCODED_LIMIT (driver expects 500)" +echo "braced UUID into a UUID column: bare literal HTTP $BARE_UUID, cast HTTP $CAST_UUID, parameter HTTP $PARAM_UUID (driver expects 200, 400, 400)" + +STATUS=0 +[ "$URL_PARAM" = "500" ] || STATUS=1 +[ "$FORM_PARAM" = "500" ] || STATUS=1 +[ "$INSERTED" = "2000000" ] || STATUS=1 +[ "$DEFAULT_LIMIT" = "400" ] || STATUS=1 +[ "$SIZED" = "200" ] || STATUS=1 +[ "$ONE_SHORT" = "400" ] || STATUS=1 +[ "$UPDATED" = "300000" ] || STATUS=1 +[ "$SUB_ESCAPE" = "5C5A" ] || STATUS=1 +[ "$ESCAPES" = "6127625C6300080C0A0D09" ] || STATUS=1 +[ "$DISTINCT" = "1,4" ] || STATUS=1 +[ "$PLUS_RAW" = "a b" ] || STATUS=1 +[ "$PLUS_ENCODED" = "a+b" ] || STATUS=1 +[ "$ENCODED_LIMIT" = "500" ] || STATUS=1 +[ "$CAST_UUID" = "$PARAM_UUID" ] || STATUS=1 +[ "$CAST_UUID" = "400" ] || STATUS=1 +exit "$STATUS" From e35f3bbd2faa8fc36bd17de98d3f3262771ce12a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 18:22:11 +0700 Subject: [PATCH 2/2] docs(changelog): add the #3781 fragments --- apple/changelog.d/3781.fixed.1.md | 1 + apple/changelog.d/3781.fixed.2.md | 1 + apple/changelog.d/3781.fixed.3.md | 1 + apple/changelog.d/3781.fixed.md | 1 + apple/changelog.d/3781.security.md | 1 + 5 files changed, 5 insertions(+) create mode 100644 apple/changelog.d/3781.fixed.1.md create mode 100644 apple/changelog.d/3781.fixed.2.md create mode 100644 apple/changelog.d/3781.fixed.3.md create mode 100644 apple/changelog.d/3781.fixed.md create mode 100644 apple/changelog.d/3781.security.md diff --git a/apple/changelog.d/3781.fixed.1.md b/apple/changelog.d/3781.fixed.1.md new file mode 100644 index 0000000000..1cc764cfdc --- /dev/null +++ b/apple/changelog.d/3781.fixed.1.md @@ -0,0 +1 @@ +ClickHouse stored a plus sign in saved, imported or copied text as a space. diff --git a/apple/changelog.d/3781.fixed.2.md b/apple/changelog.d/3781.fixed.2.md new file mode 100644 index 0000000000..a8c52a473d --- /dev/null +++ b/apple/changelog.d/3781.fixed.2.md @@ -0,0 +1 @@ +Data sync to ClickHouse turned backslashes in text into escapes, so `C:\new` arrived with a newline. diff --git a/apple/changelog.d/3781.fixed.3.md b/apple/changelog.d/3781.fixed.3.md new file mode 100644 index 0000000000..413d11de29 --- /dev/null +++ b/apple/changelog.d/3781.fixed.3.md @@ -0,0 +1 @@ +ClickHouse queries with parameters failed when a comment held an apostrophe or a question mark. diff --git a/apple/changelog.d/3781.fixed.md b/apple/changelog.d/3781.fixed.md new file mode 100644 index 0000000000..4d38d81ef8 --- /dev/null +++ b/apple/changelog.d/3781.fixed.md @@ -0,0 +1 @@ +ClickHouse refused to save text over 128 KiB and failed the first batch of a large import. diff --git a/apple/changelog.d/3781.security.md b/apple/changelog.d/3781.security.md new file mode 100644 index 0000000000..d59065027e --- /dev/null +++ b/apple/changelog.d/3781.security.md @@ -0,0 +1 @@ +A quote followed by a combining accent could end a text value early in data sync and on Trino.