以前ASP.Netで超簡単な掲示板を作りました。
これのDBの処理はEntityFrameworkと呼ばれるO/Rマッパーを利用しています。
ただ、作っていて不便な点が出てきます。それが、テーブル情報の変更時です。
ケース:投稿に名前を付けれるようにする
掲示板の仕様拡充で投稿に名前を付けれるようにするケースを考えてみます。
以前の記事にならってBoardDbContext.cs内のBoardEntityとBoardPostEntityにNameプロパティを追加してみます。
public class BoardEntity { [Key] public int Id { get; set; } [Required] public string Title { get; set; } [Required] public string Text { get; set; } [Required] public string Name { get; set; } public virtual ICollection<BoardPostEntity> Posts { get; set; } } public class BoardPostEntity { [Key] public int Id { get; set; } [Required] public string Text { get; set; } [Required] public string Name { get; set; } }
Nameを追加しました。これでNameカラムを持ったテーブルになるかと思いきやそうはいきません。
実際に実行して掲示板一覧画面に行ってみてください。
The model backing the ‘BoardDbContext’ context has changed since the database was created. Consider using Code First Migrations to update the database
と、例外が発生します。
記述の通り、EntityFrameworkではソース側の構成とデータべース側の構成に差異があると例外が発生するようになっています。
対応策は?
これに対して大きく2つの対策があります。
- データベース(テーブル)を消す
- データベース側の構成をソースに合わせる
1. データベース消す
とても単純です。EntityFrameworkはテーブルが存在すれば差異を比べてエラーを出しますが、存在しなければ自動で作ります。
なので既存のテーブルを消してやれば新たな構成でテーブルを作り直してくれます。
もちろんデメリットもあります。
既存のテーブルを消すので、既存のデータも一緒に消えて無くなります。
2. データベース側を合わせる
さて、データが消えるのは困る。という時はデータベース側を更新して差異をなくす必要があります。
EntityFrameworkではこういう時の為にMigrationという機能が提供されています。
DB Migration
かなり簡単ですので、さっそく使ってみます。
Tools → NuGet Package Manager → Package Manager Console
を選択します。
このような画面がおそらく下に出ます。(場所は設定によります…)
ここに「Enable-Migrations」と記述しエンターを押します。
PM> Enable-Migrations
そうすると自動でなにやら処理が行われて、プロジェクトに「Migrations」フォルダと「Configuration.cs」、「日付_InitialCretate.cs」という2つのファイルが生成されます。
InitialCreateはこんな感じです。
namespace TestBoard.Migrations { using System; using System.Data.Entity.Migrations; public partial class InitialCreate : DbMigration { public override void Up() { CreateTable( "dbo.BoardEntities", c => new { Id = c.Int(nullable: false, identity: true), Title = c.String(nullable: false), Text = c.String(nullable: false), }) .PrimaryKey(t => t.Id); CreateTable( "dbo.BoardPostEntities", c => new { Id = c.Int(nullable: false, identity: true), Text = c.String(nullable: false), BoardEntity_Id = c.Int(), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.BoardEntities", t => t.BoardEntity_Id) .Index(t => t.BoardEntity_Id); } public override void Down() { DropForeignKey("dbo.BoardPostEntities", "BoardEntity_Id", "dbo.BoardEntities"); DropIndex("dbo.BoardPostEntities", new[] { "BoardEntity_Id" }); DropTable("dbo.BoardPostEntities"); DropTable("dbo.BoardEntities"); } } }
中身を見るとなんとなくわかるかもしれませんが、Upには今のデータベースの構成になるようなコードが記述されています。
DownにはUpの記述を打ち消すようなコードが記述されています。
今回必要なのは、これにNameを追加したものです。これにはAdd-Migrationを使用します。
Consoleに「Add-Migration AddBoardName」と入力し実行します。
PM> Add-Migration AddBoardName
なにやら処理が走って、「日付_AddBoardName」というファイルが生成されます。
中身は
namespace TestBoard.Migrations { using System; using System.Data.Entity.Migrations; public partial class AddBoardName : DbMigration { public override void Up() { AddColumn("dbo.BoardEntities", "Name", c => c.String(nullable: false)); AddColumn("dbo.BoardPostEntities", "Name", c => c.String(nullable: false)); } public override void Down() { DropColumn("dbo.BoardPostEntities", "Name"); DropColumn("dbo.BoardEntities", "Name"); } } }
UpにはNameをAddColumnする内容、DownにはNameをDropColumnする内容が記述されています。
Add-Migrationを使う事で今のデータベースの内容と、ソース側の差異を抜き出してこのようにコードに変換してくれます。
Add-Migrationに渡すパラメータは変更の名前です。分かりやすい名前にしておいた方がいいです。今回はBoardにNameカラムを足したのでAddBoardNameとしておきました。
この段階ではまだ差分を抽出しただけでテーブルは変更されていません。
この変更をデータベースに反映するためには「Update-Database」を使用します。
PM> Update-Database
これで差異が反映されます。
具体的にどのような処理が行われたか確認したい場合は-Verboseパラメータをつけます。
PM> Update-Database -Verbose
こちらでは
Applying explicit migrations: [201602030629020_AddBoardName]. Applying explicit migration: 201602030629020_AddBoardName. ALTER TABLE [dbo].[BoardEntities] ADD [Name] [nvarchar](max) NOT NULL DEFAULT '' ALTER TABLE [dbo].[BoardPostEntities] ADD [Name] [nvarchar](max) NOT NULL DEFAULT ''
といった感じで表示されます。
この状態でServer Explorerでテーブルの内容を確認してみます。
たしかにNameが増えています。
再度、掲示板一覧を確認してみてもエラーも発生しません。
アプリ側の対応
DBにはNameが追加されたので、アプリ側の対応を行います。
掲示板投稿時
まずテストにNameを追加します。
/// <summary> /// 掲示板の投稿のテスト /// </summary> [TestMethod] public void PostCreate() { // ダミーデータの生成 var model = new BoardCreateModel { Title = "題名", Text = "本文", Name = "名前" }; var dummy = new BoardEntity { Id = 1, Title = model.Title, Text = model.Text, Name = model.Name }; var mocks = new Mocks( addDummy: dummy ); var controller = new BoardController(mocks.Context.Object); var result = controller.Create(model) as RedirectResult; Assert.IsNotNull( result ); // Addが呼ばれたかチェック mocks.Boards.Verify( m => m.Add( It.Is<BoardEntity>( o => o.Title == model.Title && o.Text == model.Text && o.Name == model.Name ) ), Times.Once ); // SaveChangesがよばれたかチェック mocks.Context.Verify( m => m.SaveChanges(), Times.Once ); Assert.AreEqual( result.Url, "/Board/Show/1" ); }
コンパイルエラーを確認して、モデルを更新します。
namespace TestBoard.Models { public class BoardPostModel { public string Text { get; set; } public string Name { get; set; } } }
テストのエラーを確認して、処理を更新します。
[HttpPost] public ActionResult Create(BoardCreateModel data) { int result = db_.Add( new BoardEntity { Title = data.Title, Text = data.Text, Name = data.Name } ); return Redirect("/Board/Show/" + result); }
テストの成功を確認したら、ビューを更新します。
@model TestBoard.Models.BoardCreateModel @{ Layout = "~/Views/Shared/_DefaultLayout.cshtml"; ViewBag.Title = "掲示板の追加"; } @using (Html.BeginForm("Create", "Board", FormMethod.Post)) { <p>タイトル: @Html.TextBoxFor( m => m.Title ) </p> <p>名前: @Html.TextBoxFor( m => m.Name ) </p>; <p>本文:</p> @Html.TextAreaFor( m => m.Text ) <div> <input type="submit" value="作成" /> </div> }
ここまでできたらブラウザで投稿テストを行います。
投稿者の名前の表示
@model TestBoard.Models.BoardEntity @{ Layout = "~/Views/Shared/_DefaultLayout.cshtml"; ViewBag.Title = Model.Title; } <div> <p>@Model.Title</p> <p>@Model.Name</p> <p>@Model.Text</p> </div> @if ( Model.Posts != null ) { <div> @foreach ( var post in Model.Posts ) { <div> @post.Text </div> } </div> } <div> @using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) ) { <div> @Html.TextArea( "Text", "" ) </div> <input type="submit" value="投稿" /> } </div>
名前を追加するだけで行けます。
返信時に名前を付ける
テストを更新します。
/// <summary> /// 返信の投稿のテスト /// </summary> [TestMethod] public void PostResponse() { // DBのモックを用意する var mocks = new Mocks( new List<BoardEntity> { new BoardEntity { Id = 1, Title = "A", Text = "a", Name = "名前" }, } ); var postData = new BoardPostModel { Text = "投稿内容", Name = "名前" }; var controller = new BoardController(mocks.Context.Object); var result = controller.PostResponse(1, postData ) as RedirectResult; // データの追加がちゃんとされているかチェック mocks.GetPosts(0).Verify( m => m.Add( It.Is<BoardPostEntity>( o => o.Text == postData.Text && o.Name == postData.Name ) ), Times.Once ); mocks.Context.Verify( m => m.SaveChanges(), Times.Once ); Assert.AreEqual( result.Url, "/Board/Show/1" ); }
エラーを確認して、モデルを更新します。
namespace TestBoard.Models { public class BoardPostModel { public string Text { get; set; } public string Name { get; set; } } }
テストの失敗を確認して、コントローラを更新します。
[HttpPost] public ActionResult PostResponse( int id, BoardPostModel data ) { db_.PostResponse( id, new BoardPostEntity { Text = data.Text, Name = data.Name } ); return Redirect("/Board/Show/" + id); }
テストが通ったらビューを更新します。
@model TestBoard.Models.BoardEntity @{ Layout = "~/Views/Shared/_DefaultLayout.cshtml"; ViewBag.Title = Model.Title; } <div> <p>@Model.Title</p> <p>@Model.Name</p> <p>@Model.Text</p> </div> @if ( Model.Posts != null ) { <div> @foreach ( var post in Model.Posts ) { <div> @post.Text </div> } </div> } <div> @using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) ) { <p>名前: @Html.TextBox( "Name", "" ) </p> <div> @Html.TextArea( "Text", "" ) </div> <input type="submit" value="投稿" /> } </div>
返信の名前を表示
ビューを更新します。
@model TestBoard.Models.BoardEntity @{ Layout = "~/Views/Shared/_DefaultLayout.cshtml"; ViewBag.Title = Model.Title; } <div> <p>@Model.Title</p> <p>@Model.Name</p> <p>@Model.Text</p> </div> @if ( Model.Posts != null ) { <div> @foreach ( var post in Model.Posts ) { <p>名前: @post.Name</p> <div> @post.Text </div> } </div> } <div> @using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) ) { <p>名前: @Html.TextBox( "Name", "" ) </p> <div> @Html.TextArea( "Text", "" ) </div> <input type="submit" value="投稿" /> } </div>
これで掲示板に名前が追加されました。
任意のマイグレーションへ更新する
Update-Databaseすると最新のものへ更新されますが、パラメータを追加する事で任意の時点のものを指定することが出来ます。
PM> Update-Database -TargetMigration:InitialCreate
TargetMigration:の後ろに任意の更新の名前を指定します。
これによって、各マイグレーションのUpメソッド/Downメソッドが呼ばれてテーブルの状態が変更されます。
この例だとAddBoardNameのDownメソッドが呼ばれてNameカラムが消えます。
注意点
カラムやテーブルの追加は問題は出にくいと思いますが、削除した場合はそのカラムやテーブルのデータも一緒に消えてしまいます。
先ほどの「Update-Database -TargetMigration:InitialCreate」した後に「Update-Database」で最新にしたとしても、投稿されたNameの内容は戻ってきません。