blob: b7902149b9fc2c8cb645ee31fea07d30daa56f74 [file]
/*
* 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 <gtest/gtest.h>
#include <arrow/api.h>
#include "test_utils.h"
class KvTableTest : public ::testing::Test {
protected:
fluss::Admin& admin() { return fluss_test::FlussTestEnvironment::Instance()->GetAdmin(); }
fluss::Connection& connection() {
return fluss_test::FlussTestEnvironment::Instance()->GetConnection();
}
};
TEST_F(KvTableTest, UpsertDeleteAndLookup) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_upsert_and_lookup_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("name", fluss::DataType::String())
.AddColumn("age", fluss::DataType::BigInt())
.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));
// Create upsert writer
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
// Upsert 3 rows (fire-and-forget, then flush)
struct TestData {
int32_t id;
std::string name;
int64_t age;
};
std::vector<TestData> test_data = {{1, "Verso", 32}, {2, "Noco", 25}, {3, "Esquie", 35}};
for (const auto& d : test_data) {
fluss::GenericRow row(3);
row.SetInt32(0, d.id);
row.SetString(1, d.name);
row.SetInt64(2, d.age);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_writer.Flush());
// Create lookuper
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
// Verify lookup results
for (const auto& d : test_data) {
fluss::GenericRow key(3);
key.SetInt32(0, d.id);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found()) << "Row with id=" << d.id << " should exist";
EXPECT_EQ(result.GetInt32(0), d.id) << "id mismatch";
EXPECT_EQ(result.GetString(1), d.name) << "name mismatch";
EXPECT_EQ(result.GetInt64(2), d.age) << "age mismatch";
}
// Update record with id=1 (await acknowledgment)
{
fluss::GenericRow updated_row(3);
updated_row.SetInt32(0, 1);
updated_row.SetString(1, "Verso");
updated_row.SetInt64(2, 33);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(updated_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify the update
{
fluss::GenericRow key(3);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt64(2), 33) << "Age should be updated";
EXPECT_EQ(result.GetString(1), "Verso") << "Name should remain unchanged";
}
// Delete record with id=1 (await acknowledgment)
{
fluss::GenericRow delete_row(3);
delete_row.SetInt32(0, 1);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Delete(delete_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify deletion
{
fluss::GenericRow key(3);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_FALSE(result.Found()) << "Record 1 should not exist after delete";
}
// Verify other records still exist
for (int id : {2, 3}) {
fluss::GenericRow key(3);
key.SetInt32(0, id);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found()) << "Record " << id
<< " should still exist after deleting record 1";
}
// Lookup non-existent key
{
fluss::GenericRow key(3);
key.SetInt32(0, 999);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_FALSE(result.Found()) << "Non-existent key should return not found";
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, LimitScan) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_limit_scan_pk_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("name", fluss::DataType::String())
.SetPrimaryKeys({"id"})
.Build();
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));
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
std::vector<std::pair<int32_t, std::string>> rows = {
{1, "Verso"}, {2, "Noco"}, {3, "Esquie"}, {4, "Aline"}, {5, "Gustave"}};
for (const auto& [id, name] : rows) {
fluss::GenericRow row(2);
row.SetInt32(0, id);
row.SetString(1, name);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_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 scanned = 0;
for (const auto& b : first) {
EXPECT_EQ(b->GetBucketId(), 0);
scanned += b->NumRows();
}
EXPECT_GT(scanned, 0);
EXPECT_LE(scanned, 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(100).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);
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, LookupWithNestedArray) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_lookup_nested_array_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("matrix",
fluss::DataType::Array(fluss::DataType::Array(fluss::DataType::Int())))
.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 upsert = table.NewUpsert();
fluss::UpsertWriter writer;
ASSERT_OK(upsert.CreateWriter(writer));
{
auto row = table.NewRow();
row.Set("id", 1);
fluss::ArrayWriter inner1(2, fluss::DataType::Int());
inner1.SetInt32(0, 11);
inner1.SetInt32(1, 12);
fluss::ArrayWriter inner2(2, fluss::DataType::Int());
inner2.SetInt32(0, 21);
inner2.SetInt32(1, 22);
fluss::ArrayWriter outer(2, fluss::DataType::Array(fluss::DataType::Int()));
outer.SetArray(0, std::move(inner1));
outer.SetArray(1, std::move(inner2));
row.Set("matrix", std::move(outer));
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
}
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.Set("id", 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
auto outer = result.GetValue("matrix");
EXPECT_EQ(outer.Type(), fluss::TypeId::Array);
ASSERT_EQ(outer.Size(), 2u);
auto first = outer.At(0);
EXPECT_EQ(first.Type(), fluss::TypeId::Array);
ASSERT_EQ(first.Size(), 2u);
EXPECT_EQ(first.At(0).GetInt32(), 11);
EXPECT_EQ(first.At(1).GetInt32(), 12);
auto second = outer.At(1);
ASSERT_EQ(second.Size(), 2u);
EXPECT_EQ(second.At(0).GetInt32(), 21);
EXPECT_EQ(second.At(1).GetInt32(), 22);
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, LookupComplexTypesMatrix) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_lookup_complex_matrix_cpp");
auto row_seq_label = arrow::struct_(
{arrow::field("seq", arrow::int32()), arrow::field("label", arrow::utf8())});
auto arrow_schema = arrow::schema({
arrow::field("id", arrow::int32()),
arrow::field("m_str_int", arrow::map(arrow::utf8(), arrow::int32())),
arrow::field("m_str_row", arrow::map(arrow::utf8(), row_seq_label)),
arrow::field("m_str_map",
arrow::map(arrow::utf8(), arrow::map(arrow::utf8(), arrow::int32()))),
arrow::field("m_str_arr", arrow::map(arrow::utf8(), arrow::list(arrow::int32()))),
arrow::field("arr_map", arrow::list(arrow::map(arrow::utf8(), arrow::int32()))),
arrow::field("arr_row", arrow::list(row_seq_label)),
arrow::field("r_deep", arrow::struct_({arrow::field(
"inner", arrow::struct_({arrow::field("n", arrow::int32())}))})),
arrow::field("r_with_arr",
arrow::struct_({arrow::field("f_int", arrow::int32()),
arrow::field("f_arr", arrow::list(arrow::int32()))})),
// row_rich: every scalar type + an array field in one ROW.
arrow::field("r_rich",
arrow::struct_({
arrow::field("f_bool", arrow::boolean()),
arrow::field("f_int", arrow::int32()),
arrow::field("f_long", arrow::int64()),
arrow::field("f_float", arrow::float32()),
arrow::field("f_double", arrow::float64()),
arrow::field("f_str", arrow::utf8()),
arrow::field("f_bytes", arrow::binary()),
arrow::field("f_decimal", arrow::decimal128(10, 2)),
arrow::field("f_date", arrow::date32()),
arrow::field("f_time", arrow::time32(arrow::TimeUnit::MILLI)),
arrow::field("f_ts_ntz", arrow::timestamp(arrow::TimeUnit::MICRO)),
arrow::field("f_ts_ltz", arrow::timestamp(arrow::TimeUnit::MICRO, "UTC")),
arrow::field("f_binary", arrow::fixed_size_binary(4)),
arrow::field("f_arr", arrow::list(arrow::int32())),
})),
arrow::field("m_str_tiny", arrow::map(arrow::utf8(), arrow::int8())),
arrow::field("arr_small", arrow::list(arrow::int16())),
});
auto schema = fluss::Schema::FromArrow(arrow_schema, {"id"});
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 upsert = table.NewUpsert();
fluss::UpsertWriter writer;
ASSERT_OK(upsert.CreateWriter(writer));
{
auto row = table.NewRow();
row.Set("id", 1);
// map<string,int> — second entry has a NULL value.
{
fluss::MapWriter m(2, fluss::DataType::String(), fluss::DataType::Int());
m.SetKeyString("a");
m.SetValueInt32(1);
m.Commit();
m.SetKeyString("b");
m.SetValueNull();
m.Commit();
row.Set("m_str_int", std::move(m));
}
// map<string, row<seq,label>> — value is a ROW, so the Arrow ctor.
{
fluss::MapWriter m(1, arrow::utf8(), row_seq_label);
m.SetKeyString("k");
fluss::GenericRow v(2);
v.SetInt32(0, 7);
v.SetString(1, "seven");
m.SetValueRow(std::move(v));
m.Commit();
row.Set("m_str_row", std::move(m));
}
// map<string, map<string,int>>
{
fluss::MapWriter m(1, arrow::utf8(), arrow::map(arrow::utf8(), arrow::int32()));
m.SetKeyString("k");
fluss::MapWriter inner(1, fluss::DataType::String(), fluss::DataType::Int());
inner.SetKeyString("x");
inner.SetValueInt32(9);
inner.Commit();
m.SetValueMap(std::move(inner));
m.Commit();
row.Set("m_str_map", std::move(m));
}
// map<string, array<int>> — value array fits the flat ctor.
{
fluss::MapWriter m(1, fluss::DataType::String(),
fluss::DataType::Array(fluss::DataType::Int()));
m.SetKeyString("k");
fluss::ArrayWriter v(2, fluss::DataType::Int());
v.SetInt32(0, 10);
v.SetInt32(1, 20);
m.SetValueArray(std::move(v));
m.Commit();
row.Set("m_str_arr", std::move(m));
}
// array<map<string,int>> — element is a MAP, so the Arrow ctor.
{
fluss::ArrayWriter a(1, arrow::map(arrow::utf8(), arrow::int32()));
fluss::MapWriter e(1, fluss::DataType::String(), fluss::DataType::Int());
e.SetKeyString("p");
e.SetValueInt32(5);
e.Commit();
a.SetMap(0, std::move(e));
row.Set("arr_map", std::move(a));
}
// array<row<seq,label>> — element is a ROW, so the Arrow ctor.
{
fluss::ArrayWriter a(2, row_seq_label);
fluss::GenericRow e0(2);
e0.SetInt32(0, 1);
e0.SetString(1, "one");
fluss::GenericRow e1(2);
e1.SetInt32(0, 2);
e1.SetString(1, "two");
a.SetRow(0, std::move(e0));
a.SetRow(1, std::move(e1));
row.Set("arr_row", std::move(a));
}
// row<inner: row<n>>
{
fluss::GenericRow inner(1);
inner.SetInt32(0, 42);
fluss::GenericRow outer(1);
outer.SetRow(0, std::move(inner));
row.Set("r_deep", std::move(outer));
}
// row<f_int, f_arr: array<int>>
{
fluss::GenericRow r(2);
r.SetInt32(0, 100);
fluss::ArrayWriter arr(3, fluss::DataType::Int());
arr.SetInt32(0, 1);
arr.SetInt32(1, 2);
arr.SetInt32(2, 3);
r.SetArray(1, std::move(arr));
row.Set("r_with_arr", std::move(r));
}
// row_rich: exercise every Value leaf getter + an array field.
{
fluss::GenericRow rr(14);
rr.SetBool(0, true);
rr.SetInt32(1, 100000);
rr.SetInt64(2, 9876543210LL);
rr.SetFloat32(3, 3.5F);
rr.SetFloat64(4, 2.5);
rr.SetString(5, "hello world");
rr.SetBytes(6, {'b', 'i', 'n'});
rr.SetDecimal(7, "123.45");
rr.SetDate(8, fluss::Date{20476});
rr.SetTime(9, fluss::Time{36827123});
rr.SetTimestampNtz(10, fluss::Timestamp{1769163227123LL, 456000});
rr.SetTimestampLtz(11, fluss::Timestamp{1769163227456LL, 0});
rr.SetBytes(12, {1, 2, 3, 4});
fluss::ArrayWriter farr(3, fluss::DataType::Int());
farr.SetInt32(0, 7);
farr.SetNull(1);
farr.SetInt32(2, 11);
rr.SetArray(13, std::move(farr));
row.Set("r_rich", std::move(rr));
}
// map<string,tinyint>
{
fluss::MapWriter m(2, fluss::DataType::String(), fluss::DataType::TinyInt());
m.SetKeyString("lo");
m.SetValueInt32(-128);
m.Commit();
m.SetKeyString("hi");
m.SetValueInt32(127);
m.Commit();
row.Set("m_str_tiny", std::move(m));
}
// array<smallint>
{
fluss::ArrayWriter a(2, fluss::DataType::SmallInt());
a.SetInt32(0, 1000);
a.SetInt32(1, -2000);
row.Set("arr_small", std::move(a));
}
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
}
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.Set("id", 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
// map<string,int> — entry 1 has a NULL value.
{
auto m = result.GetValue("m_str_int");
EXPECT_EQ(m.Type(), fluss::TypeId::Map);
ASSERT_EQ(m.Size(), 2u);
EXPECT_EQ(m.KeyAt(0).GetString(), "a");
EXPECT_FALSE(m.ValueAt(0).IsNull());
EXPECT_EQ(m.ValueAt(0).GetInt32(), 1);
EXPECT_EQ(m.KeyAt(1).GetString(), "b");
EXPECT_TRUE(m.ValueAt(1).IsNull());
}
// map<string, row<seq,label>>
{
auto m = result.GetValue("m_str_row");
ASSERT_EQ(m.Size(), 1u);
EXPECT_EQ(m.KeyAt(0).GetString(), "k");
auto v = m.ValueAt(0);
EXPECT_EQ(v.Field(0).GetInt32(), 7);
EXPECT_EQ(v.Field(1).GetString(), "seven");
}
// map<string, map<string,int>>
{
auto inner = result.GetValue("m_str_map").ValueAt(0);
ASSERT_EQ(inner.Size(), 1u);
EXPECT_EQ(inner.KeyAt(0).GetString(), "x");
EXPECT_EQ(inner.ValueAt(0).GetInt32(), 9);
}
// map<string, array<int>>
{
auto av = result.GetValue("m_str_arr").ValueAt(0);
ASSERT_EQ(av.Size(), 2u);
EXPECT_EQ(av.At(0).GetInt32(), 10);
EXPECT_EQ(av.At(1).GetInt32(), 20);
}
// array<map<string,int>>
{
auto a = result.GetValue("arr_map");
ASSERT_EQ(a.Size(), 1u);
auto e = a.At(0);
EXPECT_EQ(e.KeyAt(0).GetString(), "p");
EXPECT_EQ(e.ValueAt(0).GetInt32(), 5);
}
// array<row<seq,label>>
{
auto a = result.GetValue("arr_row");
EXPECT_EQ(a.Type(), fluss::TypeId::Array);
ASSERT_EQ(a.Size(), 2u);
EXPECT_EQ(a.At(0).Field(0).GetInt32(), 1);
EXPECT_EQ(a.At(0).Field(1).GetString(), "one");
EXPECT_EQ(a.At(1).Field(0).GetInt32(), 2);
EXPECT_EQ(a.At(1).Field(1).GetString(), "two");
}
// row<inner: row<n>>
{
auto inner = result.GetValue("r_deep").Field(0);
EXPECT_EQ(inner.Field(0).GetInt32(), 42);
}
// row<f_int, f_arr>
{
auto r = result.GetValue("r_with_arr");
EXPECT_EQ(r.Type(), fluss::TypeId::Row);
EXPECT_EQ(r.Field(0).GetInt32(), 100);
EXPECT_EQ(r.Field("f_int").GetInt32(), 100); // ROW field by name
auto arr = r.Field(1);
ASSERT_EQ(arr.Size(), 3u);
EXPECT_EQ(arr.At(2).GetInt32(), 3);
}
// row_rich — every leaf getter on one Value handle.
{
auto rr = result.GetValue("r_rich");
ASSERT_EQ(rr.FieldCount(), 14u);
EXPECT_TRUE(rr.Field(0).GetBool());
EXPECT_EQ(rr.Field(1).GetInt32(), 100000);
EXPECT_EQ(rr.Field(2).GetInt64(), 9876543210LL);
EXPECT_FLOAT_EQ(rr.Field(3).GetFloat32(), 3.5F);
EXPECT_DOUBLE_EQ(rr.Field(4).GetFloat64(), 2.5);
EXPECT_EQ(rr.Field(5).GetString(), "hello world");
auto by = rr.Field(6).GetBytes();
ASSERT_EQ(by.size(), 3u);
EXPECT_EQ(by[0], 'b');
EXPECT_EQ(rr.Field(7).GetDecimalString(), "123.45");
EXPECT_EQ(rr.Field(8).GetDate().days_since_epoch, 20476);
EXPECT_EQ(rr.Field(9).GetTime().millis_since_midnight, 36827123);
EXPECT_EQ(rr.Field(10).GetTimestamp().epoch_millis, 1769163227123LL);
EXPECT_EQ(rr.Field(11).GetTimestamp().epoch_millis, 1769163227456LL);
auto bin = rr.Field(12).GetBytes();
ASSERT_EQ(bin.size(), 4u);
EXPECT_EQ(bin[3], 4);
auto fa = rr.Field(13);
ASSERT_EQ(fa.Size(), 3u);
EXPECT_TRUE(fa.At(1).IsNull());
EXPECT_EQ(fa.At(2).GetInt32(), 11);
}
// map<string,tinyint>
{
auto m = result.GetValue("m_str_tiny");
ASSERT_EQ(m.Size(), 2u);
EXPECT_EQ(m.ValueAt(0).GetInt32(), -128);
EXPECT_EQ(m.ValueAt(1).GetInt32(), 127);
}
// array<smallint>
{
auto a = result.GetValue("arr_small");
ASSERT_EQ(a.Size(), 2u);
EXPECT_EQ(a.At(0).GetInt32(), 1000);
EXPECT_EQ(a.At(1).GetInt32(), -2000);
}
// Row 2 (id=2) — every compound column NULL.
{
const int column_count = arrow_schema->num_fields();
auto row = table.NewRow();
row.SetInt32(0, 2);
for (int i = 1; i < column_count; ++i) {
row.SetNull(i);
}
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
auto key2 = table.NewRow();
key2.SetInt32(0, 2);
fluss::LookupResult result2;
ASSERT_OK(lookuper.Lookup(key2, result2));
ASSERT_TRUE(result2.Found());
EXPECT_EQ(result2.GetInt32(0), 2);
for (int i = 1; i < column_count; ++i) {
EXPECT_TRUE(result2.IsNull(i)) << "column " << i << " should be null";
}
}
ASSERT_OK(adm.DropTable(table_path, false));
}
// Regression: timestamp map values built via the Arrow MapWriter ctor must pick
// NTZ vs LTZ from the declared value type (previously always wrote NTZ).
TEST_F(KvTableTest, MapWithTimestampValuesNtzAndLtz) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_map_timestamp_values_cpp");
auto arrow_schema = arrow::schema({
arrow::field("id", arrow::int32()),
arrow::field("mn", arrow::map(arrow::utf8(), arrow::timestamp(arrow::TimeUnit::MICRO))),
arrow::field("ml",
arrow::map(arrow::utf8(), arrow::timestamp(arrow::TimeUnit::MICRO, "UTC"))),
});
auto schema = fluss::Schema::FromArrow(arrow_schema, {"id"});
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));
fluss::UpsertWriter writer;
ASSERT_OK(table.NewUpsert().CreateWriter(writer));
const fluss::Timestamp ntz_val{1769163227123LL, 456000};
const fluss::Timestamp ltz_val{1700000000789LL, 123000};
{
auto row = table.NewRow();
row.SetInt32(0, 1);
// mn: map<string, timestamp> (NTZ), via the Arrow ctor.
fluss::MapWriter mn(1, arrow::utf8(), arrow::timestamp(arrow::TimeUnit::MICRO));
mn.SetKeyString("k");
mn.SetValueTimestamp(ntz_val);
mn.Commit();
row.SetMap(1, std::move(mn));
// ml: map<string, timestamp_ltz> (LTZ), via the Arrow ctor.
fluss::MapWriter ml(1, arrow::utf8(), arrow::timestamp(arrow::TimeUnit::MICRO, "UTC"));
ml.SetKeyString("k");
ml.SetValueTimestamp(ltz_val);
ml.Commit();
row.SetMap(2, std::move(ml));
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
}
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
auto mn = result.GetValue("mn");
ASSERT_EQ(mn.Size(), 1u);
EXPECT_EQ(mn.ValueAt(0).GetTimestamp().epoch_millis, ntz_val.epoch_millis);
EXPECT_EQ(mn.ValueAt(0).GetTimestamp().nano_of_millisecond, ntz_val.nano_of_millisecond);
auto ml = result.GetValue("ml");
ASSERT_EQ(ml.Size(), 1u);
EXPECT_EQ(ml.ValueAt(0).GetTimestamp().epoch_millis, ltz_val.epoch_millis);
EXPECT_EQ(ml.ValueAt(0).GetTimestamp().nano_of_millisecond, ltz_val.nano_of_millisecond);
ASSERT_OK(adm.DropTable(table_path, false));
}
// Deeply nested MAP/ROW via native DataType::Map / DataType::Row only — no Arrow.
TEST_F(KvTableTest, NativeNestedBuilderNoArrow) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_native_nested_builder_cpp");
// array<row<seq: int, attrs: map<string, int>>>
auto event = fluss::DataType::Row({
{"seq", fluss::DataType::Int()},
{"attrs", fluss::DataType::Map(fluss::DataType::String(), fluss::DataType::Int())},
});
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("events", fluss::DataType::Array(event))
.AddColumn("profile", fluss::DataType::Row({
{"name", fluss::DataType::String()},
{"score", fluss::DataType::Double()},
}))
.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));
fluss::UpsertWriter writer;
ASSERT_OK(table.NewUpsert().CreateWriter(writer));
{
auto row = table.NewRow();
row.Set("id", 1);
// events = [ {0, {"a":0}}, {1, {"a":10,"b":11}} ]
fluss::ArrayWriter events(2, event);
for (int i = 0; i < 2; i++) {
fluss::GenericRow ev(2);
ev.SetInt32(0, i);
fluss::MapWriter attrs(static_cast<size_t>(i + 1), fluss::DataType::String(),
fluss::DataType::Int());
attrs.SetKeyString("a");
attrs.SetValueInt32(i * 10);
attrs.Commit();
if (i == 1) {
attrs.SetKeyString("b");
attrs.SetValueInt32(11);
attrs.Commit();
}
ev.SetMap(1, std::move(attrs));
events.SetRow(i, std::move(ev));
}
row.Set("events", std::move(events));
fluss::GenericRow profile(2);
profile.SetString(0, "alice");
profile.SetFloat64(1, 9.5);
row.Set("profile", std::move(profile));
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
}
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
auto events = result.GetValue("events"); // ARRAY<ROW<int, MAP<string,int>>>
ASSERT_EQ(events.Type(), fluss::TypeId::Array);
ASSERT_EQ(events.Size(), 2u);
EXPECT_EQ(events.At(0).Field("seq").GetInt32(), 0);
EXPECT_EQ(events.At(0).Field("attrs").ValueAt(0).GetInt32(), 0);
auto e1_attrs = events.At(1).Field("attrs");
ASSERT_EQ(e1_attrs.Size(), 2u);
EXPECT_EQ(e1_attrs.KeyAt(1).GetString(), "b");
EXPECT_EQ(e1_attrs.ValueAt(1).GetInt32(), 11);
auto profile = result.GetValue("profile"); // ROW<name, score>
EXPECT_EQ(profile.Field("name").GetString(), "alice");
EXPECT_DOUBLE_EQ(profile.Field("score").GetFloat64(), 9.5);
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, ComplexTypesPartialUpdate) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_complex_partial_update_cpp");
auto arrow_schema = arrow::schema({
arrow::field("id", arrow::int32()),
arrow::field("name", arrow::utf8()),
arrow::field("score", arrow::int64()),
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())),
});
auto schema = fluss::Schema::FromArrow(arrow_schema, {"id"});
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 upsert = table.NewUpsert();
fluss::UpsertWriter w;
ASSERT_OK(upsert.CreateWriter(w));
auto row = table.NewRow();
row.SetInt32(0, 1);
row.SetString(1, "Verso");
row.SetInt64(2, 100);
fluss::GenericRow nested(2);
nested.SetInt32(0, 10);
nested.SetString(1, "alpha");
row.SetRow(3, std::move(nested));
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.SetMap(4, std::move(attrs));
fluss::ArrayWriter tags(2, fluss::DataType::String());
tags.SetString(0, "x");
tags.SetString(1, "y");
row.SetArray(5, std::move(tags));
ASSERT_OK(w.Upsert(row));
ASSERT_OK(w.Flush());
}
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.SetInt32(0, 1);
// Partial update of a scalar column — compound columns must be preserved.
{
auto pu = table.NewUpsert();
pu.PartialUpdateByName({"id", "score"});
fluss::UpsertWriter pw;
ASSERT_OK(pu.CreateWriter(pw));
auto row = table.NewRow();
row.SetInt32(0, 1);
row.SetNull(1);
row.SetInt64(2, 420);
row.SetNull(3);
row.SetNull(4);
row.SetNull(5);
ASSERT_OK(pw.Upsert(row));
ASSERT_OK(pw.Flush());
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetString(1), "Verso");
EXPECT_EQ(result.GetInt64(2), 420);
auto n = result.GetValue(3);
EXPECT_EQ(n.Field(0).GetInt32(), 10);
EXPECT_EQ(n.Field(1).GetString(), "alpha");
EXPECT_EQ(result.GetValue(4).Size(), 2u);
EXPECT_EQ(result.GetValue(5).Size(), 2u);
}
// Partial update of the ROW column — other compound columns preserved.
{
auto pu = table.NewUpsert();
pu.PartialUpdateByName({"id", "nested"});
fluss::UpsertWriter pw;
ASSERT_OK(pu.CreateWriter(pw));
auto row = table.NewRow();
row.SetInt32(0, 1);
row.SetNull(1);
row.SetNull(2);
fluss::GenericRow nn(2);
nn.SetInt32(0, 99);
nn.SetString(1, "omega");
row.SetRow(3, std::move(nn));
row.SetNull(4);
row.SetNull(5);
ASSERT_OK(pw.Upsert(row));
ASSERT_OK(pw.Flush());
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt64(2), 420);
auto n = result.GetValue(3);
EXPECT_EQ(n.Field(0).GetInt32(), 99);
EXPECT_EQ(n.Field(1).GetString(), "omega");
EXPECT_EQ(result.GetValue(4).Size(), 2u);
EXPECT_EQ(result.GetValue(5).Size(), 2u);
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PartitionedComplexTypes) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_partitioned_complex_cpp");
auto arrow_schema = arrow::schema({
arrow::field("region", arrow::utf8()),
arrow::field("user_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())),
});
auto schema = fluss::Schema::FromArrow(arrow_schema, {"region", "user_id"});
auto table_descriptor = fluss::TableDescriptor::NewBuilder()
.SetSchema(schema)
.SetPartitionKeys({"region"})
.SetProperty("table.replication.factor", "1")
.Build();
fluss_test::CreateTable(adm, table_path, table_descriptor);
fluss_test::CreatePartitions(adm, table_path, "region", {"US", "EU"});
fluss::Table table;
ASSERT_OK(conn.GetTable(table_path, table));
fluss::UpsertWriter writer;
ASSERT_OK(table.NewUpsert().CreateWriter(writer));
struct Rec {
const char* region;
int32_t user_id;
int32_t seq;
const char* label;
};
const Rec data[] = {{"US", 1, 11, "alpha"}, {"EU", 2, 22, "beta"}};
for (const auto& d : data) {
auto row = table.NewRow();
row.SetString(0, d.region);
row.SetInt32(1, d.user_id);
fluss::GenericRow nested(2);
nested.SetInt32(0, d.seq);
nested.SetString(1, d.label);
row.SetRow(2, std::move(nested));
fluss::MapWriter attrs(1, fluss::DataType::String(), fluss::DataType::Int());
attrs.SetKeyString(d.label);
attrs.SetValueInt32(d.seq);
attrs.Commit();
row.SetMap(3, std::move(attrs));
ASSERT_OK(writer.Upsert(row));
}
ASSERT_OK(writer.Flush());
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
for (const auto& d : data) {
auto key = table.NewRow();
key.SetString(0, d.region);
key.SetInt32(1, d.user_id);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
auto nested = result.GetValue(2);
EXPECT_EQ(nested.Field(0).GetInt32(), d.seq);
EXPECT_EQ(nested.Field(1).GetString(), d.label);
auto attrs = result.GetValue(3);
ASSERT_EQ(attrs.Size(), 1u);
EXPECT_EQ(attrs.KeyAt(0).GetString(), d.label);
EXPECT_EQ(attrs.ValueAt(0).GetInt32(), d.seq);
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, LookupArrayValidationErrors) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_lookup_array_validation_errors_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("vals", fluss::DataType::Array(fluss::DataType::Int()))
.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 upsert = table.NewUpsert();
fluss::UpsertWriter writer;
ASSERT_OK(upsert.CreateWriter(writer));
auto row = table.NewRow();
row.Set("id", 1);
fluss::ArrayWriter vals(2, fluss::DataType::Int());
vals.SetInt32(0, 99);
vals.SetNull(1);
row.Set("vals", std::move(vals));
ASSERT_OK(writer.Upsert(row));
ASSERT_OK(writer.Flush());
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
auto key = table.NewRow();
key.Set("id", 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
auto view = result.GetValue("vals");
EXPECT_EQ(view.Type(), fluss::TypeId::Array);
ASSERT_EQ(view.Size(), 2u);
EXPECT_EQ(view.At(0).GetInt32(), 99);
EXPECT_TRUE(view.At(1).IsNull());
// A 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);
// A 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(KvTableTest, CompositePrimaryKeys) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_composite_pk_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("region", fluss::DataType::String())
.AddColumn("score", fluss::DataType::BigInt())
.AddColumn("user_id", fluss::DataType::Int())
.SetPrimaryKeys({"region", "user_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 table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
// Insert records with composite keys
struct TestData {
std::string region;
int32_t user_id;
int64_t score;
};
std::vector<TestData> test_data = {
{"US", 1, 100}, {"US", 2, 200}, {"EU", 1, 150}, {"EU", 2, 250}};
for (const auto& d : test_data) {
auto row = table.NewRow();
row.Set("region", d.region);
row.Set("score", d.score);
row.Set("user_id", d.user_id);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_writer.Flush());
// Create lookuper
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
// Lookup (US, 1) - should return score 100
{
auto key = table.NewRow();
key.Set("region", "US");
key.Set("user_id", 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt64("score"), 100) << "Score for (US, 1) should be 100";
}
// Lookup (EU, 2) - should return score 250
{
auto key = table.NewRow();
key.Set("region", "EU");
key.Set("user_id", 2);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt64("score"), 250) << "Score for (EU, 2) should be 250";
}
// Update (US, 1) score (await acknowledgment)
{
auto update_row = table.NewRow();
update_row.Set("region", "US");
update_row.Set("user_id", 1);
update_row.Set("score", static_cast<int64_t>(500));
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(update_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify update
{
auto key = table.NewRow();
key.Set("region", "US");
key.Set("user_id", 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt64("score"), 500) << "Row score should be updated";
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PrefixLookupByBucketKey) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_prefix_lookup_cpp");
// Bucket key (a, b) is a strict prefix of the PK (a, b, c), enabling prefix lookup on (a, b).
auto schema = fluss::Schema::NewBuilder()
.AddColumn("a", fluss::DataType::Int())
.AddColumn("b", fluss::DataType::String())
.AddColumn("c", fluss::DataType::BigInt())
.AddColumn("d", fluss::DataType::String())
.SetPrimaryKeys({"a", "b", "c"})
.Build();
auto table_descriptor = fluss::TableDescriptor::NewBuilder()
.SetSchema(schema)
.SetBucketKeys({"a", "b"})
.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_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
struct TestData {
int32_t a;
std::string b;
int64_t c;
std::string d;
};
std::vector<TestData> test_data = {
{1, "aaaaaaaaa", 1, "value1"},
{1, "aaaaaaaaa", 2, "value2"},
{1, "aaaaaaaaa", 3, "value3"},
{2, "aaaaaaaaa", 4, "value4"},
};
for (const auto& row_data : test_data) {
auto row = table.NewRow();
row.Set("a", row_data.a);
row.Set("b", row_data.b);
row.Set("c", row_data.c);
row.Set("d", row_data.d);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_writer.Flush());
fluss::PrefixLookuper prefix_lookuper;
ASSERT_OK(table.NewPrefixLookup({"a", "b"}, prefix_lookuper));
// Prefix (1, "aaaaaaaaa") matches 3 rows, returned in primary-key order.
{
auto key = table.NewRow();
key.Set("a", 1);
key.Set("b", "aaaaaaaaa");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
ASSERT_EQ(result.Size(), 3u);
for (size_t i = 0; i < result.Size(); ++i) {
auto row = result.GetRow(i);
EXPECT_EQ(row.GetInt32("a"), 1);
EXPECT_EQ(row.GetString("b"), "aaaaaaaaa");
EXPECT_EQ(row.GetInt64("c"), static_cast<int64_t>(i) + 1);
EXPECT_EQ(row.GetString("d"), "value" + std::to_string(i + 1));
}
}
// Prefix (2, "aaaaaaaaa") matches a single row.
{
auto key = table.NewRow();
key.Set("a", 2);
key.Set("b", "aaaaaaaaa");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
ASSERT_EQ(result.Size(), 1u);
auto row = result.GetRow(0);
EXPECT_EQ(row.GetInt64("c"), 4);
EXPECT_EQ(row.GetString("d"), "value4");
}
// A prefix with no matching rows yields an empty result.
{
auto key = table.NewRow();
key.Set("a", 3);
key.Set("b", "a");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
EXPECT_TRUE(result.IsEmpty());
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PrefixLookupValidationErrors) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_prefix_lookup_validation_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("a", fluss::DataType::Int())
.AddColumn("b", fluss::DataType::String())
.AddColumn("c", fluss::DataType::BigInt())
.SetPrimaryKeys({"a", "b", "c"})
.Build();
auto table_descriptor = fluss::TableDescriptor::NewBuilder()
.SetSchema(schema)
.SetBucketKeys({"a", "b"})
.SetProperty("table.replication.factor", "1")
.Build();
fluss_test::CreateTable(adm, table_path, table_descriptor);
fluss::Table table;
ASSERT_OK(conn.GetTable(table_path, table));
// Lookup columns must list the bucket keys (a, b) in order.
{
fluss::PrefixLookuper lookuper;
auto result = table.NewPrefixLookup({"b", "a"}, lookuper);
ASSERT_FALSE(result.Ok());
EXPECT_EQ(result.error_code, fluss::ErrorCode::CLIENT_ERROR);
EXPECT_NE(result.error_message.find("must contain all bucket keys"), std::string::npos);
}
// Lookup columns must equal the bucket keys, not a longer prefix of the PK.
{
fluss::PrefixLookuper lookuper;
auto result = table.NewPrefixLookup({"a", "b", "c"}, lookuper);
ASSERT_FALSE(result.Ok());
EXPECT_EQ(result.error_code, fluss::ErrorCode::CLIENT_ERROR);
EXPECT_NE(result.error_message.find("must contain all bucket keys"), std::string::npos);
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PrefixLookupPartitioned) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_prefix_lookup_partitioned_cpp");
// Partitioned by region; bucket key (a, b) is a prefix of the PK minus the partition column.
auto schema = fluss::Schema::NewBuilder()
.AddColumn("region", fluss::DataType::String())
.AddColumn("a", fluss::DataType::Int())
.AddColumn("b", fluss::DataType::String())
.AddColumn("c", fluss::DataType::BigInt())
.AddColumn("d", fluss::DataType::String())
.SetPrimaryKeys({"region", "a", "b", "c"})
.Build();
auto table_descriptor = fluss::TableDescriptor::NewBuilder()
.SetSchema(schema)
.SetPartitionKeys({"region"})
.SetBucketKeys({"a", "b"})
.SetProperty("table.replication.factor", "1")
.Build();
fluss_test::CreateTable(adm, table_path, table_descriptor);
fluss_test::CreatePartitions(adm, table_path, "region", {"US", "EU"});
fluss::Table table;
ASSERT_OK(conn.GetTable(table_path, table));
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
struct TestData {
std::string region;
int32_t a;
std::string b;
int64_t c;
std::string d;
};
std::vector<TestData> test_data = {
{"US", 1, "aaaaaaaaa", 1, "us-1"},
{"US", 1, "aaaaaaaaa", 2, "us-2"},
{"US", 2, "aaaaaaaaa", 3, "us-3"},
{"EU", 1, "aaaaaaaaa", 4, "eu-1"},
{"EU", 1, "bbbbbbbbb", 5, "eu-2"},
};
for (const auto& row_data : test_data) {
auto row = table.NewRow();
row.Set("region", row_data.region);
row.Set("a", row_data.a);
row.Set("b", row_data.b);
row.Set("c", row_data.c);
row.Set("d", row_data.d);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_writer.Flush());
fluss::PrefixLookuper prefix_lookuper;
ASSERT_OK(table.NewPrefixLookup({"region", "a", "b"}, prefix_lookuper));
// (US, 1, "aaaaaaaaa") matches 2 rows.
{
auto key = table.NewRow();
key.Set("region", "US");
key.Set("a", 1);
key.Set("b", "aaaaaaaaa");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
ASSERT_EQ(result.Size(), 2u);
for (size_t i = 0; i < result.Size(); ++i) {
auto row = result.GetRow(i);
EXPECT_EQ(row.GetString("region"), "US");
EXPECT_EQ(row.GetInt32("a"), 1);
EXPECT_EQ(row.GetString("b"), "aaaaaaaaa");
}
}
// (EU, 1, "bbbbbbbbb") matches a single row.
{
auto key = table.NewRow();
key.Set("region", "EU");
key.Set("a", 1);
key.Set("b", "bbbbbbbbb");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
ASSERT_EQ(result.Size(), 1u);
EXPECT_EQ(result.GetRow(0).GetString("d"), "eu-2");
}
// A non-existent partition yields an empty result.
{
auto key = table.NewRow();
key.Set("region", "APAC");
key.Set("a", 1);
key.Set("b", "aaaaaaaaa");
fluss::PrefixLookupResult result;
ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
EXPECT_TRUE(result.IsEmpty());
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PartialUpdate) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_partial_update_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("name", fluss::DataType::String())
.AddColumn("age", fluss::DataType::BigInt())
.AddColumn("score", fluss::DataType::BigInt())
.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));
// Insert initial record with all columns
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
{
fluss::GenericRow row(4);
row.SetInt32(0, 1);
row.SetString(1, "Verso");
row.SetInt64(2, 32);
row.SetInt64(3, 6942);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(row, wr));
ASSERT_OK(wr.Wait());
}
// Verify initial record
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
{
fluss::GenericRow key(4);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt32(0), 1);
EXPECT_EQ(result.GetString(1), "Verso");
EXPECT_EQ(result.GetInt64(2), 32);
EXPECT_EQ(result.GetInt64(3), 6942);
}
// Create partial update writer to update only score column
auto partial_upsert = table.NewUpsert();
partial_upsert.PartialUpdateByName({"id", "score"});
fluss::UpsertWriter partial_writer;
ASSERT_OK(partial_upsert.CreateWriter(partial_writer));
// Update only the score column (await acknowledgment)
{
fluss::GenericRow partial_row(4);
partial_row.SetInt32(0, 1);
partial_row.SetNull(1); // not in partial update
partial_row.SetNull(2); // not in partial update
partial_row.SetInt64(3, 420);
fluss::WriteResult wr;
ASSERT_OK(partial_writer.Upsert(partial_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify partial update - name and age should remain unchanged
{
fluss::GenericRow key(4);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt32(0), 1) << "id should remain 1";
EXPECT_EQ(result.GetString(1), "Verso") << "name should remain unchanged";
EXPECT_EQ(result.GetInt64(2), 32) << "age should remain unchanged";
EXPECT_EQ(result.GetInt64(3), 420) << "score should be updated to 420";
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PartialUpdateByIndex) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_partial_update_by_index_cpp");
auto schema = fluss::Schema::NewBuilder()
.AddColumn("id", fluss::DataType::Int())
.AddColumn("name", fluss::DataType::String())
.AddColumn("age", fluss::DataType::BigInt())
.AddColumn("score", fluss::DataType::BigInt())
.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));
// Insert initial record with all columns
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
{
fluss::GenericRow row(4);
row.SetInt32(0, 1);
row.SetString(1, "Verso");
row.SetInt64(2, 32);
row.SetInt64(3, 6942);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(row, wr));
ASSERT_OK(wr.Wait());
}
// Verify initial record
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
{
fluss::GenericRow key(4);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt32(0), 1);
EXPECT_EQ(result.GetString(1), "Verso");
EXPECT_EQ(result.GetInt64(2), 32);
EXPECT_EQ(result.GetInt64(3), 6942);
}
// Create partial update writer using column indices: 0 (id) and 3 (score)
auto partial_upsert = table.NewUpsert();
partial_upsert.PartialUpdateByIndex({0, 3});
fluss::UpsertWriter partial_writer;
ASSERT_OK(partial_upsert.CreateWriter(partial_writer));
// Update only the score column (await acknowledgment)
{
fluss::GenericRow partial_row(4);
partial_row.SetInt32(0, 1);
partial_row.SetNull(1); // not in partial update
partial_row.SetNull(2); // not in partial update
partial_row.SetInt64(3, 420);
fluss::WriteResult wr;
ASSERT_OK(partial_writer.Upsert(partial_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify partial update - name and age should remain unchanged
{
fluss::GenericRow key(4);
key.SetInt32(0, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt32(0), 1) << "id should remain 1";
EXPECT_EQ(result.GetString(1), "Verso") << "name should remain unchanged";
EXPECT_EQ(result.GetInt64(2), 32) << "age should remain unchanged";
EXPECT_EQ(result.GetInt64(3), 420) << "score should be updated to 420";
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, PartitionedTableUpsertAndLookup) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_partitioned_kv_table_cpp");
// Create a partitioned KV table with region as partition key
auto schema = fluss::Schema::NewBuilder()
.AddColumn("region", fluss::DataType::String())
.AddColumn("user_id", fluss::DataType::Int())
.AddColumn("name", fluss::DataType::String())
.AddColumn("score", fluss::DataType::BigInt())
.SetPrimaryKeys({"region", "user_id"})
.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", "APAC"});
fluss::Table table;
ASSERT_OK(conn.GetTable(table_path, table));
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
// Insert records with different partitions
struct TestData {
std::string region;
int32_t user_id;
std::string name;
int64_t score;
};
std::vector<TestData> test_data = {{"US", 1, "Gustave", 100}, {"US", 2, "Lune", 200},
{"EU", 1, "Sciel", 150}, {"EU", 2, "Maelle", 250},
{"APAC", 1, "Noco", 300}};
for (const auto& d : test_data) {
fluss::GenericRow row(4);
row.SetString(0, d.region);
row.SetInt32(1, d.user_id);
row.SetString(2, d.name);
row.SetInt64(3, d.score);
ASSERT_OK(upsert_writer.Upsert(row));
}
ASSERT_OK(upsert_writer.Flush());
// Create lookuper
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
// Lookup records
for (const auto& d : test_data) {
fluss::GenericRow key(4);
key.SetString(0, d.region);
key.SetInt32(1, d.user_id);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(std::string(result.GetString(0)), d.region) << "region mismatch";
EXPECT_EQ(result.GetInt32(1), d.user_id) << "user_id mismatch";
EXPECT_EQ(std::string(result.GetString(2)), d.name) << "name mismatch";
EXPECT_EQ(result.GetInt64(3), d.score) << "score mismatch";
}
// Update within a partition (await acknowledgment)
{
fluss::GenericRow updated_row(4);
updated_row.SetString(0, "US");
updated_row.SetInt32(1, 1);
updated_row.SetString(2, "Gustave Updated");
updated_row.SetInt64(3, 999);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(updated_row, wr));
ASSERT_OK(wr.Wait());
}
// Verify the update
{
fluss::GenericRow key(4);
key.SetString(0, "US");
key.SetInt32(1, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(std::string(result.GetString(2)), "Gustave Updated");
EXPECT_EQ(result.GetInt64(3), 999);
}
// Lookup in non-existent partition should return not found
{
fluss::GenericRow key(4);
key.SetString(0, "UNKNOWN_REGION");
key.SetInt32(1, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_FALSE(result.Found()) << "Lookup in non-existent partition should return not found";
}
// Delete a record within a partition (await acknowledgment)
{
fluss::GenericRow delete_key(4);
delete_key.SetString(0, "EU");
delete_key.SetInt32(1, 1);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Delete(delete_key, wr));
ASSERT_OK(wr.Wait());
}
// Verify deletion
{
fluss::GenericRow key(4);
key.SetString(0, "EU");
key.SetInt32(1, 1);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_FALSE(result.Found()) << "Deleted record should not exist";
}
// Verify other records in same partition still exist
{
fluss::GenericRow key(4);
key.SetString(0, "EU");
key.SetInt32(1, 2);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(std::string(result.GetString(2)), "Maelle");
}
ASSERT_OK(adm.DropTable(table_path, false));
}
TEST_F(KvTableTest, AllSupportedDatatypes) {
auto& adm = admin();
auto& conn = connection();
fluss::TablePath table_path("fluss", "test_all_datatypes_cpp");
// Create a table with all supported datatypes
auto schema = fluss::Schema::NewBuilder()
.AddColumn("pk_int", fluss::DataType::Int())
.AddColumn("col_boolean", fluss::DataType::Boolean())
.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_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(20))
.SetPrimaryKeys({"pk_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));
auto table_upsert = table.NewUpsert();
fluss::UpsertWriter upsert_writer;
ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
// Test data
int32_t pk_int = 1;
bool col_boolean = true;
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;
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::FromMillis(1769163227123); // 2026-01-23 10:13:47.123
auto col_timestamp_ltz = fluss::Timestamp::FromMillis(1769163227123);
std::vector<uint8_t> col_bytes = {'b', 'i', 'n', 'a', 'r', 'y', ' ', 'd', 'a', 't', 'a'};
std::vector<uint8_t> col_binary = {'f', 'i', 'x', 'e', 'd', ' ', 'b', 'i', 'n', 'a',
'r', 'y', ' ', 'd', 'a', 't', 'a', '!', '!', '!'};
// Upsert a row with all datatypes
{
fluss::GenericRow row(17);
row.SetInt32(0, pk_int);
row.SetBool(1, col_boolean);
row.SetInt32(2, col_tinyint);
row.SetInt32(3, col_smallint);
row.SetInt32(4, col_int);
row.SetInt64(5, col_bigint);
row.SetFloat32(6, col_float);
row.SetFloat64(7, col_double);
row.SetString(8, col_char);
row.SetString(9, col_string);
row.SetDecimal(10, col_decimal);
row.SetDate(11, col_date);
row.SetTime(12, col_time);
row.SetTimestampNtz(13, col_timestamp);
row.SetTimestampLtz(14, col_timestamp_ltz);
row.SetBytes(15, col_bytes);
row.SetBytes(16, col_binary);
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(row, wr));
ASSERT_OK(wr.Wait());
}
// Lookup the record
fluss::Lookuper lookuper;
ASSERT_OK(table.NewLookup().CreateLookuper(lookuper));
{
fluss::GenericRow key(17);
key.SetInt32(0, pk_int);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
// Verify all datatypes
EXPECT_EQ(result.GetInt32(0), pk_int) << "pk_int mismatch";
EXPECT_EQ(result.GetBool(1), col_boolean) << "col_boolean mismatch";
EXPECT_EQ(result.GetInt32(2), col_tinyint) << "col_tinyint mismatch";
EXPECT_EQ(result.GetInt32(3), col_smallint) << "col_smallint mismatch";
EXPECT_EQ(result.GetInt32(4), col_int) << "col_int mismatch";
EXPECT_EQ(result.GetInt64(5), col_bigint) << "col_bigint mismatch";
EXPECT_NEAR(result.GetFloat32(6), col_float, 1e-6f) << "col_float mismatch";
EXPECT_NEAR(result.GetFloat64(7), col_double, 1e-15) << "col_double mismatch";
EXPECT_EQ(result.GetString(8), col_char) << "col_char mismatch";
EXPECT_EQ(result.GetString(9), col_string) << "col_string mismatch";
EXPECT_EQ(result.GetDecimalString(10), col_decimal) << "col_decimal mismatch";
EXPECT_EQ(result.GetDate(11).days_since_epoch, col_date.days_since_epoch) << "col_date mismatch";
EXPECT_EQ(result.GetTime(12).millis_since_midnight, col_time.millis_since_midnight) << "col_time mismatch";
EXPECT_EQ(result.GetTimestamp(13).epoch_millis, col_timestamp.epoch_millis)
<< "col_timestamp mismatch";
EXPECT_EQ(result.GetTimestamp(14).epoch_millis, col_timestamp_ltz.epoch_millis)
<< "col_timestamp_ltz mismatch";
auto [bytes_ptr, bytes_len] = result.GetBytes(15);
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] = result.GetBytes(16);
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";
}
// Test with null values for nullable columns
{
fluss::GenericRow row_with_nulls(17);
row_with_nulls.SetInt32(0, 2); // pk_int = 2
for (size_t i = 1; i < 17; ++i) {
row_with_nulls.SetNull(i);
}
fluss::WriteResult wr;
ASSERT_OK(upsert_writer.Upsert(row_with_nulls, wr));
ASSERT_OK(wr.Wait());
}
// Lookup row with nulls
{
fluss::GenericRow key(17);
key.SetInt32(0, 2);
fluss::LookupResult result;
ASSERT_OK(lookuper.Lookup(key, result));
ASSERT_TRUE(result.Found());
EXPECT_EQ(result.GetInt32(0), 2) << "pk_int mismatch";
for (size_t i = 1; i < 17; ++i) {
EXPECT_TRUE(result.IsNull(i)) << "column " << i << " should be null";
}
}
ASSERT_OK(adm.DropTable(table_path, false));
}