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

CritasWang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iotdb-client-rust.git

commit cf3d9001e70a782a89f5e8d1e8914918c7a413cf
Author: CritasWang <[email protected]>
AuthorDate: Mon Jul 13 10:32:18 2026 +0800

    V2: insert_record(s) + insert_records_of_one_device with aligned variants; 
row-oriented value buffer (type marker + BE payload), Java-style null-cell 
filtering
---
 README.md             |  10 +
 README_ZH.md          |  10 +
 src/client/session.rs | 558 +++++++++++++++++++++++++++++++++++++++++++++++++-
 src/data/mod.rs       |   1 +
 src/data/record.rs    | 118 +++++++++++
 src/data/tablet.rs    |   4 +-
 6 files changed, 698 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index 486601d..e35aa09 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,16 @@ fn main() -> Result<()> {
     tablet.add_row(1_720_000_001_000, vec![None])?; // null cell
     session.insert_tablet(&tablet)?;
 
+    // Or write a single row via insertRecord (row-oriented; aligned variants
+    // and multi-row insert_records / insert_records_of_one_device also exist).
+    session.insert_record(
+        "root.demo.d1",
+        1_720_000_002_000,
+        vec!["temperature".into()],
+        &[Value::Double(22.0)],
+        false, // is_aligned
+    )?;
+
     // Query with row iteration; the dataset borrows the session until dropped.
     {
         let mut dataset = session.execute_query("SELECT temperature FROM 
root.demo.d1")?;
diff --git a/README_ZH.md b/README_ZH.md
index 7f34309..f1f9db3 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -61,6 +61,16 @@ fn main() -> Result<()> {
     tablet.add_row(1_720_000_001_000, vec![None])?; // null 单元格
     session.insert_tablet(&tablet)?;
 
+    // 也可以通过 insertRecord 写入单行(行式编码;另有 aligned 变体
+    // 以及多行的 insert_records / insert_records_of_one_device)。
+    session.insert_record(
+        "root.demo.d1",
+        1_720_000_002_000,
+        vec!["temperature".into()],
+        &[Value::Double(22.0)],
+        false, // is_aligned
+    )?;
+
     // 逐行迭代查询结果;数据集在被 drop 之前持有会话的借用。
     {
         let mut dataset = session.execute_query("SELECT temperature FROM 
root.demo.d1")?;
diff --git a/src/client/session.rs b/src/client/session.rs
index f84ad43..265aee0 100644
--- a/src/client/session.rs
+++ b/src/client/session.rs
@@ -23,11 +23,13 @@ use std::time::Duration;
 
 use crate::client::dataset::SessionDataSet;
 use crate::connection::{Connection, Endpoint};
-use crate::data::Tablet;
+use crate::data::record::serialize_record_values;
+use crate::data::{Tablet, Value};
 use crate::error::{Error, Result};
 use crate::protocol::client::{
     TIClientRPCServiceSyncClient, TSCloseOperationReq, TSCloseSessionReq, 
TSExecuteStatementReq,
-    TSFetchResultsReq, TSInsertTabletReq, TSOpenSessionReq, TSProtocolVersion,
+    TSFetchResultsReq, TSInsertRecordReq, TSInsertRecordsOfOneDeviceReq, 
TSInsertRecordsReq,
+    TSInsertTabletReq, TSOpenSessionReq, TSProtocolVersion,
 };
 use crate::protocol::common::TSStatus;
 
@@ -356,6 +358,202 @@ impl Session {
         check_status(&status)
     }
 
+    /// Insert one row for one device via `insertRecord`. `values[i]` pairs
+    /// with `measurements[i]`. The value buffer is row-oriented (per-cell
+    /// type marker + big-endian payload), unlike the tablet's column-major
+    /// layout.
+    ///
+    /// [`Value::Null`] cells are dropped together with their measurement
+    /// before sending (matching the Java client — the server rejects null
+    /// cells against registered series); an all-null row is an error.
+    pub fn insert_record(
+        &mut self,
+        device_id: &str,
+        timestamp: i64,
+        measurements: Vec<String>,
+        values: &[Value],
+        is_aligned: bool,
+    ) -> Result<()> {
+        check_record_arity(&measurements, values)?;
+        let (measurements, values) = filter_null_cells(measurements, values);
+        if values.is_empty() {
+            return Err(Error::Client("all insert values are null".into()));
+        }
+        let req = TSInsertRecordReq::new(
+            self.session_id,
+            device_id.to_string(),
+            measurements,
+            serialize_record_values(&values),
+            timestamp,
+            is_aligned,
+            None,
+            None,
+        );
+        let status = self.connection_mut()?.client_mut().insert_record(req)?;
+        check_status(&status)
+    }
+
+    /// Insert one row per device via `insertRecords` (multi-device batch).
+    /// `device_ids`, `timestamps`, `measurements_list` and `values_list`
+    /// must have equal length; row `i` targets `device_ids[i]`.
+    ///
+    /// Null cells are dropped per row; rows that end up all-null are dropped
+    /// entirely (Java behavior). An all-null batch is an error.
+    pub fn insert_records(
+        &mut self,
+        device_ids: Vec<String>,
+        timestamps: Vec<i64>,
+        measurements_list: Vec<Vec<String>>,
+        values_list: &[Vec<Value>],
+        is_aligned: bool,
+    ) -> Result<()> {
+        let n = device_ids.len();
+        if timestamps.len() != n || measurements_list.len() != n || 
values_list.len() != n {
+            return Err(Error::Client(format!(
+                "insert_records length mismatch: {} devices, {} timestamps, \
+                 {} measurement lists, {} value lists",
+                n,
+                timestamps.len(),
+                measurements_list.len(),
+                values_list.len()
+            )));
+        }
+        let mut kept_devices = Vec::with_capacity(n);
+        let mut kept_timestamps = Vec::with_capacity(n);
+        let mut kept_measurements = Vec::with_capacity(n);
+        let mut kept_buffers = Vec::with_capacity(n);
+        for (((device, ts), measurements), values) in device_ids
+            .into_iter()
+            .zip(timestamps)
+            .zip(measurements_list)
+            .zip(values_list)
+        {
+            check_record_arity(&measurements, values)?;
+            let (measurements, values) = filter_null_cells(measurements, 
values);
+            if values.is_empty() {
+                continue; // fully-null row: drop, like the Java client
+            }
+            kept_devices.push(device);
+            kept_timestamps.push(ts);
+            kept_measurements.push(measurements);
+            kept_buffers.push(serialize_record_values(&values));
+        }
+        if kept_devices.is_empty() {
+            return Err(Error::Client("all insert values are null".into()));
+        }
+        let req = TSInsertRecordsReq::new(
+            self.session_id,
+            kept_devices,
+            kept_measurements,
+            kept_buffers,
+            kept_timestamps,
+            is_aligned,
+        );
+        let status = self.connection_mut()?.client_mut().insert_records(req)?;
+        check_status(&status)
+    }
+
+    /// Insert multiple rows for one device via `insertRecordsOfOneDevice`.
+    /// Rows are stably sorted by timestamp client-side first (the server
+    /// requires ascending time), matching the Java/Python clients.
+    ///
+    /// Null cells are dropped per row; rows that end up all-null are dropped
+    /// entirely (Java behavior). An all-null batch is an error.
+    pub fn insert_records_of_one_device(
+        &mut self,
+        device_id: &str,
+        timestamps: Vec<i64>,
+        measurements_list: Vec<Vec<String>>,
+        values_list: &[Vec<Value>],
+        is_aligned: bool,
+    ) -> Result<()> {
+        let n = timestamps.len();
+        if measurements_list.len() != n || values_list.len() != n {
+            return Err(Error::Client(format!(
+                "insert_records_of_one_device length mismatch: {} timestamps, \
+                 {} measurement lists, {} value lists",
+                n,
+                measurements_list.len(),
+                values_list.len()
+            )));
+        }
+        let mut kept_timestamps = Vec::with_capacity(n);
+        let mut kept_measurements = Vec::with_capacity(n);
+        let mut kept_values = Vec::with_capacity(n);
+        for ((ts, measurements), values) in timestamps
+            .into_iter()
+            .zip(measurements_list)
+            .zip(values_list)
+        {
+            check_record_arity(&measurements, values)?;
+            let (measurements, values) = filter_null_cells(measurements, 
values);
+            if values.is_empty() {
+                continue; // fully-null row: drop, like the Java client
+            }
+            kept_timestamps.push(ts);
+            kept_measurements.push(measurements);
+            kept_values.push(values);
+        }
+        if kept_timestamps.is_empty() {
+            return Err(Error::Client("all insert values are null".into()));
+        }
+        let (timestamps, measurements_list, values_buffers) =
+            sort_one_device_rows(kept_timestamps, kept_measurements, 
&kept_values);
+        let req = TSInsertRecordsOfOneDeviceReq::new(
+            self.session_id,
+            device_id.to_string(),
+            measurements_list,
+            values_buffers,
+            timestamps,
+            is_aligned,
+        );
+        let status = self
+            .connection_mut()?
+            .client_mut()
+            .insert_records_of_one_device(req)?;
+        check_status(&status)
+    }
+
+    /// [`Session::insert_record`] against an aligned device
+    /// (`isAligned=true` on the same RPC).
+    pub fn insert_aligned_record(
+        &mut self,
+        device_id: &str,
+        timestamp: i64,
+        measurements: Vec<String>,
+        values: &[Value],
+    ) -> Result<()> {
+        self.insert_record(device_id, timestamp, measurements, values, true)
+    }
+
+    /// [`Session::insert_records`] against aligned devices.
+    pub fn insert_aligned_records(
+        &mut self,
+        device_ids: Vec<String>,
+        timestamps: Vec<i64>,
+        measurements_list: Vec<Vec<String>>,
+        values_list: &[Vec<Value>],
+    ) -> Result<()> {
+        self.insert_records(device_ids, timestamps, measurements_list, 
values_list, true)
+    }
+
+    /// [`Session::insert_records_of_one_device`] against an aligned device.
+    pub fn insert_aligned_records_of_one_device(
+        &mut self,
+        device_id: &str,
+        timestamps: Vec<i64>,
+        measurements_list: Vec<Vec<String>>,
+        values_list: &[Vec<Value>],
+    ) -> Result<()> {
+        self.insert_records_of_one_device(
+            device_id,
+            timestamps,
+            measurements_list,
+            values_list,
+            true,
+        )
+    }
+
     /// Close the session: best-effort `closeSession` RPC, then drop the
     /// connection.
     pub fn close(&mut self) -> Result<()> {
@@ -389,6 +587,65 @@ impl Drop for Session {
     }
 }
 
+/// Stably sorts one-device record rows by timestamp — reordering the
+/// measurement lists in step and serializing each row's value buffer in the
+/// sorted order (Java `genTSInsertRecordsOfOneDeviceReq`; the server
+/// requires ascending time).
+fn sort_one_device_rows(
+    timestamps: Vec<i64>,
+    measurements_list: Vec<Vec<String>>,
+    values_list: &[Vec<Value>],
+) -> (Vec<i64>, Vec<Vec<String>>, Vec<Vec<u8>>) {
+    let mut order: Vec<usize> = (0..timestamps.len()).collect();
+    order.sort_by_key(|&i| timestamps[i]); // stable
+    if order.iter().enumerate().all(|(pos, &i)| pos == i) {
+        let buffers = values_list
+            .iter()
+            .map(|v| serialize_record_values(v))
+            .collect();
+        return (timestamps, measurements_list, buffers);
+    }
+    (
+        order.iter().map(|&i| timestamps[i]).collect(),
+        order
+            .iter()
+            .map(|&i| measurements_list[i].clone())
+            .collect(),
+        order
+            .iter()
+            .map(|&i| serialize_record_values(&values_list[i]))
+            .collect(),
+    )
+}
+
+/// Drops [`Value::Null`] cells together with their measurements (Java
+/// `filterNullValueAndMeasurement`): the server rejects a null cell against
+/// a registered series, and its bare `-2` marker carries no type to
+/// auto-create one — omitting the measurement is the protocol's way of
+/// writing "no value".
+fn filter_null_cells(measurements: Vec<String>, values: &[Value]) -> 
(Vec<String>, Vec<Value>) {
+    if !values.iter().any(Value::is_null) {
+        return (measurements, values.to_vec());
+    }
+    measurements
+        .into_iter()
+        .zip(values.iter().cloned())
+        .filter(|(_, v)| !v.is_null())
+        .unzip()
+}
+
+/// One record row must pair each measurement with exactly one value.
+fn check_record_arity(measurements: &[String], values: &[Value]) -> Result<()> 
{
+    if measurements.len() != values.len() {
+        return Err(Error::Client(format!(
+            "record has {} values for {} measurements",
+            values.len(),
+            measurements.len()
+        )));
+    }
+    Ok(())
+}
+
 /// Map a `TSStatus` to success or [`Error::Server`] (protocol spec §2):
 /// 200 OK; 400 is a **successful** write with a redirect hint; 302 succeeds
 /// iff every subStatus is itself OK.
@@ -501,6 +758,119 @@ mod tests {
         assert!(session.close().is_ok()); // close on never-opened is fine
     }
 
+    #[test]
+    fn record_arity_is_validated() {
+        use crate::data::Value;
+        assert!(check_record_arity(&["s1".into()], 
&[Value::Int32(1)]).is_ok());
+        assert!(check_record_arity(&["s1".into(), "s2".into()], 
&[Value::Int32(1)]).is_err());
+        // Validation fires before any connection use — errors on a closed
+        // session must be the arity error, not "session is not open".
+        let mut session = Session::new(SessionConfig::default());
+        let err = session
+            .insert_record("root.sg.d1", 1, vec!["s1".into()], &[], false)
+            .unwrap_err();
+        assert!(matches!(err, Error::Client(m) if m.contains("0 values for 1 
measurements")));
+    }
+
+    #[test]
+    fn null_cells_are_filtered_with_their_measurements() {
+        use crate::data::Value;
+        let (m, v) = filter_null_cells(
+            vec!["a".into(), "b".into(), "c".into()],
+            &[Value::Int32(1), Value::Null, Value::Boolean(true)],
+        );
+        assert_eq!(m, ["a", "c"]);
+        assert_eq!(v, [Value::Int32(1), Value::Boolean(true)]);
+
+        // No nulls: passthrough.
+        let (m, v) = filter_null_cells(vec!["a".into()], &[Value::Int32(1)]);
+        assert_eq!(m, ["a"]);
+        assert_eq!(v, [Value::Int32(1)]);
+
+        // All-null rows are rejected before touching the connection.
+        let mut session = Session::new(SessionConfig::default());
+        let err = session
+            .insert_record("root.sg.d1", 1, vec!["s1".into()], &[Value::Null], 
false)
+            .unwrap_err();
+        assert!(matches!(err, Error::Client(m) if m.contains("all insert 
values are null")));
+        let err = session
+            .insert_records_of_one_device(
+                "root.sg.d1",
+                vec![1],
+                vec![vec!["s1".into()]],
+                &[vec![Value::Null]],
+                false,
+            )
+            .unwrap_err();
+        assert!(matches!(err, Error::Client(m) if m.contains("all insert 
values are null")));
+    }
+
+    #[test]
+    fn one_device_rows_are_sorted_by_timestamp() {
+        use crate::data::record::serialize_record_values;
+        use crate::data::Value;
+        let rows = [
+            vec![Value::Int32(30)],
+            vec![Value::Int32(10)],
+            vec![Value::Int32(20)],
+        ];
+        let (ts, ms, bufs) = sort_one_device_rows(
+            vec![3, 1, 2],
+            vec![vec!["a".into()], vec!["b".into()], vec!["c".into()]],
+            &rows,
+        );
+        assert_eq!(ts, [1, 2, 3]);
+        assert_eq!(
+            ms,
+            [vec!["b".to_string()], vec!["c".into()], vec!["a".into()]]
+        );
+        assert_eq!(
+            bufs,
+            [
+                serialize_record_values(&[Value::Int32(10)]),
+                serialize_record_values(&[Value::Int32(20)]),
+                serialize_record_values(&[Value::Int32(30)]),
+            ]
+        );
+
+        // Already-sorted input passes through unchanged (fast path), and the
+        // sort is stable for equal timestamps.
+        let (ts, ms, _) = sort_one_device_rows(
+            vec![5, 5],
+            vec![vec!["first".into()], vec!["second".into()]],
+            &[vec![Value::Int32(1)], vec![Value::Int32(2)]],
+        );
+        assert_eq!(ts, [5, 5]);
+        assert_eq!(ms, [vec!["first".to_string()], vec!["second".into()]]);
+    }
+
+    #[test]
+    fn insert_records_length_mismatch_is_client_error() {
+        use crate::data::Value;
+        let mut session = Session::new(SessionConfig::default());
+        let err = session
+            .insert_records(
+                vec!["root.sg.d1".into(), "root.sg.d2".into()],
+                vec![1], // 2 devices but 1 timestamp
+                vec![vec!["s1".into()], vec!["s1".into()]],
+                &[vec![Value::Int32(1)], vec![Value::Int32(2)]],
+                false,
+            )
+            .unwrap_err();
+        assert!(matches!(err, Error::Client(m) if m.contains("length 
mismatch")));
+
+        let err = session
+            .insert_records_of_one_device(
+                "root.sg.d1",
+                vec![1, 2],
+                vec![vec!["s1".into()]], // 2 timestamps but 1 measurement list
+                &[vec![Value::Int32(1)]],
+                false,
+            )
+            .unwrap_err();
+        assert!(matches!(err, Error::Client(m) if m.contains("length 
mismatch")));
+    }
+
     #[test]
     fn open_without_endpoints_fails() {
         let mut session = Session::new(SessionConfig {
@@ -724,4 +1094,188 @@ mod tests {
             .expect("cleanup");
         session.close().expect("close session");
     }
+
+    /// Live roundtrip for the whole `insertRecord(s)` family: single record
+    /// (with null + all-null-marker coverage), multi-device records, 
one-device
+    /// batch given unsorted (client must sort), and aligned variants on an
+    /// aligned device. Every write is read back with SELECT and each cell
+    /// asserted. Skipped when no IoTDB instance is reachable on
+    /// localhost:6667.
+    #[test]
+    fn live_insert_records_readback() {
+        use crate::data::Value;
+        use std::net::TcpStream;
+        if TcpStream::connect_timeout(
+            &"127.0.0.1:6667".parse().unwrap(),
+            Duration::from_millis(300),
+        )
+        .is_err()
+        {
+            eprintln!("skipping live_insert_records_readback: no IoTDB server 
on 127.0.0.1:6667");
+            return;
+        }
+
+        const DB: &str = "root.rusttest_records";
+
+        let read_rows = |session: &mut Session, sql: &str| -> Vec<(i64, 
Vec<Value>)> {
+            let mut rows = Vec::new();
+            let mut dataset = session.execute_query(sql).expect("query");
+            while let Some(row) = dataset.next_row().expect("next_row") {
+                rows.push((row.timestamp.expect("timestamp"), 
row.values.clone()));
+            }
+            rows
+        };
+
+        let mut session = Session::new(SessionConfig::default());
+        session.open().expect("open session");
+        let _ = session.execute_non_query(&format!("DELETE DATABASE {DB}"));
+        session
+            .execute_non_query(&format!("CREATE DATABASE {DB}"))
+            .expect("create database");
+
+        // --- insert_record: mixed types plus an explicit null ---
+        session
+            .insert_record(
+                &format!("{DB}.d1"),
+                11,
+                vec!["i".into(), "b".into()],
+                &[Value::Int32(43), Value::Boolean(true)],
+                false,
+            )
+            .expect("insert_record");
+        session
+            .insert_record(
+                &format!("{DB}.d1"),
+                10,
+                vec!["i".into(), "d".into(), "s".into(), "b".into()],
+                &[
+                    Value::Int32(42),
+                    Value::Double(2.5),
+                    Value::Text("hello".into()),
+                    Value::Null, // filtered out client-side with its 
measurement
+                ],
+                false,
+            )
+            .expect("insert_record row with null");
+        let rows = read_rows(
+            &mut session,
+            &format!("SELECT i, d, s, b FROM {DB}.d1 ORDER BY time"),
+        );
+        assert_eq!(rows.len(), 2);
+        assert_eq!(rows[0].0, 10);
+        assert_eq!(
+            rows[0].1,
+            [
+                Value::Int32(42),
+                Value::Double(2.5),
+                Value::Text("hello".into()),
+                Value::Null,
+            ]
+        );
+        assert_eq!(rows[1].0, 11);
+        assert_eq!(
+            rows[1].1,
+            [
+                Value::Int32(43),
+                Value::Null,
+                Value::Null,
+                Value::Boolean(true),
+            ]
+        );
+
+        // --- insert_records: one row each on two devices ---
+        session
+            .insert_records(
+                vec![format!("{DB}.d2"), format!("{DB}.d3")],
+                vec![20, 21],
+                vec![vec!["x".into()], vec!["x".into()]],
+                &[vec![Value::Int64(-7)], vec![Value::Float(1.5)]],
+                false,
+            )
+            .expect("insert_records");
+        let rows = read_rows(&mut session, &format!("SELECT x FROM {DB}.d2"));
+        assert_eq!(rows, [(20, vec![Value::Int64(-7)])]);
+        let rows = read_rows(&mut session, &format!("SELECT x FROM {DB}.d3"));
+        assert_eq!(rows, [(21, vec![Value::Float(1.5)])]);
+
+        // --- insert_records_of_one_device: unsorted input, client sorts ---
+        session
+            .insert_records_of_one_device(
+                &format!("{DB}.d4"),
+                vec![32, 30, 31],
+                vec![
+                    vec!["v".into()],
+                    vec!["v".into()],
+                    vec!["v".into(), "w".into()],
+                ],
+                &[
+                    vec![Value::Int32(320)],
+                    vec![Value::Int32(300)],
+                    vec![Value::Int32(310), Value::Text("mid".into())],
+                ],
+                false,
+            )
+            .expect("insert_records_of_one_device");
+        let rows = read_rows(
+            &mut session,
+            &format!("SELECT v, w FROM {DB}.d4 ORDER BY time"),
+        );
+        assert_eq!(
+            rows,
+            [
+                (30, vec![Value::Int32(300), Value::Null]),
+                (31, vec![Value::Int32(310), Value::Text("mid".into())]),
+                (32, vec![Value::Int32(320), Value::Null]),
+            ]
+        );
+
+        // --- aligned variants on a fresh aligned device ---
+        session
+            .execute_non_query(&format!(
+                "CREATE ALIGNED TIMESERIES {DB}.a1(s1 INT32, s2 DOUBLE)"
+            ))
+            .expect("create aligned timeseries");
+        session
+            .insert_aligned_record(
+                &format!("{DB}.a1"),
+                40,
+                vec!["s1".into(), "s2".into()],
+                &[Value::Int32(400), Value::Double(4.5)],
+            )
+            .expect("insert_aligned_record");
+        session
+            .insert_aligned_records(
+                vec![format!("{DB}.a1")],
+                vec![41],
+                vec![vec!["s1".into()]],
+                &[vec![Value::Int32(410)]],
+            )
+            .expect("insert_aligned_records");
+        session
+            .insert_aligned_records_of_one_device(
+                &format!("{DB}.a1"),
+                vec![43, 42],
+                vec![vec!["s1".into()], vec!["s2".into()]],
+                &[vec![Value::Int32(430)], vec![Value::Double(4.2)]],
+            )
+            .expect("insert_aligned_records_of_one_device");
+        let rows = read_rows(
+            &mut session,
+            &format!("SELECT s1, s2 FROM {DB}.a1 ORDER BY time"),
+        );
+        assert_eq!(
+            rows,
+            [
+                (40, vec![Value::Int32(400), Value::Double(4.5)]),
+                (41, vec![Value::Int32(410), Value::Null]),
+                (42, vec![Value::Null, Value::Double(4.2)]),
+                (43, vec![Value::Int32(430), Value::Null]),
+            ]
+        );
+
+        session
+            .execute_non_query(&format!("DELETE DATABASE {DB}"))
+            .expect("cleanup");
+        session.close().expect("close session");
+    }
 }
diff --git a/src/data/mod.rs b/src/data/mod.rs
index c0b0a76..c6f9218 100644
--- a/src/data/mod.rs
+++ b/src/data/mod.rs
@@ -20,6 +20,7 @@
 //! across all IoTDB client SDKs.
 
 pub mod bitmap;
+pub mod record;
 pub mod tablet;
 pub mod tsblock;
 pub mod value;
diff --git a/src/data/record.rs b/src/data/record.rs
new file mode 100644
index 0000000..760c033
--- /dev/null
+++ b/src/data/record.rs
@@ -0,0 +1,118 @@
+// 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.
+
+//! Row-oriented value-buffer serialization for the `insertRecord(s)` RPC
+//! family. Unlike the tablet's column-major layout (spec §3.2), a record
+//! interleaves type and value per field: each cell is a 1-byte TSDataType
+//! marker followed by its big-endian payload.
+
+use super::tablet::write_binary;
+use super::value::Value;
+
+/// Java `SessionUtils.TYPE_NULL` (`-2`): a null cell is this bare marker
+/// byte with no payload — the server's `InsertRowNode` reads `-2` as
+/// null-without-type.
+const TYPE_NULL: u8 = 0xFE;
+
+/// Serializes one record row for `insertRecord`/`insertRecords`/
+/// `insertRecordsOfOneDevice`: per value a 1-byte type-code marker plus the
+/// big-endian payload (TEXT/STRING/BLOB carry a 4-byte BE length prefix;
+/// DATE is i32 `yyyyMMdd`; BOOLEAN is one byte). [`Value::Null`] cells are
+/// the bare [`TYPE_NULL`] marker, matching the Java client.
+pub fn serialize_record_values(values: &[Value]) -> Vec<u8> {
+    let mut buf = Vec::new();
+    for value in values {
+        match value {
+            Value::Null => buf.push(TYPE_NULL),
+            other => {
+                let code = other.type_code().expect("non-null value carries a 
type");
+                buf.push(code as u8);
+                match other {
+                    Value::Boolean(b) => buf.push(u8::from(*b)),
+                    Value::Int32(v) | Value::Date(v) => 
buf.extend_from_slice(&v.to_be_bytes()),
+                    Value::Int64(v) | Value::Timestamp(v) => {
+                        buf.extend_from_slice(&v.to_be_bytes())
+                    }
+                    Value::Float(v) => buf.extend_from_slice(&v.to_be_bytes()),
+                    Value::Double(v) => 
buf.extend_from_slice(&v.to_be_bytes()),
+                    Value::Text(s) | Value::String(s) => write_binary(&mut 
buf, s.as_bytes()),
+                    Value::Blob(b) => write_binary(&mut buf, b),
+                    Value::Null => unreachable!("handled above"),
+                }
+            }
+        }
+    }
+    buf
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn all_types_known_bytes() {
+        let values = [
+            Value::Boolean(true),
+            Value::Int32(7),
+            Value::Int64(-2),
+            Value::Float(1.5),
+            Value::Double(-0.5),
+            Value::Text("ab".into()),
+            Value::Timestamp(3),
+            Value::Date(20260713),
+            Value::Blob(vec![0xDE, 0xAD]),
+            Value::String("é".into()),
+        ];
+
+        let mut expected: Vec<u8> = Vec::new();
+        expected.extend_from_slice(&[0x00, 0x01]); // BOOLEAN true
+        expected.push(0x01);
+        expected.extend_from_slice(&7i32.to_be_bytes());
+        expected.push(0x02);
+        expected.extend_from_slice(&(-2i64).to_be_bytes());
+        expected.push(0x03);
+        expected.extend_from_slice(&[0x3F, 0xC0, 0x00, 0x00]); // 1.5f32 IEEE 
754 BE
+        expected.push(0x04);
+        expected.extend_from_slice(&(-0.5f64).to_be_bytes());
+        expected.extend_from_slice(&[0x05, 0, 0, 0, 2, b'a', b'b']);
+        expected.push(0x08);
+        expected.extend_from_slice(&3i64.to_be_bytes());
+        expected.push(0x09);
+        expected.extend_from_slice(&20260713i32.to_be_bytes()); // yyyyMMdd
+        expected.extend_from_slice(&[0x0A, 0, 0, 0, 2, 0xDE, 0xAD]);
+        expected.extend_from_slice(&[0x0B, 0, 0, 0, 2, 0xC3, 0xA9]); // "é" 
UTF-8
+        assert_eq!(serialize_record_values(&values), expected);
+    }
+
+    #[test]
+    fn null_is_bare_type_marker() {
+        assert_eq!(serialize_record_values(&[Value::Null]), [0xFE]);
+        // Nulls interleave with typed cells without payload bytes.
+        let buf = serialize_record_values(&[
+            Value::Int32(5),
+            Value::Null,
+            Value::Boolean(false),
+            Value::Null,
+        ]);
+        assert_eq!(buf, [0x01, 0, 0, 0, 5, 0xFE, 0x00, 0x00, 0xFE]);
+    }
+
+    #[test]
+    fn empty_row_is_empty_buffer() {
+        assert_eq!(serialize_record_values(&[]), Vec::<u8>::new());
+    }
+}
diff --git a/src/data/tablet.rs b/src/data/tablet.rs
index 4ef1953..0bc3127 100644
--- a/src/data/tablet.rs
+++ b/src/data/tablet.rs
@@ -273,7 +273,9 @@ fn write_cell(buf: &mut Vec<u8>, ty: TSDataType, cell: 
Option<&Value>) {
 }
 
 /// 4-byte big-endian length prefix + raw bytes (TEXT/STRING/BLOB, §3.2).
-fn write_binary(buf: &mut Vec<u8>, bytes: &[u8]) {
+/// Shared with the record path (`data::record`), which prefixes the same
+/// encoding with a per-cell type marker.
+pub(super) fn write_binary(buf: &mut Vec<u8>, bytes: &[u8]) {
     buf.extend_from_slice(&(bytes.len() as i32).to_be_bytes());
     buf.extend_from_slice(bytes);
 }

Reply via email to