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


目次