アジョブジ星通信

進捗が出た頃に更新されるブログ。

MySQL Connector/.NET の TreatTinyAsBoolean のバグと Dapper

MySQL Connector/.NET、長いので MySql.Data と呼びます。

バグについて

MySql.Data の接続文字列で使用できるオプションとして、「TreatTinyAsBoolean」または「Treat Tiny As Boolean」というものがあります。これはデフォルトで有効になっていて、このオプションが有効のときは TINYINT(1) のカラムを Boolean 型として扱います。

で、常に Boolean として扱ってくれるかというとそうではないという問題があります。 TINYINT(1) 型のカラムに null が入っていると、それ以降のレコードは Boolean ではなく SByte になります。これによってありがた迷惑な機能から、ただの迷惑な機能に昇格しましたね。おめでとう。

再現してみましょう。適当な MySQL サーバーを用意してえいっと。ところで MySql.Data 7.0.x は人柱機能の X Protocol のために Protobuf ライブラリに依存するのやめろオラ。

using System;
using MySql.Data.MySqlClient;

public static class Program
{
    public static void Main(string[] args)
    {
        using (var connection = new MySqlConnection("Server=localhost;User Id=root;Password=foo;Database=hello_work"))
        {
            connection.Open();

            // テーブル作成
            using (var command = connection.CreateCommand())
            {
                command.CommandText = "CREATE TEMPORARY TABLE BoolTest (Value TINYINT(1) NULL)";
                command.ExecuteNonQuery();
            }

            // データを突っ込む
            using (var command = connection.CreateCommand())
            {
                command.CommandText = "INSERT INTO BoolTest (Value) VALUES (1), (NULL), (1)";
                command.ExecuteNonQuery();
            }

            // 取得する
            using (var command = connection.CreateCommand())
            {
                command.CommandText = "SELECT * FROM BoolTest";

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var value = reader.GetValue(0);

                        if (value is DBNull)
                        {
                            Console.WriteLine("NULL");
                        }
                        else
                        {
                            Console.WriteLine("{0} ({1})", value, value.GetType().Name);
                        }
                    }
                }
            }
        }
    }
}

結果

True (Boolean)
NULL
1 (SByte)

この実験は MySql.Data 6.9.9 を使用しました。 7.0.6 は NuGet から落としたら厳密名の検証エラー起きて死んだので試してはいませんが、ソースコードを見る限り直っていなさそうです。

Dapper との相性

これによってどうなるか、 Dapper が死にます。さっきのコードを Dapper を使って試してみましょう。

using System;
using Dapper;
using MySql.Data.MySqlClient;

public class BoolTest
{
    public bool? Value { get; set; }
}

public static class Program
{
    public static void Main(string[] args)
    {
        using (var connection = new MySqlConnection("Server=localhost;User Id=root;Password=foo;Database=hello_work"))
        {
            // コネクションが開いていない状態で Dapper を使うと
            // クエリ実行時に開いて、終了したら閉じるという挙動をするのは
            // 知られている?知られていない?
            // 今回は一時テーブルを使うので閉じられたら死ぬ(コネクションプールがあるからどうなるかわからないけれど)
            connection.Open();

            // テーブル作成
            connection.Execute("CREATE TEMPORARY TABLE BoolTest (Value TINYINT(1) NULL)");

            // データを突っ込む
            connection.Execute("INSERT INTO BoolTest (Value) VALUES (1), (NULL), (1)");

            // 取得する
            try
            {
                // どこまで読み取れたかわかりやすくするためにバッファーしないようにする
                foreach (var record in connection.Query<BoolTest>("SELECT * FROM BoolTest", buffered: false))
                {
                    Console.WriteLine(record.Value?.ToString() ?? "NULL");
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }
    }
}

結果

True
NULL
System.Data.DataException: Error parsing column 0 (Value=1 - SByte) ---> System.InvalidCastException: Specified cast is not valid.
   at Deserialize84a67bd6-5dd5-4b8d-aef5-255e37628f48(IDataReader )
   --- End of inner exception stack trace ---
   at Dapper.SqlMapper.ThrowDataException(Exception ex, Int32 index, IDataReader reader, Object value)
   at Deserialize84a67bd6-5dd5-4b8d-aef5-255e37628f48(IDataReader )
   at Dapper.SqlMapper.<QueryImpl>d__125`1.MoveNext()
   at Program.Main(String[] args) in c:\users\azyob\documents\visual studio 2015\Projects\MySqlBugTest\MySqlBugTest\Program.cs:line 33

Dapper は最初の 1 レコードを見てデシリアライザを作るので、こういうことになります。デシリアライザは DynamicMethod だからデバッグで入り込めない(WinDbg しろという意見もある)のがつらい。

対応状況

このバグは1年以上前に報告されています。
MySQL Bugs: #78917: Connector modifies result type after parent tinyint value is null
が、中の人が誰も相手にしない、ふざけるな。「marked as duplicate」とか言ってる間に直せや。

Dapper 側でも報告されており、 MySql.Data 早く直せやという話になっています。
Dapper fails on MySql nullable bool · Issue #552 · StackExchange/dapper-dot-net · GitHub

Oracle を許さない。絶対に。

対処方法

1. TreatTinyAsBoolean の無効化

接続文字列に TreatTinyAsBoolean=false を追加すれば、すべて SByte 扱いになるので、型が突然変わる心配がなくなります。大抵のマッパーなら数値型から Boolean への変換は用意されているでしょうし。

2. MySql.Data を直す

原因はここで TreatAsBoolean プロパティをコピーしていないからです。一瞬で直せるでしょう。直したらついでに僕の代わりに Oracle Contribution Agreement にサインしてパッチを投げつけておいてください。

まとめ

MySql.Data のソースコードはまともな C#er が書いたとは思えない代物なので、早く他のライブラリに枯れてもらいたい。