WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

ページャー付き一覧画面から編集後同じページに戻る (MVC5)

by WebSurfer 2022年11月7日 14:19

ASP.NET MVC アプリのプロジェクトで、スキャフォールディング機能を使うと、DB の CRUD 操作を行う Controller と View のコードを一式自動生成することができます。

そのレコード一覧の表示のページにページング機能を実装し、例えばページ 5 を表示してから Edit リンクをクリックして編集ページに遷移し、DB の UPDATE 完了で元の一覧ページにリダイレクトされる際、同じページ 5 が表示されるようにする機能を実装してみました。以下に要点を備忘録として残しておきます。

ページャー付き Index

この記事で紹介するのは Visual Studio 2022 のテンプレートで、ターゲットフレームワーク .NET Framework 4.8 として作成した ASP.NET MVC5 アプリです。ASP.NET Core MVC アプリでも同様なことは可能です。

まず、ページング機能ですが、Microsoft のドキュメント「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」で紹介されている PaginatedList.cs を利用して実装します。そのコードはこの記事にも載せておきます。

一覧ページにページングを実装する場合、アクションメソッドで当該ページ部分のレコードを含む PaginatedList<T> のオブジェクトを作成し、それを View に Model として渡すようにします。

PaginatedList<T> オブジェクトからは PageIndex プロパティで現在のページ番号を取得できます。そのページ番号を、「一覧ページ」⇒「編集ページ」⇒「一覧ページ」と遷移していく際に渡していくことで、最初と最後の「一覧ページ」が同じページになるようにしました。その手順は概略以下の通りです。

  1. 一覧ページから編集ページに遷移するには、上の画像にある[Edit]リンクボタンをクリックして編集ページのアクションメソッドを GET 要求しますが、その際、クエリ文字列で現在のページ番号を渡せるようにします。ページ番号は Model.PageIndex で取得できるので、@Html.ActionLink のパラメータに pageIndex = Model.PageIndex を追加すれば OK です。
  2. 編集ページのアクションメソッドでクエリ文字列からページ番号を取得し、ViewBag を使って編集ページの View にページ番号を渡します。
  3. 編集ページの View の form タグ内に隠しフィールドを追加し、それに ViewBag で受け取ったページ番号を保存します。
  4. 編集ページでユーザーが編集を完了し[Save]ボタンをクリックすると [HttpPost] 属性が付与された方の編集ページのアクションメソッドが呼び出されます。その際、隠しフィールドに保存されたページ番号も一緒に送信されてきます。
  5. 編集ページのアクションメソッドで DB の UPDATE が完了すると一覧ページにリダイレクトされるので、クエリ文字列でページ番号を渡せるように、RedirectToAction メソッドの第 2 引数に new { pageNumber = pageIndex } というようにパラメータを追加します。
  6. 一覧ページがリダイレクトにより GET 要求されますが、クエリ文字列でページ番号が指定されるので、指定されたページを表示します。

以下にこの記事の検証に使ったサンプルコードを載せておきます。

コンテキストクラス、エンティティクラス

Microsoft のサンプルデータベース Northwind から Visual Studio の ADO.NET Entity Data Model ウィザードを使って作成した Entity Data Model に含まれるものを使いました。参考までに自動生成されたダイアグラムの Products, Categories, Supliers テーブル部分の画像を下に貼っておきます。

Entity Data Model

PaginatedList.cs

上に紹介した Microsoft のチュートリアルのコードと同じですが、少しコメントを加えて以下にアップしておきます。

このクラスがページングの中核を担うもので、コントローラーが生成した IQueryable<Product> オブジェクトを CreateAsync メソッドで受け取って、Skip, Take メソッドでページに表示する部分のみをデータベースから取得し、PaginatedList<Product> オブジェクトを生成して戻り値として返すようにしています。

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Web;

namespace Mvc5App2.Models
{
    public class PaginatedList<T> : List<T>
    {
        // 表示するページのページ番号
        public int PageIndex { get; private set; }

        // 全ページ数
        public int TotalPages { get; private set; }

        // コンストラクタ。下の CreateAsync メソッドから呼ばれる
        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
        {
            get
            {
                return (PageIndex > 1);
            }
        }

        // 表示するページの後にページがあるか?
        public bool HasNextPage
        {
            get
            {
                return (PageIndex < TotalPages);
            }
        }

        // 下の静的メソッドがコントローラーから呼ばれて戻り値がモデルとして
        // ビューに渡される。引数の pageSize は 1 ページに表示するレコード
        // 数でコントローラーから渡される
        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);
        }
    }
}

Controller / Action Method

以下のコードのアクションメソッドは一覧ページ用の Pager と編集ページ用の EditPaging のみで他は省略していますので注意してください。

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.Models;

namespace Mvc5App2.Controllers
{
    public class ProductsController : Controller
    {
        private NORTHWINDEntities db = new NORTHWINDEntities();

        // 引数の pageNumber が表示するページ
        public async Task<ActionResult> Pager(int? pageNumber)
        {
            // IDENTITY で主キーの ProductID 順に並べる
            var products = db.Products
                           .Include(p => p.Categories)
                           .OrderBy(p => p.ProductID);

            // 1 ページに表示するレコード数を指定
            int pageSize = 5;

            // CreateAsync メソッドで pageNumber に指定されるページの
            // レコードのリストを取得
            return View(await PaginatedList<Products>
                              .CreateAsync(products.AsNoTracking(),
                                           pageNumber ?? 1,
                                           pageSize));
        }

        // クエリ文字列で渡されるページ番号を取得するため引数に
        // pageIndex を追加
        public async Task<ActionResult> EditPaging(int? id, int? pageIndex)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            Products products = await db.Products.FindAsync(id);
            if (products == null)
            {
                return HttpNotFound();
            }

            // View に現在のページ番号を渡す
            ViewBag.PageIndex = pageIndex ?? 1;

            ViewBag.CategoryID = new SelectList(db.Categories, 
                                                "CategoryID",
                                                "CategoryName",
                                                products.CategoryID);
            ViewBag.SupplierID = new SelectList(db.Suppliers,
                                                "SupplierID",
                                                "CompanyName",
                                                products.SupplierID);
            return View(products);
        }

        // クエリ文字列で渡されるページ番号を取得するため引数に
        // pageIndex を追加
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> EditPaging(
            [Bind(Include = "ProductID,ProductName,SupplierID,CategoryID," +
            "QuantityPerUnit,UnitPrice,UnitsInStock,UnitsOnOrder," +
            "ReorderLevel,Discontinued")] Products products,
            int pageIndex)
        {
            if (ModelState.IsValid)
            {
                db.Entry(products).State = EntityState.Modified;
                await db.SaveChangesAsync();

                // リダイレクトの際クエリ文字列でページ番号を渡せるよう第 2 引数
                // に new { pageNumber = pageIndex } を追加
                return RedirectToAction("Pager",
                                        new { pageNumber = pageIndex });
            }
            ViewBag.CategoryID = new SelectList(db.Categories,
                                                "CategoryID",
                                                "CategoryName",
                                                products.CategoryID);
            ViewBag.SupplierID = new SelectList(db.Suppliers,
                                                "SupplierID",
                                                "CompanyName",
                                                products.SupplierID);
            return View(products);
        }

        // ・・・中略・・・

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

一覧ページ用の View

上に書いたように、[Edit]リンクボタン用の @Html.ActionLink のパラメータに pageIndex = Model.PageIndex を追加しているところに注目してください。table 要素の下にページャーも実装しています。

@model Mvc5App2.Models.PaginatedList<Products>

@{
    ViewBag.Title = "Pager";
}

<h2>Pager</h2>

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

<table class="table">
    <thead>
        <tr>
            <th>
                Id
            </th>
            <th>
                Product Name
            </th>
            <th>
                Category
            </th>
            <th>
                Unit Price
            </th>
            <th>
                Discontinued
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.ProductID)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.ProductName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Categories.CategoryName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.UnitPrice)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Discontinued)
                </td>

                <td>
                    @Html.ActionLink("Edit", "EditPaging", 
                        new { id = item.ProductID, 
                              pageIndex = Model.PageIndex }) |
                    @Html.ActionLink("Details", "Details", 
                        new { id = item.ProductID }) |
                    @Html.ActionLink("Delete", "Delete", 
                        new { id = item.ProductID })
                </td>
            </tr>
        }
    </tbody>
</table>

@*Pagination
    https://getbootstrap.jp/docs/4.2/components/pagination/*@

@{
    // ページャーの First Prev 1 2 3 ... n Next Last の 1 ~ n のボタン数
    // n は奇数にしてください
    int buttonCount = 7;
}

<span>Page @Model.PageIndex of @Model.TotalPages</span>

<br />

<nav aria-label="Page navigation">
    <ul class="pagination">
        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                @Html.ActionLink("First", "Pager", 
                    new { pageNumber = 1 }, 
                    new { @class = "page-link" })
            </li>
        }

        @if (Model.HasPreviousPage)
        {
            <li class="page-item">
                @Html.ActionLink("Prev", "Pager", 
                    new { pageNumber = (Model.PageIndex - 1) },
                    new { @class = "page-link" })
            </li>
        }

        @{
            int startPage;
            int stopPage;

            if (Model.TotalPages > buttonCount)
            {
                if (Model.PageIndex <= buttonCount / 2 + 1)
                {
                    startPage = 1;
                    stopPage = buttonCount;
                }
                else if (Model.PageIndex < (Model.TotalPages - buttonCount / 2))
                {
                    startPage = Model.PageIndex - buttonCount / 2;
                    stopPage = Model.PageIndex + buttonCount / 2;
                }
                else
                {
                    startPage = Model.TotalPages - buttonCount + 1;
                    stopPage = Model.TotalPages;
                }
            }
            else
            {
                startPage = 1;
                stopPage = Model.TotalPages;
            }

            for (int i = startPage; i <= stopPage; i++)
            {
                if (Model.PageIndex == i)
                {
                    <li class="page-item active">
                        <span class="page-link">@i</span>
                    </li>
                }
                else
                {
                    <li class="page-item">
                        @Html.ActionLink(i.ToString(), "Pager", 
                            new { pageNumber = i },
                            new { @class = "page-link" })
                    </li>
                }
            }
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                @Html.ActionLink("Next", "Pager", 
                    new { pageNumber = (Model.PageIndex + 1) },
                    new { @class = "page-link" })
            </li>
        }

        @if (Model.HasNextPage)
        {
            <li class="page-item">
                @Html.ActionLink("Last", "Pager",
                    new { pageNumber = (Model.TotalPages) },
                    new { @class = "page-link" })
            </li>
        }
    </ul>
</nav>

編集ページ用の View

以下の画像の赤枠で囲ったコードを追加した以外はスキャフォールディングで生成されるコードと同じです。前者の赤枠部分は隠しフィールドにページ番号を保存するためのもの、後者は編集を途中で止めて一覧ページに戻る際、同じページに戻るためのものです。

編集ページ用の View への追加コード

Tags: , , ,

Paging

MVC アプリでのデータの編集・更新

by WebSurfer 2016年9月18日 13:48

ASP.NET MVC アプリケーションで Entity Framework を利用してデータベースの編集・更新を行う方法について調べる機会があったのですが、その過程でいろいろ新発見があったので備忘録として残しておきます。

(1) EntityState.Modified の設定

データベースを更新 (UPDATE) するには、SaveChanges メソッドをコンテキストに適用する前に、当該エンティティの State を EntityState.Modified(以下、Modified と書きます)に設定する必要があります。

その方法には、自分が知る限りですが、以下の 2 つがあります:

(1-1) コードで明示的に設定

編集したオブジェクトをコンテキストにアタッチし、そのエンティティの State を Modified に設定します。

具体的な方法は、Microsoft の記事「Working with entity states」の Attaching an existing but modified entity to the context のセクションの説明とサンプルコードが参考になると思いますので、そちらを見てください。

なお、上に紹介した記事のコードは DbContext クラスがベースのコンテキストの場合ですので注意してください。(EF6 Code First の場合は DbContext クラスが使われます)

VS2010 の EF4 を使って DB First で作った EDM などには、DbContext クラスではなくて ObjectContext クラスが使われますが、それには Entry メソッドは定義されておらず、上に紹介した記事のコードのようにはできないので注意してください。具体的には以下のようにします。

[HttpPost]
public ActionResult Edit(Address address)
{
  if (ModelState.IsValid)
  {
    db.Address.Attach(address);
    db.ObjectStateManager.
      ChangeObjectState(address, EntityState.Modified);
    db.SaveChanges();
    return RedirectToAction("Index");
  }
  return View(address);
}

上のコードは、Microsoft が提供するサンプルデータベース AdventureWorksLT の Address テーブルから、VS2010 + EF4 を使って DB First で EDM を作成し、それをベースにスキャフォールディング機能を使って自動生成させたものです。

(1-2) エンティティのプロパティを書換

コンテキストから更新するエンティティを取得し、そのプロパティを使って内容を書き換えると、前の値から変更された場合は自動的にそのエンティティに Modified マークが付けられます。前の値と同じ値を設定した場合は Unchanged のままとなります。

言葉だけでは分かりにくいと思いますので、以下にコードを書いて説明します。

先の記事「EF でレコードの削除」で紹介した通りに Code First でデータベースを生成したとします。

Code First で生成した Blogs テーブルと Posts テーブルに、以下のコードでデータを INSERT します。

// DB に以下のデータを INSERT
Blog b = new Blog { Name = "プログラミング" };
b.Posts = new List<Post>();
b.Posts.Add(new Post { Title = "ASP", Content = "作り方" });
b.Posts.Add(new Post { Title = "WCF", Content = "書き方" });
b.Posts.Add(new Post { Title = "WPF", Content = "内容" });
db.Blogs.Add(b);
db.SaveChanges();

それを以下のようなコードで各エンティティのプロパティを使用して書き換えるとします。

// DB からエンティティを取得し、一部のプロパティを書換
BloggingContext db = new BloggingContext();
Blog b = db.Blogs.Single(i => i.BlogId == 1);
b.Name = "プログラミング";     // 変更なし
b.Posts[0].Title = "MVC";      // ASP ⇒ MVC
b.Posts[0].Content = "訂正";   // 作り方 ⇒ 訂正
b.Posts[1].Title = "NET";      // WCF ⇒ NET
b.Posts[1].Content = "書き方"; // 変更なし
b.Posts[2].Title = "WPF";      // 変更なし
b.Posts[2].Content = "内容";   // 変更なし

// 各エンティティの State を調べると:
EntityState state = db.Entry(b).State;  // Unchanged
foreach (Post p in b.Posts)
{
    state = db.Entry(p).State;
    // 順に、Modified, Modified, Unchanged
}

各エンティティの State を調べると、全てのプロパティに現在の値と同じ値を設定した場合は(即ち変更しない場合は)そのエンティティの State は Unchanged のままになり、一つでもプロパティが前の値から変更された場合はエンティティの State が自動的に Modified に変更されます。

注:Visual Studio 2010 に標準で備わっている EF4 を使って DB First で EDM を生成した場合はプロパティに現在の値と同じ値を設定しても Modified に変わるので注意してください。上の話は NuGet で EF6 を適用し、Code First でデータベースを生成した場合の話です。(EF4 / EF6 のバージョンの差によるものか、ObjectContext / DbContext のコンテキストのベースが違うためかは分かりません)


(2) MVC アプリで Modified マークをつける方法

エンティティに Modified マークをつけてから、そのエンティティをトラックしているコンテキストに SaveChanges メソッドを適用すればデータベースは更新 (UPDATE) されます。

ASP.NET MVC アプリでエンティティに Modified マークをつけるには、編集画面から送信されてきたユーザー入力情報を利用することになりますが、基本的には以下のような方法があると思います。

(2-1) UpdateModel メソッドを使う

Controller.UpdateModel メソッドは、フォーム、クエリ文字列、ルート、クッキーなどに含まれるクライアントから送信されてきたデータから、このメソッドの引数に設定されたエンティティのプロパティ名と一致するものを探して、それでエンティティの内容を書き換えます。

書き換えられると、上の (1-2) で書いたように、State が自動的に Unchanged から Modified に変わりますので、SaveChanges メソッドでデーターベースが更新 (UPDATE) されます。

UpdateModel メソッドを使う場合の問題は、フォーム、クエリ文字列、ルート、クッキーなどを使って送信されてきたデータのどれを使ってエンティティを書き換えているのかよく見えないところにあると思います。UpdateModel には多数のオーバーロードがあり、送信されたパラメータを指定 / 除外するためのオプションがあって、それらを使えばある程度コントロールできるとは思いますが・・・

そのあたりを配慮するとしても、先の記事「親子関係のあるデータの編集・削除」で書いたようなケースが問題です。

その記事に書いてありますが、UpdateModel メソッドを使うと、SaveChanges で子テーブルの関連するレコードの外部キーフィールドを NULL に書き換え、ポストされたデータでレコードを新たに作り INSERT するという動きになります。外部キーが NULL 不可に設定されている場合は特に問題で、当然制約違反でエラーになってしまいます。

これには、自分が考えた限りですが、対応不可でした。(何故そういう結果になるのか、メカニズムが解明できていません)

そもそも、普通はモデルバインディング+データアノテーション検証の機能を利用している場合がほとんどでしょうから、そういうケースでは UpdateModel メソッドは忘れてもよさそうです。

モデルバインディング+データアノテーション検証の機能を利用すると、アクションメソッドのパラメータ(引数)が指すオブジェクトにクライアントから送信されてきたデータがバインドされると同時に、サーバー側で検証が行われます。

コントローラーで ModelState.IsValid が ture であればアクションメソッドの引数が指すオブジェクトにバインドされたデータは検証結果 OK ということになります。

従って、モデルバインディング+データアノテーション検証の機能を利用しているのであれば、アクションメソッドの引数を使った方が簡単&確実だと思います。その方法は下の (2-2), (2-3) を見てください。

(2-2) バインディング結果をアタッチして Modified マーク

モデルバインディング+データアノテーション検証の機能を利用している場合、アクションメソッドの引数が指すオブジェクトに更新後のデータはバインドされており、コントローラーで ModelState.IsValid が ture であればデータは検証結果 OK ということになります。

従って、アクションメソッドの引数が指すオブジェクトを、上の (1-1) で述べた方法でコンテキストにアタッチし、そのエンティティの State を Modified に設定してやることで目的が果たせます。

UpdateModel メソッドでは (2-1) に書いた問題があった親子関係のあるデータ(子はコレクション)の場合も、親子を別々にコンテキストにアタッチし、State を Modified に設定してやれば問題は解決できます。(具体例は、先の記事「親子関係のあるデータの編集・削除」の「Edit アクションメソッド」のコードを見てください)

(2-3) バインドされたデータでエンティティを書き換え

上の (2-2) の方法では無条件に State が Midified に設定されますので、ユーザーがデータを変更していなくても SaveChanges でデータベースに UPDATE がかかってしまいます。

先の記事「親子関係のあるデータの編集・削除」ではそのようにコーディングしましたが、子のデータのほんの一部のみ更新するケースも多々あるでしょうから考え直した方がよさそうです。

また、本来更新は不要でユーザーが送信する必要のないデータも送信する必要があるという面倒なこともあります。特に外部キーのデータは送信しないと State を Midified に設定する際参照整合性制約違反でエラーとなります。

上の (1-2) で書きましたが、コンテキストから更新するエンティティを取得し、そのプロパティを使って内容を書き換えると、前の値から変更された場合のみ Modified マークが付けられ、前の値と同じ値を設定した場合は Unchanged のままとなります。

アクションメソッドの引数が指すオブジェクトにモデルバインドされたデータがあるのですから、自力でコードを書いて更新する項目のみそれらのデータをエンティティに反映させた方がよさそうです。すべてを自分のコントロール下で設定できるので安心・安全ということもあるかもしれません。

上記のことを考慮して、先の記事「親子関係のあるデータの編集・削除」を書き直すと、以下のようになるでしょうか。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Data.Entity;
using Mvc4App3.Models;

namespace Mvc4App3.Controllers
{
  public class ParentChildController : Controller
  {
    private ParentChildContext db = new ParentChildContext();
        
    // ・・・中略・・・

    // GET: /ParentChild/Edit/5
    public ActionResult Edit(int id)
    {
      Parent parent = db.Parents.Find(id);
      if (parent == null)
      {
        return HttpNotFound();
      }
      return View(parent);
    }

    // POST: /ParentChild/Edit/5
    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Edit(Parent postedParent)
    {
      if (ModelState.IsValid)
      {
        // 編集画面から送信された親の Id で、コンテキストから
        // 該当する親エンティティを取得
        Parent parent = db.Parents.Find(postedParent.Id);

        if (parent == null)
        {
          // 見つからなかったときの処理
        }

        // 編集画面から送信されたデータで、上で取得した親エン
        // ティティの中身を書き換え
        parent.Name = postedParent.Name;

        foreach (Child postedChild in postedParent.Children)
        {
          // 編集画面から送信された子の Id で、コンテキスト
          // から該当する子エンティティを取得
          Child child = db.Children.Find(postedChild.Id);
                    
          if (child == null)
          {
            // 見つからなかったときの処理
          }
                    
          // 編集画面から送信されたデータで、上で取得した
          // 子エンティティの中身を書き換え
          child.Name = postedChild.Name;
        }

        db.SaveChanges();

        return RedirectToAction("Index");
      }
      return View(postedParent);
    }

    // ・・・中略・・・

  }
}

上のコードのコメントの「見つからなかったときの処理」ですが、編集画面を表示する際に取得した Id を隠しフィールドで保持しており、更新するときそれをそのまま POST しているので、普通なら見つからないということはあり得ません。見つからないとすれば、編集中に誰かが DB の当該レコードを削除してしまったとか、不正なデータが送信されたなどの異常事態と考えた方がよいかもしれません。

Tags: ,

MVC

親子関係のあるデータの編集・削除

by WebSurfer 2014年12月22日 12:08

先に、(1) Entity Framework Code First の機能を利用して MVC4 アプリケーション用 SQL Server DB のテーブル作成、(2) Create アクションメソッドとビューを追加して作成したテーブルに親子関係のあるデータを登録する方法・・・という記事を書きました。

今回はそれに続いて Edit, Delete アクションメソッドとビューを追加し、先に追加した Create アクションメソッドで登録したデータの編集・削除を行う方法を書きます。(下の画像は Delete 操作のときのものです)

データの削除画面

先の記事 MVC4 EF Code First で書きましたように、Code First の機能を用いて生成した SQL Server DB の Parents, Children テーブルの間に外部キー制約が設定されています(Parents の Id ← Children の Parent_Id)。

なので、階層更新が必要になります。つまり、登録する場合は Parents テーブルに親レコードを INSERT した後 Children テーブルに子レコードを INSERT する、削除する場合は先に Children テーブルの関連する子レコードを全部 DELETE してから Parents テーブルの親レコードを DELETE するという操作が必要になります。

そのために Entity Framework 上で必要な操作としては、対象となるレコードの エンティティオブジェクトの状態 を、登録なら Added、編集なら Modified、削除なら Deleted としてマークし、DbContext.SaveChanges メソッド を適用すればよさそうです。

階層更新(上の例で言うと、INSERT, DELETE するときの順番)や、INSERT 時に Parents テーブルの親レコードの Id(IDENTITY 列)から値を取得して Children テーブルの子レコードの Parent_Id に設定するという操作は Entity Framework が面倒を見てくれるようです。(それを書いた公式文書が見つけられず、検証した結果だけ見てそう言っているので、100% の自信はないですけど・・・)

従って、プログラマが行うべきことで重要なのは、対象エンティティオブジェクトの状態を正しく Added, Modified, Deleted に設定してやると言うことになります。

具体的な方法は、文章で書くよりはコードを示した方がわかりやすいと思いますので、Edit, Delete 操作用のアクションメソッドと View のコードを下にアップしました。

自分が犯した失敗例や、必要な(と自分が思った)コメントも書いておきました。参考になれば幸いです。

Edit アクションメソッド

注意事項はコード内のコメントに書きましたので、それを参照してください。

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using Mvc4App2.Models;

namespace Mvc4App2.Controllers
{
  public class ParentChildController : Controller
  {
    private ParentChildContext db = new ParentChildContext();
        
    //・・・中略・・・

    //
    // GET: /ParentChild/Edit/5
    public ActionResult Edit(int id)
    {
      Parent parent = db.Parents.Find(id);
      if (parent == null)
      {
        return HttpNotFound();
      }
      return View(parent);
    }

    //
    // POST: /ParentChild/Edit/5
    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Edit(int id, Parent postedParent)
    {
      if (ModelState.IsValid)
      {
        // 以下のコードはダメ。
        // ポストされた Name の子レコードが新手に作られて
        // INSERT され、その Parent_Id が親の Id に設定さ
        // れる。既存の子レコードの Parent_Id は NULL に
        // 書き換えられる。

        //Parent parent = db.Parents.Find(id);
        //UpdateModel<Parent>(parent);
        //db.SaveChanges();

  
        // 以下のコードもダメ。
        // 親レコードしか書き換えられない。

        //db.Entry(postedParent).State = EntityState.Modified;
        //db.SaveChanges();


        // 以下のように子の方のエンティティ状態も 'Modified'
        // に設定すると親も子も期待通り更新される。

        for (int i = 0; i < postedParent.Children.Count; i++)
        {                    
          db.Entry(postedParent.Children[i]).State = 
                                       EntityState.Modified;
        }
        db.Entry(postedParent).State = EntityState.Modified;
        db.SaveChanges();

        return RedirectToAction("Index");
      }
      return View(postedParent);
    }

    //・・・中略・・・

  }
}

View (Edit.cshtml)

Parents, Children とも Id が隠しフィールド(@Html.HiddenFor)に設定されている点に注意してください。

コードの最後の方の @Scripts.Render("~/bundles/jqueryval") は入力検証用の jQuery ライブラリを登録するためのものです。これがないとクライアント側での検証はかかりませんので注意してください。

@model Mvc4App2.Models.Parent

@{
  ViewBag.Title = "Edit";
  Layout = "~/Views/Shared/_Layout.cshtml";
}

<h2>Edit</h2>

@using (Html.BeginForm()) {
  @Html.AntiForgeryToken()
  @Html.ValidationSummary(true)

  <fieldset>
    <legend>Parent</legend>

    @Html.HiddenFor(model => model.Id)

    <div class="editor-label">
      @Html.LabelFor(model => model.Name)
    </div>
    <div class="editor-field">
      @Html.EditorFor(model => model.Name)
      @Html.ValidationMessageFor(model => model.Name)
    </div>

    <hr />

    @for (int i = 0; i < Model.Children.Count; i++)
    {       
      @Html.HiddenFor(model => model.Children[i].Id)
            
      <div class="editor-label">
        @Html.LabelFor(model => 
                        model.Children[i].Name)
      </div>
      <div class="editor-field">
        @Html.EditorFor(model => 
                        model.Children[i].Name)
        @Html.ValidationMessageFor(model => 
                        model.Children[i].Name)
      </div>
                
      <hr />
    }

    <p>
      <input type="submit" value="Save" />
    </p>
  </fieldset>
}

<div>
  @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
  @Scripts.Render("~/bundles/jqueryval")
}

Delete アクションメソッド

注意事項はコード内のコメントに書きましたので、それを参照してください。


    //・・・前略・・・

    //
    // GET: /ParentChild/Delete/5
    public ActionResult Delete(int id)
    {
      Parent parent = db.Parents.Find(id);
      if (parent == null)
      {
        return HttpNotFound();
      }
      return View(parent);
    }

    //
    // POST: /ParentChild/Delete/5
    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public ActionResult DeleteConfirmed(int id)
    {
      // 以下のコードはダメ。
      // 子のデータががある場合、FK 制約に引っかかって
      // SqlException がスローされる。

      //Parent parent = db.Parents.Find(id);
      //db.Parents.Remove(parent);

            
      // 以下のコードもダメ。
      // 親と1 つ目の子レコードは削除されるが 2 つ目が残ってし
      // まう。(ただし残った子レコードの Parent_Id は NULL に
      // 書き換えられるので FK 制約には引っかからない。何故?)
      // Remove すると、その度 parent.Children.Count が一つ減っ
      // てしまう。そのため 1 つ目の子レコードを Remove した後
      // ループを抜けてしまい、2 つ目が Remove できないので、
      // db.SaveChanges() しても 2 つ目が残ってしまう。

      //Parent parent = db.Parents.Find(id);
      //for (int i = 0; i < parent.Children.Count; i++)
      //{
      //    db.Children.Remove(parent.Children[i]);
      //}
      //db.Parents.Remove(parent);            
      //db.SaveChanges();


      // 以下のように一旦 Child のコレクションを保持しておき、
      // それを使って Remove すれば OK。

      Parent parent = db.Parents.Find(id);

      List<Child> children = new List<Child>();
      foreach (Child child in parent.Children)
      {
        children.Add(child);
      }

      foreach (Child child in children)
      {
        db.Children.Remove(child);
      }
      db.Parents.Remove(parent);            
      db.SaveChanges();

      return RedirectToAction("Index");
    }

    protected override void Dispose(bool disposing)
    {
      db.Dispose();
      base.Dispose(disposing);
    }
  }
}

View (Delete.cshtml)

form 要素には action="/ParentChild/Delete/21" というように設定されます(21 は親レコードの Id)。なので、[Delete]ボタンをクリックして POST すると、アクションメソッド DeleteConfirmed(int id) の引数には親レコードの Id が渡されます。

@model Mvc4App2.Models.Parent

@{
  ViewBag.Title = "Delete";
  Layout = "~/Views/Shared/_Layout.cshtml";
}

<h2>Delete</h2>

<h3>Are you sure you want to delete this?</h3>
<fieldset>
  <legend>Parent</legend>
  <div class="display-label">
     @Html.DisplayNameFor(model => model.Id)
  </div>
  <div class="display-field">
    @Html.DisplayFor(model => model.Id)
  </div>
  <div class="display-label">
     @Html.DisplayNameFor(model => model.Name)
  </div>
  <div class="display-field">
    @Html.DisplayFor(model => model.Name)
  </div>

  <hr />

  @for (int i = 0; i < Model.Children.Count; i++)
  {       
    <div class="display-label">
      @Html.DisplayNameFor(model => 
                      model.Children[i].Id)           
    </div>
    <div class="display-field">
      @Html.DisplayFor(model => 
                      model.Children[i].Id)
    </div>
        
    <div class="display-label">
      @Html.DisplayNameFor(model => 
                      model.Children[i].Name)           
    </div>
    <div class="display-field">
      @Html.DisplayFor(model => 
                      model.Children[i].Name)
    </div>
    <hr />
  }

</fieldset>
@using (Html.BeginForm()) {
  @Html.AntiForgeryToken()
  <p>
    <input type="submit" value="Delete" /> |
    @Html.ActionLink("Back to List", "Index")
  </p>
}

次の課題は、(1) 既存の親に属する子の全部または一部を DELETE、(2) 既存の親に子を追加・・・をどう実装するかですね。やる気が湧いてきたら書いてみます。(笑)

さらなる課題は、同時実行制御や、エラーの際のロールバックをどう実装するかでしょうか。そこのところは勉強不足でまだ見当さえついてません。先はずいぶん長そうです。(汗)

2016/9/12 追記:
Microsoft の文書「Code First の規約」に従って、Child クラスにナビゲーションプロパティと外部キープロパティを定義するとどのような影響があるかを別の記事「Code First で外部キープロパティの定義」に書きました。int 型の外部キープロパティを Child クラスに追加にしたのですが、それによる大きな影響は以下の 2 点です。他にも影響はありますが、詳しくは上にリンクを張った別記事を見てください。
  • 編集・更新: Child クラスに外部キープロパティを追加したので、編集結果を送信して更新をかける際、外部キープロパティの値も送信する必要がある。そうしないと参照整合性制約違反でエラー。
  • 削除: 外部キープロパティを int 型にしたので、外部キーフィールドが NULL 不可になり、連鎖削除が可能になる。

Tags: ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar