Skip to content

Commit b482b93

Browse files
committed
feat(lance): support TIME type
Paimon Java accepts TIME columns in Lance data files and writes them as time32[ms], but Paimon C++ rejected TIME in Lance schema validation, both at top level and nested in ARRAY or ROW. Accept TIME there, and create empty integer statistics for a TIME column in LanceStatsExtractor so that finalizing a Lance data file with one does not fail. Document TIME in the data types guide, which still listed it as not supported: its time32[ms] mapping, how the declared precision is kept, and which file formats read or write it.
1 parent 3df4a06 commit b482b93

7 files changed

Lines changed: 105 additions & 45 deletions

File tree

‎docs/source/user_guide/data_types.rst‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,24 @@ and `Arrow DataTypes <https://arrow.apache.org/docs/format/Columnar.html#data-ty
146146
* - ``TIME``
147147

148148
``TIME(p)``
149-
- Not Supported
149+
- Time32
150150
- Data type of a time without time zone consisting of hour:minute:second[.fractional] with up to nanosecond precision and values ranging from 00:00:00.000000000 to 23:59:59.999999999.
151151

152152
The type can be declared using ``TIME(p)`` where p is the number of digits of
153153
fractional seconds (precision). p must have a value between 0 and 9
154154
(both inclusive). If no precision is specified, p is equal to 0.
155155

156+
Paimon C++ maps every ``TIME(p)`` to an Arrow ``Time32`` in milliseconds
157+
(``time32[ms]``) and accepts no other Arrow time type. The declared
158+
precision p is kept in the ``paimon.time.precision`` field metadata; a
159+
``Time32`` field without it is ``TIME(0)``. As in Paimon Java, values are
160+
stored as milliseconds of the day whatever p is, so ``TIME(6)`` and
161+
``TIME(9)`` values keep only millisecond precision.
162+
163+
Paimon C++ reads TIME columns from Parquet, Lance and Vortex data files
164+
and writes them to Lance and Vortex data files. ORC and Avro data files
165+
do not support TIME, and a TIME column cannot be a partition key.
166+
156167
* - ``TIMESTAMP``
157168

158169
``TIMESTAMP(p)``

‎src/paimon/core/schema/schema_validation.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,7 @@ Status SchemaValidation::ValidateLanceDataField(const std::shared_ptr<arrow::Fie
10011001
case arrow::Type::FLOAT:
10021002
case arrow::Type::DOUBLE:
10031003
case arrow::Type::DATE32:
1004+
case arrow::Type::TIME32:
10041005
case arrow::Type::STRING:
10051006
case arrow::Type::BINARY:
10061007
case arrow::Type::DECIMAL128:

‎src/paimon/core/schema/schema_validation_test.cpp‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,13 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
437437
arrow::field("string", arrow::utf8()),
438438
arrow::field("binary", arrow::binary()),
439439
arrow::field("date", arrow::date32()),
440+
arrow::field("time", arrow::time32(arrow::TimeUnit::MILLI)),
440441
arrow::field("timestamp", arrow::timestamp(arrow::TimeUnit::MICRO)),
441442
arrow::field("decimal", arrow::decimal128(38, 2)),
442443
arrow::field("array", arrow::list(arrow::float32())),
444+
arrow::field("nested_time",
445+
arrow::struct_({arrow::field(
446+
"values", arrow::list(arrow::time32(arrow::TimeUnit::MILLI)))})),
443447
arrow::field("row", arrow::struct_({arrow::field("value", arrow::int32())}),
444448
/*nullable=*/false),
445449
arrow::field("nullable_row", arrow::struct_({arrow::field("value", arrow::int32())})),
@@ -472,13 +476,9 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
472476
arrow::field("map", arrow::map(arrow::int32(), arrow::utf8())),
473477
arrow::field("ltz", arrow::timestamp(arrow::TimeUnit::MICRO, "UTC")),
474478
VariantTypeUtils::ToArrowField("variant"),
475-
arrow::field("time_millis", arrow::time32(arrow::TimeUnit::MILLI)),
476-
arrow::field("nested_time",
477-
arrow::struct_({arrow::field(
478-
"values", arrow::list(arrow::time32(arrow::TimeUnit::MILLI)))})),
479479
};
480-
std::vector<std::string> expected_errors = {"type MAP", "LOCAL_ZONED_TIMESTAMP", "type VARIANT",
481-
"type time32", "type time32"};
480+
std::vector<std::string> expected_errors = {"type MAP", "LOCAL_ZONED_TIMESTAMP",
481+
"type VARIANT"};
482482
for (size_t i = 0; i < unsupported_fields.size(); ++i) {
483483
ASSERT_OK_AND_ASSIGN(
484484
table_schema,

‎src/paimon/format/lance/lance_file_format_test.cpp‎

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include "arrow/ipc/json_simple.h"
3030
#include "gtest/gtest.h"
3131
#include "paimon/common/types/data_field.h"
32+
#include "paimon/common/types/data_type.h"
3233
#include "paimon/common/utils/arrow/mem_utils.h"
3334
#include "paimon/common/utils/arrow/status_utils.h"
3435
#include "paimon/common/utils/checked_cast.h"
@@ -176,6 +177,11 @@ TEST(LanceUtilsTest, ConvertsObjectStoreOptions) {
176177
}
177178

178179
TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
180+
auto time_field = [](const std::string& name, int32_t precision) {
181+
return arrow::field(
182+
name, arrow::time32(arrow::TimeUnit::MILLI), /*nullable=*/true,
183+
arrow::key_value_metadata({DataType::kTimePrecision}, {std::to_string(precision)}));
184+
};
179185
arrow::FieldVector fields = {
180186
arrow::field("bool_col", arrow::boolean()),
181187
arrow::field("i8_col", arrow::int8()),
@@ -187,22 +193,30 @@ TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
187193
arrow::field("string_col", arrow::utf8()),
188194
arrow::field("binary_col", arrow::binary()),
189195
arrow::field("date_col", arrow::date32()),
196+
time_field("time_col", 3),
190197
arrow::field("timestamp_col", arrow::timestamp(arrow::TimeUnit::MICRO)),
191198
arrow::field("decimal_col", arrow::decimal128(12, 2)),
192199
arrow::field("array_col", arrow::list(arrow::float32())),
193200
arrow::field("row_col", arrow::struct_({arrow::field("value", arrow::int32())})),
194201
arrow::field(
195202
"vector_col",
196203
arrow::fixed_size_list(arrow::field("item", arrow::float32(), /*nullable=*/false), 2)),
204+
arrow::field("time_array_col", arrow::list(time_field("item", 0))),
205+
arrow::field("time_row_col", arrow::struct_({time_field("value", 6)})),
206+
arrow::field("time_row_array_col",
207+
arrow::struct_({arrow::field("values", arrow::list(time_field("item", 9)))})),
197208
};
198209
std::shared_ptr<arrow::Schema> schema = arrow::schema(fields);
199210
std::shared_ptr<arrow::Array> expected =
200211
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
201-
[true,-1,-2,-3,-4,1.5,2.5,"one","abc",1,
202-
"1970-01-01 00:00:00.000001","12.34",[1.0,2.0],[7],[3.0,4.0]],
203-
[false,1,2,3,4,3.5,4.5,"two","xyz",2,
204-
"2030-12-31 23:59:59.999999","-12.34",[],[8],[5.0,6.0]],
205-
[null,null,null,null,null,null,null,null,null,null,null,null,null,[null],null]
212+
[true,-1,-2,-3,-4,1.5,2.5,"one","abc",1,0,
213+
"1970-01-01 00:00:00.000001","12.34",[1.0,2.0],[7],[3.0,4.0],
214+
[0,null,45296000],[45296123],[[3723123,null]]],
215+
[false,1,2,3,4,3.5,4.5,"two","xyz",2,86399999,
216+
"2030-12-31 23:59:59.999999","-12.34",[],[8],[5.0,6.0],
217+
[],[86399999],[[]]],
218+
[null,null,null,null,null,null,null,null,null,null,null,null,null,null,[null],null,
219+
null,[null],[null]]
206220
])")
207221
.ValueOrDie();
208222
std::string path = PathUtil::JoinPath(directory_->Str(), "supported-types.lance");
@@ -219,6 +233,19 @@ TEST_F(LanceFileFormatTest, WriteThenReadSupportedTypes) {
219233
ASSERT_TRUE(actual->Equals(arrow::ChunkedArray(expected))) << actual->ToString() << "\nvs\n"
220234
<< expected->ToString();
221235
}
236+
237+
ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, OpenReader(path, 2));
238+
::ArrowSchema ffi_schema = {};
239+
ASSERT_TRUE(arrow::ExportSchema(*schema, &ffi_schema).ok());
240+
ASSERT_OK(reader->SetReadSchema(&ffi_schema, /*predicate=*/nullptr,
241+
/*selection_bitmap=*/std::nullopt));
242+
ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, reader->NextBatch());
243+
arrow::Result<std::shared_ptr<arrow::Array>> batch_result =
244+
arrow::ImportArray(batch.first.get(), batch.second.get());
245+
ASSERT_TRUE(batch_result.ok()) << batch_result.status().ToString();
246+
std::shared_ptr<arrow::Array> batch_array = std::move(batch_result).MoveValueUnsafe();
247+
ASSERT_TRUE(batch_array->type()->Equals(arrow::struct_(fields), /*check_metadata=*/true))
248+
<< batch_array->type()->ToString(/*show_metadata=*/true);
222249
}
223250

224251
TEST_F(LanceFileFormatTest, RejectsNullTopLevelRows) {
@@ -351,10 +378,12 @@ TEST_F(LanceFileFormatTest, EmptyProjectionAndEmptySelection) {
351378

352379
TEST_F(LanceFileFormatTest, ExtractStatistics) {
353380
arrow::FieldVector fields = {arrow::field("id", arrow::int32()),
354-
arrow::field("name", arrow::utf8())};
381+
arrow::field("name", arrow::utf8()),
382+
arrow::field("time", arrow::time32(arrow::TimeUnit::MILLI))};
355383
std::shared_ptr<arrow::Schema> schema = arrow::schema(fields);
356384
std::shared_ptr<arrow::Array> data =
357-
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([[1,"a"],[2,null]])")
385+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
386+
R"([[1,"a",3723123],[2,null,null]])")
358387
.ValueOrDie();
359388
std::string path = PathUtil::JoinPath(directory_->Str(), "stats.lance");
360389
ASSERT_OK(WriteFile(path, schema, data, /*batch_size=*/2));
@@ -365,9 +394,10 @@ TEST_F(LanceFileFormatTest, ExtractStatistics) {
365394
format_->CreateStatsExtractor(&ffi_schema));
366395
ASSERT_OK_AND_ASSIGN(auto result, extractor->ExtractWithFileInfo(file_system_, path, pool_));
367396
ASSERT_EQ(result.second.GetRowCount(), 2);
368-
ASSERT_EQ(result.first.size(), 2);
397+
ASSERT_EQ(result.first.size(), 3);
369398
ASSERT_EQ(result.first[0]->ToString(), "min null, max null, null count null");
370399
ASSERT_EQ(result.first[1]->ToString(), "min null, max null, null count null");
400+
ASSERT_EQ(result.first[2]->ToString(), "min null, max null, null count null");
371401
}
372402

373403
TEST_F(LanceFileFormatTest, RejectsIncompatibleReadTypes) {

‎src/paimon/format/lance/lance_stats_extractor.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Result<std::unique_ptr<ColumnStats>> LanceStatsExtractor::CreateEmptyStats(
8686
return ColumnStats::CreateStringColumnStats(std::nullopt, std::nullopt, std::nullopt);
8787
case arrow::Type::DATE32:
8888
return ColumnStats::CreateDateColumnStats(std::nullopt, std::nullopt, std::nullopt);
89+
case arrow::Type::TIME32:
90+
return ColumnStats::CreateIntColumnStats(std::nullopt, std::nullopt, std::nullopt);
8991
case arrow::Type::TIMESTAMP: {
9092
std::shared_ptr<arrow::TimestampType> timestamp_type =
9193
checked_pointer_cast<arrow::TimestampType>(type);

‎test/inte/write_and_read_inte_test.cpp‎

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5978,37 +5978,52 @@ TEST(VortexPrimaryKeyWriteInteTest, TestPKWriteFinalize) {
59785978
R"([[0, "a", 1],[0, "b", 2]])"));
59795979
ASSERT_TRUE(success);
59805980
}
5981+
#endif
59815982

5983+
#if defined(PAIMON_ENABLE_LANCE) || defined(PAIMON_ENABLE_VORTEX)
59825984
// Regression: append-table finalization always extracts statistics, and TIME has no statistics
59835985
// factory of its own, so the extractor must still yield one entry for a TIME column.
5984-
TEST(VortexTimeColumnWriteInteTest, TestAppendTimeColumnFinalize) {
5985-
auto dir = UniqueTestDirectory::Create("local");
5986-
ASSERT_NE(dir, nullptr);
5987-
arrow::FieldVector fields = {arrow::field("f_time", arrow::time32(arrow::TimeUnit::MILLI)),
5988-
arrow::field("f1", arrow::int32())};
5989-
auto schema = arrow::schema(fields);
5990-
std::map<std::string, std::string> options = {
5991-
{Options::FILE_FORMAT, "vortex"}, {Options::BUCKET, "-1"}, {Options::FILE_SYSTEM, "local"}};
5992-
ASSERT_OK_AND_ASSIGN(auto helper, TestHelper::Create(dir->Str(), schema, /*partition_keys=*/{},
5993-
/*primary_keys=*/{}, options,
5994-
/*is_streaming_mode=*/false));
5995-
int64_t commit_identifier = 0;
5996-
ASSERT_OK_AND_ASSIGN(
5997-
std::unique_ptr<RecordBatch> batch,
5998-
TestHelper::MakeRecordBatch(arrow::struct_(fields), R"([[3723123, 1],[null, 2]])",
5999-
/*partition_map=*/{}, /*bucket=*/0, {}));
6000-
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
6001-
helper->WriteAndCommit(std::move(batch), commit_identifier++,
6002-
/*expected_commit_messages=*/std::nullopt));
6003-
arrow::FieldVector fields_with_row_kind = fields;
6004-
fields_with_row_kind.insert(fields_with_row_kind.begin(),
6005-
arrow::field("_VALUE_KIND", arrow::int8()));
6006-
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
6007-
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
6008-
ASSERT_OK_AND_ASSIGN(bool success,
6009-
helper->ReadAndCheckResult(arrow::struct_(fields_with_row_kind), splits,
6010-
R"([[0, 3723123, 1],[0, null, 2]])"));
6011-
ASSERT_TRUE(success);
5986+
TEST(TimeColumnWriteInteTest, TestAppendTimeColumnFinalize) {
5987+
std::vector<std::string> file_formats;
5988+
#ifdef PAIMON_ENABLE_LANCE
5989+
file_formats.push_back("lance");
5990+
#endif
5991+
#ifdef PAIMON_ENABLE_VORTEX
5992+
file_formats.push_back("vortex");
5993+
#endif
5994+
for (const auto& file_format : file_formats) {
5995+
SCOPED_TRACE(file_format);
5996+
auto dir = UniqueTestDirectory::Create("local");
5997+
ASSERT_NE(dir, nullptr);
5998+
arrow::FieldVector fields = {arrow::field("f_time", arrow::time32(arrow::TimeUnit::MILLI)),
5999+
arrow::field("f1", arrow::int32())};
6000+
auto schema = arrow::schema(fields);
6001+
std::map<std::string, std::string> options = {{Options::FILE_FORMAT, file_format},
6002+
{Options::BUCKET, "-1"},
6003+
{Options::FILE_SYSTEM, "local"}};
6004+
ASSERT_OK_AND_ASSIGN(auto helper,
6005+
TestHelper::Create(dir->Str(), schema, /*partition_keys=*/{},
6006+
/*primary_keys=*/{}, options,
6007+
/*is_streaming_mode=*/false));
6008+
int64_t commit_identifier = 0;
6009+
ASSERT_OK_AND_ASSIGN(
6010+
std::unique_ptr<RecordBatch> batch,
6011+
TestHelper::MakeRecordBatch(arrow::struct_(fields), R"([[3723123, 1],[null, 2]])",
6012+
/*partition_map=*/{}, /*bucket=*/0, {}));
6013+
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
6014+
helper->WriteAndCommit(std::move(batch), commit_identifier++,
6015+
/*expected_commit_messages=*/std::nullopt));
6016+
arrow::FieldVector fields_with_row_kind = fields;
6017+
fields_with_row_kind.insert(fields_with_row_kind.begin(),
6018+
arrow::field("_VALUE_KIND", arrow::int8()));
6019+
ASSERT_OK_AND_ASSIGN(
6020+
std::vector<std::shared_ptr<Split>> splits,
6021+
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
6022+
ASSERT_OK_AND_ASSIGN(
6023+
bool success, helper->ReadAndCheckResult(arrow::struct_(fields_with_row_kind), splits,
6024+
R"([[0, 3723123, 1],[0, null, 2]])"));
6025+
ASSERT_TRUE(success);
6026+
}
60126027
}
60136028
#endif
60146029

‎test/test_data/lance/append_java_compat.db/append_java_compat/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@ Commit - snapshot 2 (4 rows total)
5454
At this Java revision, nullable ROW declarations are accepted but actual null ROW parents
5555
do not survive the Lance round trip (a separate probe read id=2's null parent as non-null).
5656
Both struct parents here are valid, including id=2 where their children are NULL.
57-
TIME32 is omitted because the C++ table path does not support it; Java's Lance validator
58-
rejects local-zoned timestamps, MAP, MULTISET, VARIANT and BLOB.
57+
TIME32 is omitted because the C++ table path did not support it when this fixture was
58+
written. At this revision, Java's Lance validator rejects local-zoned timestamps, MAP,
59+
MULTISET, VARIANT and BLOB.

0 commit comments

Comments
 (0)