This is an automated email from the ASF dual-hosted git repository.
rui-mo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 9801f4169f [VL] Fix Parquet metadata scans with same-named data
columns (#13136)
9801f4169f is described below
commit 9801f4169f820b50d9a0e6d71b1f5e6e0085c320
Author: Felipe Pessoto <[email protected]>
AuthorDate: Thu Oct 29 13:32:03 2026 -0700
[VL] Fix Parquet metadata scans with same-named data columns (#13136)
---
.../util/delta-spark-ut/known-failures.txt | 12 -
cpp/velox/substrait/SubstraitToVeloxPlan.cc | 52 ++--
.../tests/Substrait2VeloxPlanConversionTest.cc | 243 +++++++++++++++++
.../GlutenFileMetadataStructSuite.scala | 293 ++++++++++++++++++++-
4 files changed, 557 insertions(+), 43 deletions(-)
diff --git a/.github/workflows/util/delta-spark-ut/known-failures.txt
b/.github/workflows/util/delta-spark-ut/known-failures.txt
index 6bb839c88f..9825b11cb3 100644
--- a/.github/workflows/util/delta-spark-ut/known-failures.txt
+++ b/.github/workflows/util/delta-spark-ut/known-failures.txt
@@ -369,14 +369,10 @@
org.apache.spark.sql.delta.deletionvectors.DeletionVectorsWithPredicatePushdownS
org.apache.spark.sql.delta.deletionvectors.DeletionVectorsWithPredicatePushdownSuite#PredicatePushdown:
Single deletion at the second row group. vectorizedReaderEnabled: true
readColumnarBatchAsRows: true
org.apache.spark.sql.delta.deletionvectors.DeletionVectorsWithPredicatePushdownSuite#huge
table: delete a small number of rows from tables of 2B rows with DVs
org.apache.spark.sql.delta.deletionvectors.DeletionVectorsWithPredicatePushdownSuite#huge
table: read from tables of 2B rows with existing DV of many zeros
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#data
skipping flags - old behavior with DataFrame schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#data
skipping stats before and after optimize
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2JsonSuite#data
skipping stats before and after optimize - old behavior with DataFrame schema
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2ParquetSuite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2ParquetSuite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2ParquetSuite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2ParquetSuite#data
skipping flags - old behavior with DataFrame schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1CheckpointV2ParquetSuite#data
skipping stats before and after optimize
@@ -385,22 +381,14 @@
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1ColMa
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1ColMapNameModeSuite#data
skipping flags - column mapping name mode - old behavior with DataFrame schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1ColMapNameModeSuite#data
skipping stats before and after optimize - column mapping name mode
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1ColMapNameModeSuite#data
skipping stats before and after optimize - column mapping name mode - old
behavior with DataFrame schema
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#data
skipping flags - old behavior with DataFrame schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#data
skipping stats before and after optimize
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1Suite#data
skipping stats before and after optimize - old behavior with DataFrame schema
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#Data
skipping handles aliasing for _metadata fields
-org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#Data
skipping handles aliasing for _metadata fields - old behavior with DataFrame
schema
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
skipping flags
org.apache.spark.sql.delta.generatedsuites.dataskipping.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
skipping flags - old behavior with DataFrame schema
org.apache.spark.sql.delta.generatedsuites.delete.DeleteTempViewSQLNameBasedSuite#test
delete on temp view - nontrivial projection - Dataset TempView
diff --git a/cpp/velox/substrait/SubstraitToVeloxPlan.cc
b/cpp/velox/substrait/SubstraitToVeloxPlan.cc
index 52e62bfadd..bb57cc6696 100644
--- a/cpp/velox/substrait/SubstraitToVeloxPlan.cc
+++ b/cpp/velox/substrait/SubstraitToVeloxPlan.cc
@@ -1656,34 +1656,48 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
auto names = colNameList;
auto types = veloxTypeList;
- // The columns we project from the file.
+ // The logical scan schema includes columns supplied by the connector, not
just the file.
auto baseSchema = ROW(std::move(names), std::move(types));
- // The columns present in the table, if not available default to the
baseSchema.
- auto tableSchema = splitInfo->tableSchema ? splitInfo->tableSchema :
baseSchema;
- // Build dataColumns from tableSchema, excluding partition columns.
// HiveTableHandle::dataColumns() is used as fileSchema for the reader.
- // Partition columns should not be validated against the file's physical
types
- // (their values come from the partition path, not from the file).
- std::unordered_set<std::string> partitionColNames;
- for (int idx = 0; idx < colNameList.size(); idx++) {
- if (columnTypes[idx] == ColumnType::kPartitionKey) {
- partitionColNames.insert(colNameList[idx]);
- }
- }
RowTypePtr dataColumns;
- if (partitionColNames.empty()) {
- dataColumns = tableSchema;
- } else {
+ if (!splitInfo->tableSchema) {
+ // Scan outputs include connector-generated columns, not just physical
fields.
+ // For example, _metadata.file_name is VARCHAR even if the file contains a
+ // file_name BIGINT column. Exclude metadata here to avoid Parquet
type-checking
+ // that unrelated field before the connector supplies the metadata
constant.
std::vector<std::string> dataColNames;
std::vector<TypePtr> dataColTypes;
- for (int idx = 0; idx < tableSchema->size(); idx++) {
- if (partitionColNames.find(tableSchema->nameOf(idx)) ==
partitionColNames.end()) {
- dataColNames.push_back(tableSchema->nameOf(idx));
- dataColTypes.push_back(tableSchema->childAt(idx));
+ for (int idx = 0; idx < colNameList.size(); idx++) {
+ if (columnTypes[idx] == ColumnType::kRegular) {
+ dataColNames.push_back(colNameList[idx]);
+ dataColTypes.push_back(veloxTypeList[idx]);
}
}
dataColumns = ROW(std::move(dataColNames), std::move(dataColTypes));
+ } else {
+ // An explicit schema describes physical fields and their ordinals. A field
+ // must not be removed just because a synthetic output has the same name.
+ const auto& tableSchema = splitInfo->tableSchema;
+ std::unordered_set<std::string> partitionColNames;
+ for (int idx = 0; idx < colNameList.size(); idx++) {
+ if (columnTypes[idx] == ColumnType::kPartitionKey) {
+ partitionColNames.insert(colNameList[idx]);
+ }
+ }
+ if (partitionColNames.empty()) {
+ dataColumns = tableSchema;
+ } else {
+ std::vector<std::string> dataColNames;
+ std::vector<TypePtr> dataColTypes;
+ for (int idx = 0; idx < tableSchema->size(); idx++) {
+ if (partitionColNames.find(tableSchema->nameOf(idx)) ==
partitionColNames.end()) {
+ dataColNames.push_back(tableSchema->nameOf(idx));
+ dataColTypes.push_back(tableSchema->childAt(idx));
+ }
+ }
+ dataColumns = ROW(std::move(dataColNames), std::move(dataColTypes));
+ }
}
connector::ConnectorTableHandlePtr tableHandle;
diff --git a/cpp/velox/tests/Substrait2VeloxPlanConversionTest.cc
b/cpp/velox/tests/Substrait2VeloxPlanConversionTest.cc
index 323b6873fe..70a4894eac 100644
--- a/cpp/velox/tests/Substrait2VeloxPlanConversionTest.cc
+++ b/cpp/velox/tests/Substrait2VeloxPlanConversionTest.cc
@@ -17,11 +17,18 @@
#include "JsonToProtoConverter.h"
+#include <google/protobuf/wrappers.pb.h>
#include <filesystem>
+#include <limits>
#include "compute/VeloxPlanConverter.h"
+#include "operators/functions/RegistrationAllFunctions.h"
#include "substrait/SubstraitToVeloxPlan.h"
+#include "substrait/VeloxToSubstraitType.h"
#include "velox/common/base/tests/GTestUtils.h"
+#include "velox/dwio/common/FileSink.h"
#include "velox/dwio/common/tests/utils/DataFiles.h"
+#include "velox/dwio/parquet/RegisterParquetReader.h"
+#include "velox/dwio/parquet/writer/Writer.h"
#include "velox/exec/tests/utils/AssertQueryBuilder.h"
#include "velox/exec/tests/utils/HiveConnectorTestBase.h"
#include "velox/exec/tests/utils/TempDirectoryPath.h"
@@ -43,6 +50,17 @@ namespace gluten {
class Substrait2VeloxPlanConversionTest : public
exec::test::HiveConnectorTestBase {
protected:
+ void SetUp() override {
+ HiveConnectorTestBase::SetUp();
+ registerAllFunctions();
+ parquet::registerParquetReaderFactory();
+ }
+
+ void TearDown() override {
+ HiveConnectorTestBase::TearDown();
+ parquet::unregisterParquetReaderFactory();
+ }
+
#ifdef ENABLE_KAFKA
std::shared_ptr<SplitInfo> parseSerializedSplitInfo(const std::string&
serialized) {
return VeloxPlanConverter::parseSplitInfo(
@@ -75,12 +93,90 @@ class Substrait2VeloxPlanConversionTest : public
exec::test::HiveConnectorTestBa
if (splitInfo->columnMappingMode.has_value()) {
splitBuilder.columnMappingMode(*splitInfo->columnMappingMode);
}
+ for (const auto& [name, value] : splitInfo->metadataColumns.at(i)) {
+ splitBuilder.infoColumn(name, value);
+ }
auto split = splitBuilder.build();
+ EXPECT_EQ(split->columnMappingMode, splitInfo->columnMappingMode);
splits.emplace_back(split);
}
return splits;
}
+ ::substrait::ReadRel makeRead(
+ const RowTypePtr& type,
+ const std::vector<::substrait::NamedStruct::ColumnType>& columnTypes =
{}) {
+ google::protobuf::Arena arena;
+ VeloxToSubstraitTypeConvertor typeConverter;
+ ::substrait::ReadRel read;
+ read.mutable_common()->mutable_direct();
+
read.mutable_base_schema()->CopyFrom(typeConverter.toSubstraitNamedStruct(arena,
type));
+ for (auto columnType : columnTypes) {
+ read.mutable_base_schema()->add_column_types(columnType);
+ }
+ return read;
+ }
+
+ ::substrait::ReadRel_LocalFiles makeParquetFiles(
+ const std::vector<std::string>& paths,
+ const RowTypePtr& tableSchema = nullptr,
+ dwio::common::ColumnMappingMode columnMappingMode =
dwio::common::ColumnMappingMode::kName) {
+ ::substrait::ReadRel_LocalFiles files;
+ google::protobuf::Arena arena;
+ VeloxToSubstraitTypeConvertor typeConverter;
+ for (const auto& path : paths) {
+ auto* file = files.add_items();
+ file->set_uri_file(path);
+ // Velox's default whole-file range; the start offset defaults to zero.
+ file->set_length(std::numeric_limits<uint64_t>::max());
+ file->mutable_parquet();
+ auto* metadata = file->add_metadata_columns();
+ metadata->set_key("file_name");
+ metadata->set_value(std::filesystem::path(path).filename().string());
+ google::protobuf::StringValue mode;
+
mode.set_value(std::string(dwio::common::ColumnMappingModeName::toName(columnMappingMode)));
+ auto* mapping = file->add_other_const_metadata_columns();
+ mapping->set_key("__gluten.column_mapping_mode");
+ mapping->mutable_value()->PackFrom(mode);
+ if (tableSchema) {
+
file->mutable_schema()->CopyFrom(typeConverter.toSubstraitNamedStruct(arena,
tableSchema));
+ }
+ }
+ return files;
+ }
+
+ std::shared_ptr<const core::TableScanNode> convertRead(
+ const ::substrait::ReadRel& read,
+ const ::substrait::ReadRel_LocalFiles& files,
+ const std::vector<std::string>& functions = {}) {
+ ::substrait::Plan plan;
+ plan.add_relations()->mutable_rel()->mutable_read()->CopyFrom(read);
+ for (size_t i = 0; i < functions.size(); ++i) {
+ auto* function = plan.add_extensions()->mutable_extension_function();
+ function->set_function_anchor(static_cast<uint32_t>(i));
+ function->set_name(functions[i]);
+ }
+ planConverter_ = std::make_shared<VeloxPlanConverter>(
+ pool(),
+ veloxCfg_.get(),
+ std::vector<std::shared_ptr<ResultIterator>>{},
+ VeloxConnectorIds{.hive =
facebook::velox::exec::test::kHiveConnectorId});
+ auto scan = std::dynamic_pointer_cast<const
core::TableScanNode>(planConverter_->toVeloxPlan(plan, {files}));
+ VELOX_CHECK_NOT_NULL(scan);
+ return scan;
+ }
+
+ void writeParquet(const std::string& name, const RowVectorPtr& rows) {
+ const auto path = tmpDir_->getPath() + name;
+ auto file = std::make_unique<LocalWriteFile>(path, false, true);
+ auto sink = std::make_unique<dwio::common::WriteFileSink>(std::move(file),
path);
+ dwio::common::WriterOptions options;
+ options.memoryPool = rootPool_.get();
+ parquet::Writer writer(std::move(sink), options, asRowType(rows->type()));
+ writer.write(rows);
+ writer.close();
+ }
+
std::shared_ptr<exec::test::TempDirectoryPath>
tmpDir_{exec::test::TempDirectoryPath::create()};
std::shared_ptr<facebook::velox::config::ConfigBase> veloxCfg_ =
std::make_shared<facebook::velox::config::ConfigBase>(std::unordered_map<std::string,
std::string>());
@@ -301,6 +397,153 @@ TEST_F(Substrait2VeloxPlanConversionTest, filterUpper) {
planNode->toString(true, true));
}
+TEST_F(Substrait2VeloxPlanConversionTest, derivedFileSchemaUsesRegularSlots) {
+ auto read = makeRead(
+ ROW({"file_name", "p", "file_name", "row_index", "keep"}, {BIGINT(),
INTEGER(), VARCHAR(), BIGINT(), BOOLEAN()}),
+ {::substrait::NamedStruct::NORMAL_COL,
+ ::substrait::NamedStruct::PARTITION_COL,
+ ::substrait::NamedStruct::METADATA_COL,
+ ::substrait::NamedStruct::ROWINDEX_COL,
+ ::substrait::NamedStruct::NORMAL_COL});
+
read.mutable_filter()->mutable_selection()->mutable_direct_reference()->mutable_struct_field()->set_field(4);
+ auto scan = convertRead(read, makeParquetFiles({"/unused.parquet"}));
+ auto table = std::dynamic_pointer_cast<const
HiveTableHandle>(scan->tableHandle());
+ ASSERT_NE(table, nullptr);
+ EXPECT_EQ(table->dataColumns()->toString(),
"ROW<file_name:BIGINT,keep:BOOLEAN>");
+ EXPECT_EQ(scan->outputType()->size(), 5);
+ EXPECT_EQ(scan->assignments().size(), 5);
+ const std::vector<ColumnType> roles{
+ ColumnType::kRegular,
+ ColumnType::kPartitionKey,
+ ColumnType::kSynthesized,
+ ColumnType::kRowIndex,
+ ColumnType::kRegular};
+ for (int i = 0; i < roles.size(); ++i) {
+ auto column =
+ std::dynamic_pointer_cast<const
HiveColumnHandle>(scan->assignments().at(scan->outputType()->nameOf(i)));
+ ASSERT_NE(column, nullptr);
+ EXPECT_EQ(column->columnType(), roles[i]);
+ }
+ EXPECT_EQ(scan->outputType()->childAt(0)->kind(), TypeKind::BIGINT);
+ EXPECT_EQ(scan->outputType()->childAt(2)->kind(), TypeKind::VARCHAR);
+ auto filter = std::dynamic_pointer_cast<const
core::FieldAccessTypedExpr>(table->remainingFilter());
+ ASSERT_NE(filter, nullptr);
+ EXPECT_EQ(filter->name(), "keep");
+}
+
+TEST_F(Substrait2VeloxPlanConversionTest, emptyAndUntaggedFileSchemas) {
+ const auto type = ROW({"file_name", "row_index"}, {VARCHAR(), BIGINT()});
+ auto scan = convertRead(
+ makeRead(type, {::substrait::NamedStruct::METADATA_COL,
::substrait::NamedStruct::ROWINDEX_COL}),
+ makeParquetFiles({"/unused.parquet"}));
+ auto table = std::dynamic_pointer_cast<const
HiveTableHandle>(scan->tableHandle());
+ ASSERT_NE(table, nullptr);
+ ASSERT_NE(table->dataColumns(), nullptr);
+ EXPECT_EQ(table->dataColumns()->size(), 0);
+ EXPECT_EQ(scan->outputType()->size(), 2);
+ EXPECT_EQ(scan->assignments().size(), 2);
+
+ scan = convertRead(makeRead(type), makeParquetFiles({"/unused.parquet"}));
+ table = std::dynamic_pointer_cast<const
HiveTableHandle>(scan->tableHandle());
+ ASSERT_NE(table, nullptr);
+ EXPECT_TRUE(table->dataColumns()->equivalent(*type));
+}
+
+TEST_F(Substrait2VeloxPlanConversionTest,
explicitFileSchemaPreservesPhysicalFields) {
+ auto read = makeRead(
+ ROW({"FILE_NAME", "P", "ROW_INDEX", "VALUE"}, {VARCHAR(), INTEGER(),
BIGINT(), BIGINT()}),
+ {::substrait::NamedStruct::METADATA_COL,
+ ::substrait::NamedStruct::PARTITION_COL,
+ ::substrait::NamedStruct::ROWINDEX_COL,
+ ::substrait::NamedStruct::NORMAL_COL});
+ auto tableSchema = ROW({"FILE_NAME", "VALUE", "ROW_INDEX", "P"}, {BIGINT(),
BIGINT(), VARCHAR(), INTEGER()});
+ auto scan = convertRead(read, makeParquetFiles({"/unused.parquet"},
tableSchema));
+ auto table = std::dynamic_pointer_cast<const
HiveTableHandle>(scan->tableHandle());
+ ASSERT_NE(table, nullptr);
+ EXPECT_EQ(table->dataColumns()->toString(),
"ROW<file_name:BIGINT,value:BIGINT,row_index:VARCHAR>");
+ EXPECT_EQ(scan->outputType()->size(), 4);
+ EXPECT_EQ(scan->outputType()->childAt(0)->kind(), TypeKind::VARCHAR);
+ EXPECT_EQ(scan->outputType()->childAt(2)->kind(), TypeKind::BIGINT);
+}
+
+TEST_F(Substrait2VeloxPlanConversionTest, parquetMetadataOnlyPreservesRows) {
+ writeParquet("/first.parquet", makeRowVector({"file_name"},
{makeFlatVector<int64_t>({10, 20})}));
+ writeParquet("/second.parquet", makeRowVector({"file_name"},
{makeFlatVector<int64_t>({30})}));
+ const auto files = makeParquetFiles({"/first.parquet", "/second.parquet"});
+ auto scan = convertRead(makeRead(ROW({"file_name"}, {VARCHAR()}),
{::substrait::NamedStruct::METADATA_COL}), files);
+ EXPECT_EQ(planConverter_->splitInfos().at(scan->id())->columnMappingMode,
dwio::common::ColumnMappingMode::kName);
+ auto expected = makeRowVector({makeFlatVector<std::string>({"first.parquet",
"first.parquet", "second.parquet"})});
+
exec::test::AssertQueryBuilder(scan).splits(makeSplits(scan)).assertResults(expected);
+
+ // The same incompatible type is still an error when requested as physical
data.
+ scan = convertRead(makeRead(ROW({"file_name"}, {VARCHAR()})), files);
+ VELOX_ASSERT_THROW(
+
exec::test::AssertQueryBuilder(scan).splits(makeSplits(scan)).assertResults(expected),
+ "Converted type BIGINT is not allowed for requested type VARCHAR");
+}
+
+TEST_F(Substrait2VeloxPlanConversionTest, parquetMetadataAndRegularFilters) {
+ auto rows =
+ makeRowVector({"file_name", "keep"}, {makeFlatVector<int64_t>({10, 20}),
makeFlatVector<bool>({true, false})});
+ writeParquet("/first.parquet", rows);
+ writeParquet("/second.parquet", rows);
+ const auto files = makeParquetFiles({"/first.parquet", "/second.parquet"});
+ for (const std::string filename : {"first.parquet", "missing.parquet"}) {
+ auto read = makeRead(
+ ROW({"file_name", "keep"}, {VARCHAR(), BOOLEAN()}),
+ {::substrait::NamedStruct::METADATA_COL,
::substrait::NamedStruct::NORMAL_COL});
+ auto* conjunction = read.mutable_filter()->mutable_scalar_function();
+ conjunction->set_function_reference(1);
+ conjunction->mutable_output_type()->mutable_bool_()->set_nullability(
+ ::substrait::Type_Nullability_NULLABILITY_NULLABLE);
+ auto* equal =
conjunction->add_arguments()->mutable_value()->mutable_scalar_function();
+ equal->set_function_reference(0);
+ equal->mutable_output_type()->CopyFrom(conjunction->output_type());
+ equal->add_arguments()
+ ->mutable_value()
+ ->mutable_selection()
+ ->mutable_direct_reference()
+ ->mutable_struct_field()
+ ->set_field(0);
+
equal->add_arguments()->mutable_value()->mutable_literal()->set_string(filename);
+ conjunction->add_arguments()
+ ->mutable_value()
+ ->mutable_selection()
+ ->mutable_direct_reference()
+ ->mutable_struct_field()
+ ->set_field(1);
+ auto scan = convertRead(read, files, {"equal:opt_str_str",
"and:opt_bool_bool"});
+ EXPECT_EQ(planConverter_->splitInfos().at(scan->id())->columnMappingMode,
dwio::common::ColumnMappingMode::kName);
+ const int size = filename == "first.parquet" ? 1 : 0;
+ auto expected = makeRowVector({
+ makeFlatVector<std::string>(size, [&](auto /*row*/) { return filename;
}),
+ makeFlatVector<bool>(size, [](auto /*row*/) { return true; }),
+ });
+
exec::test::AssertQueryBuilder(scan).splits(makeSplits(scan)).assertResults(expected);
+ }
+}
+
+TEST_F(Substrait2VeloxPlanConversionTest,
parquetMetadataWithExplicitPositionalSchema) {
+ auto rows = makeRowVector(
+ {"file_name", "physical_keep"}, {makeFlatVector<int64_t>({10, 20}),
makeFlatVector<bool>({true, false})});
+ writeParquet("/first.parquet", rows);
+ const auto files = makeParquetFiles(
+ {"/first.parquet"},
+ ROW({"file_name", "keep"}, {BIGINT(), BOOLEAN()}),
+ dwio::common::ColumnMappingMode::kPosition);
+ auto scan = convertRead(
+ makeRead(
+ ROW({"file_name", "keep"}, {VARCHAR(), BOOLEAN()}),
+ {::substrait::NamedStruct::METADATA_COL,
::substrait::NamedStruct::NORMAL_COL}),
+ files);
+ EXPECT_EQ(planConverter_->splitInfos().at(scan->id())->columnMappingMode,
dwio::common::ColumnMappingMode::kPosition);
+ auto expected = makeRowVector({
+ makeFlatVector<std::string>({"first.parquet", "first.parquet"}),
+ makeFlatVector<bool>({true, false}),
+ });
+
exec::test::AssertQueryBuilder(scan).splits(makeSplits(scan)).assertResults(expected);
+}
+
TEST_F(Substrait2VeloxPlanConversionTest, expandSelectionMustBeTopLevelField) {
const auto makeExpandRel = [](bool nestedSelection) {
::substrait::Rel rel;
diff --git
a/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/execution/datasources/GlutenFileMetadataStructSuite.scala
b/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/execution/datasources/GlutenFileMetadataStructSuite.scala
index ed347d024c..16c129a695 100644
---
a/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/execution/datasources/GlutenFileMetadataStructSuite.scala
+++
b/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/execution/datasources/GlutenFileMetadataStructSuite.scala
@@ -17,16 +17,26 @@
package org.apache.spark.sql.execution.datasources
import org.apache.gluten.backendsapi.BackendsApiManager
+import org.apache.gluten.config.GlutenConfig
import org.apache.gluten.execution.{FileSourceScanExecTransformer,
FilterExecTransformer}
import org.apache.spark.sql.{Column, DataFrame, Row}
import org.apache.spark.sql.GlutenSQLTestsBaseTrait
import org.apache.spark.sql.execution.FileSourceScanExec
-import org.apache.spark.sql.types.{IntegerType, LongType, StringType,
StructField, StructType}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{DataType, IntegerType, LongType,
StringType, StructField, StructType}
+import org.apache.spark.util.Utils
+
+import org.apache.hadoop.fs.Path
+import org.apache.parquet.hadoop.ParquetFileReader
+import org.apache.parquet.hadoop.util.HadoopInputFile
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
import java.io.File
+import java.net.URI
import java.sql.Timestamp
+import scala.jdk.CollectionConverters._
import scala.reflect.ClassTag
class GlutenFileMetadataStructSuite extends FileMetadataStructSuite with
GlutenSQLTestsBaseTrait {
@@ -45,33 +55,76 @@ class GlutenFileMetadataStructSuite extends
FileMetadataStructSuite with GlutenS
private val METADATA_FILE_NAME = "_metadata.file_name"
private val METADATA_FILE_SIZE = "_metadata.file_size"
private val METADATA_FILE_MODIFICATION_TIME =
"_metadata.file_modification_time"
+ private val METADATA_FILE_BLOCK_START = "_metadata.file_block_start"
+ private val METADATA_FILE_BLOCK_LENGTH = "_metadata.file_block_length"
+
+ private val metadataColumns = Seq(
+ METADATA_FILE_PATH,
+ METADATA_FILE_NAME,
+ METADATA_FILE_SIZE,
+ METADATA_FILE_MODIFICATION_TIME,
+ METADATA_FILE_BLOCK_START,
+ METADATA_FILE_BLOCK_LENGTH)
+
+ private val schemaWithMetadataCollisions = {
+ val collisionTypes = Map[String, DataType](
+ METADATA_FILE_PATH -> LongType,
+ METADATA_FILE_NAME -> LongType,
+ METADATA_FILE_SIZE -> StringType,
+ METADATA_FILE_MODIFICATION_TIME -> StringType,
+ METADATA_FILE_BLOCK_START -> StringType,
+ METADATA_FILE_BLOCK_LENGTH -> StringType
+ )
+ StructType(
+ metadataColumns.map {
+ name => StructField(name.stripPrefix("_metadata."),
collisionTypes(name))
+ }).add("id", LongType)
+ }
+
+ private val idColumnIndex = schemaWithMetadataCollisions.fieldIndex("id")
+ private val maxSplitBytes = 1024L * 1024
+
+ private val collisionRows0 = Seq(
+ Row(10L, 20L, "size0", "time0", "start0", "length0", 0L),
+ Row(11L, 21L, "size1", "time1", "start1", "length1", 1L))
+ private val collisionRows1 = Seq(
+ Row(12L, 22L, "size2", "time2", "start2", "length2", 2L))
private def getMetadataForFile(f: File): Map[String, Any] = {
Map(
METADATA_FILE_PATH -> f.toURI.toString,
METADATA_FILE_NAME -> f.getName,
METADATA_FILE_SIZE -> f.length(),
- METADATA_FILE_MODIFICATION_TIME -> new Timestamp(f.lastModified())
+ METADATA_FILE_MODIFICATION_TIME -> new Timestamp(f.lastModified()),
+ METADATA_FILE_BLOCK_START -> 0L,
+ METADATA_FILE_BLOCK_LENGTH -> f.length()
)
}
- private def metadataColumnsNativeTest(testName: String, fileSchema:
StructType)(
+ private def metadataColumnsNativeTest(
+ testName: String,
+ fileSchema: StructType,
+ firstFileRows: Seq[Row] = data0,
+ secondFileRows: Seq[Row] = data1)(
f: (DataFrame, Map[String, Any], Map[String, Any]) => Unit): Unit = {
Seq("parquet").foreach {
testFileFormat =>
testGluten(s"metadata struct ($testFileFormat): " + testName) {
withTempDir {
dir =>
- import scala.collection.JavaConverters._
-
// 1. create df0 and df1 and save under /data/f0 and /data/f1
- val df0 = spark.createDataFrame(data0.asJava, fileSchema)
- val f0 = new File(dir, "data/f0").getCanonicalPath
- df0.coalesce(1).write.format(testFileFormat).save(f0)
+ // Spark's writer also produces valid Parquet footers for
zero-row fixtures.
+ withSQLConf(
+ GlutenConfig.GLUTEN_ENABLED.key -> "false",
+ GlutenConfig.NATIVE_WRITER_ENABLED.key -> "false") {
+ val df0 = spark.createDataFrame(firstFileRows.asJava,
fileSchema)
+ val f0 = new File(dir, "data/f0").getCanonicalPath
+ df0.coalesce(1).write.format(testFileFormat).save(f0)
- val df1 = spark.createDataFrame(data1.asJava, fileSchema)
- val f1 = new File(dir, "data/f1 gluten").getCanonicalPath
- df1.coalesce(1).write.format(testFileFormat).save(f1)
+ val df1 = spark.createDataFrame(secondFileRows.asJava,
fileSchema)
+ val f1 = new File(dir, "data/f1 gluten").getCanonicalPath
+ df1.coalesce(1).write.format(testFileFormat).save(f1)
+ }
// 2. read both f0 and f1
val df = spark.read
@@ -86,7 +139,10 @@ class GlutenFileMetadataStructSuite extends
FileMetadataStructSuite with GlutenS
.listFiles()
.filter(_.getName.endsWith(s".$testFileFormat"))
.head
- f(df, getMetadataForFile(realF0), getMetadataForFile(realF1))
+ assert(realF0.length() < maxSplitBytes && realF1.length() <
maxSplitBytes)
+ withSQLConf(SQLConf.FILES_MAX_PARTITION_BYTES.key ->
maxSplitBytes.toString) {
+ f(df, getMetadataForFile(realF0), getMetadataForFile(realF1))
+ }
}
}
}
@@ -97,6 +153,219 @@ class GlutenFileMetadataStructSuite extends
FileMetadataStructSuite with GlutenS
assert(executedPlan.exists(plan => plan.getClass == tag.runtimeClass))
}
+ private def checkMetadataAnswer(
+ df: DataFrame,
+ expected: Seq[Row],
+ hasRowIndex: Boolean = false): Unit = {
+ checkAnswer(df, expected)
+ val settings = BackendsApiManager.getSettings
+ if (
+ settings.supportNativeMetadataColumns() &&
+ (!hasRowIndex || settings.supportNativeRowIndexColumn())
+ ) {
+ checkOperatorMatch[FileSourceScanExecTransformer](df)
+ } else {
+ checkOperatorMatch[FileSourceScanExec](df)
+ }
+ }
+
+ metadataColumns.zipWithIndex.foreach {
+ case (metadataColumn, index) =>
+ metadataColumnsNativeTest(
+ s"incompatible same-name data field for $metadataColumn",
+ schemaWithMetadataCollisions,
+ collisionRows0,
+ collisionRows1) {
+ (df, f0, f1) =>
+ val expected =
Seq.fill(collisionRows0.size)(Row(f0(metadataColumn))) ++
+ Seq.fill(collisionRows1.size)(Row(f1(metadataColumn)))
+ checkMetadataAnswer(df.select(metadataColumn), expected)
+
checkMetadataAnswer(df.select(Column(metadataColumn).as("aliased_metadata")),
expected)
+
+ val combined = df.select(
+ Column(metadataColumn.stripPrefix("_metadata.")).as("user_value"),
+ Column(metadataColumn).as("metadata_value"))
+ checkAnswer(
+ combined,
+ collisionRows0.map(row => Row(row.get(index), f0(metadataColumn)))
++
+ collisionRows1.map(row => Row(row.get(index),
f1(metadataColumn))))
+ checkOperatorMatch[FileSourceScanExec](combined)
+ }
+ }
+
+ metadataColumnsNativeTest(
+ "metadata-only projection of empty Parquet files",
+ schemaWithMetadataCollisions,
+ Seq.empty,
+ Seq.empty) {
+ (df, _, _) =>
+ assert(df.inputFiles.length == 2)
+ df.inputFiles.foreach {
+ file =>
+ val input =
+ HadoopInputFile.fromPath(new Path(new URI(file)),
spark.sessionState.newHadoopConf())
+ Utils.tryWithResource(ParquetFileReader.open(input)) {
+ reader =>
+ val footer = reader.getFooter
+ assert(footer.getBlocks.asScala.map(_.getRowCount).sum == 0L)
+ assert(
+ footer.getFileMetaData.getSchema
+ .asGroupType()
+ .getType("file_name")
+ .asPrimitiveType()
+ .getPrimitiveTypeName == PrimitiveTypeName.INT64)
+ }
+ }
+ checkMetadataAnswer(df.select(METADATA_FILE_NAME), Seq.empty)
+ }
+
+ metadataColumnsNativeTest(
+ "metadata constants with a regular sibling",
+ schemaWithMetadataCollisions,
+ collisionRows0,
+ collisionRows1) {
+ (df, f0, f1) =>
+ val selected = df.select((metadataColumns.map(Column(_)) :+
Column("id")): _*)
+ val expected = Seq((collisionRows0, f0), (collisionRows1, f1)).flatMap {
+ case (rows, metadata) =>
+ rows.map(row => Row.fromSeq(metadataColumns.map(metadata) :+
row.getLong(idColumnIndex)))
+ }
+ checkMetadataAnswer(selected, expected)
+ checkMetadataAnswer(
+ df.select(METADATA_FILE_NAME).distinct(),
+ Seq(Row(f0(METADATA_FILE_NAME)), Row(f1(METADATA_FILE_NAME))))
+ }
+
+ metadataColumnsNativeTest(
+ "whole metadata struct and struct alias with incompatible data fields",
+ schemaWithMetadataCollisions,
+ collisionRows0,
+ collisionRows1) {
+ (df, _, _) =>
+ val expected = withSQLConf(GlutenConfig.GLUTEN_ENABLED.key -> "false") {
+ df.select("_metadata").collect().toSeq
+ }
+ checkMetadataAnswer(df.select("_metadata"), expected, hasRowIndex = true)
+ checkMetadataAnswer(
+ df.select(Column("_metadata").as("aliased_metadata")),
+ expected,
+ hasRowIndex = true)
+ }
+
+ metadataColumnsNativeTest(
+ "metadata predicates do not use conflicting physical fields",
+ schemaWithMetadataCollisions,
+ collisionRows0,
+ collisionRows1) {
+ (df, f0, f1) =>
+ Seq(METADATA_FILE_NAME, METADATA_FILE_SIZE).foreach {
+ metadataColumn =>
+ val filtered = df
+ .where(Column(metadataColumn) === f0(metadataColumn) &&
Column("id") > 0L)
+ .select(METADATA_FILE_NAME, "id")
+ val expected = Seq((collisionRows0, f0), (collisionRows1, f1))
+ .filter { case (_, metadata) => metadata(metadataColumn) ==
f0(metadataColumn) }
+ .flatMap {
+ case (rows, metadata) =>
+ rows.filter(_.getLong(idColumnIndex) > 0L).map {
+ row => Row(metadata(METADATA_FILE_NAME),
row.getLong(idColumnIndex))
+ }
+ }
+ checkMetadataAnswer(filtered, expected)
+ }
+
+ // Direct timestamp synthesized-filter pushdown is not supported by the
native reader.
+ val targetSeconds = f0(METADATA_FILE_MODIFICATION_TIME)
+ .asInstanceOf[Timestamp]
+ .getTime / 1000L + 1L
+ val timestampFiltered = df
+ .where(
+ Column(METADATA_FILE_MODIFICATION_TIME).cast(LongType) +
Column("id") === targetSeconds)
+ .select(METADATA_FILE_NAME, "id")
+ val timestampExpected = Seq((collisionRows0, f0), (collisionRows1,
f1)).flatMap {
+ case (rows, metadata) =>
+ val seconds = metadata(METADATA_FILE_MODIFICATION_TIME)
+ .asInstanceOf[Timestamp]
+ .getTime / 1000L
+ rows.filter(row => seconds + row.getLong(idColumnIndex) ==
targetSeconds).map {
+ row => Row(metadata(METADATA_FILE_NAME),
row.getLong(idColumnIndex))
+ }
+ }
+ assert(timestampExpected.nonEmpty)
+ assert(timestampExpected.size < collisionRows0.size +
collisionRows1.size)
+ checkMetadataAnswer(timestampFiltered, timestampExpected)
+ if (BackendsApiManager.getSettings.supportNativeMetadataColumns()) {
+ checkOperatorMatch[FilterExecTransformer](timestampFiltered)
+ }
+ checkAnswer(
+ df.where(Column(METADATA_FILE_NAME) ===
"missing.parquet").select(METADATA_FILE_NAME),
+ Seq.empty)
+ }
+
+ metadataColumnsNativeTest(
+ "metadata collisions respect case normalization",
+ StructType(schemaWithMetadataCollisions.fields.map {
+ case field if field.name == "file_name" => field.copy(name = "FILE_NAME")
+ case field => field
+ }),
+ collisionRows0,
+ collisionRows1
+ ) {
+ (df, f0, f1) =>
+ Seq("false", "true").foreach {
+ caseSensitive =>
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> caseSensitive) {
+ checkMetadataAnswer(
+ df.select(METADATA_FILE_NAME),
+ Seq.fill(collisionRows0.size)(Row(f0(METADATA_FILE_NAME))) ++
+ Seq.fill(collisionRows1.size)(Row(f1(METADATA_FILE_NAME)))
+ )
+ }
+ }
+ }
+
+ metadataColumnsNativeTest(
+ "metadata with an explicit positional file schema",
+ schemaWithMetadataCollisions,
+ collisionRows0,
+ collisionRows1) {
+ (df, f0, f1) =>
+ withSQLConf(GlutenConfig.VELOX_PARQUET_USE_COLUMN_NAMES -> "false") {
+ checkMetadataAnswer(
+ df.select(METADATA_FILE_NAME),
+ Seq.fill(collisionRows0.size)(Row(f0(METADATA_FILE_NAME))) ++
+ Seq.fill(collisionRows1.size)(Row(f1(METADATA_FILE_NAME)))
+ )
+ checkMetadataAnswer(
+ df.select(METADATA_FILE_NAME, "id"),
+ collisionRows0.map(row => Row(f0(METADATA_FILE_NAME),
row.getLong(idColumnIndex))) ++
+ collisionRows1.map(row => Row(f1(METADATA_FILE_NAME),
row.getLong(idColumnIndex)))
+ )
+ }
+ }
+
+ testGluten("metadata constants with partition-only projection and pruning") {
+ withTempPath {
+ path =>
+ spark
+ .range(4)
+ .selectExpr("id AS file_name", "cast(id % 2 as int) AS p")
+ .coalesce(1)
+ .write
+ .partitionBy("p")
+ .parquet(path.getCanonicalPath)
+ val df = spark.read.parquet(path.getCanonicalPath)
+ val expected = withSQLConf(GlutenConfig.GLUTEN_ENABLED.key -> "false")
{
+ df.select(METADATA_FILE_NAME, "p").collect().toSeq
+ }
+ checkMetadataAnswer(df.select(METADATA_FILE_NAME), expected.map(row =>
Row(row.get(0))))
+ checkMetadataAnswer(df.select(METADATA_FILE_NAME, "p"), expected)
+ checkMetadataAnswer(
+ df.where(Column("p") === 1).select(METADATA_FILE_NAME, "p"),
+ expected.filter(_.getInt(1) == 1))
+ }
+ }
+
metadataColumnsNativeTest(
"plan check with metadata and user data select",
schemaWithFilePathField) {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]