Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions be/src/core/data_type/get_least_supertype.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,25 @@ void get_least_supertype_jsonb(const PrimitiveTypeSet& types, DataTypePtr* type)
}
}

/// Doris has no unsigned 8-bit type: DataTypeUInt8 is TYPE_BOOLEAN, so a BOOLEAN reaching this
/// function is a JSON boolean - of a Variant path, or of one JSON array's elements.
/// get_numeric_type() below counts BOOLEAN as an 8-bit unsigned integer, which would fold
/// true/false into the numeric tower and store them as 1/0 - values that then compare, group
/// and print like the numbers. JSONB holds booleans and numbers in one column without
/// converting either.
{
bool have_boolean = false;
bool have_number = false;
for (const auto& nested_type : types) {
have_boolean |= nested_type == PrimitiveType::TYPE_BOOLEAN;
have_number |= is_int(nested_type) || is_float_or_double(nested_type);
}
if (have_boolean && have_number) {
*type = std::make_shared<DataTypeJsonb>();
return;
}
}

/// For numeric types, the most complicated part.
DataTypePtr numeric_type = nullptr;
get_numeric_type(types, &numeric_type);
Expand Down
20 changes: 18 additions & 2 deletions be/test/core/block/get_common_type_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ static DataTypePtr typeFromString(const std::string& str) {
return std::make_shared<DataTypeInt32>();
} else if (str == "Int64") {
return std::make_shared<DataTypeInt64>();
} else if (str == "Int128") {
return std::make_shared<DataTypeInt128>();
} else if (str == "Float32") {
return std::make_shared<DataTypeFloat32>();
} else if (str == "Float64") {
Expand Down Expand Up @@ -132,8 +134,22 @@ INSTANTIATE_TEST_SUITE_P(data_type, LeastSuperTypeTest,
{"UInt8", "UInt8"},
{"UInt8 UInt8", "UInt8"},
{"Int8 Int8", "Int8"},
{"UInt8 Int8", "Int16"},
{"UInt8 Int16", "Int16"},
// DataTypeUInt8 is TYPE_BOOLEAN, a JSON boolean: Doris has no
// unsigned 8-bit type. This function types Variant paths and the
// elements of one JSON array, where true/false must stay distinct
// from the numbers 1/0, so a boolean mixed with a number falls
// back to JSONB instead of widening into the numeric tower.
{"UInt8 Int8", "Jsonb"},
{"UInt8 Int16", "Jsonb"},
{"UInt8 Int128", "Jsonb"},
{"UInt8 Float32", "Jsonb"},
{"UInt8 Float64", "Jsonb"},
// Combinations that do not reach the numeric tower keep their
// previous result, so the rule above cannot silently widen them.
{"UInt8 String", "Jsonb"},
{"UInt8 Jsonb", "Jsonb"},
{"UInt8 Date", "Jsonb"},
{"UInt8 Nothing", "UInt8"},
{"Int8 Int32 Int64", "Int64"},
{"Float32 Float64", "Float64"},
{"Date Date", "Date"},
Expand Down
132 changes: 132 additions & 0 deletions be/test/exec/common/schema_util_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
#include "core/data_type/data_type_ipv4.h"
#include "core/data_type/data_type_jsonb.h"
#include "core/data_type/data_type_nothing.h"
#include "core/data_type/data_type_nullable.h"
#include "core/data_type/data_type_number.h"
#include "core/data_type/data_type_string.h"
#include "core/data_type/data_type_time.h"
#include "core/data_type/data_type_timestamp_ns.h"
Expand Down Expand Up @@ -977,6 +979,136 @@ TEST_F(SchemaUtilTest, TestUpdateLeastSchemaInternal) {
EXPECT_EQ(schema->column(int_col_idx).type(), FieldType::OLAP_FIELD_TYPE_BIGINT);
}

// Segments of the same Variant path can store BOOLEAN in one rowset and a number in another.
// Merging those types must not choose a numeric column, or the booleans become 1/0.
TEST_F(SchemaUtilTest, UpdateLeastSchemaKeepsBooleanDistinctFromNumbers) {
auto schema = std::make_shared<TabletSchema>();
TabletColumn base_col;
base_col.set_unique_id(1);
base_col.set_name("test_variant");
base_col.set_type(FieldType::OLAP_FIELD_TYPE_VARIANT);
schema->append_column(base_col);

const DataTypePtr nullable_bool = make_nullable(std::make_shared<DataTypeBool>());
const DataTypePtr nullable_bigint = make_nullable(std::make_shared<DataTypeInt64>());
std::map<PathInData, DataTypes> subcolumns_types;
subcolumns_types[PathInData("test_variant.k")] = {nullable_bool, nullable_bigint};
subcolumns_types[PathInData("test_variant.d")] = {
make_nullable(std::make_shared<DataTypeFloat64>()), nullable_bool};
subcolumns_types[PathInData("test_variant.a")] = {
make_nullable(std::make_shared<DataTypeArray>(nullable_bool)),
make_nullable(std::make_shared<DataTypeArray>(nullable_bigint))};
subcolumns_types[PathInData("test_variant.n")] = {
make_nullable(std::make_shared<DataTypeInt32>()), nullable_bigint};

std::map<std::string, TabletColumnPtr> typed_columns;
ASSERT_TRUE(
variant_util::update_least_schema_internal(subcolumns_types, schema, 1, typed_columns)
.ok());

const auto column_type = [&](const std::string& name) {
const int index = schema->field_index(name);
EXPECT_GE(index, 0) << name;
return schema->column(index).type();
};
EXPECT_EQ(column_type("test_variant.k"), FieldType::OLAP_FIELD_TYPE_JSONB);
EXPECT_EQ(column_type("test_variant.d"), FieldType::OLAP_FIELD_TYPE_JSONB);
ASSERT_EQ(column_type("test_variant.a"), FieldType::OLAP_FIELD_TYPE_ARRAY);
EXPECT_EQ(schema->column(schema->field_index("test_variant.a")).get_sub_column(0).type(),
FieldType::OLAP_FIELD_TYPE_JSONB);
// Numbers of different widths still promote to a numeric column.
EXPECT_EQ(column_type("test_variant.n"), FieldType::OLAP_FIELD_TYPE_BIGINT);
}

// Compaction builds subcolumns from the physical types of every input segment. Both the
// subpath-limited branch and the all-materialized branch, and nested paths, must keep BOOLEAN
// segments from being merged into a numeric column with numeric segments.
TEST_F(SchemaUtilTest, CompactionSubcolumnsKeepBooleanDistinctFromNumbers) {
const DataTypePtr nullable_bool = make_nullable(std::make_shared<DataTypeBool>());
const DataTypePtr nullable_bigint = make_nullable(std::make_shared<DataTypeInt64>());
const DataTypePtr nullable_double = make_nullable(std::make_shared<DataTypeFloat64>());
doris::variant_util::PathToDataTypes path_to_data_types;
path_to_data_types[PathInData("k")] = {nullable_bool, nullable_bigint};
path_to_data_types[PathInData("a")] = {
make_nullable(std::make_shared<DataTypeArray>(nullable_bool)),
make_nullable(std::make_shared<DataTypeArray>(nullable_bigint))};
// A boolean merged with a floating point number takes a different branch of the numeric tower
// than the integer case above, so it needs its own path.
path_to_data_types[PathInData("d")] = {nullable_bool, nullable_double};
path_to_data_types[PathInData("n")] = {make_nullable(std::make_shared<DataTypeInt32>()),
nullable_bigint};

const auto expect_types = [](const TabletSchemaSPtr& output_schema) {
bool found_k = false, found_a = false, found_d = false, found_n = false;
for (const auto& column : output_schema->columns()) {
if (column->name().ends_with(".k")) {
found_k = true;
EXPECT_EQ(column->type(), FieldType::OLAP_FIELD_TYPE_JSONB);
} else if (column->name().ends_with(".a")) {
found_a = true;
ASSERT_EQ(column->type(), FieldType::OLAP_FIELD_TYPE_ARRAY);
EXPECT_EQ(column->get_sub_column(0).type(), FieldType::OLAP_FIELD_TYPE_JSONB);
} else if (column->name().ends_with(".d")) {
found_d = true;
EXPECT_EQ(column->type(), FieldType::OLAP_FIELD_TYPE_JSONB);
} else if (column->name().ends_with(".n")) {
found_n = true;
EXPECT_EQ(column->type(), FieldType::OLAP_FIELD_TYPE_BIGINT);
}
}
EXPECT_TRUE(found_k && found_a && found_d && found_n);
};

for (int32_t max_subcolumns_count : {10, 0}) {
TabletColumn variant;
variant.set_name("v1");
variant.set_unique_id(40);
variant.set_variant_max_subcolumns_count(max_subcolumns_count);
variant.set_aggregation_method(FieldAggregationMethod::OLAP_FIELD_AGGREGATION_NONE);
variant.set_variant_max_sparse_column_statistics_size(10000);
TabletSchemaSPtr schema = std::make_shared<TabletSchema>();
schema->append_column(variant);
TabletColumnPtr parent_column = std::make_shared<TabletColumn>(variant);
TabletSchemaSPtr output_schema = std::make_shared<TabletSchema>();
TabletSchema::PathsSetInfo paths_set_info;
if (max_subcolumns_count > 0) {
paths_set_info.sub_path_set.insert("k");
paths_set_info.sub_path_set.insert("a");
paths_set_info.sub_path_set.insert("d");
paths_set_info.sub_path_set.insert("n");
variant_util::VariantCompactionUtil::get_compaction_subcolumns_from_subpaths(
paths_set_info, parent_column, schema, path_to_data_types, {}, output_schema);
} else {
variant_util::VariantCompactionUtil::get_compaction_subcolumns_from_data_types(
paths_set_info, parent_column, schema, path_to_data_types, output_schema);
}
expect_types(output_schema);
}

TabletColumn nested_variant;
nested_variant.set_name("v2");
nested_variant.set_unique_id(41);
TabletColumnPtr nested_parent = std::make_shared<TabletColumn>(nested_variant);
TabletSchemaSPtr nested_output = std::make_shared<TabletSchema>();
nested_output->append_column(nested_variant);
TabletSchema::PathsSetInfo nested_paths_set_info;
const PathInData nested_path("items.flag");
const PathInData nested_float_path("items.ratio");
std::unordered_set<PathInData, PathInData::Hash> nested_paths {nested_path, nested_float_path};
doris::variant_util::PathToDataTypes nested_types;
nested_types[nested_path] = {nullable_bool, nullable_bigint};
nested_types[nested_float_path] = {nullable_bool, nullable_double};
ASSERT_TRUE(
variant_util::VariantCompactionUtil::get_compaction_nested_columns(
nested_paths, nested_types, nested_parent, nested_output, nested_paths_set_info)
.ok());
// The paths come from an unordered set, so check every produced subcolumn instead of one index.
ASSERT_EQ(nested_output->num_columns(), 3);
for (size_t i = 1; i < nested_output->num_columns(); ++i) {
EXPECT_EQ(nested_output->column(i).type(), FieldType::OLAP_FIELD_TYPE_JSONB);
}
}

TEST_F(SchemaUtilTest, TestUpdateLeastCommonSchema) {
// Create test schemas
std::vector<TabletSchemaSPtr> schemas;
Expand Down
98 changes: 98 additions & 0 deletions be/test/storage/variant/variant_column_writer_reader_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,104 @@ TEST(VariantPathBuilderTest, StableScalarFastPathPreservesInferenceBoundaries) {
});
}

// The `conflict` path above (int, then two bools) already falls back to JSONB and keeps the
// booleans lossless, because append_integer() throws when a BOOL value hits an already-INT
// typed_path builder, and the catch block in VariantPathBuilder::append() promotes to JSONB.
// The opposite order -- BOOL first, then a numeric value -- must behave the same way. Without the
// path_least_common_type() fix, get_numeric_type() (core/data_type/get_least_supertype.cpp) counts
// TYPE_BOOLEAN as an 8-bit unsigned integer, so BOOL merged with TINYINT/INT/BIGINT/FLOAT/DOUBLE
// silently produced a numeric common type. promote() then cast the existing BOOL column to that
// numeric type, turning the stored `true` into `1` -- an observable Variant/JSON semantics bug
// (true must never equal 1).
TEST(VariantPathBuilderTest, BoolFirstThenNumericFallsBackToJsonbPreservingBooleanValue) {
const auto verify = [](auto append_second, const std::string& expected_second) {
VariantBatchBuilder value_builder;
auto bool_row = value_builder.begin_row();
bool_row.add_bool(true);
bool_row.finish();
auto second_row = value_builder.begin_row();
append_second(second_row);
second_row.finish();
VariantBatchBuilder values = value_builder.finish_batch();

segment_v2::VariantPathBuilder builder(PathInData("metric"));
ASSERT_TRUE(builder.append(values.value_at(0), 0).ok());
ASSERT_TRUE(builder.append(values.value_at(1), 1).ok());
EXPECT_EQ(remove_nullable(builder.type())->get_primitive_type(), TYPE_JSONB);
EXPECT_EQ(builder.type()->to_string(*builder.column(), 0), "true");
EXPECT_EQ(builder.type()->to_string(*builder.column(), 1), expected_second);
};

verify([](auto& row) { row.add_int(0); }, "0");
verify([](auto& row) { row.add_double(1.5); }, "1.5");
}

// Extends the scenario to a longer sequence (bool, int, double, bool) so a later value cannot
// re-widen the path in a way that loses the earlier boolean or turns a later `false` into `0`.
TEST(VariantPathBuilderTest, BoolIntDoubleFalseSequenceKeepsBooleansAndNumbersDistinct) {
VariantBatchBuilder value_builder;
auto row0 = value_builder.begin_row();
row0.add_bool(true);
row0.finish();
auto row1 = value_builder.begin_row();
row1.add_int(0);
row1.finish();
auto row2 = value_builder.begin_row();
row2.add_double(1.5);
row2.finish();
auto row3 = value_builder.begin_row();
row3.add_bool(false);
row3.finish();
VariantBatchBuilder values = value_builder.finish_batch();

segment_v2::VariantPathBuilder builder(PathInData("metric"));
for (size_t row = 0; row < values.num_rows(); ++row) {
ASSERT_TRUE(builder.append(values.value_at(row), row).ok());
}
EXPECT_EQ(remove_nullable(builder.type())->get_primitive_type(), TYPE_JSONB);
EXPECT_EQ(builder.type()->to_string(*builder.column(), 0), "true");
EXPECT_EQ(builder.type()->to_string(*builder.column(), 1), "0");
EXPECT_EQ(builder.type()->to_string(*builder.column(), 2), "1.5");
EXPECT_EQ(builder.type()->to_string(*builder.column(), 3), "false");
}

// path_least_common_type() recurses into the array element type for ARRAY-vs-ARRAY merges, so an
// ARRAY[BOOL] path followed by an ARRAY[INT] row must also keep the boolean lossless instead of
// letting the shared numeric-tower rule fold BOOLEAN into the element's common integer type.
TEST(VariantPathBuilderTest, BoolAndIntArraysFallBackToJsonbElementPreservingBooleanValue) {
VariantBatchBuilder value_builder;
auto row0 = value_builder.begin_row();
{
auto array = row0.start_array();
row0.add_bool(true);
array.finish();
}
row0.finish();
auto row1 = value_builder.begin_row();
{
auto array = row1.start_array();
row1.add_int(0);
array.finish();
}
row1.finish();
VariantBatchBuilder values = value_builder.finish_batch();

segment_v2::VariantPathBuilder builder(PathInData("metric"));
ASSERT_TRUE(builder.append(values.value_at(0), 0).ok());
ASSERT_TRUE(builder.append(values.value_at(1), 1).ok());
const DataTypePtr base = remove_nullable(builder.type());
ASSERT_EQ(base->get_primitive_type(), TYPE_ARRAY);
const DataTypePtr element =
remove_nullable(assert_cast<const DataTypeArray&>(*base).get_nested_type());
EXPECT_EQ(element->get_primitive_type(), TYPE_JSONB);
// DataTypeJsonbSerDe::to_string() quotes its JSON text whenever _nesting_level > 1 (the same
// convention DATE/IPV4/IPV6/HLL/... serdes use for their own nested elements), so a JSONB array
// element renders quoted here. What matters for this bug is that "true" and "0" stay distinct
// strings instead of both collapsing to the same 0/1 text.
EXPECT_EQ(builder.type()->to_string(*builder.column(), 0), R"(["true"])");
EXPECT_EQ(builder.type()->to_string(*builder.column(), 1), R"(["0"])");
}

TEST(VariantPathBuilderTest, StableScalarGuardRetainsDecimalAndAppendFailureFallbacks) {
VariantBatchBuilder value_builder;
auto valid_row = value_builder.begin_row();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- This file is automatically generated. You should know what you did if you want to edit this
-- !sql_fwd_values --
1 true
2 1
3 0
4 1.5

-- !sql_rev_values --
5 1
6 0
7 1.5
8 true

-- !sql_fwd_group --
1 1
1 2
1 3
1 4

-- !sql_rev_group --
1 5
1 6
1 7
1 8

-- !before_compaction_values --
1 true [true] true
2 false [false] false
3 1 [1] 1
4 0 [0] 0

-- !before_compaction_groups --
1 1
1 2
1 3
1 4

-- !after_compaction_values --
1 true [true] true
2 false [false] false
3 1 [1] 1
4 0 [0] 0

-- !after_compaction_groups --
1 1
1 2
1 3
1 4

-- !before_compaction_values --
1 true [true] true
2 false [false] false
3 1 [1] 1
4 0 [0] 0

-- !before_compaction_groups --
1 1
1 2
1 3
1 4

-- !after_compaction_values --
1 true [true] true
2 false [false] false
3 1 [1] 1
4 0 [0] 0

-- !after_compaction_groups --
1 1
1 2
1 3
1 4

Loading
Loading