Read-Only Entities in EF Core
Introduction
One feature that has been conspicuously missing from EF Core is read-only entities, meaning, the possibility to mark an entity type as read-only, in which case, no changes to its instances (inserts, updates, deletes) will be allowed; data needs to be added to the database by other means. There is currently an open ticket to track this functionality, which has been open for quite some time. While it is currently not possible to achieve exactly this functionality, there are some possible workarounds that require very little coding.
Marking an Entity Type as a View
One possibility is to mark an entity type as coming from a database view using annotations. If this is the case, then no modifications are allowed, by default, to its instances. We can easily achieve this behaviour using an extension method such as this one:
public static class BuilderExtensions {
public static EntityTypeBuilder<T> IsReadOnly<T>(this EntityTypeBuilder<T> builder) where T : class
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
var metadata = builder.Metadata;
var tableName = metadata.GetAnnotation(RelationalAnnotationNames.TableName).Value;
var tableSchema = metadata.GetAnnotation(RelationalAnnotationNames.Schema).Value;
metadata.RemoveAnnotation(RelationalAnnotationNames.TableName);
metadata.RemoveAnnotation(RelationalAnnotationNames.Schema);
metadata.SetAnnotation(RelationalAnnotationNames.ViewName, tableName);
metadata.SetAnnotation(RelationalAnnotationNames.ViewSchema, tableSchema);
return builder;
}
}
Usage:
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<Blog>().IsReadOnly();
base.OnModelCreating(modelBuilder);
}
As you can see, we are storing the original table and schema names that the entity type was mapped to and we turn them into view equivalents. When EF Core detects that one instance marked as such is dirty, it will throw an exception. The only problem here is that the exception will mention that the entity is not associated with a table, but if we can live with this, then we should be fine!
Marking Individual Properties as Read-Only
The other option that is available already is the ability to mark individual properties (scalars, not navigation properties such as many-to-one or one-to-one) as read-only. This is implemented by EF Core’s SetAfterSaveBehavior API, and one possible implementation might be:
public static class BuilderExtensions
{
public static PropertyBuilder<T> IsReadOnly<T>(this PropertyBuilder<T> prop, bool readOnly = true)
{
ArgumentNullException.ThrowIfNull(prop, nameof(prop));
prop.Metadata.SetAfterSaveBehavior(readOnly ? PropertySaveBehavior.Throw : PropertySaveBehavior.Save);
return prop;
}
}
And here’s how to use it:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().Property(x => x.Name).IsReadOnly();
base.OnModelCreating(modelBuilder);
}
What this will do is, should any property marked with PropertySaveBehavior.Throw be found modified, EF Core will throw an exception. The alternative is to pass PropertySaveBehavior.Ignore, which will make it be silently ignored.
Say you want to do this for all the properties, well, it’s just a matter of having another IsReadOnly extension method:
public static class BuilderExtensions
{
public static EntityTypeBuilder<T> IsReadOnly<T>(this EntityTypeBuilder<T> builder, bool readOnly = true) where T : class
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
var metadata = builder.Metadata;
var props = metadata.GetProperties();
foreach (var prop in props)
{
prop.SetAfterSaveBehavior(readOnly ? PropertySaveBehavior.Throw : PropertySaveBehavior.Save);
}
return builder;
}
}
Alternative Solution
Other options might include:
-
Adding a custom annotation to the entity type that we want to make read-only
-
Adding an interceptor that hooks to the SavingChanges event, goes through all dirty entities and removes or throws an exception whenever it finds one that is marked as read-only
Let’s start with the beginning, marking the entity type with a custom annotation:
public static class BuilderExtensions
{
public static EntityTypeBuilder<T> IsReadOnly<T>(this EntityTypeBuilder<T> builder) where T : class
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
var metadata = builder.Metadata;
metadata.AddAnnotation("Custom:ReadOnly", true);
return builder;
}
}
Next, the interceptor (inherits from SaveChangesInterceptor):
public class ReadOnlySaveChangesInterceptor : SaveChangesInterceptor
{
private void DetectReadOnly(DbContext context)
{
foreach (var entry in context.ChangeTracker.Entries().Where(e => IsReadOnly(context, e.Entity) && (e.State == EntityState.Deleted || e.State == EntityState.Modified || e.State == EntityState.Added)))
{
throw new InvalidOperationException($"Entity {entry.Entity.GetType()} is marked as read-only.");
}
}
private bool IsReadOnly(DbContext context, object entity)
{
if (context.Entry(entity).Metadata.FindAnnotation("Custom:ReadOnly")?.Value is bool readOnly)
{
return readOnly;
}
return false;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
DetectReadOnly(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
DetectReadOnly(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
It needs to be added as this:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new ReadOnlySaveChangesInterceptor());
base.OnConfiguring(optionsBuilder);
}
And, yes, perhaps “Custom:ReadOnly” should be a constant!
Conclusion
And that’s it. Until we have an explicit implementation for read-only entities, it is certainly possible to find workarounds, as you can see. Mind you, this hasn’t been thoroughly tested, but does seem to work. One obvious caveat is that none works with bulk updates or deletes. As aways. keen to ear your thoughts!