From e0d9d9f072e7550dd332f2ecbf129c360645e2a3 Mon Sep 17 00:00:00 2001 From: Johannes Misch Date: Tue, 30 Jun 2026 16:14:13 +0200 Subject: [PATCH 1/3] Support writing generic LowCardinality(T) Previously, clickhouse-cpp only supported writing LowCardinality(String). With this change, support is expanded to other fundamental types as well. --- clickhouse/columns/factory.cpp | 12 ++- clickhouse/columns/lowcardinality.cpp | 120 +++++++++++++++++++++++++- ut/CreateColumnByType_ut.cpp | 23 +++++ ut/columns_ut.cpp | 70 +++++++++++++++ ut/roundtrip_column.cpp | 3 +- ut/roundtrip_tests.cpp | 28 ++++++ 6 files changed, 251 insertions(+), 5 deletions(-) diff --git a/clickhouse/columns/factory.cpp b/clickhouse/columns/factory.cpp index a01304f8..2bec7dd8 100644 --- a/clickhouse/columns/factory.cpp +++ b/clickhouse/columns/factory.cpp @@ -246,8 +246,16 @@ static ColumnRef CreateColumnFromAst(const TypeAst& ast, CreateColumnByTypeSetti std::make_shared() ) ); - default: - throw UnimplementedError("LowCardinality(" + nested.name + ") is not supported"); + default: { + // Generic LowCardinality(T): build the inner column and + // wrap it. Works for any fixed-size dictionary type that + // AppendToDictionary supports. + auto inner = CreateColumnFromAst(nested, settings); + if (!inner) { + throw UnimplementedError("LowCardinality(" + nested.name + ") is not supported"); + } + return std::make_shared(std::move(inner)); + } } } } diff --git a/clickhouse/columns/lowcardinality.cpp b/clickhouse/columns/lowcardinality.cpp index 0722ea8f..45a9006e 100644 --- a/clickhouse/columns/lowcardinality.cpp +++ b/clickhouse/columns/lowcardinality.cpp @@ -2,6 +2,13 @@ #include "string.h" #include "nullable.h" +#include "numeric.h" +#include "enum.h" +#include "date.h" +#include "ip4.h" +#include "ip6.h" +#include "uuid.h" +#include "../base/socket.h" // for htonl/ntohl and in_addr/in6_addr #include "../base/wire_format.h" #include @@ -10,6 +17,7 @@ #include #include #include +#include #include @@ -95,13 +103,46 @@ inline auto VisitIndexColumn(Vizitor && vizitor, ColumnType && col) { } } +// Number of bytes an ItemView holds for a fixed-size dictionary type, or 0 for +// variable-size (String/FixedString) or unsupported types. Used to build a +// correctly-sized zero value for the default/null dictionary item. +inline size_t FixedSizeForDictionaryType(Type::Code code) { + switch (code) { + case Type::Int8: case Type::UInt8: case Type::Enum8: + return 1; + case Type::Int16: case Type::UInt16: case Type::Enum16: case Type::Date: + return 2; + case Type::Int32: case Type::UInt32: case Type::Float32: + case Type::DateTime: case Type::Date32: case Type::IPv4: + return 4; + case Type::Int64: case Type::UInt64: case Type::Float64: + case Type::DateTime64: + return 8; + case Type::Int128: case Type::UInt128: case Type::IPv6: case Type::UUID: + return 16; + default: + return 0; + } +} + +// A zero-filled, correctly-sized ItemView for a fixed-size dictionary type. The +// backing buffer is static so the non-owning view stays valid. +inline ItemView ZeroItemForDictionary(Type::Code code) { + if (const auto size = FixedSizeForDictionaryType(code)) { + static const char zeros[16] = {}; + return ItemView{code, std::string_view{zeros, size}}; + } + // Variable-size types (String/FixedString) accept an empty value. + return ItemView{code, std::string_view{}}; +} + // A special NULL-item, which is expected at pos(0) in dictionary, // note that we distinguish empty string from NULL-value. inline auto GetNullItemForDictionary(const ColumnRef dictionary) { if (auto n = dictionary->As()) { return ItemView {}; } else { - return ItemView{dictionary->Type()->GetCode(), std::string_view{}}; + return ZeroItemForDictionary(dictionary->Type()->GetCode()); } } @@ -111,7 +152,7 @@ inline ItemView GetDefaultItemForDictionary(const ColumnRef dictionary) { if (auto n = dictionary->As()) { return GetDefaultItemForDictionary(n->Nested()); } else { - return ItemView{dictionary->Type()->GetCode(), std::string_view{}}; + return ZeroItemForDictionary(dictionary->Type()->GetCode()); } } @@ -147,6 +188,81 @@ inline void AppendToDictionary(Column& dictionary, const ItemView & item) { case Type::Nullable: AppendNullableToDictionary(column_down_cast(dictionary), item); return; + // Fixed-size dictionary types. The ItemView holds the raw stored bytes + // (see the matching ColumnXxx::GetItem), so we re-append the raw value. + case Type::Int8: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Int16: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Int32: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Int64: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::UInt8: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::UInt16: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::UInt32: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::UInt64: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Int128: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::UInt128: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Float32: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Float64: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Enum8: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Enum16: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::Date: + column_down_cast(dictionary).AppendRaw(item.get()); + return; + case Type::Date32: + column_down_cast(dictionary).AppendRaw(item.get()); + return; + case Type::DateTime: + column_down_cast(dictionary).AppendRaw(item.get()); + return; + case Type::DateTime64: + column_down_cast(dictionary).Append(item.get()); + return; + case Type::IPv4: + // ColumnIPv4::Append applies htonl, and GetItem returns the stored + // (already byte-swapped) value, so undo the swap to re-store as-is. + column_down_cast(dictionary).Append(ntohl(item.get())); + return; + case Type::IPv6: { + in6_addr addr; + std::memcpy(&addr, item.data.data(), sizeof(addr)); + column_down_cast(dictionary).Append(addr); + return; + } + case Type::UUID: { + UUID value; + std::memcpy(&value.first, item.data.data(), sizeof(value.first)); + std::memcpy(&value.second, item.data.data() + sizeof(value.first), + sizeof(value.second)); + column_down_cast(dictionary).Append(value); + return; + } default: throw ValidationError("Unexpected dictionary column type: " + dictionary.GetType().GetName()); } diff --git a/ut/CreateColumnByType_ut.cpp b/ut/CreateColumnByType_ut.cpp index e312c116..1de80fb9 100644 --- a/ut/CreateColumnByType_ut.cpp +++ b/ut/CreateColumnByType_ut.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,28 @@ TEST(CreateColumnByType, LowCardinalityAsWrappedColumn) { ASSERT_EQ(Type::FixedString, CreateColumnByType("LowCardinality(FixedString(10000))", create_column_settings)->As()->GetType().GetCode()); } +TEST(CreateColumnByType, LowCardinalityGeneralInnerTypes) { + // LowCardinality used to be supported only over String/FixedString. The + // factory now builds a generic ColumnLowCardinality for any fixed-size inner + // type. + for (const auto* type_name : { + "LowCardinality(Int8)", + "LowCardinality(Int64)", + "LowCardinality(UInt64)", + "LowCardinality(Float64)", + "LowCardinality(Date)", + "LowCardinality(DateTime)", + "LowCardinality(Nullable(Int64))", + "LowCardinality(Nullable(Float64))", + }) { + auto col = CreateColumnByType(type_name); + ASSERT_NE(nullptr, col) << type_name; + ASSERT_EQ(Type::LowCardinality, col->GetType().GetCode()) << type_name; + ASSERT_NE(nullptr, col->As()) << type_name; + EXPECT_EQ(std::string{type_name}, col->GetType().GetName()) << type_name; + } +} + TEST(CreateColumnByType, DateTime) { ASSERT_NE(nullptr, CreateColumnByType("DateTime")); ASSERT_NE(nullptr, CreateColumnByType("DateTime('Europe/Moscow')")); diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index 13a1731c..419cb359 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1189,3 +1189,73 @@ TEST(ColumnsCase, ColumnMapT_Wrap) { EXPECT_EQ("123", map_view.At(1)); EXPECT_EQ("abc", map_view.At(2)); } + +// Regression tests for general LowCardinality support over non-String inner +// types (previously only String/FixedString were supported). +TEST(ColumnLowCardinality, AppendAndReadNumeric) { + auto col = std::make_shared>(); + col->Append(7); + col->Append(7); + col->Append(9); + col->Append(7); + + ASSERT_EQ(4u, col->Size()); + EXPECT_EQ(7, col->At(0)); + EXPECT_EQ(7, col->At(1)); + EXPECT_EQ(9, col->At(2)); + EXPECT_EQ(7, col->At(3)); + // Dictionary holds the default item plus the two distinct values {7, 9}. + EXPECT_EQ(3u, col->GetDictionarySize()); + + // GetItem returns the raw value with the correct type code. + const auto item = col->GetItem(2); + EXPECT_EQ(Type::Int64, item.type); + EXPECT_EQ(9, item.get()); +} + +TEST(ColumnLowCardinality, AppendAndReadNullableNumeric) { + auto col + = std::make_shared>>(); + col->Append(7); + col->Append(std::nullopt); + col->Append(7); + col->Append(9); + col->Append(std::nullopt); + + ASSERT_EQ(5u, col->Size()); + EXPECT_EQ(std::optional{7}, col->At(0)); + EXPECT_EQ(std::nullopt, col->At(1)); + EXPECT_EQ(std::optional{7}, col->At(2)); + EXPECT_EQ(std::optional{9}, col->At(3)); + EXPECT_EQ(std::nullopt, col->At(4)); + + // Null rows are represented by a Void ItemView. + EXPECT_EQ(Type::Void, col->GetItem(1).type); + EXPECT_EQ(Type::Int64, col->GetItem(0).type); +} + +TEST(ColumnLowCardinality, NumericLoadAndSave) { + auto column_A = std::make_shared>(); + for (auto v : {1u, 2u, 1u, 3u, 2u, 1u}) { + column_A->Append(v); + } + + const auto BufferSize = 64 * 1024; + std::unique_ptr buffer = std::make_unique(BufferSize); + memset(buffer.get(), 0, BufferSize); + { + ArrayOutput output(buffer.get(), BufferSize); + ASSERT_NO_THROW(column_A->Save(&output)); + } + + auto column_B = std::make_shared>(); + { + ArrayInput input(buffer.get(), BufferSize); + ASSERT_TRUE(column_B->Load(&input, column_A->Size())); + } + + ASSERT_EQ(column_A->Size(), column_B->Size()); + for (size_t i = 0; i < column_A->Size(); ++i) { + EXPECT_EQ(column_A->At(i), column_B->At(i)) << "row " << i; + } +} diff --git a/ut/roundtrip_column.cpp b/ut/roundtrip_column.cpp index 19b18bb0..c6a325c3 100644 --- a/ut/roundtrip_column.cpp +++ b/ut/roundtrip_column.cpp @@ -38,7 +38,8 @@ ColumnRef RoundtripColumnValues(Client& client, ColumnRef expected) { client.Execute("DROP TEMPORARY TABLE IF EXISTS temporary_roundtrip_table;"); // id column is to have the same order of rows on SELECT client.Execute("CREATE TEMPORARY TABLE IF NOT EXISTS temporary_roundtrip_table (id UInt32, col " + type_name + ") " - "ENGINE = Memory SETTINGS enable_time_time64_type = 1"); + "ENGINE = Memory SETTINGS enable_time_time64_type = 1, " + "allow_suspicious_low_cardinality_types = 1"); { Block block; block.AppendColumn("col", expected); diff --git a/ut/roundtrip_tests.cpp b/ut/roundtrip_tests.cpp index 9ff4edf3..9476d2b1 100644 --- a/ut/roundtrip_tests.cpp +++ b/ut/roundtrip_tests.cpp @@ -233,6 +233,34 @@ TEST_P(RoundtripCase, LowCardinalityTNullableString) { EXPECT_TRUE(CompareRecursive(*col, *result_typed)); } +TEST_P(RoundtripCase, LowCardinalityTUInt64) { + using TestColumn = ColumnLowCardinalityT; + auto col = std::make_shared(); + + col->Append(7); + col->Append(42); + col->Append(7); + col->Append(7); + + auto result_typed = RoundtripColumnValues(*client_, col)->As(); + EXPECT_TRUE(CompareRecursive(*col, *result_typed)); +} + +TEST_P(RoundtripCase, LowCardinalityTNullableUInt64) { + using TestColumn = ColumnLowCardinalityT>; + auto col = std::make_shared(); + + col->Append(7); + col->Append(42); + col->Append(std::nullopt); + col->Append(7); + col->Append(std::nullopt); + col->Append(7); + + auto result_typed = RoundtripColumnValues(*client_, col)->As(); + EXPECT_TRUE(CompareRecursive(*col, *result_typed)); +} + TEST_P(RoundtripCase, ArrayTNullableString) { using TestColumn = ColumnArrayT>; auto col = std::make_shared(); From f88228c7df14aff49de7b58b6e68ca4daad0b47c Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Wed, 19 Aug 2026 19:08:19 +0200 Subject: [PATCH 2/3] Tighten LowCardinality dictionary type handling Remove unsupported Enum and DateTime64 dictionary append paths, guard the fixed-size zero buffer against overflow, and drop the unused index visitor. --- clickhouse/columns/lowcardinality.cpp | 54 +++++++++++---------------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/clickhouse/columns/lowcardinality.cpp b/clickhouse/columns/lowcardinality.cpp index dfb7d529..5a5d0298 100644 --- a/clickhouse/columns/lowcardinality.cpp +++ b/clickhouse/columns/lowcardinality.cpp @@ -86,39 +86,33 @@ inline ResultColumnType & column_down_cast(ColumnType & c) { return dynamic_cast(c); } -// std::visit-ish function to avoid including header, which is not present in older version of XCode. -template -inline auto VisitIndexColumn(Vizitor && vizitor, ColumnType && col) { - switch (col.Type()->GetCode()) { - case Type::UInt8: - return vizitor(column_down_cast(col)); - case Type::UInt16: - return vizitor(column_down_cast(col)); - case Type::UInt32: - return vizitor(column_down_cast(col)); - case Type::UInt64: - return vizitor(column_down_cast(col)); - default: - throw ValidationError("Invalid index column type " + col.GetType().GetName()); - } -} - // Number of bytes an ItemView holds for a fixed-size dictionary type, or 0 for // variable-size (String/FixedString) or unsupported types. Used to build a // correctly-sized zero value for the default/null dictionary item. inline size_t FixedSizeForDictionaryType(Type::Code code) { switch (code) { - case Type::Int8: case Type::UInt8: case Type::Enum8: + case Type::Int8: + case Type::UInt8: return 1; - case Type::Int16: case Type::UInt16: case Type::Enum16: case Type::Date: + case Type::Int16: + case Type::UInt16: + case Type::Date: return 2; - case Type::Int32: case Type::UInt32: case Type::Float32: - case Type::DateTime: case Type::Date32: case Type::IPv4: + case Type::Int32: + case Type::UInt32: + case Type::Float32: + case Type::DateTime: + case Type::Date32: + case Type::IPv4: return 4; - case Type::Int64: case Type::UInt64: case Type::Float64: - case Type::DateTime64: + case Type::Int64: + case Type::UInt64: + case Type::Float64: return 8; - case Type::Int128: case Type::UInt128: case Type::IPv6: case Type::UUID: + case Type::Int128: + case Type::UInt128: + case Type::IPv6: + case Type::UUID: return 16; default: return 0; @@ -130,6 +124,9 @@ inline size_t FixedSizeForDictionaryType(Type::Code code) { inline ItemView ZeroItemForDictionary(Type::Code code) { if (const auto size = FixedSizeForDictionaryType(code)) { static const char zeros[16] = {}; + if (size > sizeof(zeros)) { + throw AssertionError("The size of item view for ColumnLowCardinality exceeds the buffer size"); + } return ItemView{code, std::string_view{zeros, size}}; } // Variable-size types (String/FixedString) accept an empty value. @@ -226,12 +223,6 @@ inline void AppendToDictionary(Column& dictionary, const ItemView & item) { case Type::Float64: column_down_cast(dictionary).Append(item.get()); return; - case Type::Enum8: - column_down_cast(dictionary).Append(item.get()); - return; - case Type::Enum16: - column_down_cast(dictionary).Append(item.get()); - return; case Type::Date: column_down_cast(dictionary).AppendRaw(item.get()); return; @@ -241,9 +232,6 @@ inline void AppendToDictionary(Column& dictionary, const ItemView & item) { case Type::DateTime: column_down_cast(dictionary).AppendRaw(item.get()); return; - case Type::DateTime64: - column_down_cast(dictionary).Append(item.get()); - return; case Type::IPv4: // ColumnIPv4::Append applies htonl, and GetItem returns the stored // (already byte-swapped) value, so undo the swap to re-store as-is. From 338cc068d454238ba3706f7f86f59fd2c44e08d1 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Wed, 19 Aug 2026 20:18:56 +0200 Subject: [PATCH 3/3] Add typed tests for LowCardinality over all supported inner types Cover String, FixedString, Int8-Int128, UInt8-UInt128, Float32/64, Date, Date32, DateTime, IPv4, IPv6 and UUID, each both as LowCardinality(T) and LowCardinality(Nullable(T)): fill a base column, append it to a generic ColumnLowCardinality, insert into the DB, select as ColumnLowCardinalityT and verify the data matches row by row. Also add the ValueType alias to ColumnUUID, which is required to instantiate ColumnLowCardinalityT. --- clickhouse/columns/uuid.h | 2 + ut/BUILD.bazel | 1 + ut/CMakeLists.txt | 1 + ut/low_cardinality_types_ut.cpp | 209 ++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 ut/low_cardinality_types_ut.cpp diff --git a/clickhouse/columns/uuid.h b/clickhouse/columns/uuid.h index ccd03f84..0534787f 100644 --- a/clickhouse/columns/uuid.h +++ b/clickhouse/columns/uuid.h @@ -12,6 +12,8 @@ namespace clickhouse { */ class ColumnUUID : public Column { public: + using ValueType = UUID; + ColumnUUID(); explicit ColumnUUID(ColumnRef data); diff --git a/ut/BUILD.bazel b/ut/BUILD.bazel index e79c553e..6845fbb5 100644 --- a/ut/BUILD.bazel +++ b/ut/BUILD.bazel @@ -105,6 +105,7 @@ cc_test( "connection_failed_client_test.cpp", "connection_failed_client_test.h", "low_cardinality_nullable_tests.cpp", + "low_cardinality_types_ut.cpp", "performance_tests.cpp", "readonly_client_test.cpp", "readonly_client_test.h", diff --git a/ut/CMakeLists.txt b/ut/CMakeLists.txt index f5a5fa24..c827e5e3 100644 --- a/ut/CMakeLists.txt +++ b/ut/CMakeLists.txt @@ -10,6 +10,7 @@ SET ( clickhouse-cpp-ut-src column_as_ut.cpp column_array_ut.cpp itemview_ut.cpp + low_cardinality_types_ut.cpp socket_ut.cpp stream_ut.cpp type_parser_ut.cpp diff --git a/ut/low_cardinality_types_ut.cpp b/ut/low_cardinality_types_ut.cpp new file mode 100644 index 00000000..a00e7b8b --- /dev/null +++ b/ut/low_cardinality_types_ut.cpp @@ -0,0 +1,209 @@ +// Server round-trip tests for LowCardinality over all supported inner types: +// +// String, FixedString (originally supported) +// Int8/16/32/64/128, UInt8/16/32/64/128, Float32/64, +// Date, Date32, DateTime, IPv4, IPv6, UUID (added later) +// +// Every type is tested twice, once as LowCardinality(T) and once as +// LowCardinality(Nullable(T)), with the same scenario: +// +// 1. Create a column of the base type and fill it with values. +// 2. Create a generic ColumnLowCardinality and append the data from the column of step 1. +// 3. Send the column of step 2 through a ClickHouse server (INSERT + SELECT) and +// convert the returned column to ColumnLowCardinalityT. +// 4. Expect the data of step 1 to be equal to the data of step 3. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ut/roundtrip_column.h" +#include "ut/utils.h" +#include "ut/utils_comparison.h" +#include "ut/value_generators.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace clickhouse; + +// ColumnDate32 (unlike ColumnDate) also supports pre-epoch dates, +// extend the common Date values with one. +std::vector MakeDates32AsSeconds() { + auto result = MakeDates(); + result.push_back(std::time_t(-2) * 86400); + return result; +} + +// A single test case: base column type + value generator (+ optional column +// constructor arguments, e.g. the width of a FixedString). +template +struct TestCase { + using BaseColumn = ColumnType; + + static auto MakeColumn() { return std::make_shared(ConstructorArgs...); } + + static auto MakeValues() { + auto values = ValueGenerator(); + + // The floating-point generators produce NaNs, which never compare + // equal to themselves, drop them to keep plain EXPECT_EQ verification. + using ValueType = typename decltype(values)::value_type; + if constexpr (std::is_floating_point_v) { + values.erase(std::remove_if( + values.begin(), + values.end(), + [](ValueType v) { + return std::isnan(v); + }), + values.end()); + } + + // Duplicate a value so that the dictionary/deduplication code path is exercised too. + values.push_back(values.front()); + return values; + } +}; + +} // namespace + +template +class LowCardinalityTypedTest : public ::testing::Test { +protected: + void SetUp() override { + client_ = std::make_unique( + ClientOptions() + .SetHost( getEnvOrDefault("CLICKHOUSE_HOST", "localhost")) + .SetPort( getEnvOrDefault("CLICKHOUSE_PORT", "9000")) + .SetUser( getEnvOrDefault("CLICKHOUSE_USER", "default")) + .SetPassword( getEnvOrDefault("CLICKHOUSE_PASSWORD", "")) + .SetDefaultDatabase(getEnvOrDefault("CLICKHOUSE_DB", "default")) + .SetPingBeforeQuery(true)); + } + + void TearDown() override { + client_.reset(); + } + + std::unique_ptr client_; +}; + +using LowCardinalityTestCases = ::testing::Types< + TestCase, + TestCase, 4>, + + TestCase>, + TestCase>, + TestCase>, + TestCase>, + + TestCase>, + TestCase>, + TestCase>, + TestCase>, + + TestCase, + TestCase, + + TestCase>, + TestCase>, + + TestCase>, + TestCase, + TestCase, + + TestCase, + TestCase, + TestCase +>; + +TYPED_TEST_SUITE(LowCardinalityTypedTest, LowCardinalityTestCases); + +// LowCardinality(T) +TYPED_TEST(LowCardinalityTypedTest, RoundtripAndReadThroughTypedView) { + using Case = TypeParam; + using BaseColumn = typename Case::BaseColumn; + using TypedLowCardinality = ColumnLowCardinalityT; + + // Step 1: base column with values. + auto source = Case::MakeColumn(); + for (const auto& value : Case::MakeValues()) { + source->Append(value); + } + ASSERT_GT(source->Size(), 0u); + + // Step 2: generic LowCardinality column, append data of the base column. + auto low_cardinality = std::make_shared(Case::MakeColumn()); + low_cardinality->Append(source); + ASSERT_EQ(source->Size(), low_cardinality->Size()); + + // Step 3: send through the server (INSERT + SELECT) and convert the + // returned column to typed ColumnLowCardinalityT. + auto returned = RoundtripColumnValues(*this->client_, low_cardinality); + auto typed = returned->template AsStrict(); + ASSERT_EQ(source->Size(), typed->Size()); + + // Step 4: data of step 1 must equal data of step 3. + for (size_t i = 0; i < source->Size(); ++i) { + SCOPED_TRACE(::testing::Message("at row ") << i); + EXPECT_EQ(source->At(i), typed->At(i)); + EXPECT_EQ((*source)[i], (*typed)[i]); + } +} + +// LowCardinality(Nullable(T)) +TYPED_TEST(LowCardinalityTypedTest, RoundtripNullableAndReadThroughTypedView) { + using Case = TypeParam; + using BaseColumn = typename Case::BaseColumn; + using NullableColumn = ColumnNullableT; + using TypedLowCardinality = ColumnLowCardinalityT; + + // Step 1: nullable base column with values interleaved with NULLs. + auto source = std::make_shared(Case::MakeColumn()); + for (const auto& value : Case::MakeValues()) { + source->Append(typename NullableColumn::ValueType{value}); + source->Append(std::nullopt); + } + ASSERT_GT(source->Size(), 0u); + + // Step 2: generic LowCardinality column over a Nullable dictionary, + // append data of the nullable column. + auto nullable = std::make_shared(Case::MakeColumn()); + auto low_cardinality = std::make_shared(nullable); + low_cardinality->Append(source); + ASSERT_EQ(source->Size(), low_cardinality->Size()); + + // Step 3: send through the server (INSERT + SELECT) and convert the + // returned column to typed ColumnLowCardinalityT>. + auto returned = RoundtripColumnValues(*this->client_, low_cardinality); + auto typed = returned->template AsStrict(); + ASSERT_EQ(source->Size(), typed->Size()); + + // Step 4: data of step 1 must equal data of step 3, NULLs included. + for (size_t i = 0; i < source->Size(); ++i) { + SCOPED_TRACE(::testing::Message("at row ") << i); + + const auto expected = source->At(i); + const auto actual = typed->At(i); + + ASSERT_EQ(expected.has_value(), actual.has_value()); + if (expected.has_value()) { + EXPECT_EQ(*expected, *actual); + } + } +}