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>;