💻

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

2022/01/01に公開

はじめに

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

実装方法

Model

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

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

}

View

Views/Home/Index.cshtml

先ほど作った GroupMember を一括更新できるようにしています。データの表示は knockout.js を使っています。検索 ボタンをクリックしたときに、IDGroup エンティティを検索します。更新 ボタンをクリックしたときに、画面の情報を jQuery の ajax メソッドで送信します。MIME を application/json とすることで Controller から受け取れるようになります。

@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>

表示すると以下のような感じになります。

Controller

Controllers/HomeController.cs

Controller の 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 も変わってしまうため、何か解決方法があれば教えてほしいです。

Discussion