からめもぶろぐ。

SharePoint が得意なフレンズなんだね!すごーい!

UpdateModel で子エンティティも含めて更新しようとしたらうまくいかなかった

Controller の UpdateModel (または TryUpdateModel) で子エンティティも含めて更新しようと思ったらうまくいかなかったので、サンプル コードを書いて検証してみます。

モデル

以下のような親子関係を持つ POCO エンティティを用意します。

Models/Group.cs

    public class Group {

        [Key()]
        public int Id { get; set; }

        [Required()]
        [StringLength(64)]
        public string Name { get; set; }

        public ICollection<Member> Members { get; set; }

        public Group() {
            this.Members = new HashSet<Member>();
        }

Models/Member.cs

    public class Member {

        [Key()]
        public int Id { get; set; }

        [ForeignKey("Group")]
        public int GroupId { get; set; }

        public Group Group { get; set; }

        [Required()]
        [StringLength(64)]
        public string Name { get; set; }

    }

}

ビュー

Views/Home/Index.cshtml

少々長いですがご容赦ください。先ほど作った Group と Member を一括更新できるようにしています。データの表示は knockout.js を使っています。
[検索] ボタンをクリックしたときに、ID で Group エンティティを検索します。
[更新] ボタンをクリックしたときに、画面の情報を jQuery の ajax メソッドで送信します。MIME を application/json とすることでコントローラで受け取れるようになります。

@model Sample.Models.Group
@{
    ViewBag.Title = "ホーム";
}
@using (Html.BeginForm()) {
    <fieldset>
        <table>
            <tbody>
                <tr>
                    <td class="editor-label">@Html.LabelFor(model => model.Id)
                    </td>
                    <td class="editor-field">
                        @Html.TextBoxFor(model => model.Id, new Dictionary<string, object>() {
                            { "data-bind", "value: id" },
                        })
                        <input type="button" value="検索" data-bind="click: search" />
                    </td>
                </tr>
                <tr>
                    <td class="editor-label">@Html.LabelFor(model => model.Name)
                    </td>
                    <td class="editor-field">
                        @Html.TextBoxFor(model => model.Name, new Dictionary<string, object>() {
                            { "data-bind", "value: name" },
                        })
                    </td>
                </tr>
            </tbody>
        </table>
    </fieldset>
    <fieldset>
        <table>
            <thead>
                <tr>
                    <td class="header-label" colspan="2">
                        <input type="button" value="追加" data-bind="click: add" />
                    </td>
                </tr>
            </thead>
            <tfoot>
                <tr>
                    <td class="header-label" colspan="2">
                        <input type="button" value="更新" data-bind="click: update" />
                    </td>
                </tr>
            </tfoot>
            <tbody data-bind="foreach: members">
                <tr>
                    <td class="editor-label">@Html.LabelFor(model => Model.Members.FirstOrDefault().Name)
                    </td>
                    <td class="editor-field">
                        @Html.HiddenFor(model => Model.Members.FirstOrDefault().Id)
                        @Html.TextBoxFor(model => Model.Members.FirstOrDefault().Name, new Dictionary<string, object>() {
                            { "data-bind", "value: name" },
                        })
                        <input type="button" value="削除" data-bind="click: $parent.remove" />
                    </td>
                </tr>
            </tbody>
        </table>
    </fieldset>
}
<script type="text/javascript">
    function ViewModel() {

        var self = this;

        this.id = ko.observable(0);
        this.name = ko.observable("");
        this.members = ko.observableArray([]);

        this.search = function () {
            var url = "@Url.Action("Search")";
            var param = { Id: self.id() };
            var callback = function(data) {
                if (data != null) {
                    self.id(data.Id);
                    self.name(data.Name);
                    self.members.removeAll();
                    $.each(data.Members, function(i, e) {
                        self.members.push({
                            id: e.Id,
                            name: e.Name
                        });
                    });
                }
            };
            $.post(url, param, callback, "json");
        };

        this.update = function() {
            var url = "@Url.Action("Update")";
            var param = { 
                Id: self.id(),
                Name: self.name(),
                Members: Enumerable.from(self.members())
                    .select(function(member) { return {
                        Id: member.id,
                        Name: member.name
                    }})
                    .toArray()
            };
            var callback = function(data) {
                if (data != null) {
                    self.id(data.Id);
                }
            };
            $.ajax({
                url: url,
                type: "POST",
                contentType: "application/json",
                processData: false,
                traditional: true,
                success: callback, 
                data: $.toJSON(param), 
                dataType: "json"
            });
        };

        this.add = function() {
            self.members.push({
                id: 0,
                name: null
            });
        };

        this.remove = function(item) {
            self.members.remove(item);
        };

    };
    $(function () {
        ko.applyBindings(new ViewModel());
    });
</script>

表示するとこんな感じです。

f:id:karamem0:20160625095000p:plain

コントローラー

Controllers/HomeController.cs

コントローラーの Update メソッドでは、データがなければ DbContext に追加し、あれば UpdateModel で更新するようにしています。

    public class HomeController : Controller {

        [HttpGet()]
        public ActionResult Index() {
            return this.View();
        }

        [HttpPost()]
        public ActionResult Search(int id) {
            using (var context = new EntityContext()) {
                var model = context.Groups
                    .Include(x => x.Members)
                    .SingleOrDefault(x => x.Id == id);
                if (model != null) {
                    model.Members.ToList().ForEach(x => x.Group = null);
                }
                return this.Json(model);
            }
        }

        [HttpPost()]
        public ActionResult Update(Group model) {
            using (var context = new EntityContext()) {
                if (this.ModelState.IsValid) {
                    model = context.Groups
                        .Include(x => x.Members)
                        .SingleOrDefault(x => x.Id == model.Id);
                    if (model == null) {
                        model = new Group();
                        this.UpdateModel(model);
                        context.Groups.Add(model);
                    } else {
                        this.UpdateModel(model);
                    }
                    context.SaveChanges();
                }
            }
            model.Members.ToList().ForEach(x => x.Group = null);
            return this.Json(model);
        }


    }

実行

データを入れて登録してみます。

f:id:karamem0:20160625094937p:plain

データが正常に追加されると画面の ID が更新されます。

f:id:karamem0:20160625095008p:plain

この状態でもう一度更新すると…。

f:id:karamem0:20160625095016p:plain

例外発生!
おそらく UpdateModel のタイミングでコレクション自体が書き換わっているのですが、元のデータに Deleted のフラグがうまく立たないみたいです。とりあえずいったん全部 Delete してから追加するように逃げましたが、オート ナンバーだと ID も変わってしまうのであまりいい方法じゃないので、何か解決方法があれば教えてほしいです。