diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java index f74898268..04acf3a2b 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java @@ -648,6 +648,10 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws itemClass = float.class; } else if (firstValue instanceof Double) { itemClass = double.class; + } else if (firstValue instanceof Map) { + itemClass = Map.class; + } else if (firstValue instanceof List) { + itemClass = List.class; } array = new ArrayValue(itemClass, len); @@ -701,7 +705,7 @@ public void set(int index, Object value) { Array.set(array, index, value); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Failed to set value at index: " + index + - " value " + value + " of class " + value.getClass().getName(), e); + " value " + value + " of class " + value.getClass().getName() + " when array type is " + array.getClass(), e); } } diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java index 423cb3390..2045a0d29 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java @@ -1529,6 +1529,109 @@ public void testGetStringArrayAndGetObjectArrayWhenValueIsList() throws Exceptio } } + @Test(groups = {"integration"}) + public void testJSONScanDoesNotLeakKeysAcrossRows() throws Exception { + if (isVersionMatch("(,24.8]")) { + return; + } + + final String table = "test_json_no_key_leak"; + final CommandSettings cmdSettings = (CommandSettings) new CommandSettings() + .serverSetting("enable_json_type", "1") + .serverSetting("allow_experimental_json_type", "1"); + + client.execute("DROP TABLE IF EXISTS " + table).get().close(); + client.execute(tableDefinition(table, "id UInt32", "data JSON"), cmdSettings).get().close(); + client.execute("INSERT INTO " + table + " VALUES (1, '{\"a\": \"foo\"}'::JSON), (2, '{\"b\": \"bar\"}'::JSON)", cmdSettings).get().close(); + + List records = client.queryAll("SELECT * FROM " + table + " ORDER BY id"); + Assert.assertEquals(records.size(), 2); + + for (GenericRecord record : records) { + @SuppressWarnings("unchecked") + Map data = (Map) record.getObject("data"); + Assert.assertNotNull(data, "JSON column should not be null"); + + if (data.containsKey("a")) { + Assert.assertFalse(data.containsKey("b"), + "Row with key 'a' should not contain key 'b', but got: " + data); + } else if (data.containsKey("b")) { + Assert.assertFalse(data.containsKey("a"), + "Row with key 'b' should not contain key 'a', but got: " + data); + } else { + Assert.fail("Expected row to contain either key 'a' or 'b', but got: " + data); + } + } + } + + @Test(groups = {"integration"}, dataProvider = "testJSONSubPathAccess_dp") + public void testJSONSubPathAccess(String query, Object[] expectedValues) throws Exception { + if (isVersionMatch("(,24.8]")) { + return; + } + + final String table = "test_json_sub_path_access"; + client.execute("DROP TABLE IF EXISTS " + table).get().close(); + + CommandSettings jsonSettings = (CommandSettings) new CommandSettings() + .serverSetting("enable_json_type", "1") + .serverSetting("allow_experimental_json_type", "1"); + client.execute("CREATE TABLE " + table + " (`i` Int64, `j` JSON) ENGINE = MergeTree ORDER BY i", + jsonSettings).get().close(); + client.execute("INSERT INTO " + table + " VALUES " + + "(1, '{\"m\":{\"a\":[{\"d\": 9000}]}}'), " + + "(2, '{\"m\":{\"a\":[{\"d\": 42}, {\"d\": 7}]}}')", jsonSettings).get().close(); + + String fullQuery = query.replace("${table}", table); + try (QueryResponse response = client.query(fullQuery).get()) { + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response); + int rowIndex = 0; + while (reader.next() != null) { + Assert.assertTrue(rowIndex < expectedValues.length, + "More rows returned than expected for query: " + fullQuery); + Object actual = reader.readValue(1); + if (actual instanceof BinaryStreamReader.ArrayValue) { + actual = ((BinaryStreamReader.ArrayValue) actual).asList(); + } + Assert.assertEquals(actual, expectedValues[rowIndex], + "Mismatch at row " + rowIndex + " for query: " + fullQuery); + rowIndex++; + } + Assert.assertEquals(rowIndex, expectedValues.length, + "Row count mismatch for query: " + fullQuery); + } + } + + @DataProvider + public Object[][] testJSONSubPathAccess_dp() { + Map obj9000 = Collections.singletonMap("d", 9000L); + Map obj42 = Collections.singletonMap("d", 42L); + Map obj7 = Collections.singletonMap("d", 7L); + + return new Object[][] { + // gh#2703: backtick-quoted JSON path with array element access + { + "SELECT j.`m`.`a`[].`d`[1] FROM ${table} ORDER BY i", + new Object[] { 9000L, 42L } + }, + // dot-notation sub-path access + { + "SELECT j.m.a FROM ${table} ORDER BY i", + new Object[] { Arrays.asList(obj9000), Arrays.asList(obj42, obj7) } + }, + // array element access with index + { + "SELECT j.m.a[].d[1] FROM ${table} ORDER BY i", + new Object[] { 9000L, 42L } + }, + // sub-column access to nested field + { + "SELECT j.m.a[].d FROM ${table} ORDER BY i", + new Object[] { Arrays.asList(9000L), Arrays.asList(42L, 7L) } + }, + }; + } + public static String tableDefinition(String table, String... columns) { StringBuilder sb = new StringBuilder(); sb.append("CREATE TABLE " + table + " ( "); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java index d18f4656e..1c74cb4bb 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java @@ -6,6 +6,7 @@ import org.testng.Assert; import org.testng.annotations.Test; +import java.sql.Array; import java.sql.Connection; import java.sql.Date; import java.sql.PreparedStatement; @@ -20,6 +21,7 @@ import java.time.Month; import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.Calendar; import java.util.Properties; @@ -227,4 +229,83 @@ void testLapsTime() throws Exception { } } } + + @Test(groups = {"integration"}) + void testDateInRange() throws Exception { + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement()) { + + stmt.executeUpdate("DROP TABLE IF EXISTS test_date_in_range"); + stmt.executeUpdate("CREATE TABLE test_date_in_range ( id UInt32, d Date) Engine MergeTree ORDER BY()"); + stmt.executeUpdate("INSERT INTO test_date_in_range VALUES (1, '2025-01-01') , (2, '2025-02-01') , (3, '2025-02-03')"); + + try (PreparedStatement pStmt = conn.prepareStatement("SELECT * FROM test_date_in_range WHERE d IN (?) ORDER BY id")){ + pStmt.setDate(1, Date.valueOf("2025-02-01")); + try (ResultSet rs = pStmt.executeQuery()) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 2); + Assert.assertFalse(rs.next()); + } + + pStmt.setObject(1, LocalDate.parse("2025-02-01")); + try (ResultSet rs = pStmt.executeQuery()) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 2); + Assert.assertFalse(rs.next()); + } + + Array range = conn.createArrayOf("Date", new Object[] {Date.valueOf("2025-02-01"), + Date.valueOf("2025-02-03")}); + pStmt.setArray(1, range); + try (ResultSet rs = pStmt.executeQuery()) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 2); + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 3); + Assert.assertFalse(rs.next()); + } + } + } + } + + @Test(groups = {"integration"}) + void testTimestampInRange() throws Exception { + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement()) { + + stmt.executeUpdate("DROP TABLE IF EXISTS test_timestamp_in_range"); + stmt.executeUpdate("CREATE TABLE test_timestamp_in_range (id UInt32, ts DateTime) Engine MergeTree ORDER BY()"); + stmt.executeUpdate("INSERT INTO test_timestamp_in_range VALUES " + + "(1, '2025-01-01 08:00:00'), (2, '2025-01-01 12:00:00'), (3, '2025-01-01 18:00:00'), (4, '2025-01-02 00:00:00')"); + + ZoneId utc = ZoneId.of("UTC"); + try (PreparedStatement pStmt = conn.prepareStatement("SELECT * FROM test_timestamp_in_range WHERE ts BETWEEN ? AND ? ORDER BY id")) { + ZonedDateTime start = ZonedDateTime.of(2025, 1, 1, 10, 0, 0, 0, utc); + ZonedDateTime end = ZonedDateTime.of(2025, 1, 1, 20, 0, 0, 0, utc); + pStmt.setObject(1, start); + pStmt.setObject(2, end); + try (ResultSet rs = pStmt.executeQuery()) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 2); + Assert.assertEquals(rs.getObject(2, ZonedDateTime.class), ZonedDateTime.of(2025, 1, 1, 12, 0, 0, 0, utc)); + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 3); + Assert.assertEquals(rs.getObject(2, ZonedDateTime.class), ZonedDateTime.of(2025, 1, 1, 18, 0, 0, 0, utc)); + Assert.assertFalse(rs.next()); + } + } + + try (PreparedStatement pStmt = conn.prepareStatement("SELECT * FROM test_timestamp_in_range WHERE ts BETWEEN ? AND ? ORDER BY id")) { + pStmt.setObject(1, ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, utc)); + pStmt.setObject(2, ZonedDateTime.of(2025, 1, 1, 12, 0, 0, 0, utc)); + try (ResultSet rs = pStmt.executeQuery()) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 1); + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), 2); + Assert.assertFalse(rs.next()); + } + } + } + } } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java index 50014398a..9b1df664d 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JdbcDataTypeTests.java @@ -1443,6 +1443,80 @@ public void testArrayOfMaps() throws Exception { } } + /** + * Verifies that Array(Map(LowCardinality(String), String)) with empty maps decodes correctly. + * Regression test for #2657 + */ + @Test(groups = {"integration"}) + public void testArrayOfMapsWithLowCardinalityAndEmptyMaps() throws Exception { + runQuery("CREATE TABLE test_array_map_lc_empty (" + + "StartedDateTime DateTime, " + + "traits Array(Map(LowCardinality(String), String))" + + ") ENGINE = MergeTree ORDER BY StartedDateTime"); + + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement()) { + + stmt.executeUpdate("INSERT INTO test_array_map_lc_empty (StartedDateTime, traits) VALUES (" + + "'2025-11-11 00:00:01', " + + "[" + + " map(), " + + " map(" + + " 'RandomKey1','Value1'," + + " 'RandomKey2','Value2'," + + " 'RandomKey3','Value3'," + + " 'RandomKey4','Value4'," + + " 'RandomKey5','Value5'," + + " 'RandomKey6','Value6'," + + " 'RandomKey7','Value7'," + + " 'RandomKey8','Value8'" + + " ), " + + " map(), map(), map(), map(), map(), map()" + + "]" + + ")"); + + Map expectedNonEmptyMap = new HashMap<>(); + expectedNonEmptyMap.put("RandomKey1", "Value1"); + expectedNonEmptyMap.put("RandomKey2", "Value2"); + expectedNonEmptyMap.put("RandomKey3", "Value3"); + expectedNonEmptyMap.put("RandomKey4", "Value4"); + expectedNonEmptyMap.put("RandomKey5", "Value5"); + expectedNonEmptyMap.put("RandomKey6", "Value6"); + expectedNonEmptyMap.put("RandomKey7", "Value7"); + expectedNonEmptyMap.put("RandomKey8", "Value8"); + + // Run multiple iterations because the bug is intermittent + for (int attempt = 0; attempt < 10; attempt++) { + try (ResultSet rs = stmt.executeQuery("SELECT traits FROM test_array_map_lc_empty")) { + Assert.assertTrue(rs.next(), "Expected a row on attempt " + attempt); + + Array traitsArray = rs.getArray(1); + Assert.assertEquals(traitsArray.getBaseTypeName(), "Map(LowCardinality(String), String)"); + + Object[] maps = (Object[]) traitsArray.getArray(); + Assert.assertEquals(maps.length, 8, "Expected 8 maps in array on attempt " + attempt); + + @SuppressWarnings("unchecked") + Map firstMap = (Map) maps[0]; + Assert.assertTrue(firstMap.isEmpty(), "First map should be empty on attempt " + attempt); + + @SuppressWarnings("unchecked") + Map secondMap = (Map) maps[1]; + Assert.assertEquals(secondMap, expectedNonEmptyMap, "Second map mismatch on attempt " + attempt); + + for (int i = 2; i < 8; i++) { + @SuppressWarnings("unchecked") + Map emptyMap = (Map) maps[i]; + Assert.assertTrue(emptyMap.isEmpty(), + "Map at index " + i + " should be empty on attempt " + attempt); + } + + Assert.assertFalse(rs.next()); + } + } + } + } + @Test(groups = { "integration" }) public void testNullableTypesSimpleStatement() throws SQLException { runQuery("CREATE TABLE test_nullable (order Int8, " diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/StatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/StatementTest.java index fd0bd2f4a..3aba8b652 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/StatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/StatementTest.java @@ -1285,6 +1285,43 @@ public void testResponseWithDuplicateColumns() throws Exception { } } + @Test(groups = {"integration"}) + public void testDescribeStatement() throws Exception { + try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement()) { + boolean isResultSet = stmt.execute("DESCRIBE table (SELECT 10, 'message', 30)"); + Assert.assertTrue(isResultSet); + try (ResultSet rs = stmt.getResultSet()) { + Object[][] expected = new Object[][] { + {"10", "UInt8"}, + {"'message'", "String"}, + {"30", "UInt8"}, + }; + + for (Object[] objects : expected) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getString("name"), objects[0]); + Assert.assertEquals(rs.getString("type"), objects[1]); + } + } + } + + try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement()) { + boolean isResultSet = stmt.execute("DESCRIBE TABLE (SELECT numbers.number FROM system.numbers)"); + Assert.assertTrue(isResultSet); + try (ResultSet rs = stmt.getResultSet()) { + Object[][] expected = new Object[][] { + {"number", "UInt64"}, + }; + + for (Object[] objects : expected) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getString("name"), objects[0]); + Assert.assertEquals(rs.getString("type"), objects[1]); + } + } + } + } + private static String getDBName(Statement stmt) throws SQLException { try (ResultSet rs = stmt.executeQuery("SELECT database()")) { rs.next(); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java index 7e387d76a..a24e1d197 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/metadata/ResultSetMetaDataImplTest.java @@ -236,6 +236,48 @@ public void testGetColumnScale() throws Exception { } } + @Test(groups = { "integration" }) + public void testColumnNamesStrippedFromTablePrefix() throws Exception { + final String t1 = "rsmd_test_prefix_t1"; + final String t2 = "rsmd_test_prefix_t2"; + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement()) { + stmt.executeUpdate("DROP TABLE IF EXISTS " + t1); + stmt.executeUpdate("DROP TABLE IF EXISTS " + t2); + stmt.executeUpdate("CREATE TABLE " + t1 + " (id Int32, val String) ENGINE = MergeTree ORDER BY id"); + stmt.executeUpdate("CREATE TABLE " + t2 + " (id Int32, name String) ENGINE = MergeTree ORDER BY id"); + stmt.executeUpdate("INSERT INTO " + t1 + " VALUES (1, 'test_val')"); + stmt.executeUpdate("INSERT INTO " + t2 + " VALUES (1, 'test_name')"); + + // JOIN: ClickHouse includes table name prefix in column metadata (e.g., "t1.id") + try (ResultSet rs = stmt.executeQuery( + "SELECT " + t1 + ".id, " + t2 + ".name FROM " + t1 + " JOIN " + t2 + " ON " + t1 + ".id = " + t2 + ".id")) { + ResultSetMetaData rsmd = rs.getMetaData(); + assertEquals(rsmd.getColumnCount(), 2); + assertEquals(rsmd.getColumnName(1), "id"); + assertEquals(rsmd.getColumnName(2), "name"); + assertEquals(rsmd.getColumnLabel(1), "id"); + assertEquals(rsmd.getColumnLabel(2), "name"); + assertTrue(rs.next()); + assertEquals(rs.getInt("id"), 1); + assertEquals(rs.getString("name"), "test_name"); + assertFalse(rs.next()); + } + + // Alias dot notation: SELECT t.col FROM table AS t + try (ResultSet rs = stmt.executeQuery("SELECT t.id, t.val FROM " + t1 + " AS t")) { + ResultSetMetaData rsmd = rs.getMetaData(); + assertEquals(rsmd.getColumnCount(), 2); + assertEquals(rsmd.getColumnName(1), "id"); + assertEquals(rsmd.getColumnName(2), "val"); + assertTrue(rs.next()); + assertEquals(rs.getInt("id"), 1); + assertEquals(rs.getString("val"), "test_val"); + assertFalse(rs.next()); + } + } + } + static void assertColumnNames(ResultSet rs, String... names) throws Exception { ResultSetMetaData metadata = rs.getMetaData(); assertEquals(names.length, metadata.getColumnCount());