You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/04/04 15:25:31 UTC
(iceberg-cpp) branch main updated: feat(data): add DeleteLoader for position and equality delete files (#610)
This is an automated email from the ASF dual-hosted git repository.
gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new a1dc2f85 feat(data): add DeleteLoader for position and equality delete files (#610)
a1dc2f85 is described below
commit a1dc2f859e57ec67bc8a128111ae82310e690868
Author: Gang Wu <us...@gmail.com>
AuthorDate: Sat Apr 4 23:25:27 2026 +0800
feat(data): add DeleteLoader for position and equality delete files (#610)
- LoadPositionDeletes dispatches to LoadPositionDelete (file-based) and
LoadDV (deletion vectors, not yet implemented).
- LoadEqualityDeletes reads equality delete files into a StructLikeSet.
---
src/iceberg/CMakeLists.txt | 1 +
src/iceberg/data/delete_loader.cc | 173 +++++++++++++++++++
src/iceberg/data/delete_loader.h | 79 +++++++++
src/iceberg/meson.build | 5 +
src/iceberg/test/CMakeLists.txt | 6 +-
src/iceberg/test/delete_loader_test.cc | 295 +++++++++++++++++++++++++++++++++
src/iceberg/type_fwd.h | 7 +
src/iceberg/util/struct_like_set.h | 4 -
8 files changed, 565 insertions(+), 5 deletions(-)
diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index e0b13250..4cd5e92c 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -21,6 +21,7 @@ set(ICEBERG_SOURCES
arrow_c_data_guard_internal.cc
catalog/memory/in_memory_catalog.cc
data/data_writer.cc
+ data/delete_loader.cc
data/equality_delete_writer.cc
data/position_delete_writer.cc
data/writer.cc
diff --git a/src/iceberg/data/delete_loader.cc b/src/iceberg/data/delete_loader.cc
new file mode 100644
index 00000000..35bc926b
--- /dev/null
+++ b/src/iceberg/data/delete_loader.cc
@@ -0,0 +1,173 @@
+/*
+ * 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 "iceberg/data/delete_loader.h"
+
+#include <string>
+#include <vector>
+
+#include "iceberg/arrow_c_data_guard_internal.h"
+#include "iceberg/deletes/position_delete_index.h"
+#include "iceberg/file_reader.h"
+#include "iceberg/manifest/manifest_entry.h"
+#include "iceberg/metadata_columns.h"
+#include "iceberg/row/arrow_array_wrapper.h"
+#include "iceberg/schema.h"
+#include "iceberg/util/macros.h"
+#include "iceberg/util/struct_like_set.h"
+
+namespace iceberg {
+
+namespace {
+
+/// \brief Build the projection schema for reading position delete files.
+std::shared_ptr<Schema> PosDeleteSchema() {
+ return std::make_shared<Schema>(std::vector<SchemaField>{
+ MetadataColumns::kDeleteFilePath,
+ MetadataColumns::kDeleteFilePos,
+ });
+}
+
+/// \brief Open a delete file with the given projection schema.
+Result<std::unique_ptr<Reader>> OpenDeleteFile(const DataFile& file,
+ std::shared_ptr<Schema> projection,
+ const std::shared_ptr<FileIO>& io) {
+ ReaderOptions options{
+ .path = file.file_path,
+ .length = static_cast<size_t>(file.file_size_in_bytes),
+ .io = io,
+ .projection = std::move(projection),
+ };
+ return ReaderFactoryRegistry::Open(file.file_format, options);
+}
+
+} // namespace
+
+DeleteLoader::DeleteLoader(std::shared_ptr<FileIO> io) : io_(std::move(io)) {}
+
+DeleteLoader::~DeleteLoader() = default;
+
+Status DeleteLoader::LoadPositionDelete(const DataFile& file, PositionDeleteIndex& index,
+ std::string_view data_file_path) const {
+ // TODO(gangwu): push down path filter to open the file.
+ ICEBERG_ASSIGN_OR_RAISE(auto reader, OpenDeleteFile(file, PosDeleteSchema(), io_));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto arrow_schema, reader->Schema());
+ internal::ArrowSchemaGuard schema_guard(&arrow_schema);
+
+ while (true) {
+ ICEBERG_ASSIGN_OR_RAISE(auto batch_opt, reader->Next());
+ if (!batch_opt.has_value()) break;
+
+ auto& batch = batch_opt.value();
+ internal::ArrowArrayGuard batch_guard(&batch);
+
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto row, ArrowArrayStructLike::Make(arrow_schema, batch, /*row_index=*/0));
+
+ for (int64_t i = 0; i < batch.length; ++i) {
+ if (i > 0) {
+ ICEBERG_RETURN_UNEXPECTED(row->Reset(i));
+ }
+ // Field 0: file_path
+ ICEBERG_ASSIGN_OR_RAISE(auto path_scalar, row->GetField(0));
+ auto path = std::get<std::string_view>(path_scalar);
+
+ if (path == data_file_path) {
+ // Field 1: pos
+ ICEBERG_ASSIGN_OR_RAISE(auto pos_scalar, row->GetField(1));
+ index.Delete(std::get<int64_t>(pos_scalar));
+ }
+ }
+ }
+
+ return reader->Close();
+}
+
+Status DeleteLoader::LoadDV(const DataFile& file, PositionDeleteIndex& index) const {
+ return NotSupported("Loading deletion vectors is not yet supported");
+}
+
+Result<PositionDeleteIndex> DeleteLoader::LoadPositionDeletes(
+ std::span<const std::shared_ptr<DataFile>> delete_files,
+ std::string_view data_file_path) const {
+ PositionDeleteIndex index;
+
+ for (const auto& file : delete_files) {
+ if (file->referenced_data_file.has_value() &&
+ file->referenced_data_file.value() != data_file_path) {
+ continue;
+ }
+
+ if (file->IsDeletionVector()) {
+ ICEBERG_RETURN_UNEXPECTED(LoadDV(*file, index));
+ continue;
+ }
+
+ ICEBERG_PRECHECK(file->content == DataFile::Content::kPositionDeletes,
+ "Expected position delete file but got content type {}",
+ ToString(file->content));
+
+ ICEBERG_RETURN_UNEXPECTED(LoadPositionDelete(*file, index, data_file_path));
+ }
+
+ return index;
+}
+
+Result<std::unique_ptr<UncheckedStructLikeSet>> DeleteLoader::LoadEqualityDeletes(
+ std::span<const std::shared_ptr<DataFile>> delete_files,
+ const StructType& equality_type) const {
+ auto eq_set = std::make_unique<UncheckedStructLikeSet>(equality_type);
+
+ std::shared_ptr<Schema> projection = equality_type.ToSchema();
+
+ for (const auto& file : delete_files) {
+ ICEBERG_PRECHECK(file->content == DataFile::Content::kEqualityDeletes,
+ "Expected equality delete file but got content type {}",
+ static_cast<int>(file->content));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto reader, OpenDeleteFile(*file, projection, io_));
+ ICEBERG_ASSIGN_OR_RAISE(auto arrow_schema, reader->Schema());
+ internal::ArrowSchemaGuard schema_guard(&arrow_schema);
+
+ while (true) {
+ ICEBERG_ASSIGN_OR_RAISE(auto batch_opt, reader->Next());
+ if (!batch_opt.has_value()) break;
+
+ auto& batch = batch_opt.value();
+ internal::ArrowArrayGuard batch_guard(&batch);
+
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto row, ArrowArrayStructLike::Make(arrow_schema, batch, /*row_index=*/0));
+
+ for (int64_t i = 0; i < batch.length; ++i) {
+ if (i > 0) {
+ ICEBERG_RETURN_UNEXPECTED(row->Reset(i));
+ }
+ ICEBERG_RETURN_UNEXPECTED(eq_set->Insert(*row));
+ }
+ }
+
+ ICEBERG_RETURN_UNEXPECTED(reader->Close());
+ }
+
+ return eq_set;
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/data/delete_loader.h b/src/iceberg/data/delete_loader.h
new file mode 100644
index 00000000..0a1122f9
--- /dev/null
+++ b/src/iceberg/data/delete_loader.h
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+/// \file iceberg/data/delete_loader.h
+/// Loads position and equality delete files into in-memory indexes.
+
+#include <memory>
+#include <span>
+#include <string_view>
+
+#include "iceberg/iceberg_export.h"
+#include "iceberg/result.h"
+#include "iceberg/type_fwd.h"
+
+namespace iceberg {
+
+/// \brief Loads delete files and constructs in-memory delete indexes.
+class ICEBERG_EXPORT DeleteLoader {
+ public:
+ /// \brief Create a DeleteLoader.
+ /// \param io FileIO instance for reading delete files
+ explicit DeleteLoader(std::shared_ptr<FileIO> io);
+
+ ~DeleteLoader();
+
+ /// \brief Load position deletes for a specific data file.
+ ///
+ /// Reads the given position delete files and returns a PositionDeleteIndex
+ /// containing only the positions that apply to the specified data file path.
+ /// Supports both regular position delete files and deletion vectors.
+ ///
+ /// \param delete_files Position delete files to load (must have
+ /// content == Content::kPositionDeletes)
+ /// \param data_file_path Path of the data file to filter positions for
+ /// \return A PositionDeleteIndex with deleted positions for the data file
+ Result<PositionDeleteIndex> LoadPositionDeletes(
+ std::span<const std::shared_ptr<DataFile>> delete_files,
+ std::string_view data_file_path) const;
+
+ /// \brief Load equality deletes into an in-memory set.
+ ///
+ /// \param delete_files Equality delete files to load (must have
+ /// content == Content::kEqualityDeletes)
+ /// \param equality_type The struct type describing the equality columns
+ /// \return A StructLikeSet containing the deleted rows
+ Result<std::unique_ptr<UncheckedStructLikeSet>> LoadEqualityDeletes(
+ std::span<const std::shared_ptr<DataFile>> delete_files,
+ const StructType& equality_type) const;
+
+ private:
+ /// \brief Load a single position delete file into the index.
+ Status LoadPositionDelete(const DataFile& file, PositionDeleteIndex& index,
+ std::string_view data_file_path) const;
+
+ /// \brief Load a single deletion vector file into the index.
+ Status LoadDV(const DataFile& file, PositionDeleteIndex& index) const;
+
+ std::shared_ptr<FileIO> io_;
+};
+
+} // namespace iceberg
diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build
index 067d2797..99ac01f6 100644
--- a/src/iceberg/meson.build
+++ b/src/iceberg/meson.build
@@ -42,6 +42,11 @@ iceberg_include_dir = include_directories('..')
iceberg_sources = files(
'arrow_c_data_guard_internal.cc',
'catalog/memory/in_memory_catalog.cc',
+ 'data/data_writer.cc',
+ 'data/delete_loader.cc',
+ 'data/equality_delete_writer.cc',
+ 'data/position_delete_writer.cc',
+ 'data/writer.cc',
'delete_file_index.cc',
'deletes/position_delete_index.cc',
'deletes/roaring_position_bitmap.cc',
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index b5420231..2dc90da6 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -194,7 +194,11 @@ if(ICEBERG_BUILD_BUNDLE)
update_sort_order_test.cc
update_statistics_test.cc)
- add_iceberg_test(data_writer_test USE_BUNDLE SOURCES data_writer_test.cc)
+ add_iceberg_test(data_test
+ USE_BUNDLE
+ SOURCES
+ data_writer_test.cc
+ delete_loader_test.cc)
endif()
diff --git a/src/iceberg/test/delete_loader_test.cc b/src/iceberg/test/delete_loader_test.cc
new file mode 100644
index 00000000..6dcd564b
--- /dev/null
+++ b/src/iceberg/test/delete_loader_test.cc
@@ -0,0 +1,295 @@
+/*
+ * 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 "iceberg/data/delete_loader.h"
+
+#include <arrow/array.h>
+#include <arrow/c/bridge.h>
+#include <arrow/json/from_string.h>
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include "iceberg/arrow/arrow_fs_file_io_internal.h"
+#include "iceberg/data/equality_delete_writer.h"
+#include "iceberg/data/position_delete_writer.h"
+#include "iceberg/deletes/position_delete_index.h"
+#include "iceberg/file_format.h"
+#include "iceberg/manifest/manifest_entry.h"
+#include "iceberg/parquet/parquet_register.h"
+#include "iceberg/partition_spec.h"
+#include "iceberg/row/partition_values.h"
+#include "iceberg/schema.h"
+#include "iceberg/schema_field.h"
+#include "iceberg/schema_internal.h"
+#include "iceberg/test/matchers.h"
+#include "iceberg/type.h"
+#include "iceberg/util/struct_like_set.h"
+
+namespace iceberg {
+
+class DeleteLoaderTest : public ::testing::Test {
+ protected:
+ static void SetUpTestSuite() { parquet::RegisterAll(); }
+
+ void SetUp() override {
+ file_io_ = arrow::ArrowFileSystemFileIO::MakeMockFileIO();
+ schema_ = std::make_shared<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int32()),
+ SchemaField::MakeOptional(2, "name", string())});
+ partition_spec_ = PartitionSpec::Unpartitioned();
+ loader_ = std::make_unique<DeleteLoader>(file_io_);
+ }
+
+ /// Write position deletes using WriteDelete API and return the DataFile metadata.
+ std::shared_ptr<DataFile> WritePositionDeletes(
+ const std::string& path,
+ const std::vector<std::pair<std::string, int64_t>>& deletes) {
+ PositionDeleteWriterOptions options{
+ .path = path,
+ .schema = schema_,
+ .spec = partition_spec_,
+ .partition = PartitionValues{},
+ .format = FileFormatType::kParquet,
+ .io = file_io_,
+ .flush_threshold = 10000,
+ .properties = {{"write.parquet.compression-codec", "uncompressed"}},
+ };
+
+ auto writer = PositionDeleteWriter::Make(options).value();
+ for (const auto& [file_path, pos] : deletes) {
+ ICEBERG_THROW_NOT_OK(writer->WriteDelete(file_path, pos));
+ }
+ ICEBERG_THROW_NOT_OK(writer->Close());
+ return writer->Metadata().value().data_files[0];
+ }
+
+ /// Write equality deletes and return the DataFile metadata.
+ std::shared_ptr<DataFile> WriteEqualityDeletes(
+ const std::string& path, const std::string& json_data,
+ std::vector<int32_t> equality_field_ids = {1, 2}) {
+ EqualityDeleteWriterOptions options{
+ .path = path,
+ .schema = schema_,
+ .spec = partition_spec_,
+ .partition = PartitionValues{},
+ .format = FileFormatType::kParquet,
+ .io = file_io_,
+ .equality_field_ids = std::move(equality_field_ids),
+ .properties = {{"write.parquet.compression-codec", "uncompressed"}},
+ };
+
+ auto writer = EqualityDeleteWriter::Make(options).value();
+
+ ArrowSchema arrow_c_schema;
+ ICEBERG_THROW_NOT_OK(ToArrowSchema(*schema_, &arrow_c_schema));
+ auto arrow_type = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
+ auto test_data = ::arrow::json::ArrayFromJSONString(
+ ::arrow::struct_(arrow_type->fields()), json_data)
+ .ValueOrDie();
+ ArrowArray arrow_array;
+ auto export_status = ::arrow::ExportArray(*test_data, &arrow_array);
+ if (!export_status.ok()) throw std::runtime_error(export_status.ToString());
+ ICEBERG_THROW_NOT_OK(writer->Write(&arrow_array));
+ ICEBERG_THROW_NOT_OK(writer->Close());
+ return writer->Metadata().value().data_files[0];
+ }
+
+ std::shared_ptr<FileIO> file_io_;
+ std::shared_ptr<Schema> schema_;
+ std::shared_ptr<PartitionSpec> partition_spec_;
+ std::unique_ptr<DeleteLoader> loader_;
+};
+
+// --- Position Delete Tests ---
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesEmpty) {
+ std::vector<std::shared_ptr<DataFile>> empty;
+ auto result = loader_->LoadPositionDeletes(empty, "data.parquet");
+ ASSERT_THAT(result, IsOk());
+ ASSERT_TRUE(result->IsEmpty());
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesSingleFile) {
+ auto delete_file = WritePositionDeletes(
+ "pos_deletes_1.parquet",
+ {{"data.parquet", 0}, {"data.parquet", 5}, {"data.parquet", 10}});
+
+ std::vector<std::shared_ptr<DataFile>> files = {delete_file};
+ auto result = loader_->LoadPositionDeletes(files, "data.parquet");
+ ASSERT_THAT(result, IsOk());
+
+ auto& index = result.value();
+ ASSERT_FALSE(index.IsEmpty());
+ ASSERT_EQ(index.Cardinality(), 3);
+ ASSERT_TRUE(index.IsDeleted(0));
+ ASSERT_TRUE(index.IsDeleted(5));
+ ASSERT_TRUE(index.IsDeleted(10));
+ ASSERT_FALSE(index.IsDeleted(1));
+ ASSERT_FALSE(index.IsDeleted(6));
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesMultipleFiles) {
+ auto file1 = WritePositionDeletes("pos_deletes_a.parquet",
+ {{"data.parquet", 1}, {"data.parquet", 3}});
+ auto file2 = WritePositionDeletes("pos_deletes_b.parquet",
+ {{"data.parquet", 5}, {"data.parquet", 7}});
+
+ std::vector<std::shared_ptr<DataFile>> files = {file1, file2};
+ auto result = loader_->LoadPositionDeletes(files, "data.parquet");
+ ASSERT_THAT(result, IsOk());
+
+ auto& index = result.value();
+ ASSERT_EQ(index.Cardinality(), 4);
+ ASSERT_TRUE(index.IsDeleted(1));
+ ASSERT_TRUE(index.IsDeleted(3));
+ ASSERT_TRUE(index.IsDeleted(5));
+ ASSERT_TRUE(index.IsDeleted(7));
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesFiltersByDataFilePath) {
+ auto delete_file =
+ WritePositionDeletes("pos_deletes_mixed.parquet", {{"data_a.parquet", 0},
+ {"data_a.parquet", 2},
+ {"data_b.parquet", 10},
+ {"data_b.parquet", 20}});
+
+ std::vector<std::shared_ptr<DataFile>> files = {delete_file};
+
+ // Load only positions for data_a.parquet
+ auto result_a = loader_->LoadPositionDeletes(files, "data_a.parquet");
+ ASSERT_THAT(result_a, IsOk());
+ ASSERT_EQ(result_a.value().Cardinality(), 2);
+ ASSERT_TRUE(result_a.value().IsDeleted(0));
+ ASSERT_TRUE(result_a.value().IsDeleted(2));
+ ASSERT_FALSE(result_a.value().IsDeleted(10));
+
+ // Load only positions for data_b.parquet
+ auto result_b = loader_->LoadPositionDeletes(files, "data_b.parquet");
+ ASSERT_THAT(result_b, IsOk());
+ ASSERT_EQ(result_b.value().Cardinality(), 2);
+ ASSERT_TRUE(result_b.value().IsDeleted(10));
+ ASSERT_TRUE(result_b.value().IsDeleted(20));
+ ASSERT_FALSE(result_b.value().IsDeleted(0));
+
+ // Load for non-existent file
+ auto result_none = loader_->LoadPositionDeletes(files, "nonexistent.parquet");
+ ASSERT_THAT(result_none, IsOk());
+ ASSERT_TRUE(result_none.value().IsEmpty());
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesSkipsMismatchedReferencedDataFile) {
+ auto delete_file = std::make_shared<DataFile>(DataFile{
+ .content = DataFile::Content::kPositionDeletes,
+ .file_path = "missing-pos-delete.parquet",
+ .file_format = FileFormatType::kParquet,
+ .referenced_data_file = "other-data.parquet",
+ });
+
+ std::vector<std::shared_ptr<DataFile>> files = {delete_file};
+ auto result = loader_->LoadPositionDeletes(files, "data.parquet");
+ ASSERT_THAT(result, IsOk());
+ ASSERT_TRUE(result.value().IsEmpty());
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesRejectsDV) {
+ auto dv_file = std::make_shared<DataFile>(DataFile{
+ .content = DataFile::Content::kPositionDeletes,
+ .file_path = "dv.puffin",
+ .file_format = FileFormatType::kPuffin,
+ });
+ std::vector<std::shared_ptr<DataFile>> files = {dv_file};
+ auto result = loader_->LoadPositionDeletes(files, "data.parquet");
+ ASSERT_THAT(result, IsError(ErrorKind::kNotSupported));
+}
+
+TEST_F(DeleteLoaderTest, LoadPositionDeletesRejectsWrongContent) {
+ auto data_file = std::make_shared<DataFile>(DataFile{
+ .content = DataFile::Content::kData,
+ .file_path = "data.parquet",
+ .file_format = FileFormatType::kParquet,
+ });
+ std::vector<std::shared_ptr<DataFile>> files = {data_file};
+ auto result = loader_->LoadPositionDeletes(files, "data.parquet");
+ ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+}
+
+// --- Equality Delete Tests ---
+
+TEST_F(DeleteLoaderTest, LoadEqualityDeletesEmpty) {
+ std::vector<std::shared_ptr<DataFile>> empty;
+ StructType eq_type(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
+ SchemaField::MakeOptional(2, "name", string()),
+ });
+
+ auto result = loader_->LoadEqualityDeletes(empty, eq_type);
+ ASSERT_THAT(result, IsOk());
+ ASSERT_TRUE(result.value()->IsEmpty());
+ ASSERT_EQ(result.value()->Size(), 0);
+}
+
+TEST_F(DeleteLoaderTest, LoadEqualityDeletesSingleFile) {
+ auto delete_file =
+ WriteEqualityDeletes("eq_deletes_1.parquet", R"([[1, "Alice"], [2, "Bob"]])");
+
+ StructType eq_type(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
+ SchemaField::MakeOptional(2, "name", string()),
+ });
+
+ std::vector<std::shared_ptr<DataFile>> files = {delete_file};
+ auto result = loader_->LoadEqualityDeletes(files, eq_type);
+ ASSERT_THAT(result, IsOk());
+
+ ASSERT_FALSE(result.value()->IsEmpty());
+ ASSERT_EQ(result.value()->Size(), 2);
+}
+
+TEST_F(DeleteLoaderTest, LoadEqualityDeletesMultipleFiles) {
+ auto file1 = WriteEqualityDeletes("eq_deletes_a.parquet", R"([[1, "Alice"]])");
+ auto file2 =
+ WriteEqualityDeletes("eq_deletes_b.parquet", R"([[2, "Bob"], [3, "Charlie"]])");
+
+ StructType eq_type(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
+ SchemaField::MakeOptional(2, "name", string()),
+ });
+
+ std::vector<std::shared_ptr<DataFile>> files = {file1, file2};
+ auto result = loader_->LoadEqualityDeletes(files, eq_type);
+ ASSERT_THAT(result, IsOk());
+ ASSERT_EQ(result.value()->Size(), 3);
+}
+
+TEST_F(DeleteLoaderTest, LoadEqualityDeletesRejectsWrongContent) {
+ auto data_file = std::make_shared<DataFile>(DataFile{
+ .content = DataFile::Content::kData,
+ .file_path = "data.parquet",
+ .file_format = FileFormatType::kParquet,
+ });
+ StructType eq_type(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
+ });
+
+ std::vector<std::shared_ptr<DataFile>> files = {data_file};
+ auto result = loader_->LoadEqualityDeletes(files, eq_type);
+ ASSERT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+}
+
+} // namespace iceberg
diff --git a/src/iceberg/type_fwd.h b/src/iceberg/type_fwd.h
index 0b0cccbf..3fe199d8 100644
--- a/src/iceberg/type_fwd.h
+++ b/src/iceberg/type_fwd.h
@@ -188,6 +188,9 @@ class ArrayLike;
class MapLike;
class StructLike;
class StructLikeAccessor;
+template <bool kValidate>
+class StructLikeSet;
+using UncheckedStructLikeSet = StructLikeSet<false>;
/// \brief Catalog
class Catalog;
@@ -221,6 +224,10 @@ class UpdateSnapshotReference;
class UpdateSortOrder;
class UpdateStatistics;
+/// \brief Delete indexes.
+class DeleteLoader;
+class PositionDeleteIndex;
+
/// ----------------------------------------------------------------------------
/// TODO: Forward declarations below are not added yet.
/// ----------------------------------------------------------------------------
diff --git a/src/iceberg/util/struct_like_set.h b/src/iceberg/util/struct_like_set.h
index b74254eb..1dda0621 100644
--- a/src/iceberg/util/struct_like_set.h
+++ b/src/iceberg/util/struct_like_set.h
@@ -101,10 +101,6 @@ class ICEBERG_TEMPLATE_CLASS_EXPORT StructLikeSet {
std::unordered_set<std::unique_ptr<StructLike>, KeyHash, KeyEqual> set_;
};
-/// \brief Type alias for StructLikeSet without schema validation, for callers
-/// that guarantee schema conformance.
-using UncheckedStructLikeSet = StructLikeSet<false>;
-
extern template class ICEBERG_EXTERN_TEMPLATE_CLASS_EXPORT StructLikeSet<true>;
extern template class ICEBERG_EXTERN_TEMPLATE_CLASS_EXPORT StructLikeSet<false>;