I am writing an application using Blazor that interacts with a SQL Server database to implement basic CRUD functions (create, read, update, delete) on the database, using Entity Framework Core.
My goal is to make a web application like a news board, where articles can be stored and videos attached to them.
When I go into the update screen, make my changes, and save them, they save like they should:
But when I go back into the same article, edit it again, and save my changes again, I get a tracking error, and my changes are not saved:
How do I remove the duplicate tracking that is going on?
I have confirmed:
.AsNoTracking()
on itI am showing my code below. Please comment if there is anything else I should add
Pages/ArticleEdit.razor
:
@page "/article/edit/{ArticleId:int}"
@using EntitySQL.Data
@using EntitySQL.Models
@using EntitySQL.Services
@inject NewsServices Service
@inject NavigationManager Navigation
<PageTitle>Edit Article</PageTitle>
@if (@ArticleId == -1) {
<p>There is no article with that given id!</p>
} else {
<h1>Edit "@name"</h1>
<div class="grid-container-2">
<label for="name">Name:</label>
<input type="text" id="name" @bind-value="@name">
<label for="story">Story:</label>
<textarea id="story" rows="12" @bind="@story"/>
</div>
<div class="gap"/>
<div class="grid-container-3">
@for(int i = 0; i < videoList.Count; i++){
int index = i;
<section>
<p>Video @(index + 1):</p>
<label for="@("vname" + index)">Name:</label>
<input type="text" id="@("vname" + index)" @bind-value="@videoList[@index].Name">
<label for="@("vlink" + index)">Link:</label>
<input type="text" id="@("vlink" + index)" @bind-value="@videoList[@index].Link">
</section>
}
</div>
<div class="gap"/>
<button type="button" onclick="@(() => Submit())">Save changes!</button>
}
@code {
[Parameter]
public int ArticleId { get; set; }
string name;
string story;
List<Video> videoList;
protected override void OnInitialized()
{
Article article = Service.GetArticle(ArticleId);
name = article.Name;
story = string.Join(Constants.PARAGRAPH_SEPARATOR, article.Paragraphs);
// The below feature adds extra videos into it
/*
List<Video> videos = article.Videos?.ToList();
videoList = Enumerable.Repeat(new Video {Name = "", Link = ""}, Constants.MAX_VIDEOS).ToList();
for (int i = 0; i < videos.Count; i++)
{
Video video = videos[i];
videoList[i] = video;
}
*/
videoList = article.Videos?.ToList();
}
public void Submit()
{
ICollection<String> paragraphs = story.Split(Constants.PARAGRAPH_SEPARATOR, StringSplitOptions.RemoveEmptyEntries).ToList();
Service.UpdateArticle(ArticleId, name, paragraphs, videoList);
Navigation.NavigateTo($"/article/read/{ArticleId}");
}
}
Pages/ArticleRead.razor
:
@page "/article/read/{ArticleId:int}"
@using EntitySQL.Models
@using EntitySQL.Services
@inject NewsServices Service
<PageTitle>Read Article</PageTitle>
@if(@ArticleId == -1)
{
<p>There is no article with that given id!</p>
}
else
{
<h1>@article.Name</h1>
<a href="/article/edit/@ArticleId">Edit this article</a>
@foreach (string paragraph in @article.Paragraphs)
{
<p>@paragraph</p>
}
<section class="grid-container">
@foreach (Video video in @article.Videos)
{
<figure>
<h2>@video.Name</h2>
<a href="@video.Link">@video.Link</a>
</figure>
}
</section>
}
@code
{
[Parameter]
public int ArticleId { get; set; }
Article article;
protected override void OnInitialized()
{
article = Service.GetArticle(ArticleId);
}
}
NewsServices.cs
:
using EntitySQL.Data;
using EntitySQL.Models;
using Microsoft.EntityFrameworkCore;
namespace EntitySQL.Services;
public class NewsServices
{
private readonly NewsContext context;
/*
* Get an instance of Services
*/
public NewsServices(NewsContext con)
{
context = con;
}
/*
* Get the list of all of the articles in the database
*/
public ICollection<Article> GetAllArticles()
{
return context.Articles
.Include(a => a.Videos)
.AsNoTracking()
.ToList();
}
/**
* Add an article to the database
*/
public int AddArticle(Article article)
{
if (article.Name.Length == 0 || article.Paragraphs.Count == 0)
{
return Constants.INVALID_ARGUMENT;
}
context.Articles.Add(article);
context.SaveChanges();
return 0;
}
/*
* Get an article from the database
*/
public Article GetArticle(int Id)
{
return context.Articles
.Include(a => a.Videos)
.AsNoTracking()
.SingleOrDefault(a => a.Id == Id);
}
/**
* Update an article in the database
*/
public int UpdateArticle(int Id, string Name, ICollection<String> Paragraphs, ICollection<Video>? Videos)
{
Article article = context.Articles.Find(Id);
if (article == null)
{
return Constants.INVALID_ARGUMENT;
}
article.Name = Name;
article.Paragraphs = Paragraphs;
article.Videos = Videos;
context.SaveChanges();
return 0;
}
/**
* Delete an article from the database
*/
public int DeleteArticle(int Id)
{
Article article = context.Articles.Find(Id);
if (article == null)
{
return Constants.INVALID_ARGUMENT;
}
context.Articles.Remove(article);
context.SaveChanges();
return 0;
}
/**
* Add a video to the database
*/
public int AddVideo(string Name, string Link)
{
if (Name.Length + Link.Length == 0) //On x86 processors, adding the two and then comparing is probably faster than doing two comparisons
{
return Constants.INVALID_ARGUMENT;
}
context.Videos.Add(new Video
{
Name = Name,
Link = Link
});
context.SaveChanges();
return 0;
}
/*
* Get a video from the database
*/
public Video GetVideo(int Id)
{
return context.Videos
.AsNoTracking()
.SingleOrDefault(a => a.Id == Id);
}
/**
* Delete a video from the database
*/
public int DeleteVideo(int Id)
{
Video video = context.Videos.Find(Id);
if (video == null)
{
return Constants.INVALID_ARGUMENT;
}
context.Videos.Remove(video);
context.SaveChanges();
return 0;
}
}
With Blazor (server) the lifetime scope will generally be the session rather than the request, so passing entities around is a bit different than a web application which deserializes copies during POST, where in Blazor it will be maintaining state on the server.
This means that when you call code like:
context.Videos.Add(new Video
{
Name = Name,
Link = Link
});
or
context.Videos.Update(video);
These operations will cause the DbContext to start tracking that entity. This will interfere will future calls to Update an entity, even though you read that entity instance /w AsNoTracking()
which will give you an untracked copy read from the DB, when you go to save that untracked instance the DbContext is still tracking the instance you added / last updated. The simplest fix is wherever you call Add
or Update
(or Attach
), detach that instance when you are done.
var newVideo = new Video
{
Name = Name,
Link = Link
});
context.Videos.Add(newVideo);
context.SaveChanges()
context.Entry(newVideo).State = EntityState.Detached;
.. and ...
context.Videos.Update(video);
context.SaveChanges();
context.Entry(video).State = EntityState.Detached;
This will prevent the change tracker reference from messing up with previously attached references.