From 7fb2f8ac5dd770b466c21b9c048d48316aef29a0 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Thu, 23 Jul 2026 09:36:45 +0200 Subject: [PATCH 01/15] Make Wrap non-destructive and take const-reference arguments ColumnArrayT/ColumnNullableT/ColumnTupleT/ColumnMapT::Wrap used to steal the source column's internals (rvalue-ref parameter consumed via std::move), leaving the original unusable. Wrap now shares the source's internals through shared_ptr: the returned typed column references the same underlying data, the original stays fully valid, and mutations are visible through both. All Wrap overloads take their argument by const reference (const & / const Column& / const ColumnRef&), so std::move is no longer required and lvalues and const sources are accepted. - nullable: wrap the nested column via WrapColumn (recursive share); include utils.h for it - array: share nested data and offsets - tuple: build element columns from the source via TupleFromColumn - map: share the backing array of key/value tuples - update doc-comments to describe the shared, non-destructive semantics - add tests for non-stealing behaviour and lvalue/const acceptance ColumnLowCardinalityT::Wrap is converted in the following commit. --- clickhouse/columns/array.h | 24 ++-- clickhouse/columns/map.h | 17 ++- clickhouse/columns/nullable.h | 24 ++-- clickhouse/columns/tuple.h | 35 ++++-- ut/column_array_ut.cpp | 80 +++++++++++++ ut/columns_ut.cpp | 209 ++++++++++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 36 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index f771e4af..726a1b55 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -128,26 +128,28 @@ class ColumnArrayT : public ColumnArray { : ColumnArrayT(std::make_shared(std::forward(args)...)) {} - /** Create a ColumnArrayT from a ColumnArray, without copying data and offsets, but by 'stealing' those from `col`. + /** Create a ColumnArrayT that SHARES the internals of `col` (nested data and + * offsets) via shared_ptr, WITHOUT stealing or copying them. * - * Ownership of column internals is transferred to returned object, original (argument) object - * MUST NOT BE USED IN ANY WAY, it is only safe to dispose it. + * The original `col` remains fully valid and usable. Both the original and the + * returned wrapper reference the same underlying columns, so mutations through + * one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col in this case. - * This is a static method to make such conversion verbose. + * Throws an exception if `col` is of wrong type, it is safe to use original col + * in this case. This is a static method to make such conversion verbose. */ - static auto Wrap(ColumnArray&& col) { - auto nested_data = WrapColumn(col.GetData()); + static auto Wrap(const ColumnArray& col) { + auto nested_data = WrapColumn(ColumnRef{col.data_}); return std::make_shared>(nested_data, col.offsets_); } - static auto Wrap(Column&& col) { - return Wrap(std::move(dynamic_cast(col))); + static auto Wrap(const Column& col) { + return Wrap(dynamic_cast(col)); } // Helper to simplify integration with other APIs - static auto Wrap(ColumnRef&& col) { - return Wrap(std::move(*col->AsStrict())); + static auto Wrap(const ColumnRef& col) { + return Wrap(*col->AsStrict()); } /// A single (row) value of the Array-column, i.e. readonly array of items. diff --git a/clickhouse/columns/map.h b/clickhouse/columns/map.h index 4d644802..86ed5fc2 100644 --- a/clickhouse/columns/map.h +++ b/clickhouse/columns/map.h @@ -240,15 +240,24 @@ class ColumnMapT : public ColumnMap { typed_data_->Append(Iterator{value.begin(), functor}, Iterator{value.end(), functor}); } - static auto Wrap(ColumnMap&& col) { - auto data = ArrayColumnType::Wrap(std::move(col.data_)); + /** Create a ColumnMapT that SHARES the internals of `col` (its backing array of + * key/value tuples) via shared_ptr, WITHOUT stealing or copying them. + * + * The original `col` remains fully valid and usable. Both the original and the + * returned wrapper reference the same underlying columns, so mutations through + * one are visible through the other. + * + * Throws if `col` is of the wrong type. + */ + static auto Wrap(const ColumnMap& col) { + auto data = ArrayColumnType::Wrap(*col.data_); return std::make_shared>(std::move(data)); } - static auto Wrap(Column&& col) { return Wrap(std::move(dynamic_cast(col))); } + static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } // Helper to simplify integration with other APIs - static auto Wrap(ColumnRef&& col) { return Wrap(std::move(*col->AsStrict())); } + static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } private: std::shared_ptr typed_data_; diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index 6b34552c..a713164b 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -2,6 +2,7 @@ #include "column.h" #include "numeric.h" +#include "utils.h" #include @@ -108,25 +109,26 @@ class ColumnNullableT : public ColumnNullable { } } - /** Create a ColumnNullableT from a ColumnNullable, without copying data and offsets, but by - * 'stealing' those from `col`. + /** Create a ColumnNullableT that SHARES the internals of `col` (nested data and + * null map) via shared_ptr, WITHOUT stealing or copying them. * - * Ownership of column internals is transferred to returned object, original (argument) object - * MUST NOT BE USED IN ANY WAY, it is only safe to dispose it. + * The original `col` remains fully valid and usable. Both the original and the + * returned wrapper reference the same underlying columns, so mutations through + * one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col in this case. - * This is a static method to make such conversion verbose. + * Throws an exception if `col` is of wrong type, it is safe to use original col + * in this case. This is a static method to make such conversion verbose. */ - static auto Wrap(ColumnNullable&& col) { + static auto Wrap(const ColumnNullable& col) { return std::make_shared>( - col.Nested()->AsStrict(), - col.Nulls()->AsStrict()) ; + WrapColumn(col.Nested()), + col.Nulls()->AsStrict()); } - static auto Wrap(Column&& col) { return Wrap(std::move(dynamic_cast(col))); } + static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } // Helper to simplify integration with other APIs - static auto Wrap(ColumnRef&& col) { return Wrap(std::move(*col->AsStrict())); } + static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnNullable::Slice(begin, size)); diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index b6b0bbc7..7f9c534d 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -98,27 +98,28 @@ class ColumnTupleT : public ColumnTuple { AppendTuple(std::move(value)); } - /** Create a ColumnTupleT from a ColumnTuple, without copying data and offsets, but by - * 'stealing' those from `col`. + /** Create a ColumnTupleT that SHARES the internals of `col` (its element columns) + * via shared_ptr, WITHOUT stealing or copying them. * - * Ownership of column internals is transferred to returned object, original (argument) object - * MUST NOT BE USED IN ANY WAY, it is only safe to dispose it. + * The original `col` remains fully valid and usable. Both the original and the + * returned wrapper reference the same underlying element columns, so mutations + * through one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col in this case. - * This is a static method to make such conversion verbose. + * Throws an exception if `col` is of wrong type, it is safe to use original col + * in this case. This is a static method to make such conversion verbose. */ - static auto Wrap(ColumnTuple&& col) { + static auto Wrap(const ColumnTuple& col) { if (col.TupleSize() != std::tuple_size_v) { throw ValidationError("Can't wrap from " + col.GetType().GetName()); } auto names = col.Type()->As()->GetItemNames(); - return std::make_shared>(VectorToTuple(std::move(col)), std::move(names)); + return std::make_shared>(TupleFromColumn(col), std::move(names)); } - static auto Wrap(Column&& col) { return Wrap(std::move(dynamic_cast(col))); } + static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } // Helper to simplify integration with other APIs - static auto Wrap(ColumnRef&& col) { return Wrap(std::move(*col->AsStrict())); } + static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnTuple::Slice(begin, size)); @@ -159,6 +160,20 @@ class ColumnTupleT : public ColumnTuple { } } + template > + inline static auto TupleFromColumn([[maybe_unused]] const ColumnTuple& col) { + static_assert(column_index <= std::tuple_size_v); + if constexpr (column_index == 0) { + return std::make_tuple(); + } else { + using ColumnType = + typename std::tuple_element::type::element_type; + auto column = WrapColumn(col[column_index - 1]); + return std::tuple_cat(TupleFromColumn(col), + std::make_tuple(std::move(column))); + } + } + template > inline static auto VectorToTuple([[maybe_unused]] T columns) { static_assert(column_index <= std::tuple_size_v); diff --git a/ut/column_array_ut.cpp b/ut/column_array_ut.cpp index 0fc28d4f..04e42ca5 100644 --- a/ut/column_array_ut.cpp +++ b/ut/column_array_ut.cpp @@ -337,6 +337,86 @@ TEST(ColumnArrayT, Wrap_UInt64_2D) { EXPECT_TRUE(CompareRecursive(values, array)); } +TEST(ColumnArrayT, Wrap_AcceptsLvalue) { + // Wrap no longer requires an rvalue: lvalues and const sources are accepted. + + const std::vector> values = { + {1u, 2u}, + {3u}, + {} + }; + + auto arr = CreateArray(values); + + // Lvalue ColumnRef, no std::move required and not consumed. + ColumnRef ref = arr; + auto w1 = ColumnArrayT::Wrap(ref); + EXPECT_TRUE(CompareRecursive(values, *w1)); + EXPECT_NE(ref, nullptr); + + // Const lvalue concrete column. + const ColumnArray& cref = *arr; + auto w2 = ColumnArrayT::Wrap(cref); + EXPECT_TRUE(CompareRecursive(values, *w2)); + + // Non-const lvalue concrete column. + auto w3 = ColumnArrayT::Wrap(*arr); + EXPECT_TRUE(CompareRecursive(values, *w3)); +} + +TEST(ColumnArrayT, Wrap_DoesNotStealSource_UInt64) { + // Wrap shares storage with the source ColumnArray and leaves its contents intact. + + const std::vector> values = { + {1u, 2u, 3u}, + {4u, 5u, 6u, 7u, 8u, 9u}, + {0u}, + {}, + {13, 14} + }; + + auto original = CreateArray(values); + // Keep an independent handle to the same underlying ColumnArray. + auto keep = original; + auto wrapped_array = ColumnArrayT::Wrap(std::move(original)); + + // Wrapper sees the same data. + EXPECT_TRUE(CompareRecursive(values, *wrapped_array)); + + // Source array contents are left intact (not stolen from). + ASSERT_NE(keep, nullptr); + EXPECT_EQ(keep->Size(), values.size()); + + // Storage is shared: appending a row through the source is visible via the wrapper. + keep->AppendAsColumn(std::make_shared(std::vector{42, 43})); + EXPECT_EQ(wrapped_array->Size(), values.size() + 1); + EXPECT_EQ(wrapped_array->At(values.size()).At(0), 42u); + EXPECT_EQ(wrapped_array->At(values.size()).At(1), 43u); +} + +TEST(ColumnArrayT, Wrap_DoesNotStealSource_UInt64_2D) { + // Wrap shares all nesting layers with the source. + + const std::vector>> values = { + {{1u, 2u}, {3u}}, + {{4u}, {5u, 6u, 7u}, {8u, 9u}, {}}, + {{0u}}, + {{}}, + {{13}, {14, 15}} + }; + + auto original = Create2DArray(values); + auto keep = original; + auto wrapped_array = ColumnArrayT>::Wrap(std::move(original)); + + EXPECT_TRUE(CompareRecursive(values, *wrapped_array)); + + // Source array contents are left intact (not stolen from). + ASSERT_NE(keep, nullptr); + EXPECT_EQ(keep->Size(), values.size()); + EXPECT_TRUE(CompareRecursive(values, *ColumnArrayT>::Wrap(std::move(keep)))); +} + TEST(ColumnArrayT, Bool) { // Check inserting\reading back data from clickhouse::ColumnArrayT diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index d79debbe..f3ee76c1 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1254,6 +1254,61 @@ TEST(ColumnsCase, ColumnTupleT) { EXPECT_EQ(val, col.At(0)); } +TEST(ColumnsCase, ColumnNullableT_Wrap_DoesNotStealSource) { + auto nested = std::make_shared(); + auto nulls = std::make_shared(); + ColumnNullable col(nested, nulls); + + col.Append(false); + nested->Append(1); + col.Append(true); + nested->Append(0); + + using TestNullable = ColumnNullableT; + auto wrapped = TestNullable::Wrap(std::move(col)); + + // Wrapper sees the same data. + EXPECT_EQ(wrapped->Size(), 2u); + EXPECT_EQ(wrapped->At(0), std::optional(1)); + EXPECT_EQ(wrapped->At(1), std::optional{}); + + // Source column is left intact after Wrap (non-stealing). + EXPECT_EQ(col.Size(), 2u); + EXPECT_FALSE(col.IsNull(0)); + EXPECT_TRUE(col.IsNull(1)); + + // Storage is shared: appending through the original is visible via the wrapper. + col.Append(false); + nested->Append(42); + EXPECT_EQ(wrapped->Size(), 3u); + EXPECT_EQ(wrapped->At(2), std::optional(42)); +} + +TEST(ColumnsCase, ColumnNullableT_Wrap_AcceptsLvalue) { + auto nested = std::make_shared(); + auto nulls = std::make_shared(); + ColumnNullable col(nested, nulls); + col.Append(false); + nested->Append(7); + + using TestNullable = ColumnNullableT; + + // Non-const lvalue concrete column, no std::move required. + auto w1 = TestNullable::Wrap(col); + EXPECT_EQ(w1->At(0), std::optional(7)); + + // Const lvalue concrete column. + const ColumnNullable& cref = col; + auto w2 = TestNullable::Wrap(cref); + EXPECT_EQ(w2->At(0), std::optional(7)); + + // Lvalue ColumnRef, no std::move required and not consumed. + ColumnRef ref = std::make_shared(nested, nulls); + auto w3 = TestNullable::Wrap(ref); + EXPECT_EQ(w3->At(0), std::optional(7)); + EXPECT_NE(ref, nullptr); +} + TEST(ColumnsCase, ColumnTupleT_Wrap) { ColumnTuple col ({ std::make_shared(), @@ -1275,6 +1330,84 @@ TEST(ColumnsCase, ColumnTupleT_Wrap) { EXPECT_EQ(val, wrapped_col->At(0)); } +TEST(ColumnsCase, ColumnTupleT_Wrap_DoesNotStealSource) { + ColumnTuple col ({ + std::make_shared(), + std::make_shared(), + std::make_shared(3) + } + ); + + const auto val = std::make_tuple(1, "a", "bcd"); + + col[0]->AsStrict()->Append(std::get<0>(val)); + col[1]->AsStrict()->Append(std::get<1>(val)); + col[2]->AsStrict()->Append(std::get<2>(val)); + + using TestTuple = ColumnTupleT; + auto wrapped = TestTuple::Wrap(std::move(col)); + + // Wrapper sees the same data. + EXPECT_EQ(wrapped->Size(), 1u); + EXPECT_EQ(val, wrapped->At(0)); + + // Source column is left intact after Wrap (non-stealing). + EXPECT_EQ(col.TupleSize(), 3u); + EXPECT_EQ(col.Size(), 1u); + + // Storage is shared: appending through the original element columns is visible via the wrapper. + col[0]->AsStrict()->Append(2); + col[1]->AsStrict()->Append("xy"); + col[2]->AsStrict()->Append("zzz"); + EXPECT_EQ(wrapped->Size(), 2u); + EXPECT_EQ(std::make_tuple(2, "xy", "zzz"), wrapped->At(1)); +} + +TEST(ColumnsCase, ColumnTupleT_Wrap_DoesNotStealSource_PreservesNames) { + ColumnTuple base( + {std::make_shared(), std::make_shared()}, + {"id", "name"} + ); + + using TestTuple = ColumnTupleT; + auto wrapped = TestTuple::Wrap(std::move(base)); + EXPECT_EQ(wrapped->Type()->GetName(), "Tuple(id UInt64, name String)"); + + // Source remains usable after Wrap (non-stealing). + EXPECT_EQ(base.Type()->GetName(), "Tuple(id UInt64, name String)"); + EXPECT_EQ(base.TupleSize(), 2u); +} + +TEST(ColumnsCase, ColumnTupleT_Wrap_AcceptsLvalue) { + ColumnTuple col({ + std::make_shared(), + std::make_shared() + }); + col[0]->AsStrict()->Append(1); + col[1]->AsStrict()->Append("a"); + + using TestTuple = ColumnTupleT; + + // Non-const lvalue concrete column, no std::move required. + auto w1 = TestTuple::Wrap(col); + EXPECT_EQ(w1->At(0), std::make_tuple(uint64_t(1), std::string_view("a"))); + + // Const lvalue concrete column. + const ColumnTuple& cref = col; + auto w2 = TestTuple::Wrap(cref); + EXPECT_EQ(w2->At(0), std::make_tuple(uint64_t(1), std::string_view("a"))); + + // Lvalue ColumnRef, no std::move required and not consumed. + ColumnRef ref = std::make_shared(std::vector{col[0], col[1]}); + auto w3 = TestTuple::Wrap(ref); + EXPECT_EQ(w3->At(0), std::make_tuple(uint64_t(1), std::string_view("a"))); + EXPECT_NE(ref, nullptr); + + // Source column left intact. + EXPECT_EQ(col.TupleSize(), 2u); + EXPECT_EQ(col.Size(), 1u); +} + TEST(ColumnsCase, ColumnTupleT_Empty) { using TestTuple = ColumnTupleT<>; @@ -1381,3 +1514,79 @@ TEST(ColumnsCase, ColumnMapT_Wrap) { EXPECT_EQ("123", map_view.At(1)); EXPECT_EQ("abc", map_view.At(2)); } + +TEST(ColumnsCase, ColumnMapT_Wrap_AcceptsLvalue) { + auto tupls = std::make_shared(std::vector{ + std::make_shared(), + std::make_shared()}); + + auto data = std::make_shared(tupls); + + auto val = tupls->CloneEmpty()->As(); + (*val)[0]->AsStrict()->Append(1); + (*val)[1]->AsStrict()->Append("123"); + data->AppendAsColumn(val); + + ColumnMap col{data}; + + using TestMap = ColumnMapT; + + // Non-const lvalue concrete column, no std::move required. + auto w1 = TestMap::Wrap(col); + EXPECT_EQ("123", w1->At(0).At(1)); + + // Const lvalue concrete column. + const ColumnMap& cref = col; + auto w2 = TestMap::Wrap(cref); + EXPECT_EQ("123", w2->At(0).At(1)); + + // Lvalue ColumnRef, no std::move required and not consumed. + ColumnRef ref = std::make_shared(data); + auto w3 = TestMap::Wrap(ref); + EXPECT_EQ("123", w3->At(0).At(1)); + EXPECT_NE(ref, nullptr); + + // Source column left intact. + EXPECT_EQ(col.Size(), 1u); +} + +TEST(ColumnsCase, ColumnMapT_Wrap_DoesNotStealSource) { + auto tupls = std::make_shared(std::vector{ + std::make_shared(), + std::make_shared()}); + + auto data = std::make_shared(tupls); + + auto val = tupls->CloneEmpty()->As(); + + (*val)[0]->AsStrict()->Append(1); + (*val)[1]->AsStrict()->Append("123"); + + (*val)[0]->AsStrict()->Append(2); + (*val)[1]->AsStrict()->Append("abc"); + + data->AppendAsColumn(val); + + ColumnMap col{data}; + + using TestMap = ColumnMapT; + auto wrapped_col = TestMap::Wrap(std::move(col)); + + // Wrapper sees the same data. + auto map_view = wrapped_col->At(0); + EXPECT_THROW(map_view.At(0), ValidationError); + EXPECT_EQ("123", map_view.At(1)); + EXPECT_EQ("abc", map_view.At(2)); + + // Source column is left intact after Wrap (non-stealing). + EXPECT_EQ(col.Size(), 1u); + + // Storage is shared: appending a row through the original is visible via the wrapper. + auto val2 = tupls->CloneEmpty()->As(); + (*val2)[0]->AsStrict()->Append(7); + (*val2)[1]->AsStrict()->Append("xyz"); + data->AppendAsColumn(val2); + + EXPECT_EQ(wrapped_col->Size(), 2u); + EXPECT_EQ("xyz", wrapped_col->At(1).At(7)); +} From d176274497e308595e4c3f35d752a13e21e3b444 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Tue, 28 Jul 2026 17:31:48 +0200 Subject: [PATCH 02/15] Make ColumnLowCardinalityT::Wrap non-destructive Share the dedup map (unique_items_map_) via shared_ptr so a wrapped LowCardinality column shares dictionary, index and map with its source, keeping them coherent across both holders. Relax Wrap to const& (like the other typed columns) so it no longer steals from the source. Add tests covering non-stealing/shared-coherent semantics and lvalue acceptance. --- clickhouse/columns/lowcardinality.cpp | 16 ++++--- clickhouse/columns/lowcardinality.h | 36 +++++++++++---- ut/columns_ut.cpp | 63 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/clickhouse/columns/lowcardinality.cpp b/clickhouse/columns/lowcardinality.cpp index e286c863..2a3bd6b0 100644 --- a/clickhouse/columns/lowcardinality.cpp +++ b/clickhouse/columns/lowcardinality.cpp @@ -159,6 +159,7 @@ ColumnLowCardinality::ColumnLowCardinality(ColumnRef dictionary_column) : Column(Type::CreateLowCardinality(dictionary_column->Type())), dictionary_column_(dictionary_column->CloneEmpty()), // safe way to get an column of the same type. index_column_(std::make_shared()), + unique_items_map_(std::make_shared()), index_type_code_(Type::UInt32) { Setup(dictionary_column); @@ -168,6 +169,7 @@ ColumnLowCardinality::ColumnLowCardinality(std::shared_ptr dicti : Column(Type::CreateLowCardinality(dictionary_column->Type())), dictionary_column_(dictionary_column->CloneEmpty()), // safe way to get an column of the same type. index_column_(std::make_shared()), + unique_items_map_(std::make_shared()), index_type_code_(Type::UInt32) { AppendNullItem(); @@ -381,7 +383,7 @@ bool ColumnLowCardinality::LoadBody(InputStream* input, size_t rows) { dictionary_column_->Swap(*new_dictionary); index_column_.swap(new_index); - unique_items_map_.swap(new_unique_items_map); + unique_items_map_->swap(new_unique_items_map); index_type_code_ = index_column_->Type()->GetCode(); return true; @@ -418,7 +420,7 @@ void ColumnLowCardinality::SaveBody(OutputStream* output) { void ColumnLowCardinality::Clear() { index_column_->Clear(); dictionary_column_->Clear(); - unique_items_map_.clear(); + unique_items_map_->clear(); if (auto columnNullable = dictionary_column_->As()) { AppendNullItem(); @@ -457,7 +459,7 @@ void ColumnLowCardinality::Swap(Column& other) { dictionary_column_->Swap(*col.dictionary_column_); index_column_.swap(col.index_column_); - unique_items_map_.swap(col.unique_items_map_); + unique_items_map_->swap(*col.unique_items_map_); std::swap(index_type_code_, col.index_type_code_); } @@ -480,7 +482,7 @@ void ColumnLowCardinality::AppendUnsafe(const ItemView & value) { const auto key = computeHashKey(value); const auto initial_index_size = index_column_->Size(); // If the value is unique, then we are going to append it to a dictionary, hence new index is Size(). - auto [iterator, is_new_item] = unique_items_map_.try_emplace(key, dictionary_column_->Size()); + auto [iterator, is_new_item] = unique_items_map_->try_emplace(key, dictionary_column_->Size()); try { // Order is important, adding to dictionary last, since it is much (MUCH!!!!) harder // to remove item from dictionary column than from index column @@ -497,7 +499,7 @@ void ColumnLowCardinality::AppendUnsafe(const ItemView & value) { if (index_column_->Size() != initial_index_size) removeLastIndex(); if (is_new_item) - unique_items_map_.erase(iterator); + unique_items_map_->erase(iterator); throw; } @@ -507,13 +509,13 @@ void ColumnLowCardinality::AppendNullItem() { const auto null_item = GetNullItemForDictionary(dictionary_column_); AppendToDictionary(*dictionary_column_, null_item); - unique_items_map_.emplace(computeHashKey(null_item), 0); + unique_items_map_->emplace(computeHashKey(null_item), 0); } void ColumnLowCardinality::AppendDefaultItem() { const auto defaultItem = GetDefaultItemForDictionary(dictionary_column_); - unique_items_map_.emplace(computeHashKey(defaultItem), dictionary_column_->Size()); + unique_items_map_->emplace(computeHashKey(defaultItem), dictionary_column_->Size()); AppendToDictionary(*dictionary_column_, defaultItem); } diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index 33c339e6..f2d474f3 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -50,7 +50,15 @@ class ColumnLowCardinality : public Column { // so make sure to NOT change address of the dictionary object (with reset(), swap()) or with anything else. ColumnRef dictionary_column_; ColumnRef index_column_; - UniqueItems unique_items_map_; + // Shared so that a wrapped (ColumnLowCardinalityT::Wrap) column shares the same dedup map as its + // source, keeping dictionary/index/map coherent across both holders (same semantics as other columns). + std::shared_ptr unique_items_map_; + +protected: + // Shallow copy: shares dictionary_column_, index_column_ and unique_items_map_ (all shared_ptr), + // copies index_type_code_ and the base type. Used by ColumnLowCardinalityT::Wrap to create a + // non-destructive, storage-sharing view of `col`. + ColumnLowCardinality(const ColumnLowCardinality& col) = default; public: ColumnLowCardinality(ColumnLowCardinality&& col) = default; @@ -136,6 +144,15 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { { } + // Shares the internals of `col` (dictionary, index and dedup map) via shared_ptr, WITHOUT + // stealing or copying them. Used by Wrap to create a non-destructive, storage-sharing view. + explicit ColumnLowCardinalityT(const ColumnLowCardinality& col) + : ColumnLowCardinality(col) + , typed_dictionary_(dynamic_cast(*GetDictionary())) + , type_(GetTypeCode(typed_dictionary_)) + { + } + template explicit ColumnLowCardinalityT(Args &&... args) : ColumnLowCardinalityT(std::make_shared(std::forward(args)...)) @@ -182,23 +199,24 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { } } - /** Create a ColumnLowCardinalityT from a ColumnLowCardinality, without copying data and offsets, but by - * 'stealing' those from `col`. + /** Create a ColumnLowCardinalityT that SHARES the internals of `col` (dictionary, index and + * dedup map) via shared_ptr, WITHOUT stealing or copying them. * - * Ownership of column internals is transferred to returned object, original (argument) object - * MUST NOT BE USED IN ANY WAY, it is only safe to dispose it. + * The original `col` remains fully valid and usable. Both the original and the returned + * wrapper reference the same underlying storage, so mutations through one are visible through + * the other and remain coherent (the dedup map is shared as well). * * Throws an exception if `col` is of wrong type, it is safe to use original col in this case. * This is a static method to make such conversion verbose. */ - static auto Wrap(ColumnLowCardinality&& col) { - return std::make_shared>(std::move(col)); + static auto Wrap(const ColumnLowCardinality& col) { + return std::make_shared>(col); } - static auto Wrap(Column&& col) { return Wrap(std::move(dynamic_cast(col))); } + static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } // Helper to simplify integration with other APIs - static auto Wrap(ColumnRef&& col) { return Wrap(std::move(*col->AsStrict())); } + static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnLowCardinality::Slice(begin, size)); diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index f3ee76c1..00f422c5 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1088,6 +1088,69 @@ TEST(ColumnsCase, ColumnLowCardinalityString_Append_and_Read) { } } +TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_DoesNotStealSource) { + // Populate via the typed column (the only ergonomic per-value insert path), then Wrap it + // through an untyped ColumnRef handle, as with a column received from a query. + auto source = std::make_shared>(); + source->Append("a"); + source->Append("b"); + source->Append("a"); + + ColumnRef untyped = source; + auto wrapped = ColumnLowCardinalityT::Wrap(untyped); + + // Wrapper reads the same data. + ASSERT_EQ(wrapped->Size(), 3u); + EXPECT_EQ(wrapped->At(0), "a"); + EXPECT_EQ(wrapped->At(1), "b"); + EXPECT_EQ(wrapped->At(2), "a"); + + // Source (and the untyped handle) are left intact after Wrap (non-stealing). + EXPECT_NE(untyped, nullptr); + ASSERT_EQ(source->Size(), 3u); + EXPECT_EQ(source->At(0), "a"); + EXPECT_EQ(source->At(2), "a"); + + // Storage (dictionary + index + dedup map) is shared and stays coherent: + // a new unique value appended via the source, and a repeat appended via the wrapper. + const auto dict_before = source->GetDictionarySize(); + source->Append("c"); // new unique -> dictionary grows, visible via the wrapper + wrapped->Append("a"); // repeat -> deduped against the shared map, no dictionary growth + + EXPECT_EQ(source->Size(), 5u); + EXPECT_EQ(wrapped->Size(), 5u); + EXPECT_EQ(wrapped->At(3), "c"); + EXPECT_EQ(wrapped->At(4), "a"); + EXPECT_EQ(source->At(4), "a"); + // "c" added exactly one dictionary entry; "a" added none (shared dedup map). + EXPECT_EQ(source->GetDictionarySize(), dict_before + 1); + EXPECT_EQ(wrapped->GetDictionarySize(), dict_before + 1); +} + +TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_AcceptsLvalue) { + auto source = std::make_shared>(); + source->Append("x"); + source->Append("y"); + + using LC = ColumnLowCardinalityT; + + // Non-const lvalue (untyped base reference), no std::move required. + ColumnLowCardinality& base = *source; + auto w1 = LC::Wrap(base); + EXPECT_EQ(w1->At(0), "x"); + + // Const lvalue. + const ColumnLowCardinality& cref = *source; + auto w2 = LC::Wrap(cref); + EXPECT_EQ(w2->At(1), "y"); + + // Lvalue ColumnRef, no std::move required and not consumed. + ColumnRef ref = source; + auto w3 = LC::Wrap(ref); + EXPECT_EQ(w3->Size(), 2u); + EXPECT_NE(ref, nullptr); +} + TEST(ColumnsCase, ColumnLowCardinalityString_Clear_and_Append) { const size_t items_count = 11; ColumnLowCardinalityT col; From 63aedf078b228fb401869c200bf52f88193d6f8e Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Tue, 28 Jul 2026 18:59:46 +0200 Subject: [PATCH 03/15] Take WrapColumn argument by const reference Now that every wrappable column (including ColumnLowCardinalityT) exposes Wrap(const ColumnRef&), the WrapColumn helper no longer needs a non-const rvalue. Take the column by const reference and forward it without std::move; this also lets ColumnArrayT::Wrap pass col.data_ directly instead of copying it into a temporary ColumnRef. --- clickhouse/columns/array.h | 2 +- clickhouse/columns/utils.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index 726a1b55..f7b9557c 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -139,7 +139,7 @@ class ColumnArrayT : public ColumnArray { * in this case. This is a static method to make such conversion verbose. */ static auto Wrap(const ColumnArray& col) { - auto nested_data = WrapColumn(ColumnRef{col.data_}); + auto nested_data = WrapColumn(col.data_); return std::make_shared>(nested_data, col.offsets_); } diff --git a/clickhouse/columns/utils.h b/clickhouse/columns/utils.h index 0fb8b99b..58b6b6d2 100644 --- a/clickhouse/columns/utils.h +++ b/clickhouse/columns/utils.h @@ -30,9 +30,9 @@ struct HasWrapMethod { }; template -inline std::shared_ptr WrapColumn(ColumnRef&& column) { +inline std::shared_ptr WrapColumn(const ColumnRef& column) { if constexpr (HasWrapMethod::value) { - return T::Wrap(std::move(column)); + return T::Wrap(column); } else { return column->template AsStrict(); } From bfa680bcb7020a1a22c7a6408d0cbb93967c4270 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Tue, 28 Jul 2026 19:56:43 +0200 Subject: [PATCH 04/15] Add non-throwing Wrap overloads with a ValidationError out-parameter Each typed column's Wrap now comes in two forms per input type: - Wrap(col, ValidationError* error): returns nullptr and, if error is non-null, fills *error on a type mismatch; it never throws. - Wrap(col): calls the two-argument form and throws the resulting ValidationError when it returns nullptr. The recursive WrapColumn helper mirrors the same pair. All Wrap mismatches now throw ValidationError consistently (previously Wrap(const Column&) and LowCardinality threw std::bad_cast). Add a default ValidationError constructor so an empty error can be created without a placeholder message, and add tests covering both the nullptr+error and throwing paths. --- clickhouse/columns/array.h | 42 ++++++++++-- clickhouse/columns/lowcardinality.h | 48 +++++++++++-- clickhouse/columns/map.h | 45 +++++++++++-- clickhouse/columns/nullable.h | 52 +++++++++++++-- clickhouse/columns/tuple.h | 62 ++++++++++++++--- clickhouse/columns/utils.h | 24 ++++++- clickhouse/exceptions.h | 5 ++ ut/columns_ut.cpp | 100 ++++++++++++++++++++++++++++ 8 files changed, 342 insertions(+), 36 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index f7b9557c..6958861f 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -135,21 +135,51 @@ class ColumnArrayT : public ColumnArray { * returned wrapper reference the same underlying columns, so mutations through * one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col - * in this case. This is a static method to make such conversion verbose. + * The two-argument overloads are non-throwing: on a type mismatch they return + * nullptr and, if `error` is non-null, assign a description to `*error`. The + * single-argument overloads throw ValidationError on a type mismatch instead. */ - static auto Wrap(const ColumnArray& col) { - auto nested_data = WrapColumn(col.data_); + static std::shared_ptr> Wrap(const ColumnArray& col, ValidationError* error) { + auto nested_data = WrapColumn(col.data_, error); + if (!nested_data) { + return nullptr; + } return std::make_shared>(nested_data, col.offsets_); } + static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { + if (auto* c = dynamic_cast(&col)) { + return Wrap(*c, error); + } + if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Array"); + return nullptr; + } + + // Helper to simplify integration with other APIs + static std::shared_ptr> Wrap(const ColumnRef& col, ValidationError* error) { + return Wrap(*col, error); + } + + static auto Wrap(const ColumnArray& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } + static auto Wrap(const Column& col) { - return Wrap(dynamic_cast(col)); + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; } // Helper to simplify integration with other APIs static auto Wrap(const ColumnRef& col) { - return Wrap(*col->AsStrict()); + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; } /// A single (row) value of the Array-column, i.e. readonly array of items. diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index f2d474f3..0dde052b 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -206,17 +206,55 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { * wrapper reference the same underlying storage, so mutations through one are visible through * the other and remain coherent (the dedup map is shared as well). * - * Throws an exception if `col` is of wrong type, it is safe to use original col in this case. - * This is a static method to make such conversion verbose. + * The two-argument overloads are non-throwing: on a type mismatch they return nullptr and, + * if `error` is non-null, assign a description to `*error`. The single-argument overloads + * throw ValidationError on a type mismatch instead. */ - static auto Wrap(const ColumnLowCardinality& col) { + static std::shared_ptr> Wrap(const ColumnLowCardinality& col, ValidationError* error) { + if (!col.dictionary_column_->template As()) { + if (error) { + *error = ValidationError("Can't wrap LowCardinality column with dictionary of type " + + col.dictionary_column_->GetType().GetName()); + } + return nullptr; + } return std::make_shared>(col); } - static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } + static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { + if (auto* c = dynamic_cast(&col)) { + return Wrap(*c, error); + } + if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as LowCardinality"); + return nullptr; + } + + // Helper to simplify integration with other APIs + static std::shared_ptr> Wrap(const ColumnRef& col, ValidationError* error) { + return Wrap(*col, error); + } + + static auto Wrap(const ColumnLowCardinality& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } + + static auto Wrap(const Column& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } + static auto Wrap(const ColumnRef& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnLowCardinality::Slice(begin, size)); diff --git a/clickhouse/columns/map.h b/clickhouse/columns/map.h index 86ed5fc2..613d5483 100644 --- a/clickhouse/columns/map.h +++ b/clickhouse/columns/map.h @@ -247,17 +247,52 @@ class ColumnMapT : public ColumnMap { * returned wrapper reference the same underlying columns, so mutations through * one are visible through the other. * - * Throws if `col` is of the wrong type. + * The two-argument overloads are non-throwing: on a type mismatch they return + * nullptr and, if `error` is non-null, assign a description to `*error`. The + * single-argument overloads throw ValidationError on a type mismatch instead. */ + static std::shared_ptr> Wrap(const ColumnMap& col, ValidationError* error) { + auto data = ArrayColumnType::Wrap(*col.data_, error); + if (!data) { + return nullptr; + } + return std::make_shared>(data); + } + + static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { + if (auto* c = dynamic_cast(&col)) { + return Wrap(*c, error); + } + if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Map"); + return nullptr; + } + + // Helper to simplify integration with other APIs + static std::shared_ptr> Wrap(const ColumnRef& col, ValidationError* error) { + return Wrap(*col, error); + } + static auto Wrap(const ColumnMap& col) { - auto data = ArrayColumnType::Wrap(*col.data_); - return std::make_shared>(std::move(data)); + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; } - static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } + static auto Wrap(const Column& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } + static auto Wrap(const ColumnRef& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } private: std::shared_ptr typed_data_; diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index a713164b..93cc2c4c 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -116,19 +116,57 @@ class ColumnNullableT : public ColumnNullable { * returned wrapper reference the same underlying columns, so mutations through * one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col - * in this case. This is a static method to make such conversion verbose. + * The two-argument overloads are non-throwing: on a type mismatch they return + * nullptr and, if `error` is non-null, assign a description to `*error`. The + * single-argument overloads throw ValidationError on a type mismatch instead. */ + static std::shared_ptr> Wrap(const ColumnNullable& col, ValidationError* error) { + auto nested = WrapColumn(col.Nested(), error); + if (!nested) { + return nullptr; + } + auto nulls = col.Nulls()->As(); + if (!nulls) { + if (error) *error = ValidationError("Can't wrap Nullable column: unexpected null-map type"); + return nullptr; + } + return std::make_shared>(nested, nulls); + } + + static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { + if (auto* c = dynamic_cast(&col)) { + return Wrap(*c, error); + } + if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Nullable"); + return nullptr; + } + + // Helper to simplify integration with other APIs + static std::shared_ptr> Wrap(const ColumnRef& col, ValidationError* error) { + return Wrap(*col, error); + } + static auto Wrap(const ColumnNullable& col) { - return std::make_shared>( - WrapColumn(col.Nested()), - col.Nulls()->AsStrict()); + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; } - static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } + static auto Wrap(const Column& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } + static auto Wrap(const ColumnRef& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnNullable::Slice(begin, size)); diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index 7f9c534d..f0aacf1a 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -3,6 +3,7 @@ #include "column.h" #include "utils.h" +#include #include namespace clickhouse { @@ -105,21 +106,59 @@ class ColumnTupleT : public ColumnTuple { * returned wrapper reference the same underlying element columns, so mutations * through one are visible through the other. * - * Throws an exception if `col` is of wrong type, it is safe to use original col - * in this case. This is a static method to make such conversion verbose. + * The two-argument overloads are non-throwing: on a type mismatch they return + * nullptr and, if `error` is non-null, assign a description to `*error`. The + * single-argument overloads throw ValidationError on a type mismatch instead. */ - static auto Wrap(const ColumnTuple& col) { + static std::shared_ptr> Wrap(const ColumnTuple& col, ValidationError* error) { if (col.TupleSize() != std::tuple_size_v) { - throw ValidationError("Can't wrap from " + col.GetType().GetName()); + if (error) *error = ValidationError("Can't wrap from " + col.GetType().GetName()); + return nullptr; + } + auto columns = TupleFromColumn(col, error); + const bool all_wrapped = std::apply( + [](const auto&... column) { return (... && static_cast(column)); }, columns); + if (!all_wrapped) { + return nullptr; } auto names = col.Type()->As()->GetItemNames(); - return std::make_shared>(TupleFromColumn(col), std::move(names)); + return std::make_shared>(std::move(columns), std::move(names)); } - static auto Wrap(const Column& col) { return Wrap(dynamic_cast(col)); } + static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { + if (auto* c = dynamic_cast(&col)) { + return Wrap(*c, error); + } + if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Tuple"); + return nullptr; + } // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { return Wrap(*col->AsStrict()); } + static std::shared_ptr> Wrap(const ColumnRef& col, ValidationError* error) { + return Wrap(*col, error); + } + + static auto Wrap(const ColumnTuple& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } + + static auto Wrap(const Column& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } + + // Helper to simplify integration with other APIs + static auto Wrap(const ColumnRef& col) { + ValidationError error; + auto result = Wrap(col, &error); + if (!result) throw error; + return result; + } ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnTuple::Slice(begin, size)); @@ -160,16 +199,19 @@ class ColumnTupleT : public ColumnTuple { } } + // Builds a tuple of the element columns wrapped as their typed counterparts. Any element + // that can't be wrapped is left as a null shared_ptr (and `*error` is set if provided). template > - inline static auto TupleFromColumn([[maybe_unused]] const ColumnTuple& col) { + inline static auto TupleFromColumn([[maybe_unused]] const ColumnTuple& col, + [[maybe_unused]] ValidationError* error) { static_assert(column_index <= std::tuple_size_v); if constexpr (column_index == 0) { return std::make_tuple(); } else { using ColumnType = typename std::tuple_element::type::element_type; - auto column = WrapColumn(col[column_index - 1]); - return std::tuple_cat(TupleFromColumn(col), + auto column = WrapColumn(col[column_index - 1], error); + return std::tuple_cat(TupleFromColumn(col, error), std::make_tuple(std::move(column))); } } diff --git a/clickhouse/columns/utils.h b/clickhouse/columns/utils.h index 58b6b6d2..bfaf56bd 100644 --- a/clickhouse/columns/utils.h +++ b/clickhouse/columns/utils.h @@ -3,6 +3,7 @@ #include #include #include +#include "column.h" namespace clickhouse { @@ -29,13 +30,30 @@ struct HasWrapMethod { static constexpr bool value = !std::is_same()))>::value; }; +// Non-throwing: returns nullptr and (if `error` is non-null) fills `*error` when `column` +// can't be wrapped as T. template -inline std::shared_ptr WrapColumn(const ColumnRef& column) { +inline std::shared_ptr WrapColumn(const ColumnRef& column, ValidationError* error) { if constexpr (HasWrapMethod::value) { - return T::Wrap(column); + return T::Wrap(column, error); } else { - return column->template AsStrict(); + auto result = column->template As(); + if (!result && error) { + *error = ValidationError("Can't wrap column of type " + column->GetType().GetName()); + } + return result; } } +// Throwing convenience wrapper. +template +inline std::shared_ptr WrapColumn(const ColumnRef& column) { + ValidationError error; + auto result = WrapColumn(column, &error); + if (!result) { + throw error; + } + return result; +} + } diff --git a/clickhouse/exceptions.h b/clickhouse/exceptions.h index d2cb639c..00375820 100644 --- a/clickhouse/exceptions.h +++ b/clickhouse/exceptions.h @@ -14,6 +14,11 @@ class Error : public std::runtime_error { // Caused by any user-related code, like invalid column types or arguments passed to any method. class ValidationError : public Error { using Error::Error; + +public: + // Convenience default constructor, useful for creating an empty error to pass as an + // output parameter (e.g. to Column*T::Wrap(col, &error)). + ValidationError() : Error(std::string()) {} }; // Buffers+IO errors, failure to serialize/deserialize, checksum mismatches, etc. diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index 00f422c5..3f7ca5e1 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1653,3 +1653,103 @@ TEST(ColumnsCase, ColumnMapT_Wrap_DoesNotStealSource) { EXPECT_EQ(wrapped_col->Size(), 2u); EXPECT_EQ("xyz", wrapped_col->At(1).At(7)); } + +// --- Wrap error-reporting overloads --------------------------------------------------------- +// The two-argument Wrap(col, ValidationError*) returns nullptr (never throws) on a type +// mismatch; the single-argument Wrap(col) throws ValidationError on the same mismatch. + +TEST(ColumnsCase, ColumnArrayT_Wrap_TypeMismatch) { + using TestArray = ColumnArrayT; + + // Right kind (Array), wrong element type (String instead of UInt64). + ColumnRef bad_element = std::make_shared(std::make_shared()); + // Wrong kind entirely. + ColumnRef not_array = std::make_shared(); + + ValidationError error; + EXPECT_NO_THROW({ + EXPECT_EQ(TestArray::Wrap(bad_element, &error), nullptr); + }); + EXPECT_FALSE(std::string_view(error.what()).empty()); + + // Passing nullptr for the error is allowed and still non-throwing. + EXPECT_NO_THROW({ + EXPECT_EQ(TestArray::Wrap(not_array, nullptr), nullptr); + }); + + // Single-argument overload throws on the same mismatches. + EXPECT_THROW(TestArray::Wrap(bad_element), ValidationError); + EXPECT_THROW(TestArray::Wrap(not_array), ValidationError); + + // Sanity: a matching column wraps fine through both overloads. + ColumnRef good = std::make_shared(std::make_shared()); + EXPECT_NE(TestArray::Wrap(good, nullptr), nullptr); + EXPECT_NE(TestArray::Wrap(good), nullptr); +} + +TEST(ColumnsCase, ColumnNullableT_Wrap_TypeMismatch) { + using TestNullable = ColumnNullableT; + + // Nullable of the wrong nested type. + ColumnRef bad_nested = std::make_shared( + std::make_shared(), std::make_shared()); + ColumnRef not_nullable = std::make_shared(); + + ValidationError error; + EXPECT_EQ(TestNullable::Wrap(bad_nested, &error), nullptr); + EXPECT_FALSE(std::string_view(error.what()).empty()); + EXPECT_EQ(TestNullable::Wrap(not_nullable, nullptr), nullptr); + + EXPECT_THROW(TestNullable::Wrap(bad_nested), ValidationError); + EXPECT_THROW(TestNullable::Wrap(not_nullable), ValidationError); +} + +TEST(ColumnsCase, ColumnTupleT_Wrap_TypeMismatch) { + using TestTuple = ColumnTupleT; + + // Correct arity, wrong element type. + ColumnRef bad_element = std::make_shared(std::vector{ + std::make_shared(), std::make_shared()}); + // Wrong arity. + ColumnRef bad_arity = std::make_shared(std::vector{ + std::make_shared()}); + // Wrong kind. + ColumnRef not_tuple = std::make_shared(); + + ValidationError error; + EXPECT_EQ(TestTuple::Wrap(bad_element, &error), nullptr); + EXPECT_FALSE(std::string_view(error.what()).empty()); + EXPECT_EQ(TestTuple::Wrap(bad_arity, nullptr), nullptr); + EXPECT_EQ(TestTuple::Wrap(not_tuple, nullptr), nullptr); + + EXPECT_THROW(TestTuple::Wrap(bad_element), ValidationError); + EXPECT_THROW(TestTuple::Wrap(bad_arity), ValidationError); + EXPECT_THROW(TestTuple::Wrap(not_tuple), ValidationError); +} + +TEST(ColumnsCase, ColumnMapT_Wrap_TypeMismatch) { + using TestMap = ColumnMapT; + ColumnRef not_map = std::make_shared(); + + ValidationError error; + EXPECT_EQ(TestMap::Wrap(not_map, &error), nullptr); + EXPECT_FALSE(std::string_view(error.what()).empty()); + EXPECT_EQ(TestMap::Wrap(not_map, nullptr), nullptr); + EXPECT_THROW(TestMap::Wrap(not_map), ValidationError); +} + +TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_TypeMismatch) { + using TestLC = ColumnLowCardinalityT; + + // LowCardinality with the wrong (but valid) dictionary type. + ColumnRef bad_dict = std::make_shared(std::make_shared(4)); + ColumnRef not_lc = std::make_shared(); + + ValidationError error; + EXPECT_EQ(TestLC::Wrap(bad_dict, &error), nullptr); + EXPECT_FALSE(std::string_view(error.what()).empty()); + EXPECT_EQ(TestLC::Wrap(not_lc, nullptr), nullptr); + + EXPECT_THROW(TestLC::Wrap(bad_dict), ValidationError); + EXPECT_THROW(TestLC::Wrap(not_lc), ValidationError); +} From a1ca16fc18aeb6c985b9285e5265d9ae73e57f48 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Wed, 29 Jul 2026 11:19:11 +0200 Subject: [PATCH 05/15] Move columns/utils.h into columns/column.h The Wrap-related helpers (SliceVector, HasWrapMethod, WrapColumn) depend only on declarations already provided by column.h, so fold them directly into column.h and drop the separate utils.h header. Since column.h is included everywhere, these utilities are now available without an extra include. Remove the now-redundant #include "utils.h" from the column sources and add a warning message when attempting to import "columns/utils.h" --- clickhouse/columns/array.h | 1 - clickhouse/columns/column.h | 51 ++++++++++++++++++++++++++ clickhouse/columns/enum.cpp | 1 - clickhouse/columns/geo.cpp | 2 -- clickhouse/columns/map.cpp | 1 - clickhouse/columns/nullable.h | 1 - clickhouse/columns/numeric.cpp | 1 - clickhouse/columns/string.cpp | 1 - clickhouse/columns/tuple.h | 1 - clickhouse/columns/utils.h | 66 ++++++---------------------------- clickhouse/columns/uuid.cpp | 1 - 11 files changed, 61 insertions(+), 66 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index 6958861f..742fb32e 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -2,7 +2,6 @@ #include "column.h" #include "numeric.h" -#include "utils.h" #include diff --git a/clickhouse/columns/column.h b/clickhouse/columns/column.h index 475df89a..28d4274e 100644 --- a/clickhouse/columns/column.h +++ b/clickhouse/columns/column.h @@ -4,8 +4,10 @@ #include "../columns/itemview.h" #include "../exceptions.h" +#include #include #include +#include namespace clickhouse { @@ -104,4 +106,53 @@ class Column : public std::enable_shared_from_this { TypeRef type_; }; +template +std::vector SliceVector(const std::vector& vec, size_t begin, size_t len) { + std::vector result; + + if (begin < vec.size()) { + len = std::min(len, vec.size() - begin); + result.assign(vec.begin() + begin, vec.begin() + (begin + len)); + } + + return result; +} + +template +struct HasWrapMethod { +private: + static int detect(...); + template + static decltype(U::Wrap(std::move(std::declval()))) detect(const U&); + +public: + static constexpr bool value = !std::is_same()))>::value; +}; + +// Non-throwing: returns nullptr and (if `error` is non-null) fills `*error` when `column` +// can't be wrapped as T. +template +inline std::shared_ptr WrapColumn(const ColumnRef& column, ValidationError* error) { + if constexpr (HasWrapMethod::value) { + return T::Wrap(column, error); + } else { + auto result = column->template As(); + if (!result && error) { + *error = ValidationError("Can't wrap column of type " + column->GetType().GetName()); + } + return result; + } +} + +// Throwing convenience wrapper. +template +inline std::shared_ptr WrapColumn(const ColumnRef& column) { + ValidationError error; + auto result = WrapColumn(column, &error); + if (!result) { + throw error; + } + return result; +} + } // namespace clickhouse diff --git a/clickhouse/columns/enum.cpp b/clickhouse/columns/enum.cpp index 43fab893..1fa2cba5 100644 --- a/clickhouse/columns/enum.cpp +++ b/clickhouse/columns/enum.cpp @@ -1,5 +1,4 @@ #include "enum.h" -#include "utils.h" #include "../base/input.h" #include "../base/output.h" diff --git a/clickhouse/columns/geo.cpp b/clickhouse/columns/geo.cpp index fa987732..daf70664 100644 --- a/clickhouse/columns/geo.cpp +++ b/clickhouse/columns/geo.cpp @@ -1,7 +1,5 @@ #include "geo.h" -#include "utils.h" - namespace { using namespace ::clickhouse; diff --git a/clickhouse/columns/map.cpp b/clickhouse/columns/map.cpp index 839b0668..a8d58967 100644 --- a/clickhouse/columns/map.cpp +++ b/clickhouse/columns/map.cpp @@ -3,7 +3,6 @@ #include #include "../exceptions.h" -#include "utils.h" namespace { diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index 93cc2c4c..b1896517 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -2,7 +2,6 @@ #include "column.h" #include "numeric.h" -#include "utils.h" #include diff --git a/clickhouse/columns/numeric.cpp b/clickhouse/columns/numeric.cpp index cc33d19d..2fdf393f 100644 --- a/clickhouse/columns/numeric.cpp +++ b/clickhouse/columns/numeric.cpp @@ -1,5 +1,4 @@ #include "numeric.h" -#include "utils.h" #include "../base/wire_format.h" diff --git a/clickhouse/columns/string.cpp b/clickhouse/columns/string.cpp index 50581eea..022aaa43 100644 --- a/clickhouse/columns/string.cpp +++ b/clickhouse/columns/string.cpp @@ -1,5 +1,4 @@ #include "string.h" -#include "utils.h" #include "../base/wire_format.h" diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index f0aacf1a..70c75837 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -1,7 +1,6 @@ #pragma once #include "column.h" -#include "utils.h" #include #include diff --git a/clickhouse/columns/utils.h b/clickhouse/columns/utils.h index bfaf56bd..bcc8892d 100644 --- a/clickhouse/columns/utils.h +++ b/clickhouse/columns/utils.h @@ -1,59 +1,13 @@ #pragma once -#include -#include -#include -#include "column.h" - -namespace clickhouse { - -template -std::vector SliceVector(const std::vector& vec, size_t begin, size_t len) { - std::vector result; - - if (begin < vec.size()) { - len = std::min(len, vec.size() - begin); - result.assign(vec.begin() + begin, vec.begin() + (begin + len)); - } - - return result; -} +// Deprecated header. The Wrap helpers (SliceVector, HasWrapMethod, WrapColumn) +// moved into columns/column.h. Include "clickhouse/columns/column.h" instead. +// This forwarding stub is kept for backward compatibility and will be removed +// in a future release. +#if defined(__GNUC__) || defined(__clang__) || (defined(_MSC_VER) && _MSC_VER >= 1929) +# warning "clickhouse/columns/utils.h is deprecated; include \"clickhouse/columns/column.h\" instead" +#else +# pragma message("clickhouse/columns/utils.h is deprecated; include \"clickhouse/columns/column.h\" instead") +#endif -template -struct HasWrapMethod { -private: - static int detect(...); - template - static decltype(U::Wrap(std::move(std::declval()))) detect(const U&); - -public: - static constexpr bool value = !std::is_same()))>::value; -}; - -// Non-throwing: returns nullptr and (if `error` is non-null) fills `*error` when `column` -// can't be wrapped as T. -template -inline std::shared_ptr WrapColumn(const ColumnRef& column, ValidationError* error) { - if constexpr (HasWrapMethod::value) { - return T::Wrap(column, error); - } else { - auto result = column->template As(); - if (!result && error) { - *error = ValidationError("Can't wrap column of type " + column->GetType().GetName()); - } - return result; - } -} - -// Throwing convenience wrapper. -template -inline std::shared_ptr WrapColumn(const ColumnRef& column) { - ValidationError error; - auto result = WrapColumn(column, &error); - if (!result) { - throw error; - } - return result; -} - -} +#include "column.h" diff --git a/clickhouse/columns/uuid.cpp b/clickhouse/columns/uuid.cpp index fbaff97d..85be389f 100644 --- a/clickhouse/columns/uuid.cpp +++ b/clickhouse/columns/uuid.cpp @@ -1,5 +1,4 @@ #include "uuid.h" -#include "utils.h" #include "../exceptions.h" #include From f1b93a5ac2b82bdcaf4ff0ff7b99889ecee9c2e5 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Wed, 29 Jul 2026 12:31:11 +0200 Subject: [PATCH 06/15] Make As/AsStrict wrap into typed columns when possible Column::As() and Column::AsStrict() now recognise "wrappable" typed columns (those exposing a static Wrap method: ColumnArrayT, ColumnTupleT, ColumnMapT, ColumnNullableT, ColumnLowCardinalityT). For such a T that is not already an exact match, they Wrap the column into a storage-sharing typed view instead of failing: As returns nullptr when neither an exact cast nor a wrap is possible, and AsStrict throws ValidationError. Behaviour for non-wrappable T is unchanged, and an exact cast is always tried first to preserve object identity. The const As() overload deliberately does NOT wrap: wrapping would synthesize a mutable, storage-sharing view from a const column (via const_cast), which is not const-correct. It keeps the plain exact-downcast behaviour. The definitions are moved out-of-line below WrapColumn so the wrapping helpers are in scope. The ColumnLowCardinalityT::Wrap dictionary guard is pinned to a strict dynamic_pointer_cast because the constructor binds the dictionary via a reference dynamic_cast and therefore requires the exact stored type. --- clickhouse/columns/column.h | 63 ++++++++++++++++++++++------- clickhouse/columns/lowcardinality.h | 6 ++- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/clickhouse/columns/column.h b/clickhouse/columns/column.h index 28d4274e..ce2a9ec5 100644 --- a/clickhouse/columns/column.h +++ b/clickhouse/columns/column.h @@ -26,26 +26,25 @@ class Column : public std::enable_shared_from_this { virtual ~Column() {} /// Downcast pointer to the specific column's subtype. + /// + /// If T is a "wrappable" typed column (one exposing a static Wrap method, e.g. + /// ColumnArrayT/ColumnTupleT/ColumnMapT/ColumnNullableT/ColumnLowCardinalityT) and the + /// column is not already exactly T, this attempts to Wrap it as T (a storage-sharing + /// typed view). Returns nullptr when neither an exact cast nor a wrap is possible. + /// (Definitions are out-of-line below, after WrapColumn is declared.) template - inline std::shared_ptr As() { - return std::dynamic_pointer_cast(shared_from_this()); - } + inline std::shared_ptr As(); - /// Downcast pointer to the specific column's subtype. + /// Const overload. Unlike the non-const As(), this does NOT wrap: it only performs an + /// exact downcast and returns nullptr on mismatch (even for a wrappable T). Wrapping is + /// intentionally disabled here because it would synthesize a mutable, storage-sharing + /// view from a const column (requiring a const_cast), which is not const-correct. template - inline std::shared_ptr As() const { - return std::dynamic_pointer_cast(shared_from_this()); - } + inline std::shared_ptr As() const; - /// Downcast pointer to the specific column's subtype. + /// Like As(), but throws ValidationError instead of returning nullptr on failure. template - inline std::shared_ptr AsStrict() { - auto result = std::dynamic_pointer_cast(shared_from_this()); - if (!result) { - throw ValidationError("Can't cast from " + type_->GetName()); - } - return result; - } + inline std::shared_ptr AsStrict(); /// Get type object of the column. inline TypeRef Type() const { return type_; } @@ -155,4 +154,38 @@ inline std::shared_ptr WrapColumn(const ColumnRef& column) { return result; } +template +inline std::shared_ptr Column::As() { + if constexpr (HasWrapMethod::value) { + if (auto exact = std::dynamic_pointer_cast(shared_from_this())) { + return exact; + } + return WrapColumn(shared_from_this(), nullptr); + } else { + return std::dynamic_pointer_cast(shared_from_this()); + } +} + +template +inline std::shared_ptr Column::As() const { + // No wrapping for the const overload (see declaration): exact downcast only. + return std::dynamic_pointer_cast(shared_from_this()); +} + +template +inline std::shared_ptr Column::AsStrict() { + if constexpr (HasWrapMethod::value) { + if (auto exact = std::dynamic_pointer_cast(shared_from_this())) { + return exact; + } + return WrapColumn(shared_from_this()); + } else { + auto result = std::dynamic_pointer_cast(shared_from_this()); + if (!result) { + throw ValidationError("Can't cast from " + type_->GetName()); + } + return result; + } +} + } // namespace clickhouse diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index 0dde052b..c669adf9 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -211,7 +211,11 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { * throw ValidationError on a type mismatch instead. */ static std::shared_ptr> Wrap(const ColumnLowCardinality& col, ValidationError* error) { - if (!col.dictionary_column_->template As()) { + // Strict (non-wrapping) check on purpose: the constructor binds typed_dictionary_ as a + // DictionaryColumnType& via a reference dynamic_cast, so the stored dictionary must be + // exactly DictionaryColumnType. Using the wrapping As<> here could pass for a base + // dictionary and then make that reference cast throw std::bad_cast. + if (!std::dynamic_pointer_cast(col.dictionary_column_)) { if (error) { *error = ValidationError("Can't wrap LowCardinality column with dictionary of type " + col.dictionary_column_->GetType().GetName()); From f03d9575ee568f82d8879ec4a86aa68663b19b5d Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Wed, 29 Jul 2026 13:04:35 +0200 Subject: [PATCH 07/15] Map LowCardinality to base ColumnLowCardinality in the factory CreateColumnByType previously mapped LowCardinality(String)/LowCardinality( FixedString) to the strongly-typed ColumnLowCardinalityT<...>, unlike Array, Nullable, Tuple and Map, which all produce their base column type. Return the base ColumnLowCardinality here as well, so behaviour is consistent; callers can obtain the strongly-typed ColumnLowCardinalityT<...> view on demand via the wrapping Column::As>(). String and FixedString now share a single CreateColumnFromAst-based construction. The Nullable case keeps its own branch (documented) because it must select the ColumnLowCardinality(shared_ptr) ctor overload that seeds the NULL item at dictionary index 0, and the default case keeps an explicit UnimplementedError for unsupported dictionary types. Add a CreateColumnByType.LowCardinality test and type-name round-trip cases. --- clickhouse/columns/factory.cpp | 18 +++++++++++++++--- ut/CreateColumnByType_ut.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/clickhouse/columns/factory.cpp b/clickhouse/columns/factory.cpp index a01304f8..f64109f2 100644 --- a/clickhouse/columns/factory.cpp +++ b/clickhouse/columns/factory.cpp @@ -233,13 +233,25 @@ static ColumnRef CreateColumnFromAst(const TypeAst& ast, CreateColumnByTypeSetti } } else { + // Create the base ColumnLowCardinality (like Array/Nullable/Tuple/Map create their + // base types). Callers can obtain the strongly-typed ColumnLowCardinalityT<...> view + // on demand via Column::As>(), which wraps it. switch (nested.code) { - // TODO (nemkov): update this to maximize code reuse. case Type::String: - return std::make_shared>(); case Type::FixedString: - return std::make_shared>(GetASTChildElement(nested, 0).value); + return std::make_shared(CreateColumnFromAst(nested, settings)); case Type::Nullable: + // Nullable needs its own case (it can't reuse the generic CreateColumnFromAst + // path above) for two reasons: + // 1. Constructor overload: ColumnLowCardinality has a dedicated + // ColumnLowCardinality(shared_ptr) ctor that seeds the + // special NULL item at dictionary index 0 (via AppendNullItem()). We must + // pass a statically-typed shared_ptr so that overload is + // selected; passing a ColumnRef would statically bind to the generic + // ColumnLowCardinality(ColumnRef) ctor, which only appends the default + // item and would omit the null item, producing an incorrect nullable + // dictionary. + // 2. It lets us construct the ColumnNullable with an explicit UInt8 null-map. return std::make_shared( std::make_shared( CreateColumnFromAst(GetASTChildElement(nested, 0), settings), diff --git a/ut/CreateColumnByType_ut.cpp b/ut/CreateColumnByType_ut.cpp index 279a19cc..78225dd0 100644 --- a/ut/CreateColumnByType_ut.cpp +++ b/ut/CreateColumnByType_ut.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -45,6 +46,28 @@ TEST(CreateColumnByType, LowCardinalityAsWrappedColumn) { ASSERT_EQ(Type::FixedString, CreateColumnByType("LowCardinality(FixedString(10000))", create_column_settings)->As()->GetType().GetCode()); } +TEST(CreateColumnByType, LowCardinality) { + // In the default (non-wrapped) mode, LowCardinality(String)/LowCardinality(FixedString) map to + // the base ColumnLowCardinality (like Array/Nullable/Tuple/Map do), and the strongly-typed + // ColumnLowCardinalityT<...> view is obtained on demand via the wrapping As<>. + { + auto col = CreateColumnByType("LowCardinality(String)"); + ASSERT_NE(nullptr, col); + EXPECT_EQ("LowCardinality(String)", col->GetType().GetName()); + // Concrete type is the base ColumnLowCardinality, not ColumnLowCardinalityT<...>. + EXPECT_NE(nullptr, col->As()); + // The wrapping As<> yields the strongly-typed view. + EXPECT_NE(nullptr, col->As>()); + } + { + auto col = CreateColumnByType("LowCardinality(FixedString(10000))"); + ASSERT_NE(nullptr, col); + EXPECT_EQ("LowCardinality(FixedString(10000))", col->GetType().GetName()); + EXPECT_NE(nullptr, col->As()); + EXPECT_NE(nullptr, col->As>()); + } +} + TEST(CreateColumnByType, DateTime) { ASSERT_NE(nullptr, CreateColumnByType("DateTime")); ASSERT_NE(nullptr, CreateColumnByType("DateTime('Europe/Moscow')")); @@ -162,6 +185,8 @@ INSTANTIATE_TEST_SUITE_P(Parametrized, CreateColumnByTypeWithName, ::testing::Va INSTANTIATE_TEST_SUITE_P(Nested, CreateColumnByTypeWithName, ::testing::Values( "Nullable(FixedString(10000))", + "LowCardinality(String)", + "LowCardinality(FixedString(10000))", "Nullable(LowCardinality(FixedString(10000)))", "Array(Nullable(LowCardinality(FixedString(10000))))", "Array(Enum8('ONE' = 1, 'TWO' = 2))" From 20e3058b88b260e9d51744f447b47093f7dcfde6 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Fri, 31 Jul 2026 15:51:51 +0200 Subject: [PATCH 08/15] Wrap single-line if statements in braces --- clickhouse/columns/array.h | 16 ++++++++++++---- clickhouse/columns/lowcardinality.h | 16 ++++++++++++---- clickhouse/columns/map.h | 20 +++++++++++++++----- clickhouse/columns/nullable.h | 20 +++++++++++++++----- clickhouse/columns/tuple.h | 20 +++++++++++++++----- clickhouse/types/types.cpp | 12 +++++++++--- ut/client_ut.cpp | 12 +++++++++--- 7 files changed, 87 insertions(+), 29 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index 742fb32e..54dc9484 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -150,7 +150,9 @@ class ColumnArrayT : public ColumnArray { if (auto* c = dynamic_cast(&col)) { return Wrap(*c, error); } - if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Array"); + if (error) { + *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Array"); + } return nullptr; } @@ -162,14 +164,18 @@ class ColumnArrayT : public ColumnArray { static auto Wrap(const ColumnArray& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } static auto Wrap(const Column& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } @@ -177,7 +183,9 @@ class ColumnArrayT : public ColumnArray { static auto Wrap(const ColumnRef& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index c669adf9..0922defd 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -229,7 +229,9 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { if (auto* c = dynamic_cast(&col)) { return Wrap(*c, error); } - if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as LowCardinality"); + if (error) { + *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as LowCardinality"); + } return nullptr; } @@ -241,14 +243,18 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { static auto Wrap(const ColumnLowCardinality& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } static auto Wrap(const Column& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } @@ -256,7 +262,9 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { static auto Wrap(const ColumnRef& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } diff --git a/clickhouse/columns/map.h b/clickhouse/columns/map.h index 613d5483..ae3059d4 100644 --- a/clickhouse/columns/map.h +++ b/clickhouse/columns/map.h @@ -119,7 +119,9 @@ class ColumnMapT : public ColumnMap { inline auto At(const Key& key) const { auto it = Find(key); - if (it == end()) throw ValidationError("ColumnMap value key not found"); + if (it == end()) { + throw ValidationError("ColumnMap value key not found"); + } return (*it).second; } @@ -263,7 +265,9 @@ class ColumnMapT : public ColumnMap { if (auto* c = dynamic_cast(&col)) { return Wrap(*c, error); } - if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Map"); + if (error) { + *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Map"); + } return nullptr; } @@ -275,14 +279,18 @@ class ColumnMapT : public ColumnMap { static auto Wrap(const ColumnMap& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } static auto Wrap(const Column& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } @@ -290,7 +298,9 @@ class ColumnMapT : public ColumnMap { static auto Wrap(const ColumnRef& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index b1896517..fa40bb98 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -126,7 +126,9 @@ class ColumnNullableT : public ColumnNullable { } auto nulls = col.Nulls()->As(); if (!nulls) { - if (error) *error = ValidationError("Can't wrap Nullable column: unexpected null-map type"); + if (error) { + *error = ValidationError("Can't wrap Nullable column: unexpected null-map type"); + } return nullptr; } return std::make_shared>(nested, nulls); @@ -136,7 +138,9 @@ class ColumnNullableT : public ColumnNullable { if (auto* c = dynamic_cast(&col)) { return Wrap(*c, error); } - if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Nullable"); + if (error) { + *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Nullable"); + } return nullptr; } @@ -148,14 +152,18 @@ class ColumnNullableT : public ColumnNullable { static auto Wrap(const ColumnNullable& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } static auto Wrap(const Column& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } @@ -163,7 +171,9 @@ class ColumnNullableT : public ColumnNullable { static auto Wrap(const ColumnRef& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index 70c75837..eb270af1 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -111,7 +111,9 @@ class ColumnTupleT : public ColumnTuple { */ static std::shared_ptr> Wrap(const ColumnTuple& col, ValidationError* error) { if (col.TupleSize() != std::tuple_size_v) { - if (error) *error = ValidationError("Can't wrap from " + col.GetType().GetName()); + if (error) { + *error = ValidationError("Can't wrap from " + col.GetType().GetName()); + } return nullptr; } auto columns = TupleFromColumn(col, error); @@ -128,7 +130,9 @@ class ColumnTupleT : public ColumnTuple { if (auto* c = dynamic_cast(&col)) { return Wrap(*c, error); } - if (error) *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Tuple"); + if (error) { + *error = ValidationError("Can't wrap column of type " + col.GetType().GetName() + " as Tuple"); + } return nullptr; } @@ -140,14 +144,18 @@ class ColumnTupleT : public ColumnTuple { static auto Wrap(const ColumnTuple& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } static auto Wrap(const Column& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } @@ -155,7 +163,9 @@ class ColumnTupleT : public ColumnTuple { static auto Wrap(const ColumnRef& col) { ValidationError error; auto result = Wrap(col, &error); - if (!result) throw error; + if (!result) { + throw error; + } return result; } diff --git a/clickhouse/types/types.cpp b/clickhouse/types/types.cpp index 9b52255f..58318279 100644 --- a/clickhouse/types/types.cpp +++ b/clickhouse/types/types.cpp @@ -508,12 +508,18 @@ LowCardinalityType::~LowCardinalityType() { // Checks if `name` is a valid plain identifier (must not be quoted). // The condition for this is a match against `^[a-zA-Z_][0-9a-zA-Z_]*$` static bool IsPlainIdentifier(const std::string& name) { - if (name.empty()) return false; + if (name.empty()) { + return false; + } auto is_alpha_or_under = [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; }; auto is_alnum_or_under = [&is_alpha_or_under](char c) { return is_alpha_or_under(c) || (c >= '0' && c <= '9'); }; - if (!is_alpha_or_under(name[0])) return false; + if (!is_alpha_or_under(name[0])) { + return false; + } for (size_t i = 1; i < name.size(); ++i) - if (!is_alnum_or_under(name[i])) return false; + if (!is_alnum_or_under(name[i])) { + return false; + } return true; } diff --git a/ut/client_ut.cpp b/ut/client_ut.cpp index dff1400e..6dce77d1 100644 --- a/ut/client_ut.cpp +++ b/ut/client_ut.cpp @@ -1522,7 +1522,9 @@ TEST_P(ClientCase, InteractiveSelect_Basic) { std::vector values; while (auto block = client_->NextBlock()) { - if (block->GetRowCount() == 0) continue; + if (block->GetRowCount() == 0) { + continue; + } auto col = block->At(0)->AsStrict(); for (size_t i = 0; i < block->GetRowCount(); ++i) { values.push_back(col->At(i)); @@ -1561,7 +1563,9 @@ TEST_P(ClientCase, InteractiveSelect_MultipleBlocks) { size_t block_count = 0; std::vector values; while (auto block = client_->NextBlock()) { - if (block->GetRowCount() == 0) continue; + if (block->GetRowCount() == 0) { + continue; + } EXPECT_LE(block->GetRowCount(), 2u); block_count++; auto col = block->At(0)->AsStrict(); @@ -1587,7 +1591,9 @@ TEST_P(ClientCase, InteractiveSelect_Cancel) { // Consume one block of data, skipping any blocks with 0 rows. size_t rows_before_cancel = 0; while (auto b = client_->NextBlock()) { - if (b->GetRowCount() == 0) continue; + if (b->GetRowCount() == 0) { + continue; + } rows_before_cancel = b->GetRowCount(); break; } From 5efb684fc0dadccb1547f3010e060c0112b32a23 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Fri, 31 Jul 2026 17:15:34 +0200 Subject: [PATCH 09/15] Deduplicate typed-column Wrap overloads via WrappableColumn mixin --- clickhouse/columns/array.h | 31 +++----------------------- clickhouse/columns/column.h | 34 +++++++++++++++++++++++++++++ clickhouse/columns/lowcardinality.h | 31 +++----------------------- clickhouse/columns/map.h | 31 +++----------------------- clickhouse/columns/nullable.h | 31 +++----------------------- clickhouse/columns/tuple.h | 31 +++----------------------- 6 files changed, 49 insertions(+), 140 deletions(-) diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index 54dc9484..eeb695ed 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -106,7 +106,7 @@ class ColumnArray : public Column { }; template -class ColumnArrayT : public ColumnArray { +class ColumnArrayT : public ColumnArray, public WrappableColumn, ColumnArray> { public: class ArrayValueView; using ValueType = ArrayValueView; @@ -161,33 +161,8 @@ class ColumnArrayT : public ColumnArray { return Wrap(*col, error); } - static auto Wrap(const ColumnArray& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - static auto Wrap(const Column& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } + // Throwing single-argument overloads (concrete type / Column& / ColumnRef&). + using WrappableColumn, ColumnArray>::Wrap; /// A single (row) value of the Array-column, i.e. readonly array of items. class ArrayValueView { diff --git a/clickhouse/columns/column.h b/clickhouse/columns/column.h index ce2a9ec5..8b3cb76f 100644 --- a/clickhouse/columns/column.h +++ b/clickhouse/columns/column.h @@ -154,6 +154,40 @@ inline std::shared_ptr WrapColumn(const ColumnRef& column) { return result; } +// Provides the throwing single-argument Wrap overloads for typed columns. +// Derived must supply the non-throwing two-argument Wrap(col, ValidationError*) overloads. +// BaseColumn is Derived's concrete base (e.g. ColumnArray) so an already-typed +// argument takes the direct path instead of the dynamic_cast'ing Column& overload. +template +struct WrappableColumn { + static auto Wrap(const BaseColumn& col) { + ValidationError error; + auto result = Derived::Wrap(col, &error); + if (!result) { + throw error; + } + return result; + } + + static auto Wrap(const Column& col) { + ValidationError error; + auto result = Derived::Wrap(col, &error); + if (!result) { + throw error; + } + return result; + } + + static auto Wrap(const ColumnRef& col) { + ValidationError error; + auto result = Derived::Wrap(col, &error); + if (!result) { + throw error; + } + return result; + } +}; + template inline std::shared_ptr Column::As() { if constexpr (HasWrapMethod::value) { diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index 0922defd..5c93a2c9 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -127,7 +127,7 @@ class ColumnLowCardinality : public Column { /** Type-aware wrapper that provides simple convenience interface for accessing/appending individual items. */ template -class ColumnLowCardinalityT : public ColumnLowCardinality { +class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColumn, ColumnLowCardinality> { DictionaryColumnType& typed_dictionary_; const Type::Code type_; @@ -240,33 +240,8 @@ class ColumnLowCardinalityT : public ColumnLowCardinality { return Wrap(*col, error); } - static auto Wrap(const ColumnLowCardinality& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - static auto Wrap(const Column& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } + // Throwing single-argument overloads (concrete type / Column& / ColumnRef&). + using WrappableColumn, ColumnLowCardinality>::Wrap; ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnLowCardinality::Slice(begin, size)); diff --git a/clickhouse/columns/map.h b/clickhouse/columns/map.h index ae3059d4..7ed64597 100644 --- a/clickhouse/columns/map.h +++ b/clickhouse/columns/map.h @@ -69,7 +69,7 @@ class ColumnMap : public Column { }; template -class ColumnMapT : public ColumnMap { +class ColumnMapT : public ColumnMap, public WrappableColumn, ColumnMap> { public: using KeyColumnType = K; using ValueColumnType = V; @@ -276,33 +276,8 @@ class ColumnMapT : public ColumnMap { return Wrap(*col, error); } - static auto Wrap(const ColumnMap& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - static auto Wrap(const Column& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } + // Throwing single-argument overloads (concrete type / Column& / ColumnRef&). + using WrappableColumn, ColumnMap>::Wrap; private: std::shared_ptr typed_data_; diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index fa40bb98..ee2de32d 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -64,7 +64,7 @@ class ColumnNullable : public Column { }; template -class ColumnNullableT : public ColumnNullable { +class ColumnNullableT : public ColumnNullable, public WrappableColumn, ColumnNullable> { public: using NestedColumnType = ColumnType; using ValueType = std::optional().At(0))>>; @@ -149,33 +149,8 @@ class ColumnNullableT : public ColumnNullable { return Wrap(*col, error); } - static auto Wrap(const ColumnNullable& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - static auto Wrap(const Column& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } + // Throwing single-argument overloads (concrete type / Column& / ColumnRef&). + using WrappableColumn, ColumnNullable>::Wrap; ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnNullable::Slice(begin, size)); diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index eb270af1..e59ce01c 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -62,7 +62,7 @@ class ColumnTuple : public Column { }; template -class ColumnTupleT : public ColumnTuple { +class ColumnTupleT : public ColumnTuple, public WrappableColumn, ColumnTuple> { public: using TupleOfColumns = std::tuple...>; @@ -141,33 +141,8 @@ class ColumnTupleT : public ColumnTuple { return Wrap(*col, error); } - static auto Wrap(const ColumnTuple& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - static auto Wrap(const Column& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } - - // Helper to simplify integration with other APIs - static auto Wrap(const ColumnRef& col) { - ValidationError error; - auto result = Wrap(col, &error); - if (!result) { - throw error; - } - return result; - } + // Throwing single-argument overloads (concrete type / Column& / ColumnRef&). + using WrappableColumn, ColumnTuple>::Wrap; ColumnRef Slice(size_t begin, size_t size) const override { return Wrap(ColumnTuple::Slice(begin, size)); From bb4e22c8928f8215ac2457c6e5cfb1d1fc8c7f9f Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Fri, 31 Jul 2026 17:46:15 +0200 Subject: [PATCH 10/15] Move LowCardinality item type code into the base column The dictionary item type code was cached as a const member (type_) in the ColumnLowCardinalityT subclass. Store it in ColumnLowCardinality::item_type_code_ instead, computed once in Setup(), so the code is shared base state rather than per-subclass data. Remove the now-unused GetTypeCode helper. --- clickhouse/columns/lowcardinality.cpp | 9 +++++++++ clickhouse/columns/lowcardinality.h | 23 ++++++----------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/clickhouse/columns/lowcardinality.cpp b/clickhouse/columns/lowcardinality.cpp index 2a3bd6b0..d717a081 100644 --- a/clickhouse/columns/lowcardinality.cpp +++ b/clickhouse/columns/lowcardinality.cpp @@ -188,6 +188,15 @@ void ColumnLowCardinality::Reserve(size_t new_cap) { } void ColumnLowCardinality::Setup(ColumnRef dictionary_column) { + // Cache the dictionary item type code: for a Nullable dictionary it is the code of the + // innermost non-nullable type, otherwise the dictionary's own type code. The dictionary + // type is invariant after construction, so this stays valid. + if (auto nullable = dictionary_column_->As()) { + item_type_code_ = nullable->Nested()->Type()->GetCode(); + } else { + item_type_code_ = dictionary_column_->Type()->GetCode(); + } + AppendDefaultItem(); if (dictionary_column->Size() != 0) { diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index 5c93a2c9..95340790 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -119,6 +119,10 @@ class ColumnLowCardinality : public Column { void AppendDefaultItem(); Type::Code index_type_code_; + // Dictionary item type code (for a Nullable dictionary, the code of the innermost + // non-nullable type; otherwise the dictionary's own type code). Computed once in Setup() + // and used by ColumnLowCardinalityT to build ItemView on Append. + Type::Code item_type_code_; public: static details::LowCardinalityHashKey computeHashKey(const ItemView &); @@ -130,7 +134,6 @@ template class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColumn, ColumnLowCardinality> { DictionaryColumnType& typed_dictionary_; - const Type::Code type_; public: using WrappedColumnType = DictionaryColumnType; @@ -140,7 +143,6 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum explicit ColumnLowCardinalityT(ColumnLowCardinality&& col) : ColumnLowCardinality(std::move(col)) , typed_dictionary_(dynamic_cast(*GetDictionary())) - , type_(GetTypeCode(typed_dictionary_)) { } @@ -149,7 +151,6 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum explicit ColumnLowCardinalityT(const ColumnLowCardinality& col) : ColumnLowCardinality(col) , typed_dictionary_(dynamic_cast(*GetDictionary())) - , type_(GetTypeCode(typed_dictionary_)) { } @@ -162,7 +163,6 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum explicit ColumnLowCardinalityT(std::shared_ptr dictionary_col) : ColumnLowCardinality(dictionary_col) , typed_dictionary_(dynamic_cast(*GetDictionary())) - , type_(GetTypeCode(typed_dictionary_)) {} /// Extended interface to simplify reading/adding individual items. @@ -183,12 +183,12 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum inline void Append(const ValueType & value) { if constexpr (IsNullable) { if (value.has_value()) { - AppendUnsafe(ItemView{type_, *value}); + AppendUnsafe(ItemView{item_type_code_, *value}); } else { AppendUnsafe(ItemView{}); } } else { - AppendUnsafe(ItemView{type_, value}); + AppendUnsafe(ItemView{item_type_code_, value}); } } @@ -248,17 +248,6 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum } ColumnRef CloneEmpty() const override { return Wrap(ColumnLowCardinality::CloneEmpty()); } - -private: - - template - static auto GetTypeCode(T& column) { - if constexpr (IsNullable) { - return GetTypeCode(*column.Nested()->template AsStrict()); - } else { - return column.Type()->GetCode(); - } - } }; } From 1a7e6285fe36e08c2073ae7b4ac839d9547069e9 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Thu, 6 Aug 2026 18:52:30 +0200 Subject: [PATCH 11/15] Make composite Swap and Tuple Clear alias-coherent The custom Swap of Array/Nullable/Tuple/Map rebound their member shared_ptr slots, so As<>/Wrap views (which hold their own handles to the same sub-columns) diverged after a swap. ColumnTuple::Clear had the same problem: columns_.clear() dropped the element handles, corrupting the tuple structure and leaving views with stale data (also the root cause of the Map clear bug). Swap the sub-columns' contents in place (via their own Swap) and clear each element in place, preserving sub-object identity so every view stays coherent, including through deep nesting. The typed wrapper overrides no longer swap their cached typed handles. Tuple Swap now guards against differing arity, and mismatched element types throw via the nested Swap instead of silently producing a Type()/data mismatch. LowCardinality Swap is intentionally left as-is; making it coherent needs the shared-state refactor and is handled separately. --- clickhouse/columns/array.cpp | 7 +- clickhouse/columns/array.h | 5 +- clickhouse/columns/map.cpp | 4 +- clickhouse/columns/map.h | 5 +- clickhouse/columns/nullable.cpp | 6 +- clickhouse/columns/nullable.h | 5 +- clickhouse/columns/tuple.cpp | 15 ++- clickhouse/columns/tuple.h | 5 +- ut/columns_ut.cpp | 212 ++++++++++++++++++++++++++++++++ 9 files changed, 249 insertions(+), 15 deletions(-) diff --git a/clickhouse/columns/array.cpp b/clickhouse/columns/array.cpp index 77f8f7f9..eb90e330 100644 --- a/clickhouse/columns/array.cpp +++ b/clickhouse/columns/array.cpp @@ -138,8 +138,11 @@ size_t ColumnArray::Size() const { void ColumnArray::Swap(Column& other) { auto & col = dynamic_cast(other); - data_.swap(col.data_); - offsets_.swap(col.offsets_); + // Swap sub-column CONTENTS in place (never rebind the shared_ptr slots), so the data and + // offsets objects keep their identity and any As<>/Wrap views of this column stay coherent. + // The nested Swap also type-checks the element columns and throws on a mismatch. + data_->Swap(*col.data_); + offsets_->Swap(*col.offsets_); } void ColumnArray::OffsetsIncrease(size_t n) { diff --git a/clickhouse/columns/array.h b/clickhouse/columns/array.h index eeb695ed..665c22d5 100644 --- a/clickhouse/columns/array.h +++ b/clickhouse/columns/array.h @@ -326,8 +326,9 @@ class ColumnArrayT : public ColumnArray, public WrappableColumn &>(other); - typed_nested_data_.swap(col.typed_nested_data_); - ColumnArray::Swap(other); + // Base swaps sub-column contents in place, preserving object identity, so the cached + // typed_nested_data_ still points at the correct object and must NOT be repointed. + ColumnArray::Swap(col); } private: diff --git a/clickhouse/columns/map.cpp b/clickhouse/columns/map.cpp index a8d58967..d1dcfd93 100644 --- a/clickhouse/columns/map.cpp +++ b/clickhouse/columns/map.cpp @@ -76,7 +76,9 @@ ColumnRef ColumnMap::CloneEmpty() const { void ColumnMap::Swap(Column& other) { auto& col = dynamic_cast(other); - data_.swap(col.data_); + // Swap the backing array's CONTENTS in place (never rebind the shared_ptr slot), so the + // array/tuple/leaf objects keep their identity and any As<>/Wrap views stay coherent. + data_->Swap(*col.data_); } ColumnRef ColumnMap::GetAsColumn(size_t n) const { diff --git a/clickhouse/columns/map.h b/clickhouse/columns/map.h index 7ed64597..a9b97062 100644 --- a/clickhouse/columns/map.h +++ b/clickhouse/columns/map.h @@ -96,8 +96,9 @@ class ColumnMapT : public ColumnMap, public WrappableColumn, Co void Swap(Column& other) override { auto& col = dynamic_cast&>(other); - col.typed_data_.swap(typed_data_); - ColumnMap::Swap(other); + // Base swaps the backing array's contents in place, preserving object identity, so the + // cached typed_data_ still points at the correct object and must NOT be repointed. + ColumnMap::Swap(col); } /// A single (row) value of the Map-column i.e. read-only map. diff --git a/clickhouse/columns/nullable.cpp b/clickhouse/columns/nullable.cpp index 23940c12..bb9f295e 100644 --- a/clickhouse/columns/nullable.cpp +++ b/clickhouse/columns/nullable.cpp @@ -95,8 +95,10 @@ void ColumnNullable::Swap(Column& other) { if (!nested_->Type()->IsEqual(col.nested_->Type())) throw ValidationError("Can't swap() Nullable columns of different types."); - nested_.swap(col.nested_); - nulls_.swap(col.nulls_); + // Swap sub-column CONTENTS in place (never rebind the shared_ptr slots), so the nested and + // nulls objects keep their identity and any As<>/Wrap views of this column stay coherent. + nested_->Swap(*col.nested_); + nulls_->Swap(*col.nulls_); } ItemView ColumnNullable::GetItem(size_t index) const { diff --git a/clickhouse/columns/nullable.h b/clickhouse/columns/nullable.h index ee2de32d..eb9997e9 100644 --- a/clickhouse/columns/nullable.h +++ b/clickhouse/columns/nullable.h @@ -160,8 +160,9 @@ class ColumnNullableT : public ColumnNullable, public WrappableColumn&>(other); - typed_nested_data_.swap(col.typed_nested_data_); - ColumnNullable::Swap(other); + // Base swaps sub-column contents in place, preserving object identity, so the cached + // typed_nested_data_ still points at the correct object and must NOT be repointed. + ColumnNullable::Swap(col); } private: diff --git a/clickhouse/columns/tuple.cpp b/clickhouse/columns/tuple.cpp index 72d206b6..1712c40e 100644 --- a/clickhouse/columns/tuple.cpp +++ b/clickhouse/columns/tuple.cpp @@ -138,12 +138,23 @@ void ColumnTuple::SaveBody(OutputStream* output) { } void ColumnTuple::Clear() { - columns_.clear(); + // Clear each element column in place (do NOT drop the element handles): this preserves the + // tuple structure and the element objects' identity, so any As<>/Wrap views stay coherent. + for (auto & column : columns_) { + column->Clear(); + } } void ColumnTuple::Swap(Column& other) { auto & col = dynamic_cast(other); - columns_.swap(col.columns_); + if (columns_.size() != col.columns_.size()) + throw ValidationError("Can't swap() Tuple columns of different sizes."); + // Swap each element's CONTENTS in place (never rebind the columns_ vector), so element + // objects keep their identity and any As<>/Wrap views of this column stay coherent. + // The nested Swap also type-checks each element and throws on a mismatch. + for (size_t i = 0; i < columns_.size(); ++i) { + columns_[i]->Swap(*col.columns_[i]); + } } } diff --git a/clickhouse/columns/tuple.h b/clickhouse/columns/tuple.h index e59ce01c..0e8c396f 100644 --- a/clickhouse/columns/tuple.h +++ b/clickhouse/columns/tuple.h @@ -152,8 +152,9 @@ class ColumnTupleT : public ColumnTuple, public WrappableColumn&>(other); - typed_columns_.swap(col.typed_columns_); - ColumnTuple::Swap(other); + // Base swaps element contents in place, preserving object identity, so the cached + // typed_columns_ still point at the correct objects and must NOT be repointed. + ColumnTuple::Swap(col); } private: diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index 3f7ca5e1..11d0732a 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1507,6 +1507,218 @@ TEST(ColumnsCase, ColumnTupleT_Wrap_PreservesNames) { EXPECT_EQ(wrapped->Type()->GetName(), "Tuple(id UInt64, name String)"); } +// --- Swap/Clear must stay coherent with As<>/Wrap views (contents swapped/cleared in place) --- + +TEST(ColumnsCase, ColumnArrayT_Swap_VisibleThroughAlias) { + auto a = std::make_shared>(); + a->Append(std::vector{1, 2, 3}); + auto b = std::make_shared>(); + b->Append(std::vector{7, 8}); + + // Alias sharing a's storage. + auto alias_a = ColumnArrayT::Wrap(a); + ASSERT_NE(alias_a, nullptr); + + a->Swap(*b); + + // a now holds b's row, b holds a's row. + ASSERT_EQ(a->Size(), 1u); + EXPECT_EQ(a->At(0).size(), 2u); + ASSERT_EQ(b->Size(), 1u); + EXPECT_EQ(b->At(0).size(), 3u); + + // The swap is visible through the alias (sub-object identity preserved). + ASSERT_EQ(alias_a->Size(), 1u); + EXPECT_EQ(alias_a->At(0).size(), 2u); + EXPECT_EQ(alias_a->At(0)[0], 7u); +} + +TEST(ColumnsCase, ColumnNullableT_Swap_VisibleThroughAlias) { + auto a = std::make_shared>(); + a->Append(1); + a->Append(std::nullopt); + auto b = std::make_shared>(); + b->Append(42); + + auto alias_a = ColumnNullableT::Wrap(a); + ASSERT_NE(alias_a, nullptr); + + a->Swap(*b); + + ASSERT_EQ(a->Size(), 1u); + EXPECT_EQ(a->At(0), std::optional(42)); + ASSERT_EQ(b->Size(), 2u); + + // Visible through the alias. + ASSERT_EQ(alias_a->Size(), 1u); + EXPECT_EQ(alias_a->At(0), std::optional(42)); +} + +TEST(ColumnsCase, ColumnTupleT_Swap_VisibleThroughAlias) { + ColumnTuple a({std::make_shared(), std::make_shared()}); + a[0]->AsStrict()->Append(1); + a[1]->AsStrict()->Append("a"); + + ColumnTuple b({std::make_shared(), std::make_shared()}); + b[0]->AsStrict()->Append(2); + b[1]->AsStrict()->Append("b"); + + using TestTuple = ColumnTupleT; + auto alias_a = TestTuple::Wrap(a); + ASSERT_NE(alias_a, nullptr); + + a.Swap(b); + + EXPECT_EQ(a.Size(), 1u); + EXPECT_EQ(a[0]->AsStrict()->At(0), 2u); + EXPECT_EQ(b[0]->AsStrict()->At(0), 1u); + + // Visible through the alias. + EXPECT_EQ(alias_a->At(0), std::make_tuple(uint64_t(2), std::string_view("b"))); +} + +TEST(ColumnsCase, ColumnTuple_Swap_DifferentSizeThrows) { + ColumnTuple a({std::make_shared(), std::make_shared()}); + ColumnTuple b({std::make_shared()}); + EXPECT_THROW(a.Swap(b), ValidationError); +} + +TEST(ColumnsCase, ColumnTuple_Clear_PreservesStructure_AndAlias) { + ColumnTuple col({std::make_shared(), std::make_shared()}); + col[0]->AsStrict()->Append(1); + col[1]->AsStrict()->Append("a"); + + using TestTuple = ColumnTupleT; + auto alias = TestTuple::Wrap(col); + ASSERT_NE(alias, nullptr); + ASSERT_EQ(alias->Size(), 1u); + + col.Clear(); + + // Structure is preserved (columns not dropped) and data is cleared in place. + EXPECT_EQ(col.Size(), 0u); + EXPECT_EQ(col.TupleSize(), 2u); + // Clear propagates to the alias. + EXPECT_EQ(alias->Size(), 0u); + + // Re-appending through the original element columns is visible via the alias. + col[0]->AsStrict()->Append(2); + col[1]->AsStrict()->Append("b"); + ASSERT_EQ(alias->Size(), 1u); + EXPECT_EQ(alias->At(0), std::make_tuple(uint64_t(2), std::string_view("b"))); +} + +TEST(ColumnsCase, ColumnMapT_Swap_VisibleThroughAlias) { + using TestMap = ColumnMapT; + + auto a = std::make_shared(std::make_shared(), std::make_shared()); + a->Append(std::map{{"x", 1}}); + auto b = std::make_shared(std::make_shared(), std::make_shared()); + b->Append(std::map{{"y", 2}, {"z", 3}}); + + auto alias_a = TestMap::Wrap(a); + ASSERT_NE(alias_a, nullptr); + + a->Swap(*b); + + ASSERT_EQ(a->Size(), 1u); + EXPECT_EQ(a->At(0).size(), 2u); + ASSERT_EQ(b->Size(), 1u); + EXPECT_EQ(b->At(0).size(), 1u); + + // Visible through the alias. + ASSERT_EQ(alias_a->Size(), 1u); + EXPECT_EQ(alias_a->At(0).size(), 2u); + EXPECT_EQ(alias_a->At(0)["y"], 2u); +} + +// --- Deep-nested aliases: in-place Swap/Clear must recurse to shared leaf objects --- + +namespace { +// Builds a single-row Map(UInt64, Array(Nullable(String))): one map row with one entry +// {key -> arr}. Uses the single-offset ColumnArray ctor so the backing array has exactly one row. +std::shared_ptr MakeDeepMapRow(uint64_t key, std::vector> arr) { + auto keys = std::make_shared(); + auto vals = std::make_shared>>(); + keys->Append(key); + vals->Append(arr); + auto tuple = std::make_shared(std::vector{keys, vals}); + return std::make_shared(std::make_shared(tuple)); +} +} + +TEST(ColumnsCase, DeepMap_Swap_VisibleThroughAlias) { + using DeepMap = ColumnMapT>>; + + auto a = MakeDeepMapRow(1, {std::string("a"), std::string("b"), std::string("c")}); + auto b = MakeDeepMapRow(1, {std::string("x"), std::nullopt}); + + auto alias_a = DeepMap::Wrap(a); // deep typed view created BEFORE the swap + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->At(0).At(1).Size(), 3u); + + a->Swap(*b); + + // The swap recurses down to the shared leaf columns, so the pre-existing alias now + // reflects B's data: the value-array flips size 3 -> 2 and the null survives. + auto arr = alias_a->At(0).At(1); + ASSERT_EQ(arr.Size(), 2u); + EXPECT_EQ(arr[0], std::optional("x")); + EXPECT_EQ(arr[1], std::optional{}); + + // b now holds a's original data. + auto arr_b = DeepMap::Wrap(b)->At(0).At(1); + EXPECT_EQ(arr_b.Size(), 3u); + EXPECT_EQ(arr_b[0], std::optional("a")); +} + +TEST(ColumnsCase, DeepMap_Clear_VisibleThroughAlias_NoStaleEntries) { + using DeepMap = ColumnMapT>>; + + auto a = MakeDeepMapRow(1, {std::string("a"), std::string("b"), std::string("c")}); + auto alias_a = DeepMap::Wrap(a); + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->Size(), 1u); + + a->Clear(); + + // Clear recurses to the shared leaves in place, so the alias goes empty too. + EXPECT_EQ(a->Size(), 0u); + EXPECT_EQ(alias_a->Size(), 0u); + + // Re-appending a fresh row must not resurface the pre-clear ["a","b","c"] entry. + a->Append(MakeDeepMapRow(1, {std::string("z")})); + ASSERT_EQ(alias_a->Size(), 1u); + auto arr = alias_a->At(0).At(1); + ASSERT_EQ(arr.Size(), 1u); + EXPECT_EQ(arr[0], std::optional("z")); +} + +TEST(ColumnsCase, DeepNestedArray_Swap_VisibleThroughAlias) { + using DeepArray = ColumnArrayT>>; + + auto a = std::make_shared(); + a->Append(std::vector>>{ + {std::string("a")}, {std::string("b"), std::string("c")}}); + auto b = std::make_shared(); + b->Append(std::vector>>{ + {std::string("x"), std::nullopt}}); + + auto alias_a = DeepArray::Wrap(a); // created BEFORE the swap + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->At(0).Size(), 2u); + + a->Swap(*b); + + // Alias reflects B's data all the way down to the nullable-string leaves. + auto outer = alias_a->At(0); + ASSERT_EQ(outer.Size(), 1u); + auto inner = outer.At(0); + ASSERT_EQ(inner.Size(), 2u); + EXPECT_EQ(inner[0], std::optional("x")); + EXPECT_EQ(inner[1], std::optional{}); +} + TEST(ColumnsCase, ColumnTupleT_Slice_PreservesNames) { using TestTuple = ColumnTupleT; From 95a6fbdc2c5b9fec75b763a0ec3842d4eda679c5 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Thu, 6 Aug 2026 18:59:46 +0200 Subject: [PATCH 12/15] Make LowCardinality Swap and LoadBody alias-coherent ColumnLowCardinality::Swap and LoadBody rebound the index_column_ shared_ptr slot (and reassigned index_type_code_ by value), so As<>/Wrap views - which hold their own copies of those members - kept a stale index paired with the freshly swapped/loaded dictionary, returning wrong values or reading out of bounds. Unlike the dictionary and dedup map (mutated in place and thus already shared), the index column must be REPLACED because its numeric width can change, so it can't be swapped content-wise. Bundle the index column and its cached type code into an IndexState held behind a single shared_ptr; wrapped views share the bundle, so replacing the index (LoadBody) or swapping the bundle contents (Swap) is visible through every holder. The dictionary is still swapped in place to keep its object address stable for ColumnLowCardinalityT's typed_dictionary_. Adds wrap-then-Swap and wrap-then-Load alias-coherence tests. --- clickhouse/columns/lowcardinality.cpp | 75 ++++++++------- clickhouse/columns/lowcardinality.h | 28 ++++-- ut/columns_ut.cpp | 126 ++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 43 deletions(-) diff --git a/clickhouse/columns/lowcardinality.cpp b/clickhouse/columns/lowcardinality.cpp index d717a081..61efb903 100644 --- a/clickhouse/columns/lowcardinality.cpp +++ b/clickhouse/columns/lowcardinality.cpp @@ -158,9 +158,8 @@ namespace clickhouse { ColumnLowCardinality::ColumnLowCardinality(ColumnRef dictionary_column) : Column(Type::CreateLowCardinality(dictionary_column->Type())), dictionary_column_(dictionary_column->CloneEmpty()), // safe way to get an column of the same type. - index_column_(std::make_shared()), - unique_items_map_(std::make_shared()), - index_type_code_(Type::UInt32) + index_(std::make_shared(IndexState{std::make_shared(), Type::UInt32})), + unique_items_map_(std::make_shared()) { Setup(dictionary_column); } @@ -168,9 +167,8 @@ ColumnLowCardinality::ColumnLowCardinality(ColumnRef dictionary_column) ColumnLowCardinality::ColumnLowCardinality(std::shared_ptr dictionary_column) : Column(Type::CreateLowCardinality(dictionary_column->Type())), dictionary_column_(dictionary_column->CloneEmpty()), // safe way to get an column of the same type. - index_column_(std::make_shared()), - unique_items_map_(std::make_shared()), - index_type_code_(Type::UInt32) + index_(std::make_shared(IndexState{std::make_shared(), Type::UInt32})), + unique_items_map_(std::make_shared()) { AppendNullItem(); Setup(dictionary_column); @@ -184,7 +182,7 @@ void ColumnLowCardinality::Reserve(size_t new_cap) { // NOTE(vnemkov): Formula below (`ceil(sqrt(x))`) is a gut-feeling-good-enough estimation, // feel free to replace/adjust if you have better one suported by actual data. dictionary_column_->Reserve(static_cast(ceil(sqrt(static_cast(new_cap))))); - index_column_->Reserve(new_cap + 2); // + 1 for null item (at pos 0), + 1 for default item (at pos 1) + index_->column->Reserve(new_cap + 2); // + 1 for null item (at pos 0), + 1 for default item (at pos 1) } void ColumnLowCardinality::Setup(ColumnRef dictionary_column) { @@ -200,7 +198,7 @@ void ColumnLowCardinality::Setup(ColumnRef dictionary_column) { AppendDefaultItem(); if (dictionary_column->Size() != 0) { - // Add values, updating index_column_ and unique_items_map_. + // Add values, updating the index column and unique_items_map_. // TODO: it would be possible to eliminate copying // by adding InsertUnsafe(pos, ItemView) method to a Column @@ -213,15 +211,15 @@ void ColumnLowCardinality::Setup(ColumnRef dictionary_column) { } std::uint64_t ColumnLowCardinality::getDictionaryIndex(std::uint64_t item_index) const { - switch (index_type_code_) { + switch (index_->type_code) { case Type::UInt8: - return static_cast(*index_column_)[item_index]; + return static_cast(*index_->column)[item_index]; case Type::UInt16: - return static_cast(*index_column_)[item_index]; + return static_cast(*index_->column)[item_index]; case Type::UInt32: - return static_cast(*index_column_)[item_index]; + return static_cast(*index_->column)[item_index]; case Type::UInt64: - return static_cast(*index_column_)[item_index]; + return static_cast(*index_->column)[item_index]; default: throw ValidationError("Invalid index column type"); } @@ -229,18 +227,18 @@ std::uint64_t ColumnLowCardinality::getDictionaryIndex(std::uint64_t item_index) void ColumnLowCardinality::appendIndex(std::uint64_t item_index) { // TODO (nemkov): handle case when index should go from UInt8 to UInt16, etc. - switch (index_type_code_) { + switch (index_->type_code) { case Type::UInt8: - static_cast(*index_column_).Append(static_cast(item_index)); + static_cast(*index_->column).Append(static_cast(item_index)); break; case Type::UInt16: - static_cast(*index_column_).Append(static_cast(item_index)); + static_cast(*index_->column).Append(static_cast(item_index)); break; case Type::UInt32: - static_cast(*index_column_).Append(static_cast(item_index)); + static_cast(*index_->column).Append(static_cast(item_index)); break; case Type::UInt64: - static_cast(*index_column_).Append(static_cast(item_index)); + static_cast(*index_->column).Append(static_cast(item_index)); break; default: throw ValidationError("Invalid index column type"); @@ -248,24 +246,24 @@ void ColumnLowCardinality::appendIndex(std::uint64_t item_index) { } void ColumnLowCardinality::removeLastIndex() { - switch (index_type_code_) { + switch (index_->type_code) { case Type::UInt8: { - auto& col = static_cast(*index_column_); + auto& col = static_cast(*index_->column); col.Erase(col.Size() - 1); break; } case Type::UInt16: { - auto& col = static_cast(*index_column_); + auto& col = static_cast(*index_->column); col.Erase(col.Size() - 1); break; } case Type::UInt32: { - auto& col = static_cast(*index_column_); + auto& col = static_cast(*index_->column); col.Erase(col.Size() - 1); break; } case Type::UInt64: { - auto& col = static_cast(*index_column_); + auto& col = static_cast(*index_->column); col.Erase(col.Size() - 1); break; } @@ -391,9 +389,11 @@ bool ColumnLowCardinality::LoadBody(InputStream* input, size_t rows) { auto [new_dictionary, new_index, new_unique_items_map] = ::Load(dictionary_column_->CloneEmpty(), *input, rows); dictionary_column_->Swap(*new_dictionary); - index_column_.swap(new_index); + // Reassign the index column inside the SHARED bundle (not a local member slot) so the + // new index and its type code are visible through every wrapped view. + index_->column = std::move(new_index); + index_->type_code = index_->column->Type()->GetCode(); unique_items_map_->swap(new_unique_items_map); - index_type_code_ = index_column_->Type()->GetCode(); return true; } catch (...) { @@ -408,7 +408,7 @@ void ColumnLowCardinality::SavePrefix(OutputStream* output) { void ColumnLowCardinality::SaveBody(OutputStream* output) { const uint64_t index_serialization_type = - static_cast(indexTypeFromIndexColumn(*index_column_)) | IndexFlag::HasAdditionalKeysBit; + static_cast(indexTypeFromIndexColumn(*index_->column)) | IndexFlag::HasAdditionalKeysBit; WireFormat::WriteFixed(*output, index_serialization_type); const uint64_t number_of_keys = dictionary_column_->Size(); @@ -420,14 +420,14 @@ void ColumnLowCardinality::SaveBody(OutputStream* output) { dictionary_column_->SaveBody(output); } - const uint64_t number_of_rows = index_column_->Size(); + const uint64_t number_of_rows = index_->column->Size(); WireFormat::WriteFixed(*output, number_of_rows); - index_column_->SaveBody(output); + index_->column->SaveBody(output); } void ColumnLowCardinality::Clear() { - index_column_->Clear(); + index_->column->Clear(); dictionary_column_->Clear(); unique_items_map_->clear(); @@ -438,7 +438,7 @@ void ColumnLowCardinality::Clear() { } size_t ColumnLowCardinality::Size() const { - return index_column_->Size(); + return index_->column->Size(); } ColumnRef ColumnLowCardinality::Slice(size_t begin, size_t len) const { @@ -467,9 +467,16 @@ void ColumnLowCardinality::Swap(Column& other) { // (needed for ColumnLowCardinalityT) dictionary_column_->Swap(*col.dictionary_column_); - index_column_.swap(col.index_column_); + // Swap the index bundle CONTENTS (column + type code) in place. Both sides' wrapped views + // share their respective IndexState object, so the swap is visible through every holder. + // (The index column can't be swapped content-wise like the dictionary: the two columns may + // have different numeric widths, hence the shared-bundle indirection.) + std::swap(*index_, *col.index_); unique_items_map_->swap(*col.unique_items_map_); - std::swap(index_type_code_, col.index_type_code_); + + // NOTE: item_type_code_ is intentionally NOT swapped. It is derived solely from the dictionary + // type, and the guard above requires both columns to have the same dictionary type, so it is + // identical on both sides - swapping would be a no-op. } ItemView ColumnLowCardinality::GetItem(size_t index) const { @@ -489,7 +496,7 @@ ItemView ColumnLowCardinality::GetItem(size_t index) const { // No checks regarding value type or validity of value is made. void ColumnLowCardinality::AppendUnsafe(const ItemView & value) { const auto key = computeHashKey(value); - const auto initial_index_size = index_column_->Size(); + const auto initial_index_size = index_->column->Size(); // If the value is unique, then we are going to append it to a dictionary, hence new index is Size(). auto [iterator, is_new_item] = unique_items_map_->try_emplace(key, dictionary_column_->Size()); try { @@ -505,7 +512,7 @@ void ColumnLowCardinality::AppendUnsafe(const ItemView & value) { } } catch (...) { - if (index_column_->Size() != initial_index_size) + if (index_->column->Size() != initial_index_size) removeLastIndex(); if (is_new_item) unique_items_map_->erase(iterator); diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index 95340790..b5fbba2c 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -49,14 +49,30 @@ class ColumnLowCardinality : public Column { // IMPLEMENTATION NOTE: ColumnLowCardinalityT takes reference to underlying dictionary column object, // so make sure to NOT change address of the dictionary object (with reset(), swap()) or with anything else. ColumnRef dictionary_column_; - ColumnRef index_column_; + + // The index column and its cached type code, bundled behind one shared_ptr. A wrapped view + // (ColumnLowCardinalityT::Wrap) shares this bundle, so that LoadBody()/Swap() - which REPLACE + // the index column, since its numeric width can change - stay coherent across every holder. + // The dictionary and dedup map are only ever mutated in place (never replaced), so they don't + // need this extra level of indirection. + struct IndexState { + ColumnRef column; + Type::Code type_code; + }; + std::shared_ptr index_; + // Shared so that a wrapped (ColumnLowCardinalityT::Wrap) column shares the same dedup map as its // source, keeping dictionary/index/map coherent across both holders (same semantics as other columns). std::shared_ptr unique_items_map_; + // Dictionary item type code (for a Nullable dictionary, the code of the innermost + // non-nullable type; otherwise the dictionary's own type code). Computed once in Setup() + // and used by ColumnLowCardinalityT to build ItemView on Append. + Type::Code item_type_code_; + protected: - // Shallow copy: shares dictionary_column_, index_column_ and unique_items_map_ (all shared_ptr), - // copies index_type_code_ and the base type. Used by ColumnLowCardinalityT::Wrap to create a + // Shallow copy: shares dictionary_column_, index_ (the index bundle) and unique_items_map_ + // (all shared_ptr) and copies the base type. Used by ColumnLowCardinalityT::Wrap to create a // non-destructive, storage-sharing view of `col`. ColumnLowCardinality(const ColumnLowCardinality& col) = default; @@ -118,12 +134,6 @@ class ColumnLowCardinality : public Column { void AppendNullItem(); void AppendDefaultItem(); - Type::Code index_type_code_; - // Dictionary item type code (for a Nullable dictionary, the code of the innermost - // non-nullable type; otherwise the dictionary's own type code). Computed once in Setup() - // and used by ColumnLowCardinalityT to build ItemView on Append. - Type::Code item_type_code_; - public: static details::LowCardinalityHashKey computeHashKey(const ItemView &); }; diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index 11d0732a..cba56acf 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1127,6 +1127,69 @@ TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_DoesNotStealSource) { EXPECT_EQ(wrapped->GetDictionarySize(), dict_before + 1); } +TEST(ColumnsCase, ColumnLowCardinalityT_Swap_VisibleThroughAlias) { + auto a = std::make_shared>(); + a->Append("a"); + a->Append("b"); + a->Append("a"); + auto b = std::make_shared>(); + b->Append("x"); + + // Typed view of `a`, created BEFORE the swap; shares a's dictionary and index bundle. + auto alias_a = ColumnLowCardinalityT::Wrap(a); + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->Size(), 3u); + + a->Swap(*b); + + // a now holds b's data, b holds a's data. + ASSERT_EQ(a->Size(), 1u); + EXPECT_EQ(a->At(0), "x"); + ASSERT_EQ(b->Size(), 3u); + EXPECT_EQ(b->At(0), "a"); + EXPECT_EQ(b->At(1), "b"); + + // The swap is visible through the pre-existing alias: dictionary swapped in place and the + // shared index bundle swapped, so alias_a reflects b's data (size flips 3 -> 1). + ASSERT_EQ(alias_a->Size(), 1u); + EXPECT_EQ(alias_a->At(0), "x"); +} + +TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_ThenLoad_VisibleThroughAlias) { + // Serialize a source LowCardinality column. + ColumnLowCardinalityT src; + src.Append("p"); + src.Append("q"); + src.Append("p"); + + char buffer[256] = {'\0'}; + { + ArrayOutput output(buffer, sizeof(buffer)); + EXPECT_NO_THROW(src.Save(&output)); + } + + // A different target column, wrapped BEFORE the load. + auto target = std::make_shared>(); + target->Append("z"); + auto alias = ColumnLowCardinalityT::Wrap(target); + ASSERT_NE(alias, nullptr); + ASSERT_EQ(alias->Size(), 1u); + + // Load src's data into target: LoadBody swaps the dictionary in place and REPLACES the index + // column inside the shared bundle. + { + ArrayInput input(buffer, sizeof(buffer)); + EXPECT_TRUE(target->Load(&input, 3)); + } + + ASSERT_EQ(target->Size(), 3u); + // The pre-existing alias reflects the loaded data (shared dictionary + index bundle). + ASSERT_EQ(alias->Size(), 3u); + EXPECT_EQ(alias->At(0), "p"); + EXPECT_EQ(alias->At(1), "q"); + EXPECT_EQ(alias->At(2), "p"); +} + TEST(ColumnsCase, ColumnLowCardinalityT_Wrap_AcceptsLvalue) { auto source = std::make_shared>(); source->Append("x"); @@ -1719,6 +1782,69 @@ TEST(ColumnsCase, DeepNestedArray_Swap_VisibleThroughAlias) { EXPECT_EQ(inner[1], std::optional{}); } +// --- Deep nesting with LowCardinality: Map(UInt64, Array(LowCardinality(Nullable(String)))) --- + +namespace { +// Builds a single-row Map(UInt64, Array(LowCardinality(Nullable(String)))): one map row with one +// entry {key -> arr}, where the value is an array of LowCardinality(Nullable(String)) items. +std::shared_ptr MakeDeepLcMapRow(uint64_t key, std::vector> arr) { + auto keys = std::make_shared(); + auto vals = std::make_shared>>>(); + keys->Append(key); + vals->Append(arr); + auto tuple = std::make_shared(std::vector{keys, vals}); + return std::make_shared(std::make_shared(tuple)); +} +} + +TEST(ColumnsCase, DeepMapArrayLowCardinality_Swap_VisibleThroughAlias) { + using DeepLcMap = ColumnMapT>>>; + + auto a = MakeDeepLcMapRow(1, {std::string("a"), std::string("b"), std::string("c")}); + auto b = MakeDeepLcMapRow(1, {std::string("x"), std::nullopt}); + + auto alias_a = DeepLcMap::Wrap(a); // deep typed view created BEFORE the swap + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->At(0).At(1).Size(), 3u); + + a->Swap(*b); + + // The swap recurses through Array -> LowCardinality (dictionary swapped in place, index bundle + // swapped) down to the Nullable(String) leaves, so the pre-existing alias reflects B's data: + // the value-array flips size 3 -> 2 and the null survives through LC -> Nullable. + auto arr = alias_a->At(0).At(1); + ASSERT_EQ(arr.Size(), 2u); + EXPECT_EQ(arr[0], std::optional("x")); + EXPECT_EQ(arr[1], std::optional{}); + + // b now holds a's original data. + auto arr_b = DeepLcMap::Wrap(b)->At(0).At(1); + ASSERT_EQ(arr_b.Size(), 3u); + EXPECT_EQ(arr_b[0], std::optional("a")); +} + +TEST(ColumnsCase, DeepMapArrayLowCardinality_Clear_VisibleThroughAlias) { + using DeepLcMap = ColumnMapT>>>; + + auto a = MakeDeepLcMapRow(1, {std::string("a"), std::string("b"), std::string("c")}); + auto alias_a = DeepLcMap::Wrap(a); + ASSERT_NE(alias_a, nullptr); + ASSERT_EQ(alias_a->Size(), 1u); + + a->Clear(); + + // Clear recurses to the shared leaves in place (incl. LowCardinality), so the alias empties too. + EXPECT_EQ(a->Size(), 0u); + EXPECT_EQ(alias_a->Size(), 0u); + + // Re-appending a fresh row must not resurface the pre-clear entry. + a->Append(MakeDeepLcMapRow(1, {std::string("z")})); + ASSERT_EQ(alias_a->Size(), 1u); + auto arr = alias_a->At(0).At(1); + ASSERT_EQ(arr.Size(), 1u); + EXPECT_EQ(arr[0], std::optional("z")); +} + TEST(ColumnsCase, ColumnTupleT_Slice_PreservesNames) { using TestTuple = ColumnTupleT; From c65298547b836498776c2470cee5ced6e41c7c66 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Thu, 6 Aug 2026 21:18:29 +0200 Subject: [PATCH 13/15] Make const As()/AsStrict() wrap like their non-const overloads The const overloads previously performed exact downcasts only, so col->As>() succeeded on a mutable handle but returned nullptr on a const handle for the very same column. Make them consistent: for a wrappable T, try an exact cast then fall back to Wrap, returning a read-only shared_ptr view. Wrapping never mutates the source; it uses an internal const_pointer_cast because Wrap builds a mutable wrapper. Add a matching const AsStrict() overload that throws on mismatch. Add tests covering const As() wrapping, const exact downcast, and const AsStrict() wrapping plus its throwing paths. --- clickhouse/columns/column.h | 45 +++++++++++++++++++++++++---- ut/columns_ut.cpp | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/clickhouse/columns/column.h b/clickhouse/columns/column.h index 8b3cb76f..6f6d4e18 100644 --- a/clickhouse/columns/column.h +++ b/clickhouse/columns/column.h @@ -35,10 +35,13 @@ class Column : public std::enable_shared_from_this { template inline std::shared_ptr As(); - /// Const overload. Unlike the non-const As(), this does NOT wrap: it only performs an - /// exact downcast and returns nullptr on mismatch (even for a wrappable T). Wrapping is - /// intentionally disabled here because it would synthesize a mutable, storage-sharing - /// view from a const column (requiring a const_cast), which is not const-correct. + /// Const overload. Behaves like the non-const As(): for a wrappable T it first tries an + /// exact downcast, then falls back to Wrap, returning a storage-sharing view typed as + /// shared_ptr (read-only surface). Wrapping never mutates the source column; it + /// does require an internal const_pointer_cast because Wrap builds a mutable wrapper. + /// Caveat: the read-only guarantee is shallow -- the wrapper still holds non-const + /// references to the shared sub-columns, so a deliberate const_pointer_cast could + /// still reach the underlying storage. Casual const use stays read-only. template inline std::shared_ptr As() const; @@ -46,6 +49,10 @@ class Column : public std::enable_shared_from_this { template inline std::shared_ptr AsStrict(); + /// Const overload of AsStrict(); wraps like As() const and throws on failure. + template + inline std::shared_ptr AsStrict() const; + /// Get type object of the column. inline TypeRef Type() const { return type_; } inline const class Type& GetType() const { return *type_; } @@ -202,8 +209,17 @@ inline std::shared_ptr Column::As() { template inline std::shared_ptr Column::As() const { - // No wrapping for the const overload (see declaration): exact downcast only. - return std::dynamic_pointer_cast(shared_from_this()); + if constexpr (HasWrapMethod::value) { + if (auto exact = std::dynamic_pointer_cast(shared_from_this())) { + return exact; + } + // Wrap needs a mutable ColumnRef to build a storage-sharing view; the result is + // returned as shared_ptr so the caller keeps read-only access, and + // wrapping never mutates the source column. + return WrapColumn(std::const_pointer_cast(shared_from_this()), nullptr); + } else { + return std::dynamic_pointer_cast(shared_from_this()); + } } template @@ -222,4 +238,21 @@ inline std::shared_ptr Column::AsStrict() { } } +template +inline std::shared_ptr Column::AsStrict() const { + if constexpr (HasWrapMethod::value) { + if (auto exact = std::dynamic_pointer_cast(shared_from_this())) { + return exact; + } + // Throwing WrapColumn: raises ValidationError on a type mismatch. + return WrapColumn(std::const_pointer_cast(shared_from_this())); + } else { + auto result = std::dynamic_pointer_cast(shared_from_this()); + if (!result) { + throw ValidationError("Can't cast from " + type_->GetName()); + } + return result; + } +} + } // namespace clickhouse diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index cba56acf..ab274085 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1845,6 +1845,62 @@ TEST(ColumnsCase, DeepMapArrayLowCardinality_Clear_VisibleThroughAlias) { EXPECT_EQ(arr[0], std::optional("z")); } +// --- const As()/AsStrict() wrap like their non-const counterparts --- + +TEST(ColumnsCase, Const_As_WrapsWrappableColumn) { + using TestArray = ColumnArrayT; + + auto arr = std::make_shared(); + arr->Append(std::vector{1, 2, 3}); + + // View the column only through a const handle. + std::shared_ptr c = arr; + auto view = c->As(); + ASSERT_NE(view, nullptr); + static_assert(std::is_same_v>, + "const As() must yield shared_ptr"); + + // Read access reflects the same shared storage. + ASSERT_EQ(view->Size(), 1u); + auto row = view->At(0); + ASSERT_EQ(row.Size(), 3u); + EXPECT_EQ(row[0], 1u); + EXPECT_EQ(row[2], 3u); +} + +TEST(ColumnsCase, Const_As_ExactDowncastStillWorks) { + auto leaf = std::make_shared(); + leaf->Append(42u); + + std::shared_ptr c = leaf; + auto exact = c->As(); + ASSERT_NE(exact, nullptr); + ASSERT_EQ(exact->Size(), 1u); + EXPECT_EQ(exact->At(0), 42u); + + // Non-wrappable mismatch returns nullptr (no throw). + EXPECT_EQ(c->As(), nullptr); +} + +TEST(ColumnsCase, Const_AsStrict_WrapsAndThrows) { + using TestArray = ColumnArrayT; + + auto arr = std::make_shared(); + arr->Append(std::vector{7, 8}); + + std::shared_ptr c = arr; + auto view = c->AsStrict(); + ASSERT_NE(view, nullptr); + static_assert(std::is_same_v>, + "const AsStrict() must yield shared_ptr"); + ASSERT_EQ(view->At(0).Size(), 2u); + + // Wrappable-but-incompatible element type throws. + EXPECT_THROW((void)c->AsStrict>(), ValidationError); + // Non-wrappable mismatch throws too. + EXPECT_THROW((void)c->AsStrict(), ValidationError); +} + TEST(ColumnsCase, ColumnTupleT_Slice_PreservesNames) { using TestTuple = ColumnTupleT; From d15e9fa8654b33d5cb569cc2a443ae4c9d1638a6 Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Fri, 7 Aug 2026 18:40:23 +0200 Subject: [PATCH 14/15] Make LowCardinality(Nullable(...)) wrap from a factory-built column CreateColumnByType builds LowCardinality(Nullable(String)) with a base ColumnNullable dictionary, but the strict LC Wrap guard required the dictionary to be exactly ColumnNullableT<...>, so As>>() returned nullptr on factory- and server-produced columns. Add a fallback to Wrap: when the stored dictionary is a base column, wrap it into the typed DictionaryColumnType (a storage-sharing view over the same underlying columns) and bind typed_dictionary_ to that via a new storage-sharing constructor. The fast path (dictionary already the exact type) is unchanged, preserving existing Swap/LoadBody/Clear alias semantics. Coherence holds because ColumnNullableT::Wrap shares the exact nested and null-map objects and all dictionary mutations flow through dictionary_column_. Add CreateColumnByType.LowCardinalityNullable covering the conversion, shared-storage appends (incl. nulls), a second independent wrap, and rejection of a mismatched nested type. --- clickhouse/columns/lowcardinality.h | 95 ++++++++++++++++++----------- ut/CreateColumnByType_ut.cpp | 38 ++++++++++++ 2 files changed, 96 insertions(+), 37 deletions(-) diff --git a/clickhouse/columns/lowcardinality.h b/clickhouse/columns/lowcardinality.h index b5fbba2c..cf2f6ea0 100644 --- a/clickhouse/columns/lowcardinality.h +++ b/clickhouse/columns/lowcardinality.h @@ -45,31 +45,6 @@ class ColumnLowCardinality : public Column { template friend class ColumnLowCardinalityT; -private: - // IMPLEMENTATION NOTE: ColumnLowCardinalityT takes reference to underlying dictionary column object, - // so make sure to NOT change address of the dictionary object (with reset(), swap()) or with anything else. - ColumnRef dictionary_column_; - - // The index column and its cached type code, bundled behind one shared_ptr. A wrapped view - // (ColumnLowCardinalityT::Wrap) shares this bundle, so that LoadBody()/Swap() - which REPLACE - // the index column, since its numeric width can change - stay coherent across every holder. - // The dictionary and dedup map are only ever mutated in place (never replaced), so they don't - // need this extra level of indirection. - struct IndexState { - ColumnRef column; - Type::Code type_code; - }; - std::shared_ptr index_; - - // Shared so that a wrapped (ColumnLowCardinalityT::Wrap) column shares the same dedup map as its - // source, keeping dictionary/index/map coherent across both holders (same semantics as other columns). - std::shared_ptr unique_items_map_; - - // Dictionary item type code (for a Nullable dictionary, the code of the innermost - // non-nullable type; otherwise the dictionary's own type code). Computed once in Setup() - // and used by ColumnLowCardinalityT to build ItemView on Append. - Type::Code item_type_code_; - protected: // Shallow copy: shares dictionary_column_, index_ (the index bundle) and unique_items_map_ // (all shared_ptr) and copies the base type. Used by ColumnLowCardinalityT::Wrap to create a @@ -136,6 +111,31 @@ class ColumnLowCardinality : public Column { public: static details::LowCardinalityHashKey computeHashKey(const ItemView &); + +private: + // IMPLEMENTATION NOTE: ColumnLowCardinalityT takes reference to underlying dictionary column object, + // so make sure to NOT change address of the dictionary object (with reset(), swap()) or with anything else. + ColumnRef dictionary_column_; + + // The index column and its cached type code, bundled behind one shared_ptr. A wrapped view + // (ColumnLowCardinalityT::Wrap) shares this bundle, so that LoadBody()/Swap() - which REPLACE + // the index column, since its numeric width can change - stay coherent across every holder. + // The dictionary and dedup map are only ever mutated in place (never replaced), so they don't + // need this extra level of indirection. + struct IndexState { + ColumnRef column; + Type::Code type_code; + }; + std::shared_ptr index_; + + // Shared so that a wrapped (ColumnLowCardinalityT::Wrap) column shares the same dedup map as its + // source, keeping dictionary/index/map coherent across both holders (same semantics as other columns). + std::shared_ptr unique_items_map_; + + // Dictionary item type code (for a Nullable dictionary, the code of the innermost + // non-nullable type; otherwise the dictionary's own type code). Computed once in Setup() + // and used by ColumnLowCardinalityT to build ItemView on Append. + Type::Code item_type_code_; }; /** Type-aware wrapper that provides simple convenience interface for accessing/appending individual items. @@ -143,8 +143,6 @@ class ColumnLowCardinality : public Column { template class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColumn, ColumnLowCardinality> { - DictionaryColumnType& typed_dictionary_; - public: using WrappedColumnType = DictionaryColumnType; // Type this column takes as argument of Append and returns with At() and operator[] @@ -175,6 +173,19 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum , typed_dictionary_(dynamic_cast(*GetDictionary())) {} + // Used by Wrap() when the stored dictionary is a base column (e.g. a factory-built base + // ColumnNullable for LowCardinality(Nullable(String))) that had to be wrapped into the typed + // DictionaryColumnType. Shares col's internals (index bundle and dedup map), then swaps in the + // storage-sharing typed dictionary view so typed_dictionary_ binds to exactly a + // DictionaryColumnType. `typed_dictionary` MUST be a storage-sharing view of col's dictionary + // (as produced by DictionaryColumnType::Wrap), so mutations stay coherent across both holders. + ColumnLowCardinalityT(const ColumnLowCardinality& col, std::shared_ptr typed_dictionary) + : ColumnLowCardinality(col) + , typed_dictionary_(*typed_dictionary) + { + dictionary_column_ = std::move(typed_dictionary); + } + /// Extended interface to simplify reading/adding individual items. /// Returns element at given row number. @@ -221,18 +232,23 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum * throw ValidationError on a type mismatch instead. */ static std::shared_ptr> Wrap(const ColumnLowCardinality& col, ValidationError* error) { - // Strict (non-wrapping) check on purpose: the constructor binds typed_dictionary_ as a - // DictionaryColumnType& via a reference dynamic_cast, so the stored dictionary must be - // exactly DictionaryColumnType. Using the wrapping As<> here could pass for a base - // dictionary and then make that reference cast throw std::bad_cast. - if (!std::dynamic_pointer_cast(col.dictionary_column_)) { - if (error) { - *error = ValidationError("Can't wrap LowCardinality column with dictionary of type " - + col.dictionary_column_->GetType().GetName()); - } + // Fast path: the stored dictionary is already exactly DictionaryColumnType, so share it + // directly - typed_dictionary_ can bind to it via the reference dynamic_cast unchanged. + if (std::dynamic_pointer_cast(col.dictionary_column_)) { + return std::make_shared>(col); + } + + // Fallback: the stored dictionary is a base column, not DictionaryColumnType (e.g. the + // factory builds LowCardinality(Nullable(String)) with a base ColumnNullable dictionary). + // Wrap it into the typed DictionaryColumnType - a storage-sharing view over the same + // underlying columns - and bind typed_dictionary_ to that. Binding to the base dictionary + // directly would make the reference dynamic_cast throw std::bad_cast. + auto typed_dictionary = WrapColumn(col.dictionary_column_, error); + if (!typed_dictionary) { + // error (if requested) already set by WrapColumn. return nullptr; } - return std::make_shared>(col); + return std::make_shared>(col, typed_dictionary); } static std::shared_ptr> Wrap(const Column& col, ValidationError* error) { @@ -258,6 +274,11 @@ class ColumnLowCardinalityT : public ColumnLowCardinality, public WrappableColum } ColumnRef CloneEmpty() const override { return Wrap(ColumnLowCardinality::CloneEmpty()); } + +private: + + DictionaryColumnType& typed_dictionary_; + }; } diff --git a/ut/CreateColumnByType_ut.cpp b/ut/CreateColumnByType_ut.cpp index 78225dd0..fa13d00f 100644 --- a/ut/CreateColumnByType_ut.cpp +++ b/ut/CreateColumnByType_ut.cpp @@ -68,6 +68,44 @@ TEST(CreateColumnByType, LowCardinality) { } } +TEST(CreateColumnByType, LowCardinalityNullable) { + // The factory builds LowCardinality(Nullable(String)) as a base ColumnLowCardinality whose + // dictionary is a base ColumnNullable (not a typed ColumnNullableT). The wrapping + // As<> must still produce the strongly-typed view by wrapping that base dictionary into the + // typed one, sharing its underlying storage. + using TypedLC = ColumnLowCardinalityT>; + + auto col = CreateColumnByType("LowCardinality(Nullable(String))"); + ASSERT_NE(nullptr, col); + EXPECT_EQ("LowCardinality(Nullable(String))", col->GetType().GetName()); + EXPECT_NE(nullptr, col->As()); + + auto typed = col->As(); + ASSERT_NE(nullptr, typed); + + // Appends through the typed view are visible via the base handle (shared storage), and the + // typed accessors round-trip both real values and nulls. + typed->Append(std::string("abc")); + typed->Append(std::nullopt); + typed->Append(std::string("abc")); + + EXPECT_EQ(3u, typed->Size()); + EXPECT_EQ(3u, col->Size()); + EXPECT_EQ(std::optional("abc"), typed->At(0)); + EXPECT_EQ(std::nullopt, typed->At(1)); + EXPECT_EQ(std::optional("abc"), typed->At(2)); + + // A second independent wrap of the same base column observes the same shared data. + auto typed2 = col->As(); + ASSERT_NE(nullptr, typed2); + EXPECT_EQ(3u, typed2->Size()); + EXPECT_EQ(std::optional("abc"), typed2->At(0)); + EXPECT_EQ(std::nullopt, typed2->At(1)); + + // A dictionary whose nested type does not match must still be rejected. + EXPECT_EQ(nullptr, col->As>>()); +} + TEST(CreateColumnByType, DateTime) { ASSERT_NE(nullptr, CreateColumnByType("DateTime")); ASSERT_NE(nullptr, CreateColumnByType("DateTime('Europe/Moscow')")); From 09276ba0d60a38080a4b96db304d0bc4d248c1dd Mon Sep 17 00:00:00 2001 From: Andrew Slabko Date: Sat, 8 Aug 2026 12:15:44 +0200 Subject: [PATCH 15/15] Validate Tuple types before Swap --- clickhouse/columns/tuple.cpp | 14 ++++++++++++-- ut/columns_ut.cpp | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/clickhouse/columns/tuple.cpp b/clickhouse/columns/tuple.cpp index 1712c40e..cde48f76 100644 --- a/clickhouse/columns/tuple.cpp +++ b/clickhouse/columns/tuple.cpp @@ -147,11 +147,21 @@ void ColumnTuple::Clear() { void ColumnTuple::Swap(Column& other) { auto & col = dynamic_cast(other); - if (columns_.size() != col.columns_.size()) + if (columns_.size() != col.columns_.size()) { throw ValidationError("Can't swap() Tuple columns of different sizes."); + } + + for (size_t i = 0; i < columns_.size(); ++i) { + if (!columns_[i]->Type()->IsEqual(col.columns_[i]->Type())) { + throw ValidationError( + "Can't swap() Tuple elements of types " + + columns_[i]->GetType().GetName() + " and " + + col.columns_[i]->GetType().GetName() + "."); + } + } + // Swap each element's CONTENTS in place (never rebind the columns_ vector), so element // objects keep their identity and any As<>/Wrap views of this column stay coherent. - // The nested Swap also type-checks each element and throws on a mismatch. for (size_t i = 0; i < columns_.size(); ++i) { columns_[i]->Swap(*col.columns_[i]); } diff --git a/ut/columns_ut.cpp b/ut/columns_ut.cpp index ab274085..3c3b292b 100644 --- a/ut/columns_ut.cpp +++ b/ut/columns_ut.cpp @@ -1646,6 +1646,27 @@ TEST(ColumnsCase, ColumnTuple_Swap_DifferentSizeThrows) { EXPECT_THROW(a.Swap(b), ValidationError); } +TEST(ColumnsCase, ColumnTuple_Swap_DifferentElementTypeDoesNotModify) { + auto a0 = std::make_shared(); + auto a1 = std::make_shared(); + a0->Append(1); + a1->Append("a"); + ColumnTuple a({a0, a1}); + + auto b0 = std::make_shared(); + auto b1 = std::make_shared(); + b0->Append(2); + b1->Append(3); + ColumnTuple b({b0, b1}); + + EXPECT_THROW(a.Swap(b), ValidationError); + + EXPECT_EQ(a0->At(0), 1u); + EXPECT_EQ(a1->At(0), "a"); + EXPECT_EQ(b0->At(0), 2u); + EXPECT_EQ(b1->At(0), 3u); +} + TEST(ColumnsCase, ColumnTuple_Clear_PreservesStructure_AndAlias) { ColumnTuple col({std::make_shared(), std::make_shared()}); col[0]->AsStrict()->Append(1);