I’ve been diving into Entity Framework Core 5 and learning all I can about the object-relational mapper’s (ORM) inner-workings. With EF Core 5, the newer ORM has learned from its namesake Entity Framework’s past sins. With extensibility points and smarter defaults right out of the box, EF Core 5 puts us in a position to succeed, and we’re less likely to make mistakes that only reveal themselves in mission-critical situations.
This post will look at an extensibility point that many folks will appreciate, especially when dealing with C# objects that we can manage with identifiers. Let’s dive right into Value Converters for EF Core 5.
What Is A Value Converter?
When dealing with EF Core data modeling, we may store data differently than how we handle it within the context of our application.
Value converters allow property values to be converted when reading from or writing to the database. This conversion can be from one value to another of the same type (for example, encrypting strings) or from a value of one type to a value of another type (for example, converting enum values to and from strings in the database.) –Microsoft Docs
We can use value converters to optimize database storage, write performance, and read latency, in addition to optimizing memory in our application.
Let’s look at how we use a value converter to optimize what would otherwise be duplicate information in our database.
Working With Value Converters
Let’s start with a database model of Movie
, which will contain a StreamingService
record property. While it may seem like a new streaming service is launching every few minutes, we can pretend that this data set is finite.
public class Movie
{
public int Id { get; set; }
public string Name { get; set; }
public StreamingService StreamingService { get; set; }
}
public record StreamingService(string Id, string Description)
{
public static StreamingService Netflix { get; }
= new("netflix", "Netflix streaming service");
public static StreamingService Hulu { get; }
= new("hulu", "Hulu");
public static StreamingService HBOMax { get; }
= new("hbo-max", "HBO Max");
public static StreamingService DisneyPlus { get; }
= new("disney-plus", "Disney+");
public static StreamingService AppleTvPlus { get; }
= new("apple-tv-plus", "Apple TV+");
public static IReadOnlyList<StreamingService> All = new[] {
Netflix,
Hulu,
HBOMax,
DisneyPlus,
AppleTvPlus
};
}
It would be wasteful on the network and storage to transmit more than the Id
to our database engine. We can store the rest of the information in our C# record instances. Not to mention that duplicating these read-only entities would add to our application’s memory usage.
Let’s see how to tell EF Core to translate our record instances into string
values that we can store in our database.
public class Database
: DbContext
{
public DbSet<Movie> Movies { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine)
.UseSqlite("Data Source=movies.db");
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var converter = new ValueConverter<StreamingService, string>(
from => from.Id,
to => StreamingService.All.FirstOrDefault(s => s.Id == to)
);
modelBuilder
.Entity<Movie>()
.Property(m => m.StreamingService)
.HasConversion(converter);
base.OnModelCreating(modelBuilder);
}
}
In the OnModelCreating
, we can define a new ValueConverter
instance with two Func
definitions. EF Core 5 will use these two functions to convert to and from our a StreamingService
record to our target column, which is of type string
. Using our ModelBuilder
, we can tell EF Core that our StreamingService
property can convert using our new ValueConverter
instance.
Let’s generate our migration to see that EF Core managed to understand our mapping correctly.
public partial class initial : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Movies",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
StreamingService = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Movies", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Movies");
}
}
As we can see, the StreamingService
column is a type of TEXT
since we are using SQLite in this case.
Writing Queries With Value Converters
Our value converter expects us to use StreamingService
instances in our LINQ queries.
// 👎 wrong
var results =
await catalog
.Movies
.Where(m => m.StreamingService.Id == StreamingService.Netflix.Id)
.ToListAsync();
// 👍 correct
var results =
await catalog
.Movies
.Where(m => m.StreamingService == StreamingService.Netflix)
.ToListAsync();
It is essential to remember that EF Core only knows about our top-level entity. It doesn’t know that StreamingService
has an Id
or Description
property. Trying to use any of the StreamingService
properties in a query will end in an exception.
Let’s see the complete running sample.
using System;
using System.Linq;
using EFCoreValueConverters;
using Microsoft.EntityFrameworkCore;
// save new movie
await using var catalog = new Database();
catalog.Movies.Add(new Movie {
Name = "Birdbox",
StreamingService = StreamingService.Netflix
});
await catalog.SaveChangesAsync();
// retrieve
await using var browser = new Database();
var results =
await catalog
.Movies
.Where(m => m.StreamingService == StreamingService.Netflix)
.ToListAsync();
foreach (var result in results) {
Console.WriteLine($"{result.Name} now streaming on {result.StreamingService.Description}!");
}
Parsing through the logging of EF Core, we can see a few things. First, let’s look at how we saved our movie.
Executing DbCommand [Parameters=[@p0='Birdbox' (Size = 7), @p1='netflix' (Size = 7)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Movies" ("Name", "StreamingService")
VALUES (@p0, @p1);
SELECT "Id"
FROM "Movies"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();
We can see that netflix
was used as a parameter, meaning EF Core mapped our StreamingService
record correctly. Next, let’s see our read query.
Executed DbCommand (1ms) [Parameters=[@__Netflix_0='netflix' (Size = 7)], CommandType='Text', CommandTimeout='30']
SELECT "m"."Id", "m"."Name", "m"."StreamingService"
FROM "Movies" AS "m"
WHERE "m"."StreamingService" = @__Netflix_0
Remember, our LINQ where
criteria used an instance of StreamingService
.
var results =
await catalog
.Movies
.Where(m => m.StreamingService == StreamingService.Netflix)
.ToListAsync();
Finally, we see our result.
Birdbox now streaming on Netflix streaming service!
Awesome!
Conclusion
Value Converters allow us to map relatively complex types that might not be supported by our database engines. In the example above, we were able to store important user information in our application layer while storing the minimum value necessary to reconstitute our models. Reusing our read-only records also gains us memory savings in mission-critical situations. As we saw in the sample, defining value converters is straightforward.
Value Converters’ added benefit is that it allows third-party vendors to integrate directly into EF Core, allowing the ecosystem to thrive more than the previous Entity Framework 6 architecture.
If you’re using Value Converters today, please leave a comment below and let me know how.
And if you found this post helpful, please share it with coworkers and friends. As always, thank you.