Reputation: 1851
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
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
Reputation: 6891
I got it, but there seems to be no case of
TryUpdateModelAsync
onone-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>
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:
Upvotes: 1