読者です 読者をやめる 読者になる 読者になる

この日記は私的なものであり所属会社の見解とは無関係です。 GitHub: takahashikzn

MariaDB Connector/Jの1.3.0以降でBOOLEANのカラムが常にtrueになるバグ?

Java

ちょっと前にConnector/Jが1.3.0にアップデートされたのですが、とたんにTINYINT(1)のカラムの値を正しく取得できなくなりました。

MySQLはBOOLEANをTINYINT(1)の別名として扱います。アプリケーションから見たらこのカラムはBoolean型です。

で、Connector/Jのソースを追っかけて行った所、ドライバ側のバグだろうという結論に。

org.mariadb.jdbc.internal.queryresults.MariaDBValueObjectのgetObjectメソッドが怪しいです。以下抜粋。

public Object getObject(int datatypeMappingFlags, Calendar cal) throws ParseException {
    if (this.getBytes() == null) {
        return null;
    }
    switch (dataType) {
        case BIT:
            if (columnInfo.getLength() == 1) {
                return (getBytes()[0] != 0); // 正しい判定方法
            }
            return getBytes();
        case TINYINT:
            if ((datatypeMappingFlags & TINYINT1_IS_BIT) != 0 && columnInfo.getLength() == 1) {
                /* 
                    ココがおかしい
                    (byte) '0' == (byte) 48 である
                    "case BIT"の判定方法は正しいのに、何故こちらは文字との比較?
                */
                return (getBytes()[0] != '0'); 
            }
            return getInt();

        ...
}

ちなみに、getBytesはDBから読み出した生のバイト列を返します。

getBooleanはセーフ

getBooleanを使う場合は正しく値を取得できます。文字列で比較しているからです。

// MariaDBValueObject#getBoolean
public boolean getBoolean() {
    if (rawBytes == null) {
        return false;
    }
    final String rawVal = new String(rawBytes, StandardCharsets.UTF_8);
    return rawVal.equalsIgnoreCase("true") || rawVal.equalsIgnoreCase("1") || (rawBytes[0] & 0x1) == 1;
}

しかしまあ、よくもこんな手抜きコードを書けたものですなあ。コードレビューしてないのか?

MySQLJDBCで扱う場合、Boolean型のカラムが大量にあると性能に悪影響がありそうです。

回避策

上記のようにBoolean型のカラムが常にtrueになってしまうので、TINYINTをBITとして扱わせるのをやめて数値で扱うようにしました。

JDBCのURLのクエリパラメータに

tinyInt1isBit=false

を追加すればOK。

なぜか1.2.2でも同じコード

しかし解せないのは、このバグが発生しない1.2.2でも全く同じコードだったということです。以下抜粋。

// MySQLValueObject#getObject
public Object getObject(int datatypeMappingFlags, Calendar cal) throws ParseException {
    if (this.getBytes() == null) {
        return null;
    }
    switch (dataType) {
        case BIT:
            if (columnInfo.getLength() == 1) {
                return (getBytes()[0] != 0); // 正しい
            }
            return getBytes();
        case TINYINT:
            if ((datatypeMappingFlags & TINYINT1_IS_BIT) != 0) {
                if (columnInfo.getLength() == 1) {
                    return (getBytes()[0] != '0'); // 正しくない
                }
            }
           return getInt();

    ...
}

何なんでしょうね一体…これ以上調べる気力が起きないので一先ずはここまで。

2016-03-04追記

リリース1.3.6で修正されました。

https://mariadb.atlassian.net/browse/CONJ-255

問題の箇所が

case TINYINT:
    if (options.tinyInt1isBit && columnInfo.getLength() == 1) {
        if (!this.isBinaryEncoded) {
            return rawBytes[0] != '0';
        } else {
            return rawBytes[0] != 0;
        }
    }
    return getInt();

のように修正されており、byte値の0とcharの'0'の場合でそれぞれ正しい判定がなされるようになっています。

ついでに、ひどい手抜き実装だったgetBooleanも

public boolean getBoolean() throws SQLException {
    if (rawBytes == null) {
        return false;
    }
    if (!this.isBinaryEncoded) {
        if (rawBytes.length == 1 && rawBytes[0] == 0) {
            return false;
        }
        final String rawVal = new String(rawBytes, StandardCharsets.UTF_8);
        return !("false".equals(rawVal) || "0".equals(rawVal));
    } else {
        switch (dataType) {
            case BIT:
                return rawBytes[0] != 0;
            case TINYINT:
                return getTinyInt() != 0;
            case SMALLINT:
            case YEAR:
                return getSmallInt() != 0;
            case INTEGER:
            case MEDIUMINT:
                return getMediumInt() != 0;
            case BIGINT:
                return getLong() != 0;
            case FLOAT:
                return getFloat() != 0;
            case DOUBLE:
                return getDouble() != 0;
            default:
                final String rawVal = new String(rawBytes, StandardCharsets.UTF_8);
                return !("false".equals(rawVal) || "0".equals(rawVal));
        }
    }
}

のようにリファクタリングされました。