EF Core 8 Preview 1 的三个新特性

昨天 .NET 官方博客公布了 EF Core 8 预览版 1 的发布:

https://devblogs.microsoft.com/dotnet/announcing-ef8-preview-1/

文章提到,EF 8 正式版本将在 2023 年 11 月份随 .NET 8 正式版一起发布,而且是作为 LTS(长期支持)版本。

文章介绍了 EF 8 预览版 1 主要引入的三个新特性:非映射类型的 Raw SQL 查询、非追踪查询的懒加载和 SQL Server 的 DateOnly/TimeOnly 的支持。.

本文将通过摘取原文中的示例,展开讲讲这三个新特性。

1非映射类型的 Raw SQL 查询

我们知道 FromSqlRaw 可以支持使用 Raw SQL 查询实体,但仅支持映射数据库表的实体类,例如:

var blogs = context.Blogs.FromSqlRaw("SELECT * FROM Blogs Where ...");

从 EF 7 开始引入了支持标量类型的 Raw SQL 查询,例如:

var ids = context.Database
    .SqlQuery<int>($"SELECT BlogId FROM Blogs")
    .ToList();

EF 8 更进一步,支持非映射的自定义类型的 Raw SQL 查询,返回的还是 IQueryable,并且还可以组合使用 LINQ 查询。

比如,我们可以为要查询的一个数据结果集定义一个 Model 类 PostSummary

public class PostSummary
{
    public string BlogName { get; set; }
    public string PostTitle { get; set; }
    public DateTime PublishedOn { get; set; }
}

然后使用 Raw SQL 查询,并且组合使用 LINQ 的 Where 方法:

var summaries= await context.Database.SqlQuery<PostSummary>(
    @$" SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
        FROM Posts AS p
        INNER JOIN Blogs AS b ON p.BlogId = b.Id")
    .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
    .ToListAsync();

它最终生成的 SQL 语句是这样的:

SELECT [n].[BlogName], [n].[PostTitle], [n].[PublishedOn]
FROM (
          SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
          FROM Posts AS p
          INNER JOIN Blogs AS b ON p.BlogId = b.Id
    ) AS [n]
WHERE [n].[PublishedOn] >= @__cutoffDate_1 AND [n].[PublishedOn] < @__end_2

观察生成的 SQL,我们发现,如果 Raw SQL 和 LINQ 组合使用,Raow SQL 部分会形成一个子查询。当然本示例可以全部只用 LINQ 来写,生成的 SQL 就简洁一些,例如:

var summariesByLinq = await context.Posts
  .Select(p => new PostSummary
  {
      BlogName = p.Blog.Name,
      PostTitle = p.Title,
      PublishedOn = p.PublishedOn,
  })
  .Where(p => p.PublishedOn >= start && p.PublishedOn < end)
  .ToListAsync();

生成的 SQL:

SELECT [b].[Name] AS [BlogName], [p].[Title] AS [PostTitle], [p].[PublishedOn]
FROM [Posts] AS [p]
INNER JOIN [Blogs] AS [b] ON [p].[BlogId] = [b].[Id]
WHERE [p].[PublishedOn] >= @__start_0 AND [p].[PublishedOn] < @__end_1

EF8 的 SqlQuery 也支持从视图、函数、存储过程中查询非映射类型。

2非追踪查询的懒加载

在之前的版本中,EF Core 就已经支持导航属性的懒加载(延迟加载),但要求必须是追踪查询,即查询中不能使用 AsNOTracking() 方法。新的 EF 8 版本则没有这个限制了,非追踪查询也支持懒加载。

EF 的懒加载有两种方式,一种是使用代理(Proxy),另一种是不使用代理。这里就不展开了,下面以使用代理为例展示非追踪查询懒加载的使用。

首先,要支持这一特性,我们需要在 DbContext 中配置使用懒加载代理:

public class BlogsDbContext : DbContext
{
   ...
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {

        optionsBuilder.UseLazyLoadingProxies();
    }
    ...
}

例如,Blog 实体有个 Posts 导航属性:

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public virtual ICollection<Post> Posts { get; set; }
}

如果我要在 Blog 结果集中,仅在需要的时候再去加载其 Posts 关联数据,考虑下面的非追踪查询:

var blogs = await context.Blogs.AsNoTracking().ToListAsync();

此时,当代码访问到 Posts 时才会从数据库加载对应数据:

Console.Write("Choose a blog: ");
if (int.TryParse(ReadLine(), out var blogId))
{
    Console.WriteLine("Posts:");
    foreach (var post in blogs[blogId - 1].Posts)
    {
        Console.WriteLine($"  {post.Title}");
    }
}

EF8 还提供了判断导航属性数据集是否已经加载的方法:

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

需要注意的是,懒加载实体集查询指向它们的 DbContext,在使用懒加载时应避免实体集拥有过长的生命周期而导致出现内存泄露。

3DateOnly/TimeOnly 支持

System.DateOnly 和 System.TimeOnly 是 .NET 6 引入的类型,并且已被部分数据库(如 SQLite 和 PostgreSQL)的 Provider 支持。但由于种种原因,官方 SQL Server 的 Provider 直到 EF 8 才支持这两个类型。

考虑下面几个实体:

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

在 EF 8 中,对于 SQL Server,这些实体类将映射为下面几个表,其中 DateOnly 映射为 date 类型的列, TimeOnly 映射为 time 类型的列:

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

当执行下面这段 LINQ 查询时:

var openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours.Any(
                 o => o.DayOfWeek == dayOfWeek
                      && o.OpensAt < time && o.ClosesAt >= time))
    .ToListAsync();

通过 ToQueryString() 方法查看它生成 SQL 是这样的:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '19:53:40.4798052';

SELECT [s].[Id], [s].[Founded], [s].[Name], [o0].[SchoolId], [o0].[Id], [o0].[ClosesAt], [o0].[DayOfWeek], [o0].[OpensAt]
FROM [Schools] AS [s]
LEFT JOIN [OpeningHours] AS [o0] ON [s].[Id] = [o0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0 AND [t].[LastDay] >= @__today_0) AND EXISTS (
    SELECT 1
    FROM [OpeningHours] AS [o]
    WHERE [s].[Id] = [o].[SchoolId] AND [o].[DayOfWeek] = @__dayOfWeek_1 AND [o].[OpensAt] < @__time_2 AND [o].[ClosesAt] >= @__time_2)
ORDER BY [s].[Id], [o0].[SchoolId]

可以看到 DateOnly 和 TimeOnly 类型对应的变量值正是我们所预期的样子。

4最后

这三个新特性,我个人觉得是非常实用的,尤其是第一个。在实际项目中,难免有复杂一点的很难用 LINQ 编写的查询,所以我在大部分项目中都是 EF Core 和 Dapper 混用。现在 EF 8 支持非映射自定义类型的 Raw SQL 查询了,基本上就可以不用 Dapper 了吧。