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] 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")); + } + } }