You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tsfile.apache.org by ji...@apache.org on 2026/06/11 03:37:19 UTC

(tsfile) branch develop updated: Fix several bugs in tsfile-cli and add tag filter commands (#837)

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

jt2594838 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 719f80a6b Fix several bugs in tsfile-cli and add tag filter commands (#837)
719f80a6b is described below

commit 719f80a6bcb251a3e13525696c62311a76d93f42
Author: Zeyu Zhang <11...@users.noreply.github.com>
AuthorDate: Thu Jun 11 11:37:13 2026 +0800

    Fix several bugs in tsfile-cli and add tag filter commands (#837)
    
    * fix(tsfile-cli): fix the case inconsistency in TABLE names
    
    * fix(tsfile-cli): fix `build.sh` command
    
    * fix(tsfile-cli): add `reader.queryByRow(...)` to `head/cat` cammand
    
    * fix(tsfile-cli): add `TagFilterBuilder` predicate variable
    
    * fix(tsfile-cli): format code
    
    * fix(tsfile-cli): rename `table_filter` as `target_table_name`
---
 cpp/README-zh.md                     |   6 ++
 cpp/build.sh                         |  52 ++++++++++++---
 cpp/test/tools/cli_args_test.cc      |  28 +++++++++
 cpp/test/tools/cli_test_util.h       |  43 +++++++++++++
 cpp/test/tools/command_e2e_test.cc   | 119 +++++++++++++++++++++++++++++++++++
 cpp/tools/README.md                  |  10 ++-
 cpp/tools/cli/cli_args.cc            |  58 +++++++++++++++++
 cpp/tools/cli/cli_args.h             |  18 ++++++
 cpp/tools/cli/run_cli.cc             |  20 ++++++
 cpp/tools/commands/cmd_sample.cc     |  13 +++-
 cpp/tools/commands/cmd_schema.cc     |   5 +-
 cpp/tools/commands/commands.h        |   6 ++
 cpp/tools/commands/row_query.cc      | 111 +++++++++++++++++++++++++++++++-
 cpp/tools/commands/statistics.cc     |   6 +-
 cpp/tools/skills/tsfile-cli/SKILL.md |   5 +-
 15 files changed, 481 insertions(+), 19 deletions(-)

diff --git a/cpp/README-zh.md b/cpp/README-zh.md
index bb20dcd92..a8af952b1 100644
--- a/cpp/README-zh.md
+++ b/cpp/README-zh.md
@@ -99,6 +99,12 @@ sudo apt-get install -y cmake make g++ clang-format libuuid-dev
 bash build.sh
 ```
 
+`build.sh` 默认只编译,不执行安装。如果需要安装到 CMake 的安装前缀目录,显式传入 `install` 参数:
+
+```bash
+bash build.sh install
+```
+
 如果你安装了 Maven 工具,也可以运行:
 
 ```bash
diff --git a/cpp/build.sh b/cpp/build.sh
index d2950595b..f51c4eded 100644
--- a/cpp/build.sh
+++ b/cpp/build.sh
@@ -21,6 +21,7 @@
 build_type=Release
 build_test=0
 build_bench=0
+do_install=0
 use_cpp11=1
 enable_cov=0
 debug_se=0
@@ -39,10 +40,37 @@ get_key_value() {
     echo "${1#*=}"
 }
 
+usage()
+{
+  cat <<EOF
+Usage: bash build.sh [install] [options]
+
+Commands:
+  install                Run make install after a successful build.
+
+Options:
+  -t=<type>, -t <type>   Build type: Debug, Release, RelWithDebInfo, MinSizeRel.
+  -a=<ON|OFF>            Enable or disable AddressSanitizer.
+  -c=<ON|OFF>            Enable or disable code coverage.
+  --enable-antlr4=<ON|OFF>
+  --disable-antlr4
+  --enable-snappy=<ON|OFF>
+  --disable-snappy
+  --enable-lz4=<ON|OFF>
+  --disable-lz4
+  --enable-lzokay=<ON|OFF>
+  --disable-lzokay
+  --enable-zlib=<ON|OFF>
+  --disable-zlib
+  -h, --help             Show this help message.
+EOF
+}
+
 function print_config()
 {
   echo "build_type=$build_type"
   echo "build_test=$build_test"
+  echo "do_install=$do_install"
   echo "use_cpp11=$use_cpp11"
   echo "enable_cov=$enable_cov"
   echo "enable_asan=$enable_asan"
@@ -68,6 +96,8 @@ parse_options()
       do_clean=1;;
     run_cov)
       run_cov_only=1;;
+    install | --install)
+      do_install=1;;
     -t=*)
       build_type=$(get_key_value "$1");;
     -t)
@@ -103,18 +133,19 @@ parse_options()
       enable_lzokay=OFF;;
     --disable-zlib)
       enable_zlib=OFF;;
-    #-h | --help)
-    #  usage
-    #  exit 0;;
-    #*)
-    #  echo "Unknown option '$1'"
-    #  exit 1;;
+    -h | --help)
+      usage
+      exit 0;;
+    *)
+      echo "Unknown option '$1'"
+      usage
+      exit 1;;
     esac
     shift
   done
 }
 
-parse_options $*
+parse_options "$@"
 print_config
 
 if [[ ${run_cov_only} -eq 1 ]]
@@ -171,4 +202,9 @@ cmake ../../                           \
   -DENABLE_ZLIB=$enable_zlib
 
 VERBOSE=1 make
-VERBOSE=1 make install
\ No newline at end of file
+if [ ${do_install} -eq 1 ]
+then
+  VERBOSE=1 make install
+else
+  echo "Skip install. Pass 'install' to run 'make install'."
+fi
diff --git a/cpp/test/tools/cli_args_test.cc b/cpp/test/tools/cli_args_test.cc
index 614329463..42b7eb650 100644
--- a/cpp/test/tools/cli_args_test.cc
+++ b/cpp/test/tools/cli_args_test.cc
@@ -92,6 +92,34 @@ TEST(ParseArgsTest, LimitOffsetAndTimeRange) {
     EXPECT_EQ(p.end, 200);
 }
 
+TEST(ParseArgsTest, TagFilterParsed) {
+    auto p = tsfile_cli::parse_args(
+        {"cat", "--tag-filter", "id1", "eq", "dev_a", "data.tsfile"});
+    EXPECT_TRUE(p.error.empty());
+    EXPECT_TRUE(p.has_tag_filter);
+    EXPECT_EQ(p.tag_filter_op, tsfile_cli::ParsedArgs::TagFilterOp::kEq);
+    EXPECT_EQ(p.tag_filter_column, "id1");
+    EXPECT_EQ(p.tag_filter_value, "dev_a");
+}
+
+TEST(ParseArgsTest, TagBetweenParsed) {
+    auto p = tsfile_cli::parse_args(
+        {"cat", "--tag-between", "id1", "dev_a", "dev_c", "data.tsfile"});
+    EXPECT_TRUE(p.error.empty());
+    EXPECT_TRUE(p.has_tag_filter);
+    EXPECT_EQ(p.tag_filter_op, tsfile_cli::ParsedArgs::TagFilterOp::kBetween);
+    EXPECT_EQ(p.tag_filter_column, "id1");
+    EXPECT_EQ(p.tag_filter_value, "dev_a");
+    EXPECT_EQ(p.tag_filter_value2, "dev_c");
+}
+
+TEST(ParseArgsTest, DuplicateTagFilterIsError) {
+    auto p = tsfile_cli::parse_args({"cat", "--tag-filter", "id1", "eq",
+                                     "dev_a", "--tag-between", "id1", "a", "z",
+                                     "data.tsfile"});
+    EXPECT_FALSE(p.error.empty());
+}
+
 TEST(ParseArgsTest, UnknownFlagIsError) {
     auto p = tsfile_cli::parse_args({"ls", "--bogus", "data.tsfile"});
     EXPECT_FALSE(p.error.empty());
diff --git a/cpp/test/tools/cli_test_util.h b/cpp/test/tools/cli_test_util.h
index 0d1dccb56..5b4e532d9 100644
--- a/cpp/test/tools/cli_test_util.h
+++ b/cpp/test/tools/cli_test_util.h
@@ -99,6 +99,49 @@ inline std::string write_table_fixture() {
     return out_path;
 }
 
+inline std::string write_tag_filter_fixture() {
+    storage::libtsfile_init();
+    std::string out_path =
+        unique_temp_path("tsfile_cli_tag_filter_fixture", ".tsfile");
+    std::string table_name = "t1";
+
+    storage::WriteFile file;
+    int flags = O_WRONLY | O_CREAT | O_TRUNC;
+#ifdef _WIN32
+    flags |= O_BINARY;
+#endif
+    file.create(out_path, flags, 0666);
+
+    auto* schema = new storage::TableSchema(
+        table_name,
+        {
+            common::ColumnSchema("id1", common::STRING, common::UNCOMPRESSED,
+                                 common::PLAIN, common::ColumnCategory::TAG),
+            common::ColumnSchema("s1", common::INT64, common::UNCOMPRESSED,
+                                 common::PLAIN, common::ColumnCategory::FIELD),
+        });
+
+    auto* writer = new storage::TsFileTableWriter(&file, schema);
+    storage::Tablet tablet(
+        table_name, {"id1", "s1"}, {common::STRING, common::INT64},
+        {common::ColumnCategory::TAG, common::ColumnCategory::FIELD}, 10);
+
+    const char* tags[] = {"dev_a", "dev_b", "dev_b", "dev_c"};
+    for (int row = 0; row < 4; ++row) {
+        tablet.add_timestamp(row, static_cast<int64_t>(row));
+        tablet.add_value(row, "id1", tags[row]);
+        tablet.add_value(row, "s1", static_cast<int64_t>((row + 1) * 10));
+    }
+
+    writer->write_table(tablet);
+    writer->flush();
+    writer->close();
+
+    delete writer;
+    delete schema;
+    return out_path;
+}
+
 }  // namespace tsfile_cli_test
 
 #endif  // TSFILE_CLI_TEST_UTIL_H
diff --git a/cpp/test/tools/command_e2e_test.cc b/cpp/test/tools/command_e2e_test.cc
index ccd3eba9b..de03cf782 100644
--- a/cpp/test/tools/command_e2e_test.cc
+++ b/cpp/test/tools/command_e2e_test.cc
@@ -34,6 +34,11 @@ struct Fixture {
     ~Fixture() { std::remove(path.c_str()); }
 };
 
+struct TagFilterFixture {
+    std::string path = tsfile_cli_test::write_tag_filter_fixture();
+    ~TagFilterFixture() { std::remove(path.c_str()); }
+};
+
 size_t count_lines(const std::string& s) {
     size_t n = 0;
     for (char c : s) {
@@ -134,6 +139,28 @@ TEST(CliE2E, CatReturnsAllRows) {
     EXPECT_NE(out.str().find("time\ts1\n"), std::string::npos);
 }
 
+TEST(CliE2E, CatPushesDownOffsetAndLimit) {
+    Fixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code = tsfile_cli::run_cli(
+        {"cat", "-m", "s1", "--offset", "2", "-n", "2", "-f", "tsv", f.path},
+        out, err);
+    EXPECT_EQ(code, 0);
+    EXPECT_EQ(out.str(), "time\ts1\n2\t20\n3\t30\n");
+}
+
+TEST(CliE2E, HeadPushesDownOffsetAndLimit) {
+    Fixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code = tsfile_cli::run_cli(
+        {"head", "-m", "s1", "--offset", "1", "-n", "3", "-f", "tsv", f.path},
+        out, err);
+    EXPECT_EQ(code, 0);
+    EXPECT_EQ(out.str(), "time\ts1\n1\t10\n2\t20\n3\t30\n");
+}
+
 TEST(CliE2E, CatWithTimeRange) {
     Fixture f;
     std::ostringstream out;
@@ -145,6 +172,65 @@ TEST(CliE2E, CatWithTimeRange) {
     EXPECT_EQ(out.str(), "time\ts1\n2\t20\n3\t30\n");
 }
 
+TEST(CliE2E, CatAppliesOffsetAfterTimeRange) {
+    Fixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code =
+        tsfile_cli::run_cli({"cat", "-m", "s1", "--start", "1", "--end", "4",
+                             "--offset", "1", "-n", "2", "-f", "tsv", f.path},
+                            out, err);
+    EXPECT_EQ(code, 0);
+    EXPECT_EQ(out.str(), "time\ts1\n2\t20\n3\t30\n");
+}
+
+TEST(CliE2E, CatFiltersRowsByTagEq) {
+    TagFilterFixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code = tsfile_cli::run_cli({"cat", "-m", "s1", "--tag-filter", "id1",
+                                    "eq", "dev_b", "-f", "tsv", f.path},
+                                   out, err);
+    EXPECT_EQ(code, 0) << err.str();
+    EXPECT_EQ(out.str(), "time\ts1\n1\t20\n2\t30\n");
+}
+
+TEST(CliE2E, HeadFiltersRowsByTagBetween) {
+    TagFilterFixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code =
+        tsfile_cli::run_cli({"head", "-m", "s1", "--tag-between", "id1",
+                             "dev_b", "dev_c", "-n", "10", "-f", "tsv", f.path},
+                            out, err);
+    EXPECT_EQ(code, 0) << err.str();
+    EXPECT_EQ(out.str(), "time\ts1\n1\t20\n2\t30\n3\t40\n");
+}
+
+TEST(CliE2E, SampleFiltersRowsByTagEq) {
+    TagFilterFixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code = tsfile_cli::run_cli(
+        {"sample", "-m", "s1", "--tag-filter", "id1", "eq", "dev_b", "-n", "10",
+         "--seed", "1", "-f", "tsv", f.path},
+        out, err);
+    EXPECT_EQ(code, 0) << err.str();
+    EXPECT_EQ(out.str(), "time\ts1\n1\t20\n2\t30\n");
+}
+
+TEST(CliE2E, TagFilterRejectsFieldColumn) {
+    TagFilterFixture f;
+    std::ostringstream out;
+    std::ostringstream err;
+    int code = tsfile_cli::run_cli({"cat", "-m", "s1", "--tag-filter", "s1",
+                                    "eq", "20", "-f", "tsv", f.path},
+                                   out, err);
+    EXPECT_EQ(code, 1);
+    EXPECT_NE(err.str().find("invalid tag filter column"), std::string::npos)
+        << err.str();
+}
+
 TEST(CliE2E, CatJsonIsNdjson) {
     Fixture f;
     std::ostringstream out;
@@ -181,6 +267,39 @@ TEST(CliE2E, CountReportsSeriesCountsAndTotal) {
     EXPECT_NE(out.str().find("total\t\t"), std::string::npos);
 }
 
+TEST(CliE2E, MetadataTableFilterIsCaseInsensitive) {
+    Fixture f;
+
+    std::ostringstream schema_out;
+    std::ostringstream schema_err;
+    EXPECT_EQ(
+        tsfile_cli::run_cli({"schema", "-t", "TABLE1", "-f", "tsv", f.path},
+                            schema_out, schema_err),
+        0);
+    EXPECT_NE(schema_out.str().find("table1\ts1\tINT64"), std::string::npos)
+        << schema_out.str();
+
+    std::ostringstream count_out;
+    std::ostringstream count_err;
+    EXPECT_EQ(
+        tsfile_cli::run_cli({"count", "-t", "TABLE1", "-f", "tsv", f.path},
+                            count_out, count_err),
+        0);
+    EXPECT_NE(count_out.str().find("table1.id1_field_1.id2_field_2\ts1\t5"),
+              std::string::npos)
+        << count_out.str();
+
+    std::ostringstream stats_out;
+    std::ostringstream stats_err;
+    EXPECT_EQ(
+        tsfile_cli::run_cli({"stats", "-t", "TABLE1", "-f", "tsv", f.path},
+                            stats_out, stats_err),
+        0);
+    EXPECT_NE(stats_out.str().find("table1.id1_field_1.id2_field_2\ts1\t5"),
+              std::string::npos)
+        << stats_out.str();
+}
+
 TEST(CliE2E, SampleIsReproducibleWithSeed) {
     Fixture f;
     std::ostringstream out1;
diff --git a/cpp/tools/README.md b/cpp/tools/README.md
index bc8e5617f..42269be9a 100644
--- a/cpp/tools/README.md
+++ b/cpp/tools/README.md
@@ -44,6 +44,7 @@ Choose any one of the following.
 ```bash
 bash build.sh -t=Debug      # -> cpp/build/Debug/bin/tsfile-cli
 bash build.sh               # Release (default) -> cpp/build/Release/bin/tsfile-cli
+bash build.sh install       # Release build, then run make install
 ```
 
 **2. Maven (builds the whole C++ module).** From the repository root:
@@ -77,9 +78,10 @@ Verify the binary:
 ```
 
 The executable links the `tsfile` shared library built alongside it. To run it from
-anywhere, either run it in place by its full path, or use CMake's install step
-(`cmake --install .` / `make install`), which installs the binary to `<prefix>/bin` and
-`libtsfile` to `<prefix>/lib`.
+anywhere, either run it in place by its full path, or explicitly install it with
+`bash build.sh install`, `cmake --install .`, or `make install`. The install step places
+the binary under `<prefix>/bin` and `libtsfile` under `<prefix>/lib`. The build script
+does not install by default.
 
 ## Usage
 
@@ -117,6 +119,7 @@ Shared options:
 | `-n, --limit N` / `--offset N` | Max rows / rows to skip (`head`, `cat`; `--offset` not valid for `sample`) |
 | `--start <ms>` / `--end <ms>` | Inclusive epoch-millisecond time range (`head`, `cat`, `sample`) |
 | `--seed N` | Reproducible sampling seed (`sample` only) |
+| `--tag-filter C OP V` / `--tag-between C L U` / `--tag-not-between C L U` | Table TAG predicate for `head`, `cat`, `sample`; `OP` is `eq`, `neq`, `lt`, `lteq`, `gt`, `gteq`, `regexp`, or `not-regexp` |
 | `--no-header` | Omit the header row |
 | `--model tree\|table` | Force the model (otherwise auto-detected) |
 
@@ -130,6 +133,7 @@ BIN=cpp/build/Debug/bin/tsfile-cli
 $BIN ls -f tsv data.tsfile                          # list tables / devices
 $BIN meta data.tsfile                               # quick file overview
 $BIN count -t table1 -f tsv data.tsfile             # row counts, no page scan
+$BIN cat -t table1 --tag-filter device eq dev_1 -m temp -f tsv data.tsfile
 $BIN cat -m temp,humidity --start 1700000000000 -f csv data.tsfile | head
 $BIN sample -m temp -n 20 --seed 42 -f json data.tsfile | jq .
 ```
diff --git a/cpp/tools/cli/cli_args.cc b/cpp/tools/cli/cli_args.cc
index c34145da9..731db6ff6 100644
--- a/cpp/tools/cli/cli_args.cc
+++ b/cpp/tools/cli/cli_args.cc
@@ -67,6 +67,29 @@ bool parse_format(const std::string& s, ParsedArgs::Format& out) {
     return true;
 }
 
+bool parse_tag_filter_op(const std::string& s, ParsedArgs::TagFilterOp& out) {
+    if (s == "eq" || s == "=" || s == "==") {
+        out = ParsedArgs::TagFilterOp::kEq;
+    } else if (s == "neq" || s == "ne" || s == "!=") {
+        out = ParsedArgs::TagFilterOp::kNeq;
+    } else if (s == "lt" || s == "<") {
+        out = ParsedArgs::TagFilterOp::kLt;
+    } else if (s == "lteq" || s == "lte" || s == "le" || s == "<=") {
+        out = ParsedArgs::TagFilterOp::kLteq;
+    } else if (s == "gt" || s == ">") {
+        out = ParsedArgs::TagFilterOp::kGt;
+    } else if (s == "gteq" || s == "gte" || s == "ge" || s == ">=") {
+        out = ParsedArgs::TagFilterOp::kGteq;
+    } else if (s == "regexp" || s == "regex" || s == "=~") {
+        out = ParsedArgs::TagFilterOp::kRegexp;
+    } else if (s == "not-regexp" || s == "not-regex" || s == "!~") {
+        out = ParsedArgs::TagFilterOp::kNotRegexp;
+    } else {
+        return false;
+    }
+    return true;
+}
+
 }  // namespace
 
 ParsedArgs parse_args(const std::vector<std::string>& args) {
@@ -99,6 +122,17 @@ ParsedArgs parse_args(const std::vector<std::string>& args) {
         dst = args[++i];
         return true;
     };
+    auto need_tag_filter_slot = [&](const std::string& flag) -> bool {
+        if (p.has_tag_filter) {
+            p.error = "Only one tag filter predicate is supported";
+            return false;
+        }
+        if (i + 3 >= args.size()) {
+            p.error = "Missing value for " + flag;
+            return false;
+        }
+        return true;
+    };
 
     for (; i < args.size(); ++i) {
         const std::string& a = args[i];
@@ -180,6 +214,30 @@ ParsedArgs parse_args(const std::vector<std::string>& args) {
             p.verbose = true;
         } else if (a == "--header-match") {
             p.header_match = true;
+        } else if (a == "--tag-filter") {
+            if (!need_tag_filter_slot(a)) {
+                return p;
+            }
+            p.has_tag_filter = true;
+            p.tag_filter_column = args[++i];
+            std::string op = args[++i];
+            if (!parse_tag_filter_op(op, p.tag_filter_op)) {
+                p.error = "Invalid --tag-filter operator: " + op +
+                          " (use eq|neq|lt|lteq|gt|gteq|regexp|not-regexp)";
+                return p;
+            }
+            p.tag_filter_value = args[++i];
+        } else if (a == "--tag-between" || a == "--tag-not-between") {
+            if (!need_tag_filter_slot(a)) {
+                return p;
+            }
+            p.has_tag_filter = true;
+            p.tag_filter_op = (a == "--tag-between")
+                                  ? ParsedArgs::TagFilterOp::kBetween
+                                  : ParsedArgs::TagFilterOp::kNotBetween;
+            p.tag_filter_column = args[++i];
+            p.tag_filter_value = args[++i];
+            p.tag_filter_value2 = args[++i];
         } else if (a == "--model") {
             if (!need_value(a, val)) {
                 return p;
diff --git a/cpp/tools/cli/cli_args.h b/cpp/tools/cli/cli_args.h
index 90fa95e79..56199997a 100644
--- a/cpp/tools/cli/cli_args.h
+++ b/cpp/tools/cli/cli_args.h
@@ -28,6 +28,19 @@ namespace tsfile_cli {
 
 struct ParsedArgs {
     enum class Format { kAuto, kCsv, kTsv, kJson, kTable };
+    enum class TagFilterOp {
+        kNone,
+        kEq,
+        kNeq,
+        kLt,
+        kLteq,
+        kGt,
+        kGteq,
+        kRegexp,
+        kNotRegexp,
+        kBetween,
+        kNotBetween,
+    };
 
     std::string command;  // subcommand, e.g. "ls"/"write" (args[0])
     std::string file;     // positional <file.tsfile>; for write, the CSV/TSV
@@ -50,6 +63,11 @@ struct ParsedArgs {
     std::string columns;           // --columns spec for write (name:TYPE:cat,..)
     bool verbose = false;          // -v/--verbose; write progress to stderr
     bool header_match = false;     // --header-match; validate write header row
+    bool has_tag_filter = false;   // --tag-filter/--tag-between was supplied
+    TagFilterOp tag_filter_op = TagFilterOp::kNone;
+    std::string tag_filter_column;  // TAG column name for table row queries
+    std::string tag_filter_value;   // comparison value or BETWEEN lower bound
+    std::string tag_filter_value2;  // BETWEEN upper bound
     bool help = false;             // -h/--help requested
     bool version = false;          // --version requested
     std::string error;             // non-empty if parsing failed (the message)
diff --git a/cpp/tools/cli/run_cli.cc b/cpp/tools/cli/run_cli.cc
index d7ca285a1..27ca751f0 100644
--- a/cpp/tools/cli/run_cli.cc
+++ b/cpp/tools/cli/run_cli.cc
@@ -70,6 +70,10 @@ void print_usage(std::ostream& os) {
           "      --start <ms>         inclusive lower time bound\n"
           "      --end <ms>           inclusive upper time bound\n"
           "      --seed N             RNG seed for sample\n"
+          "      --tag-filter C OP V   table TAG predicate; OP is "
+          "eq|neq|lt|lteq|gt|gteq|regexp|not-regexp\n"
+          "      --tag-between C L U   table TAG predicate: L <= C <= U\n"
+          "      --tag-not-between C L U  table TAG predicate outside [L,U]\n"
           "      --no-header          omit the header row\n"
           "      --model tree|table   force the data model (else auto)\n"
           "  -h, --help               print this help\n"
@@ -141,6 +145,10 @@ bool validate_write_flags(const ParsedArgs& p, std::ostream& err) {
         err << "Error: --header-match cannot be combined with --no-header\n";
         return false;
     }
+    if (p.has_tag_filter) {
+        err << "Error: tag filter flags are not valid for write\n";
+        return false;
+    }
     // Name the offending flag so the user does not have to guess which of
     // the read-only options triggered the rejection.
     if (!p.measurements.empty()) {
@@ -211,6 +219,18 @@ bool validate_read_flag_applicability(const ParsedArgs& p, std::ostream& err) {
         err << "Error: --start/--end are only valid for head/cat/sample\n";
         return false;
     }
+    if (p.has_tag_filter && !is_row) {
+        err << "Error: tag filter flags are only valid for head/cat/sample\n";
+        return false;
+    }
+    if (p.has_tag_filter && p.model == "tree") {
+        err << "Error: tag filter flags are only valid for table model\n";
+        return false;
+    }
+    if (p.has_tag_filter && !p.device.empty()) {
+        err << "Error: tag filter flags cannot be combined with -d/--device\n";
+        return false;
+    }
     if (!scoped && !p.device.empty()) {
         err << "Error: -d/--device is not valid for " << c << "\n";
         return false;
diff --git a/cpp/tools/commands/cmd_sample.cc b/cpp/tools/commands/cmd_sample.cc
index 1123df26a..0a9c06360 100644
--- a/cpp/tools/commands/cmd_sample.cc
+++ b/cpp/tools/commands/cmd_sample.cc
@@ -18,6 +18,7 @@
  */
 
 #include <limits>
+#include <memory>
 #include <string>
 #include <vector>
 
@@ -25,6 +26,7 @@
 #include "commands/commands.h"
 #include "common/schema.h"
 #include "format/result_set_format.h"
+#include "reader/filter/filter.h"
 #include "reader/tsfile_reader.h"
 
 namespace tsfile_cli {
@@ -37,6 +39,7 @@ int cmd_sample(const ParsedArgs& args, storage::TsFileReader& reader,
                                      : std::numeric_limits<int64_t>::max();
     storage::ResultSet* rs = nullptr;
     int qret = 0;
+    std::unique_ptr<storage::Filter> tag_filter;
 
     if (is_table_model(args, reader)) {
         std::string table_name = args.table;
@@ -55,8 +58,16 @@ int cmd_sample(const ParsedArgs& args, storage::TsFileReader& reader,
                 cols = ts->get_measurement_names();
             }
         }
-        qret = reader.query(table_name, cols, start, end, rs);
+        tag_filter = build_table_tag_filter(args, reader, table_name, err);
+        if (args.has_tag_filter && tag_filter == nullptr) {
+            return kExitUsage;
+        }
+        qret = reader.query(table_name, cols, start, end, rs, tag_filter.get());
     } else {
+        if (args.has_tag_filter) {
+            err << "Error: tag filter flags are only valid for table model\n";
+            return kExitUsage;
+        }
         std::vector<std::string> paths = collect_tree_query_paths(args, reader);
         if (paths.empty()) {
             err << "Error: no time series found\n";
diff --git a/cpp/tools/commands/cmd_schema.cc b/cpp/tools/commands/cmd_schema.cc
index 3e03f4f99..9b8442d8c 100644
--- a/cpp/tools/commands/cmd_schema.cc
+++ b/cpp/tools/commands/cmd_schema.cc
@@ -27,18 +27,21 @@
 #include "commands/commands.h"
 #include "common/schema.h"
 #include "reader/tsfile_reader.h"
+#include "utils/storage_utils.h"
 
 namespace tsfile_cli {
 namespace {
 
 void write_table_schema_rows(const ParsedArgs& args,
                              storage::TsFileReader& reader, RowWriter& w) {
+    const std::string target_table_name = storage::to_lower(args.table);
     auto schemas = reader.get_all_table_schemas();
     for (auto& schema : schemas) {
         if (!schema) {
             continue;
         }
-        if (!args.table.empty() && schema->get_table_name() != args.table) {
+        if (!target_table_name.empty() &&
+            schema->get_table_name() != target_table_name) {
             continue;
         }
         for (const auto& ms : schema->get_measurement_schemas()) {
diff --git a/cpp/tools/commands/commands.h b/cpp/tools/commands/commands.h
index 5e26fd64b..ea7038ff5 100644
--- a/cpp/tools/commands/commands.h
+++ b/cpp/tools/commands/commands.h
@@ -20,6 +20,7 @@
 #ifndef TSFILE_CLI_COMMANDS_H
 #define TSFILE_CLI_COMMANDS_H
 
+#include <memory>
 #include <ostream>
 #include <string>
 #include <vector>
@@ -28,6 +29,7 @@
 #include "format/output_format.h"
 
 namespace storage {
+class Filter;
 class TsFileReader;
 }  // namespace storage
 
@@ -43,6 +45,10 @@ bool is_table_model(const ParsedArgs& args, storage::TsFileReader& reader);
 std::vector<std::string> collect_tree_query_paths(
     const ParsedArgs& args, storage::TsFileReader& reader);
 
+std::unique_ptr<storage::Filter> build_table_tag_filter(
+    const ParsedArgs& args, storage::TsFileReader& reader,
+    const std::string& table_name, std::ostream& err);
+
 int run_row_query(const ParsedArgs& args, storage::TsFileReader& reader,
                   OutputFormat fmt, std::ostream& out, std::ostream& err,
                   long long offset, long long limit);
diff --git a/cpp/tools/commands/row_query.cc b/cpp/tools/commands/row_query.cc
index 702deefd6..5acb63a4b 100644
--- a/cpp/tools/commands/row_query.cc
+++ b/cpp/tools/commands/row_query.cc
@@ -18,6 +18,7 @@
  */
 
 #include <algorithm>
+#include <climits>
 #include <limits>
 #include <memory>
 #include <string>
@@ -28,9 +29,87 @@
 #include "common/device_id.h"
 #include "common/schema.h"
 #include "format/result_set_format.h"
+#include "reader/filter/tag_filter.h"
 #include "reader/tsfile_reader.h"
 
 namespace tsfile_cli {
+namespace {
+
+bool can_push_down_row_window(const ParsedArgs& args, long long offset,
+                              long long limit) {
+    return !args.has_start && !args.has_end && offset <= INT_MAX &&
+           (limit < 0 || limit <= INT_MAX);
+}
+
+int to_reader_row_bound(long long value) {
+    return value < 0 ? -1 : static_cast<int>(value);
+}
+
+}  // namespace
+
+std::unique_ptr<storage::Filter> build_table_tag_filter(
+    const ParsedArgs& args, storage::TsFileReader& reader,
+    const std::string& table_name, std::ostream& err) {
+    if (!args.has_tag_filter) {
+        return std::unique_ptr<storage::Filter>();
+    }
+    auto schema = reader.get_table_schema(table_name);
+    if (!schema) {
+        err << "Error: no schema found for table " << table_name << "\n";
+        return std::unique_ptr<storage::Filter>();
+    }
+
+    storage::TagFilterBuilder builder(schema.get());
+    storage::Filter* filter = nullptr;
+    switch (args.tag_filter_op) {
+        case ParsedArgs::TagFilterOp::kEq:
+            filter = builder.eq(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kNeq:
+            filter = builder.neq(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kLt:
+            filter = builder.lt(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kLteq:
+            filter =
+                builder.lteq(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kGt:
+            filter = builder.gt(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kGteq:
+            filter =
+                builder.gteq(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kRegexp:
+            filter =
+                builder.reg_exp(args.tag_filter_column, args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kNotRegexp:
+            filter = builder.not_reg_exp(args.tag_filter_column,
+                                         args.tag_filter_value);
+            break;
+        case ParsedArgs::TagFilterOp::kBetween:
+            filter = builder.between_and(args.tag_filter_column,
+                                         args.tag_filter_value,
+                                         args.tag_filter_value2);
+            break;
+        case ParsedArgs::TagFilterOp::kNotBetween:
+            filter = builder.not_between_and(args.tag_filter_column,
+                                             args.tag_filter_value,
+                                             args.tag_filter_value2);
+            break;
+        case ParsedArgs::TagFilterOp::kNone:
+            break;
+    }
+    if (filter == nullptr) {
+        err << "Error: invalid tag filter column '" << args.tag_filter_column
+            << "' for table " << table_name << "\n";
+        return std::unique_ptr<storage::Filter>();
+    }
+    return std::unique_ptr<storage::Filter>(filter);
+}
 
 std::vector<std::string> collect_tree_query_paths(
     const ParsedArgs& args, storage::TsFileReader& reader) {
@@ -91,6 +170,8 @@ int run_row_query(const ParsedArgs& args, storage::TsFileReader& reader,
 
     storage::ResultSet* rs = nullptr;
     int qret = 0;
+    const bool push_down = can_push_down_row_window(args, offset, limit);
+    std::unique_ptr<storage::Filter> tag_filter;
 
     if (is_table_model(args, reader)) {
         std::string table_name = args.table;
@@ -109,14 +190,35 @@ int run_row_query(const ParsedArgs& args, storage::TsFileReader& reader,
                 cols = ts->get_measurement_names();
             }
         }
-        qret = reader.query(table_name, cols, start, end, rs);
+        tag_filter = build_table_tag_filter(args, reader, table_name, err);
+        if (args.has_tag_filter && tag_filter == nullptr) {
+            return kExitUsage;
+        }
+        if (push_down) {
+            qret = reader.queryByRow(table_name, cols,
+                                     to_reader_row_bound(offset),
+                                     to_reader_row_bound(limit), rs,
+                                     tag_filter.get());
+        } else {
+            qret = reader.query(table_name, cols, start, end, rs,
+                                tag_filter.get());
+        }
     } else {
+        if (args.has_tag_filter) {
+            err << "Error: tag filter flags are only valid for table model\n";
+            return kExitUsage;
+        }
         std::vector<std::string> paths = collect_tree_query_paths(args, reader);
         if (paths.empty()) {
             err << "Error: no time series found\n";
             return kExitRuntime;
         }
-        qret = reader.query(paths, start, end, rs);
+        if (push_down) {
+            qret = reader.queryByRow(paths, to_reader_row_bound(offset),
+                                     to_reader_row_bound(limit), rs);
+        } else {
+            qret = reader.query(paths, start, end, rs);
+        }
     }
 
     if (qret != 0 || rs == nullptr) {
@@ -127,7 +229,10 @@ int run_row_query(const ParsedArgs& args, storage::TsFileReader& reader,
         return kExitRuntime;
     }
 
-    int wret = emit_result_set(rs, fmt, args.no_header, out, offset, limit);
+    int wret = push_down
+                   ? emit_result_set(rs, fmt, args.no_header, out)
+                   : emit_result_set(rs, fmt, args.no_header, out, offset,
+                                     limit);
     reader.destroy_query_data_set(rs);
     if (wret != 0) {
         err << "Error: failed to read rows: " << error_code_message(wret)
diff --git a/cpp/tools/commands/statistics.cc b/cpp/tools/commands/statistics.cc
index f1c21b994..acb7e01b9 100644
--- a/cpp/tools/commands/statistics.cc
+++ b/cpp/tools/commands/statistics.cc
@@ -27,6 +27,7 @@
 #include "commands/commands.h"
 #include "common/statistic.h"
 #include "reader/tsfile_reader.h"
+#include "utils/storage_utils.h"
 
 namespace tsfile_cli {
 namespace {
@@ -138,6 +139,7 @@ StatisticCells statistic_value_cells(storage::Statistic* st) {
 std::vector<SeriesStatRow> collect_series_stats(const ParsedArgs& args,
                                                 storage::TsFileReader& reader) {
     std::vector<SeriesStatRow> rows;
+    const std::string target_table_name = storage::to_lower(args.table);
     storage::DeviceTimeseriesMetadataMap meta =
         reader.get_timeseries_metadata();
     for (auto& kv : meta) {
@@ -145,8 +147,8 @@ std::vector<SeriesStatRow> collect_series_stats(const ParsedArgs& args,
         if (!args.device.empty() && target != args.device) {
             continue;
         }
-        if (!args.table.empty() && kv.first &&
-            kv.first->get_table_name() != args.table) {
+        if (!target_table_name.empty() && kv.first &&
+            kv.first->get_table_name() != target_table_name) {
             continue;
         }
         for (auto& ts : kv.second) {
diff --git a/cpp/tools/skills/tsfile-cli/SKILL.md b/cpp/tools/skills/tsfile-cli/SKILL.md
index 4405050cd..9ff82db64 100644
--- a/cpp/tools/skills/tsfile-cli/SKILL.md
+++ b/cpp/tools/skills/tsfile-cli/SKILL.md
@@ -58,9 +58,11 @@ Table model + row verbs (`head/cat/sample`): without `-t`, only the **first** ta
 opts: -f csv|tsv|json|table  (default TTY→table, pipe→tsv)
       -d <device> | -t <table>   (mutually exclusive)
       -m a,b,c (projection) · -n N · --offset N · --start <ms> · --end <ms> (inclusive)
+      --tag-filter C OP V · --tag-between C L U · --tag-not-between C L U (table TAG predicates)
       --seed N · --no-header · --model tree|table (else auto)
 applies: -m → schema/stats/count/head/cat/sample · -d/-t → row cmds/schema/stats/count
-        (-d needs tree model, -t needs table model in head/cat/sample/schema) · --offset ∉ sample
+         (-d needs tree model, -t needs table model in head/cat/sample/schema) · --offset ∉ sample
+         tag filters → head/cat/sample table model; OP=eq|neq|lt|lteq|gt|gteq|regexp|not-regexp
 json=NDJSON (num/bool bare, else quoted, null→null, NaN/Inf→null) · csv=RFC4180 · ts=raw epoch ms
 exit: 0 ok · 1 usage · 2 file open/corrupt · 3 query/runtime
 ```
@@ -68,6 +70,7 @@ exit: 0 ok · 1 usage · 2 file open/corrupt · 3 query/runtime
 ```sh
 B=cpp/build/Debug/bin/tsfile-cli
 $B meta data.tsfile; $B count -t table1 -f tsv data.tsfile
+$B cat -t table1 --tag-filter device eq dev_1 -m temp -f tsv data.tsfile
 $B cat -m temp --start 1700000000000 -f csv data.tsfile 2>/dev/null | head
 ```