Utilizando funciones propias en lambdas de Entity Framework (core)

En este artículo se trata cómo utilizar funciones personalizadas en lambdas de Entity Framework Core.

Nota: se puede encontrar el repositorio con el código aquí

El problema

A veces, sobre todo cuando estamos utilizando DDD, sería ideal poder utilizar una función propia en una lambda. Esto, normalmente, no puede lograrse porque el framework no sabe cómo traducir esa función a SQL y, de hecho, dependiendo del IDE que utilicemos, podemos encontrar un warning como este (ejemplo de Rider):

Function is not convertible to SQL and must not be called in the database context

Sin embargo, es posible que nosotros sí que sepamos la conversión de esa función, por lo que podríamos simplemente proveer su traducción a SQL. Eso es lo que se ilustra en este artículo.

En este ejemplo, vamos a tener un value object llamado Version, que va a representar una versión en formato de versionado semántico. Queremos saber si una versión en concreto es prerelease o no. Esto es fácil, puesto que solo hay que mirar si la versión contiene un guión (por ejemplo, 1.0.0 no es una versión prerelease, pero 1.0.0-beta sí).

Para ello, en C#, bastaría con escribir la siguiente función:

public bool IsPrerelease()
{
    return Value.Contains('-');
}

Por eso igual nos llevamos una sorpresa desagradable cuando intentemos utilizar esta función en una lambda de Entity Framework y veamos que nos salta una excepción:

public IEnumerable<Application> GetPrereleaseVersions()
{
    context.Applications.Where(a => a.Version.IsPrerelease());
}

Si intentamos ejecutar este código, nos encontraremos con la siguiente excepción:

The LINQ expression could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation

La solución

Vamos a ver cómo podemos proveer esa traducción a Entity Framework. En primer lugar, empecemos por la definición de nuestro Value Object:

namespace DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects;

public sealed class Version(string value)
{
    private string Value { get; } = value;

    public override string ToString()
    {
        return Value;
    }
}

public static class VersionExtensions
{
    public static bool IsPrerelease(this Version version)
    {
        throw new NotImplementedException(message: "This method is to be used by Entity Framework only.");
    }
}

Como vemos, este tipo es simplemente un boxing de un string, y hemos implementado un método de extensión IsPrerelease, que es el que vamos a traducir. Estas son dos de las particularidades de esta aproximación:

  • El método DEBE ser un método de extensión.

  • La implementación del método es totalmente irrelevante. De hecho, se recomienda dejarlo sin implementar porque podría causar confusión si otra persona piensa que cambiando el cuerpo del método va a cambiar el comportamiento de las LINQ queries, pero si queremos usarlo en nuestra capa de dominio, podríamos implementarlo.

Vamos a crear una pequeña entidad que haga uso de ese value object:

using Version = DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects.Version;

namespace DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models;

public record Application(string Name, Version Version)
{
    public int Id { get; }
}

Y un par de casos de uso, crear una aplicación y obtener las aplicaciones:

using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Database;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.UseCases.Shared;
using Microsoft.AspNetCore.Mvc;
using Version = DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects.Version;

namespace DotnetExamples.EntityFramework.CustomFunctionsInLambdas.UseCases.Applications;

public sealed class CreateApplication
{
    private record Request(string Name, string Version);

    private record Response(int Id, string Name, string Version);

    public class Endpoint : IEndpoint
    {
        public void MapEndpoint(IEndpointRouteBuilder app)
        {
            app.MapPost(pattern: "/applications", handler: Handler)
                .WithTags("Applications");
        }
    }

    private static async Task<IResult> Handler([FromBody] Request request, DatabaseContext context, CancellationToken cancellationToken)
    {
        Application application = new(Name: request.Name, Version: new Version(request.Version));
        context.Applications.Add(application);
        await context.SaveChangesAsync(cancellationToken);
        return Results.Ok(
            new Response(
                Id: application.Id,
                Name: application.Name,
                Version: application.Version.ToString()
            )
        );
    }
}
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Database;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.UseCases.Shared;

namespace DotnetExamples.EntityFramework.CustomFunctionsInLambdas.UseCases.Applications;

public sealed class GetApplication
{
    private record Request(bool IncludePrerelease);
    private record Response(int Id, string Name, string Version);

    public class Endpoint : IEndpoint
    {
        public void MapEndpoint(IEndpointRouteBuilder app)
        {
            app.MapGet(pattern: "/applications", handler: Handler)
                .WithTags("Applications");
        }
    }

    private static IResult Handler([AsParameters] Request request, DatabaseContext context,
        CancellationToken cancellationToken)
    {
        IQueryable<Application> applications = context.Applications.AsQueryable();
        if (!request.IncludePrerelease)
        {
            applications = applications.Where(a => !a.Version.IsPrerelease());
        }
        return Results.Ok(applications.Select(a => new Response(a.Id, a.Name, a.Version.ToString())));
    }
}

Si nos fijamos en esta última clase, hemos implementado un filtrado mediante el query parameter IncludePrerelease, y en la lambda estamos utilizando nuestra función personalizada. En este punto, por supuesto, el IDE nos lanza el error mencionado anteriormente (y lo va a seguir lanzando aunque implementemos la traducción):

Finalmente, en el DatabaseContext es donde vamos a hacer la magia, específicamente en la función OnModelCreating:

using System.Data;
using System.Reflection;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Database.Converters;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models;
using DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Microsoft.EntityFrameworkCore.Storage;
using Version = DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects.Version;

namespace DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Database;

public sealed class DatabaseContext(DbContextOptions<DatabaseContext> options): DbContext(options)
{
    private const string ConnectionString = "Server=mysqldb;Port=3306;Database=customfunctionsinlambdas;Uid=root;Pwd=admin;";

    public DbSet<Application> Applications => Set<Application>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseMySql(connectionString: ConnectionString, serverVersion: ServerVersion.AutoDetect(ConnectionString));
    }

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        base.ConfigureConventions(configurationBuilder);
        configurationBuilder.Properties<Version>().HaveConversion<VersionConverter>();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<Application>().HasKey(e => e.Id);

        MethodInfo methodInfo = typeof(VersionExtensions)
            .GetMethod(name: nameof(VersionExtensions.IsPrerelease), types: [typeof(Version)])!;

        modelBuilder.HasDbFunction(methodInfo: methodInfo,
            builderAction: builder =>
            {
                builder.HasParameter(name: "version",
                        buildAction: parameterBuilder => parameterBuilder.HasStoreType("varchar(32)"))
                    .HasTranslation(args =>
                    {
                        ISqlExpressionFactory sqlExpressionFactory = this.GetService<ISqlExpressionFactory>();
                        SqlConstantExpression likePattern = sqlExpressionFactory.Constant(value: "%-%");
                        SqlUnaryExpression castExpression = sqlExpressionFactory.Convert(
                            operand: args[0],
                            type: typeof(string),
                            typeMapping: new StringTypeMapping(storeType: "varchar", dbType: DbType.String, size: 32));
                        LikeExpression expression =
                            sqlExpressionFactory.Like(match: castExpression, pattern: likePattern);
                        return expression;
                    });
            });

    }
}

Realmente el código es algo confuso pero es fácil de seguir si se entiende el resultado que queremos obtener:

SELECT `a`.`Id`, `a`.`Name`, `a`.`Version`
FROM `Applications` AS `a`
WHERE CAST(`a`.`Version` AS char) NOT LIKE '%-%'

Desafortunadamente, el CAST es necesario, yo al menos no he conseguido ejecutar el código sin eso, obteniendo una excepción tipo Cannot cast System.String to DotnetExamples.EntityFramework.CustomFunctionsInLambdas.Models.ValueObjects.Version en tiempo de ejecución.

Si ahora añadimos un par de aplicaciones, una con versión 1.0.0 y otra con versión 1.0.0-alpha, obtenemos los siguientes resultados:

GET /applications?IncludePrerelease=true

200 OK
Response body:
[
  {
    "id": 1,
    "name": "test application",
    "version": "1.0.0"
  },
  {
    "id": 2,
    "name": "test application prerelease",
    "version": "1.0.0-alpha"
  }
]
GET /applications?IncludePrerelease=false

200 OK
Response body:
[
  {
    "id": 1,
    "name": "test application",
    "version": "1.0.0"
  }
]

Como se puede observar, hemos conseguido utilizar nuestra función en la lambda de Entity Framework y retorna los resultados correctos.

Problemas y desventajas de la solución

Si bien hemos podido comprobar que la solución funciona, viene con algunas desventajas que podrían hacer que busquemos una alternativa:

  • El método que se traduce debe ser un método de extensión, no se pueden utilizar métodos de instancia.

  • El cuerpo del método de extensión es irrelevante, porque no se utiliza para la traducción a SQL. Esto puede hacer que si tenemos implementación (porque la usamos en nuestro dominio) y hay un bug o cambian los requisitos, otro desarrollador (o nosotros mismos) cambiemos el código de la implementación pensando que soluciona algo y veamos que no tiene efecto.

  • Si necesitas traducir múltiples funciones, puedes acabar con un código muy difícil de seguir y depurar.

  • Uso de reflexión, lo cual hace que los errores aparezcan en tiempo de ejecución.

Aunque es un ejercicio muy bueno para conocer cómo funciona Entity Framework, se debe utilizar con cautela en entornos productivos y valorar si existen alternativas que sean más seguras.

0
Subscribe to my newsletter

Read articles from Carlos M. Hernández directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Carlos M. Hernández
Carlos M. Hernández

Just a developer from Madrid trying to help Spanish speaking devs in their native language :)