This is an automated email from the ASF dual-hosted git repository. ColinLeeo pushed a commit to branch fix_invalid_datatype in repository https://gitbox.apache.org/repos/asf/tsfile.git
commit 39ce52947f0081370daf60ebad43f9d6ec371b1f Author: ColinLee <[email protected]> AuthorDate: Mon Jul 13 12:14:49 2026 +0800 fix invalid datatype. --- cpp/src/reader/qds_without_timegenerator.cc | 19 ++++++- cpp/src/reader/tsfile_series_scan_iterator.h | 13 +++++ .../tree_view/tsfile_tree_query_by_row_test.cc | 13 +++++ python/tests/test_query_by_row.py | 65 ++++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/cpp/src/reader/qds_without_timegenerator.cc b/cpp/src/reader/qds_without_timegenerator.cc index b612e5dc2..7f520f339 100644 --- a/cpp/src/reader/qds_without_timegenerator.cc +++ b/cpp/src/reader/qds_without_timegenerator.cc @@ -55,8 +55,14 @@ int QDSWithoutTimeGenerator::init_internal(TsFileIOReader* io_reader, std::vector<Path> valid_paths; std::vector<std::string> column_names; std::vector<common::TSDataType> data_types; + // Data type per valid path, captured from the timeseries index right after + // alloc_ssi — while itimeseries_index_ is still live. get_next_tsblock() + // may later destroy() the SSI (e.g. when limit==0 yields no TsBlock), which + // clears the index, so this must be recorded up front. + std::vector<common::TSDataType> ssi_data_types; column_names.reserve(origin_path_count); data_types.reserve(origin_path_count); + ssi_data_types.reserve(origin_path_count); Expression* global_time_expression = qe->expression_; Filter* global_time_filter = nullptr; if (global_time_expression != nullptr) { @@ -91,6 +97,7 @@ int QDSWithoutTimeGenerator::init_internal(TsFileIOReader* io_reader, ssi_vec_.push_back(ssi); valid_paths.push_back(paths[i]); column_names.push_back(paths[i].full_path_); + ssi_data_types.push_back(ssi->get_data_type()); } size_t path_count = valid_paths.size(); @@ -108,9 +115,15 @@ int QDSWithoutTimeGenerator::init_internal(TsFileIOReader* io_reader, for (size_t i = 0; i < path_count; i++) { get_next_tsblock(i, true); - data_types.push_back(value_iters_[i] != nullptr - ? value_iters_[i]->get_data_type() - : TSDataType::NULL_TYPE); + // Prefer the type carried by the value iterator, but fall back to the + // timeseries-index type captured before get_next_tsblock() when no + // TsBlock was produced (e.g. limit==0 skips every row, or the series is + // empty). Emitting NULL_TYPE here would surface an invalid datatype + // (254) to callers that map the metadata onto their own type enums. + common::TSDataType col_type = value_iters_[i] != nullptr + ? value_iters_[i]->get_data_type() + : ssi_data_types[i]; + data_types.push_back(col_type); } // Single-path: SSI may have consumed offset/limit by skipping chunks/pages // during first get_next_tsblock(); sync so QDS does not double-apply. diff --git a/cpp/src/reader/tsfile_series_scan_iterator.h b/cpp/src/reader/tsfile_series_scan_iterator.h index 77037d8e1..68f1a1f32 100644 --- a/cpp/src/reader/tsfile_series_scan_iterator.h +++ b/cpp/src/reader/tsfile_series_scan_iterator.h @@ -105,6 +105,19 @@ class TsFileSeriesScanIterator { bool is_multi_value() const { return is_multi_value_; } + /** + * Data type of the (value) column from the loaded timeseries index. + * Available as soon as the SSI is allocated, i.e. independent of whether + * any TsBlock has been materialized. Callers building result-set metadata + * should prefer this over deriving the type from a decoded TsBlock, since + * offset/limit (e.g. limit==0) may skip all rows and leave no block. + */ + common::TSDataType get_data_type() const { + return itimeseries_index_ == nullptr + ? common::INVALID_DATATYPE + : itimeseries_index_->get_data_type(); + } + friend class TsFileIOReader; private: diff --git a/cpp/test/reader/tree_view/tsfile_tree_query_by_row_test.cc b/cpp/test/reader/tree_view/tsfile_tree_query_by_row_test.cc index 1aa1b4623..870b30aaf 100644 --- a/cpp/test/reader/tree_view/tsfile_tree_query_by_row_test.cc +++ b/cpp/test/reader/tree_view/tsfile_tree_query_by_row_test.cc @@ -518,6 +518,13 @@ TEST_F(TreeQueryByRowTest, OffsetExceedsTotalRows) { ASSERT_EQ(E_OK, reader.queryByRow(devices, measurements, 100, -1, result)); ASSERT_NE(result, nullptr); + // Even with no rows, the column type must reflect the series' real type + // (INT64) rather than NULL_TYPE — downstream bindings map this onto their + // own type enums and reject NULL_TYPE (254). + auto meta = result->get_metadata(); + ASSERT_EQ(2u, meta->get_column_count()); + EXPECT_EQ(INT64, meta->get_column_type(2)); + auto timestamps = collect_timestamps(result); EXPECT_EQ(timestamps.size(), 0u); @@ -539,6 +546,12 @@ TEST_F(TreeQueryByRowTest, LimitZero) { ASSERT_EQ(E_OK, reader.queryByRow(devices, measurements, 0, 0, result)); ASSERT_NE(result, nullptr); + // limit==0 pushes down to the scan iterator and produces no TsBlock; the + // metadata must still report the real column type (INT64), not NULL_TYPE. + auto meta = result->get_metadata(); + ASSERT_EQ(2u, meta->get_column_count()); + EXPECT_EQ(INT64, meta->get_column_type(2)); + auto timestamps = collect_timestamps(result); EXPECT_EQ(timestamps.size(), 0u); diff --git a/python/tests/test_query_by_row.py b/python/tests/test_query_by_row.py index c9c993014..468b0a7e9 100644 --- a/python/tests/test_query_by_row.py +++ b/python/tests/test_query_by_row.py @@ -172,6 +172,71 @@ def test_query_table_by_row_offset_limit(): os.remove(file_path) +def test_query_tree_by_row_limit_zero(): + """limit=0 must return an empty result set with valid column metadata. + + Regression: single-path queries push limit down to the scan iterator, so + the first TsBlock is never materialized and the column type used to fall + back to NULL_TYPE (254), which broke metadata mapping on the Python side + (`ValueError: 254 is not a valid TSDataType`). + """ + file_path = "python_tree_query_by_row_limit_zero.tsfile" + if os.path.exists(file_path): + os.remove(file_path) + + try: + device_id = "root.d1" + specs = [ + ("s1", TSDataType.INT64), + ("s2", TSDataType.DOUBLE), + ("s3", TSDataType.BOOLEAN), + ("s4", TSDataType.STRING), + ] + num_rows = 10 + + writer = TsFileWriter(file_path) + for name, dtype in specs: + writer.register_timeseries(device_id, TimeseriesSchema(name, dtype)) + for t in range(num_rows): + fields = [ + Field("s1", t, TSDataType.INT64), + Field("s2", float(t), TSDataType.DOUBLE), + Field("s3", t % 2 == 0, TSDataType.BOOLEAN), + Field("s4", f"v{t}", TSDataType.STRING), + ] + writer.write_row_record(RowRecord(device_id, t, fields)) + writer.close() + + reader = TsFileReader(file_path) + + # Single-path limit=0 for each data type: metadata type must be exact + # and no rows must be returned. + for name, dtype in specs: + with reader.query_tree_by_row([device_id], [name], 0, 0) as result: + info = result.get_result_column_info() + assert info[f"{device_id}.{name}"] == dtype + assert not result.next() + + # Multi-path limit=0: every column keeps its declared type, zero rows. + names = [name for name, _ in specs] + with reader.query_tree_by_row([device_id], names, 0, 0) as result: + info = result.get_result_column_info() + for name, dtype in specs: + assert info[f"{device_id}.{name}"] == dtype + assert not result.next() + + # An offset past the end of the data hits the same no-TsBlock path. + with reader.query_tree_by_row([device_id], ["s1"], num_rows + 5, -1) as result: + info = result.get_result_column_info() + assert info[f"{device_id}.s1"] == TSDataType.INT64 + assert not result.next() + + reader.close() + finally: + if os.path.exists(file_path): + os.remove(file_path) + + def test_query_tree_by_row_skips_missing_device_and_measurement(): """Tree queryByRow: missing device or measurement paths are skipped (Java-aligned).""" file_path = "python_tree_query_by_row_skip_missing.tsfile"
