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パターン体系で押さえたい人 は、関連記事側に進むと自然な流れになる。

関連記事

以上!

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

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

コメント

コメントする

CAPTCHA


目次