Entity Framework Core NodaTime 总和持续时间 对于那些不熟悉 NodaTime 的 EF-Core 集成的人:

问题描述

EF Core下面的sql怎么写

select r."Date",sum(r."DurationActual")
from public."Reports" r
group by r."Date"

我们有以下模型(mwe)

public class Report 
{
    public LocalDate Date { get; set; }
    public Duration DurationActual { get; set; }  ​
}

我尝试了以下方法

await dbContext.Reports
    .GroupBy(r => r.Date)
    .Select(g => new
    {
      g.Key,SummedDurationActual = g.Sum(r => r.DurationActual),})
    .ToListAsync(cancellationToken);

但这不能编译,因为 Sum 仅适用于 intdoublefloatNullable<int> 等。

我也尝试对总小时数求和

await dbContext.Reports
    .GroupBy(r => r.Date)
    .Select(g => new
    {
      g.Key,SummedDurationActual = g.Sum(r => r.DurationActual.TotalHours),})
    .ToListAsync(cancellationToken)

编译但不能被EF翻译并出现以下错误

system.invalidOperationException: The LINQ expression 'GroupByShaperExpression:
KeySelector: r.Date,ElementSelector:EntityShaperExpression: 
    EntityType: Report
    ValueBufferExpression: 
        ProjectionBindingExpression: EmptyProjectionMember
    IsNullable: False

    .Sum(r => r.DurationActual.TotalHours)' Could not be translated. Either rewrite the query in a form that can be translated,or switch to client evaluation explicitly by inserting a call to 'AsEnumerable',....

当然我可以更早地列举它,但这效率不高。

进一步澄清一下:我们使用 Npgsql.EntityFrameworkCore.PostgresqlNpgsql.EntityFrameworkCore.Postgresql.NodaTime 来建立连接。 Duration 是来自 NodaTime 的数据类型,表示类似于 TimeSpan内容Duration 被映射到数据库端的 interval

我们大量使用使用 InMemoryDatabase (UseInMemoryDatabase) 的单元测试,因此该解决方案应该适用于 Psql 和 InMemory。

对于那些不熟悉 NodaTime 的 EF-Core 集成的人:

您将 UseNodaTime() 方法调用添加到配置中,例如:

services.AddDbContext<AppIdentityDbContext>(
    options => options
                      .UseLazyLoadingProxies()
                      .UseNpgsql(configuration.GetConnectionString("DbConnection"),o => o
                                 .MigrationsAssembly(Assembly.GetAssembly(typeof(DependencyInjection))!.FullName)
                                 .UseNodaTime()
                        )

这为 NodaTime 类型添加了类型映射

.AddMapping(new NpgsqlTypeMappingBuilder
                {
                    PgTypeName = "interval",NpgsqlDbType = NpgsqlDbType.Interval,ClrTypes = new[] { typeof(Period),typeof(Duration),typeof(TimeSpan),typeof(NpgsqlTimeSpan) },TypeHandlerFactory = new IntervalHandlerFactory()
                }.Build()

我不知道每一个细节,但我认为这增加一个 ValueConverter。 更多信息:https://www.npgsql.org/efcore/mapping/nodatime.html

解决方法

查看Npgsql.EntityFrameworkCore.PostgreSQL的源代码here,可以看到它无法翻译Duration的成员。 如果使用的成员属于类型不是 TranslatenullLocalDateTimeLocalDate 的对象,则方法 LocalTime 将返回 Period。在您的情况下,使用的成员是 TotalHours,它属于类型为 Duration 的对象。

因此,如果您将 DurationActual 的类型从 Duration 更改为 PeriodTimeSpan 也可以工作),您的第二个示例可以工作。

仍然无法翻译 Period 的成员 TotalHours(有关可翻译成员的完整列表,请参阅 code here)。

所以你必须自己计算这个值:

 await dbContext.Reports
                .GroupBy(r => r.Date)
                .Select(g => new
                {
                    g.Key,SummedDurationActual = g.Sum(r => r.DurationActual.Hours + ((double)r.DurationActual.Minutes / 60) + ((double)r.DurationActual.Seconds / 3600)),})
                .ToListAsync(cancellationToken)

如果无法更改 DurationActual 的类型,您可以向 Npgsql 开发人员提出问题以添加必要的翻译。他们建议在 their documentation 中这样做:

请注意,该插件远未涵盖所有翻译。如果缺少您需要的翻译,请打开一个问题来请求它。

,

@mohamed-amazirh 的回答帮助我找到了正确的方向。 无法更改为 Period(因为它根本不是一个周期,而是一个持续时间)。 我最终编写了一个 IDbContextOptionsExtension 来满足我的需求。

完整代码在这里:

public class NpgsqlNodaTimeDurationOptionsExtension : IDbContextOptionsExtension
{
    private class ExtInfo : DbContextOptionsExtensionInfo
    {
        public ExtInfo(IDbContextOptionsExtension extension) : base(extension) { }

        public override long GetServiceProviderHashCode()
        {
            return 0;
        }

        public override void PopulateDebugInfo(IDictionary<string,string> debugInfo)
        {
            return;
        }

        public override bool IsDatabaseProvider => false;
        public override string LogFragment => "using NodaTimeDurationExt ";
    }

    public NpgsqlNodaTimeDurationOptionsExtension()
    {
        Info = new ExtInfo(this);
    }

    public void ApplyServices(IServiceCollection services)
    {
        new EntityFrameworkRelationalServicesBuilder(services)
            .TryAddProviderSpecificServices(x => x.TryAddSingletonEnumerable<IMemberTranslatorPlugin,NpgsqlNodaTimeDurationMemberTranslatorPlugin>());
    }

    public void Validate(IDbContextOptions options) { }

    public DbContextOptionsExtensionInfo Info { get; set; }
}

public class NpgsqlNodaTimeDurationMemberTranslatorPlugin: IMemberTranslatorPlugin
{
    public NpgsqlNodaTimeDurationMemberTranslatorPlugin(ISqlExpressionFactory sqlExpressionFactory)
    {
        Translators = new IMemberTranslator[]
        {
            new NpgsqlNodaTimeDurationMemberTranslator(sqlExpressionFactory),};
    }

    public IEnumerable<IMemberTranslator> Translators { get; set; }
}

public class NpgsqlNodaTimeDurationMemberTranslator : IMemberTranslator
{
    private readonly ISqlExpressionFactory sqlExpressionFactory;

    public NpgsqlNodaTimeDurationMemberTranslator(ISqlExpressionFactory sqlExpressionFactory)
    {
        this.sqlExpressionFactory = sqlExpressionFactory;
    }

    public SqlExpression Translate(SqlExpression instance,MemberInfo member,Type returnType,IDiagnosticsLogger<DbLoggerCategory.Query> logger)
    {
        var declaringType = member.DeclaringType;
        if (instance is not null
            && declaringType == typeof(Duration))
        {
            return TranslateDuration(instance,member,returnType);
        }

        return null;
    }

    private SqlExpression? TranslateDuration(SqlExpression instance,Type returnType)
    {
        return member.Name switch
        {
            nameof(Duration.TotalHours) => sqlExpressionFactory
                .Divide(sqlExpressionFactory
                        .Function("DATE_PART",new[]
                            {
                                sqlExpressionFactory.Constant("EPOCH"),instance,},true,new[] { true,true },typeof(double)
                        ),sqlExpressionFactory.Constant(3600)
                ),_ => null,};
    }
}

要使用它,我必须以与 NodaTime 相同的方式添加它:

services.AddDbContext<Cockpit2DbContext>(options =>
    {
        options
            .UseLazyLoadingProxies()
            .UseNpgsql(configuration.GetConnectionString("Cockpit2DbContext"),o =>
                {
                    o.UseNodaTime();
                    var coreOptionsBuilder = ((IRelationalDbContextOptionsBuilderInfrastructure) o).OptionsBuilder;
                    var ext = coreOptionsBuilder.Options.FindExtension<NpgsqlNodaTimeDurationOptionsExtension>() ?? new NpgsqlNodaTimeDurationOptionsExtension();
                    ((IDbContextOptionsBuilderInfrastructure) coreOptionsBuilder).AddOrUpdateExtension(ext);
                })
            .ConfigureWarnings(w => w.Ignore(RelationalEventId.MultipleCollectionIncludeWarning));
    }
);