The problem
We ran into an interesting bug last week at work. We have a simple ASP.NET Core API that talks to a Postgres database using EF Core (for writes) and Dapper (for reads).
The following code is just a minimal repro, hence directly returning the database dtos and stuff like that from the action method, don't do that IRL :).
AppointmentDto.cs
public class AppointmentDto
{
public Guid Id { get; set; }
public string ExternalId { get; set; } = null!;
public Instant Start { get; set; }
public Instant End { get; set; }
}
private readonly IDatabaseConnection _db;
[HttpGet]
public async Task<List<AppointmentDto>> List()
{
var result = await _db.QueryAsync<AppointmentDto>("SELECT * From \"Appointment\"");
return result.ToList();
}
When testing this code using an integration test, everything worked flawlessly.
// Not actual code, just some psuedo
[Fact]
public async Task GET_ShouldReturnAppointmentsCorrectly()
{
var db = _fixture.CreateDbContext();
db.Appointments.Add(new Appointment(.......))
db.Appointments.Add(new Appointment(.......))
db.Appointments.Add(new Appointment(.......))
await db.SaveChangesAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/"appointments");
var client = _fixture.CreateClient();
var jsonDocument = await client.ExecuteAsJsonDocument(request);
jsonDocument.RootElement.GetProperty("data").EnumerateArray().Count.ShouldBe(3);
}
The test populates a real Postgres database with some data using a DbContext.
It then sends a request towards the API and asserts that 3 items are returned, nothing fancy.
However, when calling the same method via Postman, it didn't work, we got the following error:
InvalidCastException: Invalid cast from 'System.DateTime' to 'NodaTime.Instant'.
Weird?
The solution
Since we are using NodaTime, our PostregressDbContext (🤣) is configured like this.
services.AddDbContext<PostregressDbContext>((provider, options) =>
{
options.UseNpgsql(connectionString.Value, b => b.UseNodaTime());
});
This configures the DbContext (EF Core) for NodaTime. When injecting the PostregressDbContext
somewhere, the factory method will be run. This is key here. This is why our test works but Postman fails.
During the arrange phase in our test we are populating the database via DbContext, so the method above will run.
Now, you might ask, why does that solve the problem? You still haven't configured DAPPER for NodaTime?.
Nope, that's true, I haven't configured Dapper for NodaTime. But the UseNodaTime
method has!
When decompiling the UseNodaTime
method we get the following code:
public static NpgsqlDbContextOptionsBuilder UseNodaTime(
this NpgsqlDbContextOptionsBuilder optionsBuilder)
{
Check.NotNull(optionsBuilder, nameof(optionsBuilder));
// TODO: Global-only setup at the ADO.NET level for now, optionally allow per-connection?
NpgsqlConnection.GlobalTypeMapper.UseNodaTime();
var coreOptionsBuilder = ((IRelationalDbContextOptionsBuilderInfrastructure)optionsBuilder).OptionsBuilder;
var extension = coreOptionsBuilder.Options.FindExtension<NpgsqlNodaTimeOptionsExtension>()
?? new NpgsqlNodaTimeOptionsExtension();
((IDbContextOptionsBuilderInfrastructure)coreOptionsBuilder).AddOrUpdateExtension(extension);
return optionsBuilder;
}
The code that directly follows the TODO comment is the interesting bit.
NpgsqlConnection.GlobalTypeMapper.UseNodaTime()
This will configure the NpgsqlConnection to use NodaTime.
Test worked because
- We used DbContext to populate our database with test data
- Since we used DbContext, the
AddDbContext
ran and configured the DbContext (AND the NpsqlConnection used by Dapper).
Postman failed because
- We didn't use a DbContext anywhere so the
AddDbContext
method never fired. IF we had called any OTHER endpoint that used a DbContext and THEN called the endpoint, the code would've worked as-is.
The fix
Don't forget to configure Dapper for NodaTime... :)
// Registers a IDbConnection to be used by Dapper
services.AddScoped<IDbConnection>(_ => new NpgsqlConnection(connectionString.Value));
// Add this line to configure the NpgsqlConnection to use NodaTime
NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); <-- The fix!