How to nicely solve self-referencing object update error?

Miles 0 Reputation points
2023-11-08T10:10:32.9833333+00:00

In my project I have a bunch of models, that are all derived from the class 'Entity'. Simplified, this means they all have the following properties:

public Guid Id { get; set; }
public DateTime Created { get; set; }
public required User CreatedBy { get; set; }
public DateTime? Modified { get; set; }
public User? ModifiedBy { get; set; }

Now in all of my controllers I have some basic CRUD operations (GetAll, GetById, Post, Put, Delete). Every time any Post or Put request gets called, it requires a user id for the acting user:

  • Post request: We create a new object with the given Dto from the request body, insert the acting user into CreatedBy (and set Created to DateTime.Now) and simply add the new data via AddAsync() and save the changes.
  • Put request: We get the old object from the database, modify it, insert the acting user into ModifiedBy (and set Modified to DateTime.Now), call Update() and again save the changes.

(I am using PostreSQL if that is of any importance here.)

This all in all works fine, until it comes to the UsersController. Early on I realised that this way of implementing it made it nearly impossible to add a new User without already having one in the database, since the CreatedBy property is always required, basically creating an infinite self-referencing loop. I fixed the issue by seeding an admin user in a migration using migrationBuilder.InsertData(), since that way I can simply fill the column CreatedById instead of needing an entire User object in the CreatedBy property. At this point I was sure I had avoided any big trouble, since I now always had a user I could reference. Alas, this post exists, meaning I have once again run into a seemingly impossible to solve issue.

An existing user should be able to modify itself. This needs to be possible for many reasons, e.g. changing the Name, Password or Mail, updating the LastLogin on login, etc... Except whenever the Put request is called with a User modifying itself, it always returns a 500 response/fails at Update() with the following error:

{"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."}

Here is the method I use:

public async Task<IActionResult> PutAsync(Guid id, [FromBody] UserDto userDto, [FromQuery] Guid actingUserId)
{
    // I am validating the given id (not acting user id) here, specifics unimportant
    try
    {
        User? modifyingUser = await _context.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Id == actingUserId);
        if (modifyingUser == null)
        {
            _logger.LogDebug("Given UserId is invalid! -> BadRequest");
            return BadRequest();
        }
        User? userToUpdate = await _context.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Id == userDto.Id);
        if (userToUpdate == null)
        {
            _logger.LogDebug("Given UserId was not found in Database. -> NotFound");
            return NotFound();
        }
        Role? role = null;
        if (userDto.Role != null)
        {
            role = await _context.Roles.SingleOrDefaultAsync(x => x.Id == userDto.Role.Id);
            if (role == null)
            {
                _logger.LogDebug("Given RoleId is invalid! -> BadRequest");
                return BadRequest();
            }
        }
        User modifiedUser = _userService.ToModifiedUser(userDto, userToUpdate, modifyingUser, role); 
        UserDto modifiedUserDto = _userService.ToUserDto(modifiedUser, modifiedUser.Role != null ? _roleService.ToRoleDto(modifiedUser.Role) : null);
        _context.Users.Update(modifiedUser);
        _context.SaveChanges();
        _logger.LogDebug("Updated User: {ModifiedUser}", modifiedUser);
        return Ok(modifiedUserDto);
    }
    catch (Exception)
    {
        _logger.LogDebug("Ran into an Error while updating User!!");
        return StatusCode(StatusCodes.Status500InternalServerError, "Error updating Data");
    }
}

As stated before, the response is simply a 500 status code (plus the "Error updating Data" message), since the error happens in the try catch, but after debugging a bit I figured out, that the actual error is as mentioned above.

I've also tried these things hoping they might solve the issue:

  • Played around with AsNoTracking() on the different _context calls, it fails either way.
  • Used _context.ChangeTracker.Clear() right before _context.Users.Update(modifiedUser), did not fix it.
  • Manually added the modifiedUser to the ChangeTracker via Attach() and set its status to modified and then called SaveChanges(), fails aswell.

This has led me to believe that simply the fact that the User Object is referenced in itself is what breaks the ChangeTracker, since it's trying to track the "main" User and the ModifiedBy User, which have the same id since they are the same object. This does make sense to me, however I cannot think of a way to update the data nicely now.

I have thought about trying the following to work around the issue (all of those would only apply if actingUserId is the same as id in the request):

  • Use raw SQL to simply insert the Id into the ModifiedById column instead of needing an entire object (similar to the seeding solution from earlier)
  • Set the ModifiedBy User to Null (but update Modified to the correct time)
  • Seed a standard user and use that one instead

The first one seems bad, since I've been taught not to use raw SQL queries like that while using the Entity Framework and DbContext.

Both of the latter aren't really "clean" aswell, as they would insert wrong data in the database. I could maybe run something in the background that checks if there is data that needs correcting and uses SQL queries to update the ModifiedById to the Id of the user (depending on the approach either checks for the standard user or entries with ModifiedById as Null but Modified not Null). If that service fails however we are once again left with incorrect data in the database.

Is there any better way to work around the issue? If so, how?

ASP.NET Core
ASP.NET Core
A set of technologies in the .NET Framework for building web applications and XML web services.
4,346 questions
C#
C#
An object-oriented and type-safe programming language that has its roots in the C family of languages and includes support for component-oriented programming.
10,581 questions
ASP.NET API
ASP.NET API
ASP.NET: A set of technologies in the .NET Framework for building web applications and XML web services.API: A software intermediary that allows two applications to interact with each other.
314 questions
{count} votes