ASP.NET Core MVCで一覧画面を作ると、列ヘッダークリックで並び替え(ソート)できるUIが定番です。しかし取得処理がSQL Serverのストアドプロシージャ固定だと、ORDER BYをどの列にするかをどう切り替えるかで詰まりがち。本記事では安全で実務的な解決策を、コード付きで整理します。
Stored Procedure固定の一覧で「任意の列ソート」が難しい理由
ASP.NET MVC / ASP.NET Core MVCの一覧テーブルでは、ユーザーが列ヘッダーをクリックして「名前順」「作成日順」「更新日順」などに並び替えられると便利です。ところが一覧取得をストアドプロシージャ(Stored Procedure / SP)で実装している場合、次の壁にぶつかります。
- SQL Serverでは、ORDER BYの列名をパラメータで置き換えられない(
ORDER BY @SortColumnのような書き方は期待通りに動きません) - ページング(OFFSET/FETCH)をするならORDER BYは必須で、しかも並び順が不安定だとページが揺れる
- 列ごとに型が違う(文字列・日付・数値など)と、単純なCASEトリックは破綻しやすい
- 複数列ソート(第1キー→第2キー…)やNULLの扱いまで含めると、設計の粗がすぐ露見する
結論として、SP側で「任意列ソート」を本気でやるなら、基本は「CASEによる疑似ORDER BY」か「動的SQL(Dynamic SQL)」の二択です。加えて、要件と規模によっては「分岐で静的SQLを並べる」や「クライアントソート」も現実的な選択肢になります。
どのやり方を選ぶべきか:実務向け比較表
| 方式 | メリット | デメリット / 落とし穴 | 向いているケース |
|---|---|---|---|
| CASEで疑似ORDER BY | 動的SQLを避けられる/実装が比較的単純 | 型混在・複数列ソート・昇順降順切替で肥大化しやすい/最適化されにくいことがある | ソート対象が少数(2〜3列程度)で、要件が固定的 |
| IF/ELSEで静的SQL分岐 | SQLインジェクションの心配が最小/実行計画が安定しやすい | 分岐が増えると管理地獄/同じSELECTがコピペになりやすい | ソートパターンが少なく、SPを堅牢に保ちたい |
| 動的SQL(ホワイトリスト必須) | 複数列ソートや拡張がしやすい/要件変化に強い | 組み立てを誤るとSQLインジェクション/実行計画が分散しやすい | ソート対象が多い/複合ソートが必要/検索・フィルタも絡む |
| クライアント側でソート | SPが単純なまま/UI体験を作りやすい | 「全件で正しい並び」にならない/サーバーページングと相性が悪い | 取得件数が少ない、または1ページ分だけ並べ替えれば十分 |
| SPをやめてEF Core / LINQでソート | OrderBy/ThenByで素直に書ける/MVCの一般的実装と相性が良い | 要件や既存資産によりSP必須の場合は不可 | SP縛りが薄く、アプリ側で統一的に検索・ソート・ページングをしたい |
ここから先は、特に相談が多い「CASE」と「動的SQL」を中心に、MVCの一覧画面で使える形に落とし込みます。
CASEで疑似ORDER BYを実装する(小規模なら有効)
CASE方式は「並べ替え対象の列をCASE式で切り替える」発想です。動的SQLを使わないため心理的ハードルは低い一方、拡張すると一気に苦しくなります。
単一列ソート(同じ型が並ぶ場合)の例
たとえば「Name(文字列)」「CreatedAt(日付)」のように型が混ざると、CASEで一本化しづらいので、まずは「同じ型の列でのみ切り替える」例を示します。
CREATE OR ALTER PROCEDURE dbo.Users_List_CaseSort
@SortKey nvarchar(50) = N'Name', -- 'Name' or 'Email'
@SortDirection nvarchar(4) = N'ASC', -- 'ASC' or 'DESC'
@Offset int = 0,
@Fetch int = 50
AS
BEGIN
SET NOCOUNT ON;
SELECT
u.UserId,
u.Name,
u.Email,
u.CreatedAt
FROM dbo.Users AS u
ORDER BY
CASE WHEN @SortKey = N'Name' AND @SortDirection = N'ASC' THEN u.Name END ASC,
CASE WHEN @SortKey = N'Name' AND @SortDirection = N'DESC' THEN u.Name END DESC,
CASE WHEN @SortKey = N'Email' AND @SortDirection = N'ASC' THEN u.Email END ASC,
CASE WHEN @SortKey = N'Email' AND @SortDirection = N'DESC' THEN u.Email END DESC,
u.UserId ASC -- ページングの安定化(同値のときのタイブレーク)
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;
END
ポイントは次の通りです。
- 昇順・降順の切替をCASEで分ける(単純に
CASE ... ENDの後ろにASC/DESCをパラメータで付けられない) - 最後に必ず一意キー(例:UserId)でタイブレークして、ページング時の揺れを防ぐ
- ソート対象が増えるとCASEが雪だるま式に増える
CASE方式がすぐ限界に達する理由
実際の一覧では「更新日→名前」のような複合ソートや、「日付はNULLが混ざる」「数値の列もある」などが普通に起こります。CASE方式では次の問題が顕在化します。
| つまずきポイント | 何が起きるか | 結果 |
|---|---|---|
| 複数列ソート | 第1キー・第2キー…をCASEで表現するため、条件分岐が爆発 | 可読性・保守性が急落 |
| 型混在 | CASEの戻り値型が揃わず、暗黙変換やエラーの温床 | 実装が歪む(列ごとにCASEを分割する羽目) |
| NULL/空文字/大小比較 | NULLを末尾に寄せる等の仕様が入るとCASEがさらに増える | 「書けるが地獄」になりやすい |
小規模・固定要件ならCASE方式は有効ですが、実務の「拡張される一覧」では動的SQL側に寄せた方が結果的に安全で読みやすいケースが多いです。
IF/ELSEで静的SQL分岐する(実は堅牢で「通しやすい」選択)
「動的SQLは怖い」「監査が厳しい」という現場では、ソートパターンごとに静的SQLを書き分ける方式が採用されることもあります。これはモダンさは地味でも、セキュリティ面で非常に説明しやすいです。
CREATE OR ALTER PROCEDURE dbo.Users_List_StaticSort
@SortKey nvarchar(50) = N'CreatedAt',
@SortDirection nvarchar(4) = N'DESC',
@Offset int = 0,
@Fetch int = 50
AS
BEGIN
SET NOCOUNT ON;
IF (@SortKey = N'Name' AND @SortDirection = N'ASC')
BEGIN
SELECT u.UserId, u.Name, u.Email, u.CreatedAt
FROM dbo.Users u
ORDER BY u.Name ASC, u.UserId ASC
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;
RETURN;
END
IF (@SortKey = N'Name' AND @SortDirection = N'DESC')
BEGIN
SELECT u.UserId, u.Name, u.Email, u.CreatedAt
FROM dbo.Users u
ORDER BY u.Name DESC, u.UserId ASC
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;
RETURN;
END
-- 既定(CreatedAt DESC)
SELECT u.UserId, u.Name, u.Email, u.CreatedAt
FROM dbo.Users u
ORDER BY u.CreatedAt DESC, u.UserId ASC
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;
END
この方式は「SQLインジェクションの余地がほぼない」代わりに、ソート列が増えるほど分岐が増えます。ソート対象が数個で固定なら、最終的に一番事故が少ないこともあります。
動的SQLで「任意列ソート」を安全に実装する(実務の本命)
ソート対象が多い、複数列ソートが必要、一覧に検索条件が増える――こうなると動的SQLが現実解です。大事なのは「ユーザー入力をそのままORDER BYに入れない」ことです。
やってはいけない例(危険なORDER BY組み立て)
以下のように、受け取った文字列をそのまま結合するのはNGです。
-- NG例:@SortColumn に悪意ある文字列が入ると破綻する
SET @Sql = N'SELECT ... ORDER BY ' + @SortColumn + N' ' + @SortDirection;
列名や方向はパラメータ化できないため、「組み立てが必要=設計で安全にする」発想が必須になります。
安全にするための基本原則
- ソートキーはホワイトリストで検証する(許可したものだけ採用)
- ソート方向も固定値(ASC/DESC)に正規化する(それ以外は既定値へ)
- 実データに関わる値(検索条件など)はsp_executesqlでパラメータ化する
- ページングするならタイブレーク(例:主キー)を必ず足す
ホワイトリスト設計:列名を「そのまま通さない」のがコツ
「ホワイトリスト」と言うと QUOTENAME(@SortColumn) で囲えば良い、と誤解されがちですが、実務では次の方針が堅いです。
| 方針 | 例 | なぜ安全か |
|---|---|---|
| ソートキー(UI用)→ SQL式(DB用)のマッピング | UI: Name → SQL: u.Name | DB側は「決め打ちの式」しか採用しないため、入力が混ざっても無視できる |
| 列名をそのまま通す(非推奨寄り) | QUOTENAME(@SortColumn) | 記号注入は抑えられるが、「許可していない列」まで指定できる設計になりやすい |
特に、JOINや別名(エイリアス)がある一覧では「Name は u.Name」のように、DB側で必ず完全なSQL式に変換するのが扱いやすいです。
動的SQLの実装例:単一列ソート+検索+ページング(SQL Server)
ここでは「ユーザー一覧」を例に、現場でそのまま流用しやすい雛形を提示します。動的にしているのはORDER BYだけで、検索条件やページングはパラメータ化しています。
CREATE OR ALTER PROCEDURE dbo.Users_List
@SortKey nvarchar(50) = N'CreatedAt', -- UIのキー(例: Name, Email, CreatedAt, LastLoginAt)
@SortDirection nvarchar(4) = N'DESC', -- 'ASC' or 'DESC'
@Search nvarchar(100) = NULL, -- 例:名前/メール検索
@Offset int = 0,
@Fetch int = 50
AS
BEGIN
SET NOCOUNT ON;
-- 1) ソート列(SQL式)をホワイトリストで決定
DECLARE @OrderExpr nvarchar(200) =
CASE @SortKey
WHEN N'Name' THEN N'u.Name'
WHEN N'Email' THEN N'u.Email'
WHEN N'CreatedAt' THEN N'u.CreatedAt'
WHEN N'LastLoginAt' THEN N'u.LastLoginAt'
WHEN N'Status' THEN N'u.Status'
ELSE N'u.CreatedAt' -- 不正値は既定へ
END;
-- 2) 方向はASC/DESCに正規化(それ以外は既定に落とす)
DECLARE @Dir nvarchar(4) =
CASE WHEN UPPER(@SortDirection) = N'ASC' THEN N'ASC' ELSE N'DESC' END;
-- 3) ORDER BY は文字列結合が必要(列名はパラメータ化できない)
-- ただし、値(検索語/Offset/Fetch)は sp_executesql でパラメータ化する
DECLARE @Sql nvarchar(max) = N'
SELECT
u.UserId,
u.Name,
u.Email,
u.Status,
u.CreatedAt,
u.LastLoginAt
FROM dbo.Users AS u
WHERE
(@Search IS NULL OR @Search = N'''')
OR (u.Name LIKE N''%'' + @Search + N''%'')
OR (u.Email LIKE N''%'' + @Search + N''%'')
ORDER BY ' + @OrderExpr + N' ' + @Dir + N',
u.UserId ASC
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;';
EXEC sp_executesql
@Sql,
N'@Search nvarchar(100), @Offset int, @Fetch int',
@Search = @Search,
@Offset = @Offset,
@Fetch = @Fetch;
END
この雛形で押さえている実務ポイント
- ソート対象はCASEで「SQL式」に変換している(入力を直接使わない)
- 検索条件はsp_executesqlでパラメータ化している(SQLインジェクション対策の基本)
- u.UserIdでタイブレークしている(ページングの安定化)
- JOINが増えても、マッピング側で
o.OrderDateのように式を増やすだけで拡張できる
複数列ソート(第1キー→第2キー…)を「破綻させない」設計
一覧が育つと「第1キー:ステータス、第2キー:更新日、第3キー:ID」のように複合ソートが要求されがちです。CASE方式だと破綻しやすい領域なので、動的SQLでも“組み立て方”を一段上げるのがコツです。
現代的で拡張しやすい:TVP(テーブル値パラメータ)でソート指定を渡す
「SortKeyとDirectionを1つの文字列に詰め込む」より、SQL Serverならテーブル値パラメータ(TVP)が扱いやすいです。MVC側で「ソート順の配列」を作って渡せます。
まずユーザー定義テーブル型を作ります。
CREATE TYPE dbo.SortKeysType AS TABLE
(
Priority int NOT NULL, -- 1,2,3...
SortKey nvarchar(50) NOT NULL, -- UIキー(Name, CreatedAt...)
Direction nvarchar(4) NOT NULL -- 'ASC' or 'DESC'
);
次にSPでORDER BY句を組み立てます(SQL Server 2017以降ならSTRING_AGGが便利です)。
CREATE OR ALTER PROCEDURE dbo.Users_List_MultiSort
@SortKeys dbo.SortKeysType READONLY,
@Search nvarchar(100) = NULL,
@Offset int = 0,
@Fetch int = 50
AS
BEGIN
SET NOCOUNT ON;
DECLARE @OrderBy nvarchar(max);
;WITH Mapped AS
(
SELECT
sk.Priority,
Expr =
CASE sk.SortKey
WHEN N'Status' THEN N'u.Status'
WHEN N'LastLoginAt' THEN N'u.LastLoginAt'
WHEN N'CreatedAt' THEN N'u.CreatedAt'
WHEN N'Name' THEN N'u.Name'
ELSE NULL
END,
Dir =
CASE WHEN UPPER(sk.Direction) = N'DESC' THEN N'DESC' ELSE N'ASC' END
FROM @SortKeys AS sk
)
SELECT
@OrderBy = STRING_AGG(Expr + N' ' + Dir, N', ') WITHIN GROUP (ORDER BY Priority)
FROM Mapped
WHERE Expr IS NOT NULL;
-- 指定が空/不正なら既定に落とす
IF (@OrderBy IS NULL OR LTRIM(RTRIM(@OrderBy)) = N'')
SET @OrderBy = N'u.CreatedAt DESC';
-- ページングの安定化(タイブレークを必ず追加)
SET @OrderBy = @OrderBy + N', u.UserId ASC';
DECLARE @Sql nvarchar(max) = N'
SELECT
u.UserId, u.Name, u.Email, u.Status, u.CreatedAt, u.LastLoginAt
FROM dbo.Users AS u
WHERE
(@Search IS NULL OR @Search = N'''')
OR (u.Name LIKE N''%'' + @Search + N''%'')
OR (u.Email LIKE N''%'' + @Search + N''%'')
ORDER BY ' + @OrderBy + N'
OFFSET @Offset ROWS FETCH NEXT @Fetch ROWS ONLY;';
EXEC sp_executesql
@Sql,
N'@Search nvarchar(100), @Offset int, @Fetch int',
@Search = @Search, @Offset = @Offset, @Fetch = @Fetch;
END
この設計が強い理由は次の通りです。
- MVC側で「どの列を第1キーにするか」「第2キーは何か」を自然に表現できる
- DB側はあくまでホワイトリストでSQL式に変換するだけなので安全
- 後からソート対象が増えても、マッピングを1行増やすだけで対応できる
STRING_AGGが使えない場合の考え方
SQL Serverのバージョン都合でSTRING_AGGが使えない場合でも、同じ考え方で「ORDER BYのリスト文字列」を組み立てられます(古典的にはFOR XML PATHなど)。ただし実装が長くなるので、可能ならSQL Serverのバージョン要件を確認し、STRING_AGGを使える構成に寄せる方が保守が楽です。
ASP.NET Core MVC側の実装ポイント(SPを安全に使い切る)
SP側でホワイトリストをしていても、MVC側でのパラメータ設計が雑だと、ユーザー体験が悪化します。ここでは「一覧画面でよくある」パターンを整理します。
URLパラメータ設計の例
| パラメータ | 例 | 用途 | 注意点 |
|---|---|---|---|
| sort | Name | ソート対象(UIキー) | 列名そのものではなく、UIで定義したキーにする |
| dir | asc / desc | 昇順降順 | 2値に正規化する(不正値は既定へ) |
| page | 1 | ページ番号 | 負数や過大値をガード |
| q | tanaka | 検索文字列 | SP側でパラメータ化してLIKE検索 |
Controller例(Dapper/ADO.NETのイメージ)
SPに渡す値は「そのままDBに投げる」ではなく、アプリ側でも最低限の正規化をしておくと事故が減ります(もちろん最終防衛線はDB側のホワイトリストです)。
// 例:ASP.NET Core MVC Controller(概念コード)
public async Task<IActionResult> Index(string? sort, string? dir, int page = 1, string? q = null)
{
const int pageSize = 50;
// sort/dir の正規化(UI側の許可リスト)
sort = sort is "Name" or "Email" or "CreatedAt" or "LastLoginAt" or "Status"
? sort
: "CreatedAt";
dir = string.Equals(dir, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
page = Math.Max(page, 1);
var offset = (page - 1) * pageSize;
// SP呼び出し(例:Dapper想定)
// var rows = await connection.QueryAsync<UserRow>(
// "dbo.Users_List",
// new { SortKey = sort, SortDirection = dir, Search = q, Offset = offset, Fetch = pageSize },
// commandType: CommandType.StoredProcedure);
return View(/* rows */);
}
View側:ヘッダークリックで昇順降順をトグルする
ユーザー体験として「同じ列をもう一度クリックするとASC↔DESCが切り替わる」は定番です。実装上は次の考え方がシンプルです。
- 現在の
sortとクリック対象が同じならdirを反転 - 違う列なら既定方向(例:ASC)から開始
<!-- Razor View(概念コード) -->
<th>
<a asp-action="Index"
asp-route-sort="Name"
asp-route-dir="@(Model.CurrentSort == "Name" && Model.CurrentDir == "ASC" ? "desc" : "asc")"
asp-route-q="@Model.Query">
名前
</a>
</th>
こうしておくと、SP側は「sort/dirを受けてORDER BYを決める」だけになり、責務が分離されます。
OFFSET/FETCHは万能ではない:安定ソートがないとページが崩れる
よくある勘違いとして「OFFSET/FETCHでページングすればいい=ソート問題は解決する」があります。しかしOFFSET/FETCHはあくまでページングの手段で、正しい並び順を作るのはORDER BYの責務です。
さらに、ORDER BYが「同値が多い列」だけだと、ページング時に次のような現象が起きます。
- 同じデータなのに、ページを戻ると行の並びが微妙に変わる
- ページを跨いで同じ行が重複したり、逆に抜けたりする(更新頻度が高いテーブルで顕著)
これを避けるため、ORDER BYの最後に主キー(または一意キー)を必ず追加して、並び順を決定的にしてください。
ORDER BY u.CreatedAt DESC, u.UserId ASC
パフォーマンス設計:動的ORDER BYで遅くしないコツ
「動的SQLは遅いのでは?」と心配されがちですが、遅くなる原因は多くの場合ソート対象の列に対して適切なインデックスがない、またはフィルタが弱くて並べ替え対象が大きすぎることです。動的SQL自体が即ボトルネックになるとは限りません。
インデックス設計の目安
| ソート要件 | 推奨の方向性 | 補足 |
|---|---|---|
| CreatedAtでよく並べ替える | CreatedAtをキーにしたインデックス | 一覧で表示する列が多いならINCLUDEも検討 |
| Status→LastLoginAt の複合ソート | (Status, LastLoginAt) の複合インデックス | 第1キーの選び方が重要(絞り込みの有無も考慮) |
| 文字列の部分一致検索+ソート | LIKE ‘%xxx%’ は基本的に厳しい | 要件次第で全文検索や検索専用設計を検討 |
動的SQLと実行計画(Plan Cache)の話
動的SQLは、文字列が違えば別クエリとして扱われるため、ORDER BYのパターンが多いと実行計画が分散します。ただし実務では以下の整理が役立ちます。
- ORDER BYのパターンが「数十」程度なら、通常は致命傷になりにくい
- 頻繁に切り替わる一覧で計画キャッシュが問題になるなら、分岐方式(IF/ELSE)に寄せるか、利用頻度の高い順に絞る
- パラメータスニッフィングが疑わしい場合は、設計・統計情報・索引を含めて診断が必要(小手先でOPTION(RECOMPILE)を乱用するとCPUコストが増える)
つまり「動的SQLかどうか」だけで善悪は決まりません。どの列でどれだけの件数をソートするかが本質です。
NULLの扱い・大小比較・大文字小文字など、地味に効く仕様
ソートは「列を指定すれば終わり」ではなく、業務要件で地味に揉めるポイントがあります。初期設計で決めておくと後が楽です。
- NULLを先頭/末尾どちらに寄せるか(SQL Serverは昇順でNULLが先頭になりやすい)
- 大文字小文字の区別(照合順序=Collationの影響)
- 空文字とNULLを同一視するか
- 数値文字列の並び(”2″ と “10” を文字列比較すると “10” が先に来る)
たとえば「NULLを末尾に寄せたい」という要件が入ったら、ORDER BYで次のような式をホワイトリストに組み込むこともあります。
-- NULLを末尾に寄せる例(ASC)
ORDER BY CASE WHEN u.LastLoginAt IS NULL THEN 1 ELSE 0 END ASC,
u.LastLoginAt ASC
このような式こそ、「UIキー → SQL式マッピング」方式が活きます。列名の直渡し設計だと、要件が入った瞬間に破綻します。
クライアント側ソートはいつ使える?
「SPで頑張る」以外に、クライアント側(JavaScriptやグリッドコンポーネント)でソートする選択肢もあります。ただし適用条件が明確です。
- 表示件数が少ない(例:数十件)
- ページングしていない、または「今見えているページだけ」並べ替えできれば良い
- サーバー側の検索・ページングと「全件での順序一致」を求められない
逆に、管理画面や業務一覧のように「全件の正しい順序でページングしたい」場合は、サーバー側ソートがほぼ必須です。
可能なら、EF Core / LINQでのソートが最も素直
要件上SPが必須でないなら、EF Coreの OrderBy / ThenBy を使う方が、MVC側の一般的な「ソート・フィルタ・ページング」実装と噛み合います。特に、ソートキーの追加・複合ソート・NULL処理などの変更頻度が高いプロダクトでは、アプリ側で式を組み立てられること自体が保守性になります。
とはいえ、既存資産・権限設計・監査・性能要件などで「SP縛り」が現実に存在するのもよくある話です。その場合は、ここまで説明した通り、動的SQLを“安全に”使い切るのが最短距離になります。
最終チェックリスト:この形にすると実務で揉めにくい
| チェック項目 | 満たすと何が良いか | 実装の要点 |
|---|---|---|
| ソートキーのホワイトリスト | SQLインジェクションと仕様逸脱を防ぐ | UIキー→SQL式のマッピング(CASE) |
| 方向の正規化 | 想定外入力での事故を防ぐ | ASC/DESC以外は既定へ |
| 値はsp_executesqlでパラメータ化 | 検索条件などからの注入を防ぐ | WHERE句・OFFSET/FETCHは必ずパラメータ |
| タイブレーク(主キー追加) | ページングが安定し、重複/欠落が減る | ORDER BYの最後に一意列を固定で追加 |
| 索引(インデックス)設計 | ソートのコストを現実的に抑える | よく使うソート列を優先して設計 |
SP側で「任意の列でソート」を実現する現代的な答えは、派手な新機能ではなく、ホワイトリスト+動的ORDER BY+sp_executesqlの堅実な組み合わせです。CASE方式は小規模なら有効ですが、要件が伸びる前提なら、早い段階で動的SQLの“安全な型”を作っておくと、後からの機能追加が圧倒的に楽になります。

コメント