.NET Core MVCとEF Coreのチュートリアルを分かりやすくまとめました②(並び替え/フィルター/ページングからマイグレーションまで)

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

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

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

チュートリアルで分かりにくい表現を言い換えて解説しています。成果物はチュートリアルと同様です。第2回では、並び替え/フィルター/ページングからマイグレーションまでを見ていきます。(公式チュートリアルのPart3~4)

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

大まかに以下の機能を追加したり、概念の学習をしたりしていきます。

  • Students/Index.cshtmlに並べ替え、フィルター、ページング機能を追加
  • Aboutページを作成し、登録日付ごとに登録した受講者の数が表示される機能を追加
  • データベース生成のマイグレーションについて学習

画像はこのチュートリアルの完成イメージです。列見出し(Last NameやEnrollment Date)をクリックすると、昇順や降順に並べ替えられます。Find by name:でキーワードを含むユーザー名をフィルターできます。また、PreviousとNextボタンで3件ずつユーザーを表示させる処理も実装していきます。

Students インデックス ページ

では早速進めましょう!

列見出しに並び替えリンクの追加

Studentsインデックスページに並べ替えを追加するには、StudentsコントローラーのIndexメソッドとStudents/Index.cshtmlの修正が必要です。

Students/Index.cshtmlに列見出しリンクの追加

Views/Students/Index.cshtml のコードを次のコードに置き換え、列見出しのハイパーリンクを追加します。

<!-- 省略 -->
<table class="table">
 <thead>
  <tr>
   <th>
    <!-- 以下を追記 -->
     <a asp-action="Index" asp-route-sortOrder="@ViewData["NameSortParm"]">@Html.DisplayNameFor(model => model.LastName)</a>
   </th>
   <th>
     @Html.DisplayNameFor(model => model.FirstMidName)
   </th>
   <th>
    <!-- 以下を追記 -->
     <a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]">@Html.DisplayNameFor(model => model.EnrollmentDate)</a>
   </th>
   <th></th>
  </tr>
 </thead>
 <tbody>
 <!-- 省略 -->

第一回のチュートリアルでidパラメータを渡したように、asp-route-sortOrderとすることでタグヘルパーが以下のようなURLを生成してくれます。※現時点ではStudentsコントローラーを修正していないので、以下は生成されません。

<a href="/Students?sortOrder=name_desc

ViewData[“〇〇”]はキー(〇〇の部分)と値の組み合わせで保持されます。コントローラーとビューでのデータの受け渡しで使用できます。ViewData[“NameSortParam”]とViewData[“DateSortParam”]を参照することで、後で実装するコントローラーのViewDataから値を受け取れます。

ViewBagとViewDataの違いとは
ViewBagはViewBag.NameSortParamのようにアクセスできる動的な型のプロパティでキャストの必要がありません。
ViewDataはViewData[“NameSortParam”]のようにキーと値の組み合わせを持つDictionaryオブジェクトです。複合データの読み込み時にはキャストが必要です。
型の安全性と使い方が違うというところを抑えるといいと思います。ちなみにどちらもViewの描画後に値が破棄されるため、リダイレクトの際には保持されません。

StudentsコントローラのIndexメソッドに並び替え機能を追加

StudentsController.cs で、Index メソッドを次のコードに置き換えます。

public async Task<IActionResult> Index(string sortOrder) {
 ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
 ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";
 var students = from s in _context.Students select s;

 switch (sortOrder)
 {
   case "name_desc":
     students = students.OrderByDescending(s => s.LastName);
     break;
   case "Date":
     students = students.OrderBy(s => s.EnrollmentDate);
     break;
   case "date_desc":
     students = students.OrderByDescending(s => s.EnrollmentDate);
     break;
   default:
     students = students.OrderBy(s => s.LastName);
     break;
 }
 return View(await students.AsNoTracking().ToListAsync());
}

このコードは、先のIndex .cshtmlのタグヘルパーが生成したasp-route-sortOrderのURL 内の文字列から sortOrder パラメーターを受け取ります。sortOrderの値に応じて並び替えの分岐処理を実装しています。空文字は昇順(asc)で、descが降順で処理されます。

抑えておきたいのは以下の部分です。

 var students = from s in _context.Students select s;

これはLINQのクエリ式でStudentテーブルから全生徒データを取得するクエリです。クエリはIQuaryableオブジェクトでこの時点ではSQL文のような状態です。ToList、ToArrayなどコレクション要素を取得するメソッドが呼ばれた時点ではじめて、データとして渡されます。クエリ(SQL文のような状態)で並び替えやフィルターをしてあげることで不要なデータを参照しないため、パフォーマンスが向上します。

では、アプリを実行し、 [Students] タブを選択して、 [Last Name]  [Enrollment Date] 列見出しをクリックし、並べ替えが機能することを確認します。

 

検索機能の追加

Studentsインデックスページにフィルターを追加するには、並び替えの時と同様にStudents/Index.cshtmlとStudentsコントローラーのIndexアクションに変更を加えてあげれば良いです。

ここでは、テキスト ボックスと送信ボタンをビューに追加し、Index メソッドで対応する変更を行います。

Students/Index.cshtmlに検索フォームを追加

Views/Student/Index.cshtml に以下のコードを追加します。

<p>
 <a asp-action="Create">Create New</a> 
</p>  
<!-- 以下を追記 -->
<form asp-action="Index" method="get">
 <div class="form-actions no-color">
  <p>
   <label>Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" /></label>
   <input type="submit" value="Search" class="btn btn-default" /> |
   <a asp-action="Index">Back to Full List</a>
  </p>
 </div> 
</form>  
<!-- ここまで -->

<table class="table">

抑えておくべきポイントは、<form>で使用されているタグ ヘルパーです。asp-action=”Index”とすることでコントローラのIndexアクションにフォームを送信します。

<a><form>で使用できるアンカータグヘルパーには
asp-controller
asp-action
があります。
例えば、<form asp-controller=”Course” asp-action=”Index”></form>とすると、CourseコントローラーのIndexアクションにPOSTが送信されます。アンカータグヘルパーはよく使用するので覚えておきましょう

検索テキスト ボックスとボタンを追加します。 inputタグのvalueはデフォルト値に@ViewData[“CurrentFilter”]が設定されており、nameがSearchStringで設定されているため、フォーム送信時にパラメータとして受け取れます。この時、大文字小文字は区別されません。フォームのはmethod=”get”はHTMLのメソッド属性でGETとして送信されるように指定しています。GET送信はデータの更新が発生しないリクエスト時に推奨されています。

StudentsコントローラーのIndexメソッドにフィルター機能を追加

StudentsController.cs で、Index メソッドを次のコードに置き換えます。

public async Task<IActionResult> Index(string sortOrder, string searchString) //searchStringを追加
{
 ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
 ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";
 ViewData["CurrentFilter"] = searchString; //追加

 var students = from s in _context.Students select s;
 //以下を追加
 if (!String.IsNullOrEmpty(searchString)) 
 {
   students = students.Where(s => s.LastName.Contains(searchString)
                               || s.FirstMidName.Contains(searchString));
 } 
 //ここまで追加
 switch (sortOrder)
 {
   case "name_desc":
     students = students.OrderByDescending(s => s.LastName);
     break;
   case "Date":
     students = students.OrderBy(s => s.EnrollmentDate);
     break;
   case "date_desc":
     students = students.OrderByDescending(s => s.EnrollmentDate);
     break;
   default:
     students = students.OrderBy(s => s.LastName);
     break;
 }
 return View(await students.AsNoTracking().ToListAsync()); 
}

searchString パラメーターを Index メソッドに追加しました。 searchStringはインデックスビューに追加するテキストボックスから検索する文字列を受け取ります。文字列を受け取った場合のみ、LINQでwhere句が追加されます。

ViewData[“CurrentFilter”]は検索後、画面が再描画される際に検索で入力したキーワードが消えないようにsearchStringの値を代入しています。

アプリを実行し、 [Students] タブを選択して、検索文字列を入力し、[Search] をクリックして、フィルターの動作を確認します。

 

URL に検索文字列が含まれていると思います。

http://localhost:5813/Students?SearchString=an

method="get"  form タグに追加すると、クエリ文字列が生成されます。クエリ文字列になることでブックマークやアプリをシェアする際にフィルターされた結果で確認できるメリットがあります。

しかし、検索した後に列見出しのソートのリンクをクリックすると、 [Search] ボックスに入力したフィルター値が失われ、データも再表示されます。これをフィルターを維持したままソートできるように修正します。

Students/Index.cshtmlの列見出しリンクに検索文字列を追加

Views/Students/Index.cshtml で、以下のように修正します。

<!-- 省略 -->
<table class="table">
 <thead>
  <tr>
   <th>
    <!-- 以下1行を追記 -->
     <a asp-action="Index" asp-route-sortOrder="@ViewData["NameSortParm"]" asp-route-searchString="@ViewData["CurrentFilter"]">@Html.DisplayNameFor(model => model.LastName)</a>
   </th>
   <th>
     @Html.DisplayNameFor(model => model.FirstMidName)
   </th>
   <th>
    <!-- 以下1行を追記 -->
     <a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]" asp-route-searchString="@ViewData["CurrentFilter"]">@Html.DisplayNameFor(model => model.EnrollmentDate)</a>
   </th>
   <th></th>
  </tr>
 </thead>
 <tbody>
 <!-- 省略 -->

これでソートリンクがクリックされた時も、currentfilterの値がパラメータに渡されるようになりました。アプリを実行して、動作を確認してください。

ページング機能の追加

ここまで、フィルター機能と検索機能を追加してきました。現時点ではデータの読み込み時に一括でデータが出力されてしまいます。ここでは指定したデータ数ごとにページを分けて遷移できるようページング機能を追加していきます。この流れも基本的にはこれまでと同じく、StudentsコントローラーのIndexアクションとStudents/Index.cshtmlのそれぞれに追加するだけです。

Students/Index.cshtmlにページングのリンクを追加

Views/Students/Index.cshtml で、既存のコードを次のコードに置き換えます。

@model PaginatedList<ContosoUniversity.Models.Student><!-- 左記を追記 --> 
@{ ViewData["Title"] = "Index"; } 

<h2>Index</h2> 
<p> <a asp-action="Create">Create New</a> </p> 

<form asp-action="Index" method="get">
 <div class="form-actions no-color">
  <p>
  <label>Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" /></label>
   <input type="submit" value="Search" class="btn btn-default" /> |
   <a asp-action="Index">Back to Full List</a>
  </p>
 </div> 
</form> 

<table class="table">
 <thead>
  <tr>
   <th>
    <a asp-action="Index" asp-route-sortOrder="@ViewData["NameSortParm"]" asp-route-currentFilter="@ViewData["CurrentFilter"]">Last Name</a><!-- 左記を追記 -->
   </th>
   <th> First Name </th><!-- 左記を追記 -->
   <th>
    <a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]" asp-route-currentFilter="@ViewData["CurrentFilter"]">Enrollment Date</a><!-- 左記を追記 --> 
   </th>
   <th></th>
  </tr>
 </thead>
 <tbody>
  @foreach (var item in Model)
  {
   <tr>
    <td> @Html.DisplayFor(modelItem => item.LastName) </td>
    <td> @Html.DisplayFor(modelItem => item.FirstMidName) </td>
    <td> @Html.DisplayFor(modelItem => item.EnrollmentDate) </td>
    <td>
     <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>

 <!-- 以下を追記 --> 
@{
 var prevDisabled = !Model.HasPreviousPage ? "disabled" : "";
 var nextDisabled = !Model.HasNextPage ? "disabled" : ""; 
} 

<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.PageIndex - 1)"
   asp-route-currentFilter="@ViewData["CurrentFilter"]"
   class="btn btn-default @prevDisabled">
 Previous 
</a> 
<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.PageIndex + 1)"
   asp-route-currentFilter="@ViewData["CurrentFilter"]"
   class="btn btn-default @nextDisabled">
 Next 
</a>

最初の行の@modelディレクティブはビューが取得するオブジェクトの型を指定します。今回はPaginatedList<T>を取得するように指定しています。ビューで表示するデータを独自のViewModelに定義して、ViewModelを返すように@modelを指定する際にも同様の手順で行うため、覚えておくといいと思います。

「以下を追記」以降の行ではページングのボタンを追加しています。タグヘルパーによってリンクが生成されます。

ページングを管理するクラスを作成

プロジェクト フォルダーで、PaginatedList.cs を作成し、テンプレートのコードを次のコードに置き換えます。

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

namespace ContosoUniversity 
{
  public class PaginatedList<T> : List<T> 
  {
    public int PageIndex { get; private set; }
    public int TotalPages { get; private set; }
    public PaginatedList(List<T> items, int count, int pageIndex, int pageSize)
    {
      PageIndex = pageIndex;
      TotalPages = (int)Math.Ceiling(count / (double)pageSize);
      this.AddRange(items);
    }

    public bool HasPreviousPage => PageIndex > 1;

    public bool HasNextPage => PageIndex < TotalPages;

    public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source, int pageIndex, int pageSize) 
    {
      var count = await source.CountAsync();
      var items = await source.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
      return new PaginatedList<T>(items, count, pageIndex, pageSize);
    }
  }
}

現在のページ(PageIndex)と合計ページ数(TotalPages)、前ページの有無(HasPreviosPage)、次ページの有無(HasNextPage)をプロパティで持つクラスです。インスタンス生成の処理なので、コンストラクタで実装可能なように思いますが、非同期(async)で実装できないので、インスタンス生成メソッドとして別途作成しています。

StudentsコントローラーのIndexメソッドにページング処理を追加

StudentsController.cs で、Index メソッドを次のコードに置き換えます。


//以下を追記
public async Task<IActionResult> Index(
   string sortOrder,
   string currentFilter,
   string searchString,
   int? pageNumber)
//ここまで
{
  ViewData["CurrentSort"] = sortOrder;//左記を追記
  ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
  ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";

  //以下を追記
  if (searchString != null)
  {
   pageNumber = 1;
  }
  else
  {
   searchString = currentFilter;
  }
 //ここまで

  ViewData["CurrentFilter"] = searchString;
 
  var students = from s in _context.Students select s;

  if (!String.IsNullOrEmpty(searchString)) 
  {
   students = students.Where(s => s.LastName.Contains(searchString)
                          || s.FirstMidName.Contains(searchString));
  }
  switch (sortOrder)
  {
    case "name_desc":
       students = students.OrderByDescending(s => s.LastName);
       break;
    case "Date":
       students = students.OrderBy(s => s.EnrollmentDate); 
       break;
    case "date_desc":
       students = students.OrderByDescending(s => s.EnrollmentDate);
       break;
    default:
       students = students.OrderBy(s => s.LastName);
       break;
  }
  //以下を追記
  int pageSize = 3;
  return View(await PaginatedList<Student>.CreateAsync(students.AsNoTracking(), pageNumber ?? 1, pageSize)); 
}

最初にページが表示されるとき、またはユーザーがページングや並べ替えのリンクをクリックしていない場合、すべてのパラメーターは null になります。

CurrentSortには名前のソートもしくは登録日のソート設定が含まれ、CurrentFilterは検索文字列のフィルター設定が含まれます。これらをasp-route-〇〇でIndexアクションのパラメータに渡したり、逆にIndexアクションからViewDataで受け取ることで、ページングでの画面遷移時にも設定を保持したまま、画面を更新できます。

ではアプリを実行してみます。[Students]ページに移動します。

 

以下をテストしてください。

  • ソートした後にページングが正常に動作するか
  • 検索後にページングが機能するか
  • 検索後にソートした後、ページングが機能するか

正常に動作していれば問題ありません。

Aboutページに登録日ごとの受講者数を表示する

次は[About]ページに登録日付ごとの登録受講者数を表示する処理を作成していきます。作成の流れは以下です。

  • ViewModelクラスの作成
  • HomeコントローラーにAboutメソッドを作成
  • Aboutビューを作成します。

ViewModelクラスの作成

SchoolViewModels フォルダーを Models フォルダー内に作成します。作成したModelsフォルダー内に、EnrollmentDateGroup.cs クラスを追加します。

using System; 
using System.ComponentModel.DataAnnotations; 

namespace ContosoUniversity.Models.SchoolViewModels 
{
  public class EnrollmentDateGroup 
  {
    [DataType(DataType.Date)]
    public DateTime? EnrollmentDate { get; set; }
    public int StudentCount { get; set; }
  }
}

HomeコントローラーにAboutメソッドを作成

HomeController.cs で、ファイルの上部にusingを追加しておきます。

using Microsoft.EntityFrameworkCore; 
using ContosoUniversity.Data; 
using ContosoUniversity.Models.SchoolViewModels; 
using Microsoft.Extensions.Logging;

Homeコントローラーからもデータベースにアクセスできるよう、SchoolContextを追加しておきます。

public class HomeController : Controller {
 private readonly ILogger<HomeController> _logger;
 private readonly SchoolContext _context;   

 public HomeController(ILogger<HomeController> logger, SchoolContext context)  
 {
    _logger = logger;
    _context = context; 
 }

次のコードを含む About メソッドを追加します。

public async Task<ActionResult> About() 
{
  IQueryable<EnrollmentDateGroup> data =
       from student in _context.Students
       group student by student.EnrollmentDate into dateGroup
       select new EnrollmentDateGroup() 
       {
          EnrollmentDate = dateGroup.Key,
          StudentCount = dateGroup.Count()
       };
   return View(await data.AsNoTracking().ToListAsync()); 
}

LINQのクエリ式では登録日でグループ化した生徒エンティティの数をカウントして、登録日と登録者数の組み合わせのリスト要素として返します。

Students/About.cshtmlを更新する

表示側についても更新していきましょう。 Views/Home/About.cshtml ファイルを追加します。

@model IEnumerable<ContosoUniversity.Models.SchoolViewModels.EnrollmentDateGroup> 
@{
   ViewData["Title"] = "Student Body Statistics";
} 

<h2>Student Body Statistics</h2> 

<table>
  <tr>
    <th> Enrollment Date </th>

    <th> Students </th>
  </tr>

  @foreach (var item in Model) 
  {
    <tr>
      <td> @Html.DisplayFor(modelItem => item.EnrollmentDate) </td>
      <td> @item.StudentCount </td> 
    </tr>
  } 
</table>

アプリを実行して [About] ページに移動します。 登録の日付ごとの学生の数が、テーブルに表示されていたらOKです!

マイグレーション(移行)の使い方

実際のアプリ開発の現場ではデータベースの構造は仕様変更に合わせてどんどん変化していきます。それに合わせて、アプリ側のデータの雛形となるデータモデルも更新していかなければなりません。Entity Frameworkではモデルクラスからデータベースを自動生成するマイグレーション(移行)機能を用いて、簡単にデータベースを更新したり、ロールバックしたりできます。

マイグレーションはパッケージ マネージャー コンソール (PMC) またはコマンドプロンプト、PowerShellなどのCLIから実行できます。

では、マイグレーションを体験していきましょう。

データベースを削除する

最初にCLIを開き、プロジェクトフォルダー直下まで移動します。

Visual Studioを使用している人はプロジェクトフォルダを右クリックし、「ターミナルで開く」からプロジェクトフォルダー直下のCLIを開けます。

開けたら、以下のコマンドを1行ずつ実行してください。ここではEntity Framework Coreのツールをインストールしたのち、データベースを削除します。

dotnet tool install --global dotnet-ef 
dotnet ef database drop

続けて、データベースを作成するための元となるマイグレーションファイルを作成していきます。以下のコマンドを実行してください。

dotnet ef migrations add InitialCreate

Up/Downメソッドについて

migrations add コマンドを実行すると、Entity Frameworkはデータベースを作成するコードを生成します。 このコードは、Migrationsフォルダーに<timestamp>_InitialCreate.csという名前で生成されます。InitialCreate クラスの Up メソッドでは、migrations addの実行時点からの変更内容が、Down メソッドではUpで変更された内容をロールバックする内容が自動生成されています。

MigrationファイルのUp、Downメソッドの中身を修正することで、EntityFrameworkの更新内容を変更させることができます。

マイグレーション(移行)を実行する

ターミナルで以下のコマンドを実行します。

dotnet ef database update

SQL Server オブジェクト エクスプローラーを使用してデータベースのを調べます。 (SQLServerオブジェクトエクスプローラーが古いままの状態の場合は更新ボタンを押してください。)データベースに適用されている移行を記録する __EFMigrationsHistory テーブルにマイグレーション履歴データが追加されています。

注意点として、Migrationファイルを削除してしまった場合があります。右クリックでファイルを削除してしまうと、マイグレーション履歴データとMigrationsフォルダのMigrationファイルのデータが不一致になってしまい、マイグレーションに失敗してしまいます。

マイグレーションコマンドの紹介
データモデルからデータベースを自動生成できるマイグレーションですが、紹介したもの以外にも便利なものがあるので、合わせてご紹介しておきます。
dotnet ef migrations remove…最新のマイグレーションファイルを削除できます。・dotnet ef database update ○○…指定したマイグレーションファイルの内容にデータベースを変更できます。他にも公式サイトに詳しく載っていますので、必要に応じてご確認ください。

このチュートリアルはここまでです!次は以下のチュートリアルに進みましょう!

 

 

 

コメント

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