这个请求是由 EF Core buggy 生成的还是我的代码?

Is this request generated by EF Core buggy or is it my code?

简介

我将 EF Core 与 .NET 5.0 和 SQL Server Express 结合使用。基本上,我想知道它是否生成了有问题的 SQL 查询,或者我的代码是否有问题(可能是 :D)。我在问题的底部提供了一个 mre,但希望问题从我收集的数据中变得明显(我已经问过类似的问题,但觉得它需要彻底检修)

设置

我有一个记录和一个 DbContext 如下所示。它被简化为重要的 属性 Moment,其中 必须 属于 DateTimeOffset 类型(公司指南)。

private class Foo
{
    public int ID { get; set; }
    public DateTimeOffset Moment { get; set; }
}

private class Context : DbContext
{
    public Context(DbContextOptions<Context> options) : base(options) {}

    public DbSet<Foo> Foos { get; set; }
}

生成的数据库中的相应列的数据类型为 datetimeoffset(7),看起来不错。

我用这些数据初始化了数据库(连续 5 天,每天在各自时区的午夜):

context.Foos.Add(new Foo() { Moment = DateTimeOffset.Parse("2021-04-21 00:00 +02:00"), });
context.Foos.Add(new Foo() { Moment = DateTimeOffset.Parse("2021-04-22 00:00 +02:00"), });
context.Foos.Add(new Foo() { Moment = DateTimeOffset.Parse("2021-04-23 00:00 +02:00"), });
context.Foos.Add(new Foo() { Moment = DateTimeOffset.Parse("2021-04-24 00:00 +02:00"), });
context.Foos.Add(new Foo() { Moment = DateTimeOffset.Parse("2021-04-25 00:00 +02:00"), });

现在我想查询所有记录 Moment >= start && Moment <= end 而忽略这些参数的时间:

var start = DateTimeOffset.Parse("2021-04-22 00:00 +02:00");
var end = DateTimeOffset.Parse("2021-04-24 00:00 +02:00");

我希望得到 3 条记录并提出了 3 个与我看起来相同的查询,但是,第二个产生了不同的结果:

查询

private static async Task Query1(Context context, DateTimeOffset start, DateTimeOffset end)
{
    var records = await context.Foos
        .Where(foo => foo.Moment.Date >= start.Date && foo.Moment.Date <= end.Date)
        //... Finds 3 records, expected
}

private static async Task Query2(Context context, DateTimeOffset start, DateTimeOffset end)
{
    start = start.Date; // .Date yields DateTime -> implicit conversion to DateTimeOffset?
    end = end.Date;

    var records = await context.Foos
        .Where(foo => foo.Moment.Date >= start && foo.Moment.Date <= end)
        // ... Finds only 2 records, unexpected
}

private static async Task Query3(Context context, DateTimeOffset start, DateTimeOffset end)
{
    var start2 = start.Date; // start2 and end2 are of type DateTime now
    var end2 = end.Date;

    var records = await context.Foos
        .Where(foo => foo.Moment.Date >= start2 && foo.Moment.Date <= end2)
        // ... Finds 3 records, expected
}

结果

我还为每个查询制作了一个 LINQ 版本,其中我从 List<Foo>:

查询数据
private static void Query1(List<Foo> foos, DateTimeOffset start, DateTimeOffset end)
{
    var records = foos
        .Where(foo => foo.Moment.Date >= start.Date && foo.Moment.Date <= end.Date)
        //...
}
函数 预期记录数 来自数据库的记录 使用LINQ时的记录
查询 1 3 3 3
查询2 3 2 3
查询3 3 3 3

为什么第二个数据库只查询了return2条记录?

我知道第一个和第三个查询将 DateTimeDateTime 进行比较,而第二个查询将 DateTimeDateTimeOffset 进行比较。因此我想知道为什么 LINQ 版本的行为不同。

跟踪查询

我跟踪了发送到 SQL 服务器的实际查询,它们是不同的,但是,我真的不明白为什么它们会导致不同的结果(SQL 经验不多) :

-- From Query1()
exec sp_executesql N'SELECT [f].[ID]
FROM [Foos] AS [f]
WHERE (CONVERT(date, [f].[Moment]) >= @__start_Date_0) AND (CONVERT(date, [f].[Moment]) <= @__end_Date_1)',N'@__start_Date_0 datetime2(7),@__end_Date_1 datetime2(7)',@__start_Date_0='2021-04-22 00:00:00',@__end_Date_1='2021-04-24 00:00:00'

-- From Query2()

exec sp_executesql N'SELECT [f].[ID]
FROM [Foos] AS [f]
WHERE (CAST(CONVERT(date, [f].[Moment]) AS datetimeoffset) >= @__start_0) AND (CAST(CONVERT(date, [f].[Moment]) AS datetimeoffset) <= @__end_1)',N'@__start_0 datetimeoffset(7),@__end_1 datetimeoffset(7)',@__start_0='2021-04-22 00:00:00 +02:00',@__end_1='2021-04-24 00:00:00 +02:00'

-- From Query3()
exec sp_executesql N'SELECT [f].[ID]
FROM [Foos] AS [f]
WHERE (CONVERT(date, [f].[Moment]) >= @__start2_0) AND (CONVERT(date, [f].[Moment]) <= @__end2_1)',N'@__start2_0 datetime2(7),@__end2_1 datetime2(7)',@__start2_0='2021-04-22 00:00:00',@__end2_1='2021-04-24 00:00:00'

MRE

使用 Microsoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer 版本 5.0.6 进行测试。

namespace Playground
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;

    using Microsoft.EntityFrameworkCore;

    public static class Program
    {
        private class Foo
        {
            public int ID { get; set; }
            public DateTimeOffset Moment { get; set; }
        }

        private static Context CreateContext()
        {
            var connectionString = $"Data Source=.\SQLEXPRESS;Initial Catalog=FOO_DB;Integrated Security=SSPI";
            var optionsBuilder = new DbContextOptionsBuilder<Context>();

            optionsBuilder.UseSqlServer(connectionString).EnableSensitiveDataLogging();

            var context = new Context(optionsBuilder.Options);

            context.Database.EnsureCreated();

            return context;
        }

        private class Context : DbContext
        {
            public Context(DbContextOptions<Context> options) : base(options) { }

            public DbSet<Foo> Foos { get; set; }
        }

        private static async Task Query1(Context context, DateTimeOffset start, DateTimeOffset end)
        {
            var records = await context.Foos
                .Where(foo => foo.Moment.Date >= start.Date && foo.Moment.Date <= end.Date)
                .Select(foo => foo.ID)
                .ToListAsync();

            Console.WriteLine($"Query1 in DB found {records.Count} records");
        }

        private static async Task Query2(Context context, DateTimeOffset start, DateTimeOffset end)
        {
            start = start.Date;
            end = end.Date;

            var records = await context.Foos
                .Where(foo => foo.Moment.Date >= start && foo.Moment.Date <= end)
                .Select(foo => foo.ID)
                .ToListAsync();

            Console.WriteLine($"Query2 in DB found {records.Count} records");
        }

        private static async Task Query3(Context context, DateTimeOffset start, DateTimeOffset end)
        {
            var start2 = start.Date;
            var end2 = end.Date;

            var records = await context.Foos
                .Where(foo => foo.Moment.Date >= start2 && foo.Moment.Date <= end2)
                .Select(foo => foo.ID)
                .ToListAsync();

            Console.WriteLine($"Query3 in DB found {records.Count} records");
        }

        public async static Task Main()
        {
            var context = CreateContext();
            var foos = new List<Foo>() {
                    new Foo() { Moment = DateTimeOffset.Parse("2021-04-21 00:00 +02:00"), },
                    new Foo() { Moment = DateTimeOffset.Parse("2021-04-22 00:00 +02:00"), },
                    new Foo() { Moment = DateTimeOffset.Parse("2021-04-23 00:00 +02:00"), },
                    new Foo() { Moment = DateTimeOffset.Parse("2021-04-24 00:00 +02:00"), },
                    new Foo() { Moment = DateTimeOffset.Parse("2021-04-25 00:00 +02:00"), },
                };

            if (!context.Foos.Any())
            {
                await context.AddRangeAsync(foos);
            }

            await context.SaveChangesAsync();

            var start = DateTimeOffset.Parse("2021-04-22 00:00 +02:00");
            var end = DateTimeOffset.Parse("2021-04-24 00:00 +02:00");

            await Query1(context, start, end);
            await Query2(context, start, end);
            await Query3(context, start, end);

            context.Dispose();
        }
    }
}

第二个和其他两个 LINQ 查询之间的区别(因此翻译不同 - CAST ... as datetimeoffset)是其他使用 DateTime 比较,而这个使用 DateTimeOffset 比较。因为当start类型为DateTimeOffset时,表达式

foo.Moment.Date >= start

根据 CLR 类型系统规则(适用于 C# 生成的表达式树)实际上是

((DateTimeOffset)foo.Moment.Date) >= start

foo.Moment.Date <= end 相同。

现在是两个执行上下文 - 客户端 (CLR) 和服务器(在本例中为 SqlServer)之间 DateTimeDateTimeOffset 转换的区别:

CLR

This method is equivalent to the DateTimeOffset constructor. The offset of the resulting DateTimeOffset object depends on the value of the DateTime.Kind property of the dateTime parameter:

  • If the value of the DateTime.Kind property is DateTimeKind.Utc, the date and time of the DateTimeOffset object is set equal to dateTime, and its Offset property is set equal to 0.

  • If the value of the DateTime.Kind property is DateTimeKind.Local or DateTimeKind.Unspecified, the date and time of the DateTimeOffset object is set equal to dateTime, and its Offset property is set equal to the offset of the local system's current time zone.

SqlServer

For conversion to datetimeoffset(n), date is copied, and the time is set to 00:00.0000000 +00:00

现在你应该清楚地看出区别了。 CLR Date 属性 returns DateTimeInspecified 类型,然后使用您的 本地系统的当前类型转换回 DateTimeOffset时区偏移量 - 根据您初始化变量的方式,很可能是+2。在 SQL 查询中,列值转换为 0 偏移量。由于 DateTimeOffset 比较使用所有成员(包括偏移量),因此对于某些数据值可能会得到不同的结果。

当然,当运行在LINQ to Objects上下文中相同时,它只是使用CLR规则在本地编译和执行,因此没有区别。

简而言之,永远不要依赖非 LINQ to Objects 查询中的转换。在第一个查询中使用显式 operators/methods,或在第三个查询中更正数据类型变量,以确保您比较的是相同数据类型,没有隐藏和不确定的转换。