Search code examples
c#.netasp.net-mvcentity-framework-corefluentvalidation

How to use DbContext with FluentValidator


I am getting the error below when I try to edit a user. I have no problem creating or deleting users. As far as I understand, somehow uniqueMail method inside fluentvalidator class and Edit method inside UserController class doesn't go well together.

Exception thrown at the:

_context.Update(user)

I think problem occurs due to incorrect usage of dbcontext inside the fluentvalidator class. I would say something is off with the disposal of the context within fluentvalidator class.

Could you please help me understand what is wrong?

I also added the StartUp class in case something is off with the implementation of FluentValidation

Error:

System.InvalidOperationException: The instance of entity type 'User' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.

FluentValidatorClass:

using FluentValidation;
using System.Linq;

namespace UserDirectory.Models
{
  public class UserValidator : AbstractValidator<User>
  {
    readonly MvcUserContext _context;
    public UserValidator(MvcUserContext context)
    {
        _context = context;
        RuleFor(x => x.Email).Must(uniqueMail).WithMessage("This email is already in use");
        RuleFor(x => x.Phone).Must(uniquePhone).WithMessage("This phone is already in use");
    }

    private bool uniqueMail(User user, string email)
    {
        var mail = _context.User.Where(x => x.Email.ToLower() == user.Email.ToLower()).SingleOrDefault();

        if (mail == null) return true;
        return mail.Id == user.Id;
    }

    private bool uniquePhone(User user, string phone)
    {
        var phoneNumber = _context.User.Where(x => x.Phone == user.Phone).SingleOrDefault();

        if (phoneNumber == null) return true;
        return phoneNumber.Id == user.Id;
    }
}
}

UserController.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using UserDirectory.Models;

namespace UserDirectory.Controllers
{
    public class UsersController : Controller
    {
        private readonly MvcUserContext _context;

        public UsersController(MvcUserContext context)
        {
            _context = context;
        }

        // GET: Users
        public async Task<IActionResult> Index(string sortOrder)
        {

            ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
            ViewData["SurnameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "surname_desc" : "";
            var users = from u in _context.User
                        select u;

            switch (sortOrder)
            {
                case "surname_desc":
                    users = users.OrderByDescending(u => u.Surname);
                    break;
                case "name_desc":
                    users = users.OrderByDescending(u => u.Name);
                    break;
            }

            return View(await users.AsNoTracking().ToListAsync());
        }

        // GET: Users/Details/5
        public async Task<IActionResult> Details(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var user = await _context.User
                .FirstOrDefaultAsync(m => m.Id == id);
            if (user == null)
            {
                return NotFound();
            }

            return View(user);
        }

        // GET: Users/Create
        public IActionResult Create()
        {
            return View();
        }

        // POST: Users/Create
        // To protect from overposting attacks, enable the specific properties you want to bind to.
        // For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create([Bind("Id,Name,Surname,Email,BirthDate,Phone,Location")] User user)
        {
            if (ModelState.IsValid)
            {
                _context.Add(user);
                await _context.SaveChangesAsync();
                return RedirectToAction(nameof(Index));
            }
            return View(user);
        }

        // GET: Users/Edit/5
        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var user = await _context.User.FindAsync(id);
            if (user == null)
            {
                return NotFound();
            }
            return View(user);
        }

        // POST: Users/Edit/5
        // To protect from overposting attacks, enable the specific properties you want to bind to.
        // For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(int id, [Bind("Id,Name,Surname,Email,BirthDate,Phone,Location")] User user)
        {
            if (id != user.Id)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    _context.Update(user);
                    await _context.SaveChangesAsync();
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!UserExists(user.Id))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }
                return RedirectToAction(nameof(Index));
            }
            return View(user);
        }

        // GET: Users/Delete/5
        public async Task<IActionResult> Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var user = await _context.User
                .FirstOrDefaultAsync(m => m.Id == id);
            if (user == null)
            {
                return NotFound();
            }

            return View(user);
        }

        // POST: Users/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(int id)
        {
            var user = await _context.User.FindAsync(id);
            _context.User.Remove(user);
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }

        private bool UserExists(int id)
        {
            return _context.User.Any(e => e.Id == id);
        }
    }
    }

StartUp.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.EntityFrameworkCore;
using FluentValidation.AspNetCore;
using UserDirectory.Models;

namespace UserDirectory
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();
            services.AddMvc().AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Models.UserValidator>());
            //services.AddTransient<FluentValidation.IValidator<User>, UserValidator>();

            services.AddDbContext<MvcUserContext>(options =>
                    options.UseSqlite(Configuration.GetConnectionString("MvcUserContext")));
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
    }

Solution

  • On Validator you select from user and Track the same entity you would update, this cause the issue.

    Instead of

    var mail = _context.User.Where(x => x.Email.ToLower() == user.Email.ToLower()).SingleOrDefault();
    

    Write

    // The AsNoTracking do not add to the context the entity you select
    var mail = _context.User.AsNoTracking().Where(x => x.Email.ToLower() == user.Email.ToLower()).SingleOrDefault();
    
    

    With the Where clause you materialize the entire entity User (adding AsNoTracking do not add it to context), but if you want to check only if the entity exists you instead use the Any clause that translates to a SQL like this

    SELECT TOP 1
    FROM TABLE
    WHERE Email = 'myEmail'
    

    The result is not a User entity and thus is not added to context.

    return _context.User.Any(x => x.Email.ToLower() == user.Email.ToLower());
    

    This is must be done for uniqueMail and uniquePhone

    Any vs First vs Exists

    Any on EF