| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| #include <arrow/api.h> |
| #include <gtest/gtest.h> |
| |
| #include <algorithm> |
| #include <chrono> |
| #include <cmath> |
| #include <limits> |
| #include <thread> |
| #include <tuple> |
| |
| #include "test_utils.h" |
| |
| class LogTableTest : public ::testing::Test { |
| protected: |
| fluss::Admin& admin() { return fluss_test::FlussTestEnvironment::Instance()->GetAdmin(); } |
| |
| fluss::Connection& connection() { |
| return fluss_test::FlussTestEnvironment::Instance()->GetConnection(); |
| } |
| }; |
| |
| TEST_F(LogTableTest, AppendRecordBatchAndScan) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_append_record_batch_and_scan_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("c1", fluss::DataType::Int()) |
| .AddColumn("c2", fluss::DataType::String()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(3) |
| .SetBucketKeys({"c1"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| // Create append writer |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| |
| // Append Arrow record batches |
| { |
| auto c1 = arrow::Int32Builder(); |
| c1.AppendValues({1, 2, 3}).ok(); |
| auto c2 = arrow::StringBuilder(); |
| c2.AppendValues({"a1", "a2", "a3"}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("c1", arrow::int32()), arrow::field("c2", arrow::utf8())}), |
| 3, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| |
| { |
| auto c1 = arrow::Int32Builder(); |
| c1.AppendValues({4, 5, 6}).ok(); |
| auto c2 = arrow::StringBuilder(); |
| c2.AppendValues({"a4", "a5", "a6"}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("c1", arrow::int32()), arrow::field("c2", arrow::utf8())}), |
| 3, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Create scanner and subscribe to all 3 buckets |
| fluss::Table scan_table; |
| ASSERT_OK(conn.GetTable(table_path, scan_table)); |
| int32_t num_buckets = scan_table.GetTableInfo().num_buckets; |
| ASSERT_EQ(num_buckets, 3) << "Table should have 3 buckets"; |
| |
| auto table_scan = scan_table.NewScan(); |
| fluss::LogScanner log_scanner; |
| ASSERT_OK(table_scan.CreateLogScanner(log_scanner)); |
| |
| for (int32_t bucket_id = 0; bucket_id < num_buckets; ++bucket_id) { |
| ASSERT_OK(log_scanner.Subscribe(bucket_id, fluss::EARLIEST_OFFSET)); |
| } |
| |
| // Poll for records across all buckets |
| std::vector<std::pair<int32_t, std::string>> records; |
| fluss_test::PollRecords(log_scanner, 6, [](const fluss::ScanRecord& rec) { |
| return std::make_pair(rec.row.GetInt32(0), std::string(rec.row.GetString(1))); |
| }, records); |
| ASSERT_EQ(records.size(), 6u) << "Expected 6 records"; |
| std::sort(records.begin(), records.end()); |
| |
| std::vector<std::pair<int32_t, std::string>> expected = { |
| {1, "a1"}, {2, "a2"}, {3, "a3"}, {4, "a4"}, {5, "a5"}, {6, "a6"}}; |
| EXPECT_EQ(records, expected); |
| |
| // Verify per-bucket iteration via BucketRecords |
| { |
| fluss::Table bucket_table; |
| ASSERT_OK(conn.GetTable(table_path, bucket_table)); |
| auto bucket_scan = bucket_table.NewScan(); |
| fluss::LogScanner bucket_scanner; |
| ASSERT_OK(bucket_scan.CreateLogScanner(bucket_scanner)); |
| |
| for (int32_t bid = 0; bid < num_buckets; ++bid) { |
| ASSERT_OK(bucket_scanner.Subscribe(bid, fluss::EARLIEST_OFFSET)); |
| } |
| |
| std::vector<std::pair<int32_t, std::string>> bucket_records; |
| auto bucket_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); |
| size_t buckets_with_data = 0; |
| while (bucket_records.size() < 6 && std::chrono::steady_clock::now() < bucket_deadline) { |
| fluss::ScanRecords scan_records; |
| ASSERT_OK(bucket_scanner.Poll(500, scan_records)); |
| |
| // Iterate by bucket |
| for (size_t b = 0; b < scan_records.BucketCount(); ++b) { |
| auto bkt_records = scan_records.BucketAt(b); |
| if (!bkt_records.Empty()) { |
| buckets_with_data++; |
| } |
| for (auto rec : bkt_records) { |
| bucket_records.emplace_back(rec.row.GetInt32(0), |
| std::string(rec.row.GetString(1))); |
| } |
| } |
| } |
| |
| ASSERT_EQ(bucket_records.size(), 6u) << "Expected 6 records via per-bucket iteration"; |
| EXPECT_GT(buckets_with_data, 1u) << "Records should be distributed across multiple buckets"; |
| |
| std::sort(bucket_records.begin(), bucket_records.end()); |
| EXPECT_EQ(bucket_records, expected); |
| } |
| |
| // Test unsubscribe |
| ASSERT_OK(log_scanner.Unsubscribe(0)); |
| |
| // Verify unsubscribe_partition fails on a non-partitioned table |
| auto unsub_result = log_scanner.UnsubscribePartition(0, 0); |
| ASSERT_FALSE(unsub_result.Ok()) |
| << "unsubscribe_partition should fail on a non-partitioned table"; |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, LimitScan) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_limit_scan_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("c1", fluss::DataType::Int()) |
| .AddColumn("c2", fluss::DataType::String()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"c1"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| { |
| auto c1 = arrow::Int32Builder(); |
| c1.AppendValues({1, 2, 3, 4, 5}).ok(); |
| auto c2 = arrow::StringBuilder(); |
| c2.AppendValues({"a", "b", "c", "d", "e"}).ok(); |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("c1", arrow::int32()), arrow::field("c2", arrow::utf8())}), |
| 5, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie()}); |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| int64_t table_id = table.GetTableInfo().table_id; |
| fluss::TableBucket bucket{table_id, 0}; |
| |
| fluss::BatchScanner scanner; |
| ASSERT_OK(table.NewScan().Limit(3).CreateBucketBatchScanner(bucket, scanner)); |
| EXPECT_TRUE(scanner.Bucket() == bucket); |
| |
| fluss::ArrowRecordBatches first; |
| ASSERT_OK(scanner.NextBatch(first)); |
| ASSERT_FALSE(first.Empty()) << "first NextBatch should return a batch"; |
| int64_t rows = 0; |
| for (const auto& b : first) { |
| EXPECT_EQ(b->GetTableId(), table_id); |
| EXPECT_EQ(b->GetBucketId(), 0); |
| rows += b->NumRows(); |
| } |
| // The server may return fewer rows than the limit, but never more. |
| EXPECT_GT(rows, 0); |
| EXPECT_LE(rows, 3); |
| |
| fluss::ArrowRecordBatches spent; |
| ASSERT_OK(scanner.NextBatch(spent)); |
| EXPECT_TRUE(spent.Empty()) << "scanner must be spent after one batch"; |
| |
| fluss::BatchScanner scanner2; |
| ASSERT_OK(table.NewScan().Limit(10).CreateBucketBatchScanner(bucket, scanner2)); |
| fluss::ArrowRecordBatches all; |
| ASSERT_OK(scanner2.CollectAllBatches(all)); |
| int64_t total = 0; |
| for (const auto& b : all) { |
| total += b->NumRows(); |
| } |
| EXPECT_EQ(total, 5) << "limit 10 over a 5-row bucket returns all rows"; |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, LimitScanProjection) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_limit_scan_projection_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("c1", fluss::DataType::Int()) |
| .AddColumn("c2", fluss::DataType::String()) |
| .AddColumn("c3", fluss::DataType::BigInt()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"c1"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| { |
| auto c1 = arrow::Int32Builder(); |
| c1.AppendValues({1, 2}).ok(); |
| auto c2 = arrow::StringBuilder(); |
| c2.AppendValues({"a", "b"}).ok(); |
| auto c3 = arrow::Int64Builder(); |
| c3.AppendValues({100, 200}).ok(); |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("c1", arrow::int32()), arrow::field("c2", arrow::utf8()), |
| arrow::field("c3", arrow::int64())}), |
| 2, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie(), c3.Finish().ValueOrDie()}); |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| fluss::TableBucket bucket{table.GetTableInfo().table_id, 0}; |
| |
| // Projecting [c1, c3] skips the middle c2 string column. |
| fluss::BatchScanner scanner; |
| ASSERT_OK( |
| table.NewScan().ProjectByIndex({0, 2}).Limit(10).CreateBucketBatchScanner(bucket, scanner)); |
| fluss::ArrowRecordBatches all; |
| ASSERT_OK(scanner.CollectAllBatches(all)); |
| ASSERT_EQ(all.Size(), 1u); |
| |
| auto rb = all[0]->GetArrowRecordBatch(); |
| ASSERT_EQ(rb->num_columns(), 2); |
| EXPECT_EQ(rb->schema()->field(0)->name(), "c1"); |
| EXPECT_EQ(rb->schema()->field(1)->name(), "c3"); |
| ASSERT_EQ(rb->num_rows(), 2); |
| auto c1a = std::static_pointer_cast<arrow::Int32Array>(rb->column(0)); |
| auto c3a = std::static_pointer_cast<arrow::Int64Array>(rb->column(1)); |
| EXPECT_EQ(c1a->Value(0), 1); |
| EXPECT_EQ(c1a->Value(1), 2); |
| EXPECT_EQ(c3a->Value(0), 100); |
| EXPECT_EQ(c3a->Value(1), 200); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, LimitScanErrors) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_limit_scan_errors_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder().AddColumn("c1", fluss::DataType::Int()).Build(); |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"c1"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| int64_t table_id = table.GetTableInfo().table_id; |
| |
| { |
| fluss::BatchScanner s; |
| EXPECT_FALSE( |
| table.NewScan().CreateBucketBatchScanner(fluss::TableBucket{table_id, 0}, s).Ok()); |
| } |
| for (int32_t bad : {0, -5}) { |
| fluss::BatchScanner s; |
| EXPECT_FALSE(table.NewScan() |
| .Limit(bad) |
| .CreateBucketBatchScanner(fluss::TableBucket{table_id, 0}, s) |
| .Ok()); |
| } |
| for (const auto& bad : |
| {fluss::TableBucket{table_id + 9999, 0}, fluss::TableBucket{table_id, 99}}) { |
| fluss::BatchScanner s; |
| EXPECT_FALSE(table.NewScan().Limit(1).CreateBucketBatchScanner(bad, s).Ok()); |
| } |
| { |
| fluss::LogScanner s; |
| EXPECT_FALSE(table.NewScan().Limit(5).CreateLogScanner(s).Ok()); |
| fluss::LogScanner s2; |
| EXPECT_FALSE(table.NewScan().Limit(5).CreateRecordBatchLogScanner(s2).Ok()); |
| } |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| |
| // A non-ARROW (INDEXED) log table rejects a limit scan. |
| fluss::TablePath indexed_path("fluss", "test_limit_scan_indexed_cpp"); |
| auto indexed_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"c1"}) |
| .SetLogFormat("INDEXED") |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, indexed_path, indexed_descriptor); |
| fluss::Table indexed_table; |
| ASSERT_OK(conn.GetTable(indexed_path, indexed_table)); |
| { |
| fluss::BatchScanner s; |
| fluss::TableBucket b{indexed_table.GetTableInfo().table_id, 0}; |
| EXPECT_FALSE(indexed_table.NewScan().Limit(1).CreateBucketBatchScanner(b, s).Ok()); |
| } |
| ASSERT_OK(adm.DropTable(indexed_path, false)); |
| } |
| |
| TEST_F(LogTableTest, ListOffsets) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_list_offsets_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("name", fluss::DataType::String()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| // Wait for table initialization |
| std::this_thread::sleep_for(std::chrono::seconds(2)); |
| |
| // Earliest offset should be 0 for empty table |
| std::unordered_map<int32_t, int64_t> earliest_offsets; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Earliest(), earliest_offsets)); |
| EXPECT_EQ(earliest_offsets[0], 0) << "Earliest offset should be 0 for bucket 0"; |
| |
| // Latest offset should be 0 for empty table |
| std::unordered_map<int32_t, int64_t> latest_offsets; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Latest(), latest_offsets)); |
| EXPECT_EQ(latest_offsets[0], 0) << "Latest offset should be 0 for empty table"; |
| |
| auto before_append_ms = |
| std::chrono::duration_cast<std::chrono::milliseconds>( |
| std::chrono::system_clock::now().time_since_epoch()) |
| .count(); |
| |
| // Append records |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| |
| { |
| auto id_builder = arrow::Int32Builder(); |
| id_builder.AppendValues({1, 2, 3}).ok(); |
| auto name_builder = arrow::StringBuilder(); |
| name_builder.AppendValues({"alice", "bob", "charlie"}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema( |
| {arrow::field("id", arrow::int32()), arrow::field("name", arrow::utf8())}), |
| 3, {id_builder.Finish().ValueOrDie(), name_builder.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| std::this_thread::sleep_for(std::chrono::seconds(1)); |
| |
| auto after_append_ms = |
| std::chrono::duration_cast<std::chrono::milliseconds>( |
| std::chrono::system_clock::now().time_since_epoch()) |
| .count(); |
| |
| // Latest offset after appending should be 3 |
| std::unordered_map<int32_t, int64_t> latest_after; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Latest(), latest_after)); |
| EXPECT_EQ(latest_after[0], 3) << "Latest offset should be 3 after appending 3 records"; |
| |
| // Earliest offset should still be 0 |
| std::unordered_map<int32_t, int64_t> earliest_after; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Earliest(), earliest_after)); |
| EXPECT_EQ(earliest_after[0], 0) << "Earliest offset should still be 0"; |
| |
| // Timestamp before append should resolve to offset 0 |
| std::unordered_map<int32_t, int64_t> ts_offsets; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Timestamp(before_append_ms), |
| ts_offsets)); |
| EXPECT_EQ(ts_offsets[0], 0) |
| << "Timestamp before append should resolve to offset 0"; |
| |
| // Timestamp after append should resolve to offset 3 |
| std::unordered_map<int32_t, int64_t> ts_after_offsets; |
| ASSERT_OK(adm.ListOffsets(table_path, {0}, fluss::OffsetSpec::Timestamp(after_append_ms), |
| ts_after_offsets)); |
| EXPECT_EQ(ts_after_offsets[0], 3) |
| << "Timestamp after append should resolve to offset 3"; |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, TestProject) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_project_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("col_a", fluss::DataType::Int()) |
| .AddColumn("col_b", fluss::DataType::String()) |
| .AddColumn("col_c", fluss::DataType::Int()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| // Append 3 records |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| |
| { |
| auto col_a_builder = arrow::Int32Builder(); |
| col_a_builder.AppendValues({1, 2, 3}).ok(); |
| auto col_b_builder = arrow::StringBuilder(); |
| col_b_builder.AppendValues({"x", "y", "z"}).ok(); |
| auto col_c_builder = arrow::Int32Builder(); |
| col_c_builder.AppendValues({10, 20, 30}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("col_a", arrow::int32()), |
| arrow::field("col_b", arrow::utf8()), |
| arrow::field("col_c", arrow::int32())}), |
| 3, |
| {col_a_builder.Finish().ValueOrDie(), col_b_builder.Finish().ValueOrDie(), |
| col_c_builder.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Test project_by_name: select col_b and col_c only |
| { |
| fluss::Table proj_table; |
| ASSERT_OK(conn.GetTable(table_path, proj_table)); |
| auto scan = proj_table.NewScan(); |
| scan.ProjectByName({"col_b", "col_c"}); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| fluss::ScanRecords records; |
| ASSERT_OK(scanner.Poll(10000, records)); |
| |
| ASSERT_EQ(records.Count(), 3u) << "Should have 3 records with project_by_name"; |
| |
| std::vector<std::string> expected_col_b = {"x", "y", "z"}; |
| std::vector<int32_t> expected_col_c = {10, 20, 30}; |
| |
| // Collect and sort by col_c to get deterministic order |
| std::vector<std::pair<std::string, int32_t>> collected; |
| for (auto rec : records) { |
| collected.emplace_back(std::string(rec.row.GetString(0)), rec.row.GetInt32(1)); |
| } |
| std::sort(collected.begin(), collected.end(), |
| [](const auto& a, const auto& b) { return a.second < b.second; }); |
| |
| for (size_t i = 0; i < 3; ++i) { |
| EXPECT_EQ(collected[i].first, expected_col_b[i]) << "col_b mismatch at index " << i; |
| EXPECT_EQ(collected[i].second, expected_col_c[i]) << "col_c mismatch at index " << i; |
| } |
| } |
| |
| // Test project by column indices: select col_b (1) and col_a (0) in that order |
| { |
| fluss::Table proj_table; |
| ASSERT_OK(conn.GetTable(table_path, proj_table)); |
| auto scan = proj_table.NewScan(); |
| scan.ProjectByIndex({1, 0}); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| fluss::ScanRecords records; |
| ASSERT_OK(scanner.Poll(10000, records)); |
| |
| ASSERT_EQ(records.Count(), 3u); |
| |
| std::vector<std::string> expected_col_b = {"x", "y", "z"}; |
| std::vector<int32_t> expected_col_a = {1, 2, 3}; |
| |
| std::vector<std::pair<std::string, int32_t>> collected; |
| for (auto rec : records) { |
| collected.emplace_back(std::string(rec.row.GetString(0)), rec.row.GetInt32(1)); |
| } |
| std::sort(collected.begin(), collected.end(), |
| [](const auto& a, const auto& b) { return a.second < b.second; }); |
| |
| for (size_t i = 0; i < 3; ++i) { |
| EXPECT_EQ(collected[i].first, expected_col_b[i]) << "col_b mismatch at index " << i; |
| EXPECT_EQ(collected[i].second, expected_col_a[i]) << "col_a mismatch at index " << i; |
| } |
| } |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, TestPollBatches) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_poll_batches_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("name", fluss::DataType::String()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| std::this_thread::sleep_for(std::chrono::seconds(1)); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateRecordBatchLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| // Test 1: Empty table should return empty result |
| { |
| fluss::ArrowRecordBatches batches; |
| ASSERT_OK(scanner.PollRecordBatch(500, batches)); |
| ASSERT_TRUE(batches.Empty()); |
| } |
| |
| // Append data |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter writer; |
| ASSERT_OK(table_append.CreateWriter(writer)); |
| |
| auto make_batch = [](std::vector<int32_t> ids, std::vector<std::string> names) { |
| auto id_builder = arrow::Int32Builder(); |
| id_builder.AppendValues(ids).ok(); |
| auto name_builder = arrow::StringBuilder(); |
| name_builder.AppendValues(names).ok(); |
| return arrow::RecordBatch::Make( |
| arrow::schema( |
| {arrow::field("id", arrow::int32()), arrow::field("name", arrow::utf8())}), |
| static_cast<int64_t>(ids.size()), |
| {id_builder.Finish().ValueOrDie(), name_builder.Finish().ValueOrDie()}); |
| }; |
| |
| ASSERT_OK(writer.AppendArrowBatch(make_batch({1, 2}, {"a", "b"}))); |
| ASSERT_OK(writer.AppendArrowBatch(make_batch({3, 4}, {"c", "d"}))); |
| ASSERT_OK(writer.AppendArrowBatch(make_batch({5, 6}, {"e", "f"}))); |
| ASSERT_OK(writer.Flush()); |
| |
| // Extract ids from Arrow batches |
| auto extract_ids = [](const fluss::ArrowRecordBatches& batches) { |
| std::vector<int32_t> ids; |
| for (const auto& batch : batches) { |
| auto arr = |
| std::static_pointer_cast<arrow::Int32Array>(batch->GetArrowRecordBatch()->column(0)); |
| for (int64_t i = 0; i < arr->length(); ++i) { |
| ids.push_back(arr->Value(i)); |
| } |
| } |
| return ids; |
| }; |
| |
| // Test 2: Poll until we get all 6 records |
| std::vector<int32_t> all_ids; |
| fluss_test::PollRecordBatches(scanner, 6, extract_ids, all_ids); |
| ASSERT_EQ(all_ids, (std::vector<int32_t>{1, 2, 3, 4, 5, 6})); |
| |
| // Test 3: Append more and verify offset continuation (no duplicates) |
| ASSERT_OK(writer.AppendArrowBatch(make_batch({7, 8}, {"g", "h"}))); |
| ASSERT_OK(writer.Flush()); |
| |
| std::vector<int32_t> new_ids; |
| fluss_test::PollRecordBatches(scanner, 2, extract_ids, new_ids); |
| ASSERT_EQ(new_ids, (std::vector<int32_t>{7, 8})); |
| |
| // Test 4: Subscribing from mid-offset should truncate batch |
| { |
| fluss::Table trunc_table; |
| ASSERT_OK(conn.GetTable(table_path, trunc_table)); |
| auto trunc_scan = trunc_table.NewScan(); |
| fluss::LogScanner trunc_scanner; |
| ASSERT_OK(trunc_scan.CreateRecordBatchLogScanner(trunc_scanner)); |
| ASSERT_OK(trunc_scanner.Subscribe(0, 3)); |
| |
| std::vector<int32_t> trunc_ids; |
| fluss_test::PollRecordBatches(trunc_scanner, 5, extract_ids, trunc_ids); |
| ASSERT_EQ(trunc_ids, (std::vector<int32_t>{4, 5, 6, 7, 8})); |
| } |
| |
| // Test 5: Projection should only return requested columns |
| { |
| fluss::Table proj_table; |
| ASSERT_OK(conn.GetTable(table_path, proj_table)); |
| auto proj_scan = proj_table.NewScan(); |
| proj_scan.ProjectByName({"id"}); |
| fluss::LogScanner proj_scanner; |
| ASSERT_OK(proj_scan.CreateRecordBatchLogScanner(proj_scanner)); |
| ASSERT_OK(proj_scanner.Subscribe(0, 0)); |
| |
| fluss::ArrowRecordBatches proj_batches; |
| ASSERT_OK(proj_scanner.PollRecordBatch(10000, proj_batches)); |
| |
| ASSERT_FALSE(proj_batches.Empty()); |
| EXPECT_EQ(proj_batches[0]->GetArrowRecordBatch()->num_columns(), 1) |
| << "Projected batch should have 1 column (id), not 2"; |
| } |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, AllSupportedDatatypes) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_log_all_datatypes_cpp"); |
| |
| // Create a log table with all supported datatypes |
| auto schema = |
| fluss::Schema::NewBuilder() |
| .AddColumn("col_tinyint", fluss::DataType::TinyInt()) |
| .AddColumn("col_smallint", fluss::DataType::SmallInt()) |
| .AddColumn("col_int", fluss::DataType::Int()) |
| .AddColumn("col_bigint", fluss::DataType::BigInt()) |
| .AddColumn("col_float", fluss::DataType::Float()) |
| .AddColumn("col_double", fluss::DataType::Double()) |
| .AddColumn("col_boolean", fluss::DataType::Boolean()) |
| .AddColumn("col_char", fluss::DataType::Char(10)) |
| .AddColumn("col_string", fluss::DataType::String()) |
| .AddColumn("col_decimal", fluss::DataType::Decimal(10, 2)) |
| .AddColumn("col_date", fluss::DataType::Date()) |
| .AddColumn("col_time", fluss::DataType::Time()) |
| .AddColumn("col_timestamp", fluss::DataType::Timestamp()) |
| .AddColumn("col_timestamp_ltz", fluss::DataType::TimestampLtz()) |
| .AddColumn("col_bytes", fluss::DataType::Bytes()) |
| .AddColumn("col_binary", fluss::DataType::Binary(4)) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| size_t field_count = table.GetTableInfo().schema.columns.size(); |
| |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| |
| // Test data |
| int32_t col_tinyint = 127; |
| int32_t col_smallint = 32767; |
| int32_t col_int = 2147483647; |
| int64_t col_bigint = 9223372036854775807LL; |
| float col_float = 3.14f; |
| double col_double = 2.718281828459045; |
| bool col_boolean = true; |
| std::string col_char = "hello"; |
| std::string col_string = "world of fluss rust client"; |
| std::string col_decimal = "123.45"; |
| auto col_date = fluss::Date::FromDays(20476); // 2026-01-23 |
| auto col_time = fluss::Time::FromMillis(36827000); // 10:13:47 |
| auto col_timestamp = fluss::Timestamp::FromMillisNanos(1769163227123, 456000); |
| auto col_timestamp_ltz = fluss::Timestamp::FromMillisNanos(1769163227123, 456000); |
| std::vector<uint8_t> col_bytes = {'b', 'i', 'n', 'a', 'r', 'y', ' ', 'd', 'a', 't', 'a'}; |
| std::vector<uint8_t> col_binary = {0xDE, 0xAD, 0xBE, 0xEF}; |
| |
| // Append a row with all datatypes |
| { |
| fluss::GenericRow row(field_count); |
| row.SetInt32(0, col_tinyint); |
| row.SetInt32(1, col_smallint); |
| row.SetInt32(2, col_int); |
| row.SetInt64(3, col_bigint); |
| row.SetFloat32(4, col_float); |
| row.SetFloat64(5, col_double); |
| row.SetBool(6, col_boolean); |
| row.SetString(7, col_char); |
| row.SetString(8, col_string); |
| row.SetDecimal(9, col_decimal); |
| row.SetDate(10, col_date); |
| row.SetTime(11, col_time); |
| row.SetTimestampNtz(12, col_timestamp); |
| row.SetTimestampLtz(13, col_timestamp_ltz); |
| row.SetBytes(14, col_bytes); |
| row.SetBytes(15, col_binary); |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| |
| // Append a row with null values |
| { |
| fluss::GenericRow row_with_nulls(field_count); |
| for (size_t i = 0; i < field_count; ++i) { |
| row_with_nulls.SetNull(i); |
| } |
| ASSERT_OK(append_writer.Append(row_with_nulls)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Scan the records |
| fluss::Table scan_table; |
| ASSERT_OK(conn.GetTable(table_path, scan_table)); |
| auto table_scan = scan_table.NewScan(); |
| fluss::LogScanner log_scanner; |
| ASSERT_OK(table_scan.CreateLogScanner(log_scanner)); |
| ASSERT_OK(log_scanner.Subscribe(0, 0)); |
| |
| // Poll until we get 2 records |
| std::vector<fluss::ScanRecord> all_records; |
| fluss_test::PollRecords(log_scanner, 2, |
| [](const fluss::ScanRecord& rec) { return rec; }, all_records); |
| ASSERT_EQ(all_records.size(), 2u) << "Expected 2 records"; |
| |
| // Verify first record (all values) |
| auto& row = all_records[0].row; |
| |
| EXPECT_EQ(row.GetInt32(0), col_tinyint) << "col_tinyint mismatch"; |
| EXPECT_EQ(row.GetInt32(1), col_smallint) << "col_smallint mismatch"; |
| EXPECT_EQ(row.GetInt32(2), col_int) << "col_int mismatch"; |
| EXPECT_EQ(row.GetInt64(3), col_bigint) << "col_bigint mismatch"; |
| EXPECT_NEAR(row.GetFloat32(4), col_float, 1e-6f) << "col_float mismatch"; |
| EXPECT_NEAR(row.GetFloat64(5), col_double, 1e-15) << "col_double mismatch"; |
| EXPECT_EQ(row.GetBool(6), col_boolean) << "col_boolean mismatch"; |
| EXPECT_EQ(row.GetString(7), col_char) << "col_char mismatch"; |
| EXPECT_EQ(row.GetString(8), col_string) << "col_string mismatch"; |
| EXPECT_EQ(row.GetDecimalString(9), col_decimal) << "col_decimal mismatch"; |
| EXPECT_EQ(row.GetDate(10).days_since_epoch, col_date.days_since_epoch) << "col_date mismatch"; |
| EXPECT_EQ(row.GetTime(11).millis_since_midnight, col_time.millis_since_midnight) |
| << "col_time mismatch"; |
| EXPECT_EQ(row.GetTimestamp(12).epoch_millis, col_timestamp.epoch_millis) |
| << "col_timestamp millis mismatch"; |
| EXPECT_EQ(row.GetTimestamp(12).nano_of_millisecond, col_timestamp.nano_of_millisecond) |
| << "col_timestamp nanos mismatch"; |
| EXPECT_EQ(row.GetTimestamp(13).epoch_millis, col_timestamp_ltz.epoch_millis) |
| << "col_timestamp_ltz millis mismatch"; |
| EXPECT_EQ(row.GetTimestamp(13).nano_of_millisecond, col_timestamp_ltz.nano_of_millisecond) |
| << "col_timestamp_ltz nanos mismatch"; |
| |
| auto [bytes_ptr, bytes_len] = row.GetBytes(14); |
| EXPECT_EQ(bytes_len, col_bytes.size()) << "col_bytes length mismatch"; |
| EXPECT_TRUE(std::memcmp(bytes_ptr, col_bytes.data(), bytes_len) == 0) |
| << "col_bytes mismatch"; |
| |
| auto [binary_ptr, binary_len] = row.GetBytes(15); |
| EXPECT_EQ(binary_len, col_binary.size()) << "col_binary length mismatch"; |
| EXPECT_TRUE(std::memcmp(binary_ptr, col_binary.data(), binary_len) == 0) |
| << "col_binary mismatch"; |
| |
| // Verify second record (all nulls) |
| auto& null_row = all_records[1].row; |
| for (size_t i = 0; i < field_count; ++i) { |
| EXPECT_TRUE(null_row.IsNull(i)) << "column " << i << " should be null"; |
| } |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, PartitionedTableAppendScan) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_partitioned_log_append_cpp"); |
| |
| // Create a partitioned log table |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("region", fluss::DataType::String()) |
| .AddColumn("value", fluss::DataType::BigInt()) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetPartitionKeys({"region"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| // Create partitions |
| fluss_test::CreatePartitions(adm, table_path, "region", {"US", "EU"}); |
| |
| // Wait for partitions |
| std::this_thread::sleep_for(std::chrono::seconds(2)); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| auto table_append = table.NewAppend(); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table_append.CreateWriter(append_writer)); |
| |
| // Append rows |
| struct TestData { |
| int32_t id; |
| std::string region; |
| int64_t value; |
| }; |
| std::vector<TestData> test_data = {{1, "US", 100}, {2, "US", 200}, {3, "EU", 300}, {4, "EU", 400}}; |
| |
| for (const auto& d : test_data) { |
| fluss::GenericRow row(3); |
| row.SetInt32(0, d.id); |
| row.SetString(1, d.region); |
| row.SetInt64(2, d.value); |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Append arrow batches per partition |
| { |
| auto id_builder = arrow::Int32Builder(); |
| id_builder.AppendValues({5, 6}).ok(); |
| auto region_builder = arrow::StringBuilder(); |
| region_builder.AppendValues({"US", "US"}).ok(); |
| auto value_builder = arrow::Int64Builder(); |
| value_builder.AppendValues({500, 600}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("id", arrow::int32()), |
| arrow::field("region", arrow::utf8()), |
| arrow::field("value", arrow::int64())}), |
| 2, |
| {id_builder.Finish().ValueOrDie(), region_builder.Finish().ValueOrDie(), |
| value_builder.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| |
| { |
| auto id_builder = arrow::Int32Builder(); |
| id_builder.AppendValues({7, 8}).ok(); |
| auto region_builder = arrow::StringBuilder(); |
| region_builder.AppendValues({"EU", "EU"}).ok(); |
| auto value_builder = arrow::Int64Builder(); |
| value_builder.AppendValues({700, 800}).ok(); |
| |
| auto batch = arrow::RecordBatch::Make( |
| arrow::schema({arrow::field("id", arrow::int32()), |
| arrow::field("region", arrow::utf8()), |
| arrow::field("value", arrow::int64())}), |
| 2, |
| {id_builder.Finish().ValueOrDie(), region_builder.Finish().ValueOrDie(), |
| value_builder.Finish().ValueOrDie()}); |
| |
| ASSERT_OK(append_writer.AppendArrowBatch(batch)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Test list partition offsets |
| std::unordered_map<int32_t, int64_t> us_offsets; |
| ASSERT_OK(adm.ListPartitionOffsets(table_path, "US", {0}, fluss::OffsetSpec::Latest(), |
| us_offsets)); |
| EXPECT_EQ(us_offsets[0], 4) << "US partition should have 4 records"; |
| |
| std::unordered_map<int32_t, int64_t> eu_offsets; |
| ASSERT_OK(adm.ListPartitionOffsets(table_path, "EU", {0}, fluss::OffsetSpec::Latest(), |
| eu_offsets)); |
| EXPECT_EQ(eu_offsets[0], 4) << "EU partition should have 4 records"; |
| |
| // Subscribe to all partitions and scan |
| fluss::Table scan_table; |
| ASSERT_OK(conn.GetTable(table_path, scan_table)); |
| auto table_scan = scan_table.NewScan(); |
| fluss::LogScanner log_scanner; |
| ASSERT_OK(table_scan.CreateLogScanner(log_scanner)); |
| |
| std::vector<fluss::PartitionInfo> partition_infos; |
| ASSERT_OK(adm.ListPartitionInfos(table_path, partition_infos)); |
| |
| for (const auto& pi : partition_infos) { |
| ASSERT_OK(log_scanner.SubscribePartitionBuckets(pi.partition_id, 0, 0)); |
| } |
| |
| // Collect all records |
| using Record = std::tuple<int32_t, std::string, int64_t>; |
| auto extract_record = [](const fluss::ScanRecord& rec) -> Record { |
| return {rec.row.GetInt32(0), std::string(rec.row.GetString(1)), rec.row.GetInt64(2)}; |
| }; |
| std::vector<Record> collected; |
| fluss_test::PollRecords(log_scanner, 8, extract_record, collected); |
| |
| ASSERT_EQ(collected.size(), 8u) << "Expected 8 records total"; |
| std::sort(collected.begin(), collected.end()); |
| |
| std::vector<Record> expected = {{1, "US", 100}, {2, "US", 200}, {3, "EU", 300}, |
| {4, "EU", 400}, {5, "US", 500}, {6, "US", 600}, |
| {7, "EU", 700}, {8, "EU", 800}}; |
| EXPECT_EQ(collected, expected); |
| |
| // Test unsubscribe_partition: unsubscribe EU, should only get US data |
| { |
| fluss::Table unsub_table; |
| ASSERT_OK(conn.GetTable(table_path, unsub_table)); |
| auto unsub_scan = unsub_table.NewScan(); |
| fluss::LogScanner unsub_scanner; |
| ASSERT_OK(unsub_scan.CreateLogScanner(unsub_scanner)); |
| |
| int64_t eu_partition_id = -1; |
| for (const auto& pi : partition_infos) { |
| ASSERT_OK(unsub_scanner.SubscribePartitionBuckets(pi.partition_id, 0, 0)); |
| if (pi.partition_name == "EU") { |
| eu_partition_id = pi.partition_id; |
| } |
| } |
| ASSERT_GE(eu_partition_id, 0) << "EU partition should exist"; |
| |
| ASSERT_OK(unsub_scanner.UnsubscribePartition(eu_partition_id, 0)); |
| |
| std::vector<Record> us_only; |
| fluss_test::PollRecords(unsub_scanner, 4, extract_record, us_only); |
| |
| ASSERT_EQ(us_only.size(), 4u) << "Should receive exactly 4 US records"; |
| for (const auto& [id, region, val] : us_only) { |
| EXPECT_EQ(region, "US") << "After unsubscribe EU, only US data should be read"; |
| } |
| } |
| |
| // Test subscribe_partition_buckets (batch subscribe) |
| { |
| fluss::Table batch_table; |
| ASSERT_OK(conn.GetTable(table_path, batch_table)); |
| auto batch_scan = batch_table.NewScan(); |
| fluss::LogScanner batch_scanner; |
| ASSERT_OK(batch_scan.CreateLogScanner(batch_scanner)); |
| |
| std::vector<fluss::PartitionBucketSubscription> subs; |
| for (const auto& pi : partition_infos) { |
| subs.push_back({pi.partition_id, 0, 0}); |
| } |
| ASSERT_OK(batch_scanner.SubscribePartitionBuckets(subs)); |
| |
| std::vector<Record> batch_collected; |
| fluss_test::PollRecords(batch_scanner, 8, extract_record, batch_collected); |
| ASSERT_EQ(batch_collected.size(), 8u); |
| std::sort(batch_collected.begin(), batch_collected.end()); |
| EXPECT_EQ(batch_collected, expected); |
| } |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| // ============================================================================ |
| // Array data type tests |
| // ============================================================================ |
| |
| TEST_F(LogTableTest, AppendAndScanWithArray) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_append_scan_with_array_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("tags", fluss::DataType::Array(fluss::DataType::String())) |
| .AddColumn("scores", fluss::DataType::Array(fluss::DataType::Int())) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"id"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| auto info = table.GetTableInfo(); |
| ASSERT_GE(info.schema.columns.size(), 3u); |
| const auto& tags_type = info.schema.columns[1].data_type; |
| ASSERT_EQ(tags_type.id(), fluss::TypeId::Array); |
| ASSERT_NE(tags_type.element_type(), nullptr); |
| ASSERT_EQ(tags_type.element_type()->id(), fluss::TypeId::String); |
| const auto& scores_type = info.schema.columns[2].data_type; |
| ASSERT_EQ(scores_type.id(), fluss::TypeId::Array); |
| ASSERT_NE(scores_type.element_type(), nullptr); |
| ASSERT_EQ(scores_type.element_type()->id(), fluss::TypeId::Int); |
| |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| |
| fluss::ArrayWriter tags(2, fluss::DataType::String()); |
| tags.SetString(0, "hello"); |
| tags.SetString(1, "world"); |
| row.SetArray(1, std::move(tags)); |
| |
| fluss::ArrayWriter scores(3, fluss::DataType::Int()); |
| scores.SetInt32(0, 10); |
| scores.SetInt32(1, 20); |
| scores.SetInt32(2, 30); |
| row.SetArray(2, std::move(scores)); |
| |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 2); |
| |
| fluss::ArrayWriter tags(1, fluss::DataType::String()); |
| tags.SetNull(0); |
| row.SetArray(1, std::move(tags)); |
| |
| fluss::ArrayWriter scores(0, fluss::DataType::Int()); |
| row.SetArray(2, std::move(scores)); |
| |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| struct Record { |
| int32_t id; |
| size_t tag_count; |
| std::vector<std::string> tags; |
| size_t score_count; |
| std::vector<int32_t> scores; |
| }; |
| |
| std::vector<Record> collected; |
| auto extract = [](const fluss::ScanRecord& scan_rec) { |
| const auto& rv = scan_rec.row; |
| Record rec; |
| rec.id = rv.GetInt32(0); |
| |
| auto tags = rv.GetValue(1); |
| rec.tag_count = tags.Size(); |
| for (size_t i = 0; i < rec.tag_count; ++i) { |
| auto el = tags.At(i); |
| rec.tags.push_back(el.IsNull() ? "<null>" : el.GetString()); |
| } |
| |
| auto scores = rv.GetValue(2); |
| rec.score_count = scores.Size(); |
| for (size_t i = 0; i < rec.score_count; ++i) { |
| rec.scores.push_back(scores.At(i).GetInt32()); |
| } |
| |
| return rec; |
| }; |
| |
| fluss_test::PollRecords(scanner, 2, extract, collected); |
| |
| ASSERT_EQ(collected.size(), 2u); |
| |
| std::sort(collected.begin(), collected.end(), |
| [](const Record& a, const Record& b) { return a.id < b.id; }); |
| |
| EXPECT_EQ(collected[0].id, 1); |
| ASSERT_EQ(collected[0].tag_count, 2u); |
| EXPECT_EQ(collected[0].tags[0], "hello"); |
| EXPECT_EQ(collected[0].tags[1], "world"); |
| ASSERT_EQ(collected[0].score_count, 3u); |
| EXPECT_EQ(collected[0].scores[0], 10); |
| EXPECT_EQ(collected[0].scores[1], 20); |
| EXPECT_EQ(collected[0].scores[2], 30); |
| |
| EXPECT_EQ(collected[1].id, 2); |
| ASSERT_EQ(collected[1].tag_count, 1u); |
| EXPECT_EQ(collected[1].tags[0], "<null>"); |
| ASSERT_EQ(collected[1].score_count, 0u); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, AppendAndScanWithMapAndRow) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_append_scan_map_row_cpp"); |
| |
| // MAP / ROW columns can't be built with the flat schema builder. |
| auto arrow_schema = arrow::schema({ |
| arrow::field("id", arrow::int32()), |
| arrow::field("attrs", arrow::map(arrow::utf8(), arrow::int32())), |
| arrow::field("nested", arrow::struct_({arrow::field("seq", arrow::int32()), |
| arrow::field("label", arrow::utf8())})), |
| }); |
| auto schema = fluss::Schema::FromArrow(arrow_schema); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| |
| fluss::MapWriter attrs(2, fluss::DataType::String(), fluss::DataType::Int()); |
| attrs.SetKeyString("a"); |
| attrs.SetValueInt32(1); |
| attrs.Commit(); |
| attrs.SetKeyString("b"); |
| attrs.SetValueInt32(2); |
| attrs.Commit(); |
| row.Set("attrs", std::move(attrs)); |
| |
| fluss::GenericRow nested(2); |
| nested.SetInt32(0, 7); |
| nested.SetString(1, "seven"); |
| row.Set("nested", std::move(nested)); |
| |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| struct Record { |
| int32_t id; |
| size_t attr_count; |
| std::string k0; |
| int32_t v0; |
| int32_t seq; |
| std::string label; |
| }; |
| std::vector<Record> collected; |
| auto extract = [](const fluss::ScanRecord& scan_rec) { |
| const auto& rv = scan_rec.row; |
| Record rec; |
| rec.id = rv.GetInt32(0); |
| auto attrs = rv.GetValue(1); |
| rec.attr_count = attrs.Size(); |
| rec.k0 = attrs.KeyAt(0).GetString(); |
| rec.v0 = attrs.ValueAt(0).GetInt32(); |
| auto nested = rv.GetValue("nested"); |
| rec.seq = nested.Field(0).GetInt32(); |
| rec.label = nested.Field(1).GetString(); |
| return rec; |
| }; |
| fluss_test::PollRecords(scanner, 1, extract, collected); |
| |
| ASSERT_EQ(collected.size(), 1u); |
| EXPECT_EQ(collected[0].id, 1); |
| EXPECT_EQ(collected[0].attr_count, 2u); |
| EXPECT_EQ(collected[0].k0, "a"); |
| EXPECT_EQ(collected[0].v0, 1); |
| EXPECT_EQ(collected[0].seq, 7); |
| EXPECT_EQ(collected[0].label, "seven"); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, ProjectionWithCompoundTypes) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_log_projection_compound_cpp"); |
| |
| auto arrow_schema = arrow::schema({ |
| arrow::field("id", arrow::int32()), |
| arrow::field("nested", arrow::struct_({arrow::field("seq", arrow::int32()), |
| arrow::field("label", arrow::utf8())})), |
| arrow::field("attrs", arrow::map(arrow::utf8(), arrow::int32())), |
| arrow::field("tags", arrow::list(arrow::utf8())), |
| arrow::field("extra", arrow::utf8()), |
| }); |
| auto schema = fluss::Schema::FromArrow(arrow_schema); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| { |
| auto row = table.NewRow(); |
| row.SetInt32(0, 7); |
| fluss::GenericRow nested(2); |
| nested.SetInt32(0, 42); |
| nested.SetString(1, "hello"); |
| row.SetRow(1, std::move(nested)); |
| fluss::MapWriter attrs(2, fluss::DataType::String(), fluss::DataType::Int()); |
| attrs.SetKeyString("x"); |
| attrs.SetValueInt32(1); |
| attrs.Commit(); |
| attrs.SetKeyString("y"); |
| attrs.SetValueInt32(2); |
| attrs.Commit(); |
| row.SetMap(2, std::move(attrs)); |
| fluss::ArrayWriter tags(2, fluss::DataType::String()); |
| tags.SetString(0, "alpha"); |
| tags.SetString(1, "beta"); |
| row.SetArray(3, std::move(tags)); |
| row.SetString(4, "ignore-me"); |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| ASSERT_OK(append_writer.Flush()); |
| |
| // Project columns reordered, dropping `extra`: new layout is |
| // [nested=0, attrs=1, tags=2, id=3]. |
| auto scan = table.NewScan(); |
| scan.ProjectByName({"nested", "attrs", "tags", "id"}); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| struct Rec { |
| int32_t id; |
| int32_t seq; |
| std::string label; |
| size_t attr_count; |
| size_t tag_count; |
| std::string tag0; |
| }; |
| std::vector<Rec> collected; |
| auto extract = [](const fluss::ScanRecord& sr) { |
| const auto& rv = sr.row; |
| Rec rec; |
| auto nested = rv.GetValue(0); |
| rec.seq = nested.Field(0).GetInt32(); |
| rec.label = nested.Field(1).GetString(); |
| auto m = rv.GetValue(1); |
| rec.attr_count = m.Size(); |
| auto a = rv.GetValue(2); |
| rec.tag_count = a.Size(); |
| rec.tag0 = a.At(0).GetString(); |
| rec.id = rv.GetInt32(3); |
| return rec; |
| }; |
| fluss_test::PollRecords(scanner, 1, extract, collected); |
| |
| ASSERT_EQ(collected.size(), 1u); |
| EXPECT_EQ(collected[0].id, 7); |
| EXPECT_EQ(collected[0].seq, 42); |
| EXPECT_EQ(collected[0].label, "hello"); |
| EXPECT_EQ(collected[0].attr_count, 2u); |
| EXPECT_EQ(collected[0].tag_count, 2u); |
| EXPECT_EQ(collected[0].tag0, "alpha"); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, AppendAndScanWithNestedArray) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_append_scan_nested_array_cpp"); |
| |
| auto schema = |
| fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("matrix", |
| fluss::DataType::Array(fluss::DataType::Array(fluss::DataType::Int()))) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"id"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| |
| fluss::ArrayWriter inner1(2, fluss::DataType::Int()); |
| inner1.SetInt32(0, 1); |
| inner1.SetInt32(1, 2); |
| |
| fluss::ArrayWriter inner2(2, fluss::DataType::Int()); |
| inner2.SetInt32(0, 3); |
| inner2.SetInt32(1, 4); |
| |
| fluss::ArrayWriter outer(2, fluss::DataType::Array(fluss::DataType::Int())); |
| outer.SetArray(0, std::move(inner1)); |
| outer.SetArray(1, std::move(inner2)); |
| |
| row.SetArray(1, std::move(outer)); |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| struct Record { |
| int32_t id; |
| size_t outer_count; |
| fluss::TypeId element_type; |
| std::vector<std::vector<int32_t>> values; |
| }; |
| |
| std::vector<Record> collected; |
| auto extract = [](const fluss::ScanRecord& scan_rec) { |
| const auto& rv = scan_rec.row; |
| Record rec; |
| rec.id = rv.GetInt32(0); |
| auto outer = rv.GetValue(1); |
| rec.outer_count = outer.Size(); |
| rec.element_type = outer.Size() > 0 ? outer.At(0).Type() : fluss::TypeId::Array; |
| rec.values.reserve(outer.Size()); |
| for (size_t i = 0; i < outer.Size(); ++i) { |
| auto inner = outer.At(i); |
| std::vector<int32_t> row; |
| row.reserve(inner.Size()); |
| for (size_t j = 0; j < inner.Size(); ++j) { |
| row.push_back(inner.At(j).GetInt32()); |
| } |
| rec.values.push_back(std::move(row)); |
| } |
| return rec; |
| }; |
| |
| fluss_test::PollRecords(scanner, 1, extract, collected); |
| ASSERT_EQ(collected.size(), 1u); |
| EXPECT_EQ(collected[0].id, 1); |
| EXPECT_EQ(collected[0].outer_count, 2u); |
| EXPECT_EQ(collected[0].element_type, fluss::TypeId::Array); |
| ASSERT_EQ(collected[0].values.size(), 2u); |
| EXPECT_EQ(collected[0].values[0], (std::vector<int32_t>{1, 2})); |
| EXPECT_EQ(collected[0].values[1], (std::vector<int32_t>{3, 4})); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, AppendAndScanWithArrayRichTypes) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_append_scan_array_rich_types_cpp"); |
| |
| auto schema = |
| fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("arr_bytes", fluss::DataType::Array(fluss::DataType::Bytes())) |
| .AddColumn("arr_date", fluss::DataType::Array(fluss::DataType::Date())) |
| .AddColumn("arr_time", fluss::DataType::Array(fluss::DataType::Time())) |
| .AddColumn("arr_ts", fluss::DataType::Array(fluss::DataType::Timestamp(6))) |
| .AddColumn("arr_decimal", fluss::DataType::Array(fluss::DataType::Decimal(10, 2))) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"id"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| |
| fluss::ArrayWriter arr_bytes(2, fluss::DataType::Bytes()); |
| arr_bytes.SetBytes(0, std::vector<uint8_t>{0x10, 0x20, 0x30}); |
| arr_bytes.SetNull(1); |
| row.SetArray(1, std::move(arr_bytes)); |
| |
| fluss::ArrayWriter arr_date(2, fluss::DataType::Date()); |
| auto d0 = fluss::Date::FromDays(20000); |
| arr_date.SetDate(0, d0); |
| arr_date.SetNull(1); |
| row.SetArray(2, std::move(arr_date)); |
| |
| fluss::ArrayWriter arr_time(1, fluss::DataType::Time()); |
| auto t0 = fluss::Time::FromMillis(3600000); |
| arr_time.SetTime(0, t0); |
| row.SetArray(3, std::move(arr_time)); |
| |
| fluss::ArrayWriter arr_ts(1, fluss::DataType::Timestamp(6)); |
| auto ts0 = fluss::Timestamp::FromMillisNanos(1769163227123, 456000); |
| arr_ts.SetTimestampNtz(0, ts0); |
| row.SetArray(4, std::move(arr_ts)); |
| |
| fluss::ArrayWriter arr_decimal(2, fluss::DataType::Decimal(10, 2)); |
| arr_decimal.SetDecimal(0, "123.45"); |
| arr_decimal.SetNull(1); |
| row.SetArray(5, std::move(arr_decimal)); |
| |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| fluss::ScanRecords records; |
| ASSERT_OK(scanner.Poll(10000, records)); |
| ASSERT_EQ(records.Count(), 1u); |
| |
| auto it = records.begin(); |
| ASSERT_TRUE(it != records.end()); |
| auto rec = *it; |
| const auto& rv = rec.row; |
| |
| auto arr_bytes = rv.GetValue(1); |
| EXPECT_EQ(arr_bytes.Size(), 2u); |
| auto bytes0 = arr_bytes.At(0).GetBytes(); |
| ASSERT_EQ(bytes0.size(), 3u); |
| EXPECT_EQ(bytes0[0], 0x10); |
| EXPECT_EQ(bytes0[1], 0x20); |
| EXPECT_EQ(bytes0[2], 0x30); |
| EXPECT_TRUE(arr_bytes.At(1).IsNull()); |
| |
| auto arr_date = rv.GetValue(2); |
| EXPECT_EQ(arr_date.Size(), 2u); |
| EXPECT_EQ(arr_date.At(0).GetDate().days_since_epoch, fluss::Date::FromDays(20000).days_since_epoch); |
| EXPECT_TRUE(arr_date.At(1).IsNull()); |
| |
| auto arr_time = rv.GetValue(3); |
| EXPECT_EQ(arr_time.Size(), 1u); |
| EXPECT_EQ(arr_time.At(0).GetTime().millis_since_midnight, |
| fluss::Time::FromMillis(3600000).millis_since_midnight); |
| |
| auto arr_ts = rv.GetValue(4); |
| EXPECT_EQ(arr_ts.Size(), 1u); |
| auto ts = arr_ts.At(0).GetTimestamp(); |
| EXPECT_EQ(ts.epoch_millis, 1769163227123); |
| EXPECT_EQ(ts.nano_of_millisecond, 456000); |
| |
| auto arr_dec = rv.GetValue(5); |
| EXPECT_EQ(arr_dec.Size(), 2u); |
| EXPECT_EQ(arr_dec.At(0).GetDecimalString(), "123.45"); |
| EXPECT_TRUE(arr_dec.At(1).IsNull()); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, ArrayApiValidationErrors) { |
| // Type mismatch setter should fail through FFI Result propagation. |
| { |
| fluss::ArrayWriter bool_array(1, fluss::DataType::Boolean()); |
| bool threw = false; |
| try { |
| bool_array.SetInt32(0, 42); |
| } catch (const std::exception&) { |
| threw = true; |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| auto& adm = admin(); |
| auto& conn = connection(); |
| fluss::TablePath table_path("fluss", "test_array_api_validation_errors_cpp"); |
| |
| auto schema = fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("vals", fluss::DataType::Array(fluss::DataType::Int())) |
| .Build(); |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"id"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| fluss::ArrayWriter vals(2, fluss::DataType::Int()); |
| vals.SetInt32(0, 7); |
| vals.SetNull(1); |
| row.SetArray(1, std::move(vals)); |
| ASSERT_OK(append_writer.Append(row)); |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| fluss::ScanRecords records; |
| ASSERT_OK(scanner.Poll(10000, records)); |
| ASSERT_EQ(records.Count(), 1u); |
| auto it = records.begin(); |
| ASSERT_TRUE(it != records.end()); |
| auto rec = *it; |
| |
| auto view = rec.row.GetValue(1); |
| EXPECT_EQ(view.Type(), fluss::TypeId::Array); |
| EXPECT_EQ(view.Size(), 2u); |
| EXPECT_TRUE(view.At(1).IsNull()); |
| |
| // Out-of-bounds navigation throws. |
| bool oob_threw = false; |
| try { |
| (void)view.At(5).GetInt32(); |
| } catch (const std::exception&) { |
| oob_threw = true; |
| } |
| EXPECT_TRUE(oob_threw); |
| |
| // Wrong-type leaf read throws. |
| bool wrong_type_threw = false; |
| try { |
| (void)view.At(0).GetInt64(); |
| } catch (const std::exception&) { |
| wrong_type_threw = true; |
| } |
| EXPECT_TRUE(wrong_type_threw); |
| |
| // Typed read of a null element throws. |
| bool null_typed_getter_threw = false; |
| try { |
| (void)view.At(1).GetInt32(); |
| } catch (const std::exception&) { |
| null_typed_getter_threw = true; |
| } |
| EXPECT_TRUE(null_typed_getter_threw); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, AppendAndScanWithArrayEncodingEdgeCases) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_array_encoding_edge_cases_cpp"); |
| |
| auto schema = |
| fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("arr_long_str", fluss::DataType::Array(fluss::DataType::String())) |
| .AddColumn("arr_big_decimal", fluss::DataType::Array(fluss::DataType::Decimal(22, 5))) |
| .AddColumn("arr_ts_nano", fluss::DataType::Array(fluss::DataType::Timestamp(9))) |
| .AddColumn("arr_float", fluss::DataType::Array(fluss::DataType::Float())) |
| .AddColumn("arr_double", fluss::DataType::Array(fluss::DataType::Double())) |
| .AddColumn("arr_binary", fluss::DataType::Array(fluss::DataType::Binary(4))) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetBucketCount(1) |
| .SetBucketKeys({"id"}) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| fluss::AppendWriter append_writer; |
| ASSERT_OK(table.NewAppend().CreateWriter(append_writer)); |
| |
| { |
| auto row = table.NewRow(); |
| row.Set("id", 1); |
| |
| // >= 8 bytes forces the heap-pointer variable-length path (threshold: 7) |
| fluss::ArrayWriter arr_long_str(2, fluss::DataType::String()); |
| arr_long_str.SetString(0, "abcdefgh"); |
| arr_long_str.SetString(1, "this is a much longer string that definitely exceeds inline"); |
| row.SetArray(1, std::move(arr_long_str)); |
| |
| // precision > 18 forces non-compact decimal encoding |
| fluss::ArrayWriter arr_big_decimal(2, fluss::DataType::Decimal(22, 5)); |
| arr_big_decimal.SetDecimal(0, "12345678901234567.12345"); |
| arr_big_decimal.SetDecimal(1, "-99999999999999999.99999"); |
| row.SetArray(2, std::move(arr_big_decimal)); |
| |
| // precision > 3 forces non-compact timestamp (millis + nanos-of-millis) |
| fluss::ArrayWriter arr_ts_nano(1, fluss::DataType::Timestamp(9)); |
| auto ts_nano = fluss::Timestamp::FromMillisNanos(1769163227123, 456789); |
| arr_ts_nano.SetTimestampNtz(0, ts_nano); |
| row.SetArray(3, std::move(arr_ts_nano)); |
| |
| // IEEE 754 special values: NaN, +Infinity, -Infinity |
| fluss::ArrayWriter arr_float(3, fluss::DataType::Float()); |
| arr_float.SetFloat32(0, std::numeric_limits<float>::quiet_NaN()); |
| arr_float.SetFloat32(1, std::numeric_limits<float>::infinity()); |
| arr_float.SetFloat32(2, -std::numeric_limits<float>::infinity()); |
| row.SetArray(4, std::move(arr_float)); |
| |
| fluss::ArrayWriter arr_double(3, fluss::DataType::Double()); |
| arr_double.SetFloat64(0, std::numeric_limits<double>::quiet_NaN()); |
| arr_double.SetFloat64(1, std::numeric_limits<double>::infinity()); |
| arr_double.SetFloat64(2, -std::numeric_limits<double>::infinity()); |
| row.SetArray(5, std::move(arr_double)); |
| |
| // Fixed-length binary |
| fluss::ArrayWriter arr_binary(2, fluss::DataType::Binary(4)); |
| arr_binary.SetBytes(0, std::vector<uint8_t>{0xDE, 0xAD, 0xBE, 0xEF}); |
| arr_binary.SetNull(1); |
| row.SetArray(6, std::move(arr_binary)); |
| |
| ASSERT_OK(append_writer.Append(row)); |
| } |
| |
| ASSERT_OK(append_writer.Flush()); |
| |
| auto scan = table.NewScan(); |
| fluss::LogScanner scanner; |
| ASSERT_OK(scan.CreateLogScanner(scanner)); |
| ASSERT_OK(scanner.Subscribe(0, 0)); |
| |
| fluss::ScanRecords records; |
| ASSERT_OK(scanner.Poll(10000, records)); |
| ASSERT_EQ(records.Count(), 1u); |
| |
| auto it = records.begin(); |
| ASSERT_TRUE(it != records.end()); |
| auto rec = *it; |
| const auto& rv = rec.row; |
| |
| // Long strings: heap-encoded variable-length round-trip |
| auto strs = rv.GetValue(1); |
| EXPECT_EQ(strs.Size(), 2u); |
| EXPECT_EQ(strs.At(0).GetString(), "abcdefgh"); |
| EXPECT_EQ(strs.At(1).GetString(), "this is a much longer string that definitely exceeds inline"); |
| |
| // Non-compact decimal (precision 22 > MAX_COMPACT_PRECISION 18) |
| auto decs = rv.GetValue(2); |
| EXPECT_EQ(decs.Size(), 2u); |
| EXPECT_EQ(decs.At(0).GetDecimalString(), "12345678901234567.12345"); |
| EXPECT_EQ(decs.At(1).GetDecimalString(), "-99999999999999999.99999"); |
| |
| // Non-compact timestamp (precision 9 > MAX_COMPACT_TIMESTAMP_PRECISION 3) |
| auto tss = rv.GetValue(3); |
| EXPECT_EQ(tss.Size(), 1u); |
| auto ts = tss.At(0).GetTimestamp(); |
| EXPECT_EQ(ts.epoch_millis, 1769163227123); |
| EXPECT_EQ(ts.nano_of_millisecond, 456789); |
| |
| // Float NaN / Infinity round-trip |
| auto floats = rv.GetValue(4); |
| EXPECT_EQ(floats.Size(), 3u); |
| EXPECT_TRUE(std::isnan(floats.At(0).GetFloat32())); |
| EXPECT_TRUE(std::isinf(floats.At(1).GetFloat32())); |
| EXPECT_GT(floats.At(1).GetFloat32(), 0.0f); |
| EXPECT_TRUE(std::isinf(floats.At(2).GetFloat32())); |
| EXPECT_LT(floats.At(2).GetFloat32(), 0.0f); |
| |
| // Double NaN / Infinity round-trip |
| auto doubles = rv.GetValue(5); |
| EXPECT_EQ(doubles.Size(), 3u); |
| EXPECT_TRUE(std::isnan(doubles.At(0).GetFloat64())); |
| EXPECT_TRUE(std::isinf(doubles.At(1).GetFloat64())); |
| EXPECT_GT(doubles.At(1).GetFloat64(), 0.0); |
| EXPECT_TRUE(std::isinf(doubles.At(2).GetFloat64())); |
| EXPECT_LT(doubles.At(2).GetFloat64(), 0.0); |
| |
| // Fixed-length binary round-trip |
| auto bins = rv.GetValue(6); |
| EXPECT_EQ(bins.Size(), 2u); |
| auto bin = bins.At(0).GetBytes(); |
| ASSERT_EQ(bin.size(), 4u); |
| EXPECT_EQ(bin[0], 0xDE); |
| EXPECT_EQ(bin[1], 0xAD); |
| EXPECT_EQ(bin[2], 0xBE); |
| EXPECT_EQ(bin[3], 0xEF); |
| EXPECT_TRUE(bins.At(1).IsNull()); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |
| |
| TEST_F(LogTableTest, ArrayWriterOverflowDetection) { |
| // SetInt32 on TINYINT array must throw when value overflows i8 range (-128..127) |
| { |
| fluss::ArrayWriter tinyint_arr(1, fluss::DataType::TinyInt()); |
| EXPECT_EQ(tinyint_arr.Size(), 1u); |
| bool threw = false; |
| try { |
| tinyint_arr.SetInt32(0, 1000); |
| } catch (const std::exception& e) { |
| threw = true; |
| std::string msg(e.what()); |
| EXPECT_NE(msg.find("TINYINT"), std::string::npos); |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // SetInt32 on SMALLINT array must throw when value overflows i16 range (-32768..32767) |
| { |
| fluss::ArrayWriter smallint_arr(1, fluss::DataType::SmallInt()); |
| bool threw = false; |
| try { |
| smallint_arr.SetInt32(0, 40000); |
| } catch (const std::exception& e) { |
| threw = true; |
| std::string msg(e.what()); |
| EXPECT_NE(msg.find("SMALLINT"), std::string::npos); |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // Negative overflow: -200 doesn't fit TINYINT |
| { |
| fluss::ArrayWriter tinyint_arr(1, fluss::DataType::TinyInt()); |
| bool threw = false; |
| try { |
| tinyint_arr.SetInt32(0, -200); |
| } catch (const std::exception&) { |
| threw = true; |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // Values within range must succeed |
| { |
| fluss::ArrayWriter tinyint_arr(1, fluss::DataType::TinyInt()); |
| EXPECT_NO_THROW(tinyint_arr.SetInt32(0, 127)); |
| } |
| { |
| fluss::ArrayWriter tinyint_arr(1, fluss::DataType::TinyInt()); |
| EXPECT_NO_THROW(tinyint_arr.SetInt32(0, -128)); |
| } |
| { |
| fluss::ArrayWriter smallint_arr(1, fluss::DataType::SmallInt()); |
| EXPECT_NO_THROW(smallint_arr.SetInt32(0, 32767)); |
| } |
| } |
| |
| TEST_F(LogTableTest, MapWriterOverflowDetection) { |
| // Keys/values go through SetKeyInt32/SetValueInt32 (always i32), so TINYINT / |
| // SMALLINT must be range-checked like array elements. |
| |
| // TINYINT map value overflowing i8 (-128..127) must throw. |
| { |
| fluss::MapWriter m(1, fluss::DataType::String(), fluss::DataType::TinyInt()); |
| bool threw = false; |
| try { |
| m.SetValueInt32(1000); |
| } catch (const std::exception& e) { |
| threw = true; |
| EXPECT_NE(std::string(e.what()).find("TINYINT"), std::string::npos); |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // SMALLINT map value overflowing i16 must throw. |
| { |
| fluss::MapWriter m(1, fluss::DataType::String(), fluss::DataType::SmallInt()); |
| bool threw = false; |
| try { |
| m.SetValueInt32(40000); |
| } catch (const std::exception& e) { |
| threw = true; |
| EXPECT_NE(std::string(e.what()).find("SMALLINT"), std::string::npos); |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // Keys are checked the same way: a TINYINT key out of range throws. |
| { |
| fluss::MapWriter m(1, fluss::DataType::TinyInt(), fluss::DataType::Int()); |
| bool threw = false; |
| try { |
| m.SetKeyInt32(-200); |
| } catch (const std::exception& e) { |
| threw = true; |
| EXPECT_NE(std::string(e.what()).find("TINYINT"), std::string::npos); |
| } |
| EXPECT_TRUE(threw); |
| } |
| |
| // In-range key and value must succeed. |
| { |
| fluss::MapWriter m(1, fluss::DataType::TinyInt(), fluss::DataType::SmallInt()); |
| EXPECT_NO_THROW(m.SetKeyInt32(127)); |
| EXPECT_NO_THROW(m.SetValueInt32(32767)); |
| } |
| } |
| |
| TEST_F(LogTableTest, NullabilityPreservedInTableInfo) { |
| auto& adm = admin(); |
| auto& conn = connection(); |
| |
| fluss::TablePath table_path("fluss", "test_nullability_table_info_cpp"); |
| |
| auto schema = |
| fluss::Schema::NewBuilder() |
| .AddColumn("id", fluss::DataType::Int()) |
| .AddColumn("name", fluss::DataType::String()) |
| .AddColumn("tags", fluss::DataType::Array(fluss::DataType::String().NotNull())) |
| .AddColumn("ids", fluss::DataType::Array(fluss::DataType::Int()).NotNull()) |
| .AddColumn("nested", |
| fluss::DataType::Array( |
| fluss::DataType::Array(fluss::DataType::Int()).NotNull())) |
| .SetPrimaryKeys({"id"}) |
| .Build(); |
| |
| auto table_descriptor = fluss::TableDescriptor::NewBuilder() |
| .SetSchema(schema) |
| .SetProperty("table.replication.factor", "1") |
| .Build(); |
| |
| fluss_test::CreateTable(adm, table_path, table_descriptor); |
| |
| fluss::Table table; |
| ASSERT_OK(conn.GetTable(table_path, table)); |
| auto info = table.GetTableInfo(); |
| |
| ASSERT_EQ(info.schema.columns.size(), 5u); |
| EXPECT_EQ(info.primary_keys, std::vector<std::string>{"id"}); |
| |
| // Primary key columns are forced NOT NULL by schema normalization. |
| EXPECT_EQ(info.schema.columns[0].data_type.id(), fluss::TypeId::Int); |
| EXPECT_FALSE(info.schema.columns[0].data_type.nullable()); |
| |
| // "name" STRING (nullable) |
| EXPECT_EQ(info.schema.columns[1].data_type.id(), fluss::TypeId::String); |
| EXPECT_TRUE(info.schema.columns[1].data_type.nullable()); |
| |
| // "tags" ARRAY<STRING NOT NULL> (outer nullable) |
| EXPECT_EQ(info.schema.columns[2].data_type.id(), fluss::TypeId::Array); |
| EXPECT_TRUE(info.schema.columns[2].data_type.nullable()); |
| ASSERT_NE(info.schema.columns[2].data_type.element_type(), nullptr); |
| EXPECT_FALSE(info.schema.columns[2].data_type.element_type()->nullable()); |
| |
| // "ids" ARRAY<INT> NOT NULL (outer not null, element nullable) |
| EXPECT_EQ(info.schema.columns[3].data_type.id(), fluss::TypeId::Array); |
| EXPECT_FALSE(info.schema.columns[3].data_type.nullable()); |
| ASSERT_NE(info.schema.columns[3].data_type.element_type(), nullptr); |
| EXPECT_TRUE(info.schema.columns[3].data_type.element_type()->nullable()); |
| |
| // "nested" ARRAY<ARRAY<INT> NOT NULL> (outer nullable, inner array not null) |
| EXPECT_EQ(info.schema.columns[4].data_type.id(), fluss::TypeId::Array); |
| EXPECT_TRUE(info.schema.columns[4].data_type.nullable()); |
| ASSERT_NE(info.schema.columns[4].data_type.element_type(), nullptr); |
| EXPECT_FALSE(info.schema.columns[4].data_type.element_type()->nullable()); |
| ASSERT_NE(info.schema.columns[4].data_type.element_type()->element_type(), nullptr); |
| EXPECT_TRUE(info.schema.columns[4].data_type.element_type()->element_type()->nullable()); |
| |
| ASSERT_OK(adm.DropTable(table_path, false)); |
| } |