Entity Framework Core(EF Core)で「クイズ一覧に問題数も表示したい」と思ったとき、つい GroupBy を書きたくなります。ですが、Quiz と Question が 1対多の関係でナビゲーションが張れているなら、必要なのは集計用の GroupBy ではなく、Quiz を起点にした投影と Count() です。この記事では、最短で正しく・無駄なく「Quizごとの問題数(TotalQuestions)」を取得する方法と、実務でハマりがちな落とし穴をまとめます。
やりたいことを整理する
前提は次のような状況です。
- Quiz(クイズ)と Question(問題)が 1対多(Quiz 1件に対して Question が複数)
Status == trueの Quiz のみを対象にする- 取得したいのは Quiz の Id / Title / Status と、紐づく問題数(TotalQuestions)
Questions.QuizIdでGroupByすべきか迷っている
結論から言うと、GroupBy は不要です。外部キーで紐づいている 1対多の件数を「親(Quiz)ごと」に数えたいだけなら、Quiz を起点に Select で投影し、ナビゲーションの Count() を取れば十分です。
最短解:Quiz を起点に投影して Count を取る
まずは、相談内容そのままの要件を満たす基本形です。比較演算子は = ではなく == です(C# の代入と比較の違いは超重要ポイントです)。
var list = context.Quizzes
.Where(q => q.Status == true) // bool なら .Where(q => q.Status) でもOK
.Select(q => new
{
q.Id,
q.Title,
q.Status,
TotalQuestions = q.Questions.Count() // ナビゲーションの件数
})
.Take(10)
.ToList();
ポイントは、Count() が クエリの中にあることです。これにより EF Core は SQL に変換して、データベース側で件数を数えてくれます。つまり、Quiz を 10件取ってからアプリ側で Question を数えるのではなく、最初から「件数を含む結果」として返ってきます。
ナビゲーション名が単数・複数で違う場合
サンプルでは q.Questions を使っていますが、プロジェクトによっては q.Question のように単数名になっていることもあります。一般的には「複数形」を推奨します。
- 推奨:
Quiz.Questions - 避けたい:
Quiz.Question(複数なのに単数だと読み手が混乱しやすい)
ただし、どちらでも EF Core の挙動自体は同じなので、実際のエンティティ定義に合わせて読み替えてください。
なぜ GroupBy が不要なのか
「Quizごとに Question を数える」=「グルーピングして集計」という連想から、GroupBy が必要に見えます。しかし、今回のケースは 1対多のリレーションがすでにモデルに表現されているのが肝です。
Quiz を起点にした q.Questions.Count() は、SQL としては概ね次のような形に変換されます(実際の SQL はプロバイダーやバージョンで多少変わります)。
SELECT TOP(@__p_0) q.Id, q.Title, q.Status,
(SELECT COUNT(*)
FROM Questions AS qu
WHERE qu.QuizId = q.Id) AS TotalQuestions
FROM Quizzes AS q
WHERE q.Status = 1;
見ての通り、GroupBy を書かなくても「Quiz 行ごとに、紐づく Question を数える」形(相関サブクエリ等)で表現できます。“親を取りながら子を数える”という発想だと、Select + Count() のほうが自然です。
Include がいらない理由(件数だけ欲しいなら読み込まない)
実務でよくある誤解が、Include を付けてから件数を数える方法です。
// 件数だけが欲しいのに Include してしまう例(基本的に非推奨)
var list = context.Quizzes
.Include(q => q.Questions)
.Where(q => q.Status)
.ToList()
.Select(q => new
{
q.Id,
q.Title,
TotalQuestions = q.Questions.Count
})
.ToList();
この書き方だと、Question の全行・全カラムを読み込む方向に寄りやすく、メモリ・転送量・クエリ時間が増えます。件数だけなら、データベースに数えてもらうほうが圧倒的に効率的です。
| やり方 | メリット | デメリット | 向いている場面 |
|---|---|---|---|
Select で Count() | 必要な列+件数だけ取得。転送量が最小 | 質問一覧そのものは取れない | 一覧画面・集計表示・APIのサマリー |
Include してから数える | 質問一覧も同時に使える | データ量が多いと重い。意図せず肥大化しやすい | 詳細画面で質問一覧も表示する |
| 用途別にクエリを分ける | 一覧は軽く、詳細は必要十分に、と最適化しやすい | クエリが複数になる | 画面やAPIで責務を分けたい |
「一覧で件数だけ」「詳細で一覧も必要」という場合は、画面・API の用途でクエリを分けるのが結果的に運用しやすいです。
匿名型より DTO を使うと保守性が上がる
匿名型は手軽ですが、実務では DTO(表示用クラス)に詰める方が、拡張・テスト・リファクタリングに強くなります。例えば一覧表示用に次の DTO を用意します。
public sealed class QuizListItemDto
{
public int Id { get; init; }
public string Title { get; init; } = "";
public bool Status { get; init; }
public int TotalQuestions { get; init; }
}
そして Select で投影します。
var list = await context.Quizzes
.AsNoTracking()
.Where(q => q.Status)
.OrderBy(q => q.Id)
.Select(q => new QuizListItemDto
{
Id = q.Id,
Title = q.Title,
Status = q.Status,
TotalQuestions = q.Questions.Count()
})
.Take(10)
.ToListAsync();
AsNoTracking() は読み取り専用の一覧取得で効果が出やすい定番の最適化です(更新しないなら追跡は不要)。また、ページングするなら OrderBy を付けて結果が安定するようにしておくと、運用後に事故りにくいです。
ナビゲーションが使えない場合の代替案
何らかの理由でナビゲーションが未定義、もしくは DTO だけを扱っていてナビゲーション参照を避けたい場合でも、外部キーを使ったサブクエリで Count できます。
var list = await context.Quizzes
.AsNoTracking()
.Where(q => q.Status)
.Select(q => new
{
q.Id,
q.Title,
q.Status,
TotalQuestions = context.Questions.Count(x => x.QuizId == q.Id)
})
.ToListAsync();
ナビゲーションの Count() と目的は同じです。チームのコーディング規約や設計方針に合わせて使い分けると良いでしょう。
| パターン | 書き方 | 特徴 |
|---|---|---|
| ナビゲーションで Count | q.Questions.Count() | モデルの意図が伝わりやすい。読みやすい |
| 外部キーで Count | context.Questions.Count(x => x.QuizId == q.Id) | ナビゲーション無しでも書ける。移植しやすい |
「有効な問題だけ数えたい」など条件付き Count もできる
総数ではなく「公開中の問題だけ」「削除フラグが立っていないものだけ」など、条件付きの件数が欲しい場面も多いです。その場合も同様に、Count(predicate) を使えます。
var list = await context.Quizzes
.AsNoTracking()
.Where(q => q.Status)
.Select(q => new QuizListItemDto
{
Id = q.Id,
Title = q.Title,
Status = q.Status,
TotalQuestions = q.Questions.Count(x => x.Status) // 例:Question側のStatusがtrueのみ
})
.ToListAsync();
このように「Quiz を取るついでに集計値も返す」形にしておくと、一覧画面の表示用 API が非常に作りやすくなります。
GroupBy が必要になるのはどんなとき?
今回の「Quiz を起点に、子の件数を取りたい」だけなら不要ですが、GroupBy が活躍する場面もあります。代表例は次の通りです。
- Question 側を起点に、QuizId ごとに集計したい(Quiz の一覧を取らなくてよい)
- Quiz ごとに「難易度別の内訳」など、複数の集計値を同時に作りたい
- 特定の期間・タグ・条件で絞った Question を対象にランキングを作りたい
例えば「Question だけ見て、QuizId ごとに件数を出す」なら GroupBy が自然です。
var counts = await context.Questions
.AsNoTracking()
.Where(x => x.Status) // 例:有効なQuestionのみ
.GroupBy(x => x.QuizId)
.Select(g => new
{
QuizId = g.Key,
TotalQuestions = g.Count()
})
.ToListAsync();
さらに「Quiz の Title も一緒に欲しい」なら、集計サブクエリを Quiz と結合して 1クエリにまとめるのが安全です。
var questionCounts = context.Questions
.AsNoTracking()
.Where(x => x.Status)
.GroupBy(x => x.QuizId)
.Select(g => new { QuizId = g.Key, TotalQuestions = g.Count() });
var list = await context.Quizzes
.AsNoTracking()
.Where(q => q.Status)
.GroupJoin(
questionCounts,
q => q.Id,
c => c.QuizId,
(q, c) => new QuizListItemDto
{
Id = q.Id,
Title = q.Title,
Status = q.Status,
TotalQuestions = c.Select(x => x.TotalQuestions).FirstOrDefault()
})
.ToListAsync();
ただし、ここまで来ると「ナビゲーションで Count() した方が読みやすい」ケースがほとんどです。“Quiz の一覧を作りたい”なら Quiz 起点で Count() が第一候補、と覚えておくと判断がぶれません。
パフォーマンスの勘所:インデックスとクエリの形
件数取得は軽い処理に見えますが、データが増えると差が出ます。特に Questions テーブルの QuizId は、ほぼ確実に検索条件に使われるので、インデックス設計が重要です。
| 観点 | おすすめ | 理由 | ありがちな失敗 |
|---|---|---|---|
| 外部キー列 | Questions(QuizId) にインデックス | COUNT の対象行を素早く絞れる | インデックス無しで全表走査になりがち |
| 一覧取得 | AsNoTracking() を付ける | 追跡コストを削減しやすい | 追跡が不要なのに既定のまま |
| ページング | OrderBy + Skip/Take | 順序が安定し、再現性が高い | 順序なしでページングして欠落・重複 |
| 不要な読み込み | Include を乱用しない | 転送量・メモリを増やさない | 一覧で子テーブル全取得してしまう |
よくある落とし穴と対処
ToList() の位置を間違えてクライアント集計になる
次のように ToList() を早く呼ぶと、以降の処理がメモリ上で実行されます。データ量が多いと一気に重くなります。
// 非推奨:先に ToList してしまうと、その後はメモリ上で Count しやすい
var list = context.Quizzes
.Where(q => q.Status)
.ToList()
.Select(q => new
{
q.Id,
q.Title,
TotalQuestions = q.Questions.Count // ここで Questions が未ロードなら 0 になりやすい
})
.ToList();
投影(Select)と集計(Count)は ToList の前、これが鉄則です。
コレクション ナビゲーションは null にしない
エンティティのコレクション ナビゲーションは、初期化しておくと扱いが安定します。
public sealed class Quiz
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Status { get; set; }
public List<Question> Questions { get; set; } = new();
}
これにより、クエリ外(メモリ上)の処理でも Questions が null で落ちる事故を減らせます。
SQL を確認して安心したいときは ToQueryString()
「本当に DB 側で COUNT できてる?」が気になる場合、EF Core ならクエリから SQL を確認できます(デバッグ用途)。
var query = context.Quizzes
.Where(q => q.Status)
.Select(q => new { q.Id, TotalQuestions = q.Questions.Count() });
var sql = query.ToQueryString();
SQL の形が確認できると、Include を付けたせいで JOIN が膨らんでいないか、不要な列を引いていないかなど、パフォーマンス面の見直しがしやすくなります。
実務向け:一覧APIでそのまま使える形にする
多くの現場では、一覧画面や管理画面の API で次のような要件が乗ってきます。
- 検索(タイトルの部分一致)
- ソート(作成日降順、タイトル昇順など)
- ページング(Skip/Take)
- 集計列(TotalQuestions)
この場合も、まずは Quiz を絞る → 投影で必要列+Count を返すという流れにすると、読みやすく改修しやすいです。
public async Task<List<QuizListItemDto>> GetQuizListAsync(
string? keyword,
int skip,
int take)
{
var query = context.Quizzes.AsNoTracking()
.Where(q => q.Status);
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(q => q.Title.Contains(keyword));
}
return await query
.OrderByDescending(q => q.Id)
.Skip(skip)
.Take(take)
.Select(q => new QuizListItemDto
{
Id = q.Id,
Title = q.Title,
Status = q.Status,
TotalQuestions = q.Questions.Count()
})
.ToListAsync();
}
「検索条件が増えた」「件数の数え方を変えたい(有効な問題だけ)」といった変更が入っても、Select の部分を見ればどんなデータを返しているかが一目で分かるため、保守が楽になります。
まとめ:GroupBy より、投影+Count を第一候補にする
- Quiz と Question が 1対多で、Quiz を一覧表示したいなら GroupBy は不要
Selectで必要列を投影し、q.Questions.Count()を含めるのが最短- 件数だけなら
Includeは基本不要。読み込み量を増やさない - 匿名型より DTO を使うと実務での拡張・テストがしやすい
ToList()の位置を間違えるとクライアント集計になりやすいので注意
「一覧に件数を足す」程度の要件は小さく見えますが、Include の乱用やクライアント側集計を放置すると、データが増えた瞬間に一気に遅くなります。まずは Quiz 起点の投影+Count を基本形として覚えておくと、EF Core での集計がぐっと安定します。

コメント