.NET Core MVCとEF Coreのチュートリアルを分かりやすくまとめました④(関連データの読み取りから関連データの更新まで)

スポンサーリンク
プログラミング

.NET Core MVCとEF Coreのチュートリアルについても分かりやすく解説するブログの第4回です。前回の記事はこちらです。

当記事では以下のASP.NET Core MVCとEntity Framework Coreの公式チュートリアルをより分かりやすくまとめたものです。

チュートリアルで分かりにくい表現を言い換えて解説しています。成果物はチュートリアルと同様です。第4回では、複合データモデルの作成を見ていきます。(公式チュートリアルのPart6〜7)

目次

このチュートリアルでやること

このチュートリアルではEntity Frameworkがナビゲーションプロパティに読み込むデータ(関連データ)の読み取りと表示、更新を行なっていきます。

成果物のイメージは以下のような形です。

関連データを読み込む方法

オブジェクト リレーショナル マッピング (ORM) ソフトウェア (Entity Framework など) では、次のように関連データをエンティティのナビゲーション プロパティに読み込むことができる方法がいくつかあります。

Entity Frameworkでは関連データを読み込む方法が3つ提供されています。

  1. 一括読み込み
  2. 明示的読み込み
  3. 遅延読み込み

それぞれ確認します。

一括読み込み

この読み込み方法は関連データを含めて一括で取得する方法です。IncludeメソッドとThenIncludeメソッドを使用して、Entity Framework Core で一括読み込みを指定します。以下は使用例です。

var departments = _context.Departments.Include(d => d.Courses); //一括読み込み
foreach (Department d in departments)
{
   foreach (Course c in d.Courses)
   {
      courseList.Add(d.Name + c.Title);
   }
}

明示的読み込み

この読み込み方法は関連データは取得されません。 必要な場合のみ、Load メソッドを使用して、明示的読み込みを実行できます。 以下は使用例です。

var departments = _context.Departments; 
foreach (Department d in departments)
{
   _context.Entry(d).Collection(p => p.Courses).Load(); //明示的読み込み
   foreach (Course c in d.Courses)
   {
      courseList.Add(d.Name + c.Title);
   }
}

遅延読み込み

エンティティが最初に読み込まれるときに、関連データは取得されません。 ただし、ナビゲーションプロパティに初めてアクセスしようとすると、そのナビゲーション プロパティに必要なデータが自動的に取得されます。 遅延読み込みの使用方法はこちらを参照ください。

 

読み込み方法に優劣はなく、シーンによって使い分けるのが適切です。例えば、明示的読み込みや遅延読み込みはクエリが複数に分かれてしまうため、ネットワーク環境が遅い場合にUXが低くなります。一方で、大量のデータを表示しなければならないケースでは使用しないデータを一括読み込みすると表示速度に悪影響なケースもあります。

Courseページを作成する

以下のような画面を作成していきます。

Courseエンティティには部門名のプロパティはなく、ナビゲーションプロパティのDepartmentエンティティからNameプロパティを参照する必要があります。

前のチュートリアルでStudentsControllerを作成した手順と同様に、CoursesControllerをスキャフォールディングします。

Courseのコントローラーとビューを自動作成する

Controllerフォルダを右クリックして追加>新規スキャフォールディングアイテムから[Entity Frameworkを使用したビューがあるMVCコントローラーを追加する]を選択して作成します。

Add Courses controller

CoursesController.csIndexメソッドを開くと、Departmentナビゲーションプロパティに一括読み込みしていることが読み取れます。ここでは、AsNoTrackingを設定したクエリに書き換えます。

public async Task<IActionResult> Index()
{
   var courses = _context.Courses
      .Include(c => c.Department)
      .AsNoTracking();
   return View(await courses.ToListAsync());
}

Course/Index.cshtmlを更新する

Views/Courses/Index.cshtml を開き、以下のように書き換えます。

@model IEnumerable<ContosoUniversity.Models.Course>

@{
   ViewData["Title"] = "Courses"; //左記を追記
}

<h2>Courses</h2> <!-- 左記を追記 -->

<p>
   <a asp-action="Create">Create New</a>
</p>
<table class="table">
   <thead>
      <tr>
         <!-- 以下を追記 -->
         <th>
            @Html.DisplayNameFor(model => model.CourseID)
         </th>
         <!-- ここまで -->
         <th>
            @Html.DisplayNameFor(model => model.Title)
         </th>
         <th>
            @Html.DisplayNameFor(model => model.Credits)
         </th>
         <th>
            @Html.DisplayNameFor(model => model.Department)
         </th>
         <th></th>
      </tr>
   </thead>
   <tbody>
      @foreach (var item in Model)
      {
         <tr>
            <!-- 以下を追記 -->
            <td>
               @Html.DisplayFor(modelItem => item.CourseID)
            </td>
            <!-- ここまで -->
            <td>
               @Html.DisplayFor(modelItem => item.Title)
            </td>
            <td>
               @Html.DisplayFor(modelItem => item.Credits)
            </td>
            <td>
               @Html.DisplayFor(modelItem => item.Department.Name) <!-- 左記を追記 -->
            </td>
            <td>
               <a asp-action="Edit" asp-route-id="@item.CourseID">Edit</a> |
               <a asp-action="Details" asp-route-id="@item.CourseID">Details</a> |
               <a asp-action="Delete" asp-route-id="@item.CourseID">Delete</a>
            </td>
         </tr>
      }
   </tbody>
</table>

見出しをIndexからCoursesに変更しています。また、CourseIDを示すテーブルの列を追加しています。本来、EntityFrameworkのスキャフォールディングでは主キーが表示されません。加えて部門名が表示されるようにDepartmentナビゲーションプロパティのNameプロパティを表示しています。

アプリを実行し、 [Courses] タブを選択して部門名のリストを表示します。

Instructorページを作成する

StudentとCourseに引き続き、Instructorページを作成していきます。作成の流れは同じで、コントローラーとビューを作成していく形です。

ただし、Instructorページでは自動生成されたページに以下を行います。

  • 講師のOfficeを表示する列を追加します
  • 新たにSelectリンクボタンを用意し、講師が担当するコース、そのコースに登録している生徒を表示できるようにします

Instructors Index page

Instructor/Index.cshtmlのViewModelを作成する

Instructor/Index.cshtmlには講師データとコースデータと生徒データの3つが必要です。そのためには3つのデータを持つViewModelを作成します。

ViewModelとは
ビューに表示するデータや、ビューからコントローラーへ送信されるデータをカプセル化するクラスです。つまり、ビューとコントローラーの間でのデータのやり取りをやりやすくするために作成します。

Models/SchoolViewModels フォルダー内に InstructorIndexData.cs を作成し、既存のコードを次のコードで置き換えます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Models.SchoolViewModels
{
   public class InstructorIndexData
   {
      public IEnumerable<Instructor> Instructors { get; set; }
      public IEnumerable<Course> Courses { get; set; }
      public IEnumerable<Enrollment> Enrollments { get; set; }
   }
}

InstructorのControllerとViewを自動生成する

Controllerフォルダを右クリックして追加>新規スキャフォールディングアイテムから[Entity Frameworkを使用したビューがあるMVCコントローラーを追加する]を選択して作成します。

Add Instructors controller

Instructors/Index.cshtmlを変更する

オフィスの場所と担当コースを表示するようにViews/Instructors/Index.cshtml を次のコードに置き換えます。

<!-- 以下を変更 -->
@model ContosoUniversity.Models.SchoolViewModels.InstructorIndexData

@{
   ViewData["Title"] = "Instructors";
}

<h2>Instructors</h2>
<!-- ここまで -->

<p>
   <a asp-action="Create">Create New</a>
</p>
<table class="table">
   <thead>
      <tr>
         <!-- 以下を変更 -->
         <th>Last Name</th>
         <th>First Name</th>
         <th>Hire Date</th>
         <th>Office</th>
         <th>Courses</th>
         <!-- ここまで -->
         <th></th>
      </tr>
   </thead>
   <tbody>
      <!-- 以下を変更 -->
      @foreach (var item in Model.Instructors)
      {
         string selectedRow = "";
         if (item.ID == (int?)ViewData["InstructorID"])
         {
            selectedRow = "table-success";
         }
         <tr class="@selectedRow">
     <!-- ここまで -->
            <td>
               @Html.DisplayFor(modelItem => item.LastName)
            </td>
            <td>
               @Html.DisplayFor(modelItem => item.FirstMidName)
            </td>
            <td>
               @Html.DisplayFor(modelItem => item.HireDate)
            </td>
            <!-- 以下を変更 -->
            <td>
               @if (item.OfficeAssignment != null)
               {
                  @item.OfficeAssignment.Location
               }
            </td>
            <td>
               @foreach (var course in item.CourseAssignments)
               {
                  @course.Course.CourseID @course.Course.Title <br />
               }
            </td>
            <!-- ここまで -->
            <td>
               <!-- 以下の1行を追加 -->
               <a asp-action="Index" asp-route-id="@item.ID">Select</a> |
               <a asp-action="Edit" asp-route-id="@item.ID">Edit</a> |
               <a asp-action="Details" asp-route-id="@item.ID">Details</a> |
               <a asp-action="Delete" asp-route-id="@item.ID">Delete</a>
            </td>
         </tr>
      }
   </tbody>
</table>

ここでのポイントは@modelディレクティブです。先ほど定義したViewModelを使用しているため、Model.Instructorsで講師データを参照しています。

InstructorControllerを変更する

InstructorsController.cs を開いて、ViewModelのusingを追加します。

using ContosoUniversity.Models.SchoolViewModels;

Indexメソッドを以下の通りに更新します。

public async Task<IActionResult> Index(int? id, int? courseID)
{
   var viewModel = new InstructorIndexData();
   viewModel.Instructors = await _context.Instructors
         .Include(i => i.OfficeAssignment)
         .Include(i => i.CourseAssignments)
            .ThenInclude(i => i.Course)
               .ThenInclude(i => i.Enrollments)
                  .ThenInclude(i => i.Student)
         .Include(i => i.CourseAssignments)
            .ThenInclude(i => i.Course)
               .ThenInclude(i => i.Department)
         .AsNoTracking()
         .OrderBy(i => i.LastName)
         .ToListAsync();
   if (id != null)
   {
      ViewData["InstructorID"] = id.Value;
      Instructor instructor = viewModel.Instructors.Where(
         i => i.ID == id.Value).Single();
      viewModel.Courses = instructor.CourseAssignments.Select(s => s.Course);
   }
   if (courseID != null)
   {
      ViewData["CourseID"] = courseID.Value;
      viewModel.Enrollments = viewModel.Courses.Where(
         x => x.CourseID == courseID).Single().Enrollments;
   }
   return View(viewModel);
}

ここでのポイントはviewModel.Instructorsに代入している処理です。.ThenIncludeでCourseナビゲーションプロパティ、Enrollmentsプロパティ、Studentプロパティを含めたクエリを取得しておくことでビューでInstructorに紐づくこれらのデータを簡単に参照できるようにしています。

アプリを実行し、 [Instructors] タブで表示を確認してみましょう。

Instructors/Index.cshtmlで講師の担当コースが表示されるように更新する

続けて、講師が選択された際に講師の担当するコースが表示されるようにViews/Instructors/Index.cshtmlを更新します。</table>の下に、次のコードを追加します。

@if (Model.Courses != null)
{
   <h3>Courses Taught by Selected Instructor</h3>
   <table class="table">
      <tr>
         <th></th>
         <th>Number</th>
         <th>Title</th>
         <th>Department</th>
      </tr>
      @foreach (var item in Model.Courses)
      {
         string selectedRow = "";
         if (item.CourseID == (int?)ViewData["CourseID"])
         {
            selectedRow = "table-success";
         }
         <tr class="@selectedRow">
            <td>
               @Html.ActionLink("Select", "Index", new { courseID = item.CourseID })
            </td>
            <td>
               @item.CourseID
            </td>
            <td>
               @item.Title
            </td>
            <td>
               @item.Department.Name
            </td>
         </tr>
      }
   </table>
}

抑えておきたいポイントとしては、@Html.ActionLinkです。これはSelectの文字列にURLリンクを設定し、遷移先はIndexアクションに指定しています。選択したコースIDをパラメータに渡しています。

では、アプリを実行してみましょう。[Instructorsタブ]に移動します。

Instructors/Index.cshtmlでコースの登録者リストを表示するよう更新する

さらにInstructors/Index.cshtmlに以下のコードを追加します。コースの選択時にコースに登録されている受講者のリストを表示します。

@if (Model.Enrollments != null)
{
   <h3>
      Students Enrolled in Selected Course
   </h3>
   <table class="table">
      <tr>
         <th>Name</th>
         <th>Grade</th>
      </tr>
      @foreach (var item in Model.Enrollments)
      {
         <tr>
            <td>
               @item.Student.FullName
            </td>
            <td>
               @Html.DisplayFor(modelItem => item.Grade)
            </td>
         </tr>
      }
   </table>
}

再度、アプリを実行して、[Instructorsタブ]にアクセスします。

ここまででInstructorタブのカスタマイズは終わりです。

関連データの更新

これまでは関連データの表示を行ってきました。ここからは関連データの更新する方法を解説していきます。具体的には外部キーのフィールドとナビゲーションプロパティを更新することで関連データを更新していきます。

ここでの成果物のイメージは以下の通りです。

Coursesに関連データの更新処理を追加する

新しいコースが作成される時、既存の部門と関連付けをしながら登録できると便利です。スキャフォールディングで自動生成されたコードにはナビゲーションプロパティである部門のドロップダウンリストが自動生成されていますが、DepartmentIDが表示されているだけでは、どの部門か分かりづらいです。

そこで、ここでは他のViewでも使いまわせるように部門名のSelectListコレクションをViewBagに設定するメソッドを作成し、スキャフォールディングされたコードを置き換えていこうと思います。

DropDownListメソッドを作成する

CoursesController.csの中にドロップダウン リストに部門情報を読み込むPopulateDepartmentsDropDownListメソッドを作成します。

private void PopulateDepartmentsDropDownList(object selectedDepartment = null)
{
   var departmentsQuery = from d in _context.Departments
                          orderby d.Name
                          select d;
   ViewBag.DepartmentID = new SelectList(departmentsQuery.AsNoTracking(), "DepartmentID", "Name", selectedDepartment);
}

このメソッドでは部門名で昇順ソートされたSelectListViewBagでビューに渡します。 また、省略可能なselectedDepartmentパラメーターによってデフォルトで選択される値が設定できます。

CoursesControllerの作成・更新処理を変更する

次に、CoursesController.csでCreateメソッドを削除し、以下に置き換えます。

public IActionResult Create()
{
   PopulateDepartmentsDropDownList();
   return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("CourseID,Credits,DepartmentID,Title")] Course course)
{
   if (ModelState.IsValid)
   {
      _context.Add(course);
      await _context.SaveChangesAsync();
      return RedirectToAction(nameof(Index));
   }
   PopulateDepartmentsDropDownList(course.DepartmentID);
   return View(course);
}

CoursesController.csのEditメソッドも同様に変更します。

public async Task<IActionResult> Edit(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var course = await _context.Courses
         .AsNoTracking()
         .FirstOrDefaultAsync(m => m.CourseID == id);
   if (course == null)
   {
      return NotFound();
   }
   PopulateDepartmentsDropDownList(course.DepartmentID);
   return View(course);
}
[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var courseToUpdate = await _context.Courses
         .FirstOrDefaultAsync(c => c.CourseID == id);
   if (await TryUpdateModelAsync<Course>(courseToUpdate,
       "",
       c => c.Credits, c => c.DepartmentID, c => c.Title))
   {
      try
      {
         await _context.SaveChangesAsync();
      }
      catch (DbUpdateException /* ex */)
      {
         //Log the error (uncomment ex variable name and write a log.)
         ModelState.AddModelError("", "Unable to save changes. " +
         "Try again, and if the problem persists, " +
         "see your system administrator.");
      }
      return RedirectToAction(nameof(Index));
   }
   PopulateDepartmentsDropDownList(courseToUpdate.DepartmentID);
   return View(courseToUpdate);
}

これでCreateページとEditページにViewBag.DepartmentIDでアクセスできるSelectListが渡されるようになりました。[HttpPost]でもメソッドを呼び出しているのは、フォームのエラーによるページの再表示でもドロップダウンの値が保持されるようにするためです。

CoursesControllerの詳細・削除ページに追跡なしのクエリを追加する

詳細(Detail)・削除(Delete)ページのGETアクセスでのデータは読み取り専用で更新の必要がありません。そのため、取得するメソッドにAsNoTrackingを追加します。

public async Task<IActionResult> Details(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var course = await _context.Courses
            .Include(c => c.Department)
            .AsNoTracking() //左記を追記
            .FirstOrDefaultAsync(m => m.CourseID == id);
   if (course == null)
   {
      return NotFound();
   }
   return View(course);
}
public async Task<IActionResult> Delete(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var course = await _context.Courses
            .Include(c => c.Department)
            .AsNoTracking() //左記を追記
            .FirstOrDefaultAsync(m => m.CourseID == id);
   if (course == null)
   {
      return NotFound();
   }
   return View(course);
}

Courses/Create・Edit.cshtmlに部門名のドロップダウンを設定する

Views/Courses/Create.cshtmlViews/Courses/Edit.cshtmlの該当箇所を以下の通りに更新します。(変更内容は同様です。)

<div class="form-group">
   <label asp-for="Credits" class="control-label"></label>
   <input asp-for="Credits" class="form-control" />
   <span asp-validation-for="Credits" class="text-danger"></span>
</div>
<!-- 以下をコメントアウト -->
<!--
<div class="form-group">
   <label asp-for="DepartmentID" class="control-label"></label>
   <select asp-for="DepartmentID" class="form-control" asp-items="ViewBag.DepartmentID"></select>
   <span asp-validation-for="DepartmentID" class="text-danger"></span>
</div>
-->
<!-- ここまで -->
<!-- 以下を追記 -->
<div class="form-group">
   <label asp-for="Department" class="control-label"></label>
   <select asp-for="DepartmentID" class="form-control" asp-items="ViewBag.DepartmentID">
      <option value="">-- Select Department --</option>
   </select>
   <span asp-validation-for="DepartmentID" class="text-danger" />
</div>
<!-- ここまで -->
<div class="form-group">
   <input type="submit" value="Create" class="btn btn-primary" />
</div>

また、Views/Courses/Edit.cshtml には[Title](タイトル) フィールドの前に[CourseID](コース番号)フィールドを追加します。 コース番号は表示しますが、主キーなので、変更不可です。

<div class="form-group">
   <label asp-for="CourseID" class="control-label"></label>
   <div>@Html.DisplayFor(model => model.CourseID)</div>
</div>

Courses/Details・Delete.cshtmlで部門名が表示されるように設定する

Views/Courses/Details.cshtml Views/Courses/Delete.cshtml を開き、上部にコース番号フィールドを追加し、部門ID を部門名に変更します。

@model ContosoUniversity.Models.Course

@{
   ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<h3>Are you sure you want to delete this?</h3>
<div>
   <h4>Course</h4>
   <hr />
   <dl class="row">
      <!-- 以下を追記 -->
      <dt class="col-sm-2">
         @Html.DisplayNameFor(model => model.CourseID)
      </dt>
      <dd class="col-sm-10">
         @Html.DisplayFor(model => model.CourseID)
      </dd>
      <!-- ここまで -->
      <dt class="col-sm-2">
         @Html.DisplayNameFor(model => model.Title)
      </dt>
      <dd class="col-sm-10">
         @Html.DisplayFor(model => model.Title)
      </dd>
      <dt class="col-sm-2">
         @Html.DisplayNameFor(model => model.Credits)
      </dt>
      <dd class="col-sm-10">
         @Html.DisplayFor(model => model.Credits)
      </dd>
      <dt class="col-sm-2">
         @Html.DisplayNameFor(model => model.Department)
      </dt>
      <dd class="col-sm-10">
         @Html.DisplayFor(model => model.Department.Name)<!-- 左記に変更 -->
      </dd>
   </dl>

   <form asp-action="Delete">
      <div class="form-actions no-color">
         <input type="submit" value="Delete" class="btn btn-default" /> |
         <a asp-action="Index">Back to List</a>
      </div>
   </form>
</div>

Courseタブを開いてテストする

アプリを実行して、 [Courses] タブを選択し、 [新規作成] をクリックして新しいコースのデータを入力します。

[Create] をクリックしてください。 Courses/Index ページには、リストに追加された新しいコースが表示されます。 Index ページのリストの部門名は、ナビゲーション プロパティから取得されています。

Courses/Index ページのコースで [Edit] をクリックします。

ページ上のデータを変更し、 [Save](保存) をクリックします。 Courses/Index ページには、更新されたコース データが表示されます。

Instructorに関連データの更新処理を追加する

コースに続いて、講師(Instructor)です。InstructorエンティティはOfficeAssignmentエンティティと1対0または1対1のリレーションを持ちます。ここではオフィスの場所をInstructorページから編集できるようにします。

Instructors/Edit.cshtmlにオフィスの場所を編集する入力欄を追加する

Views/Instructors/Edit.cshtml で、オフィスの場所を編集するための入力欄を追加していきます。[Save]ボタンの直前に以下のコードを追加してください。

<div class="form-group">
   <label asp-for="OfficeAssignment.Location" class="control-label"></label>
   <input asp-for="OfficeAssignment.Location" class="form-control" />
   <span asp-validation-for="OfficeAssignment.Location" class="text-danger" />
</div>

asp-forでOfficeAssignmentのLocationプロパティにバインディングしています。

InstructorsController.csにオフィス更新処理を追加する

InstructorsController.cs を以下の通りに更新します。

public async Task<IActionResult> Edit(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var instructor = await _context.Instructors
         .Include(i => i.OfficeAssignment)
         .AsNoTracking()
         .FirstOrDefaultAsync(m => m.ID == id);
   if (instructor == null)
   {
      return NotFound();
   }
   return View(instructor);
}
[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var instructorToUpdate = await _context.Instructors
         .Include(i => i.OfficeAssignment)
         .FirstOrDefaultAsync(s => s.ID == id);
   if (await TryUpdateModelAsync<Instructor>(
       instructorToUpdate,
       "",
       i => i.FirstMidName, i => i.LastName, i => i.HireDate, i => i.OfficeAssignment))
   {
      if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment?.Location))
      {
         instructorToUpdate.OfficeAssignment = null;
      }
      try
      {
         await _context.SaveChangesAsync();
      }
      catch (DbUpdateException /* ex */)
      {
         //Log the error (uncomment ex variable name and write a log.)
         ModelState.AddModelError("", "Unable to save changes. " +
            "Try again, and if the problem persists, " +
            "see your system administrator.");
      }
      return RedirectToAction(nameof(Index));
   }
   return View(instructorToUpdate);
}

抑えておくべきはinstructorToUpdate.OfficeAssignment = nullのところです。instructorToUpdateはIncludeでOfficeAssignmentが設定されているため、nullを代入することでOfficeAssignmentのテーブル行を削除できます。

Instructorタブをテストする

アプリを実行し、 [Instructors](インストラクター) タブを選択し、インストラクターで [Edit](編集) をクリックします。 [Office Location](オフィスの場所) を変更し、 [Save](保存) をクリックします。

Instructors/Edit.cshtmlにコース選択を追加する

講師は複数のコースを担当することがあるため、チェックボックスを利用して、コースの割り当てを変更する機能を追加します。下の画像は実装のイメージです。

Instructor Edit page with courses

講師とコースのリレーションは多対多です。コースはデータベース内のコースエンティティを全て表示します。以下のコードもSaveボタンのとOfficeAssignment.Locationの間のform-groupとして挿入します。

<div class="form-group">
   <div class="col-md-offset-2 col-md-10">
      <table>
         <tr>
            @{
               int cnt = 0;
               List<ContosoUniversity.Models.SchoolViewModels.AssignedCourseData> courses = ViewBag.Courses;
               foreach (var course in courses)
               {
                  if (cnt++ % 3 == 0)
                  {
                     @:</tr><tr>
                  }
                  @:<td>
                     <input type="checkbox"
                            name="selectedCourses"
                            value="@course.CourseID"
                            @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) />
                            @course.CourseID @: @course.Title
                  @:</td>
               }
               @:</tr>
            }
      </table>
   </div>
</div>

※適切に改行しなければエラーになります。Models.SchoolViewModels.AssignedCourseDataのエラーは未定義のため問題ありません。

チェックボックスはname属性にselectedCoursesが設定されており、valueにCourseIDを持ちます。フォームが送信されると、Controllerの引数にCourseIDの配列が渡されます。

InstructorsController.csに講師のコースと担当情報を渡すよう変更する

先にデータの受け渡しをしやすくするためにViewModelを作成します。 SchoolViewModels フォルダー内に AssignedCourseData.cs を作成します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Models.SchoolViewModels
{
   public class AssignedCourseData
   {
      public int CourseID { get; set; }
      public string Title { get; set; }
      public bool Assigned { get; set; }
   }
}

InstructorsController.cs でEditメソッドを更新します。Editメソッド内で使用する PopulateAssignedCourseData メソッドとUpdateInstructorCourses メソッドも追加します。

public async Task<IActionResult> Edit(int? id)
{
   if (id == null)
   {
      return NotFound();
   }
   var instructor = await _context.Instructors
         .Include(i => i.OfficeAssignment)
         .Include(i => i.CourseAssignments).ThenInclude(i => i.Course)//左記を追記
         .AsNoTracking()
         .FirstOrDefaultAsync(m => m.ID == id);
   if (instructor == null)
   {
      return NotFound();
   }
   PopulateAssignedCourseData(instructor);//左記を追記
   return View(instructor);
}

//以下はEditPostを削除し、代わりに作成
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int? id, string[] selectedCourses)
{
   if (id == null)
   {
      return NotFound();
   }
   var instructorToUpdate = await _context.Instructors
         .Include(i => i.OfficeAssignment)
         .Include(i => i.CourseAssignments)
         .ThenInclude(i => i.Course)
         .FirstOrDefaultAsync(m => m.ID == id);
   if (await TryUpdateModelAsync<Instructor>(
       instructorToUpdate,
       "",
       i => i.FirstMidName, i => i.LastName, i => i.HireDate, i => i.OfficeAssignment))
   {
      if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment?.Location))
      {
         instructorToUpdate.OfficeAssignment = null;
      }
      UpdateInstructorCourses(selectedCourses, instructorToUpdate);
      try
      {
         await _context.SaveChangesAsync();
      }
      catch (DbUpdateException /* ex */)
      {
         //Log the error (uncomment ex variable name and write a log.)
         ModelState.AddModelError("", "Unable to save changes. " +
         "Try again, and if the problem persists, " +
         "see your system administrator.");
      }
      return RedirectToAction(nameof(Index));
   }
   UpdateInstructorCourses(selectedCourses, instructorToUpdate);
   PopulateAssignedCourseData(instructorToUpdate);
   return View(instructorToUpdate);
}
//ここまで

//以下のメソッドを新規作成
private void UpdateInstructorCourses(string[] selectedCourses, Instructor instructorToUpdate)
{
   if (selectedCourses == null)
   {
      instructorToUpdate.CourseAssignments = new List<CourseAssignment>();
      return;
   }
   var selectedCoursesHS = new HashSet<string>(selectedCourses);
   var instructorCourses = new HashSet<int>
       (instructorToUpdate.CourseAssignments.Select(c => c.Course.CourseID));
   foreach (var course in _context.Courses)
   {
      if (selectedCoursesHS.Contains(course.CourseID.ToString()))
      {
         if (!instructorCourses.Contains(course.CourseID))
         {
            instructorToUpdate.CourseAssignments.Add(new CourseAssignment { InstructorID = instructorToUpdate.ID, CourseID = course.CourseID });
         }
      }
      else
      {
         if (instructorCourses.Contains(course.CourseID))
         {
            CourseAssignment courseToRemove = instructorToUpdate.CourseAssignments.FirstOrDefault(i => i.CourseID == course.CourseID);
            _context.Remove(courseToRemove);
         }
      }
   }
}
//ここまで

//以下のメソッドを新規作成
private void PopulateAssignedCourseData(Instructor instructor)
{
   var allCourses = _context.Courses;
   var instructorCourses = new HashSet<int>(instructor.CourseAssignments.Select(c => c.CourseID));
   var viewModel = new List<AssignedCourseData>();
   foreach (var course in allCourses)
   {
      viewModel.Add(new AssignedCourseData
      {
         CourseID = course.CourseID,
         Title = course.Title,
         Assigned = instructorCourses.Contains(course.CourseID)
      });
   }
   ViewData["Courses"] = viewModel;
}
//ここまで

最初のEditメソッドでは、関連データの読み込みで CourseAssignment  Course  Include が行われています。PopulateAssignedCourseData も呼び出されています。

呼び出されたPopulateAssignedCourseData では全てのコースエンティティを取得しつつ、講師のコース一覧をHashSetに入れることで、全てのコース一覧から講師の担当コースの主キーリストに一致するものはAssigned プロパティがtrueに設定されます。

HashSet<T>クラスとは
重複したオブジェクトを追加できないリストクラス。この例では主キーとなるコースIDをコレクションとして格納している。

EditPostから置換されたHttpPostのEditメソッドの処理を説明します。Postされた際にビューにはCourseエンティティのコレクションがありません。(あるのはstring型の配列のselectedCourses)そのため、モデルバインディングでInstructor.CourseAssignmentsナビゲーションプロパティを更新することはできません。別途CourseAssignmentsを更新するためのUpdateInstructorCoursesメソッドを定義しています。

UpdateInstructorCoursesメソッドではデータベース内に保存されている講師の担当コースデータとPostされたコースデータのHashSetを比較し、不一致のものはInstructor.CourseAssignmentsナビゲーションプロパティに返されます。

Instructorタブの編集が機能するかテストする

アプリを実行し、 [Instructors](インストラクター) タブを選択し、 [Edit](編集) をクリックして Edit ページを表示します。

コースのチェックボックスを変更して、[Save](保存) をクリックします。 行った変更が Index ページに反映されていれば、正しく変更できています。

Instructors/Edit.cshtmlを更新する

InstructorsController.cs で、DeleteConfirmed メソッドを修正します。

[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
   //以下を追記
   Instructor instructor = await _context.Instructors
      .Include(i => i.CourseAssignments)
      .SingleAsync(i => i.ID == id);

   var departments = await _context.Departments
      .Where(d => d.InstructorID == id)
      .ToListAsync();
   departments.ForEach(d => d.InstructorID = null);
   //ここまで

   _context.Instructors.Remove(instructor);

   await _context.SaveChangesAsync();
   return RedirectToAction(nameof(Index));
}

講師が削除されたら、担当コースの紐付けであるCourseAssignments も削除されるべきです。そのためにはIncludeする必要があります。(データベースでの連鎖削除設定を行う場合はInclude不要です。)

Instructors/Create.cshtmlにオフィスと担当コースの設定項目を追加する

Instructor/Create.cshtml に、オフィスのテキストボックスと担当コースのチェックボックスを [Submit](送信) ボタンの直前に追加します。

<div class="form-group">
   <label asp-for="OfficeAssignment.Location" class="control-label"></label>
   <input asp-for="OfficeAssignment.Location" class="form-control" />
   <span asp-validation-for="OfficeAssignment.Location" class="text-danger" />
</div>
<div class="form-group">
   <div class="col-md-offset-2 col-md-10">
      <table>
         <tr>
            @{
               int cnt = 0;
               List<ContosoUniversity.Models.SchoolViewModels.AssignedCourseData> courses = ViewBag.Courses;
               foreach (var course in courses)
               {
                  if (cnt++ % 3 == 0)
                  {
                     @:</tr><tr>
                  }
                  @:<td>
                  <input type="checkbox"
                         name="selectedCourses"
                         value="@course.CourseID"
                         @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) />
                         @course.CourseID @: @course.Title
                  @:</td>
               }
               @:</tr>
            }
      </table>
   </div>
</div>

InstructorsController.csにオフィスと担当コースの設定処理を追加する

InstructorsController.cs で、HttpGet と HttpPost の Create メソッドを以下の通りに修正します。

public IActionResult Create()
{
   //以下を追記
   var instructor = new Instructor();
   instructor.CourseAssignments = new List<CourseAssignment>();
   PopulateAssignedCourseData(instructor);
   //ここまで
   return View();
}

// POST: Instructors/Create
[HttpPost]
[ValidateAntiForgeryToken]
//以下の1行を修正
public async Task<IActionResult> Create([Bind("FirstMidName,HireDate,LastName,OfficeAssignment")] Instructor instructor, string[] selectedCourses)
{
   //以下を追記
   if (selectedCourses != null)
   {
      instructor.CourseAssignments = new List<CourseAssignment>();
      foreach (var course in selectedCourses)
      {
         var courseToAdd = new CourseAssignment { InstructorID = instructor.ID, CourseID = int.Parse(course) };
         instructor.CourseAssignments.Add(courseToAdd);
      }
   }
   //ここまで
   if (ModelState.IsValid)
   {
      _context.Add(instructor);
      await _context.SaveChangesAsync();
      return RedirectToAction(nameof(Index));
   }
   //以下の1行を追記
   PopulateAssignedCourseData(instructor);
   return View(instructor);
}

上の変更はEditに近いですが、違いはEditのように修正対象となるコースがない点です。PopulateAssignedCourseData メソッドはコース一覧と講師の担当コース情報を合わせたコレクション要素をViewData[“Course”]にセットするメソッドですが、ここでは空の講師インスタンスで担当コースがない、つまりチェックのつかないコース一覧をViewData[“Course”]にセットしています。instructorのナビゲーションプロパティであるCourseAssignmentにもインスタンスを生成していますが、null参照を回避するためです。

HttpPostのCreateメソッドでは、選択されたコースIDがselectedCourseに配列で格納されるので、instructor.CourseAssignmentsにAddした上で更新しています。

Instructorタブの作成が機能するかテストする

アプリを実行して、Instructorタブを開き、講師ページで講師が追加できるかをテストします。

 

ここまでお疲れ様でした。EntityFrameworkCoreのチュートリアルはこの後も続きますが、高度なトピックに入ってしまうので、解説はここまでとしたいと思います。もし、ご要望の声が多ければ、以降のチュートリアルも解説しようと思います。

最後までお読みいただき、ありがとうございました!

コメント

タイトルとURLをコピーしました