EF Core 9.0 中的新增功能

EF Core 9 (EF9) 是 EF Core 8 之后的下一版本,计划于 2024 年 11 月发布。 有关详细信息,请参阅规划 Entity Framework Core 9

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

提示

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

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

提示

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

LINQ 和 SQL 转换

团队正在研究 EF Core 9 中查询管道的一些重大体系结构更改,作为我们对 JSON 映射和文档数据库不断改进的一部分。 这意味着我们需要让像你这样的人在这些新的内部组件上运行代码。 (如果你正在阅读该版本中的“新增功能”文档,那么你是真正参与社区的一员;谢谢!)我们有超过 120,000 个测试,但这还不够! 我们需要你,在我们的平台上运行真实代码的用户,以便发现问题并发布可靠的版本!

删除传递给 OPENJSON WITH 子句的列

提示

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

EF9 在调用 OPENJSON WITH 时会移除不必要的列。 例如,考虑一个使用谓词从 JSON 集合获取计数的查询:

var postsUpdatedOn = await context.Posts
    .Where(p => p.Metadata!.Updates.Count(e => e.UpdatedOn >= date) == 1)
    .ToListAsync();

在 EF8 中,使用 Azure SQL 数据库提供程序时,此查询会生成以下 SQL:

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 (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Metadata], '$.Updates') WITH (
        [PostedFrom] nvarchar(45) '$.PostedFrom',
        [UpdatedBy] nvarchar(max) '$.UpdatedBy',
        [UpdatedOn] date '$.UpdatedOn',
        [Commits] nvarchar(max) '$.Commits' AS JSON
    ) AS [u]
    WHERE [u].[UpdatedOn] >= @__date_0) = 1

请注意,此查询中不需要 UpdatedByCommits。 从 EF9 开始,这些列现已被删除:

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 (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Metadata], '$.Updates') WITH (
        [PostedFrom] nvarchar(45) '$.PostedFrom',
        [UpdatedOn] date '$.UpdatedOn'
    ) AS [u]
    WHERE [u].[UpdatedOn] >= @__date_0) = 1

在某些情况下,这会导致 WITH 子句被完全移除。 例如:

var tagsWithCount = await context.Tags.Where(p => p.Text.Length == 1).ToListAsync();

在 EF8 中,此查询转换为以下 SQL:

SELECT [t].[Id], [t].[Text]
FROM [Tags] AS [t]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([t].[Text]) WITH ([value] nvarchar(max) '$') AS [t0]) = 1

在 EF9 中,已改进为:

SELECT [t].[Id], [t].[Text]
FROM [Tags] AS [t]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([t].[Text]) AS [t0]) = 1

涉及 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 和参数:

info: 2/5/2024 15:43:13.789 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      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 值来执行。 在这种情况下创建常量会导致查询缓存受到大量仅参数值不同的查询的污染。 这对于数据库的整体性能非常不利。

一般来说,不应更改这些默认值。 但是,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 值包含常量:

info: 2/5/2024 15:43:13.812 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      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”字符串包含参数:

info: 2/5/2024 15:43:13.803 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      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

新的 ToHashSetAsync<T> 方法

提示

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

自 .NET Core 2.0 以来,Enumerable.ToHashSet 方法已经存在。 在 EF9 中,已添加等效的异步方法。 例如:

var set1 = await context.Posts
    .Where(p => p.Tags.Count > 3)
    .ToHashSetAsync();

var set2 = await context.Posts
    .Where(p => p.Tags.Count > 3)
    .ToHashSetAsync(ReferenceEqualityComparer.Instance);

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

ExecuteUpdate 和 ExecuteDelete

允许将复杂类型实例传递给 ExecuteUpdate

提示

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

EF7 中引入了 ExecuteUpdate API,用于对数据库执行即时、直接更新,而无需跟踪或 SaveChanges。 例如:

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

运行此代码会执行以下查询,将 Region 更新为“Deutschland”:

UPDATE [s]
SET [s].[Region] = N'Deutschland'
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Germany'

在 EF8 中,还可以使用 ExecuteUpdate 来更新复杂类型属性的值。 但是,必须显式指定每个复杂类型的成员。 例如:

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

await context.Stores
    .Where(e => e.Region == "Deutschland")
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.StoreAddress.Line1, newAddress.Line1)
            .SetProperty(b => b.StoreAddress.Line2, newAddress.Line2)
            .SetProperty(b => b.StoreAddress.City, newAddress.City)
            .SetProperty(b => b.StoreAddress.Country, newAddress.Country)
            .SetProperty(b => b.StoreAddress.PostCode, newAddress.PostCode));

运行此代码将导致以下查询执行:

UPDATE [s]
SET [s].[StoreAddress_PostCode] = @__newAddress_PostCode_4,
    [s].[StoreAddress_Country] = @__newAddress_Country_3,
    [s].[StoreAddress_City] = @__newAddress_City_2,
    [s].[StoreAddress_Line2] = NULL,
    [s].[StoreAddress_Line1] = @__newAddress_Line1_0
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Deutschland'

在 EF9 中,可以通过传递复杂类型实例本身来执行相同的更新。 也就是说,不需要显式指定每个成员。 例如:

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));

运行此代码会导致与前面的示例相同的查询执行:

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 的单次调用中。 例如:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(
                b => b.CustomerInfo.WorkAddress, new Address("Gressenhall Workhouse", null, "Beetley", "Norfolk", "NR20 4DR"))
            .SetProperty(b => b.CustomerInfo.HomeAddress, new Address("Gressenhall Farm", null, "Beetley", "Norfolk", "NR20 4DR"))
            .SetProperty(b => b.CustomerInfo.Tag, "Tog"));

运行此代码会导致与前面的示例相同的查询执行:

UPDATE [c]
SET [c].[CustomerInfo_Tag] = N'Tog',
    [c].[CustomerInfo_HomeAddress_City] = N'Beetley',
    [c].[CustomerInfo_HomeAddress_Country] = N'Norfolk',
    [c].[CustomerInfo_HomeAddress_Line1] = N'Gressenhall Farm',
    [c].[CustomerInfo_HomeAddress_Line2] = NULL,
    [c].[CustomerInfo_HomeAddress_PostCode] = N'NR20 4DR',
    [c].[CustomerInfo_WorkAddress_City] = N'Beetley',
    [c].[CustomerInfo_WorkAddress_Country] = N'Norfolk',
    [c].[CustomerInfo_WorkAddress_Line1] = N'Gressenhall Workhouse',
    [c].[CustomerInfo_WorkAddress_Line2] = NULL,
    [c].[CustomerInfo_WorkAddress_PostCode] = N'NR20 4DR'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

迁移

改进了临时表迁移

对于 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);
}

模型构建

指定序列的缓存

提示

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

EF9 允许为支持此操作的任何关系数据库提供程序设置数据库序列的缓存选项。 例如,UseCache 可用于显式启用缓存并设置缓存大小:

modelBuilder.HasSequence<int>("MyCachedSequence")
    .HasMin(10).HasMax(255000)
    .IsCyclic()
    .StartsAt(11).IncrementsBy(2)
    .UseCache(3);

这会导致使用 SQL Server 时出现以下序列定义:

CREATE SEQUENCE [MyCachedSequence] AS int START WITH 11 INCREMENT BY 2 MINVALUE 10 MAXVALUE 255000 CYCLE CACHE 3;

同样,UseNoCache 会显式禁用缓存:

modelBuilder.HasSequence<int>("MyUncachedSequence")
    .HasMin(10).HasMax(255000)
    .IsCyclic()
    .StartsAt(11).IncrementsBy(2)
    .UseNoCache();
CREATE SEQUENCE [MyUncachedSequence] AS int START WITH 11 INCREMENT BY 2 MINVALUE 10 MAXVALUE 255000 CYCLE NO CACHE;

如果 UseCacheUseNoCache 两者均未调用,则未指定缓存,并且数据库将使用其默认值。 对于不同的数据库,这可能是不同的默认值。

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

指定键和索引的填充因子

提示

此处显示的代码来自 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);

注意

预览版 2 中当前存在一个 bug,即在首次创建表时不会包括填充因子。 问题 #33269 将对此进行跟踪

这种增强功能是由 @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 行为的进行的调整往往会产生意外后果,因此我们要求像你这样的人尝试此操作,并报告你的任何负面体验。