SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム

SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム

みなさんこんにちは!ヒロポンです!!

今回は業務系の C# でガチで踏みやすいやつ!!の話。

朝、客先で席に着いて Outlook を開いた瞬間、「また昨日のバッチが落ちてます」と肩を叩かれる、ってこと、ないっすか??スタックトレースを見ると System.InvalidCastException: 型 'System.DBNull' のオブジェクトを型 'System.String' にキャストできません と書いてある。原因はだいたい1行で済む話で、Convert.ToInt32(reader["amount"]) が、テーブルの NULL を int にキャストしようとして爆発しているだけ。業務系の C# で DBNull に絡む例外は、たぶん全エラーの2割を占める くらい頻度が高い。

俺も2社目くらいでこれにガッツリハマった経験があって、半日デバッガで追って詰まった夕方を挟んでから「ん?毎回これ同じ罠やん?」となって、それ以来「DBNull 周りは書き方を1パターンに揃える」運用に切り替えたんですよね。今回は C# で SQL Server の DBNull を安全にハンドリングする5つのイディオム を、DataReader / DataAdapter / EF Core の3シナリオでコピペで動く形でまとめておきます。.NET Framework 4.7.2 / SQL Server 2016 の業務系を想定して、DataAdapter + DataTable + 生 SQL の構成で動く書き方を中心に置いてて、こんな感じで判断軸も整理してます。

目次

結論:DBNull の握り方は「読み出す層」で決まる

先に答え。SQL Server の値が NULL になる可能性がある時、C# 側の握り方は 値を取り出す層がどこか で決まる。

  1. 手で値を読む(DataRow / object) → イディオム1: is DBNull 比較
  2. 三項演算子で1行で潰したい → イディオム2: ?? 演算子 + Convert.IsDBNull
  3. DataReader でストリーム読み取り → イディオム3: reader.IsDBNull(i) 先行チェック
  4. DataTable を扱っているなら → イディオム4: DataRow.Field<int?>() で Nullable 受け(業務系で最強)
  5. EF Core / モダン構成なら → イディオム5: クラスのプロパティを int? で定義して自然マップ

業務系の現場で一番出番が多いのはイディオム4の DataRow.Field<int?>()。型安全で、null 許容型として受けられて、コードがシンプル。残り4つは「使えるけど代替案あり」のポジション。

関連する基礎は既存記事の C# DataAdapter.Update() で DBNull 例外が出た時の最短対処 でも触れているので、更新側で DBNull が絡む時の単発処理だけ知りたい人はそちらを先に読むと早い。今回はそこから踏み込んで、取得側で5パターン並行して使い分ける時の判断軸 に焦点を絞る。

イディオム1: is DBNull で直接比較する基本形

一番低レベルのやり方。DataRow から object で値を取って、その場で is DBNull で分岐する。

DataRow row = dt.Rows[0];
object val = row["bikou"];  // この時点で値は object 型

string bikou = val is DBNull
    ? null
    : (string)val;

is DBNull は型パターンマッチ(C# 7+)で、valDBNull.Value インスタンスかを判定する。val == DBNull.Value でも書けるが、is のほうが意図が明確 で、null チェックと並べた時の一貫性も高い。

// 整数列を扱う場合
int? amount = row["amount"] is DBNull
    ? (int?)null
    : Convert.ToInt32(row["amount"]);

値型(int / DateTime)は Nullable<T> で受けて、(int?)null のように明示的にキャストする必要がある。三項演算子の戻り値型を C# が決められるように、片方を (int?)null にしておく。

欠点: 1〜2列だけならいいが、列が多くなると is DBNull の三項演算子が並んで読みにくくなる。20列のテーブルを毎行読むコードでこれを書くと、行頭が三項演算子だらけになる。まとまったテーブル処理にはイディオム4の DataRow.Field<T>() のほうが向く

イディオム2: Convert.IsDBNull + ?? 演算子のシンプルパターン

少し読みやすくしたい時は、Convert.IsDBNull 関数を使って ?? 演算子と組み合わせる。

// 文字列列の null 安全取得
string bikou = Convert.IsDBNull(row["bikou"]) ? null : row["bikou"].ToString();

// あるいは ?? と組み合わせて空文字フォールバック
string bikouOrEmpty = (Convert.IsDBNull(row["bikou"]) ? null : row["bikou"].ToString()) ?? string.Empty;

Convert.IsDBNullval == DBNull.Value を内部でやっているだけ なので挙動はイディオム1と同じだが、関数名の自己説明性が高い。チームに新人が入った時に「これは何をしているの?」と聞かれない書き方になる。

Convert.ToString(null)Convert.ToInt32(null) 系は null を渡すと空文字や 0 を返してくれる ので、これだけで凌げそうに見えるが、

// 罠:Convert.ToInt32(DBNull.Value) は 0 を返す
int amount = Convert.ToInt32(row["amount"]);
// row["amount"] が DBNull だと amount = 0 になる
// 「金額0円」と「未入力」を区別したい業務系では事故の元

「未入力」と「0」を意味的に区別したい場面では、Convert 系を裸で使うと事故る。明示的に IsDBNull を分岐に挟む書き方が安全。

欠点: コードはイディオム1より読みやすいが、結局「null チェックを毎列で書く」構造は変わらない。多列処理ならイディオム4へ移行するメリットのほうが大きい。

イディオム3: DataReader.IsDBNull(i) で読み取り時に分岐

SqlDataReader でストリーム処理する時の鉄板。reader.IsDBNull(ordinal) で先にチェックしてから取りに行く。

using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("SELECT id, name, amount FROM customers", conn))
{
    conn.Open();
    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            int id = reader.GetInt32(0);
            string name = reader.IsDBNull(1) ? null : reader.GetString(1);
            int? amount = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2);

            // 業務処理...
        }
    }
}

reader.GetInt32(2)DBNull が来た瞬間に例外を投げる 設計なので、IsDBNull(2) を先に呼んで分岐する習慣をつける。順序を逆にすると InvalidCastException で本番障害になる。

DataReader はメモリ消費が少なく、大量行(数十万件以上)のバッチ処理に向く。逆に、列数が多くて毎列で IsDBNull を書くのは面倒。

// 拡張メソッド化すると行が圧縮できる
public static class SqlReaderExtensions
{
    public static T? GetNullable<T>(this SqlDataReader reader, int ordinal)
        where T : struct
        => reader.IsDBNull(ordinal) ? (T?)null : (T)reader.GetValue(ordinal);

    public static string GetStringOrNull(this SqlDataReader reader, int ordinal)
        => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
}

// 呼び出し側
int? amount = reader.GetNullable<int>(2);
string name = reader.GetStringOrNull(1);

業務系で DataReader を多用するならこの拡張メソッドを Common 系のクラスに置いておくと、画面ごとに同じ書き方ができる。

欠点: ordinal(カラム序数)で指定するので、SELECT 句を変えると ordinal が崩れる。列名指定の reader.GetOrdinal("name") を使うか、SELECT * を避けて固定の SELECT 句で運用するのが現実的。

イディオム4: DataRow.Field<T>() で Nullable<T> 受け(業務系最強)

ここが業務系で 圧倒的に出番が多い書き方DataRow.Field<T>() 拡張メソッド(System.Data.DataSetExtensions 名前空間)は、Nullable を直接受けられる。

using System.Data;

DataRow row = dt.Rows[0];

// 値型 → Nullable<T> で受ける
int? amount = row.Field<int?>("amount");      // DBNull → null に自動変換
DateTime? createdAt = row.Field<DateTime?>("created_at");

// 参照型は Nullable 不要
string name = row.Field<string>("name");      // DBNull → null に自動変換

これだけ。is DBNullConvert.IsDBNull も不要で、ジェネリックの型引数として int? を渡すだけで DBNull を null に翻訳してくれる。読み手にも「null になり得る列」が型シグネチャから一発で伝わる。

業務系の DataAdapter + DataTable 構成で、20列のテーブルを順次表示する画面なら、

foreach (DataRow row in dt.Rows)
{
    var customer = new CustomerVm
    {
        Id = row.Field<int>("id"),                      // NOT NULL
        Name = row.Field<string>("name"),               // NULL 許容
        Amount = row.Field<int?>("amount"),             // NULL 許容
        CreatedAt = row.Field<DateTime>("created_at"),  // NOT NULL
        Bikou = row.Field<string>("bikou"),             // NULL 許容
    };
    customers.Add(customer);
}

20列でも見通しがよく、列ごとに NULL 許容かどうかがコードの上で自己ドキュメント化される。DataRow.Field<T> を知っているかどうかで、業務系のコード可読性が体感で2倍違うレベル。

欠点: System.Data.DataSetExtensions.dll の参照が必要(.NET Framework 4.0 以降は標準だが、新規プロジェクトテンプレートで省かれることがある)。.NET 5+ の SDK スタイルプロジェクトでは System.Data.DataSetExtensions パッケージを別途引く必要があるケースもあるので、参照漏れに注意。

イディオム5: EF Core で Nullable<T> プロパティに自然マップ

EF Core / EF6 を使えるプロジェクトなら、そもそも DBNull を意識する必要がほぼなくなる。

public class Customer
{
    public int Id { get; set; }                  // NOT NULL
    public string Name { get; set; }             // NULL 許容
    public int? Amount { get; set; }             // NULL 許容
    public DateTime CreatedAt { get; set; }      // NOT NULL
    public string Bikou { get; set; }            // NULL 許容
}

// 取得側
var customers = await context.Customers
    .Where(c => c.Amount > 1000)
    .ToListAsync();

// c.Amount が NULL の行は Where 句で除外される(NULL 比較は false 扱い)

クラス側のプロパティを int? / string で書いておくだけで、EF Core が DB の NULL を C# の null に翻訳 してくれる。DBNull.Value という型を C# 側で見ることがほぼなくなる。

ただし EF Core 固有の罠がある。

// 罠:null 比較が SQL に翻訳される時の挙動
var noBikou = await context.Customers
    .Where(c => c.Bikou == null)
    .ToListAsync();

// EF Core 6+: 自動で `WHERE Bikou IS NULL` に変換される(期待通り)
// EF6 (旧): `WHERE Bikou = NULL` になることがあり、SQL では常に false なので結果0件

EF6 の古い挙動を踏むと「null の行が引けない」障害になる。バージョンと挙動の組み合わせは Microsoft Docs の Null semantics で確認しておくと事故が減る。

欠点: EF Core が使える前提が必要。.NET Framework 4.7.2 / DataAdapter + DataTable + 生 SQL の業務系現場では、ORM 採用が止まっていることが多くて、このイディオムが選択肢に入らないケースも多い。「将来 ORM に移れたら強い」くらいの位置づけで頭の片隅に置く。

機能比較表:5イディオムの使い分け

イディオム データアクセス層 コード行数 型安全性 業務系の出番
1: is DBNull DataRow / object 中(毎列で分岐) △ object キャスト要 △ 単発処理のみ
2: Convert.IsDBNull + ?? DataRow / object △ 同上 ○ 自己説明性高
3: Reader.IsDBNull(i) SqlDataReader 中(拡張メソッドで圧縮可) ○ ordinal 指定で型確定 ◎ 大量行ストリーム
4: DataRow.Field<T?>() DataRow / DataTable ◎ ジェネリックで型確定 ◎◎ DataAdapter 構成で最強
5: EF Core Nullable<T> EF Core クラス ◎ プロパティ型で確定 × Framework 4.7.2 では不可

ハマりポイント:5イディオム共通で踏むやつ

Convert.ToString(DBNull.Value) の挙動

Convert.ToStringDBNull を渡すと空文字を返す。これは便利に見えて、業務系では事故の元。

// 罠:DBNull が空文字に化ける
string bikou = Convert.ToString(row["bikou"]);
// row["bikou"] が DBNull の場合、bikou は ""(空文字)になる
// 「未入力」と「空文字」を区別したい画面で表示が崩れる

「null と空文字を区別したい」業務要件があるなら、Convert.ToString は使わずに is DBNull で明示的に分岐する。表示要件次第なので、画面ごとにチェックするのが無難。

Convert.ToInt32(DBNull.Value) は0を返す(再掲)

int amount = Convert.ToInt32(row["amount"]);  // DBNull → 0
// 金額0円と未入力が同じ値になる

これも要件次第だが、金額・件数・年齢のような業務的に意味のある数字0 を未入力と同義に扱うのは危険。int? で受けて、表示側で null なら "—" を出すような書き方が無難。

値型と参照型の Nullable 表記の違い

書き方
値型 Nullable<T> または T?where T : struct int?, DateTime?, decimal?
参照型 そのまま(既定で null 可能) string, byte[](C# 7まで)
C# 8+ 参照型 string? で「null 許容」を型シグネチャで明示 #nullable enable で挙動変化

C# 8+ で #nullable enable を入れたプロジェクトでは、stringstring? がコンパイラレベルで区別される。.NET Framework 4.7.2 の現場では C# 7.3 までしか使えないので、参照型の null 許容性は コメント or 命名規則で表現 する流派が多い。

イディオムを混在させない

1記事で5パターン並べたが、実際のプロジェクトでは1〜2個に絞って統一する のが正解。同じテーブルを扱うコードで、ある画面では DataRow.Field<int?> を使い、別の画面では is DBNull の三項演算子を使う、みたいな混在は保守時の認知コストを上げる。チームで「DataRow を扱う時は Field 一択」と決めるだけで、コードの統一感が出る。

振り返って学んだこと:DBNull は「翻訳ステップ」を1個挟むと事故が減る

C# の null と ADO.NET の DBNull.Value は別物。DB の世界 ⇄ C# の世界 を行き来する時に、毎回1ステップ翻訳を挟む という習慣を持つと、業務系の本番障害が体感で半分以下になる。

俺もフリーランス案件で「DBNull で落ちた時の障害対応」の見積もりを聞かれたら、いつもこう答える: 「障害そのものは10分で直る、でも全テーブル全列の翻訳ステップを点検して根本対応するなら3日見てください」。場当たり対応と根本対応の温度差が、DBNull はとくに大きい。

業務系で5年・10年触ってきた人は、この翻訳ステップの感覚を肌で知っている。同じスキルセットで TypeScript の strict モード(string | null の扱い)に行っても、感覚は地続きで使える。「null を境界値で潰す癖」は、SQL Server / DataTable の現場で身につけて、Web 系の TypeScript 現場でも資産として通用する。

まとめ

  • DBNull の握り方は読み出す層で決まる。手書き読み取り(is DBNull / Convert.IsDBNull) / DataReader.IsDBNull(i) / DataRow.Field<T?> / EF Core Nullable の4ライン
  • 業務系の DataAdapter + DataTable 構成では DataRow.Field<T?>() が圧倒的に楽。20列のテーブル処理でも見通しが保てる
  • DataReader でのストリーム処理は IsDBNull(i) 先行チェックを拡張メソッド化して圧縮
  • Convert.ToInt32(DBNull.Value) が 0 を返すのは便利だが、「未入力 vs 0」を区別したい場面では事故の元
  • イディオムは1プロジェクト内で1〜2個に絞って統一する。混在は保守の認知コストを上げる

よくある質問

Q1. is DBNull と Convert.IsDBNull、どっちを使うべき?

挙動は同じなので好みでOK。チームで使う側を統一するのが大事。is DBNull のほうが C# 7+ のパターンマッチング文化に合っていて短い。Convert.IsDBNull は関数名で意図が伝わるので新人含むチームに優しい。

Q2. DataRow.Field で例外が出るパターンは?

存在しない列名を渡した時の ArgumentException と、型変換が失敗した時の InvalidCastException(例: string の列を int? で受けようとした等)。列名のタイポ・スキーマ変更時に踏みやすいので、列名は定数化しておくと安全。

Q3. Dapper を使う場合は?

Dapper は DBNull を C# の null に自動変換 してくれるので、ほぼ EF Core と同じ感覚で使える。int? のプロパティを書いておけばマップされる。.NET Framework 4.7.2 でも動くので、ORM 全部入りは重いが Dapper 程度ならアリ、という現場で選ばれる。

Q4. EF Core でクエリ結果の null を集計関数(Sum / Avg)に渡すとどうなる?

SumNULL 行を無視して足す(SQL 仕様)。Avg も NULL を無視して平均を取る。C# 側で int?IEnumerable に対して LINQ の Sum を呼ぶと、null を 0 として扱う。同じ「合計」でも DB 側と LINQ で挙動が違うので、要件確認が大事。

Q5. パフォーマンスに差はある?

5イディオムの差はマイクロ秒オーダーで、業務系の数百〜数千行程度では誤差。設計判断で迷ったら可読性と型安全性を優先 で問題ない。性能で意味のある差が出るのは、数十万行のバッチで DataReader を使うケースくらい。

関連記事

以上!

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

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

コメント

コメントする

CAPTCHA


目次