Search code examples
entity-framework-coreblazor

Why does updating an object more than once throw a tracking error?


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:

enter image description here

enter image description here

enter image description here

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:

enter image description here

How do I remove the duplicate tracking that is going on?

I have confirmed:

  • I am using scoped rather than singleton on my service
  • Each get call on the database has .AsNoTracking() on it
  • The input ids are different for each input field

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

Solution

  • 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.