今回のテーマ・課題
ASP.NET Core MVCアプリケーションで、EntityFrameworkを利用して作成したデータ一覧表示画面に検索機能と並べ替え機能を追加する。
1. 作業概要上のポイント
-
ASP.NET CoreアプリケーションではRazorページアプリケーションでもMVCでも、ビューで使用する(=表示したり入力したりする)可変要素は全てページモデルのプロパティーとして割り当てる。
一覧画面の場合、既定のモデルはListのように表示対象要素のリストである。
この画面に検索条件やソート機能を追加する場合、以下のような事柄が画面UIから設定される。- どの項目を検索対象項目にするか?
- 検索条件はどのようにするか?(=検索値は何か?一致条件はどうするか?)
- どの項目をソート対象項目にするか?
- ソートは昇順か?降順か?
-
したがってページモデル側はそれらの項目をPropertyとして用意する必要がある。
即ちEntityFrameworkのスキャフォールディングが自動生成してくれた大元のモデルのリストをメンバーの一つとして新たな「専用ページモデル」を作成する必要がある。一覧表示するリストはページモデルのプロパティーの一つとなる。 -
ビュー側には検索条件+ソート条件設定用のUIを
<form><input type=XXXX>
として用意する。検索やソートはモデル側のデータを更新するわけではないので<form method="Get">
となる。
こうしておいて<form asp-action=[コントローラ名]> <div class="row"> <span class="col-md-5">氏名の検索値を入力(一部一致検索)</span> <input type="text" asp-for="Crt_Name" class="col-md-4 form-control" /> </div> ・・・
のように書いて行けば、自動的に一覧画面ビューのGet時にコールされるメソッドで何も特別なことをしないでCrt_Nameを引数に指定するだけで自動的にバインドしてくれる。
ここがASP.NET Coreの素晴らしいところ!
2. 元となるモデル(=Personal/ 個人データ)の作成とスキャフォールディング
\Models\Personal.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ANCEntry_EFMvcApp.Models
{
/// <summary>
/// 個人最小情報モデル
/// </summary>
public class Person
{
/// <summary>
/// 個人ID。ScafoldingによりDB上では自動付番項目になる。
/// </summary>
public int PersonID { get; set; }
/// <summary>
/// 名前
/// </summary>
public string FirstName { get; set; }
/// <summary>
/// 姓
/// </summary>
public string LastName { get; set; }
/// <summary>
/// メールアドレス
/// </summary>
public string EMail { get; set; }
/// <summary>
/// 年齢・・・ゆくゆくは生年月日にして年齢を計算項目にする。
/// </summary>
public int Age { get; set; }
}
}
【解説】
-
まずはこのモデルをベースにしてスキャフォールディング使用して既定のコントローラとビューを作成し、続けてとマイグレーションを使用してデータベースにPersonテーブルを作成する。(スキャフォールディングの結果作成されたPersonControllerや\View\Person\Index.cshtmlを後続の処理で修正しながらこのノートの課題をクリアしていく。
-
データベースに初期データを投入するための静的メソッドを持つSeedDataクラスを作ってProgram.csからコールさせる
\Models\SeedData.cs
using ANCEntry_EFMvcApp.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
namespace ANCEntry_EFMvcApp.Models
{
public class SeedData
{
public static void Initialize(IServiceProvider serviceProvider)
{
using (var context = new ANCEntry_EFMvcAppContext(
serviceProvider.GetRequiredService<DbContextOptions<ANCEntry_EFMvcAppContext>>()))
{
// Look for any movies.
if (context.Person.Count() > 4)
{
return; // DB has been seeded
}
context.Person.AddRange(
new Person
{
FirstName = "裕子",
LastName = "高橋",
EMail = "yukorin@gmail.com",
Age = 52
},
new Person
{
FirstName = "裕子",
LastName = "金子",
EMail = "yukorin@gmail.com",
Age = 45
},
new Person
{
FirstName = "ひさえ",
LastName = "加藤",
EMail = "hisane@gmail.com",
Age = 51
},
// こんな感じで投入データを書き加えていく。
);
context.SaveChanges();
}
}
}
}
Program.csの中でSeedData.Initalizeをコールするには以下のような書き方が一般的
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
namespace ANCEntry_EFMvcApp
{
public class Program
{
public static void Main(string[] args)
{
var host = CreateHostBuilder(args).Build();
using (var scope = host.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
ANCEntry_EFMvcApp.Models.SeedData.Initialize(services);
}
catch (Exception exp)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(exp, "An error occurred seeding the DB.");
}
}
host.Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
こうしてアプリケーションを実行すると以下のようにPersonテーブルに初期データが挿入される。
3. Indexで使用する専用ページモデルの作成
Modelsフォルダ内に新しいモデルクラスを作成し、Personをリスト化してメンバーにする。
\Models\PersonalSearchModel.cs
using ANCEntry_EFMvcApp.Common;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
namespace ANCEntry_EFMvcApp.Models
{
public class PersonSearchModel
{
/// <summary>
/// 項目名とソート方法(ASC/DSC)を繋ぐ区切り文字
/// </summary>
internal const char DLM = '$';
// public List<Person> People { get; set; }
/// <summary>
/// ビュー側に一覧表示するモデル(=Person)のリスト
/// </summary>
public List<Person> People { get; set; }
/// <summary>
/// ビュー側で選択されたソート対象項目
/// </summary>
public string SortField { get; set; }
/// <summary>
/// 名前(FirstName/ LastNameの何れか)の検索値
/// </summary>
public string Crt_Name { get; set; }
/// <summary>
/// 年齢(Age)の検索値
/// </summary>
public string Crt_Age { get; set; }
/// <summary>
/// ビューの一覧表示グリッドの項目名リンクのタグヘルパーasp-for-SortFieldに使用する関数。
/// コールされると元の引数に指定された昇順・降順を反転させて返す。
/// 元の引数に昇順・降順の指定がない場合は既定で昇順をセットして返す。
/// </summary>
/// <param name="sortFieldName">元となる項目名。</param>
/// <returns>
/// リンククリック時に当該ページをコールバックする際のクエリパラメータ「SortField」にセットする
/// 項目名+区切り文字+昇順・降順の指定を返す。
/// </returns>
public string GetSortFieldParamValue(string sortFieldName)
{
var result = sortFieldName;
string direction = "ASC";
if (!string.IsNullOrEmpty(this.SortField) &&
(this.SortField.StartsWith(sortFieldName, StringComparison.CurrentCultureIgnoreCase)))
{
string[] tokens = this.SortField.Split(DLM);
if (tokens.Length > 1)
{
var sortDirection = tokens[tokens.Length - 1];
if (sortDirection.Equals("ASC", StringComparison.CurrentCultureIgnoreCase))
{
direction = "DESC";
}
else if (sortDirection.Equals("DESC", StringComparison.CurrentCultureIgnoreCase))
{
direction = "ASC";
}
}
}
result += DLM + direction;
return result;
}
/// <summary>
/// ビューの一覧表示グリッドの項目名リンクの表示文字列の出力に使用する関数。
/// 当該クラスのソート項目が引数と一致する場合、現在の昇順・降順の指定とは逆方向の
/// アイコンを項目名の右隣にセットして返す。
/// </summary>
/// <param name="sortFieldName">ソート元となる項目名。</param>
/// <returns>
/// 当該クラスのソート項目が引数と一致する場合、現在の昇順・降順の指定とは逆方向の
/// アイコンを項目名の右隣にセットした値。
/// 一致しない場合は単に引数の項目名のみを返す。
/// </returns>
public string GetSortFieldDisplayName(string sortFieldName)
{
var result = sortFieldName;
if (!string.IsNullOrEmpty(this.SortField) &&
(this.SortField.StartsWith(sortFieldName, StringComparison.CurrentCultureIgnoreCase)))
{
string direction = "▲";
string[] tokens = this.SortField.Split(DLM);
if (tokens.Length > 1)
{
var sortDirection = tokens[tokens.Length - 1];
if (sortDirection.Equals("ASC", StringComparison.CurrentCultureIgnoreCase))
{
direction = "▲";
}
else if (sortDirection.Equals("DESC", StringComparison.CurrentCultureIgnoreCase))
{
direction = "▼";
}
}
result += direction;
}
return result;
}
}
}
【解説】
-
一覧表示する項目はList Peopleのみである。それ以外に以下のメンバーを有する。
- 検索/並べ替え用のプロパティー(クエリパラメータとして引き渡されるため何れも文字列)
- 名前(FirstName/ LastName共通)の検索値
- 年齢の検索値
- ソート条件(項目名と昇順・降順の指定を所定の区切り文字で繋ぐ)
- 一覧表示グリッド項目欄にソート条件/ソート指定を行うためのメソッド
- 検索/並べ替え用のプロパティー(クエリパラメータとして引き渡されるため何れも文字列)
-
ソート条件用のメソッドは画面上の項目名リンククリックで昇順・降順が反転するよにしており、項目表示メソッドは現在の並べ替え状態ではなく、次のクリックで並べ替えられる方向を示す。
4. ビューの修正
Index.cshtmlを修正して検索条件入力エリアを設け、また一覧表示テーブルのタイトル行の項目名をソート指定用のリンク(<a>)に修正する。
\Views\People\Index.cshtml
@model ANCEntry_EFMvcApp.Models.PersonSearchModel
@{
ViewData["Title"] = "個人情報一覧";
}
<h1>個人情報一覧</h1>
@* 検索条件入力エリア *@
<form asp-controller="People" asp-action="Index" method="get">
<div class="row">
<span class="col-md-5">氏名の検索値を入力(一部一致検索)</span>
<input type="text" asp-for="Crt_Name" class="col-md-4 form-control" />
</div>
<div class="row">
<span class="col-md-5">年齢を入力</span>
<input type="text" asp-for="Crt_Age" class="col-md-4 form-control" />
<input type="submit" value="検索実行" class="col-2 btn btn-primary" />
</div>
</form>
<table class="table">
<thead>
@* ヘッダー部の項目名にはソート条件式及び検索条件式をGet命令で引き渡す用意をさせている。 *@
<tr>
<th>
@* リンクのアクションにはコントローラメソッド(=GET用)を指定して、その下に引き渡すクエリパラメータを列挙する。
ここではModel側に用意したプロパティーやメソッドをフル活用する。 *@
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].PersonID))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].PersonID))
</a>
</th>
<th>
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].FirstName))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].FirstName))
</a>
</th>
<th>
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].LastName))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].LastName))
</a>
</th>
<th>
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].EMail))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].EMail))
</a>
</th>
<th>
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].Age))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].Age))
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.People)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.PersonID)
</td>
<td>
@Html.DisplayFor(modelItem => item.FirstName)
</td>
<td>
@Html.DisplayFor(modelItem => item.LastName)
</td>
<td>
@Html.DisplayFor(modelItem => item.EMail)
</td>
<td>
@Html.DisplayFor(modelItem => item.Age)
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.PersonID">Edit</a> |
<a asp-action="Details" asp-route-id="@item.PersonID">Details</a> |
<a asp-action="Delete" asp-route-id="@item.PersonID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
【解説】
-
一覧表示画面において検索条件・ソート条件を入力して実行する箇所は
<form>
であっても<a>
であっても必ず以下のタグヘルパーを付ける。- asp-controller=[コントローラ名(この場合People)]
- asp-action=[メソッド名(=View名、この場合Index)]
-
<form>
の場合はaction="get"を明示する。 - これにより一覧検索画面は大抵の場合GET命令一つで足りる。
-
即ち自分自身にGET命令で戻すため、検索条件+ソート条件としてクエリパラメータを引き渡さないといけない。その値もタグに応じて以下のようにタグヘルパーで指定する。
-
<form>
内の<input>
のasp-forでページが受け付けるパラメータ名を指定。(大抵検索条件の値) -
<a>
であれば、asp-forでページがページが受け付けるパラメータ名を指定。(大抵ソート条件の値)
-
-
検索条件/ソート条件を受け付けるページでは、大抵の場合それらを全て複数パラメータとして引き受けるため、
<a>
タグで自身をコールバックする場合には往々にして一つのタグ内に複数のタグヘルパーを書く必要が生じる。 -
asp-controllerとasp-actionを使用できるのがMVCアプリケーションの強み!
5. 一覧ページアクションの修正
コントローラのIndexアクションを検索条件とソート条件をパラメータとして受け取るように修正し、実際の検索とソート処理を実装する。また検索条件によるデータ絞り込みにはトライアル的にSQLの直接実行を組み込む。
\Controllers\PeopleController
#define USE_RAWSQL
using ANCEntry_EFMvcApp.Common;
using ANCEntry_EFMvcApp.Data;
using ANCEntry_EFMvcApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
// ★非常に重要 System.Data.SqlClientを使わないこと!
// ★特にSystem.Data.SqlClient.SqlParameterはEFのFromSqlRawでは使えない!
namespace ANCEntry_EFMvcApp.Controllers
{
/// <summary>
/// このクラス名によりアプリケーションからは/People/{メソッド名}にてPersonモデルに対する
/// CRUD処理の呼び出しが可能。
/// </summary>
public class PeopleController : Controller
{
private const int PageSize = 5;
private readonly ANCEntry_EFMvcAppContext _context;
public PeopleController(ANCEntry_EFMvcAppContext context)
{
_context = context;
}
// ・・・・(途中省略)・・・・
/// <summary>
/// 検索ソート機能付き一覧画面表示用メソッド。
/// 2項目用の検索値とソート条件指定を受け取る。
/// </summary>
/// <param name="crt_name">名前(FirstName/LastName共通)の検索値。一致条件は一部一致とする。</param>
/// <param name="crt_age">年齢の検索条件。一致条件は前後5歳の幅を持たせる。</param>
/// <param name="sortfield">ソート条件。項目名に所定の区切り文字で昇順・逆順指定を加える。</param>
/// <returns>引数の検索+ソート条件で抽出されたレコードのリスト。更にLINQ式により変換が可能</returns>
[HttpGet]
public async Task<IActionResult> Index(string crt_name, string crt_age,
string sortfield)
{
IQueryable<Person> selected;
#if USE_RAWSQL
// こっちが有効、生のSQLが引き渡される。
string strSQL = "SELECT * FROM dbo.Person";
string strWhere = string.Empty;
var param_name = new SqlParameter("@param_name", string.Empty);
var param_age = new SqlParameter("@param_age", 0);
param_age.Value = 0;
if (!string.IsNullOrEmpty(crt_name))
{
param_name.Value = "%" + crt_name + "%";
strWhere = "( FirstName LIKE @param_name ) OR ( LastName LIKE @param_name )";
}
if (!string.IsNullOrEmpty(crt_age))
{
var int_age = 0;
if (Int32.TryParse(crt_age, out int_age))
{
param_age.Value = int_age;
strWhere = (!string.IsNullOrEmpty(strWhere) ? string.Format("({0}) AND", strWhere) : string.Empty)
+ "(( Age >= @param_age - 5 ) AND ( Age <= @param_age + 5 ))";
}
}
if (!string.IsNullOrEmpty(strWhere))
{
strSQL = strSQL += Environment.NewLine + "WHERE " + strWhere;
}
// ★ショックなことにSQLでOrder byを書くとPaginatingListによるPagingが効かなくなる!
//bool desc = false;
//bool.TryParse(sortdesc, out desc);
//if (!string.IsNullOrEmpty(sortfield))
//{
// strSQL += Environment.NewLine +
// "ORDER BY " + sortfield + (desc ? " DESC" : string.Empty);
//}
selected = _context.Person.FromSqlRaw(strSQL, param_name, param_age);
#else
// SQLを使わずに全てLINQで書く場合。
selected = from p in _context.Person select p;
if (!string.IsNullOrEmpty(crt_name))
{
selected = selected.Where(model =>
(
(model.LastName.ToLower().Contains(crt_name.ToLower())) ||
(model.FirstName.ToLower().Contains(crt_name.ToLower()))
));
}
if (!string.IsNullOrEmpty(crt_age))
{
int age = 0;
if (Int32.TryParse(crt_age, out age))
{
selected =selected.Where(model =>
((model.Age >= age - 5) && (model.Age <= age + 5)));
}
}
#endif
// ★ショックなことにSQLでOrder byを使うとページング機能が使えないため
// 下のようにグダグダ書かないといけない。
bool desc = false;
string sortfiledBody = string.Empty;
if (!string.IsNullOrEmpty(sortfield))
{
string[] tokens = sortfield.Split(PersonFind2Model.DLM);
if (tokens.Length > 1)
{
var sortDirection = tokens[tokens.Length - 1];
if (sortDirection.Equals("DESC", StringComparison.CurrentCultureIgnoreCase))
{
desc = true;
}
for(int i = 0; i < tokens.Length - 1; i++)
{
sortfiledBody += tokens[i] + PersonFind2Model.DLM;
}
sortfiledBody = sortfiledBody.Substring(0, sortfiledBody.Length - 1);
}
else
{
sortfiledBody = sortfield;
}
// ★OrderByやOrderByDescendingが受けるのはFunc<T>ではなくて
// Linq.Expression.Expression<Func<T>>である。
// これは構文解析したFunc<T>なので、Func<T>を先に定義してから
// その後で逆アセンブリすることはかなり難しい模様なのでいったんここで終了。
switch (sortfiledBody.ToLower())
{
case "personid":
if (desc)
{
selected = selected.OrderByDescending(m => m.PersonID);
}
else
{
selected = selected.OrderBy(m => m.PersonID);
}
break;
case "firstname":
if (desc)
{
selected = selected.OrderByDescending(m => m.FirstName);
}
else
{
selected = selected.OrderBy(m => m.FirstName);
}
break;
case "lastname":
if (desc)
{
selected = selected.OrderByDescending(m => m.LastName);
}
else
{
selected = selected.OrderBy(m => m.LastName);
}
break;
case "email":
if (desc)
{
selected = selected.OrderByDescending(m => m.EMail);
}
else
{
selected = selected.OrderBy(m => m.EMail);
}
break;
case "age":
if (desc)
{
selected = selected.OrderByDescending(m => m.Age);
}
else
{
selected = selected.OrderBy(m => m.Age);
}
break;
}
}
else
{
if (desc)
{
selected = selected.OrderByDescending(m => m.PersonID);
}
else
{
selected = selected.OrderBy(m => m.PersonID);
}
}
var peopleList = await selected.ToListAsync();
var myModel = new PersonSearchModel
{
People = peopleList,
Crt_Name = crt_name,
Crt_Age = crt_age,
SortField = sortfield,
};
return View(myModel);
}
}
【解説】
-
ここではスキャフォールディングによるお任せLINQ式を使うのではなく可能な限りSQLを直書きする方法を紹介する。
-
SQLを直書きする際には当該コントローラの処理対象メインモデル(この場合Person)のFromSqlRaw拡張メソッドを使用する。
- FromRawSqlはMicrosoft.EntityFrameworkCore.RelationalQueryableExtensionsクラスで拡張メソッドとして定義されている。
-
FromSqlRawにパラメータを引き渡す場合にはSystem.Data.SqlClientではなく、Microsoft.Data.SqlClientを使用すること。
-
FromSqlRawの戻り値は**IQueryable<TEntity>**である。従ってこの戻り値に対してLINQ式を追加して絞り込み条件やソート条件の追加を行うことが出来る。
-
ソート条件をFromRawSqlと"Order By"句を使用して実行すると、次節で説明する一覧表示グリッドのページングが実行できない。(=例外が返される。)
従ってソート条件はソースコードにある通り少々冗長になるがLINQに任せるしかない。
-
-
IndexメソッドはGET要求しか受け取らないように
[HttpGet]
属性で修飾する。-
これにより自動的にメソッド引数であるcrt_name、crt_age、sortfieldはクエリパラメータとして受け取る。
-
ビュー(.cshtml)側で検索条件やソート条件を指定する入力・リンクタグはタグヘルパー「asp-for-XXXX」でバインドするパラメータを指定するだけで、内部実装無しでこのメソッドに自動的に渡ってくる。
● 注意するのはasp-for-XXXXのパラメータ名部分のスペルミスのみ。
-
-
予告編として、次節で一覧表示対象となるpepleListをページングする方法を紹介する。
6. 表示結果
■ 参考サイト
ASP.NET Core MVC アプリへの検索の追加
(https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/first-mvc-app/search?view=aspnetcore-3.0)
ASP.NET Core の Razor Pages と EF Core - 並べ替え、フィルター、ページング - 3/8
(https://docs.microsoft.com/ja-jp/aspnet/core/data/ef-rp/sort-filter-page?view=aspnetcore-3.0)