EntityFrameworkのMigrationを試してみる

以前ASP.Netで超簡単な掲示板を作りました。

ASP.Netで0から超簡易掲示板を作る
ASP.Netで完全に0から超簡易の掲示板を作りました。 あくまでASP.Netがどんなもんか見る為のものだったのでいろいろと雑です。 (細かいエラー処理やらセキュリティ回りはノータッチです。) githubに上げています...

 

これの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. データベース(テーブル)を消す
  2. データベース側の構成をソースに合わせる

1. データベース消す

とても単純です。EntityFrameworkはテーブルが存在すれば差異を比べてエラーを出しますが、存在しなければ自動で作ります。

なので既存のテーブルを消してやれば新たな構成でテーブルを作り直してくれます。

もちろんデメリットもあります。

既存のテーブルを消すので、既存のデータも一緒に消えて無くなります。

2. データベース側を合わせる

さて、データが消えるのは困る。という時はデータベース側を更新して差異をなくす必要があります。

EntityFrameworkではこういう時の為にMigrationという機能が提供されています。

DB Migration

かなり簡単ですので、さっそく使ってみます。

Tools → NuGet Package Manager → Package Manager Console

を選択します。

PackageManagerConsole

このような画面がおそらく下に出ます。(場所は設定によります…)

PackageManagerConsole2

ここに「Enable-Migrations」と記述しエンターを押します。

PM> Enable-Migrations

そうすると自動でなにやら処理が行われて、プロジェクトに「Migrations」フォルダと「Configuration.cs」、「日付_InitialCretate.cs」という2つのファイルが生成されます。

Enable-Migrations

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でテーブルの内容を確認してみます。

ServerExplorer

たしかに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の内容は戻ってきません。

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