C# DataAdapter.Update() で DBNull 例外が出た時の最短対処

C# DataAdapter.Update()でDBNull例外が出た時の最短対処

朝、客先で席に座った瞬間に「障害発生してます」と肩を叩かれる。ログを見たらSystem.InvalidCastExceptionの文字列の下にDBNullが並んでる。心当たりはある。昨日触ったDataAdapter.Update()だ。

俺もまったく同じ場面で30分溶かしたことがあるので、今回はC#のDataAdapterでDBNull起因の例外が出た時に、コピペで動かして帰れる最短対処をまとめておく。.NET Framework 4.7.2 / SQL Server 2016時代の現場で実際に効いた書き方なので、レガシー業務系で同じ構成の人にはそのまま刺さるはず。

目次

結論:DBNull例外は「nullとDBNull.Valueの使い分け」で9割解決する

先に答えを置いておくと、DataAdapter.Update()DBNullで落ちる時の対処は、ほぼ次の3つで足りる。

  1. SqlParameter.ValuenullではなくDBNull.Valueを渡す
  2. DataTableに書き戻す側でも、nullをDBNull.Valueに揃える
  3. 取得時のキャストはis DBNullで先にガードしてから受ける

裏を返すと、C#のnullとADO.NETのDBNull.Value別物で、DataAdapterはその境界線をまったく面倒見てくれない。SqlCommandのパラメータにnullを渡すと、DataAdapter.Update()の段階で「パラメータが指定されていません」系の例外が飛んでくる。これがまず1個目の事故ポイント。

C# null と DBNull.Value の境界線フロー

なぜDataAdapter.Update()でDBNull例外が出るのか

DBNullが原因でUpdate()が落ちる典型パターンは、ざっくり3つに分かれる。現場で見かけるのはほぼこのどれか。

パターン1: SqlParameterにC#のnullをそのまま渡している

これが一番多い。よくあるのは、画面のテキストボックスから取った値をそのままパラメータに突っ込むケース。

// NGパターン:nullをそのまま渡してる
var p = new SqlParameter("@bikou", SqlDbType.NVarChar);
p.Value = textBoxBikou.Text == "" ? null : textBoxBikou.Text;  // ←ここで爆発の種を仕込む
cmd.Parameters.Add(p);

textBoxBikou.Textが空文字の時にp.Value = nullになる。これだとDataAdapter.Update()の実行時に、ADO.NETが「valueがセットされていない」と判定してパラメータごと落とす。SQL Server側からすると、そもそもパラメータが届いていない扱いになる。

パターン2: DataTableの行にnullを入れている

DataTableに手で値をセットする時にも同じ落とし穴がある。

// NGパターン:DataTableの列にnullを直接入れる
row["bikou"] = string.IsNullOrEmpty(input)? null : input;

このままUpdate()を叩くと、行がDataRowState.Modifiedになっているのに値がnull、というよく分からない状態になる。DataAdapterが組み立てるUPDATE文のパラメータにnullが混じって、結局パターン1と同じ場所で落ちる。

パターン3:取得時にDBNullをstringにキャストしている

これはUpdateの手前、DataTableに積んだ後で起きることが多い。

// NGパターン:DBNullをstringにキャストしようとしている
string bikou = (string)row["bikou"];  // DBNull.ValueならInvalidCastException

取得側の例外なので「Updateが落ちた」と勘違いしやすいが、スタックトレースをよく見るとUpdateで詰んでいる。Updateのせいに見えて、実は手前の取得処理でキャストミスしている、というやつ。俺もこの勘違いで30分溶かした。

最短対処:コピペで動く3つの書き方

ここからが本題。上の3パターンに対して、現場で実際に効いた書き方を順に出していく。全部.NET Framework 4.7.2で動作確認済みの書き方なので、VS2019時代の業務系で問題なく入る。

対処1: nullをDBNull.Valueに変換する拡張メソッドを作る

毎回三項演算子を書くと記述量がしんどいので、拡張メソッドにしてしまうのが楽。

public static class DbNullExtensions
{
    /// <summary>nullならDBNull.Value、そうでなければ自身を返す</summary>
    public static object ToDbValue(this object value)
    {
        return value ?? DBNull.Value;
    }
}

呼び出し側はこうなる。

var p = new SqlParameter("@bikou", SqlDbType.NVarChar);
p.Value = (textBoxBikou.Text == "" ? null : textBoxBikou.Text).ToDbValue();
cmd.Parameters.Add(p);

これでnullを渡しても自動でDBNull.Valueに置き換わる。1行で済むので使い勝手がいい。

null と DBNull.Value の変換イメージ

対処2: DataTableに書き戻す時もDBNull.Valueで揃える

DataTableを編集してからUpdate()を呼ぶ場合は、こっちの書き方になる。

//値を入れる前にDBNull.Valueに揃える
row["bikou"] = string.IsNullOrEmpty(input)
    ? (object)DBNull.Value
    : input;

(object)キャストが地味に重要。stringDBNullは型が違うので、三項演算子で混ぜるとコンパイルが通らないことがある。(object)で揃えると三項演算子の戻り値型がobjectに決まるので、ここを通せる。

対処3:取得時はis DBNullでガードしてから受ける

Update()直前にDataTableから値を読み出すなら、is DBNullでガードしてからキャストするのが安全。

string bikou = row["bikou"] is DBNull
    ? null
    : (string)row["bikou"];

短く書きたいなら、これも拡張メソッド化できる。

public static T AsOrDefault<T>(this object value)where T : class
{
    return value is DBNull ? null : value as T;
}

//呼び出し側
string bikou = row["bikou"].AsOrDefault<string>();

as演算子はclass制約付きなので参照型でしか使えない。ここは値型と分けて書く必要がある。詳しくは後の「ハマりポイント」で書く。

ハマりポイント:知らないと一晩飛ぶやつ

ここからは、最短対処を入れたに踏むやつ。俺が現場で実際に踏んだ順に並べておく。

値型(int / DateTime)にはasが使えない

上のAsOrDefault<T>を、そのままのノリでint?を取ろうとすると詰む。

//コンパイルエラー:where T : classが効いてる
int? amount = row["amount"].AsOrDefault<int?>();

Nullable<int>は構造体なので、class制約に引っかかってメソッドごと呼べない。値型用は別で書く。

public static T? AsOrNull<T>(this object value)where T : struct
{
    return value is DBNull ? (T?)null : (T)value;
}

//呼び出し側
int? amount = row["amount"].AsOrNull<int>();
DateTime? sakuseiBi = row["sakusei_bi"].AsOrNull<DateTime>();

AsOrDefaultAsOrNull参照型と値型を分ける。これに気付かずに同じメソッド名で混在させると、片方だけ動く謎仕様の関数ができて、後でほぼ確実に詰む。俺はこれで一晩潰した。

Nullable<T>をSqlParameterに渡す時の暗黙変換

もう1つ、地味に詰まるのがint?SqlParameterに渡す時。

//動くと思いきや微妙に怪しい書き方
int? amount = null;
var p = new SqlParameter("@amount", SqlDbType.Int);
p.Value = amount;  // ← nullがDBNull.Valueに化けてくれない

Nullable<int>.ValueはあくまでC#のnull。SqlParameter.Valueに渡してもDBNull.Valueには化けない。素直にこう書く。

p.Value = amount.HasValue ? (object)amount.Value : DBNull.Value;

これも結局「nullDBNull.Valueは別物」という1個目の話に戻る。NullableはC#側でnullを表現する型なので、ADO.NETの世界に出す時はDBNull.Valueに翻訳する、という1ステップを挟むと事故が減る。

System.InvalidCastException:型'System.DBNull'のオブジェクトを型'System.String'にキャストできません。

このエラーが出てる時点で、ほぼ間違いなくこの境界線のどこかで翻訳をサボっている。

現場メモ:障害対応で時間を溶かさないために

ここからは記事の本題から少し離れる、現場での話。

C# WinForms / DataAdapter /生SQLの構成は、ReactやTypeScriptのキラキラ系から見ると「時間が止まった現場」みたいに言われやすい。同期は転職して年収700万らしい、みたいな話を聞いて気が滅入る日もあると思う。俺も流通系のSIer正社員だった時、額面450万・残業30hで「このまま5年経ったら市場価値どうなってんだ」と毎月思ってた。

ただ、業務系のDataAdapterのような「枯れた構成」ほど、ハマるポイントが固定されているという強みがある。今日書いた「nullDBNull.Valueの境界線」みたいなやつは、20年前から踏まれていて、20年後もたぶん踏まれる。この境界線を3つくらい知っているだけで、障害対応の時間が半分になる

C#は完全な型言語なので、ここで身につけた「nullをどう扱うか」「型変換でどこに事故が起きるか」という感覚は、TypeScript / Java / Kotlinに持っていけばそのまま使える知識になる。野球で言えばショートみたいなもので、内野ならどこのポジションにも回れる。俺自身、C#経験を引きずってTypeScriptの現場に入ったが、最初に詰まったポイントは結局「nullの扱い」だった。同じだった。

「C#しかできない」じゃなくて「型言語を1つ深くやっている」と読み替えると、今日のこのDBNullの話も、無駄な時間つぶしじゃなくて将来に積み上がっている時間に見えてくる。

まとめ

  • C#のnullとADO.NETのDBNull.Valueは別物。SqlParameterにnullを渡すとDataAdapter.Update()で落ちる
  • 対処は3つ:拡張メソッドでnull → DBNull.Valueに変換/ DataTableに書く時は(object)DBNull.Valueで型を揃える/取得時はis DBNullでガードしてから受ける
  • 値型はwhere T : structの別メソッドを用意して、AsOrDefaultAsOrNullで参照型と値型を分ける
  • Nullable<T>SqlParameter.Valueに渡す時は、HasValueで分岐して明示的にDBNull.Valueを出す

ここまで覚えておけば、朝の障害対応でDBNullのスタックトレースを見ても焦らずに済む。

よくある質問

Q1. SqlCommandのAddWithValueでも同じ問題は起きますか?

起きる。AddWithValue("@bikou", null)も同じく落ちるので、AddWithValue("@bikou", value ?? (object)DBNull.Value)の形で書く。AddWithValueは型推論の挙動が癖があるので、できればSqlParameterを明示的に組んだほうが事故が少ない。

Q2. DataAdapterではなくDataReaderならこの問題は起きない?

DataReaderでもis DBNullのガードは必要。reader["col"]DBNull.Valueの状態で(string)キャストすると同じInvalidCastExceptionが飛ぶ。reader.IsDBNull(ordinal)で先にチェックしてから取りに行くのが定石。

Q3. Entity FrameworkやDapperならこの境界線は意識しなくていい?

EF Core / Dapperだと、nullDBNull.Valueの翻訳は内部でやってくれるので、ほぼ意識しなくて済む。ただ、業務系で.NET Framework 4.7.2 / DataAdapter + DataTable構成の現場ではORM採用が止まっているケースが多いので、当面この境界線とは付き合うことになる。

Q4. DataAdapter.Update()の前にどうやって落ちる場所を特定する?

DataTable.GetChanges(DataRowState.Modified)で更新対象だけ抜き出してから、各行のカラム値をrow.IsNull("colname")でログに吐くと早い。「どの行のどの列でnullとDBNull.Valueが混ざっているか」が見えれば、原因の半分は終わっている。

Q5. RowUpdatingイベントで強制的に書き換える方法もあると聞きましたが?

SqlDataAdapter.RowUpdatinge.Command.Parametersを直接書き換える手もある。ただ、これはイベント側で全パラメータを舐め直すので可読性が落ちやすい。最初は呼び出し側でDBNull.Valueに揃えておく方をおすすめする。RowUpdatingは「呼び出し側を直せない既存コードに後付けで挟む」用途で取っておくのがいい。


以上!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次