C#でSqlDataReaderを使ってSQL Serverから一覧を読み込むと、画面に「何件取得できたか」を出したくなります。しかし@@ROWCOUNTやConsole.WriteLineを混ぜると、Webではうまく表示できない/常に0になるなどの落とし穴も。本記事では、最短で正しく件数を出す方法と、用途別の実装パターンを整理します。
よくある状況:SqlDataReaderで映画一覧を読み、Listに追加している
例えば、映画一覧をSQL Serverから読み取り、ListMovies に追加して画面に出す処理は次のようになります。
public class MovieInfo
{
public int MovieId { get; set; }
public string Title { get; set; } = "";
public int ReleaseYear { get; set; }
}
public List<MovieInfo> ListMovies { get; } = new();
public void LoadMovies()
{
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;", con);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var movie = new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
};
ListMovies.Add(movie);
}
}
このとき、次の要望が出がちです。
- 読み取ったレコード件数(行数)を知りたい
- その件数をRazor Pages / MVC / Web APIの画面(またはJSON)に表示したい
- @@ROWCOUNTを足して「2つ目の結果セット」で件数を取ろうとしたが、扱いが分からず表示できない
- whileループ内でカウンタを増やしたのに、画面上ではなぜか常に0になる
結論:すでにListに追加しているなら、件数はList.Countが最短で確実
「取得した件数=Listに追加した件数」なので、最終的に ListMovies.Count を見ればそれが行数です。クエリに@@ROWCOUNTを足す必要も、SqlDataReaderで複数結果セットを読む必要もありません。
ループ完了後に一度だけCountを取る
public int TotalRecordsRead { get; private set; }
public void LoadMovies()
{
ListMovies.Clear();
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;", con);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var movie = new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
};
ListMovies.Add(movie);
}
// 件数は最後に一回だけ確定させる
TotalRecordsRead = ListMovies.Count;
}
Listが最初から空とは限らない場合は差分で取る
「画面の都合でListを使い回している」「読み取り前に別の要素が入っている可能性がある」といったケースでは、読み取り前後の差分を取るのが安全です。
public int TotalRecordsRead { get; private set; }
public void LoadMovies()
{
var originalCount = ListMovies.Count;
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;", con);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
ListMovies.Add(new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
});
}
TotalRecordsRead = ListMovies.Count - originalCount;
}
Webアプリで「Console.WriteLine」は画面に出ない
Webアプリ(ASP.NET CoreのRazor Pages / MVC / Web API)で Console.WriteLine を使っても、基本的にブラウザの画面には表示されません。これは「どこかのコンソールウィンドウに出す」ための出力であり、HTMLのレンダリングとは別物だからです。
Webで件数を表示したいなら、次のいずれかにする必要があります。
- Razor:PageModel(またはViewModel)のプロパティに件数を入れて、HTMLに埋め込む
- Web API:レスポンスDTOに
TotalCountを含め、JSONとして返す - ログ:画面ではなく運用目的なら
ILoggerでログ出力する
落とし穴:「件数」をMovieInfo(各行)に持たせると混乱しやすい
MovieInfoは「映画1件」を表すデータです。そこに TotalRecordsRead(総件数)を持たせると、設計上も表示上もややこしくなります。
| 持たせる場所 | 向いているデータ | 理由 |
|---|---|---|
| MovieInfo(各行) | Title / ReleaseYearなど、その行固有の値 | 1件単位で完結する情報 |
| PageModel / ViewModel / DTO | 総件数、ページ番号、検索条件、並び替え条件 | 一覧全体に関わる情報で、foreachの外で表示するのが自然 |
件数は「一覧全体」の情報なので、ページ全体のモデルにプロパティとして持たせ、表示もforeachの外に置くのが定石です。
Razor Pagesで件数を表示する実装例
PageModel側:Listと件数プロパティを用意する
public class MoviesModel : PageModel
{
private readonly string _connectionString;
public MoviesModel(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")!;
}
public List<MovieInfo> ListMovies { get; } = new();
public int TotalRecordsRead { get; private set; }
public void OnGet()
{
ListMovies.Clear();
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;", con);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
ListMovies.Add(new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
});
}
TotalRecordsRead = ListMovies.Count;
}
}
Razor側:foreachの外で件数を表示する
「一覧の上に出す」「下に出す」「テーブルのフッターとして出す」など、どれでもOKです。大事なのは、件数表示をforeachの外に置くことです。
<h2>映画一覧</h2>
<div class="notice">
取得件数:@Model.TotalRecordsRead 件
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>タイトル</th>
<th>公開年</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.ListMovies)
{
<tr>
<td>@item.MovieId</td>
<td>@item.Title</td>
<td>@item.ReleaseYear</td>
</tr>
}
</tbody>
</table>
<div class="footer">
総件数:@Model.ListMovies.Count 件
</div>
なお、@Model.TotalRecordsRead と @Model.ListMovies.Count は、読み取り後にListへ追加している限り同じ値になります。どちらを表示しても問題ありません。
Web APIで件数を返す実装例
Web APIでは「HTMLに埋め込む」ではなく「JSONに含める」形になります。フロント側(React/Vue/Blazor/素のJSなど)が一覧と件数を同時に受け取りやすいように、レスポンスDTOを作るのが定番です。
レスポンスDTO(Items + TotalCount)
public class MoviesResponse
{
public List<MovieInfo> Items { get; set; } = new();
public int TotalCount { get; set; }
}
Controller例:List.CountをTotalCountに入れて返す
[ApiController]
[Route("api/movies")]
public class MoviesController : ControllerBase
{
private readonly string _connectionString;
public MoviesController(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")!;
}
[HttpGet]
public ActionResult<MoviesResponse> Get()
{
var list = new List<MovieInfo>();
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;", con);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
list.Add(new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
});
}
return Ok(new MoviesResponse
{
Items = list,
TotalCount = list.Count
});
}
}
返ってくるJSONのイメージ
{
"items": [
{ "movieId": 1, "title": "Example", "releaseYear": 1999 }
],
"totalCount": 1
}
この形にしておくと、クライアント側は「itemsを描画」「totalCountを表示」を分けて実装できます。ページングやフィルタ条件を付けたときにも拡張が容易です。
「whileでカウントしたのに画面では0」になりやすい原因
取得処理の中で count++ しているのに画面で0になる場合、SQLやSqlDataReaderが悪いのではなく、Webの表示モデルに値が渡っていないことがほとんどです。典型例を整理します。
- ローカル変数のまま
int totalRecordsRead = 0;をメソッド内で増やしても、それをRazorが参照できるプロパティに代入しないと表示できません。 - MovieInfoに入れてしまい、表示場所がforeachの中/外でズレている
各行に同じ総件数を入れる設計になり、表示側が@item.TotalRecordsReadを出したり@Model.TotalRecordsReadを出したりで混乱します。 - 読み取り後に代入していない/代入が別の分岐にある
例:例外で途中 return しており、TotalRecordsReadの代入まで到達していない。 - ListをClearしていない/二重に読み込んでいる
画面上は0でも実は別のインスタンスを見ている、などの事故が起きやすいです。 - 非同期処理の結果を待たずに描画している(JS側など)
APIのレスポンスが来る前に初期値0を描画し、更新処理が書かれていないケース。
対策としてはシンプルで、「表示したい値は、表示モデル(PageModel/DTO)のプロパティに入れる」、そして「foreachの外で表示する」を徹底すると解決しやすいです。
どうしても@@ROWCOUNTを使うなら:複数結果セットをNextResultで読む
「select …; select @@ROWCOUNT;」のように書くと、SqlDataReaderは結果セットを2つ持ちます。1つ目が映画一覧、2つ目が件数です。2つ目を読むには NextResult() が必要です。
ただし、すでに一覧を全部読み取ってListに入れているなら、List.Countで十分です。@@ROWCOUNTを使うと実装が複雑になり、読みやすさも落ちやすいので「どうしても」の場合に限定するのがおすすめです。
SQL例:2つのSELECTで結果セットを分ける
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;
SELECT @@ROWCOUNT AS TotalCount;
C#例:最初の結果セットをRead、次にNextResultで件数へ
public int TotalRecordsRead { get; private set; }
public List<MovieInfo> ListMovies { get; } = new();
public void LoadMoviesWithRowCount()
{
ListMovies.Clear();
TotalRecordsRead = 0;
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear
FROM dbo.Movies
ORDER BY MovieId;
SELECT @@ROWCOUNT AS TotalCount;", con);
con.Open();
using var reader = cmd.ExecuteReader();
// 1つ目:一覧
while (reader.Read())
{
ListMovies.Add(new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
});
}
// 2つ目:件数
if (reader.NextResult())
{
if (reader.Read())
{
// 2つ目の結果セットは1列だけの想定なら reader[0] でもOK
TotalRecordsRead = reader.GetInt32(reader.GetOrdinal("TotalCount"));
}
}
}
@@ROWCOUNT運用がハマりやすいポイント
- 「最後に実行された文」の影響を受ける
途中に別のSQL文が入ると、意図しない行数になることがあります。 - コード量が増える割にメリットが薄い
一覧を読み切っているならList.Countで十分で、より明快です。 - 将来の改修で壊れやすい
クエリに手が入ると結果セットの順番や列名が変わり、表示が崩れる原因になります。
「件数だけ欲しい」場合はCOUNT(*) + ExecuteScalarが最適
要件を整理すると、「一覧も表示したい」ではなく「件数だけ知りたい」ケースもあります。この場合、全行を読み取ってCountするのは無駄が大きいことがあります。件数だけならSQLでCOUNT(*)を取り、ExecuteScalarで受け取るのが速くて簡単です。
SQL:件数だけ取得
SELECT COUNT(*)
FROM dbo.Movies;
C#:ExecuteScalarで1つの値を受け取る
public int GetMoviesCount()
{
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"SELECT COUNT(*) FROM dbo.Movies;", con);
con.Open();
var result = cmd.ExecuteScalar();
return Convert.ToInt32(result);
}
検索条件(WHERE句)がある場合も同様で、WHERE を揃えれば「該当件数」を正確に出せます。
ページングがあるなら:COUNT(*) OVER()で「一覧+総件数」を一度に返す
一覧画面でよくあるのが「1ページ20件だけ表示したいが、総件数(全体のヒット数)は欲しい」というパターンです。ここで単純にList.Countを表示すると「今ページの件数」になり、総件数とズレます。
この場合はSQL Serverのウィンドウ関数 COUNT(*) OVER() を使うと、各行に総件数を添付した形で返せます(DBへの往復を増やさずに済むのが利点)。
SQL例:総件数をTotalCount列として付与しつつページング
DECLARE @Page int = 1;
DECLARE @PageSize int = 20;
SELECT
MovieId,
Title,
ReleaseYear,
COUNT(*) OVER() AS TotalCount
FROM dbo.Movies
WHERE ReleaseYear >= 2000
ORDER BY MovieId
OFFSET (@Page - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
C#例:最初の行でTotalCountを拾い、Itemsは通常通りListへ
public class MoviesResponse
{
public List<MovieInfo> Items { get; set; } = new();
public int TotalCount { get; set; }
}
public MoviesResponse GetMoviesPaged(int page, int pageSize)
{
var res = new MoviesResponse();
using var con = new SqlConnection(_connectionString);
using var cmd = new SqlCommand(@"
SELECT MovieId, Title, ReleaseYear, COUNT(*) OVER() AS TotalCount
FROM dbo.Movies
WHERE ReleaseYear >= @minYear
ORDER BY MovieId
OFFSET (@page - 1) * @pageSize ROWS
FETCH NEXT @pageSize ROWS ONLY;", con);
cmd.Parameters.AddWithValue("@minYear", 2000);
cmd.Parameters.AddWithValue("@page", page);
cmd.Parameters.AddWithValue("@pageSize", pageSize);
con.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (res.TotalCount == 0)
{
// どの行にも同じTotalCountが入るので、最初に一度だけ拾う
res.TotalCount = reader.GetInt32(reader.GetOrdinal("TotalCount"));
}
res.Items.Add(new MovieInfo
{
MovieId = reader.GetInt32(reader.GetOrdinal("MovieId")),
Title = reader.GetString(reader.GetOrdinal("Title")),
ReleaseYear = reader.GetInt32(reader.GetOrdinal("ReleaseYear"))
});
}
return res;
}
この方式は「今ページの表示件数」と「検索結果の総件数」を両立できます。表示側は Items.Count と TotalCount を分けて使えるため、UIが作りやすくなります。
手段ごとのおすすめ度と使い分け早見表
| 目的 | おすすめ手段 | メリット | 注意点 |
|---|---|---|---|
| 一覧を読み取った「取得件数」を出したい | List.Count(または差分) | 最短・確実・読みやすい | 「総件数」ではなく「読み取った件数」 |
| 件数だけ欲しい(一覧不要) | COUNT(*) + ExecuteScalar | 高速・通信量が少ない | 一覧も必要なら別クエリが必要 |
| ページングしつつ総件数も欲しい | COUNT(*) OVER() + OFFSET/FETCH | 往復を増やさず総件数を返せる | SQLが少し長くなる |
| どうしても@@ROWCOUNTで取りたい | 複数結果セット + NextResult() | 仕組みを理解できれば取得可能 | 実装が複雑・改修で壊れやすい |
実務で効く改善ポイント:表示・設計・パフォーマンス
「取得件数」と「総件数」を言葉と変数名で分ける
要件によって意味が変わるので、命名で事故を減らすのが有効です。
- 取得件数(読み取った件数):
FetchedCount/RecordsRead/List.Count - 総件数(検索ヒットの総数):
TotalCount/MatchedCount
ページングがある画面で List.Count を「総件数」として表示してしまうのは、かなり起きやすいミスです。
PageModel/DTOに「一覧全体の情報」を集約する
件数だけでなく、実務では次のような情報も一緒に画面に出したくなります。
- 検索条件(例:公開年の下限)
- 並び順(例:MovieId昇順)
- ページ番号、ページサイズ
- 総件数、現在表示件数
これらはMovieInfoのような「行データ」ではなく、画面全体のモデル(ViewModel/DTO)にまとめると、フロント実装も保守も楽になります。
ログに出したいならILoggerを使う
「画面表示ではなく調査のために件数を知りたい」なら、Consoleではなくログフレームワーク(ASP.NET Core標準のILogger)に出すのが運用向きです。
private readonly ILogger<MoviesModel> _logger;
public MoviesModel(IConfiguration config, ILogger logger)
{
_connectionString = config.GetConnectionString("DefaultConnection")!;
_logger = logger;
}
public void OnGet()
{
// ... 読み取り処理 ...
_logger.LogInformation("Movies fetched: {Count}", ListMovies.Count);
}
大量データのときは「全部読んでCount」より「必要な分だけ」に寄せる
一覧が数万件、数十万件になると、すべてをSqlDataReaderで読み取ってListに貯めるだけでも負荷になります。画面表示が目的なら、次の方向が現実的です。
- ページングを入れて「表示分だけ取得」する
- 総件数はCOUNT(*)またはCOUNT(*) OVER()で別途返す
- 必要なら検索条件に合うインデックス設計を見直す(DB側の話)
チェックリスト:この形にしておくと迷いにくい
- 一覧を読み取ってListに追加しているなら、件数は List.Count
- Webで表示する値は、ローカル変数ではなく PageModel/DTOのプロパティへ
- 件数表示はforeachの外に置く(一覧全体の情報として表示)
- ページングがあるなら List.Count(今ページ) と TotalCount(総件数) を分ける
- どうしても複数結果セットなら NextResult() を忘れない
件数の取り方は一見小さな話ですが、画面表示・API設計・ページング・運用ログまで繋がる「地味に重要な基礎」です。まずはList.Countで最短ルートを押さえ、必要になったタイミングでCOUNT(*)やCOUNT(*) OVER()へ拡張していくのが、実務では最もトラブルが少ない進め方です。

コメント