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 で期間内に該当が無い場合でも、次のような形で返したい、という要件です。
| 項目 | 値 | 意味 |
|---|---|---|
| CompanyId | 1 | 対象の会社ID(必ず返す) |
| PaymentAmount | 0 | 支払い合計(0件なら0) |
| PurchaseAmount | 0 | 購入合計(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方式や会社マスタ起点の左外部結合へ移行する、という段階的な整理が現場では進めやすいはずです。

コメント