Value Generators in EF Core
Introduction
EF Core has the concept of value generators. Very simply, they provide values for properties, either the primary key or another one.
Wasn't too long ago that this feature - configurable value generators - was missing from EF Core, but now we have it, and it works well. We still don't have all of the id generators offered by NHibernate, but, we have something, and, what's best, we can build our own!
Strategies versus Value Generators
Before value generators were around, we could specify a generation or update strategy for properties, like the id. Strategies were a hint for the database provider, they didn't exactly specify how the value would be generated. The strategies are:
- None: the value is never generated
- Identity: the value is generated only when the record is first inserted
- Computed: the value is generated when the row is either inserted or modified
Usually, Identity meant that something like auto-increment or SQL Server IDENTITY will be used, when referring to numeric values, and when applied to Guids, it's something like NEWID() function for SQL Server, or similar in other databases.
The strategy could either be applied through the [DatabaseGenerated] attribute or by code:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MyEntity>()
.Property(x => x.Id)
.ValueGeneratedNever();
base.OnModelCreating(modelBuilder);
}
The methods to use for code configuration are:
- ValueGeneratedNever(): value is never generated, uses what is provided, both for inserts and updates
- ValueGeneratedOnAdd(): value is generated for inserts only
- ValueGeneratedOnAddOrUpdate(): value is generated for inserts and updates
- ValueGeneratedOnUpdate(): value is generated for updates only
- ValueGeneratedOnUpdateSometimes(): value is generated for updates only if it has changed
Built-in Value Generators
Value generators allow us to be specific about how the values will be generated. The base class for a value generator is ValueGenerator, but most likely, we will use ValueGenerator<T>, if we want to build our own.
Built-in generators are:
Generator | Generated Type | Description |
BinaryValueGenerator | byte[] | Generates a Guid using Guid.NewGuid() and returns the value as a byte[] by calling Guid.ToByteArray() |
GuidValueGenerator | Guid | Generates a Guid using Guid.NewGuid() |
HiLoValueGenerator<TValue> | T | Generates an integral number using the HiLo algorithm using a database sequence. For databases that support sequences only |
SequentialGuidValueGenerator | Guid | Generates a sequential Guid |
StringValueGenerator | String | Generates a Guid using Guid.NewGuid() and returns it as a String |
TemporaryGuidValueGenerator | Guid | Generates a temporary Guid using Guid.NewGuid() but it will be replaced by a server-provided value |
A value generator is associated with a property in one of a couple of ways, like as a generic parameter:
modelBuilder.Entity<MyEntity>()
.Property(x => x.Id)
.HasValueGenerator<GuidValueGenerator>();
Or as a Type:
modelBuilder.Entity<MyEntity>()
.Property(x => x.Id)
.HasValueGenerator(typeof(GuidValueGenerator));
Or, if we need to know, on the generator, the property and type it will generate for:
modelBuilder.Entity<MyEntity>()
.Property(x => x.Id)
.HasValueGenerator((prop, type) => new SomeValueGenerator(prop, type));
Using a Convention to Set the Value Generator
We can define a value generator from a convention (IModelFinalizingConvention), which runs after the entity type configuration occurs:
class PrimaryKeyConvention(string? idPropertyName = null) : IModelFinalizingConvention { public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context) { foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) { var key = entityType.FindPrimaryKey(); var prop = (key!.Properties.Count == 1 || string.IsNullOrWhiteSpace(idPropertyName)) ? key!.Properties.Single() : key!.Properties.Single(x => x.Name == idPropertyName); if (prop.PropertyInfo!.PropertyType == typeof(string)) { var factory = prop.GetValueGeneratorFactory(); if (factory == null) { prop.SetValueGeneratorFactory(static (prop, type) => new GuidValueGenerator()); } } } } }
This example allows us to pass an id property name (e.g., "Id"), or just use the single property column. This convention first checks if a value generator is already assigned to the property (GetValueGeneratorFactory).
Conventions are added to the Conventions collection of ModelConfigurationBuilder, generally on method DbContext.ConfigureConventions:
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(new ProcessKeyConvention("Id"));
base.ConfigureConventions(configurationBuilder);
}
Custom Generators
A custom generator can either perform generation on the client-side (e.g., for example, generating a GUID using the Guid class), but it can also access the database. When implementing a generator we should inherit from ValueGenerator<T>, providing the template parameter (e.g., int, string, etc), and, at the very least, need to implement the Next method and the GeneratesTemporaryValues property.
Next is the one that actually returns the generated value. It receives an EntityEntry parameter, from where we can get the model information, current status for the property and record for which we are generating the value, and also the DbContext, which we can use to go to the database (I'll show an example in a moment).
GeneratesTemporaryValues is used to tell EF Core if the value that is being generated should be replaced (and obtained from) in the database. One example of a temporary value is generating negative numbers for a key and the foreign keys, and then replacing it by something on the database. A final value, such as a Guid, won't be temporary.
GeneratesStableValues is an virtual property inherited from ValueGenerator, which defaults to false, and it is a hint for EF Core that the algorithm for generating a value will be the same for subsequent invocations of it for the same property and entity. Generally safe to leave it as that.
A Simple Generator that Wraps an Existing One
Consider the case where we need a Guid as a string:
public class SequentialGuidAsStringValueGenerator : ValueGenerator<string> { private readonly SequentialGuidValueGenerator _guidValueGenerator = new SequentialGuidValueGenerator();
public override bool GeneratesTemporaryValues => _guidValueGenerator.GeneratesTemporaryValues; public override string Next(EntityEntry entry) => _guidValueGenerator.Next(entry).ToString(); }
This is a very simple adaptation of the SequentialGuidValueGenerator class that returns the generated Guid as a string by delegating to the inner generator and then converting. Easy to understand, I hope.
A Simple Time-based Generator
Or, for a counter that just returns the current time as milliseconds:
public class CurrentTimeMillisValueGenerator : ValueGenerator<long> { public override bool GeneratesTemporaryValues => false; public override long Next(EntityEntry entry) => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); }
Please note that this one is not safe for using in concurrent scenarios, where more than one process at a time are inserting records.
A Value Generator with a Shared Table for Ids
Now let's consider a more advanced scenario: we want to have a table that lists the next id for a given name, which can be an entity name or something else. Multiple entities can get their ids from the same name, or get their own.
There is a private TableId entity that looks like this:
record TableId
{
public int Id { get; set; }
public required string Name { get; set; }
public long Value { get; set; }
}
We need to map this entity to the database, and we also add an annotation to the entity, which, by default, will be the entity's name:
public static class ModelBuilderExtensions { public static ModelBuilder AddTableId<T>(this ModelBuilder modelBuilder, string? name = null) where T : class { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.Entity<TableId>() .HasIndex(x => x.Name) .IsUnique(); modelBuilder.Entity<T>().HasAnnotation("TableIdName", name ?? typeof(T).Name); return modelBuilder; } }
The actual table on the database will look like this (in SQL Server):
Column | Type |
Id | INT (PRIMARY KEY IDENTITY) |
Name | NVARCHAR(DEFAULT) |
Value | BIGINT |
So, for example, we can have records like:
Id | Name | Value |
1 | EntityA | 1 |
2 | EntityB | 5 |
3 | SharedName | 10 |
In this case, records with Names "EntityA" and "EntityB" are specific to an entity, while "SharedName" is shared amongst different entities - those passing this name to AddTableId().
We need an extension method to tell EF Core to use this new value generator for a property:
public static class PropertyBuilderExtensions { public static PropertyBuilder<T> HasTableIdValueGenerator<T>(this PropertyBuilder<T> builder) where T : INumber<T> { ArgumentNullException.ThrowIfNull(builder); return builder.HasValueGenerator<TableIdValueGenerator<T>>(); } }
It forces the generated value to be a number (INumber<T>) and just calls HasValueGenerator with our own generator.
As for the TableIdValueGenerator itself, here is the code:
public class TableIdValueGenerator<T> : ValueGenerator<T> where T : INumber<T> { public override bool GeneratesTemporaryValues => false; public override T Next(EntityEntry entry) { var context = entry.Context; var tableIdName = entry.Metadata.FindAnnotation("TableIdName")?.Value?.ToString(); if (!string.IsNullOrWhiteSpace(tableIdName)) { using var scope = context.CreateScope(); using var clone = (DbContext) scope.ServiceProvider.GetService(context.GetType())!; using var tx = clone.Database.BeginTransaction(); var tableIdValue = clone.Set<TableId>() .Where(x => x.Name == tableIdName) .SingleOrDefault() ?? new TableId { Name = tableIdName, Value = 0 }; tableIdValue.Value++; T value = (T) Convert.ChangeType(tableIdValue.Value, typeof(T)); if (tableIdValue.Value == 1) { //new entry clone.Set<TableId>().Add(tableIdValue); } clone.SaveChanges(true); tx.Commit(); return value; } throw new InvalidOperationException($"Entity {entry.Entity.GetType().FullName} is not configured for Table Id"); } }
Explanation:
- The current context is obtained from the EntityEntry parameter
- We try to get the annotation that contains the Name to use, or use the entity's name if not present
- Creates a new scope and, from it, instantiate a DbContext of the same type as the current one
- A new transaction is created
- Tries to get the current Value for the record with the Name we want
- If not found, set it to 0, increment it in any case
- If it's a new record, persist it
- Saves changes and commits transaction
- Returns the current Value
- The scope and the instantiated DbContext are disposed of with the method
Conclusion
This post went a little further than just scratching the surface of EF Core value generators, and presented some real-life examples. I hope I was able to explain about the importance of understanding and leveraging this powerful extension mechanism that EF Core provides.
Comments
Post a Comment