EF CoreでQuizごとの問題数(TotalQuestions)を取得する方法|GroupBy不要のCount集計

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() と目的は同じです。チームのコーディング規約や設計方針に合わせて使い分けると良いでしょう。

パターン書き方特徴
ナビゲーションで Countq.Questions.Count()モデルの意図が伝わりやすい。読みやすい
外部キーで Countcontext.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 での集計がぐっと安定します。

この記事を書いた人

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

コメント

コメントする

目次