wangyong9999 commented on code in PR #315:
URL: https://github.com/apache/paimon-cpp/pull/315#discussion_r3996417595


##########
src/paimon/format/lance/lance_file_batch_reader.cpp:
##########
@@ -0,0 +1,304 @@
+/*
+ * 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 "paimon/format/lance/lance_file_batch_reader.h"
+
+#include <utility>
+
+#include "arrow/api.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "fmt/format.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/checked_cast.h"
+#include "paimon/core/utils/nested_projection_utils.h"
+#include "paimon/format/lance/lance_utils.h"
+#include "paimon/fs/file_system.h"
+
+namespace paimon::lance {
+
+LanceFileBatchReader::LanceFileBatchReader(const std::shared_ptr<InputStream>& 
input,
+                                           int32_t batch_size, uint32_t 
batch_readahead,
+                                           PaimonLanceReader* reader,
+                                           const 
std::shared_ptr<arrow::Schema>& file_schema,
+                                           uint64_t total_rows,
+                                           const std::shared_ptr<MemoryPool>& 
pool,
+                                           const 
std::shared_ptr<arrow::MemoryPool>& arrow_pool)
+    : input_(input),
+      batch_size_(batch_size),
+      batch_readahead_(batch_readahead),
+      reader_(reader),
+      file_schema_(file_schema),
+      total_rows_(total_rows),
+      pool_(pool),
+      arrow_pool_(arrow_pool),
+      metrics_(std::make_shared<MetricsImpl>()) {}
+
+Result<std::unique_ptr<LanceFileBatchReader>> LanceFileBatchReader::Create(
+    const std::shared_ptr<InputStream>& input, int32_t batch_size, uint32_t 
batch_readahead,
+    const std::map<std::string, std::string>& options, const 
std::shared_ptr<MemoryPool>& pool,
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
+    if (input == nullptr || pool == nullptr || arrow_pool == nullptr || 
batch_size <= 0 ||
+        batch_readahead == 0) {
+        return Status::Invalid(
+            "Lance reader requires non-null input and memory pools, positive "
+            "batch size, and positive batch readahead");
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::string uri, input->GetUri());
+    if (uri.empty()) {
+        return Status::Invalid("Lance reader input URI is empty");
+    }
+    LanceStorageOptions storage_options = GetLanceStorageOptions(options, uri);
+    std::vector<const char*> keys;
+    std::vector<const char*> values;
+    keys.reserve(storage_options.size());
+    values.reserve(storage_options.size());
+    for (const auto& [key, value] : storage_options) {
+        keys.push_back(key.c_str());
+        values.push_back(value.c_str());
+    }
+    PaimonLanceReader* reader = nullptr;
+    if (paimon_lance_reader_open(uri.c_str(), keys.data(), values.data(), 
keys.size(), &reader) !=
+        0) {
+        return LanceFfiError("open Lance reader");
+    }
+
+    ::ArrowSchema ffi_schema = {};
+    if (paimon_lance_reader_export_schema(reader, &ffi_schema) != 0) {
+        paimon_lance_reader_free(reader);
+        return LanceFfiError("read Lance schema");
+    }
+    arrow::Result<std::shared_ptr<arrow::Schema>> schema_result = 
arrow::ImportSchema(&ffi_schema);
+    if (!schema_result.ok()) {
+        paimon_lance_reader_free(reader);
+        return ToPaimonStatus(schema_result.status());
+    }
+    uint64_t total_rows = 0;
+    if (paimon_lance_reader_num_rows(reader, &total_rows) != 0) {
+        paimon_lance_reader_free(reader);
+        return LanceFfiError("read Lance row count");
+    }
+    return std::unique_ptr<LanceFileBatchReader>(new LanceFileBatchReader(
+        input, batch_size, batch_readahead, reader, 
std::move(schema_result).MoveValueUnsafe(),
+        total_rows, pool, arrow_pool));
+}
+
+LanceFileBatchReader::~LanceFileBatchReader() {
+    CloseInternal();
+}
+
+Status LanceFileBatchReader::OpenStream() {
+    if (batch_reader_ != nullptr || (has_selection_ && 
selection_row_ids_.empty())) {
+        return Status::OK();
+    }
+    std::vector<const char*> projection_pointers;
+    projection_pointers.reserve(projection_names_.size());
+    for (const std::string& name : projection_names_) {
+        projection_pointers.push_back(name.c_str());
+    }
+    if (paimon_lance_reader_open_stream(reader_, 
static_cast<uint32_t>(batch_size_),
+                                        batch_readahead_, 
projection_pointers.data(),
+                                        projection_pointers.size(), 
has_selection_,
+                                        selection_starts_.data(), 
selection_ends_.data(),
+                                        selection_starts_.size(), 
&batch_reader_) != 0) {
+        return LanceFfiError("open Lance batch reader");
+    }
+    return Status::OK();
+}
+
+Result<BatchReader::ReadBatch> LanceFileBatchReader::AlignBatch(ReadBatch 
batch) const {
+    if (read_schema_ == nullptr) {
+        return batch;
+    }
+    auto& [ffi_array, ffi_schema] = batch;
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                      arrow::ImportArray(ffi_array.get(), 
ffi_schema.get()));
+    if (array->type_id() != arrow::Type::STRUCT) {
+        return Status::Invalid("Lance reader returned a non-struct Arrow 
batch");
+    }
+    auto struct_array = checked_pointer_cast<arrow::StructArray>(array);
+    arrow::ArrayVector fields;
+    fields.reserve(read_schema_->num_fields());
+    for (const std::shared_ptr<arrow::Field>& field : read_schema_->fields()) {
+        std::shared_ptr<arrow::Array> child = 
struct_array->GetFieldByName(field->name());
+        if (child == nullptr) {
+            PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+                child,
+                arrow::MakeArrayOfNull(field->type(), struct_array->length(), 
arrow_pool_.get()));
+        } else if (!child->type()->Equals(field->type()) &&

Review Comment:
   Agreed. Removed the format reader's MakeArrayOfNull fallback and stopped 
filtering missing projection names. Missing table fields remain 
FieldMappingReader's responsibility. Nested missing-field and data-evolution 
tests pass.
   
   Commit: c45f394c.



##########
src/paimon/format/lance/lance_utils.cpp:
##########
@@ -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.
+ */
+
+#include "paimon/format/lance/lance_utils.h"
+
+#include <cstring>
+#include <map>
+#include <optional>
+
+#include "paimon/common/utils/path_util.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/format/lance/lance_ffi.h"
+
+namespace paimon::lance {
+namespace {
+
+void CopyOption(const std::map<std::string, std::string>& options, const 
std::string& source,
+                const std::string& target, std::map<std::string, std::string>* 
storage_options) {
+    auto iter = options.find(source);
+    if (iter != options.end() && !iter->second.empty()) {
+        storage_options->insert_or_assign(target, iter->second);
+    }
+}
+
+const char* CanonicalS3OptionName(const std::string& option) {
+    if (option == "access-key" || option == "access.key" || option == 
"accessKeyId") {
+        return "s3.access-key";
+    }
+    if (option == "secret-key" || option == "secret.key" || option == 
"accessKeySecret") {
+        return "s3.secret-key";
+    }
+    if (option == "session.token" || option == "session-token" || option == 
"security.token" ||
+        option == "security-token" || option == "securityToken") {
+        return "s3.session.token";
+    }
+    if (option == "endpoint") {
+        return "s3.endpoint";
+    }
+    if (option == "region") {
+        return "s3.region";
+    }
+    if (option == "path-style-access" || option == "path.style.access") {
+        return "s3.path-style-access";
+    }
+    return nullptr;
+}
+
+std::map<std::string, std::string> NormalizeS3Options(
+    const std::map<std::string, std::string>& options) {
+    std::map<std::string, std::string> normalized = options;
+    for (const auto& [key, value] : options) {
+        for (const char* prefix : {"s3a.", "fs.s3.", "fs.s3a.", "s3."}) {
+            if (!StringUtils::StartsWith(key, prefix)) {
+                continue;
+            }
+            const char* canonical = 
CanonicalS3OptionName(key.substr(std::strlen(prefix)));
+            if (canonical != nullptr && normalized.find(canonical) == 
normalized.end()) {
+                normalized.emplace(canonical, value);
+            }
+            break;
+        }
+    }
+    return normalized;
+}
+
+void AddS3Options(const std::map<std::string, std::string>& options,
+                  std::map<std::string, std::string>* storage_options) {
+    const std::map<std::string, std::string> normalized = 
NormalizeS3Options(options);
+    CopyOption(normalized, "s3.region", "region", storage_options);
+    CopyOption(normalized, "s3.endpoint", "endpoint", storage_options);
+    CopyOption(normalized, "s3.access-key", "access_key_id", storage_options);
+    CopyOption(normalized, "s3.secret-key", "secret_access_key", 
storage_options);
+    CopyOption(normalized, "s3.session.token", "session_token", 
storage_options);
+    auto endpoint = storage_options->find("endpoint");
+    if (endpoint != storage_options->end() && endpoint->second.find("://") == 
std::string::npos) {
+        endpoint->second = "https://"; + endpoint->second;
+    }
+    auto path_style = normalized.find("s3.path-style-access");
+    if (path_style != normalized.end()) {
+        std::optional<bool> enabled = 
StringUtils::StringToValue<bool>(path_style->second);
+        if (enabled) {
+            storage_options->insert_or_assign("virtual_hosted_style_request",
+                                              *enabled ? "false" : "true");
+        }
+    }
+}
+
+void AddOssOptions(const std::map<std::string, std::string>& options, const 
std::string& bucket,
+                   std::map<std::string, std::string>* storage_options) {
+    auto copy_oss_option = [&](const std::string& suffix, const std::string& 
target) {
+        CopyOption(options, "fs.oss." + suffix, target, storage_options);
+        if (!bucket.empty()) {
+            CopyOption(options, "fs.oss.bucket." + bucket + "." + suffix, 
target, storage_options);
+        }
+    };
+    copy_oss_option("endpoint", "oss_endpoint");
+    copy_oss_option("region", "oss_region");
+    copy_oss_option("accessKeyId", "oss_access_key_id");
+    copy_oss_option("accessKeySecret", "oss_secret_access_key");
+    copy_oss_option("sessionToken", "oss_session_token");
+    copy_oss_option("securityToken", "oss_session_token");
+    copy_oss_option("accessKeyId", "access_key_id");
+    copy_oss_option("accessKeySecret", "secret_access_key");
+    copy_oss_option("sessionToken", "session_token");
+    copy_oss_option("securityToken", "session_token");
+    auto endpoint = storage_options->find("oss_endpoint");
+    if (endpoint != storage_options->end() && endpoint->second.find("://") == 
std::string::npos) {
+        endpoint->second = "https://"; + endpoint->second;
+    }
+}
+
+}  // namespace
+
+LanceStorageOptions GetLanceStorageOptions(const std::map<std::string, 
std::string>& options,
+                                           const std::string& uri) {
+    std::map<std::string, std::string> storage_options;

Review Comment:
   The pinned oss:// backend uses OpenDAL: it consumes oss_endpoint, adds the 
bucket itself and defaults bare hosts to HTTPS. Java's S3-style 
endpoint/virtual_hosted_style_request keys are unused here. Kept the equivalent 
mapping and added comments, endpoint/override tests and documentation. Also 
documented that this backend does not forward table-option OSS session tokens; 
mapping tests do not establish STS support.
   
   Commit: c45f394c.



##########
src/paimon/format/lance/lance_format_writer.cpp:
##########
@@ -0,0 +1,139 @@
+/*
+ * 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 "paimon/format/lance/lance_format_writer.h"
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/math.h"
+#include "paimon/common/utils/scope_guard.h"
+
+namespace paimon::lance {
+
+LanceFormatWriter::LanceFormatWriter(const std::shared_ptr<arrow::Schema>& 
schema,
+                                     const std::shared_ptr<arrow::MemoryPool>& 
arrow_pool,
+                                     PaimonLanceWriter* writer)
+    : schema_(schema),
+      arrow_pool_(arrow_pool),
+      writer_(writer),
+      metrics_(std::make_shared<MetricsImpl>()) {}
+
+Result<std::unique_ptr<LanceFormatWriter>> LanceFormatWriter::Create(
+    const std::string& path, const std::shared_ptr<arrow::Schema>& schema,
+    const LanceStorageOptions& storage_options,
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
+    if (path.empty() || schema == nullptr || arrow_pool == nullptr) {
+        return Status::Invalid("Lance writer requires a path, schema, and 
memory pool");
+    }
+    std::vector<const char*> keys;
+    std::vector<const char*> values;
+    keys.reserve(storage_options.size());
+    values.reserve(storage_options.size());
+    for (const auto& [key, value] : storage_options) {
+        keys.push_back(key.c_str());
+        values.push_back(value.c_str());
+    }
+    PaimonLanceWriter* writer = nullptr;
+    if (paimon_lance_writer_open(path.c_str(), keys.data(), values.data(), 
keys.size(), &writer) !=
+        0) {
+        return LanceFfiError("open Lance writer");
+    }
+    return std::unique_ptr<LanceFormatWriter>(new LanceFormatWriter(schema, 
arrow_pool, writer));
+}
+
+LanceFormatWriter::~LanceFormatWriter() {
+    paimon_lance_writer_free(writer_);
+}
+
+Status LanceFormatWriter::AddBatch(::ArrowArray* batch) {
+    if (batch == nullptr) {
+        return Status::Invalid("Lance writer batch is nullptr");
+    }
+    if (finished_) {
+        return Status::Invalid("cannot add a batch after Lance writer is 
finished");
+    }
+    auto row_count = static_cast<uint64_t>(batch->length);
+    ::ArrowSchema import_schema = {};
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema_, 
&import_schema));
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                      arrow::ImportArray(batch, 
&import_schema));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> normalized,
+                           ArrowUtils::NormalizeArrayOffsets(array, 
arrow_pool_.get()));

Review Comment:
   This handles sliced nested arrays crossing the C++/Rust Arrow C Data 
Interface, rather than rejecting all non-zero offsets. Normalization keeps 
child buffers and offsets consistent for Lance 0.39. Added a comment; the 
supported-types test writes sliced batches and the new ROW/ARRAY<ROW> test 
reads back a non-zero-offset slice.
   
   Commit: c45f394c.



##########
src/paimon/format/lance/lance_format_writer.cpp:
##########
@@ -0,0 +1,139 @@
+/*
+ * 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 "paimon/format/lance/lance_format_writer.h"
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/math.h"
+#include "paimon/common/utils/scope_guard.h"
+
+namespace paimon::lance {
+
+LanceFormatWriter::LanceFormatWriter(const std::shared_ptr<arrow::Schema>& 
schema,
+                                     const std::shared_ptr<arrow::MemoryPool>& 
arrow_pool,
+                                     PaimonLanceWriter* writer)
+    : schema_(schema),
+      arrow_pool_(arrow_pool),
+      writer_(writer),
+      metrics_(std::make_shared<MetricsImpl>()) {}
+
+Result<std::unique_ptr<LanceFormatWriter>> LanceFormatWriter::Create(
+    const std::string& path, const std::shared_ptr<arrow::Schema>& schema,
+    const LanceStorageOptions& storage_options,
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
+    if (path.empty() || schema == nullptr || arrow_pool == nullptr) {
+        return Status::Invalid("Lance writer requires a path, schema, and 
memory pool");
+    }
+    std::vector<const char*> keys;
+    std::vector<const char*> values;
+    keys.reserve(storage_options.size());
+    values.reserve(storage_options.size());
+    for (const auto& [key, value] : storage_options) {
+        keys.push_back(key.c_str());
+        values.push_back(value.c_str());
+    }
+    PaimonLanceWriter* writer = nullptr;
+    if (paimon_lance_writer_open(path.c_str(), keys.data(), values.data(), 
keys.size(), &writer) !=
+        0) {
+        return LanceFfiError("open Lance writer");
+    }
+    return std::unique_ptr<LanceFormatWriter>(new LanceFormatWriter(schema, 
arrow_pool, writer));
+}
+
+LanceFormatWriter::~LanceFormatWriter() {
+    paimon_lance_writer_free(writer_);
+}
+
+Status LanceFormatWriter::AddBatch(::ArrowArray* batch) {
+    if (batch == nullptr) {
+        return Status::Invalid("Lance writer batch is nullptr");
+    }
+    if (finished_) {
+        return Status::Invalid("cannot add a batch after Lance writer is 
finished");
+    }
+    auto row_count = static_cast<uint64_t>(batch->length);
+    ::ArrowSchema import_schema = {};
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema_, 
&import_schema));
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                      arrow::ImportArray(batch, 
&import_schema));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> normalized,
+                           ArrowUtils::NormalizeArrayOffsets(array, 
arrow_pool_.get()));
+    ::ArrowArray ffi_array = {};
+    ::ArrowSchema ffi_schema = {};
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*normalized, 
&ffi_array, &ffi_schema));
+    ScopeGuard guard([&]() {
+        ArrowArrayRelease(&ffi_array);
+        ArrowSchemaRelease(&ffi_schema);
+    });
+    if (paimon_lance_writer_write(writer_, &ffi_array, &ffi_schema) != 0) {
+        return LanceFfiError("write Lance batch");
+    }
+    rows_written_ += row_count;
+    return Status::OK();
+}
+
+Status LanceFormatWriter::Flush() {
+    return Status::OK();
+}
+
+Status LanceFormatWriter::Finish() {
+    if (finished_) {
+        return Status::OK();
+    }
+    uint64_t row_count = 0;
+    if (paimon_lance_writer_finish(writer_, &row_count) != 0) {
+        return LanceFfiError("finish Lance writer");
+    }
+    if (row_count != rows_written_) {
+        return Status::Invalid("Lance writer row count mismatch");
+    }
+    finished_ = true;
+    return Status::OK();
+}
+
+Result<bool> LanceFormatWriter::ReachTargetSize(bool suggested_check, int64_t 
target_size) const {
+    if (!suggested_check) {
+        return false;
+    }
+    PAIMON_RETURN_NOT_OK(ValidateValueNonNegative(target_size, "Lance target 
size"));
+    uint64_t position = 0;
+    if (paimon_lance_writer_tell(writer_, &position) != 0) {
+        return LanceFfiError("get Lance writer position");
+    }
+    return position >= static_cast<uint64_t>(target_size);

Review Comment:
   Added the comment: C++ checks the current encoded file position, excluding 
buffered data, while Java estimates input Arrow-buffer size. The same 
target-file-size need not produce identical rolling boundaries.
   
   Commit: c45f394c.



##########
src/paimon/format/lance/lance_file_format_test.cpp:
##########
@@ -0,0 +1,344 @@
+/*
+ * 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 <cstdint>
+#include <map>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/api.h"
+#include "arrow/c/bridge.h"
+#include "arrow/ipc/json_simple.h"
+#include "gtest/gtest.h"
+#include "paimon/common/types/data_field.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/format/column_stats.h"
+#include "paimon/format/file_format.h"
+#include "paimon/format/file_format_factory.h"
+#include "paimon/format/format_stats_extractor.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/lance/lance_utils.h"
+#include "paimon/format/writer_builder.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/testing/utils/read_result_collector.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/utils/roaring_bitmap32.h"
+
+namespace paimon::lance::test {
+
+class LanceFileFormatTest : public ::testing::Test {
+ public:
+    void SetUp() override {
+        ASSERT_OK_AND_ASSIGN(format_, FileFormatFactory::Get("lance", 
{{"file.format", "lance"}}));
+        file_system_ = std::make_shared<LocalFileSystem>();
+        directory_ = paimon::test::UniqueTestDirectory::Create();
+        ASSERT_NE(directory_, nullptr);
+        pool_ = GetDefaultPool();
+        arrow_pool_ = GetArrowPool(pool_);
+    }
+
+    Status WriteFile(const std::string& path, const 
std::shared_ptr<arrow::Schema>& schema,
+                     const std::shared_ptr<arrow::Array>& array, int32_t 
batch_size) const {
+        ::ArrowSchema ffi_schema = {};
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema, 
&ffi_schema));
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<WriterBuilder> writer_builder,
+                               format_->CreateWriterBuilder(&ffi_schema, 
batch_size));
+        writer_builder->WithMemoryPool(pool_);
+        auto* direct_writer_builder = 
dynamic_cast<DirectWriterBuilder*>(writer_builder.get());
+        if (direct_writer_builder == nullptr) {
+            return Status::Invalid("Lance writer builder is not path based");
+        }
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatWriter> writer,
+                               direct_writer_builder->BuildFromPath(path));
+        for (int64_t offset = 0; offset < array->length(); offset += 
batch_size) {
+            std::shared_ptr<arrow::Array> slice = array->Slice(offset, 
batch_size);
+            ::ArrowArray ffi_array = {};
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*slice, 
&ffi_array));
+            PAIMON_RETURN_NOT_OK(writer->AddBatch(&ffi_array));
+        }
+        return writer->Finish();
+    }
+
+    Result<std::unique_ptr<FileBatchReader>> OpenReader(const std::string& 
path,
+                                                        int32_t batch_size) 
const {
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReaderBuilder> builder,
+                               format_->CreateReaderBuilder(batch_size));
+        builder->WithMemoryPool(pool_);
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input, 
file_system_->Open(path));
+        return builder->Build(input);
+    }
+
+ protected:
+    std::shared_ptr<FileFormat> format_;
+    std::shared_ptr<LocalFileSystem> file_system_;
+    std::unique_ptr<paimon::test::UniqueTestDirectory> directory_;
+    std::shared_ptr<MemoryPool> pool_;
+    std::shared_ptr<arrow::MemoryPool> arrow_pool_;
+};
+
+TEST(LanceUtilsTest, ConvertsObjectStoreOptions) {
+    auto to_map = [](const LanceStorageOptions& values) {
+        return std::map<std::string, std::string>(values.begin(), 
values.end());
+    };
+    auto s3_options = to_map(GetLanceStorageOptions({{"s3.region", 
"us-west-2"},
+                                                     {"s3.endpoint", 
"http://s3.example.com"},
+                                                     {"s3.access-key", "key"},
+                                                     {"s3.secret-key", 
"secret"},
+                                                     {"s3.session.token", 
"token"},
+                                                     {"s3.path-style-access", 
"true"},
+                                                     {"lance.storage.region", 
"override"}},
+                                                    "s3://bucket/data.lance"));
+    ASSERT_EQ(s3_options.at("region"), "override");
+    ASSERT_EQ(s3_options.at("endpoint"), "http://s3.example.com";);
+    ASSERT_EQ(s3_options.at("access_key_id"), "key");
+    ASSERT_EQ(s3_options.at("secret_access_key"), "secret");
+    ASSERT_EQ(s3_options.at("session_token"), "token");
+    ASSERT_EQ(s3_options.at("virtual_hosted_style_request"), "false");
+    ASSERT_EQ(s3_options.at("allow_http"), "true");
+
+    auto python_s3_options =
+        to_map(GetLanceStorageOptions({{"fs.s3.region", "us-west-1"},
+                                       {"fs.s3.endpoint", 
"s3.python.example.com"},
+                                       {"fs.s3.accessKeyId", "python-key"},
+                                       {"fs.s3.accessKeySecret", 
"python-secret"},
+                                       {"fs.s3.securityToken", "python-token"},
+                                       {"fs.s3.path.style.access", "false"}},
+                                      "s3://bucket/data.lance"));
+    ASSERT_EQ(python_s3_options.at("region"), "us-west-1");
+    ASSERT_EQ(python_s3_options.at("endpoint"), 
"https://s3.python.example.com";);
+    ASSERT_EQ(python_s3_options.at("access_key_id"), "python-key");
+    ASSERT_EQ(python_s3_options.at("secret_access_key"), "python-secret");
+    ASSERT_EQ(python_s3_options.at("session_token"), "python-token");
+    ASSERT_EQ(python_s3_options.at("virtual_hosted_style_request"), "true");
+
+    auto java_s3_options = to_map(GetLanceStorageOptions({{"s3a.region", 
"eu-west-1"},
+                                                          {"s3a.access.key", 
"java-key"},
+                                                          {"s3a.secret.key", 
"java-secret"},
+                                                          
{"s3a.session.token", "java-token"},
+                                                          {"s3.access-key", 
"canonical-key"}},
+                                                         
"s3://bucket/data.lance"));
+    ASSERT_EQ(java_s3_options.at("region"), "eu-west-1");
+    ASSERT_EQ(java_s3_options.at("access_key_id"), "canonical-key");
+    ASSERT_EQ(java_s3_options.at("secret_access_key"), "java-secret");
+    ASSERT_EQ(java_s3_options.at("session_token"), "java-token");
+
+    auto oss_options =
+        to_map(GetLanceStorageOptions({{"fs.oss.endpoint", 
"global.example.com"},
+                                       {"fs.oss.accessKeyId", "global-key"},
+                                       {"fs.oss.accessKeySecret", 
"global-secret"},
+                                       {"fs.oss.bucket.bucket.endpoint", 
"bucket.example.com"},
+                                       {"fs.oss.bucket.bucket.accessKeyId", 
"bucket-key"},
+                                       
{"fs.oss.bucket.bucket.accessKeySecret", "bucket-secret"},
+                                       {"fs.oss.bucket.bucket.securityToken", 
"bucket-token"}},
+                                      "oss://bucket/data.lance"));
+    ASSERT_EQ(oss_options.at("oss_endpoint"), "https://bucket.example.com";);
+    ASSERT_EQ(oss_options.at("oss_access_key_id"), "bucket-key");
+    ASSERT_EQ(oss_options.at("oss_secret_access_key"), "bucket-secret");
+    ASSERT_EQ(oss_options.at("oss_session_token"), "bucket-token");
+    ASSERT_EQ(oss_options.at("access_key_id"), "bucket-key");
+    ASSERT_EQ(oss_options.at("secret_access_key"), "bucket-secret");
+    ASSERT_EQ(oss_options.at("session_token"), "bucket-token");
+}
+
+TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
+    arrow::FieldVector fields = {
+        arrow::field("bool_col", arrow::boolean()),
+        arrow::field("i8_col", arrow::int8()),
+        arrow::field("i16_col", arrow::int16()),
+        arrow::field("i32_col", arrow::int32()),
+        arrow::field("i64_col", arrow::int64()),
+        arrow::field("float_col", arrow::float32()),
+        arrow::field("double_col", arrow::float64()),
+        arrow::field("string_col", arrow::utf8()),
+        arrow::field("binary_col", arrow::binary()),
+        arrow::field("date_col", arrow::date32()),
+        arrow::field("time_col", arrow::time32(arrow::TimeUnit::MILLI)),
+        arrow::field("timestamp_col", 
arrow::timestamp(arrow::TimeUnit::MICRO)),
+        arrow::field("decimal_col", arrow::decimal128(12, 2)),
+        arrow::field("array_col", arrow::list(arrow::float32())),
+        arrow::field("row_col", arrow::struct_({arrow::field("value", 
arrow::int32())})),
+        arrow::field(
+            "vector_col",
+            arrow::fixed_size_list(arrow::field("item", arrow::float32(), 
/*nullable=*/false), 2)),
+    };
+    std::shared_ptr<arrow::Schema> schema = arrow::schema(fields);
+    std::shared_ptr<arrow::Array> expected =
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+          [true,-1,-2,-3,-4,1.5,2.5,"one","abc",1,1234,
+           "1970-01-01 00:00:00.000001","12.34",[1.0,2.0],[7],[3.0,4.0]],
+          [false,1,2,3,4,3.5,4.5,"two","xyz",2,5678,
+           "2030-12-31 23:59:59.999999","-12.34",[],[8],[5.0,6.0]],
+          
[null,null,null,null,null,null,null,null,null,null,null,null,null,null,[null],null]
+        ])")
+            .ValueOrDie();
+    std::string path = PathUtil::JoinPath(directory_->Str(), 
"supported-types.lance");
+    ASSERT_OK(WriteFile(path, schema, expected, /*batch_size=*/2));
+
+    for (int32_t batch_size : {1, 2, 4}) {
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
OpenReader(path, batch_size));
+        ::ArrowSchema ffi_schema = {};
+        ASSERT_TRUE(arrow::ExportSchema(*schema, &ffi_schema).ok());
+        ASSERT_OK(reader->SetReadSchema(&ffi_schema, /*predicate=*/nullptr,
+                                        /*selection_bitmap=*/std::nullopt));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> actual,
+                             
paimon::test::ReadResultCollector::CollectResult(std::move(reader)));
+        ASSERT_TRUE(actual->Equals(arrow::ChunkedArray(expected))) << 
actual->ToString() << "\nvs\n"
+                                                                   << 
expected->ToString();
+    }
+}
+
+TEST_F(LanceFileFormatTest, RejectsNullTopLevelRows) {
+    arrow::FieldVector fields = {arrow::field("id", arrow::int32())};
+    std::shared_ptr<arrow::Array> data =
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), 
R"([[1],null])")
+            .ValueOrDie();
+    ASSERT_NOK_WITH_MSG(WriteFile(PathUtil::JoinPath(directory_->Str(), 
"null-row.lance"),
+                                  arrow::schema(fields), data, 
/*batch_size=*/2),
+                        "Lance writer does not accept null top-level rows");
+}
+
+TEST_F(LanceFileFormatTest, ProjectionSelectionAndFileRowIds) {
+    arrow::FieldVector fields = {arrow::field("id", arrow::int32()),
+                                 arrow::field("name", arrow::utf8())};
+    std::shared_ptr<arrow::Schema> schema = arrow::schema(fields);
+    std::shared_ptr<arrow::Array> data =
+        arrow::ipc::internal::json::ArrayFromJSON(
+            arrow::struct_(fields), 
R"([[0,"zero"],[1,"one"],[2,"two"],[3,"three"],[4,"four"]])")
+            .ValueOrDie();
+    std::string path = PathUtil::JoinPath(directory_->Str(), 
"selection.lance");
+    ASSERT_OK(WriteFile(path, schema, data, /*batch_size=*/2));
+
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
OpenReader(path, 2));
+    std::shared_ptr<arrow::Field> read_field =
+        arrow::field("name", arrow::utf8(), /*nullable=*/false,
+                     arrow::key_value_metadata({DataField::FIELD_ID}, {"1"}));
+    std::shared_ptr<arrow::Schema> projected_schema = 
arrow::schema({read_field});
+    ::ArrowSchema ffi_schema = {};
+    ASSERT_TRUE(arrow::ExportSchema(*projected_schema, &ffi_schema).ok());
+    ASSERT_OK(reader->SetReadSchema(&ffi_schema, /*predicate=*/nullptr,
+                                    RoaringBitmap32::From({1, 2, 4})));
+
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch first_batch, 
reader->NextBatch());
+    arrow::Result<std::shared_ptr<arrow::Array>> first_result =
+        arrow::ImportArray(first_batch.first.get(), first_batch.second.get());
+    ASSERT_TRUE(first_result.ok()) << first_result.status().ToString();
+    std::shared_ptr<arrow::Array> first = 
std::move(first_result).MoveValueUnsafe();
+    auto first_type = 
std::static_pointer_cast<arrow::StructType>(first->type());
+    ASSERT_TRUE(first_type->field(0)->Equals(*read_field, 
/*check_metadata=*/true));
+    ASSERT_TRUE(first->Equals(arrow::ipc::internal::json::ArrayFromJSON(

Review Comment:
   This test checks field metadata and nullability. CollectResultOneBatch uses 
DictArrayConverter, which normalizes those attributes away. Kept the direct 
Arrow import here and added a comment; result-only tests continue using 
ReadResultCollector.
   
   Commit: c45f394c.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to