Skip to content

第29章 Web 社員管理アプリ:検索

この章では、第 28 章の社員一覧画面に 検索機能 を追加します。 第 24 章(Windows 版検索)と同じく、次の 2 つの条件を組み合わせて絞り込みます。

  • 名前(姓または名)の部分一致:検索ボックスに入力した文字列
  • 部署:プルダウン(<select>)で選んだ部署

ここで重要になるのが、第 24 章でも学んだ パラメータ化クエリ(SqlParameter) です。 ユーザーが入力した文字列をそのまま SQL に連結すると、SQL インジェクション という重大なセキュリティ問題を引き起こします。Web では誰でもブラウザから URL を組み立てて送れてしまうため、Windows フォーム以上にこの危険が現実的です。

本章では加えて、Web ならではのテーマである 「検索条件をどう URL に乗せるか」 ── GET と POST の使い分けも整理します。


この章でできるようになること

Section titled “この章でできるようになること”

この章を終えると、次のことができるようになります。

  • Web アプリにおける SQL インジェクションの危険を具体例で説明できる
  • SqlParameter を使ったパラメータ化クエリで部分一致検索を書ける
  • 複数条件を組み合わせる SQL を書ける(WHERE 句の組み立て方)
  • EmployeeRepositorySearch メソッドを追加し、GetAll と共通の private ヘルパー(ReadEmployees)で重複を減らせる
  • 検索画面用の ViewModel(EmployeeSearchViewModel) を設計できる
  • Controller のアクション引数で クエリ文字列(?keyword=...&departmentId=...)を受け取れる
  • View で <form method="get"> + <select> の検索フォームを書ける
  • 検索結果を Razor の @foreach で再表示できる
  • 「検索画面は GET が基本」という現場の流儀を理解できる

項目内容
開発環境Visual Studio 2022
プロジェクト種類ASP.NET Core Web アプリ(Model-View-Controller)
対象フレームワーク.NET 8
ソリューション名KadaiWebApp(第 28 章で作成・続けて使う)
プロジェクト名MvcEmployeeApp(第 28 章の続き・作り直さない)
ベースとなる章第 28 章(MvcEmployeeApp をそのまま開く)
データベースSQLServer 2022(TrainingDB)
認証方式(DB)SQL 認証(app_user、第 27 章で作成済み)
NuGet パッケージMicrosoft.Data.SqlClient

この章は第 28 章の MvcEmployeeApp を続けて使います

新しいプロジェクトは作りません。KadaiWebApp ソリューションの MvcEmployeeApp を開き、検索機能を足していきます(Nullable・NuGet・SQL 認証は第 28 章までで設定済み)。


  • 第 28 章を Git に提出済み、または手元にコードがある
  • app_userTrainingDB に接続できる(第 27 章 27-2)
  • 第 28 章 MvcEmployeeApp/Employees が動く
  • 第 24 章(Windows 版検索)の EmployeeRepository.Search を読んだことがある(参考用)

第 28 章の /Employees 画面に、検索 UI を追加します。

社員一覧
名前: [山田 ] 部署: [総務 ▼] [検索] [クリア] ← 本章で追加
2 件の社員が見つかりました。
| 社員ID | 氏名 | 部署名 | メール | 入社日 | 給与 |
|--------|--------------|--------|-----------------------|------------|----------|
| 1001 | 山田 二郎 | 総務 | yamada.jiro@... | 2001/04/01 | ¥500,000 |
| 1008 | 中山 大輔 | 総務 | nakayama.daisuke@... | ... | ¥400,000 |
[新規登録] [削除] ← まだ枠だけ

検索の仕様(第 24 章と同じ):

条件動き
名前のみ入力姓または名に部分一致する社員を表示
部署のみ選択その部署の社員を表示
名前+部署 両方AND 条件で絞り込み
両方とも空 / 「すべての部署」全社員を表示(GetAll と同じ)

URL の例:

/Employees ← 全件表示
/Employees?keyword=山 ← 名前に「山」を含む
/Employees?departmentId=1 ← 部署が「総務」(id=1)
/Employees?keyword=山&departmentId=1 ← 両方の AND

検索条件が URL に乗るため、ブックマークしたり、URL をコピーして共有したりできます ── これが Web ならではの良さです。


29-2 SQL インジェクションの脅威(Web 版)

Section titled “29-2 SQL インジェクションの脅威(Web 版)”

検索機能を書く前に、やってはいけない書き方 を再確認します。第 24 章でも学びましたが、Web では危険が一段と現実的です。

ユーザーの入力をそのまま SQL に連結する書き方:

// ❌ やってはいけない書き方
string keyword = Request.Query["keyword"];
string sql =
"SELECT * FROM employees WHERE last_name LIKE '%" + keyword + "%'";

ブラウザのアドレスバーから、誰でも次のような URL を送れます。

/Employees?keyword=%' OR '1'='1

この値が SQL に直接埋め込まれると、

SELECT * FROM employees WHERE last_name LIKE '%%' OR '1'='1%'

'1'='1' は常に true なので、全社員が表示 されてしまいます。 これでもまだ「全件見えるだけ」ですが、もっと巧妙な攻撃では:

/Employees?keyword=x'; DROP TABLE employees; --

を送られると、テーブルそのものが破壊 されてしまいます。

Windows フォームWeb アプリ
入力は社員(社内ユーザー)のキーボードから入力は 世界中のブラウザ から来うる
攻撃には、社員アカウントと社内 PC が必要URL を組み立てて送るだけ。自動化も容易
攻撃を試す機会は限られる24 時間、自動ツールが攻撃を試し続ける

Web では「性善説のコードは即座に攻撃対象になる」と思っておくのが現場の標準です。


29-3 パラメータ化クエリの基本(復習)

Section titled “29-3 パラメータ化クエリの基本(復習)”

防御の正しいやり方は パラメータ化クエリ です(第 24 章 24-3 で導入したのと同じ)。

SQL 文には プレースホルダー(@param のような名前) だけを書き、値は別途 SqlParameter として渡します。

const string sql =
"SELECT * FROM employees WHERE last_name LIKE '%' + @keyword + '%'";
using SqlCommand cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@keyword", keyword);
仕組み説明
SQL 文に @keyword を書く「ここに値が入る」というマーカー
Parameters.AddWithValue で値を渡すSQLServer が 文字列として 安全に扱う
SQL とデータが分離データに '; が含まれても SQL の構造を壊せない

Web からどんな入力が来ても、@keyword の中身は「ただの文字列」として扱われるため、SQL を改変されることはありません。


29-4 EmployeeRepository に Search を追加する

Section titled “29-4 EmployeeRepository に Search を追加する”

第 28 章の EmployeeRepository を拡張し、Search メソッドを追加します。 GetAllSearch読み取りループの中身が重複 するので、ReadEmployees という private ヘルパーに切り出します(第 24 章と同じパターン)。

EmployeeRepository.cs
using Microsoft.Data.SqlClient;
using MvcEmployeeApp.Models;
namespace MvcEmployeeApp.Data;
public class EmployeeRepository
{
private const string ConnectionString =
"Server=localhost;Database=TrainingDB;User Id=app_user;Password=AppUserPass1!;TrustServerCertificate=true;";
public List<Employee> GetAll()
{
const string sql = @"
SELECT
e.employee_id, e.last_name, e.first_name, e.email,
e.hire_date, e.salary, e.department_id, d.department_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.department_id
ORDER BY e.employee_id";
using SqlConnection conn = new SqlConnection(ConnectionString);
conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn);
return ReadEmployees(cmd);
}
public List<Employee> Search(string keyword, int departmentId)
{
const string sql = @"
SELECT
e.employee_id, e.last_name, e.first_name, e.email,
e.hire_date, e.salary, e.department_id, d.department_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.department_id
WHERE
(@keyword = ''
OR e.last_name LIKE '%' + @keyword + '%'
OR e.first_name LIKE '%' + @keyword + '%')
AND (@departmentId = -1 OR e.department_id = @departmentId)
ORDER BY e.employee_id";
using SqlConnection conn = new SqlConnection(ConnectionString);
conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@keyword", keyword ?? "");
cmd.Parameters.AddWithValue("@departmentId", departmentId);
return ReadEmployees(cmd);
}
public int Insert(Employee employee)
{
// 第 30 章で実装します
throw new NotImplementedException("Insert は第 30 章で実装します。");
}
public int Delete(int employeeId)
{
// 第 30 章で実装します
throw new NotImplementedException("Delete は第 30 章で実装します。");
}
private static List<Employee> ReadEmployees(SqlCommand cmd)
{
List<Employee> list = new List<Employee>();
using SqlDataReader reader = cmd.ExecuteReader();
int idxId = reader.GetOrdinal("employee_id");
int idxLast = reader.GetOrdinal("last_name");
int idxFirst = reader.GetOrdinal("first_name");
int idxEmail = reader.GetOrdinal("email");
int idxHire = reader.GetOrdinal("hire_date");
int idxSalary = reader.GetOrdinal("salary");
int idxDeptId = reader.GetOrdinal("department_id");
int idxDeptName = reader.GetOrdinal("department_name");
while (reader.Read())
{
Employee emp = new Employee();
emp.EmployeeId = reader.GetInt32(idxId);
emp.LastName = reader.GetString(idxLast);
emp.FirstName = reader.GetString(idxFirst);
emp.Email = reader.IsDBNull(idxEmail) ? "" : reader.GetString(idxEmail);
emp.HireDate = reader.GetDateTime(idxHire);
emp.Salary = reader.IsDBNull(idxSalary) ? 0m : reader.GetDecimal(idxSalary);
emp.DepartmentId = reader.IsDBNull(idxDeptId) ? 0 : reader.GetInt32(idxDeptId);
emp.DepartmentName = reader.IsDBNull(idxDeptName) ? "" : reader.GetString(idxDeptName);
list.Add(emp);
}
return list;
}
}
ポイント説明
@keyword / @departmentId でパラメータ化SQL インジェクション対策
LIKE '%' + @keyword + '%'SQL 内で % を両側に付けて部分一致
@keyword = '' OR ...キーワードが空のときは全件マッチ(SQL 内で吸収)
@departmentId = -1 OR ...-1 を「指定なし」のマーカーとして使う
keyword ?? ""null が渡されたら "" として扱う

ReadEmployees をなぜ private static にしたか

Section titled “ReadEmployees をなぜ private static にしたか”
  • private:Repository の外からは使わせない(内部実装)
  • static:インスタンスのフィールドに依存しないので static で書ける

GetOrdinal をループの前に 1 回だけ 呼んでおくと、列が多いときに少し効率がよくなります。

第 24 章 24-4 とほぼ同じコード

Repository は「DB アクセスをまとめる層」なので、Windows / Web で同じ書き方になります。 違うのは接続文字列(app_user)と、namespace だけです。

ここで一度ビルド確認

Search を足しましたが、Controller も View もまだ第 28 章のまま(検索フォームは無い)です。Ctrl + Shift + B でビルドが通ること、F5 で /Employeesこれまでどおり一覧表示 されることを確認しておきましょう。検索フォームは 29-6〜29-8 で追加し、29-9 で動かします。


29-5 DepartmentRepository を確認する

Section titled “29-5 DepartmentRepository を確認する”

部署プルダウン用に、第 27 章で作った DepartmentRepository.GetAll() をそのまま使います。 第 28 章でコピー済みなので、本章では追加の作業は不要です。

もし忘れていたら

Data/DepartmentRepository.csModels/Department.cs が無い場合は、第 27 章のものをコピーしてください(namespace は MvcEmployeeApp.Data / MvcEmployeeApp.Models に直す)。


検索画面では、次の 3 種類のデータ を View に渡す必要があります。

  • 検索条件:現在の keyworddepartmentId(入力欄に値を残すため)
  • プルダウンの選択肢:部署一覧(Department のリスト)
  • 検索結果:Employee のリスト

これらをまとめて持つ専用のクラスを用意します。第 26 章の FortuneViewModel と同じく、View 専用の入れ物(ViewModel) です。

Models フォルダに EmployeeSearchViewModel.cs を追加します。

EmployeeSearchViewModel.cs
namespace MvcEmployeeApp.Models;
public class EmployeeSearchViewModel
{
// 検索条件
public string Keyword { get; set; } = "";
public int DepartmentId { get; set; } = -1;
// プルダウンの選択肢
public List<Department> Departments { get; set; } = new List<Department>();
// 検索結果
public List<Employee> Results { get; set; } = new List<Employee>();
}
プロパティ役割
Keyword検索ボックスの内容(空のときは「指定なし」)
DepartmentId選んだ部署 ID(-1 のときは「すべての部署」)
Departmentsプルダウンに並べる部署一覧
Results検索結果の社員一覧

ViewModel のメリット

ViewBag に複数の値を入れることもできますが、@ViewBag.Keyword は型が解決されないので タイプミスがビルド時に検出されません。 ViewModel にまとめると、Model.Keyword のように 型付きでアクセス でき、ミスが減ります。


29-7 EmployeesController を検索対応にする

Section titled “29-7 EmployeesController を検索対応にする”

EmployeesController.Index を、検索パラメータを受け取れる形に書き換えます。

EmployeesController.cs
using Microsoft.AspNetCore.Mvc;
using MvcEmployeeApp.Data;
using MvcEmployeeApp.Models;
namespace MvcEmployeeApp.Controllers;
public class EmployeesController : Controller
{
public IActionResult Index(string keyword = "", int departmentId = -1)
{
EmployeeRepository empRepo = new EmployeeRepository();
DepartmentRepository deptRepo = new DepartmentRepository();
EmployeeSearchViewModel model = new EmployeeSearchViewModel();
model.Keyword = keyword ?? "";
model.DepartmentId = departmentId;
model.Departments = deptRepo.GetAll();
if (string.IsNullOrEmpty(model.Keyword) && model.DepartmentId == -1)
{
// 検索条件なし → 全件
model.Results = empRepo.GetAll();
}
else
{
model.Results = empRepo.Search(model.Keyword, model.DepartmentId);
}
return View(model);
}
}
ポイント説明
アクションの引数 keyworddepartmentIdクエリ文字列 ?keyword=...&departmentId=...Model Binding で自動的に入る
既定値 "" / -1クエリ文字列に値が無いときの初期値(「条件なし」を意味する)
検索条件の判定キーワード空 & 部署「すべて」のときは GetAll、それ以外は Search
return View(model)ViewModel ごと View に渡す

なぜ GetAllSearch を分けるのか

実は Search("", -1)(キーワード空・部署「すべて」)でも、SQL の @keyword = '' OR ...@departmentId = -1 OR ... が効いて全件が返ります。つまり Search だけでも動きます。 それでも条件なしを GetAll に分けているのは、条件がないときは検索用の複雑な WHERE を通さず、単純な SQL で取得できる からです(読みやすさと、無駄な条件評価を避ける意図)。動作はどちらでも同じなので、Search 一本にまとめても構いません ── ここは設計の好みです。

Model Binding の基本(第 26 章 26-10 の復習)

ASP.NET Core は、URL のクエリ文字列・フォーム値・ルート値などを、アクションの引数名と一致するもの に自動でセットしてくれます。 Index(string keyword, ...) というアクションがあると、?keyword=山 の値が keyword 引数に入ります。


29-8 View に検索フォームを追加する

Section titled “29-8 View に検索フォームを追加する”

Views/Employees/Index.cshtml を、検索フォーム + 結果テーブルの構成に書き換えます。

Index.cshtml
@model MvcEmployeeApp.Models.EmployeeSearchViewModel
@{
ViewData["Title"] = "社員一覧・検索";
}
<h1>社員一覧</h1>
<form method="get" asp-controller="Employees" asp-action="Index" class="row g-3 mb-3">
<div class="col-auto">
<label for="keyword" class="form-label">名前</label>
<input type="text" id="keyword" name="keyword"
value="@Model.Keyword" class="form-control" />
</div>
<div class="col-auto">
<label for="departmentId" class="form-label">部署</label>
<select id="departmentId" name="departmentId" class="form-control">
<option value="-1">すべての部署</option>
@foreach (MvcEmployeeApp.Models.Department dept in Model.Departments)
{
if (dept.DepartmentId == Model.DepartmentId)
{
<option value="@dept.DepartmentId" selected>@dept.DepartmentName</option>
}
else
{
<option value="@dept.DepartmentId">@dept.DepartmentName</option>
}
}
</select>
</div>
<div class="col-auto align-self-end">
<button type="submit" class="btn btn-primary">検索</button>
<a asp-controller="Employees" asp-action="Index" class="btn btn-secondary">クリア</a>
</div>
</form>
<p>@Model.Results.Count 件の社員が見つかりました。</p>
<table class="table">
<thead>
<tr>
<th>社員ID</th>
<th>氏名</th>
<th>部署名</th>
<th>メール</th>
<th>入社日</th>
<th>給与</th>
</tr>
</thead>
<tbody>
@foreach (MvcEmployeeApp.Models.Employee emp in Model.Results)
{
<tr>
<td>@emp.EmployeeId</td>
<td>@emp.FullName</td>
<td>@emp.DepartmentName</td>
<td>@emp.Email</td>
<td>@emp.HireDate.ToString("yyyy/MM/dd")</td>
<td>@emp.Salary.ToString("C0")</td>
</tr>
}
</tbody>
</table>
<div class="mt-3">
<button type="button" class="btn btn-primary" disabled>新規登録</button>
<button type="button" class="btn btn-danger" disabled>削除</button>
</div>
<p class="text-muted small mt-2">※ 新規登録・削除は第 30 章で実装します。</p>

各部分のポイント:

第 26 章との違い:ここでは asp-for を使っていません

第 26 章では <input asp-for="UserName"> のように asp-for を使いました。本章で name/value/selected を手書きにしているのは、部署プルダウンの選択肢を部署一覧から動的に作る ためです。属性の対応(name → クエリ文字列のキー、value → 入力値の保持、selected → 選択状態)を手書きで明示すると、検索条件がどう URL に乗り、どう画面に戻るかを追いやすくなります。asp-for でも書けますが、<select> の選択肢を動的生成するには SelectList などの追加の仕組みが要ります。

検索フォームは GET で送信します。送信ボタンを押すと、フォームの各 name 属性とその値が URL のクエリ文字列に組み立てられて、同じアクションが呼ばれます。

名前欄に「山」、部署「総務」を選んで送信
/Employees?keyword=山&departmentId=1

<input name="keyword" value="@Model.Keyword">

Section titled “<input name="keyword" value="@Model.Keyword">”

name 属性が クエリ文字列のキー になります。value="@Model.Keyword" で、検索後も入力値が消えずに残ります。

<select>(プルダウン)はどう組み立てるか

Section titled “<select>(プルダウン)はどう組み立てるか”

<select> は「ドロップダウン(プルダウン)」を作る HTML タグです。中に並べる選択肢ひとつひとつが <option> です。たとえば手書きすると次のようになります。

<select name="departmentId">
<option value="-1">すべての部署</option>
<option value="1">総務</option>
<option value="2">営業</option>
</select>

<option> には 2 つの顔(見せる文字と送る値)があり、加えて <select> 自身が name を持ちます。次の 3 点が要点です。

部分役割
<option>value="1"サーバーに 送られる値(?departmentId=11 の部分)
<option>総務</option>(タグで囲んだ文字)画面に 見える表示(ユーザーが読む「総務」)
<select>name="departmentId"クエリ文字列の キー になる(?departmentId=...)

つまり「見せる文字(総務)送る値(1) を別々に持てる」のが <option> です。画面では部署名を選ぶのに、サーバーには ID(数字)が届く、という仕組みです。

本章では、この <option> を手書きせず、部署一覧(DB から取得した Model.Departments)から @foreach で 1 個ずつ作って います。

<select id="departmentId" name="departmentId" class="form-control">
<option value="-1">すべての部署</option>
@foreach (... dept in Model.Departments)
{
<option value="@dept.DepartmentId">@dept.DepartmentName</option>
}
</select>

この @foreachサーバー側で実行され、ただの HTML に展開されてから ブラウザに届きます。部署が 5 件なら、@foreach<option> を 5 行ぶん作ります(先頭の固定の「すべての部署」とあわせて全 6 行)。

<!-- ブラウザが実際に受け取る HTML(@foreach は消えている) -->
<select id="departmentId" name="departmentId" class="form-control">
<option value="-1">すべての部署</option>
<option value="1">総務</option>
<option value="2">営業</option>
<option value="3">開発</option>
<option value="4">マーケティング</option>
<option value="5">品質管理</option>
</select>

部署が増えても減っても View は直さなくてよい ── これが「選択肢を DB から作る」良さです。

<select> / <option> などタグ自体の基本は、付録 K「この研修で使う最低限の HTML」K-3 にもまとまっています。HTML に不慣れなら先に目を通しておくと安心です。

@foreach (... in Model.Departments)
{
if (dept.DepartmentId == Model.DepartmentId)
{
<option value="@dept.DepartmentId" selected>@dept.DepartmentName</option>
}
else
{
<option value="@dept.DepartmentId">@dept.DepartmentName</option>
}
}

検索後にプルダウンに 検索時に選んだ値が選択されたまま 残るように、対応する <option>selected 属性を付けます。

「クリア」は、見た目はボタンですが <a>(ハイパーリンク) です。btn btn-secondary の Bootstrap クラスでボタン風に見せているだけです。

<a asp-controller="Employees" asp-action="Index" class="btn btn-secondary">クリア</a>
  • asp-controller/asp-action から、ブラウザに届くときは <a href="/Employees">(クエリ文字列なし)に変換されます。
  • クリックすると、フォームを送信するのではなく その URL へ画面遷移 します(<a> はフォームの中にあっても送信ボタンにはならず、リンク先へ移動するだけ)。
  • 遷移先 /Employees には条件が無いので、Controller の既定値(keyword=""/departmentId=-1)が効いて 全件表示・入力欄も空・プルダウンも「すべての部署」で返ってきます。

つまり「クリア」は、ブラウザ側で入力欄を消しているのではなく、条件なしの URL へ移動して、サーバーから新しい(空の)画面を受け取り直している ということです。検索の <button type="submit">(フォーム送信)や、入力欄だけを画面側で消す一般的なクリアボタン(type="reset")とは動きが違います。

@foreach で表(テーブル)が描かれるしくみ

Section titled “@foreach で表(テーブル)が描かれるしくみ”

HTML の表は 「行(<tr>)」の集まり で、各行は 「セル(<td>)」 でできています。<thead> の 1 行は 見出し(固定)<tbody> の中が データ行 です。

<table>
<thead>
<tr><th>社員ID</th><th>氏名</th> ...</tr> <!-- 見出し(1行・固定) -->
</thead>
<tbody>
<tr><td>...</td><td>...</td> ...</tr> <!-- データ1行 -->
</tbody>
</table>

本章では <tbody> の中身を手で並べず、検索結果 Model.Results@foreach で 1 人ずつ回し、1 人につき <tr> を 1 行 作っています。

<tbody>
@foreach (... emp in Model.Results)
{
<tr>
<td>@emp.EmployeeId</td>
<td>@emp.FullName</td>
<td>@emp.DepartmentName</td>
...
</tr>
}
</tbody>

検索で社員が 2 人見つかったとき、この @foreachサーバー側で 2 回まわり、次のような ただの HTML(タグだけ) に展開されてからブラウザに届きます。

<!-- ブラウザが実際に受け取る HTML(@foreach は消えている) -->
<tbody>
<tr>
<td>1001</td><td>山田 二郎</td><td>総務</td> ...
</tr>
<tr>
<td>1008</td><td>中山 大輔</td><td>総務</td> ...
</tr>
</tbody>

ポイントは次の 2 つです。

  • ループが回った回数だけ <tr> が増える ── 検索結果が 10 人なら 10 行、0 人なら 0 行(<tbody> が空になるだけ)。件数に合わせて行数が自動で変わります。
  • @emp.EmployeeId のような @ で始まる部分が、その社員の値に置き換わる ── @ は Razor の印で、サーバーで処理されて消えます。

.cshtml はそのままブラウザに届くわけではない

View(.cshtml)は サーバーで HTML に変換されてから ブラウザに送られます。@foreach@emp.FullName などの @(Razor)はサーバー側で消化され、ブラウザに届くのは 素の <table><tr><td> だけ です。この感覚は 第 26 章 26-6付録 K「最低限の HTML」K-4(表) でも扱っています。

@model@foreachasp-*class の役割は早見表に

検索フォームの <form>/<select>/asp-controller/asp-actionclass="..." の役割は 第 26 章 26-12「MVC のおまじない早見表」 を参照してください。class="row g-3" / form-control / btn などは Bootstrap の見た目クラスで、付けるとフォームやボタンが整って並ぶだけです(中身は気にしなくて OK)。

検索全体の流れ(ブラウザ → Controller → Repository → DB → View)

Section titled “検索全体の流れ(ブラウザ → Controller → Repository → DB → View)”

ここまでの ViewModel・Controller・View が、検索ボタンを押したときに どう連携するか を 1 枚にまとめると次のようになります。検索は GET の往復(リクエスト → レスポンス)で完結します。

  • ブラウザは URL(クエリ文字列) を送るだけ。受け取るのは できあがった HTML だけです(.cshtml は届きません ── 第 26 章 26-6)。
  • 値が SQL に渡るところは必ず パラメータ(@keyword / @departmentId) ── 29-2〜29-3 の防御がこの 1 本の流れの中で効いています。
  • 「クリア」も同じ往復で、条件なしの /Employees を GET し直しているだけです。

ViewModel・Controller・View(検索フォーム)までそろったので、検索を動かします(先ほどのビルド確認に続く 2 回目=動作確認です)。 F5 で実行し、ブラウザのアドレスバーに次の URL を入れて開いてください。

https://localhost:xxxx/Employees

10 件の社員が表示されたら、検索フォームに次の値を入れて動作を確認します。

操作URL の例期待される結果
「山」と入力して検索?keyword=山姓に「山」を含む社員:山田 二郎・山口 洋子・中山 大輔 の 3 件
部署「総務」を選んで検索?departmentId=1部署が総務の社員(山田・佐々木・中山 など)
「山」+ 「総務」?keyword=山&departmentId=1姓に「山」を含み、かつ総務:山田 二郎・中山 大輔 の 2 件
「クリア」をクリック(パラメータなし)全 10 件
URL を直接編集して ?keyword=星野 を試す?keyword=星野星野 健一 のみ

SQL インジェクション対策が効いていることを確認

試しに ?keyword=' OR '1'='1 のような URL を打ち込んでみてください。 パラメータ化されているので、「' OR '1'='1」という 文字列をそのまま検索キーワードとして扱う ため、該当社員はいません(0 件)。 攻撃が無力化されている、というのが感じ取れます。

おすすめ:デバッグでパラメータを観察

EmployeeRepository.SearchExecuteReader 直前にブレークポイントを置き、cmd.Parameters の中身を見ると、@keyword / @departmentId がそれぞれの値で渡されている様子が確認できます。


29-10 GET と POST どちらを使うか

Section titled “29-10 GET と POST どちらを使うか”

検索フォームを method="get" にしている理由を整理します。第 26 章 26-15 で学んだ違いを、検索の文脈で見直すと次のようになります。

観点GET(検索)POST(更新系)
URL に条件が乗るはいいいえ
ブックマークできるはいいいえ
ブラウザ履歴に残るはいURL は残るが値は残らない
同じ操作を何度繰り返しても安全かはい(冪等)注意(二重送信になり得る)
検索結果ページ、フィルタ画面フォーム送信、ログイン、登録

検索は基本的に「何度やっても同じ結果が返るだけで、サーバーの状態を変えない」ので、GET が自然です。

?keyword=山&departmentId=1」のような URL をブックマークに入れたり、Slack に貼ったりして共有できる ── これは Web ならではの良さで、Windows フォームには無い体験です。

POST にする場面はあるか?

検索条件が非常に長い(数百文字)場合や、機密情報を URL に残したくない場合は POST にすることもあります。 しかし通常の業務検索では、GET + クエリ文字列 が現場の標準です。


29-11 Windows フォーム編との比較

Section titled “29-11 Windows フォーム編との比較”

第 24 章(Windows 版検索)と本章の構造を並べてみます。

観点Windows フォーム(第 24 章)Web MVC(本章)
検索 UITextBoxComboBoxButton をフォームに配置<input><select><button>.cshtml に書く
検索条件の取得keywordTextBox.TextdepartmentComboBox.SelectedValueアクションの引数(Model Binding)
検索条件の渡し方フォーム内の値を直接読むURL のクエリ文字列(?keyword=...)
検索結果の表示DataGridView.DataSource = list;View(model) で View に渡す
状態の保持コントロールが値を保持(同じフォーム)URL に値が乗る + View で value 属性に再表示
Repository.Searchほぼ同じ(接続文字列のみ違う)ほぼ同じ(接続文字列のみ違う)
SQL インジェクション対策SqlParameterSqlParameter(同じ)

DB アクセス層は コードがほぼ同じ、上層(画面 / Controller / 状態管理)は Web 流に書き換え ── 第 28 章で見たパターンが、検索でも同じように現れます。


つまずき原因対応
検索後にプルダウンの選択が「すべての部署」に戻るselected 属性の付与忘れView の @ifselected を出力しているか確認
検索後にテキスト入力欄が空に戻るvalue="@Model.Keyword" を書き忘れ<input>value を追加
?keyword=山 で結果が 0 件LIKE% が SQL 内に無いLIKE '%' + @keyword + '%'% 配置を確認
URL のクエリが受け取れないアクションの引数名と name 属性が違うフォームの name="keyword" と Controller の string keyword を一致させる
部署プルダウンが空Departments プロパティに値が入っていないController で deptRepo.GetAll() を呼んでセットしているか確認
検索結果が「件数 0」と出るのに表に全件出るView の @foreach が別の変数を使っているModel.Results を回しているか確認
model.Keywordnull でエラークエリ文字列が無いと既定値が効かないケースController の引数で string keyword = "" のように既定値を付ける
「クリア」ボタンが効かないasp-controller / asp-action のスペルミスIndex 大文字小文字、Controller 名を確認

  • SQL インジェクションがなぜ Web で特に危険かを説明できる
  • SqlParameter を使ったパラメータ化クエリを書ける
  • LIKE '%' + @keyword + '%' で部分一致を表現できる
  • 複数条件を AND で組み合わせる WHERE 句を書ける
  • GetAllSearch で共通の読み取り処理を private static メソッドに切り出せる
  • 検索画面用の ViewModel(EmployeeSearchViewModel)を設計できる
  • Controller のアクションがクエリ文字列を Model Binding で受け取る仕組みを説明できる
  • View で <form method="get"> の検索フォームを書ける
  • <select> で選択中の項目に selected を付けて状態を保てる
  • 「検索は GET、更新は POST」の使い分けを説明できる
  • Windows 版(第 24 章)と Web 版(本章)で、Repository の中身がほぼ同じであることを確認できた

研修の進め方によっては、隣の人またはチーム内で説明確認を行います。

次の内容を、自分の言葉で説明してください。

  1. SQL インジェクションは、Windows フォームと比べて Web でなぜ危険度が上がりますか。
  2. @keyword = '' OR ... を SQL に入れている理由は何ですか。
  3. ReadEmployeesprivate static にした 2 つの理由を答えてください。
  4. なぜ検索フォームは <form method="get"> にしているのですか。
  5. プルダウンの選択値を検索後も残すには、HTML 側で何をしていますか。
  6. ViewModel(EmployeeSearchViewModel)を導入したことで、ViewBag を使う場合と比べて何が良くなりますか。
  7. 「クリア」リンクと「検索」ボタンは、HTTP レベルでは何が違いますか(URL の形)。

この章の演習課題に取り組みます(タイマーはありません。動かして観察し、自分の言葉で説明できることを重視します。チームで進める場合は声を掛け合いながら自分のペースで進めてください)。

必須課題は、本文で育てている MvcEmployeeApp プロジェクト にそのまま書き込みます(「なぜ」コメントも同じプロジェクト)。発展課題だけは、同じ KadaiWebApp ソリューション内に 別プロジェクト として作ります。

課題プロジェクト内容
課題 29-1(必須)MvcEmployeeApp(本文の続き)検索機能 + 「なぜ」コメント
課題 29-2(発展)Ext_EmployeeSearchExtra(新規)検索をカスタマイズ
課題 29-3(発展)(コードなし・レポート)第 24 章とコード比較


課題 29-1 ソースを読み解いて「なぜ」コメントを付ける

Section titled “課題 29-1 ソースを読み解いて「なぜ」コメントを付ける”

本文 29-4〜29-9 で検索機能まで仕上げた MvcEmployeeAppEmployeeRepository.csEmployeeSearchViewModel.csEmployeesController.cs を読み返し、第 23・24 章の課題と同じ要領 で、次の 2 種類のコメントを書き込んでください。本章のヤマは パラメータ化(SQL インジェクション対策) なので、その「なぜ」を重視します。

  • (A) メソッドの役割:各メソッドの 上の行 に、何をするメソッドかを 1 行(//)で書く
  • (B) 難所の「なぜ」:下の表の各箇所に、「なぜそう書くのか」「何のためか」前の行 に自分の言葉で書く(言い換えコメントは NG。→ 第 23 章「課題 23-1」・第 7 章コラム)

View(.cshtml)の <form method="get">asp-*@foreachselectedclassMVC の「おまじない」 なので、「何をする決まりか」を一言で十分です(→ 26-12 早見表)。

(B) 「なぜ」コメントを付ける箇所

ファイル箇所説明する観点(= ここに「なぜ」を書く)
EmployeeRepository.cscmd.Parameters.AddWithValue("@keyword", ...)なぜ + で連結せずパラメータで渡すのか(Web で何を防ぐか)
EmployeeRepository.csLIKE '%' + @keyword + '%'% は何を表すか / なぜ両側に付けるか
EmployeeRepository.cs@keyword = '' OR ...なぜ空のとき全件になるのか
EmployeeRepository.cs@departmentId = -1 OR ...なぜ -1 を「指定なし」のしるしに使うのか
EmployeeRepository.csReadEmployeesprivate static に切り出した点なぜ GetAllSearch で共通化したのか
EmployeeSearchViewModel.cs条件 / 選択肢 / 結果の 3 種をまとめている点なぜ ViewBag でなく ViewModel にまとめるのか
EmployeesController.csIndex(string keyword = "", int departmentId = -1)なぜ引数で受け取れるのか(Model Binding)・既定値の意味
EmployeesController.cs条件なしは GetAll / それ以外は Search の分岐なぜこの分岐にするのか

確認すること

  • (A) 各メソッドの上に「役割」を 1 行コメントした
  • (B) 表のすべての箇所に「なぜ/何のため」を前行で書いた
  • とくに パラメータ化の「なぜ」(SQL インジェクション対策) を自分の言葉で書けた
  • View のおまじない(<form method="get">/asp-*/selected)は「何をする決まりか」を一言で書いた
  • 言い換え・丸写しになっていない/?keyword=' OR '1'='1 で 0 件になることを確認した

課題 29-2 検索をカスタマイズする

Section titled “課題 29-2 検索をカスタマイズする”

KadaiWebApp ソリューションに新しいプロジェクト Ext_EmployeeSearchExtra を作成し、MvcEmployeeApp のコードをコピーした上で、下の A〜D から 1 つ以上 を選んで改造してください(本体は壊さず、実験はこちらで)。

選択肢内容ヒント
A:給与レンジ検索を追加「給与下限」「給与上限」の入力欄を増やすWHERE e.salary BETWEEN @minSalary AND @maxSalary、「指定なし」は OR で吸収。もちろんパラメータ化
B:メール部分一致を加えるキーワードを email にも当てるOR e.email LIKE '%' + @keyword + '%' を追加
C:結果 0 件のときに案内を出す表でなく「該当する社員はいませんでした。」を表示View で @if (Model.Results.Count == 0) 分岐
D:氏名列で並べ替え(やや難)氏名ヘッダーのクリックで昇順 / 降順をトグル(?sort=name_asc 等)sort を引数で受け、ORDER BYswitch許可値だけ 当てる。⚠️ sort を SQL に直接連結しない(これも SQL インジェクション対策。default で安全側に)。ヘッダーは <a asp-action="Index" asp-route-keyword="..." asp-route-sort="name_asc">氏名</a>

確認すること

  • 選んだ改造が正しく動く
  • パラメータ化を崩していない(+ で値を連結しない/sort を直接連結しない)
  • 改造を入れた場所(Repository / Controller / View)を自分で指せる

課題 29-3 第 24 章と比べてレポートを書く

Section titled “課題 29-3 第 24 章と比べてレポートを書く”

本章の EmployeeRepository.Search と、第 24 章 24-4 の EmployeeRepository.Search見比べて、下の表を埋めてください(紙のノート、Markdown、ペア確認の口頭発表 ── 形式は自由)。

観点第 24 章(Windows)本章(Web)
メソッドのシグネチャ
SQL 文の中身
AddWithValue の呼び方
検索条件をどこから取るか
結果を画面にどう渡すか

そのうえで、次の問いに答えてください。

  1. Web 版で 追加された防御 は何か(あれば挙げる、なければ「同じ」と書く)
  2. 検索条件を URL に乗せる ことで生じるメリットとデメリットを 1 つずつ挙げる
  3. クライアント(ブラウザ)が SqlParameter を直接送ってくる ことはあるか?

この課題はコードを書きません。「同じ Repository が Windows と Web の両方で動く」を体感する ことが目的です。


  • ソリューション名が KadaiWebApp、本体プロジェクト名が MvcEmployeeApp
  • csproj が <Nullable>disable</Nullable>
  • Microsoft.Data.SqlClient が NuGet で追加されている
  • Models/EmployeeSearchViewModel.cs が作成されている
  • Data/EmployeeRepository.csSearch(string, int)private static ReadEmployees(SqlCommand) がある
  • Controllers/EmployeesController.csIndex がクエリ文字列を受け取る
  • Views/Employees/Index.cshtml に検索フォームが追加されている
  • <form method="get"> で送信している
  • <select> の選択値が検索後も保持される(selected 属性が動的に付く)
  • 「クリア」リンクで条件なしの /Employees に戻れる
  • ?keyword=' OR '1'='1 のような攻撃 URL を試して、結果が 0 件になることを確認した
  • 必須課題 29-1:各メソッド役割 1 行+難所の表すべてに「なぜ」を前行コメントした(とくにパラメータ化の理由)
  • View のおまじない(<form method="get">/asp-*/selected)は「何をする決まりか」を一言で書いた
  • (発展 29-2 を実施した場合)Ext_EmployeeSearchExtra で改造が動く
  • binobj.vs フォルダが Git 管理に入っていない

Terminal window
git status
git add .
git commit -m "Chapter29: 社員検索完成+なぜコメント / <パラメータ化の「なぜ」を一言>"
git push origin main

Git の詳しい操作は、付録 C「Git のインストールと提出ルール」 を参照してください。


  • 検索条件は クエリ文字列(?keyword=...&departmentId=...) で Controller に渡るのが Web の自然な形
  • <form method="get"> を使うと、name 属性付きの入力部品が 自動でクエリ文字列に組み立てられる
  • SQL インジェクションは Web で特に危険(全世界から URL を組み立てて送られうるため)
  • 防御の正しい書き方は パラメータ化クエリ(@param + SqlParameter)。Windows / Web で同じ
  • GetAllSearch の重複を private static ReadEmployees で共通化 すると保守性が上がる
  • ViewModel(EmployeeSearchViewModel)に検索条件・選択肢・結果をまとめると、View 側で型付きアクセスができる
  • <input value="@Model.Keyword"><option selected>検索後の入力状態を保持 できる
  • 「検索は GET、更新は POST」が現場の使い分けの基本

次章では、いよいよ Web 社員管理アプリの仕上げ ── 編集・更新 に入ります。 第 25 章(Windows 版編集)と同じ範囲を Web で実装し、INSERT / UPDATE / DELETE を完成させます。

新たに登場するテーマ:

  • ルーティングパラメータ(/Employees/Edit/1001 のような URL 設計)
  • 編集フォームの POST 送信(検索とは違って状態を変える操作)
  • PRG(Post-Redirect-Get)パターン(更新後に GET にリダイレクトしてリロード問題を防ぐ)
  • 入力チェック(モデルバリデーション) の基礎
  • 第 28・29 章で disabled だった 新規登録・削除ボタン が、ついに動き出す

第 25 章のコードを手元に置いて、Windows と Web の最後の対比を楽しみながら進めましょう。