From c0f72df3ccf7c103731adeb80deb61a07855fddf Mon Sep 17 00:00:00 2001 From: Mirro Mutth Date: Tue, 13 Feb 2024 18:33:25 +0900 Subject: [PATCH] Align behaviors about time zone - Add `preserveInstants`, `connectionTimeZone` and `forceConnectionTimeZoneToSession` - Default `connectionTimeZone` to "LOCAL" - Mark `serverZoneId` as deprecated. It will notice users to use `connectionTimeZone` instead - Add `TimeZoneIntegrationTest` to test JDBC alignment of time zone behavior - Correct `OffsetTimeCodec` to not convert time zone --- r2dbc-mysql/pom.xml | 2 +- .../r2dbc/mysql/ConnectionContext.java | 44 ++- .../asyncer/r2dbc/mysql/MySqlConnection.java | 115 ++---- .../mysql/MySqlConnectionConfiguration.java | 118 +++++- .../r2dbc/mysql/MySqlConnectionFactory.java | 36 +- .../mysql/MySqlConnectionFactoryProvider.java | 43 ++- .../r2dbc/mysql/codec/CodecContext.java | 18 +- .../r2dbc/mysql/codec/InstantCodec.java | 10 +- .../mysql/codec/OffsetDateTimeCodec.java | 6 +- .../r2dbc/mysql/codec/OffsetTimeCodec.java | 14 +- .../r2dbc/mysql/codec/ZonedDateTimeCodec.java | 14 +- .../mysql/internal/util/StringUtils.java | 44 +++ .../r2dbc/mysql/ConnectionContextTest.java | 38 +- ...va => DateTimeIntegrationTestSupport.java} | 29 +- .../mysql/MariaDbIntegrationTestSupport.java | 128 +++++-- .../MySqlConnectionConfigurationTest.java | 4 +- .../MySqlConnectionFactoryProviderTest.java | 8 +- .../r2dbc/mysql/MySqlTestKitSupport.java | 11 +- ...va => PrepareDateTimeIntegrationTest.java} | 4 +- .../mysql/SessionStateIntegrationTest.java | 63 ++- ....java => TextDateTimeIntegrationTest.java} | 4 +- .../r2dbc/mysql/TimeZoneIntegrationTest.java | 362 ++++++++++++++++++ .../mysql/codec/DateTimeCodecTestSupport.java | 12 +- .../mysql/codec/OffsetDateTimeCodecTest.java | 6 +- .../mysql/codec/OffsetTimeCodecTest.java | 7 +- .../mysql/codec/ZonedDateTimeCodecTest.java | 6 +- test-native-image/pom.xml | 2 +- 27 files changed, 919 insertions(+), 229 deletions(-) rename r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/{TimeZoneIntegrationTestSupport.java => DateTimeIntegrationTestSupport.java} (92%) rename r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/{PrepareTimeZoneIntegrationTest.java => PrepareDateTimeIntegrationTest.java} (88%) rename r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/{TextTimeZoneIntegrationTest.java => TextDateTimeIntegrationTest.java} (88%) create mode 100644 r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java diff --git a/r2dbc-mysql/pom.xml b/r2dbc-mysql/pom.xml index abce991e7..da87d0c2c 100644 --- a/r2dbc-mysql/pom.xml +++ b/r2dbc-mysql/pom.xml @@ -151,4 +151,4 @@ - \ No newline at end of file + diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionContext.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionContext.java index 0445ff914..47ac71076 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionContext.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionContext.java @@ -48,8 +48,10 @@ public final class ConnectionContext implements CodecContext { private final int localInfileBufferSize; + private final boolean preserveInstants; + @Nullable - private ZoneId serverZoneId; + private ZoneId timeZone; /** * Assume that the auto commit is always turned on, it will be set after handshake V10 request message, or @@ -60,12 +62,18 @@ public final class ConnectionContext implements CodecContext { @Nullable private volatile Capability capability = null; - ConnectionContext(ZeroDateOption zeroDateOption, @Nullable Path localInfilePath, - int localInfileBufferSize, @Nullable ZoneId serverZoneId) { + ConnectionContext( + ZeroDateOption zeroDateOption, + @Nullable Path localInfilePath, + int localInfileBufferSize, + boolean preserveInstants, + @Nullable ZoneId timeZone + ) { this.zeroDateOption = requireNonNull(zeroDateOption, "zeroDateOption must not be null"); this.localInfilePath = localInfilePath; this.localInfileBufferSize = localInfileBufferSize; - this.serverZoneId = serverZoneId; + this.preserveInstants = preserveInstants; + this.timeZone = timeZone; } /** @@ -101,27 +109,33 @@ public CharCollation getClientCollation() { } @Override - public ZoneId getServerZoneId() { - if (serverZoneId == null) { + public boolean isPreserveInstants() { + return preserveInstants; + } + + @Override + public ZoneId getTimeZone() { + if (timeZone == null) { throw new IllegalStateException("Server timezone have not initialization"); } - return serverZoneId; + return timeZone; } - @Override - public boolean isMariaDb() { - return capability.isMariaDb() || serverVersion.isMariaDb(); + public boolean isTimeZoneInitialized() { + return timeZone != null; } - boolean shouldSetServerZoneId() { - return serverZoneId == null; + @Override + public boolean isMariaDb() { + Capability capability = this.capability; + return (capability != null && capability.isMariaDb()) || serverVersion.isMariaDb(); } - void setServerZoneId(ZoneId serverZoneId) { - if (this.serverZoneId != null) { + void setTimeZone(ZoneId timeZone) { + if (isTimeZoneInitialized()) { throw new IllegalStateException("Server timezone have been initialized"); } - this.serverZoneId = serverZoneId; + this.timeZone = timeZone; } @Override diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnection.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnection.java index 199f92cf1..2bf3a968c 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnection.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnection.java @@ -34,6 +34,7 @@ import io.r2dbc.spi.IsolationLevel; import io.r2dbc.spi.Lifecycle; import io.r2dbc.spi.R2dbcNonTransientResourceException; +import io.r2dbc.spi.Readable; import io.r2dbc.spi.TransactionDefinition; import io.r2dbc.spi.ValidationDepth; import org.jetbrains.annotations.Nullable; @@ -64,12 +65,6 @@ public final class MySqlConnection implements Connection, Lifecycle, ConnectionS private static final String PING_MARKER = "/* ping */"; - private static final String ZONE_PREFIX_POSIX = "posix/"; - - private static final String ZONE_PREFIX_RIGHT = "right/"; - - private static final int PREFIX_LENGTH = 6; - private static final ServerVersion MARIA_11_1_1 = ServerVersion.create(11, 1, 1, true); private static final ServerVersion MYSQL_8_0_3 = ServerVersion.create(8, 0, 3); @@ -333,7 +328,8 @@ public Mono setTransactionIsolationLevel(IsolationLevel isolationLevel) { requireNonNull(isolationLevel, "isolationLevel must not be null"); // Set subsequent transaction isolation level. - return QueryFlow.executeVoid(client, "SET SESSION TRANSACTION ISOLATION LEVEL " + isolationLevel.asSql()) + return QueryFlow.executeVoid(client, + "SET SESSION TRANSACTION ISOLATION LEVEL " + isolationLevel.asSql()) .doOnSuccess(ignored -> { this.sessionLevel = isolationLevel; if (!this.isInTransaction()) { @@ -436,7 +432,7 @@ public Mono setStatementTimeout(Duration timeout) { final ServerVersion serverVersion = context.getServerVersion(); final long timeoutMs = timeout.toMillis(); final String sql = isMariaDb ? "SET max_statement_time=" + timeoutMs / 1000.0 - : "SET SESSION MAX_EXECUTION_TIME=" + timeoutMs; + : "SET SESSION MAX_EXECUTION_TIME=" + timeoutMs; // mariadb: https://mariadb.com/kb/en/aborting-statements/ // mysql: https://dev.mysql.com/blog-archive/server-side-select-statement-timeouts/ @@ -485,10 +481,10 @@ static Mono init( Mono connection = initSessionVariables(client, sessionVariables) .then(loadSessionVariables(client, codecs, context)) .map(data -> { - ZoneId serverZoneId = data.serverZoneId; - if (serverZoneId != null) { - logger.debug("Set server time zone to {} from init query", serverZoneId); - context.setServerZoneId(serverZoneId); + ZoneId timeZone = data.timeZone; + if (timeZone != null) { + logger.debug("Got server time zone {} from loading session variables", timeZone); + context.setTimeZone(timeZone); } return new MySqlConnection(client, context, codecs, data.level, data.lockWaitTimeout, @@ -531,7 +527,7 @@ private static Mono initSessionVariables(Client client, List sessi return QueryFlow.executeVoid(client, query.toString()); } - private static Mono loadSessionVariables( + private static Mono loadSessionVariables( Client client, Codecs codecs, ConnectionContext context ) { StringBuilder query = new StringBuilder(160) @@ -539,13 +535,13 @@ private static Mono loadSessionVariables( .append(transactionIsolationColumn(context)) .append(",@@innodb_lock_wait_timeout AS l,@@version_comment AS v"); - Function> handler; + Function> handler; - if (context.shouldSetServerZoneId()) { - query.append(",@@system_time_zone AS s,@@time_zone AS t"); - handler = MySqlConnection::fullInit; + if (context.isTimeZoneInitialized()) { + handler = r -> convertSessionData(r, false); } else { - handler = MySqlConnection::init; + query.append(",@@system_time_zone AS s,@@time_zone AS t"); + handler = r -> convertSessionData(r, true); } return new TextSimpleStatement(client, codecs, context, query.toString()) @@ -569,70 +565,39 @@ private static Mono initDatabase(Client client, String database) { }); } - private static Flux init(MySqlResult r) { - return r.map((row, meta) -> new InitData(convertIsolationLevel(row.get(0, String.class)), - convertLockWaitTimeout(row.get(1, Long.class)), - row.get(2, String.class), null)); - } - - private static Flux fullInit(MySqlResult r) { - return r.map((row, meta) -> { - IsolationLevel level = convertIsolationLevel(row.get(0, String.class)); - long lockWaitTimeout = convertLockWaitTimeout(row.get(1, Long.class)); - String product = row.get(2, String.class); - String systemTimeZone = row.get(3, String.class); - String timeZone = row.get(4, String.class); - ZoneId zoneId; - - if (timeZone == null || timeZone.isEmpty() || "SYSTEM".equalsIgnoreCase(timeZone)) { - if (systemTimeZone == null || systemTimeZone.isEmpty()) { - logger.warn("MySQL does not return any timezone, trying to use system default timezone"); - zoneId = ZoneId.systemDefault(); - } else { - zoneId = convertZoneId(systemTimeZone); - } - } else { - zoneId = convertZoneId(timeZone); - } + private static Flux convertSessionData(MySqlResult r, boolean timeZone) { + return r.map(readable -> { + IsolationLevel level = convertIsolationLevel(readable.get(0, String.class)); + long lockWaitTimeout = convertLockWaitTimeout(readable.get(1, Long.class)); + String product = readable.get(2, String.class); - return new InitData(level, lockWaitTimeout, product, zoneId); + return new SessionData(level, lockWaitTimeout, product, timeZone ? readZoneId(readable) : null); }); } - /** - * Creates a {@link ZoneId} from MySQL timezone result, or fallback to system default timezone if not - * found. - * - * @param id the ID/name of MySQL timezone. - * @return the {@link ZoneId}. - */ - private static ZoneId convertZoneId(String id) { - String realId; + private static ZoneId readZoneId(Readable readable) { + String systemTimeZone = readable.get(3, String.class); + String timeZone = readable.get(4, String.class); - if (id.startsWith(ZONE_PREFIX_POSIX) || id.startsWith(ZONE_PREFIX_RIGHT)) { - realId = id.substring(PREFIX_LENGTH); + if (timeZone == null || timeZone.isEmpty() || "SYSTEM".equalsIgnoreCase(timeZone)) { + if (systemTimeZone == null || systemTimeZone.isEmpty()) { + logger.warn("MySQL does not return any timezone, trying to use system default timezone"); + return ZoneId.systemDefault().normalized(); + } else { + return convertZoneId(systemTimeZone); + } } else { - realId = id; + return convertZoneId(timeZone); } + } + private static ZoneId convertZoneId(String id) { try { - switch (realId) { - case "Factory": - // It seems like UTC. - return ZoneOffset.UTC; - case "America/Nuuk": - // America/Godthab is the same as America/Nuuk, with DST. - return ZoneId.of("America/Godthab"); - case "ROC": - // It is equal to +08:00. - return ZoneId.of("+8"); - } - - return ZoneId.of(realId, ZoneId.SHORT_IDS); + return StringUtils.parseZoneId(id); } catch (DateTimeException e) { logger.warn("The server timezone is unknown <{}>, trying to use system default timezone", id, e); - return ZoneId.systemDefault(); + return ZoneId.systemDefault().normalized(); } } @@ -691,7 +656,7 @@ private static String transactionIsolationColumn(ConnectionContext context) { "@@transaction_isolation AS i" : "@@tx_isolation AS i"; } - private static class InitData { + private static class SessionData { private final IsolationLevel level; @@ -701,14 +666,14 @@ private static class InitData { private final String product; @Nullable - private final ZoneId serverZoneId; + private final ZoneId timeZone; - private InitData(IsolationLevel level, long lockWaitTimeout, @Nullable String product, - @Nullable ZoneId serverZoneId) { + private SessionData(IsolationLevel level, long lockWaitTimeout, @Nullable String product, + @Nullable ZoneId timeZone) { this.level = level; this.lockWaitTimeout = lockWaitTimeout; this.product = product; - this.serverZoneId = serverZoneId; + this.timeZone = timeZone; } } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java index 60ea6ae3b..5953495ce 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java @@ -44,6 +44,7 @@ import java.util.function.Predicate; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.require; +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonEmpty; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; import static io.asyncer.r2dbc.mysql.internal.util.InternalArrays.EMPTY_STRINGS; @@ -78,8 +79,11 @@ public final class MySqlConnectionConfiguration { @Nullable private final Duration connectTimeout; - @Nullable - private final ZoneId serverZoneId; + private final boolean preserveInstants; + + private final String connectionTimeZone; + + private final boolean forceConnectionTimeZoneToSession; private final ZeroDateOption zeroDateOption; @@ -120,7 +124,10 @@ public final class MySqlConnectionConfiguration { private MySqlConnectionConfiguration( boolean isHost, String domain, int port, MySqlSslConfiguration ssl, boolean tcpKeepAlive, boolean tcpNoDelay, @Nullable Duration connectTimeout, - ZeroDateOption zeroDateOption, @Nullable ZoneId serverZoneId, + ZeroDateOption zeroDateOption, + boolean preserveInstants, + String connectionTimeZone, + boolean forceConnectionTimeZoneToSession, String user, @Nullable CharSequence password, @Nullable String database, boolean createDatabaseIfNotExist, @Nullable Predicate preferPrepareStatement, List sessionVariables, @@ -137,7 +144,9 @@ private MySqlConnectionConfiguration( this.tcpNoDelay = tcpNoDelay; this.connectTimeout = connectTimeout; this.ssl = ssl; - this.serverZoneId = serverZoneId; + this.preserveInstants = preserveInstants; + this.connectionTimeZone = requireNonNull(connectionTimeZone, "connectionTimeZone must not be null"); + this.forceConnectionTimeZoneToSession = forceConnectionTimeZoneToSession; this.zeroDateOption = requireNonNull(zeroDateOption, "zeroDateOption must not be null"); this.user = requireNonNull(user, "user must not be null"); this.password = password; @@ -198,9 +207,16 @@ ZeroDateOption getZeroDateOption() { return zeroDateOption; } - @Nullable - ZoneId getServerZoneId() { - return serverZoneId; + boolean isPreserveInstants() { + return preserveInstants; + } + + String getConnectionTimeZone() { + return connectionTimeZone; + } + + boolean isForceConnectionTimeZoneToSession() { + return forceConnectionTimeZoneToSession; } String getUser() { @@ -283,7 +299,9 @@ public boolean equals(Object o) { tcpKeepAlive == that.tcpKeepAlive && tcpNoDelay == that.tcpNoDelay && Objects.equals(connectTimeout, that.connectTimeout) && - Objects.equals(serverZoneId, that.serverZoneId) && + preserveInstants == that.preserveInstants && + Objects.equals(connectionTimeZone, that.connectionTimeZone) && + forceConnectionTimeZoneToSession == that.forceConnectionTimeZoneToSession && zeroDateOption == that.zeroDateOption && user.equals(that.user) && Objects.equals(password, that.password) && @@ -305,7 +323,8 @@ public boolean equals(Object o) { @Override public int hashCode() { return Objects.hash(isHost, domain, port, ssl, tcpKeepAlive, tcpNoDelay, connectTimeout, - serverZoneId, zeroDateOption, user, password, database, createDatabaseIfNotExist, + preserveInstants, connectionTimeZone, forceConnectionTimeZoneToSession, + zeroDateOption, user, password, database, createDatabaseIfNotExist, preferPrepareStatement, sessionVariables, loadLocalInfilePath, localInfileBufferSize, queryCacheSize, prepareCacheSize, compressionAlgorithms, zstdCompressionLevel, loopResources, extensions, passwordPublisher); @@ -316,7 +335,10 @@ public String toString() { if (isHost) { return "MySqlConnectionConfiguration{host='" + domain + "', port=" + port + ", ssl=" + ssl + ", tcpNoDelay=" + tcpNoDelay + ", tcpKeepAlive=" + tcpKeepAlive + - ", connectTimeout=" + connectTimeout + ", serverZoneId=" + serverZoneId + + ", connectTimeout=" + connectTimeout + + ", preserveInstants=" + preserveInstants + + ", connectionTimeZone=" + connectionTimeZone + + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + ", preferPrepareStatement=" + preferPrepareStatement + @@ -331,7 +353,10 @@ public String toString() { } return "MySqlConnectionConfiguration{unixSocket='" + domain + - "', connectTimeout=" + connectTimeout + ", serverZoneId=" + serverZoneId + + "', connectTimeout=" + connectTimeout + + ", preserveInstants=" + preserveInstants + + ", connectionTimeZone=" + connectionTimeZone + + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + ", preferPrepareStatement=" + preferPrepareStatement + @@ -372,8 +397,11 @@ public static final class Builder { private ZeroDateOption zeroDateOption = ZeroDateOption.USE_NULL; - @Nullable - private ZoneId serverZoneId; + private boolean preserveInstants = true; + + private String connectionTimeZone = "LOCAL"; + + private boolean forceConnectionTimeZoneToSession; @Nullable private SslMode sslMode; @@ -453,7 +481,11 @@ public MySqlConnectionConfiguration build() { MySqlSslConfiguration ssl = MySqlSslConfiguration.create(sslMode, tlsVersion, sslHostnameVerifier, sslCa, sslKey, sslKeyPassword, sslCert, sslContextBuilderCustomizer); return new MySqlConnectionConfiguration(isHost, domain, port, ssl, tcpKeepAlive, tcpNoDelay, - connectTimeout, zeroDateOption, serverZoneId, user, password, database, + connectTimeout, zeroDateOption, + preserveInstants, + connectionTimeZone, + forceConnectionTimeZoneToSession, + user, password, database, createDatabaseIfNotExist, preferPrepareStatement, sessionVariables, loadLocalInfilePath, localInfileBufferSize, queryCacheSize, prepareCacheSize, compressionAlgorithms, zstdCompressionLevel, loopResources, @@ -580,15 +612,63 @@ public Builder username(String user) { } /** - * Configures the time zone of server. Default to query server time zone in initialization. + * Configures the time zone conversion. Default to {@code true} means enable conversion between JVM + * and {@link #connectionTimeZone(String)}. + *

+ * Note: disable it will ignore the time zone of connection, and use the JVM local time zone. * - * @param serverZoneId the {@link ZoneId}, or {@code null} if query in initialization. - * @return this {@link Builder}. + * @param enabled {@code true} to preserve instants, or {@code false} to disable conversion. + * @return {@link Builder this} + * @since 1.1.2 + */ + public Builder preserveInstants(boolean enabled) { + this.preserveInstants = enabled; + return this; + } + + /** + * Configures the time zone of connection. Default to {@code LOCAL} means use JVM local time zone. + * {@code "SERVER"} means querying the server-side timezone during initialization. + * + * @param connectionTimeZone {@code "LOCAL"}, {@code "SERVER"}, or a valid ID of {@code ZoneId}. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code connectionTimeZone} is {@code null} or empty. + * @since 1.1.2 + */ + public Builder connectionTimeZone(String connectionTimeZone) { + requireNonEmpty(connectionTimeZone, "connectionTimeZone must not be empty"); + + this.connectionTimeZone = connectionTimeZone; + return this; + } + + /** + * Configures to force the connection time zone to session time zone. Default to {@code false}. Used + * only if the {@link #connectionTimeZone(String)} is not {@code "SERVER"}. + *

+ * Note: alter the time zone of session will affect the results of MySQL date/time functions, e.g. + * {@code NOW([n])}, {@code CURRENT_TIME([n])}, {@code CURRENT_DATE()}, etc. Please use with caution. + * + * @param enabled {@code true} to force the connection time zone to session time zone. + * @return {@link Builder this} + * @since 1.1.2 + */ + public Builder forceConnectionTimeZoneToSession(boolean enabled) { + this.forceConnectionTimeZoneToSession = enabled; + return this; + } + + /** + * Configures the time zone of server. Since 1.1.2, default to use JVM local time zone. + * + * @param serverZoneId the {@link ZoneId}, or {@code null} if query server during initialization. + * @return {@link Builder this} * @since 0.8.2 + * @deprecated since 1.1.2, use {@link #connectionTimeZone(String)} instead. */ + @Deprecated public Builder serverZoneId(@Nullable ZoneId serverZoneId) { - this.serverZoneId = serverZoneId; - return this; + return connectionTimeZone(serverZoneId == null ? "SERVER" : serverZoneId.getId()); } /** diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java index d29bdb968..ec2d57339 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java @@ -25,6 +25,7 @@ import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.extension.CodecRegistrar; +import io.asyncer.r2dbc.mysql.internal.util.StringUtils; import io.netty.buffer.ByteBufAllocator; import io.netty.channel.unix.DomainSocketAddress; import io.r2dbc.spi.ConnectionFactory; @@ -35,6 +36,9 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Set; @@ -94,18 +98,23 @@ public static MySqlConnectionFactory from(MySqlConnectionConfiguration configura CharSequence password = configuration.getPassword(); SslMode sslMode = ssl.getSslMode(); int zstdCompressionLevel = configuration.getZstdCompressionLevel(); + ZoneId connectionTimeZone = retrieveZoneId(configuration.getConnectionTimeZone()); ConnectionContext context = new ConnectionContext( configuration.getZeroDateOption(), configuration.getLoadLocalInfilePath(), configuration.getLocalInfileBufferSize(), - configuration.getServerZoneId() + configuration.isPreserveInstants(), + connectionTimeZone ); Set compressionAlgorithms = configuration.getCompressionAlgorithms(); - List sessionVariables = configuration.getSessionVariables(); Extensions extensions = configuration.getExtensions(); Predicate prepare = configuration.getPreferPrepareStatement(); int prepareCacheSize = configuration.getPrepareCacheSize(); Publisher passwordPublisher = configuration.getPasswordPublisher(); + boolean forceTimeZone = configuration.isForceConnectionTimeZoneToSession(); + List sessionVariables = forceTimeZone && connectionTimeZone != null ? + mergeSessionVariables(configuration.getSessionVariables(), connectionTimeZone) : + configuration.getSessionVariables(); if (Objects.nonNull(passwordPublisher)) { return Mono.from(passwordPublisher).flatMap(token -> getMySqlConnection( @@ -170,6 +179,29 @@ private static Mono getMySqlConnection( }); } + @Nullable + private static ZoneId retrieveZoneId(String timeZone) { + if ("LOCAL".equalsIgnoreCase(timeZone)) { + return ZoneId.systemDefault().normalized(); + } else if ("SERVER".equalsIgnoreCase(timeZone)) { + return null; + } + + return StringUtils.parseZoneId(timeZone); + } + + private static List mergeSessionVariables(List sessionVariables, ZoneId timeZone) { + List res = new ArrayList<>(sessionVariables.size() + 1); + + String offerStr = timeZone instanceof ZoneOffset && "Z".equalsIgnoreCase(timeZone.getId()) ? + "+00:00" : timeZone.getId(); + + res.addAll(sessionVariables); + res.add("time_zone='" + offerStr + "'"); + + return res; + } + private static final class LazyQueryCache { private final int capacity; diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java index 27b0c6842..652bfd5fe 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java @@ -64,12 +64,44 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr */ public static final Option UNIX_SOCKET = Option.valueOf("unixSocket"); + /** + * Option to set the time zone conversion. Default to {@code true} means enable conversion between JVM + * and {@link #CONNECTION_TIME_ZONE}. + *

+ * Note: disable it will ignore the time zone of connection, and use the JVM local time zone. + * + * @since 1.1.2 + */ + public static final Option PRESERVE_INSTANTS = Option.valueOf("preserveInstants"); + + /** + * Option to set the time zone of connection. Default to {@code LOCAL} means use JVM local time zone. + * It should be {@code "LOCAL"}, {@code "SERVER"}, or a valid ID of {@code ZoneId}. {@code "SERVER"} means + * querying the server-side timezone during initialization. + * + * @since 1.1.2 + */ + public static final Option CONNECTION_TIME_ZONE = Option.valueOf("connectionTimeZone"); + + /** + * Option to force the time zone of connection to session time zone. Default to {@code false}. + *

+ * Note: alter the time zone of session will affect the results of MySQL date/time functions, e.g. + * {@code NOW([n])}, {@code CURRENT_TIME([n])}, {@code CURRENT_DATE()}, etc. Please use with caution. + * + * @since 1.1.2 + */ + public static final Option FORCE_CONNECTION_TIME_ZONE_TO_SESSION = + Option.valueOf("forceConnectionTimeZoneToSession"); + /** * Option to set {@link ZoneId} of server. If it is set, driver will ignore the real time zone of * server-side. * * @since 0.8.2 + * @deprecated since 1.1.2, use {@link #CONNECTION_TIME_ZONE} instead. */ + @Deprecated public static final Option SERVER_ZONE_ID = Option.valueOf("serverZoneId"); /** @@ -309,8 +341,15 @@ static MySqlConnectionConfiguration setup(ConnectionFactoryOptions options) { mapper.optional(UNIX_SOCKET).asString() .to(builder::unixSocket) .otherwise(() -> setupHost(builder, mapper)); - mapper.optional(SERVER_ZONE_ID).as(ZoneId.class, id -> ZoneId.of(id, ZoneId.SHORT_IDS)) - .to(builder::serverZoneId); + mapper.optional(PRESERVE_INSTANTS).asBoolean() + .to(builder::preserveInstants); + mapper.optional(CONNECTION_TIME_ZONE).asString() + .to(builder::connectionTimeZone) + .otherwise(() -> mapper.optional(SERVER_ZONE_ID) + .as(ZoneId.class, id -> ZoneId.of(id, ZoneId.SHORT_IDS)) + .to(builder::serverZoneId)); + mapper.optional(FORCE_CONNECTION_TIME_ZONE_TO_SESSION).asBoolean() + .to(builder::forceConnectionTimeZoneToSession); mapper.optional(TCP_KEEP_ALIVE).asBoolean() .to(builder::tcpKeepAlive); mapper.optional(TCP_NO_DELAY).asBoolean() diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/CodecContext.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/CodecContext.java index c674f3b16..8eda9c985 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/CodecContext.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/CodecContext.java @@ -28,28 +28,36 @@ public interface CodecContext { /** - * Get the {@link ZoneId} of server-side. + * Checks if the connection is set to preserve instants, i.e. convert instant values to connection time + * zone. + * + * @return if preserve instants. + */ + boolean isPreserveInstants(); + + /** + * Gets the {@link ZoneId} of connection. * * @return the {@link ZoneId}. */ - ZoneId getServerZoneId(); + ZoneId getTimeZone(); /** - * Get the option for zero date handling which is set by connection configuration. + * Gets the option for zero date handling which is set by connection configuration. * * @return the {@link ZeroDateOption}. */ ZeroDateOption getZeroDateOption(); /** - * Get the MySQL server version, which is available after database user logon. + * Gets the MySQL server version, which is available after database user logon. * * @return the {@link ServerVersion}. */ ServerVersion getServerVersion(); /** - * Get the {@link CharCollation} that the client is using. + * Gets the {@link CharCollation} that the client is using. * * @return the {@link CharCollation}. */ diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/InstantCodec.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/InstantCodec.java index 23147bbbb..17d0793ed 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/InstantCodec.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/InstantCodec.java @@ -26,6 +26,8 @@ import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; /** * Codec for {@link Instant}. @@ -46,7 +48,10 @@ public Instant decode(ByteBuf value, MySqlColumnMetadata metadata, Class targ return null; } - return origin.toInstant(context.getServerZoneId().getRules().getOffset(origin)); + ZoneId zone = context.isPreserveInstants() ? context.getTimeZone() : ZoneOffset.systemDefault(); + + return origin.toInstant(zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() + .getOffset(origin)); } @Override @@ -108,7 +113,8 @@ public int hashCode() { } private LocalDateTime serverValue() { - return LocalDateTime.ofInstant(value, context.getServerZoneId()); + return LocalDateTime.ofInstant(value, context.isPreserveInstants() ? context.getTimeZone() : + ZoneId.systemDefault()); } @Override diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodec.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodec.java index d1681ae31..694578b13 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodec.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodec.java @@ -48,7 +48,7 @@ public OffsetDateTime decode(ByteBuf value, MySqlColumnMetadata metadata, Class< return null; } - ZoneId zone = context.getServerZoneId(); + ZoneId zone = context.isPreserveInstants() ? context.getTimeZone() : ZoneId.systemDefault(); return OffsetDateTime.of(origin, zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() .getOffset(origin)); @@ -113,7 +113,9 @@ public int hashCode() { } private LocalDateTime serverValue() { - ZoneId zone = context.getServerZoneId(); + ZoneId zone = context.isPreserveInstants() ? context.getTimeZone() : + ZoneId.systemDefault().normalized(); + return zone instanceof ZoneOffset ? value.withOffsetSameInstant((ZoneOffset) zone).toLocalDateTime() : value.toZonedDateTime().withZoneSameInstant(zone).toLocalDateTime(); diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodec.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodec.java index db1009c37..1ed3769a6 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodec.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodec.java @@ -44,8 +44,9 @@ private OffsetTimeCodec() { @Override public OffsetTime decode(ByteBuf value, MySqlColumnMetadata metadata, Class target, boolean binary, CodecContext context) { + // OffsetTime is not an instant value, so preserveInstants is not used here. LocalTime origin = LocalTimeCodec.decodeOrigin(binary, value); - ZoneId zone = context.getServerZoneId(); + ZoneId zone = ZoneId.systemDefault().normalized(); return OffsetTime.of(origin, zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() .getStandardOffset(Instant.EPOCH)); @@ -112,9 +113,14 @@ public int hashCode() { } private LocalTime serverValue() { - ZoneId zone = context.getServerZoneId(); - ZoneOffset offset = zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() - .getStandardOffset(Instant.EPOCH); + // OffsetTime is not an instant value, so preserveInstants is not used here. + ZoneId zone = ZoneId.systemDefault().normalized(); + + if (zone instanceof ZoneOffset) { + return value.withOffsetSameInstant((ZoneOffset) zone).toLocalTime(); + } + + ZoneOffset offset = zone.getRules().getStandardOffset(Instant.EPOCH); return value.toLocalTime() .plusSeconds(offset.getTotalSeconds() - value.getOffset().getTotalSeconds()); diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodec.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodec.java index dcaee0cc7..3bd0072b6 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodec.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodec.java @@ -28,6 +28,7 @@ import java.lang.reflect.ParameterizedType; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.chrono.ChronoZonedDateTime; @@ -78,7 +79,13 @@ public boolean canDecode(MySqlColumnMetadata metadata, Class target) { @Nullable private static ZonedDateTime decode0(ByteBuf value, boolean binary, CodecContext context) { LocalDateTime origin = LocalDateTimeCodec.decodeOrigin(value, binary, context); - return origin == null ? null : ZonedDateTime.of(origin, context.getServerZoneId()); + + if (origin == null) { + return null; + } + + return ZonedDateTime.of(origin, context.isPreserveInstants() ? context.getTimeZone() : + ZoneId.systemDefault()); } private static final class ZonedDateTimeMySqlParameter extends AbstractMySqlParameter { @@ -127,7 +134,10 @@ public int hashCode() { } private LocalDateTime serverValue() { - return value.withZoneSameInstant(context.getServerZoneId()) + ZoneId zoneId = context.isPreserveInstants() ? context.getTimeZone() : + ZoneId.systemDefault().normalized(); + + return value.withZoneSameInstant(zoneId) .toLocalDateTime(); } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/StringUtils.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/StringUtils.java index 34f6bacd8..e5c3596b6 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/StringUtils.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/StringUtils.java @@ -16,6 +16,9 @@ package io.asyncer.r2dbc.mysql.internal.util; +import java.time.ZoneId; +import java.time.ZoneOffset; + import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonEmpty; /** @@ -25,6 +28,12 @@ public final class StringUtils { private static final char QUOTE = '`'; + private static final String ZONE_PREFIX_POSIX = "posix/"; + + private static final String ZONE_PREFIX_RIGHT = "right/"; + + private static final int ZONE_PREFIX_LENGTH = 6; + /** * Quotes identifier with backticks, it will escape backticks in the identifier. * @@ -70,6 +79,41 @@ public static String extendReturning(String sql, String returning) { return returning.isEmpty() ? sql : sql + " RETURNING " + returning; } + /** + * Parses a normalized {@link ZoneId} from a time zone string of MySQL. + *

+ * Note: since java 14.0.2, 11.0.8, 8u261 and 7u271, America/Nuuk is already renamed from America/Godthab. + * See also tzdata2020a + * + * @param zoneId the time zone string + * @return the normalized {@link ZoneId} + * @throws IllegalArgumentException if the time zone string is {@code null} or empty + * @throws java.time.DateTimeException if the time zone string has an invalid format + * @throws java.time.zone.ZoneRulesException if the time zone string cannot be found + */ + public static ZoneId parseZoneId(String zoneId) { + requireNonEmpty(zoneId, "zoneId must not be empty"); + + String realId; + + if (zoneId.startsWith(ZONE_PREFIX_POSIX) || zoneId.startsWith(ZONE_PREFIX_RIGHT)) { + realId = zoneId.substring(ZONE_PREFIX_LENGTH); + } else { + realId = zoneId; + } + + switch (realId) { + case "Factory": + // It seems like UTC. + return ZoneOffset.UTC; + case "ROC": + // It is equal to +08:00. + return ZoneOffset.ofHours(8); + } + + return ZoneId.of(realId, ZoneId.SHORT_IDS).normalized(); + } + private StringUtils() { } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ConnectionContextTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ConnectionContextTest.java index dce1b0ddb..7e98e5d6c 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ConnectionContextTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ConnectionContextTest.java @@ -31,46 +31,30 @@ public class ConnectionContextTest { @Test - void getServerZoneId() { + void getTimeZone() { for (int i = -12; i <= 12; ++i) { String id = i < 0 ? "UTC" + i : "UTC+" + i; ConnectionContext context = new ConnectionContext( ZeroDateOption.USE_NULL, null, - 8192, ZoneId.of(id)); + 8192, true, ZoneId.of(id)); - assertThat(context.getServerZoneId()).isEqualTo(ZoneId.of(id)); + assertThat(context.getTimeZone()).isEqualTo(ZoneId.of(id)); } } @Test - void shouldSetServerZoneId() { + void setTwiceTimeZone() { ConnectionContext context = new ConnectionContext(ZeroDateOption.USE_NULL, null, - 8192, null); - assertThat(context.shouldSetServerZoneId()).isTrue(); - context.setServerZoneId(ZoneId.systemDefault()); - assertThat(context.shouldSetServerZoneId()).isFalse(); + 8192, true, null); + context.setTimeZone(ZoneId.systemDefault()); + assertThatIllegalStateException().isThrownBy(() -> context.setTimeZone(ZoneId.systemDefault())); } @Test - void shouldNotSetServerZoneId() { + void badSetTimeZone() { ConnectionContext context = new ConnectionContext(ZeroDateOption.USE_NULL, null, - 8192, ZoneId.systemDefault()); - assertThat(context.shouldSetServerZoneId()).isFalse(); - } - - @Test - void setTwiceServerZoneId() { - ConnectionContext context = new ConnectionContext(ZeroDateOption.USE_NULL, null, - 8192, null); - context.setServerZoneId(ZoneId.systemDefault()); - assertThatIllegalStateException().isThrownBy(() -> context.setServerZoneId(ZoneId.systemDefault())); - } - - @Test - void badSetServerZoneId() { - ConnectionContext context = new ConnectionContext(ZeroDateOption.USE_NULL, null, - 8192, ZoneId.systemDefault()); - assertThatIllegalStateException().isThrownBy(() -> context.setServerZoneId(ZoneId.systemDefault())); + 8192, true, ZoneId.systemDefault()); + assertThatIllegalStateException().isThrownBy(() -> context.setTimeZone(ZoneId.systemDefault())); } public static ConnectionContext mock() { @@ -83,7 +67,7 @@ public static ConnectionContext mock(boolean isMariaDB) { public static ConnectionContext mock(boolean isMariaDB, ZoneId zoneId) { ConnectionContext context = new ConnectionContext(ZeroDateOption.USE_NULL, null, - 8192, zoneId); + 8192, true, zoneId); context.init(1, ServerVersion.parse(isMariaDB ? "11.2.22.MOCKED" : "8.0.11.MOCKED"), Capability.of(~(isMariaDB ? 1 : 0))); diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTestSupport.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/DateTimeIntegrationTestSupport.java similarity index 92% rename from r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTestSupport.java rename to r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/DateTimeIntegrationTestSupport.java index 7e32d07e4..891c4cb41 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTestSupport.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/DateTimeIntegrationTestSupport.java @@ -16,7 +16,10 @@ package io.asyncer.r2dbc.mysql; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; import reactor.core.publisher.Flux; import java.time.Instant; @@ -35,9 +38,10 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Base class considers integration tests for time zone conversion. + * Base class considers integration tests for date times. */ -abstract class TimeZoneIntegrationTestSupport extends IntegrationTestSupport { +@Isolated +abstract class DateTimeIntegrationTestSupport extends IntegrationTestSupport { private static final String TIMESTAMP_TABLE = "CREATE TEMPORARY TABLE test " + "(id INT PRIMARY KEY AUTO_INCREMENT, value TIMESTAMP)"; @@ -56,7 +60,11 @@ abstract class TimeZoneIntegrationTestSupport extends IntegrationTestSupport { private static final ZoneId SERVER_ZONE = ZoneId.of("America/New_York"); - static { + private static TimeZone defaultTimeZone; + + @BeforeAll + static void setUpTimeZone() { + defaultTimeZone = TimeZone.getDefault(); TimeZone.setDefault(TimeZone.getTimeZone("GMT+6")); // Make sure test cases contains daylight. @@ -64,10 +72,15 @@ abstract class TimeZoneIntegrationTestSupport extends IntegrationTestSupport { .isEqualTo(DST.atZone(SERVER_ZONE).plusHours(1)); } - TimeZoneIntegrationTestSupport( + @AfterAll + static void tearDownTimeZone() { + TimeZone.setDefault(defaultTimeZone); + } + + DateTimeIntegrationTestSupport( Function customizer ) { - super(configuration(builder -> customizer.apply(builder.serverZoneId(SERVER_ZONE)))); + super(configuration(builder -> customizer.apply(builder.connectionTimeZone(SERVER_ZONE.getId())))); } @Test @@ -128,8 +141,7 @@ void queryOffsetTime() { .bind(0, 0) .execute()) .flatMap(r -> r.map((row, meta) -> row.get(0, OffsetTime.class))) - .doOnNext(it -> assertThat(it.getOffset()) - .isEqualTo(SERVER_ZONE.getRules().getStandardOffset(Instant.EPOCH))) + .doOnNext(it -> assertThat(it.getOffset()).isEqualTo(ZoneId.systemDefault().normalized())) .map(OffsetTime::toLocalTime) .collectList() .doOnNext(it -> assertThat(it) @@ -208,8 +220,7 @@ void updateOffsetTime() { .bind(0, 0) .execute()) .flatMap(r -> r.map((row, meta) -> row.get(0, OffsetTime.class))) - .doOnNext(it -> assertThat(it.getOffset()) - .isEqualTo(SERVER_ZONE.getRules().getStandardOffset(Instant.EPOCH))) + .doOnNext(it -> assertThat(it.getOffset()).isEqualTo(ZoneId.systemDefault().normalized())) .map(it -> it.withOffsetSameInstant(ZoneId.systemDefault().getRules() .getStandardOffset(Instant.EPOCH)) .toLocalTime()) diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MariaDbIntegrationTestSupport.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MariaDbIntegrationTestSupport.java index 8b08b3ead..dcd9fd482 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MariaDbIntegrationTestSupport.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MariaDbIntegrationTestSupport.java @@ -18,12 +18,17 @@ import io.r2dbc.spi.Readable; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import java.time.Instant; +import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.util.List; import java.util.function.Function; import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; /** * Base class considers integration tests for MariaDB. @@ -39,17 +44,17 @@ abstract class MariaDbIntegrationTestSupport extends IntegrationTestSupport { @Test void returningExpression() { complete(conn -> conn.createStatement("CREATE TEMPORARY TABLE test (" + - "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,value INT NOT NULL)") + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,value INT NOT NULL)") .execute() .flatMap(IntegrationTestSupport::extractRowsUpdated) .thenMany(conn.createStatement("INSERT INTO test(value) VALUES (?)") .bind(0, 2) - .returnGeneratedValues("CURRENT_TIMESTAMP") + .returnGeneratedValues("POW(value, 4)") .execute()) - .flatMap(result -> result.map(r -> r.get(0, ZonedDateTime.class))) + .flatMap(result -> result.map(r -> r.get(0, Integer.class))) .collectList() - .doOnNext(list -> assertThat(list).hasSize(1) - .noneMatch(it -> it.isBefore(ZonedDateTime.now().minusSeconds(10))))); + .doOnNext(list -> assertThat(list).hasSize(1)) + .doOnNext(list -> assertThat(list.get(0)).isEqualTo(16))); } @Test @@ -70,13 +75,9 @@ void allReturning() { .execute()) .flatMap(result -> result.map(DataEntity::read)) .collectList() - .doOnNext(list -> assertThat(list).hasSize(5) - .map(DataEntity::getValue) - .containsExactly(2, 4, 6, 8, 10)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getId).distinct()).hasSize(5)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getCreatedAt)) - .noneMatch(it -> it.isBefore(ZonedDateTime.now().minusSeconds(10)))) - .thenMany(conn.createStatement("REPLACE test(id, value) VALUES (1,?),(2,?),(3,?),(4,?),(5,?)") + .doOnNext(list -> assertThat(list).hasSize(5)) + .as(list -> assertWithSelectAll(conn, list)) + .thenMany(conn.createStatement("REPLACE test(id,value) VALUES (1,?),(2,?),(3,?),(4,?),(5,?)") .bind(0, 3) .bind(1, 5) .bind(2, 7) @@ -86,11 +87,8 @@ void allReturning() { .execute()) .flatMap(result -> result.map(DataEntity::read)) .collectList() - .doOnNext(list -> assertThat(list).hasSize(5) - .map(DataEntity::getValue) - .containsExactly(3, 5, 7, 9, 11)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getCreatedAt)) - .noneMatch(it -> it.isBefore(ZonedDateTime.now().minusSeconds(10))))); + .doOnNext(list -> assertThat(list).hasSize(5)) + .as(list -> assertWithSelectAll(conn, list))); } @Test @@ -107,38 +105,30 @@ void partialReturning() { .bind(2, 6) .bind(3, 8) .bind(4, 10) - .returnGeneratedValues("id", "created_at") + .returnGeneratedValues("id", "value") .execute()) - .flatMap(result -> result.map(DataEntity::withoutValue)) + .flatMap(result -> result.map(DataEntity::withoutCreatedAt)) .collectList() - .doOnNext(list -> assertThat(list).hasSize(5) - .map(DataEntity::getValue) - .containsOnly(0)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getId).distinct()).hasSize(5)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getCreatedAt)) - .noneMatch(it -> it.isBefore(ZonedDateTime.now().minusSeconds(10)))) + .doOnNext(list -> assertThat(list).hasSize(5)) + .as(list -> assertWithoutCreatedAt(conn, list)) .thenMany(conn.createStatement("REPLACE test(id, value) VALUES (1,?),(2,?),(3,?),(4,?),(5,?)") .bind(0, 3) .bind(1, 5) .bind(2, 7) .bind(3, 9) .bind(4, 11) - .returnGeneratedValues("id", "created_at") + .returnGeneratedValues("id", "value") .execute()) - .flatMap(result -> result.map(DataEntity::withoutValue)) + .flatMap(result -> result.map(DataEntity::withoutCreatedAt)) .collectList() - .doOnNext(list -> assertThat(list).hasSize(5) - .map(DataEntity::getValue) - .containsOnly(0)) - .doOnNext(list -> assertThat(list.stream().map(DataEntity::getCreatedAt)) - .noneMatch(it -> it.isBefore(ZonedDateTime.now().minusSeconds(10)))) - ); + .doOnNext(list -> assertThat(list).hasSize(5)) + .as(list -> assertWithoutCreatedAt(conn, list))); } @Test void returningGetRowUpdated() { complete(conn -> conn.createStatement("CREATE TEMPORARY TABLE test(" + - "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,value INT NOT NULL)") + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,value INT NOT NULL)") .execute() .flatMap(IntegrationTestSupport::extractRowsUpdated) .thenMany(conn.createStatement("INSERT INTO test(value) VALUES (?),(?)") @@ -150,6 +140,36 @@ void returningGetRowUpdated() { .doOnNext(it -> assertThat(it).isEqualTo(2))); } + private static Mono assertWithSelectAll(MySqlConnection conn, Mono> returning) { + return returning.zipWhen(list -> conn.createStatement("SELECT * FROM test WHERE id IN (?,?,?,?,?)") + .bind(0, list.get(0).getId()) + .bind(1, list.get(1).getId()) + .bind(2, list.get(2).getId()) + .bind(3, list.get(3).getId()) + .bind(4, list.get(4).getId()) + .execute() + .flatMap(result -> result.map(DataEntity::read)) + .collectList()) + .doOnNext(list -> assertThat(list.getT1()).isEqualTo(list.getT2())) + .then(); + } + + private static Mono assertWithoutCreatedAt(MySqlConnection conn, Mono> returning) { + String sql = "SELECT id,value FROM test WHERE id IN (?,?,?,?,?)"; + + return returning.zipWhen(list -> conn.createStatement(sql) + .bind(0, list.get(0).getId()) + .bind(1, list.get(1).getId()) + .bind(2, list.get(2).getId()) + .bind(3, list.get(3).getId()) + .bind(4, list.get(4).getId()) + .execute() + .flatMap(result -> result.map(DataEntity::withoutCreatedAt)) + .collectList()) + .doOnNext(list -> assertThat(list.getT1()).isEqualTo(list.getT2())) + .then(); + } + private static final class DataEntity { private final int id; @@ -176,6 +196,38 @@ ZonedDateTime getCreatedAt() { return createdAt; } + DataEntity incremented() { + return new DataEntity(id, value + 1, createdAt); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DataEntity)) { + return false; + } + + DataEntity that = (DataEntity) o; + + return id == that.id && value == that.value && createdAt.equals(that.createdAt); + } + + @Override + public int hashCode() { + int result = 31 * id + value; + return 31 * result + createdAt.hashCode(); + } + + @Override + public String toString() { + return "DataEntity{id=" + id + + ", value=" + value + + ", createdAt=" + createdAt + + '}'; + } + static DataEntity read(Readable readable) { Integer id = readable.get("id", Integer.TYPE); Integer value = readable.get("value", Integer.class); @@ -188,14 +240,14 @@ static DataEntity read(Readable readable) { return new DataEntity(id, value, createdAt); } - static DataEntity withoutValue(Readable readable) { + static DataEntity withoutCreatedAt(Readable readable) { Integer id = readable.get("id", Integer.TYPE); - ZonedDateTime createdAt = readable.get("created_at", ZonedDateTime.class); + Integer value = readable.get("value", Integer.TYPE); requireNonNull(id, "id must not be null"); - requireNonNull(createdAt, "createdAt must not be null"); + requireNonNull(value, "value must not be null"); - return new DataEntity(id, 0, createdAt); + return new DataEntity(id, value, ZonedDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC)); } } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java index e0baef7d0..717ecaa0f 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java @@ -243,7 +243,9 @@ private static MySqlConnectionConfiguration filledUp() { .tlsVersion(TlsVersions.TLS1_1, TlsVersions.TLS1_2, TlsVersions.TLS1_3) .compressionAlgorithms(CompressionAlgorithm.ZSTD, CompressionAlgorithm.ZLIB, CompressionAlgorithm.UNCOMPRESSED) - .serverZoneId(ZoneId.systemDefault()) + .preserveInstants(true) + .connectionTimeZone("LOCAL") + .forceConnectionTimeZoneToSession(true) .zeroDateOption(ZeroDateOption.USE_NULL) .sslHostnameVerifier((host, s) -> true) .queryCacheSize(128) diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java index d34a947bf..41b2ef45a 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java @@ -142,7 +142,7 @@ void validProgrammaticHost() { .option(SSL, true) .option(Option.valueOf(CONNECT_TIMEOUT.name()), Duration.ofSeconds(3).toString()) .option(DATABASE, "r2dbc") - .option(Option.valueOf("serverZoneId"), "Asia/Tokyo") + .option(Option.valueOf("connectionTimeZone"), "Asia/Tokyo") .option(Option.valueOf("useServerPrepareStatement"), AllTruePredicate.class.getName()) .option(Option.valueOf("zeroDate"), "use_round") .option(Option.valueOf("sslMode"), "verify_identity") @@ -171,7 +171,7 @@ void validProgrammaticHost() { assertThat(configuration.getZeroDateOption()).isEqualTo(ZeroDateOption.USE_ROUND); assertThat(configuration.isTcpKeepAlive()).isTrue(); assertThat(configuration.isTcpNoDelay()).isTrue(); - assertThat(configuration.getServerZoneId()).isEqualTo(ZoneId.of("Asia/Tokyo")); + assertThat(configuration.getConnectionTimeZone()).isEqualTo("Asia/Tokyo"); assertThat(configuration.getPreferPrepareStatement()).isExactlyInstanceOf(AllTruePredicate.class); assertThat(configuration.getExtensions()).isEqualTo(Extensions.from(Collections.emptyList(), true)); @@ -288,7 +288,7 @@ void validProgrammaticUnixSocket() { .option(Option.valueOf(CONNECT_TIMEOUT.name()), Duration.ofSeconds(3).toString()) .option(DATABASE, "r2dbc") .option(Option.valueOf("createDatabaseIfNotExist"), true) - .option(Option.valueOf("serverZoneId"), "Asia/Tokyo") + .option(Option.valueOf("connectionTimeZone"), "Asia/Tokyo") .option(Option.valueOf("useServerPrepareStatement"), AllTruePredicate.class.getName()) .option(Option.valueOf("zeroDate"), "use_round") .option(Option.valueOf("sslMode"), "verify_identity") @@ -314,7 +314,7 @@ void validProgrammaticUnixSocket() { assertThat(configuration.getZeroDateOption()).isEqualTo(ZeroDateOption.USE_ROUND); assertThat(configuration.isTcpKeepAlive()).isTrue(); assertThat(configuration.isTcpNoDelay()).isTrue(); - assertThat(configuration.getServerZoneId()).isEqualTo(ZoneId.of("Asia/Tokyo")); + assertThat(configuration.getConnectionTimeZone()).isEqualTo("Asia/Tokyo"); assertThat(configuration.getPreferPrepareStatement()).isExactlyInstanceOf(AllTruePredicate.class); assertThat(configuration.getExtensions()).isEqualTo(Extensions.from(Collections.emptyList(), true)); diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java index 635d94921..7b85d4150 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java @@ -21,9 +21,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import java.time.Duration; -import java.time.ZoneId; import java.util.Optional; -import java.util.TimeZone; /** * Base class considers integration tests of {@link TestKit}. @@ -88,11 +86,10 @@ private static JdbcTemplate jdbc(MySqlConnectionConfiguration configuration) { source.setConnectionTimeout(Optional.ofNullable(configuration.getConnectTimeout()) .map(Duration::toMillis).orElse(0L)); - ZoneId zoneId = configuration.getServerZoneId(); - - if (zoneId != null) { - source.addDataSourceProperty("serverTimezone", TimeZone.getTimeZone(zoneId).getID()); - } + source.addDataSourceProperty("preserveInstants", configuration.isPreserveInstants()); + source.addDataSourceProperty("connectionTimeZone", configuration.getConnectionTimeZone()); + source.addDataSourceProperty("forceConnectionTimeZoneToSession", + configuration.isForceConnectionTimeZoneToSession()); return new JdbcTemplate(source); } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareTimeZoneIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareDateTimeIntegrationTest.java similarity index 88% rename from r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareTimeZoneIntegrationTest.java rename to r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareDateTimeIntegrationTest.java index 6b53312e5..11c26a547 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareTimeZoneIntegrationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/PrepareDateTimeIntegrationTest.java @@ -19,9 +19,9 @@ /** * Integration tests for time zone conversion in the binary protocol. */ -class PrepareTimeZoneIntegrationTest extends TimeZoneIntegrationTestSupport { +class PrepareDateTimeIntegrationTest extends DateTimeIntegrationTestSupport { - PrepareTimeZoneIntegrationTest() { + PrepareDateTimeIntegrationTest() { super(builder -> builder.useServerPrepareStatement(sql -> true)); } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/SessionStateIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/SessionStateIntegrationTest.java index 49ed0b672..a121374e9 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/SessionStateIntegrationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/SessionStateIntegrationTest.java @@ -16,12 +16,16 @@ package io.asyncer.r2dbc.mysql; +import io.asyncer.r2dbc.mysql.internal.util.StringUtils; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; @@ -29,10 +33,63 @@ import java.util.stream.Stream; /** - * Integration tests for session state. + * Integration tests for session states. */ class SessionStateIntegrationTest { + @Test + void forcedLocalTimeZone() { + ZoneId zoneId = ZoneId.systemDefault().normalized(); + + connectionFactory(builder -> builder.connectionTimeZone("local") + .forceConnectionTimeZoneToSession(true)) + .create() + .flatMapMany( + connection -> connection.createStatement("SELECT @@time_zone").execute() + .flatMap(result -> result.map(r -> r.get(0, String.class))) + .map(StringUtils::parseZoneId) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .concatWith(connection.close().then(Mono.empty())) + ) + .as(StepVerifier::create) + .expectNext(zoneId) + .verifyComplete(); + } + + @ParameterizedTest + @ValueSource(strings = { + "America/New_York", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Tokyo", + "Europe/London", + "Factory", + "GMT", + "JST", + "ROC", + "UTC", + "+00:00", + "+09:00", + "-09:00", + }) + void forcedConnectionTimeZone(String timeZone) { + ZoneId zoneId = StringUtils.parseZoneId(timeZone); + + connectionFactory(builder -> builder.connectionTimeZone(timeZone) + .forceConnectionTimeZoneToSession(true)) + .create() + .flatMapMany( + connection -> connection.createStatement("SELECT @@time_zone").execute() + .flatMap(result -> result.map(r -> r.get(0, String.class))) + .map(StringUtils::parseZoneId) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .concatWith(connection.close().then(Mono.empty())) + ) + .as(StepVerifier::create) + .expectNext(zoneId) + .verifyComplete(); + } + @ParameterizedTest @MethodSource void sessionVariables(Map variables) { @@ -50,10 +107,10 @@ void sessionVariables(Map variables) { connectionFactory(builder -> builder.sessionVariables(pairs)) .create() .flatMapMany(connection -> connection.createStatement(selection).execute() - .flatMap(result -> result.map((row, metadata) -> { + .flatMap(result -> result.map(r -> { Map map = new LinkedHashMap<>(); for (String key : keys) { - map.put(key, row.get(key, String.class)); + map.put(key, r.get(key, String.class)); } return map; })) diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextTimeZoneIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextDateTimeIntegrationTest.java similarity index 88% rename from r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextTimeZoneIntegrationTest.java rename to r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextDateTimeIntegrationTest.java index 336a0d5c1..4d1e153c0 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextTimeZoneIntegrationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TextDateTimeIntegrationTest.java @@ -19,9 +19,9 @@ /** * Integration tests for time zone conversion in the text protocol. */ -class TextTimeZoneIntegrationTest extends TimeZoneIntegrationTestSupport { +class TextDateTimeIntegrationTest extends DateTimeIntegrationTestSupport { - TextTimeZoneIntegrationTest() { + TextDateTimeIntegrationTest() { super(builder -> builder); } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java new file mode 100644 index 000000000..0d302494c --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java @@ -0,0 +1,362 @@ +package io.asyncer.r2dbc.mysql; + +import com.zaxxer.hikari.HikariDataSource; +import org.assertj.core.data.TemporalUnitOffset; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.jdbc.core.JdbcTemplate; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.function.Tuple3; +import reactor.util.function.Tuple4; +import reactor.util.function.Tuple6; +import reactor.util.function.Tuple8; +import reactor.util.function.Tuples; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.util.Optional; +import java.util.TimeZone; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Integration tests for aligning time zone configuration options with jdbc. + */ +@Isolated +class TimeZoneIntegrationTest { + + // Earlier versions did not support microseconds, so it is almost always within 1 second, extending to + // 2 seconds due to network reasons + private static final TemporalUnitOffset TINY_WITHIN = within(2, ChronoUnit.SECONDS); + + private static TimeZone defaultTimeZone; + + @BeforeAll + static void setUpTimeZone() { + defaultTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("GMT+9:30")); + } + + @AfterAll + static void tearDownTimeZone() { + TimeZone.setDefault(defaultTimeZone); + } + + @BeforeEach + void setUp() { + String tdl = "CREATE TABLE IF NOT EXISTS test_time_zone (" + + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," + + "data1 DATETIME" + dateTimeSuffix(false) + " NOT NULL," + + "data2 TIMESTAMP" + dateTimeSuffix(false) + " NOT NULL)"; + + MySqlConnectionFactory.from(configuration(Function.identity())).create() + .flatMapMany(connection -> connection.createStatement(tdl) + .execute() + .flatMap(MySqlResult::getRowsUpdated) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .then(connection.close())) + .as(StepVerifier::create) + .verifyComplete(); + } + + @AfterEach + void tearDown() { + MySqlConnectionFactory.from(configuration(Function.identity())).create() + .flatMapMany(connection -> connection.createStatement("DROP TABLE IF EXISTS test_time_zone") + .execute() + .flatMap(MySqlResult::getRowsUpdated) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .then(connection.close())) + .as(StepVerifier::create) + .verifyComplete(); + } + + @ParameterizedTest + @MethodSource + void alignDateTimeFunction(boolean instants, String timeZone, boolean force) { + String selectQuery = "SELECT CURRENT_TIMESTAMP" + dateTimeSuffix(true) + + ", NOW" + dateTimeSuffix(true) + + ", CURRENT_TIME" + dateTimeSuffix(true) + + ", CURRENT_DATE()"; + MySqlConnectionConfiguration config = configuration(builder -> builder + .preserveInstants(instants) + .connectionTimeZone(timeZone) + .forceConnectionTimeZoneToSession(force)); + JdbcTemplate jdbc = jdbc(config); + + Tuple4< + Tuple3, + Tuple3, + Tuple3, + LocalDate + > expectedTuples = jdbc.query(selectQuery, (rs, ignored) -> Tuples.of( + Tuples.of( + requireNonNull(rs.getObject(1, LocalDateTime.class)), + requireNonNull(rs.getObject(1, ZonedDateTime.class)), + requireNonNull(rs.getObject(1, OffsetDateTime.class)) + ), + Tuples.of( + requireNonNull(rs.getObject(2, LocalDateTime.class)), + requireNonNull(rs.getObject(2, ZonedDateTime.class)), + requireNonNull(rs.getObject(2, OffsetDateTime.class)) + ), + Tuples.of( + rs.getObject(3, LocalTime.class), + rs.getObject(3, OffsetTime.class), + rs.getObject(3, Duration.class) + ), + rs.getObject(4, LocalDate.class) + )).get(0); + + MySqlConnectionFactory.from(config).create() + .flatMapMany(connection -> connection.createStatement(selectQuery) + .execute() + .flatMap(result -> result.map((row, metadata) -> Tuples.of( + Tuples.of( + requireNonNull(row.get(0, LocalDateTime.class)), + requireNonNull(row.get(0, ZonedDateTime.class)), + requireNonNull(row.get(0, OffsetDateTime.class)), + requireNonNull(row.get(0, Instant.class)) + ), + Tuples.of( + requireNonNull(row.get(1, LocalDateTime.class)), + requireNonNull(row.get(1, ZonedDateTime.class)), + requireNonNull(row.get(1, OffsetDateTime.class)), + requireNonNull(row.get(1, Instant.class)) + ), + Tuples.of( + requireNonNull(row.get(2, LocalTime.class)), + requireNonNull(row.get(2, OffsetTime.class)), + requireNonNull(row.get(2, Duration.class)) + ), + requireNonNull(row.get(3, LocalDate.class)) + ))) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .assertNext(data -> { + assertDateTimeTuples(data.getT1(), expectedTuples.getT1()); + assertDateTimeTuples(data.getT2(), expectedTuples.getT2()); + + assertThat(data.getT3().getT1()).isCloseTo(expectedTuples.getT3().getT1(), TINY_WITHIN); + assertThat(data.getT3().getT2().getOffset()) + .isEqualTo(expectedTuples.getT3().getT2().getOffset()); + assertThat(data.getT3().getT2()).isCloseTo(expectedTuples.getT3().getT2(), TINY_WITHIN); + assertThat(data.getT3().getT3()).isCloseTo(expectedTuples.getT3().getT3(), Duration.ofSeconds(2)); + + // If the test case is run close to UTC midnight, it may fail, just run it again + assertThat(data.getT4()).isEqualTo(expectedTuples.getT4()); + }) + .verifyComplete(); + + requireNonNull((HikariDataSource) jdbc.getDataSource()).close(); + } + + @ParameterizedTest + @MethodSource + void alignSendAndReceiveTimeZoneOption(boolean instants, String timeZone, boolean force, Temporal now) { + String insertQuery = "INSERT INTO test_time_zone VALUES (DEFAULT, ?, ?)"; + String selectQuery = "SELECT data1, data2 FROM test_time_zone"; + MySqlConnectionConfiguration config = configuration(builder -> builder + .preserveInstants(instants) + .connectionTimeZone(timeZone) + .forceConnectionTimeZoneToSession(force)); + JdbcTemplate jdbc = jdbc(config); + + assertThat(jdbc.update(insertQuery, now, now)).isOne(); + Tuple6 expectedTuples = jdbc.query(selectQuery, (rs, ignored) -> Tuples.of( + requireNonNull(rs.getObject(1, LocalDateTime.class)), + requireNonNull(rs.getObject(2, LocalDateTime.class)), + requireNonNull(rs.getObject(1, ZonedDateTime.class)), + requireNonNull(rs.getObject(2, ZonedDateTime.class)), + requireNonNull(rs.getObject(1, OffsetDateTime.class)), + requireNonNull(rs.getObject(2, OffsetDateTime.class)) + )).get(0); + Consumer> assertion = actual -> { + assertThat(actual.getT1()).isCloseTo(expectedTuples.getT1(), TINY_WITHIN); + assertThat(actual.getT2()).isCloseTo(expectedTuples.getT2(), TINY_WITHIN); + assertThat(actual.getT3().getZone().normalized()) + .isEqualTo(expectedTuples.getT3().getZone().normalized()); + assertThat(actual.getT3()).isCloseTo(expectedTuples.getT3(), TINY_WITHIN); + assertThat(actual.getT4().getZone().normalized()) + .isEqualTo(expectedTuples.getT4().getZone().normalized()); + assertThat(actual.getT4()).isCloseTo(expectedTuples.getT4(), TINY_WITHIN); + assertThat(actual.getT5().getOffset()).isEqualTo(expectedTuples.getT5().getOffset()); + assertThat(actual.getT5()).isCloseTo(expectedTuples.getT5(), TINY_WITHIN); + assertThat(actual.getT6().getOffset()).isEqualTo(expectedTuples.getT6().getOffset()); + assertThat(actual.getT6()).isCloseTo(expectedTuples.getT6(), TINY_WITHIN); + assertThat(actual.getT7()).isCloseTo(expectedTuples.getT3().toInstant(), TINY_WITHIN); + assertThat(actual.getT8()).isCloseTo(expectedTuples.getT4().toInstant(), TINY_WITHIN); + assertThat(actual.getT7()).isCloseTo(expectedTuples.getT5().toInstant(), TINY_WITHIN); + assertThat(actual.getT8()).isCloseTo(expectedTuples.getT6().toInstant(), TINY_WITHIN); + }; + + MySqlConnectionFactory.from(config).create() + .flatMapMany(connection -> connection.createStatement(insertQuery) + .bind(0, now) + .bind(1, now) + .execute() + .flatMap(MySqlResult::getRowsUpdated) + .thenMany(connection.createStatement(selectQuery).execute()) + .flatMap(result -> result.map(r -> Tuples.of( + requireNonNull(r.get(0, LocalDateTime.class)), + requireNonNull(r.get(1, LocalDateTime.class)), + requireNonNull(r.get(0, ZonedDateTime.class)), + requireNonNull(r.get(1, ZonedDateTime.class)), + requireNonNull(r.get(0, OffsetDateTime.class)), + requireNonNull(r.get(1, OffsetDateTime.class)), + requireNonNull(r.get(0, Instant.class)), + requireNonNull(r.get(1, Instant.class)) + ))) + .onErrorResume(e -> connection.close().then(Mono.error(e))) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .assertNext(assertion) + .assertNext(assertion) + .verifyComplete(); + + requireNonNull((HikariDataSource) jdbc.getDataSource()).close(); + } + + static Stream alignDateTimeFunction() { + return Stream.of( + Arguments.of(false, "LOCAL", false), + Arguments.of(false, "LOCAL", true), + Arguments.of(true, "LOCAL", false), + Arguments.of(true, "LOCAL", true), + Arguments.of(false, "SERVER", false), + Arguments.of(false, "SERVER", true), + Arguments.of(true, "SERVER", false), + Arguments.of(true, "SERVER", true), + Arguments.of(false, "GMT+2", false), + Arguments.of(false, "GMT+3", true), + Arguments.of(true, "GMT+4", false), + Arguments.of(true, "GMT+5", true) + ); + } + + static Stream alignSendAndReceiveTimeZoneOption() { + ZonedDateTime dateTime = ZonedDateTime.now(); + + return Stream.of(dateTime).flatMap(now -> Stream.of( + now.toLocalDateTime(), + now, + now.toOffsetDateTime(), + now.withZoneSameInstant(ZoneOffset.ofHours(2)).toLocalDateTime(), + now.withZoneSameInstant(ZoneOffset.ofHours(3)), + now.withZoneSameInstant(ZoneOffset.ofHours(4)).toOffsetDateTime(), + now.withZoneSameLocal(ZoneOffset.ofHours(5)).toLocalDateTime(), + now.withZoneSameLocal(ZoneOffset.ofHours(6)), + now.withZoneSameLocal(ZoneOffset.ofHours(7)).toOffsetDateTime() + )).flatMap(temporal -> Stream.of( + Arguments.of(false, "LOCAL", false, temporal), + Arguments.of(false, "LOCAL", true, temporal), + Arguments.of(true, "LOCAL", false, temporal), + Arguments.of(true, "LOCAL", true, temporal), + Arguments.of(false, "SERVER", false, temporal), + Arguments.of(false, "SERVER", true, temporal), + Arguments.of(true, "SERVER", false, temporal), + Arguments.of(true, "SERVER", true, temporal), + Arguments.of(false, "GMT+1", false, temporal), + Arguments.of(false, "GMT+1", true, temporal), + Arguments.of(true, "GMT+1", false, temporal), + Arguments.of(true, "GMT+1", true, temporal) + )); + } + + private static MySqlConnectionConfiguration configuration( + Function customizer + ) { + String password = System.getProperty("test.mysql.password"); + + if (password == null || password.isEmpty()) { + throw new IllegalStateException("Property test.mysql.password must exists and not be empty"); + } + + MySqlConnectionConfiguration.Builder builder = MySqlConnectionConfiguration.builder() + .host("localhost") + .port(3306) + .user("root") + .password(password) + .database("r2dbc"); + + return customizer.apply(builder).build(); + } + + private static JdbcTemplate jdbc(MySqlConnectionConfiguration config) { + HikariDataSource source = new HikariDataSource(); + + source.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", config.getDomain(), + config.getPort(), config.getDatabase())); + source.setUsername(config.getUser()); + source.setPassword(Optional.ofNullable(config.getPassword()) + .map(Object::toString).orElse(null)); + source.setMaximumPoolSize(1); + source.setConnectionTimeout(Optional.ofNullable(config.getConnectTimeout()) + .map(Duration::toMillis).orElse(0L)); + + source.addDataSourceProperty("preserveInstants", config.isPreserveInstants()); + source.addDataSourceProperty("connectionTimeZone", config.getConnectionTimeZone()); + source.addDataSourceProperty("forceConnectionTimeZoneToSession", + config.isForceConnectionTimeZoneToSession()); + + return new JdbcTemplate(source); + } + + private static String dateTimeSuffix(boolean function) { + String version = System.getProperty("test.mysql.version"); + return version != null && isMicrosecondSupported(version) ? "(6)" : function ? "()" : ""; + } + + private static boolean isMicrosecondSupported(String version) { + if (version.isEmpty()) { + return false; + } + + ServerVersion ver = ServerVersion.parse(version); + String type = System.getProperty("test.db.type"); + + return "mariadb".equalsIgnoreCase(type) || + ver.isGreaterThanOrEqualTo(ServerVersion.create(5, 6, 0)); + } + + private static void assertDateTimeTuples( + Tuple4 actual, + Tuple3 expected + ) { + assertThat(actual.getT1()).isCloseTo(expected.getT1(), TINY_WITHIN); + assertThat(actual.getT2().getZone().normalized()) + .isEqualTo(expected.getT2().getZone().normalized()); + assertThat(actual.getT2()).isCloseTo(expected.getT2(), TINY_WITHIN); + assertThat(actual.getT3().getOffset()) + .isEqualTo(expected.getT3().getOffset()); + assertThat(actual.getT3()).isCloseTo(expected.getT3(), TINY_WITHIN); + assertThat(actual.getT4()) + .isCloseTo(expected.getT2().toInstant(), TINY_WITHIN); + assertThat(actual.getT4()) + .isCloseTo(expected.getT3().toInstant(), TINY_WITHIN); + } +} diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/DateTimeCodecTestSupport.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/DateTimeCodecTestSupport.java index 100ea09cf..8d4adf4b1 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/DateTimeCodecTestSupport.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/DateTimeCodecTestSupport.java @@ -21,7 +21,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.format.SignStyle; @@ -29,14 +29,20 @@ import java.util.Locale; import java.util.concurrent.TimeUnit; -import static java.time.temporal.ChronoField.*; +import static java.time.temporal.ChronoField.DAY_OF_MONTH; +import static java.time.temporal.ChronoField.HOUR_OF_DAY; +import static java.time.temporal.ChronoField.MICRO_OF_SECOND; +import static java.time.temporal.ChronoField.MINUTE_OF_HOUR; +import static java.time.temporal.ChronoField.MONTH_OF_YEAR; +import static java.time.temporal.ChronoField.SECOND_OF_MINUTE; +import static java.time.temporal.ChronoField.YEAR; /** * Base class considers codecs unit tests of date/time. */ abstract class DateTimeCodecTestSupport implements CodecTestSupport { - protected static final ZoneId ENCODE_SERVER_ZONE = ZoneId.of("+6"); + protected static final ZoneOffset ENCODE_SERVER_ZONE = ZoneOffset.ofHours(6); private static final DateTimeFormatter FORMATTER = new DateTimeFormatterBuilder() .appendLiteral('\'') diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodecTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodecTest.java index 97774b2a4..8485b1059 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodecTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetDateTimeCodecTest.java @@ -22,6 +22,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Arrays; @@ -58,6 +59,9 @@ public ByteBuf[] binaryParameters(Charset charset) { } private LocalDateTime convert(OffsetDateTime value) { - return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalDateTime(); + ZoneOffset offset = context().isPreserveInstants() ? ENCODE_SERVER_ZONE : + ZoneId.systemDefault().getRules().getOffset(value.toLocalDateTime()); + + return value.withOffsetSameInstant(offset).toLocalDateTime(); } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodecTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodecTest.java index 4aa3ecdee..b3d849745 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodecTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/OffsetTimeCodecTest.java @@ -19,8 +19,10 @@ import io.netty.buffer.ByteBuf; import java.nio.charset.Charset; +import java.time.Instant; import java.time.LocalTime; import java.time.OffsetTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Arrays; @@ -67,6 +69,9 @@ public ByteBuf[] binaryParameters(Charset charset) { } private LocalTime convert(OffsetTime value) { - return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalTime(); + ZoneId zone = ZoneId.systemDefault().normalized(); + + return value.withOffsetSameInstant(zone instanceof ZoneOffset ? (ZoneOffset) zone : + zone.getRules().getStandardOffset(Instant.EPOCH)).toLocalTime(); } } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodecTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodecTest.java index 66697a75a..10f729193 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodecTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/codec/ZonedDateTimeCodecTest.java @@ -75,6 +75,10 @@ public ByteBuf[] binaryParameters(Charset charset) { } private LocalDateTime convert(ZonedDateTime value) { - return value.withZoneSameInstant(ENCODE_SERVER_ZONE).toLocalDateTime(); + if (context().isPreserveInstants()) { + return value.withZoneSameInstant(ENCODE_SERVER_ZONE).toLocalDateTime(); + } + + return value.toLocalDateTime(); } } diff --git a/test-native-image/pom.xml b/test-native-image/pom.xml index 2c49360d3..ac3368454 100644 --- a/test-native-image/pom.xml +++ b/test-native-image/pom.xml @@ -43,4 +43,4 @@ - \ No newline at end of file +