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 の当該レコードを削除してしまったとか、不正なデータが送信されたなどの異常事態と考えた方がよいかもしれません。