開発 JAVA SQL

【MySQL】Connector/j(JDBC) 8でTimestampをgetStringする際の挙動が異なる

Java+Mysqlを扱っているシステムで、MySQL5.7のサポート期限が2023年10月とあと1年を切っていることから、MySQL8.xへのバージョンアップを実施いたしました。
合わせてJDBCも5.1.xから8.0.xへバージョンアップを行った際にgetStringで取得していたソースで挙動が異なったので記事にいたしました。

getStringで取得する際の挙動が変わる

サンプルコード

下記SQLとJavaソースで検証した。MySQLは8.0.23でDockerより起動

version: "3"
services:
  db:
    platform: linux/x86_64
    image: mysql:8.0.23
    ports:
      - 13306:3306
    volumes:
      - db-store:/var/lib/mysql
      - ./logs:/var/log/mysql
      - ./dockerfiles/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    container_name: mysql
    environment:
      - MYSQL_DATABASE=test
      - MYSQL_USER=test
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=password
volumes:
  db-store:
---ミリ秒(3桁)を表示するTimestampが挿入できるテーブルを作成
create table test(colum1 timestamp(3));
insert into test values('2022-11-22 11:22:11');
insert into test values('2022-11-22 11:22:11.555');

-- タイムゾーンを東京に設定する場合
SET GLOBAL time_zone = 'Asia/Tokyo';

-- タイムゾーンをUTCに設定する場合
SET GLOBAL time_zone = 'Asia/Tokyo';
package jp.example;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class MysqlTest  {
	public static void main(String args[]) {
		
		Connection con = null;
		PreparedStatement pstmt = null;
		ResultSet rs = null;
		
		try {
			String jdbcUri="jdbc:mysql://localhost:13306/test?useSSL=false";
			String userName="root";
			String password="password";
			con = DriverManager.getConnection(jdbcUri,userName,password);
			
			String sql="select * from test";
			pstmt = con.prepareStatement(sql);
			rs = pstmt.executeQuery();
			
			while(rs.next()) {
				System.out.println(rs.getString("colum1"));
				
			}
		} catch (SQLException e) {
			e.printStackTrace();
		} finally {
			try {
				if(rs != null) {
					rs.close();
				}
				if(pstmt != null) {
					pstmt.close();
				}
				if(con != null) {
					con.close();
				}
				
			} catch (SQLException e) {
				e.printStackTrace();
			}
		}
	}

}

結果

下記の通り、バージョンによって出力結果が異なります。
特に5.1系までは0ミリ秒の場合でも「.0」と表示されていたのですが、8系からは省略されています。

//5.1.49の場合
2022-11-22 11:22:11.0
2022-11-22 11:22:11.555

//8.0.18の場合
2022-11-22 11:22:11
2022-11-22 11:22:11.555000000

//8.0.31の場合
2022-11-22 11:22:11
2022-11-22 11:22:11.555

原因

原因を調査したところ、5.1系はTimestamp型をtoStringで出力し、8.0系は年月日単位で文字列切り出しをしていることがわかりました。

5.1.49の場合

ResultSetImpl.java:5298行目付近java.sql.Timestamp型をtoStringに変換していることがわかります。
Timestamp型は0ミリ秒の場合、「.0」と出力されるようです。

//https://github.com/mysql/mysql-connector-j/blob/release/5.1/src/com/mysql/jdbc/ResultSetImpl.java#L5298 付近
                    case Types.TIMESTAMP:
                        Timestamp ts = getTimestampFromString(columnIndex, null, stringVal, this.getDefaultTimeZone(), false);

                        if (ts == null) {
                            this.wasNullFlag = true;

                            return null;
                        }

                        this.wasNullFlag = false;

                        return ts.toString();

8.0.0~8.0.18の場合

8.0.xでも8.0.18以前、8.0.19~8.0.22、8.0.23以降で仕様変更がありました。

まずは、8.0.18の場合ですが、MysqlTextValueDecoder.javaでnanoの桁数処理を行い、StringValueFactory.javaでnanoが0秒の場合はコンマ秒以下の値を出力しないようにStringFormatの制御をしています。

//「MysqlTextValueDecoder.java」 367行目付近でnano秒の桁数設定
    public static InternalTimestamp getTimestamp(byte[] bytes, int offset, int length, int scale) {
        if (length < TIMESTAMP_STR_LEN_NO_FRAC || (length > TIMESTAMP_STR_LEN_WITH_MICROS && length != TIMESTAMP_STR_LEN_WITH_NANOS)) {
            throw new DataReadException(Messages.getString("ResultSet.InvalidLengthForType", new Object[] { length, "TIMESTAMP" }));
        } else if (length != TIMESTAMP_STR_LEN_NO_FRAC) {
            // need at least two extra bytes for fractional, '.' and a digit
            if (bytes[offset + TIMESTAMP_STR_LEN_NO_FRAC] != (byte) '.' || length < TIMESTAMP_STR_LEN_NO_FRAC + 2) {
                throw new DataReadException(
                        Messages.getString("ResultSet.InvalidFormatForType", new Object[] { StringUtils.toString(bytes, offset, length), "TIMESTAMP" }));
            }
        }

        // delimiter verification
        if (bytes[offset + 4] != (byte) '-' || bytes[offset + 7] != (byte) '-' || bytes[offset + 10] != (byte) ' ' || bytes[offset + 13] != (byte) ':'
                || bytes[offset + 16] != (byte) ':') {
            throw new DataReadException(
                    Messages.getString("ResultSet.InvalidFormatForType", new Object[] { StringUtils.toString(bytes, offset, length), "TIMESTAMP" }));
        }

        int year = getInt(bytes, offset, offset + 4);
        int month = getInt(bytes, offset + 5, offset + 7);
        int day = getInt(bytes, offset + 8, offset + 10);
        int hours = getInt(bytes, offset + 11, offset + 13);
        int minutes = getInt(bytes, offset + 14, offset + 16);
        int seconds = getInt(bytes, offset + 17, offset + 19);
        // nanos from MySQL fractional
        int nanos;
        if (length == TIMESTAMP_STR_LEN_WITH_NANOS) {
            nanos = getInt(bytes, offset + 20, offset + length);
        } else {
            nanos = (length == TIMESTAMP_STR_LEN_NO_FRAC) ? 0 : getInt(bytes, offset + 20, offset + length);
            // scale out nanos appropriately. mysql supports up to 6 digits of fractional seconds, each additional digit increasing the range by a factor of
            // 10. one digit is tenths, two is hundreths, etc
            nanos = nanos * (int) Math.pow(10, 9 - (length - TIMESTAMP_STR_LEN_NO_FRAC - 1));
        }

        return new InternalTimestamp(year, month, day, hours, minutes, seconds, nanos, scale);
    }
}
//「StringValueFactory.java」78行目付近でString Formatで修正

    /**
     * Create a string from InternalTime. The fields are formatted in a HH:MM:SS[.nnnnnnnnn] format.
     * 
     * @param it
     *            {@link InternalTime}
     * @return string
     */
    public String createFromTime(InternalTime it) {
        if (it.getNanos() > 0) {
            return String.format("%02d:%02d:%02d.%09d", it.getHours(), it.getMinutes(), it.getSeconds(), it.getNanos());
        }
        return String.format("%02d:%02d:%02d", it.getHours(), it.getMinutes(), it.getSeconds());
    }

8.0.19~8.0.22の場合

制御方法は8.0.18と同じですが、コンマ秒以下のが9桁で出力されていた「StringValueFactory.java」ですが、コンマ秒以下の表記を「TimeUtil」クラスで行うように変更されています。

//「StringValueFactory.java」78行目付近
//TimeUtil.formatNanosが追加されている
    /**
     * Create a string from InternalTime. The fields are formatted in a HH:MM:SS[.nnnnnnnnn] format.
     * 
     * @param it
     *            {@link InternalTime}
     * @return string
     */
    public String createFromTime(InternalTime it) {
        if (it.getNanos() > 0) {
            return String.format("%02d:%02d:%02d.%s", it.getHours(), it.getMinutes(), it.getSeconds(),
                    TimeUtil.formatNanos(it.getNanos(), it.getScale(), false));
        }
        return String.format("%02d:%02d:%02d", it.getHours(), it.getMinutes(), it.getSeconds());
    }
//「TimeUtil.java」235行目付近
   /**
     * Return a string representation of a fractional seconds part. This method assumes that all Timestamp adjustments are already done before,
     * thus no rounding is needed, only a proper "0" padding to be done.
     * 
     * @param nanos
     *            fractional seconds value
     * @param fsp
     *            required fractional part length
     * @param truncateTrailingZeros
     *            whether to remove trailing zero characters in a fractional part after formatting
     * @return fractional seconds part as a string
     */
    public static String formatNanos(int nanos, int fsp, boolean truncateTrailingZeros) {
        if (nanos < 0 || nanos > 999999999) {
            throw ExceptionFactory.createException(WrongArgumentException.class, "nanos value must be in 0 to 999999999 range but was " + nanos);
        }
        if (fsp < 0 || fsp > 6) {
            throw ExceptionFactory.createException(WrongArgumentException.class, "fsp value must be in 0 to 6 range but was " + fsp);
        }

        if (fsp == 0 || nanos == 0) {
            return "0";
        }

        // just truncate because we expect the rounding was done before
        nanos = (int) (nanos / Math.pow(10, 9 - fsp));
        if (nanos == 0) {
            return "0";
        }

        String nanosString = Integer.toString(nanos);
        final String zeroPadding = "000000000";

        nanosString = zeroPadding.substring(0, fsp - nanosString.length()) + nanosString;

        if (truncateTrailingZeros) {
            int pos = fsp - 1; // the end, we're padded to the end by the code above
            while (nanosString.charAt(pos) == '0') {
                pos--;
            }
            nanosString = nanosString.substring(0, pos + 1);
        }
        return nanosString;
    }

8.0.23~の場合

8.0.23以降はMySQL Blogより日付型の取得・設定方法の見直しを行ったようで、取得方法の仕様が変更されています。

8.0.22との違いはMysqlTextValueDecoder.javaまでは同じで、「StringValueFactory.java」から処理が異なっておりました。

//「StringValueFactory.java」 82行目付近
//InternalTime型に格納し、toString出力している。   

 /**
     * Create a string from InternalTime. The fields are formatted in a HH:MM:SS[.nnnnnnnnn] format.
     * 
     * @param it
     *            {@link InternalTime}
     * @return string
     */
    public String createFromTime(InternalTime it) {
        return it.toString();
    }

    /**
     * Create a string from time fields. The fields are formatted by concatenating the result of {@link #createFromDate(InternalDate)} and {@link
     * #createFromTime(InternalTime)}.
     * 
     * @param its
     *            {@link InternalTimestamp}
     * @return string
     */
    public String createFromTimestamp(InternalTimestamp its) {
        return String.format("%s %s", createFromDate(its),
                createFromTime(new InternalTime(its.getHours(), its.getMinutes(), its.getSeconds(), its.getNanos(), its.getScale())));
    }
//   「InternalTime.java」141行目付近
// toStringをOverrideしている
@Override
    public String toString() {
        if (this.nanos > 0) {
            return String.format("%02d:%02d:%02d.%s", this.hours, this.minutes, this.seconds, TimeUtil.formatNanos(this.nanos, this.scale, false));
        }
        return String.format("%02d:%02d:%02d", this.hours, this.minutes, this.seconds);
    }

【補足】JDBC8.0.23以降はタイムゾーン設定によるシステムエラーがなくなった。

MySQLでタイムゾーンを設定するとクライアント側とうまくいかず、システムエラーや日付がうまくとれないなどで悩まされていた人がいたかと思いますが、
JDBC8.0.23以降では解消されるようです。
逆にあれこれやってたシステムは直さないといけないのでどちらにしろ大変なきがしますが。

試しに「SET GLOBAL time_zone = 'Asia/Tokyo';」でサーバのローカルタイムゾーンを変更し、サーバ再起動実施後に接続してみると
JDBC8.0.22以下・・・「java.sql.SQLException: The server time zone value '' is unrecognized or represents more than one time zone. You must configure either the server or JDBC driver (via the serverTimezone configuration property) to use a more specifc time zone value if you want to utilize time zone support.」が発生

JDBC8.0.23以上・・・データ取得可能

でした。

JDBCのバージョンを上げる際は確認しよう

8.0.xとメンテナンスバージョンであっても、仕様などが大きく変わるんだなと思いました。
Timestamp型は2038年問題の点から今後DateTimeに変更することになると思いますが、バージョンアップする際は何が変わったのか確認したうえで上げた方がいいのでしょうね。

-開発, JAVA, SQL
-, , ,