第24章 Windowsフォーム社員管理アプリ:検索
この章の目的
Section titled “この章の目的”この章では、第 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に再表示できる
本章で使用する環境
Section titled “本章で使用する環境”| 項目 | 内容 |
|---|---|
| 開発環境 | 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 接続文字列を設定する」の補足を参照してください。
作業前チェック
Section titled “作業前チェック”- 第 23 章を Git に提出済み、または第 23 章のコードを手元に持っている
-
TrainingDBのemployees/departmentsが動作している -
EmployeeRepository.GetAll()が動くことを確認した
24-1 この章で追加する機能
Section titled “24-1 この章で追加する機能”第 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 として渡します。
✅ 正しい書き方
Section titled “✅ 正しい書き方”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 文として実行されることはありません。
パラメータ化の利点
Section titled “パラメータ化の利点”| 利点 | 説明 |
|---|---|
| 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 メソッド に切り出します。
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; }}Search メソッドのポイント
Section titled “Search メソッドのポイント”| ポイント | 説明 |
|---|---|
@keyword / @departmentId でパラメータ化 | SQL インジェクション対策 |
LIKE '%' + @keyword + '%' | SQL 内で % をパラメータの両側に付けて部分一致 |
@keyword = '' OR ... | キーワードが空文字列のときは全件マッチ |
@departmentId = -1 OR e.department_id = @departmentId | -1 を「指定なし」のマーカーとして使う |
keyword ?? string.Empty | null が渡されたら "" として扱う |
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 を新規追加します。
namespace EmployeeApp;
public class Department{ public int DepartmentId { get; set; } public string DepartmentName { get; set; }}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; }}24-6 画面に検索 UI を追加する
Section titled “24-6 画面に検索 UI を追加する”第 23 章の画面に、次のコントロールを追加します。DataGridView の上に 検索パネル をはさむイメージです。
DataGridView の位置を下げる
Section titled “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 |
TextBox | textBoxKeyword | Location = 60, 53、Size = 150, 23 |
Label | (任意) | Text = 部署:、Location = 230, 56 |
ComboBox | comboBoxDepartment | Location = 280, 53、Size = 150, 23、DropDownStyle = DropDownList |
Button | buttonSearch | Text = 検索、Location = 450, 51、Size = 90, 28 |
Button | buttonClear | Text = クリア、Location = 550, 51、Size = 90, 28 |
DropDownStyle を DropDownList にすると、ユーザーは選択肢以外を入力できなくなります(誤入力防止)。
イベントの紐付けについて
Section titled “イベントの紐付けについて”ボタンのイベント紐付け(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 も用意します。
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 のパターンで確かめてください。
24-8 動作確認
Section titled “24-8 動作確認”次のパターンで動作を確認します。
| 入力 | 期待する結果 |
|---|---|
| 名前=「山田」、部署=「(すべての部署)」 | 山田 二郎 だけ表示 |
| 名前=「」、部署=「総務」 | 総務部の社員(4 名)が表示 |
| 名前=「藤」、部署=「営業」 | 佐藤 昭夫(部分一致で藤を含む、営業)が表示 |
| 名前=「」、部署=「(すべての部署)」 | 全 10 名表示 |
名前=「'」(シングルクォート 1 文字) | エラーにならず、該当 0 件として表示される(SQL インジェクションが効かないことの確認) |
24-9 完成形の確認(答え合わせ用)
Section titled “24-9 完成形の確認(答え合わせ用)”まずは 24-7 のステップを自分で積み上げてから見てください
これは、24-7 のステップ1〜6 をすべて終えたあとの
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); }}よくあるつまずき
Section titled “よくあるつまずき”| 症状 | 原因 | 対処 |
|---|---|---|
| 検索結果が常に空 | 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) |
学んだことチェック
Section titled “学んだことチェック”- SQL インジェクションとは何か、なぜ危険かを説明できる
- パラメータ化クエリの基本パターン(
@param+SqlParameter)を書ける -
LIKEで部分一致検索を書ける - 検索条件が空のときに全件返す SQL の書き方を 1 つ挙げられる
-
ComboBoxのDataSource/DisplayMember/ValueMemberの役割を説明できる - 「すべての部署」のような全件表示用の特別な値(マーカー)の使い方を説明できる
- Repository に新しいメソッド(
Search)を追加できる - 複数の Repository(
EmployeeRepositoryとDepartmentRepository)をフォームから使える
- SQL インジェクションを 1 文で説明してください。
- パラメータ化クエリの「パラメータ」とは何ですか。
LIKE '%' + @keyword + '%'の%は何を意味しますか。- 「全件」を表すために
-1を使っていますが、なぜ0ではなく-1にしているか、考えてみてください。 DisplayMemberとValueMemberの使い分けを説明してください。AddWithValueの利点と注意点を 1 つずつ挙げてください。- クリアボタンを押したときの動作を、自分の言葉で 3 ステップで説明してください。
第 24 章も、第 23 章と同じ チームで自走するハンズオン形式 で進めます。 チームの役割分担(リーダー / 技術部長 / タイムキーパー)と「自走のすすめ」は 第 17 章「ここからはチームで進める」、ミニ発表の進め方は 第 23 章「演習課題」 を参照してください。本文を手順書として、ペア・チームで確認し合いながら、自分たちのペースでアプリを組み上げます(提供ソースを使うので、できあがる検索アプリは全員ほぼ同じ動作になります)。
検索が動いたら、演習課題に進みます。第 23 章と同じく、必須課題は、できあがったソースを読み解いて「なぜそう書くのか」をコメントとして書き残す 作業です。本章はとくに パラメータ化クエリ(SQL インジェクション対策) がヤマなので、その「なぜ」を自分の言葉にすることを重視します。発展課題として、検索条件のカスタマイズにも挑戦できます。
この章の進め方
Section titled “この章の進め方”- チームで本文 24-2〜24-9 の 実装ステップ に沿って、検索アプリを組み上げる(= 検索が動く)。24-7 のステップ1〜6 は自分の手で。詰まったら 24-9 の完成形で答え合わせ
- 「24-8 動作確認」の表のパターンで動作を確かめる(シングルクォート 1 文字でも落ちない ことも忘れずに)
- 【必須課題 24-1】 できあがったソースに「なぜ」コメントを付ける
- 【発展課題 24-2 / 24-3】 余裕があれば検索条件をカスタマイズする
- タイムキーパーの合図で手を止め、チーム内で ミニ発表(下記)を行う
必須課題は、第 23 章から続けている EmployeeApp プロジェクト にそのまま書き込みます(「なぜ」コメントも同じプロジェクト)。発展課題だけは、同じ KadaiWinFormsApp ソリューション内に 別プロジェクト として作ります。
| 課題 | プロジェクト | 内容 |
|---|---|---|
| 課題 24-1(必須) | EmployeeApp(本文の続き) | 検索アプリ本体 + 「なぜ」コメント |
| 課題 24-2(発展) | Ext_SalaryRange(新規) | 給与レンジ検索を追加 |
| 課題 24-3(発展) | Ext_HireYearFilter(新規) | 入社年で絞り込み |
ミニ発表(成果の共有)
Section titled “ミニ発表(成果の共有)”ひととおり動いたら、チーム内で一人ずつ ごく簡単に発表 します。次の 3 つを話すだけで十分です。
- デモ:検索を実際に動かして見せる(名前で部分一致、部署で絞り込み、クリアで全件)
- 1 問説明:自分が付けた「なぜ」コメントから 1 つ、または上の「ペア確認」から 1 つを選び、自分の言葉で説明する(チームで 1 人は「なぜ
+で連結せずパラメータ化するのか」 を説明できると安心です) - 一言:コメントを書いていて一番「なるほど」と思った点、または詰まったポイントを一言
発表は「説明できる = 理解できている」の確認です
点数をつけるためのものではありません。本章のヤマは パラメータ化クエリで 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.cs | command.Parameters.AddWithValue("@keyword", ...) | なぜ + で連結せずパラメータで渡すのか(何を防ぐか) |
EmployeeRepository.cs | LIKE '%' + @keyword + '%' | % は何を表すか / なぜ両側に付けるか |
EmployeeRepository.cs | @keyword = '' OR e.last_name LIKE ... | なぜ @keyword = '' で「指定なし=全件」になるのか |
EmployeeRepository.cs | @departmentId = -1 OR e.department_id = @departmentId | なぜ -1 を「指定なし」のしるしに使うのか |
EmployeeRepository.cs | keyword ?? string.Empty | なぜ null を空文字に置き換えるのか |
EmployeeRepository.cs | ReadEmployees を private static に切り出した点 | なぜ GetAll と Search で共通化したのか |
Form1.cs | private const int AllDepartments = -1; | なぜ定数(マーカー値)にして使い回すのか |
Form1.cs | comboBoxDepartment.DisplayMember / ValueMember | それぞれ何を担当しているのか |
Form1.cs | (int)comboBoxDepartment.SelectedValue | なぜ int で取り出せるのか |
Form1.cs | textBoxKeyword.Text.Trim() | なぜ Trim() するのか |
Form1.cs | ShowError を共通メソッドにした点 | なぜ各メソッドに 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 文字列につなぐわけではないので、本章で学んだインジェクション対策とは矛盾しません。
- (ア)極端な既定値でうめる:空欄のとき
- もちろん、どの案でもパラメータ化クエリで書く
課題 24-3 入社年で絞り込む
Section titled “課題 24-3 入社年で絞り込む”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 の(イ)と同じやり方)。
提出前チェックリスト
Section titled “提出前チェックリスト”- 全プロジェクトが
KadaiWinFormsAppソリューションに入っている - 各プロジェクトで Nullable を disable にしている
-
Microsoft.Data.SqlClientを NuGet で追加した - パラメータ化クエリで書いている(
+での SQL 連結が残っていない) - シングルクォート入りの入力で落ちない
- 各メソッドの上に「役割」を 1 行コメントした
- 指定の難所すべてに「なぜ」コメントを前行で書いた(パラメータ化の理由を含む)
-
SqlExceptionをキャッチしてMessageBoxでエラーを表示する -
bin・obj・.vsフォルダが Git 管理に入っていない - チーム内でミニ発表(デモ + 1 問説明 + 一言)を行った
Git への提出
Section titled “Git への提出”完成したところまでを保存して提出します(タイマーはありません。自分のペースで区切りのよいところまで)。
git statusgit add .git commit -m "Chapter24: 検索アプリ完成+なぜコメント / <パラメータ化の「なぜ」を一言>"git push origin main提出方法:Git が使えないときはサーバへコピー
Git の調子が悪いときは、講師の指示で
pushの代わりにKadaiWinFormsAppフォルダをサーバ上の自分のフォルダへコピーして提出します。 その場合は、コミットメッセージの代わりに、提出先へエクスプローラーの右クリック →「新規作成」→「テキスト ドキュメント」で提出メモ.txtを作り、「どこまで完成したか」「詰まったポイント」を書いておいてください。
Git の詳しい操作は、付録 C「Git のインストールと提出ルール」 を参照してください。
この章のまとめ
Section titled “この章のまとめ”- 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)が一通り揃った業務アプリの最小完成形になります。