This is an automated email from the ASF dual-hosted git repository.

ColinLeeo pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/tsfile.git


The following commit(s) were added to refs/heads/develop by this push:
     new e5e4cccfd [Python][C++] Fix tree-model TsFileDataFrame returning empty 
data on reused readers and uppercase names (#862)
e5e4cccfd is described below

commit e5e4cccfd192d35207747e0458767dcf5a3908d9
Author: Le Yang <[email protected]>
AuthorDate: Thu Jul 16 14:43:07 2026 +0800

    [Python][C++] Fix tree-model TsFileDataFrame returning empty data on reused 
readers and uppercase names (#862)
    
    * fix: correct tree-model TsFileDataFrame reads on reused readers and 
uppercase names
    
    Two related fixes for reading tree-model TsFiles through TsFileDataFrame, 
which together caused 'read one series OK, then reading another series returns 
empty' on a reused reader.
    
    C++: make StringArrayDeviceID::split_table_name()/init_prefix_segments() 
idempotent. Device IDs are cached and reused across queries; the previous code 
appended to prefix_segments_ on every call, so prefixes accumulated (and 
leaked) across successive queries and corrupted per-device path columns, making 
a second series read match nothing. Now it clears/frees existing prefixes 
before rebuilding. Adds DeviceIdTest.SplitTableNameIsIdempotent.
    
    Python: read series with uppercase measurement/path names. 
query_table_on_tree synthesizes a case-insensitive table schema, so the native 
layer lower-cases column names and path segments, while the dataset catalog 
keeps the original casing. The tree read paths matched field columns and device 
paths case-sensitively and returned empty for uppercase names. 
_read_series_by_row_tree and _read_arrow_tree now match case-insensitively. 
Adds a regression test.
    
    * style(python): apply spotless (black) formatting to tree-model regression 
test
    
    * revert(python): drop case-insensitive tree-model matching; add 
case-collision regression test
    
    Revert the .lower() case-insensitive column/path matching in the tree read 
paths (from d13cd236). It let single-variant uppercase measurements read, but 
conflated case-distinct measurements (e.g. temperature vs Temperature) into one 
series. Restore case-sensitive matching (native still lower-cases names, so 
uppercase reads are empty for now) and add a regression test asserting 
case-distinct measurements do not collide. Two tree-model tests are 
intentionally red pending the C++ root fi [...]
    
    * fix(cpp): preserve case for tree-derived (virtual) table schemas
    
    Tree-model device paths and measurement names are case-sensitive, but the
    
    virtual TableSchema synthesized in query_on_tree lower-cased them via the
    
    TableSchema constructor. This collapsed case-distinct series (e.g.
    
    "temperature" vs "Temperature") into one column, so reads returned empty
    
    or mixed data.
    
    - TableSchema: add virtual_table flag; skip lower-casing table/measurement
    
      names and keep column_pos_index_ in original case for virtual tables.
    
    - find_column_index/find_id_column_order: match case-sensitively for
    
      virtual tables, unchanged (lower-cased) for real tables.
    
    - query_on_tree: build the synthesized schema with virtual_table=true.
    
    * docs(cpp): trim case-sensitivity comments in TableSchema
    
    * spotless
---
 cpp/src/common/device_id.cc            |   8 ++
 cpp/src/common/schema.h                |  35 ++++---
 cpp/src/reader/table_query_executor.cc |   8 +-
 cpp/test/common/device_id_test.cc      |  20 ++++
 python/tests/test_tsfile_dataset.py    | 164 +++++++++++++++++++++++++++++++++
 5 files changed, 221 insertions(+), 14 deletions(-)

diff --git a/cpp/src/common/device_id.cc b/cpp/src/common/device_id.cc
index e88cdac8a..0b5e6c107 100644
--- a/cpp/src/common/device_id.cc
+++ b/cpp/src/common/device_id.cc
@@ -103,6 +103,13 @@ std::string StringArrayDeviceID::get_device_name() const {
 }
 
 void StringArrayDeviceID::init_prefix_segments() {
+    // Idempotent: device IDs are cached and reused across queries, so clear
+    // previous prefixes before rebuilding to avoid accumulation and leaks.
+    for (const auto& prefix_segment : prefix_segments_) {
+        delete prefix_segment;
+    }
+    prefix_segments_.clear();
+
 #ifdef ENABLE_ANTLR4
     auto splits = storage::PathNodesGenerator::invokeParser(*segments_[0]);
 #else
@@ -130,6 +137,7 @@ int StringArrayDeviceID::serialize(common::ByteStream& 
write_stream) {
 
 int StringArrayDeviceID::deserialize(common::ByteStream& read_stream) {
     int ret = common::E_OK;
+
     uint32_t num_segments;
     if (RET_FAIL(common::SerializationUtil::read_var_uint(num_segments,
                                                           read_stream))) {
diff --git a/cpp/src/common/schema.h b/cpp/src/common/schema.h
index 81008b715..827ae6d83 100644
--- a/cpp/src/common/schema.h
+++ b/cpp/src/common/schema.h
@@ -198,9 +198,16 @@ class TableSchema {
      * in the table.
      */
     TableSchema(const std::string& table_name,
-                const std::vector<common::ColumnSchema>& column_schemas)
-        : table_name_(table_name), updatable_(false) {
-        to_lowercase_inplace(table_name_);
+                const std::vector<common::ColumnSchema>& column_schemas,
+                bool virtual_table = false)
+        : table_name_(table_name),
+          is_virtual_table_(virtual_table),
+          updatable_(false) {
+        // Virtual (tree-derived) schemas are case-sensitive; only real tables
+        // are normalized to lower case.
+        if (!is_virtual_table_) {
+            to_lowercase_inplace(table_name_);
+        }
         for (const common::ColumnSchema& column_schema : column_schemas) {
             column_schemas_.emplace_back(std::make_shared<MeasurementSchema>(
                 column_schema.get_column_name(),
@@ -210,7 +217,9 @@ class TableSchema {
         }
         int idx = 0;
         for (const auto& measurement_schema : column_schemas_) {
-            to_lowercase_inplace(measurement_schema->measurement_name_);
+            if (!is_virtual_table_) {
+                to_lowercase_inplace(measurement_schema->measurement_name_);
+            }
             column_pos_index_.insert(
                 std::make_pair(measurement_schema->measurement_name_, idx++));
         }
@@ -325,21 +334,23 @@ class TableSchema {
     int32_t get_columns_num() const { return column_schemas_.size(); }
 
     int find_column_index(const std::string& column_name) {
-        std::string lower_case_column_name = to_lower(column_name);
-        auto it = column_pos_index_.find(lower_case_column_name);
+        // Virtual tables match case-sensitively; real tables are lower-cased.
+        std::string lookup_name =
+
+            is_virtual_table_ ? column_name : to_lower(column_name);
+        auto it = column_pos_index_.find(lookup_name);
         if (it != column_pos_index_.end()) {
             return it->second;
         } else {
             int index = -1;
             for (size_t i = 0; i < column_schemas_.size(); ++i) {
-                if (column_schemas_[i]->measurement_name_ ==
-                    lower_case_column_name) {
+                if (column_schemas_[i]->measurement_name_ == lookup_name) {
                     index = static_cast<int>(i);
                     break;
                 }
             }
             if (index != -1) {
-                column_pos_index_[lower_case_column_name] = index;
+                column_pos_index_[lookup_name] = index;
             }
             return index;
         }
@@ -440,12 +451,12 @@ class TableSchema {
     }
 
     int32_t find_id_column_order(const std::string& column_name) {
-        std::string lower_case_column_name = to_lower(column_name);
+        std::string lookup_name =
+            is_virtual_table_ ? column_name : to_lower(column_name);
 
         int column_order = 0;
         for (size_t i = 0; i < column_schemas_.size(); ++i) {
-            if (column_schemas_[i]->measurement_name_ ==
-                    lower_case_column_name &&
+            if (column_schemas_[i]->measurement_name_ == lookup_name &&
                 column_categories_[i] == common::ColumnCategory::TAG) {
                 return column_order;
             } else if (column_categories_[i] == common::ColumnCategory::TAG) {
diff --git a/cpp/src/reader/table_query_executor.cc 
b/cpp/src/reader/table_query_executor.cc
index d5145104d..3983f8540 100644
--- a/cpp/src/reader/table_query_executor.cc
+++ b/cpp/src/reader/table_query_executor.cc
@@ -233,8 +233,12 @@ int TableQueryExecutor::query_on_tree(
         }
     }
 
-    auto schema = std::make_shared<TableSchema>("default", col_schema);
-    schema->set_virtual_table();
+    // Tree-derived (virtual) tables are case-sensitive: keep the original
+    // casing of device path segments and measurement names so that
+    // case-distinct series (e.g. "temperature" vs "Temperature") map to
+    // separate columns instead of colliding.
+    auto schema = std::make_shared<TableSchema>("default", col_schema,
+                                                /*virtual_table=*/true);
     std::shared_ptr<ColumnMapping> column_mapping =
         std::make_shared<ColumnMapping>();
     for (size_t i = 0; i < col_schema.size(); ++i) {
diff --git a/cpp/test/common/device_id_test.cc 
b/cpp/test/common/device_id_test.cc
index 9d97607ab..839258f67 100644
--- a/cpp/test/common/device_id_test.cc
+++ b/cpp/test/common/device_id_test.cc
@@ -99,4 +99,24 @@ TEST(DeviceIdTest, NullTagVsLiteralNullAreDistinct) {
     ASSERT_FALSE(null_first == literal_null);
     ASSERT_TRUE(null_first != literal_null);
 }
+
+// Regression: cached device IDs are reused across queries, so
+// split_table_name() must be idempotent and not accumulate prefix segments.
+TEST(DeviceIdTest, SplitTableNameIsIdempotent) {
+    StringArrayDeviceID device_id("root.ln.wf01.wt01");
+
+    const std::vector<std::string> expected = {"root", "ln", "wf01", "wt01"};
+
+    for (int round = 0; round < 3; ++round) {
+        device_id.split_table_name();
+
+        ASSERT_EQ(static_cast<int>(expected.size()),
+                  device_id.get_split_seg_num());
+        for (int i = 0; i < device_id.get_split_seg_num(); ++i) {
+            std::string* seg = device_id.get_split_segname_at(i);
+            ASSERT_NE(nullptr, seg);
+            ASSERT_EQ(expected[static_cast<size_t>(i)], *seg);
+        }
+    }
+}
 }  // namespace storage
diff --git a/python/tests/test_tsfile_dataset.py 
b/python/tests/test_tsfile_dataset.py
index 12b847e9e..ee016d087 100644
--- a/python/tests/test_tsfile_dataset.py
+++ b/python/tests/test_tsfile_dataset.py
@@ -1507,12 +1507,176 @@ def test_dataset_tree_model_series_access(tmp_path):
         np.testing.assert_array_equal(aligned.timestamps, np.arange(5, 
dtype=np.int64))
 
 
+def test_dataset_tree_model_reads_uppercase_measurement_names(tmp_path):
+    """Tree-model series with uppercase measurement names must read data.
+
+    Regression: a measurement like ``Temperature``/``STATUS`` must return its
+    values, not an empty array, when read back through TsFileDataFrame.
+    """
+    from tsfile import Field, RowRecord, TimeseriesSchema, TsFileWriter
+
+    path = tmp_path / "tree_upper.tsfile"
+    writer = TsFileWriter(str(path))
+    writer.register_timeseries(
+        "root.ln.wf01.wt01", TimeseriesSchema("Temperature", TSDataType.DOUBLE)
+    )
+    writer.register_timeseries(
+        "root.ln.wf01.wt01", TimeseriesSchema("STATUS", TSDataType.INT32)
+    )
+    for t in range(5):
+        writer.write_row_record(
+            RowRecord(
+                "root.ln.wf01.wt01",
+                t,
+                [
+                    Field("Temperature", float(t) + 0.5, TSDataType.DOUBLE),
+                    Field("STATUS", t * 2, TSDataType.INT32),
+                ],
+            )
+        )
+    writer.close()
+
+    with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+        assert sorted(tsdf.list_timeseries()) == [
+            "root.ln.wf01.wt01.STATUS",
+            "root.ln.wf01.wt01.Temperature",
+        ]
+        np.testing.assert_array_equal(
+            tsdf["root.ln.wf01.wt01.Temperature"][:],
+            np.array([0.5, 1.5, 2.5, 3.5, 4.5]),
+        )
+        np.testing.assert_array_equal(
+            tsdf["root.ln.wf01.wt01.STATUS"][:],
+            np.array([0.0, 2.0, 4.0, 6.0, 8.0]),
+        )
+
+
+def 
test_dataset_tree_model_case_distinct_measurements_do_not_collide(tmp_path, 
capsys):
+    """Case-distinct measurements on one device must not be conflated.
+
+    ``temperature`` and ``Temperature`` are two independent series; each must
+    return its own values instead of an empty array or the other's data.
+    """
+    from tsfile import Field, RowRecord, TimeseriesSchema, TsFileWriter
+
+    path = tmp_path / "tree_case.tsfile"
+
+    writer = TsFileWriter(str(path))
+    writer.register_timeseries(
+        "root.case.d1", TimeseriesSchema("temperature", TSDataType.DOUBLE)
+    )
+    writer.register_timeseries(
+        "root.case.d1", TimeseriesSchema("Temperature", TSDataType.DOUBLE)
+    )
+    for t in range(3):
+        writer.write_row_record(
+            RowRecord(
+                "root.case.d1",
+                t,
+                [
+                    Field("temperature", 30.0 + t, TSDataType.DOUBLE),
+                    Field("Temperature", 40.0 + t, TSDataType.DOUBLE),
+                ],
+            )
+        )
+    writer.close()
+
+    with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+        # Two independent tree-model series are discovered (case preserved).
+        assert len(tsdf) == 2
+        assert sorted(tsdf.list_timeseries()) == [
+            "root.case.d1.Temperature",
+            "root.case.d1.temperature",
+        ]
+
+        # Metadata layer keeps both rows distinct with their original casing.
+        meta = tsdf.list_timeseries_metadata()
+        assert sorted(meta.index.tolist()) == [
+            "root.case.d1.Temperature",
+            "root.case.d1.temperature",
+        ]
+        for name in ("root.case.d1.temperature", "root.case.d1.Temperature"):
+            assert meta.loc[name, "_col_1"] == "case"
+            assert meta.loc[name, "_col_2"] == "d1"
+            assert meta.loc[name, "count"] == 3
+        assert set(meta["field"]) == {"temperature", "Temperature"}
+
+        # print(tsdf) renders both case-distinct series verbatim. The timestamp
+        # text is derived from format_timestamp so the whole-string match stays
+        # stable across timezones.
+        t0, t2 = format_timestamp(0), format_timestamp(2)
+        expected_repr = "\n".join(
+            [
+                "TsFileDataFrame(tree model, 2 time series, 1 files)",
+                "   _col_1  _col_2        field           start_time           
      end_time  count",
+                f"0    case      d1  Temperature  {t0}  {t2}      3",
+                f"1    case      d1  temperature  {t0}  {t2}      3",
+            ]
+        )
+        assert repr(tsdf) == expected_repr
+        print(tsdf)
+        assert capsys.readouterr().out == expected_repr + "\n"
+
+        # Each series must read back its OWN values -- not empty, not the
+        # other's data. Lower-casing anywhere in the read path breaks this.
+        np.testing.assert_array_equal(
+            tsdf["root.case.d1.temperature"][:],
+            np.array([30.0, 31.0, 32.0]),
+        )
+        np.testing.assert_array_equal(
+            tsdf["root.case.d1.Temperature"][:],
+            np.array([40.0, 41.0, 42.0]),
+        )
+
+        # Aligned read of both case-distinct series keeps them separated.
+        aligned = tsdf.loc[
+            0:2,
+            ["root.case.d1.temperature", "root.case.d1.Temperature"],
+        ]
+        assert aligned.series_names == [
+            "root.case.d1.temperature",
+            "root.case.d1.Temperature",
+        ]
+        np.testing.assert_array_equal(
+            aligned.values,
+            np.array([[30.0, 40.0], [31.0, 41.0], [32.0, 42.0]]),
+        )
+
+
+def test_tree_reader_handles_stale_path_columns_after_reused_queries(tmp_path):
+    """Reusing a reader must not leak prefix path state across queries.
+
+    Reading one device series then another reuses the cached device id; stale
+    prefix segments used to mismatch the device and return empty data.
+    """
+    path = tmp_path / "tree.tsfile"
+    _write_tree_file(path)
+
+    with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+        # First read establishes query state on the reader.
+        np.testing.assert_array_equal(
+            tsdf["root.ln.wf01.wt01.temperature"][:],
+            np.array([0.5, 1.5, 2.5, 3.5, 4.5]),
+        )
+        # Second read reuses the same reader for another device.
+        np.testing.assert_array_equal(
+            tsdf["root.ln.wf02.wt02.status"][:],
+            np.array([0.0, 2.0, 4.0, 6.0, 8.0]),
+        )
+        # Read back the first series to confirm alternating reads stay stable.
+        np.testing.assert_array_equal(
+            tsdf["root.ln.wf01.wt01.temperature"][:],
+            np.array([0.5, 1.5, 2.5, 3.5, 4.5]),
+        )
+
+
 def test_dataset_tree_model_list_timeseries_metadata(tmp_path):
     path = tmp_path / "tree.tsfile"
     _write_tree_file(path)
 
     with TsFileDataFrame(str(path), show_progress=False) as tsdf:
         meta = tsdf.list_timeseries_metadata()
+
         assert isinstance(meta, pd.DataFrame)
         assert list(meta.columns) == [
             "field",

Reply via email to