From fd25cf60750b821019c3cdca4e015393f2ef36e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=87=E5=A1=9E=E5=A4=A7=E5=98=B4=E5=A5=BD=E5=B8=A5?= <66861267+DaZuiZui@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:02:13 +0800 Subject: [PATCH 1/2] fix(jdbc): forward column mapping in table-model PreparedStatement IoTDBTablePreparedStatement.processQueryResult() called the 14-argument IoTDBJDBCResultSet constructor, which hard-codes tableModel=false and replaces the server-provided columnIndex2TsBlockColumnIndexList with an identity mapping. As a result, every table-model PreparedStatement query with a non-identity projection returned columns in physical order, and a repeated projection/alias (duplicate mapping entries) threw ArrayIndexOutOfBoundsException. Use the full constructor, forwarding the connection charset, the response's tableModel flag and a null-safe defensive copy of the column mapping, matching what IoTDBStatement already does. The copy is required because IoTDBRpcDataSet prepends the tree-model time column in place, and it must stay null-safe so the existing fallback still applies when the server sends no mapping. Add mocked-RPC unit tests (reordered columns, repeated projection, identity control, missing mapping fallback) and real-server IT coverage in the JDBC and PREPARE/EXECUTE paths, including TIMESTAMP projection. --- .../db/it/IoTDBTablePreparedStatementIT.java | 35 +++++ .../it/IoTDBTablePreparedStatementJDBCIT.java | 105 ++++++++++++++ .../jdbc/IoTDBTablePreparedStatement.java | 7 +- .../jdbc/IoTDBTablePreparedStatementTest.java | 137 ++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementIT.java index c7c5902e8e2dc..bc89145f7d378 100644 --- a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementIT.java @@ -382,4 +382,39 @@ public void testPrepareAndExecuteWithStringParameter() { fail("testPrepareAndExecuteWithStringParameter failed: " + e.getMessage()); } } + + @Test + public void testPrepareAndExecuteWithReorderedProjection() { + String[] expectedHeader = new String[] {"value", "name"}; + String[] retArray = new String[] {"200.3,Bob,"}; + try (Connection connection = EnvFactory.getEnv().getTableConnection(); + Statement statement = connection.createStatement()) { + statement.execute("USE " + DATABASE_NAME); + // Projection order differs from the table's column order + statement.execute("PREPARE stmt9 FROM SELECT value, name FROM test_table WHERE id = ?"); + executePreparedStatementAndVerify( + connection, statement, "EXECUTE stmt9 USING 2", expectedHeader, retArray); + statement.execute("DEALLOCATE PREPARE stmt9"); + } catch (SQLException e) { + fail("testPrepareAndExecuteWithReorderedProjection failed: " + e.getMessage()); + } + } + + @Test + public void testPrepareAndExecuteWithRepeatedProjection() { + String[] expectedHeader = new String[] {"x", "y"}; + String[] retArray = new String[] {"Bob,Bob,"}; + try (Connection connection = EnvFactory.getEnv().getTableConnection(); + Statement statement = connection.createStatement()) { + statement.execute("USE " + DATABASE_NAME); + // The same physical column is projected twice under different aliases + statement.execute( + "PREPARE stmt10 FROM SELECT name AS x, name AS y FROM test_table WHERE id = ?"); + executePreparedStatementAndVerify( + connection, statement, "EXECUTE stmt10 USING 2", expectedHeader, retArray); + statement.execute("DEALLOCATE PREPARE stmt10"); + } catch (SQLException e) { + fail("testPrepareAndExecuteWithRepeatedProjection failed: " + e.getMessage()); + } + } } diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementJDBCIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementJDBCIT.java index 18f2c9c04f0a1..0e22de860423d 100644 --- a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementJDBCIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBTablePreparedStatementJDBCIT.java @@ -495,4 +495,109 @@ public void testPreparedStatementWithAllDataTypes() throws SQLException { } } } + + @Test + public void testPreparedStatementWithTimestampProjection() throws SQLException { + try (Connection connection = getConnection(); + Statement stmt = connection.createStatement()) { + stmt.execute("USE " + DATABASE_NAME); + // Self-contained fixture: the all-types table is created by another test method + stmt.execute( + "CREATE TABLE IF NOT EXISTS timestamp_projection_table(" + + "int32_col INT32 FIELD, timestamp_col TIMESTAMP FIELD)"); + stmt.execute( + "INSERT INTO timestamp_projection_table VALUES " + + "(2025-06-01T00:00:00, 123, 2025-06-15T10:30:00)"); + + // TIMESTAMP is a valid table-model field type and must keep its mapping as well + try (PreparedStatement ps = + connection.prepareStatement( + "SELECT timestamp_col, int32_col FROM timestamp_projection_table " + + "WHERE int32_col = ?")) { + ps.setInt(1, 123); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals( + Timestamp.valueOf("2025-06-15 10:30:00").getTime(), rs.getTimestamp(1).getTime()); + assertEquals(123, rs.getInt(2)); + assertEquals( + Timestamp.valueOf("2025-06-15 10:30:00").getTime(), + rs.getTimestamp("timestamp_col").getTime()); + assertEquals(123, rs.getInt("int32_col")); + assertFalse(rs.next()); + } + } + } + } + + @Test + public void testPreparedStatementWithReorderedProjection() throws SQLException { + try (Connection connection = getConnection(); + Statement stmt = connection.createStatement()) { + stmt.execute("USE " + DATABASE_NAME); + + // Projection order ("value", "name") differs from the materialized order + try (PreparedStatement ps = + connection.prepareStatement("SELECT value, name FROM test_table WHERE int_value = ?")) { + ps.setInt(1, 20); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + ResultSetMetaData metaData = rs.getMetaData(); + assertEquals(2, metaData.getColumnCount()); + assertEquals("value", metaData.getColumnName(1)); + assertEquals("name", metaData.getColumnName(2)); + assertEquals(200.3, rs.getDouble(1), 0.001); + assertEquals("Bob", rs.getString(2)); + assertEquals("Bob", rs.getString("name")); + assertEquals(200.3, rs.getDouble("value"), 0.001); + assertFalse(rs.next()); + } + } + } + } + + @Test + public void testPreparedStatementWithRepeatedProjection() throws SQLException { + try (Connection connection = getConnection(); + Statement stmt = connection.createStatement()) { + stmt.execute("USE " + DATABASE_NAME); + + // The same physical column is projected twice under different aliases + try (PreparedStatement ps = + connection.prepareStatement( + "SELECT name AS x, name AS y FROM test_table WHERE int_value = ?")) { + ps.setInt(1, 20); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals("Bob", rs.getString(1)); + assertEquals("Bob", rs.getString(2)); + assertEquals("Bob", rs.getString("x")); + assertEquals("Bob", rs.getString("y")); + assertFalse(rs.next()); + } + } + } + } + + @Test + public void testPreparedStatementWithIdentityProjection() throws SQLException { + try (Connection connection = getConnection(); + Statement stmt = connection.createStatement()) { + stmt.execute("USE " + DATABASE_NAME); + + // Identity mapping: a regression control for the two cases above + try (PreparedStatement ps = + connection.prepareStatement("SELECT name, value FROM test_table WHERE int_value = ?")) { + ps.setInt(1, 20); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals("Bob", rs.getString(1)); + assertEquals(200.3, rs.getDouble(2), 0.001); + assertEquals("Bob", rs.getString("name")); + assertEquals(200.3, rs.getDouble("value"), 0.001); + assertFalse(rs.next()); + } + } + } + } } 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 2fd0f382435fb..efd81dddb1ebc 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 @@ -229,7 +229,12 @@ private ResultSet processQueryResult(TSExecuteStatementResp resp) throws SQLExce resp.tracingInfo, (long) queryTimeout * 1000, resp.isSetMoreData() && resp.isMoreData(), - zoneId); + zoneId, + charset, + resp.isSetTableModel() && resp.isTableModel(), + resp.getColumnIndex2TsBlockColumnIndexList() == null + ? null + : new ArrayList<>(resp.getColumnIndex2TsBlockColumnIndexList())); return resultSet; } return null; 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 dd1c7ecbcb209..bac799a7dda7a 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 @@ -22,11 +22,15 @@ import org.apache.iotdb.common.rpc.thrift.TSStatus; import org.apache.iotdb.rpc.TSStatusCode; import org.apache.iotdb.service.rpc.thrift.IClientRPCService.Iface; +import org.apache.iotdb.service.rpc.thrift.TSCloseOperationReq; import org.apache.iotdb.service.rpc.thrift.TSExecutePreparedReq; import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementResp; import org.apache.iotdb.service.rpc.thrift.TSPrepareReq; import org.apache.iotdb.service.rpc.thrift.TSPrepareResp; +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.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -35,8 +39,15 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; +import java.io.IOException; +import java.sql.ResultSet; import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; @@ -58,6 +69,7 @@ public class IoTDBTablePreparedStatementTest { public void before() throws Exception { MockitoAnnotations.initMocks(this); when(connection.getSqlDialect()).thenReturn("table"); + when(connection.getTimeFactor()).thenReturn(1_000); when(execStatementResp.getStatus()).thenReturn(Status_SUCCESS); when(execStatementResp.getQueryId()).thenReturn(queryId); @@ -81,6 +93,8 @@ public TSPrepareResp answer(InvocationOnMock invocation) throws Throwable { // Mock for executePreparedStatement when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) .thenReturn(execStatementResp); + + when(client.closeOperation(any(TSCloseOperationReq.class))).thenReturn(Status_SUCCESS); } /** Count the number of '?' placeholders in a SQL string, ignoring those inside quotes */ @@ -237,4 +251,127 @@ public void testTableModelStringWithNull() throws Exception { verify(client).executePreparedStatement(argument.capture()); assertTrue(argument.getValue().getParameters() != null); } + + // ========== Table Model result column mapping tests ========== + + private static TsBlockBuilder int32BlockBuilder(int columnCount) { + TsBlockBuilder builder = new TsBlockBuilder(Collections.nCopies(columnCount, TSDataType.INT32)); + builder.getTimeColumnBuilder().writeLong(1000L); + builder.declarePosition(); + return builder; + } + + private TSExecuteStatementResp buildQueryResp( + List columns, List mapping, TsBlockBuilder blockBuilder) throws IOException { + TSExecuteStatementResp resp = new TSExecuteStatementResp(); + resp.setStatus(Status_SUCCESS); + resp.setQueryId(1L); + resp.setColumns(columns); + List dataTypeList = new ArrayList<>(); + for (int i = 0; i < columns.size(); i++) { + dataTypeList.add(TSDataType.INT32.name()); + } + resp.setDataTypeList(dataTypeList); + resp.setIgnoreTimeStamp(true); + resp.setTableModel(true); + if (mapping != null) { + resp.setColumnIndex2TsBlockColumnIndexList(mapping); + } + resp.setQueryResult( + Collections.singletonList(new TsBlockSerde().serialize(blockBuilder.build()))); + resp.setMoreData(false); + return resp; + } + + @SuppressWarnings("resource") + @Test + public void testTableModelPreparedStatementReorderedColumns() throws Exception { + TsBlockBuilder blockBuilder = int32BlockBuilder(2); + blockBuilder.getColumnBuilder(0).writeInt(10); // physical column 0 + blockBuilder.getColumnBuilder(1).writeInt(20); // physical column 1 + + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn(buildQueryResp(Arrays.asList("b", "a"), Arrays.asList(1, 0), blockBuilder)); + + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT b, a FROM t WHERE a > ?", zoneId); + ps.setInt(1, 0); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals(20, rs.getInt(1)); + assertEquals(10, rs.getInt(2)); + assertEquals(20, rs.getInt("b")); + assertEquals(10, rs.getInt("a")); + } + } + + @SuppressWarnings("resource") + @Test + public void testTableModelPreparedStatementRepeatedProjection() throws Exception { + TsBlockBuilder blockBuilder = int32BlockBuilder(1); + blockBuilder.getColumnBuilder(0).writeInt(10); // physical column 0, referenced twice + + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn(buildQueryResp(Arrays.asList("x", "y"), Arrays.asList(0, 0), blockBuilder)); + + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT a AS x, a AS y FROM t WHERE a > ?", zoneId); + ps.setInt(1, 0); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals(10, rs.getInt(1)); + assertEquals(10, rs.getInt(2)); + assertEquals(10, rs.getInt("x")); + assertEquals(10, rs.getInt("y")); + } + } + + @SuppressWarnings("resource") + @Test + public void testTableModelPreparedStatementIdentityMapping() throws Exception { + TsBlockBuilder blockBuilder = int32BlockBuilder(2); + blockBuilder.getColumnBuilder(0).writeInt(10); + blockBuilder.getColumnBuilder(1).writeInt(20); + + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn(buildQueryResp(Arrays.asList("a", "b"), Arrays.asList(0, 1), blockBuilder)); + + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT a, b FROM t WHERE a > ?", zoneId); + ps.setInt(1, 0); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals(10, rs.getInt(1)); + assertEquals(20, rs.getInt(2)); + assertEquals(10, rs.getInt("a")); + assertEquals(20, rs.getInt("b")); + } + } + + @SuppressWarnings("resource") + @Test + public void testTableModelPreparedStatementMissingMappingFallsBack() throws Exception { + TsBlockBuilder blockBuilder = int32BlockBuilder(2); + blockBuilder.getColumnBuilder(0).writeInt(10); + blockBuilder.getColumnBuilder(1).writeInt(20); + + // No columnIndex2TsBlockColumnIndexList: the client must keep its own derivation + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn(buildQueryResp(Arrays.asList("a", "b"), null, blockBuilder)); + + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT a, b FROM t WHERE a > ?", zoneId); + ps.setInt(1, 0); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertEquals(10, rs.getInt(1)); + assertEquals(20, rs.getInt(2)); + assertEquals(10, rs.getInt("a")); + assertEquals(20, rs.getInt("b")); + } + } } From a69af867d2bc7e10337836b8a82a1915a85bb17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=87=E5=A1=9E=E5=A4=A7=E5=98=B4=E5=A5=BD=E5=B8=A5?= <66861267+DaZuiZui@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:51:35 +0800 Subject: [PATCH 2/2] test(jdbc): cover connection charset forwarding in table-model PreparedStatement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a mocked-RPC unit test for the charset argument forwarded to IoTDBJDBCResultSet. A STRING column holds 中文 and the statement is opened with a GBK charset; getBytes(int) and getBytes(String) must encode with GBK rather than the TSFileConfig default. The test fails against the pre-fix code (UTF-8 bytes) and passes with it. buildQueryResp() now takes the column data types so a non-INT32 column can be described; the existing INT32 helper delegates to it. Co-Authored-By: Claude Opus 4.8 --- .../jdbc/IoTDBTablePreparedStatementTest.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) 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 686392e20e733..762998cf30e28 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 @@ -35,6 +35,7 @@ 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.ArgumentCaptor; @@ -45,6 +46,7 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.charset.Charset; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; @@ -58,6 +60,7 @@ import java.util.List; import java.util.stream.Collectors; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -277,13 +280,23 @@ private static TsBlockBuilder int32BlockBuilder(int columnCount) { private TSExecuteStatementResp buildQueryResp( List columns, List mapping, TsBlockBuilder blockBuilder) throws IOException { + return buildQueryResp( + columns, Collections.nCopies(columns.size(), TSDataType.INT32), mapping, blockBuilder); + } + + private TSExecuteStatementResp buildQueryResp( + List columns, + List dataTypes, + List mapping, + TsBlockBuilder blockBuilder) + throws IOException { TSExecuteStatementResp resp = new TSExecuteStatementResp(); resp.setStatus(Status_SUCCESS); resp.setQueryId(1L); resp.setColumns(columns); List dataTypeList = new ArrayList<>(); - for (int i = 0; i < columns.size(); i++) { - dataTypeList.add(TSDataType.INT32.name()); + for (TSDataType dataType : dataTypes) { + dataTypeList.add(dataType.name()); } resp.setDataTypeList(dataTypeList); resp.setIgnoreTimeStamp(true); @@ -389,6 +402,41 @@ public void testTableModelPreparedStatementMissingMappingFallsBack() throws Exce } } + // ========== Charset forwarding ========== + + @SuppressWarnings("resource") + @Test + public void testTableModelPreparedStatementGetBytesUsesConnectionCharset() throws Exception { + // A STRING column holding 中文; the value travels as UTF-8 in the TsBlock, but getBytes must + // encode the decoded String with the charset the connection was opened with. + TsBlockBuilder blockBuilder = new TsBlockBuilder(Collections.singletonList(TSDataType.STRING)); + blockBuilder.getTimeColumnBuilder().writeLong(1000L); + blockBuilder.declarePosition(); + blockBuilder.getColumnBuilder(0).writeBinary(new Binary("中文", Charset.forName("UTF-8"))); + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn( + buildQueryResp( + Collections.singletonList("name"), + Collections.singletonList(TSDataType.STRING), + null, + blockBuilder)); + + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, + client, + sessionId, + "SELECT name FROM t WHERE name = ?", + zoneId, + Charset.forName("GBK")); + ps.setString(1, "中文"); + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next()); + assertArrayEquals("中文".getBytes(Charset.forName("GBK")), rs.getBytes(1)); + assertArrayEquals("中文".getBytes(Charset.forName("GBK")), rs.getBytes("name")); + } + } + // ========== Cancel, fetch size and timestamp precision ========== @SuppressWarnings("resource")