You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iotdb.apache.org by ja...@apache.org on 2026/10/21 08:49:04 UTC

(iotdb) 03/03: [JDBC] Accept read-only hint, default to auto-commit and fix client-side SQL binding

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

JackieTien97 pushed a commit to branch fix/jdbc-table-model-metadata-cancel
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit 5bd997c8f2845d4c4d283c5ca375d73e1cb47a82
Author: JackieTien97 <ja...@gmail.com>
AuthorDate: Wed Oct 21 16:07:15 2026 +0800

    [JDBC] Accept read-only hint, default to auto-commit and fix client-side SQL binding
    
    IoTDBConnection:
    - setReadOnly records the hint instead of throwing, and isReadOnly
      reports it. Trino calls setReadOnly(true) on every read connection.
    - New connections are in auto-commit mode, as JDBC requires; IoTDB has
      no transactions, so commit / rollback stay no-ops.
    - getCatalog returns null in the table dialect, matching the NULL
      TABLE_CAT of its DatabaseMetaData, and setCatalog(null) is ignored
      instead of failing with an NPE. The tree dialect keeps "Apache IoTDB",
      which its DatabaseMetaData compares against, now null-safely.
    
    IoTDBTablePreparedStatement:
    - setObject (all overloads) accepts java.time.LocalDate and binds it like
      setDate.
    - SQL completed on the client is split at '?' the way the table-model
      lexer tokenizes: string literals, double-quoted and back-quoted
      identifiers and comments hide '?', quotes are escaped only by doubling,
      and a backslash is an ordinary character.
    - NaN is written as the string 'NaN', which the server stores as NaN in
      FLOAT / DOUBLE columns. Infinity has no accepted SQL form (insert and
      CAST both reject it), so binding it there fails with a clear
      SQLException; server-prepared queries still send it in binary.
    
    Metadata:
    - Report OBJECT columns with type name OBJECT and java.sql.Types.OTHER in
      ResultSetMetaData (class String, as getObject describes the value) and
      in DatabaseMetaData.getColumns.
---
 .../org/apache/iotdb/jdbc/i18n/JdbcMessages.java   |   2 +-
 .../org/apache/iotdb/jdbc/i18n/JdbcMessages.java   |   2 +-
 .../iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java  |   5 +
 .../org/apache/iotdb/jdbc/IoTDBConnection.java     |  17 +--
 .../apache/iotdb/jdbc/IoTDBDatabaseMetadata.java   |   2 +-
 .../org/apache/iotdb/jdbc/IoTDBResultMetadata.java |  11 +-
 .../iotdb/jdbc/IoTDBTablePreparedStatement.java    |  92 ++++++++++-----
 .../org/apache/iotdb/jdbc/IoTDBConnectionTest.java |  39 +++++++
 .../iotdb/jdbc/IoTDBDatabaseMetadataTest.java      |  20 ++++
 .../jdbc/IoTDBRelationalDatabaseMetadataTest.java  |  14 +++
 .../apache/iotdb/jdbc/IoTDBResultMetadataTest.java |  14 +++
 .../jdbc/IoTDBTablePreparedStatementTest.java      | 130 +++++++++++++++++++++
 12 files changed, 310 insertions(+), 38 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 fe2d071b066..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";
@@ -230,5 +229,6 @@ public final class JdbcMessages {
   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 ccbfc04a02f..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";
@@ -229,5 +228,6 @@ public final class JdbcMessages {
   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 1b0cc9166b4..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;
 
@@ -479,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;
     }
@@ -500,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/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/IoTDBTablePreparedStatement.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java
index e0822a32ea9..8efb287389d 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;
@@ -373,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
@@ -404,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 + "'");
   }
@@ -497,6 +520,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[]) {
@@ -723,36 +748,51 @@ 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)) {
+        int end = sql.indexOf('\n', i);
+        i = end < 0 ? sql.length() : end + 1;
+      } 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/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/IoTDBRelationalDatabaseMetadataTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java
index 2c4e311a59a..6517e7dbf2b 100644
--- 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
@@ -319,6 +319,20 @@ public class IoTDBRelationalDatabaseMetadataTest {
     }
   }
 
+  @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(
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 83e91ddddeb..fa00f2add37 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
@@ -42,6 +42,8 @@ import org.mockito.stubbing.Answer;
 import java.nio.ByteBuffer;
 import java.sql.SQLException;
 import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.List;
@@ -363,6 +365,134 @@ public class IoTDBTablePreparedStatementTest {
         "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 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);