This is an automated email from the ASF dual-hosted git repository. yiguolei pushed a commit to branch branch-4.2 in repository https://gitbox.apache.org/repos/asf/doris.git
commit 42bf1581939f7c0b1f529322400e3280c226f1a7 Author: Gabriel <[email protected]> AuthorDate: Tue Sep 15 18:41:30 2026 +0800 [Feature](lance) Support nested Arrow Null types (#67921) ### What problem does this PR solve? Follow-up to #67325; partially addresses #66496. Lance columns containing Arrow Null inside lists, structs, or map values are currently reported as unsupported. This change maps nullable Null leaves recursively, including deeper list/map/struct combinations, materializes them without accessing nonexistent Arrow validity/value buffers, and preserves their logical `NULL_TYPE` through nested schema RPC serialization. Array offsets, parent nullability, empty collections, and sliced inputs remain intact. Unknown extensions and non-nullable Null leaves are rejected before materialization. Generic `file()` scans preserve the delegate’s reader compatibility requirements and Local schema-backend affinity during rolling upgrades. Search TVFs retain reader compatibility requirements for deferred columns across TopN lazy materialization. Both read phases are checked without adding deferred fields back to the first-phase tuple. ### Tests - Lazy search mixed-version translation and Lance scan tests: 19 passed in an isolated runner using cached dependencies, including both search TVFs, aliased deferred fields, and ordinary projections. Both new rejection tests failed before the fix. - Generic-file upgrade scheduling and external TVF tests: 13 passed in an isolated runner using cached dependencies. Both new safety tests failed before the fix. - FE type-conversion tests: 12 passed in an isolated runner using existing dependencies; the new nested mapping test failed before the change. - BE nested Null tests: 10 passed in an isolated harness using the changed routines and compatible cached dependencies. The original schema conversion, RPC type preservation, and nested read failures were reproduced. - All affected C++ translation units passed syntax checks against the current source headers and generated schemas. - FE `mvn checkstyle:check`, clang-format 16 checks, and `git diff --check` passed. - The canonical fixture rebuild generates and validates the nested-null dataset. Its full check passes against the committed fixture; a temporary rebuild passes with `--repin` (one existing HNSW discriminator changes on retraining), and the check rejects a missing nested-null dataset. - Added S3 TVF regression coverage and a reproducible seven-column Lance fixture, verified by PyLance round-trip reading. Full FE Maven tests are blocked locally by a Thrift compiler/runtime version mismatch (installed compiler 0.16 versus branch dependency 0.24). The complete BE suite and S3 regression have not been run locally and require CI. ### Release note Support Arrow Null elements and fields nested in Lance arrays, structs, and map values. --- be/src/core/data_type/data_type.h | 4 +- .../data_type_serde/data_type_nullable_serde.cpp | 12 + be/src/format_v2/lance/lance_reader_helper.cpp | 24 +- be/test/format_v2/lance/lance_nested_null_test.cpp | 292 +++++++++++++++++++++ be/test/format_v2/table/lance_reader_test.cpp | 19 -- .../iceberg/scripts/lance_build_nested_null.py | 70 +++++ .../scripts/lance_build_preinstalled_catalog.py | 9 +- .../0-d6a5ba97-9164-4b3e-b773-15de06005c54.txn | Bin 0 -> 643 bytes .../_versions/18446744073709551614.manifest | Bin 0 -> 1363 bytes .../_versions/latest_version_hint.json | 1 + ...1011111010110112b5b4742fd82cf05955fa974a3.lance | Bin 0 -> 3278 bytes .../doris/datasource/lance/LanceTypeConverter.java | 27 +- .../datasource/lance/source/LanceScanNode.java | 11 +- .../doris/datasource/tvf/source/TVFScanNode.java | 14 +- .../glue/translator/PhysicalPlanTranslator.java | 35 +-- .../physical/PhysicalLazyMaterializeTVFScan.java | 6 +- .../ExternalFileTableValuedFunction.java | 5 + .../tablefunction/FileTableValuedFunction.java | 12 + .../tablefunction/LocalTableValuedFunction.java | 2 +- .../datasource/lance/LanceTypeConverterTest.java | 100 ++++++- .../datasource/tvf/source/TVFScanNodeTest.java | 95 +++++++ .../LanceLazyMaterializationUpgradeTest.java | 159 +++++++++++ .../lance/test_lance_nested_null.out | 11 + .../lance/test_lance_nested_null.groovy | 67 +++++ 24 files changed, 874 insertions(+), 101 deletions(-) diff --git a/be/src/core/data_type/data_type.h b/be/src/core/data_type/data_type.h index b976809bbfd..552729d7d2f 100644 --- a/be/src/core/data_type/data_type.h +++ b/be/src/core/data_type/data_type.h @@ -170,7 +170,9 @@ public: auto node = ptype->add_types(); node->set_type(TTypeNodeType::SCALAR); auto scalar_type = node->mutable_scalar_type(); - scalar_type->set_type(doris::to_thrift(get_primitive_type())); + // NULL uses UInt8 internally; preserve its logical type even inside complex schemas. + scalar_type->set_type(is_null_literal() ? TPrimitiveType::NULL_TYPE + : doris::to_thrift(get_primitive_type())); to_protobuf(ptype, node, scalar_type); } #ifdef BE_TEST diff --git a/be/src/core/data_type_serde/data_type_nullable_serde.cpp b/be/src/core/data_type_serde/data_type_nullable_serde.cpp index dd84eda304c..115b37ea2a8 100644 --- a/be/src/core/data_type_serde/data_type_nullable_serde.cpp +++ b/be/src/core/data_type_serde/data_type_nullable_serde.cpp @@ -389,6 +389,18 @@ Status DataTypeNullableSerDe::read_column_from_arrow(IColumn& column, const arrow::Array* arrow_array, int64_t start, int64_t end, const cctz::time_zone& ctz) const { + if (arrow_array->type_id() == arrow::Type::NA) { + // Arrow Null has neither a validity bitmap nor values, even when sliced. Avoid the + // physical SerDe for Doris's UInt8 placeholder and keep nested values and nulls aligned. + if (arrow_array->offset() < 0 || start < 0 || end < start || end > arrow_array->length()) { + return Status::InvalidArgument( + "Invalid Arrow Null read range: start={}, end={}, " + "length={}, offset={}", + start, end, arrow_array->length(), arrow_array->offset()); + } + column.insert_many_defaults(end - start); + return Status::OK(); + } if (config::enable_arrow_input_validation) { check_arrow_array_range(*arrow_array, start, end); check_arrow_validity_bitmap(*arrow_array); diff --git a/be/src/format_v2/lance/lance_reader_helper.cpp b/be/src/format_v2/lance/lance_reader_helper.cpp index c47e67a38a1..8599b7c3850 100644 --- a/be/src/format_v2/lance/lance_reader_helper.cpp +++ b/be/src/format_v2/lance/lance_reader_helper.cpp @@ -138,9 +138,9 @@ Status get_lance_extension(const std::shared_ptr<arrow::Field>& field, *extension_name, field->name()); } -// Map an Arrow field to a Doris type, allowing Doris NULL only at the top level. +// Map Arrow fields recursively, preserving Null leaves in complex types. Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field, - DataTypePtr* doris_type, bool allow_null) { + DataTypePtr* doris_type) { const auto nullable_primitive = [&](PrimitiveType type, int precision = 0, int scale = 0, int len = -1) { *doris_type = @@ -166,10 +166,12 @@ Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field, switch (arrow_type->id()) { case arrow::Type::NA: - return allow_null ? nullable_primitive(TYPE_NULL) - : Status::NotSupported( - "nested Arrow null type is unsupported for Lance field '{}'", - field->name()); + // Required Null leaves can bypass NullableSerDe through FE schema reconstruction. + // Reject them before Arrow NA buffers reach the Boolean-backed physical SerDe. + if (!field->nullable()) { + return Status::NotSupported("non-nullable Lance Arrow Null field: {}", field->name()); + } + return nullable_primitive(TYPE_NULL); case arrow::Type::BOOL: return nullable_primitive(TYPE_BOOLEAN); case arrow::Type::INT8: @@ -237,7 +239,7 @@ Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field, case arrow::Type::FIXED_SIZE_LIST: { const auto list = std::static_pointer_cast<arrow::BaseListType>(arrow_type); DataTypePtr value_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(list->value_field(), &value_type, false)); + RETURN_IF_ERROR(arrow_field_to_doris_type(list->value_field(), &value_type)); *doris_type = make_nullable(std::make_shared<DataTypeArray>(value_type)); return Status::OK(); } @@ -245,8 +247,8 @@ Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field, const auto map = std::static_pointer_cast<arrow::MapType>(arrow_type); DataTypePtr key_type; DataTypePtr item_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(map->key_field(), &key_type, false)); - RETURN_IF_ERROR(arrow_field_to_doris_type(map->item_field(), &item_type, false)); + RETURN_IF_ERROR(arrow_field_to_doris_type(map->key_field(), &key_type)); + RETURN_IF_ERROR(arrow_field_to_doris_type(map->item_field(), &item_type)); *doris_type = make_nullable(std::make_shared<DataTypeMap>(key_type, item_type)); return Status::OK(); } @@ -258,7 +260,7 @@ Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field, field_names.reserve(struct_type->num_fields()); for (const auto& child : struct_type->fields()) { DataTypePtr field_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(child, &field_type, false)); + RETURN_IF_ERROR(arrow_field_to_doris_type(child, &field_type)); field_types.emplace_back(std::move(field_type)); field_names.emplace_back(child->name()); } @@ -766,7 +768,7 @@ Status convert_arrow_schema_to_doris(const std::shared_ptr<arrow::Schema>& arrow return Status::InvalidArgument("duplicate Lance schema column: {}", field->name()); } DataTypePtr doris_type; - const auto type_status = arrow_field_to_doris_type(field, &doris_type, true); + const auto type_status = arrow_field_to_doris_type(field, &doris_type); if (type_status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>()) { parsed_types.emplace_back(std::make_shared<DataTypeNothing>()); } else { diff --git a/be/test/format_v2/lance/lance_nested_null_test.cpp b/be/test/format_v2/lance/lance_nested_null_test.cpp new file mode 100644 index 00000000000..8d1115687df --- /dev/null +++ b/be/test/format_v2/lance/lance_nested_null_test.cpp @@ -0,0 +1,292 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#include <arrow/api.h> +#include <gtest/gtest.h> + +#include "common/config.h" +#include "core/column/column_array.h" +#include "core/column/column_map.h" +#include "core/column/column_nullable.h" +#include "core/column/column_struct.h" +#include "core/data_type/data_type_array.h" +#include "core/data_type/data_type_factory.hpp" +#include "core/data_type/data_type_map.h" +#include "core/data_type/data_type_nullable.h" +#include "core/data_type/data_type_struct.h" +#include "format_v2/lance/lance_reader_helper.h" + +namespace doris::format::lance { +namespace { + +class LanceNestedNullTest : public testing::Test { +protected: + void SetUp() override { + _old_validation = config::enable_arrow_input_validation; + config::enable_arrow_input_validation = true; + } + void TearDown() override { config::enable_arrow_input_validation = _old_validation; } + + DataTypePtr null_type() { + return DataTypeFactory::instance().create_data_type(TYPE_NULL, true); + } + + void expect_nulls(const IColumn& column, size_t count) { + const auto& nullable = assert_cast<const ColumnNullable&>(column); + ASSERT_EQ(count, nullable.size()); + ASSERT_EQ(count, nullable.get_nested_column().size()); + for (size_t row = 0; row < count; ++row) { + EXPECT_TRUE(nullable.is_null_at(row)); + } + } + + void read(const std::shared_ptr<arrow::Array>& array, const DataTypePtr& type, + MutableColumnPtr* column) { + std::shared_ptr<arrow::Array> normalized; + ASSERT_TRUE(normalize_lance_arrow_array(arrow::field("value", array->type()), array, + arrow::default_memory_pool(), &normalized) + .ok()); + *column = type->create_column(); + ASSERT_TRUE(type->get_serde() + ->read_column_from_arrow(**column, normalized.get(), 0, + normalized->length(), cctz::time_zone {}) + .ok()); + } + +private: + bool _old_validation; +}; + +TEST_F(LanceNestedNullTest, MapsNestedNullSchemas) { + const auto null_field = arrow::field("value", arrow::null()); + const auto schema = arrow::schema({ + arrow::field("list", arrow::list(null_field)), + arrow::field("large_list", arrow::large_list(null_field)), + arrow::field("fixed_list", arrow::fixed_size_list(null_field, 2)), + arrow::field("struct", arrow::struct_({null_field})), + arrow::field("map", arrow::map(arrow::utf8(), null_field)), + arrow::field("nested", arrow::list(arrow::struct_({null_field}))), + arrow::field("list_list", arrow::list(arrow::list(null_field))), + arrow::field("struct_list", + arrow::struct_({arrow::field("list", arrow::list(null_field))})), + arrow::field("list_map", arrow::list(arrow::map(arrow::utf8(), null_field))), + }); + std::vector<std::string> names; + std::vector<DataTypePtr> types; + ASSERT_TRUE(convert_arrow_schema_to_doris(schema, &names, &types).ok()); + ASSERT_EQ(9, types.size()); + for (const auto& type : types) { + EXPECT_NE(INVALID_TYPE, type->get_primitive_type()); + } + for (size_t i = 0; i < 3; ++i) { + ASSERT_EQ(TYPE_ARRAY, types[i]->get_primitive_type()); + EXPECT_TRUE(assert_cast<const DataTypeArray&>(*remove_nullable(types[i])) + .get_nested_type() + ->is_null_literal()); + } +} + +TEST_F(LanceNestedNullTest, RejectsNonNullableNullLeaves) { + const auto leaf = arrow::field("item", arrow::null(), false); + for (const auto& type : std::vector<std::shared_ptr<arrow::DataType>> { + arrow::null(), arrow::list(leaf), arrow::large_list(leaf), + arrow::fixed_size_list(leaf, 2), arrow::struct_({leaf}), + arrow::map(arrow::null(), arrow::utf8()), arrow::map(arrow::utf8(), leaf), + arrow::list(arrow::map(arrow::utf8(), leaf))}) { + SCOPED_TRACE(type->ToString()); + std::vector<std::string> names; + std::vector<DataTypePtr> types; + ASSERT_TRUE(convert_arrow_schema_to_doris( + arrow::schema({arrow::field("value", type, false)}), &names, &types) + .ok()); + ASSERT_EQ(1, types.size()); + EXPECT_EQ(INVALID_TYPE, types[0]->get_primitive_type()); + } +} + +TEST_F(LanceNestedNullTest, PreservesNullTypeInNestedSchemaRpc) { + const auto null = null_type(); + const auto type = make_nullable(std::make_shared<DataTypeStruct>( + DataTypes {null, make_nullable(std::make_shared<DataTypeArray>(null)), + make_nullable(std::make_shared<DataTypeMap>( + DataTypeFactory::instance().create_data_type(TYPE_STRING, true), + null))}, + Strings {"scalar", "list", "map"})); + PTypeDesc descriptor; + type->to_protobuf(&descriptor); + ASSERT_EQ(7, descriptor.types_size()); + for (int index : {1, 3, 6}) { + EXPECT_EQ(TPrimitiveType::NULL_TYPE, descriptor.types(index).scalar_type().type()); + } +} + +TEST_F(LanceNestedNullTest, ReadsNullLeafRangesWithoutPhysicalBuffers) { + const auto type = null_type(); + const auto source = std::make_shared<arrow::NullArray>(7)->Slice(2, 4); + for (bool validation : {false, true}) { + config::enable_arrow_input_validation = validation; + auto column = type->create_column(); + column->insert_default(); + ASSERT_TRUE( + type->get_serde() + ->read_column_from_arrow(*column, source.get(), 1, 3, cctz::time_zone {}) + .ok()); + expect_nulls(*column, 3); + ASSERT_TRUE( + type->get_serde() + ->read_column_from_arrow(*column, source.get(), 4, 4, cctz::time_zone {}) + .ok()); + expect_nulls(*column, 3); + } +} + +TEST_F(LanceNestedNullTest, RejectsInvalidNullRangesBeforeAppending) { + const auto type = null_type(); + const arrow::NullArray source(3); + for (bool validation : {false, true}) { + config::enable_arrow_input_validation = validation; + auto column = type->create_column(); + column->insert_default(); + for (const auto& [start, end] : + std::vector<std::pair<int64_t, int64_t>> {{-1, 1}, {2, 1}, {0, 4}}) { + EXPECT_FALSE(type->get_serde() + ->read_column_from_arrow(*column, &source, start, end, + cctz::time_zone {}) + .ok()); + expect_nulls(*column, 1); + } + } +} + +TEST_F(LanceNestedNullTest, ReadsNullStructParents) { + const std::vector<uint8_t> validity {0x05}; + const auto source = std::make_shared<arrow::StructArray>( + arrow::struct_({arrow::field("empty", arrow::null())}), 3, + arrow::ArrayVector {std::make_shared<arrow::NullArray>(3)}, + arrow::Buffer::Wrap(validity), 1); + const auto type = make_nullable( + std::make_shared<DataTypeStruct>(DataTypes {null_type()}, Strings {"empty"})); + MutableColumnPtr column; + read(source->Slice(1, 2), type, &column); + ASSERT_NE(nullptr, column.get()); + const auto& parent = assert_cast<const ColumnNullable&>(*column); + EXPECT_EQ((NullMap {1, 0}), parent.get_null_map_data()); + const auto& structure = assert_cast<const ColumnStruct&>(parent.get_nested_column()); + expect_nulls(structure.get_column(0), 2); +} + +TEST_F(LanceNestedNullTest, ReadsNullListElementsAndPreservesParentShape) { + const auto null = null_type(); + const auto type = make_nullable(std::make_shared<DataTypeArray>(null)); + const auto values = std::make_shared<arrow::NullArray>(5); + const std::vector<int32_t> offsets {0, 2, 2, 2, 5}; + const std::vector<int64_t> large_offsets {0, 2, 2, 2, 5}; + const std::vector<uint8_t> validity {0x0d}; + const std::vector<std::shared_ptr<arrow::Array>> arrays { + std::make_shared<arrow::ListArray>(arrow::list(arrow::null()), 4, + arrow::Buffer::Wrap(offsets), values, + arrow::Buffer::Wrap(validity), 1), + std::make_shared<arrow::LargeListArray>(arrow::large_list(arrow::null()), 4, + arrow::Buffer::Wrap(large_offsets), values, + arrow::Buffer::Wrap(validity), 1), + }; + for (const auto& source : arrays) { + for (bool sliced : {false, true}) { + MutableColumnPtr column; + read(sliced ? source->Slice(1, 3) : source, type, &column); + ASSERT_NE(nullptr, column.get()); + const auto& parent = assert_cast<const ColumnNullable&>(*column); + EXPECT_EQ((sliced ? NullMap {1, 0, 0} : NullMap {0, 1, 0, 0}), + parent.get_null_map_data()); + const auto& list = assert_cast<const ColumnArray&>(parent.get_nested_column()); + EXPECT_EQ((sliced ? ColumnArray::Offsets64 {0, 0, 3} + : ColumnArray::Offsets64 {2, 2, 2, 5}), + list.get_offsets()); + expect_nulls(list.get_data(), sliced ? 3 : 5); + } + } +} + +TEST_F(LanceNestedNullTest, ReadsFixedNullLists) { + const std::vector<uint8_t> validity {0x05}; + const auto source = std::make_shared<arrow::FixedSizeListArray>( + arrow::fixed_size_list(arrow::null(), 2), 3, std::make_shared<arrow::NullArray>(6), + arrow::Buffer::Wrap(validity), 1); + const auto type = make_nullable(std::make_shared<DataTypeArray>(null_type())); + MutableColumnPtr column; + read(source->Slice(1, 2), type, &column); + ASSERT_NE(nullptr, column.get()); + const auto& parent = assert_cast<const ColumnNullable&>(*column); + EXPECT_EQ((NullMap {1, 0}), parent.get_null_map_data()); + const auto& list = assert_cast<const ColumnArray&>(parent.get_nested_column()); + EXPECT_EQ((ColumnArray::Offsets64 {2, 4}), list.get_offsets()); + expect_nulls(list.get_data(), 4); +} + +TEST_F(LanceNestedNullTest, ReadsNullStructFieldsInsideLists) { + const auto nulls = std::make_shared<arrow::NullArray>(3); + const std::vector<int32_t> ids {10, 20, 30}; + const auto id_array = std::make_shared<arrow::Int32Array>(3, arrow::Buffer::Wrap(ids)); + const auto struct_type = arrow::struct_( + {arrow::field("empty", arrow::null()), arrow::field("id", arrow::int32())}); + const auto structs = std::make_shared<arrow::StructArray>(struct_type, 3, + arrow::ArrayVector {nulls, id_array}); + const std::vector<int32_t> offsets {0, 1, 3}; + const auto source = std::make_shared<arrow::ListArray>(arrow::list(struct_type), 2, + arrow::Buffer::Wrap(offsets), structs); + const auto doris_struct = make_nullable(std::make_shared<DataTypeStruct>( + DataTypes {null_type(), DataTypeFactory::instance().create_data_type(TYPE_INT, true)}, + Strings {"empty", "id"})); + MutableColumnPtr column; + read(source->Slice(1, 1), make_nullable(std::make_shared<DataTypeArray>(doris_struct)), + &column); + ASSERT_NE(nullptr, column.get()); + const auto& list = assert_cast<const ColumnArray&>( + assert_cast<const ColumnNullable&>(*column).get_nested_column()); + const auto& result = assert_cast<const ColumnStruct&>( + assert_cast<const ColumnNullable&>(list.get_data()).get_nested_column()); + expect_nulls(result.get_column(0), 2); + const auto& id_column = assert_cast<const ColumnInt32&>( + assert_cast<const ColumnNullable&>(result.get_column(1)).get_nested_column()); + EXPECT_EQ((ColumnInt32::Container {20, 30}), id_column.get_data()); +} + +TEST_F(LanceNestedNullTest, ReadsNullMapValues) { + const std::vector<int32_t> keys {10, 20, 30}; + const std::vector<int32_t> offsets {0, 1, 1, 3}; + const std::vector<uint8_t> validity {0x05}; + const auto source = std::make_shared<arrow::MapArray>( + arrow::map(arrow::int32(), arrow::null()), 3, arrow::Buffer::Wrap(offsets), + std::make_shared<arrow::Int32Array>(3, arrow::Buffer::Wrap(keys)), + std::make_shared<arrow::NullArray>(3), arrow::Buffer::Wrap(validity), 1); + const auto type = make_nullable(std::make_shared<DataTypeMap>( + DataTypeFactory::instance().create_data_type(TYPE_INT, true), null_type())); + MutableColumnPtr column; + read(source->Slice(1, 2), type, &column); + ASSERT_NE(nullptr, column.get()); + const auto& parent = assert_cast<const ColumnNullable&>(*column); + EXPECT_EQ((NullMap {1, 0}), parent.get_null_map_data()); + const auto& map = assert_cast<const ColumnMap&>(parent.get_nested_column()); + EXPECT_EQ((ColumnArray::Offsets64 {0, 2}), map.get_offsets()); + expect_nulls(map.get_values(), 2); + const auto& key_column = assert_cast<const ColumnInt32&>( + assert_cast<const ColumnNullable&>(map.get_keys()).get_nested_column()); + EXPECT_EQ((ColumnInt32::Container {20, 30}), key_column.get_data()); +} + +} // namespace +} // namespace doris::format::lance diff --git a/be/test/format_v2/table/lance_reader_test.cpp b/be/test/format_v2/table/lance_reader_test.cpp index cfcb4597963..e23d7c2ea6b 100644 --- a/be/test/format_v2/table/lance_reader_test.cpp +++ b/be/test/format_v2/table/lance_reader_test.cpp @@ -1795,25 +1795,6 @@ TEST(LanceTableReaderSchemaTest, RejectsMalformedKnownExtensionStorage) { } } -// Verifies nested Null fields remain unsupported. -TEST(LanceTableReaderSchemaTest, MarksNestedNullTypesAsUnsupported) { - const auto arrow_schema = arrow::schema({ - arrow::field("null_list", arrow::list(arrow::field("item", arrow::null()))), - arrow::field("null_struct", arrow::struct_({arrow::field("value", arrow::null())})), - }); - - std::vector<std::string> column_names; - std::vector<DataTypePtr> column_types; - ASSERT_TRUE(convert_arrow_schema_to_doris(arrow_schema, &column_names, &column_types).ok()); - - EXPECT_EQ((std::vector<std::string> {"null_list", "null_struct"}), column_names); - ASSERT_EQ(2, column_types.size()); - for (const auto& column_type : column_types) { - ASSERT_NE(nullptr, column_type); - EXPECT_EQ(INVALID_TYPE, column_type->get_primitive_type()); - } -} - // Verifies values, nullability, and precision when reading the additional types. TEST(LanceTableReaderTypeTest, ReadsAdditionalArrowAndLanceTypes) { const auto json_extension_metadata = diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_nested_null.py b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_nested_null.py new file mode 100644 index 00000000000..73d1e7a064b --- /dev/null +++ b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_nested_null.py @@ -0,0 +1,70 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generate the nested Null regression fixture using lance_fixture_requirements.txt.""" + +import argparse +from pathlib import Path + +import lance +import pyarrow as pa + + +def expected_table() -> pa.Table: + null_struct = pa.struct([("empty", pa.null()), ("value", pa.int32())]) + schema = pa.schema([ + ("id", pa.int32()), + ("null_list", pa.list_(pa.null())), + ("null_large_list", pa.large_list(pa.null())), + ("null_fixed_list", pa.list_(pa.null(), 2)), + ("null_struct", null_struct), + ("nested_list", pa.list_(null_struct)), + ("null_map", pa.map_(pa.string(), pa.null())), + ]) + rows = [ + {"id": 1, "null_list": [None, None], "null_large_list": [None], + "null_fixed_list": [None, None], "null_struct": {"empty": None, "value": 10}, + "nested_list": [{"empty": None, "value": 11}, {"empty": None, "value": 12}], + "null_map": [("a", None), ("b", None)]}, + {"id": 2, "null_list": None, "null_large_list": None, "null_fixed_list": None, + "null_struct": {"empty": None, "value": 20}, "nested_list": None, "null_map": None}, + {"id": 3, "null_list": [], "null_large_list": [], + "null_fixed_list": [None, None], "null_struct": {"empty": None, "value": 30}, + "nested_list": [], "null_map": []}, + {"id": 4, "null_list": [None], "null_large_list": [None, None, None], + "null_fixed_list": [None, None], "null_struct": {"empty": None, "value": 40}, + "nested_list": [{"empty": None, "value": 41}], "null_map": [("c", None)]}, + ] + # pylance 7 cannot encode a null parent struct with a Null child; BE tests cover that shape. + return pa.Table.from_pylist(rows, schema=schema) + + +def check(output: Path) -> None: + # Validate the persisted schema and values, including empty collections and null parents. + actual = lance.dataset(str(output)).to_table() + assert actual.equals(expected_table()), f"nested Null fixture differs from expected data: {output}" + + +def build(output: Path) -> None: + lance.write_dataset(expected_table(), str(output), data_storage_version="2.2") + check(output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + build(parser.parse_args().output) diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py index d6ea30be203..92220554c7b 100644 --- a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py +++ b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py @@ -28,6 +28,7 @@ every "indexed" query in test_lance_vector_search silently ran a flat KNN scan. The generated catalog contains: - __manifest Directory Namespace V2 manifest table (with its scalar indexes). - all_types.lance The pre-existing compatibility-mode root table, re-registered as-is. + - nested_null.lance Nullable Null leaves inside lists, structs, and maps. - The `doris` namespace with two full-text-search fixtures, one indexed vector table per cell of the algorithm x element type x metric matrix (hash-prefixed directories), listed in VECTOR_TABLES below; BREADTH_TABLE, one table carrying the remaining cells at plan @@ -85,6 +86,7 @@ import lance import lance_namespace import pyarrow as pa import pyarrow.ipc as ipc +from lance_build_nested_null import build as build_nested_null, check as check_nested_null from lance_namespace_urllib3_client.models import ( CreateNamespaceRequest, CreateTableRequest, @@ -99,6 +101,7 @@ FRAGMENT_ROWS = 512 NUM_PARTITIONS = 4 NAMESPACE = "doris" ALL_TYPES_DIR = "all_types.lance" +NESTED_NULL_DIR = "nested_null.lance" MANIFEST_DIR = "__manifest" # 4-bit PQ keeps codebook training comfortable on 1024 rows. This only serves fixture @@ -918,7 +921,8 @@ def build_multi_frag(root: Path) -> None: location = str(root / MULTI_FRAG_DIR) for index in range(MULTI_FRAG_NUM_FRAGMENTS): offset = index * MULTI_FRAG_FRAGMENT_ROWS - fragment = make_fragment_table(offset, offset + MULTI_FRAG_FRAGMENT_ROWS) + # The shared builder requires a vector profile even though embedding is dropped below. + fragment = make_fragment_table(COLLINEAR, pa.float32(), offset, offset + MULTI_FRAG_FRAGMENT_ROWS) fragment = fragment.drop_columns(["embedding"]) # Match all_types.lance (data storage version 2.2) so every committed Lance data file # shares one on-disk format and the oldest reader (lance-rs 4.0.1) can open it. @@ -933,6 +937,8 @@ def build_multi_frag(root: Path) -> None: def build(root: Path, all_types_source: Path) -> None: shutil.copytree(all_types_source, root / ALL_TYPES_DIR) build_multi_frag(root) + # Recreate this fixture in staging because promotion replaces the entire catalog tree. + build_nested_null(root / NESTED_NULL_DIR) namespace = lance_namespace.connect("dir", {"root": str(root)}) namespace.register_table( RegisterTableRequest(id=["all_types"], location=ALL_TYPES_DIR) @@ -1721,6 +1727,7 @@ def check_catalog(root: Path) -> None: assert nested_path.is_dir(), f"{NESTED_TABLE} location missing: {nested.location}" check_nested_dataset(nested.location) check_multi_frag(root) + check_nested_null(root / NESTED_NULL_DIR) full_fts = namespace.describe_table(DescribeTableRequest(id=[NAMESPACE, FTS_TABLE])) check_fts_dataset( diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_transactions/0-d6a5ba97-9164-4b3e-b773-15de06005c54.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_transactions/0-d6a5ba97-9164-4b3e-b773-15de06005c54.txn new file mode 100644 index 00000000000..e5933f5bbd5 Binary files /dev/null and b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_transactions/0-d6a5ba97-9164-4b3e-b773-15de06005c54.txn differ diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/18446744073709551614.manifest b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/18446744073709551614.manifest new file mode 100644 index 00000000000..1b5fb4b08fa Binary files /dev/null and b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/18446744073709551614.manifest differ diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/latest_version_hint.json b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/latest_version_hint.json new file mode 100644 index 00000000000..491d734467a --- /dev/null +++ b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/_versions/latest_version_hint.json @@ -0,0 +1 @@ +{"version":1} \ No newline at end of file diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/data/0110100101011111010110112b5b4742fd82cf05955fa974a3.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/data/0110100101011111010110112b5b4742fd82cf05955fa974a3.lance new file mode 100644 index 00000000000..0ba4ec7cafa Binary files /dev/null and b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/nested_null.lance/data/0110100101011111010110112b5b4742fd82cf05955fa974a3.lance differ diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTypeConverter.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTypeConverter.java index 77f23f04144..fd9576a3c94 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTypeConverter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTypeConverter.java @@ -44,11 +44,6 @@ public final class LanceTypeConverter { private LanceTypeConverter() { } - /** Converts Arrow fields exposed by Lance to Doris types. */ - public static Type toDorisType(Field field) { - return toDorisType(field, true); - } - /** Returns whether this field needs the current BE Lance materialization logic. */ public static boolean requiresCurrentBeReader(Field field) { ArrowType.ArrowTypeID typeId = field.getType().getTypeID(); @@ -71,8 +66,8 @@ public final class LanceTypeConverter { return false; } - /** Converts an Arrow field, allowing Doris NULL only at the top level. */ - private static Type toDorisType(Field field, boolean allowNull) { + /** Converts Arrow fields, including Null leaves in complex types. */ + public static Type toDorisType(Field field) { // TODO(lance): Dataset.getSchema() currently erases the Dictionary marker, while // Dataset.getLanceSchema() fails to convert a schema containing Dictionary in the // Lance 9.1.0-beta.3 Java SDK. Reject physical Dictionary columns after that SDK @@ -89,7 +84,8 @@ public final class LanceTypeConverter { ArrowType arrowType = field.getType(); switch (arrowType.getTypeID()) { case Null: - return allowNull ? Type.NULL : Type.UNSUPPORTED; + // Required Null leaves cannot use the nullable SerDe that handles Arrow NA buffers. + return field.isNullable() ? Type.NULL : Type.UNSUPPORTED; case Bool: return Type.BOOLEAN; case Int: @@ -135,24 +131,25 @@ public final class LanceTypeConverter { case LargeList: case FixedSizeList: requireChildren(field, 1); - Type itemType = toDorisType(field.getChildren().get(0), false); - return itemType.isSupported() ? new ArrayType(itemType) : Type.UNSUPPORTED; + Type itemType = toDorisType(field.getChildren().get(0)); + // Generic isSupported() rejects Null items even inside successfully converted composites. + return itemType.equals(Type.UNSUPPORTED) ? Type.UNSUPPORTED : new ArrayType(itemType); case Map: requireChildren(field, 1); Field entries = field.getChildren().get(0); requireChildren(entries, 2); Field key = entries.getChildren().get(0); Field value = entries.getChildren().get(1); - Type keyType = toDorisType(key, false); - Type valueType = toDorisType(value, false); - return keyType.isSupported() && valueType.isSupported() + Type keyType = toDorisType(key); + Type valueType = toDorisType(value); + return !keyType.equals(Type.UNSUPPORTED) && !valueType.equals(Type.UNSUPPORTED) ? new MapType(keyType, valueType, key.isNullable(), value.isNullable()) : Type.UNSUPPORTED; case Struct: List<StructField> fields = new ArrayList<>(); for (Field child : field.getChildren()) { - Type childType = toDorisType(child, false); - if (!childType.isSupported()) { + Type childType = toDorisType(child); + if (childType.equals(Type.UNSUPPORTED)) { return Type.UNSUPPORTED; } fields.add(new StructField(child.getName(), childType, diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java index 4bbde106e81..5b079ab4e85 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java @@ -97,6 +97,7 @@ public class LanceScanNode extends FileQueryScanNode { private final SearchKind searchKind; private byte[] lanceSubstraitFilter = new byte[0]; private String lancePushdownPredicate = ""; + private final Set<String> lazyMaterializedColumns = new HashSet<>(); private long plannedVersion = -1; private int plannedFragments; private int plannedUnindexedFragments; @@ -166,9 +167,15 @@ public class LanceScanNode extends FileQueryScanNode { } } - /** Checks whether any projected Lance column requires the current BE reader. */ + public void addLazyMaterializedColumn(String columnName) { + lazyMaterializedColumns.add(columnName.toLowerCase(Locale.ROOT)); + } + + /** Checks columns read in either phase of a Lance scan. */ private boolean projectsCurrentReaderType() { - Set<String> projectedColumns = new HashSet<>(); + // Global row IDs route the second-phase take back to the first-phase BE, so lazy pruning + // must not hide a column's reader requirement when checking mixed-version backends. + Set<String> projectedColumns = new HashSet<>(lazyMaterializedColumns); for (SlotDescriptor slot : desc.getSlots()) { if (slot.getColumn() != null) { projectedColumns.add(slot.getColumn().getName().toLowerCase(Locale.ROOT)); diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java index 2dccffa071b..dd7b4620ada 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java @@ -40,7 +40,6 @@ import org.apache.doris.qe.SessionVariable; import org.apache.doris.spi.Split; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.tablefunction.ExternalFileTableValuedFunction; -import org.apache.doris.tablefunction.LocalTableValuedFunction; import org.apache.doris.thrift.TBrokerFileStatus; import org.apache.doris.thrift.TFileAttributes; import org.apache.doris.thrift.TFileCompressType; @@ -82,14 +81,11 @@ public class TVFScanNode extends FileQueryScanNode { @Override protected void initBackendPolicy() throws UserException { - if (tableValuedFunction instanceof LocalTableValuedFunction) { - long backendId = - ((LocalTableValuedFunction) tableValuedFunction).getBackendIdForExecution(); - if (backendId != -1) { - backendPolicy.initWithBackendId(backendId); - numNodes = backendPolicy.numBackends(); - return; - } + long backendId = tableValuedFunction.getBackendIdForExecution(); + if (backendId != -1) { + backendPolicy.initWithBackendId(backendId); + numNodes = backendPolicy.numBackends(); + return; } backendPolicy.init(); numNodes = backendPolicy.numBackends(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java index 4271c7fffa3..be66a89526c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -1257,6 +1257,12 @@ public class PhysicalPlanTranslator extends DefaultPlanVisitor<PlanFragment, Pla TableValuedFunctionIf catalogFunction = tvfRelation.getFunction().getCatalogFunction(); SessionVariable sv = ConnectContext.get().getSessionVariable(); ScanNode scanNode = catalogFunction.getScanNode(context.nextPlanNodeId(), tupleDescriptor, sv); + if (scanNode instanceof LanceScanNode && tvfRelation instanceof PhysicalLazyMaterializeTVFScan) { + for (Slot slot : ((PhysicalLazyMaterializeTVFScan) tvfRelation).getLazySlots()) { + ((LanceScanNode) scanNode).addLazyMaterializedColumn( + ((SlotReference) slot).getOriginalColumn().map(Column::getName).orElse(slot.getName())); + } + } scanNode.setNereidsId(tvfRelation.getId()); context.getNereidsIdToPlanNodeIdMap().put(tvfRelation.getId(), scanNode.getId()); Utils.execWithUncheckedException(scanNode::init); @@ -3001,34 +3007,7 @@ public class PhysicalPlanTranslator extends DefaultPlanVisitor<PlanFragment, Pla @Override public PlanFragment visitPhysicalLazyMaterializeTVFScan(PhysicalLazyMaterializeTVFScan tvfRelation, PlanTranslatorContext context) { - List<Slot> slots = tvfRelation.getOutput(); - TupleDescriptor tupleDescriptor = generateTupleDesc(slots, tvfRelation.getFunction().getTable(), context); - - TableValuedFunctionIf catalogFunction = tvfRelation.getFunction().getCatalogFunction(); - SessionVariable sv = ConnectContext.get().getSessionVariable(); - ScanNode scanNode = catalogFunction.getScanNode(context.nextPlanNodeId(), tupleDescriptor, sv); - scanNode.setNereidsId(tvfRelation.getId()); - context.getNereidsIdToPlanNodeIdMap().put(tvfRelation.getId(), scanNode.getId()); - Utils.execWithUncheckedException(scanNode::init); - context.getRuntimeTranslator().ifPresent( - runtimeFilterGenerator -> runtimeFilterGenerator.getContext().getTargetListByScan(tvfRelation) - .forEach(expr -> runtimeFilterGenerator.translateRuntimeFilterTarget(expr, scanNode, context) - ) - ); - context.addScanNode(scanNode, tvfRelation); - - // TODO: it is weird update label in this way - // set label for explain - for (Slot slot : slots) { - String tableColumnName = TableValuedFunctionIf.TVF_TABLE_PREFIX + tvfRelation.getFunction().getName() - + "." + slot.getName(); - context.findSlotRef(slot.getExprId()).setLabel(tableColumnName); - } - - PlanFragment planFragment = createPlanFragment(scanNode, DataPartition.RANDOM, tvfRelation); - context.addPlanFragment(planFragment); - updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), tvfRelation); - return planFragment; + return visitPhysicalTVFRelation(tvfRelation, context); } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalLazyMaterializeTVFScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalLazyMaterializeTVFScan.java index 03787887563..1278cfee53b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalLazyMaterializeTVFScan.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalLazyMaterializeTVFScan.java @@ -47,7 +47,11 @@ public class PhysicalLazyMaterializeTVFScan extends PhysicalTVFRelation { super(scan.getRelationId(), scan.getFunction(), scan.getOperativeSlots(), scan.getLogicalProperties()); this.scan = scan; this.rowId = rowId; - this.lazySlots = lazySlots; + this.lazySlots = ImmutableList.copyOf(lazySlots); + } + + public List<Slot> getLazySlots() { + return lazySlots; } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java index 3c4acfaab8a..5c4f7bc3a3e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java @@ -144,6 +144,11 @@ public abstract class ExternalFileTableValuedFunction extends TableValuedFunctio private List<LanceFragmentInfo> lanceFragments = Collections.emptyList(); private Set<String> lanceCurrentReaderColumns = Collections.emptySet(); + /** Return the only backend that may execute this TVF, or -1 when execution may be distributed. */ + public long getBackendIdForExecution() { + return -1; + } + public abstract TFileType getTFileType(); public abstract String getFilePath(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FileTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FileTableValuedFunction.java index 6ec9f12a81c..46e29ff25e5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FileTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FileTableValuedFunction.java @@ -95,6 +95,18 @@ public class FileTableValuedFunction extends ExternalFileTableValuedFunction { return delegateTvf.isLanceFormat(); } + @Override + public boolean requiresCurrentLanceReader(String columnName) { + // Schema discovery records reader requirements on the delegate, not this wrapper. + return delegateTvf.requiresCurrentLanceReader(columnName); + } + + @Override + public long getBackendIdForExecution() { + // Local Lance must run on its schema backend, including through the generic file() entry point. + return delegateTvf.getBackendIdForExecution(); + } + @Override public long getLanceDatasetVersion() { return delegateTvf.getLanceDatasetVersion(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LocalTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LocalTableValuedFunction.java index 3e04d614aac..4e4bf163145 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LocalTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LocalTableValuedFunction.java @@ -159,7 +159,7 @@ public class LocalTableValuedFunction extends ExternalFileTableValuedFunction { return backendId; } - /** Return the only backend that may execute this TVF, or -1 when execution may be distributed. */ + @Override public long getBackendIdForExecution() { return isLanceFormat() ? backendIdForRequest : backendId; } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceTypeConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceTypeConverterTest.java index 9730b0af39e..1e842385f50 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceTypeConverterTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceTypeConverterTest.java @@ -17,7 +17,10 @@ package org.apache.doris.datasource.lance; +import org.apache.doris.catalog.ArrayType; +import org.apache.doris.catalog.MapType; import org.apache.doris.catalog.ScalarType; +import org.apache.doris.catalog.StructType; import org.apache.doris.catalog.Type; import org.apache.arrow.vector.types.DateUnit; @@ -30,6 +33,7 @@ import org.apache.arrow.vector.types.pojo.FieldType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.Collections; public class LanceTypeConverterTest { @@ -124,21 +128,91 @@ public class LanceTypeConverterTest { Assertions.assertTrue(LanceTypeConverter.requiresCurrentBeReader(durationList)); } - /** Verifies nested Null fields remain unsupported. */ @Test - public void testNestedNullIsUnsupported() { + public void testNestedNullMappings() { Field nullItem = Field.nullable("item", ArrowType.Null.INSTANCE); - Field nullList = new Field( - "null_list", - FieldType.nullable(ArrowType.List.INSTANCE), - Collections.singletonList(nullItem)); - Field nullStruct = new Field( - "null_struct", - FieldType.nullable(ArrowType.Struct.INSTANCE), - Collections.singletonList(Field.nullable("value", ArrowType.Null.INSTANCE))); - - Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(nullList)); - Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(nullStruct)); + for (ArrowType listType : Arrays.asList(ArrowType.List.INSTANCE, + ArrowType.LargeList.INSTANCE, new ArrowType.FixedSizeList(2))) { + Field list = new Field("null_list", FieldType.nullable(listType), + Collections.singletonList(nullItem)); + Type converted = LanceTypeConverter.toDorisType(list); + Assertions.assertInstanceOf(ArrayType.class, converted); + Assertions.assertEquals(Type.NULL, ((ArrayType) converted).getItemType()); + Assertions.assertTrue(LanceTypeConverter.requiresCurrentBeReader(list)); + } + + Field struct = new Field("null_struct", FieldType.nullable(ArrowType.Struct.INSTANCE), + Arrays.asList(Field.nullable("id", new ArrowType.Int(32, true)), nullItem)); + Type convertedStruct = LanceTypeConverter.toDorisType(struct); + Assertions.assertInstanceOf(StructType.class, convertedStruct); + Assertions.assertEquals(Type.NULL, ((StructType) convertedStruct).getFields().get(1).getType()); + + Field entries = new Field("entries", FieldType.notNullable(ArrowType.Struct.INSTANCE), + Arrays.asList(Field.notNullable("key", ArrowType.Utf8.INSTANCE), nullItem)); + Field map = new Field("null_map", FieldType.nullable(new ArrowType.Map(false)), + Collections.singletonList(entries)); + Type convertedMap = LanceTypeConverter.toDorisType(map); + Assertions.assertInstanceOf(MapType.class, convertedMap); + Assertions.assertEquals(Type.NULL, ((MapType) convertedMap).getValueType()); + + Field nested = new Field("nested", FieldType.nullable(ArrowType.List.INSTANCE), + Collections.singletonList(struct)); + ArrayType convertedNested = (ArrayType) LanceTypeConverter.toDorisType(nested); + Assertions.assertEquals(Type.NULL, + ((StructType) convertedNested.getItemType()).getFields().get(1).getType()); + } + + @Test + public void testDeepNullComposites() { + Field leaf = Field.nullable("item", ArrowType.Null.INSTANCE); + Field list = new Field("item", FieldType.nullable(ArrowType.List.INSTANCE), + Collections.singletonList(leaf)); + Field entries = new Field("entries", FieldType.notNullable(ArrowType.Struct.INSTANCE), + Arrays.asList(Field.notNullable("key", ArrowType.Utf8.INSTANCE), leaf)); + Field map = new Field("item", FieldType.nullable(new ArrowType.Map(false)), + Collections.singletonList(entries)); + for (Field child : Arrays.asList(list, map)) { + Type childType = LanceTypeConverter.toDorisType(child); + for (ArrowType outer : Arrays.asList(ArrowType.List.INSTANCE, + ArrowType.LargeList.INSTANCE, new ArrowType.FixedSizeList(2))) { + Field nested = new Field("nested", FieldType.nullable(outer), + Collections.singletonList(child)); + Assertions.assertEquals(new ArrayType(childType), LanceTypeConverter.toDorisType(nested)); + } + Field struct = new Field("nested", FieldType.nullable(ArrowType.Struct.INSTANCE), + Collections.singletonList(child)); + Type converted = LanceTypeConverter.toDorisType(struct); + Assertions.assertInstanceOf(StructType.class, converted); + Assertions.assertEquals(childType, ((StructType) converted).getFields().get(0).getType()); + Field nestedEntries = new Field("entries", FieldType.notNullable(ArrowType.Struct.INSTANCE), + Arrays.asList(Field.notNullable("key", ArrowType.Utf8.INSTANCE), child)); + Field nestedMap = new Field("nested", FieldType.nullable(new ArrowType.Map(false)), + Collections.singletonList(nestedEntries)); + Assertions.assertEquals(new MapType(Type.STRING, childType, false, true), + LanceTypeConverter.toDorisType(nestedMap)); + } + } + + @Test + public void testNonNullableNullLeavesAreUnsupported() { + Field leaf = Field.notNullable("item", ArrowType.Null.INSTANCE); + Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(leaf)); + for (ArrowType outer : Arrays.asList(ArrowType.List.INSTANCE, + ArrowType.LargeList.INSTANCE, new ArrowType.FixedSizeList(2), ArrowType.Struct.INSTANCE)) { + Field nested = new Field("nested", FieldType.nullable(outer), Collections.singletonList(leaf)); + Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(nested)); + } + for (boolean nullKey : Arrays.asList(false, true)) { + Field entries = new Field("entries", FieldType.notNullable(ArrowType.Struct.INSTANCE), + Arrays.asList(nullKey ? leaf : Field.notNullable("key", ArrowType.Utf8.INSTANCE), + nullKey ? Field.nullable("value", ArrowType.Utf8.INSTANCE) : leaf)); + Field map = new Field("map", FieldType.nullable(new ArrowType.Map(false)), + Collections.singletonList(entries)); + Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(map)); + Field nested = new Field("nested", FieldType.nullable(ArrowType.List.INSTANCE), + Collections.singletonList(map)); + Assertions.assertEquals(Type.UNSUPPORTED, LanceTypeConverter.toDorisType(nested)); + } } /** Verifies known extension mappings and storage validation. */ diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java index ef2dbb8f041..8c3087962d1 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/tvf/source/TVFScanNodeTest.java @@ -21,6 +21,7 @@ import org.apache.doris.analysis.SlotDescriptor; import org.apache.doris.analysis.SlotId; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; +import org.apache.doris.catalog.ArrayType; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.FunctionGenTable; import org.apache.doris.catalog.Type; @@ -37,7 +38,9 @@ import org.apache.doris.qe.SessionVariable; import org.apache.doris.spi.Split; import org.apache.doris.system.Backend; import org.apache.doris.tablefunction.ExternalFileTableValuedFunction; +import org.apache.doris.tablefunction.FileTableValuedFunction; import org.apache.doris.tablefunction.LocalTableValuedFunction; +import org.apache.doris.tablefunction.S3TableValuedFunction; import org.apache.doris.thrift.TBrokerFileStatus; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; @@ -252,6 +255,7 @@ public class TVFScanNodeTest { Mockito.when(table.getTvf()).thenReturn(tvf); Mockito.when(tvf.isLanceFormat()).thenReturn(true); Mockito.when(tvf.requiresCurrentLanceReader("json_value")).thenReturn(true); + Mockito.when(tvf.getBackendIdForExecution()).thenCallRealMethod(); desc.setTable(table); Backend smoothUpgradeSource = Mockito.mock(Backend.class); @@ -270,4 +274,95 @@ public class TVFScanNodeTest { Assert.assertTrue(exception.getMessage().contains("102")); } + + @Test + public void testFileS3LanceNestedNullRejectsSmoothUpgradeSource() throws Exception { + S3TableValuedFunction delegate = Mockito.mock(S3TableValuedFunction.class); + Mockito.when(delegate.isLanceFormat()).thenReturn(true); + Mockito.when(delegate.getBackendIdForExecution()).thenCallRealMethod(); + Mockito.when(delegate.requiresCurrentLanceReader("nested_null")).thenReturn(true); + FileTableValuedFunction tvf = wrapFileTvf(delegate); + TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); + SlotDescriptor slot = new SlotDescriptor(new SlotId(1), desc); + slot.setColumn(new Column("nested_null", ArrayType.create(Type.NULL, true))); + desc.addSlot(slot); + FunctionGenTable table = Mockito.mock(FunctionGenTable.class); + Mockito.when(table.getTvf()).thenReturn(tvf); + desc.setTable(table); + + Backend source = Mockito.mock(Backend.class); + Mockito.when(source.isSmoothUpgradeSrc()).thenReturn(true); + Mockito.when(source.getId()).thenReturn(102L); + FederationBackendPolicy policy = Mockito.mock(FederationBackendPolicy.class); + Mockito.when(policy.getBackends()).thenReturn(Collections.singletonList(source)); + TVFScanNode node = new TVFScanNode( + new PlanNodeId(0), desc, false, new SessionVariable(), ScanContext.EMPTY); + setBackendPolicy(node, policy); + + UserException exception = Assert.assertThrows(UserException.class, node::initBackendPolicy); + Assert.assertTrue(exception.getMessage().contains("102")); + + // An ordinary projection must remain usable while an unrelated root needs the new reader. + slot.setColumn(new Column("ordinary", Type.INT)); + node.initBackendPolicy(); + Mockito.verify(policy, Mockito.times(2)).init(); + } + + @Test + public void testFileLocalLancePinsExecutionToSchemaBackendDuringUpgrade() throws Exception { + LocalTableValuedFunction delegate = Mockito.mock(LocalTableValuedFunction.class); + Mockito.when(delegate.isLanceFormat()).thenReturn(true); + Mockito.when(delegate.getBackendIdForExecution()).thenCallRealMethod(); + Field backendId = LocalTableValuedFunction.class.getDeclaredField("backendId"); + backendId.setAccessible(true); + backendId.set(delegate, -1L); + Field requestBackend = LocalTableValuedFunction.class.getDeclaredField("backendIdForRequest"); + requestBackend.setAccessible(true); + requestBackend.set(delegate, 101L); + Field sharedStorage = LocalTableValuedFunction.class.getDeclaredField("sharedStorage"); + sharedStorage.setAccessible(true); + sharedStorage.set(delegate, true); + FileTableValuedFunction tvf = wrapFileTvf(delegate); + TupleDescriptor desc = new TupleDescriptor(new TupleId(0)); + FunctionGenTable table = Mockito.mock(FunctionGenTable.class); + Mockito.when(table.getTvf()).thenReturn(tvf); + desc.setTable(table); + + Backend source = Mockito.mock(Backend.class); + Mockito.when(source.isSmoothUpgradeSrc()).thenReturn(true); + FederationBackendPolicy policy = Mockito.mock(FederationBackendPolicy.class); + Mockito.when(policy.getBackends()).thenReturn(Collections.singletonList(source)); + Mockito.when(policy.numBackends()).thenReturn(1); + TVFScanNode node = new TVFScanNode( + new PlanNodeId(0), desc, false, new SessionVariable(), ScanContext.EMPTY); + setBackendPolicy(node, policy); + + node.initBackendPolicy(); + Mockito.verify(policy).initWithBackendId(101L); + Mockito.verify(policy, Mockito.never()).init(); + + // Shared non-Lance files still allow distributed execution through the same wrapper. + Mockito.when(delegate.isLanceFormat()).thenReturn(false); + node.initBackendPolicy(); + Mockito.verify(policy).init(); + } + + private static FileTableValuedFunction wrapFileTvf(ExternalFileTableValuedFunction delegate) + throws Exception { + // Avoid storage discovery while exercising the real wrapper methods used by FunctionGenTable. + FileTableValuedFunction wrapper = Mockito.mock(FileTableValuedFunction.class, Mockito.CALLS_REAL_METHODS); + Field delegateField = FileTableValuedFunction.class.getDeclaredField("delegateTvf"); + delegateField.setAccessible(true); + delegateField.set(wrapper, delegate); + Field columns = ExternalFileTableValuedFunction.class.getDeclaredField("lanceCurrentReaderColumns"); + columns.setAccessible(true); + columns.set(wrapper, Collections.emptySet()); + return wrapper; + } + + private static void setBackendPolicy(TVFScanNode node, FederationBackendPolicy policy) throws Exception { + Field field = ExternalScanNode.class.getDeclaredField("backendPolicy"); + field.setAccessible(true); + field.set(node, policy); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/LanceLazyMaterializationUpgradeTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/LanceLazyMaterializationUpgradeTest.java new file mode 100644 index 00000000000..a28e4959aa0 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/LanceLazyMaterializationUpgradeTest.java @@ -0,0 +1,159 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.glue.translator; + +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.catalog.ArrayType; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.FunctionGenTable; +import org.apache.doris.catalog.Table; +import org.apache.doris.catalog.Type; +import org.apache.doris.datasource.ExternalScanNode; +import org.apache.doris.datasource.FederationBackendPolicy; +import org.apache.doris.datasource.lance.LanceExternalTable; +import org.apache.doris.datasource.lance.LanceTableMetadata; +import org.apache.doris.datasource.lance.source.LanceScanNode; +import org.apache.doris.nereids.properties.DataTrait; +import org.apache.doris.nereids.properties.LogicalProperties; +import org.apache.doris.nereids.trees.expressions.ExprId; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.expressions.SlotReference; +import org.apache.doris.nereids.trees.expressions.functions.table.FullTextSearch; +import org.apache.doris.nereids.trees.expressions.functions.table.TableValuedFunction; +import org.apache.doris.nereids.trees.expressions.functions.table.VectorSearch; +import org.apache.doris.nereids.trees.plans.RelationId; +import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeTVFScan; +import org.apache.doris.nereids.trees.plans.physical.PhysicalTVFRelation; +import org.apache.doris.planner.PlanNodeId; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; +import org.apache.doris.system.Backend; +import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction; +import org.apache.doris.tablefunction.TableValuedFunctionIf; +import org.apache.doris.tablefunction.VectorSearchTableValuedFunction; +import org.apache.doris.thrift.TExternalSearchQuery; +import org.apache.doris.thrift.TExternalSearchRequest; +import org.apache.doris.thrift.TFullTextSearchParams; +import org.apache.doris.thrift.TVectorSearchParams; + +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +public class LanceLazyMaterializationUpgradeTest { + @Test + public void testVectorSearchLazyNullUpgradeFence() throws Exception { + assertUpgradeFence(true); + } + + @Test + public void testFullTextSearchLazyNullUpgradeFence() throws Exception { + assertUpgradeFence(false); + } + + private void assertUpgradeFence(boolean vector) throws Exception { + ConnectContext previous = ConnectContext.get(); + ConnectContext context = new ConnectContext(); + context.setThreadLocalInfo(); + try { + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> translate(vector, true, true)); + Assertions.assertTrue(exception.toString().contains("smooth upgrade source"), exception.toString()); + translate(vector, true, false); + // An unreferenced nested Null field must not fence an ordinary lazy projection. + translate(vector, false, true); + } finally { + if (previous == null) { + ConnectContext.remove(); + } else { + previous.setThreadLocalInfo(); + } + } + } + + private void translate(boolean vector, boolean lazyNull, boolean mixedVersion) throws Exception { + String functionName = vector ? "vector_search" : "full_text_search"; + String scoreName = vector ? "_distance" : "_score"; + Field nestedNull = new Field("nested_null", FieldType.nullable(ArrowType.List.INSTANCE), + Collections.singletonList(Field.nullable("item", ArrowType.Null.INSTANCE))); + LanceTableMetadata metadata = LanceTableMetadata.withoutIndexSegments( + "s3://bucket/table.lance", 42, + new Schema(Arrays.asList(nestedNull, Field.nullable("ordinary", ArrowType.Utf8.INSTANCE))), + Collections.emptyList(), Collections.emptyMap()); + Column score = new Column(scoreName, Type.DOUBLE); + Column payload = new Column("nested_null", ArrayType.create(Type.NULL, true)); + Column ordinary = new Column("ordinary", Type.STRING); + Column rowIdColumn = new Column(Column.GLOBAL_ROWID_COL + functionName, Type.STRING); + TableValuedFunctionIf catalogFunction = vector + ? Mockito.mock(VectorSearchTableValuedFunction.class) + : Mockito.mock(FullTextSearchTableValuedFunction.class); + FunctionGenTable table = new FunctionGenTable(1, functionName, Table.TableType.TABLE_VALUED_FUNCTION, + Arrays.asList(score, payload, ordinary), catalogFunction); + TableValuedFunction function = vector ? Mockito.mock(VectorSearch.class) : Mockito.mock(FullTextSearch.class); + Mockito.when(function.getName()).thenReturn(functionName); + Mockito.when(function.getTable()).thenReturn(table); + Mockito.when(function.getCatalogFunction()).thenReturn(catalogFunction); + SlotReference scoreSlot = SlotReference.fromColumn(new ExprId(1), table, score, Collections.emptyList()); + // Retain the source column identity even when the output uses an alias. + SlotReference lazySlot = SlotReference.fromColumn(new ExprId(2), table, + lazyNull ? payload : ordinary, "payload_alias", Collections.emptyList()); + SlotReference rowId = SlotReference.fromColumn(new ExprId(3), table, rowIdColumn, Collections.emptyList()); + List<Slot> output = Arrays.asList(scoreSlot, lazySlot); + PhysicalTVFRelation relation = new PhysicalTVFRelation(new RelationId(1), function, + Collections.singletonList(scoreSlot), new LogicalProperties(() -> output, () -> DataTrait.EMPTY_TRAIT)); + PhysicalLazyMaterializeTVFScan lazyScan = new PhysicalLazyMaterializeTVFScan( + relation, rowId, Collections.singletonList(lazySlot)); + Assertions.assertFalse(lazyScan.getOutput().contains(lazySlot)); + + Backend current = Mockito.mock(Backend.class); + Backend old = Mockito.mock(Backend.class); + Mockito.when(old.isSmoothUpgradeSrc()).thenReturn(true); + Mockito.when(old.getId()).thenReturn(102L); + FederationBackendPolicy policy = Mockito.mock(FederationBackendPolicy.class); + Mockito.when(policy.getBackends()).thenReturn(mixedVersion + ? Arrays.asList(current, old) : Collections.singletonList(current)); + LanceExternalTable source = Mockito.mock(LanceExternalTable.class); + AtomicReference<LanceScanNode> translated = new AtomicReference<>(); + Mockito.when(catalogFunction.getScanNode(Mockito.any(), Mockito.any(), Mockito.any())).thenAnswer(call -> { + TExternalSearchQuery query = vector + ? TExternalSearchQuery.vector_search(new TVectorSearchParams().setColumn("vector")) + : TExternalSearchQuery.full_text_search(new TFullTextSearchParams().setColumn("text")); + LanceScanNode node = LanceScanNode.forExternalSearch(call.getArgument(0, PlanNodeId.class), + call.getArgument(1, TupleDescriptor.class), source, metadata, 0, + new TExternalSearchRequest().setSearchQuery(query), call.getArgument(2, SessionVariable.class)); + java.lang.reflect.Field backendPolicy = ExternalScanNode.class.getDeclaredField("backendPolicy"); + backendPolicy.setAccessible(true); + backendPolicy.set(node, policy); + translated.set(node); + return node; + }); + PlanTranslatorContext translatorContext = new PlanTranslatorContext(); + lazyScan.accept(new PhysicalPlanTranslator(translatorContext), translatorContext); + Assertions.assertFalse(translated.get().getTupleDesc().getSlots().stream() + .anyMatch(slot -> "nested_null".equals(slot.getColumn().getName()))); + } +} diff --git a/regression-test/data/external_table_p0/lance/test_lance_nested_null.out b/regression-test/data/external_table_p0/lance/test_lance_nested_null.out new file mode 100644 index 00000000000..b3f168eda4c --- /dev/null +++ b/regression-test/data/external_table_p0/lance/test_lance_nested_null.out @@ -0,0 +1,11 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !nested_null_values -- +1 false 2 true false 1 false 2 true 10 false 2 false 2 true +2 true -1 true true -1 true -1 true 20 true -1 true -1 true +3 false 0 true false 0 false 2 true 30 false 0 false 0 true +4 false 1 true false 3 false 2 true 40 false 1 false 1 true + +-- !nested_null_projection -- +1 [null, null] true +2 \N true +3 [] true diff --git a/regression-test/suites/external_table_p0/lance/test_lance_nested_null.groovy b/regression-test/suites/external_table_p0/lance/test_lance_nested_null.groovy new file mode 100644 index 00000000000..47ba3db8443 --- /dev/null +++ b/regression-test/suites/external_table_p0/lance/test_lance_nested_null.groovy @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_lance_nested_null", "p0,external") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable Lance S3 TVF test because the Iceberg MinIO environment is disabled.") + return + } + + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String lanceTvf = """ + s3( + "uri" = "s3://warehouse/lance/nested_null.lance", + "s3.endpoint" = "http://${externalEnvIp}:${minioPort}", + "s3.access_key" = "admin", + "s3.secret_key" = "password", + "s3.region" = "us-east-1", + "use_path_style" = "true", + "format" = "lance" + ) + """ + + String originalScannerV2 = sql("SHOW VARIABLES LIKE 'enable_file_scanner_v2'")[0][1] + try { + sql "SET enable_file_scanner_v2 = true" + def columns = sql "DESC FUNCTION ${lanceTvf}" + assertEquals(7, columns.size()) + assertTrue(columns.every { !it[1].toString().contains("UNSUPPORTED") }) + assertEquals("array<null_type>", columns.find { it[0] == "null_list" }[1].toString()) + assertEquals("map<text,null_type>", columns.find { it[0] == "null_map" }[1].toString()) + + qt_nested_null_values """ + SELECT id, + null_list IS NULL, COALESCE(size(null_list), -1), null_list[1] IS NULL, + null_large_list IS NULL, COALESCE(size(null_large_list), -1), + null_fixed_list IS NULL, COALESCE(size(null_fixed_list), -1), + struct_element(null_struct, 'empty') IS NULL, + struct_element(null_struct, 'value'), + nested_list IS NULL, COALESCE(size(nested_list), -1), + null_map IS NULL, COALESCE(map_size(null_map), -1), null_map['a'] IS NULL + FROM ${lanceTvf} ORDER BY id + """ + // Complex output uses JSON-style null; a top-level SQL NULL remains \N. + qt_nested_null_projection """ + SELECT id, null_list, struct_element(null_struct, 'empty') IS NULL + FROM ${lanceTvf} WHERE id >= 1 ORDER BY id LIMIT 3 + """ + } finally { + sql "SET enable_file_scanner_v2 = ${originalScannerV2}" + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
