EF Core LINQ集計で0件時に「Enumeration yielded no results」を防ぐ方法(Union/GroupBy/Sum対応)

ASP.NET Core+EF Coreで「支払い」「購入」など複数テーブルをLINQ集計し、会社ID単位の合計金額をJSONで返す実装は定番です。一方で期間内の該当データが0件だと、結果が空になり例外(Enumeration yielded no results相当)や想定外のレスポンスになりがちです。会社IDは必ず返し、金額は0にする実装パターンを整理します。

目次

発生している問題:0件時に「空のシーケンス」になり、後段で例外化する

「会社IDを条件に、2つのテーブル(支払い・購入)をそれぞれ会社IDでグループ化して合計(SUM)し、Unionでまとめて返す」構成は、データがあるときは素直に動きます。しかし、対象期間に支払いも購入も1件もない場合、どちらの集計クエリも0行になり、Unionしても0行になります。

ここで起きるトラブルは主に次の2パターンです。

  • 「必ず1件ある前提」の処理が後ろにある(First() / Single() / インデクサ [0] など)
  • APIの契約として「会社IDは必ず返す」想定なのに、空配列 [] が返ってフロントや呼び出し側が崩れる

つまり「集計が空になり得る」こと自体が問題というより、空になったときの扱いが未定義であることが根本原因です。要件が「会社IDは返し、金額系は0にする」なら、0件でも必ず1レコード返す仕組みを入れるのが正攻法です。

期待するレスポンス形(0件でも会社IDは返す)

たとえば companyId=1 で期間内に該当が無い場合でも、次のような形で返したい、という要件です。

項目値意味
CompanyId1対象の会社ID(必ず返す)
PaymentAmount0支払い合計(0件なら0)
PurchaseAmount0購入合計(0件なら0)

まず押さえるべき前提:GroupBy集計は「0件なら行が存在しない」

SQLの世界では、GROUP BY は「グループが存在する行だけが返る」ため、対象行が0件なら結果は0行です。つまり次のような形は、0件時に「会社ID=1 の行が作られる」わけではありません。

  • 支払い0件 → 支払い集計は0行
  • 購入0件 → 購入集計は0行
  • 両方0件 → Unionしても0行

この「0行」を「会社ID=1、金額=0の1行」に変換する処理を、どこかで必ず入れます。

解決策A:Union後に0件なら「0の行」を追加する(現場で採用しやすい)

要件に最も忠実で、実装が読みやすいのがこの方法です。ポイントは「0件になり得る前提」で、最終結果が空なら1行足すことです。

また、質問意図が「期間(_lastdate〜_fitsdate)内」なら、フィルターは両端を入れるのが自然です。例では >= _lastdate && <= _fitsdate を両方のテーブルに入れています。

public class JoinClass
{
    public int CompanyId { get; set; }
    public decimal PaymentAmount { get; set; }
    public decimal PurchaseAmount { get; set; }
}

public async Task<IActionResult> Summary(int companyid, DateTime _lastdate, DateTime _fitsdate)
{
    var result1 = await (from p in _context.tbl_Companypayments.AsNoTracking()
                         where p.Company_ID == companyid
                         where p.PaymentDate >= _lastdate && p.PaymentDate <= _fitsdate
                         group p by p.Company_ID into g
                         select new JoinClass
                         {
                             CompanyId = g.Key,
                             PaymentAmount = g.Sum(x => x.PaymentAmount),
                             PurchaseAmount = 0m
                         }).ToListAsync();

    var result2 = await (from p in _context.tbl_Purchases.AsNoTracking()
                         where p.CompanyId == companyid
                         where p.PurchaseDate >= _lastdate && p.PurchaseDate <= _fitsdate
                         group p by p.CompanyId into g
                         select new JoinClass
                         {
                             CompanyId = g.Key,
                             PaymentAmount = 0m,
                             PurchaseAmount = g.Sum(x => x.PurchaseValue)
                         }).ToListAsync();

    var final = result1.Union(result2).ToList();

    if (!final.Any())
    {
        final.Add(new JoinClass
        {
            CompanyId = companyid,
            PaymentAmount = 0m,
            PurchaseAmount = 0m
        });
    }

    return Json(final);
}

この方法が強い理由

  • 0件時のふるまいが明確で、API契約を守りやすい
  • 集計自体はDB側で行い、結果は最大でも「支払い1行+購入1行」程度なので、ToListしても重くなりにくい
  • フロント側が「会社IDの行が必ずある」前提で実装でき、分岐や例外が減る

注意点:Unionは「重複排除」なので意図によってはConcatが適切

Union は集合演算なので重複行を排除します。通常このケースで支払い行と購入行が完全一致することは多くありませんが、設計意図としては「2つを足し合わせるために並べる」ことが多いはずです。

その場合、重複排除をしないConcat(SQL的には UNION ALL 相当)の方が意図に合います。後述の「1行にまとめる」パターンでは特に Concat が向いています。

解決策B:DefaultIfEmptyで「空なら既定値」を返す(ただし適用位置が重要)

DefaultIfEmpty() は「空シーケンスなら既定値を1件返す」ためのメソッドです。理屈としてはこの用途にピッタリですが、EF Coreのクエリ(IQueryable)のまま複雑な既定オブジェクトを組み立てると、SQL変換できずに例外になることがあります。

そのため現場では、次のようにいったんToListで実体化してから適用するのが安全です。

var final = result1
    .Concat(result2) // 意図が「足し合わせ」ならConcatが無難
    .ToList();

final = final
    .DefaultIfEmpty(new JoinClass { CompanyId = companyid, PaymentAmount = 0m, PurchaseAmount = 0m })
    .ToList();

return Json(final);

DefaultIfEmptyが向いているケース

  • 「空ならこの1行」というルールを、LINQの流れとして表現したい
  • 後段にFirst()等を置くのではなく、コレクションとして返したい

DefaultIfEmptyの落とし穴

  • EF CoreのSQL変換制約により、IQueryableの段階で複雑な既定値を作ると失敗することがある
  • 「最終的に1行返す」ことが要件なら、後述の「1行にまとめて返す設計」に切り替えた方がシンプルな場合も多い

補足:今のUnion構造だと「2行」になる。1行にまとめたいなら再集計する

支払いと購入をそれぞれ集計して Union/Concat すると、結果は基本的に次のどちらかです。

  • 両方データあり → 2行(支払い行・購入行)
  • 片方だけデータあり → 1行
  • 両方0件 → 0行(ここが問題)

もしAPIとして「会社IDごとに1行で、支払い合計と購入合計を同じ行に入れたい」なら、Concat後にもう一度GroupByして足し合わせるのが分かりやすく、バグが少ないです。

var paymentRows = await (from p in _context.tbl_Companypayments.AsNoTracking()
                         where p.Company_ID == companyid
                         where p.PaymentDate >= _lastdate && p.PaymentDate <= _fitsdate
                         group p by p.Company_ID into g
                         select new JoinClass
                         {
                             CompanyId = g.Key,
                             PaymentAmount = g.Sum(x => x.PaymentAmount),
                             PurchaseAmount = 0m
                         }).ToListAsync();

var purchaseRows = await (from p in _context.tbl_Purchases.AsNoTracking()
                          where p.CompanyId == companyid
                          where p.PurchaseDate >= _lastdate && p.PurchaseDate <= _fitsdate
                          group p by p.CompanyId into g
                          select new JoinClass
                          {
                              CompanyId = g.Key,
                              PaymentAmount = 0m,
                              PurchaseAmount = g.Sum(x => x.PurchaseValue)
                          }).ToListAsync();

var merged = paymentRows
    .Concat(purchaseRows)
    .GroupBy(x => x.CompanyId)
    .Select(g => new JoinClass
    {
        CompanyId = g.Key,
        PaymentAmount = g.Sum(x => x.PaymentAmount),
        PurchaseAmount = g.Sum(x => x.PurchaseAmount)
    })
    .ToList();

if (!merged.Any())
{
    merged.Add(new JoinClass { CompanyId = companyid, PaymentAmount = 0m, PurchaseAmount = 0m });
}

return Json(merged);

この形にしておくと、フロントは常に「会社ID1行」を扱う設計になり、UIや集計表示のロジックが単純になります。

よりシンプルな別解:そもそも「Unionしない」スカラー集計で1行を作る

対象が「特定の会社ID1社」なら、実はUnionやGroupByを使わずに、支払い合計・購入合計をそれぞれスカラーで取って1つのDTOに詰めるのが最もシンプルです。

重要ポイントは、SQLのSUMは対象行が0件だとNULLになり得ることです。そこでEF Coreでは、nullableでSumしてから ?? 0 を付けると、0件でも0に落とせます(SQL的には COALESCE に相当)。

public async Task<IActionResult> SummaryOneRow(int companyid, DateTime _lastdate, DateTime _fitsdate)
{
    var paymentSum = await _context.tbl_Companypayments.AsNoTracking()
        .Where(p => p.Company_ID == companyid)
        .Where(p => p.PaymentDate >= _lastdate && p.PaymentDate <= _fitsdate)
        .SumAsync(p => (decimal?)p.PaymentAmount) ?? 0m;

    var purchaseSum = await _context.tbl_Purchases.AsNoTracking()
        .Where(p => p.CompanyId == companyid)
        .Where(p => p.PurchaseDate >= _lastdate && p.PurchaseDate <= _fitsdate)
        .SumAsync(p => (decimal?)p.PurchaseValue) ?? 0m;

    var dto = new JoinClass
    {
        CompanyId = companyid,
        PaymentAmount = paymentSum,
        PurchaseAmount = purchaseSum
    };

    return Json(dto); // 1行(1オブジェクト)として返す
}

この別解のメリット

  • 0件でも必ず1オブジェクトが返る(会社IDは固定で詰める)
  • 「Unionして空になったら足す」といった後処理が不要で、読みやすく保守しやすい
  • 合計値が必要なだけなら、GroupBy自体が不要

デメリット・注意点

  • クエリが2回(支払いSum、購入Sum)になるため、超高頻度APIでは要検討
  • ただし、各Sumはインデックスが効けば非常に軽く、実務では問題になりにくいケースが多い

さらに堅牢にする別解:会社マスタを起点に左外部結合する

「会社IDは必ず返す」をDBの形として保証するなら、会社マスタ(例:Companies)を起点にして、支払い・購入の集計結果を左外部結合するのが定石です。これなら「会社が存在する限り」必ず1行が返ります。

会社マスタがある場合のイメージは次の通りです(概念例)。

var query =
    from c in _context.Companies.AsNoTracking()
    where c.Id == companyid
    join pay in
        (from p in _context.tbl_Companypayments.AsNoTracking()
         where p.PaymentDate >= _lastdate && p.PaymentDate <= _fitsdate
         group p by p.Company_ID into g
         select new { CompanyId = g.Key, PaymentAmount = g.Sum(x => x.PaymentAmount) })
    on c.Id equals pay.CompanyId into payJoin
    from pay in payJoin.DefaultIfEmpty()
    join pur in
        (from p in _context.tbl_Purchases.AsNoTracking()
         where p.PurchaseDate >= _lastdate && p.PurchaseDate <= _fitsdate
         group p by p.CompanyId into g
         select new { CompanyId = g.Key, PurchaseAmount = g.Sum(x => x.PurchaseValue) })
    on c.Id equals pur.CompanyId into purJoin
    from pur in purJoin.DefaultIfEmpty()
    select new JoinClass
    {
        CompanyId = c.Id,
        PaymentAmount = pay != null ? pay.PaymentAmount : 0m,
        PurchaseAmount = pur != null ? pur.PurchaseAmount : 0m
    };

var dto = await query.SingleAsync();
return Json(dto);

このアプローチは、会社が存在しない場合にどう返すか(404にする、0行にする等)も設計として整理しやすく、APIの一貫性が高まります。

どの方法を選ぶべきか:比較表

方法0件でもCompanyIdを返せる返却形SQL変換の安全性特徴
解決策A:Union後に0行なら追加◎配列(List)◎(後処理なので安全)最小改修で要件を満たしやすい
解決策B:DefaultIfEmpty(ToList後)◎配列(List)○(適用位置が重要)LINQの流れで「空なら既定値」を表現できる
Concat後に再GroupByして1行化+0行なら追加◎配列 or 1行◎「支払いと購入を同一行で返す」要件に強い
スカラーSumでDTOを組み立て◎1オブジェクト◎(COALESCE相当が作りやすい)最も読みやすい。Union自体が不要
会社マスタ起点の左外部結合◎(会社が存在する限り)1オブジェクト○〜◎(書き方次第)API契約が堅牢。会社不存在の扱いも設計しやすい

「Enumeration yielded no results」系の例外を確実に潰すチェックリスト

0件が原因の例外は、集計ロジックそのものではなく「取り出し方」で発生していることが多いです。次のポイントを順に確認すると、原因が特定しやすくなります。

1) First / Single を使っていないか

  • First():0件で例外
  • Single():0件でも複数件でも例外
  • FirstOrDefault() / SingleOrDefault():0件ならnull(ただし後段でnull参照注意)

「必ず会社IDを返し、金額は0にしたい」なら、nullを許容するより、0のDTOを作る方が呼び出し側が楽になります。

2) 期間条件(From-To)が片側だけになっていないか

意図が「_lastdate〜_fitsdate の範囲」なら、>= _lastdate と <= _fitsdate の両方を入れるのが基本です。片側だけだと、古いデータまで拾ってしまい、集計結果がズレます。

3) Union/Concat の意図を明確にする

  • Union:重複排除したい(集合として扱う)
  • Concat:単純に足し合わせたい(UNION ALL的)

集計を足し合わせる目的なら、基本は Concat を選び、必要なら最後に GroupBy でまとめます。

4) 金額型はdecimal、NULL対策を入れる

金額はdecimalが定石です。また、SQLのSUMは0件でNULLになり得るため、スカラー集計では(decimal?)にしてから?? 0mで落とすのが実務的に安全です。

パフォーマンスと運用のコツ(地味だけど効く)

読み取り専用なら AsNoTracking() を付ける

集計APIは読み取りが中心です。追跡不要なら AsNoTracking() を付けると、EF Coreの追跡コストが下がり、メモリも節約できます。

インデックスを意識する

件数が増えてくると、次のような列にインデックスがあるかで体感が変わります。

  • 支払い:Company_ID、PaymentDate
  • 購入:CompanyId、PurchaseDate

「会社ID+日付」で絞ってSUMする処理は、インデックスと相性が良い典型です。

レスポンス形を固定すると、呼び出し側のバグが激減する

0件時に [] が返る設計だと、フロントは「配列が空の場合」の分岐が常に必要です。要件が許すなら、常に1オブジェクトを返す形(スカラーSumや会社マスタ起点)に寄せると、UIの実装がかなり安定します。

まとめ:0件は「異常」ではなく「仕様」。必ず返す形を先に決める

支払い・購入のように「0件が普通に起こり得る」データを集計する場合、0件を例外として扱ってしまうと運用で必ず詰まります。今回のように「会社IDは返し、金額は0」という要件が明確なら、最終結果が空なら0の行を追加する、または最初から1行DTOを組み立てる設計が効果的です。

まずは解決策Aで確実に仕様を満たし、将来的にAPIの形を「1行(1オブジェクト)」に寄せたくなったら、スカラーSum方式や会社マスタ起点の左外部結合へ移行する、という段階的な整理が現場では進めやすいはずです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次