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 對很重要,請投票給您。

現在,假設我們想要將訂單寄送給客戶,並使用客戶的位址作為出貨位址的預設帳單。 這樣做的自然方式是將 物件從 Customer Order複製到 Address 。 例如:

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呼叫 API 來設定ComplexProperty。 依慣例不會探索複雜型別。

例如, Address 可以使用 來設定 ComplexTypeAttribute類型:

[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 的延伸模組。

ComplexProperty 回整個複雜物件之專案的方法 EntityEntry 。 例如,若要取得 的目前值 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巢狀 Address Contact 取得城市:

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的 內建數位型態來儲存這些專案。 對於其他資料庫,有兩種常見的方法:

  • 使用基本類型值的數據行和另一個數據行建立數據表,以做為外鍵,將每個值連結到集合的擁有者。
  • 將基本集合串行化為資料庫所處理的某些數據行類型,例如,串行化至字串或從字串串列化。

第一個選項在許多情況下都有優點,我們將在本節結尾快速查看。 不過,它不是模型中數據的自然表示法,而且如果您真正擁有的是基本類型的集合,則第二個選項會更有效率。

從 Preview 4 開始,EF8 現在包含第二個選項的內建支援,使用 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; }
}

注意

在此內容中,我們所說的「基本類型」是什麼意思? 基本上,資料庫提供者知道如何對應,必要時使用某種值轉換。 例如,在上述實體類型中,類型 intstringDateOnly DateTimebool 全都會由資料庫提供者處理,而不會進行轉換。 SQL Server 沒有未簽署的 INT 或 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,
}

和 一樣 PubDogWalk 也包含參觀日期的集合,以及最接近酒吧的鏈接,因為,你知道,有時狗需要一個飛碟啤酒后長時間行走。

使用此模型,我們將執行的第一個查詢是一個簡單的 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 Preview 4

重要

此處的內嵌值是以沒有 SQL 插入式攻擊機率的方式完成。 使用以下所述的 JSON 變更全都是關於效能,與安全性無關。

針對EF Core 8,預設值現在是將地形清單當做包含 JSON 集合的單一參數來傳遞。 例如:

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

查詢接著會在 OpenJson SQL Server 上使用:

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

或在 json_each SQLite 上:

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)

請注意,因為 EF 知道基本集合包含日期,因此查詢會在這裡使用日期特定函DATEPART式。 它似乎不像它,但這實際上真的很重要。 因為 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。 不過,這與將擁有的實體類型對應 至包含EF7 中引進 JSON 文件的數據行不同。 但是,如果該 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。 這會在查詢中處理,方法是將投影值轉換成可為 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 類型比 SqlHierarchyId 更符合 .NET 的規範,它會在 .NET Framework 類型裝載於 SQL Server 資料庫引擎後進行建模。 HierarchyId 是設計來與 EF Core 搭配使用的,但也可在 EF Core 外部的其他應用程式使用。 Microsoft.EntityFrameworkCore.SqlServer.Abstractions 套件不會參照任何其他套件,因此對已部署的應用程式大小和相依性的影響極小。

對查詢和更新等 EF Core 功能使用 HierarchyId,需要 Microsoft.EntityFrameworkCore.SqlServer.HierarchyId 套件。 此套件包含 Microsoft.EntityFrameworkCore.SqlServer.AbstractionsMicrosoft.SqlServer.Types 這些可轉移的相依性,因此通常是唯一必需的套件。 安裝套件之後,您可以在應用程式呼叫 UseSqlServer 的過程中呼叫 UseHierarchyId,藉此使用 HierarchyId。 例如:

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

注意

EF Core 中的非官方支援hierarchyid已透過 EntityFrameworkCore.SqlServer.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/ 表示。 請注意, HierarchyId 巴爾博 (/1/) 的 是他所有孩子的前置詞。
  • 同樣地,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 欄。

重新建立子階層的父系

舉例來說,我想大家都記得 SR 1752 (也就是「LongoGate」) 事件:DNA 測試顯示 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”和 “Popy”,同時查詢 “Ponto” 的子系會傳回 “Longo”、“Rosa”、“Polo”、“Otho”、“Posco”、“Prisca”、“Lotho”、“Ponto”、“波爾圖”、“牡丹”和 “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 是,它會傳回 IQueryable 可使用 LINQ 撰寫的 。 例如,可以將 '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 已設定延遲載入,例如,使用延遲載入 Proxy,則存取 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,因為無法以異步方式存取屬性。

從未追蹤的實體延遲載入適用於 延遲載入 Proxy沒有 Proxy 的延遲載入。

從未追蹤的實體明確載入

EF8 支援在未追蹤的實體上載入導覽,即使未設定延遲載入的實體或流覽也一併載入。 不同於延遲載入, 此明確載入 可以異步完成。 例如:

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

退出退出特定導覽的延遲載入

EF8 允許設定特定導覽,使其不延遲載入,即使其他所有專案都設定為這麼做也一樣。 例如,若要將 Post.Author 瀏覽設定為不延遲載入,請執行下列動作:

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

停用延遲載入,這適用於 延遲載入 Proxy沒有 Proxy 的延遲載入。

延遲載入 Proxy 的運作方式是覆寫虛擬導覽屬性。 在傳統EF6 應用程式中,常見的 Bug 來源會忘記讓流覽虛擬,因為流覽會以無訊息方式不延遲載入。 因此,當導覽不是虛擬時,EF Core Proxy 預設會擲回。

這可以在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 繼承對應的 字串歧視性數據行現在已設定長度上限。 此長度會計算為涵蓋所有已定義之歧視性值的最小 Fibonacci 數位。 例如,請考慮下列階層:

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]),

提示

Fibonacci 數位可用來限制移轉產生的次數,以在將新類型新增至階層時變更數據行長度。

SQL Server 上支援的 DateOnly/TimeOnly

DateOnlyTimeOnly 類型是在 .NET 6 中引進的,自推出以來,已支援數個資料庫提供者(例如 SQLite、MySQL 和 PostgreSQL)。 針對 SQL Server,以 .NET 6 為目標的 Microsoft.Data.SqlClient 套件最新版本允許 ErikEJ 在 ADO.NET 層級新增這些類型的支援。 這反過來又為 EF8 的支援 DateOnly 鋪平了實體類型中的 屬性 TimeOnly

提示

DateOnlyTimeOnly 可以使用來自 @ErikEJErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly 社群套件,在 EF Core 6 和 7 中使用。

例如,請考慮下列英國學校的 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
名稱 法爾高中
成立 1964-05-01
OpenHours
[
{ “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 反向工程(也就是現有資料庫的 Scaffolding)現在支援 Synapse 無伺服器 SQL 集 區和 Dynamics 365 TDS 端點 資料庫。

警告

這些資料庫系統與一般 SQL Server 和 Azure SQL 資料庫有差異。 這些差異表示在針對這些資料庫系統撰寫查詢或執行其他作業時,不支援所有 EF Core 功能。

數學翻譯的增強功能

.NET 7 中引進了泛型數學 介面。 像是 double 和實作這些介面的具象類型,會新增新的 API,以反映 Math 和 MathF 的現有float功能。

EF Core 8 會使用 和 的現有 SQL 翻譯,在 LINQ 中轉譯這些泛型數學 API 的MathMathF呼叫。 這表示您現在可以在 EF 查詢中自由選擇呼叫,例如 Math.Sindouble.Sin

我們與 .NET 小組合作,在 和 floatdouble實作的 .NET 8 中新增兩個新的泛型數學方法。 這些也會轉譯為 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 Scaffolding 的增強功能

SQLite 僅支援四種基本數據類型:INTEGER、REAL、TEXT 和 BLOB。 先前,這表示當您反向設計 SQLite 資料庫來建立 EF Core 模型時,產生的實體類型只會包含 、、 stringbyte[]類型的longdouble屬性。 EF Core SQLite 提供者支援其他 .NET 類型,方法是在它們與四個基本 SQLite 類型之一之間進行轉換。

在EF Core 8 中,除了 SQLite 類型之外,我們現在還使用數據格式和數據行類型名稱,以判斷模型中要使用的更適當的 .NET 類型。 下表顯示一些案例,其中其他資訊會導致模型中更好的屬性類型。

資料行類型名稱 .NET 類型
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT long
字串 byte[]string
資料格式 .NET 類型
'0.0' 字串十進位
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' 字串TimeSpan
'00000000-0000-0000-0000-000000000000' 字串Guid

Sentinel 值和資料庫預設值

如果插入數據列時未提供任何值,資料庫允許數據行設定為產生預設值。 這可以在 EF HasDefaultValue 中使用 常數來表示:

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

HasDefaultValueSql 任意 SQL 子句:

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

提示

如下所示的程式代碼來自 DefaultConstraintSample.cs

為了讓EF能夠使用此專案,它必須判斷何時及何時不傳送數據行的值。 根據預設,EF 會使用CLR預設值做為此的sentinel。 也就是說,當上述範例中的 或 LeaseDateStatus是這些類型的 CLR 預設值時,EF 會解譯為表示尚未設定屬性,因此不會將值傳送至資料庫。 這適用於參考型別,例如,如果 string 屬性 Statusnull,則 EF 不會傳送 null 至資料庫,而是不包含任何值,以便使用資料庫預設值 ("Hidden") 。 同樣地,針對 DateTime 屬性 LeaseDate,EF 不會插入的 CLR 預設值 1/1/0001 12:00:00 AM,而是會省略此值,以便使用資料庫預設值。

不過,在某些情況下,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);

如果 Credits 設定為 -1,則 EF 現在只會使用資料庫預設值;將像任何其他數量一樣插入零的值。

在實體類型和 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時才會插入值。 當資料庫預設值為 時,這表示當 屬性值為 falsefalse時,則會使用資料庫預設值,也就是 false。 否則,如果屬性值為 truetrue 則會插入 。 因此,當資料庫預設值為 false時,資料庫數據行最後會有正確的值。

另一方面,如果資料庫預設值為 ,這表示當 屬性值為 truefalse時,將會使用資料庫預設值,也就是 true! 當屬性值為 true時, true 將會插入 。 因此,不論屬性值為何,數據行中的值一律都會在資料庫中結束 true

EF8 修正此問題,方法是將bool屬性的sentinel設定為與資料庫預設值相同的值。 此兩種情況都會插入正確的值,不論資料庫預設值為 truefalse

提示

從現有的資料庫建構時,EF8 會剖析,然後將簡單的預設值包含在呼叫中 HasDefaultValue 。 (先前,所有預設值都會以不透明 HasDefaultValueSql 呼叫的形式進行 Scaffold 處理。這表示具有 truefalse 常數資料庫預設值的非可為 Null 布爾數據行已不再以可為 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 是由資料庫指派。 這不是原意!

如果列舉定義為資料庫預設值的「未知」或「未指定」值,則不會發生此問題:

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 的備份欄位

若要處理上述問題的較一般方式,就是為不可為 Null 屬性建立可為 Null 的備份欄位。 例如,請考慮具有 屬性的下列實體類型 bool

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

屬性可以指定可為 Null 的備份欄位:

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

    private bool? _isActive;

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

除非實際呼叫屬性 setter,否則此處的備份欄位會維持null不變。 也就是說,備份欄位的值更能指出是否已設定屬性,而不是屬性的CLR預設值。 這與 EF 搭配運作,因為 EF 預設會使用備份字段來讀取和寫入屬性。

Better ExecuteUpdate 和 ExecuteDelete

執行更新和刪除的 SQL 命令,例如和 ExecuteUpdate ExecuteDelete 方法所產生的命令,必須以單一資料庫數據表為目標。 不過,在 EF7 中,即使查詢最終影響單一數據表,ExecuteUpdateExecuteDelete不支援存取多個實體類型的更新。 EF8 會移除這項限制。 例如,請考慮 Customer 具有 CustomerInfo 擁有類型的實體類型:

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 查詢傳回的實例。 例如,我們可以使用 的區域來更新任何 CustomerFrance同時,任何 Customer 已使用區域 France流覽商店的人員:

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 嘗試同時更新 Name 和 屬性失敗, Note 因為它們會對應至不同的數據表。 例如:

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 產生更好的查詢,而不是 EXISTS;除了產生更容易閱讀的 SQL IN 之外,在某些情況下,這可能會導致查詢更快。 例如,請考慮使用下列 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"),這是 相互關聯的子查詢,這表示 Posts 必須針對數據表中的每個 Blogs 數據列執行子查詢。 在 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 中,您可以輕鬆地將數據行對應 rowversionlongulong 屬性。 例如:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .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 還進行了許多較小的變更。 這包括: