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

jt2594838 pushed a commit to branch insert_numeric_row
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit 7ad6ab3edd03f81f07ed5eaa9e8e6dbe4e80b5f0
Author: Tian Jiang <[email protected]>
AuthorDate: Thu Sep 17 11:07:48 2026 +0800

    add null bitmap
---
 .../it/session/IoTDBTableModelSessionIT.java       | 79 ++++++++++++++++++++++
 .../org/apache/iotdb/isession/ITableSession.java   | 14 ++++
 .../java/org/apache/iotdb/isession/NumericRow.java | 29 +++++++-
 .../isession/NumericRowPreparedStatement.java      | 10 +++
 .../apache/iotdb/session/i18n/SessionMessages.java |  2 +
 .../apache/iotdb/session/i18n/SessionMessages.java |  2 +
 .../java/org/apache/iotdb/session/Session.java     |  5 +-
 .../apache/iotdb/session/SessionConnection.java    |  9 +++
 .../org/apache/iotdb/session/TableSession.java     | 35 +++++++++-
 .../iotdb/session/pool/TableSessionWrapper.java    | 15 +++-
 .../apache/iotdb/session/util/SessionUtils.java    | 53 ++++++++++++++-
 .../session/PreparedNumericRowBufferTest.java      | 56 +++++++++++++++
 .../iotdb/session/util/SessionUtilsTest.java       | 63 +++++++++++++++++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  6 ++
 .../iotdb/db/i18n/DataNodeQueryMessages.java       |  6 ++
 .../protocol/session/PreparedNumericRowInfo.java   | 25 ++++++-
 .../protocol/thrift/impl/ClientRPCServiceImpl.java |  3 +-
 .../plan/parser/StatementGenerator.java            |  3 +-
 .../statement/crud/InsertNumericRowStatement.java  | 17 ++++-
 .../apache/iotdb/db/utils/NumericRowValues.java    | 30 ++++++++
 .../session/PreparedNumericRowInfoTest.java        | 75 ++++++++++++++++++++
 .../thrift-datanode/src/main/thrift/client.thrift  |  5 ++
 22 files changed, 527 insertions(+), 15 deletions(-)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/session/IoTDBTableModelSessionIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/session/IoTDBTableModelSessionIT.java
index 29ee26b30c0..9ac67ad9653 100644
--- 
a/integration-test/src/test/java/org/apache/iotdb/relational/it/session/IoTDBTableModelSessionIT.java
+++ 
b/integration-test/src/test/java/org/apache/iotdb/relational/it/session/IoTDBTableModelSessionIT.java
@@ -34,6 +34,7 @@ import org.apache.iotdb.rpc.StatementExecutionException;
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
 import org.apache.tsfile.read.common.RowRecord;
+import org.apache.tsfile.utils.BitMap;
 import org.apache.tsfile.write.record.Tablet;
 import org.junit.After;
 import org.junit.Assume;
@@ -232,6 +233,84 @@ public class IoTDBTableModelSessionIT {
     }
   }
 
+  @Test
+  public void testNumericRowNullBitmap() throws Exception {
+    // Exercise bits 0/7/8, interleaved TAG metadata, prepared fast/fallback 
paths and pooled
+    // sessions.
+    try (ITableSession session = 
EnvFactory.getEnv().getTableSessionConnection()) {
+      session.executeNonQueryStatement("CREATE DATABASE numeric_nulls");
+      session.executeNonQueryStatement("USE numeric_nulls");
+      StringBuilder ddl = new StringBuilder("CREATE TABLE readings(tag STRING 
TAG");
+      List<String> names = new ArrayList<>();
+      List<TSDataType> types = new ArrayList<>();
+      List<ColumnCategory> categories = new ArrayList<>();
+      long[] values = new long[9];
+      for (int i = 0; i < 9; i++) {
+        ddl.append(", f").append(i).append(" DOUBLE FIELD");
+        names.add("f" + i);
+        types.add(TSDataType.DOUBLE);
+        categories.add(ColumnCategory.FIELD);
+        values[i] = Double.doubleToRawLongBits(i + 0.25);
+      }
+      session.executeNonQueryStatement(ddl.append(")").toString());
+      names.add(1, "tag");
+      types.add(1, TSDataType.STRING);
+      categories.add(1, ColumnCategory.TAG);
+      BitMap nulls = new BitMap(9);
+      nulls.mark(0);
+      nulls.mark(7);
+      nulls.mark(8);
+      String[] tags = {"device"};
+      session.insert(new NumericRow("readings", 1, names, types, categories, 
values, tags, nulls));
+      try (NumericRowPreparedStatement prepared =
+          session.prepareInsertNumericRow("readings", names, types, 
categories)) {
+        session.executePreparedInsertNumericRow(prepared, 2, values, tags, 
nulls);
+        session.executePreparedInsertNumericRow(prepared, 3, values, tags);
+        // All-null table rows retain their timestamp/TAGs; null slots must 
never become zeros.
+        BitMap allNulls = new BitMap(9);
+        allNulls.markAll();
+        session.executePreparedInsertNumericRow(prepared, 3, values, tags, 
allNulls);
+        session.insert(
+            new NumericRow("readings", 10, names, types, categories, values, 
tags, allNulls));
+        session.executePreparedInsertNumericRow(prepared, 11, values, tags, 
allNulls);
+        session.executeNonQueryStatement("ALTER TABLE readings ADD COLUMN tag2 
STRING TAG");
+        session.executePreparedInsertNumericRow(prepared, 4, values, tags, 
nulls);
+      }
+      try (ITableSessionPool pool = EnvFactory.getEnv().getTableSessionPool(1, 
"numeric_nulls");
+          ITableSession pooled = pool.getSession();
+          NumericRowPreparedStatement prepared =
+              pooled.prepareInsertNumericRow("readings", names, types, 
categories)) {
+        pooled.insert(new NumericRow("readings", 5, names, types, categories, 
values, tags, nulls));
+        pooled.executePreparedInsertNumericRow(prepared, 6, values, tags, 
nulls);
+      }
+      assertNumericNullRows(session);
+      session.executeNonQueryStatement("FLUSH");
+      assertNumericNullRows(session);
+    }
+  }
+
+  private void assertNumericNullRows(ITableSession session) throws Exception {
+    try (SessionDataSet dataSet =
+        session.executeQueryStatement(
+            "SELECT time,tag,tag2,f0,f1,f2,f3,f4,f5,f6,f7,f8 FROM readings 
ORDER BY time")) {
+      for (int time : new int[] {1, 2, 3, 4, 5, 6, 10, 11}) {
+        assertTrue(dataSet.hasNext());
+        RowRecord row = dataSet.next();
+        assertEquals(time, row.getFields().get(0).getLongV());
+        assertEquals("device", row.getFields().get(1).getStringValue());
+        assertNull(row.getFields().get(2).getDataType());
+        for (int field = 0; field < 9; field++) {
+          if (time >= 10 || (time != 3 && (field == 0 || field == 7 || field 
== 8))) {
+            assertNull(row.getFields().get(field + 3).getDataType());
+          } else {
+            assertEquals(field + 0.25, row.getFields().get(field + 
3).getDoubleV(), 0);
+          }
+        }
+      }
+      assertFalse(dataSet.hasNext());
+    }
+  }
+
   @Test
   public void testPreparedNumericRowAndTabletWritePerformance() throws 
Exception {
     Assume.assumeTrue(
diff --git 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/ITableSession.java
 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/ITableSession.java
index d14abab1238..35a2f71b7dc 100644
--- 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/ITableSession.java
+++ 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/ITableSession.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.rpc.StatementExecutionException;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
 import org.apache.tsfile.write.record.Tablet;
 
 import java.util.List;
@@ -73,6 +74,19 @@ public interface ITableSession extends AutoCloseable {
       NumericRowPreparedStatement prepared, long timestamp, long[] values, 
String[] tagValues)
       throws StatementExecutionException, IoTDBConnectionException;
 
+  /**
+   * Executes a prepared row with an optional FIELD-only null bitmap sized to 
{@code values.length}.
+   * Marked slots are null and their values are ignored. Do not mutate the 
bitmap during execution.
+   * Requires a server supporting numeric row null bitmaps.
+   */
+  void executePreparedInsertNumericRow(
+      NumericRowPreparedStatement prepared,
+      long timestamp,
+      long[] values,
+      String[] tagValues,
+      BitMap nullBitmap)
+      throws StatementExecutionException, IoTDBConnectionException;
+
   /**
    * Executes a non-query SQL statement, such as a DDL or DML command.
    *
diff --git 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRow.java 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRow.java
index 502c75e05e4..040cb7632ba 100644
--- 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRow.java
+++ 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRow.java
@@ -21,6 +21,7 @@ package org.apache.iotdb.isession;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
 
 import java.util.List;
 
@@ -29,8 +30,9 @@ import java.util.List;
  *
  * <p>Column names, types and categories describe all supplied columns in the 
same order. The value
  * arrays contain only their respective FIELD or TAG values, in occurrence 
order in the column list.
- * TAGs may be null or empty; missing TAG columns are null. ATTRIBUTE columns, 
nonnumeric FIELDs and
- * null FIELD values are not supported.
+ * TAGs may be null or empty; missing TAG columns are null. An optional null 
bitmap marks null
+ * FIELDs by their index in {@code values}; marked slots retain their position 
but are not sent.
+ * ATTRIBUTE columns and nonnumeric FIELDs are not supported.
  *
  * <p>BOOLEAN uses zero for false and nonzero for true. INT32 and DATE use the 
low 32 bits (DATE is
  * encoded as yyyyMMdd); INT64 and TIMESTAMP use the long value. FLOAT uses 
{@link
@@ -48,6 +50,7 @@ public final class NumericRow {
   private final List<ColumnCategory> columnCategories;
   private final long[] values;
   private final String[] tagValues;
+  private final BitMap nullBitmap;
 
   /** Creates a row with numeric FIELDs and optional STRING TAGs in separate 
arrays. */
   public NumericRow(
@@ -58,6 +61,23 @@ public final class NumericRow {
       List<ColumnCategory> columnCategories,
       long[] values,
       String[] tagValues) {
+    this(tableName, timestamp, columnNames, dataTypes, columnCategories, 
values, tagValues, null);
+  }
+
+  /**
+   * Creates a row with an optional FIELD-only null bitmap, sized exactly to 
{@code values.length}.
+   * A marked bit denotes null. The bitmap is retained and must not change 
during insertion.
+   * Requires a server supporting numeric row null bitmaps when the bitmap is 
supplied.
+   */
+  public NumericRow(
+      String tableName,
+      long timestamp,
+      List<String> columnNames,
+      List<TSDataType> dataTypes,
+      List<ColumnCategory> columnCategories,
+      long[] values,
+      String[] tagValues,
+      BitMap nullBitmap) {
     this.tableName = tableName;
     this.timestamp = timestamp;
     this.columnNames = columnNames;
@@ -65,6 +85,7 @@ public final class NumericRow {
     this.columnCategories = columnCategories;
     this.values = values;
     this.tagValues = tagValues;
+    this.nullBitmap = nullBitmap;
   }
 
   public String getTableName() {
@@ -94,4 +115,8 @@ public final class NumericRow {
   public String[] getTagValues() {
     return tagValues;
   }
+
+  public BitMap getNullBitmap() {
+    return nullBitmap;
+  }
 }
diff --git 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRowPreparedStatement.java
 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRowPreparedStatement.java
index 8852d05a8a8..ee0b0f21804 100644
--- 
a/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRowPreparedStatement.java
+++ 
b/iotdb-client/isession/src/main/java/org/apache/iotdb/isession/NumericRowPreparedStatement.java
@@ -29,6 +29,7 @@ public final class NumericRowPreparedStatement implements 
AutoCloseable {
   private final int valueBytes;
   private ByteBuffer tagBuffer;
   private ByteBuffer valueBuffer;
+  private ByteBuffer nullBitmapBuffer;
   private long preparedId = -1;
   private Object owner;
   private Runnable closeAction = () -> {};
@@ -108,6 +109,15 @@ public final class NumericRowPreparedStatement implements 
AutoCloseable {
     return preparedId;
   }
 
+  /** Returns a reusable FIELD null bitmap buffer, allocated only for nullable 
executions. */
+  public ByteBuffer prepareNullBitmapBuffer() {
+    if (nullBitmapBuffer == null) {
+      nullBitmapBuffer = ByteBuffer.allocate((dataTypes.size() - tagCount + 7) 
/ 8);
+    }
+    nullBitmapBuffer.clear();
+    return nullBitmapBuffer;
+  }
+
   public Object getOwner() {
     return owner;
   }
diff --git 
a/iotdb-client/session/src/main/i18n/en/org/apache/iotdb/session/i18n/SessionMessages.java
 
b/iotdb-client/session/src/main/i18n/en/org/apache/iotdb/session/i18n/SessionMessages.java
index 173260e4a07..a1bdf45262e 100644
--- 
a/iotdb-client/session/src/main/i18n/en/org/apache/iotdb/session/i18n/SessionMessages.java
+++ 
b/iotdb-client/session/src/main/i18n/en/org/apache/iotdb/session/i18n/SessionMessages.java
@@ -20,6 +20,8 @@
 package org.apache.iotdb.session.i18n;
 
 public final class SessionMessages {
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_SIZE_MISMATCH_EXPECTED_ARG_FIELDS_BUT_GOT_ARG_8D30F6A0
 =
+      "Numeric row null bitmap size mismatch: expected %d FIELDs but got %d";
   public static final String 
EXCEPTION_NUMERIC_ROW_ARGUMENTS_MUST_BE_NON_NULL_COLUMN_METADATA_LENGTHS_AND_PER_CATEGORY_VALUE_COUNTS_MUST_MATCH_EB12B233
 = "Numeric row arguments must be non-null; column metadata lengths and 
per-category value counts must match.";
   public static final String 
EXCEPTION_NUMERIC_ROWS_SUPPORT_NUMERIC_FIELD_COLUMNS_AND_STRING_TAG_COLUMNS_ONLY_87159FFA
 = "Numeric rows support numeric FIELD columns and STRING TAG columns only.";
 
diff --git 
a/iotdb-client/session/src/main/i18n/zh/org/apache/iotdb/session/i18n/SessionMessages.java
 
b/iotdb-client/session/src/main/i18n/zh/org/apache/iotdb/session/i18n/SessionMessages.java
index b45704d8738..260b000e61a 100644
--- 
a/iotdb-client/session/src/main/i18n/zh/org/apache/iotdb/session/i18n/SessionMessages.java
+++ 
b/iotdb-client/session/src/main/i18n/zh/org/apache/iotdb/session/i18n/SessionMessages.java
@@ -20,6 +20,8 @@
 package org.apache.iotdb.session.i18n;
 
 public final class SessionMessages {
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_SIZE_MISMATCH_EXPECTED_ARG_FIELDS_BUT_GOT_ARG_8D30F6A0
 =
+      "Numeric row 的 null bitmap 大小不匹配:预期 %d 个 FIELD,实际为 %d";
   public static final String 
EXCEPTION_NUMERIC_ROW_ARGUMENTS_MUST_BE_NON_NULL_COLUMN_METADATA_LENGTHS_AND_PER_CATEGORY_VALUE_COUNTS_MUST_MATCH_EB12B233
 = "数值行参数不能为 null;列元数据长度必须一致,FIELD 和 TAG 的数量必须分别与对应的值数组长度一致。";
   public static final String 
EXCEPTION_NUMERIC_ROWS_SUPPORT_NUMERIC_FIELD_COLUMNS_AND_STRING_TAG_COLUMNS_ONLY_87159FFA
 = "数值行仅支持数值类型的 FIELD 列和 STRING 类型的 TAG 列。";
 
diff --git 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/Session.java 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/Session.java
index 835cf924777..f5c6ebd1b80 100644
--- a/iotdb-client/session/src/main/java/org/apache/iotdb/session/Session.java
+++ b/iotdb-client/session/src/main/java/org/apache/iotdb/session/Session.java
@@ -2922,7 +2922,7 @@ public class Session implements ISession {
     }
     ByteBuffer valueBuffer =
         SessionUtils.getValueBuffer(
-            rpcTypes, values, rpcMeasurements, rpcColumnCategories, tagValues);
+            rpcTypes, values, rpcMeasurements, rpcColumnCategories, tagValues, 
row.getNullBitmap());
     TSInsertRecordReq request = new TSInsertRecordReq();
     request.setPrefixPath(tableName);
     request.setTimestamp(timestamp);
@@ -2930,6 +2930,9 @@ public class Session implements ISession {
     request.setValues(valueBuffer);
     request.setIsWriteToTable(true);
     request.setIsNumericRow(true);
+    if (row.getNullBitmap() != null) {
+      
request.setNullBitmap(SessionUtils.encodeNumericRowNullBitmap(row.getNullBitmap(),
 null));
+    }
     request.setIsAligned(true);
     request.setColumnCategoryies(
         rpcColumnCategories.stream()
diff --git 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/SessionConnection.java
 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/SessionConnection.java
index e01c5dd8a20..f98e58965ae 100644
--- 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/SessionConnection.java
+++ 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/SessionConnection.java
@@ -782,8 +782,17 @@ public class SessionConnection {
   protected void executePreparedNumericRow(
       long id, long timestamp, ByteBuffer values, ByteBuffer tags)
       throws IoTDBConnectionException, StatementExecutionException {
+    executePreparedNumericRow(id, timestamp, values, tags, null);
+  }
+
+  protected void executePreparedNumericRow(
+      long id, long timestamp, ByteBuffer values, ByteBuffer tags, ByteBuffer 
nullBitmap)
+      throws IoTDBConnectionException, StatementExecutionException {
     TSExecutePreparedNumericRowReq request =
         new TSExecutePreparedNumericRowReq(sessionId, id, timestamp, values, 
tags);
+    if (nullBitmap != null) {
+      request.setNullBitmap(nullBitmap);
+    }
     try {
       RpcUtils.verifySuccess(client.executePreparedNumericRow(request));
     } catch (TException e) {
diff --git 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/TableSession.java 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/TableSession.java
index 7833557e10b..da93c37586f 100644
--- 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/TableSession.java
+++ 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/TableSession.java
@@ -27,9 +27,11 @@ import org.apache.iotdb.rpc.IoTDBConnectionException;
 import org.apache.iotdb.rpc.StatementExecutionException;
 import org.apache.iotdb.service.rpc.thrift.TSPrepareNumericRowReq;
 import org.apache.iotdb.session.i18n.SessionMessages;
+import org.apache.iotdb.session.util.SessionUtils;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
 import org.apache.tsfile.utils.ReadWriteIOUtils;
 import org.apache.tsfile.write.record.Tablet;
 
@@ -101,6 +103,17 @@ public class TableSession implements ITableSession {
   public void executePreparedInsertNumericRow(
       NumericRowPreparedStatement prepared, long timestamp, long[] values, 
String[] tagValues)
       throws StatementExecutionException, IoTDBConnectionException {
+    executePreparedInsertNumericRow(prepared, timestamp, values, tagValues, 
null);
+  }
+
+  @Override
+  public void executePreparedInsertNumericRow(
+      NumericRowPreparedStatement prepared,
+      long timestamp,
+      long[] values,
+      String[] tagValues,
+      BitMap nullBitmap)
+      throws StatementExecutionException, IoTDBConnectionException {
     SessionConnection connection = session.getDefaultSessionConnection();
     if (prepared == null || prepared.isClosed() || prepared.getOwner() != 
connection) {
       throw new IoTDBConnectionException(
@@ -109,10 +122,14 @@ public class TableSession implements ITableSession {
     }
     int tagCount = prepared.getTagCount();
     int fieldCount = prepared.getDataTypes().size() - tagCount;
-    if (values.length != fieldCount || tagValues.length != tagCount) {
+    if (values == null
+        || tagValues == null
+        || values.length != fieldCount
+        || tagValues.length != tagCount) {
       throw new IllegalArgumentException(
           
SessionMessages.EXCEPTION_PREPARED_NUMERIC_ROW_VALUE_COUNT_MISMATCH_9ACD3A0C);
     }
+    SessionUtils.validateNumericRowNullBitmap(nullBitmap, fieldCount);
     int tagBytes = 0;
     for (String tag : tagValues) {
       tagBytes += ReadWriteIOUtils.sizeToWrite(tag);
@@ -127,6 +144,9 @@ public class TableSession implements ITableSession {
     for (int i = 0; i < prepared.getDataTypes().size(); i++) {
       if (prepared.getColumnCategories().get(i) == ColumnCategory.FIELD) {
         long value = values[valueIndex++];
+        if (nullBitmap != null && nullBitmap.isMarked(valueIndex - 1)) {
+          continue;
+        }
         switch (prepared.getDataTypes().get(i)) {
           case BOOLEAN -> valueBuffer.put((byte) (value == 0 ? 0 : 1));
           case INT32, DATE, FLOAT -> valueBuffer.putInt((int) value);
@@ -138,8 +158,17 @@ public class TableSession implements ITableSession {
       }
     }
     valueBuffer.flip();
-    connection.executePreparedNumericRow(
-        prepared.getPreparedId(), timestamp, valueBuffer, tagBuffer);
+    if (nullBitmap == null) {
+      connection.executePreparedNumericRow(
+          prepared.getPreparedId(), timestamp, valueBuffer, tagBuffer);
+    } else {
+      connection.executePreparedNumericRow(
+          prepared.getPreparedId(),
+          timestamp,
+          valueBuffer,
+          tagBuffer,
+          SessionUtils.encodeNumericRowNullBitmap(nullBitmap, 
prepared.prepareNullBitmapBuffer()));
+    }
   }
 
   @Override
diff --git 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/pool/TableSessionWrapper.java
 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/pool/TableSessionWrapper.java
index 627501a3200..7c950949d0d 100644
--- 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/pool/TableSessionWrapper.java
+++ 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/pool/TableSessionWrapper.java
@@ -31,6 +31,7 @@ import org.apache.iotdb.session.i18n.SessionMessages;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
 import org.apache.tsfile.write.record.Tablet;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -104,8 +105,20 @@ public class TableSessionWrapper implements ITableSession {
   public void executePreparedInsertNumericRow(
       NumericRowPreparedStatement prepared, long timestamp, long[] values, 
String[] tagValues)
       throws StatementExecutionException, IoTDBConnectionException {
+    executePreparedInsertNumericRow(prepared, timestamp, values, tagValues, 
null);
+  }
+
+  @Override
+  public void executePreparedInsertNumericRow(
+      NumericRowPreparedStatement prepared,
+      long timestamp,
+      long[] values,
+      String[] tagValues,
+      BitMap nullBitmap)
+      throws StatementExecutionException, IoTDBConnectionException {
     try {
-      tableSession.executePreparedInsertNumericRow(prepared, timestamp, 
values, tagValues);
+      tableSession.executePreparedInsertNumericRow(
+          prepared, timestamp, values, tagValues, nullBitmap);
     } catch (IoTDBConnectionException e) {
       sessionPool.cleanSessionAndMayThrowConnectionException(session);
       closed.set(true);
diff --git 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/util/SessionUtils.java
 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/util/SessionUtils.java
index 40fa0fae41f..fd1dd00cc38 100644
--- 
a/iotdb-client/session/src/main/java/org/apache/iotdb/session/util/SessionUtils.java
+++ 
b/iotdb-client/session/src/main/java/org/apache/iotdb/session/util/SessionUtils.java
@@ -171,6 +171,18 @@ public class SessionUtils {
       List<ColumnCategory> categories,
       String[] tagValues)
       throws IoTDBConnectionException {
+    return getValueBuffer(types, values, measurements, categories, tagValues, 
null);
+  }
+
+  /** Null FIELD slots retain their indices but consume no payload bytes. */
+  public static ByteBuffer getValueBuffer(
+      List<TSDataType> types,
+      long[] values,
+      List<String> measurements,
+      List<ColumnCategory> categories,
+      String[] tagValues,
+      BitMap nullBitmap)
+      throws IoTDBConnectionException {
     if (types == null
         || values == null
         || measurements == null
@@ -180,6 +192,7 @@ public class SessionUtils {
         || types.size() != categories.size()) {
       throw invalidNumericRowArguments();
     }
+    validateNumericRowNullBitmap(nullBitmap, values.length);
     int fieldCount = 0;
     int tagCount = 0;
     int length = types.size();
@@ -191,14 +204,20 @@ public class SessionUtils {
         }
         length += ReadWriteIOUtils.sizeToWrite(tagValues[tagCount++]);
       } else if (categories.get(i) == ColumnCategory.FIELD && type != null) {
-        fieldCount++;
-        length +=
+        if (fieldCount >= values.length) {
+          throw invalidNumericRowArguments();
+        }
+        int valueSize =
             switch (type) {
               case BOOLEAN -> Byte.BYTES;
               case INT32, DATE, FLOAT -> Integer.BYTES;
               case INT64, TIMESTAMP, DOUBLE -> Long.BYTES;
               default -> throw new 
IoTDBConnectionException(MSG_UNSUPPORTED_DATA_TYPE + type);
             };
+        if (nullBitmap == null || !nullBitmap.isMarked(fieldCount)) {
+          length += valueSize;
+        }
+        fieldCount++;
       } else {
         throw new IllegalArgumentException(
             SessionMessages
@@ -226,6 +245,9 @@ public class SessionUtils {
       TSDataType type = types.get(i);
       if (categories.get(i) == ColumnCategory.FIELD) {
         long value = values[fieldIndex++];
+        if (nullBitmap != null && nullBitmap.isMarked(fieldIndex - 1)) {
+          continue;
+        }
         switch (type) {
           case BOOLEAN -> buffer.put((byte) (value == 0 ? 0 : 1));
           case INT32, DATE, FLOAT -> buffer.putInt((int) value);
@@ -244,6 +266,33 @@ public class SessionUtils {
             
.EXCEPTION_NUMERIC_ROW_ARGUMENTS_MUST_BE_NON_NULL_COLUMN_METADATA_LENGTHS_AND_PER_CATEGORY_VALUE_COUNTS_MUST_MATCH_EB12B233);
   }
 
+  public static void validateNumericRowNullBitmap(BitMap nullBitmap, int 
fieldCount) {
+    if (nullBitmap != null && nullBitmap.getSize() != fieldCount) {
+      throw new IllegalArgumentException(
+          String.format(
+              SessionMessages
+                  
.EXCEPTION_NUMERIC_ROW_NULL_BITMAP_SIZE_MISMATCH_EXPECTED_ARG_FIELDS_BUT_GOT_ARG_8D30F6A0,
+              fieldCount,
+              nullBitmap.getSize()));
+    }
+  }
+
+  /** Encodes FIELD bits LSB first with zero padding, including when 
BitMap.markAll() was used. */
+  public static ByteBuffer encodeNumericRowNullBitmap(BitMap nullBitmap, 
ByteBuffer buffer) {
+    int fieldCount = nullBitmap.getSize();
+    int length = (fieldCount + 7) / 8;
+    if (buffer == null) {
+      buffer = ByteBuffer.allocate(length);
+    }
+    buffer.clear();
+    buffer.put(nullBitmap.getByteArray(), 0, length);
+    if (fieldCount % 8 != 0) {
+      buffer.put(length - 1, (byte) (buffer.get(length - 1) & ((1 << 
(fieldCount % 8)) - 1)));
+    }
+    buffer.flip();
+    return buffer;
+  }
+
   public static int calculateLength(List<TSDataType> types, List<? extends 
Object> values)
       throws IoTDBConnectionException {
     int res = 0;
diff --git 
a/iotdb-client/session/src/test/java/org/apache/iotdb/session/PreparedNumericRowBufferTest.java
 
b/iotdb-client/session/src/test/java/org/apache/iotdb/session/PreparedNumericRowBufferTest.java
index a409d6844f0..500e4a7b088 100644
--- 
a/iotdb-client/session/src/test/java/org/apache/iotdb/session/PreparedNumericRowBufferTest.java
+++ 
b/iotdb-client/session/src/test/java/org/apache/iotdb/session/PreparedNumericRowBufferTest.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.rpc.IoTDBConnectionException;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
 import org.apache.tsfile.utils.ReadWriteIOUtils;
 import org.junit.Test;
 
@@ -44,6 +45,61 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 public class PreparedNumericRowBufferTest {
+  // Reusing the statement after sparse/all-null rows must reset both payload 
and bitmap cursors.
+  @Test
+  public void testNullBitmapBufferReuse() throws Exception {
+    Session session = mock(Session.class);
+    SessionConnection connection = mock(SessionConnection.class);
+    when(session.getDefaultSessionConnection()).thenReturn(connection);
+    TableSession tableSession = new TableSession(session);
+    NumericRowPreparedStatement prepared =
+        new NumericRowPreparedStatement(
+            "t",
+            List.of("a", "b", "c"),
+            List.of(TSDataType.INT64, TSDataType.INT64, TSDataType.INT64),
+            List.of(ColumnCategory.FIELD, ColumnCategory.FIELD, 
ColumnCategory.FIELD));
+    prepared.bind(1, connection, () -> {});
+    List<ByteBuffer> bitmaps = new ArrayList<>();
+    List<ByteBuffer> payloads = new ArrayList<>();
+    int[] masks = {2, 7, 0};
+    doAnswer(
+            invocation -> {
+              int index = bitmaps.size();
+              ByteBuffer values = invocation.getArgument(2);
+              ByteBuffer bitmap = invocation.getArgument(4);
+              bitmaps.add(bitmap);
+              payloads.add(values);
+              assertEquals(masks[index], bitmap.get());
+              assertFalse(bitmap.hasRemaining());
+              for (int field = 0; field < 3; field++) {
+                if ((masks[index] & (1 << field)) == 0) {
+                  assertEquals(field + 10, values.getLong());
+                }
+              }
+              assertFalse(values.hasRemaining());
+              return null;
+            })
+        .when(connection)
+        .executePreparedNumericRow(
+            eq(1L), anyLong(), any(ByteBuffer.class), any(ByteBuffer.class), 
any(ByteBuffer.class));
+    BitMap nulls = new BitMap(3);
+    for (int mask : masks) {
+      nulls.reset();
+      for (int i = 0; i < 3; i++) {
+        if ((mask & (1 << i)) != 0) {
+          nulls.mark(i);
+        }
+      }
+      tableSession.executePreparedInsertNumericRow(
+          prepared, 1, new long[] {10, 11, 12}, new String[0], nulls);
+    }
+    assertEquals(3, bitmaps.size());
+    for (int i = 1; i < 3; i++) {
+      assertSame(bitmaps.get(0), bitmaps.get(i));
+      assertSame(payloads.get(0), payloads.get(i));
+    }
+  }
+
   @Test
   public void testBuffersAcrossTagGrowthShrinkAndRpcFailure() throws Exception 
{
     Session session = mock(Session.class);
diff --git 
a/iotdb-client/session/src/test/java/org/apache/iotdb/session/util/SessionUtilsTest.java
 
b/iotdb-client/session/src/test/java/org/apache/iotdb/session/util/SessionUtilsTest.java
index dfb1ef4a2bf..632fbbda8fb 100644
--- 
a/iotdb-client/session/src/test/java/org/apache/iotdb/session/util/SessionUtilsTest.java
+++ 
b/iotdb-client/session/src/test/java/org/apache/iotdb/session/util/SessionUtilsTest.java
@@ -46,6 +46,69 @@ import static org.junit.Assert.assertThrows;
 
 public class SessionUtilsTest {
 
+  // Null FIELDs are omitted without shifting indices around interleaved TAG 
metadata or NaN bits.
+  @Test
+  public void testNumericRowNullBitmapEncoding() throws Exception {
+    BitMap nulls = new BitMap(9);
+    nulls.mark(0);
+    nulls.mark(7);
+    nulls.mark(8);
+    List<TSDataType> types = new ArrayList<>(Collections.nCopies(9, 
TSDataType.DOUBLE));
+    List<ColumnCategory> categories = new ArrayList<>(Collections.nCopies(9, 
ColumnCategory.FIELD));
+    types.add(3, TSDataType.STRING);
+    categories.add(3, ColumnCategory.TAG);
+    long[] values = new long[9];
+    Arrays.fill(values, 0x7ff8000000001234L);
+    ByteBuffer payload =
+        SessionUtils.getValueBuffer(
+            types, values, Collections.nCopies(10, "v"), categories, new 
String[] {"tag"}, nulls);
+    for (TSDataType type : types) {
+      Assert.assertEquals(type.serialize(), payload.get());
+    }
+    Assert.assertEquals("tag", ReadWriteIOUtils.readString(payload));
+    for (int i = 0; i < 6; i++) {
+      Assert.assertEquals(0x7ff8000000001234L, payload.getLong());
+    }
+    Assert.assertFalse(payload.hasRemaining());
+    Assert.assertEquals(
+        ByteBuffer.wrap(new byte[] {(byte) 0x81, 1}),
+        SessionUtils.encodeNumericRowNullBitmap(nulls, null));
+  }
+
+  // markAll may set padding in BitMap; the wire representation uses exactly 
ceil(FIELDs/8) bytes.
+  @Test
+  public void testNumericRowNullBitmapBoundaries() throws Exception {
+    for (int size : new int[] {1, 7, 8, 9, 16}) {
+      BitMap nulls = new BitMap(size);
+      nulls.markAll();
+      byte[] original = nulls.getByteArray().clone();
+      ByteBuffer bitmap = SessionUtils.encodeNumericRowNullBitmap(nulls, null);
+      Assert.assertEquals((size + 7) / 8, bitmap.remaining());
+      Assert.assertEquals(
+          size % 8 == 0 ? 255 : (1 << (size % 8)) - 1, 
bitmap.get(bitmap.limit() - 1) & 255);
+      Assert.assertArrayEquals(original, nulls.getByteArray());
+      ByteBuffer payload =
+          SessionUtils.getValueBuffer(
+              Collections.nCopies(size, TSDataType.INT64),
+              new long[size],
+              Collections.nCopies(size, "v"),
+              Collections.nCopies(size, ColumnCategory.FIELD),
+              new String[0],
+              nulls);
+      Assert.assertEquals(size, payload.remaining());
+    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            SessionUtils.getValueBuffer(
+                List.of(TSDataType.INT64),
+                new long[1],
+                List.of("v"),
+                List.of(ColumnCategory.FIELD),
+                new String[0],
+                new BitMap(2)));
+  }
+
   @Test
   public void testNumericRowEncodingPreservesRawBits() throws 
IoTDBConnectionException {
     List<TSDataType> types =
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index bf7a3601003..63de7dbb603 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -20,6 +20,12 @@
 package org.apache.iotdb.db.i18n;
 
 public final class DataNodeQueryMessages {
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_LENGTH_MISMATCH_EXPECTED_ARG_BYTES_FOR_ARG_FIELDS_BUT_GOT_ARG_31F1DB42
 =
+      "Numeric row null bitmap length mismatch: expected %d bytes for %d 
FIELDs but got %d";
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_HAS_SET_BITS_OUTSIDE_ARG_FIELDS_AD6066D1 =
+      "Numeric row null bitmap has set bits outside %d FIELDs";
+  public static final String 
EXCEPTION_NUMERIC_ROW_PAYLOAD_HAS_ARG_TRAILING_BYTES_89E46A34 =
+      "Numeric row payload has %d trailing bytes";
 
   // --- Common ---
 
diff --git 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
index f26f2306be2..12e61875802 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java
@@ -20,6 +20,12 @@
 package org.apache.iotdb.db.i18n;
 
 public final class DataNodeQueryMessages {
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_LENGTH_MISMATCH_EXPECTED_ARG_BYTES_FOR_ARG_FIELDS_BUT_GOT_ARG_31F1DB42
 =
+      "Numeric row 的 null bitmap 长度不匹配:预期 %d 字节(对应 %d 个 FIELD),实际为 %d";
+  public static final String 
EXCEPTION_NUMERIC_ROW_NULL_BITMAP_HAS_SET_BITS_OUTSIDE_ARG_FIELDS_AD6066D1 =
+      "Numeric row 的 null bitmap 在 %d 个 FIELD 范围外存在置位";
+  public static final String 
EXCEPTION_NUMERIC_ROW_PAYLOAD_HAS_ARG_TRAILING_BYTES_89E46A34 =
+      "Numeric row 数据存在 %d 个多余的尾随字节";
 
   // --- Common ---
 
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfo.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfo.java
index 6d5dc1fce4b..938afbdc852 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfo.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfo.java
@@ -205,6 +205,11 @@ public final class PreparedNumericRowInfo {
   }
 
   public InsertNumericRowStatement bind(long timestamp, ByteBuffer tags, 
ByteBuffer values) {
+    return bind(timestamp, tags, values, null);
+  }
+
+  public InsertNumericRowStatement bind(
+      long timestamp, ByteBuffer tags, ByteBuffer values, ByteBuffer 
nullBitmap) {
     if (tags == null) {
       throw invalid(
           
DataNodeMiscMessages.EXCEPTION_PREPARED_NUMERIC_ROW_TAG_BUFFER_IS_NULL_3FF27BF7);
@@ -213,11 +218,21 @@ public final class PreparedNumericRowInfo {
       throw invalid(
           
DataNodeMiscMessages.EXCEPTION_PREPARED_NUMERIC_ROW_VALUE_BUFFER_IS_NULL_A928F919);
     }
-    if (values.remaining() != valueBytes) {
+    NumericRowValues.validateNullBitmap(nullBitmap, types.length - tagCount);
+    // Preserve the cached fixed-width length for the common non-null path.
+    int expectedBytes = valueBytes;
+    if (nullBitmap != null) {
+      for (int i = tagCount; i < types.length; i++) {
+        if (NumericRowValues.isNullField(nullBitmap, i - tagCount)) {
+          expectedBytes -= types[i].getDataTypeSize();
+        }
+      }
+    }
+    if (values.remaining() != expectedBytes) {
       throw invalid(
           DataNodeMiscMessages
               
.EXCEPTION_PREPARED_NUMERIC_ROW_VALUE_BUFFER_SIZE_MISMATCH_EXPECTED_ARG_BYTES_BUT_GOT_ARG_B75D48C8,
-          valueBytes,
+          expectedBytes,
           values.remaining());
     }
     TimestampPrecisionUtils.checkTimestampPrecision(timestamp);
@@ -252,7 +267,11 @@ public final class PreparedNumericRowInfo {
             tags.remaining());
       }
       for (int i = tagCount; i < types.length; i++) {
-        row.read(i, types[i], values);
+        if (NumericRowValues.isNullField(nullBitmap, i - tagCount)) {
+          row.setNull(i);
+        } else {
+          row.read(i, types[i], values);
+        }
       }
     } catch (BufferUnderflowException e) {
       throw invalid(
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java
index d48aef4cbf6..f74ed0b8a58 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java
@@ -2620,7 +2620,8 @@ public class ClientRPCServiceImpl implements 
IClientRPCServiceWithHandler {
               .bind(
                   req.getTimestamp(),
                   ByteBuffer.wrap(req.getTagValues()),
-                  ByteBuffer.wrap(req.getValues()));
+                  ByteBuffer.wrap(req.getValues()),
+                  req.bufferForNullBitmap());
       long queryId = SESSION_MANAGER.requestQueryId();
       return COORDINATOR.executeForTableModel(
               statement,
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGenerator.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGenerator.java
index 3aac7cfaccb..eb8cebad679 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGenerator.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGenerator.java
@@ -328,7 +328,8 @@ public class StatementGenerator {
       }
       insertStatement.setColumnCategories(columnCategories);
       if (insertRecordReq.isIsNumericRow()) {
-        ((InsertNumericRowStatement) 
insertStatement).fillValues(insertRecordReq.values);
+        ((InsertNumericRowStatement) insertStatement)
+            .fillValues(insertRecordReq.values, 
insertRecordReq.bufferForNullBitmap());
         for (int i = 0; i < columnCategories.length; i++) {
           TSDataType type = insertStatement.getDataType(i);
           if (!(columnCategories[i] == TsTableColumnCategory.TAG && type == 
TSDataType.STRING)
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/crud/InsertNumericRowStatement.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/crud/InsertNumericRowStatement.java
index 7093b1615fb..98e255de911 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/crud/InsertNumericRowStatement.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/crud/InsertNumericRowStatement.java
@@ -100,6 +100,10 @@ public class InsertNumericRowStatement extends 
InsertBaseStatement {
 
   /** RPC cells carry typed primitive FIELD bits or a length-prefixed STRING 
TAG. */
   public void fillValues(ByteBuffer buffer) throws QueryProcessException {
+    fillValues(buffer, null);
+  }
+
+  public void fillValues(ByteBuffer buffer, ByteBuffer nullBitmap) throws 
QueryProcessException {
     dataTypes = new TSDataType[measurements.length];
     for (int i = 0; i < dataTypes.length; i++) {
       byte type = buffer.get();
@@ -124,6 +128,7 @@ public class InsertNumericRowStatement extends 
InsertBaseStatement {
       }
     }
     int tagCount = countTagColumns(columnCategories);
+    NumericRowValues.validateNullBitmap(nullBitmap, measurements.length - 
tagCount);
     numericValues = new NumericRowValues(tagCount, measurements.length - 
tagCount);
     ColumnCategory[] categories = new ColumnCategory[measurements.length];
     for (int i = 0; i < measurements.length; i++) {
@@ -140,9 +145,19 @@ public class InsertNumericRowStatement extends 
InsertBaseStatement {
     }
     for (int i = 0; i < dataTypes.length; i++) {
       if (columnCategories[i] == TsTableColumnCategory.FIELD) {
-        numericValues.read(i, dataTypes[i], buffer);
+        if (NumericRowValues.isNullField(nullBitmap, i - tagCount)) {
+          numericValues.setNull(i);
+        } else {
+          numericValues.read(i, dataTypes[i], buffer);
+        }
       }
     }
+    if (buffer.hasRemaining()) {
+      throw new IllegalArgumentException(
+          String.format(
+              
DataNodeQueryMessages.EXCEPTION_NUMERIC_ROW_PAYLOAD_HAS_ARG_TRAILING_BYTES_89E46A34,
+              buffer.remaining()));
+    }
   }
 
   @Override
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/utils/NumericRowValues.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/utils/NumericRowValues.java
index 8b9a03a44cc..1638f0fd00d 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/utils/NumericRowValues.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/utils/NumericRowValues.java
@@ -161,6 +161,36 @@ public final class NumericRowValues {
         };
   }
 
+  /** Validates the optional FIELD-only RPC bitmap without changing its 
cursor. */
+  public static void validateNullBitmap(ByteBuffer bitmap, int fieldCount) {
+    if (bitmap == null) {
+      return;
+    }
+    int length = (fieldCount + 7) / 8;
+    if (bitmap.remaining() != length) {
+      throw new IllegalArgumentException(
+          String.format(
+              DataNodeQueryMessages
+                  
.EXCEPTION_NUMERIC_ROW_NULL_BITMAP_LENGTH_MISMATCH_EXPECTED_ARG_BYTES_FOR_ARG_FIELDS_BUT_GOT_ARG_31F1DB42,
+              length,
+              fieldCount,
+              bitmap.remaining()));
+    }
+    if (fieldCount % 8 != 0
+        && (bitmap.get(bitmap.limit() - 1) & ~((1 << (fieldCount % 8)) - 1)) 
!= 0) {
+      throw new IllegalArgumentException(
+          String.format(
+              DataNodeQueryMessages
+                  
.EXCEPTION_NUMERIC_ROW_NULL_BITMAP_HAS_SET_BITS_OUTSIDE_ARG_FIELDS_AD6066D1,
+              fieldCount));
+    }
+  }
+
+  public static boolean isNullField(ByteBuffer bitmap, int fieldIndex) {
+    return bitmap != null
+        && (bitmap.get(bitmap.position() + fieldIndex / 8) & (1 << (fieldIndex 
% 8))) != 0;
+  }
+
   public Object getObject(int index, TSDataType type) {
     if (isNull(index)) {
       return null;
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfoTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfoTest.java
index e195d69660c..e8e6ef31153 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfoTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/protocol/session/PreparedNumericRowInfoTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.iotdb.db.protocol.session;
 
+import org.apache.iotdb.commons.exception.SemanticException;
 import org.apache.iotdb.commons.queryengine.common.SessionInfo;
 import org.apache.iotdb.commons.schema.table.TreeViewSchema;
 import org.apache.iotdb.commons.schema.table.TsTable;
@@ -29,12 +30,14 @@ import org.apache.iotdb.db.queryengine.common.QueryId;
 import 
org.apache.iotdb.db.queryengine.plan.analyze.lock.DataNodeSchemaLockManager;
 import org.apache.iotdb.db.queryengine.plan.analyze.lock.SchemaLockType;
 import org.apache.iotdb.db.queryengine.plan.analyze.schema.SchemaValidator;
+import org.apache.iotdb.db.queryengine.plan.parser.StatementGenerator;
 import org.apache.iotdb.db.queryengine.plan.relational.metadata.Metadata;
 import 
org.apache.iotdb.db.queryengine.plan.relational.metadata.fetcher.TableHeaderSchemaValidator;
 import org.apache.iotdb.db.queryengine.plan.relational.security.AccessControl;
 import 
org.apache.iotdb.db.queryengine.plan.relational.sql.ast.InsertNumericRow;
 import 
org.apache.iotdb.db.queryengine.plan.statement.crud.InsertNumericRowStatement;
 import org.apache.iotdb.db.schemaengine.table.DataNodeTableCache;
+import org.apache.iotdb.service.rpc.thrift.TSInsertRecordReq;
 import org.apache.iotdb.service.rpc.thrift.TSPrepareNumericRowReq;
 
 import org.apache.tsfile.enums.ColumnCategory;
@@ -45,14 +48,17 @@ import org.junit.Test;
 
 import java.nio.ByteBuffer;
 import java.time.ZoneId;
+import java.util.ArrayList;
 import java.util.Arrays;
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -191,6 +197,75 @@ public class PreparedNumericRowInfoTest {
     assertNull(statement.getPreparedTableVersion());
   }
 
+  // The optional bitmap uses FIELD indices, not measurement indices, and 
accepts sliced buffers.
+  @Test
+  public void testNullBitmapDecodingAndValidation() throws Exception {
+    TSPrepareNumericRowReq req = request("tag", "v");
+    req.setMeasurements(new ArrayList<>(req.getMeasurements()));
+    req.setDataTypes(new ArrayList<>(req.getDataTypes()));
+    req.setColumnCategories(new ArrayList<>(req.getColumnCategories()));
+    for (int i = 1; i < 9; i++) {
+      req.addToMeasurements("v" + i);
+      req.addToDataTypes(TSDataType.DOUBLE.ordinal());
+      req.addToColumnCategories((byte) ColumnCategory.FIELD.ordinal());
+    }
+    PreparedNumericRowInfo info = new PreparedNumericRowInfo(DATABASE, req);
+    ByteBuffer bitmap = ByteBuffer.wrap(new byte[] {99, (byte) 0x81, 1, 99});
+    bitmap.position(1).limit(3);
+    ByteBuffer values = ByteBuffer.allocate(6 * 8);
+    for (int i = 1; i <= 6; i++) {
+      values.putLong(i == 1 ? 0x7ff8000000001234L : 
Double.doubleToRawLongBits(i));
+    }
+    values.flip();
+    InsertNumericRowStatement prepared = info.bind(1, tagBuffer(1), values, 
bitmap);
+    assertEquals(1, bitmap.position());
+    assertEquals(0, values.position());
+    ByteBuffer payload = ByteBuffer.allocate(10 + 5 + values.remaining());
+    payload.put(TSDataType.STRING.serialize());
+    for (int i = 0; i < 9; i++) {
+      payload.put(TSDataType.DOUBLE.serialize());
+    }
+    payload.put(tagBuffer(1)).put(values.duplicate()).flip();
+    TSInsertRecordReq insertReq = new TSInsertRecordReq();
+    insertReq
+        .setPrefixPath("t")
+        .setTimestamp(1)
+        .setMeasurements(req.getMeasurements())
+        .setColumnCategoryies(req.getColumnCategories())
+        .setIsNumericRow(true)
+        .setIsWriteToTable(true)
+        .setValues(payload)
+        .setNullBitmap(bitmap);
+    InsertNumericRowStatement direct =
+        (InsertNumericRowStatement) 
StatementGenerator.createStatement(insertReq);
+    assertEquals(prepared.getNumericValues(), direct.getNumericValues());
+    assertTrue(prepared.getNumericValues().isNull(1));
+    assertTrue(prepared.getNumericValues().isNull(8));
+    assertTrue(prepared.getNumericValues().isNull(9));
+    assertFalse(prepared.getNumericValues().isNull(0));
+    assertEquals(0x7ff8000000001234L, prepared.getNumericValues().get(2));
+    for (byte[] bad : new byte[][] {new byte[0], new byte[1], new byte[3], {0, 
2}}) {
+      assertThrows(
+          IllegalArgumentException.class,
+          () -> info.bind(1, tagBuffer(1), values, ByteBuffer.wrap(bad)));
+      insertReq.setValues(payload.duplicate().rewind()).setNullBitmap(bad);
+      assertThrows(
+          IllegalArgumentException.class, () -> 
StatementGenerator.createStatement(insertReq));
+    }
+    assertThrows(
+        SemanticException.class,
+        () -> info.bind(1, tagBuffer(1), ByteBuffer.allocate(9 * 8), bitmap));
+    ByteBuffer allNulls = ByteBuffer.wrap(new byte[] {(byte) 255, 1});
+    insertReq.setValues(payload.duplicate().rewind()).setNullBitmap(allNulls);
+    assertThrows(
+        IllegalArgumentException.class, () -> 
StatementGenerator.createStatement(insertReq));
+    InsertNumericRowStatement allNull =
+        info.bind(1, tagBuffer(1), ByteBuffer.allocate(0), allNulls);
+    for (int i = 1; i <= 9; i++) {
+      assertTrue(allNull.getNumericValues().isNull(i));
+    }
+  }
+
   private static void install(TsTable table) {
     DataNodeTableCache.getInstance().preUpdateTable(DATABASE, table, null);
     DataNodeTableCache.getInstance().commitUpdateTable(DATABASE, "t", null);
diff --git a/iotdb-protocol/thrift-datanode/src/main/thrift/client.thrift 
b/iotdb-protocol/thrift-datanode/src/main/thrift/client.thrift
index c08959e5fe4..edcf8834804 100644
--- a/iotdb-protocol/thrift-datanode/src/main/thrift/client.thrift
+++ b/iotdb-protocol/thrift-datanode/src/main/thrift/client.thrift
@@ -250,6 +250,9 @@ struct TSInsertRecordReq {
   8: optional list<byte> columnCategoryies
   // Numeric table rows retain primitive storage throughout the server write 
path.
   9: optional bool isNumericRow
+  // Numeric FIELDs only, in FIELD occurrence order: bit i (LSB first) means 
null.
+  // Exactly ceil(FIELD count / 8) bytes, unused high bits zero; null FIELD 
payloads are omitted.
+  10: optional binary nullBitmap
 }
 
 struct TSInsertStringRecordReq {
@@ -735,4 +738,6 @@ struct TSExecutePreparedNumericRowReq {
   4: required binary values
   // Length-prefixed UTF-8 TAGs; length -1 represents a null TAG.
   5: required binary tagValues
+  // Same FIELD-only bitmap and omitted-null-payload encoding as 
TSInsertRecordReq.
+  6: optional binary nullBitmap
 }

Reply via email to