dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.52k stars 3.13k forks source link

Materialization Interceptor - Change a property value #33359

Open GLuca74 opened 3 months ago

GLuca74 commented 3 months ago

I developed some extensions for my custom provider for EFCore and I am trying to port this extensions when the user is using another provider to keep the same user experience.

For example, with this simplified model (please not consider too much that in the example below I am working directly with file system, like a client server app, the actual implementation works with distribuited systems but is much more complicated to explain) :

public class Person
{
    public Guid ID {get;}
    public string Name {get;}
    public string ImagePath {get;}
}

When I configure the model I 'mark' the ImagePath with a fluent extension

modelBuilder.Entity<Person>().Property(itm => itm.ImagePath).IsFilePath() this adds an Annotation with a 'metadata class' (class FilesMetadataExtension :MetadataExtension{ } ) When I configure my provider

            var contextOptions = new DbContextOptionsBuilder<MyContext>()
                .UseMyProvider(xxx,optionsBuilder => optionsBuilder.UseFilesExtension(@"\\ImagesStore\")).Options;

            var DB = new MyContext(contextOptions);

UseFilesExtension(@"\\ImagesStore\") registers some services through a class that implements IDbContextOptionsExtension. The expected behavour is any image must be tranfered to the shared store images and data must be wrote and read accordingly.

When the user add a Person :

        Person p = new Person();
        p.PersonID = guid.NewGuid();
        p.Name = "Name";
        p.ImagePath = @"d:\PersonImage.png";
        DB.Set<Person>().Add(p);
        DB.SaveChanges();

In my implementation of Database class, I am able to check that the property ImagePath is marked with the annotation, so I can get in the registered services the one that generate a path in the shared configured path, example "\Person{ID}", copy the image to the shared path and change the saved property value to "Person{ID}" (instead of "d:\PersonImage.png").

When the user retrive the image path:

var personImage = DB.Set<Person>().Select(itm => itm.ImagePath).Single(); In the class that inherit from QueryableMethodTranslatingExpressionVisitor Im am able to check that i am getting an entity property and that the property is marked with the annotation, so I can add to the query/expression the root path

[...].Select(itm => {rootPath}+itm.ImagePath).Single(); so the query returns the full path of the image and the value is bindable to any viewer.

Similar story when the user retrive the full person :

var person = DB.Set<Person>().Single(); when I build the shaper, I am able to check that i am getting an entity and there is the property marked with the annotation so also here the person entity will have the property as the bindable full path in the Image store.

Now, If I want to bring this behavour when the user uses another provider, for example SqlServer. The model configuration, with the added Annotation, is the same.

When the user Save an entity:

        Person p = new Person();
        p.PersonID = guid.NewGuid();
        p.Name = "Name";
        p.ImagePath = @"d:\PersonImage.png";
        DB.Set<Person>().Add(p);
        DB.SaveChanges();

I can use a SaveChangesInterceptor, I can get the Saving entityEntries, check in EntityTypethe marked properties and do the same work I do in my provider.

When the user retrive the image path

var personImage = DB.Set<Person>().Select(itm => itm.ImagePath).Single(); I can use an IQueryExpressionInterceptorso I can check that I am retriving an marked Entity property so I can return the expression

DB.Set<Person>().Select(itm => {rootPath}+itm.ImagePath).Single();

My problem is when the query retrive the full Entity

var person = DB.Set<Person>().Single();

the interceptor to use should be an IMaterializationInterceptor. In this interceptor I have the MaterializationInterceptionData.EntityType property that let me check if i am retriving an entity with a marked property, but I can only read the property value. In both InitializingInstanceand InitializedInstancethere is no way to change the value of the property(in my case add the root of the shared image path to the saved value).

So, with the SaveChangesInterceptor and IQueryExpressionInterceptorI am able to manipulate the value the user passed and the value is retrived by the database but with IMaterializationInterceptorI can only read and be just a watcher of the data that from the database is returned to the user. All the example I found about IMaterializationInterceptor, change properties that are not actual mapped and always knowing the EntityTypewhere change the not mapped value.

The perfect way may be if in InitializingInstance, when the values are still not assigned to entity instance, MaterializationInterceptionDatagive two methods to change the value, like it give two methods to read the value, but now have I a way to complete this last step?

Li7ye commented 2 months ago

@GLuca74, currently, for SQL Server provider, only use data reader to get the corresponding property value. So that you can't change property value. image

The workaround is you can use reflection to set property value again in IMaterializationInterceptor.InitializedInstance.

You can create an IConventionSetPlugin for retrieving all entity properties and discover property with IsFilePath() annotation. I assume the name of annotation is filePath for IsFilePath().

internal class FilePathAnnotationPlugin : IConventionSetPlugin
{
    public ConventionSet ModifyConventions(ConventionSet conventionSet)
    {
        conventionSet.Add(new AddFilePathRuntimeAnnotationForEntity());

        return conventionSet;
    }

    public class AddFilePathRuntimeAnnotationForEntity : IModelFinalizedConvention
    {
        public IModel ProcessModelFinalized(IModel model)
        {
            foreach (var entity in model.GetEntityTypes())
            {
                PropertyInfo? filePathProp = entity.GetProperties().FirstOrDefault(x => x.FindAnnotation("filePath") != null)?.PropertyInfo;

                if (filePathProp !=null)
                {
                    entity.AddRuntimeAnnotation("hasFilePath", filePathProp);
                }
            }

            return model;
        }
    }
}

In your IDbContextOptionsExtension class, you can register FilePathAnnotationPlugin in dbcontext DI.

public void ApplyServices(IServiceCollection services)
{
   ...
    services.AddSingleton<IConventionSetPlugin, FilePathAnnotationPlugin>();
}

In IMaterializationInterceptor.InitializedInstance method, you can change property value as below code:

public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
    if (materializationData.QueryTrackingBehavior is null) return entity;

    var filePathProp = (PropertyInfo?)materializationData.EntityType.FindRuntimeAnnotation("hasFilePath")?.Value;

    if (filePathProp is not null)
    {
        var dbValue = materializationData.GetPropertyValue(filePathProp.Name);

        filePathProp.SetValue(entity, $"{rootPath}"+ dbValue);
    }

    return entity;
}
GLuca74 commented 2 months ago

Hello @Li7ye , thanks. Now i am deep focused on another task, when I will complete the task I am working on, I will try your solution