2

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>
Jordan Lewallen
  • 1,681
  • 19
  • 54
  • Can you provide us with your `OnModelCreating` method for reference? What is the meaning of the two same RtdbFiles you mentioned here, and what is the data you want to update? – LouraQ May 16 '20 at 09:49
  • @YongqingYu there's nothing really relevant in my `OnModelCreating`, it is just setting join table keys for M-M relationships not related to these models. The two RtdbFiles I'm referring to are: 1. The collection of RtdbFiles from my form that are passed through the context on `POST` and 2. the collection of RtdbFiles that are eagerly loaded from my `Include` statement. I need to update the `TestRecord` and the nested collection of `RtdbFiles` shown in the TestRecord model I've shown above – Jordan Lewallen May 16 '20 at 18:16

2 Answers2

1

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

LouraQ
  • 6,443
  • 2
  • 6
  • 16
  • Thanks! Yea I was aware of handling it like this, but since `RtdbFile` has a ton of properties I didn't want to have to dup the same code over several different controller methods. I'm gonna set a bounty on this to see if someone can accomplish this task with TryUpdateModel but will come back to this if no one can suggest something else. – Jordan Lewallen May 17 '20 at 23:40
  • @JordanLewallen, If you are trying to avoid writing many field updates, you can separate key fields to emphasize not update, but update all other fields, please refer to the above code to update me, I hope to help you, refer to the link: https://stackoverflow.com/a/5567616/12884742 – LouraQ May 18 '20 at 01:50
1

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");
        }
    }
}
Arman Ebrahimpour
  • 4,252
  • 1
  • 14
  • 46
  • could you please share your code? Thank you. The RtdbFiles are displayed on the page via `BeginCollectionItem` – Jordan Lewallen May 18 '20 at 16:02
  • @JordanLewallen My code is exactly yours except that I call its api with `Postman`. which version of EF and .Net Core are you using? could you update your question adding your razor code? The other question, did you define your DbContext as scoped? – Arman Ebrahimpour May 18 '20 at 17:07
  • Weird. I"ve added my Razor code, removed non relevant properties. The RtdbFiles are abstracted into a `File` ViewComponent and within that view component, I loop through to display all Files. I am using .Net Core 3.1.201. My DbContext is defined as the following: `services.AddDbContext(options => options.UseLazyLoadingProxies().UseSqlServer(connectionString).EnableSensitiveDataLogging());` – Jordan Lewallen May 18 '20 at 17:53
  • @JordanLewallen: tried with no luck! everything goes well with your config!. I suggest you to see the objects that posted back to your controller using Network tab of browsers development tool or set breakpoint to your controller and looking for any duplicated Id in your received model. And one other thing that is red flag for me is that your RtdbFiles is not second level of your View model! it's in third level and currently EF has issues with updating third level entities! – Arman Ebrahimpour May 19 '20 at 11:45
  • can you please share a repo of your working code? I know you said it’s the same but I really want to do a side by side – Jordan Lewallen May 19 '20 at 14:47
  • @JordanLewallen I was somehow busy to create and test full working sample without `Postman` but finally I did it. Please see my edited answer. If you had issue with my code, I can share full project with you to download and test – Arman Ebrahimpour May 19 '20 at 18:59
  • @JordanLewallen : it's full project to test: https://gofile.io/d/eyza8O – Arman Ebrahimpour May 19 '20 at 19:24
  • Re your comment here: `So I think some RtdbFile items in your page got wrong/duplicated ids.` I have set a breakpoint when the POST method hits my controller, when I inspect the form element, the `RtdbFiles` only has a single File element in the Collection so I do not believe this to be the issue. Thanks for posting your code though! I'm still digging...this one is puzzling – Jordan Lewallen May 19 '20 at 23:08
  • Also just for confirmation, here's my form body sent in the HttpContext Request for proof that duplicate `RtdbFiles` are not being sent: https://snipboard.io/dQwuGk.jpg – Jordan Lewallen May 19 '20 at 23:14
  • @JordanLewallen : Why Indices are GUID?!! that could be exactly the problem! If Index of your model was not exactly the same as index of what you fetch from database (with include) it will add beside that and because both of these items will have the same Id dupplication that I mentioned will be happen! – Arman Ebrahimpour May 20 '20 at 04:33
  • those indexes are autogenerated by the `BeginCollectionItem` helper, but you bring up an interesting point. I'll give it a shot first thing tomorrow and let you know! – Jordan Lewallen May 20 '20 at 04:35
  • But I guess I don't quite understand how that GUID matters, because when I change my method argument to `UpdateMilestones(TestRecord testRecord)` so that I can observe the passed form item instead of from the context, I simply see an array of TestRecords (ie RtdbFiles[0].Id etc) – Jordan Lewallen May 20 '20 at 04:42
  • @JordanLewallen Weird! Last thing that brings to my mind is that set Breakpoint exactly on `await context.SaveChangesAsync();` (after `TryUpdateModelAsync`) and check your model, if everything were OK there I can't go further without your full project and debugging it – Arman Ebrahimpour May 20 '20 at 05:23
  • hey! Ok so I went ahead and tested your latest suggestion with the breakpoint just now and the `RtdbFiles` looks exactly as it should. Which leads me back to the only thing that I can think of which is causing issues. The `include` adds `RtdbFiles` to tracking context. And from the comment I saw on a .NET GitHub repo ticket stating that `non top-level properties are not merged, but simply completely replaced`. By replacing the `RtdbFiles` completely, we now have two identically tracked items in the context. Wish I could just share the entire project with you ahhh! – Jordan Lewallen May 20 '20 at 05:34
  • @JordanLewallen If that Github ticket was our issue my sample shouldn't work too, but as probabely you see it also has second level props and worked without any error – Arman Ebrahimpour May 20 '20 at 05:49
  • haha yea, just tried refactoring where I no longer use `BeginCollectionItem` and use the for loop and iterate like this `RtdbFiles[{i}].Id` just like your example and still have the issue. The only other thing I notice is that your virtual RtdbFiles is an List while mine is an ICollection...last ditch to try that haha – Jordan Lewallen May 20 '20 at 06:01
  • @JordanLewallen : Whats up jordan? Any progress? – Arman Ebrahimpour May 20 '20 at 21:22
  • I've been building off your base project this afternoon. Yours works flawlessly, I am now working backwards to try and find when this issue starts occurring. I basically have the whole form reconstructed with no errors.....what..Currently pulling my hair out! I'll keep ya posted – Jordan Lewallen May 20 '20 at 21:25
  • @JordanLewallen Anyway if you finally couldn't find the issue feel free to share it with me and let me try ;) – Arman Ebrahimpour May 20 '20 at 21:34
  • it's because my TestRecord Id is a string in my actual project (team lead request)....if you try to modify your project and make TestRecord Id primary key a string you'll hit my error. I didn't realize this would cause an issue and to simplify things I wrote out my example with a simple integer here. WHY does a unique string primary key cause this issue? Any thoughts? – Jordan Lewallen May 21 '20 at 00:28
  • @JordanLewallen Try my updated answer and let me know the result – Arman Ebrahimpour May 21 '20 at 09:52
  • thank you so much for all the help! Marking this as correct with bounty. The request for primary key as string was so that they could unique identify using "TR2019_20" or something like that. Gonna take on the task of replacing the string pk with an integer. Think this is a bug that I should submit to .NET team? The fact that it's triggering Lazy Loading even when we disable it seems really odd... – Jordan Lewallen May 22 '20 at 20:12
  • @JordanLewallen thanks Jordan. I think it's a bug too. Difference in behaviour when Id changed from int to string is obvious! Anyway replacing PK to int seems good idea for preventing any further unknown problems – Arman Ebrahimpour May 22 '20 at 20:37