This is an automated email from the ASF dual-hosted git repository.
leekeiabstraction pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new f7e9cb9 [rust] lookup decoding schema id (#517)
f7e9cb9 is described below
commit f7e9cb90ce96a6992715d7e32cbf4982fc98d1fc
Author: Anton Borisov <[email protected]>
AuthorDate: Sun May 3 15:25:10 2026 +0100
[rust] lookup decoding schema id (#517)
---
bindings/python/src/lookup.rs | 2 +-
crates/fluss/src/client/admin.rs | 27 +-
crates/fluss/src/client/mod.rs | 2 +
crates/fluss/src/client/schema_getter.rs | 76 ++++
crates/fluss/src/client/table/lookup.rs | 413 +++++++++++++++------
crates/fluss/src/client/table/mod.rs | 15 +-
crates/fluss/src/metadata/datatype.rs | 157 ++++++++
crates/fluss/src/metadata/json_serde.rs | 102 +++++
crates/fluss/src/metadata/mod.rs | 2 +
crates/fluss/src/metadata/schema_util.rs | 204 ++++++++++
crates/fluss/src/metadata/table.rs | 81 +++-
crates/fluss/src/proto/fluss_api.proto | 12 +
.../fluss/src/record/kv/kv_record_read_context.rs | 2 +-
crates/fluss/src/record/kv/test_util.rs | 2 +-
crates/fluss/src/row/fixed_schema_decoder.rs | 196 ++++++++++
crates/fluss/src/row/lookup_row.rs | 122 ++++++
crates/fluss/src/row/mod.rs | 6 +
crates/fluss/src/row/projected_row.rs | 276 ++++++++++++++
crates/fluss/src/rpc/api_key.rs | 4 +
crates/fluss/src/rpc/message/get_table_schema.rs | 57 +++
crates/fluss/src/rpc/message/mod.rs | 2 +
website/docs/user-guide/rust/api-reference.md | 26 +-
22 files changed, 1666 insertions(+), 120 deletions(-)
diff --git a/bindings/python/src/lookup.rs b/bindings/python/src/lookup.rs
index 0946526..f7bd09a 100644
--- a/bindings/python/src/lookup.rs
+++ b/bindings/python/src/lookup.rs
@@ -74,7 +74,7 @@ impl Lookuper {
// Convert to Python with GIL
Python::attach(|py| match row_opt {
- Some(compacted_row) => internal_row_to_dict(py,
&compacted_row, &table_info),
+ Some(row) => internal_row_to_dict(py, &row, &table_info),
None => Ok(py.None()),
})
})
diff --git a/crates/fluss/src/client/admin.rs b/crates/fluss/src/client/admin.rs
index 7f1f64e..0828b83 100644
--- a/crates/fluss/src/client/admin.rs
+++ b/crates/fluss/src/client/admin.rs
@@ -19,13 +19,13 @@ use crate::client::metadata::Metadata;
use crate::cluster::ServerNode;
use crate::metadata::{
DatabaseDescriptor, DatabaseInfo, JsonSerde, LakeSnapshot, PartitionInfo,
PartitionSpec,
- PhysicalTablePath, TableBucket, TableDescriptor, TableInfo, TablePath,
+ PhysicalTablePath, Schema, SchemaInfo, TableBucket, TableDescriptor,
TableInfo, TablePath,
};
use crate::rpc::message::{
CreateDatabaseRequest, CreatePartitionRequest, CreateTableRequest,
DatabaseExistsRequest,
DropDatabaseRequest, DropPartitionRequest, DropTableRequest,
GetDatabaseInfoRequest,
- GetLatestLakeSnapshotRequest, GetTableRequest, ListDatabasesRequest,
ListPartitionInfosRequest,
- ListTablesRequest, TableExistsRequest,
+ GetLatestLakeSnapshotRequest, GetTableRequest, GetTableSchemaRequestMsg,
ListDatabasesRequest,
+ ListPartitionInfosRequest, ListTablesRequest, TableExistsRequest,
};
use crate::rpc::message::{ListOffsetsRequest, OffsetSpec};
use crate::rpc::{RpcClient, ServerConnection};
@@ -111,6 +111,27 @@ impl FlussAdmin {
Ok(())
}
+ /// Fetch the schema for `table_path` at the given `schema_id`. Pass
+ /// `None` to request the latest.
+ pub async fn get_table_schema(
+ &self,
+ table_path: &TablePath,
+ schema_id: Option<i32>,
+ ) -> Result<SchemaInfo> {
+ let response = self
+ .admin_gateway()
+ .await?
+ .request(GetTableSchemaRequestMsg::new(table_path, schema_id))
+ .await?;
+
+ let schema_node: serde_json::Value =
serde_json::from_slice(&response.schema_json)
+ .map_err(|e| Error::JsonSerdeError {
+ message: format!("Failed to parse schema_json: {e}"),
+ })?;
+ let schema = Schema::deserialize_json(&schema_node)?;
+ Ok(SchemaInfo::new(schema, response.schema_id))
+ }
+
pub async fn get_table_info(&self, table_path: &TablePath) ->
Result<TableInfo> {
let response = self
.admin_gateway()
diff --git a/crates/fluss/src/client/mod.rs b/crates/fluss/src/client/mod.rs
index 77c3020..f802794 100644
--- a/crates/fluss/src/client/mod.rs
+++ b/crates/fluss/src/client/mod.rs
@@ -20,6 +20,7 @@ mod connection;
mod credentials;
pub mod lookup;
mod metadata;
+mod schema_getter;
mod table;
mod write;
@@ -28,5 +29,6 @@ pub use connection::*;
pub use credentials::*;
pub use lookup::LookupClient;
pub use metadata::*;
+pub(crate) use schema_getter::ClientSchemaGetter;
pub use table::*;
pub use write::*;
diff --git a/crates/fluss/src/client/schema_getter.rs
b/crates/fluss/src/client/schema_getter.rs
new file mode 100644
index 0000000..4b643c0
--- /dev/null
+++ b/crates/fluss/src/client/schema_getter.rs
@@ -0,0 +1,76 @@
+// 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.
+
+//! Per-table schema cache that lazily fetches missing schema versions
+//! from the coordinator. Used by the lookup path to decode rows that
+//! predate the table's current schema.
+
+use crate::client::admin::FlussAdmin;
+use crate::error::{Error, Result};
+use crate::metadata::{Schema, SchemaInfo, TablePath};
+use parking_lot::RwLock;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+pub(crate) struct ClientSchemaGetter {
+ table_path: TablePath,
+ admin: Arc<FlussAdmin>,
+ /// Pre-seeded with the table's current schema so the dominant case
+ /// (every row written under the latest schema) needs zero RPCs.
+ cache: RwLock<HashMap<i32, Arc<Schema>>>,
+}
+
+impl ClientSchemaGetter {
+ pub fn new(table_path: TablePath, admin: Arc<FlussAdmin>, latest:
SchemaInfo) -> Self {
+ let mut map = HashMap::new();
+ let (schema, schema_id) = latest.into_parts();
+ map.insert(schema_id, Arc::new(schema));
+ Self {
+ table_path,
+ admin,
+ cache: RwLock::new(map),
+ }
+ }
+
+ /// Concurrent fetches for the same id are not deduplicated; we
+ /// accept one redundant RPC in exchange for staying off
+ /// `tokio::sync` machinery. Schemas are immutable per id, so
+ /// last-write-wins on the cache insert is correct.
+ pub async fn get_schema(&self, schema_id: i32) -> Result<Arc<Schema>> {
+ if let Some(schema) = self.cache.read().get(&schema_id).cloned() {
+ return Ok(schema);
+ }
+
+ let info = self
+ .admin
+ .get_table_schema(&self.table_path, Some(schema_id))
+ .await?;
+ let (schema, fetched_id) = info.into_parts();
+ if fetched_id != schema_id {
+ return Err(Error::UnexpectedError {
+ message: format!(
+ "Requested schema id {schema_id}, but server returned
schema id {fetched_id}"
+ ),
+ source: None,
+ });
+ }
+ let schema = Arc::new(schema);
+
+ self.cache.write().insert(schema_id, Arc::clone(&schema));
+ Ok(schema)
+ }
+}
diff --git a/crates/fluss/src/client/table/lookup.rs
b/crates/fluss/src/client/table/lookup.rs
index 2adb53e..51a0a07 100644
--- a/crates/fluss/src/client/table/lookup.rs
+++ b/crates/fluss/src/client/table/lookup.rs
@@ -16,73 +16,126 @@
// under the License.
use crate::bucketing::BucketingFunction;
+use crate::client::ClientSchemaGetter;
use crate::client::lookup::LookupClient;
use crate::client::metadata::Metadata;
use crate::client::table::partition_getter::PartitionGetter;
use crate::error::{Error, Result};
-use crate::metadata::{PhysicalTablePath, RowType, TableBucket, TableInfo,
TablePath};
+use crate::metadata::{
+ KvFormat, PhysicalTablePath, RowType, Schema, TableBucket, TableInfo,
TablePath,
+};
use crate::record::RowAppendRecordBatchBuilder;
use crate::record::kv::SCHEMA_ID_LENGTH;
-use crate::row::InternalRow;
-use crate::row::compacted::CompactedRow;
use crate::row::encode::{KeyEncoder, KeyEncoderFactory};
+use crate::row::{FixedSchemaDecoder, InternalRow, LookupRow};
use arrow::array::RecordBatch;
+use byteorder::{ByteOrder, LittleEndian};
+use futures::future::try_join_all;
+use parking_lot::RwLock;
+use std::collections::HashMap;
use std::sync::Arc;
-/// The result of a lookup operation.
-///
-/// Contains the rows returned from a lookup. For primary-key lookups,
-/// this will contain at most one row. For prefix-key lookups, it may
-/// contain multiple rows.
+/// Per-Lookuper decoder cache. The target-schema decoder is held
+/// directly so the dominant decode path is a single field access; older
+/// schemas are populated lazily on first observation.
+struct DecoderCache {
+ target_id: i16,
+ target_decoder: Arc<FixedSchemaDecoder>,
+ others: RwLock<HashMap<i16, Arc<FixedSchemaDecoder>>>,
+}
+
+impl DecoderCache {
+ fn new(target_id: i16, target_decoder: Arc<FixedSchemaDecoder>) -> Self {
+ Self {
+ target_id,
+ target_decoder,
+ others: RwLock::new(HashMap::new()),
+ }
+ }
+
+ fn decode<'a>(&self, schema_id: i16, bytes: &'a [u8]) ->
Result<LookupRow<'a>> {
+ if schema_id == self.target_id {
+ return self.target_decoder.decode(bytes);
+ }
+ let decoder =
+ self.others
+ .read()
+ .get(&schema_id)
+ .cloned()
+ .ok_or_else(|| Error::RowConvertError {
+ message: format!("No decoder available for schema id
{schema_id}"),
+ })?;
+ decoder.decode(bytes)
+ }
+
+ fn contains(&self, schema_id: i16) -> bool {
+ schema_id == self.target_id ||
self.others.read().contains_key(&schema_id)
+ }
+
+ fn insert(&self, schema_id: i16, decoder: Arc<FixedSchemaDecoder>) {
+ self.others.write().insert(schema_id, decoder);
+ }
+
+ #[cfg(test)]
+ fn get(&self, schema_id: i16) -> Option<Arc<FixedSchemaDecoder>> {
+ if schema_id == self.target_id {
+ return Some(Arc::clone(&self.target_decoder));
+ }
+ self.others.read().get(&schema_id).cloned()
+ }
+}
+
+/// Rows returned from a lookup. Primary-key lookups produce at most one
+/// row; prefix-key lookups may produce many. Rows written under older
+/// schemas are decoded with their original schema and projected to the
+/// schema captured when the `Lookuper` was created — schema evolutions
+/// that land after that point are not picked up by an existing
+/// `Lookuper`; create a new one to see them.
pub struct LookupResult {
rows: Vec<Vec<u8>>,
- row_type: Arc<RowType>,
+ target_row_type: Arc<RowType>,
+ decoders: Arc<DecoderCache>,
}
impl LookupResult {
- /// Creates a new LookupResult from a list of row bytes.
- fn new(rows: Vec<Vec<u8>>, row_type: Arc<RowType>) -> Self {
- Self { rows, row_type }
- }
-
- /// Creates an empty LookupResult.
- fn empty(row_type: Arc<RowType>) -> Self {
+ fn new(rows: Vec<Vec<u8>>, target_row_type: Arc<RowType>, decoders:
Arc<DecoderCache>) -> Self {
Self {
- rows: Vec::new(),
- row_type,
+ rows,
+ target_row_type,
+ decoders,
}
}
- /// Extracts the row payload by stripping the schema id prefix.
- fn extract_payload(bytes: &[u8]) -> Result<&[u8]> {
- bytes
- .get(SCHEMA_ID_LENGTH..)
- .ok_or_else(|| Error::RowConvertError {
+ fn read_schema_id(bytes: &[u8]) -> Result<i16> {
+ if bytes.len() < SCHEMA_ID_LENGTH {
+ return Err(Error::RowConvertError {
message: format!(
"Row payload too short: {} bytes, need at least {} for
schema id",
bytes.len(),
SCHEMA_ID_LENGTH
),
- })
+ });
+ }
+ let schema_id = LittleEndian::read_i16(&bytes[..SCHEMA_ID_LENGTH]);
+ if schema_id < 0 {
+ return Err(Error::RowConvertError {
+ message: format!("Invalid negative schema id {schema_id}; row
prefix is corrupt"),
+ });
+ }
+ Ok(schema_id)
}
- /// Returns the only row in the result set as a [`CompactedRow`].
- ///
- /// This method provides a zero-copy view of the row data, which means the
returned
- /// `CompactedRow` borrows from this result set and cannot outlive it.
- ///
- /// # Returns
- /// - `Ok(Some(row))`: If exactly one row exists.
- /// - `Ok(None)`: If the result set is empty.
- /// - `Err(Error::UnexpectedError)`: If the result set contains more than
one row.
- /// - `Err(Error)`: If the row payload is too short to contain a schema id.
- pub fn get_single_row(&self) -> Result<Option<CompactedRow<'_>>> {
+ fn decode<'a>(&self, bytes: &'a [u8]) -> Result<LookupRow<'a>> {
+ let schema_id = Self::read_schema_id(bytes)?;
+ self.decoders.decode(schema_id, bytes)
+ }
+
+ /// Returns the single row when exactly one is present, `None` for
+ /// empty, or an error if the result holds more than one row.
+ pub fn get_single_row(&self) -> Result<Option<LookupRow<'_>>> {
match self.rows.len() {
0 => Ok(None),
- 1 => {
- let payload = Self::extract_payload(&self.rows[0])?;
- Ok(Some(CompactedRow::from_bytes(&self.row_type, payload)))
- }
+ 1 => Ok(Some(self.decode(&self.rows[0])?)),
_ => Err(Error::UnexpectedError {
message: "LookupResult contains multiple rows, use get_rows()
instead".to_string(),
source: None,
@@ -90,44 +143,105 @@ impl LookupResult {
}
}
- /// Returns all rows in the result set as [`CompactedRow`]s.
- ///
- /// # Returns
- /// - `Ok(rows)` - All rows in the result set.
- /// - `Err(Error)` - If any row payload is too short to contain a schema
id.
- pub fn get_rows(&self) -> Result<Vec<CompactedRow<'_>>> {
- self.rows
- .iter()
- // TODO Add schema id check and fetch when implementing prefix
lookup
- .map(|bytes| {
- let payload = Self::extract_payload(bytes)?;
- Ok(CompactedRow::from_bytes(&self.row_type, payload))
- })
- .collect()
+ pub fn get_rows(&self) -> Result<Vec<LookupRow<'_>>> {
+ self.rows.iter().map(|bytes| self.decode(bytes)).collect()
}
- /// Converts all rows in this result into an Arrow [`RecordBatch`].
- ///
- /// This is useful for integration with DataFusion or other Arrow-based
tools.
- ///
- /// # Returns
- /// - `Ok(RecordBatch)` - All rows in columnar Arrow format. Returns an
empty
- /// batch (with the correct schema) if the result set is empty.
- /// - `Err(Error)` - If the conversion fails.
pub fn to_record_batch(&self) -> Result<RecordBatch> {
- let mut builder = RowAppendRecordBatchBuilder::new(&self.row_type)?;
-
+ let mut builder =
RowAppendRecordBatchBuilder::new(&self.target_row_type)?;
for bytes in &self.rows {
- let payload = Self::extract_payload(bytes)?;
-
- let row = CompactedRow::from_bytes(&self.row_type, payload);
+ let row = self.decode(bytes)?;
builder.append(&row)?;
}
-
builder.build_arrow_record_batch().map(Arc::unwrap_or_clone)
}
}
+struct LookupSchemaCtx {
+ target_schema: Arc<Schema>,
+ target_row_type: Arc<RowType>,
+ kv_format: KvFormat,
+ schema_getter: Arc<ClientSchemaGetter>,
+ decoders: Arc<DecoderCache>,
+}
+
+impl LookupSchemaCtx {
+ fn new(table_info: &TableInfo, schema_getter: Arc<ClientSchemaGetter>) ->
Result<Self> {
+ let target_schema_i32 = table_info.get_schema_id();
+ if !(0..=i16::MAX as i32).contains(&target_schema_i32) {
+ return Err(Error::UnexpectedError {
+ message: format!(
+ "Schema id {target_schema_i32} does not fit in 16 bits —
wire format violated"
+ ),
+ source: None,
+ });
+ }
+ let target_schema = Arc::new(table_info.get_schema().clone());
+ let target_row_type = Arc::new(table_info.row_type().clone());
+ let kv_format = table_info.get_table_config().get_kv_format()?;
+ let target_decoder = Arc::new(FixedSchemaDecoder::new_no_projection(
+ kv_format,
+ target_schema.as_ref(),
+ )?);
+ let decoders = Arc::new(DecoderCache::new(target_schema_i32 as i16,
target_decoder));
+ Ok(Self {
+ target_schema,
+ target_row_type,
+ kv_format,
+ schema_getter,
+ decoders,
+ })
+ }
+
+ async fn ensure_decoders(&self, rows: &[Vec<u8>]) -> Result<()> {
+ let mut missing: Vec<i16> = Vec::new();
+ for bytes in rows {
+ let schema_id = LookupResult::read_schema_id(bytes)?;
+ if !self.decoders.contains(schema_id) &&
!missing.contains(&schema_id) {
+ missing.push(schema_id);
+ }
+ }
+ if missing.is_empty() {
+ return Ok(());
+ }
+
+ let fetches = missing.into_iter().map(|schema_id| {
+ let cache = Arc::clone(&self.decoders);
+ let schema_getter = Arc::clone(&self.schema_getter);
+ let target_schema = Arc::clone(&self.target_schema);
+ let kv_format = self.kv_format;
+ async move {
+ let source = schema_getter.get_schema(schema_id as i32).await?;
+ let decoder =
+ FixedSchemaDecoder::new(kv_format, source.as_ref(),
target_schema.as_ref())?;
+ cache.insert(schema_id, Arc::new(decoder));
+ Ok::<_, Error>(())
+ }
+ });
+ try_join_all(fetches).await?;
+ Ok(())
+ }
+
+ async fn build_result(&self, rows: Vec<Vec<u8>>) -> Result<LookupResult> {
+ if !rows.is_empty() {
+ self.ensure_decoders(&rows).await?;
+ }
+ Ok(LookupResult::new(
+ rows,
+ Arc::clone(&self.target_row_type),
+ Arc::clone(&self.decoders),
+ ))
+ }
+
+ fn empty_result(&self) -> LookupResult {
+ LookupResult::new(
+ Vec::new(),
+ Arc::clone(&self.target_row_type),
+ Arc::clone(&self.decoders),
+ )
+ }
+}
+
/// Builder for lookup operations. `create_lookuper()` builds a primary-key
/// `Lookuper`; `lookup_by(columns).create_lookuper()` builds a
/// `PrefixKeyLookuper` for prefix scans.
@@ -136,6 +250,7 @@ pub struct TableLookup {
lookup_client: Arc<LookupClient>,
table_info: TableInfo,
metadata: Arc<Metadata>,
+ schema_getter: Arc<ClientSchemaGetter>,
}
impl TableLookup {
@@ -143,11 +258,13 @@ impl TableLookup {
lookup_client: Arc<LookupClient>,
table_info: TableInfo,
metadata: Arc<Metadata>,
+ schema_getter: Arc<ClientSchemaGetter>,
) -> Self {
Self {
lookup_client,
table_info,
metadata,
+ schema_getter,
}
}
@@ -161,6 +278,7 @@ impl TableLookup {
lookup_client: self.lookup_client,
table_info: self.table_info,
metadata: self.metadata,
+ schema_getter: self.schema_getter,
lookup_column_names,
}
}
@@ -208,11 +326,10 @@ impl TableLookup {
None
};
- let row_type = Arc::new(self.table_info.row_type().clone());
+ let schema_ctx = LookupSchemaCtx::new(&self.table_info,
self.schema_getter)?;
Ok(Lookuper {
table_path: Arc::new(self.table_info.table_path.clone()),
- row_type,
table_info: self.table_info,
metadata: self.metadata,
lookup_client: self.lookup_client,
@@ -221,6 +338,7 @@ impl TableLookup {
bucket_key_encoder,
partition_getter,
num_buckets,
+ schema_ctx,
})
}
}
@@ -239,7 +357,6 @@ impl TableLookup {
pub struct Lookuper {
table_path: Arc<TablePath>,
table_info: TableInfo,
- row_type: Arc<RowType>,
metadata: Arc<Metadata>,
lookup_client: Arc<LookupClient>,
bucketing_function: Box<dyn BucketingFunction>,
@@ -247,6 +364,7 @@ pub struct Lookuper {
bucket_key_encoder: Option<Box<dyn KeyEncoder>>,
partition_getter: Option<PartitionGetter>,
num_buckets: i32,
+ schema_ctx: LookupSchemaCtx,
}
impl Lookuper {
@@ -281,7 +399,7 @@ impl Lookuper {
.await?
{
Some(id) => Some(id),
- None => return
Ok(LookupResult::empty(Arc::clone(&self.row_type))),
+ None => return Ok(self.schema_ctx.empty_result()),
}
} else {
None
@@ -300,13 +418,11 @@ impl Lookuper {
.lookup(self.table_path.as_ref().clone(), table_bucket, pk_bytes)
.await?;
- match result {
- Some(value_bytes) => Ok(LookupResult::new(
- vec![value_bytes],
- Arc::clone(&self.row_type),
- )),
- None => Ok(LookupResult::empty(Arc::clone(&self.row_type))),
- }
+ let rows = match result {
+ Some(value_bytes) => vec![value_bytes],
+ None => Vec::new(),
+ };
+ self.schema_ctx.build_result(rows).await
}
/// Returns a reference to the table info.
@@ -319,6 +435,7 @@ pub struct TablePrefixLookup {
lookup_client: Arc<LookupClient>,
table_info: TableInfo,
metadata: Arc<Metadata>,
+ schema_getter: Arc<ClientSchemaGetter>,
lookup_column_names: Vec<String>,
}
@@ -346,11 +463,10 @@ impl TablePrefixLookup {
None
};
- let full_row_type = Arc::new(self.table_info.row_type().clone());
+ let schema_ctx = LookupSchemaCtx::new(&self.table_info,
self.schema_getter)?;
Ok(PrefixKeyLookuper {
table_path: Arc::new(self.table_info.table_path.clone()),
- row_type: full_row_type,
table_info: self.table_info,
metadata: self.metadata,
lookup_client: self.lookup_client,
@@ -358,6 +474,7 @@ impl TablePrefixLookup {
prefix_key_encoder,
partition_getter,
num_buckets,
+ schema_ctx,
})
}
}
@@ -454,13 +571,13 @@ fn validate_prefix_lookup(table_info: &TableInfo,
lookup_columns: &[String]) ->
pub struct PrefixKeyLookuper {
table_path: Arc<TablePath>,
table_info: TableInfo,
- row_type: Arc<RowType>,
metadata: Arc<Metadata>,
lookup_client: Arc<LookupClient>,
bucketing_function: Box<dyn BucketingFunction>,
prefix_key_encoder: Box<dyn KeyEncoder>,
partition_getter: Option<PartitionGetter>,
num_buckets: i32,
+ schema_ctx: LookupSchemaCtx,
}
impl PrefixKeyLookuper {
@@ -479,7 +596,7 @@ impl PrefixKeyLookuper {
.await?
{
Some(id) => Some(id),
- None => return
Ok(LookupResult::empty(Arc::clone(&self.row_type))),
+ None => return Ok(self.schema_ctx.empty_result()),
}
} else {
None
@@ -497,7 +614,7 @@ impl PrefixKeyLookuper {
.prefix_lookup(self.table_path.as_ref().clone(), table_bucket,
prefix_bytes)
.await?;
- Ok(LookupResult::new(rows, Arc::clone(&self.row_type)))
+ self.schema_ctx.build_result(rows).await
}
pub fn table_info(&self) -> &TableInfo {
@@ -508,7 +625,7 @@ impl PrefixKeyLookuper {
#[cfg(test)]
mod tests {
use super::*;
- use crate::metadata::{DataField, DataTypes};
+ use crate::metadata::{Column, DataTypes, Schema};
use crate::row::binary::BinaryWriter;
use crate::row::compacted::CompactedRowWriter;
use arrow::array::Int32Array;
@@ -520,34 +637,56 @@ mod tests {
bytes
}
+ fn schema_with_ids(columns: &[(i32, &str, crate::metadata::DataType)]) ->
Schema {
+ let cols: Vec<Column> = columns
+ .iter()
+ .map(|(id, name, dt)| Column::new(*name, dt.clone()).with_id(*id))
+ .collect();
+ Schema::builder().with_columns(cols).build().unwrap()
+ }
+
+ fn cache_with(
+ target_id: i16,
+ target_decoder: FixedSchemaDecoder,
+ others: Vec<(i16, FixedSchemaDecoder)>,
+ ) -> Arc<DecoderCache> {
+ let cache = DecoderCache::new(target_id, Arc::new(target_decoder));
+ for (id, decoder) in others {
+ cache.insert(id, Arc::new(decoder));
+ }
+ Arc::new(cache)
+ }
+
+ fn lookup_result_from(
+ rows: Vec<Vec<u8>>,
+ target_schema: &Schema,
+ decoders: Arc<DecoderCache>,
+ ) -> LookupResult {
+ LookupResult::new(rows, Arc::new(target_schema.row_type().clone()),
decoders)
+ }
+
#[test]
fn test_to_record_batch_empty() {
- let row_type = Arc::new(RowType::new(vec![DataField::new(
- "id",
- DataTypes::int(),
- None,
- )]));
- let result = LookupResult::empty(row_type);
+ let target = schema_with_ids(&[(0, "id", DataTypes::int())]);
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &target).unwrap();
+ let result = lookup_result_from(Vec::new(), &target, cache_with(0,
decoder, vec![]));
let batch = result.to_record_batch().unwrap();
assert_eq!(batch.num_rows(), 0);
assert_eq!(batch.num_columns(), 1);
}
#[test]
- fn test_to_record_batch_with_row() {
- let row_type = Arc::new(RowType::new(vec![DataField::new(
- "id",
- DataTypes::int(),
- None,
- )]));
+ fn test_to_record_batch_with_row_at_target_schema() {
+ let target = schema_with_ids(&[(0, "id", DataTypes::int())]);
let mut writer = CompactedRowWriter::new(1);
writer.write_int(42);
let row_bytes = make_row_bytes(0, writer.buffer());
- let result = LookupResult::new(vec![row_bytes], Arc::clone(&row_type));
- let batch = result.to_record_batch().unwrap();
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &target).unwrap();
+ let result = lookup_result_from(vec![row_bytes], &target,
cache_with(0, decoder, vec![]));
+ let batch = result.to_record_batch().unwrap();
assert_eq!(batch.num_rows(), 1);
let col = batch
.column(0)
@@ -557,15 +696,79 @@ mod tests {
assert_eq!(col.value(0), 42);
}
+ #[test]
+ fn test_get_rows_decodes_per_row_schema_id_with_projection() {
+ let source = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ let target = schema_with_ids(&[(0, "a", DataTypes::int()), (1, "b",
DataTypes::string())]);
+
+ let mut w = CompactedRowWriter::new(1);
+ w.write_int(7);
+ let old_row = make_row_bytes(3, w.buffer());
+
+ let mut w = CompactedRowWriter::new(2);
+ w.write_int(8);
+ w.write_string("eight");
+ let new_row = make_row_bytes(7, w.buffer());
+
+ let target_decoder =
+ FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED,
&target).unwrap();
+ let projection_decoder =
+ FixedSchemaDecoder::new(KvFormat::COMPACTED, &source,
&target).unwrap();
+ let cache = cache_with(7, target_decoder, vec![(3,
projection_decoder)]);
+ let result = lookup_result_from(vec![old_row, new_row], &target,
cache);
+
+ let rows = result.get_rows().unwrap();
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0].get_int(0).unwrap(), 7);
+ assert!(rows[0].is_null_at(1).unwrap());
+ assert_eq!(rows[1].get_int(0).unwrap(), 8);
+ assert_eq!(rows[1].get_string(1).unwrap(), "eight");
+ }
+
#[test]
fn test_to_record_batch_payload_too_short() {
- let row_type = Arc::new(RowType::new(vec![DataField::new(
- "id",
- DataTypes::int(),
- None,
- )]));
- // Only 1 byte — shorter than SCHEMA_ID_LENGTH (2)
- let result = LookupResult::new(vec![vec![0u8]], Arc::clone(&row_type));
+ let target = schema_with_ids(&[(0, "id", DataTypes::int())]);
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &target).unwrap();
+ let result = lookup_result_from(vec![vec![0u8]], &target,
cache_with(0, decoder, vec![]));
assert!(result.to_record_batch().is_err());
}
+
+ #[test]
+ fn test_get_rows_errors_when_no_decoder_for_schema_id() {
+ let target = schema_with_ids(&[(0, "id", DataTypes::int())]);
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &target).unwrap();
+ let mut w = CompactedRowWriter::new(1);
+ w.write_int(1);
+ let row = make_row_bytes(99, w.buffer());
+ let result = lookup_result_from(vec![row], &target, cache_with(0,
decoder, vec![]));
+
+ let err = result
+ .get_rows()
+ .map(|_| ())
+ .map_err(|e| e.to_string())
+ .unwrap_err();
+ assert!(err.contains("schema id 99"), "{err}");
+ }
+
+ #[test]
+ fn test_read_schema_id_rejects_negative() {
+ let bytes = [0xFFu8, 0xFFu8, 0u8];
+ let err = LookupResult::read_schema_id(&bytes).unwrap_err();
+ assert!(
+ err.to_string().contains("Invalid negative schema id"),
+ "{err}"
+ );
+ }
+
+ #[test]
+ fn test_decoder_cache_target_lookup_skips_lock() {
+ let target = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ let target_decoder =
+
Arc::new(FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED,
&target).unwrap());
+ let cache = DecoderCache::new(7, Arc::clone(&target_decoder));
+
+ let returned = cache.get(7).expect("target id must hit the cache");
+ assert!(Arc::ptr_eq(&returned, &target_decoder));
+ assert!(cache.get(99).is_none());
+ }
}
diff --git a/crates/fluss/src/client/table/mod.rs
b/crates/fluss/src/client/table/mod.rs
index 2d3d017..ba1edd2 100644
--- a/crates/fluss/src/client/table/mod.rs
+++ b/crates/fluss/src/client/table/mod.rs
@@ -17,8 +17,9 @@
use crate::client::connection::FlussConnection;
use crate::client::metadata::Metadata;
+use crate::client::schema_getter::ClientSchemaGetter;
use crate::error::{Error, Result};
-use crate::metadata::{TableInfo, TablePath};
+use crate::metadata::{SchemaInfo, TableInfo, TablePath};
use std::sync::Arc;
pub const EARLIEST_OFFSET: i64 = -2;
@@ -121,10 +122,22 @@ impl<'a> FlussTable<'a> {
});
}
let lookup_client = self.conn.get_or_create_lookup_client()?;
+ // Pre-seed the schema getter with the table's current schema —
+ // rows written under it (the dominant case) never trigger an RPC.
+ let latest = SchemaInfo::new(
+ self.table_info.get_schema().clone(),
+ self.table_info.get_schema_id(),
+ );
+ let schema_getter = Arc::new(ClientSchemaGetter::new(
+ self.table_path.clone(),
+ self.conn.get_admin()?,
+ latest,
+ ));
Ok(TableLookup::new(
lookup_client,
self.table_info.clone(),
self.metadata.clone(),
+ schema_getter,
))
}
diff --git a/crates/fluss/src/metadata/datatype.rs
b/crates/fluss/src/metadata/datatype.rs
index d9eb56b..4103aa3 100644
--- a/crates/fluss/src/metadata/datatype.rs
+++ b/crates/fluss/src/metadata/datatype.rs
@@ -93,6 +93,60 @@ impl DataType {
DataType::Bytes(v) => DataType::Bytes(v.as_non_nullable()),
}
}
+
+ /// Structural equality ignoring the outermost nullability flag at
+ /// every level. Equivalent to comparing `as_non_nullable()` on both
+ /// sides but without the recursive clone.
+ pub(crate) fn eq_ignore_nullable(&self, other: &DataType) -> bool {
+ match self {
+ DataType::Boolean(_) => matches!(other, DataType::Boolean(_)),
+ DataType::TinyInt(_) => matches!(other, DataType::TinyInt(_)),
+ DataType::SmallInt(_) => matches!(other, DataType::SmallInt(_)),
+ DataType::Int(_) => matches!(other, DataType::Int(_)),
+ DataType::BigInt(_) => matches!(other, DataType::BigInt(_)),
+ DataType::Float(_) => matches!(other, DataType::Float(_)),
+ DataType::Double(_) => matches!(other, DataType::Double(_)),
+ DataType::Date(_) => matches!(other, DataType::Date(_)),
+ DataType::String(_) => matches!(other, DataType::String(_)),
+ DataType::Bytes(_) => matches!(other, DataType::Bytes(_)),
+ DataType::Char(a) => {
+ matches!(other, DataType::Char(b) if a.length() == b.length())
+ }
+ DataType::Binary(a) => {
+ matches!(other, DataType::Binary(b) if a.length() ==
b.length())
+ }
+ DataType::Decimal(a) => matches!(
+ other,
+ DataType::Decimal(b) if a.precision() == b.precision() &&
a.scale() == b.scale()
+ ),
+ DataType::Time(a) => {
+ matches!(other, DataType::Time(b) if a.precision() ==
b.precision())
+ }
+ DataType::Timestamp(a) => {
+ matches!(other, DataType::Timestamp(b) if a.precision() ==
b.precision())
+ }
+ DataType::TimestampLTz(a) => {
+ matches!(other, DataType::TimestampLTz(b) if a.precision() ==
b.precision())
+ }
+ DataType::Array(a) => matches!(
+ other,
+ DataType::Array(b) if
a.get_element_type().eq_ignore_nullable(b.get_element_type())
+ ),
+ DataType::Map(a) => matches!(
+ other,
+ DataType::Map(b)
+ if a.key_type().eq_ignore_nullable(b.key_type())
+ && a.value_type().eq_ignore_nullable(b.value_type())
+ ),
+ DataType::Row(a) => matches!(
+ other,
+ DataType::Row(b) if a.fields().len() == b.fields().len()
+ && a.fields().iter().zip(b.fields().iter()).all(|(x, y)| {
+ x.name() == y.name() &&
x.data_type().eq_ignore_nullable(y.data_type())
+ })
+ ),
+ }
+ }
}
impl Display for DataType {
@@ -1695,3 +1749,106 @@ fn test_row_type_project_duplicate_indices() {
assert_eq!(projected.fields()[1].name, "id");
assert_eq!(projected.fields()[2].name, "name");
}
+
+#[cfg(test)]
+mod eq_ignore_nullable_tests {
+ use super::*;
+
+ #[test]
+ fn ignores_nullability_at_top_level() {
+ let nullable = DataType::Int(IntType::new());
+ let non_nullable = DataType::Int(IntType::with_nullable(false));
+ assert_ne!(nullable, non_nullable, "PartialEq still distinguishes");
+ assert!(nullable.eq_ignore_nullable(&non_nullable));
+ assert!(non_nullable.eq_ignore_nullable(&nullable));
+ }
+
+ #[test]
+ fn rejects_different_kinds() {
+ assert!(
+
!DataType::Int(IntType::new()).eq_ignore_nullable(&DataType::BigInt(BigIntType::new()))
+ );
+ }
+
+ #[test]
+ fn compares_parameterized_types() {
+ // Char length must match.
+ assert!(
+ DataType::Char(CharType::with_nullable(10, true))
+
.eq_ignore_nullable(&DataType::Char(CharType::with_nullable(10, false)))
+ );
+ assert!(
+ !DataType::Char(CharType::with_nullable(10, true))
+
.eq_ignore_nullable(&DataType::Char(CharType::with_nullable(11, true)))
+ );
+
+ // Decimal precision + scale must match.
+ let a = DataType::Decimal(DecimalType::with_nullable(true, 10,
2).unwrap());
+ let b = DataType::Decimal(DecimalType::with_nullable(false, 10,
2).unwrap());
+ let c = DataType::Decimal(DecimalType::with_nullable(true, 10,
3).unwrap());
+ assert!(a.eq_ignore_nullable(&b));
+ assert!(!a.eq_ignore_nullable(&c));
+ }
+
+ #[test]
+ fn recurses_into_array_and_map() {
+ // Array<Int NULL> ~ Array<Int NOT NULL>
+ let a = DataType::Array(ArrayType::with_nullable(
+ true,
+ DataType::Int(IntType::new()),
+ ));
+ let b = DataType::Array(ArrayType::with_nullable(
+ false,
+ DataType::Int(IntType::with_nullable(false)),
+ ));
+ assert!(a.eq_ignore_nullable(&b));
+
+ // Map<String, Int> on both sides, mixed nullability.
+ let m1 = DataType::Map(MapType::with_nullable(
+ true,
+ DataType::String(StringType::new()),
+ DataType::Int(IntType::new()),
+ ));
+ let m2 = DataType::Map(MapType::with_nullable(
+ false,
+ DataType::String(StringType::with_nullable(false)),
+ DataType::Int(IntType::with_nullable(false)),
+ ));
+ assert!(m1.eq_ignore_nullable(&m2));
+
+ // Map element-type mismatch is still caught.
+ let m3 = DataType::Map(MapType::with_nullable(
+ true,
+ DataType::String(StringType::new()),
+ DataType::BigInt(BigIntType::new()),
+ ));
+ assert!(!m1.eq_ignore_nullable(&m3));
+ }
+
+ #[test]
+ fn recurses_into_row_fields() {
+ let r1 = DataType::Row(RowType::new(vec![
+ DataField::new("a", DataType::Int(IntType::new()), None),
+ DataField::new("b", DataType::String(StringType::new()), None),
+ ]));
+ let r2 = DataType::Row(RowType::with_nullable(
+ false,
+ vec![
+ DataField::new("a",
DataType::Int(IntType::with_nullable(false)), None),
+ DataField::new(
+ "b",
+ DataType::String(StringType::with_nullable(false)),
+ None,
+ ),
+ ],
+ ));
+ assert!(r1.eq_ignore_nullable(&r2));
+
+ // Field name mismatch must fail.
+ let r3 = DataType::Row(RowType::new(vec![
+ DataField::new("renamed_a", DataType::Int(IntType::new()), None),
+ DataField::new("b", DataType::String(StringType::new()), None),
+ ]));
+ assert!(!r1.eq_ignore_nullable(&r3));
+ }
+}
diff --git a/crates/fluss/src/metadata/json_serde.rs
b/crates/fluss/src/metadata/json_serde.rs
index 20f5d09..efbce84 100644
--- a/crates/fluss/src/metadata/json_serde.rs
+++ b/crates/fluss/src/metadata/json_serde.rs
@@ -377,6 +377,7 @@ impl Column {
const NAME: &'static str = "name";
const DATA_TYPE: &'static str = "data_type";
const COMMENT: &'static str = "comment";
+ const ID: &'static str = "id";
}
impl JsonSerde for Column {
@@ -394,6 +395,9 @@ impl JsonSerde for Column {
obj.insert(Self::COMMENT.to_string(), json!(comment));
}
+ // The Java client requires `id` on input.
+ obj.insert(Self::ID.to_string(), json!(self.id()));
+
Ok(Value::Object(obj))
}
@@ -419,6 +423,15 @@ impl JsonSerde for Column {
column = column.with_comment(comment);
}
+ // Pre-id JSON is treated as unassigned; SchemaBuilder will
+ // auto-assign on build.
+ if let Some(id) = node.get(Self::ID).and_then(|v| v.as_i64()) {
+ let id = i32::try_from(id).map_err(|_| Error::JsonSerdeError {
+ message: format!("Column id {id} does not fit in i32"),
+ })?;
+ column = column.with_id(id);
+ }
+
Ok(column)
}
}
@@ -671,6 +684,95 @@ mod tests {
use super::*;
use crate::metadata::DataTypes;
+ #[test]
+ fn column_id_round_trip_through_json() {
+ use crate::metadata::Column;
+
+ let col = Column::new("a", DataTypes::int())
+ .with_id(7)
+ .with_comment("desc");
+ let json = col.serialize_json().unwrap();
+ assert_eq!(json.get("id").and_then(|v| v.as_i64()), Some(7));
+ let round_tripped = Column::deserialize_json(&json).unwrap();
+ assert_eq!(round_tripped, col);
+ }
+
+ #[test]
+ fn schema_assigns_ids_when_absent_and_preserves_when_present() {
+ use crate::metadata::{Column, Schema};
+
+ let auto = Schema::builder()
+ .column("a", DataTypes::int())
+ .column("b", DataTypes::string())
+ .build()
+ .unwrap();
+ let ids: Vec<i32> = auto.columns().iter().map(|c| c.id()).collect();
+ assert_eq!(ids, vec![0, 1]);
+
+ let preserved = Schema::builder()
+ .with_columns(vec![
+ Column::new("a", DataTypes::int()).with_id(3),
+ Column::new("b", DataTypes::string()).with_id(7),
+ ])
+ .build()
+ .unwrap();
+ let ids: Vec<i32> = preserved.columns().iter().map(|c|
c.id()).collect();
+ assert_eq!(ids, vec![3, 7]);
+ }
+
+ #[test]
+ fn schema_rejects_duplicate_ids() {
+ use crate::metadata::Column;
+ let err = Schema::builder()
+ .with_columns(vec![
+ Column::new("a", DataTypes::int()).with_id(7),
+ Column::new("b", DataTypes::string()).with_id(7),
+ ])
+ .build()
+ .unwrap_err();
+ assert!(err.to_string().contains("Duplicate column id 7"), "{err}");
+ }
+
+ #[test]
+ fn schema_rejects_negative_non_sentinel_ids() {
+ use crate::metadata::Column;
+ let err = Schema::builder()
+ .with_columns(vec![Column::new("a", DataTypes::int()).with_id(-7)])
+ .build()
+ .unwrap_err();
+ assert!(err.to_string().contains("invalid id -7"), "{err}");
+ }
+
+ #[test]
+ fn column_json_id_overflow_errors() {
+ use crate::metadata::Column;
+ let json = serde_json::json!({
+ "name": "a",
+ "data_type": Column::new("a",
DataTypes::int()).serialize_json().unwrap()
+ .get("data_type").unwrap(),
+ "id": (i32::MAX as i64) + 1,
+ });
+ let err = Column::deserialize_json(&json).unwrap_err();
+ assert!(err.to_string().contains("does not fit in i32"), "{err}");
+ }
+
+ #[test]
+ fn schema_rejects_partially_assigned_ids() {
+ use crate::metadata::Column;
+
+ let err = Schema::builder()
+ .with_columns(vec![
+ Column::new("a", DataTypes::int()).with_id(0),
+ Column::new("b", DataTypes::string()),
+ ])
+ .build()
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("All columns must have an id"),
+ "{err}"
+ );
+ }
+
#[test]
fn test_datatype_json_serde() {
let data_types = vec![
diff --git a/crates/fluss/src/metadata/mod.rs b/crates/fluss/src/metadata/mod.rs
index 0ca654a..c1d1b72 100644
--- a/crates/fluss/src/metadata/mod.rs
+++ b/crates/fluss/src/metadata/mod.rs
@@ -20,6 +20,7 @@ mod database;
mod datatype;
mod json_serde;
mod partition;
+mod schema_util;
mod table;
pub use data_lake_format::*;
@@ -27,4 +28,5 @@ pub use database::*;
pub use datatype::*;
pub use json_serde::*;
pub use partition::*;
+pub(crate) use schema_util::{UNEXIST_MAPPING, index_mapping};
pub use table::*;
diff --git a/crates/fluss/src/metadata/schema_util.rs
b/crates/fluss/src/metadata/schema_util.rs
new file mode 100644
index 0000000..498a526
--- /dev/null
+++ b/crates/fluss/src/metadata/schema_util.rs
@@ -0,0 +1,204 @@
+// 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.
+
+use crate::error::{Error, Result};
+use crate::metadata::{Schema, UNKNOWN_COLUMN_ID};
+use std::collections::{HashMap, HashSet};
+
+/// Sentinel for an expected column that does not exist in the origin
+/// schema. Used by [`index_mapping`] and [`crate::row::ProjectedRow`].
+pub(crate) const UNEXIST_MAPPING: i32 = -1;
+
+/// For each column in `expected_schema`, return the index of the column
+/// with the same id in `origin_schema`, or [`UNEXIST_MAPPING`] if absent.
+/// Matching by id keeps mappings stable across `ALTER TABLE … RENAME`.
+pub(crate) fn index_mapping(origin_schema: &Schema, expected_schema: &Schema)
-> Result<Vec<i32>> {
+ let origin_columns = origin_schema.columns();
+ let mut origin_id_to_index: HashMap<i32, usize> =
HashMap::with_capacity(origin_columns.len());
+ for (i, col) in origin_columns.iter().enumerate() {
+ if col.id() == UNKNOWN_COLUMN_ID {
+ return Err(Error::RowConvertError {
+ message: format!(
+ "origin schema column '{}' has no assigned id; cannot
build index mapping",
+ col.name()
+ ),
+ });
+ }
+ if origin_id_to_index.insert(col.id(), i).is_some() {
+ return Err(Error::RowConvertError {
+ message: format!("duplicate column id {} in origin schema",
col.id()),
+ });
+ }
+ }
+
+ let expected_columns = expected_schema.columns();
+ let mut mapping = Vec::with_capacity(expected_columns.len());
+ let mut expected_seen: HashSet<i32> =
HashSet::with_capacity(expected_columns.len());
+
+ for expected in expected_columns {
+ if expected.id() == UNKNOWN_COLUMN_ID {
+ return Err(Error::RowConvertError {
+ message: format!(
+ "expected schema column '{}' has no assigned id; cannot
build index mapping",
+ expected.name()
+ ),
+ });
+ }
+ if !expected_seen.insert(expected.id()) {
+ return Err(Error::RowConvertError {
+ message: format!("duplicate column id {} in expected schema",
expected.id()),
+ });
+ }
+ match origin_id_to_index.get(&expected.id()) {
+ None => mapping.push(UNEXIST_MAPPING),
+ Some(&idx) => {
+ let origin = &origin_columns[idx];
+ if
!origin.data_type().eq_ignore_nullable(expected.data_type()) {
+ return Err(Error::RowConvertError {
+ message: format!(
+ "Expected datatype of column(id={},name={}) is
[{}], while the actual datatype is [{}]",
+ expected.id(),
+ expected.name(),
+ expected.data_type(),
+ origin.data_type()
+ ),
+ });
+ }
+ mapping.push(idx as i32);
+ }
+ }
+ }
+
+ Ok(mapping)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::metadata::{Column, DataType, DataTypes};
+
+ fn schema_auto(columns: &[(&str, DataType)]) -> Schema {
+ let mut b = Schema::builder();
+ for (name, dt) in columns {
+ b = b.column(*name, dt.clone());
+ }
+ b.build().expect("schema build")
+ }
+
+ fn schema_with_ids(columns: &[(i32, &str, DataType)]) -> Schema {
+ let cols: Vec<Column> = columns
+ .iter()
+ .map(|(id, name, dt)| Column::new(*name, dt.clone()).with_id(*id))
+ .collect();
+ Schema::builder()
+ .with_columns(cols)
+ .build()
+ .expect("schema build")
+ }
+
+ #[test]
+ fn identity_mapping_when_schemas_equal() {
+ let s = schema_auto(&[
+ ("a", DataTypes::bigint()),
+ ("b", DataTypes::string()),
+ ("c", DataTypes::int()),
+ ]);
+ assert_eq!(index_mapping(&s, &s).unwrap(), vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn projection_subset_in_order() {
+ let origin = schema_auto(&[
+ ("a", DataTypes::bigint()),
+ ("b", DataTypes::string()),
+ ("c", DataTypes::int()),
+ ]);
+ let expected =
+ schema_with_ids(&[(0, "a", DataTypes::bigint()), (2, "c",
DataTypes::int())]);
+ assert_eq!(index_mapping(&origin, &expected).unwrap(), vec![0, 2]);
+ }
+
+ #[test]
+ fn reorder_mapping() {
+ let origin = schema_auto(&[
+ ("a", DataTypes::bigint()),
+ ("b", DataTypes::string()),
+ ("c", DataTypes::int()),
+ ]);
+ let expected = schema_with_ids(&[
+ (2, "c", DataTypes::int()),
+ (0, "a", DataTypes::bigint()),
+ (1, "b", DataTypes::string()),
+ ]);
+ assert_eq!(index_mapping(&origin, &expected).unwrap(), vec![2, 0, 1]);
+ }
+
+ #[test]
+ fn missing_column_returns_sentinel() {
+ let origin = schema_auto(&[("a", DataTypes::bigint())]);
+ let expected = schema_with_ids(&[
+ (0, "a", DataTypes::bigint()),
+ (1, "new_col", DataTypes::string()),
+ ]);
+ assert_eq!(
+ index_mapping(&origin, &expected).unwrap(),
+ vec![0, UNEXIST_MAPPING]
+ );
+ }
+
+ #[test]
+ fn rename_preserves_mapping_when_id_matches() {
+ let origin = schema_with_ids(&[(0, "old_name", DataTypes::int())]);
+ let expected = schema_with_ids(&[(0, "new_name", DataTypes::int())]);
+ assert_eq!(index_mapping(&origin, &expected).unwrap(), vec![0]);
+ }
+
+ #[test]
+ fn drop_then_add_with_same_name_does_not_alias() {
+ let origin = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ let expected = schema_with_ids(&[(5, "a", DataTypes::int())]);
+ assert_eq!(
+ index_mapping(&origin, &expected).unwrap(),
+ vec![UNEXIST_MAPPING]
+ );
+ }
+
+ #[test]
+ fn datatype_mismatch_returns_error() {
+ let origin = schema_auto(&[("a", DataTypes::bigint())]);
+ let expected = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ let err = index_mapping(&origin, &expected).unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("id=0"), "{msg}");
+ assert!(msg.contains("name=a"), "{msg}");
+ assert!(msg.contains("INT"), "{msg}");
+ assert!(msg.contains("BIGINT"), "{msg}");
+ }
+
+ #[test]
+ fn nullability_difference_does_not_error() {
+ // Primary-key normalization makes the origin non-nullable while
+ // the expected is nullable.
+ let origin = Schema::builder()
+ .column("a", DataTypes::int())
+ .primary_key(["a"])
+ .build()
+ .unwrap();
+ let expected = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ assert_eq!(index_mapping(&origin, &expected).unwrap(), vec![0]);
+ }
+}
diff --git a/crates/fluss/src/metadata/table.rs
b/crates/fluss/src/metadata/table.rs
index dba9a52..ebaf085 100644
--- a/crates/fluss/src/metadata/table.rs
+++ b/crates/fluss/src/metadata/table.rs
@@ -28,11 +28,15 @@ use std::fmt::{Display, Formatter};
use std::sync::Arc;
use strum_macros::EnumString;
+/// Sentinel for a column whose stable id has not yet been assigned.
+pub const UNKNOWN_COLUMN_ID: i32 = -1;
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Column {
name: String,
data_type: DataType,
comment: Option<String>,
+ id: i32,
}
impl Column {
@@ -41,6 +45,7 @@ impl Column {
name: name.into(),
data_type,
comment: None,
+ id: UNKNOWN_COLUMN_ID,
}
}
@@ -54,9 +59,15 @@ impl Column {
name: self.name.clone(),
data_type: data_type.clone(),
comment: self.comment.clone(),
+ id: self.id,
}
}
+ pub fn with_id(mut self, id: i32) -> Self {
+ self.id = id;
+ self
+ }
+
// Getters...
pub fn name(&self) -> &str {
&self.name
@@ -69,6 +80,12 @@ impl Column {
pub fn comment(&self) -> Option<&str> {
self.comment.as_deref()
}
+
+ /// Returns the stable column id, or [`UNKNOWN_COLUMN_ID`] when the
+ /// id has not yet been assigned by a [`SchemaBuilder`].
+ pub fn id(&self) -> i32 {
+ self.id
+ }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -152,6 +169,31 @@ impl Schema {
}
}
+/// A schema together with its server-assigned version id.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SchemaInfo {
+ schema: Schema,
+ schema_id: i32,
+}
+
+impl SchemaInfo {
+ pub fn new(schema: Schema, schema_id: i32) -> Self {
+ Self { schema, schema_id }
+ }
+
+ pub fn schema(&self) -> &Schema {
+ &self.schema
+ }
+
+ pub fn schema_id(&self) -> i32 {
+ self.schema_id
+ }
+
+ pub fn into_parts(self) -> (Schema, i32) {
+ (self.schema, self.schema_id)
+ }
+}
+
#[derive(Debug, Default)]
pub struct SchemaBuilder {
columns: Vec<Column>,
@@ -236,6 +278,7 @@ impl SchemaBuilder {
pub fn build(&self) -> Result<Schema> {
let columns = Self::normalize_columns(&self.columns,
self.primary_key.as_ref())?;
+ let columns = Self::assign_column_ids(columns)?;
let column_names: HashSet<_> = columns.iter().map(|c|
&c.name).collect();
for auto_inc_col in &self.auto_increment_col_names {
@@ -265,6 +308,42 @@ impl SchemaBuilder {
})
}
+ /// All-or-none: preserve ids if every column has one, auto-assign
+ /// 0..N-1 if none do, error on mixed input. When preserving ids,
+ /// also reject duplicates and negative-but-not-sentinel values.
+ fn assign_column_ids(columns: Vec<Column>) -> Result<Vec<Column>> {
+ let with_id = columns.iter().filter(|c| c.id !=
UNKNOWN_COLUMN_ID).count();
+ if with_id == 0 {
+ return Ok(columns
+ .into_iter()
+ .enumerate()
+ .map(|(i, c)| c.with_id(i as i32))
+ .collect());
+ }
+ if with_id != columns.len() {
+ return Err(IllegalArgument {
+ message: "All columns must have an id assigned, or none of
them must.".to_string(),
+ });
+ }
+ let mut seen: HashSet<i32> = HashSet::with_capacity(columns.len());
+ for col in &columns {
+ if col.id < 0 {
+ return Err(IllegalArgument {
+ message: format!(
+ "Column '{}' has invalid id {}; ids must be
non-negative",
+ col.name, col.id
+ ),
+ });
+ }
+ if !seen.insert(col.id) {
+ return Err(IllegalArgument {
+ message: format!("Duplicate column id {} in schema",
col.id),
+ });
+ }
+ }
+ Ok(columns)
+ }
+
fn normalize_columns(
columns: &[Column],
primary_key: Option<&PrimaryKey>,
@@ -662,7 +741,7 @@ impl LogFormat {
}
}
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, EnumString)]
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq,
EnumString)]
pub enum KvFormat {
INDEXED,
COMPACTED,
diff --git a/crates/fluss/src/proto/fluss_api.proto
b/crates/fluss/src/proto/fluss_api.proto
index f32d6b2..a544906 100644
--- a/crates/fluss/src/proto/fluss_api.proto
+++ b/crates/fluss/src/proto/fluss_api.proto
@@ -184,6 +184,18 @@ message GetTableInfoResponse {
required int64 modified_time = 5;
}
+// get table schema request and response. Mirrors the Java RPC at api key 1011.
+// Omitting `schema_id` requests the latest schema.
+message GetTableSchemaRequest {
+ required PbTablePath table_path = 1;
+ optional int32 schema_id = 2;
+}
+
+message GetTableSchemaResponse {
+ required int32 schema_id = 1;
+ required bytes schema_json = 2;
+}
+
message ListTablesRequest {
required string database_name = 1;
}
diff --git a/crates/fluss/src/record/kv/kv_record_read_context.rs
b/crates/fluss/src/record/kv/kv_record_read_context.rs
index 77cdbcb..4200e04 100644
--- a/crates/fluss/src/record/kv/kv_record_read_context.rs
+++ b/crates/fluss/src/record/kv/kv_record_read_context.rs
@@ -88,7 +88,7 @@ impl ReadContext for KvRecordReadContext {
let row_type = schema.row_type().clone();
// Create decoder outside lock
- let decoder = RowDecoderFactory::create(self.kv_format.clone(),
row_type)?;
+ let decoder = RowDecoderFactory::create(self.kv_format, row_type)?;
// Second check: insert only if another thread didn't beat us to it
{
diff --git a/crates/fluss/src/record/kv/test_util.rs
b/crates/fluss/src/record/kv/test_util.rs
index 50ab911..54eaac8 100644
--- a/crates/fluss/src/record/kv/test_util.rs
+++ b/crates/fluss/src/record/kv/test_util.rs
@@ -45,6 +45,6 @@ impl ReadContext for TestReadContext {
fn get_row_decoder(&self, _schema_id: i16) -> Result<Arc<dyn RowDecoder>> {
// Directly create decoder from data types - no Schema needed!
let row_type = RowType::with_data_types(self.data_types.clone());
- RowDecoderFactory::create(self.kv_format.clone(), row_type)
+ RowDecoderFactory::create(self.kv_format, row_type)
}
}
diff --git a/crates/fluss/src/row/fixed_schema_decoder.rs
b/crates/fluss/src/row/fixed_schema_decoder.rs
new file mode 100644
index 0000000..eec83f3
--- /dev/null
+++ b/crates/fluss/src/row/fixed_schema_decoder.rs
@@ -0,0 +1,196 @@
+// 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.
+
+//! Decode a `[schema_id (2 bytes) | row]` value into an [`InternalRow`]
+//! conforming to a fixed target schema, projecting across schema
+//! versions when needed.
+
+use crate::error::{Error, Result};
+use crate::metadata::{KvFormat, Schema, index_mapping};
+use crate::record::kv::SCHEMA_ID_LENGTH;
+use crate::row::{LookupRow, ProjectedRow, RowDecoder, RowDecoderFactory};
+use std::sync::Arc;
+
+pub(crate) struct FixedSchemaDecoder {
+ row_decoder: Arc<dyn RowDecoder>,
+ index_mapping: Option<Arc<[i32]>>,
+}
+
+impl FixedSchemaDecoder {
+ pub fn new_no_projection(kv_format: KvFormat, schema: &Schema) ->
Result<Self> {
+ let row_decoder = RowDecoderFactory::create(kv_format,
schema.row_type().clone())?;
+ Ok(Self {
+ row_decoder,
+ index_mapping: None,
+ })
+ }
+
+ pub fn new(
+ kv_format: KvFormat,
+ source_schema: &Schema,
+ target_schema: &Schema,
+ ) -> Result<Self> {
+ let mapping = index_mapping(source_schema, target_schema)?;
+ let row_decoder = RowDecoderFactory::create(kv_format,
source_schema.row_type().clone())?;
+ Ok(Self {
+ row_decoder,
+ index_mapping: Some(Arc::from(mapping.into_boxed_slice())),
+ })
+ }
+
+ pub fn decode<'a>(&self, value_bytes: &'a [u8]) -> Result<LookupRow<'a>> {
+ let payload =
+ value_bytes
+ .get(SCHEMA_ID_LENGTH..)
+ .ok_or_else(|| Error::RowConvertError {
+ message: format!(
+ "Row payload too short: {} bytes, need at least {} for
schema id",
+ value_bytes.len(),
+ SCHEMA_ID_LENGTH
+ ),
+ })?;
+ let row = self.row_decoder.decode(payload);
+ match &self.index_mapping {
+ None => Ok(LookupRow::raw(row)),
+ Some(mapping) => Ok(LookupRow::projected(ProjectedRow::new(
+ row,
+ Arc::clone(mapping),
+ ))),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::metadata::{Column, DataTypes, Schema};
+ use crate::record::kv::SCHEMA_ID_LENGTH;
+ use crate::row::InternalRow;
+ use crate::row::binary::BinaryWriter;
+ use crate::row::compacted::CompactedRowWriter;
+
+ fn schema_with_ids(columns: &[(i32, &str, crate::metadata::DataType)]) ->
Schema {
+ let cols: Vec<Column> = columns
+ .iter()
+ .map(|(id, name, dt)| Column::new(*name, dt.clone()).with_id(*id))
+ .collect();
+ Schema::builder().with_columns(cols).build().unwrap()
+ }
+
+ fn write_value(schema_id: i16, writer: CompactedRowWriter) -> Vec<u8> {
+ let row_bytes = writer.to_bytes();
+ let mut out = Vec::with_capacity(SCHEMA_ID_LENGTH + row_bytes.len());
+ out.extend_from_slice(&schema_id.to_le_bytes());
+ out.extend_from_slice(row_bytes.as_ref());
+ out
+ }
+
+ #[test]
+ fn decode_no_projection_strips_schema_id_and_returns_row() {
+ let schema = schema_with_ids(&[(0, "a", DataTypes::int()), (1, "b",
DataTypes::string())]);
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &schema).unwrap();
+
+ let mut writer = CompactedRowWriter::new(2);
+ writer.write_int(42);
+ writer.write_string("hi");
+ let value = write_value(7, writer);
+
+ let row = decoder.decode(&value).unwrap();
+ assert_eq!(row.get_field_count(), 2);
+ assert_eq!(row.get_int(0).unwrap(), 42);
+ assert_eq!(row.get_string(1).unwrap(), "hi");
+ }
+
+ #[test]
+ fn decode_with_projection_pads_missing_field_with_null() {
+ // Source schema (older): [a:int, b:string]
+ let source = schema_with_ids(&[(0, "a", DataTypes::int()), (1, "b",
DataTypes::string())]);
+ // Target schema (newer): added column c at id=2
+ let target = schema_with_ids(&[
+ (0, "a", DataTypes::int()),
+ (1, "b", DataTypes::string()),
+ (2, "c", DataTypes::bigint()),
+ ]);
+ let decoder = FixedSchemaDecoder::new(KvFormat::COMPACTED, &source,
&target).unwrap();
+
+ let mut writer = CompactedRowWriter::new(2);
+ writer.write_int(7);
+ writer.write_string("seven");
+ let value = write_value(0, writer);
+
+ let row = decoder.decode(&value).unwrap();
+ assert_eq!(row.get_field_count(), 3);
+ assert_eq!(row.get_int(0).unwrap(), 7);
+ assert_eq!(row.get_string(1).unwrap(), "seven");
+ assert!(
+ row.is_null_at(2).unwrap(),
+ "added-but-missing column must read as null"
+ );
+ }
+
+ #[test]
+ fn decode_with_projection_drops_removed_field() {
+ // Source schema (older): [a, b, c]
+ let source = schema_with_ids(&[
+ (0, "a", DataTypes::int()),
+ (1, "b", DataTypes::string()),
+ (2, "c", DataTypes::bigint()),
+ ]);
+ // Target schema (newer): dropped b
+ let target = schema_with_ids(&[(0, "a", DataTypes::int()), (2, "c",
DataTypes::bigint())]);
+ let decoder = FixedSchemaDecoder::new(KvFormat::COMPACTED, &source,
&target).unwrap();
+
+ let mut writer = CompactedRowWriter::new(3);
+ writer.write_int(1);
+ writer.write_string("dropped");
+ writer.write_long(99);
+ let value = write_value(0, writer);
+
+ let row = decoder.decode(&value).unwrap();
+ assert_eq!(row.get_field_count(), 2);
+ assert_eq!(row.get_int(0).unwrap(), 1);
+ assert_eq!(row.get_long(1).unwrap(), 99);
+ }
+
+ #[test]
+ fn decode_with_projection_reorders_fields() {
+ let source = schema_with_ids(&[(0, "a", DataTypes::int()), (1, "b",
DataTypes::string())]);
+ // Target reorders: b first, then a.
+ let target = schema_with_ids(&[(1, "b", DataTypes::string()), (0, "a",
DataTypes::int())]);
+ let decoder = FixedSchemaDecoder::new(KvFormat::COMPACTED, &source,
&target).unwrap();
+
+ let mut writer = CompactedRowWriter::new(2);
+ writer.write_int(123);
+ writer.write_string("xyz");
+ let value = write_value(0, writer);
+
+ let row = decoder.decode(&value).unwrap();
+ assert_eq!(row.get_string(0).unwrap(), "xyz");
+ assert_eq!(row.get_int(1).unwrap(), 123);
+ }
+
+ #[test]
+ fn decode_payload_too_short_errors() {
+ let schema = schema_with_ids(&[(0, "a", DataTypes::int())]);
+ let decoder =
FixedSchemaDecoder::new_no_projection(KvFormat::COMPACTED, &schema).unwrap();
+ // Only 1 byte — short of the schema id.
+ match decoder.decode(&[0u8]) {
+ Ok(_) => panic!("expected error for short payload"),
+ Err(e) => assert!(e.to_string().contains("too short"), "got: {e}"),
+ }
+ }
+}
diff --git a/crates/fluss/src/row/lookup_row.rs
b/crates/fluss/src/row/lookup_row.rs
new file mode 100644
index 0000000..c002369
--- /dev/null
+++ b/crates/fluss/src/row/lookup_row.rs
@@ -0,0 +1,122 @@
+// 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.
+
+//! Return type of [`crate::client::table::LookupResult`] getters: a row
+//! decoded under the table's current schema, possibly via projection
+//! over an older schema's bytes.
+
+use crate::client::WriteFormat;
+use crate::error::Result;
+use crate::row::compacted::CompactedRow;
+use crate::row::datum::{Date, Time, TimestampLtz, TimestampNtz};
+use crate::row::projected_row::ProjectedRow;
+use crate::row::{Decimal, FlussArray, InternalRow};
+
+pub struct LookupRow<'a> {
+ inner: Inner<'a>,
+}
+
+enum Inner<'a> {
+ Raw(CompactedRow<'a>),
+ Projected(ProjectedRow<CompactedRow<'a>>),
+}
+
+impl<'a> LookupRow<'a> {
+ pub(crate) fn raw(row: CompactedRow<'a>) -> Self {
+ Self {
+ inner: Inner::Raw(row),
+ }
+ }
+
+ pub(crate) fn projected(row: ProjectedRow<CompactedRow<'a>>) -> Self {
+ Self {
+ inner: Inner::Projected(row),
+ }
+ }
+}
+
+macro_rules! delegate {
+ ($self:ident, $method:ident $(, $arg:expr)*) => {
+ match &$self.inner {
+ Inner::Raw(r) => r.$method($($arg),*),
+ Inner::Projected(r) => r.$method($($arg),*),
+ }
+ };
+}
+
+impl<'a> InternalRow for LookupRow<'a> {
+ fn get_field_count(&self) -> usize {
+ delegate!(self, get_field_count)
+ }
+ fn is_null_at(&self, pos: usize) -> Result<bool> {
+ delegate!(self, is_null_at, pos)
+ }
+ fn get_boolean(&self, pos: usize) -> Result<bool> {
+ delegate!(self, get_boolean, pos)
+ }
+ fn get_byte(&self, pos: usize) -> Result<i8> {
+ delegate!(self, get_byte, pos)
+ }
+ fn get_short(&self, pos: usize) -> Result<i16> {
+ delegate!(self, get_short, pos)
+ }
+ fn get_int(&self, pos: usize) -> Result<i32> {
+ delegate!(self, get_int, pos)
+ }
+ fn get_long(&self, pos: usize) -> Result<i64> {
+ delegate!(self, get_long, pos)
+ }
+ fn get_float(&self, pos: usize) -> Result<f32> {
+ delegate!(self, get_float, pos)
+ }
+ fn get_double(&self, pos: usize) -> Result<f64> {
+ delegate!(self, get_double, pos)
+ }
+ fn get_char(&self, pos: usize, length: usize) -> Result<&str> {
+ delegate!(self, get_char, pos, length)
+ }
+ fn get_string(&self, pos: usize) -> Result<&str> {
+ delegate!(self, get_string, pos)
+ }
+ fn get_decimal(&self, pos: usize, precision: usize, scale: usize) ->
Result<Decimal> {
+ delegate!(self, get_decimal, pos, precision, scale)
+ }
+ fn get_date(&self, pos: usize) -> Result<Date> {
+ delegate!(self, get_date, pos)
+ }
+ fn get_time(&self, pos: usize) -> Result<Time> {
+ delegate!(self, get_time, pos)
+ }
+ fn get_timestamp_ntz(&self, pos: usize, precision: u32) ->
Result<TimestampNtz> {
+ delegate!(self, get_timestamp_ntz, pos, precision)
+ }
+ fn get_timestamp_ltz(&self, pos: usize, precision: u32) ->
Result<TimestampLtz> {
+ delegate!(self, get_timestamp_ltz, pos, precision)
+ }
+ fn get_binary(&self, pos: usize, length: usize) -> Result<&[u8]> {
+ delegate!(self, get_binary, pos, length)
+ }
+ fn get_bytes(&self, pos: usize) -> Result<&[u8]> {
+ delegate!(self, get_bytes, pos)
+ }
+ fn get_array(&self, pos: usize) -> Result<FlussArray> {
+ delegate!(self, get_array, pos)
+ }
+ fn as_encoded_bytes(&self, write_format: WriteFormat) -> Option<&[u8]> {
+ delegate!(self, as_encoded_bytes, write_format)
+ }
+}
diff --git a/crates/fluss/src/row/mod.rs b/crates/fluss/src/row/mod.rs
index 359a9a5..c6bdd7c 100644
--- a/crates/fluss/src/row/mod.rs
+++ b/crates/fluss/src/row/mod.rs
@@ -26,6 +26,9 @@ pub(crate) mod column_writer;
pub mod compacted;
pub mod encode;
pub mod field_getter;
+mod fixed_schema_decoder;
+mod lookup_row;
+mod projected_row;
mod row_decoder;
use crate::client::WriteFormat;
@@ -36,6 +39,9 @@ pub use compacted::CompactedRow;
pub use datum::*;
pub use decimal::{Decimal, MAX_COMPACT_PRECISION};
pub use encode::KeyEncoder;
+pub(crate) use fixed_schema_decoder::FixedSchemaDecoder;
+pub use lookup_row::LookupRow;
+pub(crate) use projected_row::ProjectedRow;
pub use row_decoder::{CompactedRowDecoder, RowDecoder, RowDecoderFactory};
pub struct BinaryRow<'a> {
diff --git a/crates/fluss/src/row/projected_row.rs
b/crates/fluss/src/row/projected_row.rs
new file mode 100644
index 0000000..cdd946a
--- /dev/null
+++ b/crates/fluss/src/row/projected_row.rs
@@ -0,0 +1,276 @@
+// 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.
+
+//! View over an [`InternalRow`] that re-orders, drops, and null-pads
+//! fields according to a target→source index mapping.
+
+use crate::client::WriteFormat;
+use crate::error::Error::IllegalArgument;
+use crate::error::Result;
+use crate::metadata::UNEXIST_MAPPING;
+use crate::row::datum::{Date, Time, TimestampLtz, TimestampNtz};
+use crate::row::{Decimal, FlussArray, InternalRow};
+use std::sync::Arc;
+
+pub(crate) struct ProjectedRow<R> {
+ index_mapping: Arc<[i32]>,
+ inner: R,
+}
+
+impl<R> ProjectedRow<R> {
+ pub fn new(inner: R, index_mapping: Arc<[i32]>) -> Self {
+ Self {
+ index_mapping,
+ inner,
+ }
+ }
+
+ fn source_index(&self, pos: usize) -> Result<usize> {
+ let mapped = self
+ .index_mapping
+ .get(pos)
+ .copied()
+ .ok_or_else(|| IllegalArgument {
+ message: format!(
+ "position {pos} out of bounds (projected row has {}
fields)",
+ self.index_mapping.len()
+ ),
+ })?;
+ if mapped == UNEXIST_MAPPING {
+ return Err(IllegalArgument {
+ message: format!(
+ "field at position {pos} does not exist in the source row \
+ (caller should check is_null_at first)"
+ ),
+ });
+ }
+ Ok(mapped as usize)
+ }
+}
+
+macro_rules! project {
+ ($self:ident, $method:ident, $pos:expr $(, $arg:expr)*) => {
+ $self.inner.$method($self.source_index($pos)?, $($arg),*)
+ };
+}
+
+impl<R: InternalRow> InternalRow for ProjectedRow<R> {
+ fn get_field_count(&self) -> usize {
+ self.index_mapping.len()
+ }
+
+ fn is_null_at(&self, pos: usize) -> Result<bool> {
+ let mapped = self
+ .index_mapping
+ .get(pos)
+ .copied()
+ .ok_or_else(|| IllegalArgument {
+ message: format!(
+ "position {pos} out of bounds (projected row has {}
fields)",
+ self.index_mapping.len()
+ ),
+ })?;
+ if mapped == UNEXIST_MAPPING {
+ return Ok(true);
+ }
+ self.inner.is_null_at(mapped as usize)
+ }
+
+ fn get_boolean(&self, pos: usize) -> Result<bool> {
+ project!(self, get_boolean, pos)
+ }
+ fn get_byte(&self, pos: usize) -> Result<i8> {
+ project!(self, get_byte, pos)
+ }
+ fn get_short(&self, pos: usize) -> Result<i16> {
+ project!(self, get_short, pos)
+ }
+ fn get_int(&self, pos: usize) -> Result<i32> {
+ project!(self, get_int, pos)
+ }
+ fn get_long(&self, pos: usize) -> Result<i64> {
+ project!(self, get_long, pos)
+ }
+ fn get_float(&self, pos: usize) -> Result<f32> {
+ project!(self, get_float, pos)
+ }
+ fn get_double(&self, pos: usize) -> Result<f64> {
+ project!(self, get_double, pos)
+ }
+ fn get_char(&self, pos: usize, length: usize) -> Result<&str> {
+ project!(self, get_char, pos, length)
+ }
+ fn get_string(&self, pos: usize) -> Result<&str> {
+ project!(self, get_string, pos)
+ }
+ fn get_decimal(&self, pos: usize, precision: usize, scale: usize) ->
Result<Decimal> {
+ project!(self, get_decimal, pos, precision, scale)
+ }
+ fn get_date(&self, pos: usize) -> Result<Date> {
+ project!(self, get_date, pos)
+ }
+ fn get_time(&self, pos: usize) -> Result<Time> {
+ project!(self, get_time, pos)
+ }
+ fn get_timestamp_ntz(&self, pos: usize, precision: u32) ->
Result<TimestampNtz> {
+ project!(self, get_timestamp_ntz, pos, precision)
+ }
+ fn get_timestamp_ltz(&self, pos: usize, precision: u32) ->
Result<TimestampLtz> {
+ project!(self, get_timestamp_ltz, pos, precision)
+ }
+ fn get_binary(&self, pos: usize, length: usize) -> Result<&[u8]> {
+ project!(self, get_binary, pos, length)
+ }
+ fn get_bytes(&self, pos: usize) -> Result<&[u8]> {
+ project!(self, get_bytes, pos)
+ }
+ fn get_array(&self, pos: usize) -> Result<FlussArray> {
+ project!(self, get_array, pos)
+ }
+
+ fn as_encoded_bytes(&self, _write_format: WriteFormat) -> Option<&[u8]> {
+ // Projection changes the field layout, so the inner row's
+ // encoded form no longer matches.
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::row::{Datum, GenericRow};
+
+ fn mapping(slots: &[i32]) -> Arc<[i32]> {
+ Arc::from(slots.to_vec().into_boxed_slice())
+ }
+
+ fn row_of<'a>(values: Vec<Datum<'a>>) -> GenericRow<'a> {
+ GenericRow { values }
+ }
+
+ #[test]
+ fn projects_and_reorders_longs() {
+ let mapping = mapping(&[2, 0, 1, 4]);
+ let inner = row_of(vec![
+ Datum::Int64(0),
+ Datum::Int64(1),
+ Datum::Int64(2),
+ Datum::Int64(3),
+ Datum::Int64(4),
+ ]);
+ let projected = ProjectedRow::new(inner, mapping);
+
+ assert_eq!(projected.get_field_count(), 4);
+ assert_eq!(projected.get_long(0).unwrap(), 2);
+ assert_eq!(projected.get_long(1).unwrap(), 0);
+ assert_eq!(projected.get_long(2).unwrap(), 1);
+ assert_eq!(projected.get_long(3).unwrap(), 4);
+ }
+
+ #[test]
+ fn projects_strings_and_doubles() {
+ let mapping = mapping(&[2, 0, 1, 4]);
+
+ let strings = row_of(vec![
+ Datum::String("0".into()),
+ Datum::String("1".into()),
+ Datum::String("2".into()),
+ Datum::String("3".into()),
+ Datum::String("4".into()),
+ ]);
+ let projected = ProjectedRow::new(strings, Arc::clone(&mapping));
+ assert_eq!(projected.get_string(0).unwrap(), "2");
+ assert_eq!(projected.get_string(1).unwrap(), "0");
+ assert_eq!(projected.get_string(3).unwrap(), "4");
+
+ let doubles = row_of(vec![
+ Datum::Float64(0.5.into()),
+ Datum::Float64(0.6.into()),
+ Datum::Float64(0.7.into()),
+ Datum::Float64(0.8.into()),
+ Datum::Float64(0.9.into()),
+ Datum::Float64(1.0.into()),
+ ]);
+ let projected = ProjectedRow::new(doubles, Arc::clone(&mapping));
+ assert_eq!(projected.get_double(0).unwrap(), 0.7);
+ assert_eq!(projected.get_double(1).unwrap(), 0.5);
+ assert_eq!(projected.get_double(3).unwrap(), 0.9);
+ }
+
+ #[test]
+ fn null_handling_passes_through_inner_nulls() {
+ let mapping = mapping(&[2, 0, 1, 4]);
+ let inner = row_of(vec![
+ Datum::Int64(5),
+ Datum::Int64(6),
+ Datum::Null,
+ Datum::Int64(8),
+ Datum::Null,
+ Datum::Int64(10),
+ ]);
+ let projected = ProjectedRow::new(inner, mapping);
+
+ assert!(projected.is_null_at(0).unwrap());
+ assert!(!projected.is_null_at(1).unwrap());
+ assert!(!projected.is_null_at(2).unwrap());
+ assert!(projected.is_null_at(3).unwrap());
+ }
+
+ #[test]
+ fn unexist_mapping_reports_null_and_errors_on_get() {
+ let mapping = mapping(&[0, 1, UNEXIST_MAPPING, 2]);
+ let inner = row_of(vec![Datum::Int64(10), Datum::Int64(20),
Datum::Int64(30)]);
+ let projected = ProjectedRow::new(inner, mapping);
+
+ assert_eq!(projected.get_field_count(), 4);
+ assert_eq!(projected.get_long(0).unwrap(), 10);
+ assert_eq!(projected.get_long(1).unwrap(), 20);
+ assert!(projected.is_null_at(2).unwrap());
+ let err = projected.get_long(2).unwrap_err();
+ assert!(err.to_string().contains("does not exist"), "got: {err}");
+ assert_eq!(projected.get_long(3).unwrap(), 30);
+ }
+
+ #[test]
+ fn out_of_bounds_position_returns_error() {
+ let mapping = mapping(&[0, 1]);
+ let inner = row_of(vec![Datum::Int64(1), Datum::Int64(2)]);
+ let projected = ProjectedRow::new(inner, mapping);
+
+ let err = projected.is_null_at(5).unwrap_err();
+ assert!(err.to_string().contains("out of bounds"), "got: {err}");
+ let err = projected.get_long(5).unwrap_err();
+ assert!(err.to_string().contains("out of bounds"), "got: {err}");
+ }
+
+ #[test]
+ fn shared_mapping_can_back_many_rows() {
+ let mapping = mapping(&[1, 0]);
+ let row_a = ProjectedRow::new(
+ row_of(vec![Datum::Int64(10), Datum::Int64(20)]),
+ Arc::clone(&mapping),
+ );
+ let row_b = ProjectedRow::new(
+ row_of(vec![Datum::Int64(30), Datum::Int64(40)]),
+ Arc::clone(&mapping),
+ );
+ assert_eq!(row_a.get_long(0).unwrap(), 20);
+ assert_eq!(row_a.get_long(1).unwrap(), 10);
+ assert_eq!(row_b.get_long(0).unwrap(), 40);
+ assert_eq!(row_b.get_long(1).unwrap(), 30);
+ }
+}
diff --git a/crates/fluss/src/rpc/api_key.rs b/crates/fluss/src/rpc/api_key.rs
index d524978..1ea0269 100644
--- a/crates/fluss/src/rpc/api_key.rs
+++ b/crates/fluss/src/rpc/api_key.rs
@@ -29,6 +29,7 @@ pub enum ApiKey {
ListTables, // 1008
ListPartitionInfos, // 1009
TableExists, // 1010
+ GetTableSchema, // 1011
MetaData, // 1012
ProduceLog, // 1014
FetchLog, // 1015
@@ -60,6 +61,7 @@ impl From<i16> for ApiKey {
1008 => ApiKey::ListTables,
1009 => ApiKey::ListPartitionInfos,
1010 => ApiKey::TableExists,
+ 1011 => ApiKey::GetTableSchema,
1012 => ApiKey::MetaData,
1014 => ApiKey::ProduceLog,
1015 => ApiKey::FetchLog,
@@ -94,6 +96,7 @@ impl From<ApiKey> for i16 {
ApiKey::ListTables => 1008,
ApiKey::ListPartitionInfos => 1009,
ApiKey::TableExists => 1010,
+ ApiKey::GetTableSchema => 1011,
ApiKey::MetaData => 1012,
ApiKey::ProduceLog => 1014,
ApiKey::FetchLog => 1015,
@@ -131,6 +134,7 @@ mod tests {
(1008, ApiKey::ListTables),
(1009, ApiKey::ListPartitionInfos),
(1010, ApiKey::TableExists),
+ (1011, ApiKey::GetTableSchema),
(1012, ApiKey::MetaData),
(1014, ApiKey::ProduceLog),
(1015, ApiKey::FetchLog),
diff --git a/crates/fluss/src/rpc/message/get_table_schema.rs
b/crates/fluss/src/rpc/message/get_table_schema.rs
new file mode 100644
index 0000000..ad7b23f
--- /dev/null
+++ b/crates/fluss/src/rpc/message/get_table_schema.rs
@@ -0,0 +1,57 @@
+// 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.
+
+use crate::proto::{GetTableSchemaRequest, GetTableSchemaResponse, PbTablePath};
+use crate::rpc::api_key::ApiKey;
+use crate::rpc::api_version::ApiVersion;
+use crate::rpc::frame::WriteError;
+use crate::rpc::message::{ReadVersionedType, RequestBody, WriteVersionedType};
+
+use crate::metadata::TablePath;
+use crate::rpc::frame::ReadError;
+
+use crate::{impl_read_version_type, impl_write_version_type};
+use bytes::{Buf, BufMut};
+use prost::Message;
+
+/// `schema_id = None` requests the latest schema.
+#[derive(Debug)]
+pub struct GetTableSchemaRequestMsg {
+ pub inner_request: GetTableSchemaRequest,
+}
+
+impl GetTableSchemaRequestMsg {
+ pub fn new(table_path: &TablePath, schema_id: Option<i32>) -> Self {
+ let inner_request = GetTableSchemaRequest {
+ table_path: PbTablePath {
+ database_name: table_path.database().to_owned(),
+ table_name: table_path.table().to_owned(),
+ },
+ schema_id,
+ };
+ Self { inner_request }
+ }
+}
+
+impl RequestBody for GetTableSchemaRequestMsg {
+ type ResponseBody = GetTableSchemaResponse;
+ const API_KEY: ApiKey = ApiKey::GetTableSchema;
+ const REQUEST_VERSION: ApiVersion = ApiVersion(0);
+}
+
+impl_write_version_type!(GetTableSchemaRequestMsg);
+impl_read_version_type!(GetTableSchemaResponse);
diff --git a/crates/fluss/src/rpc/message/mod.rs
b/crates/fluss/src/rpc/message/mod.rs
index d4739dc..456f9cb 100644
--- a/crates/fluss/src/rpc/message/mod.rs
+++ b/crates/fluss/src/rpc/message/mod.rs
@@ -33,6 +33,7 @@ mod get_database_info;
mod get_latest_lake_snapshot;
mod get_security_token;
mod get_table;
+mod get_table_schema;
mod header;
mod init_writer;
mod limit_scan;
@@ -61,6 +62,7 @@ pub use get_database_info::*;
pub use get_latest_lake_snapshot::*;
pub use get_security_token::*;
pub use get_table::*;
+pub use get_table_schema::*;
pub use header::*;
pub use init_writer::*;
pub use limit_scan::*;
diff --git a/website/docs/user-guide/rust/api-reference.md
b/website/docs/user-guide/rust/api-reference.md
index 15a62c1..9f2994a 100644
--- a/website/docs/user-guide/rust/api-reference.md
+++ b/website/docs/user-guide/rust/api-reference.md
@@ -53,13 +53,14 @@ Complete API reference for the Fluss Rust client.
### Table Operations
-| Method
| Description |
-|----------------------------------------------------------------------------------------------------------------------------|---------------------------|
-| `async fn create_table(&self, table_path: &TablePath, descriptor:
&TableDescriptor, ignore_if_exists: bool) -> Result<()>` | Create a table
|
-| `async fn drop_table(&self, table_path: &TablePath, ignore_if_not_exists:
bool) -> Result<()>` | Drop a table |
-| `async fn get_table_info(&self, table_path: &TablePath) ->
Result<TableInfo>` | Get table
metadata |
-| `async fn list_tables(&self, database_name: &str) -> Result<Vec<String>>`
| List tables in a database |
-| `async fn table_exists(&self, table_path: &TablePath) -> Result<bool>`
| Check if a table exists |
+| Method
| Description
|
+|----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
+| `async fn create_table(&self, table_path: &TablePath, descriptor:
&TableDescriptor, ignore_if_exists: bool) -> Result<()>` | Create a table
|
+| `async fn drop_table(&self, table_path: &TablePath, ignore_if_not_exists:
bool) -> Result<()>` | Drop a table
|
+| `async fn get_table_info(&self, table_path: &TablePath) ->
Result<TableInfo>` | Get table
metadata |
+| `async fn get_table_schema(&self, table_path: &TablePath, schema_id:
Option<i32>) -> Result<SchemaInfo>` | Get a table's schema by
id, or the latest schema when `schema_id` is `None` |
+| `async fn list_tables(&self, database_name: &str) -> Result<Vec<String>>`
| List tables in a database
|
+| `async fn table_exists(&self, table_path: &TablePath) -> Result<bool>`
| Check if a table exists
|
### Partition Operations
@@ -267,6 +268,17 @@ writer.append(&row)?.await?;
| `fn primary_key(keys: Vec<&str>) -> Self` | Set primary key
columns |
| `fn build() -> Result<Schema>` | Build the schema
|
+## `SchemaInfo`
+
+A schema together with its server-assigned version id. Returned by
[`FlussAdmin::get_table_schema`](#flussadmin).
+
+| Method | Description
|
+|--------------------------------------------------|------------------------------------------|
+| `fn new(schema: Schema, schema_id: i32) -> Self` | Construct from a schema
and id |
+| `fn schema(&self) -> &Schema` | Borrow the schema
|
+| `fn schema_id(&self) -> i32` | Get the server-assigned
schema id |
+| `fn into_parts(self) -> (Schema, i32)` | Consume and return
`(schema, schema_id)` |
+
## `TableDescriptor`
| Method | Description
|