diff --git a/.gitignore b/.gitignore index b6dd9374c..b5cc91a03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.m2 # MacOS .DS_Store diff --git a/docs/clickhouse-docs/navigation.json b/docs/clickhouse-docs/navigation.json index b8fae20c4..49579c233 100644 --- a/docs/clickhouse-docs/navigation.json +++ b/docs/clickhouse-docs/navigation.json @@ -6,7 +6,8 @@ "integrations/language-clients/java/client", "integrations/language-clients/java/jdbc", "integrations/language-clients/java/r2dbc", - "integrations/language-clients/java/date-time-guide" + "integrations/language-clients/java/date-time-guide", + "integrations/language-clients/java/spring-io-integration" ], "root": "integrations/language-clients/java/index" } diff --git a/docs/clickhouse-docs/spring-io-integration.md b/docs/clickhouse-docs/spring-io-integration.md new file mode 100644 index 000000000..2d7e99005 --- /dev/null +++ b/docs/clickhouse-docs/spring-io-integration.md @@ -0,0 +1,81 @@ +--- +sidebarTitle: 'Spring integration' +keywords: ['java', 'jdbc', 'spring', 'spring boot', 'JdbcTemplate', 'RowSet', 'ZonedDateTime'] +description: 'Integrating Spring applications with ClickHouse' +slug: /integrations/language-clients/java/spring-jdbc-template +title: 'Integrating Spring with ClickHouse' +doc_type: 'guide' +integration: + - support_level: 'core' + - category: 'language_client' +--- + +This guide covers different aspects of integrating Spring applications with ClickHouse, including configuration, +data-access APIs, type conversion, and limitations of the Spring abstractions involved. + +## `JdbcTemplate` {#jdbc-template} + +`JdbcTemplate` is Spring's abstraction for executing JDBC operations and managing their resources. This section focuses +on its read APIs: callbacks, row mappers, convenience query methods, and row sets. Choosing an API matters because each +one hides different logic for iterating, mapping, buffering, and returning data from the driver's `ResultSet`. + +### Ways to read data {#jdbc-template-reading-data} + +`JdbcTemplate` provides several fetching APIs over the same JDBC driver result: + +| API | Interaction with the driver result | Specific behavior or limitation | +| --- | --- | --- | +| `RowCallbackHandler` | Receives the live `ResultSet` at the current row | Spring advances the cursor; the callback does not return a value | +| `ResultSetExtractor` | Receives the complete live `ResultSet` | The extractor controls cursor iteration and constructs the return value | +| `RowMapper` | Receives the live `ResultSet` once per row | Spring collects every mapped object into the returned `List` | +| `queryForObject`, `queryForList`, `queryForMap` | Use Spring's built-in result mapping | The application does not choose which JDBC getters perform the conversion | +| `queryForRowSet` (`SqlRowSet`) | Copies values from the driver result into a `CachedRowSet` | Reads are disconnected from the driver and use the cached Java objects | + +Prefer these `query` overloads because they operate on the driver's live `ResultSet` without first copying it into a row +set: + +- `RowCallbackHandler` for efficient, independent row-by-row processing. +- `ResultSetExtractor` for full control over iteration and result construction. +- `RowMapper` when returning a list is intentional. Direct access to the JDBC result makes type conversion more + predictable and portable than relying on a particular `CachedRowSet` implementation. + + +**Timestamp handling with `queryForRowSet()`** + +`queryForRowSet()` is different from the other `queryFor*` methods: it reads the entire driver `ResultSet` into a +disconnected `CachedRowSet` before returning. The `SqlRowSet` returned by Spring does not support +`getObject(column, Class)`, so a call such as `getObject("event_time", ZonedDateTime.class)` fails with +`Not supported yet`. + +For `DateTime` and `DateTime64`, configure +`jdbc_type_mappings=DateTime64=java.time.ZonedDateTime,DateTime=java.time.ZonedDateTime` in the connection pool's +`data-source-properties`. The cached value can then be read with the untyped +`(ZonedDateTime) rowSet.getObject("event_time")` call. This mapping applies to every `getObject(column)` call on the +connection, not only to row sets. See the +[Date/Time values guide](/integrations/language-clients/java/jdbc_date_time_guide) for details. + + +The following example uses the recommended callback API. `JdbcTemplate` advances the cursor and invokes the callback +once for each row; the callback should not call `next()`: + +```java +public void processSignals(JdbcTemplate jdbcTemplate, long locationId) { + String sql = "SELECT device_id, value, event_time " + + "FROM iot_signals " + + "WHERE location_id = ? " + + "ORDER BY event_time"; + + jdbcTemplate.query( + sql, + (ResultSet rs) -> { + String deviceId = rs.getString("device_id"); + double value = rs.getDouble("value"); + ZonedDateTime eventTime = rs.getObject("event_time", ZonedDateTime.class); + // process the row + }, + locationId); +} +``` + +For the complete API and callback contracts, see the +[Spring JDBC documentation](https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html). diff --git a/examples/demo-spring-service/src/test/java/com/clickhouse/examples/SignalIngestionTests.java b/examples/demo-spring-service/src/test/java/com/clickhouse/examples/SignalIngestionTests.java index fc1b15600..617408744 100755 --- a/examples/demo-spring-service/src/test/java/com/clickhouse/examples/SignalIngestionTests.java +++ b/examples/demo-spring-service/src/test/java/com/clickhouse/examples/SignalIngestionTests.java @@ -20,12 +20,15 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.testcontainers.junit.jupiter.Testcontainers; +import java.sql.ResultSet; import java.time.Duration; import java.time.Instant; +import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @@ -152,6 +155,42 @@ SELECT max(value) assertThat(durationSamples).isNotNull().isGreaterThanOrEqualTo(1.0); } + @Test + void selectDataViaRowSet() { + int eventCount = 100; + UUID locationId = UUID.randomUUID(); + Instant start = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS); + + for (int i = 0; i < eventCount; i++) { + boolean temperature = i % 2 == 0; + postSignal( + locationId, + "rowset-device-" + (i % 5), + temperature ? "TEMPERATURE" : "HUMIDITY", + i, + temperature ? "C" : "%", + start.plusMillis(i * 10L)); + } + + AtomicInteger read = new AtomicInteger(); + jdbc.query(""" + SELECT device_id, signal_type, value, unit, event_time + FROM iot_signals + WHERE location_id = ? + ORDER BY event_time + """, new Object[] {locationId}, (ResultSet rs) -> { + int i = read.getAndIncrement(); + boolean temperature = i % 2 == 0; + assertThat(rs.getString("device_id")).isEqualTo("rowset-device-" + (i % 5)); + assertThat(rs.getString("signal_type")).isEqualTo(temperature ? "TEMPERATURE" : "HUMIDITY"); + assertThat(rs.getDouble("value")).isEqualTo(i); + assertThat(rs.getString("unit")).isEqualTo(temperature ? "C" : "%"); + assertThat(rs.getObject("event_time", ZonedDateTime.class).toInstant()) + .isEqualTo(start.plusMillis(i * 10L)); + }); + assertThat(read.get()).isEqualTo(eventCount); + } + @Test void acceptsReconciliationBatchAndStoresItAsynchronously() throws InterruptedException { UUID firstId = UUID.randomUUID();