Skip to content

Commit 2b9e4cc

Browse files
committed
Escape special characters in Enum/DateTime type names (encode + decode)
ClickHouse renders string literals inside type names — Enum labels (`Enum8('foo' = 1)`) and DateTime/DateTime64 timezones (`DateTime('Europe/Amsterdam')`) — with C-style escape sequences for control and grammar-significant bytes. Previously `clickhouse-cpp` neither produced these escapes when generating a type name (`Type::GetName()`) nor decoded them when parsing a type string, so any label/timezone containing `'`, `\`, or a control byte could be corrupted or mis-parsed. This PR makes escaping symmetric in both directions for the following bytes: | byte | escaped | |------|---------| | `0x00` NUL | `\0` | | `0x08` BS | `\b` | | `0x09` TAB | `\t` | | `0x0A` LF | `\n` | | `0x0C` FF | `\f` | | `0x0D` CR | `\r` | | `0x5C` `\` | `\\` | | `0x27` `'` | `\'` | **Decode (`clickhouse/types/type_parser.cpp`)** - The single-quoted-string lexer now unescapes escape sequences instead of copying bytes verbatim, via a new `kUnescapeMap` plus explicit handling of `\'` and `\0`. **Encode (`clickhouse/types/types.cpp`)** - New `EscapeStringLiteral()` helper backed by `kEscapeMap`. - Applied in `EnumType::GetName()`, `DateTimeType::GetName()`, and `DateTime64Type::GetName()`. - **Tuples are intentionally out of scope.** Tuple field-name identifiers use a different quoting scheme (backticks) and were reverted to the existing implementation; only a benign non-escape tuple round-trip test is included. - **DateTime/DateTime64 escaping is client-side only in practice.** The server validates timezone names against the tz database, so a timezone containing escape sequences can't be stored server-side; the escape/unescape logic is still implemented and unit-tested for correctness.
1 parent faf632e commit 2b9e4cc

7 files changed

Lines changed: 381 additions & 18 deletions

File tree

clickhouse/types/type_parser.cpp

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <cmath>
88
#include <map>
99
#include <mutex>
10+
#include <array>
1011
#include <unordered_map>
1112

1213
#if defined _win_
@@ -76,6 +77,17 @@ static const std::unordered_map<std::string, Type::Code> kTypeCode = {
7677
{ "JSON", Type::JSON },
7778
};
7879

80+
static constexpr auto kUnescapeMap = [](){
81+
std::array<char, 256> m{};
82+
m['\\'] = '\\';
83+
m['b'] = '\b';
84+
m['f'] = '\f';
85+
m['n'] = '\n';
86+
m['r'] = '\r';
87+
m['t'] = '\t';
88+
return m;
89+
}();
90+
7991
template <typename L, typename R>
8092
inline int CompateStringsCaseInsensitive(const L& left, const R& right) {
8193
int64_t size_diff = left.size() - right.size();
@@ -247,19 +259,30 @@ TypeParser::Token TypeParser::NextToken() {
247259
return Token{Token::Comma, StringView(cur_++, 1)};
248260
case '\'':
249261
{
250-
const auto end_quote_length = 1;
251-
const StringView end_quote{cur_, end_quote_length};
252-
// Fast forward to the closing quote.
253-
const auto start = cur_++;
254-
for (; cur_ < end_ - end_quote_length; ++cur_) {
255-
// TODO (nemkov): handle escaping ?
256-
if (end_quote == StringView{cur_, end_quote_length}) {
257-
cur_ += end_quote_length;
258-
259-
return Token{Token::QuotedString, StringView{start, cur_}};
262+
scratch_.clear();
263+
scratch_ += *(cur_++); // quotes must be included
264+
for (; end_ - cur_ > 1; ++cur_) {
265+
if (*cur_ == '\\' && end_ - cur_ > 1 && kUnescapeMap[(uint8_t)*(cur_ + 1)] != 0) {
266+
scratch_ += kUnescapeMap[(uint8_t)*(cur_ + 1)];
267+
++cur_;
268+
}
269+
else if (*cur_ == '\\' && end_ - cur_ > 1 && *(cur_ + 1) == '\'') {
270+
scratch_ += '\'';
271+
++cur_;
272+
}
273+
else if (*cur_ == '\\' && end_ - cur_ > 1 && *(cur_ + 1) == '0') {
274+
scratch_ += '\0';
275+
++cur_;
276+
}
277+
else if ('\'' == *cur_) {
278+
scratch_ += *(cur_++);
279+
return Token{Token::QuotedString, StringView(scratch_)};
280+
}
281+
else {
282+
scratch_ += *cur_;
260283
}
261284
}
262-
return Token{Token::QuotedString, StringView(cur_++, 1)};
285+
return Token{Token::QuotedString, StringView(scratch_)};
263286
}
264287
case '"':
265288
case '`':

clickhouse/types/type_parser.h

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class TypeParser {
7777
bool Parse(TypeAst* type);
7878

7979
private:
80+
// WARNING: The StringView in this token is only valid until the next NextToken() call.
8081
Token NextToken();
8182

8283
private:
@@ -85,10 +86,11 @@ class TypeParser {
8586

8687
TypeAst* type_;
8788
std::stack<TypeAst*> open_elements_;
88-
// Backing storage for unescaped QuotedIdentifier token values. When a
89-
// quoted identifier contains escape sequences the unescaped content is
90-
// written here and the returned StringView points into this string.
91-
// Valid only until the next NextToken() call.
89+
90+
// Backing storage for identifiers with processed escape sequences. When a
91+
// identifier contains escape sequences the cleaned content is written here and
92+
// the returned StringView points into this scratch.
93+
// WARNING: Valid only until the next NextToken() call.
9294
std::string scratch_;
9395
};
9496

clickhouse/types/types.cpp

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,24 @@
55
#include <city.h>
66

77
#include <cassert>
8+
#include <array>
89

910
namespace clickhouse {
1011

12+
static constexpr auto kEscapeMap = []{
13+
std::array<char, 256> m{};
14+
m['\''] = '\'';
15+
m['\0'] = '0';
16+
m['\\'] = '\\';
17+
m['\b'] = 'b';
18+
m['\f'] = 'f';
19+
m['\n'] = 'n';
20+
m['\r'] = 'r';
21+
m['\t'] = 't';
22+
m['\t'] = 't';
23+
return m;
24+
}();
25+
1126
Type::Type(const Code code)
1227
: code_(code)
1328
, type_unique_id_(0)
@@ -304,6 +319,22 @@ DecimalType::DecimalType(size_t precision, size_t scale)
304319
// TODO: assert(precision <= 38 && precision > 0);
305320
}
306321

322+
static std::string EscapeStringLiteral(std::string_view in)
323+
{
324+
std::string ret{};
325+
ret.reserve(in.size());
326+
for (char c : in) {
327+
if (kEscapeMap[(uint8_t)c] != 0) {
328+
ret.push_back('\\');
329+
ret.push_back(kEscapeMap[(uint8_t)c]);
330+
}
331+
else {
332+
ret.push_back(c);
333+
}
334+
}
335+
return ret;
336+
}
337+
307338
std::string DecimalType::GetName() const {
308339
switch (GetCode()) {
309340
case Decimal:
@@ -340,7 +371,7 @@ std::string EnumType::GetName() const {
340371

341372
for (auto ei = value_to_name_.begin(); ei != value_to_name_.end();) {
342373
result += "'";
343-
result += ei->second;
374+
result += EscapeStringLiteral(ei->second);
344375
result += "' = ";
345376
result += std::to_string(ei->first);
346377

@@ -413,7 +444,7 @@ std::string DateTimeType::GetName() const {
413444
std::string datetime_representation = "DateTime";
414445
const auto & timezone = Timezone();
415446
if (!timezone.empty())
416-
datetime_representation += "('" + timezone + "')";
447+
datetime_representation += "('" + EscapeStringLiteral(timezone) + "')";
417448

418449
return datetime_representation;
419450
}
@@ -436,7 +467,7 @@ std::string DateTime64Type::GetName() const {
436467

437468
const auto & timezone = Timezone();
438469
if (!timezone.empty()) {
439-
datetime64_representation += ", '" + timezone + "'";
470+
datetime64_representation += ", '" + EscapeStringLiteral(timezone) + "'";
440471
}
441472

442473
datetime64_representation += ")";

ut/CreateColumnByType_ut.cpp

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <clickhouse/columns/date.h>
44
#include <clickhouse/columns/numeric.h>
55
#include <clickhouse/columns/string.h>
6+
#include <clickhouse/columns/tuple.h>
67
#include <clickhouse/columns/json.h>
78

89
#include <gtest/gtest.h>
@@ -50,6 +51,64 @@ TEST(CreateColumnByType, DateTime) {
5051

5152
ASSERT_EQ(CreateColumnByType("DateTime('UTC')")->As<ColumnDateTime>()->Timezone(), "UTC");
5253
ASSERT_EQ(CreateColumnByType("DateTime64(3, 'UTC')")->As<ColumnDateTime64>()->Timezone(), "UTC");
54+
ASSERT_EQ(CreateColumnByType("DateTime('Etc/Can\\'t')")->As<ColumnDateTime>()->Timezone(), "Etc/Can't");
55+
ASSERT_EQ(CreateColumnByType("DateTime64(3, 'A\\\\B')")->As<ColumnDateTime64>()->Timezone(), "A\\B");
56+
}
57+
58+
TEST(CreateColumnByType, EnumEscapedNames) {
59+
const std::vector<Type::EnumItem> enum_items = {{"can't", 1}, {"a\\b", 2}, {"a,b=(c)", 3}, {"", 4}};
60+
auto col = CreateColumnByType("Enum8('can\\'t' = 1, 'a\\\\b' = 2, 'a,b=(c)' = 3, '' = 4)");
61+
ASSERT_NE(nullptr, col);
62+
ASSERT_TRUE(col->Type()->IsEqual(Type::CreateEnum8(enum_items)));
63+
}
64+
65+
// Round-trip: build a Type from raw values, render it via GetName() (escape),
66+
// re-parse the rendered name via CreateColumnByType (unescape) and verify the
67+
// raw values survive the render->parse cycle. Uses only escape symbols that are
68+
// currently implemented.
69+
70+
TEST(CreateColumnByType, RoundTrip_Enum) {
71+
const std::vector<Type::EnumItem> enum_items = {{"can't", 1}, {"a\\b", 2}, {"tab\there", 3}, {"line\nbreak", 4}};
72+
auto type = Type::CreateEnum8(enum_items);
73+
74+
auto col = CreateColumnByType(type->GetName());
75+
ASSERT_NE(nullptr, col);
76+
EXPECT_TRUE(col->Type()->IsEqual(type));
77+
78+
const auto* enum_type = col->Type()->As<EnumType>();
79+
ASSERT_NE(nullptr, enum_type);
80+
EXPECT_EQ(enum_type->GetEnumName(1), "can't");
81+
EXPECT_EQ(enum_type->GetEnumValue("a\\b"), 2);
82+
EXPECT_EQ(enum_type->GetEnumName(3), "tab\there");
83+
EXPECT_EQ(enum_type->GetEnumValue("line\nbreak"), 4);
84+
}
85+
86+
TEST(CreateColumnByType, RoundTrip_DateTime) {
87+
auto type = Type::CreateDateTime("Etc/Can't");
88+
89+
auto col = CreateColumnByType(type->GetName());
90+
ASSERT_NE(nullptr, col);
91+
EXPECT_EQ(col->As<ColumnDateTime>()->Timezone(), "Etc/Can't");
92+
}
93+
94+
TEST(CreateColumnByType, RoundTrip_DateTime64) {
95+
auto type = Type::CreateDateTime64(3, "A\\B");
96+
97+
auto col = CreateColumnByType(type->GetName());
98+
ASSERT_NE(nullptr, col);
99+
EXPECT_EQ(col->As<ColumnDateTime64>()->Timezone(), "A\\B");
100+
}
101+
102+
TEST(CreateColumnByType, RoundTrip_Tuple) {
103+
const std::vector<std::string> item_names = {"a`b", "c.d"};
104+
auto type = Type::CreateTuple({Type::CreateSimple<uint8_t>(), Type::CreateString()}, item_names);
105+
106+
auto col = CreateColumnByType(type->GetName());
107+
ASSERT_NE(nullptr, col);
108+
109+
const auto* tuple_type = col->Type()->As<TupleType>();
110+
ASSERT_NE(nullptr, tuple_type);
111+
EXPECT_EQ(tuple_type->GetItemNames(), item_names);
53112
}
54113

55114
TEST(CreateColumnByType, AggregateFunction) {

ut/client_ut.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,62 @@ TEST_P(ClientCase, Enum) {
835835
EXPECT_EQ(sizeof(TEST_DATA)/sizeof(TEST_DATA[0]), row);
836836
}
837837

838+
// Live-server round-trip of escape sequences in an Enum type: hand-write a
839+
// CREATE TABLE whose Enum labels contain escape sequences, insert matching
840+
// values via the streaming BeginInsert/SendInsertBlock/EndInsert API (so the
841+
// inserted block's columns are derived from the server-reported schema), select
842+
// them back and verify the client decodes the labels unchanged.
843+
// NOTE: the '\b' (backspace) label is a known gap: it is absent from the
844+
// client escape/unescape maps, so this test is expected to fail on that label
845+
// until the production code is extended (handled separately).
846+
// NOTE: DateTime/DateTime64 are intentionally not covered here because the
847+
// server validates timezone names, so escaped/unknown timezones can't be
848+
// stored (those cases stay covered by the client-side unit tests).
849+
TEST_P(ClientCase, EscapeRoundtrip_Enum) {
850+
const std::vector<Type::EnumItem> enum_items = {
851+
{"q'q", 1}, {"bs\\bs", 2}, {"tab\ttab", 3}, {"nl\nnl", 4}, {"cr\rcr", 5}, {"bsp\bbsp", 6},
852+
};
853+
854+
client_->Execute("DROP TEMPORARY TABLE IF EXISTS test_clickhouse_cpp_escape_enum;");
855+
client_->Execute(
856+
"CREATE TEMPORARY TABLE IF NOT EXISTS test_clickhouse_cpp_escape_enum "
857+
"(id UInt64, e Enum8('q\\'q' = 1, 'bs\\\\bs' = 2, 'tab\\ttab' = 3, 'nl\\nnl' = 4, 'cr\\rcr' = 5, 'bsp\\bbsp' = 6))");
858+
859+
{
860+
auto block = client_->BeginInsert("INSERT INTO test_clickhouse_cpp_escape_enum VALUES");
861+
ASSERT_EQ(size_t(2), block.GetColumnCount());
862+
auto id = block[0]->As<ColumnUInt64>();
863+
auto e = block[1]->As<ColumnEnum8>();
864+
for (const auto& [name, value] : enum_items) {
865+
id->Append(static_cast<uint64_t>(value));
866+
e->Append(name);
867+
}
868+
block.RefreshRowCount();
869+
client_->SendInsertBlock(block);
870+
client_->EndInsert();
871+
}
872+
873+
size_t row = 0;
874+
client_->Select("SELECT id, e FROM test_clickhouse_cpp_escape_enum ORDER BY id",
875+
[&](const Block& block) {
876+
if (block.GetRowCount() == 0) {
877+
return;
878+
}
879+
const auto* enum_type = block[1]->Type()->As<EnumType>();
880+
ASSERT_NE(nullptr, enum_type);
881+
for (size_t i = 0; i < block.GetRowCount(); ++i, ++row) {
882+
ASSERT_LT(row, enum_items.size());
883+
const auto& [name, value] = enum_items[row];
884+
EXPECT_EQ(static_cast<uint64_t>(value), (*block[0]->As<ColumnUInt64>())[i]);
885+
EXPECT_EQ(value, block[1]->As<ColumnEnum8>()->At(i));
886+
EXPECT_EQ(name, block[1]->As<ColumnEnum8>()->NameAt(i));
887+
EXPECT_EQ(name, enum_type->GetEnumName(value));
888+
EXPECT_EQ(value, enum_type->GetEnumValue(name));
889+
}
890+
});
891+
EXPECT_EQ(enum_items.size(), row);
892+
}
893+
838894
TEST_P(ClientCase, Decimal) {
839895
client_->Execute(
840896
"CREATE TEMPORARY TABLE IF NOT EXISTS "

0 commit comments

Comments
 (0)