Adding and removing objects from your database
Okay, so how about adding and removing objects from the database?
Luckily, this is rather straightforward. Let's uncomment our POST request and start there.
The call to _repository.Create
will be replaced with two lines:
/// <summary>
/// Creates a new employee.
/// </summary>
/// <param name="employeeRequest">The employee to be created.</param>
/// <returns>A link to the employee that was created.</returns>
[HttpPost]
[ProducesResponseType(typeof(GetEmployeeResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> CreateEmployee([FromBody] CreateEmployeeRequest employeeRequest)
{
var newEmployee = new Employee
{
FirstName = employeeRequest.FirstName!,
LastName = employeeRequest.LastName!,
SocialSecurityNumber = employeeRequest.SocialSecurityNumber,
Address1 = employeeRequest.Address1,
Address2 = employeeRequest.Address2,
City = employeeRequest.City,
State = employeeRequest.State,
ZipCode = employeeRequest.ZipCode,
PhoneNumber = employeeRequest.PhoneNumber,
Email = employeeRequest.Email
};
_dbContext.Employees.Add(newEmployee);
await _dbContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetEmployeeById), new { id = newEmployee.Id }, newEmployee);
}
You can also do _dbContext.Add(obj)
to add the object.
Let's uncomment our test now and see if it works. They should be passing - that was easy!
Alright, let's do the same for the update endpoint, but this time we'll just edit the object inline, call SaveChangesAsync
, and see what happens.
/// <summary>
/// Updates an employee.
/// </summary>
/// <param name="id">The ID of the employee to update.</param>
/// <param name="employeeRequest">The employee data to update.</param>
/// <returns></returns>
[HttpPut("{id}")]
[ProducesResponseType(typeof(GetEmployeeResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> UpdateEmployee(int id, [FromBody] UpdateEmployeeRequest employeeRequest)
{
_logger.LogInformation("Updating employee with ID: {EmployeeId}", id);
var existingEmployee = await _dbContext.Employees.FindAsync(id);
if (existingEmployee == null)
{
_logger.LogWarning("Employee with ID: {EmployeeId} not found", id);
return NotFound();
}
_logger.LogDebug("Updating employee details for ID: {EmployeeId}", id);
existingEmployee.Address1 = employeeRequest.Address1;
existingEmployee.Address2 = employeeRequest.Address2;
existingEmployee.City = employeeRequest.City;
existingEmployee.State = employeeRequest.State;
existingEmployee.ZipCode = employeeRequest.ZipCode;
existingEmployee.PhoneNumber = employeeRequest.PhoneNumber;
existingEmployee.Email = employeeRequest.Email;
try
{
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Employee with ID: {EmployeeId} successfully updated", id);
return Ok(existingEmployee);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while updating employee with ID: {EmployeeId}", id);
return StatusCode(500, "An error occurred while updating the employee");
}
}
(This time, I used FindAsync
to get the object - up to you if you prefer that over SingleOrDefault
- but either way, make sure you check it for null
!)
Then we'll uncomment out the tests and... wait, a failure?!
Oh yeah, our validation isn't running because we commented that out as well. (See how tests protect you from forgetting important things?)
We'll refactor it to pull the Employee
object out of the database and check it there.
This is what our updated test looks like:
public class UpdateEmployeeRequestValidator : AbstractValidator<UpdateEmployeeRequest>
{
private readonly HttpContext _httpContext;
private readonly AppDbContext _appDbContext;
public UpdateEmployeeRequestValidator(IHttpContextAccessor httpContextAccessor, AppDbContext appDbContext)
{
this._httpContext = httpContextAccessor.HttpContext!;
this._appDbContext = appDbContext;
RuleFor(x => x.Address1).MustAsync(NotBeEmptyIfItIsSetOnEmployeeAlreadyAsync).WithMessage("Address1 must not be empty.");
}
private async Task<bool> NotBeEmptyIfItIsSetOnEmployeeAlreadyAsync(string? address, CancellationToken token)
{
await Task.CompletedTask; //again, we'll not make this async for now!
var id = Convert.ToInt32(_httpContext.Request.RouteValues["id"]);
var employee = await _appDbContext.Employees.FindAsync(id);
if (employee!.Address1 != null && string.IsNullOrWhiteSpace(address))
{
return false;
}
return true;
}
}
Run our tests and... everything is passing! But wait, how did EF Core know how to update the object?
Discussing the Change Tracker
By default, EF Core will track the state of the objects that are added to the context. This is done using the ChangeTracker
.
Change tracking is a feature of EF Core that allows you to track changes to your entities. It does this by comparing the current state of the entity to the original state of the entity when it was read from the database.
This makes using EF Core very convenient out of the box, but the Change Tracker has a few minor quirks that you should know about:
- It's slower than tracking changes manually (we'll demonstrate manual tracking shortly). If you load 10,000 entities using EF Core, it'll happily track them all - and then check to see if they should be updated once you call
SaveChangesAsync
. This can makeSaveChangesAsync
slower than it needs to be. - It's behavior is "magic" - and magic in dev land always is a red flag for Spencer. Sometimes, you may find that deep in the code you're writing, you're updating an entity you didn't mean to update. (Believe me, it's happened before.)
🌶️🌶️🌶️ Spencer recommends turning change tracking off when you can and manually tracking changes. Explicit code is better than implicit magic. That said, it's really up to you and your team. We're going to keep it off for the remainder of the course.
Let's do that.
We'll start by disabling change tracking in our EF Core configuration option, run the tests, and see what happens. (Again, tests make it easy to experiment because the feedback loop is so small - you make a change, run the tests, and see what breaks.)
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
Wait, nothing broke. Does it mean this change did nothing? No, it just means our tests aren't sufficient. Let's make a couple of changes to our test to get it to fail, then get it to pass again.
🌶️🌶️🌶️ Tests give you confidence in coding, but don't let it become OVERconfidence. Remember, tests can only test what you tell them to test.
[Fact]
public async Task UpdateEmployee_ReturnsOkResult()
{
var client = _factory.CreateClient();
var response = await client.PutAsJsonAsync("/employees/1", new Employee {
FirstName = "John",
LastName = "Doe",
Address1 = "123 Main Smoot"
});
response.EnsureSuccessStatusCode();
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var employee = await db.Employees.FindAsync(1);
Assert.Equal("123 Main Smoot", employee.Address1);
}
That's better. But now how do we get it to pass? We need to manually update the entity.
In our UpdateEmployee
method:
//this is the magic sauce
_dbContext.Entry(existingEmployee).State = EntityState.Modified;
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Employee with ID: {EmployeeId} successfully updated", id);
return Ok(existingEmployee);
Run the tests and... they pass!
There's just a couple more things you can do though. You can call AsTracking
to opt into change tracking right in the query:
var existingEmployee = await _dbContext.Employees
.AsTracking()
.SingleOrDefaultAsync(e => e.Id == id); //can't use FindAsync here because that's only available on DbSet<T>, not IQueryable<T>
You remove the Entry
call, run the tests, and... they pass!
Lastly, if you choose to keep change tracking on, you can gain a little performance for read-only scenarios by calling AsNoTracking
on the query:
var existingEmployee = await _dbContext.Employees.AsNoTracking();
Handy!
Removing objects
Removing objects from the database is just as easy. This feels like a good time to introduce a DELETE
endpoint!
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DeleteEmployee(int id)
{
var employee = await _dbContext.Employees.FindAsync(id);
if (employee == null)
{
return NotFound();
}
_dbContext.Employees.Remove(employee);
await _dbContext.SaveChangesAsync();
return NoContent();
}
You can write a couple of tests to make sure this works:
[Fact]
public async Task DeleteEmployee_ReturnsNoContentResult()
{
var client = _factory.CreateClient();
var newEmployee = new Employee { FirstName = "Meow", LastName = "Garita" };
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Employees.Add(newEmployee);
await db.SaveChangesAsync();
}
var response = await client.DeleteAsync($"/employees/{newEmployee.Id}");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
public async Task DeleteEmployee_ReturnsNotFoundResult()
{
var client = _factory.CreateClient();
var response = await client.DeleteAsync("/employees/99999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Run 'em all, and yay, they pass!