からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

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 Karamem0.Samples.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: JSON.stringify(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>

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


コントローラー

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);
    }

}

実行

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

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

しかし、この状態でもう一度更新すると、例外が発生します。

System.InvalidOperationException: 'The operation failed: The relationship could not be changed because one or more of the foreign-key properties is non-nullable. When a change is made to a relationship, the related foreign-key property is set to a null value. If the foreign-key does not support null values, a new relationship must be defined, the foreign-key property must be assigned another non-null value, or the unrelated object must be deleted.'

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