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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7aa8e63a7c6 [JDBC] Fix table-model driver defects that break generic 
JDBC clients (#18962)
7aa8e63a7c6 is described below

commit 7aa8e63a7c6be84a5189006a2e5b884ab33f2a90
Author: Jackie Tien <[email protected]>
AuthorDate: Fri Oct 23 17:00:34 2026 +0800

    [JDBC] Fix table-model driver defects that break generic JDBC clients 
(#18962)
---
 .../org/apache/iotdb/jdbc/i18n/JdbcMessages.java   |   7 +-
 .../org/apache/iotdb/jdbc/i18n/JdbcMessages.java   |   7 +-
 .../iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java  |  12 +-
 .../org/apache/iotdb/jdbc/IoTDBConnection.java     |  17 +-
 .../apache/iotdb/jdbc/IoTDBDatabaseMetadata.java   |   2 +-
 .../org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java  |  79 ++-
 .../org/apache/iotdb/jdbc/IoTDBResultMetadata.java |  11 +-
 .../java/org/apache/iotdb/jdbc/IoTDBStatement.java |  27 +-
 .../iotdb/jdbc/IoTDBTablePreparedStatement.java    | 128 ++++-
 .../IoTDBRelationalDatabaseMetadata.java           | 555 +++++++++++-------
 .../org/apache/iotdb/jdbc/IoTDBConnectionTest.java |  39 ++
 .../iotdb/jdbc/IoTDBDatabaseMetadataTest.java      |  20 +
 .../apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java  | 209 +++++++
 .../jdbc/IoTDBRelationalDatabaseMetadataTest.java  | 634 +++++++++++++++++++++
 .../apache/iotdb/jdbc/IoTDBResultMetadataTest.java |  14 +
 .../jdbc/IoTDBTablePreparedStatementTest.java      | 313 ++++++++++
 .../main/java/org/apache/iotdb/rpc/RpcUtils.java   |   5 +-
 .../java/org/apache/iotdb/rpc/RpcUtilsTest.java    |  28 +
 18 files changed, 1826 insertions(+), 281 deletions(-)

diff --git 
a/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
 
b/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
index 17f33cdf922..ea3eea76fb8 100644
--- 
a/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
+++ 
b/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
@@ -164,7 +164,6 @@ public final class JdbcMessages {
       "Does not support setTransactionIsolation";
   public static final String NOT_SUPPORT_GET_TYPE_MAP = "Does not support 
getTypeMap";
   public static final String NOT_SUPPORT_SET_TYPE_MAP = "Does not support 
setTypeMap";
-  public static final String NOT_SUPPORT_READ_ONLY = "Does not support 
readOnly";
   public static final String NOT_SUPPORT_NATIVE_SQL = "Does not support 
nativeSQL";
   public static final String NOT_SUPPORT_RELEASE_SAVEPOINT =
       "Does not support releaseSavepoint";
@@ -225,5 +224,11 @@ public final class JdbcMessages {
       "Can not establish connection with %s : You may try to connect an old 
version IoTDB instance"
       + " using a client with new version: %s. ";
   public static final String 
EXCEPTION_CAN_NOT_ESTABLISH_CONNECTION_ARG_ARG_D7246055 = "Can not establish 
connection with %s : %s. ";
+  public static final String 
EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001 = "Timestamp %s 
is out of range for the time precision %s";
+  public static final String EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158 
= "Cannot convert a %s column to %s";
+  public static final String 
EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC = "The target type of 
getObject must not be null";
+  public static final String LOG_SHOW_TABLES_ERROR_ARG_068CF1BA = "Show tables 
error: {}";
+  public static final String EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278 = 
"Unknown column name: %s";
+  public static final String 
EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32 = 
"Cannot bind %s: IoTDB SQL has no literal for an infinite floating-point value";
 
 }
diff --git 
a/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
 
b/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
index a1cc83ea5e6..727a8d7809c 100644
--- 
a/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
+++ 
b/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java
@@ -163,7 +163,6 @@ public final class JdbcMessages {
       "不支持 setTransactionIsolation";
   public static final String NOT_SUPPORT_GET_TYPE_MAP = "不支持 getTypeMap";
   public static final String NOT_SUPPORT_SET_TYPE_MAP = "不支持 setTypeMap";
-  public static final String NOT_SUPPORT_READ_ONLY = "不支持 readOnly";
   public static final String NOT_SUPPORT_NATIVE_SQL = "不支持 nativeSQL";
   public static final String NOT_SUPPORT_RELEASE_SAVEPOINT =
       "不支持 releaseSavepoint";
@@ -224,5 +223,11 @@ public final class JdbcMessages {
       "无法与 %s 建立连接:可能正在使用新版本客户端连接旧版本 IoTDB 实例:"
       + "%s。";
   public static final String 
EXCEPTION_CAN_NOT_ESTABLISH_CONNECTION_ARG_ARG_D7246055 = "无法与 %s 建立连接:%s。";
+  public static final String 
EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001 = "时间戳 %s 超出了时间精度 
%s 可表示的范围";
+  public static final String EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158 
= "无法将 %s 类型的列转换为 %s";
+  public static final String 
EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC = "getObject 的目标类型不能为 
null";
+  public static final String LOG_SHOW_TABLES_ERROR_ARG_068CF1BA = "查询表信息出错:{}";
+  public static final String EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278 = 
"未知列名:%s";
+  public static final String 
EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32 = 
"无法绑定 %s:IoTDB SQL 没有表示无穷大浮点数的字面量";
 
 }
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java
 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java
index 0c50512e80b..b52f3bba55e 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java
@@ -92,6 +92,7 @@ public abstract class IoTDBAbstractDatabaseMetadata 
implements DatabaseMetaData
   protected static final String BLOB = "BLOB";
   protected static final String TIMESTAMP = "TIMESTAMP";
   protected static final String DATE = "DATE";
+  protected static final String OBJECT = "OBJECT";
 
   protected static String sqlKeywordsThatArentSQL92;
 
@@ -415,10 +416,15 @@ public abstract class IoTDBAbstractDatabaseMetadata 
implements DatabaseMetaData
     for (List<Object> valuesInRow : valuesList) {
       tsBlockBuilder.getTimeColumnBuilder().writeLong(0);
       for (int j = 0; j < tsDataTypeList.size(); j++) {
+        Object value = valuesInRow.get(j);
+        if (value == null) {
+          tsBlockBuilder.getColumnBuilder(j).appendNull();
+          continue;
+        }
         TSDataType columnType = tsDataTypeList.get(j);
         COLUMN_VALUE_WRITER_SERVICE
             .call(Type.fromTsDataType(columnType))
-            .write(tsBlockBuilder.getColumnBuilder(j), valuesInRow.get(j));
+            .write(tsBlockBuilder.getColumnBuilder(j), value);
       }
       tsBlockBuilder.declarePosition();
     }
@@ -474,6 +480,9 @@ public abstract class IoTDBAbstractDatabaseMetadata 
implements DatabaseMetaData
         return Types.TIMESTAMP;
       case DATE:
         return Types.DATE;
+      case OBJECT:
+        // Database-specific: getObject() describes the value instead of 
returning it
+        return Types.OTHER;
       default:
         break;
     }
@@ -495,6 +504,7 @@ public abstract class IoTDBAbstractDatabaseMetadata 
implements DatabaseMetaData
       case "TEXT":
       case BLOB:
       case STRING:
+      case OBJECT:
         return Integer.MAX_VALUE;
       case TIMESTAMP:
         return 3;
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java
index cbd501aa887..c207a123964 100644
--- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java
+++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java
@@ -98,7 +98,10 @@ public class IoTDBConnection implements Connection {
   private ZoneId zoneId;
   private Charset charset;
 
-  private boolean autoCommit;
+  // IoTDB has no transactions, so every statement takes effect at once as 
JDBC auto-commit does
+  private boolean autoCommit = true;
+  // Recorded as the hint JDBC defines it to be; IoTDB has no read-only 
connection mode
+  private boolean readOnly;
   private String url;
 
   public String getUserName() {
@@ -143,7 +146,6 @@ public class IoTDBConnection implements Connection {
     openSession();
     // Wrap the client with a thread-safe proxy to serialize the RPC calls
     setClient(RpcUtils.newSynchronizedClient(getClient()));
-    autoCommit = false;
   }
 
   public String getUrl() {
@@ -270,13 +272,14 @@ public class IoTDBConnection implements Connection {
 
   @Override
   public String getCatalog() {
-    return APACHE_IOTDB;
+    // The table model has no catalogs, matching the NULL TABLE_CAT of its 
DatabaseMetaData
+    return getSqlDialect().equals(Constant.TABLE_DIALECT) ? null : 
APACHE_IOTDB;
   }
 
   @Override
   public void setCatalog(String arg0) throws SQLException {
     if (getSqlDialect().equals(Constant.TABLE_DIALECT)) {
-      if (APACHE_IOTDB.equals(arg0)) {
+      if (arg0 == null || APACHE_IOTDB.equals(arg0)) {
         return;
       }
       for (String str : 
IoTDBRelationalDatabaseMetadata.allIotdbTableSQLKeywords) {
@@ -400,14 +403,12 @@ public class IoTDBConnection implements Connection {
 
   @Override
   public boolean isReadOnly() {
-    return false;
+    return readOnly;
   }
 
   @Override
   public void setReadOnly(boolean readonly) throws SQLException {
-    if (readonly) {
-      throw new SQLException(JdbcMessages.NOT_SUPPORT_READ_ONLY);
-    }
+    this.readOnly = readonly;
   }
 
   @Override
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java
 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java
index 0e5d5b64a92..a9c03650e50 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java
@@ -387,7 +387,7 @@ public class IoTDBDatabaseMetadata extends 
IoTDBAbstractDatabaseMetadata {
       throws SQLException {
     try (Statement stmt = this.connection.createStatement()) {
 
-      if (this.connection.getCatalog().equals(catalog)) {
+      if (catalog != null && catalog.equals(this.connection.getCatalog())) {
         catalog = null;
       }
 
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java
index 3ea3c52320d..7857e624a87 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java
@@ -60,6 +60,7 @@ import java.sql.SQLXML;
 import java.sql.Statement;
 import java.sql.Time;
 import java.sql.Timestamp;
+import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.Calendar;
 import java.util.List;
@@ -67,8 +68,6 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
-import static org.apache.iotdb.rpc.RpcUtils.convertToTimestamp;
-
 public class IoTDBJDBCResultSet implements ResultSet {
 
   public static final String OBJECT_ERR_MSG = "OBJECT Type only support 
getString";
@@ -645,13 +644,69 @@ public class IoTDBJDBCResultSet implements ResultSet {
   }
 
   @Override
-  public <T> T getObject(int arg0, Class<T> arg1) throws SQLException {
-    throw new SQLException(Constant.METHOD_NOT_SUPPORTED);
+  public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
+    if (type == null) {
+      throw new 
SQLException(JdbcMessages.EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC);
+    }
+    try {
+      // Rejects a bad index before getDataType fails on it with an unchecked 
exception
+      ioTDBRpcDataSet.findColumnNameByIndex(columnIndex);
+      final TSDataType dataType = ioTDBRpcDataSet.getDataType(columnIndex);
+      final Object value;
+      if (type == String.class) {
+        value = ioTDBRpcDataSet.getString(columnIndex);
+      } else if (type == byte[].class) {
+        value = getBytes(columnIndex);
+      } else if (type == Timestamp.class && dataType == TSDataType.TIMESTAMP) {
+        value = ioTDBRpcDataSet.getTimestamp(columnIndex);
+      } else if (type == LocalDate.class && dataType == TSDataType.DATE) {
+        value = ioTDBRpcDataSet.getDate(columnIndex);
+      } else if (type == Boolean.class && dataType == TSDataType.BOOLEAN) {
+        value = nullIfLastReadWasNull(ioTDBRpcDataSet.getBoolean(columnIndex));
+      } else if (type == Integer.class && dataType == TSDataType.INT32) {
+        value = nullIfLastReadWasNull(ioTDBRpcDataSet.getInt(columnIndex));
+      } else if (type == Long.class
+          && (dataType == TSDataType.INT32
+              || dataType == TSDataType.INT64
+              || dataType == TSDataType.TIMESTAMP)) {
+        value = nullIfLastReadWasNull(ioTDBRpcDataSet.getLong(columnIndex));
+      } else if (type == Float.class && dataType == TSDataType.FLOAT) {
+        value = nullIfLastReadWasNull(ioTDBRpcDataSet.getFloat(columnIndex));
+      } else if (type == Double.class && dataType == TSDataType.FLOAT) {
+        value = nullIfLastReadWasNull((double) 
ioTDBRpcDataSet.getFloat(columnIndex));
+      } else if (type == Double.class && dataType == TSDataType.DOUBLE) {
+        value = nullIfLastReadWasNull(ioTDBRpcDataSet.getDouble(columnIndex));
+      } else {
+        throw new SQLException(
+            String.format(
+                JdbcMessages.EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158,
+                dataType,
+                type.getName()));
+      }
+      return type.cast(value);
+    } catch (StatementExecutionException e) {
+      throw new SQLException(e.getMessage());
+    }
+  }
+
+  // The primitive getters return a default value such as 0 for SQL NULL
+  private Object nullIfLastReadWasNull(Object value) {
+    return ioTDBRpcDataSet.isLastReadWasNull() ? null : value;
   }
 
   @Override
-  public <T> T getObject(String arg0, Class<T> arg1) throws SQLException {
-    throw new SQLException(Constant.METHOD_NOT_SUPPORTED);
+  public <T> T getObject(String columnLabel, Class<T> type) throws 
SQLException {
+    return getObject(findColumnIndex(columnLabel), type);
+  }
+
+  // findColumn() fails with an unchecked exception for an unknown label
+  private int findColumnIndex(String columnLabel) throws SQLException {
+    int index = ioTDBRpcDataSet.getColumnNameList().indexOf(columnLabel);
+    if (index < 0) {
+      throw new SQLException(
+          
String.format(JdbcMessages.EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278, 
columnLabel));
+    }
+    return index + 1;
   }
 
   @Override
@@ -741,12 +796,20 @@ public class IoTDBJDBCResultSet implements ResultSet {
 
   @Override
   public Timestamp getTimestamp(int columnIndex) throws SQLException {
-    return convertToTimestamp(getLong(columnIndex), statement.getTimeFactor());
+    try {
+      return ioTDBRpcDataSet.getTimestamp(columnIndex);
+    } catch (StatementExecutionException e) {
+      throw new SQLException(e.getMessage());
+    }
   }
 
   @Override
   public Timestamp getTimestamp(String columnName) throws SQLException {
-    return new Timestamp(getLong(columnName));
+    try {
+      return ioTDBRpcDataSet.getTimestamp(columnName);
+    } catch (StatementExecutionException | IllegalArgumentException e) {
+      throw new SQLException(e.getMessage());
+    }
   }
 
   @Override
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java
 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java
index 59279d88465..91a9c8cfbb2 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java
@@ -51,6 +51,7 @@ public class IoTDBResultMetadata implements ResultSetMetaData 
{
 
   private static final String DATE = "DATE";
   private static final String TIMESTAMP = "TIMESTAMP";
+  private static final String OBJECT = "OBJECT";
 
   /** Constructor of IoTDBResultMetadata. */
   public IoTDBResultMetadata(
@@ -165,6 +166,9 @@ public class IoTDBResultMetadata implements 
ResultSetMetaData {
         return Date.class.getName();
       case BLOB:
         return "byte[]";
+      case OBJECT:
+        // getObject() describes an OBJECT value with a String such as 
"(Object) 1.2 MB"
+        return String.class.getName();
       default:
         break;
     }
@@ -232,6 +236,8 @@ public class IoTDBResultMetadata implements 
ResultSetMetaData {
         return Types.TIMESTAMP;
       case BLOB:
         return Types.BLOB;
+      case OBJECT:
+        return Types.OTHER;
       default:
         break;
     }
@@ -255,7 +261,8 @@ public class IoTDBResultMetadata implements 
ResultSetMetaData {
         || STRING.equals(typeString)
         || BLOB.equals(typeString)
         || DATE.equals(typeString)
-        || TIMESTAMP.equals(typeString)) {
+        || TIMESTAMP.equals(typeString)
+        || OBJECT.equals(typeString)) {
       return typeString;
     }
     return null;
@@ -282,6 +289,7 @@ public class IoTDBResultMetadata implements 
ResultSetMetaData {
       case TEXT:
       case BLOB:
       case STRING:
+      case OBJECT:
         return Integer.MAX_VALUE;
       case TIMESTAMP:
         return 3;
@@ -309,6 +317,7 @@ public class IoTDBResultMetadata implements 
ResultSetMetaData {
       case STRING:
       case TIMESTAMP:
       case DATE:
+      case OBJECT:
         return 0;
       case FLOAT:
         return 6;
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java
index 2af6e970a92..260ef79724c 100644
--- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java
+++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java
@@ -357,11 +357,7 @@ public class IoTDBStatement implements Statement {
   private boolean executeSQL(String sql) throws TException, SQLException {
     isCancelled = false;
     TSExecuteStatementReq execReq = new TSExecuteStatementReq(sessionId, sql, 
stmtId);
-    int rows = fetchSize;
-    if (maxRows != 0 && fetchSize > maxRows) {
-      rows = maxRows;
-    }
-    execReq.setFetchSize(rows);
+    execReq.setFetchSize(getRequestFetchSize());
     execReq.setTimeout((long) queryTimeout * 1000);
     TSExecuteStatementResp execResp =
         callWithRetryAndReconnect(
@@ -510,11 +506,7 @@ public class IoTDBStatement implements Statement {
   private ResultSet executeQuerySQL(String sql, long timeoutInMS) throws 
TException, SQLException {
     isCancelled = false;
     TSExecuteStatementReq execReq = new TSExecuteStatementReq(sessionId, sql, 
stmtId);
-    int rows = fetchSize;
-    if (maxRows != 0 && fetchSize > maxRows) {
-      rows = maxRows;
-    }
-    execReq.setFetchSize(rows);
+    execReq.setFetchSize(getRequestFetchSize());
     execReq.setTimeout(timeoutInMS);
     execReq.setJdbcQuery(true);
     TSExecuteStatementResp execResp =
@@ -825,6 +817,21 @@ public class IoTDBStatement implements Statement {
     return stmtId;
   }
 
+  /** The fetch size to send with an execution request, capped by maxRows when 
that is set. */
+  int getRequestFetchSize() {
+    return maxRows != 0 && fetchSize > maxRows ? maxRows : fetchSize;
+  }
+
+  /** Lets {@link #cancel()} reach the server again; called when a new 
execution starts. */
+  void clearCancelled() {
+    isCancelled = false;
+  }
+
+  /** Records the server-side query of the latest execution, which {@link 
#cancel()} cancels. */
+  void setQueryId(long queryId) {
+    this.queryId = queryId;
+  }
+
   public long getMilliSecond(long time) {
     return RpcUtils.getMilliSecond(time, connection.getTimeFactor());
   }
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java
 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java
index 2fd0f382435..3c95afa107d 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java
@@ -60,6 +60,7 @@ import java.sql.SQLXML;
 import java.sql.Time;
 import java.sql.Timestamp;
 import java.sql.Types;
+import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Calendar;
@@ -199,12 +200,15 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
     req.setParameters(
         PreparedParameterSerde.serialize(parameterValues, parameterTypes, 
parameterCount));
     req.setStatementId(getStmtId());
+    req.setFetchSize(getRequestFetchSize());
     if (queryTimeout > 0) {
       req.setTimeout(queryTimeout * 1000L);
     }
 
+    clearCancelled();
     try {
       TSExecuteStatementResp resp = client.executePreparedStatement(req);
+      setQueryId(resp.isSetQueryId() ? resp.getQueryId() : -1);
       RpcUtils.verifySuccess(resp.getStatus());
       return resp;
     } catch (TException | StatementExecutionException e) {
@@ -370,14 +374,32 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
   public void setFloat(int parameterIndex, float x) throws SQLException {
     checkParameterIndex(parameterIndex);
     setPreparedParameterValue(parameterIndex, x, Types.FLOAT);
-    this.parameters.put(parameterIndex, Float.toString(x));
+    this.parameters.put(parameterIndex, floatingPointLiteral(x, 
Float.toString(x)));
   }
 
   @Override
   public void setDouble(int parameterIndex, double x) throws SQLException {
     checkParameterIndex(parameterIndex);
     setPreparedParameterValue(parameterIndex, x, Types.DOUBLE);
-    this.parameters.put(parameterIndex, Double.toString(x));
+    this.parameters.put(parameterIndex, floatingPointLiteral(x, 
Double.toString(x)));
+  }
+
+  /**
+   * The literal for a FLOAT or DOUBLE parameter of SQL completed on the 
client. IoTDB SQL has no
+   * NaN or Infinity literal, but stores the string 'NaN' as NaN and rejects 
Infinity in any form.
+   */
+  private String floatingPointLiteral(double value, String text) throws 
SQLException {
+    if (serverSidePrepared || Double.isFinite(value)) {
+      return text;
+    }
+    if (Double.isNaN(value)) {
+      return "'NaN'";
+    }
+    throw new SQLException(
+        String.format(
+            JdbcMessages
+                
.EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32,
+            text));
   }
 
   @Override
@@ -401,9 +423,13 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
 
   @Override
   public void setDate(int parameterIndex, Date x) throws SQLException {
+    setLocalDate(parameterIndex, x.toLocalDate());
+  }
+
+  private void setLocalDate(int parameterIndex, LocalDate x) throws 
SQLException {
     checkParameterIndex(parameterIndex);
     // Use ISO-8601 format YYYY-MM-DD for DATE columns
-    String dateStr = x.toLocalDate().toString();
+    String dateStr = x.toString();
     setPreparedParameterValue(parameterIndex, dateStr, Types.DATE);
     this.parameters.put(parameterIndex, "'" + dateStr + "'");
   }
@@ -446,10 +472,32 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
   @Override
   public void setTimestamp(int parameterIndex, Timestamp x) throws 
SQLException {
     checkParameterIndex(parameterIndex);
-    // Use millisecond value for SQL compatibility with TIMESTAMP columns
-    long timestampMs = x.getTime();
-    setPreparedParameterValue(parameterIndex, timestampMs, Types.BIGINT);
-    this.parameters.put(parameterIndex, Long.toString(timestampMs));
+    // The server reads time values in its own precision (ms, us or ns)
+    long time = toServerTime(x);
+    setPreparedParameterValue(parameterIndex, time, Types.BIGINT);
+    this.parameters.put(parameterIndex, Long.toString(time));
+  }
+
+  private long toServerTime(Timestamp x) throws SQLException {
+    int timeFactor = getTimeFactor();
+    // getTime() carries the milliseconds of getNanos(), so only its whole 
seconds are used
+    long seconds = Math.floorDiv(x.getTime(), 1_000L);
+    long fraction = x.getNanos() / (1_000_000_000 / timeFactor);
+    try {
+      if (seconds < 0 && fraction > 0) {
+        // Borrow a second, as Instant.toEpochMilli() does, so that the 
product cannot underflow
+        // for a value whose positive fraction brings it back into range
+        return Math.addExact(Math.multiplyExact(seconds + 1, timeFactor), 
fraction - timeFactor);
+      }
+      return Math.addExact(Math.multiplyExact(seconds, timeFactor), fraction);
+    } catch (ArithmeticException e) {
+      throw new SQLException(
+          String.format(
+              
JdbcMessages.EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001,
+              x,
+              RpcUtils.getTimePrecision(timeFactor)),
+          e);
+    }
   }
 
   @Override
@@ -477,6 +525,8 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
       setTimestamp(parameterIndex, (Timestamp) x);
     } else if (x instanceof Date) {
       setDate(parameterIndex, (Date) x);
+    } else if (x instanceof LocalDate) {
+      setLocalDate(parameterIndex, (LocalDate) x);
     } else if (x instanceof Time) {
       setTime(parameterIndex, (Time) x);
     } else if (x instanceof byte[]) {
@@ -703,36 +753,54 @@ public class IoTDBTablePreparedStatement extends 
IoTDBStatement implements Prepa
     return newSql.toString();
   }
 
+  /**
+   * Splits the SQL at its parameter markers, skipping string literals, quoted 
identifiers and
+   * comments as the table-model lexer does. Quotes are escaped only by 
doubling them; a backslash
+   * is an ordinary character.
+   */
   private List<String> splitSqlStatement(final String sql) {
     List<String> parts = new ArrayList<>();
-    int apCount = 0;
     int off = 0;
-    boolean skip = false;
-
-    for (int i = 0; i < sql.length(); i++) {
+    int i = 0;
+    while (i < sql.length()) {
       char c = sql.charAt(i);
-      if (skip) {
-        skip = false;
-        continue;
-      }
-      switch (c) {
-        case '\'':
-          apCount++;
-          break;
-        case '\\':
-          skip = true;
-          break;
-        case '?':
-          if ((apCount & 1) == 0) {
-            parts.add(sql.substring(off, i));
-            off = i + 1;
-          }
-          break;
-        default:
-          break;
+      if (c == '\'' || c == '"' || c == '`') {
+        i = skipQuoted(sql, i, c);
+      } else if (sql.startsWith("--", i)) {
+        // Like the lexer's SIMPLE_COMMENT, the comment ends at the first CR 
or LF
+        i += 2;
+        while (i < sql.length() && sql.charAt(i) != '\r' && sql.charAt(i) != 
'\n') {
+          i++;
+        }
+      } else if (sql.startsWith("/*", i)) {
+        int end = sql.indexOf("*/", i + 2);
+        i = end < 0 ? sql.length() : end + 2;
+      } else {
+        if (c == '?') {
+          parts.add(sql.substring(off, i));
+          off = i + 1;
+        }
+        i++;
       }
     }
     parts.add(sql.substring(off));
     return parts;
   }
+
+  /** Returns the index just past the quoted text that starts at {@code 
start}. */
+  private static int skipQuoted(String sql, int start, char quote) {
+    int i = start + 1;
+    while (i < sql.length()) {
+      if (sql.charAt(i) == quote) {
+        if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) {
+          // A doubled quote stands for the quote character itself
+          i += 2;
+          continue;
+        }
+        return i + 1;
+      }
+      i++;
+    }
+    return sql.length();
+  }
 }
diff --git 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java
 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java
index 7ddfca1c01a..42d716f497f 100644
--- 
a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java
+++ 
b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java
@@ -45,6 +45,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.regex.Pattern;
 
 public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetadata {
 
@@ -56,7 +57,13 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
           ? 
IoTDBRelationalDatabaseMetadata.class.getPackage().getImplementationVersion()
           : "UNKNOWN";
 
-  public static final String SHOW_TABLES_ERROR_MSG = "Show tables error: {}";
+  public static final String SHOW_TABLES_ERROR_MSG =
+      JdbcMessages.LOG_SHOW_TABLES_ERROR_ARG_068CF1BA;
+
+  private static final String TABLE_TYPE_TABLE = "TABLE";
+  private static final String TIME_CATEGORY = "TIME";
+  // Same as getSearchStringEscape()
+  private static final char SEARCH_STRING_ESCAPE = '\\';
 
   public static final String[] allIotdbTableSQLKeywords = {
     "ALTER",
@@ -207,117 +214,52 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
       throws SQLException {
 
     Statement stmt = this.connection.createStatement();
-    boolean legacyMode = true;
-
-    ResultSet rs;
+    List<TableInfo> tables;
     try {
-      String sql =
-          String.format(
-              "select * from information_schema.tables where database like 
'%s' escape '\\'",
-              schemaPattern);
-      rs = stmt.executeQuery(sql);
-    } catch (SQLException e) {
-      LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-
-      try {
-        String sql = String.format("show tables details from %s", 
removeEscape(schemaPattern));
-        rs = stmt.executeQuery(sql);
-        legacyMode = false;
-      } catch (SQLException e1) {
-        LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-        throw e;
-      }
+      // Every table is reported as TABLE, so a type filter without TABLE 
matches nothing
+      tables =
+          isTableTypeRequested(types)
+              ? listTables(stmt, schemaPattern, tableNamePattern)
+              : Collections.emptyList();
     } finally {
       stmt.close();
     }
 
     // Setup Fields
-    Field[] fields = new Field[6];
-    fields[0] = new Field("", TABLE_SCHEM, "TEXT");
-    fields[1] = new Field("", TABLE_NAME, "TEXT");
-    fields[2] = new Field("", TABLE_TYPE, "TEXT");
-    fields[3] = new Field("", REMARKS, "TEXT");
-    fields[4] = new Field("", COLUMN_SIZE, INT32);
-    fields[5] = new Field("", DECIMAL_DIGITS, INT32);
+    Field[] fields = new Field[7];
+    fields[0] = new Field("", TABLE_CAT, "TEXT");
+    fields[1] = new Field("", TABLE_SCHEM, "TEXT");
+    fields[2] = new Field("", TABLE_NAME, "TEXT");
+    fields[3] = new Field("", TABLE_TYPE, "TEXT");
+    fields[4] = new Field("", REMARKS, "TEXT");
+    fields[5] = new Field("", COLUMN_SIZE, INT32);
+    fields[6] = new Field("", DECIMAL_DIGITS, INT32);
     List<TSDataType> tsDataTypeList =
         Arrays.asList(
             TSDataType.TEXT,
             TSDataType.TEXT,
             TSDataType.TEXT,
             TSDataType.TEXT,
+            TSDataType.TEXT,
             TSDataType.INT32,
             TSDataType.INT32);
-    List<String> columnNameList = new ArrayList<>();
-    List<String> columnTypeList = new ArrayList<>();
-    Map<String, Integer> columnNameIndex = new HashMap<>();
-    List<List<Object>> valuesList = new ArrayList<>();
-    for (int i = 0; i < fields.length; i++) {
-      columnNameList.add(fields[i].getName());
-      columnTypeList.add(fields[i].getSqlType());
-      columnNameIndex.put(fields[i].getName(), i);
-    }
 
     // Extract Values
-    boolean hasResultSet = false;
-    while (rs.next()) {
-      hasResultSet = true;
+    List<List<Object>> valuesList = new ArrayList<>();
+    for (TableInfo table : tables) {
       List<Object> valueInRow = new ArrayList<>();
-      for (int i = 0; i < fields.length; i++) {
-        if (i == 0) {
-          valueInRow.add(schemaPattern);
-        } else if (i == 1) {
-          // valueInRow.add(rs.getString(2));
-          valueInRow.add(legacyMode ? rs.getString("table_name") : 
rs.getString("TableName"));
-        } else if (i == 2) {
-          valueInRow.add("TABLE");
-        } else if (i == 3) {
-          // String tgtString = "";
-          // String ttl = rs.getString("ttl(ms)");
-          // tgtString += "TTL(ms): " + ttl;
-          String comment = legacyMode ? rs.getString("comment") : 
rs.getString("Comment");
-          if (comment != null && !comment.isEmpty()) {
-            valueInRow.add(comment);
-          } else {
-            valueInRow.add("");
-          }
-        } else if (i == 4) {
-          valueInRow.add(getTypePrecision(fields[i].getSqlType()));
-        } else if (i == 5) {
-          valueInRow.add(getTypeScale(fields[i].getSqlType()));
-        } else {
-          valueInRow.add("TABLE");
-        }
-      }
+      // TABLE_CAT: the table model has no catalogs
+      valueInRow.add(null);
+      valueInRow.add(table.database());
+      valueInRow.add(table.tableName());
+      valueInRow.add(TABLE_TYPE_TABLE);
+      valueInRow.add(table.comment() == null ? "" : table.comment());
+      valueInRow.add(getTypePrecision(fields[5].getSqlType()));
+      valueInRow.add(getTypeScale(fields[6].getSqlType()));
       valuesList.add(valueInRow);
     }
 
-    // Convert Values to ByteBuffer
-    ByteBuffer tsBlock = null;
-    try {
-      tsBlock = convertTsBlock(valuesList, tsDataTypeList);
-    } catch (IOException e) {
-      LOGGER.error(CONVERT_ERROR_MSG, e.getMessage());
-    } finally {
-      close(rs, stmt);
-    }
-
-    return hasResultSet
-        ? new IoTDBJDBCResultSet(
-            stmt,
-            columnNameList,
-            columnTypeList,
-            columnNameIndex,
-            true,
-            client,
-            null,
-            -1,
-            sessionId,
-            Collections.singletonList(tsBlock),
-            null,
-            (long) 60 * 1000,
-            false,
-            zoneId)
-        : null;
+    return toResultSet(stmt, fields, tsDataTypeList, valuesList);
   }
 
   /**
@@ -328,7 +270,15 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
    * @return eg. information_schema
    */
   public static String removeEscape(String pattern) {
-    return pattern.replaceAll("\\_", "_");
+    StringBuilder name = new StringBuilder(pattern.length());
+    for (int i = 0; i < pattern.length(); i++) {
+      char c = pattern.charAt(i);
+      if (c == SEARCH_STRING_ESCAPE && i + 1 < pattern.length()) {
+        c = pattern.charAt(++i);
+      }
+      name.append(c);
+    }
+    return name.toString();
   }
 
   @Override
@@ -337,30 +287,9 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
       throws SQLException {
 
     Statement stmt = this.connection.createStatement();
-    boolean legacyMode = true;
-    ResultSet rs;
-
-    // Get Table Metadata
+    List<ColumnInfo> columns;
     try {
-      String sql =
-          String.format(
-              "select * from information_schema.columns where database like 
'%s' escape '\\' and table_name like '%s' escape '\\'",
-              schemaPattern, tableNamePattern);
-      rs = stmt.executeQuery(sql);
-    } catch (SQLException e) {
-      LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-
-      try {
-        String sql =
-            String.format(
-                "desc %s.%s details", removeEscape(schemaPattern), 
removeEscape(tableNamePattern));
-        rs = stmt.executeQuery(sql);
-        legacyMode = false;
-      } catch (SQLException e1) {
-        LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-        throw e;
-      }
-
+      columns = listColumns(stmt, schemaPattern, tableNamePattern, 
columnNamePattern);
     } finally {
       stmt.close();
     }
@@ -418,36 +347,27 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
             TSDataType.TEXT,
             TSDataType.TEXT,
             TSDataType.TEXT);
-    List<String> columnNameList = new ArrayList<>();
-    List<String> columnTypeList = new ArrayList<>();
-    Map<String, Integer> columnNameIndex = new HashMap<>();
     List<List<Object>> valuesList = new ArrayList<>();
-    for (int i = 0; i < fields.length; i++) {
-      columnNameList.add(fields[i].getName());
-      columnTypeList.add(fields[i].getSqlType());
-      columnNameIndex.put(fields[i].getName(), i);
-    }
 
     // Extract Metadata
-    int count = 1;
-    while (rs.next()) {
-      String columnName =
-          legacyMode ? rs.getString("column_name") : 
rs.getString("ColumnName"); // 3
-      String type = legacyMode ? rs.getString("datatype") : 
rs.getString("DataType"); // 4
+    for (ColumnInfo column : columns) {
+      // The time column can have any name, so it is recognized by its category
+      boolean isTimeColumn = TIME_CATEGORY.equals(column.category());
       List<Object> valueInRow = new ArrayList<>();
       for (int i = 0; i < fields.length; i++) {
         if (i == 0) {
-          valueInRow.add("");
+          // TABLE_CAT: the table model has no catalogs
+          valueInRow.add(null);
         } else if (i == 1) {
-          valueInRow.add(schemaPattern);
+          valueInRow.add(column.database());
         } else if (i == 2) {
-          valueInRow.add(tableNamePattern);
+          valueInRow.add(column.tableName());
         } else if (i == 3) {
-          valueInRow.add(columnName);
+          valueInRow.add(column.columnName());
         } else if (i == 4) {
-          valueInRow.add(getSQLType(type));
+          valueInRow.add(getSQLType(column.dataType()));
         } else if (i == 5) {
-          valueInRow.add(type);
+          valueInRow.add(column.dataType());
         } else if (i == 6) {
           valueInRow.add(0);
         } else if (i == 7) {
@@ -457,13 +377,13 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
         } else if (i == 9) {
           valueInRow.add(0);
         } else if (i == 10) {
-          if (!columnName.equals("time")) {
+          if (!isTimeColumn) {
             valueInRow.add(ResultSetMetaData.columnNullableUnknown);
           } else {
             valueInRow.add(ResultSetMetaData.columnNoNulls);
           }
         } else if (i == 11) {
-          String comment = legacyMode ? rs.getString("comment") : 
rs.getString("Comment");
+          String comment = column.comment();
           if (comment != null && !comment.isEmpty()) {
             valueInRow.add(comment);
           } else {
@@ -478,9 +398,9 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
         } else if (i == 15) {
           valueInRow.add(65535);
         } else if (i == 16) {
-          valueInRow.add(count++);
+          valueInRow.add(column.ordinalPosition());
         } else if (i == 17) {
-          if (!columnName.equals("time")) {
+          if (!isTimeColumn) {
             valueInRow.add("YES");
           } else {
             valueInRow.add("NO");
@@ -504,14 +424,167 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
       valuesList.add(valueInRow);
     }
 
-    // Convert Values to ByteBuffer
-    ByteBuffer tsBlock = null;
+    return toResultSet(stmt, fields, tsDataTypeList, valuesList);
+  }
+
+  /** Lists the tables matching the patterns, with their real database and 
table names. */
+  private List<TableInfo> listTables(Statement stmt, String schemaPattern, 
String tableNamePattern)
+      throws SQLException {
+    String sql = informationSchemaQuery("tables", schemaPattern, 
tableNamePattern);
+    try (ResultSet rs = stmt.executeQuery(sql)) {
+      List<TableInfo> tables = new ArrayList<>();
+      while (rs.next()) {
+        tables.add(
+            new TableInfo(
+                rs.getString("database"), rs.getString("table_name"), 
rs.getString("comment")));
+      }
+      return tables;
+    } catch (SQLException e) {
+      LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
+      try {
+        return listTablesWithShowStatements(stmt, schemaPattern, 
tableNamePattern);
+      } catch (SQLException fallbackException) {
+        e.addSuppressed(fallbackException);
+        throw e;
+      }
+    }
+  }
+
+  /**
+   * Lists tables with SHOW statements, for users who may not query 
information_schema. SHOW TABLES
+   * cannot filter by pattern, so the patterns are matched here.
+   */
+  private List<TableInfo> listTablesWithShowStatements(
+      Statement stmt, String schemaPattern, String tableNamePattern) throws 
SQLException {
+    List<String> databases = new ArrayList<>();
+    if (isLiteral(schemaPattern)) {
+      databases.add(removeEscape(schemaPattern));
+    } else {
+      try (ResultSet rs = stmt.executeQuery(SHOW_DATABASES_SQL)) {
+        while (rs.next()) {
+          String database = rs.getString(1);
+          if (matchesPattern(database, schemaPattern)) {
+            databases.add(database);
+          }
+        }
+      }
+    }
+
+    List<TableInfo> tables = new ArrayList<>();
+    for (String database : databases) {
+      try (ResultSet rs =
+          stmt.executeQuery("show tables details from " + 
quoteIdentifier(database))) {
+        while (rs.next()) {
+          String tableName = rs.getString("TableName");
+          if (matchesPattern(tableName, tableNamePattern)) {
+            tables.add(new TableInfo(database, tableName, 
rs.getString("Comment")));
+          }
+        }
+      }
+    }
+    return tables;
+  }
+
+  /**
+   * Lists the columns matching the patterns. ORDINAL_POSITION counts every 
column of the table, so
+   * the column name pattern is matched here rather than by the server.
+   */
+  private List<ColumnInfo> listColumns(
+      Statement stmt, String schemaPattern, String tableNamePattern, String 
columnNamePattern)
+      throws SQLException {
+    String sql = informationSchemaQuery("columns", schemaPattern, 
tableNamePattern);
+    try (ResultSet rs = stmt.executeQuery(sql)) {
+      List<ColumnInfo> columns = new ArrayList<>();
+      // information_schema lists the columns of each table in their defined 
order
+      Map<List<String>, Integer> ordinalPositions = new HashMap<>();
+      while (rs.next()) {
+        String database = rs.getString("database");
+        String tableName = rs.getString("table_name");
+        int ordinalPosition =
+            ordinalPositions.merge(Arrays.asList(database, tableName), 1, 
Integer::sum);
+        String columnName = rs.getString("column_name");
+        if (matchesPattern(columnName, columnNamePattern)) {
+          columns.add(
+              new ColumnInfo(
+                  database,
+                  tableName,
+                  columnName,
+                  rs.getString("datatype"),
+                  rs.getString("category"),
+                  rs.getString("comment"),
+                  ordinalPosition));
+        }
+      }
+      return columns;
+    } catch (SQLException e) {
+      LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
+      try {
+        return listColumnsWithDescribe(stmt, schemaPattern, tableNamePattern, 
columnNamePattern);
+      } catch (SQLException fallbackException) {
+        e.addSuppressed(fallbackException);
+        throw e;
+      }
+    }
+  }
+
+  /** Lists columns with DESC statements, for users who may not query 
information_schema. */
+  private List<ColumnInfo> listColumnsWithDescribe(
+      Statement stmt, String schemaPattern, String tableNamePattern, String 
columnNamePattern)
+      throws SQLException {
+    List<ColumnInfo> columns = new ArrayList<>();
+    for (TableInfo table : listTablesWithShowStatements(stmt, schemaPattern, 
tableNamePattern)) {
+      String sql =
+          "desc "
+              + quoteIdentifier(table.database())
+              + "."
+              + quoteIdentifier(table.tableName())
+              + " details";
+      try (ResultSet rs = stmt.executeQuery(sql)) {
+        int ordinalPosition = 0;
+        while (rs.next()) {
+          ordinalPosition++;
+          String columnName = rs.getString("ColumnName");
+          if (matchesPattern(columnName, columnNamePattern)) {
+            columns.add(
+                new ColumnInfo(
+                    table.database(),
+                    table.tableName(),
+                    columnName,
+                    rs.getString("DataType"),
+                    rs.getString("Category"),
+                    rs.getString("Comment"),
+                    ordinalPosition));
+          }
+        }
+      }
+    }
+    return columns;
+  }
+
+  private ResultSet toResultSet(
+      Statement stmt,
+      Field[] fields,
+      List<TSDataType> tsDataTypeList,
+      List<List<Object>> valuesList)
+      throws SQLException {
+    List<String> columnNameList = new ArrayList<>();
+    List<String> columnTypeList = new ArrayList<>();
+    Map<String, Integer> columnNameIndex = new HashMap<>();
+    for (int i = 0; i < fields.length; i++) {
+      columnNameList.add(fields[i].getName());
+      columnTypeList.add(fields[i].getSqlType());
+      columnNameIndex.put(fields[i].getName(), i);
+    }
+
+    // Convert Values to ByteBuffer. No rows must mean no TsBlock: an empty 
one reads as one row.
+    List<ByteBuffer> tsBlocks;
     try {
-      tsBlock = convertTsBlock(valuesList, tsDataTypeList);
+      tsBlocks =
+          valuesList.isEmpty()
+              ? Collections.emptyList()
+              : Collections.singletonList(convertTsBlock(valuesList, 
tsDataTypeList));
     } catch (IOException e) {
-      LOGGER.error(CONVERT_ERROR_MSG, e.getMessage());
-    } finally {
-      close(rs, stmt);
+      throw new SQLException(e);
     }
 
     return new IoTDBJDBCResultSet(
@@ -524,41 +597,98 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
         null,
         -1,
         sessionId,
-        Collections.singletonList(tsBlock),
+        tsBlocks,
         null,
         (long) 60 * 1000,
         false,
         zoneId);
   }
 
+  /**
+   * getTableTypes() reports the type as "table", so the requested types are 
compared ignoring case.
+   */
+  private static boolean isTableTypeRequested(String[] types) {
+    if (types == null) {
+      return true;
+    }
+    for (String type : types) {
+      if (TABLE_TYPE_TABLE.equalsIgnoreCase(type)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** A query on information_schema.{@code table}, narrowed by the non-null 
name patterns. */
+  private static String informationSchemaQuery(
+      String table, String schemaPattern, String tableNamePattern) {
+    StringBuilder sql = new StringBuilder("select * from 
information_schema.").append(table);
+    String keyword = " where ";
+    if (schemaPattern != null) {
+      sql.append(keyword).append(likePredicate("database", schemaPattern));
+      keyword = " and ";
+    }
+    if (tableNamePattern != null) {
+      sql.append(keyword).append(likePredicate("table_name", 
tableNamePattern));
+    }
+    return sql.toString();
+  }
+
+  private static String likePredicate(String column, String pattern) {
+    return column + " like '" + pattern.replace("'", "''") + "' escape '\\'";
+  }
+
+  /** Whether the pattern names a single object, i.e. it has no unescaped 
wildcard. */
+  private static boolean isLiteral(String pattern) {
+    if (pattern == null) {
+      return false;
+    }
+    for (int i = 0; i < pattern.length(); i++) {
+      char c = pattern.charAt(i);
+      if (c == SEARCH_STRING_ESCAPE) {
+        i++;
+      } else if (c == '%' || c == '_') {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Matches a name the way LIKE matches it; a null pattern matches every 
name. */
+  private static boolean matchesPattern(String name, String pattern) {
+    if (pattern == null) {
+      return true;
+    }
+    StringBuilder regex = new StringBuilder();
+    for (int i = 0; i < pattern.length(); i++) {
+      char c = pattern.charAt(i);
+      if (c == SEARCH_STRING_ESCAPE && i + 1 < pattern.length()) {
+        regex.append(Pattern.quote(String.valueOf(pattern.charAt(++i))));
+      } else if (c == '%') {
+        regex.append(".*");
+      } else if (c == '_') {
+        regex.append('.');
+      } else {
+        regex.append(Pattern.quote(String.valueOf(c)));
+      }
+    }
+    return Pattern.compile(regex.toString(), 
Pattern.DOTALL).matcher(name).matches();
+  }
+
+  private static String quoteIdentifier(String name) {
+    return "\"" + name.replace("\"", "\"\"") + "\"";
+  }
+
   @Override
   public ResultSet getPrimaryKeys(String catalog, String schemaPattern, String 
tableNamePattern)
       throws SQLException {
 
     Statement stmt = connection.createStatement();
-    boolean legacyMode = true;
-    ResultSet rs;
-
+    List<ColumnInfo> columns;
     try {
-      String sql =
-          String.format(
-              "select * from information_schema.columns where database like 
'%s'  escape '\\' and table_name like '%s' escape '\\' and (category='TAG' or 
category='TIME')",
-              schemaPattern, tableNamePattern);
-      rs = stmt.executeQuery(sql);
-    } catch (SQLException e) {
-      LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-
-      try {
-        String sql =
-            String.format(
-                "desc %s.%s", removeEscape(schemaPattern), 
removeEscape(tableNamePattern));
-        rs = stmt.executeQuery(sql);
-        legacyMode = false;
-      } catch (SQLException e1) {
-        LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage());
-        throw e;
-      }
-
+      // The arguments are names rather than patterns, but some tools escape 
them anyway
+      columns =
+          listColumns(stmt, toExactPattern(schemaPattern), 
toExactPattern(tableNamePattern), null);
     } finally {
       stmt.close();
     }
@@ -578,65 +708,41 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
             TSDataType.TEXT,
             TSDataType.INT32,
             TSDataType.TEXT);
-    List<String> columnNameList = new ArrayList<>();
-    List<String> columnTypeList = new ArrayList<>();
-    Map<String, Integer> columnNameIndex = new HashMap<>();
     List<List<Object>> valuesList = new ArrayList<>();
-    for (int i = 0; i < fields.length; i++) {
-      columnNameList.add(fields[i].getName());
-      columnTypeList.add(fields[i].getSqlType());
-      columnNameIndex.put(fields[i].getName(), i);
-    }
 
-    int count = 1;
-    while (rs.next()) {
-      String columnName = legacyMode ? rs.getString("column_name") : 
rs.getString("ColumnName");
-      String category = legacyMode ? rs.getString("category") : 
rs.getString("Category");
+    Map<List<String>, Integer> keySeqs = new HashMap<>();
+    for (ColumnInfo column : columns) {
+      String category = column.category();
       if (category.equals("TAG") || category.equals("TIME")) {
         List<Object> valueInRow = new ArrayList<>();
-        for (int i = 0; i < fields.length; ++i) {
-          if (i == 0) {
-            valueInRow.add(schemaPattern);
-          } else if (i == 1) {
-            valueInRow.add(schemaPattern);
-          } else if (i == 2) {
-            valueInRow.add(tableNamePattern);
-          } else if (i == 3) {
-            valueInRow.add(columnName);
-          } else if (i == 4) {
-            valueInRow.add(count++);
-          } else {
-            valueInRow.add(PRIMARY);
-          }
-        }
+        // TABLE_CAT: the table model has no catalogs
+        valueInRow.add(null);
+        valueInRow.add(column.database());
+        valueInRow.add(column.tableName());
+        valueInRow.add(column.columnName());
+        valueInRow.add(
+            keySeqs.merge(Arrays.asList(column.database(), 
column.tableName()), 1, Integer::sum));
+        valueInRow.add(PRIMARY);
         valuesList.add(valueInRow);
       }
     }
 
-    ByteBuffer tsBlock = null;
-    try {
-      tsBlock = convertTsBlock(valuesList, tsDataTypeList);
-    } catch (IOException e) {
-      LOGGER.error(JdbcMessages.RELATIONAL_GET_PRIMARY_KEYS_ERROR, 
e.getMessage());
-    } finally {
-      close(null, stmt);
-    }
+    return toResultSet(stmt, fields, tsDataTypeList, valuesList);
+  }
 
-    return new IoTDBJDBCResultSet(
-        stmt,
-        columnNameList,
-        columnTypeList,
-        columnNameIndex,
-        true,
-        client,
-        null,
-        -1,
-        sessionId,
-        Collections.singletonList(tsBlock),
-        null,
-        (long) 60 * 1000,
-        false,
-        zoneId);
+  /** A pattern matching exactly the given name, which may already be escaped 
like a pattern. */
+  private static String toExactPattern(String name) {
+    if (name == null) {
+      return null;
+    }
+    StringBuilder pattern = new StringBuilder();
+    for (char c : removeEscape(name).toCharArray()) {
+      if (c == '%' || c == '_' || c == SEARCH_STRING_ESCAPE) {
+        pattern.append(SEARCH_STRING_ESCAPE);
+      }
+      pattern.append(c);
+    }
+    return pattern.toString();
   }
 
   @Override
@@ -653,4 +759,17 @@ public class IoTDBRelationalDatabaseMetadata extends 
IoTDBAbstractDatabaseMetada
   public String getSchemaTerm() throws SQLException {
     return "";
   }
+
+  /** A table as listed by the server, with its real (unescaped) names. */
+  private record TableInfo(String database, String tableName, String comment) 
{}
+
+  /** A column as listed by the server, with its real (unescaped) names. */
+  private record ColumnInfo(
+      String database,
+      String tableName,
+      String columnName,
+      String dataType,
+      String category,
+      String comment,
+      int ordinalPosition) {}
 }
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java
index 2919e51d265..be3c7de929e 100644
--- 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java
@@ -43,6 +43,11 @@ import java.util.List;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 public class IoTDBConnectionTest {
@@ -119,4 +124,38 @@ public class IoTDBConnectionTest {
     connection.setQueryTimeout(60);
     Assert.assertEquals(60, connection.getQueryTimeout());
   }
+
+  @Test
+  public void testReadOnlyIsAcceptedAsHint() throws SQLException {
+    Assert.assertFalse(connection.isReadOnly());
+    connection.setReadOnly(true);
+    Assert.assertTrue(connection.isReadOnly());
+    connection.setReadOnly(false);
+    Assert.assertFalse(connection.isReadOnly());
+  }
+
+  @Test
+  public void testNewConnectionIsInAutoCommitMode() {
+    Assert.assertTrue(new IoTDBConnection().getAutoCommit());
+  }
+
+  @Test
+  public void testCatalogIsNullInTableDialect() throws SQLException {
+    IoTDBConnection tableConnection = spy(new IoTDBConnection());
+    doReturn(Constant.TABLE_DIALECT).when(tableConnection).getSqlDialect();
+    Assert.assertNull(tableConnection.getCatalog());
+
+    // The tree model keeps its catalog, which its DatabaseMetaData compares 
against
+    Assert.assertEquals("Apache IoTDB", connection.getCatalog());
+  }
+
+  @Test
+  public void testSetCatalogToNullIsIgnoredInTableDialect() throws 
SQLException {
+    IoTDBConnection tableConnection = spy(new IoTDBConnection());
+    doReturn(Constant.TABLE_DIALECT).when(tableConnection).getSqlDialect();
+
+    tableConnection.setCatalog(null);
+
+    verify(tableConnection, never()).prepareStatement(anyString());
+  }
 }
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java
index ab29a141399..a4f31979686 100644
--- 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java
@@ -159,6 +159,26 @@ public class IoTDBDatabaseMetadataTest {
     Assert.assertEquals("PKTABLE_SCHEM", 
resultSet.getMetaData().getColumnName(3));
   }
 
+  @Test
+  public void testGetColumnsToleratesConnectionWithoutCatalog() throws 
Exception {
+    // A connection that switched to the table dialect reports no catalog
+    when(connection.getCatalog()).thenReturn(null);
+    TSExecuteStatementResp showTimeseriesResp =
+        new 
TSExecuteStatementResp(RpcUtils.getStatus(TSStatusCode.SUCCESS_STATUS));
+    showTimeseriesResp.setQueryId(1L);
+    showTimeseriesResp.setColumns(Collections.singletonList("Timeseries"));
+    showTimeseriesResp.setDataTypeList(Collections.singletonList("TEXT"));
+    showTimeseriesResp.setIgnoreTimeStamp(true);
+    showTimeseriesResp.setColumnIndex2TsBlockColumnIndexList(
+        new ArrayList<>(Collections.singletonList(0)));
+    showTimeseriesResp.setQueryResult(Collections.emptyList());
+    when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class)))
+        .thenReturn(showTimeseriesResp);
+    
when(client.closeOperation(any())).thenReturn(RpcUtils.getStatus(TSStatusCode.SUCCESS_STATUS));
+
+    Assert.assertNotNull(databaseMetaData.getColumns(null, "root.sg", null, 
null));
+  }
+
   @Test
   public void testGetIndexInfo() throws SQLException {
     ResultSet resultSet = databaseMetaData.getIndexInfo(null, null, null, 
false, false);
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java
index 5805fb10330..0446e6473f3 100644
--- 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java
@@ -30,9 +30,12 @@ import 
org.apache.iotdb.service.rpc.thrift.TSFetchMetadataResp;
 import org.apache.iotdb.service.rpc.thrift.TSFetchResultsReq;
 import org.apache.iotdb.service.rpc.thrift.TSFetchResultsResp;
 
+import org.apache.tsfile.block.column.ColumnBuilder;
+import org.apache.tsfile.common.conf.TSFileConfig;
 import org.apache.tsfile.enums.TSDataType;
 import org.apache.tsfile.read.common.block.TsBlockBuilder;
 import org.apache.tsfile.read.common.block.column.TsBlockSerde;
+import org.apache.tsfile.utils.Binary;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
@@ -47,11 +50,14 @@ import java.sql.SQLException;
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.sql.Types;
+import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
@@ -107,6 +113,18 @@ import static org.mockito.Mockito.when;
 
 public class IoTDBJDBCResultSetTest {
 
+  private static final List<TSDataType> ALL_TYPES =
+      Arrays.asList(
+          TSDataType.BOOLEAN,
+          TSDataType.INT32,
+          TSDataType.INT64,
+          TSDataType.FLOAT,
+          TSDataType.DOUBLE,
+          TSDataType.STRING,
+          TSDataType.BLOB,
+          TSDataType.TIMESTAMP,
+          TSDataType.DATE);
+
   @Mock TSExecuteStatementResp execResp;
   private long queryId;
   private long sessionId;
@@ -279,6 +297,7 @@ public class IoTDBJDBCResultSetTest {
       Assert.assertEquals("2", resultSet.getString(1));
       Assert.assertEquals(new Timestamp(2), resultSet.getObject(1));
       Assert.assertEquals(new Timestamp(2), resultSet.getTimestamp(1));
+      Assert.assertEquals(new Timestamp(2), resultSet.getTimestamp("Time"));
 
       // The strictly-typed ones must report a SQLException, not an unchecked 
exception.
       assertTimeColumnRejected(() -> resultSet.getBoolean(1));
@@ -292,6 +311,196 @@ public class IoTDBJDBCResultSetTest {
     }
   }
 
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetObjectConvertsToRequestedClass() throws Exception {
+    try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, 
allTypesRow())) {
+      Assert.assertTrue(resultSet.next());
+      Assert.assertEquals(Boolean.TRUE, resultSet.getObject(1, Boolean.class));
+      Assert.assertEquals(Integer.valueOf(42), resultSet.getObject(2, 
Integer.class));
+      Assert.assertEquals(Long.valueOf(42L), resultSet.getObject(2, 
Long.class));
+      Assert.assertEquals(Long.valueOf(7L), resultSet.getObject(3, 
Long.class));
+      Assert.assertEquals(Float.valueOf(1.5F), resultSet.getObject(4, 
Float.class));
+      Assert.assertEquals(Double.valueOf(1.5D), resultSet.getObject(4, 
Double.class));
+      Assert.assertEquals(Double.valueOf(2.25D), resultSet.getObject(5, 
Double.class));
+      Assert.assertEquals("hello", resultSet.getObject(6, String.class));
+      Assert.assertArrayEquals(new byte[] {1, 2, 3}, resultSet.getObject(7, 
byte[].class));
+      Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L);
+      expectedTimestamp.setNanos(123_456_000);
+      Assert.assertEquals(expectedTimestamp, resultSet.getObject(8, 
Timestamp.class));
+      Assert.assertEquals(Long.valueOf(1_700_000_000_123_456L), 
resultSet.getObject(8, Long.class));
+      Assert.assertEquals(LocalDate.of(2024, 1, 15), resultSet.getObject(9, 
LocalDate.class));
+      Assert.assertEquals("42", resultSet.getObject(2, String.class));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetObjectReturnsNullForSqlNull() throws Exception {
+    try (ResultSet resultSet =
+        executeTableModelQueryWithAllTypes(1_000, new 
Object[ALL_TYPES.size()])) {
+      Assert.assertTrue(resultSet.next());
+      Assert.assertNull(resultSet.getObject(1, Boolean.class));
+      Assert.assertNull(resultSet.getObject(2, Integer.class));
+      Assert.assertNull(resultSet.getObject(3, Long.class));
+      Assert.assertNull(resultSet.getObject(4, Float.class));
+      Assert.assertNull(resultSet.getObject(5, Double.class));
+      Assert.assertNull(resultSet.getObject(6, String.class));
+      Assert.assertNull(resultSet.getObject(7, byte[].class));
+      Assert.assertNull(resultSet.getObject(8, Timestamp.class));
+      Assert.assertNull(resultSet.getObject(9, LocalDate.class));
+      Assert.assertTrue(resultSet.wasNull());
+      Assert.assertNull(resultSet.getObject("i", Integer.class));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetObjectRejectsUnsupportedConversion() throws Exception {
+    try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000, 
allTypesRow())) {
+      Assert.assertTrue(resultSet.next());
+      assertRejectedWithSqlException(() -> resultSet.getObject(6, 
Integer.class));
+      assertRejectedWithSqlException(() -> resultSet.getObject(1, Long.class));
+      assertRejectedWithSqlException(() -> resultSet.getObject(9, 
Timestamp.class));
+      assertRejectedWithSqlException(() -> resultSet.getObject(2, 
StringBuilder.class));
+      assertRejectedWithSqlException(() -> resultSet.getObject(2, (Class<?>) 
null));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetObjectByLabelConvertsToRequestedClass() throws Exception {
+    try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, 
allTypesRow())) {
+      Assert.assertTrue(resultSet.next());
+      Assert.assertEquals(Integer.valueOf(42), resultSet.getObject("i", 
Integer.class));
+      Assert.assertEquals(Long.valueOf(7L), resultSet.getObject("l", 
Long.class));
+      Assert.assertEquals("hello", resultSet.getObject("s", String.class));
+      Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L);
+      expectedTimestamp.setNanos(123_456_000);
+      Assert.assertEquals(expectedTimestamp, resultSet.getObject("ts", 
Timestamp.class));
+      Assert.assertEquals(LocalDate.of(2024, 1, 15), resultSet.getObject("dt", 
LocalDate.class));
+      assertRejectedWithSqlException(() -> resultSet.getObject("s", 
Integer.class));
+      assertRejectedWithSqlException(() -> 
resultSet.getObject("no_such_column", Integer.class));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetTimestampHonorsTimePrecisionByIndexAndLabel() throws 
Exception {
+    try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, 
allTypesRow())) {
+      Assert.assertTrue(resultSet.next());
+      Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L);
+      expectedTimestamp.setNanos(123_456_000);
+      Assert.assertEquals(expectedTimestamp, resultSet.getTimestamp(8));
+      Assert.assertEquals(expectedTimestamp, resultSet.getTimestamp("ts"));
+      assertRejectedWithSqlException(() -> 
resultSet.getTimestamp("no_such_column"));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetTimestampKeepsPreEpochValues() throws Exception {
+    Object[] row = allTypesRow();
+    // 1.5 seconds before the epoch, in milliseconds
+    row[7] = -1_500L;
+    try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000, row)) 
{
+      Assert.assertTrue(resultSet.next());
+      Assert.assertEquals(new Timestamp(-1_500L), 
resultSet.getTimestamp("ts"));
+      Assert.assertEquals(new Timestamp(-1_500L), resultSet.getTimestamp(8));
+      Assert.assertEquals(new Timestamp(-1_500L), resultSet.getObject(8, 
Timestamp.class));
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testGetTimestampReturnsNullForSqlNull() throws Exception {
+    try (ResultSet resultSet =
+        executeTableModelQueryWithAllTypes(1_000, new 
Object[ALL_TYPES.size()])) {
+      Assert.assertTrue(resultSet.next());
+      Assert.assertNull(resultSet.getTimestamp(8));
+      Assert.assertTrue(resultSet.wasNull());
+      Assert.assertNull(resultSet.getTimestamp("ts"));
+      Assert.assertTrue(resultSet.wasNull());
+    }
+  }
+
+  private void assertRejectedWithSqlException(ResultSetRead read) {
+    try {
+      read.run();
+      Assert.fail("the read should have been rejected");
+    } catch (SQLException e) {
+      // expected
+    } catch (RuntimeException e) {
+      Assert.fail(
+          "expected a SQLException but got " + e.getClass().getName() + ": " + 
e.getMessage());
+    }
+  }
+
+  /** One value of each type in ALL_TYPES; the TIMESTAMP is in microseconds. */
+  private static Object[] allTypesRow() {
+    return new Object[] {
+      true, 42, 7L, 1.5F, 2.25D, "hello", new byte[] {1, 2, 3}, 
1_700_000_000_123_456L, 20240115
+    };
+  }
+
+  /** Runs a table-model query whose single row holds one column of each type 
in ALL_TYPES. */
+  private ResultSet executeTableModelQueryWithAllTypes(int timeFactor, 
Object[] row)
+      throws Exception {
+    TsBlockBuilder tsBlockBuilder = new TsBlockBuilder(ALL_TYPES);
+    tsBlockBuilder.getTimeColumnBuilder().writeLong(0);
+    for (int i = 0; i < ALL_TYPES.size(); i++) {
+      ColumnBuilder columnBuilder = tsBlockBuilder.getColumnBuilder(i);
+      Object value = row[i];
+      if (value == null) {
+        columnBuilder.appendNull();
+        continue;
+      }
+      switch (ALL_TYPES.get(i)) {
+        case BOOLEAN:
+          columnBuilder.writeBoolean((Boolean) value);
+          break;
+        case INT32:
+        case DATE:
+          columnBuilder.writeInt((Integer) value);
+          break;
+        case INT64:
+        case TIMESTAMP:
+          columnBuilder.writeLong((Long) value);
+          break;
+        case FLOAT:
+          columnBuilder.writeFloat((Float) value);
+          break;
+        case DOUBLE:
+          columnBuilder.writeDouble((Double) value);
+          break;
+        case STRING:
+          columnBuilder.writeBinary(new Binary((String) value, 
TSFileConfig.STRING_CHARSET));
+          break;
+        default:
+          columnBuilder.writeBinary(new Binary((byte[]) value));
+          break;
+      }
+    }
+    tsBlockBuilder.declarePosition();
+
+    List<String> columns = Arrays.asList("b", "i", "l", "f", "d", "s", "blob", 
"ts", "dt");
+    TSExecuteStatementResp resp = new TSExecuteStatementResp(successStatus);
+    resp.setQueryId(queryId);
+    resp.setColumns(columns);
+    
resp.setDataTypeList(ALL_TYPES.stream().map(TSDataType::name).collect(Collectors.toList()));
+    resp.setIgnoreTimeStamp(true);
+    resp.setTableModel(true);
+    resp.setMoreData(false);
+    resp.setColumnIndex2TsBlockColumnIndexList(
+        IntStream.range(0, 
columns.size()).boxed().collect(Collectors.toList()));
+    resp.setQueryResult(
+        Collections.singletonList(new 
TsBlockSerde().serialize(tsBlockBuilder.build())));
+
+    when(connection.getTimeFactor()).thenReturn(timeFactor);
+    
when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class))).thenReturn(resp);
+    return statement.executeQuery("select * from all_types");
+  }
+
   @FunctionalInterface
   private interface ResultSetRead {
     void run() throws SQLException;
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java
new file mode 100644
index 00000000000..6517e7dbf2b
--- /dev/null
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java
@@ -0,0 +1,634 @@
+/*
+ * 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.
+ */
+
+package org.apache.iotdb.jdbc;
+
+import org.apache.iotdb.jdbc.relational.IoTDBRelationalDatabaseMetadata;
+import org.apache.iotdb.rpc.RpcUtils;
+import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.service.rpc.thrift.IClientRPCService;
+import org.apache.iotdb.service.rpc.thrift.TSCloseOperationReq;
+import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementReq;
+import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementResp;
+
+import org.apache.tsfile.block.column.ColumnBuilder;
+import org.apache.tsfile.common.conf.TSFileConfig;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.read.common.block.TsBlockBuilder;
+import org.apache.tsfile.read.common.block.column.TsBlockSerde;
+import org.apache.tsfile.utils.Binary;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.Types;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+public class IoTDBRelationalDatabaseMetadataTest {
+
+  private static final String TABLES_QUERY = "select * from 
information_schema.tables";
+  private static final String COLUMNS_QUERY = "select * from 
information_schema.columns";
+
+  // Result headers as the server produces them for each metadata source.
+  private static final List<String> INFORMATION_SCHEMA_TABLES_HEADER =
+      Arrays.asList(
+          "database",
+          "table_name",
+          "ttl(ms)",
+          "status",
+          "comment",
+          "table_type",
+          "need_last_cache");
+  private static final List<TSDataType> INFORMATION_SCHEMA_TABLES_TYPES =
+      Arrays.asList(
+          TSDataType.STRING,
+          TSDataType.STRING,
+          TSDataType.STRING,
+          TSDataType.STRING,
+          TSDataType.STRING,
+          TSDataType.STRING,
+          TSDataType.BOOLEAN);
+  private static final List<String> INFORMATION_SCHEMA_COLUMNS_HEADER =
+      Arrays.asList(
+          "database", "table_name", "column_name", "datatype", "category", 
"status", "comment");
+  private static final List<String> SHOW_DATABASES_HEADER =
+      Arrays.asList(
+          "Database",
+          "TTL(ms)",
+          "SchemaReplicationFactor",
+          "DataReplicationFactor",
+          "TimePartitionInterval");
+  private static final List<TSDataType> SHOW_DATABASES_TYPES =
+      Arrays.asList(
+          TSDataType.TEXT, TSDataType.TEXT, TSDataType.INT32, 
TSDataType.INT32, TSDataType.INT64);
+  private static final List<String> SHOW_TABLES_DETAILS_HEADER =
+      Arrays.asList("TableName", "TTL(ms)", "Status", "Comment", "TableType", 
"NeedLastCache");
+  private static final List<TSDataType> SHOW_TABLES_DETAILS_TYPES =
+      Arrays.asList(
+          TSDataType.TEXT,
+          TSDataType.TEXT,
+          TSDataType.TEXT,
+          TSDataType.TEXT,
+          TSDataType.TEXT,
+          TSDataType.BOOLEAN);
+  private static final List<String> DESC_DETAILS_HEADER =
+      Arrays.asList("ColumnName", "DataType", "Category", "Status", "Comment");
+
+  @Mock private IoTDBConnection connection;
+  @Mock private IClientRPCService.Iface client;
+
+  private final long sessionId = 1L;
+  private final ZoneId zoneId = ZoneId.systemDefault();
+  private DatabaseMetaData metadata;
+
+  /** Canned server responses, picked by the prefix of the statement the 
driver sends. */
+  private final Map<String, TSExecuteStatementResp> responses = new 
LinkedHashMap<>();
+
+  private final List<String> executedStatements = new ArrayList<>();
+
+  @Before
+  public void setUp() throws Exception {
+    MockitoAnnotations.initMocks(this);
+    when(connection.isClosed()).thenReturn(false);
+    when(connection.getTimeFactor()).thenReturn(1_000);
+    when(connection.createStatement())
+        .thenAnswer(invocation -> new IoTDBStatement(connection, client, 
sessionId, zoneId, 0, 1L));
+    
when(client.closeOperation(any(TSCloseOperationReq.class))).thenReturn(RpcUtils.SUCCESS_STATUS);
+    when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class)))
+        .thenAnswer(
+            invocation -> {
+              String statement = ((TSExecuteStatementReq) 
invocation.getArgument(0)).getStatement();
+              executedStatements.add(statement);
+              return responses.entrySet().stream()
+                  .filter(entry -> statement.startsWith(entry.getKey()))
+                  .map(entry -> withUnreadBuffers(entry.getValue()))
+                  .findFirst()
+                  .orElseGet(() -> failure("unexpected statement: " + 
statement));
+            });
+    metadata = new IoTDBRelationalDatabaseMetadata(connection, client, 
sessionId, zoneId);
+  }
+
+  // ========== getTables ==========
+
+  @Test
+  public void testGetTablesReportsRealNamesForEscapedPatterns() throws 
Exception {
+    responses.put(
+        TABLES_QUERY,
+        informationSchemaTables(informationSchemaTable("db_1", "sensor_data", 
"sensor table")));
+
+    try (ResultSet rs = metadata.getTables("Apache IoTDB", "db\\_1", 
"sensor\\_data", null)) {
+      assertTrue(rs.next());
+      assertNull(rs.getString("TABLE_CAT"));
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+      assertEquals("TABLE", rs.getString("TABLE_TYPE"));
+      assertEquals("sensor table", rs.getString("REMARKS"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetTablesStartsWithStandardColumns() throws Exception {
+    responses.put(
+        TABLES_QUERY, informationSchemaTables(informationSchemaTable("db_1", 
"sensor_data", null)));
+
+    try (ResultSet rs = metadata.getTables(null, "db_1", null, null)) {
+      assertEquals(1, rs.findColumn("TABLE_CAT"));
+      assertEquals(2, rs.findColumn("TABLE_SCHEM"));
+      assertEquals(3, rs.findColumn("TABLE_NAME"));
+      assertEquals(4, rs.findColumn("TABLE_TYPE"));
+      assertEquals(5, rs.findColumn("REMARKS"));
+      assertTrue(rs.next());
+      assertEquals("db_1", rs.getString(2));
+      assertEquals("sensor_data", rs.getString(3));
+    }
+  }
+
+  @Test
+  public void testGetTablesFiltersByTableNamePatternAndEscapesQuotes() throws 
Exception {
+    responses.put(TABLES_QUERY, informationSchemaTables());
+
+    metadata.getTables(null, "db\\_1", "it's%", null).close();
+
+    assertEquals(
+        Collections.singletonList(
+            "select * from information_schema.tables"
+                + " where database like 'db\\_1' escape '\\'"
+                + " and table_name like 'it''s%' escape '\\'"),
+        executedStatements);
+  }
+
+  @Test
+  public void testGetTablesWithNullPatternsListsEveryDatabase() throws 
Exception {
+    responses.put(
+        TABLES_QUERY,
+        informationSchemaTables(
+            informationSchemaTable("db_1", "t1", null),
+            informationSchemaTable("db_2", "t2", null)));
+
+    try (ResultSet rs = metadata.getTables(null, null, null, null)) {
+      assertTrue(rs.next());
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("t1", rs.getString("TABLE_NAME"));
+      assertTrue(rs.next());
+      assertEquals("db_2", rs.getString("TABLE_SCHEM"));
+      assertEquals("t2", rs.getString("TABLE_NAME"));
+      assertFalse(rs.next());
+    }
+    assertEquals(
+        Collections.singletonList("select * from information_schema.tables"), 
executedStatements);
+  }
+
+  @Test
+  public void testGetTablesReturnsEmptyResultSetWhenNothingMatches() throws 
Exception {
+    responses.put(TABLES_QUERY, informationSchemaTables());
+
+    try (ResultSet rs = metadata.getTables(null, "no_such_db", null, null)) {
+      assertNotNull(rs);
+      assertEquals(3, rs.findColumn("TABLE_NAME"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetTablesHonorsTableTypes() throws Exception {
+    responses.put(
+        TABLES_QUERY, informationSchemaTables(informationSchemaTable("db_1", 
"sensor_data", null)));
+
+    try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] 
{"VIEW"})) {
+      assertFalse(rs.next());
+    }
+    // getTableTypes() reports the type in lower case, so the match must 
ignore case.
+    try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] 
{"table"})) {
+      assertTrue(rs.next());
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+    }
+    try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] 
{"TABLE", "VIEW"})) {
+      assertTrue(rs.next());
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+    }
+  }
+
+  @Test
+  public void testGetTablesFallsBackToShowTablesForLiteralSchema() throws 
Exception {
+    responses.put(TABLES_QUERY, failure("no permission on 
information_schema"));
+    responses.put(
+        "show tables details from \"db_1\"",
+        showTablesDetails(
+            showTablesRow("sensor_data", "sensor table"), 
showTablesRow("other", "")));
+
+    try (ResultSet rs = metadata.getTables(null, "db\\_1", "sensor%", null)) {
+      assertTrue(rs.next());
+      assertNull(rs.getString("TABLE_CAT"));
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+      assertEquals("sensor table", rs.getString("REMARKS"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetTablesFallbackListsEveryDatabaseForNullSchema() throws 
Exception {
+    responses.put(TABLES_QUERY, failure("no permission on 
information_schema"));
+    responses.put("SHOW DATABASES", showDatabases("db_1", "db_2"));
+    responses.put("show tables details from \"db_1\"", 
showTablesDetails(showTablesRow("t1", "")));
+    responses.put("show tables details from \"db_2\"", 
showTablesDetails(showTablesRow("t2", "")));
+
+    try (ResultSet rs = metadata.getTables(null, null, null, null)) {
+      assertTrue(rs.next());
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("t1", rs.getString("TABLE_NAME"));
+      assertTrue(rs.next());
+      assertEquals("db_2", rs.getString("TABLE_SCHEM"));
+      assertEquals("t2", rs.getString("TABLE_NAME"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testRemoveEscapeDropsSearchStringEscapes() {
+    assertEquals(
+        "information_schema", 
IoTDBRelationalDatabaseMetadata.removeEscape("information\\_schema"));
+    assertEquals("a%b\\c", 
IoTDBRelationalDatabaseMetadata.removeEscape("a\\%b\\\\c"));
+    assertEquals("plain", 
IoTDBRelationalDatabaseMetadata.removeEscape("plain"));
+  }
+
+  // ========== getColumns ==========
+
+  @Test
+  public void testGetColumnsReportsRealNamesForEscapedPatterns() throws 
Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", 
"TIME"),
+            informationSchemaColumn("db_1", "sensor_data", "region", "STRING", 
"TAG")));
+
+    try (ResultSet rs = metadata.getColumns(null, "db\\_1", "sensor\\_data", 
null)) {
+      assertTrue(rs.next());
+      assertNull(rs.getString("TABLE_CAT"));
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+      assertEquals("ts", rs.getString("COLUMN_NAME"));
+      assertEquals(Types.TIMESTAMP, rs.getInt("DATA_TYPE"));
+      assertEquals("TIMESTAMP", rs.getString("TYPE_NAME"));
+      assertTrue(rs.next());
+      assertNull(rs.getString("TABLE_CAT"));
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+      assertEquals("region", rs.getString("COLUMN_NAME"));
+      assertEquals(Types.VARCHAR, rs.getInt("DATA_TYPE"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetColumnsReportsObjectAsDatabaseSpecificType() throws 
Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "files", "content", "OBJECT", 
"FIELD")));
+
+    try (ResultSet rs = metadata.getColumns(null, "db_1", "files", null)) {
+      assertTrue(rs.next());
+      assertEquals("OBJECT", rs.getString("TYPE_NAME"));
+      assertEquals(Types.OTHER, rs.getInt("DATA_TYPE"));
+    }
+  }
+
+  @Test
+  public void testGetColumnsDerivesNullabilityFromTimeCategory() throws 
Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", 
"TIME"),
+            informationSchemaColumn("db_1", "sensor_data", "temperature", 
"FLOAT", "FIELD")));
+
+    try (ResultSet rs = metadata.getColumns(null, "db_1", "sensor_data", 
null)) {
+      assertTrue(rs.next());
+      assertEquals("ts", rs.getString("COLUMN_NAME"));
+      assertEquals(ResultSetMetaData.columnNoNulls, rs.getInt("NULLABLE"));
+      assertEquals("NO", rs.getString("IS_NULLABLE"));
+      assertTrue(rs.next());
+      assertEquals("temperature", rs.getString("COLUMN_NAME"));
+      assertEquals(ResultSetMetaData.columnNullableUnknown, 
rs.getInt("NULLABLE"));
+      assertEquals("YES", rs.getString("IS_NULLABLE"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetColumnsNumbersColumnsWithinEachTable() throws Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "t1", "time", "TIMESTAMP", "TIME"),
+            informationSchemaColumn("db_1", "t1", "a", "INT32", "FIELD"),
+            informationSchemaColumn("db_1", "t1", "b", "INT64", "FIELD"),
+            informationSchemaColumn("db_1", "t2", "time", "TIMESTAMP", "TIME"),
+            informationSchemaColumn("db_1", "t2", "c", "DOUBLE", "FIELD")));
+
+    List<String> columns = new ArrayList<>();
+    try (ResultSet rs = metadata.getColumns(null, "db_1", "t%", null)) {
+      while (rs.next()) {
+        columns.add(
+            rs.getString("TABLE_NAME")
+                + "."
+                + rs.getString("COLUMN_NAME")
+                + "#"
+                + rs.getInt("ORDINAL_POSITION"));
+      }
+    }
+    assertEquals(Arrays.asList("t1.time#1", "t1.a#2", "t1.b#3", "t2.time#1", 
"t2.c#2"), columns);
+  }
+
+  @Test
+  public void testGetColumnsFiltersByColumnNamePatternKeepingOrdinalPosition() 
throws Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "t1", "time", "TIMESTAMP", "TIME"),
+            informationSchemaColumn("db_1", "t1", "a", "INT32", "FIELD"),
+            informationSchemaColumn("db_1", "t1", "b", "INT64", "FIELD")));
+
+    try (ResultSet rs = metadata.getColumns(null, "db_1", "t1", "b")) {
+      assertTrue(rs.next());
+      assertEquals("b", rs.getString("COLUMN_NAME"));
+      assertEquals(3, rs.getInt("ORDINAL_POSITION"));
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetColumnsBuildsNullSafeQuotedQuery() throws Exception {
+    responses.put(COLUMNS_QUERY, informationSchemaColumns());
+
+    metadata.getColumns(null, "db\\_1", "it's", "c%").close();
+    metadata.getColumns(null, null, null, null).close();
+
+    assertEquals(
+        Arrays.asList(
+            "select * from information_schema.columns"
+                + " where database like 'db\\_1' escape '\\'"
+                + " and table_name like 'it''s' escape '\\'",
+            "select * from information_schema.columns"),
+        executedStatements);
+  }
+
+  @Test
+  public void testGetColumnsReturnsEmptyResultSetForUnknownTable() throws 
Exception {
+    responses.put(COLUMNS_QUERY, informationSchemaColumns());
+
+    try (ResultSet rs = metadata.getColumns(null, "db_1", "no_such_table", 
null)) {
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetColumnsFallsBackToDescribeTable() throws Exception {
+    responses.put(COLUMNS_QUERY, failure("no permission on 
information_schema"));
+    responses.put(
+        "show tables details from \"db_1\"",
+        showTablesDetails(showTablesRow("sensor_data", ""), 
showTablesRow("sensorXdata", "")));
+    responses.put(
+        "desc \"db_1\".\"sensor_data\" details",
+        describeTableDetails(
+            new Object[] {"ts", "TIMESTAMP", "TIME", "USING", null},
+            new Object[] {"temperature", "FLOAT", "FIELD", "USING", 
"celsius"}));
+
+    try (ResultSet rs = metadata.getColumns(null, "db\\_1", "sensor\\_data", 
null)) {
+      assertTrue(rs.next());
+      assertNull(rs.getString("TABLE_CAT"));
+      assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+      assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+      assertEquals("ts", rs.getString("COLUMN_NAME"));
+      assertEquals(ResultSetMetaData.columnNoNulls, rs.getInt("NULLABLE"));
+      assertEquals(1, rs.getInt("ORDINAL_POSITION"));
+      assertTrue(rs.next());
+      assertEquals("temperature", rs.getString("COLUMN_NAME"));
+      assertEquals("celsius", rs.getString("REMARKS"));
+      assertEquals("YES", rs.getString("IS_NULLABLE"));
+      assertEquals(2, rs.getInt("ORDINAL_POSITION"));
+      assertFalse(rs.next());
+    }
+  }
+
+  // ========== getPrimaryKeys ==========
+
+  @Test
+  public void testGetPrimaryKeysReportsRealNamesOfTimeAndTagColumns() throws 
Exception {
+    responses.put(
+        COLUMNS_QUERY,
+        informationSchemaColumns(
+            informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", 
"TIME"),
+            informationSchemaColumn("db_1", "sensor_data", "region", "STRING", 
"TAG"),
+            informationSchemaColumn("db_1", "sensor_data", "model", "STRING", 
"ATTRIBUTE"),
+            informationSchemaColumn("db_1", "sensor_data", "plant", "STRING", 
"TAG"),
+            informationSchemaColumn("db_1", "sensor_data", "temperature", 
"FLOAT", "FIELD")));
+
+    List<String> keys = new ArrayList<>();
+    // Some tools escape the names as if they were patterns
+    try (ResultSet rs = metadata.getPrimaryKeys(null, "db\\_1", 
"sensor\\_data")) {
+      while (rs.next()) {
+        assertNull(rs.getString("TABLE_CAT"));
+        assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+        assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+        assertEquals("PRIMARY", rs.getString("PK_NAME"));
+        keys.add(rs.getString("COLUMN_NAME") + "#" + rs.getInt("KEY_SEQ"));
+      }
+    }
+    assertEquals(Arrays.asList("ts#1", "region#2", "plant#3"), keys);
+  }
+
+  @Test
+  public void testGetPrimaryKeysMatchesExactNamesWhetherEscapedOrNot() throws 
Exception {
+    responses.put(COLUMNS_QUERY, informationSchemaColumns());
+
+    metadata.getPrimaryKeys(null, "db_1", "it's").close();
+    metadata.getPrimaryKeys(null, "db\\_1", "sensor\\_data").close();
+    metadata.getPrimaryKeys(null, null, "t1").close();
+
+    assertEquals(
+        Arrays.asList(
+            "select * from information_schema.columns"
+                + " where database like 'db\\_1' escape '\\'"
+                + " and table_name like 'it''s' escape '\\'",
+            "select * from information_schema.columns"
+                + " where database like 'db\\_1' escape '\\'"
+                + " and table_name like 'sensor\\_data' escape '\\'",
+            "select * from information_schema.columns where table_name like 
't1' escape '\\'"),
+        executedStatements);
+  }
+
+  @Test
+  public void testGetPrimaryKeysReturnsEmptyResultSetForUnknownTable() throws 
Exception {
+    responses.put(COLUMNS_QUERY, informationSchemaColumns());
+
+    try (ResultSet rs = metadata.getPrimaryKeys(null, "db_1", 
"no_such_table")) {
+      assertFalse(rs.next());
+    }
+  }
+
+  @Test
+  public void testGetPrimaryKeysFallsBackToDescribeTable() throws Exception {
+    responses.put(COLUMNS_QUERY, failure("no permission on 
information_schema"));
+    responses.put(
+        "show tables details from \"db_1\"",
+        showTablesDetails(showTablesRow("sensor_data", ""), 
showTablesRow("sensorXdata", "")));
+    responses.put(
+        "desc \"db_1\".\"sensor_data\" details",
+        describeTableDetails(
+            new Object[] {"ts", "TIMESTAMP", "TIME", "USING", null},
+            new Object[] {"region", "STRING", "TAG", "USING", null},
+            new Object[] {"temperature", "FLOAT", "FIELD", "USING", null}));
+
+    List<String> keys = new ArrayList<>();
+    try (ResultSet rs = metadata.getPrimaryKeys(null, "db_1", "sensor_data")) {
+      while (rs.next()) {
+        assertNull(rs.getString("TABLE_CAT"));
+        assertEquals("db_1", rs.getString("TABLE_SCHEM"));
+        assertEquals("sensor_data", rs.getString("TABLE_NAME"));
+        keys.add(rs.getString("COLUMN_NAME") + "#" + rs.getInt("KEY_SEQ"));
+      }
+    }
+    assertEquals(Arrays.asList("ts#1", "region#2"), keys);
+  }
+
+  // ========== fake server responses ==========
+
+  private static Object[] informationSchemaTable(String database, String 
table, String comment) {
+    return new Object[] {database, table, "INF", "USING", comment, "BASE 
TABLE", false};
+  }
+
+  private static TSExecuteStatementResp informationSchemaTables(Object[]... 
rows)
+      throws IOException {
+    return queryResult(INFORMATION_SCHEMA_TABLES_HEADER, 
INFORMATION_SCHEMA_TABLES_TYPES, rows);
+  }
+
+  private static Object[] informationSchemaColumn(
+      String database, String table, String column, String dataType, String 
category) {
+    return new Object[] {database, table, column, dataType, category, "USING", 
null};
+  }
+
+  private static TSExecuteStatementResp informationSchemaColumns(Object[]... 
rows)
+      throws IOException {
+    return queryResult(
+        INFORMATION_SCHEMA_COLUMNS_HEADER,
+        Collections.nCopies(INFORMATION_SCHEMA_COLUMNS_HEADER.size(), 
TSDataType.STRING),
+        rows);
+  }
+
+  private static TSExecuteStatementResp showDatabases(String... databases) 
throws IOException {
+    Object[][] rows = new Object[databases.length][];
+    for (int i = 0; i < databases.length; i++) {
+      rows[i] = new Object[] {databases[i], "INF", 1, 1, 604_800_000L};
+    }
+    return queryResult(SHOW_DATABASES_HEADER, SHOW_DATABASES_TYPES, rows);
+  }
+
+  private static Object[] showTablesRow(String table, String comment) {
+    return new Object[] {table, "INF", "USING", comment, "BASE TABLE", false};
+  }
+
+  private static TSExecuteStatementResp showTablesDetails(Object[]... rows) 
throws IOException {
+    return queryResult(SHOW_TABLES_DETAILS_HEADER, SHOW_TABLES_DETAILS_TYPES, 
rows);
+  }
+
+  private static TSExecuteStatementResp describeTableDetails(Object[]... rows) 
throws IOException {
+    return queryResult(
+        DESC_DETAILS_HEADER,
+        Collections.nCopies(DESC_DETAILS_HEADER.size(), TSDataType.TEXT),
+        rows);
+  }
+
+  /** A successful table-model query response carrying the given rows in one 
TsBlock. */
+  private static TSExecuteStatementResp queryResult(
+      List<String> header, List<TSDataType> types, Object[]... rows) throws 
IOException {
+    TsBlockBuilder builder = new TsBlockBuilder(types);
+    for (Object[] row : rows) {
+      builder.getTimeColumnBuilder().writeLong(0);
+      for (int i = 0; i < row.length; i++) {
+        ColumnBuilder columnBuilder = builder.getColumnBuilder(i);
+        Object value = row[i];
+        if (value == null) {
+          columnBuilder.appendNull();
+        } else if (value instanceof Boolean) {
+          columnBuilder.writeBoolean((Boolean) value);
+        } else if (value instanceof Integer) {
+          columnBuilder.writeInt((Integer) value);
+        } else if (value instanceof Long) {
+          columnBuilder.writeLong((Long) value);
+        } else {
+          columnBuilder.writeBinary(new Binary((String) value, 
TSFileConfig.STRING_CHARSET));
+        }
+      }
+      builder.declarePosition();
+    }
+
+    TSExecuteStatementResp resp = new 
TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS);
+    resp.setQueryId(100L);
+    resp.setColumns(header);
+    
resp.setDataTypeList(types.stream().map(TSDataType::name).collect(Collectors.toList()));
+    resp.setIgnoreTimeStamp(true);
+    resp.setTableModel(true);
+    resp.setMoreData(false);
+    resp.setColumnIndex2TsBlockColumnIndexList(
+        IntStream.range(0, 
header.size()).boxed().collect(Collectors.toList()));
+    resp.setQueryResult(
+        rows.length == 0
+            ? Collections.emptyList()
+            : Collections.singletonList(new 
TsBlockSerde().serialize(builder.build())));
+    return resp;
+  }
+
+  /** Reading a result consumes its TsBlock buffers, so every execution gets 
its own view. */
+  private static TSExecuteStatementResp 
withUnreadBuffers(TSExecuteStatementResp resp) {
+    TSExecuteStatementResp copy = resp.deepCopy();
+    if (copy.isSetQueryResult()) {
+      copy.setQueryResult(
+          
copy.getQueryResult().stream().map(ByteBuffer::duplicate).collect(Collectors.toList()));
+    }
+    return copy;
+  }
+
+  private static TSExecuteStatementResp failure(String message) {
+    return RpcUtils.getTSExecuteStatementResp(TSStatusCode.NO_PERMISSION, 
message);
+  }
+}
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java
index dc9869bddda..13d84c6d76a 100644
--- 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java
@@ -173,4 +173,18 @@ public class IoTDBResultMetadataTest {
       assertEquals(metadata.getColumnType(i + 1), types[i - 1]);
     }
   }
+
+  @Test
+  public void testObjectColumnIsDescribed() throws SQLException {
+    metadata =
+        new IoTDBResultMetadata(
+            false, null, "QUERY", Arrays.asList("file"), 
Arrays.asList("OBJECT"), true);
+
+    assertEquals("OBJECT", metadata.getColumnTypeName(1));
+    assertEquals(Types.OTHER, metadata.getColumnType(1));
+    // getObject() renders an OBJECT value as a String such as "(Object) 1.2 
MB"
+    assertEquals(String.class.getName(), metadata.getColumnClassName(1));
+    assertEquals(Integer.MAX_VALUE, metadata.getPrecision(1));
+    assertEquals(0, metadata.getScale(1));
+  }
 }
diff --git 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java
 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java
index dd1c7ecbcb2..d60cd8afdcf 100644
--- 
a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java
+++ 
b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java
@@ -20,9 +20,13 @@
 package org.apache.iotdb.jdbc;
 
 import org.apache.iotdb.common.rpc.thrift.TSStatus;
+import org.apache.iotdb.rpc.RpcUtils;
 import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.rpc.stmt.PreparedParameterSerde;
 import org.apache.iotdb.service.rpc.thrift.IClientRPCService.Iface;
+import org.apache.iotdb.service.rpc.thrift.TSCancelOperationReq;
 import org.apache.iotdb.service.rpc.thrift.TSExecutePreparedReq;
+import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementReq;
 import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementResp;
 import org.apache.iotdb.service.rpc.thrift.TSPrepareReq;
 import org.apache.iotdb.service.rpc.thrift.TSPrepareResp;
@@ -35,10 +39,23 @@ import org.mockito.MockitoAnnotations;
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
 
+import java.nio.ByteBuffer;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.Instant;
+import java.time.LocalDate;
 import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -237,4 +254,300 @@ public class IoTDBTablePreparedStatementTest {
     verify(client).executePreparedStatement(argument.capture());
     assertTrue(argument.getValue().getParameters() != null);
   }
+
+  // ========== Cancel, fetch size and timestamp precision ==========
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testCancelTargetsQueryOfLatestPreparedExecution() throws 
Exception {
+    when(client.executePreparedStatement(any(TSExecutePreparedReq.class)))
+        .thenReturn(preparedQueryResp(42L), preparedQueryResp(43L));
+    when(client.cancelOperation(any(TSCancelOperationReq.class)))
+        .thenReturn(RpcUtils.SUCCESS_STATUS);
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", 
zoneId);
+    ps.setInt(1, 1);
+
+    ps.execute();
+    ps.cancel();
+    ps.execute();
+    ps.cancel();
+
+    ArgumentCaptor<TSCancelOperationReq> argument =
+        ArgumentCaptor.forClass(TSCancelOperationReq.class);
+    verify(client, times(2)).cancelOperation(argument.capture());
+    assertEquals(
+        Arrays.asList(42L, 43L),
+        argument.getAllValues().stream()
+            .map(TSCancelOperationReq::getQueryId)
+            .collect(Collectors.toList()));
+    assertEquals(sessionId, argument.getValue().getSessionId());
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testPreparedExecutionSendsFetchSize() throws Exception {
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", 
zoneId);
+    ps.setInt(1, 1);
+    ps.setFetchSize(123);
+
+    ps.execute();
+
+    TSExecutePreparedReq req = lastPreparedExecution();
+    assertTrue(req.isSetFetchSize());
+    assertEquals(123, req.getFetchSize());
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testPreparedExecutionFetchSizeIsCappedByMaxRows() throws 
Exception {
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", 
zoneId);
+    ps.setInt(1, 1);
+    ps.setFetchSize(1000);
+    ps.setMaxRows(10);
+
+    ps.execute();
+
+    assertEquals(10, lastPreparedExecution().getFetchSize());
+  }
+
+  @Test
+  public void testSetTimestampUsesServerTimePrecision() throws Exception {
+    Timestamp timestamp = new Timestamp(1_700_000_000_123L);
+    timestamp.setNanos(123_456_789);
+    assertEquals(1_700_000_000_123L, preparedTimestampValue(1_000, timestamp));
+    assertEquals(1_700_000_000_123_456L, preparedTimestampValue(1_000_000, 
timestamp));
+    assertEquals(1_700_000_000_123_456_789L, 
preparedTimestampValue(1_000_000_000, timestamp));
+
+    // 1.5 seconds before the epoch: the whole-second part must be floored, 
not truncated.
+    Timestamp beforeEpoch = new Timestamp(-1_500L);
+    assertEquals(-1_500L, preparedTimestampValue(1_000, beforeEpoch));
+    assertEquals(-1_500_000L, preparedTimestampValue(1_000_000, beforeEpoch));
+    assertEquals(-1_500_000_000L, preparedTimestampValue(1_000_000_000, 
beforeEpoch));
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testSetTimestampRejectsValueBeyondServerPrecisionRange() throws 
Exception {
+    when(connection.getTimeFactor()).thenReturn(1_000_000_000);
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE time = ?", 
zoneId);
+
+    // Nanosecond precision can only represent instants up to the year 2262.
+    Timestamp farFuture = Timestamp.valueOf("2300-01-01 00:00:00");
+    assertThrows(SQLException.class, () -> ps.setTimestamp(1, farFuture));
+
+    // ...and down to 1677-09-21T00:12:43.145224192Z, which is Long.MIN_VALUE 
nanoseconds.
+    Timestamp beforeLowerBound = 
Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224191Z"));
+    assertThrows(SQLException.class, () -> ps.setTimestamp(1, 
beforeLowerBound));
+  }
+
+  @Test
+  public void testSetTimestampAcceptsNanosecondLowerBound() throws Exception {
+    assertEquals(
+        Long.MIN_VALUE + 1,
+        preparedTimestampValue(
+            1_000_000_000, 
Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224193Z"))));
+    assertEquals(
+        Long.MIN_VALUE,
+        preparedTimestampValue(
+            1_000_000_000, 
Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224192Z"))));
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testSetTimestampInNonQueryStatementUsesServerTimePrecision() 
throws Exception {
+    when(connection.getTimeFactor()).thenReturn(1_000_000);
+    when(client.executeUpdateStatement(any(TSExecuteStatementReq.class)))
+        .thenReturn(new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS));
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "INSERT INTO t(time, s1) VALUES(?, 
1)", zoneId);
+    Timestamp timestamp = new Timestamp(1_700_000_000_123L);
+    timestamp.setNanos(123_456_789);
+    ps.setTimestamp(1, timestamp);
+
+    ps.executeUpdate();
+
+    ArgumentCaptor<TSExecuteStatementReq> argument =
+        ArgumentCaptor.forClass(TSExecuteStatementReq.class);
+    verify(client).executeUpdateStatement(argument.capture());
+    assertEquals(
+        "INSERT INTO t(time, s1) VALUES(1700000000123456, 1)", 
argument.getValue().getStatement());
+  }
+
+  // ========== LocalDate, client-side SQL completion and NaN/Infinity 
==========
+
+  @Test
+  public void testSetObjectAcceptsLocalDateInClientCompletedSql() throws 
Exception {
+    LocalDate date = LocalDate.of(2024, 1, 15);
+    String expected = "INSERT INTO t(time, d) VALUES(1, '2024-01-15')";
+    String sql = "INSERT INTO t(time, d) VALUES(1, ?)";
+    assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, 
date)));
+    assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, date, 
Types.DATE)));
+    assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, date, 
Types.DATE, 0)));
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testSetObjectBindsLocalDateLikeSetDate() throws Exception {
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE d = ? AND e 
= ?", zoneId);
+    ps.setObject(1, LocalDate.of(2024, 1, 15));
+    ps.setDate(2, java.sql.Date.valueOf("2024-01-15"));
+    ps.execute();
+
+    List<PreparedParameterSerde.DeserializedParam> parameters = 
lastPreparedParameters();
+    assertEquals("2024-01-15", parameters.get(0).value);
+    assertEquals(parameters.get(1).type, parameters.get(0).type);
+    assertEquals(parameters.get(1).value, parameters.get(0).value);
+  }
+
+  @Test
+  public void testQuotedIdentifiersHideQuestionMarksAndQuotes() throws 
Exception {
+    assertEquals(
+        "INSERT INTO \"my?table\"(time, s1) VALUES(1, 'a')",
+        clientCompletedSql("INSERT INTO \"my?table\"(time, s1) VALUES(?, ?)", 
this::bindRow));
+    assertEquals(
+        "INSERT INTO \"it's\"(time, s1) VALUES(1, 'a')",
+        clientCompletedSql("INSERT INTO \"it's\"(time, s1) VALUES(?, ?)", 
this::bindRow));
+    assertEquals(
+        "INSERT INTO `a?b`(time, s1) VALUES(1, 'a')",
+        clientCompletedSql("INSERT INTO `a?b`(time, s1) VALUES(?, ?)", 
this::bindRow));
+  }
+
+  @Test
+  public void testStringLiteralsEscapeQuotesOnlyByDoublingThem() throws 
Exception {
+    // A backslash is an ordinary character, so 'C:\' ends at its second quote
+    assertEquals(
+        "INSERT INTO t(time, s1, s2) VALUES(1, 'C:\\', 'a')",
+        clientCompletedSql("INSERT INTO t(time, s1, s2) VALUES(?, 'C:\\', ?)", 
this::bindRow));
+    assertEquals(
+        "INSERT INTO t(time, s1, s2) VALUES(1, 'it''s ?', 'a')",
+        clientCompletedSql("INSERT INTO t(time, s1, s2) VALUES(?, 'it''s ?', 
?)", this::bindRow));
+  }
+
+  @Test
+  public void testCommentsHideQuestionMarks() throws Exception {
+    assertEquals(
+        "INSERT INTO t(time, s1) VALUES(1, 'a') -- why?\n",
+        clientCompletedSql("INSERT INTO t(time, s1) VALUES(?, ?) -- why?\n", 
this::bindRow));
+    assertEquals(
+        "INSERT /* ? */ INTO t(time, s1) VALUES(1, 'a')",
+        clientCompletedSql("INSERT /* ? */ INTO t(time, s1) VALUES(?, ?)", 
this::bindRow));
+  }
+
+  @Test
+  public void testLineCommentEndsAtCarriageReturnOrLineFeed() throws Exception 
{
+    // As in the lexer's SIMPLE_COMMENT rule, a lone CR ends the comment just 
like LF or CRLF
+    assertEquals(
+        "INSERT -- comment\rINTO t(time, s1) VALUES(1, 'a')\n",
+        clientCompletedSql("INSERT -- comment\rINTO t(time, s1) VALUES(?, 
?)\n", this::bindRow));
+    assertEquals(
+        "INSERT -- comment\r\nINTO t(time, s1) VALUES(1, 'a')",
+        clientCompletedSql("INSERT -- comment\r\nINTO t(time, s1) VALUES(?, 
?)", this::bindRow));
+  }
+
+  @Test
+  public void testNaNIsSentAsStringInClientCompletedSql() throws Exception {
+    // The server has no NaN literal but stores the string 'NaN' as NaN in 
FLOAT/DOUBLE columns
+    assertEquals(
+        "INSERT INTO t(time, d, f) VALUES(1, 'NaN', 'NaN')",
+        clientCompletedSql(
+            "INSERT INTO t(time, d, f) VALUES(1, ?, ?)",
+            ps -> {
+              ps.setDouble(1, Double.NaN);
+              ps.setFloat(2, Float.NaN);
+            }));
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testInfinityIsRejectedInClientCompletedSql() throws Exception {
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "INSERT INTO t(time, d, f) 
VALUES(1, ?, ?)", zoneId);
+    assertThrows(SQLException.class, () -> ps.setDouble(1, 
Double.POSITIVE_INFINITY));
+    assertThrows(SQLException.class, () -> ps.setFloat(2, 
Float.NEGATIVE_INFINITY));
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void testInfinityIsBoundAsBinaryInServerPreparedQuery() throws 
Exception {
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE d < ?", 
zoneId);
+    ps.setDouble(1, Double.POSITIVE_INFINITY);
+    ps.execute();
+
+    assertEquals(Double.POSITIVE_INFINITY, 
lastPreparedParameters().get(0).value);
+  }
+
+  @FunctionalInterface
+  private interface ParameterBinder {
+    void bind(IoTDBTablePreparedStatement ps) throws SQLException;
+  }
+
+  private void bindRow(IoTDBTablePreparedStatement ps) throws SQLException {
+    ps.setLong(1, 1);
+    ps.setString(2, "a");
+  }
+
+  /** Runs a statement that the driver completes on the client and returns the 
SQL it sends. */
+  @SuppressWarnings("resource")
+  private String clientCompletedSql(String sql, ParameterBinder binder) throws 
Exception {
+    when(client.executeUpdateStatement(any(TSExecuteStatementReq.class)))
+        .thenReturn(new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS));
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(connection, client, sessionId, sql, 
zoneId);
+    binder.bind(ps);
+    ps.executeUpdate();
+
+    ArgumentCaptor<TSExecuteStatementReq> argument =
+        ArgumentCaptor.forClass(TSExecuteStatementReq.class);
+    verify(client, atLeastOnce()).executeUpdateStatement(argument.capture());
+    return argument.getValue().getStatement();
+  }
+
+  private List<PreparedParameterSerde.DeserializedParam> 
lastPreparedParameters() throws Exception {
+    return PreparedParameterSerde.deserialize(
+        ByteBuffer.wrap(lastPreparedExecution().getParameters()));
+  }
+
+  private static TSExecuteStatementResp preparedQueryResp(long queryId) {
+    TSExecuteStatementResp resp = new 
TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS);
+    resp.setQueryId(queryId);
+    return resp;
+  }
+
+  private TSExecutePreparedReq lastPreparedExecution() throws Exception {
+    ArgumentCaptor<TSExecutePreparedReq> argument =
+        ArgumentCaptor.forClass(TSExecutePreparedReq.class);
+    verify(client, atLeastOnce()).executePreparedStatement(argument.capture());
+    return argument.getValue();
+  }
+
+  /** Binds {@code timestamp} under the given server time factor and returns 
the value sent. */
+  @SuppressWarnings("resource")
+  private long preparedTimestampValue(int timeFactor, Timestamp timestamp) 
throws Exception {
+    when(connection.getTimeFactor()).thenReturn(timeFactor);
+    IoTDBTablePreparedStatement ps =
+        new IoTDBTablePreparedStatement(
+            connection, client, sessionId, "SELECT * FROM t WHERE time = ?", 
zoneId);
+    ps.setTimestamp(1, timestamp);
+    ps.execute();
+
+    List<PreparedParameterSerde.DeserializedParam> parameters =
+        PreparedParameterSerde.deserialize(
+            ByteBuffer.wrap(lastPreparedExecution().getParameters()));
+    assertEquals(1, parameters.size());
+    return (Long) parameters.get(0).value;
+  }
 }
diff --git 
a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java 
b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java
index 9c88bf3f8b7..4c4c3c20989 100644
--- a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java
+++ b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java
@@ -434,12 +434,13 @@ public class RpcUtils {
     return sql.substring(0, 11).equalsIgnoreCase("sql_dialect");
   }
 
+  // Seconds are floored so that a pre-epoch time keeps a nanos part in [0, 
1e9), as Timestamp needs
   public static long getMilliSecond(long time, int timeFactor) {
-    return time / timeFactor * 1_000;
+    return Math.floorDiv(time, timeFactor) * 1_000;
   }
 
   public static int getNanoSecond(long time, int timeFactor) {
-    return (int) (time % timeFactor * (1_000_000_000 / timeFactor));
+    return Math.floorMod(time, timeFactor) * (1_000_000_000 / timeFactor);
   }
 
   public static Timestamp convertToTimestamp(long time, int timeFactor) {
diff --git 
a/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java 
b/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java
index 54da8cb1e70..a061bc1506c 100644
--- 
a/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java
+++ 
b/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.common.rpc.thrift.TSStatus;
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.sql.Timestamp;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
@@ -120,4 +121,31 @@ public class RpcUtilsTest {
       RpcSslUtils.configure(originProtocol);
     }
   }
+
+  @Test
+  public void convertToTimestampKeepsPreEpochFractions() {
+    // 1.5 seconds before the epoch in each precision
+    Assert.assertEquals(new Timestamp(-1_500L), 
RpcUtils.convertToTimestamp(-1_500L, 1_000));
+    Assert.assertEquals(
+        new Timestamp(-1_500L), RpcUtils.convertToTimestamp(-1_500_000L, 
1_000_000));
+    Assert.assertEquals(
+        new Timestamp(-1_500L), RpcUtils.convertToTimestamp(-1_500_000_000L, 
1_000_000_000));
+
+    // 1 ns before the epoch: the second before it, plus 999,999,999 ns
+    Timestamp oneNanoBeforeEpoch = new Timestamp(-1_000L);
+    oneNanoBeforeEpoch.setNanos(999_999_999);
+    Assert.assertEquals(oneNanoBeforeEpoch, RpcUtils.convertToTimestamp(-1L, 
1_000_000_000));
+
+    // Whole seconds and post-epoch values are unaffected
+    Assert.assertEquals(new Timestamp(-2_000L), 
RpcUtils.convertToTimestamp(-2_000L, 1_000));
+    Assert.assertEquals(new Timestamp(1_500L), 
RpcUtils.convertToTimestamp(1_500L, 1_000));
+  }
+
+  @Test
+  public void splitTimeIntoFlooredSecondsAndNonNegativeNanos() {
+    Assert.assertEquals(-2_000L, RpcUtils.getMilliSecond(-1_500L, 1_000));
+    Assert.assertEquals(500_000_000, RpcUtils.getNanoSecond(-1_500L, 1_000));
+    Assert.assertEquals(1_000L, RpcUtils.getMilliSecond(1_500L, 1_000));
+    Assert.assertEquals(500_000_000, RpcUtils.getNanoSecond(1_500L, 1_000));
+  }
 }

Reply via email to