Skip to content

第24章 Windowsフォーム社員管理アプリ:検索

この章では、第 23 章で作った社員一覧画面に 検索機能 を追加します。

具体的には、次の 2 つの絞り込みを組み合わせられるようにします。

  • 名前(姓 または 名)の部分一致:テキストボックスに入力した文字列で検索
  • 部署:プルダウン(ComboBox)で選択した部署で絞り込み

ここで重要になるのが パラメータ化クエリ(SqlParameter)です。 ユーザーが入力した文字列をそのまま SQL に連結すると、SQL インジェクション という重大なセキュリティ問題を引き起こします。 パラメータ自体は第 17 章「17-7」で一度学びましたが、本章ではそれを 検索(部分一致・複数条件)に応用し、@param の形でユーザー入力を安全に渡す書き方を確実にします。


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

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

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

  • SQL インジェクションがなぜ危険かを具体例で説明できる
  • SqlParameter を使ったパラメータ化クエリを書ける
  • LIKE 演算子による部分一致検索を実装できる
  • 複数の検索条件を組み合わせる SQL を書ける(WHERE 句の組み立て方)
  • ComboBox の DataSource に List<T> を設定して選択肢を表示できる
  • Repository クラスにメソッドを追加して機能を拡張できる(第 23 章の EmployeeRepository の拡張)
  • 検索結果を DataGridView に再表示できる

項目内容
開発環境Visual Studio 2022
プロジェクト種類Windows フォーム アプリ
対象フレームワーク.NET 8
ソリューション名KadaiWinFormsApp(第 23 章で作成済み・続けて使う)
プロジェクト名EmployeeApp(第 23 章の続き・作り直さない)
ベースとなる章第 23 章
データベースSQLServer 2022(TrainingDB)
認証方式Windows 統合認証
NuGet パッケージMicrosoft.Data.SqlClient

この章は第 23 章の EmployeeApp を続けて使います

新しいプロジェクトは作りません。第 23 章で作った KadaiWinFormsApp ソリューションの EmployeeApp を開き、そこに検索機能を足していきます(Nullable・NuGet は第 23 章で設定済みのはずです)。

Server=localhost で接続できないとき

第 23 章と同じく、SQLServer のインスタンス名によっては Server=localhost\SQLEXPRESS 等の指定が必要です。詳しくは第 23 章「23-4 接続文字列を設定する」の補足を参照してください。


  • 第 23 章を Git に提出済み、または第 23 章のコードを手元に持っている
  • TrainingDB の employees / departments が動作している
  • EmployeeRepository.GetAll() が動くことを確認した

第 23 章のアプリにベースの画面はあります。そこに次の UI を追加します。

┌──────────────────────────────────────────────────────────────┐
│ 社員一覧 [再読込]│
├──────────────────────────────────────────────────────────────┤
│ 名前:[ 山田 ] 部署:[ 総務 ▼ ] [検索] [クリア]│ ← 本章で追加
├──────────────────────────────────────────────────────────────┤
│ EmployeeId │ LastName │ FirstName │ Email │ Salary │
├────────────┼──────────┼───────────┼─────────────────┼────────┤
│ 1001 │ 山田 │ 二郎 │ yamada.jiro@... │ 500000 │
│ 1006 │ 佐々木 │ 明子 │ sasaki.akiko@.. │ 800000 │
│ ... │
├──────────────────────────────────────────────────────────────┤
│ [新規登録] [削除] │
└──────────────────────────────────────────────────────────────┘

検索の仕様:

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

24-2 SQL インジェクションの脅威

Section titled “24-2 SQL インジェクションの脅威”

検索機能を書く前に、やってはいけない書き方 を見ておきます。

ユーザーが入力した文字列を、そのまま SQL に連結する書き方です。

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

このコードは一見動きます。しかし、ユーザーが次のような文字列を入力したらどうなるでしょうか。

';DROP TABLE employees;--

SQL は次のように組み上がります。

SELECT * FROM employees WHERE last_name LIKE '%';DROP TABLE employees;--%'

セミコロンで SQL 文が区切られ、2 文目の DROP TABLE employees が実行されてしまい、テーブルそのものが消える可能性があります。 これは SQL インジェクション(SQL injection、SQL の注入)と呼ばれる、Web・業務システムで最も古典的かつ深刻な脆弱性の 1 つです。

入力チェックでは不十分

「; を弾けばいいのでは?」「シングルクォートをエスケープすればいい?」と考えがちですが、攻撃手法はいくつもあり、漏れがあると即破られます。 アプリの責務は「文字列をパラメータとして渡すこと」、SQL の解釈は DB に任せる、という分業が正解です。

これを実現するのが パラメータ化クエリ です。


24-3 パラメータ化クエリの基本

Section titled “24-3 パラメータ化クエリの基本”

第 17 章の復習

パラメータ(@名前 のプレースホルダーを SQL に書き、値は別途渡す書き方)は、第 17 章「17-7」で一度学びました。第 17 章では「指定した部署の社員数」のように 単一の値(ExecuteScalar)を求めるのに使いました。 本章では同じしくみを、複数行を返す検索(LIKE の部分一致 + 複数条件)に応用します。書き方の骨格は第 17 章と同じです。

パラメータ化クエリでは、SQL 文に プレースホルダー(@param のような名前) を書き、値は別途 SqlParameter として渡します。

string sql = "SELECT * FROM employees WHERE last_name LIKE @keyword";
using SqlCommand command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@keyword", "%" + keyword + "%");

@keyword の値は SQL の構文として解釈されません。 たとえ keyword に ';DROP TABLE ... が入っていても、それは 検索したい文字列そのもの として DB に渡されるだけで、SQL 文として実行されることはありません。

利点説明
SQL インジェクションを防げる値が SQL 構文として解釈されない
エスケープの心配が不要シングルクォートを含む名前(O'Brien など)もそのまま渡せる
型が明示される文字列と数値、日付の取り違えがない
SQL がキャッシュされやすいDB 側でクエリプランを再利用しやすい

AddWithValue のシンプルさと注意点

Section titled “AddWithValue のシンプルさと注意点”

AddWithValue はパラメータ名と値を渡すだけで使える簡単なメソッドで、研修ではこれで十分です。 ただし、AddWithValue は 値から型を推測する ため、まれに型変換のずれが起きることがあります。 本格的な業務コードでは、型を明示する Parameters.Add を使うことも多くあります(発展)。

// 型を明示する書き方(発展)
command.Parameters.Add("@keyword", SqlDbType.NVarChar, 50).Value = "%" + keyword + "%";

24-4 EmployeeRepository に検索メソッドを追加する

Section titled “24-4 EmployeeRepository に検索メソッドを追加する”

第 23 章の EmployeeRepository に Search メソッドを追加します。 ループ部分が GetAll と重複するので、SqlDataReader から Employee を組み立てる処理を private メソッド に切り出します。

EmployeeRepository.cs
namespace EmployeeApp;
using Microsoft.Data.SqlClient;
using System.Collections.Generic;
public class EmployeeRepository
{
private readonly string _connectionString;
public EmployeeRepository(string connectionString)
{
_connectionString = connectionString;
}
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 connection = new SqlConnection(_connectionString);
connection.Open();
using SqlCommand command = new SqlCommand(sql, connection);
return ReadEmployees(command);
}
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 connection = new SqlConnection(_connectionString);
connection.Open();
using SqlCommand command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@keyword", keyword ?? string.Empty);
command.Parameters.AddWithValue("@departmentId", departmentId);
return ReadEmployees(command);
}
public int Insert(Employee employee)
{
// 第25章で実装します
throw new NotImplementedException("Insert は第25章で実装します。");
}
public int Delete(int employeeId)
{
// 第25章で実装します
throw new NotImplementedException("Delete は第25章で実装します。");
}
private static List<Employee> ReadEmployees(SqlCommand command)
{
List<Employee> list = new List<Employee>();
using SqlDataReader reader = command.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 employee = new Employee
{
EmployeeId = reader.GetInt32(idxId),
LastName = reader.GetString(idxLast),
FirstName = reader.GetString(idxFirst),
Email = reader.IsDBNull(idxEmail) ? string.Empty : reader.GetString(idxEmail),
HireDate = reader.GetDateTime(idxHire),
Salary = reader.IsDBNull(idxSalary) ? 0m : reader.GetDecimal(idxSalary),
DepartmentId = reader.IsDBNull(idxDeptId) ? 0 : reader.GetInt32(idxDeptId),
DepartmentName = reader.IsDBNull(idxDeptName) ? string.Empty : reader.GetString(idxDeptName)
};
list.Add(employee);
}
return list;
}
}
ポイント説明
@keyword / @departmentId でパラメータ化SQL インジェクション対策
LIKE '%' + @keyword + '%'SQL 内で % をパラメータの両側に付けて部分一致
@keyword = '' OR ...キーワードが空文字列のときは全件マッチ
@departmentId = -1 OR e.department_id = @departmentId-1 を「指定なし」のマーカーとして使う
keyword ?? string.Emptynull が渡されたら "" として扱う

ReadEmployees をなぜ private static にしたか

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

GetOrdinal で列番号を 1 度だけ取得 してからループに入ると、列が多いときに少し効率がよくなります。


24-5 Department クラスと DepartmentRepository

Section titled “24-5 Department クラスと DepartmentRepository”

部署プルダウン用に、部署のリストを取得する Repository も用意します。

Department.cs を新規追加します。

Department.cs
namespace EmployeeApp;
public class Department
{
public int DepartmentId { get; set; }
public string DepartmentName { get; set; }
}

DepartmentRepository.cs を新規追加します。

DepartmentRepository.cs
namespace EmployeeApp;
using Microsoft.Data.SqlClient;
using System.Collections.Generic;
public class DepartmentRepository
{
private readonly string _connectionString;
public DepartmentRepository(string connectionString)
{
_connectionString = connectionString;
}
public List<Department> GetAll()
{
List<Department> list = new List<Department>();
const string sql = "SELECT department_id, department_name FROM departments ORDER BY department_id";
using SqlConnection connection = new SqlConnection(_connectionString);
connection.Open();
using SqlCommand command = new SqlCommand(sql, connection);
using SqlDataReader reader = command.ExecuteReader();
while (reader.Read())
{
list.Add(new Department
{
DepartmentId = reader.GetInt32(reader.GetOrdinal("department_id")),
DepartmentName = reader.GetString(reader.GetOrdinal("department_name"))
});
}
return list;
}
}

第 23 章の画面に、次のコントロールを追加します。DataGridView の上に 検索パネル をはさむイメージです。

第 23 章で DataGridView を Location = 12, 50 に置きましたが、検索パネルを上部に挟むため、まず DataGridView の Location を 12, 90、Size を 860, 360 に変更します。Anchor は引き続き Top, Bottom, Left, Right のままにしておきます。

検索パネルのコントロール配置

Section titled “検索パネルのコントロール配置”
コントロール(Name)プロパティ
Label(任意)Text = 名前:、Location = 12, 56
TextBoxtextBoxKeywordLocation = 60, 53、Size = 150, 23
Label(任意)Text = 部署:、Location = 230, 56
ComboBoxcomboBoxDepartmentLocation = 280, 53、Size = 150, 23、DropDownStyle = DropDownList
ButtonbuttonSearchText = 検索、Location = 450, 51、Size = 90, 28
ButtonbuttonClearText = クリア、Location = 550, 51、Size = 90, 28

DropDownStyle を DropDownList にすると、ユーザーは選択肢以外を入力できなくなります(誤入力防止)。

ボタンのイベント紐付け(buttonSearch / buttonClear のダブルクリック)は、次の 24-7 で機能を書くのと一緒に 行います。ここではコントロールを配置するだけで大丈夫です。


24-7 フォームに検索機能を組み込む

Section titled “24-7 フォームに検索機能を組み込む”

第 23 章で作った一覧表示の Form1.cs を土台に、検索の部品を 1 つずつ足していきます。第 23 章と同じく、ヘルパー(自分で呼ぶ部品)を先に用意し、そのあとボタンの イベントを 1 つずつ紐付けて中身を書きます。

この節の進め方

ソースを丸ごと貼り付けるのではなく、ステップごとに自分の手で組み立てます。各ステップのあとでビルド(Ctrl+Shift+B)を試すと安心です。完成形(答え合わせ用)は 24-9 にあります。先に見ず、まず自分で積み上げてみてください。

ステップ1 フィールドを増やす(社員 + 部署の 2 つの Repository)

Section titled “ステップ1 フィールドを増やす(社員 + 部署の 2 つの Repository)”

第 23 章では Repository は 1 つ(_repository)でしたが、本章では部署プルダウン用に DepartmentRepository も使います。区別のため社員用を _employeeRepository に改名し、_departmentRepository を追加します。あわせて「すべての部署」を表す定数 AllDepartments も用意します。

Form1.cs
namespace EmployeeApp;
using Microsoft.Data.SqlClient;
public partial class Form1 : Form
{
private const string ConnectionString =
"Server=localhost;Database=TrainingDB;Integrated Security=true;TrustServerCertificate=true;";
private const int AllDepartments = -1; // ← 追加:「すべての部署」を表す値
private readonly EmployeeRepository _employeeRepository; // ← 第23章の _repository を改名
private readonly DepartmentRepository _departmentRepository; // ← 追加
public Form1()
{
InitializeComponent();
_employeeRepository = new EmployeeRepository(ConnectionString);
_departmentRepository = new DepartmentRepository(ConnectionString); // ← 追加
}
}
書いたもの意図
AllDepartments = -1「すべての部署」を表すマーカー値(パラメータにもこの値を渡す)
_employeeRepository / _departmentRepository社員用・部署用の 2 つの DB アクセス窓口

ビルドの状態:通ります(EmployeeRepository・DepartmentRepository は 24-4・24-5 で作成済み)。

ステップ2 画面から呼ぶ部品(ヘルパーメソッド)を用意する

Section titled “ステップ2 画面から呼ぶ部品(ヘルパーメソッド)を用意する”

イベントから呼び出す共通処理を、先に ヘルパーメソッド として用意します。これらは イベントではなく、自分のコードから呼ぶ部品 です。コンストラクタの後ろに、次の 4 つを書きます。

(1) エラー表示をまとめる ShowError

第 23 章では LoadEmployees の中に try-catch を直接書きましたが、本章では検索・プルダウン初期化でも同じエラー表示を使うので、共通メソッドに切り出します。

private static void ShowError(Exception ex)
{
string title = ex is SqlException ? "DB エラー" : "エラー";
string message = ex is SqlException sqlEx
? $"SQLServer 関連のエラーが発生しました。\nエラー番号:{sqlEx.Number}\nメッセージ:{sqlEx.Message}"
: $"予期しないエラーが発生しました。\n内容:{ex.Message}";
MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error);
}

(2) 部署プルダウンを初期化する InitializeDepartmentCombo

private void InitializeDepartmentCombo()
{
try
{
List<Department> departments = _departmentRepository.GetAll();
// 先頭に「すべての部署」を表す項目を追加
departments.Insert(0, new Department
{
DepartmentId = AllDepartments,
DepartmentName = "(すべての部署)"
});
comboBoxDepartment.DataSource = departments;
comboBoxDepartment.DisplayMember = nameof(Department.DepartmentName);
comboBoxDepartment.ValueMember = nameof(Department.DepartmentId);
comboBoxDepartment.SelectedValue = AllDepartments;
}
catch (Exception ex)
{
ShowError(ex);
}
}

(3) 全件表示の LoadEmployees(第 23 章とほぼ同じ。_employeeRepository を使い、エラーは ShowError に任せる)

private void LoadEmployees()
{
try
{
List<Employee> list = _employeeRepository.GetAll();
dataGridViewEmployees.DataSource = list;
}
catch (Exception ex)
{
ShowError(ex);
}
}

(4) 検索する SearchEmployees

private void SearchEmployees()
{
try
{
string keyword = textBoxKeyword.Text.Trim();
int departmentId = (int)comboBoxDepartment.SelectedValue;
List<Employee> list = _employeeRepository.Search(keyword, departmentId);
dataGridViewEmployees.DataSource = list;
}
catch (Exception ex)
{
ShowError(ex);
}
}
書いたもの意図
DisplayMember / ValueMemberプルダウンの表示は DepartmentName、値は DepartmentId
textBoxKeyword.Text.Trim()前後の空白を除去してから検索
(int)comboBoxDepartment.SelectedValue選択中の部署 ID(ValueMember が DepartmentId なので int)

ビルドの状態:通ります(まだイベントから呼んでいませんが、メソッドが存在するだけなら問題ありません)。

ステップ3 起動時に一覧と部署プルダウンを用意する(Form の Load)

Section titled “ステップ3 起動時に一覧と部署プルダウンを用意する(Form の Load)”

イベントの紐付け:Form Designer でフォームの空白部分をクリック → プロパティウィンドウの 稲妻アイコン → Load をダブルクリック(第 23 章で紐付け済みなら、生成済みの Form1_Load をそのまま使います)。

Form1_Load の中を次のようにします。第 23 章の一覧表示に加えて、部署プルダウンの初期化を呼びます。

private void Form1_Load(object sender, EventArgs e)
{
InitializeDepartmentCombo();
LoadEmployees();
}

意図:起動時に「部署の選択肢をそろえる」→「一覧を出す」の順で準備する。

ステップ4 再読込ボタン(buttonReload の Click)

Section titled “ステップ4 再読込ボタン(buttonReload の Click)”

第 23 章と同じく、buttonReload をダブルクリックして紐付け、中身は一覧の取り直しだけです。

private void buttonReload_Click(object sender, EventArgs e)
{
LoadEmployees();
}

ステップ5 検索ボタン(buttonSearch の Click)

Section titled “ステップ5 検索ボタン(buttonSearch の Click)”

イベントの紐付け:Form Designer で buttonSearch を ダブルクリック → 空の buttonSearch_Click が生成される。

中身は、ステップ2で用意した検索ヘルパーを呼ぶだけです。

private void buttonSearch_Click(object sender, EventArgs e)
{
SearchEmployees();
}

意図:「検索」を押したら、入力中のキーワードと部署で絞り込む。

ステップ6 クリアボタン(buttonClear の Click)

Section titled “ステップ6 クリアボタン(buttonClear の Click)”

イベントの紐付け:Form Designer で buttonClear を ダブルクリック → 空の buttonClear_Click が生成される。

中身は、入力を空に戻して全件を再表示します。

private void buttonClear_Click(object sender, EventArgs e)
{
textBoxKeyword.Text = string.Empty;
comboBoxDepartment.SelectedValue = AllDepartments;
LoadEmployees();
}

意図:キーワードを消し、部署を「(すべての部署)」に戻し、全件を表示し直す。

ここまでで検索・クリアが動きます。実行(F5)して、次の 24-8 のパターンで確かめてください。


次のパターンで動作を確認します。

入力期待する結果
名前=「山田」、部署=「(すべての部署)」山田 二郎 だけ表示
名前=「」、部署=「総務」総務部の社員(4 名)が表示
名前=「藤」、部署=「営業」佐藤 昭夫(部分一致で藤を含む、営業)が表示
名前=「」、部署=「(すべての部署)」全 10 名表示
名前=「'」(シングルクォート 1 文字)エラーにならず、該当 0 件として表示される(SQL インジェクションが効かないことの確認)

24-9 完成形の確認(答え合わせ用)

Section titled “24-9 完成形の確認(答え合わせ用)”

まずは 24-7 のステップを自分で積み上げてから見てください

これは、24-7 のステップ1〜6 をすべて終えたあとの Form1.cs の 完成形 です。詰まったときの 答え合わせ に使ってください。最初からこれを貼り付けると、イベントの紐付けやメソッドの置き場所が身につきません。

Form1.cs
namespace EmployeeApp;
using Microsoft.Data.SqlClient;
public partial class Form1 : Form
{
private const string ConnectionString =
"Server=localhost;Database=TrainingDB;Integrated Security=true;TrustServerCertificate=true;";
private const int AllDepartments = -1;
private readonly EmployeeRepository _employeeRepository;
private readonly DepartmentRepository _departmentRepository;
public Form1()
{
InitializeComponent();
_employeeRepository = new EmployeeRepository(ConnectionString);
_departmentRepository = new DepartmentRepository(ConnectionString);
}
private void Form1_Load(object sender, EventArgs e)
{
InitializeDepartmentCombo();
LoadEmployees();
}
private void buttonReload_Click(object sender, EventArgs e)
{
LoadEmployees();
}
private void buttonSearch_Click(object sender, EventArgs e)
{
SearchEmployees();
}
private void buttonClear_Click(object sender, EventArgs e)
{
textBoxKeyword.Text = string.Empty;
comboBoxDepartment.SelectedValue = AllDepartments;
LoadEmployees();
}
private void InitializeDepartmentCombo()
{
try
{
List<Department> departments = _departmentRepository.GetAll();
// 先頭に「すべての部署」を表す項目を追加
departments.Insert(0, new Department
{
DepartmentId = AllDepartments,
DepartmentName = "(すべての部署)"
});
comboBoxDepartment.DataSource = departments;
comboBoxDepartment.DisplayMember = nameof(Department.DepartmentName);
comboBoxDepartment.ValueMember = nameof(Department.DepartmentId);
comboBoxDepartment.SelectedValue = AllDepartments;
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void LoadEmployees()
{
try
{
List<Employee> list = _employeeRepository.GetAll();
dataGridViewEmployees.DataSource = list;
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void SearchEmployees()
{
try
{
string keyword = textBoxKeyword.Text.Trim();
int departmentId = (int)comboBoxDepartment.SelectedValue;
List<Employee> list = _employeeRepository.Search(keyword, departmentId);
dataGridViewEmployees.DataSource = list;
}
catch (Exception ex)
{
ShowError(ex);
}
}
private static void ShowError(Exception ex)
{
string title = ex is SqlException ? "DB エラー" : "エラー";
string message = ex is SqlException sqlEx
? $"SQLServer 関連のエラーが発生しました。\nエラー番号:{sqlEx.Number}\nメッセージ:{sqlEx.Message}"
: $"予期しないエラーが発生しました。\n内容:{ex.Message}";
MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}

症状原因対処
検索結果が常に空LIKE のワイルドカード位置間違いSQL 内で '%' + @keyword + '%' のように両側に % を付ける
ComboBox に何も表示されないDisplayMember / ValueMember のスペルミスプロパティ名を nameof(Department.DepartmentName) などで指定して typo を防ぐ
comboBoxDepartment.SelectedValue が nullバインド前のタイミングで参照DataSource 設定後に SelectedValue を読む
int departmentId = (int)comboBoxDepartment.SelectedValue; で例外初期化前の null を int にキャストしようとした本文では Form1_Load 内の InitializeDepartmentCombo で必ず初期化しているため通常は発生しない。万一に備える場合は int departmentId = comboBoxDepartment.SelectedValue is int v ? v : AllDepartments; のように is int v パターンでガードする
名前検索でヒットしないTrim を忘れて末尾の空白がついているtextBoxKeyword.Text.Trim() を使う
シングルクォートを入れたら落ちたパラメータ化していない箇所が残っている+ で SQL を組み立てている箇所がないか確認
string プロパティ(DepartmentName など)に警告 CS8618 が出るプロジェクトの Nullable が enable のまま(disable にし忘れ。新規プロジェクトは既定で enable)EmployeeApp.csproj を <Nullable>disable</Nullable> に変更する(第 1 章 1-1・第 23 章 23-2)。これは警告でありエラーではなく、消さなくても実行はできる。実行時の「アプリケーション制御ポリシー」ブロックとは無関係(→ 付録 E)

  • SQL インジェクションとは何か、なぜ危険かを説明できる
  • パラメータ化クエリの基本パターン(@param + SqlParameter)を書ける
  • LIKE で部分一致検索を書ける
  • 検索条件が空のときに全件返す SQL の書き方を 1 つ挙げられる
  • ComboBox の DataSource / DisplayMember / ValueMember の役割を説明できる
  • 「すべての部署」のような全件表示用の特別な値(マーカー)の使い方を説明できる
  • Repository に新しいメソッド(Search)を追加できる
  • 複数の Repository(EmployeeRepository と DepartmentRepository)をフォームから使える

  1. SQL インジェクションを 1 文で説明してください。
  2. パラメータ化クエリの「パラメータ」とは何ですか。
  3. LIKE '%' + @keyword + '%' の % は何を意味しますか。
  4. 「全件」を表すために -1 を使っていますが、なぜ 0 ではなく -1 にしているか、考えてみてください。
  5. DisplayMember と ValueMember の使い分けを説明してください。
  6. AddWithValue の利点と注意点を 1 つずつ挙げてください。
  7. クリアボタンを押したときの動作を、自分の言葉で 3 ステップで説明してください。

第 24 章も、第 23 章と同じ チームで自走するハンズオン形式 で進めます。 チームの役割分担(リーダー / 技術部長 / タイムキーパー)と「自走のすすめ」は 第 17 章「ここからはチームで進める」、ミニ発表の進め方は 第 23 章「演習課題」 を参照してください。本文を手順書として、ペア・チームで確認し合いながら、自分たちのペースでアプリを組み上げます(提供ソースを使うので、できあがる検索アプリは全員ほぼ同じ動作になります)。

検索が動いたら、演習課題に進みます。第 23 章と同じく、必須課題は、できあがったソースを読み解いて「なぜそう書くのか」をコメントとして書き残す 作業です。本章はとくに パラメータ化クエリ(SQL インジェクション対策) がヤマなので、その「なぜ」を自分の言葉にすることを重視します。発展課題として、検索条件のカスタマイズにも挑戦できます。


  1. チームで本文 24-2〜24-9 の 実装ステップ に沿って、検索アプリを組み上げる(= 検索が動く)。24-7 のステップ1〜6 は自分の手で。詰まったら 24-9 の完成形で答え合わせ
  2. 「24-8 動作確認」の表のパターンで動作を確かめる(シングルクォート 1 文字でも落ちない ことも忘れずに)
  3. 【必須課題 24-1】 できあがったソースに「なぜ」コメントを付ける
  4. 【発展課題 24-2 / 24-3】 余裕があれば検索条件をカスタマイズする
  5. タイムキーパーの合図で手を止め、チーム内で ミニ発表(下記)を行う

必須課題は、第 23 章から続けている EmployeeApp プロジェクト にそのまま書き込みます(「なぜ」コメントも同じプロジェクト)。発展課題だけは、同じ KadaiWinFormsApp ソリューション内に 別プロジェクト として作ります。

課題プロジェクト内容
課題 24-1(必須)EmployeeApp(本文の続き)検索アプリ本体 + 「なぜ」コメント
課題 24-2(発展)Ext_SalaryRange(新規)給与レンジ検索を追加
課題 24-3(発展)Ext_HireYearFilter(新規)入社年で絞り込み

ひととおり動いたら、チーム内で一人ずつ ごく簡単に発表 します。次の 3 つを話すだけで十分です。

  1. デモ:検索を実際に動かして見せる(名前で部分一致、部署で絞り込み、クリアで全件)
  2. 1 問説明:自分が付けた「なぜ」コメントから 1 つ、または上の「ペア確認」から 1 つを選び、自分の言葉で説明する(チームで 1 人は「なぜ + で連結せずパラメータ化するのか」 を説明できると安心です)
  3. 一言:コメントを書いていて一番「なるほど」と思った点、または詰まったポイントを一言

発表は「説明できる = 理解できている」の確認です

点数をつけるためのものではありません。本章のヤマは パラメータ化クエリで SQL インジェクションを防ぐ ことです。なぜ + で連結してはいけないのか、自分の言葉で言えるか確かめましょう。



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

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

本文 24-2〜24-9 の実装ステップで組み上げた検索アプリ(EmployeeApp)の Form1.cs と EmployeeRepository.cs を読み返し、第 23 章の課題 23-1 と同じ要領 で、次の 2 種類のコメントを書き込んでください。

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

本章のヤマは パラメータ化クエリ です。とくに「なぜ + で連結してはいけないのか」を自分の言葉にできるかが鍵になります。

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

ファイル箇所説明する観点(= ここに「なぜ」を書く)
EmployeeRepository.cscommand.Parameters.AddWithValue("@keyword", ...)なぜ + で連結せずパラメータで渡すのか(何を防ぐか)
EmployeeRepository.csLIKE '%' + @keyword + '%'% は何を表すか / なぜ両側に付けるか
EmployeeRepository.cs@keyword = '' OR e.last_name LIKE ...なぜ @keyword = '' で「指定なし=全件」になるのか
EmployeeRepository.cs@departmentId = -1 OR e.department_id = @departmentIdなぜ -1 を「指定なし」のしるしに使うのか
EmployeeRepository.cskeyword ?? string.Emptyなぜ null を空文字に置き換えるのか
EmployeeRepository.csReadEmployees を private static に切り出した点なぜ GetAll と Search で共通化したのか
Form1.csprivate const int AllDepartments = -1;なぜ定数(マーカー値)にして使い回すのか
Form1.cscomboBoxDepartment.DisplayMember / ValueMemberそれぞれ何を担当しているのか
Form1.cs(int)comboBoxDepartment.SelectedValueなぜ int で取り出せるのか
Form1.cstextBoxKeyword.Text.Trim()なぜ Trim() するのか
Form1.csShowError を共通メソッドにした点なぜ各メソッドに try-catch を書かず 1 か所にまとめたのか

確認すること

  • (A) 各メソッドの上に「役割」を 1 行コメントした
  • (B) 表のすべての箇所に「なぜ/何のため」のコメントを前行で書いた
  • とくに パラメータ化の「なぜ」(SQL インジェクション対策) を自分の言葉で書けた
  • 言い換えコメントになっていない/本文の解説の丸写しになっていない
  • 検索アプリが動く(名前部分一致・部署絞り込み・クリア・シングルクォートで落ちない)


課題 24-2 給与レンジ検索を追加する

Section titled “課題 24-2 給与レンジ検索を追加する”

KadaiWinFormsApp ソリューションに新しいプロジェクト Ext_SalaryRange を作成し、EmployeeApp のコードをコピーした上で 給与の下限/上限 での絞り込みを追加してください。

仕様

  • 画面に「給与:下限 [ ] 上限 [ ]」の TextBox を 2 つ追加
  • 空欄なら「指定なし」と扱う
  • 数値以外が入力されたら MessageBox で警告(decimal.TryParse を使う)
  • 基本の形は salary >= @min AND salary <= @max。ただし 下限・上限は片方だけ指定 もありえます。空欄(指定なし)の扱いには何通りかあるので、好きな方法でどうぞ(どの方法でも 値は必ずパラメータで渡す)。
    • (ア)極端な既定値でうめる:空欄のとき @min に 0、@max に非常に大きい値(999999999 など)を入れ、salary >= @min AND salary <= @max で一律に書く。いちばん手軽ですが、「下限 0」「上限 10 億」というマジックナンバーが出るのと、上限をいくつにするか決めうちになるのが弱点です。
    • (イ)SQL 側で「指定なし」を吸収する:WHERE (@min IS NULL OR salary >= @min) AND (@max IS NULL OR salary <= @max) と書き、空欄のパラメータには DBNull.Value を渡す。「@min が NULL なら下限条件は素通り」になり、1 本の SQL で片方だけ・両方・指定なしのすべてに対応できます。本文の検索で使った @keyword = '' OR ...(指定なしを SQL 内で表す)と同じ発想 です。
    • (ウ)C# 側で WHERE を組み立てる:指定された条件だけを WHERE に足していく。下限が入っていれば文字列に AND salary >= @min を足し、その時だけ command.Parameters.AddWithValue("@min", min) する、というふうに。実務でいちばんよく使う形で、条件が増えても拡張しやすいのが利点です。

      ここで足すのは 条件の枠(AND salary >= @min)だけ で、値は必ず @min などのパラメータで渡します。ユーザーが入れた数値そのものを SQL 文字列につなぐわけではないので、本章で学んだインジェクション対策とは矛盾しません。

  • もちろん、どの案でもパラメータ化クエリで書く

KadaiWinFormsApp ソリューションに新しいプロジェクト Ext_HireYearFilter を作成し、EmployeeApp のコードをコピーした上で 入社年(西暦) での絞り込みを追加してください。

仕様

  • 画面に「入社年:[ ]」の TextBox を 1 つ追加
  • 空欄なら「指定なし」と扱う
  • 4 桁の数字以外が入ったら MessageBox で警告
  • SQL は次のどちらで書いても構いません。どちらの場合も年や日付は必ずパラメータで渡し、SQL 文字列に年を直接埋め込まないこと(本章で学んだパラメータ化の徹底)。
    • おすすめ(まずはこちら・分かりやすさ重視):WHERE YEAR(hire_date) = @year … 入力された年をそのまま @year に渡すだけで済み、いちばん直感的に書けます。YEAR(hire_date) のように 列を関数で加工すると、その列のインデックスは使えなくなります が、研修のような小さなテーブルでは速度はまず問題になりません(データが小さいうちは、分かりやすさを優先して構いません)。
    • 速度を意識するなら:C# 側で DateTime startDate = new DateTime(year, 1, 1); と DateTime endDate = new DateTime(year + 1, 1, 1); を作り、WHERE hire_date >= @startDate AND hire_date < @endDate で絞り込みます。列(hire_date)をそのまま比較するのでインデックスが効きやすく、データ量が増えても速度が落ちにくいのが利点です。終わりを 「翌年の 1 月 1 日より前」 とすることで「その年いっぱい」を取りこぼしなく拾えます(<= 12/31 のように終端を書くより安全です)。
  • 空欄(指定なし)のときの扱い も、課題 24-2 と同じく何通りかあります。どれでも構いません(やはり 年の値は必ずパラメータで渡します)。
    • 年が空欄ならそもそも年の条件を付けない:年が入っているときだけ年の条件を足し、空欄なら本章の通常の検索クエリをそのまま実行する。
    • SQL 側で吸収する:WHERE (@year IS NULL OR YEAR(hire_date) = @year) とし、空欄のときは @year に DBNull.Value を渡す(課題 24-2 の(イ)と同じやり方)。

  • 全プロジェクトが KadaiWinFormsApp ソリューションに入っている
  • 各プロジェクトで Nullable を disable にしている
  • Microsoft.Data.SqlClient を NuGet で追加した
  • パラメータ化クエリで書いている(+ での SQL 連結が残っていない)
  • シングルクォート入りの入力で落ちない
  • 各メソッドの上に「役割」を 1 行コメントした
  • 指定の難所すべてに「なぜ」コメントを前行で書いた(パラメータ化の理由を含む)
  • SqlException をキャッチして MessageBox でエラーを表示する
  • bin・obj・.vs フォルダが Git 管理に入っていない
  • チーム内でミニ発表(デモ + 1 問説明 + 一言)を行った

完成したところまでを保存して提出します(タイマーはありません。自分のペースで区切りのよいところまで)。

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

提出方法:Git が使えないときはサーバへコピー

Git の調子が悪いときは、講師の指示で push の代わりに KadaiWinFormsApp フォルダをサーバ上の自分のフォルダへコピーして提出します。 その場合は、コミットメッセージの代わりに、提出先へエクスプローラーの右クリック →「新規作成」→「テキスト ドキュメント」で 提出メモ.txt を作り、「どこまで完成したか」「詰まったポイント」を書いておいてください。

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


  • SQL インジェクションは、入力をそのまま SQL に連結したときに起きる重大な脆弱性
  • 防御の正しいやり方は パラメータ化クエリ(@param + SqlParameter)
  • LIKE '%' + @keyword + '%' で部分一致検索を実装できる
  • @keyword = '' OR ... の形で「指定なしなら全件」を SQL 内で表現できる
  • ComboBox は DataSource / DisplayMember / ValueMember でリストをバインドできる
  • 「すべて」のようなメタな選択肢には -1 などのマーカー値を使う
  • Repository にメソッドを追加して機能を拡張する流れを身に付けた

次章 第 25 章「Windowsフォーム社員管理アプリ:編集・更新」 では、いよいよ第 23 章から枠だけ用意していた 新規登録 と 削除 を実装し、加えて 編集・更新(UPDATE)機能を追加します。

新しい話題として、

  • 一覧から行を選んで編集画面を開く 画面遷移
  • 編集画面での入力チェック
  • 編集後に一覧画面に戻って自動で再読み込み

を扱います。 これで Windows フォーム社員管理アプリは、CRUD(Create / Read / Update / Delete)が一通り揃った業務アプリの最小完成形になります。