C# DataTable を LINQ でフィルタ・GroupBy・分割する3パターン

C# DataTableをLINQでフィルタ・GroupBy・分割する3パターン

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

C#のDataTableいじってると、「これLINQで書きたい」って思った瞬間にコンパイルエラーで止まる、ってこと、ないっすか??

// ❌これは動かない
var actives = dt.Where(r => r["IsActive"].ToString()== "1");
//             ↑ 'DataTable'に'Where'の定義はない

俺もこれで最初の正社員時代によく詰まった。for (int i = 0; i < dt.Rows.Count; i++)で30行近く書いてた処理を、LINQで3行にしたい時に「ん?書けないやん…??」とハマる。答えは1行で済んで、AsEnumerable()を挟むだけ。これだけでDataTableにLINQ一式がそのまま使えるようになって、こんな感じでスッキリした書き方になります。

この記事ではVS2019・.NET Framework 4.7.2・C# 7.3の業務系で実際に使ってるDataTableにLINQをかける3パターン(フィルタ/GroupBy/分割)を、コード4本+DBNullのハマり1本でまとめる。

3行で結論:

  • AsEnumerable()を挟むIEnumerable<DataRow>に化けるのでLINQ一式が使える
  • 値の取得はr.Field<T>("col") →型安全+NULL許容ならField<int?>で受ける
  • DataTableに戻したい時は.CopyToDataTable() →ただし空シーケンスで例外を吐くので分岐が要る
目次

前提:AsEnumerable()でIEnumerable化する

DataTableはIEnumerable<DataRow>を直接実装していない(旧来のIEnumerableは実装してる)ので、WhereGroupByがそのまま生えてない。これをAsEnumerable()という拡張メソッドでIEnumerable<DataRow>に変換すると、LINQが一式かかるようになる。

using System.Data;
using System.Linq;
// using System.Data.DataSetExtensions;  // .NET Framework 4.0以降は標準で参照済みのはず

DataTable dt = LoadFromDb();   // SqlDataAdapterで埋めたDataTable

// AsEnumerable()でIEnumerable<DataRow>化
IEnumerable<DataRow> rows = dt.AsEnumerable();

//ここから先は普通のLINQ
var firstActive = rows.FirstOrDefault(r => r.Field<bool>("IsActive"));

ポイントは:

  • AsEnumerable()System.Data.DataSetExtensions名前空間にある拡張メソッド
  • 戻り値はEnumerableRowCollection<DataRow>IEnumerable<DataRow>を実装)
  • 値の取得はr["col"]でも書けるけど、r.Field<T>("col")の方が型安全+NULL安全

r["col"]objectを返すので、ほとんどのケースでConvert.ToInt32(r["col"])のような変換が要る。NULLが来た瞬間にInvalidCastExceptionが飛ぶ罠もある。Field<T>を覚えてしまえばこの辺りの面倒事が一気に減る。これを使い始めて、DataTableコードの可読性は体感で半分くらいになった。

パターン1:フィルタ(Where)

一番出番が多いやつ。「特定条件の行だけ別のDataTableやDataGridViewに出したい」みたいな業務要件で使う。

//価格1000以上、かつ"在庫あり"のフラグが立ってる行を抽出
var filtered = dt.AsEnumerable()
    .Where(r => r.Field<int>("price")>= 1000
             && r.Field<bool>("in_stock"));

foreach (var r in filtered)
{
    Console.WriteLine($"{r.Field<string>("name")}: {r.Field<int>("price")}");
}

これでforループ+if分岐+一時変数の昔ながらの書き方がいい感じに3〜4行へ圧縮できる。読み手が条件を1行で把握できるのがデカい。

業務系のコードレビューで「このforループ何やってるの?」と聞かれて、AsEnumerable().Where(...)に書き直すと「ああ、そういうことね」と一瞬で伝わる!!LINQのWhereは、ロジックを「条件式そのもの」にすることでコメントが要らなくなる効果が一番強いパターンだ。

ただし欠点として、Whereした結果はIEnumerable<DataRow>なので、そのままDataGridViewのDataSourceに貼れない。DataGridViewに流したいなら次のパターン3でCopyToDataTable()する。

パターン2: GroupBy +集計(Sum / Count / Average)

「カテゴリ別の件数」「店舗別の売上合計」みたいな集計をクライアント側でやりたい時に使う。本当はSQL側でGROUP BYを書く方が速いけど、DataTableに既にロード済みのデータを再集計するケースで重宝する。

//カテゴリ別の件数と合計金額
var summary = dt.AsEnumerable()
    .GroupBy(r => r.Field<string>("category"))
    .Select(g => new
    {
        Category = g.Key,
        Count = g.Count(),
        TotalPrice = g.Sum(r => r.Field<int>("price")),
        AvgPrice = g.Average(r => r.Field<int>("price"))
    })
    .OrderByDescending(x => x.TotalPrice)
    .ToList();

foreach (var s in summary)
{
    Console.WriteLine($"{s.Category}: {s.Count}件/合計{s.TotalPrice} /平均{s.AvgPrice:F0}");
}

GroupByが返すのはIEnumerable<IGrouping<TKey, DataRow>>で、g.Keyがキー(カテゴリ名)、g自体がIEnumerable<DataRow>として該当行を保持してる。Sum / Count / Average / Min / Maxあたりは標準のLINQ集計メソッドがそのまま使える。

業務系で「在庫一覧画面のカテゴリ別件数を画面下に出したい」「日次バッチでサマリ表を作りたい」みたいな時にこれ1ブロックで終わる。forループでDictionary<string, int>を組み立ててたコードの8〜9割はGroupByで潰せる

ただし欠点として、GroupByの挙動は遅延評価+全行スキャン。データが万行を超えるDataTableに対して画面再描画ごとにGroupByをかけるのは重い。集計結果は一度ToList()で固めてキャッシュする方が現場では安全だ。

パターン3:分割(CopyToDataTable / ImportRow)

「フィルタした結果を別のDataGridViewに貼りたい」「条件で2つのDataTableに分けたい」みたいな要件で使う。CopyToDataTable()という拡張メソッドでIEnumerable<DataRow>を新しいDataTableに戻せる。

//価格1000以上の行だけ別DataTableに分割
var highPriceRows = dt.AsEnumerable()
    .Where(r => r.Field<int>("price")>= 1000);

DataTable dtHighPrice;
if (highPriceRows.Any())
{
    dtHighPrice = highPriceRows.CopyToDataTable();
}
else
{
    // ⚠️空シーケンスでCopyToDataTable()を呼ぶとInvalidOperationException
    dtHighPrice = dt.Clone();   //スキーマだけコピーした空DataTableを作る
}

dgvHighPrice.DataSource = dtHighPrice;

CopyToDataTable()元のDataTableのスキーマ(列定義・制約)を保ったまま新しいDataTableを返してくれる。なので元のDataGridViewと同じ列構成で別画面に出せる。

ただしCopyToDataTable()空シーケンスで呼ぶとInvalidOperationExceptionを吐く。これ、結構踏みやすい。if (rows.Any()){ ... } else { dt.Clone(); }の分岐をテンプレ化しておく方が事故が減る。

CopyToDataTable()を使わず、新しいDataTableに1行ずつImportRowする書き方もある:

DataTable dtCopy = dt.Clone();   //スキーマだけコピー
foreach (var r in highPriceRows)
{
    dtCopy.ImportRow(r);
}
dgvHighPrice.DataSource = dtCopy;

こっちの欠点は行数が多いとループが重くなること。基本はCopyToDataTable()+空チェックで書いて、空対策の分岐が嫌ならImportRowのforeach版にする、くらいの判断軸で十分だ。

DBNullのハマり:Field vs Field<int?>

DataTable + LINQで一番踏みやすいのがDBNullだ。Field<T>は型引数の指定で挙動が変わる。

// ❌ NG: NULL許容な列にField<int>を使うとNULL行で例外
var prices = dt.AsEnumerable()
    .Select(r => r.Field<int>("amount"))// amountがNULLの行で例外(Typed DataSetならStrongTypingException、一般ADO.NETではInvalidCastException)
    .ToList();

// ✅ OK: Nullable<T>で受ける
var prices = dt.AsEnumerable()
    .Select(r => r.Field<int?>("amount"))// NULLはnullとして返ってくる
    .ToList();

// ✅ OK: NULL許容を扱いつつ??で初期値
var pricesWithDefault = dt.AsEnumerable()
    .Select(r => r.Field<int?>("amount")?? 0)
    .ToList();

ルールは単純で:

  • NOT NULL列ならField<T>int / bool / DateTime等)
  • NULL許容列ならField<T?>int? / bool? / DateTime?等)
  • stringは参照型なのでField<string>のままでOK(NULLはnullとして返る)

これ俺、最初の正社員時代に半日デバッガで追った経験がある。流通系の基幹システムで集計画面を組んでて、「特定の店舗だけでWhereの中で例外」が出てて、原因が「その店舗だけamount列にNULLが入ってる」というやつ。Field<int>Field<int?>に変えて1行で解決した。SQL側でIS NULLを見れば判明する話だけど、コード読んでも分からないので潰すのに時間がかかった。

DBNull周りの体系的なハンドリングは別記事SQL ServerのDBNullをC#で安全にハンドリングする5つのイディオムで5パターンまとめて整理してるので、ハマりが多い人はそっちと併せて押さえておくのが早い。

ハマりポイント3つ—俺が踏んだやつ

① Fieldの型違いで例外(StrongTypingExceptionまたはInvalidCastException)

上のDBNull例と被るけど、Field<T>の型とDataColumnのDataTypeが一致してないと例外が飛ぶ。Typed DataSet経由ならStrongTypingException、一般的なADO.NET DataTableではInvalidCastExceptionが出るケースが多い(型違いの組み合わせやプロバイダで挙動が変わるので、両方頭に入れておくのが安全)。int列にField<long>でアクセスすると例外。

//テーブル定義: amount INT NOT NULL
// ❌ NG: longで受けると例外
var v = r.Field<long>("amount");

// ✅ OK: intで受けてからlongにキャスト
var v = (long)r.Field<int>("amount");

これは30分くらいデバッガで追ってハマった。「ん?nullじゃないのに型エラー??」と最初は混乱したが、DataColumn.DataTypeを見て型が違うことに気づいた。テーブル定義がSQLのINTならC#側はintで受けないと駄目、というのを覚えておく。

② CopyToDataTable()で空シーケンス例外

これも上で触れたが、踏みやすいので再掲。

// ❌ NG:該当行が0件だとInvalidOperationException
var dtNew = dt.AsEnumerable()
    .Where(r => r.Field<int>("price")> 99999)//空ヒット
    .CopyToDataTable();

// ✅ OK: Any()で先に空チェック
var rows = dt.AsEnumerable().Where(r => r.Field<int>("price")> 99999);
DataTable dtNew = rows.Any()? rows.CopyToDataTable(): dt.Clone();

業務系のフィルタ条件は「条件で全件落ちる」が普通に起きるので、本番でいきなり踏むと画面に「予期しないエラー」ダイアログが出て関係者が血の気を引かせる。コードレビューでCopyToDataTable()を見たら空チェックがあるか毎回確認する、くらいでちょうどいい。最初に踏んだ時、夕方の本番リリース直後にユーザーから「画面が落ちる」報告が来て1時間半対応に追われた

③大きなDataTableでWhereを多重に重ねると遅い

LINQの遅延評価が裏目に出るパターン。同じフィルタを複数回foreachで回すと、毎回先頭から再評価される。

// ❌ NG:同じフィルタを2回評価する
var filtered = dt.AsEnumerable().Where(r => r.Field<int>("price")> 1000);

int count = filtered.Count();             // 1回目スキャン
int sum = filtered.Sum(r => r.Field<int>("price"));   // 2回目スキャン

// ✅ OK:一度ToList()で固める
var filtered = dt.AsEnumerable()
    .Where(r => r.Field<int>("price")> 1000)
    .ToList();   //ここで1回だけ評価

int count = filtered.Count;
int sum = filtered.Sum(r => r.Field<int>("price"));

万行クラスのDataTableを画面に乗せてる業務アプリで、この多重評価が原因で「画面再描画が3秒かかる」みたいな症状が出る。ToList()/ ToArray()でメモリに固めるか、集計結果だけ別変数に取るのが基本対処。これも気づくのに数日経ってからプロファイラかけてやっと見えた類の遅延バグ。

俺の現場メモ—流通系SIer時代のDataTable業務

最初の正社員時代、流通系SIerの受託で2年間C# WinFormsばっか書いてた。VS2019・.NET Framework 4.7.2・C# 7.3の構成で、業務ロジックはDataAdapter + DataTable +生SQLという、業務系C#でよく見るスタックだった。

その現場でDataTable周りにLINQを持ち込むかどうかがチーム内で揺れた時期があった。古参のメンバーはfor (int i = 0; i < dt.Rows.Count; i++)で書く文化、若手はLINQ派、というよくある構図。最終的に「30行を超えるループでif分岐がある場合はLINQに書き直してOK」という規約を後輩と一緒に作った。短いループはどっちでもいい、長くて分岐が増えるところだけLINQに寄せる、という運用。

この時に役立ったのがAsEnumerable()+ Field<T>の型安全アクセスで、コード行数が3割減って、コードレビュー時の認識ズレも減ったr["price"]を読み取る時に「何型だっけ?」を毎回考えなくて良くなったのが地味に効いた。LINQそのものより、Field<T>を強制する規約の方がチーム的には効果が大きかった印象だ。

業務SEがDataTableを扱う時、LINQは使い方より使わない判断の方が難しい。短いforループをLINQで書き直すのは趣味の領域だけど、長くなって分岐や集計が入った瞬間にLINQに倒すと一気に読みやすくなる。判断軸を1回決めてしまえば、現場で迷う時間が確実に減る。

まとめ

ここまででDataTable + LINQの3パターンとDBNullのハマりはだいたい押さえた。要点をもう一度:

  • AsEnumerable()を挟むだけでDataTableにLINQ一式がかかるようになる
  • 値の取得はField<T> / Field<T?>を使い分ける(NOT NULLはField<T>、NULL許容はField<T?>
  • 3パターン:フィルタ(Where)/集計(GroupBy + Sum/Count)/分割(CopyToDataTable
  • 空シーケンスでCopyToDataTable()は例外を吐くので分岐or Any()チェック
  • 大きなDataTableはToList()で1回固めて多重評価を避ける

VS2019・.NET Framework 4.7.2・C# 7.3の業務系でも、最新の.NET 8 + DataTableでも、この3パターンは挙動が変わらない。業務系C#でDataTableを扱う限り長く効く知識なので、1回まとめて覚えてしまうとラクだ。

よくある質問

Q1. AsEnumerable()の参照が解決できないと言われる時は?

A. System.Data.DataSetExtensionsの参照が抜けてるパターン。.NET Framework 4.0以降の通常プロジェクトテンプレートでは標準参照だけど、SDKスタイルのプロジェクトや.NET 5+では明示的に追加が要る場合がある。<PackageReference Include="System.Data.DataSetExtensions" Version="..." />をcsprojに足すか、Visual Studioの「参照の追加」からSystem.Data.DataSetExtensions.dllを選ぶ。

Q2. DataTable.Select(条件文字列)とLINQのWhere、どっち使うべき?

A. 基本LINQのWhere推奨DataTable.Select("price > 1000 AND in_stock = true")は文字列で条件を書くADO.NET流のAPIで、SQL風の式を渡せるけど:

  • 文字列なのでコンパイル時の型チェックが効かない
  • IDEの補完・リファクタが効かない
  • 例外が実行時に出る

LINQのWhereは型安全・IDE補完・コンパイル時チェックが全部効くので、新規コードはこっちに寄せた方が保守しやすい。DataTable.Selectは既存コードで出てきた時に「ああ、こういう書き方もあったな」程度の認識でOK。

Q3.複数のDataTableをJOINしたい時は?

A. LINQのjoinキーワードで書ける。

var joined = from o in dtOrders.AsEnumerable()
             join c in dtCustomers.AsEnumerable()
                  on o.Field<int>("customer_id")equals c.Field<int>("id")
             select new
             {
                 OrderId = o.Field<int>("id"),
                 Amount = o.Field<int>("amount"),
                 CustomerName = c.Field<string>("name")
             };

ただしJOINは基本SQL側で書く方が速い。DataTable同士のJOINを書きたくなったら、まず「これSQL側で1クエリにまとめられないか?」を先に検討する方が現場では正解になることが多い。

Q4. DataTable + LINQ vs Dapper、どっちが今風?

A.新規プロジェクトならDapper(or EF Core)の方が今風。Dapper.Query<Customer>("SELECT ... FROM customers")POCOクラスに直接マップされるので、Field<T>を毎回書く手間自体がなくなる。

ただし業務系の保守案件はDataAdapter + DataTableで書かれてる現場が多いので、既存資産を触る時はAsEnumerable + Fieldパターンが一番事故が少ない。新規モジュールだけDapperに寄せて、既存はLINQ to DataTableで整える、みたいなハイブリッド運用が現実的だ。

ここまででDataTable + LINQの挙動とDBNullの罠は押さえた。同じDataTable系で取得時のDBNull処理を5パターン体系で押さえたい人は、関連記事側に進むと自然な流れになる。

関連記事

以上!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次