Jordan Lewallen
Jordan Lewallen

Reputation: 1851

Error using TryUpdateModelAsync in a form with navigation properties

Suppose I have a form that contains the following model structure:

TestRecord.cs

class TestRecord {
    public string Id { get; set; }
    public string RecordName { get; set; }
    ...other props
    public ICollection<RtdbFiles> RtdbFiles { get; set; }
}

Where the corresponding RtdbFile contains the following props:

RtdbFile.cs

class RtdbFile {
    public int Id { get; set; }
    public string Filename { get; set; }
    ...other props
}

When I POST this model to my controller to update, I receive the following error:

The instance of entity type 'RtdbFile' cannot be tracked because another instance with the key value '{Id: 2021}' is already being tracked

So it appears that two of the same RtdbFile are being attached to the context. Here's how my controller method is formatted:

[HttpPost("UpdateMilestones")]
public async Task<IActionResult> UpdateMilestones(string testRecordId)
{

    db.ChangeTracker.LazyLoadingEnabled = false;

    var record = db.TestRecords
        .Include(tr => tr.RtdbFiles)
        .FirstOrDefault(tr => tr.TestRecordId == testRecordId);

    if (await TryUpdateModelAsync(record))
    {
        await db.SaveChangesAsync();
    }

    return RedirectToAction("Milestones", new { id = testRecordId });
}

Is TryUpdateModelAsync() not made to handle situations with a One-to-Many relationship? When is the duplicate RtdbFile being added to the context? I've disabled lazy loading and eagerly load the RtdbFiles. This is similar to what is done in the Contoso University example by Microsoft but the difference is their eagerly loaded property is a One-to-One relationship.

How can I fix this? Thanks!


EDIT to show Razor Pages:

UpdateMilestones.cshtml

@model rtdb.Models.TestRecord
@addTagHelper *, rtdb

<input type="hidden" asp-for="@Model.TestRecordId" />

<div class="form-section-text">Milestones & Tracking</div>

<!--unrelated inputs removed -->

<div class="form-group">
    <vc:file record="@Model" type="@RtdbFile.AttachmentType.TPR" approvers="true"></vc:file>
</div>

The RtdbFiles are abstracted out a bit in to view components:

File View Component

@model rtdb.Models.ViewModels.FileViewModel
@addTagHelper *, rtdb
@using HtmlHelpers.BeginCollectionItemCore

<div class="form-group attachments">
    <div class="link-header">@(Model.AttachmentType.ToString("G"))</div>
    <div class="row">
        <div class="col-sm-12">
            @if (Model.TestRecord.RtdbFiles.Count > 0)
            {
                foreach (var file in Model.TestRecord.RtdbFiles.Where(f => f.IsDeleted != true && f.Type == Model.AttachmentType && f.TestRecordId == Model.TestRecord.TestRecordId).ToList())
                {
                    <div class="attachment">
                        @using (Html.BeginCollectionItem("RtdbFiles"))
                        {
                            <div class="form-group">
                                <div class="form-row">
                                    <input asp-for="@file.Id" hidden />
                                    <input asp-for="@file.Type" hidden />
                                    <div class="col-sm-6">
                                        @if (@file.Id < 1)
                                        {
                                            <input class="FileInput" asp-for="@file.UploadedFile" type="file" />
                                        }
                                        else
                                        {
                                            <div><span data-file-id="@file.Id"><a href='@Url.Action("Download", "RtdbFiles", new { id = file.Id })'>@file.Filename (@file.Type.ToString("G"))</a></span></div>
                                        }
                                    </div>
                                    <div class="col-sm-6">
                                        <div>
                                            <label asp-for="@file.FileApproverPersonnel" class="col-form-label col-form-label-sm">Approvers:</label>
                                            <input asp-for="@file.FileApproverPersonnel" class="form-control file-approver-personnel ldap-tags" />
                                        </div>
                                    </div>
                                </div>
                            </div>
                        }
                    </div>
                }
            }
            <div id="@(Model.AttachmentType.ToString("G"))s"></div>
            <button type="button" class="add-file btn btn-primary" data-test-type="Other" data-attachment-type="TPR" data-container="@(Model.AttachmentType.ToString("G"))s">Add @(Model.AttachmentType.ToString("G"))</button>
            <small style="display: block; margin-top: 6px">File size limit: 100MB</small>
        </div>
    </div>
</div>

Upvotes: 2

Views: 1155

Answers (2)

Arman Ebrahimpour
Arman Ebrahimpour

Reputation: 4461

What is obvious is TryUpdateModelAsync or maybe ChangeTracker has some issues with string ForeignKeys. First of all I highly recommend you to change PrimaryKey to int because EF shows some odd behaviour in such cases. But if you insist on it, I tried some ways and finally reach this way: Preventing object from tracking with AsNoTracking and use context.Update after updating record based on controller model

Based on your latest models, It's my sample that works well:

Models:

public class TestRecord
{
    public string Id { get; set; }
    public string RecordName { get; set; }

    public virtual IList<RtdbFile> RtdbFiles { get; set; }
}

public class RtdbFile
{
    public int Id { get; set; }
    public string TestRecordId { get; set; }
    public string Filename { get; set; }
}

Razor Page:

Note: This part has the most important effect on your result. specially RtdbFiles[{i}].Id and RtdbFiles[{i}].Filename Your View have to send items and values with exactly same name to server object to take effect correctly:

@model Jordan.TestRecord

@using (Html.BeginForm("UpdateMilestones", "Home", FormMethod.Post))
{
    @Html.HiddenFor(p => p.Id);

    @for (int i = 0; i < Model.RtdbFiles.Count; i++)
    {
        @Html.Hidden($"RtdbFiles[{i}].Id", Model.RtdbFiles[i].Id);
        @Html.TextBox($"RtdbFiles[{i}].Filename", Model.RtdbFiles[i].Filename);
    }
    <button type="submit">Save</button>
}

Controller:

namespace Jordan
{
    [Route("")]
    public class HomeController : Controller
    {
        private readonly AppDbContext context;

        public HomeController(AppDbContext context)
        {
            this.context = context;
            context.Database.EnsureCreated();
        }

        [HttpGet]
        public IActionResult Index()
        {
            var sampleRecord = context.TestRecords
                .Include(r => r.RtdbFiles)
                .FirstOrDefault();

            return View(sampleRecord);
        }

        [HttpPost]
        [Route("UpdateMilestones")]
        public async Task<IActionResult> UpdateMilestones(int Id)
        {
            context.ChangeTracker.LazyLoadingEnabled = false;

            var record = context.TestRecords
                .Include(tr => tr.RtdbFiles)
                .AsNoTracking()
                .FirstOrDefault(tr => tr.Id == Id);

            if (await TryUpdateModelAsync(record))
            {
                context.Update(record);
                await context.SaveChangesAsync();
            }

            return RedirectToAction("Index");
        }
    }
}

Upvotes: 1

LouraQ
LouraQ

Reputation: 6891

I got it, but there seems to be no case of TryUpdateModelAsync on one-to-many online. (And I tried without success).

Therefore, I suggest that you can use our common method of updating the one-to-many data model.

In the view, you need to bind each field of TestRecord to the corresponding control and pass the latest data of TestRecord to UpdateMilestones action.

Please refer to the following code:

View (which show one record of TestRecord and it related to multiple RtdbFiles datas):

@model WebApplication_core_mvc.Controllers.TestRecord
@{
    ViewData["Title"] = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
    var i = 0;
}

<h1>Index</h1> 
<form asp-action="UpdateMilestones" method="post">
    <input id="Text1" type="text" asp-for="Id"  hidden />
    <label>@Model.Id</label>
    <input id="Text2" type="text" asp-for="RecordName" />
    <br />
    <h4>Rtb:</h4>
    <table>
        <tr>
            <th>Id</th>
            <th>FileName</th>
        </tr>

        @foreach (var item in Model.RtdbFiles)
        {
            <tr>
                <td> <input id="Text1" type="text" value="@item.Id" name="RtdbFiles[@i].Id" hidden/>@item.Id</td>
                <td>  <input id="Text1" type="text" value="@item.Filename" name="RtdbFiles[@i].Filename" /></td>
            </tr>
            i++;
        }
    </table>
    <input id="Submit1" type="submit" value="submit" />
</form>

Update

Controller:

 [HttpPost("UpdateMilestones")] 
    public async Task<IActionResult> UpdateMilestones(TestRecord testRecord)
    {  
        db.Entry(testRecord).State = EntityState.Modified;
        db.Entry(testRecord).Property(x => x.Id).IsModified = false;
        foreach (var item in testRecord.RtdbFiles)
        {
            db.Entry(item).State = EntityState.Modified;
            db.Entry(item).Property(x => x.Id).IsModified = false;
        }
        await db.SaveChangesAsync(); 
        return RedirectToAction("Milestones", new { id = testRecord.Id });
    }

Here is the test result:

enter image description here

Upvotes: 1

Related Questions