This is an automated email from the ASF dual-hosted git repository.
lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new b04d6e1c feat(lance): support TIMESTAMP_LTZ type (#479)
b04d6e1c is described below
commit b04d6e1cf8c89fb941a29aec96ab2dea4eb2647d
Author: Nicholas Jiang <[email protected]>
AuthorDate: Fri Oct 30 08:09:57 2026 +0700
feat(lance): support TIMESTAMP_LTZ type (#479)
---
docs/source/user_guide/data_types.rst | 5 +
src/paimon/core/schema/schema_validation.cpp | 11 +-
src/paimon/core/schema/schema_validation_test.cpp | 20 ++-
src/paimon/core/utils/nested_projection_utils.cpp | 95 ++++++++++++++
src/paimon/core/utils/nested_projection_utils.h | 10 ++
.../core/utils/nested_projection_utils_test.cpp | 88 +++++++++++++
.../format/lance/lance_file_batch_reader.cpp | 11 +-
src/paimon/format/lance/lance_file_format_test.cpp | 34 ++++-
src/paimon/format/lance/lance_format_writer.cpp | 35 +++++-
.../format/vortex/vortex_file_batch_reader.cpp | 65 +---------
test/inte/scan_and_read_inte_test.cpp | 67 ++++++++++
test/inte/write_and_read_inte_test.cpp | 138 ++++++++++++++++++++-
.../append_ltz_java_compat/README.md | 40 ++++++
...ta-4fcb4a78-f17b-4a9c-9454-ea37ee78e0c5-0.lance | Bin 0 -> 3094 bytes
...ta-e9ac3746-5001-45e0-8060-d0e84efae172-0.lance | Bin 0 -> 2555 bytes
...manifest-1e2d27b3-41db-46d5-9c0e-507c8ffe695a-0 | Bin 0 -> 2173 bytes
...manifest-fe3d8af9-46b4-4157-b164-7a0660316cf5-0 | Bin 0 -> 2172 bytes
...est-list-1a908b65-80a0-47d5-9dcd-81c70689d863-0 | Bin 0 -> 1158 bytes
...est-list-1a908b65-80a0-47d5-9dcd-81c70689d863-1 | Bin 0 -> 1267 bytes
...est-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-0 | Bin 0 -> 1267 bytes
...est-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-1 | Bin 0 -> 1267 bytes
.../append_ltz_java_compat/schema/schema-0 | 56 +++++++++
.../append_ltz_java_compat/snapshot/EARLIEST | 1 +
.../append_ltz_java_compat/snapshot/LATEST | 1 +
.../append_ltz_java_compat/snapshot/snapshot-1 | 18 +++
.../append_ltz_java_compat/snapshot/snapshot-2 | 18 +++
.../append_ltz_python_compat/README.md | 43 +++++++
...ta-3dd49f37-400b-4d87-ab60-dda8278083c8-0.lance | Bin 0 -> 2854 bytes
...ta-cb6cba6a-5d96-42f4-b8b9-2b5b4616c60f-0.lance | Bin 0 -> 2315 bytes
...manifest-2630e922-a399-4613-9181-54cc65803426-0 | Bin 0 -> 2208 bytes
...manifest-db163726-5626-4b91-a607-33f79a420829-0 | Bin 0 -> 2206 bytes
...est-list-92cdba02-3525-463d-981d-381562e9cb9d-0 | Bin 0 -> 897 bytes
...est-list-92cdba02-3525-463d-981d-381562e9cb9d-1 | Bin 0 -> 897 bytes
...est-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-0 | Bin 0 -> 799 bytes
...est-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-1 | Bin 0 -> 897 bytes
.../append_ltz_python_compat/schema/schema-0 | 71 +++++++++++
.../append_ltz_python_compat/snapshot/LATEST | 1 +
.../append_ltz_python_compat/snapshot/snapshot-1 | 15 +++
.../append_ltz_python_compat/snapshot/snapshot-2 | 15 +++
39 files changed, 775 insertions(+), 83 deletions(-)
diff --git a/docs/source/user_guide/data_types.rst
b/docs/source/user_guide/data_types.rst
index 92b1dda1..19de6ac2 100644
--- a/docs/source/user_guide/data_types.rst
+++ b/docs/source/user_guide/data_types.rst
@@ -194,6 +194,11 @@ and `Arrow DataTypes
<https://arrow.apache.org/docs/format/Columnar.html#data-ty
Paimon C++ supports precision values 0 (seconds), 3 (milliseconds), 6
(microseconds), and 9 (nanoseconds).
+ As in Paimon Java, Lance data files store this type as an Arrow
+ timestamp with the ``UTC`` time zone whatever the local time zone is,
+ and reads return it in the local time zone. Only the time zone of the
+ Arrow type changes; the values since the epoch are kept as they are.
+
* - ``ARRAY<t>``
- List
- Data type of an array of elements with same subtype.
diff --git a/src/paimon/core/schema/schema_validation.cpp
b/src/paimon/core/schema/schema_validation.cpp
index 7717b27f..63801ba9 100644
--- a/src/paimon/core/schema/schema_validation.cpp
+++ b/src/paimon/core/schema/schema_validation.cpp
@@ -1002,19 +1002,14 @@ Status SchemaValidation::ValidateLanceDataField(const
std::shared_ptr<arrow::Fie
case arrow::Type::DOUBLE:
case arrow::Type::DATE32:
case arrow::Type::TIME32:
+ // Lance cannot parse a time zone id containing ':', but
LanceFormatWriter, like Paimon
+ // Java, writes every zoned timestamp with the "UTC" time zone.
+ case arrow::Type::TIMESTAMP:
case arrow::Type::STRING:
case arrow::Type::BINARY:
case arrow::Type::DECIMAL128:
case arrow::Type::FIXED_SIZE_LIST:
return Status::OK();
- case arrow::Type::TIMESTAMP: {
- const auto& timestamp_type = checked_cast<const
arrow::TimestampType&>(*type);
- if (!timestamp_type.timezone().empty()) {
- return Status::Invalid(
- "Lance file format does not support type
LOCAL_ZONED_TIMESTAMP");
- }
- return Status::OK();
- }
case arrow::Type::LIST:
return ValidateLanceDataField(type->field(0));
case arrow::Type::STRUCT:
diff --git a/src/paimon/core/schema/schema_validation_test.cpp
b/src/paimon/core/schema/schema_validation_test.cpp
index 4f7b4116..2b9c6957 100644
--- a/src/paimon/core/schema/schema_validation_test.cpp
+++ b/src/paimon/core/schema/schema_validation_test.cpp
@@ -439,6 +439,14 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
arrow::field("date", arrow::date32()),
arrow::field("time", arrow::time32(arrow::TimeUnit::MILLI)),
arrow::field("timestamp", arrow::timestamp(arrow::TimeUnit::MICRO)),
+ arrow::field("ltz_0", arrow::timestamp(arrow::TimeUnit::SECOND,
"UTC")),
+ arrow::field("ltz_3", arrow::timestamp(arrow::TimeUnit::MILLI,
"Asia/Shanghai")),
+ arrow::field("ltz_6", arrow::timestamp(arrow::TimeUnit::MICRO,
"GMT-10:00")),
+ arrow::field("ltz_9", arrow::timestamp(arrow::TimeUnit::NANO,
"+08:00")),
+ arrow::field("ltz_array",
arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO, "UTC"))),
+ arrow::field("nested_ltz",
+ arrow::struct_({arrow::field(
+ "values",
arrow::list(arrow::timestamp(arrow::TimeUnit::MILLI, "UTC")))})),
arrow::field("decimal", arrow::decimal128(38, 2)),
arrow::field("array", arrow::list(arrow::float32())),
arrow::field("nested_time",
@@ -474,11 +482,9 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
arrow::FieldVector unsupported_fields = {
arrow::field("map", arrow::map(arrow::int32(), arrow::utf8())),
- arrow::field("ltz", arrow::timestamp(arrow::TimeUnit::MICRO, "UTC")),
VariantTypeUtils::ToArrowField("variant"),
};
- std::vector<std::string> expected_errors = {"type MAP",
"LOCAL_ZONED_TIMESTAMP",
- "type VARIANT"};
+ std::vector<std::string> expected_errors = {"type MAP", "type VARIANT"};
for (size_t i = 0; i < unsupported_fields.size(); ++i) {
ASSERT_OK_AND_ASSIGN(
table_schema,
@@ -504,6 +510,14 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
{Options::CHANGELOG_FILE_FORMAT, "lance"}}) {
std::map<std::string, std::string> alternate_format_options = {
{Options::BUCKET, "-1"}, {Options::FILE_FORMAT, "parquet"},
{option_key, option_value}};
+ ASSERT_OK_AND_ASSIGN(
+ table_schema,
+ TableSchema::Create(
+ /*schema_id=*/0,
+ arrow::schema({arrow::field(
+ "ltz",
arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO, "GMT-10:00")))}),
+ /*partition_keys=*/{}, /*primary_keys=*/{},
alternate_format_options));
+ ASSERT_OK(SchemaValidation::ValidateTableSchema(*table_schema));
ASSERT_OK_AND_ASSIGN(
table_schema,
TableSchema::Create(
diff --git a/src/paimon/core/utils/nested_projection_utils.cpp
b/src/paimon/core/utils/nested_projection_utils.cpp
index ff6368a7..ff069029 100644
--- a/src/paimon/core/utils/nested_projection_utils.cpp
+++ b/src/paimon/core/utils/nested_projection_utils.cpp
@@ -727,6 +727,92 @@ std::shared_ptr<arrow::DataType>
NormalizeLeafRepresentation(
}
return t;
}
+
+std::shared_ptr<arrow::ArrayData> AlignTimestampTimezonesData(
+ const std::shared_ptr<arrow::ArrayData>& data,
+ const std::shared_ptr<arrow::DataType>& read_type) {
+ const std::shared_ptr<arrow::DataType>& type = data->type;
+ if (type->id() == arrow::Type::TIMESTAMP && read_type->id() ==
arrow::Type::TIMESTAMP) {
+ const auto& stored = checked_cast<const arrow::TimestampType&>(*type);
+ const auto& wanted = checked_cast<const
arrow::TimestampType&>(*read_type);
+ if (stored.unit() != wanted.unit() || stored.timezone().empty() ||
+ wanted.timezone().empty() || stored.timezone() ==
wanted.timezone()) {
+ return data;
+ }
+ std::shared_ptr<arrow::ArrayData> retyped = data->Copy();
+ retyped->type = arrow::timestamp(wanted.unit(), wanted.timezone());
+ return retyped;
+ }
+ if (type->id() == arrow::Type::STRUCT && read_type->id() ==
arrow::Type::STRUCT) {
+ const auto& wanted_struct = checked_cast<const
arrow::StructType&>(*read_type);
+ arrow::FieldVector fields = type->fields();
+ std::shared_ptr<arrow::ArrayData> retyped;
+ for (int32_t i = 0; i < static_cast<int32_t>(data->child_data.size());
++i) {
+ const int32_t index =
wanted_struct.GetFieldIndex(fields[i]->name());
+ if (index < 0) {
+ continue;
+ }
+ std::shared_ptr<arrow::ArrayData> child =
AlignTimestampTimezonesData(
+ data->child_data[i], wanted_struct.field(index)->type());
+ if (child == data->child_data[i]) {
+ continue;
+ }
+ if (retyped == nullptr) {
+ retyped = data->Copy();
+ }
+ retyped->child_data[i] = child;
+ fields[i] = fields[i]->WithType(child->type);
+ }
+ if (retyped == nullptr) {
+ return data;
+ }
+ retyped->type = arrow::struct_(fields);
+ return retyped;
+ }
+ if ((type->id() == arrow::Type::LIST || type->id() ==
arrow::Type::FIXED_SIZE_LIST) &&
+ type->id() == read_type->id() && !data->child_data.empty()) {
+ std::shared_ptr<arrow::ArrayData> values =
+ AlignTimestampTimezonesData(data->child_data[0],
read_type->field(0)->type());
+ if (values == data->child_data[0]) {
+ return data;
+ }
+ std::shared_ptr<arrow::ArrayData> retyped = data->Copy();
+ retyped->child_data[0] = values;
+ const std::shared_ptr<arrow::Field> value_field =
type->field(0)->WithType(values->type);
+ retyped->type = type->id() == arrow::Type::LIST
+ ? arrow::list(value_field)
+ : arrow::fixed_size_list(
+ value_field,
+ checked_cast<const
arrow::FixedSizeListType&>(*type).list_size());
+ return retyped;
+ }
+ if (type->id() == arrow::Type::MAP && read_type->id() == arrow::Type::MAP
&&
+ !data->child_data.empty()) {
+ const auto& stored_map = checked_cast<const arrow::MapType&>(*type);
+ const auto& wanted_map = checked_cast<const
arrow::MapType&>(*read_type);
+ const std::shared_ptr<arrow::ArrayData>& entries = data->child_data[0];
+ std::shared_ptr<arrow::ArrayData> keys =
+ AlignTimestampTimezonesData(entries->child_data[0],
wanted_map.key_type());
+ std::shared_ptr<arrow::ArrayData> items =
+ AlignTimestampTimezonesData(entries->child_data[1],
wanted_map.item_type());
+ if (keys == entries->child_data[0] && items == entries->child_data[1])
{
+ return data;
+ }
+ const std::shared_ptr<arrow::Field> key_field =
+ stored_map.key_field()->WithType(keys->type);
+ const std::shared_ptr<arrow::Field> item_field =
+ stored_map.item_field()->WithType(items->type);
+ std::shared_ptr<arrow::ArrayData> retyped_entries = entries->Copy();
+ retyped_entries->type = arrow::struct_({key_field, item_field});
+ retyped_entries->child_data = {keys, items};
+ std::shared_ptr<arrow::ArrayData> retyped = data->Copy();
+ retyped->type =
+ std::make_shared<arrow::MapType>(key_field, item_field,
stored_map.keys_sorted());
+ retyped->child_data = {retyped_entries};
+ return retyped;
+ }
+ return data;
+}
} // namespace
Result<std::shared_ptr<arrow::Array>>
NestedProjectionUtils::AlignArrayToReadType(
@@ -840,4 +926,13 @@ Result<std::shared_ptr<arrow::Array>>
NestedProjectionUtils::AlignArrayToReadTyp
}
}
+std::shared_ptr<arrow::Array> NestedProjectionUtils::AlignTimestampTimezones(
+ const std::shared_ptr<arrow::Array>& array, const
std::shared_ptr<arrow::DataType>& read_type) {
+ std::shared_ptr<arrow::ArrayData> data =
AlignTimestampTimezonesData(array->data(), read_type);
+ if (data == array->data()) {
+ return array;
+ }
+ return arrow::MakeArray(data);
+}
+
} // namespace paimon
diff --git a/src/paimon/core/utils/nested_projection_utils.h
b/src/paimon/core/utils/nested_projection_utils.h
index 083e878e..b25aef5d 100644
--- a/src/paimon/core/utils/nested_projection_utils.h
+++ b/src/paimon/core/utils/nested_projection_utils.h
@@ -128,6 +128,16 @@ class PAIMON_EXPORT NestedProjectionUtils {
const std::shared_ptr<arrow::Array>& array,
const std::shared_ptr<arrow::DataType>& read_type, arrow::MemoryPool*
pool);
+ /// Relabel a TIMESTAMP in `array` with the zone `read_type` asks for,
only when both sides
+ /// have a zone and the same unit. A zoned value is an instant since the
epoch, so only the
+ /// type changes. STRUCT children are matched by name, and
LIST/FIXED_SIZE_LIST elements and
+ /// MAP keys and values are followed; every other difference, a unit
change included, is left
+ /// as is.
+ /// @return `array` itself when no TIMESTAMP is relabelled, otherwise a
shallow copy.
+ static std::shared_ptr<arrow::Array> AlignTimestampTimezones(
+ const std::shared_ptr<arrow::Array>& array,
+ const std::shared_ptr<arrow::DataType>& read_type);
+
private:
static Result<bool> HasNestedSubfieldProjectionType(
const std::shared_ptr<arrow::DataType>& file_type,
diff --git a/src/paimon/core/utils/nested_projection_utils_test.cpp
b/src/paimon/core/utils/nested_projection_utils_test.cpp
index e052c521..9ca69e91 100644
--- a/src/paimon/core/utils/nested_projection_utils_test.cpp
+++ b/src/paimon/core/utils/nested_projection_utils_test.cpp
@@ -520,6 +520,94 @@ TEST(NestedProjectionUtilsTest,
AlignArrayToReadTypeKeepsNestedLargeBinaryBlob)
ASSERT_TRUE(blob_out->Equals(*blob));
}
+TEST(NestedProjectionUtilsTest,
AlignTimestampTimezonesRelabelsZonedLeavesOfSameUnit) {
+ auto make_type = [](const std::string& zone, arrow::TimeUnit::type
other_unit) {
+ return arrow::struct_({
+ arrow::field("ltz", arrow::timestamp(arrow::TimeUnit::MICRO,
zone)),
+ arrow::field("row", arrow::struct_({arrow::field(
+ "ltz",
arrow::timestamp(arrow::TimeUnit::MILLI, zone))})),
+ arrow::field("list",
arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO, zone))),
+ arrow::field("fixed_list",
+
arrow::fixed_size_list(arrow::timestamp(arrow::TimeUnit::MICRO, zone), 2)),
+ arrow::field("map",
arrow::map(arrow::timestamp(arrow::TimeUnit::MILLI, zone),
+
arrow::timestamp(arrow::TimeUnit::MICRO, zone))),
+ arrow::field("other_unit", arrow::timestamp(other_unit, zone)),
+ arrow::field("ntz", arrow::timestamp(arrow::TimeUnit::MICRO)),
+ });
+ };
+ const std::string json = R"([[1, [2], [3, null], [16, null], [[11, 12],
[13, null]], 4, 5],
+ [null, [null], null, null, null, null, null],
+ [6, [7], [8], [17, 18], [[14, 15]], 9, 10]])";
+ std::shared_ptr<arrow::DataType> data_type = make_type("UTC",
arrow::TimeUnit::MICRO);
+ std::shared_ptr<arrow::Array> array =
+ arrow::ipc::internal::json::ArrayFromJSON(data_type,
json).ValueOrDie();
+ ASSERT_EQ(NestedProjectionUtils::AlignTimestampTimezones(array,
array->type()).get(),
+ array.get());
+
+ std::shared_ptr<arrow::DataType> read_type = make_type("Asia/Shanghai",
arrow::TimeUnit::MILLI);
+ std::shared_ptr<arrow::Array> aligned =
+ NestedProjectionUtils::AlignTimestampTimezones(array, read_type);
+ auto expected_type = arrow::struct_({
+ arrow::field("ltz", arrow::timestamp(arrow::TimeUnit::MICRO,
"Asia/Shanghai")),
+ arrow::field("row",
+ arrow::struct_({arrow::field(
+ "ltz", arrow::timestamp(arrow::TimeUnit::MILLI,
"Asia/Shanghai"))})),
+ arrow::field("list",
+ arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO,
"Asia/Shanghai"))),
+ arrow::field(
+ "fixed_list",
+ arrow::fixed_size_list(arrow::timestamp(arrow::TimeUnit::MICRO,
"Asia/Shanghai"), 2)),
+ arrow::field("map",
arrow::map(arrow::timestamp(arrow::TimeUnit::MILLI, "Asia/Shanghai"),
+
arrow::timestamp(arrow::TimeUnit::MICRO, "Asia/Shanghai"))),
+ arrow::field("other_unit", arrow::timestamp(arrow::TimeUnit::MICRO,
"UTC")),
+ arrow::field("ntz", arrow::timestamp(arrow::TimeUnit::MICRO)),
+ });
+ std::shared_ptr<arrow::Array> expected =
+ arrow::ipc::internal::json::ArrayFromJSON(expected_type,
json).ValueOrDie();
+ ASSERT_TRUE(aligned->type()->Equals(expected_type)) <<
aligned->type()->ToString();
+ ASSERT_TRUE(aligned->Equals(*expected));
+ ASSERT_TRUE(array->type()->Equals(data_type)) << array->type()->ToString();
+
+ std::shared_ptr<arrow::Array> aligned_slice =
+ NestedProjectionUtils::AlignTimestampTimezones(array->Slice(1),
read_type);
+ ASSERT_TRUE(aligned_slice->Equals(*expected->Slice(1)));
+
+ std::shared_ptr<arrow::Array> zoned =
+
arrow::ipc::internal::json::ArrayFromJSON(arrow::timestamp(arrow::TimeUnit::MICRO,
"UTC"),
+ "[1, null]")
+ .ValueOrDie();
+ ASSERT_EQ(NestedProjectionUtils::AlignTimestampTimezones(
+ zoned, arrow::timestamp(arrow::TimeUnit::MICRO))
+ .get(),
+ zoned.get());
+ std::shared_ptr<arrow::Array> unzoned =
+
arrow::ipc::internal::json::ArrayFromJSON(arrow::timestamp(arrow::TimeUnit::MICRO),
+ "[1, null]")
+ .ValueOrDie();
+ ASSERT_EQ(NestedProjectionUtils::AlignTimestampTimezones(
+ unzoned, arrow::timestamp(arrow::TimeUnit::MICRO, "UTC"))
+ .get(),
+ unzoned.get());
+}
+
+TEST(NestedProjectionUtilsTest,
AlignTimestampTimezonesMatchesStructChildrenByName) {
+ auto utc = arrow::timestamp(arrow::TimeUnit::MICRO, "UTC");
+ auto shanghai = arrow::timestamp(arrow::TimeUnit::MICRO, "Asia/Shanghai");
+ const std::string json = "[[1, 2], [null, null]]";
+ std::shared_ptr<arrow::Array> array =
+ arrow::ipc::internal::json::ArrayFromJSON(
+ arrow::struct_({arrow::field("unread", utc), arrow::field("read",
utc)}), json)
+ .ValueOrDie();
+ std::shared_ptr<arrow::Array> aligned =
NestedProjectionUtils::AlignTimestampTimezones(
+ array, arrow::struct_({arrow::field("read", shanghai)}));
+ auto expected_type =
+ arrow::struct_({arrow::field("unread", utc), arrow::field("read",
shanghai)});
+ std::shared_ptr<arrow::Array> expected =
+ arrow::ipc::internal::json::ArrayFromJSON(expected_type,
json).ValueOrDie();
+ ASSERT_TRUE(aligned->type()->Equals(expected_type)) <<
aligned->type()->ToString();
+ ASSERT_TRUE(aligned->Equals(*expected));
+}
+
TEST(NestedProjectionUtilsTest, HasNestedSubfieldProjectionNoProjection) {
auto file_schema = arrow::schema({
MakeField("f0", arrow::int32(), 1),
diff --git a/src/paimon/format/lance/lance_file_batch_reader.cpp
b/src/paimon/format/lance/lance_file_batch_reader.cpp
index b1e0163e..c2606afa 100644
--- a/src/paimon/format/lance/lance_file_batch_reader.cpp
+++ b/src/paimon/format/lance/lance_file_batch_reader.cpp
@@ -128,9 +128,14 @@ Result<BatchReader::ReadBatch>
LanceFileBatchReader::AlignBatch(ReadBatch batch)
if (array->type_id() != arrow::Type::STRUCT) {
return Status::Invalid("Lance reader returned a non-struct Arrow
batch");
}
- PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> aligned,
- NestedProjectionUtils::AlignArrayToReadType(
- array, arrow::struct_(read_schema_->fields()),
arrow_pool_.get()));
+ std::shared_ptr<arrow::DataType> read_type =
arrow::struct_(read_schema_->fields());
+ // Zoned timestamps come back as UTC, as LanceFormatWriter and Paimon Java
store them, while
+ // paimon asks for the local zone; the instants are the same.
+ std::shared_ptr<arrow::Array> relabelled =
+ NestedProjectionUtils::AlignTimestampTimezones(array, read_type);
+ PAIMON_ASSIGN_OR_RAISE(
+ std::shared_ptr<arrow::Array> aligned,
+ NestedProjectionUtils::AlignArrayToReadType(relabelled, read_type,
arrow_pool_.get()));
auto out_array = std::make_unique<::ArrowArray>();
auto out_schema = std::make_unique<::ArrowSchema>();
PAIMON_RETURN_NOT_OK_FROM_ARROW(
diff --git a/src/paimon/format/lance/lance_file_format_test.cpp
b/src/paimon/format/lance/lance_file_format_test.cpp
index 0805d49e..cdee8831 100644
--- a/src/paimon/format/lance/lance_file_format_test.cpp
+++ b/src/paimon/format/lance/lance_file_format_test.cpp
@@ -182,6 +182,19 @@ TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
name, arrow::time32(arrow::TimeUnit::MILLI), /*nullable=*/true,
arrow::key_value_metadata({DataType::kTimePrecision},
{std::to_string(precision)}));
};
+ auto ltz_fields = [](const std::string& zone) {
+ return arrow::FieldVector{
+ arrow::field("ltz_0_col",
arrow::timestamp(arrow::TimeUnit::SECOND, zone)),
+ arrow::field("ltz_3_col", arrow::timestamp(arrow::TimeUnit::MILLI,
zone)),
+ arrow::field("ltz_6_col", arrow::timestamp(arrow::TimeUnit::MICRO,
zone)),
+ arrow::field("ltz_9_col", arrow::timestamp(arrow::TimeUnit::NANO,
zone)),
+ arrow::field("ltz_array_col",
+ arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO,
zone))),
+ arrow::field("ltz_row_col",
+ arrow::struct_({arrow::field(
+ "value", arrow::timestamp(arrow::TimeUnit::MILLI,
zone))})),
+ };
+ };
arrow::FieldVector fields = {
arrow::field("bool_col", arrow::boolean()),
arrow::field("i8_col", arrow::int8()),
@@ -206,17 +219,21 @@ TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
arrow::field("time_row_array_col",
arrow::struct_({arrow::field("values",
arrow::list(time_field("item", 9)))})),
};
+ arrow::FieldVector local_ltz_fields = ltz_fields("GMT-10:00");
+ fields.insert(fields.end(), local_ltz_fields.begin(),
local_ltz_fields.end());
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,0,
"1970-01-01 00:00:00.000001","12.34",[1.0,2.0],[7],[3.0,4.0],
- [0,null,45296000],[45296123],[[3723123,null]]],
+ [0,null,45296000],[45296123],[[3723123,null]],
+ 1700000000,1700000000123,1700000000123456,1700000000123456789,
+ [1700000000123456,null,-1],[1700000000123]],
[false,1,2,3,4,3.5,4.5,"two","xyz",2,86399999,
"2030-12-31 23:59:59.999999","-12.34",[],[8],[5.0,6.0],
- [],[86399999],[[]]],
+ [],[86399999],[[]],-1,-1,-1,-1,[],[-1]],
[null,null,null,null,null,null,null,null,null,null,null,null,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");
@@ -246,6 +263,17 @@ TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
std::shared_ptr<arrow::Array> batch_array =
std::move(batch_result).MoveValueUnsafe();
ASSERT_TRUE(batch_array->type()->Equals(arrow::struct_(fields),
/*check_metadata=*/true))
<< batch_array->type()->ToString(/*show_metadata=*/true);
+
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<::ArrowSchema> c_file_schema,
reader->GetFileSchema());
+ arrow::Result<std::shared_ptr<arrow::Schema>> file_schema =
+ arrow::ImportSchema(c_file_schema.get());
+ ASSERT_TRUE(file_schema.ok()) << file_schema.status().ToString();
+ for (const std::shared_ptr<arrow::Field>& utc_field : ltz_fields("UTC")) {
+ std::shared_ptr<arrow::Field> file_field =
+ file_schema.ValueUnsafe()->GetFieldByName(utc_field->name());
+ ASSERT_NE(file_field, nullptr) << utc_field->name();
+ ASSERT_TRUE(file_field->type()->Equals(utc_field->type())) <<
file_field->ToString();
+ }
}
TEST_F(LanceFileFormatTest, RejectsNullTopLevelRows) {
diff --git a/src/paimon/format/lance/lance_format_writer.cpp
b/src/paimon/format/lance/lance_format_writer.cpp
index 6fb0c163..0e7776c4 100644
--- a/src/paimon/format/lance/lance_format_writer.cpp
+++ b/src/paimon/format/lance/lance_format_writer.cpp
@@ -26,6 +26,7 @@
#include "arrow/array/array_nested.h"
#include "arrow/c/bridge.h"
#include "arrow/c/helpers.h"
+#include "arrow/type.h"
#include "paimon/common/metrics/metrics_impl.h"
#include "paimon/common/utils/arrow/arrow_utils.h"
#include "paimon/common/utils/arrow/status_utils.h"
@@ -64,12 +65,44 @@ Status ValidateRowValues(const
std::shared_ptr<arrow::Array>& array) {
return Status::OK();
}
+// Lance 0.39 stores a zoned timestamp as `timestamp:<unit>:<zone>` and cannot
parse a zone id
+// containing ':' (e.g. GMT-10:00), so store every zone as UTC, as Paimon Java
does. The values
+// are instants since the epoch whatever the zone, so only the type changes.
+std::shared_ptr<arrow::DataType> ToLanceType(const
std::shared_ptr<arrow::DataType>& type) {
+ switch (type->id()) {
+ case arrow::Type::TIMESTAMP: {
+ const auto& timestamp_type = checked_cast<const
arrow::TimestampType&>(*type);
+ if (timestamp_type.timezone().empty()) {
+ return type;
+ }
+ return arrow::timestamp(timestamp_type.unit(), "UTC");
+ }
+ case arrow::Type::LIST:
+ return
arrow::list(type->field(0)->WithType(ToLanceType(type->field(0)->type())));
+ case arrow::Type::STRUCT: {
+ arrow::FieldVector fields;
+ fields.reserve(type->num_fields());
+ for (const std::shared_ptr<arrow::Field>& field : type->fields()) {
+ fields.push_back(field->WithType(ToLanceType(field->type())));
+ }
+ return arrow::struct_(fields);
+ }
+ default:
+ return type;
+ }
+}
+
+std::shared_ptr<arrow::Schema> ToLanceSchema(const
std::shared_ptr<arrow::Schema>& schema) {
+ return
arrow::schema(ToLanceType(arrow::struct_(schema->fields()))->fields(),
+ schema->metadata());
+}
+
} // namespace
LanceFormatWriter::LanceFormatWriter(const std::shared_ptr<arrow::Schema>&
schema,
const std::shared_ptr<arrow::MemoryPool>&
arrow_pool,
PaimonLanceWriter* writer)
- : schema_(schema),
+ : schema_(ToLanceSchema(schema)),
arrow_pool_(arrow_pool),
writer_(writer),
metrics_(std::make_shared<MetricsImpl>()) {}
diff --git a/src/paimon/format/vortex/vortex_file_batch_reader.cpp
b/src/paimon/format/vortex/vortex_file_batch_reader.cpp
index 6fa537b9..e171589e 100644
--- a/src/paimon/format/vortex/vortex_file_batch_reader.cpp
+++ b/src/paimon/format/vortex/vortex_file_batch_reader.cpp
@@ -168,68 +168,6 @@ Result<std::shared_ptr<arrow::Array>>
NormalizeViewArray(const std::shared_ptr<a
}
}
-// Vortex reports the file's zone and paimon asks for the reader's; the
instants are the same.
-std::shared_ptr<arrow::ArrayData> AlignTimestampTimezones(
- const std::shared_ptr<arrow::ArrayData>& data,
- const std::shared_ptr<arrow::DataType>& read_type) {
- const std::shared_ptr<arrow::DataType>& type = data->type;
- if (type->id() == arrow::Type::TIMESTAMP && read_type->id() ==
arrow::Type::TIMESTAMP) {
- const auto& stored = checked_cast<const arrow::TimestampType&>(*type);
- const auto& wanted = checked_cast<const
arrow::TimestampType&>(*read_type);
- if (stored.unit() != wanted.unit() || stored.timezone().empty() ||
- wanted.timezone().empty() || stored.timezone() ==
wanted.timezone()) {
- return data;
- }
- std::shared_ptr<arrow::ArrayData> retyped = data->Copy();
- retyped->type = arrow::timestamp(wanted.unit(), wanted.timezone());
- return retyped;
- }
- if (type->id() == arrow::Type::STRUCT && read_type->id() ==
arrow::Type::STRUCT) {
- const auto& wanted_struct = checked_cast<const
arrow::StructType&>(*read_type);
- arrow::FieldVector fields = type->fields();
- std::shared_ptr<arrow::ArrayData> retyped;
- for (int32_t i = 0; i < static_cast<int32_t>(data->child_data.size());
++i) {
- const int32_t index =
wanted_struct.GetFieldIndex(fields[i]->name());
- if (index < 0) {
- continue;
- }
- std::shared_ptr<arrow::ArrayData> child =
- AlignTimestampTimezones(data->child_data[i],
wanted_struct.field(index)->type());
- if (child == data->child_data[i]) {
- continue;
- }
- if (retyped == nullptr) {
- retyped = data->Copy();
- }
- retyped->child_data[i] = child;
- fields[i] = fields[i]->WithType(child->type);
- }
- if (retyped == nullptr) {
- return data;
- }
- retyped->type = arrow::struct_(fields);
- return retyped;
- }
- if ((type->id() == arrow::Type::LIST || type->id() ==
arrow::Type::FIXED_SIZE_LIST) &&
- type->id() == read_type->id() && !data->child_data.empty()) {
- std::shared_ptr<arrow::ArrayData> values =
- AlignTimestampTimezones(data->child_data[0],
read_type->field(0)->type());
- if (values == data->child_data[0]) {
- return data;
- }
- std::shared_ptr<arrow::ArrayData> retyped = data->Copy();
- retyped->child_data[0] = values;
- const std::shared_ptr<arrow::Field> value_field =
type->field(0)->WithType(values->type);
- retyped->type = type->id() == arrow::Type::LIST
- ? arrow::list(value_field)
- : arrow::fixed_size_list(
- value_field,
- checked_cast<const
arrow::FixedSizeListType&>(*type).list_size());
- return retyped;
- }
- return data;
-}
-
} // namespace
VortexFileBatchReader::VortexFileBatchReader(
@@ -395,8 +333,9 @@ Result<std::shared_ptr<arrow::Array>>
VortexFileBatchReader::AlignToReadSchema(
}
// TODO(zhouyc-ali): push top-level column pruning into the Vortex scan
std::shared_ptr<arrow::DataType> read_type =
arrow::struct_(read_schema_->fields());
+ // Vortex reports the file's zone and paimon asks for the reader's; the
instants are the same.
return NestedProjectionUtils::AlignArrayToReadType(
- arrow::MakeArray(AlignTimestampTimezones(array->data(), read_type)),
read_type,
+ NestedProjectionUtils::AlignTimestampTimezones(array, read_type),
read_type,
arrow_pool_.get());
}
diff --git a/test/inte/scan_and_read_inte_test.cpp
b/test/inte/scan_and_read_inte_test.cpp
index 3bfb58c7..17807e25 100644
--- a/test/inte/scan_and_read_inte_test.cpp
+++ b/test/inte/scan_and_read_inte_test.cpp
@@ -3637,6 +3637,73 @@ TEST_F(ScanAndReadInteTest,
TestLancePythonCompatibility) {
check(1, {"f_struct", "id", "f_vector", "f_array_int"},
projected->Slice(0, 2));
check(2, {"f_struct", "id", "f_vector", "f_array_int"}, projected);
}
+
+TEST_F(ScanAndReadInteTest, TestLanceLtzCompatibility) {
+ TimezoneGuard timezone_guard("Asia/Shanghai");
+ const std::string zone = DateTimeUtils::GetLocalTimezoneName();
+ arrow::FieldVector fields = {
+ arrow::field("_VALUE_KIND", arrow::int8()),
+ arrow::field("id", arrow::int32()),
+ arrow::field("f_ltz_0", arrow::timestamp(arrow::TimeUnit::SECOND,
zone)),
+ arrow::field("f_ltz_3", arrow::timestamp(arrow::TimeUnit::MILLI,
zone)),
+ arrow::field("f_ltz_6", arrow::timestamp(arrow::TimeUnit::MICRO,
zone)),
+ arrow::field("f_ltz_9", arrow::timestamp(arrow::TimeUnit::NANO, zone)),
+ arrow::field("f_ltz_array",
arrow::list(arrow::timestamp(arrow::TimeUnit::MICRO, zone))),
+ arrow::field(
+ "f_ltz_struct",
+ arrow::struct_({arrow::field("number", arrow::int32()),
+ arrow::field("ts",
arrow::timestamp(arrow::TimeUnit::MILLI, zone))})),
+ };
+ auto expected = std::make_shared<arrow::ChunkedArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+[0, 1, "1970-01-01 00:00:01", "1970-01-01 00:00:01.123", "1970-01-01
00:00:01.123456", "1970-01-01 00:00:01.123456789", ["1970-01-01
00:00:01.123456", null, "1969-12-31 23:59:59.999999"], [1, "1970-01-01
00:00:01.123"]],
+[0, 2, null, null, null, null, null, [null, null]],
+[0, 10, "1969-12-31 00:00:00", "1969-12-31 23:59:59.999", "1969-12-31
23:59:59.999999", "1969-12-31 23:59:59.999999999", [], [10, "1969-12-31
23:59:59.999"]],
+[0, 11, "2024-10-04 12:34:56", "2024-10-04 12:34:56.123", "2024-10-04
12:34:56.123456", "2024-10-04 12:34:56.123456789", ["2024-10-04
12:34:56.123456"], [11, "2024-10-04 12:34:56.123"]]
+])")
+ .ValueOrDie());
+ auto projected = std::make_shared<arrow::ChunkedArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(
+ arrow::struct_({fields[0], fields[7], fields[1], fields[5],
fields[6]}), R"([
+[0, [1, "1970-01-01 00:00:01.123"], 1, "1970-01-01 00:00:01.123456789",
["1970-01-01 00:00:01.123456", null, "1969-12-31 23:59:59.999999"]],
+[0, [null, null], 2, null, null],
+[0, [10, "1969-12-31 23:59:59.999"], 10, "1969-12-31 23:59:59.999999999", []],
+[0, [11, "2024-10-04 12:34:56.123"], 11, "2024-10-04 12:34:56.123456789",
["2024-10-04 12:34:56.123456"]]
+])")
+ .ValueOrDie());
+ auto check = [&](const std::string& table_path, int64_t snapshot_id,
+ const std::vector<std::string>& projection,
+ const std::shared_ptr<arrow::ChunkedArray>&
expected_result) {
+ SCOPED_TRACE("snapshot " + std::to_string(snapshot_id));
+ ScanContextBuilder scan_builder(table_path);
+ scan_builder.AddOption(Options::SCAN_SNAPSHOT_ID,
std::to_string(snapshot_id));
+ ASSERT_OK_AND_ASSIGN(auto scan_context, scan_builder.Finish());
+ ASSERT_OK_AND_ASSIGN(auto scan,
TableScan::Create(std::move(scan_context)));
+ ASSERT_OK_AND_ASSIGN(auto plan, scan->CreatePlan());
+ ASSERT_EQ(plan->SnapshotId(), std::optional<int64_t>(snapshot_id));
+ ASSERT_FALSE(plan->Splits().empty());
+
+ ReadContextBuilder read_builder(table_path);
+ read_builder.AddOption("read.batch-size", "1");
+ if (!projection.empty()) {
+ read_builder.SetReadFieldNames(projection);
+ }
+ ASSERT_OK_AND_ASSIGN(auto read_context, read_builder.Finish());
+ ASSERT_OK_AND_ASSIGN(auto read,
TableRead::Create(std::move(read_context)));
+ ASSERT_OK_AND_ASSIGN(auto reader, read->CreateReader(plan->Splits()));
+ ASSERT_OK_AND_ASSIGN(auto actual,
ReadResultCollector::CollectResult(std::move(reader)));
+ ASSERT_TRUE(actual);
+ ASSERT_TRUE(expected_result->Equals(actual))
+ << "actual: " << actual->ToString() << "\nexpected: " <<
expected_result->ToString();
+ };
+ for (const std::string table_name : {"append_ltz_java_compat",
"append_ltz_python_compat"}) {
+ SCOPED_TRACE(table_name);
+ std::string table_path = GetDataDir() + "/lance/" + table_name +
".db/" + table_name;
+ check(table_path, 1, /*projection=*/{}, expected->Slice(0, 2));
+ check(table_path, 2, /*projection=*/{}, expected);
+ check(table_path, 2, {"f_ltz_struct", "id", "f_ltz_9", "f_ltz_array"},
projected);
+ }
+}
#endif
#ifdef PAIMON_ENABLE_MOSAIC
diff --git a/test/inte/write_and_read_inte_test.cpp
b/test/inte/write_and_read_inte_test.cpp
index 8b36d3c7..dbbdf63f 100644
--- a/test/inte/write_and_read_inte_test.cpp
+++ b/test/inte/write_and_read_inte_test.cpp
@@ -22,6 +22,7 @@
#include <memory>
#include <optional>
#include <string>
+#include <tuple>
#include <utility>
#include <vector>
@@ -2589,7 +2590,7 @@ TEST_P(WriteAndReadInteTest, TestAppendTimestampType) {
};
auto schema = arrow::schema(fields);
auto [file_format, file_system] = GetParam();
- if (file_format == "mosaic" || file_format == "lance") {
+ if (file_format == "mosaic") {
return;
}
std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
file_format},
@@ -2646,7 +2647,7 @@ TEST_P(WriteAndReadInteTest, TestPkTimestampType) {
};
auto schema = arrow::schema(fields);
auto [file_format, file_system] = GetParam();
- if (file_format == "mosaic" || file_format == "lance") {
+ if (file_format == "mosaic") {
return;
}
std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
file_format},
@@ -6027,6 +6028,139 @@ TEST(TimeColumnWriteInteTest,
TestAppendTimeColumnFinalize) {
}
#endif
+#ifdef PAIMON_ENABLE_LANCE
+class LanceTimestampLtzInteTest
+ : public ::testing::Test,
+ public ::testing::WithParamInterface<std::tuple<std::string, bool>> {};
+
+TEST_P(LanceTimestampLtzInteTest, TestWriteAndReadInPinnedTimezone) {
+ TimezoneGuard timezone_guard(std::get<0>(GetParam()));
+ const std::string timezone = DateTimeUtils::GetLocalTimezoneName();
+ const bool with_primary_key = std::get<1>(GetParam());
+ auto dir = UniqueTestDirectory::Create("local");
+ ASSERT_NE(dir, nullptr);
+
+ auto ltz = [&timezone](arrow::TimeUnit::type unit) { return
arrow::timestamp(unit, timezone); };
+ arrow::FieldVector fields = {
+ arrow::field("id", arrow::int32()),
+ arrow::field("ltz_0", ltz(arrow::TimeUnit::SECOND)),
+ arrow::field("ltz_9", ltz(arrow::TimeUnit::NANO)),
+ arrow::field("ltz_array", arrow::list(ltz(arrow::TimeUnit::MICRO))),
+ arrow::field(
+ "ltz_row",
+ arrow::struct_({arrow::field("ltz_3", ltz(arrow::TimeUnit::MILLI)),
+ arrow::field("ntz",
arrow::timestamp(arrow::TimeUnit::MICRO))})),
+ arrow::field("ntz_to_ltz", arrow::timestamp(arrow::TimeUnit::MICRO)),
+ arrow::field("ltz_to_ntz", ltz(arrow::TimeUnit::MICRO)),
+ };
+ std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
"lance"},
+ {Options::BUCKET,
with_primary_key ? "1" : "-1"},
+ {Options::FILE_SYSTEM,
"local"}};
+ std::vector<std::string> primary_keys;
+ if (with_primary_key) {
+ primary_keys.push_back("id");
+ }
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<TestHelper> helper,
+ TestHelper::Create(dir->Str(), arrow::schema(fields),
+ /*partition_keys=*/{},
primary_keys, options,
+ /*is_streaming_mode=*/false));
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch_v0,
+ TestHelper::MakeRecordBatch(arrow::struct_(fields),
R"([
+ [1, "1969-12-31 23:59:59", "1969-12-31 23:59:59.999999999",
+ ["2024-10-04 12:34:56.123456", null],
+ ["1970-01-01 00:00:00.001", "1970-01-01 00:00:00.000001"],
+ "1969-12-31 23:59:59.999999", "1970-01-01 00:00:00.000001"],
+ [2, null, null, null, [null, null], null, null],
+ [3, "2024-10-04 12:34:56", "2024-10-04 12:34:56.123456789", [],
+ ["2024-10-04 12:34:56.123", null], "2024-10-04 12:34:56.123456",
+ "2024-10-04 12:34:56.123456"]
+ ])",
+ /*partition_map=*/{},
/*bucket=*/0, {}));
+ ASSERT_OK(helper->WriteAndCommit(std::move(batch_v0),
/*commit_identifier=*/0,
+
/*expected_commit_messages=*/std::nullopt));
+
+ const std::string table_path = PathUtil::JoinPath(dir->Str(),
"foo.db/bar");
+ SchemaManager schema_manager(dir->GetFileSystem(), table_path);
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> schema_v0,
schema_manager.ReadSchema(0));
+ const int32_t added_field_id = schema_v0->HighestFieldId() + 1;
+ std::shared_ptr<arrow::Field> added_field =
+ arrow::field("ltz_added", ltz(arrow::TimeUnit::MICRO));
+ arrow::FieldVector fields_v1 = fields;
+ fields_v1[5] = fields[5]->WithType(ltz(arrow::TimeUnit::MICRO));
+ fields_v1[6] =
fields[6]->WithType(arrow::timestamp(arrow::TimeUnit::MICRO));
+ fields_v1.push_back(added_field);
+ std::vector<DataField> data_fields_v1 = schema_v0->Fields();
+ data_fields_v1[5] = DataField(data_fields_v1[5].Id(), fields_v1[5]);
+ data_fields_v1[6] = DataField(data_fields_v1[6].Id(), fields_v1[6]);
+ data_fields_v1.emplace_back(added_field_id, added_field);
+ ASSERT_OK(TestHelper::WriteNextSchema(dir->GetFileSystem(), table_path,
data_fields_v1,
+ added_field_id, options));
+ helper.reset();
+ ASSERT_OK_AND_ASSIGN(helper,
+ TestHelper::Create(table_path, options,
/*is_streaming_mode=*/false));
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch_v1,
+
TestHelper::MakeRecordBatch(arrow::struct_(fields_v1), R"([
+ [4, "1970-01-01 00:00:00", null, [null], [null, "1969-12-31
23:59:59.999999"],
+ "1969-12-31 23:59:59.999999", "1970-01-01 00:00:00.000001",
+ "2024-10-04 12:34:56.123456"]
+ ])",
+ /*partition_map=*/{},
/*bucket=*/0, {}));
+ ASSERT_OK(helper->WriteAndCommit(std::move(batch_v1),
/*commit_identifier=*/1,
+
/*expected_commit_messages=*/std::nullopt));
+
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
+ helper->NewScan(StartupMode::LatestFull(),
/*snapshot_id=*/std::nullopt));
+ ReadContextBuilder read_context_builder(table_path);
+ read_context_builder.SetOptions(options).SetReadFieldNames(
+ {"ltz_row", "ltz_added", "ltz_9", "id", "ltz_array", "ltz_0",
"ltz_to_ntz", "ntz_to_ltz"});
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> read_context,
read_context_builder.Finish());
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableRead> table_read,
+ TableRead::Create(std::move(read_context)));
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<BatchReader> batch_reader,
+ table_read->CreateReader(splits));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> actual,
+
ReadResultCollector::CollectResult(std::move(batch_reader)));
+ const std::map<std::string, std::vector<std::string>> converted_values = {
+ {"UTC",
+ {"1970-01-01 00:00:00.000001", "1969-12-31 23:59:59.999999",
"2024-10-04 12:34:56.123456",
+ "2024-10-04 12:34:56.123456"}},
+ {"Asia/Shanghai",
+ {"1970-01-01 08:00:00.000001", "1969-12-31 15:59:59.999999",
"2024-10-04 20:34:56.123456",
+ "2024-10-04 04:34:56.123456"}},
+ {"Pacific/Honolulu",
+ {"1969-12-31 14:00:00.000001", "1970-01-01 09:59:59.999999",
"2024-10-04 02:34:56.123456",
+ "2024-10-04 22:34:56.123456"}},
+ };
+ const std::vector<std::string>& converted =
converted_values.at(std::get<0>(GetParam()));
+ auto expected_type = arrow::struct_({arrow::field("_VALUE_KIND",
arrow::int8()), fields_v1[4],
+ fields_v1[7], fields_v1[2],
fields_v1[0], fields_v1[3],
+ fields_v1[1], fields_v1[6],
fields_v1[5]});
+ auto expected = std::make_shared<arrow::ChunkedArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(
+ expected_type, fmt::format(R"([
+ [0, ["1970-01-01 00:00:00.001", "1970-01-01 00:00:00.000001"],
null,
+ "1969-12-31 23:59:59.999999999", 1, ["2024-10-04
12:34:56.123456", null],
+ "1969-12-31 23:59:59", "{}", "{}"],
+ [0, [null, null], null, null, 2, null, null, null, null],
+ [0, ["2024-10-04 12:34:56.123", null], null, "2024-10-04
12:34:56.123456789", 3, [],
+ "2024-10-04 12:34:56", "{}", "{}"],
+ [0, [null, "1969-12-31 23:59:59.999999"], "2024-10-04
12:34:56.123456", null, 4,
+ [null], "1970-01-01 00:00:00", "1970-01-01 00:00:00.000001",
+ "1969-12-31 23:59:59.999999"]
+ ])",
+ converted[0], converted[1],
converted[2], converted[3]))
+ .ValueOrDie());
+ ASSERT_TRUE(expected->Equals(actual))
+ << "actual: " << actual->ToString() << "\nexpected: " <<
expected->ToString();
+}
+
+INSTANTIATE_TEST_SUITE_P(TimezoneAndPrimaryKey, LanceTimestampLtzInteTest,
+
::testing::Combine(::testing::Values(std::string("UTC"),
+
std::string("Asia/Shanghai"),
+
std::string("Pacific/Honolulu")),
+ ::testing::Bool()));
+#endif
+
INSTANTIATE_TEST_SUITE_P(FileFormatAndFileSystem, WriteAndReadInteTest,
::testing::ValuesIn(GetTestValuesForWriteAndReadInteTest()));
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/README.md
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/README.md
new file mode 100644
index 00000000..2e808e92
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/README.md
@@ -0,0 +1,40 @@
+Table: append_ltz_java_compat
+Writer: Paimon Java 2.2-SNAPSHOT at commit
535a5c944f854cb8b68196d44677e576cf22120b
+Lance version: lance-core 0.39.0
+Arrow Java version: 15.0.0
+JVM default time zone: GMT-10:00
+
+Schema:
+id INT
+f_ltz_0 TIMESTAMP(0) WITH LOCAL TIME ZONE
+f_ltz_3 TIMESTAMP(3) WITH LOCAL TIME ZONE
+f_ltz_6 TIMESTAMP(6) WITH LOCAL TIME ZONE
+f_ltz_9 TIMESTAMP(9) WITH LOCAL TIME ZONE
+f_ltz_array ARRAY<TIMESTAMP(6) WITH LOCAL TIME ZONE>
+f_ltz_struct ROW<number INT, ts TIMESTAMP(3) WITH LOCAL TIME ZONE>
+
+Options:
+bucket = -1
+file.format = lance
+metadata.stats-mode = none
+
+This unpartitioned append-only table has no primary key. The Java catalog and
+BatchTableWrite/BatchTableCommit APIs wrote two rows per commit into two Lance
data files,
+with schema, Avro manifests and two snapshots. All four rows were read back
and verified in Java.
+Values below follow schema order; timestamps are UTC.
+
+Add: (1, 1970-01-01 00:00:01, 1970-01-01 00:00:01.123, 1970-01-01
00:00:01.123456, 1970-01-01 00:00:01.123456789, [1970-01-01 00:00:01.123456,
NULL, 1969-12-31 23:59:59.999999], (1, 1970-01-01 00:00:01.123))
+Add: (2, NULL, NULL, NULL, NULL, NULL, (NULL, NULL))
+
+Commit - snapshot 1 (2 rows)
+
+Add: (10, 1969-12-31 00:00:00, 1969-12-31 23:59:59.999, 1969-12-31
23:59:59.999999, 1969-12-31 23:59:59.999999999, [], (10, 1969-12-31
23:59:59.999))
+Add: (11, 2024-10-04 12:34:56, 2024-10-04 12:34:56.123, 2024-10-04
12:34:56.123456, 2024-10-04 12:34:56.123456789, [2024-10-04 12:34:56.123456],
(11, 2024-10-04 12:34:56.123))
+
+Commit - snapshot 2 (4 rows total)
+
+The writer ran with a default time zone containing ':', which Lance 0.39
cannot parse as a
+time zone id. Java writes every local-zoned timestamp, including the ARRAY
element and the
+ROW child, to Lance as a timestamp with the "UTC" time zone, leaving the
instants unchanged.
+ROW parents are always valid, including id=2 where both children are NULL,
because Lance 0.39
+does not preserve null ROW parents.
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-4fcb4a78-f17b-4a9c-9454-ea37ee78e0c5-0.lance
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-4fcb4a78-f17b-4a9c-9454-ea37ee78e0c5-0.lance
new file mode 100644
index 00000000..d4b8fd8b
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-4fcb4a78-f17b-4a9c-9454-ea37ee78e0c5-0.lance
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-e9ac3746-5001-45e0-8060-d0e84efae172-0.lance
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-e9ac3746-5001-45e0-8060-d0e84efae172-0.lance
new file mode 100644
index 00000000..b6bdcade
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/bucket-0/data-e9ac3746-5001-45e0-8060-d0e84efae172-0.lance
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-1e2d27b3-41db-46d5-9c0e-507c8ffe695a-0
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-1e2d27b3-41db-46d5-9c0e-507c8ffe695a-0
new file mode 100644
index 00000000..1c8b8ffc
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-1e2d27b3-41db-46d5-9c0e-507c8ffe695a-0
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-fe3d8af9-46b4-4157-b164-7a0660316cf5-0
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-fe3d8af9-46b4-4157-b164-7a0660316cf5-0
new file mode 100644
index 00000000..9fb3d4ed
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-fe3d8af9-46b4-4157-b164-7a0660316cf5-0
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-0
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-0
new file mode 100644
index 00000000..c14fca8c
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-0
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-1
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-1
new file mode 100644
index 00000000..a86721c2
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-1
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-0
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-0
new file mode 100644
index 00000000..555dd609
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-0
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-1
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-1
new file mode 100644
index 00000000..4fbf2b48
Binary files /dev/null and
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/manifest/manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-1
differ
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/schema/schema-0
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/schema/schema-0
new file mode 100644
index 00000000..af124f64
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/schema/schema-0
@@ -0,0 +1,56 @@
+{
+ "version" : 3,
+ "id" : 0,
+ "fields" : [ {
+ "id" : 0,
+ "name" : "id",
+ "type" : "INT"
+ }, {
+ "id" : 1,
+ "name" : "f_ltz_0",
+ "type" : "TIMESTAMP(0) WITH LOCAL TIME ZONE"
+ }, {
+ "id" : 2,
+ "name" : "f_ltz_3",
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ }, {
+ "id" : 3,
+ "name" : "f_ltz_6",
+ "type" : "TIMESTAMP(6) WITH LOCAL TIME ZONE"
+ }, {
+ "id" : 4,
+ "name" : "f_ltz_9",
+ "type" : "TIMESTAMP(9) WITH LOCAL TIME ZONE"
+ }, {
+ "id" : 5,
+ "name" : "f_ltz_array",
+ "type" : {
+ "type" : "ARRAY",
+ "element" : "TIMESTAMP(6) WITH LOCAL TIME ZONE"
+ }
+ }, {
+ "id" : 6,
+ "name" : "f_ltz_struct",
+ "type" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "id" : 7,
+ "name" : "number",
+ "type" : "INT"
+ }, {
+ "id" : 8,
+ "name" : "ts",
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ } ]
+ }
+ } ],
+ "highestFieldId" : 8,
+ "partitionKeys" : [ ],
+ "primaryKeys" : [ ],
+ "options" : {
+ "bucket" : "-1",
+ "file.format" : "lance",
+ "metadata.stats-mode" : "none"
+ },
+ "timeMillis" : 1793201358194
+}
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/EARLIEST
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/EARLIEST
new file mode 100644
index 00000000..56a6051c
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/EARLIEST
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/LATEST
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/LATEST
new file mode 100644
index 00000000..d8263ee9
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/LATEST
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-1
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-1
new file mode 100644
index 00000000..e93e1817
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-1
@@ -0,0 +1,18 @@
+{
+ "version" : 3,
+ "uuid" : "cd56a571-077d-420f-ac22-695717c200de",
+ "id" : 1,
+ "schemaId" : 0,
+ "baseManifestList" : "manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-0",
+ "baseManifestListSize" : 1158,
+ "deltaManifestList" : "manifest-list-1a908b65-80a0-47d5-9dcd-81c70689d863-1",
+ "deltaManifestListSize" : 1267,
+ "commitUser" : "41aabf22-c463-441a-9c3a-bfc9f6852590",
+ "writerVersion" :
"java-2.2-SNAPSHOT-535a5c944f854cb8b68196d44677e576cf22120b",
+ "commitIdentifier" : 9223372036854775807,
+ "commitKind" : "APPEND",
+ "timeMillis" : 1793201360419,
+ "totalRecordCount" : 2,
+ "deltaRecordCount" : 2,
+ "nextRowId" : 0
+}
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-2
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-2
new file mode 100644
index 00000000..ecfa750e
--- /dev/null
+++
b/test/test_data/lance/append_ltz_java_compat.db/append_ltz_java_compat/snapshot/snapshot-2
@@ -0,0 +1,18 @@
+{
+ "version" : 3,
+ "uuid" : "10e95ecd-534f-4016-9644-e37ce4fb51ec",
+ "id" : 2,
+ "schemaId" : 0,
+ "baseManifestList" : "manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-0",
+ "baseManifestListSize" : 1267,
+ "deltaManifestList" : "manifest-list-366b231f-4abf-408d-bd62-e7de9c6c1f0f-1",
+ "deltaManifestListSize" : 1267,
+ "commitUser" : "44116caa-dcc7-4397-b617-fa43046b55b8",
+ "writerVersion" :
"java-2.2-SNAPSHOT-535a5c944f854cb8b68196d44677e576cf22120b",
+ "commitIdentifier" : 9223372036854775807,
+ "commitKind" : "APPEND",
+ "timeMillis" : 1793201360592,
+ "totalRecordCount" : 4,
+ "deltaRecordCount" : 2,
+ "nextRowId" : 0
+}
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/README.md
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/README.md
new file mode 100644
index 00000000..f48c8511
--- /dev/null
+++
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/README.md
@@ -0,0 +1,43 @@
+Table: append_ltz_python_compat
+Writer: Apache Paimon Python at commit 63ffd403648e1cf9848e32bb29a398c1b3b16ab1
+Source:
https://github.com/apache/paimon/tree/63ffd403648e1cf9848e32bb29a398c1b3b16ab1/paimon-python
+Python version: 3.11.15
+Lance version: pylance 0.39.0
+Arrow Python version: 19.0.1
+
+This unpartitioned append-only table has no primary key. The Python Catalog
and batch
+table-write/commit APIs wrote two rows per commit into two Lance files, with
Avro manifests
+and two snapshots. All values were read back in Python after each commit;
reordered
+projection was also verified after snapshot 2.
+
+Schema (all fields nullable):
+id INT
+f_ltz_0 TIMESTAMP_LTZ(0)
+f_ltz_3 TIMESTAMP_LTZ(3)
+f_ltz_6 TIMESTAMP_LTZ(6)
+f_ltz_9 TIMESTAMP_LTZ(9)
+f_ltz_array ARRAY<TIMESTAMP_LTZ(6)>
+f_ltz_struct ROW<number INT, ts TIMESTAMP_LTZ(3)>
+
+Options:
+bucket = -1
+file.format = lance
+manifest.format = avro
+metadata.stats-mode = none
+
+Values below follow schema order; timestamps are UTC. They match
append_ltz_java_compat.
+
+Add: (1, 1970-01-01 00:00:01, 1970-01-01 00:00:01.123, 1970-01-01
00:00:01.123456, 1970-01-01 00:00:01.123456789, [1970-01-01 00:00:01.123456,
NULL, 1969-12-31 23:59:59.999999], (1, 1970-01-01 00:00:01.123))
+Add: (2, NULL, NULL, NULL, NULL, NULL, (NULL, NULL))
+
+Commit - snapshot 1 (2 rows)
+
+Add: (10, 1969-12-31 00:00:00, 1969-12-31 23:59:59.999, 1969-12-31
23:59:59.999999, 1969-12-31 23:59:59.999999999, [], (10, 1969-12-31
23:59:59.999))
+Add: (11, 2024-10-04 12:34:56, 2024-10-04 12:34:56.123, 2024-10-04
12:34:56.123456, 2024-10-04 12:34:56.123456789, [2024-10-04 12:34:56.123456],
(11, 2024-10-04 12:34:56.123))
+
+Commit - snapshot 2 (4 rows total)
+
+Paimon Python maps a pyarrow timestamp with a time zone to TIMESTAMP_LTZ and
writes every
+TIMESTAMP_LTZ, including the ARRAY element and the ROW child, to Lance as a
timestamp with
+the "UTC" time zone.
+ROW parents are always valid: this fixture does not claim null-parent ROW
compatibility.
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-3dd49f37-400b-4d87-ab60-dda8278083c8-0.lance
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-3dd49f37-400b-4d87-ab60-dda8278083c8-0.lance
new file mode 100644
index 00000000..a88ebd17
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-3dd49f37-400b-4d87-ab60-dda8278083c8-0.lance
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-cb6cba6a-5d96-42f4-b8b9-2b5b4616c60f-0.lance
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-cb6cba6a-5d96-42f4-b8b9-2b5b4616c60f-0.lance
new file mode 100644
index 00000000..0fbf94bb
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/bucket-0/data-cb6cba6a-5d96-42f4-b8b9-2b5b4616c60f-0.lance
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-2630e922-a399-4613-9181-54cc65803426-0
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-2630e922-a399-4613-9181-54cc65803426-0
new file mode 100644
index 00000000..583437a5
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-2630e922-a399-4613-9181-54cc65803426-0
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-db163726-5626-4b91-a607-33f79a420829-0
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-db163726-5626-4b91-a607-33f79a420829-0
new file mode 100644
index 00000000..54f23577
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-db163726-5626-4b91-a607-33f79a420829-0
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-0
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-0
new file mode 100644
index 00000000..03a8e842
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-0
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-1
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-1
new file mode 100644
index 00000000..78e113ff
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-1
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-0
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-0
new file mode 100644
index 00000000..d7aaffb2
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-0
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-1
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-1
new file mode 100644
index 00000000..0f034d16
Binary files /dev/null and
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/manifest/manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-1
differ
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/schema/schema-0
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/schema/schema-0
new file mode 100644
index 00000000..ddf0dc4f
--- /dev/null
+++
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/schema/schema-0
@@ -0,0 +1,71 @@
+{
+ "version": 3,
+ "id": 0,
+ "fields": [
+ {
+ "id": 0,
+ "name": "id",
+ "type": "INT"
+ },
+ {
+ "id": 1,
+ "name": "f_ltz_0",
+ "type": "TIMESTAMP_LTZ(0)"
+ },
+ {
+ "id": 2,
+ "name": "f_ltz_3",
+ "type": "TIMESTAMP_LTZ(3)"
+ },
+ {
+ "id": 3,
+ "name": "f_ltz_6",
+ "type": "TIMESTAMP_LTZ(6)"
+ },
+ {
+ "id": 4,
+ "name": "f_ltz_9",
+ "type": "TIMESTAMP_LTZ(9)"
+ },
+ {
+ "id": 5,
+ "name": "f_ltz_array",
+ "type": {
+ "type": "ARRAY",
+ "element": "TIMESTAMP_LTZ(6)",
+ "nullable": true
+ }
+ },
+ {
+ "id": 6,
+ "name": "f_ltz_struct",
+ "type": {
+ "type": "ROW",
+ "fields": [
+ {
+ "id": 7,
+ "name": "number",
+ "type": "INT"
+ },
+ {
+ "id": 8,
+ "name": "ts",
+ "type": "TIMESTAMP_LTZ(3)"
+ }
+ ],
+ "nullable": true
+ }
+ }
+ ],
+ "highestFieldId": 8,
+ "partitionKeys": [],
+ "primaryKeys": [],
+ "options": {
+ "bucket": "-1",
+ "file.format": "lance",
+ "manifest.format": "avro",
+ "metadata.stats-mode": "none"
+ },
+ "comment": null,
+ "timeMillis": 1793201390242
+}
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/LATEST
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/LATEST
new file mode 100644
index 00000000..d8263ee9
--- /dev/null
+++
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/LATEST
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-1
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-1
new file mode 100644
index 00000000..337aa98c
--- /dev/null
+++
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-1
@@ -0,0 +1,15 @@
+{
+ "version": 3,
+ "id": 1,
+ "schemaId": 0,
+ "baseManifestList": "manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-0",
+ "deltaManifestList": "manifest-list-df1a6587-9d9b-43e9-a73a-18245a5217b2-1",
+ "totalRecordCount": 2,
+ "deltaRecordCount": 2,
+ "commitUser": "bf1553ec-84d3-4bba-a63f-af57a0821aac",
+ "commitIdentifier": 9223372036854775807,
+ "commitKind": "APPEND",
+ "timeMillis": 1793201390271,
+ "uuid": "146996fb-0a39-443e-8c5b-8716ef072816",
+ "writerVersion": "python-2.1.dev-63ffd403648e1cf9848e32bb29a398c1b3b16ab1"
+}
\ No newline at end of file
diff --git
a/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-2
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-2
new file mode 100644
index 00000000..450f7d73
--- /dev/null
+++
b/test/test_data/lance/append_ltz_python_compat.db/append_ltz_python_compat/snapshot/snapshot-2
@@ -0,0 +1,15 @@
+{
+ "version": 3,
+ "id": 2,
+ "schemaId": 0,
+ "baseManifestList": "manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-0",
+ "deltaManifestList": "manifest-list-92cdba02-3525-463d-981d-381562e9cb9d-1",
+ "totalRecordCount": 4,
+ "deltaRecordCount": 2,
+ "commitUser": "a1338729-f60a-4d94-895b-8c4860517802",
+ "commitIdentifier": 9223372036854775807,
+ "commitKind": "APPEND",
+ "timeMillis": 1793201390281,
+ "uuid": "d721aff9-8b3f-4e4a-82ad-b522916f5e08",
+ "writerVersion": "python-2.1.dev-63ffd403648e1cf9848e32bb29a398c1b3b16ab1"
+}
\ No newline at end of file