EF Core State Validation
Introduction
How many times have you tried to save changes to your context only to get an exception about validation problems, like a required property being null? I certainly have, and because of this, I came up with a solution to figure out what is happening. I will also provide some possible solutions for the problem of fully validating an entity using the Data Annotations API (a post on general validation with Data Annotations here).
State Validation
All the entities that are currently tracked by a DbContext have some state known to the context. This state includes:
- The entity's state (State) in regards to persistence (Modified, Added, Deleted, Unchanged)
- Each property's values (Property), both current (Current) and original (Original)
- The entity itself (Entity)
- Any navigation properties (References, Navigations)
So, we can iterate through each tracked entry by means of the Entries, Entries<T>, or Entry methods:
var entity = ...; var entry = ctx.Entry(entity); var entries = ctx.ChangeTracker.Entries;
If the entity is not known to the context, it's state will be Detached, and it won't be possible to get the properties' values. For those that are known, we can validate their properties like this:
public static class DbContextExtensions
{
public static IEnumerable<ValidationResult> ValidateState(this DbContext context) where TEntity : class where TContext : DbContext
{
ArgumentNullException.ThrowIfNull(context);
var entries = context.ChangeTracker
.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified);
foreach (var entry in entries)
{
foreach (var prop in entry.Properties)
{
var isRequired = (prop.Metadata.IsNullable == false);
var value = prop.CurrentValue;
if (isRequired && (value == null || (value is string strValue1 && strValue1.Length == 0)))
{
yield return new ValidationResult($"Property '{entry.Metadata.ClrType}.{prop.Metadata.Name}' is null for entity '{entry.Entity}' with state '{entry.State}'.", [prop.Metadata.Name]);
}
else if (prop.Metadata.GetMaxLength() > 0 && value is string strValue2 && strValue2?.Length > prop.Metadata.GetMaxLength())
{
yield return new ValidationResult($"Property '{entry.Metadata.ClrType}.{prop.Metadata.Name}' has length greater than the allowed {prop.Metadata.GetMaxLength()} for entity '{entry.Entity}' with state '{entry.State}'.", [prop.Metadata.Name]);
}
}
}
}
}
We loop through all modified and added entities, look into their properties' values, and from its metadata we find out if they're mandatory (IsNullable) or what's the maximum length, if provided (GetMaxLength). To use:
var entity = new Person { Name = null };
entity.Persons.Add(entity);
var errors = entity.ValidateState(); //validation error: Property 'Person.Name' is null for entity '{ Name = , ... }''
For each validation error we return a ValidationResult instance with a message and the faulting property name.
For the time being, only two validations are implemented, as you can see:
- Check for null values where the property is required
- Check for string values whose length exceeds the maximum allowed
Entity Validation
You may remember my post on entity validation using Data Annotations, I also wrote another one on entity validation on EF Core, which I will extend here.
EF Core does not feature entity validation out of the box, but there are ways to add it:
- By overriding SaveChanges/SaveChangesAsync and plug our validation there
- By using a custom interceptor such as SaveChangesInterceptor
- By hooking an event handler to the SavingChanges event
Options 1 is very intrusive, 2 is less so, and 3 probably provides the cleanest approach. Let's go through them one by one. There are other options, but they are far too convoluted, and I won't go into them.
In any case, all validations will be executed by calling Validator.ValidateObject, which throws a ValidationException when validation fails.
Overriding SaveChanges/SaveChangesAsync
That one is easy to implement but does not scale so well - we need to do the same on all DbContext classes:
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
CoreSaveChanges();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken)
{
CoreSaveChanges();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void CoreSaveChanges()
{
foreach (var entry in ChangeTracker.Entries().Where(x => x.State is EntityState.Modified or EntityState.Added))
{
Validator.ValidateObject(entry.Entity, new ValidationContext(entry.Entity), true);
}
}
All good, now, when we save the context, synchronously or asynchronously, validation kicks in and an exception is through in case of a validation error. As you can see, this one requires changes to the DbContext, which we probably don't like to do.
Adding an Interceptor
This option involves adding a custom interceptor to the context. This can be done:
- At registration time using AddDbContext, AddDbContextFactory, AddDbContextPool, AddPooledDbContextFactory, or ConfigureDbContext
- From inside the OnConfiguring method
For the former option, here's a simple example with AddDbContext:
builder.Services.AddDbContext<MyContext>(options =>
{
options.AddInterceptors(new ValidationInterceptor());
});
For the latter:
protected override void OnConfiguring(DbContextOptionsBuilder builder)
{
builder.AddInterceptors(new ValidationInterceptor());
base.OnConfiguring(builder);
}
The actual validator, ValidationInterceptor, inherits from SaveChangesInterceptor, and can be implemented like this:
public class ValidationInterceptor : SaveChangesInterceptor
{
private void CoreValidate(DbContext ctx)
{
foreach (var entry in ctx.ChangeTracker.Entries().Where(x => x.State is EntityState.Modified or EntityState.Added))
{
Validator.ValidateObject(entry.Entity, new ValidationContext(entry.Entity), true);
}
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
CoreValidate(eventData.Context!);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
CoreValidate(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
The interceptor must override both SavingChanges and SavingChangesAsync, because one or the other will be called, depending on wether we call SaveChanges or SaveChangesAsync.
Hooking to an Event
This one is the cleanest, in my opinion. We have to hook to the SavingChanges event of the DbContext, which can be done by anyone:
DbContext ctx = ...;
ctx.SavingChanges += (args) => ValidateContext(ctx);
static void ValidateContext(DbContext ctx)
{
foreach (var entry in ctx.ChangeTracker.Entries().Where(x => x.State is EntityState.Modified or EntityState.Added))
{
Validator.ValidateObject(entry.Entity, new ValidationContext(entry.Entity), true);
}
};
Pretty simple, don't you think? The ValidateContext method can be put in some shared class; it can also be used by any of the other two alternatives.
Conclusion
As you can see, EF Core provides almost limitless extension points, and it's always possible to add any missing functionality! Stay tuned for more EF Core posts coming soon!
Comments
Post a Comment