SQL Serverストアドプロシージャで動的ORDER BYを安全に実装する方法(ASP.NET Core MVCの一覧ソート)

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.NameDB側は「決め打ちの式」しか採用しないため、入力が混ざっても無視できる
列名をそのまま通す(非推奨寄り)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パラメータ設計の例

パラメータ例用途注意点
sortNameソート対象(UIキー)列名そのものではなく、UIで定義したキーにする
dirasc / desc昇順降順2値に正規化する(不正値は既定へ)
page1ページ番号負数や過大値をガード
qtanaka検索文字列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の“安全な型”を作っておくと、後からの機能追加が圧倒的に楽になります。

この記事を書いた人

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

コメント

コメントする

目次