EF Core 8 の新機能

EF Core 8.0 (EF8) は 2023 年 11 月にリリースされました。

ヒント

サンプルは、GitHub からサンプル コードをダウンロードすることにより、実行してデバッグできます。 各セクションは、そのセクションに固有のソース コードにリンクします。

EF8 では、.NET 8 SDK をビルドする必要があり、.NET 8 ランタイムを実行する必要があります。 EF8 は以前の .NET バージョンでは実行されず、.NET Framework では実行されません。

複合型を使用した値オブジェクト

データベースに保存されたオブジェクトは、次の 3 つのカテゴリに大きく分けることができます。

  • 非構造化であり、1 つの値を保持するオブジェクト。 たとえば、intGuidstringIPAddress です。 これらは、(やや緩い) "プリミティブ型" と呼ばれます。
  • 複数の値を保持するように構成され、オブジェクトの ID がキー値によって定義されているオブジェクト。 たとえば、BlogPostCustomer などです。 これらは "エンティティ型" と呼ばれます。
  • 複数の値を保持するように構成されているものの、オブジェクトに ID を定義するキーがないオブジェクト。 たとえば、AddressCoordinate のようになります。

EF8 より前は、3 番目の型のオブジェクトをマップする適切な方法はありませんでした。 所有型を使用できますが、所有型は実際にはエンティティ型であるため、そのキー値が非表示の場合でも、キー値に基づくセマンティクスがあります。

EF8 では、この 3 番目の型のオブジェクトに対応する "複合型" がサポートされるようになりました。 複合型オブジェクトは次のとおりです。

  • キー値によって識別されることも追跡されることもありません。
  • エンティティ型の一部として定義する必要があります (つまり、複合型の 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 は、単純な顧客/注文モデルの 3 か所で使用されます。

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 テーブルの列にインラインで保存されます。 これは、所有型のテーブル共有動作と一致します。

Note

複合型を独自のテーブルにマップすることを許可する予定はありません。 しかし、今後のリリースで、複合型を JSON ドキュメントとして 1 つの列に保存できるようにする予定です。 これが重要な場合は、Issue #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 エンティティ型の 1 つのインスタンスが、3 つの異なるエンティティ インスタンスで使用されているためです。 一方、複合プロパティ間で同じインスタンスを共有することは許可されているため、複合型を使用する場合はコードが期待どおりに動作します。

複合型の構成

複合型は、マッピング属性を使用するか、OnModelCreatingComplexProperty API を呼び出すことによって、モデルで構成する必要があります。 規則により、複合型は検出されません。

たとえば、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);
    });
}

変更可能性

上記の例では、最終的に 3 か所で同じ Address インスタンスが使用されました。 これは許可されており、複合型を使用する場合に EF Core の問題は発生しません。 ただし、同じ参照型のインスタンスを共有することは、インスタンスのプロパティ値が変更された場合、その変更が 3 つの使用法すべてに反映されることを意味します。 たとえば、上記に従って、顧客の住所の 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;

3 つの 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 型の任意のフレーバーで行うことができます。

Note

EF Core では複合型値のコンストラクターの挿入がまだサポートされていないため、Contact 型にプライマリ コンストラクターを使用していません。 これが重要な場合は、Issue #31621 に投票してください。

Customer のプロパティとして Contact を追加します。

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

Order のプロパティとして PhoneNumber を追加する場合:

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 の 2 つの点に注目してください。

  • 顧客 ''および'' 入れ子になったすべての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

複合型オブジェクトには追跡に使用する ID がないため、複合型のプロジェクションを追跡できないことに注意してください。

述語での使用

複合型のメンバーは、述語で使用できます。 たとえば、特定の市区町村への注文をすべて見つけるとします。

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)

複合型の各メンバーを展開することで、等式が実行されることに注目してください。 これは、ID のキーを持たない複合型と一致するため、複合型インスタンスは、すべてのメンバーが等しい場合にのみ、別の複合型インスタンスと等しくなります。 また、これはレコード型に対して .NET によって定義された等式と一致します。

複合型値の操作

EF8 では、複合型の現在の値と元の値、およびプロパティ値が変更されたかどうかなどの追跡情報にアクセスできます。 API 複合型は、エンティティ型に既に使用されている変更追跡 API を拡張したものです。

EntityEntryComplexProperty メソッドからは、複合オブジェクト全体のエントリが返されます。 たとえば、Order.BillingAddress の現在の値を取得するには、次のようにします。

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

複合型のプロパティにアクセスするために Property の呼び出しを追加できます。 たとえば、請求先の郵便番号の現在の値を取得するには、次のようにします。

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

入れ子になった複合型には、ComplexProperty への入れ子になった呼び出しを使用してアクセスします。 たとえば、CustomerContact の入れ子になった 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 の issue に投票 (👍) してください。

EF8 の複合型の制限事項は次のとおりです。

  • 複合型のコレクションをサポートする。 (Issue #31237)
  • 複合型のプロパティを null にすることを許可する。 (Issue #31376)
  • 複合型のプロパティを JSON 列にマップする。 (Issue #31252)
  • 複合型のコンストラクター挿入。 (Issue #31621)
  • 複合型のシード データ サポートを追加する。 (Issue #31254)
  • Cosmos プロバイダーの複合型プロパティをマップする。 (Issue #31253)
  • インメモリ データベースの複合型を実装する。 (Issue #31464)

プリミティブ コレクション

リレーショナル データベースを使うときに、プリミティブ型のコレクション、つまり整数、日付/時刻、文字列などの一覧や配列を処理する方法は根強い問題です。 PostgreSQL を使っている場合、PostgreSQL の組み込みの配列型を使ってこれらを簡単に格納できます。 他のデータベースの場合、2 つの一般的なアプローチがあります。

  • プリミティブ型の値を格納する列と、各値をコレクションの所有者にリンクする外部キーとして機能する別の列があるテーブルを作成します。
  • プリミティブ コレクションを、データベースが処理する何らかの列型にシリアル化します。たとえば、文字列との間でシリアル化します。

1 つ目の選択肢は、多くの状況で利点があります。このセクションの最後で簡単に説明します。 ただし、これはモデル内のデータの自然な表現ではなく、実際に存在するものがプリミティブ型のコレクションである場合は、2 つ目の選択肢の方が効果的です。

EF8 の Preview 4 以降には、シリアル化形式として JSON を使い、2 つ目の選択肢のサポートが組み込まれています。 JSON はこの用途に適しています。なぜなら、最近のリレーショナル データベースには、JSON を照会および操作するメカニズムが組み込まれているからです。そのため、実際にそのテーブルを作成するオーバーヘッドなしで、必要なときに JSON 列を効率的にテーブルとして扱うことができます。 これらと同じメカニズムにより、JSON をパラメーターで渡し、クエリのテーブル値パラメーターと同様の方法で使用できます。この詳細については後で説明します。

ヒント

ここで示すコードは PrimitiveCollectionsSample.cs から引用したものです。

プリミティブ コレクションのプロパティ

EF Core は、IEnumerable<T> プロパティ (T はプリミティブ型) をデータベース内の JSON 列にマップできます。 これは、ゲッターとセッターの両方を持つパブリック プロパティの規則によって行われます。 たとえば、次のエンティティ型のすべてのプロパティは、規則によって JSON 列にマップされます。

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

注意

この文脈で "プリミティブ型" とはどのような意味でしょうか。 基本的には、データベース プロバイダーが、(必要に応じて何らかの値変換を使って) マップする方法を認識しているものです。 たとえば、上記のエンティティ型では、型 intstringDateTimeDateOnlybool は、いずれもデータベース プロバイダーによって変換されずに処理されます。 SQL Server は符号なし 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);
}

プリミティブ コレクションを使ったクエリ

プリミティブ型のコレクションを利用するクエリをいくつか見てみましょう。 このために、2 つのエンティティ型を持つ単純なモデルが必要です。 1 つ目は、英国のパブリック ハウス (つまり "パブ") を表します。

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 型には 2 つのプリミティブ コレクションが含まれています。

  • Beers は、パブで飲めるビールのブランドを表す文字列の配列です。
  • DaysVisited はパブを訪れた日付の一覧です。

ヒント

実際のアプリケーションでは、ビールのエンティティ型を作成し、ビールのテーブルを用意する方がおそらく理にかなっています。 ここでは、しくみを説明するためにプリミティブ コレクションを示しています。 ただし、プリミティブ コレクションとしてモデル化できるからといって、必ずしもそうする必要があるわけではないことを覚えておいてください。

2 つ目のエンティティ型は、英国の田舎での犬の散歩を表しています。

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 Preview 4 の発表に関するページを参照してください。

重要

この値のインライン化は、SQL インジェクション攻撃を受ける可能性がない方法で行われています。 後述する JSON を使う変更は、すべてパフォーマンスに関するものであり、セキュリティとは関係ありません。

EF Core 8 の既定では、JSON コレクションを含む 1 つのパラメーターとして地形一覧を渡すようになりました。 次に例を示します。

@__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 が含まれています。 ただし、これは、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 では ->> 演算子を利用できるようになったことに注目してください。読みやすく、多くの場合はパフォーマンスが高くなります。

プリミティブ コレクションのテーブルへのマッピング

プリミティブ コレクションのもう 1 つの選択肢として、別のテーブルにマップできることを先ほど説明しました。 これに対するファースト クラスのサポートは Issue #25163 で追跡されています。お客様にとって重要な場合は、この issue に投票してください。 これが実装されるまでは、プリミティブのラッピング型を作成するのが最善の方法です。 たとえば、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 配列でのインデックス作成がサポートされます。 たとえば、次のクエリでは、最初の 2 つの更新が特定の日付より前に行われたかどうかがチェックされます。

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

注意

このクエリは、特定の投稿に更新がない場合や、更新プログラムが 1 つだけの場合でも成功します。 このような場合、JSON_VALUENULL が返され、述語が一致しません。

JSON 配列へのインデックス作成を使用して、配列の要素を最終的な結果に投影することもできます。 たとえば、次のクエリでは、各投稿の 1 番目と 2 番目の更新の 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]))

JSON の SQLite 用の列

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 関数を使います。 たとえば、先ほど参照したドキュメントの "authors in Chigley" (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 関数を使います。 たとえば、ドキュメント内の 1 つのプロパティを更新する場合:

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 という特殊なデータ型があります。 この場合、"階層データ" とは、基本的に、各項目が親や子を持つことができるツリー構造を形成するデータのことです。 このようなデータの例を次に示します。

  • 組織構造
  • ファイル システム
  • プロジェクト内のタスクのセット
  • 言語の用語の分類
  • Web ページ間のリンクのグラフ

この場合、データベースは、このデータに対してその階層構造を使ってクエリを実行できます。 たとえば、クエリを使って特定の項目の先祖と依存関係を見つけることや、階層の特定の深さにあるすべての項目を見つけることができます。

.NET と EF Core でのサポート

SQL Server hierarchyid 型の正式なサポートが最新の .NET プラットフォーム (つまり ".NET Core") に追加されたのはつい最近です。 これは Microsoft.SqlServer.Types NuGet パッケージという形でサポートされています。これにより、下位レベルの SQL Server 固有の型が導入されました。 この場合、下位レベルの型は SqlHierarchyId と呼ばれます。

次のレベルでは、新しい Microsoft.EntityFrameworkCore.SqlServer.Abstractions パッケージが導入されました。これには、エンティティ型での使用を目的とした上位レベルの HierarchyId 型が含まれています。

ヒント

.NET Framework 型が SQL Server データベース エンジン内でホストされる方法に基づいてモデル化された SqlHierarchyId よりも、HierarchyId 型は .NET の規範に沿っています。 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 には 5 人の子供がおり、/1//2//3//4//5/ で表されます。
  • Balbo の最初の子供である Mungo にも、/1/1//1/2//1/3//1/4//1/5/ で表される 5 人の子供がいます。 Balbo の HierarchyId (/1/) は、すべての子供のプレフィックスであることに注目してください。
  • 同様に、Balbo の 3 人目の子供である Ponto には /3/1//3/2/ で表される 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();

ヒント

必要に応じて、10 進数値を使って 2 つの既存のノード間に新しいノードを作成できます。 たとえば、/3/2/2//3/3/2/ の間に /3/2.5/2/ を挿入します。

階層のクエリ

HierarchyId は、LINQ クエリで使用できるいくつかのメソッドを公開しています。

Method 説明
GetAncestor(int n) 階層ツリーの n レベル上にあるノードを取得します。
GetDescendant(HierarchyId? child1, HierarchyId? child2) child1 より大きくて child2 より小さい子孫ノードの値を取得します。
GetLevel() 階層ツリー内のこのノードのレベルを取得します。
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) oldRoot からここまでのパスに相当する、newRoot からのパスを持つ新しいノードの位置を表す値を取得します。この結果、これは新しい位置に移動されます。
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 は、1 レベル、または指定した数のレベル分、上または下に検索する場合に便利です。 一方、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" が返されます。

共通の祖先を見つける

この特定の家系図についてよく聞かれる質問の 1 つは、"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") のスキャンダルは皆さん覚えていますよね。Longo が実は Mungo の息子ではなく、Ponto の息子であることが 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"、"Poppy" が返されます。一方、"Ponto" の子孫を照会すると、"Longo"、"Rosa"、"Polo"、"Otho"、"Posco"、"Prisca"、"Lotho"、"Ponto"、"Porto"、"Peony"、"Angelica" が返されます。

マップされていない型の生 SQL クエリ

EF7 で、スカラー型を返す生 SQL クエリが導入されました。 EF8 では、この機能が強化され、EF モデルにその型を含めずに、マップ可能な CLR 型を返す生 SQL クエリが追加されます。

ヒント

ここに示すコードは 、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 の優れた機能の 1 つが、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 アプリケーションでは、ナビゲーションによって、遅延読み込みが自動的に行われないため、バグの一般的な原因はナビゲーション仮想の作成を忘れることです。 そのため、ナビゲーションが仮想ではない場合、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/"))!;

これまで、検索から常に 1 つのエントリまたは 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 で DateOnlyTimeOnly をエンティティ型のプロパティとしてサポートする道が開けました。

ヒント

@ErikEJ からの ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly コミュニティ パッケージを使用して、EF Core 6 および 7 で DateOnlyTimeOnly を使用できます。

たとえば、次の英国の学校の EF モデルを考えてみます。

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

ヒント

ここに示すコードは DateOnlyTimeOnlySample.cs からのものです。

注意

このモデルは英国の学校のみを表し、現地 (GMT) 時刻として時刻を格納します。 異なるタイムゾーンを処理する場合、このコードはかなり複雑になります。 ここでは DateTimeOffset を使っても役に立たないことに注意してください。夏時間が有効かどうかに応じて、開始時間と終了時刻のオフセットが異なるためです。

これらのエンティティ型は、SQL Server を使用する場合、次のテーブルにマップされます。 DateOnly プロパティは date 列にマップされ、TimeOnly プロパティは time 列にマップされます。

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

DateOnlyTimeOnly を使用するクエリは、期待どおりに機能します。 たとえば、次の LINQ クエリは、現在開いている学校を検索します。

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

ToQueryString によって示されるように、このクエリは次の SQL に変換されます。

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
Name Farr High School
設立 1964-05-01
OpeningHours
[
{ "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 の 2 つの機能を組み合わせると、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();

ToQueryString によって示されるように、このクエリは次の SQL に変換されます。

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

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

最後に、更新と削除は、追跡と SaveChanges、または ExecuteUpdate と ExecuteDelete を使用して実行できます。 たとえば、次のように入力します。

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

この更新は、次の SQL に変換されます。

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

Synapse と Dynamics 365 TDS のリバース エンジニアリング

EF8 のリバース エンジニアリング (既存のデータベースからのスキャフォールディング) で、Synapse のサーバーレス SQL プールDynamics 365 TDS エンドポイント データベースがサポートされるようになります。

警告

これらのデータベース システムには、通常の SQL Server データベースと Azure SQL データベースとの違いがあります。 これらの違いは、これらのデータベース システムに対してクエリを作成したり、他の操作を実行したりするときに、すべての EF Core 機能がサポートされるわけではないことを意味します。

数値演算の機能強化

.NET 7 では、ジェネリック型数値演算インターフェイスが導入されました。 doublefloat などの具象型では、これらのインターフェイスが実装され、MathmathF の既存の機能をミラーリングする新しい API が追加されました。

EF Core 8 では、MathMathF について、プロバイダーの既存の SQL 変換を使用して、LINQ のこれらのジェネリック型数値演算 API の呼び出しを変換します。 つまり、EF クエリで Math.Sindouble.Sin などの呼び出しを自由に選択できるようになりました。

.NET チームと協力して、doublefloat に実装されている 2 つの新しいジェネリック型数値演算メソッドを .NET 8 に追加しました。 これらは、EF Core 8 の SQL にも変換されます。

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

最後に、SQLitePCLRaw プロジェクトの Eric Sink と協力して、ネイティブ SQLite ライブラリのビルドで SQLite 数学関数を有効にしました。 これには、EF Core SQLite プロバイダーをインストールするときに既定で取得するネイティブ ライブラリが含まれます。 これにより、LINQ で次のような新しい SQL 翻訳が可能になります: Acosh、Asin、Asinh、Atan、Atan2、Atanh、Ceiling, Cos、Cosh、DegreesToRadians、Exp、Floor、Log、Log2、Log10、Pow、RadiansToDegrees、Sign、Sin、Sinh、Sqrt、Tan、Tanh、Truncate など。

保留中のモデル変更の確認

最後の移行以降にモデルの変更が行われたかどうかを確認する新しい dotnet ef コマンドが追加されました。 これは CI/CD シナリオで役立ち、自分やチームメイトが移行の追加を忘れていなかったことを確認できます。

dotnet ef migrations has-pending-model-changes

このチェックは、新しい dbContext.Database.HasPendingModelChanges() メソッドを使用して、アプリケーションまたはテストのプログラムから実行することもできます。

SQLite スキャフォールディングの機能強化

SQLite には、INTEGER、REAL、TEXT、BLOB の 4 つのプリミティブ データ型のみをサポートしています。 つまり、以前は、EF Core モデルをスキャフォールディングするために SQLite データベースをリバース エンジニアリングした場合、結果のエンティティ型には型 longdoublestring および byte[] のみのプロパティが含まれていました。 その他の .NET 型は、EF Core SQLite プロバイダーが、これらの型と 4 つのプリミティブ SQLite 型のいずれかとの間で変換を行うことでサポートされます。

EF Core 8 では、モデルで使用するより適切な .NET 型を決定するために、SQLite 型に加えてデータ形式名と列の型名を使用するようになりました。 次の表に、追加情報がモデルのプロパティ型の向上につながるケースをいくつか示します。

列の種類名 .NET 型
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT long
STRING byte[]string
データの書式 .NET 型
'0.0' stringdecimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Sentinel 値とデータベースの既定値

データベースを使用すると、行を挿入するときに値が指定されなかった場合に、既定値を生成するように列を構成できます。 これは、EF で定数に HasDefaultValue を使用して表すことができます。

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

または、任意の SQL 句の場合は HasDefaultValueSql:

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

ヒント

次に示すコードは、DefaultConstraintSample.cs から取得されます。

EF でこれを利用するには、列の値を送信するタイミングと送信しないタイミングを決定する必要があります。 既定では、EF はこのための Sentinel として CLR の既定値を使用します。 つまり、上記の例の Status または LeaseDate の値がこれらの型の 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 個割り当てられます。 ただし、これは、0 が CLR の既定値であるため、クレジットが 0 のレコードを挿入できないことを意味します。そのため、EF から値は送信されません。 EF8 では、プロパティの Sentinel を 0 から -1 に変更することで、これを修正できます。

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

これで、EF は、Credits-1 に設定されている場合にのみ、データベースの既定値を使用するようになり、値 0 が他の容量と同様に挿入されます。

多くの場合、これをエンティティ型だけでなく、EF 構成にも反映すると便利です。 次に例を示します。

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

これは、Sentinel 値 -1 は、インスタンス作成時に自動的に設定されることを意味します。つまり、プロパティは "未設定" 状態で開始されるということです。

ヒント

Migrations が列を作成するときに使用するデータベースの既定の制約を構成しますが、EF で常に値を挿入したい場合は、プロパティが生成されないように構成してください。 たとえば、b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever(); のようにします。

ブール値のデータベースの既定値

CLR の既定値 (false) は 2 つの有効な値のうちの 1 つであるため、ブール値のプロパティは、この問題の極端な形式を示します。 つまり、データベースの既定の制約を持つ bool プロパティには、その値が true の場合にのみ値が挿入されます。 データベースの既定値が false の場合は、プロパティ値が false の場合、データベースの既定値 (つまり、false) が使用されることを意味します。 それ以外の場合、プロパティ値が true の場合は、true が挿入されます。 そのため、データベースの既定値が false の場合、データベースの列は正しい値になります。

一方、データベースの既定値が true の場合、つまり、プロパティ値が false の場合は、データベースの既定値 (つまり、true) が使用されます。 プロパティ値が true の場合、true が挿入されます。 そのため、プロパティ値が何であるかに関係なく、列の値は常にデータベース内で true で終了します。

EF8 では、bool プロパティの Sentinel をデータベースの既定値と同じ値に設定することで、この問題を修正します。 上記のいずれの場合も、データベースの既定値が truefalse かに関係なく、正しい値が挿入されます。

ヒント

既存のデータベースからスキャフォールディングする場合、EF8 は解析し、単純な既定値を HasDefaultValue 呼び出しに含めます。 (以前は、すべての既定値があいまいな HasDefaultValueSql 呼び出しとしてスキャフォールディングされていました)。つまり、true または false の定数データベースの既定値を持つ null 非許容の bool 列は null 許容としてスキャフォールディングされなくなるということです。

列挙型のデータベースの既定値

列挙型のプロパティは、列挙型が通常、非常に小さな有効な値のセットを持ち、CLR の既定値はこれらの値のいずれかである可能性があるため、bool プロパティと同様の問題が発生することがあります。 たとえば、次のようなエンティティ型と列挙型について考えます。

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

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

Level プロパティは、次のようにデータベースの既定値を使用して構成されます。

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

この構成では、EFは値が Level.Beginner に設定されている場合、データベースへの値の送信を除外し、代わりに Level.Intermediate がデータベースによって割り当てられます。 これは意図したものではありません。

この問題は、次のように列挙型にデータベースの既定値である "unknown" または "unspecified" 値が定義されている場合、問題は発生しませんでした。

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

しかし、既存の列挙型を変更できるとは限らないため、EF8 では、Sentinel をもう一度指定できます。 たとえば、次のように元の列挙型に戻ります。

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

これで、Level.Beginner は通常どおりに挿入され、データベースの既定値はプロパティ値が Level.Unspecified のときにのみ使用されます。 これをエンティティ型自体に反映すると、また役に立ちます。 次に例を示します。

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

Null 許容バッキング フィールドの使用

上記の問題を処理するより一般的な方法は、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;
    }
}

"プロパティ セッターが実際に呼び出されない限り"、ここでのバッキング フィールドには null が保持されます。 つまり、バッキング フィールドの値は、プロパティの CLR の既定値よりも、プロパティが設定されているかどうかを適切に示します。 EF ではバッキング フィールドを使用して、既定でプロパティの読み取りと書き込みを行うので、これは EF ですぐに動作します。

より良い ExecuteUpdate と ExecuteDelete

更新や削除を実行する SQL コマンド (ExecuteUpdateExecuteDelete メソッドで生成されたコマンドなど) は、単一のデータベース テーブルをターゲットとする必要があります。 しかし、EF7 では、"クエリが最終的に 1 つのテーブルに影響を与えた場合でも"、ExecuteUpdateExecuteDelete は複数のエンティティ型へのアクセスの更新はサポートされていませんでした。 EF8 では、この制限が取り除かれています。 たとえば、CustomerInfo 所有型を持つ Customer エンティティ型について考えてみます。

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

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

これらのエンティティ型はどちらも Customers テーブルにマップされます。 しかし、EF7 では両方のエンティティ型を使用するため、次の一括更新は失敗します。

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

EF8 では、Azure SQL を使用する場合、これは次の SQL に変換されるようになりました。

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

同様に、Union クエリから返されるインスタンスは、更新がすべて同じテーブルを対象としている限り更新できます。 たとえば、France のリージョンを含む任意の Customer を更新することができ、同時に、そのリージョン France でストアにアクセスした任意の Customer を更新できます。

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

EF8 では、Azure SQL を使っていると、このクエリでは以下が生成されます。

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

最後の例として、EF8 では、ExecuteUpdate は更新されたすべてのプロパティが同じテーブルにマップされている限り、TPT 階層内のエンティティを更新するために使用できます。 たとえば、TPT を使用してマップされたこのエンティティ型について考えてみます。

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

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

EF8 では、Note プロパティを更新できます。

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

または、Name プロパティを更新できます。

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

ただし、EF8 では、NameNote が異なるテーブルにマップされているため、これらをどちらも更新しようすると失敗します。 次に例を示します。

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

次の例外をスローします。

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

IN クエリをうまく活用する

Contains LINQ 演算子をサブクエリと共に使用すると、EF Core では、EXISTS の代わりに SQL IN を使用してより優れたクエリが生成されるようになりました。より読み取りやすい SQL を生成するだけでなく、場合によっては、クエリが大幅に高速化される可能性があります。 たとえば、次のような LINQ クエリについて考えてみます。

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

EF7 では、PostgreSQL 用に以下が生成されます。

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

サブクエリは外部の Blogs テーブルを参照するため (b."Id" 経由)、これは "相関サブクエリ" になります。つまり、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 を参照しなくなったため、この評価は 1 回で済み、ほとんどのデータベース システムでパフォーマンスが大幅に向上します。 ただし、一部のデータベース システム (特に SQL Server) では、パフォーマンスが同じになるように、データベースは最初のクエリを 2 番目のクエリに最適化することができます。

SQL Azure または SQL Server の数値行バージョン

SQL Server の自動オプティミスティック同時実行制御は、rowversionを使用して処理されます。 rowversion は、データベース、クライアント、サーバーの間で渡される 8 バイトのあいまいな値です。 既定では、SqlClient は、変更可能な参照型が rowversion セマンティクスに対して不適切な一致であるにもかかわらず、rowversion 型を byte[] として公開します。 EF8 では、代わりに rowversion 列を long または ulong プロパティにマップするのが簡単です。 次に例を示します。

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

かっこの削除

読み取り可能な SQL の生成は、EF Core にとって重要な目標です。 EF8 では、不要なかっこは自動的に削除されるため、生成される SQL はさらに読みやすくなります。 たとえば、次の LINQ クエリは、

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

EF7 を使っている場合は、次の Azure SQL に変換されます。

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

EF8 を使っている場合は、次のように改善されます。

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

RETURNING/OUTPUT 句の特定のオプトアウト

EF7 は、データベースで生成された列のフェッチに RETURNING/OUTPUT を使用するために、既定の更新 SQL を変更しました。 これが機能しない場合も確認されているため、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 には多くの小さな変更が加えられています。 これには、次のものが含まれます。