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..0ce86ed9a 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 (this.timeZone != null) { 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..1656464bb 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,8 +144,10 @@ 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.zeroDateOption = requireNonNull(zeroDateOption, "zeroDateOption must not be null"); + this.forceConnectionTimeZoneToSession = forceConnectionTimeZoneToSession; this.user = requireNonNull(user, "user must not be null"); this.password = password; this.database = database == null || database.isEmpty() ? "" : database; @@ -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,8 @@ public boolean equals(Object o) { tcpKeepAlive == that.tcpKeepAlive && tcpNoDelay == that.tcpNoDelay && Objects.equals(connectTimeout, that.connectTimeout) && - Objects.equals(serverZoneId, that.serverZoneId) && + Objects.equals(connectionTimeZone, that.connectionTimeZone) && + forceConnectionTimeZoneToSession == that.forceConnectionTimeZoneToSession && zeroDateOption == that.zeroDateOption && user.equals(that.user) && Objects.equals(password, that.password) && @@ -305,7 +322,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, + connectionTimeZone, forceConnectionTimeZoneToSession, + zeroDateOption, user, password, database, createDatabaseIfNotExist, preferPrepareStatement, sessionVariables, loadLocalInfilePath, localInfileBufferSize, queryCacheSize, prepareCacheSize, compressionAlgorithms, zstdCompressionLevel, loopResources, extensions, passwordPublisher); @@ -316,7 +334,9 @@ public String toString() { if (isHost) { return "MySqlConnectionConfiguration{host='" + domain + "', port=" + port + ", ssl=" + ssl + ", tcpNoDelay=" + tcpNoDelay + ", tcpKeepAlive=" + tcpKeepAlive + - ", connectTimeout=" + connectTimeout + ", serverZoneId=" + serverZoneId + + ", connectTimeout=" + connectTimeout + + ", connectionTimeZone=" + connectionTimeZone + + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + ", preferPrepareStatement=" + preferPrepareStatement + @@ -331,7 +351,9 @@ public String toString() { } return "MySqlConnectionConfiguration{unixSocket='" + domain + - "', connectTimeout=" + connectTimeout + ", serverZoneId=" + serverZoneId + + "', connectTimeout=" + connectTimeout + + ", connectionTimeZone=" + connectionTimeZone + + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + ", preferPrepareStatement=" + preferPrepareStatement + @@ -372,8 +394,11 @@ public static final class Builder { private ZeroDateOption zeroDateOption = ZeroDateOption.USE_NULL; - @Nullable - private ZoneId serverZoneId; + private boolean preserveInstants = true; + + private String connectionTimeZone = "SERVER"; + + private boolean forceConnectionTimeZoneToSession; @Nullable private SslMode sslMode; @@ -453,7 +478,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, @@ -579,16 +608,59 @@ public Builder username(String user) { return user(user); } + /** + * Configures the time zone conversion. Default to {@code true} means enable conversion between JVM + * and {@link #connectionTimeZone(String)}. + * + * @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 SERVER} means query server time zone in + * 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"}. + * + * @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. Default to query server time zone in initialization. * - * @param serverZoneId the {@link ZoneId}, or {@code null} if query in initialization. + * @param serverZoneId the {@link ZoneId}, or {@code null} if query server in initialization. * @return this {@link Builder}. * @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..3e5ace7ec 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,38 @@ 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}. + * + * @since 1.1.2 + */ + public static final Option PRESERVE_INSTANTS = Option.valueOf("preserveInstants"); + + /** + * Option to set the time zone of connection. Default to {@code SERVER} means query server time zone in + * initialization. It should be {@code "LOCAL"}, {@code "SERVER"}, or a valid ID of {@code ZoneId}. + * + * @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}. + * + * @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 +335,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..ab540bc58 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,11 @@ public int hashCode() { } private LocalDateTime serverValue() { - return LocalDateTime.ofInstant(value, context.getServerZoneId()); + if (context.isPreserveInstants()) { + return LocalDateTime.ofInstant(value, context.getTimeZone()); + } + + return LocalDateTime.ofInstant(value, 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..189bc3b8d 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,10 +113,15 @@ public int hashCode() { } private LocalDateTime serverValue() { - ZoneId zone = context.getServerZoneId(); - return zone instanceof ZoneOffset ? - value.withOffsetSameInstant((ZoneOffset) zone).toLocalDateTime() : - value.toZonedDateTime().withZoneSameInstant(zone).toLocalDateTime(); + if (context.isPreserveInstants()) { + ZoneId zone = context.getTimeZone(); + + return zone instanceof ZoneOffset ? + value.withOffsetSameInstant((ZoneOffset) zone).toLocalDateTime() : + value.toZonedDateTime().withZoneSameInstant(zone).toLocalDateTime(); + } + + return value.toLocalDateTime(); } @Override 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..df1d094cb 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 @@ -45,7 +45,7 @@ private OffsetTimeCodec() { public OffsetTime decode(ByteBuf value, MySqlColumnMetadata metadata, Class target, boolean binary, CodecContext context) { LocalTime origin = LocalTimeCodec.decodeOrigin(binary, value); - ZoneId zone = context.getServerZoneId(); + ZoneId zone = context.isPreserveInstants() ? context.getTimeZone() : ZoneId.systemDefault(); return OffsetTime.of(origin, zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() .getStandardOffset(Instant.EPOCH)); @@ -112,12 +112,16 @@ public int hashCode() { } private LocalTime serverValue() { - ZoneId zone = context.getServerZoneId(); - ZoneOffset offset = zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() - .getStandardOffset(Instant.EPOCH); + if (context.isPreserveInstants()) { + ZoneId zone = context.getTimeZone(); + ZoneOffset offset = zone instanceof ZoneOffset ? (ZoneOffset) zone : zone.getRules() + .getStandardOffset(Instant.EPOCH); - return value.toLocalTime() - .plusSeconds(offset.getTotalSeconds() - value.getOffset().getTotalSeconds()); + return value.toLocalTime() + .plusSeconds(offset.getTotalSeconds() - value.getOffset().getTotalSeconds()); + } + + return value.toLocalTime(); } @Override 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..73fc3d1bc 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,9 @@ 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()); + ZoneId zoneId = context.isPreserveInstants() ? context.getTimeZone() : ZoneId.systemDefault(); + + return origin == null ? null : ZonedDateTime.of(origin, zoneId); } private static final class ZonedDateTimeMySqlParameter extends AbstractMySqlParameter { @@ -127,8 +130,12 @@ public int hashCode() { } private LocalDateTime serverValue() { - return value.withZoneSameInstant(context.getServerZoneId()) - .toLocalDateTime(); + if (context.isPreserveInstants()) { + return value.withZoneSameInstant(context.getTimeZone()) + .toLocalDateTime(); + } + + return value.toLocalDateTime(); } @Override 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..53677ea3a 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,38 @@ 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 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) { + 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 95% 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..75cae5700 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,6 +16,8 @@ package io.asyncer.r2dbc.mysql; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -35,9 +37,9 @@ 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 { +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 +58,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 +70,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 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..de3541fc3 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 @@ -88,11 +88,9 @@ 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("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..00fdc7f6c --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java @@ -0,0 +1,245 @@ +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +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.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.util.List; +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. + */ +class TimeZoneIntegrationTest { + + private static final TemporalUnitOffset TINY_WITHIN = within(1, ChronoUnit.SECONDS); + + private static final TemporalUnitOffset SMALL_WITHIN = within(2, ChronoUnit.SECONDS); + + private static final String INSERT = "INSERT INTO test_time_zone VALUES (DEFAULT, ?, ?)"; + + private static final String SELECT = "SELECT data1, data2 FROM test_time_zone"; + + 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() { + MySqlConnectionFactory.from(configuration(Function.identity())).create() + .flatMapMany(connection -> connection.createStatement( + "CREATE TABLE IF NOT EXISTS test_time_zone (" + + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," + + "data1 " + dateTimeType(connection.getMetadata()) + " NOT NULL," + + "data2 " + timestampType(connection.getMetadata()) + " NOT NULL)") + .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 alignTimeZoneFeatures(boolean preserveInstants, boolean forceTimeZone, Temporal now, + Instant expected) { + MySqlConnectionConfiguration config = configuration(builder -> builder + .preserveInstants(preserveInstants) + .connectionTimeZone("LOCAL") + .forceConnectionTimeZoneToSession(forceTimeZone)); + JdbcOperations jdbc = jdbc(config); + + assertThat(jdbc.update(INSERT, now, now)).isOne(); + List> tuples = jdbc.query(SELECT, (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)) + )); + Consumer> assertion = actual -> { + Tuple6 expectedTuples = tuples.get(0); + + 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().normalized()) + .isEqualTo(expectedTuples.getT5().getOffset().normalized()); + assertThat(actual.getT5()).isCloseTo(expectedTuples.getT5(), TINY_WITHIN); + assertThat(actual.getT6().getOffset().normalized()) + .isEqualTo(expectedTuples.getT6().getOffset().normalized()); + assertThat(actual.getT6()).isCloseTo(expectedTuples.getT6(), TINY_WITHIN); + assertThat(actual.getT7()).isCloseTo(expected, SMALL_WITHIN); + assertThat(actual.getT8()).isCloseTo(expected, SMALL_WITHIN); + }; + + MySqlConnectionFactory.from(config).create() + .flatMapMany(connection -> connection.createStatement(INSERT) + .bind(0, now) + .bind(1, now) + .execute() + .flatMap(MySqlResult::getRowsUpdated) + .thenMany(connection.createStatement(SELECT).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(); + } + + static Stream alignTimeZoneFeatures() { + ZonedDateTime now = ZonedDateTime.now(); + return Stream.of( + Arguments.of(now.toLocalDateTime(), now.toInstant()), + Arguments.of(now, now.toInstant()), + Arguments.of(now.toOffsetDateTime(), now.toInstant()) + ).flatMap(args -> { + Object[] array = args.get(); + return Stream.of( + Arguments.of(data(false, false, array)), + Arguments.of(data(false, true, array)), + Arguments.of(data(true, false, array)), + Arguments.of(data(true, true, array)) + ); + }); + } + + private static Object[] data(boolean preserveInstants, boolean forceTimeZone, Object[] args) { + Object[] result = new Object[args.length + 2]; + + System.arraycopy(args, 0, result, 2, args.length); + result[0] = preserveInstants; + result[1] = forceTimeZone; + + return result; + } + + 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 dateTimeType(MySqlConnectionMetadata metadata) { + return isMicrosecondSupported(metadata.getDatabaseVersion()) ? "DATETIME(6)" : "DATETIME"; + } + + private static String timestampType(MySqlConnectionMetadata metadata) { + return isMicrosecondSupported(metadata.getDatabaseVersion()) ? "TIMESTAMP(6)" : "TIMESTAMP"; + } + + 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)); + } +} 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..32db5d0ff 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 @@ -58,6 +58,10 @@ public ByteBuf[] binaryParameters(Charset charset) { } private LocalDateTime convert(OffsetDateTime value) { - return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalDateTime(); + if (context().isPreserveInstants()) { + return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalDateTime(); + } + + return value.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..7912c4c2e 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 @@ -67,6 +67,10 @@ public ByteBuf[] binaryParameters(Charset charset) { } private LocalTime convert(OffsetTime value) { - return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalTime(); + if (context().isPreserveInstants()) { + return value.withOffsetSameInstant((ZoneOffset) ENCODE_SERVER_ZONE).toLocalTime(); + } + + return value.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 +