路过的小熊猫 · C/C++ 内存反射式DLL注入 ...· 5 天前 · |
失恋的汽水 · oracle判断字段是否包含某个字符串-掘金· 11 月前 · |
有腹肌的红烧肉 · R语言随机森林ROC曲线下的面积如何计算? ...· 1 年前 · |
文质彬彬的豆芽 · Kerberos高可用HA配置 - 简书· 1 年前 · |
深情的鞭炮 · python sqlalchemy ...· 1 年前 · |
爱笑的日记本 · C#之多线程和异步编程 - 知乎· 1 年前 · |
EF Core 8.0 (EF8) 是 EF Core 7.0 之后的下一版本,计划于 2023 年 11 月发布。 有关详细信息,请参阅 Entity Framework Core 8 计划 。有关该计划的进展,请参阅 有关 .NET 8 和 EF8 的最新消息和进展 。
EF8 作为 日常版本 提供,其中包含所有最新的 EF8 功能和 API 调整。 此处的示例使用这些日常版本。
可通过 从 GitHub 下载示例代码 来运行和调试示例。 每个部分都链接到特定于该部分的源代码。
EF8 需要 最新的 .NET 8 预览版 SDK 。 EF8 无法在早期 .NET 版本上运行,也无法在 .NET Framework 上运行。
保存到数据库的对象可以分为三大类:
int
、
Guid
、
string
、
IPAddress
。 这些类型(有点宽泛)称为“基元类型”。
Blog
、
Post
、
Customer
。 它们称为“实体类型”。
Address
、
Coordinate
。
在 EF8 之前,没有很好的方法来映射第三种类型的对象。 从属类型 是可以使用的,但由于从属类型实际上是实体类型,因此它们具有基于键值的语义,即便该键值处于隐藏状态时也是如此。
EF8 现在支持“复杂类型”来涵盖此第三种类型的对象。 复杂类型对象:
DbSet
。)
例如,想一想
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
列都已更改,因为它们都共享同一实例。 这通常不是我们想要的。
如果客户地址更改时订单地址应自动更改,请考虑将地址映射为实体类型。 然后,Order
和 Customer
可以通过导航属性安全地引用同一地址实例(它现在由键来标识)。
处理此类问题的一个好办法是使类型不可变。 事实上,当某个类型很适合成为复杂类型时,这种不可变性通常是很自然的。 例如,提供一个复杂且新的 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 PhoneNumrequired ber MobilePhone { get; init; }
或在 OnModelCreating
中:
protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<Customer>(
b.ComplexProperty(
e => e.Contact,
b.ComplexProperty(e => e.Address);
b.ComplexProperty(e => e.HomePhone);
b.ComplexProperty(e => e.WorkPhone);
b.ComplexProperty(e => e.MobilePhone);
modelBuilder.Entity<Order>(
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 中的两件事:
返回了所有内容以填充客户以及所有嵌套的 Contact
、Address
、PhoneNumber
复杂类型。
所有复杂类型值都存储为实体类型的表中的列。 复杂类型永远不会映射到单独的表。
可以从查询投影复杂类型。 例如,仅从订单中选择发货地址:
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 的扩展。
EntityEntry 的 ComplexProperty
方法返回整个复杂对象的条目。 例如,要获取 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 中的复杂类型限制包括:
支持复杂类型的集合。 (问题 #31237)
允许复杂类型属性为 null。 (问题 #31376)
将复杂类型属性映射到 JSON 列。 (问题 #31252)
复杂类型的构造函数注入。 (问题 #31621)
为复杂类型添加种子数据支持。 (问题 #31254)
映射 Cosmos 提供程序的复杂类型属性。 (问题 #31253)
为内存中数据库实现复杂类型。 (问题 #31464)
使用关系数据库时长期存在的问题是,如何处理基元类型的集合;即整数、日期/时间、字符串等的列表或数组。 如果使用的是 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 ISet<DateTime> DateTimes { 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; }
在此上下文中,“基元类型”是什么意思? 从本质上讲,数据库提供程序知道如何映射的内容,必要时使用某种值转换。 例如,在上面的实体类型中,类型 int
、string
、DateTime
、DateOnly
和 bool
均由数据库提供程序处理,无需转换。 SQL Server 没有对无符号整数或 URI 的本机支持,但 uint
和 Uri
仍被视为基元类型,因为这些类型有内置的值转换器。
默认情况下,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)
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.Abstractions
和 Microsoft.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 查询的多种方法。
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。
使用非映射类型的查询是使用 SqlQuery 或 SqlQueryRaw 执行的。 前者使用字符串内插来参数化查询,这有助于确保所有非常量值都被参数化。 例如,考虑以下数据表:
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
DateOnly 和 TimeOnly 类型是在 .NET 6 中引入的,自引入以来,一直支持多个数据库提供程序(例如 SQLite、MySQL 和 PostgreSQL)。 对于 SQL Server,面向 .NET 6 的 Microsoft.Data.SqlClient 包的最新版本已允许 ErikEJ 在 ADO.NET 级别增加对这些类型的支持。 这反过来又为 EF8 中支持将 DateOnly
和 TimeOnly
作为实体类型的属性做好了准备。
在 EF Core 6 和 7 中,可以通过 @ErikEJ 中的 ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly 社区包使用 DateOnly
和 TimeOnly
。
例如,请考虑英国学校的以下 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; }