EF Core 8 中的新增功能

EF Core 8.0(EF8)于 2023 年 11 月 发布

提示

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

EF8 需要 .NET 8 SDK 才能生成,并且需要运行 .NET 8 运行时。 EF8 无法在早期 .NET 版本上运行,也无法在 .NET Framework 上运行。

使用复杂类型的值对象

保存到数据库的对象可以分为三大类:

  • 非结构化并保存单个值的对象。 例如,intGuidstringIPAddress。 这些类型(有点宽泛)称为“基元类型”。
  • 为保存多个值而构造的对象,对象的标识由键值定义。 例如:BlogPostCustomer。 它们称为“实体类型”。
  • 为保存多个值而构造的对象,但对象没有用键定义的标识。 例如:AddressCoordinate

在 EF8 之前,没有很好的方法来映射第三种类型的对象。 从属类型是可以使用的,但由于从属类型实际上是实体类型,因此它们具有基于键值的语义,即便该键值处于隐藏状态时也是如此。

EF8 现在支持“复杂类型”来涵盖此第三种类型的对象。 复杂类型对象:

  • 未按键值标识或跟踪。
  • 必须定义为实体类型的一部分。 (换句话说,你不能有复杂类型的 DbSet。)
  • 可以是 .NET 值类型引用类型
  • 实例可以由多个属性共享。

简单示例

例如,想一想 Address 类型:

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

然后,Address 用于一个简单的客户/订单模型中的三个位置:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

让我们使用其地址创建并保存客户:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

这会导致将以下行插入数据库:

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

请注意,复杂类型不会有自己的表。 相反,它们会被内联保存到 Customers 表的列。 这与从属类型的表共享行为相匹配。

注意

我们并不打算允许将复杂类型映射到其自己的表。 但是,在将来的版本中,我们确实计划允许将复杂类型另存为单列的 JSON 文档。 如果这对你很重要,请为问题 #31252 投票。

现在假设我们要将订单寄送给客户,并使用客户的地址作为默认的计费地址和发货地址。 执行此操作的自然方法是将 Address 对象从 Customer 复制到 Order。 例如:

customer.Orders.Add(
    new Order { Contents = "Tesco Tasty Treats", BillingAddress = customer.Address, ShippingAddress = customer.Address, });

await context.SaveChangesAsync();

对于复杂类型,此过程会按预期工作,地址会插入 Orders 表中:

INSERT INTO [Orders] ([Contents], [CustomerId],
    [BillingAddress_City], [BillingAddress_Country], [BillingAddress_Line1], [BillingAddress_Line2], [BillingAddress_PostCode],
    [ShippingAddress_City], [ShippingAddress_Country], [ShippingAddress_Line1], [ShippingAddress_Line2], [ShippingAddress_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

看到这里,你可能会说,“从属类型也可以做到这一点!”但是,从属类型的“实体类型”语义很快就会成为障碍。 例如,使用从属类型运行上述代码会导致大量警告,然后出现错误:

warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.BillingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Order.BillingAddress#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update) 
      An exception occurred in the database while saving changes for context type 'NewInEfCore8.ComplexTypesSample+CustomerContext'.
      System.InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()

这是因为 Address 实体类型的单个实例(具有相同的隐藏键值)被用于三个不同的实体实例。 另一方面,允许在复杂属性之间共享同一实例,因此使用复杂类型时代码会按预期工作。

复杂类型的配置

必须使用映射属性或通过调用 OnModelCreating 中的 ComplexProperty API 来在模型中配置复杂类型。 复杂类型不按约定发现。

例如,可以使用 ComplexTypeAttribute 配置 Address 类型:

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

或在 OnModelCreating 中:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ComplexProperty(e => e.Address);

    modelBuilder.Entity<Order>(b =>
    {
        b.ComplexProperty(e => e.BillingAddress);
        b.ComplexProperty(e => e.ShippingAddress);
    });
}

可变性

在上面的示例中,我们最后在三个位置中使用了相同的 Address 实例。 这是被允许的,并且不会在使用复杂类型时对 EF Core 造成任何问题。 但是,共享同一引用类型的实例意味着,如果修改了实例上的属性值,则该更改将反映在所有三个使用中。 例如,接着上面的例子,让我们更改客户地址的 Line1 并保存更改:

customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();

这会导致使用 SQL Server 时对数据库进行以下更新:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Orders] SET [BillingAddress_Line1] = @p2, [ShippingAddress_Line1] = @p3
OUTPUT 1
WHERE [Id] = @p4;

请注意,三个 Line1 列都已更改,因为它们都共享同一实例。 这通常不是我们想要的。

提示

如果客户地址更改时订单地址应自动更改,请考虑将地址映射为实体类型。 然后,OrderCustomer 可以通过导航属性安全地引用同一地址实例(它现在由键来标识)。

处理此类问题的一个好办法是使类型不可变。 事实上,当某个类型很适合成为复杂类型时,这种不可变性通常是很自然的。 例如,提供一个复杂且新的 Address 对象通常比仅仅改变国家/地区并让其余部分保持不变更说得通。

引用和值类型都可设为不可变。 我们将在下面的部分中看一些示例。

引用类型作为复杂类型

不可变类

我们在上面的示例中使用了一个简单、可变的 class。 为了防止上述意外突变问题,我们可以使类不可变。 例如:

public class Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string Country { get; }
    public string PostCode { get; }
}

提示

在 C# 12 或更高版本中,可以使用主构造函数简化此类定义:

public class Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

现在无法更改现有地址上的 Line1 值。 相反,我们需要创建一个具有已更改值的新实例。 例如:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

这次对 SaveChangesAsync 的调用仅更新了客户地址:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;

请注意,即使 Address 对象不可变且整个对象已更改,EF 仍会跟踪对各个属性的更改,因此只会更新具有更改值的列。

不可变记录

C# 9 推出了记录类型,它使得创建和使用不可变对象变得更加容易。 例如,可以将 Address 对象设为记录类型:

public record Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string Country { get; init; }
    public string PostCode { get; init; }
}

提示

可以使用主构造函数简化此记录定义:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

替换可变对象和调用 SaveChanges 现在所需的代码更少了:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

值类型作为复杂类型

可变结构

简单的可变值类型可用作复杂类型。 例如,Address 可以在 C# 中定义为 struct

public struct Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

将客户 Address 对象分配给发货和计费 Address 属性会导致每个属性得到 Address 的副本,因为这是值类型的工作方式。 这意味着修改客户的 Address 不会更改发货或计费 Address 实例,因此可变结构没有可变类会遇到的实例共享问题。

但是,通常不建议在 C# 中使用可变结构,因此请在使用它们之前仔细思考。

不可变结构

跟不可变类一样,不可变结构作为复杂类型时表现地很好。 例如,Address 可以经过定义使其无法修改:

public readonly struct Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

更改地址的代码现在看起来与使用不可变类时相同:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

不可变结构记录

C# 10 推出了 struct record 类型,通过它可以轻松创建和使用不可变结构记录,就像处理不可变类记录一样。 例如,我们可以将 Address 定义为不可变结构记录:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

更改地址的代码现在看起来与使用不可变类记录时相同:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

嵌套的复杂类型

一个复杂类型可以包含其他复杂类型的属性。 例如,让我们将上述的 Address 复杂类型与 PhoneNumber 复杂类型一起使用,并将它们嵌套在另一个复杂类型中:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

我们在此处使用不可变记录,因为这些记录非常适合复杂类型的语义,但复杂类型的嵌套可以使用任何 .NET 类型来做到。

注意

我们没有对 Contact 类型使用主构造函数,因为 EF Core 尚不支持复杂类型值的构造函数注入。 如果这对你很重要,请为问题 #31621 投票。

我们会将 Contact 添加为 Customer 的属性:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

并将 PhoneNumber 添加为 Order 的属性:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

可以使用 ComplexTypeAttribute 再次配置嵌套的复杂类型:

[ComplexType]
public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

[ComplexType]
public record PhoneNumber(int CountryCode, long Number);

[ComplexType]
public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

或在 OnModelCreating 中:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

查询

实体类型上复杂类型的属性被视为和实体类型的任何其他非导航属性同等。 这意味着,加载实体类型时,会始终加载它们。 这也适用于任何嵌套的复杂类型属性。 例如,某个客户的查询:

var customer = await context.Customers.FirstAsync(e => e.Id == customerId);

在使用 SQL Server 时被转换为以下 SQL:

SELECT TOP(1) [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country],
    [c].[Contact_Address_Line1], [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode],
    [c].[Contact_HomePhone_CountryCode], [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode],
    [c].[Contact_MobilePhone_Number], [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__customerId_0

请注意此 SQL 中的两件事:

  • 返回了所有内容以填充客户以及所有嵌套的 ContactAddressPhoneNumber 复杂类型。
  • 所有复杂类型值都存储为实体类型的表中的列。 复杂类型永远不会映射到单独的表。

投影

可以从查询投影复杂类型。 例如,仅从订单中选择发货地址:

var shippingAddress = await context.Orders
    .Where(e => e.Id == orderId)
    .Select(e => e.ShippingAddress)
    .SingleAsync();

使用 SQL Server 时,这会转换为以下内容:

SELECT TOP(2) [o].[ShippingAddress_City], [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1],
    [o].[ShippingAddress_Line2], [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[Id] = @__orderId_0

请注意,无法跟踪复杂类型的投影,因为复杂类型对象没有用于跟踪的标识。

在谓词中使用

复杂类型的成员可用于谓词。 例如,查找前往特定城市的所有订单:

var city = "Walpole St Peter";
var walpoleOrders = await context.Orders.Where(e => e.ShippingAddress.City == city).ToListAsync();

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

SELECT [o].[Id], [o].[Contents], [o].[CustomerId], [o].[BillingAddress_City], [o].[BillingAddress_Country],
    [o].[BillingAddress_Line1], [o].[BillingAddress_Line2], [o].[BillingAddress_PostCode],
    [o].[ContactPhone_CountryCode], [o].[ContactPhone_Number], [o].[ShippingAddress_City],
    [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1], [o].[ShippingAddress_Line2],
    [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[ShippingAddress_City] = @__city_0

还可以在谓词中使用完整的复杂类型实例。 例如,查找具有给定电话号码的所有客户:

var phoneNumber = new PhoneNumber(44, 7777555777);
var customersWithNumber = await context.Customers
    .Where(
        e => e.Contact.MobilePhone == phoneNumber
             || e.Contact.WorkPhone == phoneNumber
             || e.Contact.HomePhone == phoneNumber)
    .ToListAsync();

使用 SQL Server 时,这会转换为以下 SQL:

SELECT [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country], [c].[Contact_Address_Line1],
     [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode], [c].[Contact_HomePhone_CountryCode],
     [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode], [c].[Contact_MobilePhone_Number],
     [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE ([c].[Contact_MobilePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_MobilePhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_WorkPhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_WorkPhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_HomePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_HomePhone_Number] = @__entity_equality_phoneNumber_0_Number)

请注意,通过扩展复杂类型的每个成员,执行了相等性。 这与没有标识键的复杂类型保持一致,因此当且仅当成员全部相等时,一个复杂类型实例等于另一个复杂类型实例。 这也符合 .NET 为记录类型定义的相等性。

复杂类型值的操作

EF8 提供了跟踪信息(例如复杂类型的当前值和原始值),以及属性值是否已修改的信息。 API 复杂类型是已用于实体类型的更改跟踪 API 的扩展。

EntityEntryComplexProperty 方法返回整个复杂对象的条目。 例如,要获取 Order.BillingAddress 的当前值:

var billingAddress = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .CurrentValue;

可以添加对 Property 的调用以访问复杂类型的属性。 例如,要只获取计费邮政编码的当前值:

var postCode = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .Property(e => e.PostCode)
    .CurrentValue;

使用对 ComplexProperty 的嵌套调用访问嵌套的复杂类型。 例如,要在 Customer 上从 Contact 的嵌套的 Address 中获取城市:

var currentCity = context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.City)
    .CurrentValue;

还有其他方法可用于读取和更改状态。 例如,PropertyEntry.IsModified 可用于将复杂类型的属性设置为已修改:

context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.PostCode)
    .IsModified = true;

当前限制

复杂类型代表着跨 EF 堆栈的重大投资。 我们无法在此版本中做到尽善尽美,但我们计划在未来的版本中补上一些缺口。 如果修复其中的任何限制对你很重要,请务必对相应的 GitHub 问题进行投票 (👍)。

EF8 中的复杂类型限制包括:

基元集合

使用关系数据库时长期存在的问题是,如何处理基元类型的集合;即整数、日期/时间、字符串等的列表或数组。 如果使用的是 PostgreSQL,则可以使用 PostgreSQL 的内置数组类型轻松存储这些内容。 对于其他数据库,有两种常见方法:

  • 创建一个表,其中一列包含基元类型值,另一列用作外键,将每个值链接到其集合的所有者。
  • 将基元集合序列化为由数据库处理的某个列类型,例如,序列化为字符串以及从字符串序列化。

第一个选项在很多情况下都有优势,我们将在本部分末尾快速了解一下。 但是,它不是模型中数据的自然表示形式,如果你真正拥有的是基元类型的集合,则第二个选项可能更有效。

从预览版 4 开始,EF8 现在包含对第二个选项的内置支持,使用 JSON 作为序列化格式。 JSON 非常适合此情况,因为新式关系数据库包含用于查询和操作 JSON 的内置机制,以便在需要时可以有效地将 JSON 列视为表,而无需实际创建该表的开销。 这些相同的机制允许在参数中传递 JSON,然后以类似于查询中的表值参数的方式使用 -- 稍后将对此进行详细介绍。

提示

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

基元集合属性

EF Core 可以将任何 IEnumerable<T> 属性(其中 T 是基元类型)映射到数据库中的 JSON 列。 这是通过同时具有 Getter 和 Setter 的公共属性的约定完成的。 例如,按照约定,以下实体类型中的所有属性都映射到 JSON 列:

public class PrimitiveCollections
{
    public IEnumerable<int> Ints { get; set; }
    public ICollection<string> Strings { get; set; }
    public IList<DateOnly> Dates { get; set; }
    public uint[] UnsignedInts { get; set; }
    public List<bool> Booleans { get; set; }
    public List<Uri> Urls { get; set; }
}

注意

在此上下文中,“基元类型”是什么意思? 从本质上讲,数据库提供程序知道如何映射的内容,必要时使用某种值转换。 例如,在上面的实体类型中,类型 intstringDateTimeDateOnlybool 均由数据库提供程序处理,无需转换。 SQL Server 没有对无符号整数或 URI 的本机支持,但 uintUri 仍被视为基元类型,因为这些类型有内置的值转换器

默认情况下,EF Core 使用不受约束的 Unicode 字符串列类型来保存 JSON,因为这可以防止大型集合丢失数据。 但是,在某些数据库系统(如 SQL Server)上,为字符串指定最大长度可以提高性能。 这与其他列配置一起可以使用正常方式完成。 例如:

modelBuilder
    .Entity<PrimitiveCollections>()
    .Property(e => e.Booleans)
    .HasMaxLength(1024)
    .IsUnicode(false);

或者,使用映射属性:

[MaxLength(2500)]
[Unicode(false)]
public uint[] UnsignedInts { get; set; }

默认列配置可用于使用预约定模型配置的特定类型的所有属性。 例如:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<List<DateOnly>>()
        .AreUnicode(false)
        .HaveMaxLength(4000);
}

使用基元集合的查询

让我们来看一些使用基元类型集合的查询。 为此,我们需要一个具有两种实体类型的简单模型。 第一个表示英国公共房屋,或“酒吧”:

public class Pub
{
    public Pub(string name, string[] beers)
    {
        Name = name;
        Beers = beers;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Beers { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

Pub 类型包含两个基元集合:

  • Beers 是一个字符串数组,表示酒吧提供的啤酒品牌。
  • DaysVisited 是访问酒吧的日期列表。

提示

在实际应用程序中,为啤酒创建实体类型并创建一个啤酒表可能更有意义。 我们将在此处展示一个基元集合,以演示它们的工作原理。 但请记住,仅仅因为你可以将某些内容建模为基元集合并不意味着你一定应该这样做。

第二个实体类型表示英国农村的遛狗:

public class DogWalk
{
    public DogWalk(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public Terrain Terrain { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
    public Pub ClosestPub { get; set; } = null!;
}

public enum Terrain
{
    Forest,
    River,
    Hills,
    Village,
    Park,
    Beach,
}

Pub 一样,DogWalk 也包含访问日期的集合,以及访问的最近酒吧,因为,你知道,有时在遛狗后需要喝点啤酒。

使用此模型,我们将执行的第一个查询是一个简单的 Contains 查询,用于查找具有多种不同地形之一的所有步程:

var terrains = new[] { Terrain.River, Terrain.Beach, Terrain.Park };
var walksWithTerrain = await context.Walks
    .Where(e => terrains.Contains(e.Terrain))
    .Select(e => e.Name)
    .ToListAsync();

当前版本的 EF Core 已通过内联要查找的值进行转换。 例如使用 SQL Server 时:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE [w].[Terrain] IN (1, 5, 4)

但是,此策略不太适用于数据库查询缓存,请参阅 .NET 博客上的宣布推出 EF8 预览版 4,参与此问题的讨论。

重要

内联此处的值是采用这样一种方式完成的,即没有发生 SQL 注入攻击的可能性。 下面所述的使用 JSON 的更改与性能有关,与安全性无关。

对于 EF Core 8,现在默认将地形列表作为包含 JSON 集合的单个参数传递。 例如:

@__terrains_0='[1,5,4]'

然后,查询使用 SQL Server 上的 OpenJson

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__terrains_0) AS [t]
    WHERE CAST([t].[value] AS int) = [w].[Terrain])

或 SQLite 上的 json_each

SELECT "w"."Name"
FROM "Walks" AS "w"
WHERE EXISTS (
    SELECT 1
    FROM json_each(@__terrains_0) AS "t"
    WHERE "t"."value" = "w"."Terrain")

注意

OpenJson 仅适用于 SQL Server 2016(兼容性级别 130)及更高版本。 可以通过将兼容性级别配置为 UseSqlServer 的一部分来告知 SQL Server 你使用的是旧版本。 例如:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(
            @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow",
            sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseCompatibilityLevel(120));

让我们尝试不同类型的 Contains 查询。 在本例中,我们将在列中查找参数集合的值。 例如,存放喜力啤酒的任何酒吧:

var beer = "Heineken";
var pubsWithHeineken = await context.Pubs
    .Where(e => e.Beers.Contains(beer))
    .Select(e => e.Name)
    .ToListAsync();

EF7 新增功能中的现有文档提供了有关 JSON 映射、查询和更新的详细信息。 本文档现在也适用于 SQLite。

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[Beers]) AS [b]
    WHERE [b].[value] = @__beer_0)

现在,OpenJson 用于从 JSON 列中提取值,以便每个值都可以与传递的参数匹配。

我们可以将参数上的 OpenJson 与列上的 OpenJson 结合使用。 例如,若要查找存放各种拉格啤酒的酒吧,请执行以下操作:

var beers = new[] { "Carling", "Heineken", "Stella Artois", "Carlsberg" };
var pubsWithLager = await context.Pubs
    .Where(e => beers.Any(b => e.Beers.Contains(b)))
    .Select(e => e.Name)
    .ToListAsync();

它转换为 SQL Server 上的以下内容:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__beers_0) AS [b]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[Beers]) AS [b0]
        WHERE [b0].[value] = [b].[value] OR ([b0].[value] IS NULL AND [b].[value] IS NULL)))

此处的 @__beers_0 参数值为 ["Carling","Heineken","Stella Artois","Carlsberg"]

让我们来看一下使用包含日期集合的列的查询。 例如,若要查找今年访问的酒吧,请执行以下操作:

var thisYear = DateTime.Now.Year;
var pubsVisitedThisYear = await context.Pubs
    .Where(e => e.DaysVisited.Any(v => v.Year == thisYear))
    .Select(e => e.Name)
    .ToListAsync();

它转换为 SQL Server 上的以下内容:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[DaysVisited]) AS [d]
    WHERE DATEPART(year, CAST([d].[value] AS date)) = @__thisYear_0)

请注意,查询在此处使用特定于日期的函数 DATEPART,因为 EF 知道基元集合包含日期。 它看起来可能不是这样,但这实际上真的很重要。 由于 EF 知道集合中的内容,因此它可以生成适当的 SQL,以便将类型化值与参数、函数、其他列等一起使用。

让我们再次使用日期集合,这次是为从集合中提取的类型和项目值按适当的顺序排序。 例如,让我们按首次访问它们的顺序列出酒吧,以及访问每个酒吧的第一个日期和最后一个日期:

var pubsVisitedInOrder = await context.Pubs
    .Select(e => new
    {
        e.Name,
        FirstVisited = e.DaysVisited.OrderBy(v => v).First(),
        LastVisited = e.DaysVisited.OrderByDescending(v => v).First(),
    })
    .OrderBy(p => p.FirstVisited)
    .ToListAsync();

它转换为 SQL Server 上的以下内容:

SELECT [p].[Name], (
    SELECT TOP(1) CAST([d0].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d0]
    ORDER BY CAST([d0].[value] AS date)) AS [FirstVisited], (
    SELECT TOP(1) CAST([d1].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d1]
    ORDER BY CAST([d1].[value] AS date) DESC) AS [LastVisited]
FROM [Pubs] AS [p]
ORDER BY (
    SELECT TOP(1) CAST([d].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d]
    ORDER BY CAST([d].[value] AS date))

最后,我们有多少次在遛狗时最终去了最近的酒吧? 接下来了解一下:

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

它转换为 SQL Server 上的以下内容:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[DaysVisited]) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

显示以下数据:

The Prince of Wales Feathers was visited 5 times in 8 "Ailsworth to Nene" walks.
The Prince of Wales Feathers was visited 6 times in 9 "Caster Hanglands" walks.
The Royal Oak was visited 6 times in 8 "Ferry Meadows" walks.
The White Swan was visited 7 times in 9 "Woodnewton" walks.
The Eltisley was visited 6 times in 8 "Eltisley" walks.
Farr Bay Inn was visited 7 times in 11 "Farr Beach" walks.
Farr Bay Inn was visited 7 times in 9 "Newlands" walks.

看起来啤酒和遛狗是一个成功的组合!

JSON 文档中的基元集合

在上述所有示例中,基元集合的列包含 JSON。 但是,这与将从属实体类型映射到包含 JSON 文档的列不同,这是在 EF7 中引入的。 但是,如果该 JSON 文档本身包含基元集合,该怎么办? 那么,上述所有查询仍以相同的方式工作! 例如,假设我们将访问天数的数据移动到映射到 JSON 文档的从属类型 Visits

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public BeerData Beers { get; set; } = null!;
    public Visits Visits { get; set; } = null!;
}

public class Visits
{
    public string? LocationTag { get; set; }
    public List<DateOnly> DaysVisited { get; set; } = null!;
}

提示

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

现在,我们可以运行最终查询的变体,这次从 JSON 文档中提取数据,包括对文档中包含的基元集合的查询:

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

它转换为 SQL Server 上的以下内容:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], JSON_VALUE([w].[Visits], '$.LocationTag') AS [WalkLocationTag], JSON_VALUE([p].[Visits], '$.LocationTag') AS [PubLocationTag], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson(JSON_VALUE([p].[Visits], '$.DaysVisited')) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

使用 SQLite 时的类似查询:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", "w"."Visits" ->> 'LocationTag' AS "WalkLocationTag", "p"."Visits" ->> 'LocationTag' AS "PubLocationTag", (
    SELECT COUNT(*)
    FROM json_each("w"."Visits" ->> 'DaysVisited') AS "d"
    WHERE EXISTS (
        SELECT 1
        FROM json_each("p"."Visits" ->> 'DaysVisited') AS "d0"
        WHERE "d0"."value" = "d"."value")) AS "Count", json_array_length("w"."Visits" ->> 'DaysVisited') AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

提示

请注意,在 SQLite 上,EF Core 现在使用 ->> 运算符,从而生成更易于阅读且通常实现更高性能的查询。

将基元集合映射到表

我们在上面提到,基元集合的另一个选项是将它们映射到不同的表。 问题 #25163 跟踪对此的一级支持;如果此问题对你很重要,请确保为此问题投票。 在实现此操作之前,最佳方法是为基元创建包装类型。 例如,让我们为 Beer 创建一个类型:

[Owned]
public class Beer
{
    public Beer(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

请注意,类型只是包装基元值,它没有定义主键或任何外键。 然后,可以在 Pub 类中使用此类型:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public List<Beer> Beers { get; set; } = new();
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

EF 现在将创建一个 Beer 表,将主键列和外键列合成回 Pubs 表。 例如,在 SQL Server 上:

CREATE TABLE [Beer] (
    [PubId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Beer] PRIMARY KEY ([PubId], [Id]),
    CONSTRAINT [FK_Beer_Pubs_PubId] FOREIGN KEY ([PubId]) REFERENCES [Pubs] ([Id]) ON DELETE CASCADE

对 JSON 列映射的增强

EF8 包括对 EF7 中引入的 JSON 列映射支持的改进。

提示

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

将元素访问转换为 JSON 数组

EF8 支持在执行查询时在 JSON 数组中编制索引。 例如,以下查询检查是否在给定日期之前进行了前两次更新。

var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
    .Where(
        p => p.Metadata!.Updates[0].UpdatedOn < cutoff
             && p.Metadata!.Updates[1].UpdatedOn < cutoff)
    .ToListAsync();

使用 SQL Server 时,这会转换为以下 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 CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) < @__cutoff_0
  AND CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) < @__cutoff_0

注意

即使给定的帖子没有任何更新,或者只有一个更新,该查询也会成功。 在这种情况下,JSON_VALUE 返回 NULL 且谓词不匹配。

对 JSON 数组的索引也可用于将数组中的元素投影到最终结果中。 例如,以下查询显示每个帖子的第一次和第二次更新的 UpdatedOn 日期。

var postsAndRecentUpdatesNullable = await context.Posts
    .Select(p => new
    {
        p.Title,
        LatestUpdate = (DateOnly?)p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = (DateOnly?)p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

使用 SQL Server 时,这会转换为以下 SQL:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]

如上所述,如果数组的元素不存在,JSON_VALUE 将返回 null。 通过在查询中将投影值转换为可为空的 DateOnly 来处理此问题。 强制转换值的替代方法是筛选查询结果,以便 JSON_VALUE 永远不会返回 null。 例如:

var postsAndRecentUpdates = await context.Posts
    .Where(p => p.Metadata!.Updates[0].UpdatedOn != null
                && p.Metadata!.Updates[1].UpdatedOn != null)
    .Select(p => new
    {
        p.Title,
        LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

使用 SQL Server 时,这会转换为以下 SQL:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]
      WHERE (CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) IS NOT NULL)
        AND (CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) IS NOT NULL)

将查询转换为嵌入集合

EF8 支持针对 JSON 文档中嵌入的基元(如上所述)和非基元类型的集合进行查询。 例如,以下查询返回包含任意搜索词列表的所有帖子:

var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };

var postsWithSearchTerms = await context.Posts
    .Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
    .ToListAsync();

使用 SQL Server 时,这会转换为以下 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 EXISTS (
    SELECT 1
    FROM OPENJSON([p].[Metadata], '$.TopSearches') WITH (
        [Count] int '$.Count',
        [Term] nvarchar(max) '$.Term'
    ) AS [t]
    WHERE EXISTS (
        SELECT 1
        FROM OPENJSON(@__searchTerms_0) WITH ([value] nvarchar(max) '$') AS [s]
        WHERE [s].[value] = [t].[Term]))

SQLite 的 JSON 列

EF7 引入了在使用 Azure SQL/SQL Server 时映射到 JSON 列的支持。 EF8 将此支持扩展到 SQLite 数据库。 至于 SQL Server 支持,这包括:

  • 将从 .NET 类型生成的聚合映射到存储在 SQLite 列中的 JSON 文档
  • 对 JSON 列的查询,例如按文档元素进行筛选和排序
  • 将 JSON 文档之外的元素投影到结果中的查询
  • 更新和保存对 JSON 文档的更改

EF7 新增功能中的现有文档提供了有关 JSON 映射、查询和更新的详细信息。 本文档现在也适用于 SQLite。

提示

EF7 文档中显示的代码已更新为也在 SQLite 上运行,这可以在 JsonColumnsSample.cs 中找到。

对 JSON 列的查询

对 SQLite 上的 JSON 列的查询使用 json_extract 函数。 例如,对上面引用的文档中的“Chigley 中的作者”查询:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

使用 SQLite 时转换为以下 SQL:

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE json_extract("a"."Contact", '$.Address.City') = 'Chigley'

更新 JSON 列

对于更新,EF 使用 SQLite 上的 json_set 函数。 例如,更新文档中的单个属性时:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

EF 生成以下参数:

info: 3/10/2023 10:51:33.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

哪个使用 SQLite 上的 json_set 函数:

UPDATE "Authors" SET "Contact" = json_set("Contact", '$.Address.Country', json_extract(@p0, '$[0]'))
WHERE "Id" = @p1
RETURNING 1;

.NET 和 EF Core 中的 HierarchyId

Azure SQL 和 SQL Server 具有一个名为 hierarchyid 的特殊数据类型,用于存储分层数据。 在这种情况下,“分层数据”实质上是指形成树结构的数据,其中每个项都可以有父级和/或子级。 此类数据的示例包括:

  • 组织结构
  • 文件系统
  • 项目中的一组任务
  • 语言术语分类
  • 网页间链接图

然后,数据库可以使用其分层结构对此数据运行查询。 例如,查询可以查找给定项的上级和依赖项,或查找层次结构中某个深度的所有项。

.NET 和 EF Core 中的支持

SQL Server hierarchyid 类型的官方支持最近转为新式 .NET 平台(即“.NET Core”)。 此支持采用 Microsoft.SqlServer.Types NuGet 包的形式,它引入低级别 SQL Server 特定类型。 在这种情况下,低级别类型称为 SqlHierarchyId

在下一个级别,引入了新的 Microsoft.EntityFrameworkCore.SqlServer.Abstractions 包,其中包括用于实体类型的高级 HierarchyId 类型。

提示

HierarchyId 类型更习惯于 .NET 标准,而不是 SqlHierarchyId 标准,后者是在 SQL Server 数据库引擎中托管 .NET Framework 类型之后建模的。 HierarchyId 设计为与 EF Core 结合使用,但它也可以在其他应用程序中的 EF Core 外部使用。 Microsoft.EntityFrameworkCore.SqlServer.Abstractions 包不引用任何其他包,因此对部署的应用程序大小和依赖项的影响最小。

对查询和更新等 EF Core 功能使用 HierarchyId 需要 Microsoft.EntityFrameworkCore.SqlServer.HierarchyId 包。 此包将 Microsoft.EntityFrameworkCore.SqlServer.AbstractionsMicrosoft.SqlServer.Types 作为可传递依赖项引入,因此通常是唯一需要的包。 安装包后,通过调用 UseHierarchyId 作为应用程序对 UseSqlServer 的调用的一部分来使用 HierarchyId。 例如:

options.UseSqlServer(
    connectionString,
    x => x.UseHierarchyId());

注意

多年来已通过 EntityFrameworkCore.SqlServer.HierarchyId 包提供对 EF Core 中 hierarchyid 的非官方支持。 此包作为社区与 EF 团队之间的协作受到维护。 现在有了对 .NET 中 hierarchyid 的官方支持,此社区包中的代码在原始参与者的许可下形成此处所述的官方包的基础。 非常感谢多年来的所有相关人员,包括 @aljones@cutig3r@huan086@kmataru@mehdihaghshenas@vyrotek

层次结构建模

HierarchyId 类型可用于实体类型的属性。 例如,假设我们要为一些虚构半成年人的父系家谱建模。 在 Halfling 的实体类型中,HierarchyId 属性可用于查找家谱中的每个半成年人。

public class Halfling
{
    public Halfling(HierarchyId pathFromPatriarch, string name, int? yearOfBirth = null)
    {
        PathFromPatriarch = pathFromPatriarch;
        Name = name;
        YearOfBirth = yearOfBirth;
    }

    public int Id { get; private set; }
    public HierarchyId PathFromPatriarch { get; set; }
    public string Name { get; set; }
    public int? YearOfBirth { get; set; }
}

提示

此处和以下示例中显示的代码来自 HierarchyIdSample.cs

提示

如果需要,HierarchyId 适合用作密钥属性类型。

在这种情况下,家谱以家庭的家长为根。 可以使用 PathFromPatriarch 属性根据树下的家长跟踪每个半成年人。 SQL Server 对这些路径使用压缩的二进制格式,但在使用代码时,通常要分析到用户可读的字符串表示形式或从中分析。 在此表示形式中,每个级别的位置由 / 字符分隔。 例如,请考虑下图中的家谱:

半成年人家谱

在此树中:

  • Balbo 位于树的根处,由 / 表示。
  • Balbo 有五个孩子,由 /1//2//3//4//5/ 表示。
  • Balbo 的第一个孩子 Mungo 也有五个孩子,由 /1/1//1/2//1/3//1/4//1/5/ 表示。 请注意,Balbo (/1/) 的 HierarchyId 是其所有孩子的前缀。
  • 同样,Balbo 的第三个孩子 Ponto 有两个孩子,由 /3/1//3/2/ 表示。 同样,其中每个孩子都以 Ponto 的 HierarchyId 为前缀,表示为 /3/
  • 树下方还有其他...

以下代码使用 EF Core 将此家谱插入数据库:

await AddRangeAsync(
    new Halfling(HierarchyId.Parse("/"), "Balbo", 1167),
    new Halfling(HierarchyId.Parse("/1/"), "Mungo", 1207),
    new Halfling(HierarchyId.Parse("/2/"), "Pansy", 1212),
    new Halfling(HierarchyId.Parse("/3/"), "Ponto", 1216),
    new Halfling(HierarchyId.Parse("/4/"), "Largo", 1220),
    new Halfling(HierarchyId.Parse("/5/"), "Lily", 1222),
    new Halfling(HierarchyId.Parse("/1/1/"), "Bungo", 1246),
    new Halfling(HierarchyId.Parse("/1/2/"), "Belba", 1256),
    new Halfling(HierarchyId.Parse("/1/3/"), "Longo", 1260),
    new Halfling(HierarchyId.Parse("/1/4/"), "Linda", 1262),
    new Halfling(HierarchyId.Parse("/1/5/"), "Bingo", 1264),
    new Halfling(HierarchyId.Parse("/3/1/"), "Rosa", 1256),
    new Halfling(HierarchyId.Parse("/3/2/"), "Polo"),
    new Halfling(HierarchyId.Parse("/4/1/"), "Fosco", 1264),
    new Halfling(HierarchyId.Parse("/1/1/1/"), "Bilbo", 1290),
    new Halfling(HierarchyId.Parse("/1/3/1/"), "Otho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/"), "Falco", 1303),
    new Halfling(HierarchyId.Parse("/3/2/1/"), "Posco", 1302),
    new Halfling(HierarchyId.Parse("/3/2/2/"), "Prisca", 1306),
    new Halfling(HierarchyId.Parse("/4/1/1/"), "Dora", 1302),
    new Halfling(HierarchyId.Parse("/4/1/2/"), "Drogo", 1308),
    new Halfling(HierarchyId.Parse("/4/1/3/"), "Dudo", 1311),
    new Halfling(HierarchyId.Parse("/1/3/1/1/"), "Lotho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/1/"), "Poppy", 1344),
    new Halfling(HierarchyId.Parse("/3/2/1/1/"), "Ponto", 1346),
    new Halfling(HierarchyId.Parse("/3/2/1/2/"), "Porto", 1348),
    new Halfling(HierarchyId.Parse("/3/2/1/3/"), "Peony", 1350),
    new Halfling(HierarchyId.Parse("/4/1/2/1/"), "Frodo", 1368),
    new Halfling(HierarchyId.Parse("/4/1/3/1/"), "Daisy", 1350),
    new Halfling(HierarchyId.Parse("/3/2/1/1/1/"), "Angelica", 1381));

await SaveChangesAsync();

提示

如果需要,十进制值可用于在两个现有节点之间创建新节点。 示例:/3/2.5/2//3/2/2//3/3/2/ 之间。

查询层次结构

HierarchyId 公开可用于 LINQ 查询的多种方法。

方法 说明
GetAncestor(int n) 在层次结构树中向上获取节点 n 个级别。
GetDescendant(HierarchyId? child1, HierarchyId? child2) 获取大于 child1 且小于 child2 的后代节点的值。
GetLevel() 获取层次结构树中此节点的级别。
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) 获取一个值,该值表示新节点的位置,该节点具有从 newRoot 开始的路径,该路径等于从 oldRoot 到该节点的路径,并且有效地将该节点移到这个新位置。
IsDescendantOf(HierarchyId? parent) 获取一个值,该值指示此节点是否是 parent 的后代。

此外,还可以使用运算符 ==!=<<=>>=

下面是在 LINQ 查询中使用这些方法的示例。

获取树中给定级别的实体

以下查询使用 GetLevel 返回家谱中给定级别的所有半成年人:

var generation = await context.Halflings.Where(halfling => halfling.PathFromPatriarch.GetLevel() == level).ToListAsync();

它转换为以下 SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetLevel() = @__level_0

在循环中运行此功能,可以获取每一代的半成年人:

Generation 0: Balbo
Generation 1: Mungo, Pansy, Ponto, Largo, Lily
Generation 2: Bungo, Belba, Longo, Linda, Bingo, Rosa, Polo, Fosco
Generation 3: Bilbo, Otho, Falco, Posco, Prisca, Dora, Drogo, Dudo
Generation 4: Lotho, Poppy, Ponto, Porto, Peony, Frodo, Daisy
Generation 5: Angelica

获取实体的直接祖先

以下查询使用 GetAncestor 查找半成年人的直接祖先,给定该半成年人的名称:

async Task<Halfling?> FindDirectAncestor(string name)
    => await context.Halflings
        .SingleOrDefaultAsync(
            ancestor => ancestor.PathFromPatriarch == context.Halflings
                .Single(descendent => descendent.Name == name).PathFromPatriarch
                .GetAncestor(1));

它转换为以下 SQL:

SELECT TOP(2) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch] = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0).GetAncestor(1)

对半成年人“Bilbo”运行此查询将返回“Bungo”。

获取实体的直接子代

以下查询也使用 GetAncestor,但此次用来查找半成年人的直接子代,给定该半成年人的名称:

IQueryable<Halfling> FindDirectDescendents(string name)
    => context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.GetAncestor(1) == context.Halflings
            .Single(ancestor => ancestor.Name == name).PathFromPatriarch);

它转换为以下 SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetAncestor(1) = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0)

对半成年人“Mungo”运行此查询将返回“Bungo”、“Belba”、“Longo”和“Linda”。

获取实体的所有祖先

GetAncestor 可用于向上或向下一级进行搜索,或者实际上向上向下指定级别数进行搜索。 另一方面,IsDescendantOf 可用于查找所有祖先或依赖项。 例如,以下查询使用 IsDescendantOf 查找半成年人的所有祖先,给定半成年人的名称:

IQueryable<Halfling> FindAllAncestors(string name)
    => context.Halflings.Where(
            ancestor => context.Halflings
                .Single(
                    descendent =>
                        descendent.Name == name
                        && ancestor.Id != descendent.Id)
                .PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel());

重要

IsDescendantOf 为自身返回 true,这就是在上面的查询中筛选掉它的原因。

它转换为以下 SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id]).IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

对半成年人“Bilbo”运行此查询将返回“Bungo”、“Mungo”和“Balbo”。

获取实体的所有子代

以下查询也使用 IsDescendantOf,但此次用来查找半成年人的所有子代,给定该半成年人的名称:

IQueryable<Halfling> FindAllDescendents(string name)
    => context.Halflings.Where(
            descendent => descendent.PathFromPatriarch.IsDescendantOf(
                context.Halflings
                    .Single(
                        ancestor =>
                            ancestor.Name == name
                            && descendent.Id != ancestor.Id)
                    .PathFromPatriarch))
        .OrderBy(descendent => descendent.PathFromPatriarch.GetLevel());

它转换为以下 SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].IsDescendantOf((
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id])) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel()

对半成年人“Mungo”运行此查询将返回“Bungo”、“Belba”、“Longo”、“Linda”、“Bingo”、“Bilbo”、“Otho”、“Falco”、“Lotho”和“Poppy”。

查找共同祖先

关于此特殊家谱的最常见问题之一是,“谁是 Frodo 和 Bilbo 的共同祖先?”可以使用 IsDescendantOf 编写此类查询:

async Task<Halfling?> FindCommonAncestor(Halfling first, Halfling second)
    => await context.Halflings
        .Where(
            ancestor => first.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch)
                        && second.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel())
        .FirstOrDefaultAsync();

它转换为以下 SQL:

SELECT TOP(1) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE @__first_PathFromPatriarch_0.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
  AND @__second_PathFromPatriarch_1.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

使用“Bilbo”和“Frodo”运行此查询会告诉我们,他们的共同祖先是“Balbo”。

更新层次结构

常规更改跟踪SaveChanges 机制可用于更新 hierarchyid 列。

重新设置子层次结构的父级

例如,当 DNA 测试显示,Longo 实际上不是 Mungo 的儿子,而是 Ponto 的儿子时,我确定我们都记得 SR 1752(也称为“LongoGate”)的丑闻! 这场丑闻的一个后果是,家谱需要重新编写。 特别是,Longo 及其所有子代的父级需要从 Mungo 重置为 Ponto。 GetReparentedValue 可用于执行此操作。 例如,查询第一个“Longo”及其所有子代:

var longoAndDescendents = await context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.IsDescendantOf(
            context.Halflings.Single(ancestor => ancestor.Name == "Longo").PathFromPatriarch))
    .ToListAsync();

然后,GetReparentedValue 用于更新 Longo 和每个子代的 HierarchyId,接着调用 SaveChangesAsync

foreach (var descendent in longoAndDescendents)
{
    descendent.PathFromPatriarch
        = descendent.PathFromPatriarch.GetReparentedValue(
            mungo.PathFromPatriarch, ponto.PathFromPatriarch)!;
}

await context.SaveChangesAsync();

这将生成以下数据库更新:

SET NOCOUNT ON;
UPDATE [Halflings] SET [PathFromPatriarch] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Halflings] SET [PathFromPatriarch] = @p2
OUTPUT 1
WHERE [Id] = @p3;
UPDATE [Halflings] SET [PathFromPatriarch] = @p4
OUTPUT 1
WHERE [Id] = @p5;

使用以下参数:

 @p1='9',
 @p0='0x7BC0' (Nullable = false) (Size = 2) (DbType = Object),
 @p3='16',
 @p2='0x7BD6' (Nullable = false) (Size = 2) (DbType = Object),
 @p5='23',
 @p4='0x7BD6B0' (Nullable = false) (Size = 3) (DbType = Object)

注意

HierarchyId 属性的参数值以压缩的二进制格式发送到数据库。

更新后,查询“Mungo”的子代将返回“Bungo”、“Belba”、“Linda”、“Bingo”、“Bilbo”、“Falco”和“Poppy”,而查询“Ponto”的子代将返回“Longo”、“Rosa”、“Polo”、“Otho”、“Posco”、“Prisca”、“Lotho”、“Ponto”、“Porto”、“Peony”和“Angelica”。

未映射类型的原始 SQL 查询

EF7 引入了返回标量类型的原始 SQL 查询。 这在 EF8 中得到了增强,包括返回任何可映射 CLR 类型的原始 SQL 查询,而无需在 EF 模型中包括该类型。

提示

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

使用非映射类型的查询是使用 SqlQuerySqlQueryRaw 执行的。 前者使用字符串内插来参数化查询,这有助于确保所有非常量值都被参数化。 例如,考虑以下数据表:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [PublishedOn] date NOT NULL,
    [BlogId] int NOT NULL,
);

SqlQuery 可用于查询此表并返回 BlogPost 类型的实例,该实例具有对应于表中列的属性:

例如: 。

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

例如:

var start = new DateOnly(2022, 1, 1);
var end = new DateOnly(2023, 1, 1);
var postsIn2022 =
    await context.Database
        .SqlQuery<BlogPost>($"SELECT * FROM Posts as p WHERE p.PublishedOn >= {start} AND p.PublishedOn < {end}")
        .ToListAsync();

此查询被参数化并被执行为:

SELECT * FROM Posts as p WHERE p.PublishedOn >= @p0 AND p.PublishedOn < @p1

用于查询结果的类型可包含 EF Core 支持的常见映射构造,例如参数化构造函数和映射属性。 例如:

public class BlogPost
{
    public BlogPost(string blogTitle, string content, DateOnly publishedOn)
    {
        BlogTitle = blogTitle;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }

    [Column("Title")]
    public string BlogTitle { get; set; }

    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

注意

以这种方式使用的类型没有定义键,也不能与其他类型有关系。 必须将具有关系的类型映射到模型中。

使用的类型必须对结果集中的每个值都有一个属性,但不需要匹配数据库中的任何表。 例如,以下类型仅表示每个帖子的一部分信息,并包括来自 Blogs 表的博客名称:

public class PostSummary
{
    public string BlogName { get; set; } = null!;
    public string PostTitle { get; set; } = null!;
    public DateOnly? PublishedOn { get; set; }
}

并且可以按照与以前相同的方式使用 SqlQuery 进行查询:


var cutoffDate = new DateOnly(2022, 1, 1);
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.PublishedOn >= {cutoffDate}")
        .ToListAsync();

SqlQuery 的一个不错的功能是,它返回可使用 LINQ 进行组合的 IQueryable。 例如,可以将“Where”子句添加到上面的查询中:

var summariesIn2022 =
    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();

此操作执行方式如下:

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

在这一点上,需要牢记的是,以上所有操作都可以完全在 LINQ 中完成,而无需编写任何 SQL。 其中包括返回非映射类型的实例,例如 PostSummary。 例如,上述查询可用 LINQ 编写为:

var summaries =
    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

提示

当 EF 负责整个查询时,它能够生成比通过用户提供的 SQL 进行组合时更清晰的 SQL,因为在前一种情况下,查询的完整语义对 EF 可用。

到目前为止,所有查询都是直接针对表执行的。 SqlQuery 也可用于在不映射 EF 模型中的视图类型的情况下从视图返回结果。 例如:

var summariesFromView =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM PostAndBlogSummariesView")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

同样,SqlQuery 可用于函数的结果:

var summariesFromFunc =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM GetPostsPublishedAfter({cutoffDate})")
        .Where(p => p.PublishedOn < end)
        .ToListAsync();

如果返回的 IQueryable 为视图或函数的结果,则可以对其进行组合,就像它可以是表查询的结果一样。 也可使用 SqlQuery 执行存储过程,但大多数数据库不支持对其进行组合。 例如:

var summariesFromStoredProc =
    await context.Database.SqlQuery<PostSummary>(
            @$"exec GetRecentPostSummariesProc")
        .ToListAsync();

延迟加载的增强

针对非跟踪查询的延迟加载

EF8 添加了对未被 DbContext 跟踪的实体的延迟加载导航的支持。 这意味着,可以在非跟踪查询之后在非跟踪查询返回的实体上延迟加载导航。

提示

下面显示的延迟加载示例的代码来自 LazyLoadingSample.cs

例如,考虑针对博客的非跟踪查询:

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

如果 Blog.Posts 配置为延迟加载(例如,使用延迟加载代理),则访问 Posts 将使其从数据库加载:

Console.WriteLine();
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 被释放后,延迟加载才会成功。
  • 以这种方式查询的实体会保留对其 DbContext 的引用,即使这些实体未被其跟踪。 如果实体实例的生存期很长,则应注意避免内存泄漏。
  • 通过将实体的状态设置为 EntityState.Detached 来显式分离实体,这会切断对 DbContext 的引用,并且延迟加载将不再有效。
  • 请记住,所有延迟加载都使用同步 I/O,因为无法以异步方式访问属性。

来自未跟踪实体的延迟加载适用于延迟加载代理无代理延迟加载

从未跟踪的实体显式加载

EF8 支持在未跟踪的实体上加载导航,即使实体或导航未配置为延迟加载也是如此。 与延迟加载不同,此显式加载可通过异步方式完成。 例如:

await context.Entry(blog).Collection(e => e.Posts).LoadAsync();

选择退出针对特定导航的延迟加载

EF8 允许将特定导航配置为非延迟加载,即使其他所有内容都设置为非延迟加载也是如此。 例如,要将 Post.Author 导航配置为非延迟加载,请执行以下操作:

modelBuilder
    .Entity<Post>()
    .Navigation(p => p.Author)
    .EnableLazyLoading(false);

像这样禁用延迟加载适用于延迟加载代理无代理延迟加载

延迟加载代理通过替代虚拟导航属性来工作。 在经典的 EF6 应用程序中,一个常见的 bug 来源是忘记将导航虚拟化,因为导航将以无提示方式不延迟加载。 因此,如果导航不是虚拟的,则默认引发 EF Core 代理。

这可以在 EF8 中更改为选择加入经典的 EF6 行为,这样只需使导航成为非虚拟的,就可以使导航不延迟加载。 此选择加入配置为调用 UseLazyLoadingProxies 的一部分。 例如:

optionsBuilder.UseLazyLoadingProxies(b => b.IgnoreNonVirtualNavigations());

访问跟踪的实体

按主键、备用键或外键查找被跟踪的实体

在内部,EF 维护用于按主键、备用键或外键查找被跟踪的实体的数据结构。 这些数据结构用于在跟踪新实体或关系更改时进行相关实体之间的有效修复。

EF8 包含新的公共 API,因此应用程序现可使用这些数据结构来高效地查找被跟踪的实体。 这些 API 通过实体类型的 LocalView<TEntity> 进行访问。 例如,通过主键查找被跟踪的实体:

var blogEntry = context.Blogs.Local.FindEntry(2)!;

提示

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

FindEntry 方法返回被跟踪的实体的 EntityEntry<TEntity>,如果未跟踪具有给定键的实体,则返回 null。 与 LocalView 上的所有方法一样,永远不会查询数据库,即使未找到实体也是如此。 返回的条目包含实体本身和跟踪信息。 例如:

Console.WriteLine($"Blog '{blogEntry.Entity.Name}' with key {blogEntry.Entity.Id} is tracked in the '{blogEntry.State}' state.");

通过主键以外的任何方式查找实体都需要指定属性名称。 例如,按备用键查找:

var siteEntry = context.Websites.Local.FindEntry(nameof(Website.Uri), new Uri("https://www.bricelam.net/"))!;

或者按唯一的外键查找:

var blogAtSiteEntry = context.Blogs.Local.FindEntry(nameof(Blog.SiteUri), new Uri("https://www.bricelam.net/"))!;

到目前为止,查找始终返回单个条目或 null。 但是,某些查找可以返回多个条目,例如按非唯一外键进行查找时。 GetEntries 方法应用于这些查找。 例如:

var postEntries = context.Posts.Local.GetEntries(nameof(Post.BlogId), 2);

在所有这些情况下,用于查找的值是主键、备用键或外键值。 EF 使用其内部数据结构来进行这些查找。 但是,按值查找也可用于任何属性或属性组合的值。 例如,查找所有已存档的帖子:

var archivedPostEntries = context.Posts.Local.GetEntries(nameof(Post.Archived), true);

此查找需要扫描所有被跟踪的 Post 实例,因此其效率将低于键查找。 但它通常仍比使用 ChangeTracker.Entries<TEntity>() 的简单查询更快。

最后,还可以针对组合键、多个属性的其他组合或在编译时不知道属性类型时执行查找。 例如:

var postTagEntry = context.Set<PostTag>().Local.FindEntryUntyped(new object[] { 4, "TagEF" });

模型构建

鉴别器列具有最大长度

在 EF8 中,用于 TPH 继承映射的字符串鉴别器列现在配置为最大长度。 此长度计算结果为涵盖所有定义的鉴别器值的最小斐波那契数。 例如,考虑以下层次结构:

public abstract class Document
{
    public int Id { get; set; }
    public string Title { get; set; }
}

public abstract class Book : Document
{
    public string? Isbn { get; set; }
}

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

public class Magazine : Document
{
    public int IssueNumber { get; set; }
}

按照使用类名作为鉴别器值的惯例,此处可能的值为“PaperbackEdition”、“HardbackEdition”和“Magazine”,因此鉴别器列的最大长度配置为 21。 例如使用 SQL Server 时:

CREATE TABLE [Documents] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Discriminator] nvarchar(21) NOT NULL,
    [Isbn] nvarchar(max) NULL,
    [IssueNumber] int NULL,
    CONSTRAINT [PK_Documents] PRIMARY KEY ([Id]),

提示

斐波那契数用于限制在向层次结构中添加新类型时生成迁移,以更改列长度的次数。

SQL Server 上支持的 DateOnly/TimeOnly

DateOnlyTimeOnly 类型是在 .NET 6 中引入的,自引入以来,一直支持多个数据库提供程序(例如 SQLite、MySQL 和 PostgreSQL)。 对于 SQL Server,面向 .NET 6 的 Microsoft.Data.SqlClient 包的最新版本已允许 ErikEJ 在 ADO.NET 级别增加对这些类型的支持。 这反过来又为 EF8 中支持将 DateOnlyTimeOnly 作为实体类型的属性做好了准备。

提示

在 EF Core 6 和 7 中,可以通过 @ErikEJ 中的 ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly 社区包使用 DateOnlyTimeOnly

例如,请考虑英国学校的以下 EF 模型:

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

提示

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

注意

此模型仅表示英国学校,并将时间存储为当地 (GMT) 时间。 处理不同的时区会使此代码变得非常复杂。 请注意,在这里使用 DateTimeOffset 没有帮助,因为开放和关闭时间具有不同的偏移量,根据是否处于夏令时而定。

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

使用 DateOnlyTimeOnly 的查询按预期方式工作。 例如,以下 LINQ 查询查找当前开放的学校:

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

此查询将转换为以下 SQL,如 ToQueryString 所示:

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]

DateOnlyTimeOnly 也可以在 JSON 列中使用。 例如,可以将 OpeningHours 另存为 JSON 文档,从而生成如下所示的数据:

ID 2
名称 Farr High School
成立时间 1964-05-01
开放时间
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": null }
]

结合 EF8 中的两个功能,我们现在可以通过索引到 JSON 集合来查询开放时间。 例如:

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

此查询将转换为以下 SQL,如 ToQueryString 所示:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '20:14:34.7795877';

SELECT [s].[Id], [s].[Founded], [s].[Name], [s].[OpeningHours]
FROM [Schools] AS [s]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0
      AND [t].[LastDay] >= @__today_0)
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].OpensAt') AS time) < @__time_2
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].ClosesAt') AS time) >= @__time_2

最后,可以通过跟踪和 SaveChanges 或使用 ExecuteUpdate/ExecuteDelete 完成更新和删除。 例如:

await context.Schools
    .Where(e => e.Terms.Any(t => t.LastDay.Year == 2022))
    .SelectMany(e => e.Terms)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastDay, t => t.LastDay.AddDays(1)));

此更新转换为以下 SQL:

UPDATE [t0]
SET [t0].[LastDay] = DATEADD(day, CAST(1 AS int), [t0].[LastDay])
FROM [Schools] AS [s]
INNER JOIN [Term] AS [t0] ON [s].[Id] = [t0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND DATEPART(year, [t].[LastDay]) = 2022)

反向工程 Synapse 和 Dynamics 365 TDS

EF8 反向工程(也称为现有数据库中的基架)现在支持 Synapse 无服务器 SQL 池Dynamics 365 TDS 终结点数据库。

警告

这些数据库系统与普通 SQL Server 和 Azure SQL 数据库有所不同。 这些差异意味着,在针对这些数据库系统编写查询或执行其他操作时,并非所有 EF Core 功能都受支持。

数学转换的增强

.NET 7 中推出了通用数学接口。 具体类型(例如 doublefloat)实现了这些接口,添加了新的 API,反映了 MathMathF 的现有功能。

EF Core 8 使用提供程序对 MathMathF 的现有 SQL 转换来转换对 LINQ 中这些通用数学 API 的调用。 这意味着你现在可以自由选择在 EF 查询中使用 Math.Sindouble.Sin 调用。

我们与 .NET 团队合作,在 .NET 8 中添加了两个新的通用数学方法,在 doublefloat 上实现。 它们在 EF Core 8 中也转换为 SQL。

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

最后,我们就 SQLitePCLRaw 项目与 Eric Sink 展开了合作,在他们本机 SQLite 库的生成中启用了 SQLite 数学函数。 这包括你安装 EF Core SQLite 提供程序时默认获取的本机库。 这将在 LINQ 中启用多个新的 SQL 翻译,包括:Acos、Acosh、Asin、Asinh、Atan、Atan2、Atanh、Ceiling、Cos、Cosh、DegreesToRadian、Exp、Floor、Log、Log2、Log10、Pow、RadiansToDegrees、Sign、Sin、Sinh、Sqrt、Tan、Tanh 和 Truncate。

检查挂起的模型更改

我们添加了一个新 dotnet ef 命令,可检查自上次迁移以来是否进行了任何模型更改。 这在 CI/CD 方案中非常有用,可确保你或团队成员不会忘记添加迁移。

dotnet ef migrations has-pending-model-changes

还可以使用新的 dbContext.Database.HasPendingModelChanges() 方法以编程方式在应用程序或测试中执行此检查。

SQLite 基架的增强功能

SQLite 仅支持四种基元数据类型:INTEGER、REAL、TEXT 和 BLOB。 以前,这意味着在对 SQLite 数据库进行逆向工程以搭建 EF Core 模型时,生成的实体类型将仅包含类型、longdoublestringbyte[] 的属性。 EF Core SQLite 提供程序通过在其他 .NET 类型和四种基元 SQLite 类型之间进行转换来支持其他 .NET 类型。

在 EF Core 8 中,除了 SQLite 类型之外,我们现在还使用数据格式和列类型名称来确定要在模型中使用的更合适的 .NET 类型。 下表显示了一些用例,其中附加信息导致模型中出现了更好的属性类型。

列类型名称 .NET 类型
布尔值 byte[]bool
SMALLINT longshort
INT longint
BIGINT long
字符串 byte[]string
数据格式 .NET 类型
“0.0” stringdecimal
“1970-01-01” stringDateOnly
“1970-01-01 00:00:00” stringDateTime
“00:00:00” stringTimeSpan
“00000000-0000-0000-0000-000000000000” stringGuid

Sentinel 值和数据库默认值

如果插入行时未提供任何值,则数据库允许将列配置为生成默认值。 这可以在 EF 中使用常量的 HasDefaultValue 表示:

b.Property(e => e.Status).HasDefaultValue("Hidden");

或者任意 SQL 子句的 HasDefaultValueSql

b.Property(e => e.LeaseDate).HasDefaultValueSql("getutcdate()");

提示

下面显示的代码来自 DefaultConstraintSample.cs

为了使 EF 使用它,它必须确定何时为列发送值以及何时不发送。 默认情况下,EF 将 CLR 默认值用作此值的 sentinel。 也就是说,当上述示例中 StatusLeaseDate 的值是这些类型的 CLR 默认值时,EF 将解释为表示尚未设置属性,因此不会向数据库发送值。 这适用于引用类型,例如,如果 string 属性 Statusnull,则 EF 不会向数据库发送 null,而是不包含任何值,以便使用数据库默认值("Hidden")。 同样,对于 DateTime 属性 LeaseDate,EF 不会插入 1/1/0001 12:00:00 AM 的 CLR 默认值,而是省略此值,以便使用数据库默认值。

但是,在某些情况下,CLR 默认值是一个要插入的有效值。 EF8 通过允许列更改 sentinel 值来处理此问题。 例如,请考虑使用数据库默认值配置的整数列:

b.Property(e => e.Credits).HasDefaultValueSql(10);

在这种情况下,我们希望使用给定数量的信用点数插入新实体,除非未指定,在这种情况下,将分配 10 个点数。 但是,这意味着无法插入具有零信用点数的记录,因为零是 CLR 默认值,因此会导致 EF 不发送任何值。 在 EF8 中,可以通过将属性的 sentinel 从零更改为 -1 来修复此问题:

b.Property(e => e.Credits).HasDefaultValueSql(10).HasSentinel(-1);

EF 现在仅当 Credits 设置为 -1时使用数据库默认值;将像插入任何其他数量一样插入零的值。

在实体类型和 EF 配置中反映这种情况通常很有用。 例如:

public class Person
{
    public int Id { get; set; }
    public int Credits { get; set; } = -1;
}

这意味着在创建实例时自动设置 -1 的 sentinel 值,意思是该属性以“未设置”状态启动。

提示

如果要在 Migrations 创建列时配置要使用的数据库默认约束,但希望 EF 始终插入值,就将属性配置为未生成。 例如 b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();

布尔值的数据库默认值

布尔属性存在此问题的极端形式,因为 CLR 默认值(false)是唯一两个有效值之一。 这意味着具有数据库默认约束的 bool 属性仅在该值为 true 时插入一个值。 当数据库默认值为 false 时,这意味着当属性值为 false 时,将使用数据库默认值,即 false。 否则,如果属性值为 true,将插入 true。 因此,当数据库默认值为 false 时,数据库列最终具有正确的值。

另一方面,如果数据库默认值为 true,这意味着当属性值为 false 时,将使用数据库默认值,即 true! 当属性值为 true 时,将插入 true。 因此,无论属性值是什么,列中的值都将始终以数据库结尾 true

EF8 通过将布尔属性的 sentinel 设置为与数据库默认值相同的值来修复此问题。 然后,上述两种情况都会导致插入正确的值,无论数据库默认值是 true 还是 false

提示

从现有数据库搭建基架时,EF8 会分析,然后将简单的默认值包含在调用 HasDefaultValue 中。 (以前,所有默认值都基架为不透明 HasDefaultValueSql 调用。)这意味着,具有 truefalse 常量数据库默认值的不可为 null 的布尔列不再被基架为可为空。

枚举的数据库默认值

枚举属性可能具有与 bool 属性类似的问题,因为枚举通常有一组非常小的有效值,CLR 默认值可能是这些值之一。 例如,请考虑以下实体类型和枚举:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; }
}

public enum Level
{
    Beginner,
    Intermediate,
    Advanced,
    Unspecified
}

然后使用数据库默认值配置 Level 属性:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate);

使用此配置,EF 将排除在设置为 Level.Beginner 时向数据库发送值,而是由数据库分配 Level.Intermediate。 这不是本意!

如果枚举使用数据库默认值为“unknown”或“unspecified”值定义,就不会发生此问题:

public enum Level
{
    Unspecified,
    Beginner,
    Intermediate,
    Advanced
}

但是,并不总是可以更改现有枚举,因此在 EF8 中,可以再次指定 sentinel。 例如,返回到原始枚举:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate)
    .HasSentinel(Level.Unspecified);

现在 Level.Beginner 将按正常方式插入,仅当属性值为 Level.Unspecified 时,才会使用数据库默认值。 在实体类型本身中反映这一点可能也有用。 例如:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; } = Level.Unspecified;
}

使用可为空的支持字段

处理上述问题的更一般的方法是为不可为 null 属性创建可为空的支持字段。 例如,请考虑具有 bool 属性的以下实体类型:

public class Account
{
    public int Id { get; set; }
    public bool IsActive { get; set; }
}

可以为该属性提供可为空的支持字段:

public class Account
{
    public int Id { get; set; }

    private bool? _isActive;

    public bool IsActive
    {
        get => _isActive ?? false;
        set => _isActive = value;
    }
}

此处的支持字段将保持 null,除非属性资源库实际上称为 。 也就是说,支持字段的值是是否已设置或不是 CLR 默认值属性的更好的指示属性。 这与 EF 开箱即用,因为 EF 将默认使用支持字段读取和写入属性。

更好的 ExecuteUpdate 和 ExecuteDelete

执行更新和删除的 SQL 命令(如由 ExecuteUpdateExecuteDelete 方法生成的命令)必须面向单个数据库表。 但是,在 EF7 中,即使查询最终影响单个表 ,ExecuteUpdateExecuteDelete 也不支持访问多个实体类型 的更新。 EF8 消除了此限制。 例如,考虑具有 CustomerInfo 拥有类型的 Customer 实体类型:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required CustomerInfo CustomerInfo { get; set; }
}

[Owned]
public class CustomerInfo
{
    public string? Tag { get; set; }
}

这两种实体类型都映射到 Customers 表。 但是,EF7 上的以下批量更新失败,因为它使用两种实体类型:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.CustomerInfo.Tag, "Tagged")
            .SetProperty(b => b.Name, b => b.Name + "_Tagged"));

在 EF8 中,使用 Azure SQL 时,现在这会转换为以下 SQL:

UPDATE [c]
SET [c].[Name] = [c].[Name] + N'_Tagged',
    [c].[CustomerInfo_Tag] = N'Tagged'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

同样,只要更新所有针对同一表,就可以更新从 Union 查询返回的实例。 例如,我们可以使用 France 区域更新任何 Customer,同时,使用区域 France 访问商店的任何 Customer

await context.CustomersWithStores
    .Where(e => e.Region == "France")
    .Union(context.Stores.Where(e => e.Region == "France").SelectMany(e => e.Customers))
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Tag, "The French Connection"));

在 EF8 中,使用 Azure SQL 时,此查询将生成以下内容:

UPDATE [c]
SET [c].[Tag] = N'The French Connection'
FROM [CustomersWithStores] AS [c]
INNER JOIN (
    SELECT [c0].[Id], [c0].[Name], [c0].[Region], [c0].[StoreId], [c0].[Tag]
    FROM [CustomersWithStores] AS [c0]
    WHERE [c0].[Region] = N'France'
    UNION
    SELECT [c1].[Id], [c1].[Name], [c1].[Region], [c1].[StoreId], [c1].[Tag]
    FROM [Stores] AS [s]
    INNER JOIN [CustomersWithStores] AS [c1] ON [s].[Id] = [c1].[StoreId]
    WHERE [s].[Region] = N'France'
) AS [t] ON [c].[Id] = [t].[Id]

作为最后一个示例,在 EF8 中,只要所有更新的属性都映射到同一个表,ExecuteUpdate 就可以更新 TPT 层次结构中的实体。 例如,请考虑使用以下 TPT 映射的实体类型:

[Table("TptSpecialCustomers")]
public class SpecialCustomerTpt : CustomerTpt
{
    public string? Note { get; set; }
}

[Table("TptCustomers")]
public class CustomerTpt
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

使用 EF8,可以更新 Note 属性:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted"));

或者可以更新 Name 属性:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Name, b => b.Name + " (Noted)"));

但是,EF8 无法尝试更新 NameNote 属性,因为它们映射到不同的表。 例如:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted")
        .SetProperty(b => b.Name, b => b.Name + " (Noted)"));

抛出以下异常:

The LINQ expression 'DbSet<SpecialCustomerTpt>()
    .Where(s => s.Name == __name_0)
    .ExecuteUpdate(s => s.SetProperty<string>(
        propertyExpression: b => b.Note,
        valueExpression: "Noted").SetProperty<string>(
        propertyExpression: b => b.Name,
        valueExpression: b => b.Name + " (Noted)"))' could not be translated. Additional information: Multiple 'SetProperty' invocations refer to different tables ('b => b.Note' and 'b => b.Name'). A single 'ExecuteUpdate' call can only update the columns of a single table. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

更好地使用 IN 查询

Contains LINQ 运算符与子查询一起使用时,EF Core 现在使用 SQL IN 而不是 EXISTS 生成更好的查询;除了生成更易读的 SQL 之外,在某些情况下,这可能会导致查询速度更快。 例如,请考虑下面这个 LINQ 查询:

var blogsWithPosts = await context.Blogs
    .Where(b => context.Posts.Select(p => p.BlogId).Contains(b.Id))
    .ToListAsync();

EF7 为 PostgreSQL 生成以下内容:

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

由于子查询引用外部 Blogs 表(通过 b."Id"),因此这是一个相关的子查询,这意味着必须为 Blogs 表中的每一行执行 Posts 子查询。 在 EF8 中,将改为生成以下 SQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE b."Id" IN (
          SELECT p."BlogId"
          FROM "Posts" AS p
      )

由于子查询不再引用 Blogs,因此可以评估一次,从而在大多数数据库系统上产生巨大的性能改进。 但是,某些数据库系统(尤其是 SQL Server)能够将第一个查询优化到第二个查询,以便性能相同。

SQL Azure/SQL Server 的数字行版本

SQL Server 自动乐观并发是使用 rowversion处理的。 rowversion 是在数据库、客户端和服务器之间传递的 8 字字节不透明值。 默认情况下,SqlClient 将 rowversion 类型公开为 byte[],尽管可变引用类型与 rowversion 语义不匹配。 在 EF8 中,可以轻松地将 rowversion 列映射到 longulong 属性。 例如:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .HasConversion<byte[]>()
    .IsRowVersion();

括号消除

生成可读 SQL 是 EF Core 的重要目标。 在 EF8 中,通过自动消除不需要的括号,生成的 SQL 更易于阅读。 例如,以下 LINQ 查询:

await ctx.Customers  
    .Where(c => c.Id * 3 + 2 > 0 && c.FirstName != null || c.LastName != null)  
    .ToListAsync();  

使用 EF7 时转换为以下 Azure SQL:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ((([c].[Id] * 3) + 2) > 0 AND ([c].[FirstName] IS NOT NULL)) OR ([c].[LastName] IS NOT NULL)

已对使用 EF8 时的以下内容进行了改进:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ([c].[Id] * 3 + 2 > 0 AND [c].[FirstName] IS NOT NULL) OR [c].[LastName] IS NOT NULL

RETURNING/OUTPUT 子句的特定选择退出

EF7 更改了默认更新 SQL,以使用 RETURNING/OUTPUT 提取数据库生成的列。 标识此行为不起作用的位置的情况,这样 EF8 会为此行为引入显式选择退出。

例如,在使用 SQL Server/Azure SQL 提供程序时选择退出 OUTPUT

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlOutputClause(false));

或使用 SQLite 提供程序时选择退出 RETURNING

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlReturningClause(false));

其他次要更改

除了上述增强功能外,EF8 还进行了许多较小的更改。 这包括: