EF Core 9.0 中的新增功能

EF Core 9 (EF9) 是 EF Core 8 之后的下一版本,计划于 2024 年 11 月发布。

EF9 作为日常版本提供,其中包含所有最新的 EF9 功能和 API 调整。 此处的示例使用这些日常版本。

提示

可通过从 GitHub 下载示例代码来运行和调试示例。 下面每个部分都链接到特定于该部分的源代码。

EF9 面向 .NET 8,因此可与 .NET 8 (LTS).NET 9 预览版一起使用。

提示

更新了每个预览版的新增功能文档。 已将所有示例设置为使用 EF9 日常版本,与最新预览版相比,这通常需要额外几周时间来完成工作。 强烈建议在测试新功能时使用日常版本,以便不会针对过时位执行测试。

Azure Cosmos DB for NoSQL

EF 9.0 为适用于 Azure Cosmos DB 的 EF Core 提供程序带来了实质性的改进;已重写提供程序的重要部分,以提供新功能、允许新形式的查询,并更好地使提供程序与 Cosmos DB 最佳做法保持一致。 主要的高级改进如下:有关完整列表,请参阅此长篇故事问题

警告

作为提供程序改进的一部分,必须进行一些影响重大的重大更改:如果要升级现有应用程序,请仔细阅读重大更改部分

改进了使用分区键和文档 ID 进行查询

存储在 Cosmos 数据库中的每个文档都具有唯一的资源 ID。 此外,每个文档可以包含一个“分区键”,用于确定数据的逻辑分区,以便有效地缩放数据库。 有关选择分区键的详细信息,请参阅 Azure Cosmos DB 中的分区和水平缩放

在 EF 9.0 中,Cosmos DB 提供程序在识别 LINQ 查询中的分区键比较方面明显更好,并将其提取出来,使查询只发送到相关分区;这可以大大提高查询的性能并降低成本。 例如:

var sessions = await context.Sessions
    .Where(b => b.PartitionKey == "someValue" && b.Username.StartsWith("x"))
    .ToListAsync();

在此查询中,提供程序会自动识别比较 PartitionKey;如果检查日志,将看到以下内容:

Executed ReadNext (189.8434 ms, 2.8 RU) ActivityId='8cd669ed-2ca5-4f2b-8923-338899071361', Container='test', Partition='["someValue"]', Parameters=[]
SELECT VALUE c
FROM root c
WHERE STARTSWITH(c["Username"], "x")

请注意,WHERE 子句不包含 PartitionKey:该比较已被“取消”,仅用于对相关分区执行查询。 在以前的版本中,在很多情况下,该比较保留在 WHERE 子句中,导致对所有分区执行查询,从而导致成本增加并降低性能。

此外,如果查询还为文档的 ID 属性提供了一个值,并且不包括任何其他查询操作,则提供程序可以应用额外的优化:

var somePartitionKey = "someValue";
var someId = 8;
var sessions = await context.Sessions
    .Where(b => b.PartitionKey == somePartitionKey && b.Id == someId)
    .SingleAsync();

日志显示了此查询的以下内容:

Executed ReadItem (73 ms, 1 RU) ActivityId='13f0f8b8-d481-47f0-bf41-67f7deb008b2', Container='test', Id='8', Partition='["someValue"]'

在这里,根本不发送 SQL 查询。 相反,提供程序会执行一个极其高效的点读取 (ReadItem API),它直接获取给定分区键和 ID 的文档。 这是可以在 Cosmos DB 中执行的最高效且经济高效的读取类型;有关点读取的详细信息,请参阅 Cosmos DB 文档

若要了解有关使用分区键和点读取进行查询的详细信息,请参阅查询文档页

分层分区键

提示

此处显示的代码来自 HierarchicalPartitionKeysSample.cs

Azure Cosmos DB 最初支持单个分区键,但后来扩展了分区功能,通过在分区键中指定最多三个层次结构级别的规范来支持子分区。 EF Core 9 为分层分区键提供完全支持,使你能够利用与此功能关联的更好的性能和成本节省。

分区键是使用模型生成 API 指定的,通常位于 DbContext.OnModelCreating。 对于分区键的每个级别,实体类型中必须有一个映射的属性。 例如,考虑 UserSession 实体类型:

public class UserSession
{
    // Item ID
    public Guid Id { get; set; }

    // Partition Key
    public string TenantId { get; set; } = null!;
    public Guid UserId { get; set; }
    public int SessionId { get; set; }

    // Other members
    public string Username { get; set; } = null!;
}

以下代码使用 TenantIdUserIdSessionId 属性指定三级分区键:

modelBuilder
    .Entity<UserSession>()
    .HasPartitionKey(e => new { e.TenantId, e.UserId, e.SessionId });

提示

此分区键定义遵循从 Azure Cosmos DB 文档中选择分层分区键中给出的示例。

请注意,从 EF Core 9 开始,任何映射类型的属性都可以在分区键中使用。 对于 bool 和数值类型(如 int SessionId 属性),该值直接在分区键中使用。 其他类型(如 Guid UserId 属性)会自动转换为字符串。

查询时,EF 会自动从查询中提取分区键值,并将其应用于 Cosmos 查询 API,以确保查询被适当地限制在尽可能少的分区中。 例如,考虑以下提供分区键值的 LINQ 查询:

var tenantId = "Microsoft";
var sessionId = 7;
var userId = new Guid("99A410D7-E467-4CC5-92DE-148F3FC53F4C");

var sessions = await context.Sessions
    .Where(
        e => e.TenantId == tenantId
             && e.UserId == userId
             && e.SessionId == sessionId
             && e.Username.Contains("a"))
    .ToListAsync();

执行此查询时,EF Core 将提取 tenantIduserIdsessionId 参数的值,并将其作为分区键值传递给 Cosmos 查询 API。 例如,请参阅执行上述查询的日志:

info: 6/10/2024 19:06:00.017 CosmosEventId.ExecutingSqlQuery[30100] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executing SQL query for container 'UserSessionContext' in partition '["Microsoft","99a410d7-e467-4cc5-92de-148f3fc53f4c",7.0]' [Parameters=[]]
      SELECT c
      FROM root c
      WHERE ((c["Discriminator"] = "UserSession") AND CONTAINS(c["Username"], "a"))

请注意,分区键比较已从 WHERE 子句中删除,而是用作高效执行的分区键:["Microsoft","99a410d7-e467-4cc5-92de-148f3fc53f4c",7.0]

有关详细信息,请参阅有关使用分区键进行查询的文档。

显著提高了 LINQ 查询功能

在 EF 9.0 中,Cosmos DB 提供程序的 LINQ 转换功能得到了极大的扩展,该提供程序现在可以执行更多的查询类型。 查询改进的完整列表太长,无法列出,但以下是主要亮点:

  • Cosmos 提供程序现在完全支持 EF 的基元集合,允许对 ints 或字符串等集合执行 LINQ 查询。 有关详细信息,请参阅 EF8 中的新增功能:基元集合
  • 还添加了对非基元集合的任意查询的支持。
  • 现在支持许多其他 LINQ 运算符:索引到集合、Length/CountElementAtContains 和许多其他运算符。
  • 添加了对聚合运算符(如 CountSum)的支持。
  • 添加了许多函数转换(有关支持的转换的完整列表,请参阅函数映射文档):
    • 已添加对 DateTimeDateTimeOffset 组件成员(DateTime.YearDateTimeOffset.Month)的转换。
    • EF.Functions.IsDefinedEF.Functions.CoalesceUndefined 现在允许处理 undefined 值。
    • string.ContainsStartsWithEndsWith 现在支持 StringComparison.OrdinalIgnoreCase

有关查询改进的完整列表,请参阅此问题

改进的建模符合 Cosmos 和 JSON 标准

EF 9.0 以对基于 JSON 的文档数据库更自然的方式映射到 Cosmos DB 文档,并帮助与其他访问文档的系统进行互操作。 尽管这需要重大更改,但存在允许在所有情况下恢复到 9.0 之前行为的 API。

无鉴别器的简化 id 属性

首先,以前版本的 EF 将鉴别器值插入 JSON id 属性,并生成如下文档:

{
    "id": "Blog|1099",
    ...
}

这样做是为了允许不同类型的文档(例如博客和帖子)和相同的键值 (1099) 存在于同一容器分区中。 从 EF 9.0 开始,id 属性仅包含键值:

{
    "id": 1099,
    ...
}

这是一种更自然的映射到 JSON 的方式,使外部工具和系统更容易与 EF 生成的 JSON 文档进行交互;这样的外部系统通常不知道 EF 鉴别器值,默认情况下,EF 鉴别器值来自 .NET 类型。

请注意,这是一项重大更改,因为 EF 将无法再查询旧 id 格式的现有文档。 引入了 API 以恢复到以前的行为。有关更多详细信息,请参阅重大更改说明文档

鉴别器属性已重命名为 $type

默认的鉴别器属性以前命名为 Discriminator。 EF 9.0 将默认值更改为 $type

{
    "id": 1099,
    "$type": "Blog",
    ...
}

这遵循了 JSON 多态性的新兴标准,允许与其他工具更好的互操作性。 例如,.NET 的 System.Text.Json 还支持多态性,使用 $type 作为其默认的鉴别器属性名称 (docs)。

请注意,这是一项重大更改,因为 EF 将无法再使用旧的鉴别器属性名查询现有文档。 有关如何恢复到以前的命名的详细信息,请参阅重大更改说明

矢量相似性搜索(预览版)

Azure Cosmos DB 对矢量相似性搜索的支持现为预览版。 矢量搜索是某些应用程序类型的基本部分,包括 AI、语义搜索等。 Cosmos DB 对矢量搜索的支持允许在单个数据库中存储数据和矢量,并执行查询,这可以大大简化体系结构,并消除在堆栈中额外使用专用矢量数据库解决方案的需要。 若要了解有关 Cosmos DB 矢量搜索的详细信息,请参阅文档

正确设置 Cosmos DB 容器后,通过 EF 使用矢量搜索只需添加矢量属性并对其进行配置即可:

public class Blog
{
    ...

    public float[] Vector { get; set; }
}

public class BloggingContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Embeddings)
            .IsVector(DistanceFunction.Cosine, dimensions: 1536);
    }
}

完成后,使用 LINQ 查询中的 EF.Functions.VectorDistance() 函数执行矢量相似性搜索:

var blogs = await context.Blogs
    .OrderBy(s => EF.Functions.VectorDistance(s.Vector, vector))
    .Take(5)
    .ToListAsync();

有关详细信息,请参阅有关矢量搜索的文档

分页支持

Cosmos DB 提供程序现在允许通过延续令牌对查询结果进行分页,这比传统使用 SkipTake 更高效、更具成本效益:

var firstPage = await context.Posts
    .OrderBy(p => p.Id)
    .ToPageAsync(pageSize: 10, continuationToken: null);

var continuationToken = page.ContinuationToken;
foreach (var post in page.Values)
{
    // Display/send the posts to the user
}

ToPageAsync 运算符返回 CosmosPage,它公开一个延续令牌,可用于在稍后有效地恢复查询,获取接下来的 10 个项目:

var nextPage = await context.Sessions.OrderBy(s => s.Id).ToPageAsync(10, continuationToken);

有关详细信息,请参阅有关分页的文档部分

FromSql 实现更安全的 SQL 查询

Cosmos DB 提供程序允许通过 FromSqlRaw 进行 SQL 查询。 但是,当用户提供的数据内插或连接到 SQL 时,该 API 可能容易受到 SQL 注入攻击。 在 EF 9.0 中,现在可以使用新 FromSql 方法,该方法始终将参数化数据作为 SQL 外部的参数进行集成:

var maxAngle = 8;
_ = await context.Blogs
    .FromSql($"SELECT VALUE c FROM root c WHERE c.Angle1 <= {maxAngle}")
    .ToListAsync();

有关详细信息,请参阅有关分页的文档部分

基于角色的访问权限

Azure Cosmos DB for NoSQL 包含内置的基于角色的访问控制 (RBAC) 系统。 EF9 现在支持通过它对容器进行管理和使用。 应用程序代码无需更改。 有关详细信息,请参阅问题 #32197

默认情况下,同步 I/O 现在被阻止

Azure Cosmos DB for NoSQL 不支持应用程序代码中的同步(阻止)API。 以前,EF 通过在异步调用上阻止来掩盖这一点。 但是,这既鼓励使用同步 I/O,这是一种不好的做法,并且可能会导致死锁。。 因此,从 EF 9 开始,尝试同步访问时会引发异常。 例如:

通过适当配置警告级别,目前仍可使用同步 I/O。 例如,在 DbContext 类型上的 OnConfiguring 中:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.ConfigureWarnings(b => b.Ignore(CosmosEventId.SyncNotSupported));

但请注意,我们计划在 EF 11 中完全删除同步支持,因此请尽快开始更新以使用异步方法,如 ToListAsyncSaveChangesAsync

AOT 和预编译查询

如简介中所述,我们在幕后进行了大量工作,让 EF Core 能够在没有实时 (JIT) 编译的情况下运行。 相反,EF 会预先 (AOT) 编译运行应用程序中的查询所需的所有内容。 此 AOT 编译和相关的处理将作为生成和发布应用程序的一部分进行。 目前在 EF9 版本中,没有太多功能可供像你这样的应用开发人员使用。 但是,如果感兴趣的话,EF9 中支持 AOT 和预编译查询的已完成问题包括:

在此处查看有关如何在体验结合时使用预编译查询的示例。

LINQ 和 SQL 转换

与每个版本一样,EF9 包括对 LINQ 查询功能的大量改进。 新的查询可以被转换,并且许多受支持方案的 SQL 翻译已经得到了改进,以获得更好的性能和可读性。

改进的数量太多了,无法在这里一一列出。 下面重点介绍了一些更重要的改进;有关在 9.0 中完成的工作的更完整列表,请参阅此问题

我们要感谢 Andrea Canciani (@ranma42) 为优化 EF Core 生成的 SQL 做出的众多高质量贡献!

复杂类型:GroupBy 和 ExecuteUpdate 支持

GroupBy

提示

此处显示的代码来自 ComplexTypesSample.cs

EF9 支持按复杂类型实例进行分组。 例如:

var groupedAddresses = await context.Stores
    .GroupBy(b => b.StoreAddress)
    .Select(g => new { g.Key, Count = g.Count() })
    .ToListAsync();

EF 将它转换为按复杂类型的每个成员进行分组,这与复杂类型作为值对象的语义相一致。 例如,在 Azure SQL 上:

SELECT [s].[StoreAddress_City], [s].[StoreAddress_Country], [s].[StoreAddress_Line1], [s].[StoreAddress_Line2], [s].[StoreAddress_PostCode], COUNT(*) AS [Count]
FROM [Stores] AS [s]
GROUP BY [s].[StoreAddress_City], [s].[StoreAddress_Country], [s].[StoreAddress_Line1], [s].[StoreAddress_Line2], [s].[StoreAddress_PostCode]

ExecuteUpdate

提示

此处显示的代码来自 ExecuteUpdateSample.cs

同样,在 EF9 ExecuteUpdate 中也进行了改进,以接受复杂类型属性。 但是,必须显式指定每个复杂类型的成员。 例如:

var newAddress = new Address("Gressenhall Farm Shop", null, "Beetley", "Norfolk", "NR20 4DR");

await context.Stores
    .Where(e => e.Region == "Germany")
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.StoreAddress, newAddress));

这将生成 SQL,更新映射到复杂类型的每个列:

UPDATE [s]
SET [s].[StoreAddress_City] = @__complex_type_newAddress_0_City,
    [s].[StoreAddress_Country] = @__complex_type_newAddress_0_Country,
    [s].[StoreAddress_Line1] = @__complex_type_newAddress_0_Line1,
    [s].[StoreAddress_Line2] = NULL,
    [s].[StoreAddress_PostCode] = @__complex_type_newAddress_0_PostCode
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Germany'

以前,必须在 ExecuteUpdate 调用中手动列出复杂类型的不同属性。

从 SQL 中删除不需要的元素

以前,EF 有时会生成包含实际不需要的元素的 SQL;在大多数情况下,这些可能是在 SQL 处理的早期阶段需要的,并且被抛在了后面。 EF9 现在删除了大多数此类元素,从而使 SQL 更紧凑,在某些情况下更高效。

表删除

作为第一个示例,EF 生成的 SQL 有时包含查询中实际上不需要的表的 JOIN。 请考虑以下模型,该模型使用每个类型一个表 (TPT) 继承映射

public class Order
{
    public int Id { get; set; }
    ...

    public Customer Customer { get; set; }
}

public class DiscountedOrder : Order
{
    public double Discount { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    ...

    public List<Order> Orders { get; set; }
}

public class BlogContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>().UseTptMappingStrategy();
    }
}

如果我们然后执行以下查询以获取至少有一个订单的所有客户:

var customers = await context.Customers.Where(o => o.Orders.Any()).ToListAsync();

EF8 生成了以下 SQL:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    LEFT JOIN [DiscountedOrders] AS [d] ON [o].[Id] = [d].[Id]
    WHERE [c].[Id] = [o].[CustomerId])

请注意,尽管查询中没有引用任何列,但它包含了对 DiscountedOrders 表的联接。 EF9 生成一个没有联接的删除后的 SQL:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId])

投影删除

同样,让我们检查以下查询:

var orders = await context.Orders
    .Where(o => o.Amount > 10)
    .Take(5)
    .CountAsync();

在 EF8 上,此查询生成了以下 SQL:

SELECT COUNT(*)
FROM (
    SELECT TOP(@__p_0) [o].[Id]
    FROM [Orders] AS [o]
    WHERE [o].[Amount] > 10
) AS [t]

请注意,子查询中不需要 [o].[Id] 投影,因为外部 SELECT 表达式只是对行进行计数。 EF9 将改为生成以下内容:

SELECT COUNT(*)
FROM (
    SELECT TOP(@__p_0) 1 AS empty
    FROM [Orders] AS [o]
    WHERE [o].[Amount] > 10
) AS [s]

...并且投影为空。 这可能看起来不多,但在某些情况下,它可以显著简化 SQL;欢迎你滚动浏览测试中的一些 SQL 更改,以查看效果。

涉及 GREATEST/LEAST 的转换

提示

此处显示的代码来自 LeastGreatestSample.cs

引入了一些使用 GREATESTLEAST SQL 函数的新转换。

重要

GREATESTLEAST 函数已在 2022 版本中引入到 SQL Server/Azure SQL 数据库。 Visual Studio 2022 默认安装 SQL Server 2019。 建议安装 SQL Server Developer Edition 2022 以试用 EF9 中的这些新转换。

例如,使用 Math.MaxMath.Min 的查询现在分别使用 GREATESTLEAST 针对 Azure SQL 进行转换。 例如:

var walksUsingMin = await context.Walks
    .Where(e => Math.Min(e.DaysVisited.Count, e.ClosestPub.Beers.Length) > 4)
    .ToListAsync();

当使用 EF9 针对 SQL Server 2022 执行时,此查询将转换为以下 SQL:

SELECT [w].[Id], [w].[ClosestPubId], [w].[DaysVisited], [w].[Name], [w].[Terrain]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]
WHERE LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([w].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b])) >

Math.MinMath.Max 也可以用于基元集合的值。 例如:

var pubsInlineMax = await context.Pubs
    .SelectMany(e => e.Counts)
    .Where(e => Math.Max(e, threshold) > top)
    .ToListAsync();

当使用 EF9 针对 SQL Server 2022 执行时,此查询将转换为以下 SQL:

SELECT [c].[value]
FROM [Pubs] AS [p]
CROSS APPLY OPENJSON([p].[Counts]) WITH ([value] int '$') AS [c]
WHERE GREATEST([c].[value], @__threshold_0) > @__top_1

最后,可以使用 RelationalDbFunctionsExtensions.LeastRelationalDbFunctionsExtensions.Greatest 直接调用 SQL 中的 LeastGreatest 函数。 例如:

var leastCount = await context.Pubs
    .Select(e => EF.Functions.Least(e.Counts.Length, e.DaysVisited.Count, e.Beers.Length))
    .ToListAsync();

当使用 EF9 针对 SQL Server 2022 执行时,此查询将转换为以下 SQL:

SELECT LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([p].[Counts]) AS [c]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b]))
FROM [Pubs] AS [p]

强制或防止查询参数化

提示

此处显示的代码来自 QuerySample.cs

除某些特殊情况外,EF Core 将参数化 LINQ 查询中使用的变量,但在生成的 SQL 中包含常量。 例如,考虑以下查询方法:

async Task<List<Post>> GetPosts(int id)
    => await context.Posts
        .Where(e => e.Title == ".NET Blog" && e.Id == id)
        .ToListAsync();

使用 Azure SQL 时,这会转换为以下 SQL 和参数:

Executed DbCommand (1ms) [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE [p].[Title] = N'.NET Blog' AND [p].[Id] = @__id_0

请注意,EF 在 SQL 中为“.NET Blog”创建了一个常量,因为该值不会因查询而异。 使用常量允许数据库引擎在创建查询计划时检查该值,这可能可以提高查询的效率。

另一方面,id 的值是参数化的,因为同一查询可能会使用许多不同的 id 值来执行。 在这种情况下创建常量将导致查询缓存受到大量查询的污染,这些查询仅在 id 值上有所不同。 这对于数据库的整体性能非常不利。

一般来说,不应更改这些默认值。 但是,EF Core 8.0.2 引入了一种 EF.Constant 方法,该方法强制 EF 使用常量,即使默认情况下使用参数也是如此。 例如:

async Task<List<Post>> GetPostsForceConstant(int id)
    => await context.Posts
        .Where(e => e.Title == ".NET Blog" && e.Id == EF.Constant(id))
        .ToListAsync();

现在,转换为 id 值包含常量:

Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE [p].[Title] = N'.NET Blog' AND [p].[Id] = 1

EF9 引入了 EF.Parameter 方法来执行相反的操作。 也就是说,强制 EF 使用参数,即使该值是代码中的常量。 例如:

async Task<List<Post>> GetPostsForceParameter(int id)
    => await context.Posts
        .Where(e => e.Title == EF.Parameter(".NET Blog") && e.Id == id)
        .ToListAsync();

转换现在为“.NET Blog”字符串包含参数:

Executed DbCommand (1ms) [Parameters=[@__p_0='.NET Blog' (Size = 4000), @__id_1='1'], CommandType='Text', CommandTimeout='30']
SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE [p].[Title] = @__p_0 AND [p].[Id] = @__id_1

内联无关子查询

提示

此处显示的代码来自 QuerySample.cs

在 EF8 中,可将另一个查询中引用的 IQueryable 作为单独的数据库往返执行。 例如,请考虑下面这个 LINQ 查询:

var dotnetPosts = context
    .Posts
    .Where(p => p.Title.Contains(".NET"));

var results = dotnetPosts
    .Where(p => p.Id > 2)
    .Select(p => new { Post = p, TotalCount = dotnetPosts.Count() })
    .Skip(2).Take(10)
    .ToArray();

在 EF8 中,对 dotnetPosts 的查询作为一次往返执行,然后将最终结果作为第二次查询执行。 例如,在 SQL Server 上:

SELECT COUNT(*)
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%'

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

在 EF9 中,dotnetPosts 中的 IQueryable 是内联的,从而产生单一数据库往返:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata], (
    SELECT COUNT(*)
    FROM [Posts] AS [p0]
    WHERE [p0].[Title] LIKE N'%.NET%')
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

优化了使用 Count != 0 的查询

提示

此处显示的代码来自 QuerySample.cs

在 EF8 中,以下 LINQ 查询已转换为使用 SQL COUNT 函数:

var blogsWithPost = await context.Blogs
    .Where(b => b.Posts.Count > 0)
    .ToListAsync();

EF9 现在使用 EXISTS 生成更高效的转换:

SELECT "b"."Id", "b"."Name", "b"."SiteUri"
FROM "Blogs" AS "b"
WHERE EXISTS (
    SELECT 1
    FROM "Posts" AS "p"
    WHERE "b"."Id" = "p"."BlogId")

C# 对可为 null 值进行比较操作的语义

在 EF8 中,对于某些方案,可以为 null 的元素之间的比较没有正确执行。 在 C# 中,如果一个或两个操作数都为 null,则比较操作的结果为 false;否则,将比较操作数的包含值。 在 EF8 中,我们使用数据库 null 语义来转换比较。 这将产生与使用 LINQ to Objects 的类似查询不同的结果。 此外,在筛选器与投影中完成比较时,我们将产生不同的结果。 某些查询还会在 Sql Server 和 Sqlite/Postgres 之间产生差异结果。

例如,查询:

var negatedNullableComparisonFilter = await context.Entities
    .Where(x => !(x.NullableIntOne > x.NullableIntTwo))
    .Select(x => new { x.NullableIntOne, x.NullableIntTwo }).ToListAsync();

将生成以下 SQL:

SELECT [e].[NullableIntOne], [e].[NullableIntTwo]
FROM [Entities] AS [e]
WHERE NOT ([e].[NullableIntOne] > [e].[NullableIntTwo])

其中筛选出其 NullableIntOneNullableIntTwo 设置为 null 的实体。

在 EF9 中,我们生成:

SELECT [e].[NullableIntOne], [e].[NullableIntTwo]
FROM [Entities] AS [e]
WHERE CASE
    WHEN [e].[NullableIntOne] > [e].[NullableIntTwo] THEN CAST(0 AS bit)
    ELSE CAST(1 AS bit)
END = CAST(1 AS bit)

在投影中进行了类似的比较:

var negatedNullableComparisonProjection = await context.Entities.Select(x => new
{
    x.NullableIntOne,
    x.NullableIntTwo,
    Operation = !(x.NullableIntOne > x.NullableIntTwo)
}).ToListAsync();

结果为以下 SQL:

SELECT [e].[NullableIntOne], [e].[NullableIntTwo], CASE
    WHEN NOT ([e].[NullableIntOne] > [e].[NullableIntTwo]) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END AS [Operation]
FROM [Entities] AS [e]

对于 NullableIntOneNullableIntTwo 设置为 null 的实体(而不是 C# 中预期的 true),它返回 false。 在 Sqlite 上运行相同的方案:

SELECT "e"."NullableIntOne", "e"."NullableIntTwo", NOT ("e"."NullableIntOne" > "e"."NullableIntTwo") AS "Operation"
FROM "Entities" AS "e"

这会导致 Nullable object must have a value 异常,因为对于 NullableIntOneNullableIntTwo 为 null 的情况,转换会产生 null 值。

EF9 现在可正确处理这些方案,从而产生与 LINQ to Objects 一致且跨不同提供程序的结果。

此增强功能由 @ranma42 提供。 非常感谢!

改进了逻辑非运算符 (!) 的转换

EF9 围绕 SQL CASE/WHENCOALESCE、否定和各种其他构造带来了许多优化;其中大部分是由 Andrea Canciani (@ranma42) 提供的——非常感谢做出的所有贡献! 下面,我们将详细介绍围绕逻辑非的一些优化。

让我们检查以下查询:

var negatedContainsSimplification = await context.Posts
    .Where(p => !p.Content.Contains("Announcing"))
    .Select(p => new { p.Content }).ToListAsync();

在 EF8 中,我们将生成以下 SQL:

SELECT "p"."Content"
FROM "Posts" AS "p"
WHERE NOT (instr("p"."Content", 'Announcing') > 0)

在 EF9 中,我们将“推送”NOT 操作纳入比较:

SELECT "p"."Content"
FROM "Posts" AS "p"
WHERE instr("p"."Content", 'Announcing') <= 0

另一个适用于 SQL Server 的示例是否定的条件操作。

var caseSimplification = await context.Blogs
    .Select(b => !(b.Id > 5 ? false : true))
    .ToListAsync();

在 EF8 中,用于生成嵌套 CASE 块:

SELECT CASE
    WHEN CASE
        WHEN [b].[Id] > 5 THEN CAST(0 AS bit)
        ELSE CAST(1 AS bit)
    END = CAST(0 AS bit) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END
FROM [Blogs] AS [b]

在 EF9 中,我们删除了嵌套:

SELECT CASE
    WHEN [b].[Id] > 5 THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END
FROM [Blogs] AS [b]

在 SQL Server 上,投影否定的布尔属性时:

var negatedBoolProjection = await context.Posts.Select(x => new { x.Title, Active = !x.Archived }).ToListAsync();

EF8 将生成 CASE 块,因为比较不能直接显示在 SQL Server 查询中的投影中:

SELECT [p].[Title], CASE
   WHEN [p].[Archived] = CAST(0 AS bit) THEN CAST(1 AS bit)
   ELSE CAST(0 AS bit)
END AS [Active]
FROM [Posts] AS [p]

在 EF9 中,此转换已简化,现在使用异或 (^):

SELECT [p].[Title], [p].[Archived] ^ CAST(1 AS bit) AS [Active]
FROM [Posts] AS [p]

其他查询改进

  • EF8 中引入的基元集合查询支持已扩展,以支持所有 ICollection<T> 类型。 请注意,这仅适用于参数和内联集合 - 作为实体一部分的基元集合仍然仅限于数组、列表和 EF9 中的只读数组/列表
  • ToHashSetAsync 函数将查询结果作为 HashSet 返回(#30033,由 @wertzui 提供)。
  • TimeOnly.FromDateTimeFromTimeSpan 现在已在 SQL Server 上转换 (#33678)。
  • ToString over enums 现在已转换(#33706,由 @Danevandy99 提供)。
  • string.Join现在在 SQL Server 的非聚合上下文中转换为 CONCAT_WS (#28899)。
  • EF.Functions.PatIndex 现在转换为 SQL Server PATINDEX 函数,该函数返回模式第一次出现的起始位置(#33702@smnsht)。
  • SumAverage 现在在 SQLite 上支持小数(#33721,由 @ranma42 提供)。
  • 修复和优化了 string.StartsWithEndsWith (#31482)。
  • Convert.To* 方法现在可以接受类型 object 的参数(#33891,由 @imangd 提供)。

以上只是 EF9 中一些更重要的查询改进;有关更完整的列表,请参阅此问题

迁移

改进了临时表迁移

对于 EF9,将现有表更改为临时表时创建的迁移已缩小。 例如,在 EF8 中,将单个现有表设为临时表会导致以下迁移:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "SiteUri",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "Name",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<int>(
        name: "Id",
        table: "Blogs",
        type: "int",
        nullable: false,
        oldClrType: typeof(int),
        oldType: "int")
        .Annotation("SqlServer:Identity", "1, 1")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart")
        .OldAnnotation("SqlServer:Identity", "1, 1");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
}

在 EF9 中,相同的操作现在会导致更小的迁移:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodEndColumn", true);

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
}

模型构建

自动编译的模型

提示

此处显示的代码来自 NewInEFCore9.CompiledModels 示例。

编译的模型可以改善大型模型(即实体类型计数在 100 个或 1000 个以上)的应用程序启动时间。 在以前版本的 EF Core 中,必须使用命令行手动生成编译的模型。 例如:

dotnet ef dbcontext optimize

运行该命令后,必须将类似于 .UseModel(MyCompiledModels.BlogsContextModel.Instance) 的代码行添加到 OnConfiguring,以便让 EF Core 使用编译的模型。

从 EF9 开始,当应用程序的 DbContext 类型与编译的模型位于同一项目/程序集中时,不再需要此 .UseModel 行。 相反,将自动检测和使用编译的模型。 只要让 EF 在生成模型时记录日志,就可以看到这一点。 运行一个简单的应用程序,然后在应用程序启动时显示生成模型的 EF:

Starting application...
>> EF is building the model...
Model loaded with 2 entity types.

在模型项目上运行 dotnet ef dbcontext optimize 的输出为:

PS D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model> dotnet ef dbcontext optimize

Build succeeded in 0.3s

Build succeeded in 0.3s
Build started...
Build succeeded.
>> EF is building the model...
>> EF is building the model...
Successfully generated a compiled model, it will be discovered automatically, but you can also call 'options.UseModel(BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model> 

请注意,日志输出指示模型在命令运行时生成。 如果现在再次运行应用程序,则在不进行任何代码更改的情况下重新生成后,输出为:

Starting application...
Model loaded with 2 entity types.

请注意,启动应用程序时未生成模型,因为自动检测并使用了编译的模型。

MSBuild 集成

使用上述方法时,在更改实体类型或 DbContext 配置时,仍需手动重新生成编译的模型。 但是,EF9 附带 MSBuild 和目标包,可在模型项目生成时自动更新编译的模型! 若要开始,请安装 Microsoft.EntityFrameworkCore.Tasks NuGet 包。 例如:

dotnet add package Microsoft.EntityFrameworkCore.Tasks --version 9.0.0-preview.4.24205.3

提示

使用上述命令中与你使用的 EF Core 版本匹配的包版本。

然后,通过将 EFOptimizeContext 属性设置为 .csproj 文件来启用集成。 例如:

<PropertyGroup>
    <EFOptimizeContext>true</EFOptimizeContext>
</PropertyGroup>

还有一些额外的可选 MSBuild 属性用于控制模型的生成方式,相当于通过命令行向 dotnet ef dbcontext optimize 传递的选项。 这些设置包括:

MSBuild 属性 说明
EFOptimizeContext 设置为 true 以启用自动编译的模型。
DbContextName 要使用的 DbContext 类。 仅类名或完全限定命名的空间。 如果省略此选项,EF Core 将查找上下文类。 如果有多个上下文类,则此选项是必需的。
EFStartupProject 启动项目的相对路径。 默认值是当前文件夹。
EFTargetNamespace 要用于所有生成的类的命名空间。 默认设置为从根命名空间和输出目录以及 CompiledModels 生成。

在我们的示例中,我们需要指定启动项目:

<PropertyGroup>
  <EFOptimizeContext>true</EFOptimizeContext>
  <EFStartupProject>..\App\App.csproj</EFStartupProject>
</PropertyGroup>

现在,如果生成项目,我们可以在生成时看到日志记录,它指示正在生成编译的模型:

Optimizing DbContext...
dotnet exec --depsfile D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.deps.json
  --additionalprobingpath G:\packages 
  --additionalprobingpath "C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages" 
  --runtimeconfig D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.runtimeconfig.json G:\packages\microsoft.entityframeworkcore.tasks\9.0.0-preview.4.24205.3\tasks\net8.0\..\..\tools\netcoreapp2.0\ef.dll dbcontext optimize --output-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model\obj\Release\net8.0\ 
  --namespace NewInEfCore9 
  --suffix .g 
  --assembly D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model\bin\Release\net8.0\Model.dll --startup-assembly D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.dll 
  --project-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model 
  --root-namespace NewInEfCore9 
  --language C# 
  --nullable 
  --working-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App 
  --verbose 
  --no-color 
  --prefix-output 

运行应用程序时会显示已检测到编译的模型,因此该模型不会再次生成:

Starting application...
Model loaded with 2 entity types.

现在,只要模型发生更改,编译的模型就会在项目生成后立即自动重新生成。

[注意!] 我们正在处理对 EF8 和 EF9 中的编译模型的更改中的一些性能问题。 有关详细信息,请参阅问题 33483#

只读基元集合

提示

此处显示的代码来自 PrimitiveCollectionsSample.cs

EF8 引入了对映射数组和基元类型的可变列表的支持。 这已在 EF9 中扩展,以包含只读集合/列表。 具体而言,EF9 支持类型化为 IReadOnlyListIReadOnlyCollectionReadOnlyCollection 的集合。 例如,在以下代码中,DaysVisited 将按约定映射为日期的基元集合:

public class DogWalk
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ReadOnlyCollection<DateOnly> DaysVisited { get; set; }
}

如果需要,只读集合可以由普通可变集合提供支持。 例如,在以下代码中,DaysVisited 可以映射为日期的基元集合,同时仍允许类中的代码操作基础列表。

    public class Pub
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public IReadOnlyCollection<string> Beers { get; set; }

        private List<DateOnly> _daysVisited = new();
        public IReadOnlyList<DateOnly> DaysVisited => _daysVisited;
    }

然后,可以按正常方式在查询中使用这些集合。 例如,使用 SQL Server 时,以下 LINQ 查询:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        Count = w.DaysVisited.Count(v => w.ClosestPub.DaysVisited.Contains(v)),
        TotalCount = w.DaysVisited.Count
    }).ToListAsync();

这会转换为 SQLite 上的以下 SQL:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", (
    SELECT COUNT(*)
    FROM json_each("w"."DaysVisited") AS "d"
    WHERE "d"."value" IN (
        SELECT "d0"."value"
        FROM json_each("p"."DaysVisited") AS "d0"
    )) AS "Count", json_array_length("w"."DaysVisited") AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

指定键和索引的填充因子

提示

此处显示的代码来自 ModelBuildingSample.cs

EF9 支持使用 EF Core 迁移创建密钥和索引时 SQL Server 填充因子的规范。 在 SQL Server 文档中,“创建或重新生成索引时,填充因子值确定了要使用数据填充的每个叶级页面上空间的百分比,从而将每个页面上的剩余空间保留为将来增长的可用空间。”

可以在单个或复合主键和备用键与索引上设置填充因子。 例如:

modelBuilder.Entity<User>()
    .HasKey(e => e.Id)
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasAlternateKey(e => new { e.Region, e.Ssn })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Name })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Region, e.Tag })
    .HasFillFactor(80);

应用于现有表时,这会将表更改为约束的填充因子:

ALTER TABLE [User] DROP CONSTRAINT [AK_User_Region_Ssn];
ALTER TABLE [User] DROP CONSTRAINT [PK_User];
DROP INDEX [IX_User_Name] ON [User];
DROP INDEX [IX_User_Region_Tag] ON [User];

ALTER TABLE [User] ADD CONSTRAINT [AK_User_Region_Ssn] UNIQUE ([Region], [Ssn]) WITH (FILLFACTOR = 80);
ALTER TABLE [User] ADD CONSTRAINT [PK_User] PRIMARY KEY ([Id]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Name] ON [User] ([Name]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Region_Tag] ON [User] ([Region], [Tag]) WITH (FILLFACTOR = 80);

这种增强功能是由 @deano-hunter 贡献的。 非常感谢!

使现有的模型构建约定更具可扩展性

提示

此处显示的代码来自 CustomConventionsSample.cs

应用程序的公共模型构建约定是在 EF7 中引入的。 在 EF9 中,我们简化了一些现有约定的扩展。 例如,在 EF7 中按特性映射属性的代码如下:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

在 EF9 中,这可以简化为以下内容:

public class AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
    : PropertyDiscoveryConvention(dependencies)
{
    protected override bool IsCandidatePrimitiveProperty(
        MemberInfo memberInfo, IConventionTypeBase structuralType, out CoreTypeMapping? mapping)
    {
        if (base.IsCandidatePrimitiveProperty(memberInfo, structuralType, out mapping))
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                return true;
            }

            structuralType.Builder.Ignore(memberInfo.Name);
        }

        mapping = null;
        return false;
    }
}

更新 ApplyConfigurationsFromAssembly 以调用非公共构造函数

在 EF Core 的早期版本中,ApplyConfigurationsFromAssembly 方法仅使用公共无参数构造函数实例化配置类型。 在 EF9 中,我们改进了失败时生成的错误消息,并且还启用了非公共构造函数的实例化。 当在私有嵌套类中共同定位配置时,这非常有用,而该私有嵌套类永远不应由应用程序代码实例化。 例如:

public class Country
{
    public int Code { get; set; }
    public required string Name { get; set; }

    private class FooConfiguration : IEntityTypeConfiguration<Country>
    {
        private FooConfiguration()
        {
        }

        public void Configure(EntityTypeBuilder<Country> builder)
        {
            builder.HasKey(e => e.Code);
        }
    }
}

顺便说一句,有些人认为这种模式令人厌恶,因为它将实体类型与配置耦合在一起。 其他人则认为它非常有用,因为它将配置与实体类型放在一起。 我们不要在这里讨论这个问题。 :-)

SQL Server HierarchyId

提示

此处显示的代码来自 HierarchyIdSample.cs

用于 HierarchyId 路径生成的简便方法

对 SQL Server HierarchyId 类型的第一类支持是在 EF8 中添加的。 在 EF9 中,添加了一种简便方法,以便更轻松地在树结构中创建新的子节点。 例如,以下代码会查询具有 HierarchyId 属性的现有实体:

var daisy = await context.Halflings.SingleAsync(e => e.Name == "Daisy");

然后,可将此 HierarchyId 属性用于创建子节点,而无需任何显式字符串操作。 例如:

var child1 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1), "Toast");
var child2 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 2), "Wills");

如果 daisyHierarchyId/4/1/3/1/,则 child1 会获取 HierarchyId“/4/1/3/1/1/”,child2 会获取 HierarchyId“/4/1/3/1/2/”。

要在这两个子级之间创建节点,可以使用其他子级别。 例如:

var child1b = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1, 5), "Toast");

这会创建一个 HierarchyId/4/1/3/1/1.5/ 的节点,并将其置于 child1child2 之间。

这种增强功能是由 @Rezakazemi890 贡献的。 非常感谢!

工具

减少重新生成次数

默认情况下,dotnet ef 命令行工具会在执行该工具之前会生成项目。 这是因为在出现故障的情况下,在运行该工具之前不进行重新生成会是一种常见的混淆源。 经验丰富的开发人员可以使用 --no-build 选项来避免这种可能会非常缓慢的生成。 但即使 --no-build 选项也可能导致下次在 EF 工具外部生成项目时重新生成该项目。

我们认为 @Suchiman社区贡献已经解决了这一问题。 但我们也意识到,围绕 MSBuild 行为的进行的调整往往会产生意外后果,因此我们要求像你这样的人尝试此操作,并报告你的任何负面体验。