Bagikan melalui


Yang Baru di EF Core 8

EF Core 8.0 (EF8) dirilis pada November 2023.

Tip

Anda dapat menjalankan dan men-debug ke dalam sampel dengan mengunduh kode sampel dari GitHub. Setiap bagian menautkan ke kode sumber khusus untuk bagian tersebut.

EF8 memerlukan .NET 8 SDK untuk membangun dan memerlukan runtime .NET 8 untuk dijalankan. EF8 tidak akan berjalan pada versi .NET sebelumnya, dan tidak akan berjalan pada .NET Framework.

Objek nilai menggunakan Jenis Kompleks

Objek yang disimpan ke database dapat dibagi menjadi tiga kategori luas:

  • Objek yang tidak terstruktur dan menyimpan satu nilai. Misalnya, int, Guid, string, IPAddress. Ini (agak longgar) yang disebut "jenis primitif".
  • Objek yang disusun untuk menyimpan beberapa nilai, dan di mana identitas objek ditentukan oleh nilai kunci. Misalnya, Blog, Post, Customer. Ini disebut "jenis entitas".
  • Objek yang disusun untuk menyimpan beberapa nilai, tetapi objek tidak memiliki kunci yang menentukan identitas. Misalnya, Address, Coordinate.

Sebelum EF8, tidak ada cara yang baik untuk memetakan jenis objek ketiga. Jenis yang dimiliki dapat digunakan, tetapi karena jenis yang dimiliki sebenarnya adalah jenis entitas, mereka memiliki semantik berdasarkan nilai kunci, bahkan ketika nilai kunci tersebut disembunyikan.

EF8 sekarang mendukung "Jenis Kompleks" untuk mencakup jenis objek ketiga ini. Objek jenis kompleks:

  • Tidak diidentifikasi atau dilacak oleh nilai kunci.
  • Harus didefinisikan sebagai bagian dari jenis entitas. (Dengan kata lain, Anda tidak dapat memiliki DbSet tipe kompleks.)
  • Dapat berupa jenis nilai .NET atau jenis referensi.
  • Instans dapat dibagikan oleh beberapa properti.

Contoh sederhana

Misalnya, pertimbangkan Address jenis:

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 kemudian digunakan di tiga tempat dalam model pelanggan/pesanan sederhana:

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

Mari kita buat dan simpan pelanggan dengan alamat mereka:

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

Ini menghasilkan baris berikut yang disisipkan ke dalam database:

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

Perhatikan bahwa jenis kompleks tidak mendapatkan tabel mereka sendiri. Sebagai gantinya, tabel disimpan sebaris ke kolom Customers tabel. Ini cocok dengan perilaku berbagi tabel dari jenis yang dimiliki.

Catatan

Kami tidak berencana untuk mengizinkan jenis kompleks dipetakan ke tabel mereka sendiri. Namun, dalam rilis mendatang, kami berencana untuk mengizinkan jenis kompleks disimpan sebagai dokumen JSON dalam satu kolom. Pilih Masalah #31252 jika ini penting bagi Anda.

Sekarang katakanlah kita ingin mengirim pesanan ke pelanggan dan menggunakan alamat pelanggan sebagai penagihan default alamat pengiriman. Cara alami untuk melakukan ini adalah dengan menyalin Address objek dari ke Customer dalam Order. Contohnya:

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

await context.SaveChangesAsync();

Dengan jenis kompleks, ini berfungsi seperti yang diharapkan, dan alamat disisipkan ke Orders dalam tabel:

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

Sejauh ini Anda mungkin mengatakan, "tetapi saya bisa melakukan ini dengan jenis yang dimiliki!" Namun, semantik "jenis entitas" dari jenis yang dimiliki dengan cepat menghalangi. Misalnya, menjalankan kode di atas dengan jenis yang dimiliki menghasilkan banyak peringatan dan kemudian kesalahan:

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

Ini karena satu instans jenis Address entitas (dengan nilai kunci tersembunyi yang sama) digunakan untuk tiga instans entitas yang berbeda . Di sisi lain, berbagi instans yang sama antara properti kompleks diizinkan, sehingga kode berfungsi seperti yang diharapkan saat menggunakan jenis kompleks.

Konfigurasi jenis kompleks

Jenis kompleks harus dikonfigurasi dalam model menggunakan atribut pemetaan atau dengan memanggilComplexProperty API di OnModelCreating. Jenis kompleks tidak ditemukan oleh konvensi.

Misalnya, Address jenis dapat dikonfigurasi menggunakan 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; }
}

Atau di 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);
    });
}

Mutabilitas

Dalam contoh di atas, kami berakhir dengan instans yang sama yang Address digunakan di tiga tempat. Ini diizinkan dan tidak menyebabkan masalah apa pun untuk EF Core saat menggunakan jenis kompleks. Namun, berbagi instans dengan jenis referensi yang sama berarti bahwa jika nilai properti pada instans dimodifikasi, perubahan tersebut akan tercermin dalam ketiga penggunaan. Misalnya, mengikuti dari atas, mari kita ubah Line1 alamat pelanggan dan simpan perubahan:

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

Ini menghasilkan pembaruan berikut ke database saat menggunakan 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;

Perhatikan bahwa ketiga Line1 kolom telah berubah, karena semuanya berbagi instans yang sama. Ini biasanya tidak seperti yang kita inginkan.

Tip

Jika alamat pesanan harus berubah secara otomatis saat alamat pelanggan berubah, maka pertimbangkan untuk memetakan alamat sebagai jenis entitas. Order dan Customer kemudian dapat dengan aman mereferensikan instans alamat yang sama (yang sekarang diidentifikasi oleh kunci) melalui properti navigasi.

Cara yang baik untuk menangani masalah seperti ini adalah dengan membuat jenis tidak dapat diubah. Memang, kekekalan ini sering kali wajar ketika jenis adalah kandidat yang baik untuk menjadi jenis yang kompleks. Misalnya, biasanya masuk akal untuk menyediakan objek baru Address yang kompleks daripada hanya bermutasi, katakanlah, negara sambil membiarkan sisanya sama.

Jenis referensi dan nilai dapat dibuat tidak dapat diubah. Kita akan melihat beberapa contoh di bagian berikut.

Jenis referensi sebagai jenis kompleks

Kelas yang tidak dapat diubah

Kami menggunakan yang sederhana dan dapat class diubah dalam contoh di atas. Untuk mencegah masalah dengan mutasi yang tidak disengaja yang dijelaskan di atas, kita dapat membuat kelas tidak dapat diubah. Contohnya:

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

Tip

Dengan C# 12 atau lebih tinggi, definisi kelas ini dapat disederhanakan menggunakan konstruktor utama:

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

Sekarang tidak dimungkinkan Line1 untuk mengubah nilai pada alamat yang ada. Sebagai gantinya, kita perlu membuat instans baru dengan nilai yang diubah. Contohnya:

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

await context.SaveChangesAsync();

Kali ini panggilan untuk SaveChangesAsync hanya memperbarui alamat pelanggan:

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

Perhatikan bahwa meskipun objek Alamat tidak dapat diubah, dan seluruh objek telah diubah, EF masih melacak perubahan pada properti individual, jadi hanya kolom dengan nilai yang diubah yang diperbarui.

Rekaman yang tidak dapat diubah

C# 9 memperkenalkan jenis rekaman, yang membuat pembuatan dan penggunaan objek yang tidak dapat diubah lebih mudah. Misalnya, Address objek dapat dibuat sebagai jenis rekaman:

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

Tip

Definisi rekaman ini dapat disederhanakan menggunakan konstruktor utama:

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

Mengganti objek yang dapat diubah dan memanggil SaveChanges sekarang memerlukan lebih sedikit kode:

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

await context.SaveChangesAsync();

Jenis nilai sebagai jenis kompleks

Struktur yang dapat diubah

Jenis nilai yang dapat diubah sederhana dapat digunakan sebagai jenis kompleks. Misalnya, Address dapat didefinisikan sebagai struct dalam C#:

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

Menetapkan objek pelanggan Address ke properti pengiriman dan penagihan Address menghasilkan setiap properti mendapatkan salinan Address, karena ini adalah cara kerja jenis nilai. Ini berarti bahwa memodifikasi Address pada pelanggan tidak akan mengubah instans pengiriman atau penagihan Address , sehingga struktur yang dapat diubah tidak memiliki masalah berbagi instans yang sama yang terjadi dengan kelas yang dapat diubah.

Namun, struktur yang dapat diubah umumnya tidak dianjurkan dalam C#, jadi pikirkan dengan sangat hati-hati sebelum menggunakannya.

Struktur yang tidak dapat diubah

Struktur yang tidak dapat diubah berfungsi dengan baik seperti jenis kompleks, seperti halnya kelas yang tidak dapat diubah. Misalnya, Address dapat didefinisikan sih sehingga tidak dapat dimodifikasi:

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

Kode untuk mengubah alamat sekarang terlihat sama seperti saat menggunakan kelas yang tidak dapat diubah:

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

await context.SaveChangesAsync();

Rekaman struct yang tidak dapat diubah

Jenis yang diperkenalkan struct record C# 10, yang memudahkan untuk membuat dan bekerja dengan catatan struct yang tidak dapat diubah seperti itu dengan catatan kelas yang tidak dapat diubah. Misalnya, kita dapat mendefinisikan Address sebagai rekaman struct yang tidak dapat diubah:

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

Kode untuk mengubah alamat sekarang terlihat sama seperti saat menggunakan rekaman kelas yang tidak dapat diubah:

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

await context.SaveChangesAsync();

Jenis kompleks berlapis

Jenis kompleks dapat berisi properti dari jenis kompleks lainnya. Misalnya, mari kita gunakan jenis kompleks kita Address dari atas bersama dengan PhoneNumber jenis kompleks, dan sarangkan keduanya di dalam jenis kompleks lain:

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

Kami menggunakan catatan yang tidak dapat diubah di sini, karena ini adalah kecocokan yang baik untuk semantik jenis kompleks kami, tetapi bersarangnya jenis kompleks dapat dilakukan dengan rasa apa pun dari jenis .NET.

Catatan

Kami tidak menggunakan konstruktor utama untuk jenis tersebut Contact karena EF Core belum mendukung injeksi konstruktor dari nilai jenis kompleks. Pilih Masalah #31621 jika ini penting bagi Anda.

Kami akan menambahkan Contact sebagai properti dari 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();
}

Dan PhoneNumber sebagai properti dari 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!;
}

Konfigurasi jenis kompleks berlapis dapat kembali dicapai menggunakan 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; }
}

Atau di 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);
        });
}

Kueri

Properti jenis kompleks pada jenis entitas diperlakukan seperti properti non-navigasi lainnya dari jenis entitas. Ini berarti bahwa mereka selalu dimuat ketika jenis entitas dimuat. Ini juga berlaku untuk setiap properti jenis kompleks berlapis. Misalnya, mengkueri untuk pelanggan:

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

Diterjemahkan ke SQL berikut saat menggunakan SQL Server:

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

Perhatikan dua hal dari SQL ini:

  • Semuanya dikembalikan untuk mengisi pelanggan dan semua jenis berlapis Contact, Address, dan PhoneNumber kompleks.
  • Semua nilai jenis kompleks disimpan sebagai kolom dalam tabel untuk jenis entitas. Jenis kompleks tidak pernah dipetakan ke tabel terpisah.

Proyeksi

Jenis kompleks dapat diproyeksikan dari kueri. Misalnya, memilih hanya alamat pengiriman dari pesanan:

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

Ini diterjemahkan ke yang berikut ini saat menggunakan 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

Perhatikan bahwa proyeksi jenis kompleks tidak dapat dilacak, karena objek jenis kompleks tidak memiliki identitas untuk digunakan untuk pelacakan.

Gunakan dalam predikat

Anggota jenis kompleks dapat digunakan dalam predikat. Misalnya, menemukan semua pesanan yang masuk ke kota tertentu:

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

Yang diterjemahkan ke SQL berikut di SQL Server:

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

Instans jenis kompleks penuh juga dapat digunakan dalam predikat. Misalnya, menemukan semua pelanggan dengan nomor telepon tertentu:

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

Ini diterjemahkan ke SQL berikut saat menggunakan SQL Server:

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)

Perhatikan bahwa kesetaraan dilakukan dengan memperluas setiap anggota dari jenis kompleks. Ini selaras dengan jenis kompleks yang tidak memiliki kunci untuk identitas dan karenanya instans jenis kompleks sama dengan instans jenis kompleks lainnya jika dan hanya jika semua anggota mereka sama. Ini juga selaras dengan kesetaraan yang ditentukan oleh .NET untuk jenis rekaman.

Manipulasi nilai jenis kompleks

EF8 menyediakan akses ke informasi pelacakan seperti nilai saat ini dan asli dari jenis kompleks dan apakah nilai properti telah dimodifikasi atau belum. Jenis kompleks API adalah ekstensi dari API pelacakan perubahan yang sudah digunakan untuk jenis entitas.

Metode ComplexProperty EntityEntry mengembalikan entri untuk seluruh objek kompleks. Misalnya, untuk mendapatkan nilai saat ini dari Order.BillingAddress:

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

Panggilan ke Property dapat ditambahkan untuk mengakses properti jenis kompleks. Misalnya untuk mendapatkan nilai saat ini hanya dari kode posting penagihan:

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

Jenis kompleks berlapis diakses menggunakan panggilan berlapis ke ComplexProperty. Misalnya, untuk mendapatkan kota dari bersarang Address Contact pada Customer:

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

Metode lain tersedia untuk membaca dan mengubah status. Misalnya, PropertyEntry.IsModified dapat digunakan untuk mengatur properti dari jenis kompleks sebagai dimodifikasi:

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

Batasan saat ini

Jenis kompleks mewakili investasi yang signifikan di seluruh tumpukan EF. Kami tidak dapat membuat semuanya berfungsi dalam rilis ini, tetapi kami berencana untuk menutup beberapa celah dalam rilis mendatang. Pastikan untuk memilih (👍) pada masalah GitHub yang sesuai jika memperbaiki salah satu batasan ini penting bagi Anda.

Batasan jenis kompleks di EF8 meliputi:

Koleksi primitif

Pertanyaan persisten saat menggunakan database relasional adalah apa yang harus dilakukan dengan kumpulan jenis primitif; yaitu, daftar atau array bilangan bulat, tanggal/waktu, string, dan sebagainya. Jika Anda menggunakan PostgreSQL, maka mudah untuk menyimpan hal-hal ini menggunakan jenis array bawaan PostgreSQL. Untuk database lain, ada dua pendekatan umum:

  • Buat tabel dengan kolom untuk nilai jenis primitif dan kolom lain untuk bertindak sebagai kunci asing yang menautkan setiap nilai ke pemilik koleksinya.
  • Serialisasi koleksi primitif ke dalam beberapa jenis kolom yang ditangani oleh database--misalnya, serialisasi ke dan dari string.

Opsi pertama memiliki keuntungan dalam banyak situasi --kita akan melihat sekilas di akhir bagian ini. Namun, ini bukan representasi alami dari data dalam model, dan jika apa yang Benar-benar Anda miliki adalah kumpulan jenis primitif, maka opsi kedua bisa lebih efektif.

Dimulai dengan Pratinjau 4, EF8 sekarang menyertakan dukungan bawaan untuk opsi kedua, menggunakan JSON sebagai format serialisasi. JSON bekerja dengan baik untuk ini karena database relasional modern mencakup mekanisme bawaan untuk mengkueri dan memanipulasi JSON, sehingga kolom JSON dapat, secara efektif, diperlakukan sebagai tabel ketika diperlukan, tanpa overhead benar-benar membuat tabel itu. Mekanisme yang sama ini memungkinkan JSON untuk diteruskan dalam parameter dan kemudian digunakan dengan cara yang sama dengan parameter bernilai tabel dalam kueri--lebih lanjut tentang ini nanti.

Tip

Kode yang ditampilkan di sini berasal dari PrimitiveCollectionsSample.cs.

Properti koleksi primitif

EF Core dapat memetakan properti apa pun IEnumerable<T> , di mana T adalah jenis primitif, ke kolom JSON dalam database. Ini dilakukan oleh konvensi untuk properti publik yang memiliki getter dan setter. Misalnya, semua properti dalam jenis entitas berikut dipetakan ke kolom JSON menurut konvensi:

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

Catatan

Apa yang kita maksud dengan "jenis primitif" dalam konteks ini? Pada dasarnya, sesuatu yang diketahui penyedia database cara memetakan, menggunakan semacam konversi nilai jika perlu. Misalnya, dalam jenis entitas di atas, jenis int, , stringDateTime, DateOnly dan bool semuanya ditangani tanpa konversi oleh penyedia database. SQL Server tidak memiliki dukungan asli untuk ints atau URI yang tidak ditandatangani, tetapi uint dan Uri masih diperlakukan sebagai jenis primitif karena ada pengonversi nilai bawaan untuk jenis ini.

Secara default, EF Core menggunakan jenis kolom string Unicode yang tidak dibatasi untuk menahan JSON, karena ini melindungi dari kehilangan data dengan koleksi besar. Namun, pada beberapa sistem database, seperti SQL Server, menentukan panjang maksimum untuk string dapat meningkatkan performa. Ini, bersama dengan konfigurasi kolom lainnya, dapat dilakukan dengan cara normal. Contohnya:

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

Atau, menggunakan atribut pemetaan:

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

Konfigurasi kolom default dapat digunakan untuk semua properti dari jenis tertentu menggunakan konfigurasi model pra-konvensi. Contohnya:

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

Kueri dengan koleksi primitif

Mari kita lihat beberapa kueri yang memanfaatkan koleksi jenis primitif. Untuk ini, kita akan memerlukan model sederhana dengan dua jenis entitas. Yang pertama mewakili rumah publik Inggris, atau "pub":

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

Jenisnya Pub berisi dua koleksi primitif:

  • Beers adalah array string yang mewakili merek bir yang tersedia di pub.
  • DaysVisited adalah daftar tanggal di mana pub dikunjungi.

Tip

Dalam aplikasi nyata, mungkin akan lebih masuk akal untuk membuat jenis entitas untuk bir, dan memiliki tabel untuk bir. Kami menampilkan koleksi primitif di sini untuk menggambarkan cara kerjanya. Tapi ingat, hanya karena Anda dapat memodelkan sesuatu sebagai koleksi primitif tidak berarti bahwa Anda harus melakukannya.

Jenis entitas kedua mewakili perjalanan anjing di pedesaan Inggris:

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

Seperti Pub, DogWalk juga berisi koleksi tanggal yang dikunjungi, dan tautan ke pub terdekat karena, Anda tahu, kadang-kadang anjing membutuhkan saus bir setelah berjalan-jalan.

Menggunakan model ini, kueri pertama yang akan kita lakukan adalah kueri sederhana Contains untuk menemukan semua panduan dengan salah satu dari beberapa medan yang berbeda:

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

Ini sudah diterjemahkan oleh versi EF Core saat ini dengan menginlining nilai yang akan dicari. Misalnya, saat menggunakan SQL Server:

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

Namun, strategi ini tidak berfungsi dengan baik dengan penembolokan kueri database; lihat Mengumumkan Pratinjau EF8 4 di Blog .NET untuk diskusi tentang masalah ini.

Penting

Inlining nilai di sini dilakukan sed sehingga tidak ada kemungkinan serangan injeksi SQL. Perubahan untuk menggunakan JSON yang dijelaskan di bawah ini adalah tentang performa, dan tidak ada hubungannya dengan keamanan.

Untuk EF Core 8, defaultnya sekarang adalah meneruskan daftar medan sebagai parameter tunggal yang berisi koleksi JSON. Contohnya:

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

Kueri kemudian menggunakan OpenJson di 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])

Atau json_each di SQLite:

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

Catatan

OpenJson hanya tersedia di SQL Server 2016 (tingkat kompatibilitas 130) dan yang lebih baru. Anda dapat memberi tahu SQL Server bahwa Anda menggunakan versi yang lebih lama dengan mengonfigurasi tingkat kompatibilitas sebagai bagian UseSqlServerdari . Contohnya:

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

Mari kita coba jenis Contains kueri yang berbeda. Dalam hal ini, kita akan mencari nilai koleksi parameter di kolom . Misalnya, pub apa pun yang menimbun Heineken:

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

Dokumentasi yang ada dari Apa yang Baru di EF7 menyediakan informasi terperinci tentang pemetaan, kueri, dan pembaruan JSON. Dokumentasi ini sekarang juga berlaku untuk SQLite.

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

OpenJson sekarang digunakan untuk mengekstrak nilai dari kolom JSON sehingga setiap nilai dapat dicocokkan dengan parameter yang diteruskan.

Kita dapat menggabungkan penggunaan OpenJson pada parameter dengan OpenJson pada kolom . Misalnya, untuk menemukan pub yang menimbun salah satu dari berbagai lager:

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

Ini diterjemahkan ke yang berikut ini di 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)))

Nilai @__beers_0 parameter di sini adalah ["Carling","Heineken","Stella Artois","Carlsberg"].

Mari kita lihat kueri yang menggunakan kolom yang berisi kumpulan tanggal. Misalnya, untuk menemukan pub yang dikunjungi tahun ini:

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

Ini diterjemahkan ke yang berikut ini di 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)

Perhatikan bahwa kueri menggunakan fungsi DATEPART khusus tanggal di sini karena EF tahu bahwa koleksi primitif berisi tanggal. Mungkin tidak tampak seperti itu, tetapi ini sebenarnya benar-benar penting. Karena EF tahu apa yang ada dalam koleksi, EF dapat menghasilkan SQL yang sesuai untuk menggunakan nilai yang diketik dengan parameter, fungsi, kolom lain, dll.

Mari kita gunakan koleksi tanggal lagi, kali ini untuk memesan dengan tepat untuk jenis dan nilai proyek yang diekstrak dari koleksi. Misalnya, mari kita daftar pub dalam urutan bahwa mereka pertama kali dikunjungi, dan dengan tanggal pertama dan terakhir setiap pub dikunjungi:

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

Ini diterjemahkan ke yang berikut ini di 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))

Dan akhirnya, seberapa sering kita akhirnya mengunjungi pub terdekat ketika mengajak anjing berjalan-jalan? Mari kita cari tahu:

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

Ini diterjemahkan ke yang berikut ini di 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]

Dan mengungkapkan data berikut:

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.

Sepertinya bir dan anjing berjalan adalah kombinasi yang menang!

Koleksi primitif dalam dokumen JSON

Dalam semua contoh di atas, kolom untuk koleksi primitif berisi JSON. Namun, ini tidak sama dengan memetakan jenis entitas yang dimiliki ke kolom yang berisi dokumen JSON, yang diperkenalkan di EF7. Tapi bagaimana jika dokumen JSON itu sendiri berisi koleksi primitif? Nah, semua kueri di atas masih berfungsi dengan cara yang sama! Misalnya, bayangkan kita memindahkan hari-hari data yang dikunjungi ke dalam jenis Visits milik yang dipetakan ke dokumen JSON:

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

Tip

Kode yang ditampilkan di sini berasal dari PrimitiveCollectionsInJsonSample.cs.

Kita sekarang dapat menjalankan variasi kueri akhir kita yang, kali ini, mengekstrak data dari dokumen JSON, termasuk kueri ke dalam koleksi primitif yang terkandung dalam dokumen:

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

Ini diterjemahkan ke yang berikut ini di 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]

Dan untuk kueri serupa saat menggunakan 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"

Tip

Perhatikan bahwa pada SQLite EF Core sekarang memanfaatkan ->> operator, menghasilkan kueri yang lebih mudah dibaca dan sering kali lebih berkinerja.

Memetakan koleksi primitif ke tabel

Kami menyebutkan di atas bahwa opsi lain untuk koleksi primitif adalah memetakannya ke tabel yang berbeda. Dukungan kelas satu untuk ini dilacak oleh Masalah #25163; pastikan untuk memilih masalah ini jika penting bagi Anda. Sampai ini diimplementasikan, pendekatan terbaik adalah membuat jenis pembungkusan untuk primitif. Misalnya, mari kita buat jenis untuk Beer:

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

    public string Name { get; private set; }
}

Perhatikan bahwa jenis hanya membungkus nilai primitif --tidak memiliki kunci primer atau kunci asing apa pun yang ditentukan. Jenis ini kemudian dapat digunakan di Pub kelas :

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 sekarang akan membuat Beer tabel, mensintesis kunci primer dan kolom kunci asing kembali ke Pubs tabel. Misalnya, di 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

Penyempurnaan pemetaan kolom JSON

EF8 mencakup penyempurnaan dukungan pemetaan kolom JSON yang diperkenalkan di EF7.

Tip

Kode yang ditampilkan di sini berasal dari JsonColumnsSample.cs.

Menerjemahkan akses elemen ke dalam array JSON

EF8 mendukung pengindeksan dalam array JSON saat menjalankan kueri. Misalnya, kueri berikut memeriksa apakah dua pembaruan pertama dibuat sebelum tanggal tertentu.

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

Ini diterjemahkan ke dalam SQL berikut saat menggunakan SQL Server:

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

Catatan

Kueri ini akan berhasil meskipun postingan tertentu tidak memiliki pembaruan apa pun, atau hanya memiliki satu pembaruan. Dalam kasus seperti itu, JSON_VALUE pengembalian NULL dan predikat tidak cocok.

Pengindeksan ke dalam array JSON juga dapat digunakan untuk memproyeksikan elemen dari array ke hasil akhir. Misalnya, kueri berikut memproyeksikan UpdatedOn tanggal untuk pembaruan pertama dan kedua dari setiap postingan.

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

Ini diterjemahkan ke dalam SQL berikut saat menggunakan SQL Server:

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]

Seperti disebutkan di atas, JSON_VALUE mengembalikan null jika elemen array tidak ada. Ini ditangani dalam kueri dengan mentransmisikan nilai yang diproyeksikan ke nullable DateOnly. Alternatif untuk transmisi nilai adalah memfilter hasil kueri sehingga JSON_VALUE tidak akan pernah mengembalikan null. Contohnya:

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

Ini diterjemahkan ke dalam SQL berikut saat menggunakan SQL Server:

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)

Menerjemahkan kueri ke dalam koleksi yang disematkan

EF8 mendukung kueri terhadap koleksi jenis primitif (dibahas di atas) dan non-primitif yang disematkan dalam dokumen JSON. Misalnya, kueri berikut mengembalikan semua postingan dengan salah satu daftar istilah pencarian arbitrer:

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

Ini diterjemahkan ke dalam SQL berikut saat menggunakan SQL Server:

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

Kolom JSON untuk SQLite

EF7 memperkenalkan dukungan untuk pemetaan ke kolom JSON saat menggunakan Azure SQL/SQL Server. EF8 memperluas dukungan ini ke database SQLite. Adapun dukungan SQL Server, ini termasuk:

  • Pemetaan agregat yang dibangun dari jenis .NET ke dokumen JSON yang disimpan dalam kolom SQLite
  • Kueri ke dalam kolom JSON, seperti pemfilteran dan pengurutan menurut elemen dokumen
  • Kueri yang memproyeksikan elemen dari dokumen JSON menjadi hasil
  • Memperbarui dan menyimpan perubahan pada dokumen JSON

Dokumentasi yang ada dari Apa yang Baru di EF7 menyediakan informasi terperinci tentang pemetaan, kueri, dan pembaruan JSON. Dokumentasi ini sekarang juga berlaku untuk SQLite.

Tip

Kode yang ditampilkan dalam dokumentasi EF7 telah diperbarui untuk juga dijalankan di SQLite dapat ditemukan di JsonColumnsSample.cs.

Kueri ke dalam kolom JSON

Kueri ke dalam kolom JSON di SQLite menggunakan fungsi .json_extract Misalnya, kueri "penulis di Chigley" dari dokumentasi yang dirujuk di atas:

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

Diterjemahkan ke SQL berikut saat menggunakan SQLite:

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

Memperbarui kolom JSON

Untuk pembaruan, EF menggunakan json_set fungsi pada SQLite. Misalnya, saat memperbarui satu properti dalam dokumen:

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

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

await context.SaveChangesAsync();

EF menghasilkan parameter berikut:

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

Yang menggunakan json_set fungsi pada SQLite:

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

HierarchyId di .NET dan EF Core

Azure SQL dan SQL Server memiliki jenis data khusus yang disebut hierarchyid yang digunakan untuk menyimpan data hierarkis. Dalam hal ini, "data hierarkis" pada dasarnya berarti data yang membentuk struktur pohon, di mana setiap item dapat memiliki induk dan/atau anak. Contoh data tersebut adalah:

  • Struktur organisasi
  • Sistem file
  • Sekumpulan tugas dalam proyek
  • Taksonomi istilah bahasa
  • Grafik tautan antar halaman Web

Database kemudian dapat menjalankan kueri terhadap data ini menggunakan struktur hierarkisnya. Misalnya, kueri dapat menemukan leluhur dan dependen item tertentu, atau menemukan semua item pada kedalaman tertentu dalam hierarki.

Dukungan di .NET dan EF Core

Dukungan resmi untuk jenis SQL Server hierarchyid baru-baru ini datang ke platform .NET modern (yaitu ".NET Core"). Dukungan ini dalam bentuk paket NuGet Microsoft.SqlServer.Types , yang membawa jenis khusus SQL Server tingkat rendah. Dalam hal ini, jenis tingkat rendah disebut SqlHierarchyId.

Pada tingkat berikutnya, paket Microsoft.EntityFrameworkCore.SqlServer.Abstractions baru telah diperkenalkan, yang mencakup jenis tingkat HierarchyId yang lebih tinggi yang dimaksudkan untuk digunakan dalam jenis entitas.

Tip

Jenis HierarchyId ini lebih idiomatik dengan norma .NET daripada SqlHierarchyId, yang sebaliknya dimodelkan setelah bagaimana jenis .NET Framework dihosting di dalam mesin database SQL Server. HierarchyId dirancang untuk bekerja dengan EF Core, tetapi juga dapat digunakan di luar EF Core di aplikasi lain. Paket Microsoft.EntityFrameworkCore.SqlServer.Abstractions ini tidak mereferensikan paket lain, sehingga berdampak minimal pada ukuran dan dependensi aplikasi yang disebarkan.

Penggunaan HierarchyId untuk fungsionalitas EF Core seperti kueri dan pembaruan memerlukan paket Microsoft.EntityFrameworkCore.SqlServer.HierarchyId . Paket ini membawa dan Microsoft.EntityFrameworkCore.SqlServer.Abstractions Microsoft.SqlServer.Types sebagai dependensi transitif, dan begitu juga seringkali satu-satunya paket yang diperlukan. Setelah paket diinstal, penggunaan HierarchyId diaktifkan dengan memanggil UseHierarchyId sebagai bagian dari panggilan aplikasi ke UseSqlServer. Contoh:

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

Catatan

Dukungan tidak resmi untuk hierarchyid di EF Core telah tersedia selama bertahun-tahun melalui paket EntityFrameworkCore.SqlServer.HierarchyId . Paket ini telah dipertahankan sebagai kolaborasi antara komunitas dan tim EF. Sekarang setelah ada dukungan resmi untuk hierarchyid di .NET, kode dari formulir paket komunitas ini, dengan izin kontributor asli, dasar untuk paket resmi yang dijelaskan di sini. Banyak terima kasih kepada semua orang yang terlibat selama bertahun-tahun, termasuk @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas, dan @vyrotek

Hierarki pemodelan

Jenis HierarchyId dapat digunakan untuk properti jenis entitas. Misalnya, asumsikan kita ingin memodelkan pohon keluarga paternal dari beberapa paruh fiksi. Dalam jenis entitas untuk Halfling, HierarchyId properti dapat digunakan untuk menemukan setiap setengah di pohon keluarga.

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

Tip

Kode yang ditunjukkan di sini dan dalam contoh di bawah ini berasal dari HierarchyIdSample.cs.

Tip

Jika diinginkan, HierarchyId cocok untuk digunakan sebagai jenis properti kunci.

Dalam hal ini, pohon keluarga berakar pada patriarki keluarga. Setiap halfling dapat dilacak dari patriarki di bawah pohon menggunakan propertinya PathFromPatriarch . SQL Server menggunakan format biner yang ringkas untuk jalur ini, tetapi umum untuk mengurai ke dan dari representasi string yang dapat dibaca manusia saat bekerja dengan kode. Dalam representasi ini, posisi di setiap tingkat dipisahkan oleh / karakter. Misalnya, pertimbangkan pohon keluarga dalam diagram di bawah ini:

Pohon keluarga halfling

Di pohon ini:

  • Balbo berada di akar pohon, diwakili oleh /.
  • Balbo memiliki lima anak, yang diwakili oleh /1/, , /2//3/, /4/, dan /5/.
  • Anak pertama Balbo, Mungo, juga memiliki lima anak, yang diwakili oleh /1/1/, , /1/2//1/3/, /1/4/, dan /1/5/. Perhatikan bahwa HierarchyId untuk Balbo (/1/) adalah awalan untuk semua anak-anaknya.
  • Demikian pula, anak ketiga Balbo, Ponto, memiliki dua anak, diwakili oleh /3/1/ dan /3/2/. Sekali lagi masing-masing anak ini diawali oleh HierarchyId untuk Ponto, yang diwakili sebagai /3/.
  • Dan sebagainya di bawah pohon ...

Kode berikut menyisipkan pohon keluarga ini ke dalam database menggunakan 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();

Tip

Jika diperlukan, nilai desimal dapat digunakan untuk membuat simpul baru di antara dua simpul yang ada. Misalnya, /3/2.5/2/ berjalan antara /3/2/2/ dan /3/3/2/.

Mengkueri hierarki

HierarchyId mengekspos beberapa metode yang dapat digunakan dalam kueri LINQ.

Metode Deskripsi
GetAncestor(int n) Mendapatkan tingkat simpul n ke atas pohon hierarkis.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Mendapatkan nilai node turunan yang lebih besar dari child1 dan kurang dari child2.
GetLevel() Mendapatkan tingkat simpul ini di pohon hierarkis.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Mendapatkan nilai yang mewakili lokasi simpul baru yang memiliki jalur dari newRoot sama dengan jalur dari oldRoot ke ini, secara efektif memindahkan ini ke lokasi baru.
IsDescendantOf(HierarchyId? parent) Mendapatkan nilai yang menunjukkan apakah simpul ini adalah turunan dari parent.

Selain itu, operator ==, , !=<, <=, > dan >= dapat digunakan.

Berikut ini adalah contoh penggunaan metode ini dalam kueri LINQ.

Mendapatkan entitas pada tingkat tertentu di pohon

Kueri berikut menggunakan GetLevel untuk mengembalikan semua halfling pada tingkat tertentu di pohon keluarga:

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

Ini diterjemahkan ke SQL berikut:

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

Menjalankan ini dalam perulangan kita bisa mendapatkan halfling untuk setiap generasi:

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

Mendapatkan leluhur langsung entitas

Kueri berikut menggunakan GetAncestor untuk menemukan leluhur langsung dari halfling, mengingat nama halfling tersebut:

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

Ini diterjemahkan ke SQL berikut:

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)

Menjalankan kueri ini untuk halfling "Bilbo" mengembalikan "Bungo".

Mendapatkan turunan langsung entitas

Kueri berikut juga menggunakan GetAncestor, tetapi kali ini untuk menemukan keturunan langsung dari halfling, mengingat nama halfling itu:

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

Ini diterjemahkan ke SQL berikut:

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)

Menjalankan kueri ini untuk halfling "Mungo" mengembalikan "Bungo", "Belba", "Longo", dan "Linda".

Mendapatkan semua leluhur entitas

GetAncestor berguna untuk mencari ke atas atau ke bawah satu tingkat, atau, memang, jumlah tingkat yang ditentukan. Di sisi lain, IsDescendantOf berguna untuk menemukan semua leluhur atau dependen. Misalnya, kueri berikut menggunakan IsDescendantOf untuk menemukan semua leluhur dari halfling, mengingat nama halfling itu:

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

Penting

IsDescendantOf mengembalikan true untuk dirinya sendiri, itulah sebabnya difilter dalam kueri di atas.

Ini diterjemahkan ke SQL berikut:

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

Menjalankan kueri ini untuk halfling "Bilbo" mengembalikan "Bungo", "Mungo", dan "Balbo".

Mendapatkan semua keturunan entitas

Kueri berikut juga menggunakan IsDescendantOf, tetapi kali ini untuk semua keturunan dari halfling, mengingat nama halfling itu:

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

Ini diterjemahkan ke SQL berikut:

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

Menjalankan kueri ini untuk halfling "Mungo" mengembalikan "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho", dan "Poppy".

Menemukan leluhur umum

Salah satu pertanyaan paling umum yang diajukan tentang pohon keluarga khusus ini adalah, "siapa nenek moyang umum Frodo dan Bilbo?" Kita dapat menggunakan IsDescendantOf untuk menulis kueri seperti itu:

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

Ini diterjemahkan ke SQL berikut:

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

Menjalankan kueri ini dengan "Bilbo" dan "Frodo" memberi tahu kita bahwa leluhur umum mereka adalah "Balbo".

Memperbarui hierarki

Mekanisme pelacakan perubahan normal dan SaveChanges dapat digunakan untuk memperbarui hierarchyid kolom.

Mengasuh ulang sub-hierarki

Misalnya, saya yakin kita semua ingat skandal SR 1752 (alias "LongoGate") ketika pengujian DNA mengungkapkan bahwa Longo sebenarnya bukan putra Mungo, tetapi sebenarnya putra Ponto! Satu fallout dari skandal ini adalah bahwa pohon keluarga perlu ditulis ulang. Secara khusus, Longo dan semua keturunannya perlu diasuh kembali dari Mungo ke Ponto. GetReparentedValue dapat digunakan untuk melakukan ini. Misalnya, "Longo" pertama dan semua keturunannya dikueri:

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

Kemudian GetReparentedValue digunakan untuk memperbarui HierarchyId untuk Longo dan setiap keturunan, diikuti dengan panggilan ke SaveChangesAsync:

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

await context.SaveChangesAsync();

Ini menghasilkan pembaruan database berikut:

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;

Menggunakan parameter ini:

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

Catatan

Nilai parameter untuk HierarchyId properti dikirim ke database dalam format biner yang ringkas.

Setelah pembaruan, mengkueri keturunan "Mungo" mengembalikan "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco", dan "Poppy", sementara mengkueri keturunan "Ponto" mengembalikan "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony", dan "Angelica".

Kueri SQL mentah untuk jenis yang tidak dipetakan

EF7 memperkenalkan kueri SQL mentah yang mengembalikan jenis skalar. Ini ditingkatkan dalam EF8 untuk menyertakan kueri SQL mentah yang mengembalikan jenis CLR yang dapat dipetakan, tanpa menyertakan jenis tersebut dalam model EF.

Tip

Kode yang ditampilkan di sini berasal dari RawSqlSample.cs.

Kueri yang menggunakan jenis yang tidak dipetakan dijalankan menggunakan SqlQuery atau SqlQueryRaw. Yang pertama menggunakan interpolasi string untuk membuat parameter kueri, yang membantu memastikan bahwa semua nilai non-konstanta diparameterkan. Misalnya, pertimbangkan tabel database berikut:

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 dapat digunakan untuk mengkueri tabel ini dan mengembalikan instans jenis dengan properti yang BlogPost sesuai dengan kolom dalam tabel:

Contohnya:

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

Contohnya:

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

Kueri ini diparameterkan dan dijalankan sebagai:

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

Jenis yang digunakan untuk hasil kueri dapat berisi konstruksi pemetaan umum yang didukung oleh EF Core, seperti konstruktor berparameter dan atribut pemetaan. Contoh:

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

Catatan

Jenis yang digunakan dengan cara ini tidak memiliki kunci yang ditentukan dan tidak dapat memiliki hubungan dengan jenis lain. Jenis dengan hubungan harus dipetakan dalam model.

Jenis yang digunakan harus memiliki properti untuk setiap nilai dalam tataan hasil, tetapi tidak perlu mencocokkan tabel apa pun dalam database. Misalnya, jenis berikut hanya mewakili subset informasi untuk setiap postingan, dan menyertakan nama blog, yang berasal dari Blogs tabel:

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

Dan dapat dikueri menggunakan SqlQuery dengan cara yang sama seperti sebelumnya:


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

Salah satu fitur SqlQuery yang bagus adalah bahwa ia mengembalikan yang IQueryable dapat terdiri dari menggunakan LINQ. Misalnya, klausa 'Di mana' dapat ditambahkan ke kueri di atas:

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

Ini dijalankan sebagai:

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

Pada titik ini perlu diingat bahwa semua hal di atas dapat dilakukan sepenuhnya di LINQ tanpa perlu menulis SQL apa pun. Ini termasuk mengembalikan instans dari jenis yang tidak dipetakan seperti PostSummary. Misalnya, kueri sebelumnya dapat ditulis dalam LINQ sebagai:

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

Yang diterjemahkan ke SQL yang jauh lebih bersih:

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

Tip

EF dapat menghasilkan SQL yang lebih bersih ketika bertanggung jawab atas seluruh kueri daripada saat menyusun SQL yang disediakan pengguna karena, dalam kasus sebelumnya, semantik lengkap kueri tersedia untuk EF.

Sejauh ini, semua kueri telah dijalankan langsung terhadap tabel. SqlQuery juga dapat digunakan untuk mengembalikan hasil dari tampilan tanpa memetakan jenis tampilan dalam model EF. Contohnya:

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

Demikian juga, SqlQuery dapat digunakan untuk hasil fungsi:

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

Yang dikembalikan IQueryable dapat terdiri ketika merupakan hasil dari tampilan atau fungsi, sama seperti hasil kueri tabel. Prosedur tersimpan juga dapat dijalankan menggunakan SqlQuery, tetapi sebagian besar database tidak mendukung pembuatannya. Contohnya:

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

Penyempurnaan untuk pemuatan malas

Pemuatan malas untuk kueri tanpa pelacakan

EF8 menambahkan dukungan untuk pemuatan navigasi yang malas pada entitas yang tidak dilacak oleh DbContext. Ini berarti kueri tanpa pelacakan dapat diikuti dengan pemuatan navigasi yang malas pada entitas yang dikembalikan oleh kueri tanpa pelacakan.

Tip

Kode untuk contoh pemuatan malas yang ditunjukkan di bawah ini berasal dari LazyLoadingSample.cs.

Misalnya, pertimbangkan kueri tanpa pelacakan untuk blog:

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

Jika Blog.Posts dikonfigurasi untuk pemuatan malas, misalnya, menggunakan proksi pemuatan malas, maka mengakses Posts akan menyebabkannya dimuat dari database:

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 juga melaporkan apakah navigasi tertentu dimuat untuk entitas yang tidak dilacak oleh konteks atau tidak. Contohnya:

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

Ada beberapa pertimbangan penting saat menggunakan pemuatan malas dengan cara ini:

  • Pemuatan malas hanya akan berhasil sampai DbContext digunakan untuk mengkueri entitas dibuang.
  • Entitas yang dikueri dengan cara ini mempertahankan referensi ke mereka DbContext, meskipun tidak dilacak olehnya. Perawatan harus dilakukan untuk menghindari kebocoran memori jika instans entitas akan memiliki masa pakai yang lama.
  • Secara eksplisit melepaskan entitas dengan mengatur statusnya untuk EntityState.Detached memutuskan referensi ke DbContext pemuatan malas dan tidak akan berfungsi lagi.
  • Ingatlah bahwa semua pemuatan malas menggunakan I/O sinkron, karena tidak ada cara untuk mengakses properti dengan cara asinkron.

Pemuatan malas dari entitas yang tidak terlacak berfungsi untuk proksi pemuatan malas dan pemuatan malas tanpa proksi.

Pemuatan eksplisit dari entitas yang tidak terlacak

EF8 mendukung pemuatan navigasi pada entitas yang tidak terlacak bahkan ketika entitas atau navigasi tidak dikonfigurasi untuk pemuatan malas. Tidak seperti pemuatan malas, pemuatan eksplisit ini dapat dilakukan secara asinkron. Contohnya:

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

Menolak pemuatan malas untuk navigasi tertentu

EF8 memungkinkan konfigurasi navigasi tertentu untuk tidak malas memuat, bahkan ketika segala sesuatu yang lain disiapkan untuk melakukannya. Misalnya, untuk mengonfigurasi Post.Author navigasi agar tidak malas memuat, lakukan hal berikut:

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

Menonaktifkan Pemuatan Malas seperti ini berfungsi untuk proksi pemuatan malas dan pemuatan malas tanpa proksi.

Proksi pemuatan malas berfungsi dengan mengambil alih properti navigasi virtual. Dalam aplikasi EF6 klasik, sumber bug umum lupa membuat navigasi virtual, karena navigasi kemudian akan diam-diam tidak malas-beban. Oleh karena itu, proksi EF Core dilemparkan secara default ketika navigasi tidak virtual.

Ini dapat diubah di EF8 untuk ikut serta dalam perilaku EF6 klasik sehingga navigasi dapat dilakukan untuk tidak malas-beban hanya dengan membuat navigasi non-virtual. Keikutsertaan ini dikonfigurasi sebagai bagian dari panggilan ke UseLazyLoadingProxies. Contohnya:

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

Akses ke entitas terlacak

Cari entitas yang dilacak berdasarkan kunci utama, alternatif, atau asing

Secara internal, EF mempertahankan struktur data untuk menemukan entitas yang dilacak berdasarkan kunci primer, alternatif, atau asing. Struktur data ini digunakan untuk perbaikan yang efisien antara entitas terkait ketika entitas baru dilacak atau hubungan berubah.

EF8 berisi API publik baru sehingga aplikasi sekarang dapat menggunakan struktur data ini untuk mencari entitas yang dilacak secara efisien. API ini diakses melalui LocalView<TEntity> jenis entitas. Misalnya, untuk mencari entitas terlacak dengan kunci utamanya:

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

Tip

Kode yang ditampilkan di sini berasal dari LookupByKeySample.cs.

Metode mengembalikan FindEntry EntityEntry<TEntity> untuk entitas yang dilacak, atau null jika tidak ada entitas dengan kunci yang diberikan yang sedang dilacak. Seperti semua metode pada LocalView, database tidak pernah dikueri, bahkan jika entitas tidak ditemukan. Entri yang dikembalikan berisi entitas itu sendiri, serta informasi pelacakan. Contohnya:

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

Mencari entitas dengan apa pun selain kunci primer mengharuskan nama properti ditentukan. Misalnya, untuk mencari dengan kunci alternatif:

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

Atau untuk mencari dengan kunci asing yang unik:

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

Sejauh ini, pencarian selalu mengembalikan satu entri, atau null. Namun, beberapa pencarian dapat mengembalikan lebih dari satu entri, seperti saat mencari oleh kunci asing yang tidak unik. Metode GetEntries harus digunakan untuk pencarian ini. Contohnya:

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

Dalam semua kasus ini, nilai yang digunakan untuk pencarian adalah kunci primer, kunci alternatif, atau nilai kunci asing. EF menggunakan struktur data internalnya untuk pencarian ini. Namun, pencarian berdasarkan nilai juga dapat digunakan untuk nilai properti atau kombinasi properti apa pun. Misalnya, untuk menemukan semua postingan yang diarsipkan:

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

Pencarian ini memerlukan pemindaian semua instans yang dilacak Post , sehingga akan kurang efisien daripada pencarian utama. Namun, biasanya masih lebih cepat daripada kueri naif menggunakan ChangeTracker.Entries<TEntity>().

Akhirnya, dimungkinkan juga untuk melakukan pencarian terhadap kunci komposit, kombinasi lain dari beberapa properti, atau ketika jenis properti tidak diketahui pada waktu kompilasi. Contohnya:

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

Bangunan model

Kolom diskriminator memiliki panjang maksimal

Di EF8, kolom diskriminator string yang digunakan untuk pemetaan warisan TPH sekarang dikonfigurasi dengan panjang maksimum. Panjang ini dihitung sebagai angka Fibonacci terkecil yang mencakup semua nilai diskriminator yang ditentukan. Misalnya, pertimbangkan hierarki berikut:

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

Dengan konvensi penggunaan nama kelas untuk nilai diskriminator, nilai yang mungkin di sini adalah "PaperbackEdition", "HardbackEdition", dan "Magazine", dan karenanya kolom diskriminator dikonfigurasi untuk panjang maksimum 21. Misalnya, saat menggunakan 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]),

Tip

Angka fibonacci digunakan untuk membatasi berapa kali migrasi dihasilkan untuk mengubah panjang kolom saat jenis baru ditambahkan ke hierarki.

DateOnly/TimeOnly didukung di SQL Server

Jenis DateOnly dan TimeOnly diperkenalkan di .NET 6 dan telah didukung untuk beberapa penyedia database (misalnya SQLite, MySQL, dan PostgreSQL) sejak pengenalan mereka. Untuk SQL Server, rilis terbaru paket Microsoft.Data.SqlClient yang menargetkan .NET 6 telah memungkinkan ErikEJ untuk menambahkan dukungan untuk jenis ini di tingkat ADO.NET. Ini pada gilirannya membuka jalan untuk dukungan di EF8 untuk DateOnly dan TimeOnly sebagai properti dalam jenis entitas.

Tip

DateOnly dan TimeOnly dapat digunakan dalam EF Core 6 dan 7 menggunakan paket komunitas ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly dari @ErikEJ.

Misalnya, pertimbangkan model EF berikut untuk sekolah-sekolah Inggris:

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

Tip

Kode yang ditampilkan di sini berasal dari DateOnlyTimeOnlySample.cs.

Catatan

Model ini hanya mewakili sekolah inggris dan menyimpan waktu sebagai waktu lokal (GMT). Menangani zona waktu yang berbeda akan mempersulit kode ini secara signifikan. Perhatikan bahwa penggunaan DateTimeOffset tidak akan membantu di sini, karena waktu buka dan tutup memiliki offset yang berbeda tergantung apakah waktu musim panas aktif atau tidak.

Jenis entitas ini memetakan ke tabel berikut saat menggunakan SQL Server. Perhatikan bahwa properti memetakan DateOnly ke date kolom, dan properti memetakan TimeOnly ke time kolom.

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

Kueri menggunakan DateOnly dan TimeOnly bekerja dengan cara yang diharapkan. Misalnya, kueri LINQ berikut menemukan sekolah yang saat ini terbuka:

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

Kueri ini diterjemahkan ke SQL berikut, seperti yang ditunjukkan oleh 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]

DateOnly dan TimeOnly juga dapat digunakan dalam kolom JSON. Misalnya, OpeningHours dapat disimpan sebagai dokumen JSON, menghasilkan data yang terlihat seperti ini:

Kolom Nilai
Id 2
Nama SMA Farr
Didirikan 1964-05-01
OpeningHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Selasa", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Rabu", "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 }
]

Menggabungkan dua fitur dari EF8, kita sekarang dapat meminta jam buka dengan mengindeks ke dalam koleksi JSON. Contohnya:

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

Kueri ini diterjemahkan ke SQL berikut, seperti yang ditunjukkan oleh 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

Terakhir, pembaruan dan penghapusan dapat dilakukan dengan pelacakan dan SaveChanges, atau menggunakan ExecuteUpdate/ExecuteDelete. Contohnya:

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

Pembaruan ini diterjemahkan ke SQL berikut:

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)

Reverse engineer Synapse dan Dynamics 365 TDS

Rekayasa terbalik EF8 (alias perancah dari database yang ada) sekarang mendukung Synapse Serverless SQL Pool dan Dynamics 365 database Titik Akhir TDS.

Peringatan

Sistem database ini memiliki perbedaan dari database SQL Server dan Azure SQL normal. Perbedaan ini berarti bahwa tidak semua fungsionalitas EF Core didukung saat menulis kueri terhadap atau melakukan operasi lain dengan sistem database ini.

Penyempurnaan terjemahan Matematika

Antarmuka matematika generik diperkenalkan di .NET 7. Jenis konkret seperti double dan float menerapkan antarmuka ini yang menambahkan API baru yang mencerminkan fungsionalitas Matematika dan MathF yang ada.

EF Core 8 menerjemahkan panggilan ke API matematika generik ini di LINQ menggunakan terjemahan SQL penyedia yang ada untuk Math dan MathF. Ini berarti Anda sekarang bebas memilih antara panggilan baik seperti Math.Sin atau double.Sin dalam kueri EF Anda.

Kami bekerja dengan tim .NET untuk menambahkan dua metode matematika generik baru di .NET 8 yang diimplementasikan pada double dan float. Ini juga diterjemahkan ke SQL di EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Terakhir, kami bekerja dengan Eric Sink dalam proyek SQLitePCLRaw untuk mengaktifkan fungsi matematika SQLite dalam build pustaka SQLite asli. Ini termasuk pustaka asli yang Anda dapatkan secara default saat Anda menginstal penyedia EF Core SQLite. Ini memungkinkan beberapa terjemahan SQL baru di LINQ termasuk: Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh, dan Truncate.

Memeriksa perubahan model yang tertunda

Kami telah menambahkan perintah baru dotnet ef untuk memeriksa apakah ada perubahan model yang telah dilakukan sejak migrasi terakhir. Ini dapat berguna dalam skenario CI/CD untuk memastikan Anda atau rekan satu tim tidak lupa menambahkan migrasi.

dotnet ef migrations has-pending-model-changes

Anda juga dapat melakukan pemeriksaan ini secara terprogram dalam aplikasi atau pengujian Anda menggunakan metode baru dbContext.Database.HasPendingModelChanges() .

Penyempurnaan perancah SQLite

SQLite hanya mendukung empat jenis data primitif--INTEGER, REAL, TEXT, dan BLOB. Sebelumnya, ini berarti bahwa ketika Anda merekayasa balik database SQLite untuk merancang model EF Core, jenis entitas yang dihasilkan hanya akan menyertakan properti jenis long, , doublestring, dan byte[]. Jenis .NET tambahan didukung oleh penyedia EF Core SQLite dengan mengonversi di antara mereka dan salah satu dari empat jenis SQLite primitif.

Di EF Core 8, kita sekarang menggunakan format data dan nama jenis kolom selain jenis SQLite untuk menentukan jenis .NET yang lebih sesuai untuk digunakan dalam model. Tabel berikut menunjukkan beberapa kasus di mana informasi tambahan mengarah ke jenis properti yang lebih baik dalam model.

Nama jenis kolom Jenis .NET
BOOLEAN byte[]bool
SMALLINT pendek panjang
INT int panjang
BIGINT long
STRING byte[]string
Format data Jenis .NET
'0.0' desimal string
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' Rentang Waktu string
'00000000-0000-0000-0000-000000000000' Guid string

Nilai Sentinel dan default database

Database memungkinkan kolom dikonfigurasi untuk menghasilkan nilai default jika tidak ada nilai yang disediakan saat menyisipkan baris. Ini dapat diwakili dalam EF menggunakan HasDefaultValue untuk konstanta:

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

Atau HasDefaultValueSql untuk klausul SQL arbitrer:

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

Tip

Kode yang ditunjukkan di bawah ini berasal dari DefaultConstraintSample.cs.

Agar EF dapat menggunakan ini, EF harus menentukan kapan dan kapan tidak mengirim nilai untuk kolom. Secara default, EF menggunakan default CLR sebagai sentinel untuk ini. Artinya, ketika nilai Status atau LeaseDate dalam contoh di atas adalah default CLR untuk jenis ini, maka EF menginterpretasikan bahwa berarti bahwa properti belum ditetapkan, sehingga tidak mengirim nilai ke database. Ini berfungsi dengan baik untuk jenis referensi--misalnya, jika string properti Status adalah null, maka EF tidak mengirim null ke database, melainkan tidak menyertakan nilai apa pun sehingga default database ("Hidden") digunakan. Demikian juga, untuk DateTime properti LeaseDate, EF tidak akan menyisipkan nilai default CLR dari 1/1/0001 12:00:00 AM, tetapi akan menghilangkan nilai ini sehingga default database digunakan.

Namun, dalam beberapa kasus, nilai default CLR adalah nilai yang valid untuk disisipkan. EF8 menangani ini dengan memungkinkan nilai sentinel untuk kolom berubah. Misalnya, pertimbangkan kolom bilangan bulat yang dikonfigurasi dengan default database:

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

Dalam hal ini, kami ingin entitas baru dimasukkan dengan jumlah kredit yang diberikan, kecuali ini tidak ditentukan, dalam hal ini 10 kredit ditetapkan. Namun, ini berarti bahwa memasukkan rekaman dengan kredit nol tidak dimungkinkan, karena nol adalah default CLR, dan karenanya akan menyebabkan EF tidak mengirim nilai. Di EF8, ini dapat diperbaiki dengan mengubah sentinel untuk properti dari nol menjadi -1:

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

EF sekarang hanya akan menggunakan default database jika Credits diatur ke -1; nilai nol akan dimasukkan seperti jumlah lainnya.

Seringkali dapat berguna untuk mencerminkan ini dalam jenis entitas serta dalam konfigurasi EF. Contohnya:

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

Ini berarti bahwa nilai sentinel -1 diatur secara otomatis ketika instans dibuat, yang berarti bahwa properti dimulai dalam status "tidak ditetapkan".

Tip

Jika Anda ingin mengonfigurasi batasan default database untuk digunakan saat Migrations membuat kolom, tetapi Anda ingin EF selalu menyisipkan nilai, maka konfigurasikan properti sebagai tidak dihasilkan. Contohnya,b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Default database untuk boolean

Properti Boolean menyajikan bentuk ekstrem dari masalah ini, karena default CLR (false) adalah salah satu dari hanya dua nilai yang valid. Ini berarti bahwa bool properti dengan batasan default database hanya akan memiliki nilai yang disisipkan jika nilai tersebut adalah true. Ketika nilai default database adalah false, maka ini berarti ketika nilai properti adalah false, maka default database akan digunakan, yaitu false. Jika tidak, jika nilai properti adalah true, maka true akan disisipkan. Jadi, ketika default database adalah false, maka kolom database berakhir dengan nilai yang benar.

Di sisi lain, jika nilai default database adalah true, ini berarti ketika nilai properti adalah false, maka default database akan digunakan, yaitu true! Dan ketika nilai properti adalah true, maka true akan disisipkan. Jadi, nilai dalam kolom akan selalu berakhir true dalam database, terlepas dari apa nilai propertinya.

EF8 memperbaiki masalah ini dengan mengatur sentinel untuk properti bool ke nilai yang sama dengan nilai default database. Kedua kasus di atas kemudian mengakibatkan nilai yang benar dimasukkan, terlepas dari apakah default database adalah true atau false.

Tip

Saat perancah dari database yang ada, EF8 mengurai lalu menyertakan nilai default sederhana ke dalam HasDefaultValue panggilan. (Sebelumnya, semua nilai default di-scaffolding sebagai panggilan buram HasDefaultValueSql .) Ini berarti bahwa kolom bool yang tidak dapat diubah ke null dengan true default database atau false konstanta tidak lagi di-scaffolded sebagai nullable.

Default database untuk enum

Properti Enum dapat memiliki masalah bool serupa dengan properti karena enum biasanya memiliki sekumpulan nilai yang valid yang sangat kecil, dan default CLR mungkin salah satu nilai ini. Misalnya, pertimbangkan jenis entitas dan enum ini:

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

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

Properti Level kemudian dikonfigurasi dengan default database:

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

Dengan konfigurasi ini, EF akan mengecualikan pengiriman nilai ke database saat diatur ke Level.Beginner, dan sebagai gantinya Level.Intermediate ditetapkan oleh database. Ini bukan apa yang dimaksudkan!

Masalah tidak akan terjadi jika enum telah ditentukan dengan nilai "tidak diketahui" atau "tidak ditentukan" menjadi default database:

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

Namun, tidak selalu mungkin untuk mengubah enum yang ada, jadi di EF8, sentinel dapat kembali ditentukan. Misalnya, kembali ke enum asli:

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

Sekarang Level.Beginner akan disisipkan seperti biasa, dan default database hanya akan digunakan ketika nilai properti adalah Level.Unspecified. Ini dapat kembali berguna untuk mencerminkan ini dalam jenis entitas itu sendiri. Contohnya:

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

Menggunakan bidang backing nullable

Cara yang lebih umum untuk menangani masalah yang dijelaskan di atas adalah dengan membuat bidang dukungan nullable untuk properti yang tidak dapat diubah ke null. Misalnya, pertimbangkan jenis entitas berikut dengan bool properti:

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

Properti dapat diberikan bidang dukungan nullable:

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

    private bool? _isActive;

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

Bidang backing di sini akan tetap null ada kecuali setter properti benar-benar dipanggil. Artinya, nilai bidang pencadangan adalah indikasi yang lebih baik tentang apakah properti telah ditetapkan atau tidak daripada default CLR properti. Ini berfungsi secara langsung dengan EF, karena EF akan menggunakan bidang pencadangan untuk membaca dan menulis properti secara default.

ExecuteUpdate dan ExecuteDelete yang Lebih Baik

Perintah SQL yang melakukan pembaruan dan penghapusan, seperti yang dihasilkan oleh ExecuteUpdate metode dan ExecuteDelete , harus menargetkan satu tabel database. Namun, di EF7, ExecuteUpdate dan ExecuteDelete tidak mendukung pembaruan yang mengakses beberapa jenis entitas bahkan ketika kueri pada akhirnya memengaruhi satu tabel. EF8 menghapus batasan ini. Misalnya, pertimbangkan Customer jenis entitas dengan CustomerInfo jenis yang dimiliki:

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

Kedua jenis entitas ini memetakan ke Customers tabel. Namun, pembaruan massal berikut gagal pada EF7 karena menggunakan kedua jenis entitas:

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

Di EF8, ini sekarang diterjemahkan ke SQL berikut saat menggunakan Azure SQL:

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

Demikian pula, instans yang Union dikembalikan dari kueri dapat diperbarui selama pembaruan semua menargetkan tabel yang sama. Misalnya, kita dapat memperbarui apa pun Customer dengan wilayah France, dan pada saat yang sama, siapa pun Customer yang telah mengunjungi toko dengan wilayah 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"));

Di EF8, kueri ini menghasilkan hal berikut saat menggunakan 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]

Sebagai contoh terakhir, di EF8, ExecuteUpdate dapat digunakan untuk memperbarui entitas dalam hierarki TPT selama semua properti yang diperbarui dipetakan ke tabel yang sama. Misalnya, pertimbangkan jenis entitas ini yang dipetakan menggunakan 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; }
}

Dengan EF8, Note properti dapat diperbarui:

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

Name Atau properti dapat diperbarui:

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

Namun, EF8 gagal mencoba memperbarui Name properti dan Note karena dipetakan ke tabel yang berbeda. Contohnya:

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

Melemparkan pengecualian berikut:

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.

Penggunaan kueri yang IN lebih baik

Contains Ketika operator LINQ digunakan dengan subkueri, EF Core sekarang menghasilkan kueri yang lebih baik menggunakan SQL IN alih-alih EXISTS; selain menghasilkan SQL yang lebih mudah dibaca, dalam beberapa kasus ini dapat menghasilkan kueri yang lebih cepat secara dramatis. Misalnya, pertimbangkan kueri LINQ berikut:

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

EF7 menghasilkan hal berikut untuk PostgreSQL:

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

Karena subkueri mereferensikan tabel eksternal Blogs (melalui b."Id"), ini adalah subkueri berkorelasi, yang berarti bahwa Posts subkueri harus dijalankan untuk setiap baris dalam Blogs tabel. Di EF8, SQL berikut dihasilkan sebagai gantinya:

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

Karena subkueri tidak lagi mereferensikan , subkueri Blogsdapat dievaluasi sekali, menghasilkan peningkatan performa besar-besaran pada sebagian besar sistem database. Namun, beberapa sistem database, terutama SQL Server, database dapat mengoptimalkan kueri pertama ke kueri kedua sehingga performanya sama.

Rowversion numerik untuk SQL Azure/SQL Server

Konkurensi optimis otomatis SQL Server ditangani menggunakan rowversion kolom. adalah rowversion nilai buram 8 byte yang diteruskan antara database, klien, dan server. Secara default, SqlClient mengekspos rowversion jenis sebagai byte[], meskipun jenis referensi yang dapat diubah menjadi kecocokan yang buruk untuk rowversion semantik. Di EF8, mudah untuk memetakan rowversion kolom ke long atau ulong properti. Contohnya:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .IsRowVersion();

Eliminasi tanda kurung

Menghasilkan SQL yang dapat dibaca adalah tujuan penting untuk EF Core. Dalam EF8, SQL yang dihasilkan lebih dapat dibaca melalui penghapusan otomatis kurung yang tidak diperlukan. Misalnya, kueri LINQ berikut:

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

Diterjemahkan ke Azure SQL berikut saat menggunakan EF7:

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)

Yang telah ditingkatkan menjadi berikut saat menggunakan 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

Penolakan khusus untuk klausul RETURNING/OUTPUT

EF7 mengubah SQL pembaruan default yang akan digunakan RETURNING/OUTPUT untuk mengambil kembali kolom yang dihasilkan database. Beberapa kasus di mana diidentifikasi di mana ini tidak berfungsi, sehingga EF8 memperkenalkan penolakan eksplisit untuk perilaku ini.

Misalnya, untuk menolak OUTPUT saat menggunakan penyedia SQL Server/Azure SQL:

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

Atau untuk menolak RETURNING saat menggunakan penyedia SQLite:

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

Perubahan kecil lainnya

Selain peningkatan yang dijelaskan di atas, ada banyak perubahan yang lebih kecil yang dilakukan pada EF8. Drive ini termasuk: