Novità di EF Core 8

EF Core 8.0 (EF8) è stato rilasciato a novembre 2023.

Suggerimento

È possibile eseguire ed eseguire il debug negli esempi scaricando il codice di esempio da GitHub. Ogni sezione è collegata al codice sorgente specifico di tale sezione.

EF8 richiede .NET 8 SDK per compilare e richiede l'esecuzione del runtime .NET 8. EF8 non verrà eseguito nelle versioni precedenti di .NET e non verrà eseguito in .NET Framework.

Oggetti Valore che usano tipi complessi

Gli oggetti salvati nel database possono essere suddivisi in tre categorie generali:

  • Oggetti non strutturati e che contengono un singolo valore. Ad esempio, int, Guid, string, IPAddress. Si tratta (in qualche modo distesa) denominati "tipi primitivi".
  • Oggetti strutturati per contenere più valori e dove l'identità dell'oggetto è definita da un valore di chiave. Ad esempio Blog, Post, Customer. Questi tipi sono denominati "tipi di entità".
  • Oggetti strutturati per contenere più valori, ma l'oggetto non ha una chiave che definisce l'identità. Ad esempio, Address, Coordinate.

Prima di EF8, non c'era un buon modo per eseguire il mapping del terzo tipo di oggetto. I tipi di proprietà possono essere usati, ma poiché i tipi di proprietà sono effettivamente tipi di entità, hanno semantica basata su un valore chiave, anche quando tale valore chiave è nascosto.

EF8 supporta ora "Tipi complessi" per coprire questo terzo tipo di oggetto. Oggetti di tipo complesso:

  • Non vengono identificati o rilevati in base al valore della chiave.
  • Deve essere definito come parte di un tipo di entità. In altre parole, non è possibile avere un DbSet tipo complesso.
  • Può essere un tipo valore .NET o un tipo riferimento.
  • Le istanze possono essere condivise da più proprietà.

Esempio semplice

Si consideri ad esempio un Address tipo:

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 viene quindi usato in tre posizioni in un modello semplice cliente/ordini:

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

Creare e salvare un cliente con il proprio indirizzo:

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

Ciò comporta l'inserimento della riga seguente nel 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);

Si noti che i tipi complessi non ottengono tabelle personalizzate. Vengono invece salvati inline in colonne della Customers tabella. Corrisponde al comportamento di condivisione delle tabelle dei tipi di proprietà.

Nota

Non si prevede di consentire il mapping di tipi complessi alla propria tabella. Tuttavia, in una versione futura, si prevede di consentire il salvataggio del tipo complesso come documento JSON in una singola colonna. Votare per il problema n. 31252 se questo è importante per te.

Si supponga ora di voler spedire un ordine a un cliente e di usare l'indirizzo del cliente sia come indirizzo di fatturazione predefinito. Il modo naturale per eseguire questa operazione consiste nel copiare l'oggetto Address da Customer in Order. Ad esempio:

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

await context.SaveChangesAsync();

Con i tipi complessi, funziona come previsto e l'indirizzo viene inserito nella Orders tabella:

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

Finora potresti dire: "Ma potrei farlo con i tipi di proprietà!" Tuttavia, la semantica "tipo di entità" dei tipi di proprietà si ottiene rapidamente. Ad esempio, l'esecuzione del codice precedente con i tipi di proprietà genera un'eccezione di avvisi e quindi un errore:

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

Ciò è dovuto al fatto che viene usata una singola istanza del tipo di Address entità (con lo stesso valore di chiave nascosta) per tre istanze di entità diverse . D'altra parte, la condivisione della stessa istanza tra proprietà complesse è consentita e quindi il codice funziona come previsto quando si usano tipi complessi.

Configurazione di tipi complessi

I tipi complessi devono essere configurati nel modello usando attributi di mapping o chiamando ComplexProperty l'API in OnModelCreating. I tipi complessi non vengono individuati per convenzione.

Ad esempio, il Address tipo può essere configurato usando :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; }
}

Oppure in 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);
    });
}

Modificabilità

Nell'esempio precedente si è conclusa con la stessa Address istanza usata in tre posizioni. Ciò è consentito e non causa problemi per EF Core quando si usano tipi complessi. Tuttavia, la condivisione di istanze dello stesso tipo di riferimento indica che se viene modificato un valore di proprietà nell'istanza di , tale modifica verrà riflessa in tutti e tre gli utilizzi. Ad esempio, seguendo da sopra, è possibile modificare Line1 l'indirizzo del cliente e salvare le modifiche:

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

Questo comporta l'aggiornamento seguente al database quando si usa 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;

Si noti che tutte e tre Line1 le colonne sono state modificate, poiché condividono tutte la stessa istanza. Questo di solito non è quello che vogliamo.

Suggerimento

Se gli indirizzi degli ordini devono cambiare automaticamente quando l'indirizzo del cliente cambia, è consigliabile eseguire il mapping dell'indirizzo come tipo di entità. Order e Customer possono quindi fare riferimento in modo sicuro alla stessa istanza dell'indirizzo (ora identificata da una chiave) tramite una proprietà di navigazione.

Un buon modo per gestire problemi come questo è rendere il tipo non modificabile. In effetti, questa immutabilità è spesso naturale quando un tipo è un buon candidato per essere un tipo complesso. Ad esempio, in genere ha senso fornire un nuovo Address oggetto complesso anziché semplicemente mutare, ad esempio, il paese lasciando il resto lo stesso.

I tipi reference e value possono essere resi non modificabili. Verranno esaminati alcuni esempi nelle sezioni seguenti.

Tipi di riferimento come tipi complessi

Classe non modificabile

Nell'esempio precedente è stata usata una semplice modificabile class . Per evitare i problemi relativi alla mutazione accidentale descritta in precedenza, è possibile rendere la classe non modificabile. Ad esempio:

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

Suggerimento

Con C# 12 o versione successiva, questa definizione di classe può essere semplificata usando un costruttore primario:

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

Non è ora possibile modificare il Line1 valore in un indirizzo esistente. È invece necessario creare una nuova istanza con il valore modificato. Ad esempio:

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

await context.SaveChangesAsync();

Questa volta la chiamata a SaveChangesAsync aggiorna solo l'indirizzo del cliente:

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

Si noti che anche se l'oggetto Address non è modificabile e l'intero oggetto è stato modificato, EF sta ancora monitorando le modifiche apportate alle singole proprietà, quindi vengono aggiornate solo le colonne con valori modificati.

Record non modificabile

In C# 9 sono stati introdotti tipi di record che semplificano la creazione e l'uso di oggetti non modificabili. Ad esempio, l'oggetto Address può essere reso un tipo di record:

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

Suggerimento

Questa definizione di record può essere semplificata usando un costruttore primario:

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

La sostituzione dell'oggetto modificabile e la chiamata SaveChanges ora richiede meno codice:

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

await context.SaveChangesAsync();

Tipi valore come tipi complessi

Struct modificabile

Un tipo di valore modificabile semplice può essere usato come tipo complesso. Ad esempio, Address può essere definito come in struct 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; }
}

L'assegnazione dell'oggetto cliente Address alle proprietà di spedizione e fatturazione Address comporta che ogni proprietà ottenga una copia di , poiché si tratta del funzionamento dei Addresstipi di valore. Ciò significa che la modifica di nel Address cliente non modificherà le istanze di spedizione o fatturazione Address , quindi gli struct modificabili non presentano gli stessi problemi di condivisione delle istanze che si verificano con le classi modificabili.

Tuttavia, gli struct modificabili sono in genere sconsigliati in C#, quindi pensa molto attentamente prima di usarli.

Struct non modificabile

Gli struct non modificabili funzionano bene come i tipi complessi, proprio come le classi non modificabili. Ad esempio, Address può essere definito in modo che non possa essere modificato:

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

Il codice per la modifica dell'indirizzo ha ora lo stesso aspetto dell'uso della classe non modificabile:

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

await context.SaveChangesAsync();

Record struct non modificabile

I tipi introdotti struct record in C# 10 semplificano la creazione e l'uso di record di struct non modificabili come i record di classe non modificabili. Ad esempio, è possibile definire Address come record di struct non modificabile:

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

Il codice per la modifica dell'indirizzo è ora identico a quando si usa un record di classe non modificabile:

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

await context.SaveChangesAsync();

Tipi complessi annidati

Un tipo complesso può contenere proprietà di altri tipi complessi. Ad esempio, si userà il Address tipo complesso precedente insieme a un PhoneNumber tipo complesso e annidiamoli entrambi all'interno di un altro tipo complesso:

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

In questo caso vengono usati record non modificabili, poiché si tratta di una buona corrispondenza per la semantica dei tipi complessi, ma l'annidamento di tipi complessi può essere eseguito con qualsiasi sapore di tipo .NET.

Nota

Non viene usato un costruttore primario per il Contact tipo perché EF Core non supporta ancora l'inserimento di costruttori di valori di tipo complessi. Votare per il problema n. 31621 se questo è importante per te.

Si aggiungerà Contact come proprietà di 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();
}

E PhoneNumber come proprietà di 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!;
}

È possibile ottenere di nuovo la configurazione dei tipi complessi annidati usando 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; }
}

Oppure in 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);
        });
}

Query

Le proprietà dei tipi complessi sui tipi di entità vengono considerate come qualsiasi altra proprietà non di navigazione del tipo di entità. Ciò significa che vengono sempre caricati quando viene caricato il tipo di entità. Questo vale anche per tutte le proprietà del tipo complesso annidate. Ad esempio, l'esecuzione di query per un cliente:

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

Viene convertito nel codice SQL seguente quando si usa 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

Si notino due elementi di questo SQL:

  • Viene restituito tutto per popolare il cliente e tutti i tipi complessi , e PhoneNumber annidatiContactAddress.
  • Tutti i valori di tipo complesso vengono archiviati come colonne nella tabella per il tipo di entità. I tipi complessi non vengono mai mappati a tabelle separate.

Proiezioni

I tipi complessi possono essere proiettati da una query. Ad esempio, selezionando solo l'indirizzo di spedizione da un ordine:

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

Ciò si traduce nel modo seguente quando si usa 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

Si noti che non è possibile tenere traccia delle proiezioni di tipi complessi, perché gli oggetti di tipo complesso non hanno identità da usare per il rilevamento.

Usare nei predicati

I membri di tipi complessi possono essere usati nei predicati. Ad esempio, trovare tutti gli ordini che passano a una determinata città:

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

Che si traduce nel codice SQL seguente in 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

Nei predicati può essere usata anche un'istanza di tipo complesso completa. Ad esempio, trovare tutti i clienti con un determinato numero di telefono:

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

Ciò si traduce nel codice SQL seguente quando si usa 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)

Si noti che l'uguaglianza viene eseguita espandendo ogni membro del tipo complesso. Questo è allineato ai tipi complessi senza chiave per l'identità e pertanto un'istanza di tipo complesso è uguale a un'altra istanza di tipo complesso se e solo se tutti i relativi membri sono uguali. Ciò si allinea anche all'uguaglianza definita da .NET per i tipi di record.

Manipolazione di valori di tipo complesso

EF8 fornisce l'accesso alle informazioni di rilevamento, ad esempio i valori correnti e originali di tipi complessi e se è stato modificato o meno un valore della proprietà. I tipi complessi di API sono un'estensione dell'API di rilevamento delle modifiche già usata per i tipi di entità.

I ComplexProperty metodi di EntityEntry restituiscono una voce per un intero oggetto complesso. Ad esempio, per ottenere il valore corrente di Order.BillingAddress:

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

È possibile aggiungere una chiamata a Property per accedere a una proprietà del tipo complesso. Ad esempio, per ottenere il valore corrente del solo codice postale di fatturazione:

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

È possibile accedere ai tipi complessi annidati usando chiamate annidate a ComplexProperty. Ad esempio, per ottenere la città dall'annidato Address di Contact in un Customeroggetto :

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

Altri metodi sono disponibili per la lettura e la modifica dello stato. Ad esempio, PropertyEntry.IsModified può essere usato per impostare una proprietà di un tipo complesso come modificato:

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

Limitazioni correnti

I tipi complessi rappresentano un investimento significativo nello stack ef. Non è stato possibile eseguire tutte le operazioni in questa versione, ma si prevede di chiudere alcune delle lacune in una versione futura. Assicurarsi di votare (👍) sui problemi di GitHub appropriati se la correzione di una di queste limitazioni è importante per l'utente.

Le limitazioni dei tipi complessi in EF8 includono:

  • Supportare raccolte di tipi complessi. (Problema 31237)
  • Consenti che le proprietà del tipo complesso siano null. (Problema 31376)
  • Eseguire il mapping delle proprietà dei tipi complessi alle colonne JSON. (Problema 31252)
  • Inserimento del costruttore per tipi complessi. (Problema 31621)
  • Aggiungere il supporto dei dati di inizializzazione per tipi complessi. (Numero 31254)
  • Eseguire il mapping delle proprietà dei tipi complessi per il provider Cosmos. (Problema 31253)
  • Implementare tipi complessi per il database in memoria. (Problema 31464)

Raccolte primitive

Una domanda persistente quando si usano database relazionali è cosa fare con le raccolte di tipi primitivi; ovvero elenchi o matrici di numeri interi, date/ore, stringhe e così via. Se si usa PostgreSQL, è facile archiviare questi elementi usando il tipo di matrice predefinito di PostgreSQL. Per altri database, esistono due approcci comuni:

  • Creare una tabella con una colonna per il valore del tipo primitivo e un'altra colonna da usare come chiave esterna che collega ogni valore al proprietario della raccolta.
  • Serializzare la raccolta primitiva in un tipo di colonna gestito dal database, ad esempio serializzare da e verso una stringa.

La prima opzione presenta vantaggi in molte situazioni: verrà esaminata rapidamente alla fine di questa sezione. Tuttavia, non è una rappresentazione naturale dei dati nel modello e se ciò che si ha effettivamente è una raccolta di un tipo primitivo, la seconda opzione può essere più efficace.

A partire dall'anteprima 4, EF8 include ora il supporto predefinito per la seconda opzione, usando JSON come formato di serializzazione. JSON funziona correttamente perché i database relazionali moderni includono meccanismi predefiniti per l'esecuzione di query e la modifica di JSON, in modo che la colonna JSON possa, in modo efficace, essere considerata come una tabella quando necessario, senza il sovraccarico della creazione effettiva di tale tabella. Questi stessi meccanismi consentono di passare JSON nei parametri e quindi usati in modo analogo ai parametri con valori di tabella nelle query. Più avanti.

Suggerimento

Il codice illustrato di seguito proviene da PrimitiveCollectionsSample.cs.

Proprietà della raccolta primitiva

EF Core può eseguire il mapping di qualsiasi IEnumerable<T> proprietà, dove T è un tipo primitivo, a una colonna JSON nel database. Questa operazione viene eseguita per convenzione per le proprietà pubbliche che hanno sia un getter che un setter. Ad esempio, tutte le proprietà nel tipo di entità seguente vengono mappate alle colonne JSON per convenzione:

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

Nota

Cosa si intende per "tipo primitivo" in questo contesto? Essenzialmente, un elemento che il provider di database sa eseguire il mapping, usando un tipo di conversione di valori, se necessario. Ad esempio, nel tipo di entità precedente, i tipi int, string, DateTimeDateOnly e bool vengono gestiti senza conversione dal provider di database. SQL Server non dispone del supporto nativo per gli URI o gli URI non firmati, ma uint vengono Uri comunque considerati come tipi primitivi perché sono disponibili convertitori di valori predefiniti per questi tipi.

Per impostazione predefinita, EF Core usa un tipo di colonna stringa Unicode non vincolato per contenere il codice JSON, poiché ciò protegge dalla perdita di dati con raccolte di grandi dimensioni. Tuttavia, in alcuni sistemi di database, ad esempio SQL Server, la specifica di una lunghezza massima per la stringa può migliorare le prestazioni. Questa operazione, insieme ad altre configurazioni di colonna, può essere eseguita normalmente. Ad esempio:

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

In alternativa, usando gli attributi di mapping:

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

È possibile usare una configurazione di colonna predefinita per tutte le proprietà di un determinato tipo usando la configurazione del modello pre-convenzione. Ad esempio:

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

Query con raccolte primitive

Verranno ora esaminate alcune query che usano raccolte di tipi primitivi. Per questo motivo, sarà necessario un modello semplice con due tipi di entità. Il primo rappresenta una casa pubblica britannica, o "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();
}

Il Pub tipo contiene due raccolte primitive:

  • Beers è una matrice di stringhe che rappresentano i marchi di birra disponibili al pub.
  • DaysVisited è un elenco delle date in cui è stato visitato il pub.

Suggerimento

In un'applicazione reale, probabilmente sarebbe più opportuno creare un tipo di entità per la birra e avere una tabella per le birre. Qui viene mostrata una raccolta primitiva per illustrare come funzionano. Tenere presente, tuttavia, solo perché è possibile modellare qualcosa come una raccolta primitiva non significa necessariamente che sia necessario.

Il secondo tipo di entità rappresenta una passeggiata cane nella campagna britannica:

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

Come Pub, DogWalk contiene anche una raccolta di date visitate, e un collegamento al pub più vicino poiché, sai, a volte il cane ha bisogno di una salsa di birra dopo una lunga passeggiata.

Usando questo modello, la prima query che verrà eseguita è una semplice Contains query per trovare tutte le passeggiate con uno dei diversi terreni:

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

Questa operazione è già tradotta dalle versioni correnti di EF Core inlining dei valori da cercare. Ad esempio, quando si usa SQL Server:

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

Tuttavia, questa strategia non funziona correttamente con la memorizzazione nella cache delle query di database; per una descrizione del problema, vedere Annuncio di EF8 Preview 4 nel blog .NET.

Importante

L'inlining dei valori viene eseguito in modo tale che non vi sia alcuna possibilità di un attacco SQL injection. La modifica all'uso di JSON descritta di seguito riguarda tutte le prestazioni e nulla a che fare con la sicurezza.

Per EF Core 8, l'impostazione predefinita consiste ora nel passare l'elenco di terreni come singolo parametro contenente una raccolta JSON. Ad esempio:

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

La query usa OpenJson quindi in 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])

Oppure json_each in SQLite:

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

Nota

OpenJson è disponibile solo in SQL Server 2016 (livello di compatibilità 130) e versioni successive. È possibile indicare a SQL Server di usare una versione precedente configurando il livello di compatibilità come parte di UseSqlServer. Ad esempio:

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

Si proverà ora a un tipo di Contains query diverso. In questo caso, si cercherà un valore della raccolta di parametri nella colonna . Ad esempio, qualsiasi pub che stock Heineken:

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

La documentazione esistente di What's New in EF7 fornisce informazioni dettagliate su mapping JSON, query e aggiornamenti. Questa documentazione si applica ora anche a SQLite.

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

OpenJson viene ora usato per estrarre i valori dalla colonna JSON in modo che ogni valore possa essere confrontato con il parametro passato.

È possibile combinare l'uso di OpenJson nel parametro con OpenJson nella colonna . Ad esempio, per trovare pub che stock qualsiasi di una varietà di lagger:

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

Questo comportamento si traduce nel codice seguente in 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)))

Il valore del @__beers_0 parametro qui è ["Carling","Heineken","Stella Artois","Carlsberg"].

Si esaminerà ora una query che usa la colonna contenente una raccolta di date. Ad esempio, per trovare pub visitati quest'anno:

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

Questo comportamento si traduce nel codice seguente in 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)

Si noti che la query usa la funzione DATEPART specifica della data perché EF sa che la raccolta primitiva contiene date. Potrebbe non sembrare, ma questo è davvero importante. Poiché EF conosce il contenuto della raccolta, può generare SQL appropriato per usare i valori tipizzati con parametri, funzioni, altre colonne e così via.

Si userà di nuovo la raccolta di date, questa volta per ordinare in modo appropriato i valori di tipo e progetto estratti dalla raccolta. Ad esempio, elenchiamo i pub nell'ordine in cui sono stati visitati per la prima volta e con la prima e l'ultima data in cui ogni pub è stato visitato:

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

Questo comportamento si traduce nel codice seguente in 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))

E infine, quanto spesso finiamo per visitare il pub più vicino quando prendiamo il cane per una passeggiata? Verrà ora esaminato questo aspetto:

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

Questo comportamento si traduce nel codice seguente in 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]

E rivela i dati seguenti:

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.

Sembra che birra e cane camminano sono una combinazione vincente!

Raccolte primitive nei documenti JSON

In tutti gli esempi precedenti, la colonna per la raccolta primitiva contiene JSON. Tuttavia, ciò non equivale al mapping di un tipo di entità di proprietà a una colonna contenente un documento JSON, introdotto in EF7. Ma cosa succede se il documento JSON contiene una raccolta primitiva? Beh, tutte le query precedenti continuano a funzionare nello stesso modo. Si supponga, ad esempio, di spostare i giorni visitati in un tipo Visits di proprietà mappato a un documento 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!;
}

Suggerimento

Il codice illustrato di seguito proviene da PrimitiveCollectionsInJsonSample.cs.

È ora possibile eseguire una variante della query finale che, questa volta, estrae i dati dal documento JSON, incluse le query nelle raccolte primitive contenute nel documento:

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

Questo comportamento si traduce nel codice seguente in 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]

E a una query simile quando si usa 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"

Suggerimento

Si noti che in SQLite EF Core ora usa l'operatore ->> , ottenendo query che sono sia più facili da leggere che spesso con prestazioni più elevate.

Mapping di raccolte primitive a una tabella

È stato accennato in precedenza che un'altra opzione per le raccolte primitive consiste nel mapparle a una tabella diversa. Il supporto di prima classe per questo problema viene rilevato dal problema #25163. Assicurarsi di votare per questo problema se è importante per te. Fino a quando non viene implementato, l'approccio migliore consiste nel creare un tipo di wrapping per la primitiva. Ad esempio, si creerà un tipo per Beer:

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

    public string Name { get; private set; }
}

Si noti che il tipo esegue semplicemente il wrapping del valore primitivo, ma non ha una chiave primaria o nessuna chiave esterna definita. Questo tipo può quindi essere usato nella Pub classe :

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 creerà ora una Beer tabella, sintetizzando la chiave primaria e le colonne chiave esterna nella Pubs tabella. Ad esempio, in 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

Miglioramenti al mapping delle colonne JSON

EF8 include miglioramenti al supporto per il mapping delle colonne JSON introdotto in EF7.

Suggerimento

Il codice illustrato di seguito proviene da JsonColumnsSample.cs.

Tradurre l'accesso agli elementi in matrici JSON

EF8 supporta l'indicizzazione in matrici JSON durante l'esecuzione di query. Ad esempio, la query seguente controlla se i primi due aggiornamenti sono stati eseguiti prima di una determinata data.

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

Ciò si traduce nel codice SQL seguente quando si usa 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

Nota

Questa query avrà esito positivo anche se un determinato post non ha aggiornamenti o ha un singolo aggiornamento. In questo caso, restituisce JSON_VALUE NULL e il predicato non corrisponde.

L'indicizzazione in matrici JSON può essere usata anche per proiettare gli elementi di una matrice nei risultati finali. Ad esempio, la query seguente proietta la UpdatedOn data per il primo e il secondo aggiornamento di ogni post.

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

Ciò si traduce nel codice SQL seguente quando si usa 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]

Come indicato in precedenza, JSON_VALUE restituisce null se l'elemento della matrice non esiste. Questa operazione viene gestita nella query eseguendo il cast del valore proiettato su un valore nullable DateOnly. Un'alternativa al cast del valore consiste nel filtrare i risultati della query in modo che JSON_VALUE non restituisca mai null. Ad esempio:

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

Ciò si traduce nel codice SQL seguente quando si usa 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)

Convertire le query in raccolte incorporate

EF8 supporta query su raccolte di tipi primitivi (descritti in precedenza) e non primitivi incorporati nel documento JSON. Ad esempio, la query seguente restituisce tutti i post con un elenco arbitrario di termini di ricerca:

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

Ciò si traduce nel codice SQL seguente quando si usa 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]))

Colonne JSON per SQLite

EF7 ha introdotto il supporto per il mapping alle colonne JSON quando si usa Azure SQL/SQL Server. EF8 estende questo supporto ai database SQLite. Per quanto riguarda il supporto di SQL Server, questo include:

  • Mapping di aggregazioni compilate dai tipi .NET ai documenti JSON archiviati nelle colonne SQLite
  • Query in colonne JSON, ad esempio il filtro e l'ordinamento in base agli elementi dei documenti
  • Esegue query sugli elementi del progetto dal documento JSON nei risultati
  • Aggiornamento e salvataggio delle modifiche ai documenti JSON

La documentazione esistente di What's New in EF7 fornisce informazioni dettagliate su mapping JSON, query e aggiornamenti. Questa documentazione si applica ora anche a SQLite.

Suggerimento

Il codice illustrato nella documentazione di EF7 è stato aggiornato per l'esecuzione anche in SQLite è disponibile in JsonColumnsSample.cs.

Query in colonne JSON

Le query nelle colonne JSON in SQLite usano la json_extract funzione . Ad esempio, la query "authors in Chigley" della documentazione a cui si fa riferimento in precedenza:

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

Viene convertito nel codice SQL seguente quando si usa SQLite:

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

Aggiornamento delle colonne JSON

Per gli aggiornamenti, EF usa la json_set funzione in SQLite. Ad esempio, quando si aggiorna una singola proprietà in un documento:

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

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

await context.SaveChangesAsync();

Ef genera i parametri seguenti:

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

Che usano la json_set funzione in SQLite:

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

HierarchyId in .NET e EF Core

Azure SQL e SQL Server hanno un tipo di dati speciale denominato hierarchyid usato per archiviare dati gerarchici. In questo caso, "dati gerarchici" significa essenzialmente i dati che costituiscono una struttura ad albero, in cui ogni elemento può avere un elemento padre e/o figlio. Esempi di tali dati sono:

  • Una struttura organizzativa
  • Un file system
  • Un set di attività di un progetto
  • Una tassonomia di termini del linguaggio
  • Un grafico di collegamenti tra pagine Web

Il database è quindi in grado di eseguire query su questi dati usando la relativa struttura gerarchica. Ad esempio, una query può trovare predecessori e dipendenti di elementi specificati o trovare tutti gli elementi in una certa profondità nella gerarchia.

Supporto in .NET ed EF Core

Il supporto ufficiale per il tipo DI SQL Server hierarchyid è destinato solo alle piattaforme .NET moderne (ad esempio ".NET Core"). Questo supporto è sotto forma di pacchetto NuGet Microsoft.SqlServer.Types , che introduce tipi specifici di SQL Server di basso livello. In questo caso, il tipo di basso livello viene chiamato SqlHierarchyId.

Al livello successivo è stato introdotto un nuovo pacchetto Microsoft.EntityFrameworkCore.SqlServer.Abstractions , che include un tipo di livello HierarchyId superiore destinato all'uso nei tipi di entità.

Suggerimento

Il HierarchyId tipo è più idiotico rispetto alle norme di .NET rispetto SqlHierarchyIda , che viene invece modellato dopo il modo in cui i tipi .NET Framework sono ospitati all'interno del motore di database di SQL Server. HierarchyId è progettato per funzionare con EF Core, ma può essere usato anche all'esterno di EF Core in altre applicazioni. Il Microsoft.EntityFrameworkCore.SqlServer.Abstractions pacchetto non fa riferimento ad altri pacchetti e quindi ha un impatto minimo sulle dimensioni e sulle dipendenze delle applicazioni distribuite.

L'uso di per le funzionalità di HierarchyId EF Core, ad esempio query e aggiornamenti, richiede il pacchetto Microsoft.EntityFrameworkCore.SqlServer.HierarchyId . Questo pacchetto inserisce Microsoft.EntityFrameworkCore.SqlServer.Abstractions e Microsoft.SqlServer.Types come dipendenze transitive e quindi è spesso l'unico pacchetto necessario. Dopo aver installato il pacchetto, l'uso di HierarchyId viene abilitato chiamando UseHierarchyId come parte della chiamata dell'applicazione a UseSqlServer. Ad esempio:

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

Nota

Il supporto non ufficiale per hierarchyid in EF Core è disponibile da molti anni tramite il pacchetto EntityFrameworkCore.SqlServer.HierarchyId . Questo pacchetto è stato mantenuto come collaborazione tra la community e il team ef. Ora che è disponibile il supporto ufficiale per hierarchyid in .NET, il codice di questo pacchetto della community, con l'autorizzazione dei collaboratori originali, la base per il pacchetto ufficiale descritto qui. Molti grazie a tutti coloro coinvolti nel corso degli anni, tra cui @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas e @vyrotek

Modellazione di gerarchie

Il HierarchyId tipo può essere usato per le proprietà di un tipo di entità. Si supponga, ad esempio, di voler modellare l'albero della famiglia paterna di alcuni halfling fittizi. Nel tipo di entità per Halfling, una HierarchyId proprietà può essere usata per individuare ogni mezzoling nell'albero di famiglia.

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

Suggerimento

Il codice illustrato qui e negli esempi seguenti proviene da HierarchyIdSample.cs.

Suggerimento

Se necessario, HierarchyId è adatto per l'uso come tipo di proprietà chiave.

In questo caso, l'albero della famiglia è radicato con il patriarca della famiglia. Ogni halfling può essere tracciato dal patriarca verso il basso l'albero usando la sua PathFromPatriarch proprietà. SQL Server usa un formato binario compatto per questi percorsi, ma è comune analizzare da e verso una rappresentazione di stringa leggibile quando si usa codice. In questa rappresentazione, la posizione a ogni livello è separata da un / carattere. Si consideri ad esempio l'albero della famiglia nel diagramma seguente:

Albero della famiglia halfling

In questo albero:

  • Balbo si trova nella radice dell'albero, rappresentato da /.
  • Balbo ha cinque figli, rappresentati da /1/, /2/, /3//4/, e /5/.
  • Il primo figlio di Balbo, Mungo, ha anche cinque figli, rappresentati da /1/1/, /1/2//1/3/, /1/4/, e /1/5/. Si noti che il HierarchyId per Balbo (/1/) è il prefisso per tutti i suoi figli.
  • Analogamente, il terzo figlio di Balbo, Ponto, ha due figli, rappresentati da /3/1/ e /3/2/. Anche in questo caso, ognuno di questi figli è preceduto da HierarchyId per Ponto, rappresentato come /3/.
  • E così via giù per l'albero...

Il codice seguente inserisce questo albero di famiglia in un database usando 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();

Suggerimento

Se necessario, i valori decimali possono essere usati per creare nuovi nodi tra due nodi esistenti. Ad esempio, /3/2.5/2/ passa tra /3/2/2/ e /3/3/2/.

Esecuzione di query sulle gerarchie

HierarchyId espone diversi metodi che possono essere usati nelle query LINQ.

metodo Descrizione
GetAncestor(int n) Ottiene i livelli del nodo n fino all'albero gerarchico.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Ottiene il valore di un nodo discendente maggiore child1 di e minore di child2.
GetLevel() Ottiene il livello di questo nodo nell'albero gerarchico.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Ottiene un valore che rappresenta la posizione di un nuovo nodo con un percorso uguale newRoot al percorso da oldRoot a questo, spostando in modo efficace il percorso nella nuova posizione.
IsDescendantOf(HierarchyId? parent) Ottiene un valore che indica se il nodo è un discendente di parent.

Inoltre, gli operatori ==, !=<, , <=> e >= possono essere usati.

Di seguito sono riportati esempi di utilizzo di questi metodi nelle query LINQ.

Ottenere entità a un determinato livello nell'albero

La query seguente usa GetLevel per restituire tutti i halfling a un determinato livello nell'albero di famiglia:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa operazione in un ciclo consente di ottenere i halfling per ogni generazione:

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

Ottenere il predecessore diretto di un'entità

La query seguente usa GetAncestor per trovare il predecessore diretto di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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)

L'esecuzione di questa query per il halfling "Bilbo" restituisce "Bungo".

Ottenere i discendenti diretti di un'entità

La query seguente usa GetAncestoranche , ma questa volta per trovare i discendenti diretti di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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)

L'esecuzione di questa query per il halfling "Mungo" restituisce "Bungo", "Belba", "Longo" e "Linda".

Ottenere tutti i predecessori di un'entità

GetAncestor è utile per la ricerca su o verso il basso di un singolo livello, o, in effetti, un numero specificato di livelli. D'altra parte, IsDescendantOf è utile per trovare tutti i predecessori o dipendenti. Ad esempio, la query seguente usa IsDescendantOf per trovare tutti i predecessori di un halfling, dato il nome di halfling:

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

Importante

IsDescendantOf restituisce true per se stesso, motivo per cui viene filtrato nella query precedente.

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query per il halfling "Bilbo" restituisce "Bungo", "Mungo" e "Balbo".

Ottenere tutti i discendenti di un'entità

La query seguente usa IsDescendantOfanche , ma questa volta per tutti i discendenti di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query per il halfling "Mungo" restituisce "Bungo", "Belba", "Longo", "Linda", "Tomb", "Bilbo", "Otho", "Falco", "Lotho" e "Poppy".

Ricerca di un predecessore comune

Una delle domande più comuni poste su questo particolare albero di famiglia è: "chi è l'antenato comune di Frodo e Bilbo?" È possibile usare IsDescendantOf per scrivere una query di questo tipo:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query con "Bilbo" e "Frodo" indica che il loro predecessore comune è "Balbo".

Aggiornamento delle gerarchie

Il normale rilevamento delle modifiche e i meccanismi SaveChanges possono essere usati per aggiornare le hierarchyid colonne.

Ri-parenting di una gerarchia secondaria

Ad esempio, sono sicuro che tutti ricordiamo lo scandalo della SR 1752 (a.k.a. "LongoGate") quando il test del DNA ha rivelato che Longo non era in realtà il figlio di Mungo, ma in realtà il figlio di Ponto! Una caduta da questo scandalo era che l'albero della famiglia doveva essere riscritto. In particolare, Longo e tutti i suoi discendenti dovevano essere ri-genitorialmente da Mungo a Ponto. GetReparentedValue può essere usato per eseguire questa operazione. Ad esempio, vengono eseguite query su "Longo" e tutti i relativi discendenti:

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

Viene quindi GetReparentedValue usato per aggiornare per HierarchyId Longo e ogni discendente, seguito da una chiamata a SaveChangesAsync:

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

await context.SaveChangesAsync();

Questo comporta l'aggiornamento del database seguente:

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;

Uso di questi parametri:

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

Nota

I valori dei parametri per HierarchyId le proprietà vengono inviati al database nel formato binario compatto.

Dopo l'aggiornamento, l'esecuzione di query per i discendenti di "Mungo" restituisce "Bungo", "Belba", "Linda", "Tomb", "Bilbo", "Falco" e "Poppy", mentre si esegue una query per i discendenti di "Ponto" restituisce "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" e "Angelica".

Query SQL non elaborate per tipi non mappati

EF7 ha introdotto query SQL non elaborate che restituiscono tipi scalari. Questa funzionalità è migliorata in EF8 per includere query SQL non elaborate che restituiscono qualsiasi tipo CLR mappabile, senza includere tale tipo nel modello EF.

Suggerimento

Il codice illustrato di seguito proviene da RawSqlSample.cs.

Le query che usano tipi non mappati vengono eseguite usando SqlQuery o SqlQueryRaw. In precedenza viene utilizzata l'interpolazione di stringhe per parametrizzare la query, assicurandosi che tutti i valori non costanti siano parametrizzati. Si consideri ad esempio la tabella di database seguente:

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 può essere usato per eseguire query su questa tabella e restituire istanze di un BlogPost tipo con proprietà corrispondenti alle colonne della tabella:

Ad esempio:

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

Ad esempio:

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

Questa query viene parametrizzata ed eseguita come segue:

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

Il tipo usato per i risultati della query può contenere costrutti di mapping comuni supportati da EF Core, ad esempio costruttori con parametri e attributi di mapping. Ad esempio:

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

Nota

I tipi usati in questo modo non hanno chiavi definite e non possono avere relazioni con altri tipi. I tipi con relazioni devono essere mappati nel modello.

Il tipo utilizzato deve avere una proprietà per ogni valore nel set di risultati, ma non deve corrispondere ad alcuna tabella nel database. Ad esempio, il tipo seguente rappresenta solo un subset di informazioni per ogni post e include il nome del Blogs blog, che deriva dalla tabella:

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

Ed è possibile eseguire query usando SqlQuery nello stesso modo di prima:


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

Una caratteristica interessante di SqlQuery è che restituisce un oggetto IQueryable che può essere composto con LINQ. Ad esempio, una clausola 'Where' può essere aggiunta alla query precedente:

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

Questa operazione viene eseguita come segue:

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

A questo punto vale la pena ricordare che tutte le operazioni precedenti possono essere eseguite completamente in LINQ senza la necessità di scrivere sql. Ciò include la restituzione di istanze di un tipo non mappato, ad esempio PostSummary. Ad esempio, la query precedente può essere scritta in LINQ come segue:

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

Che si traduce in SQL molto più pulito:

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

Suggerimento

Ef è in grado di generare SQL più pulito quando è responsabile dell'intera query rispetto a quando si compone su SQL fornito dall'utente perché, nel caso precedente, la semantica completa della query è disponibile per ENTITY Framework.

Finora tutte le query sono state eseguite direttamente sulle tabelle. SqlQuery può essere usato anche per restituire risultati da una vista senza eseguire il mapping del tipo di visualizzazione nel modello di Entity Framework. Ad esempio:

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

Analogamente, SqlQuery può essere usato per i risultati di una funzione:

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

L'oggetto restituito IQueryable può essere composto quando è il risultato di una vista o di una funzione, proprio come può essere per il risultato di una query di tabella. Le stored procedure possono essere eseguite anche usando SqlQuery, ma la maggior parte dei database non supporta la composizione di tali database. Ad esempio:

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

Miglioramenti al caricamento differita

Caricamento differita per le query senza rilevamento

EF8 aggiunge il supporto per il caricamento differita degli spostamenti nelle entità che non vengono rilevate da DbContext. Ciò significa che una query senza rilevamento può essere seguita da un caricamento differita degli spostamenti sulle entità restituite dalla query senza rilevamento.

Suggerimento

Il codice per gli esempi di caricamento differita illustrato di seguito deriva da LazyLoadingSample.cs.

Si consideri, ad esempio, una query senza rilevamento per i blog:

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

Se Blog.Posts è configurato per il caricamento lazy, ad esempio usando proxy lazy-loading, l'accesso Posts causerà il caricamento dal 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 indica anche se una navigazione specificata viene caricata per le entità non rilevate dal contesto. Ad esempio:

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

Esistono alcune considerazioni importanti quando si usa il caricamento differita in questo modo:

  • Il caricamento differita avrà esito positivo solo finché l'oggetto DbContext usato per eseguire query sull'entità non viene eliminato.
  • Le entità sottoposte a query in questo modo mantengono un riferimento al relativo DbContext, anche se non vengono rilevate da esso. Prestare attenzione a evitare perdite di memoria se le istanze dell'entità avranno durate lunghe.
  • Scollegare in modo esplicito l'entità impostando il relativo stato su EntityState.Detached se il riferimento a DbContext e il caricamento differita non funzionerà più.
  • Tenere presente che tutto il caricamento differita usa operazioni di I/O sincrone, poiché non è possibile accedere a una proprietà in modo asincrono.

Il caricamento differita da entità non rilevate funziona sia per i proxy di caricamento differita che per il caricamento differita senza proxy.

Caricamento esplicito da entità non rilevate

EF8 supporta il caricamento di spostamenti su entità non rilevate anche quando l'entità o lo spostamento non è configurato per il caricamento differita. A differenza del caricamento differita, questo caricamento esplicito può essere eseguito in modo asincrono. Ad esempio:

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

Rifiutare esplicitamente il caricamento differita per spostamenti specifici

EF8 consente la configurazione di spostamenti specifici per non eseguire il caricamento differita, anche quando tutto il resto è configurato per farlo. Ad esempio, per configurare lo Post.Author spostamento in modo da non eseguire il caricamento differita, eseguire le operazioni seguenti:

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

La disabilitazione del caricamento differita in questo modo funziona sia per i proxy di caricamento differita che per il caricamento differita senza proxy.

I proxy di caricamento differita funzionano eseguendo l'override delle proprietà di spostamento virtuale. Nelle applicazioni EF6 classiche, un'origine comune di bug sta dimenticando di rendere virtuale uno spostamento, poiché lo spostamento non verrà quindi invisibile all'utente. Pertanto, i proxy di EF Core generano per impostazione predefinita quando una navigazione non è virtuale.

Questa operazione può essere modificata in EF8 per acconsentire esplicitamente al comportamento di EF6 classico in modo che una navigazione possa essere eseguita in modo da non eseguire il caricamento differita semplicemente rendendo la navigazione non virtuale. Questo consenso esplicito viene configurato come parte della chiamata a UseLazyLoadingProxies. Ad esempio:

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

Accesso alle entità rilevate

Ricerca di entità rilevate per chiave primaria, alternativa o esterna

Internamente, Entity Framework gestisce strutture di dati per trovare entità rilevate in base a chiave primaria, alternativa o esterna. Queste strutture di dati vengono usate per una correzione efficiente tra entità correlate quando vengono rilevate nuove entità o le relazioni cambiano.

EF8 contiene nuove API pubbliche in modo che le applicazioni possano ora usare queste strutture di dati per cercare in modo efficiente le entità rilevate. Queste API sono accessibili tramite il LocalView<TEntity> del tipo di entità. Ad esempio, per cercare un'entità rilevata in base alla chiave primaria:

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

Suggerimento

Il codice illustrato di seguito proviene da LookupByKeySample.cs.

Il FindEntry metodo restituisce per EntityEntry<TEntity> l'entità rilevata oppure null se non viene rilevata alcuna entità con la chiave specificata. Analogamente a tutti i metodi in LocalView, il database non viene mai sottoposto a query, anche se l'entità non viene trovata. La voce restituita contiene l'entità stessa, nonché le informazioni di rilevamento. Ad esempio:

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

Per cercare un'entità diversa da una chiave primaria, è necessario specificare il nome della proprietà. Ad esempio, per cercare in base a una chiave alternativa:

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

Oppure per cercare una chiave esterna univoca:

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

Finora, le ricerche hanno sempre restituito una singola voce, o null. Tuttavia, alcune ricerche possono restituire più di una voce, ad esempio quando si cerca in base a una chiave esterna non univoca. Il GetEntries metodo deve essere usato per queste ricerche. Ad esempio:

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

In tutti questi casi, il valore usato per la ricerca è una chiave primaria, una chiave alternativa o un valore di chiave esterna. Ef usa le relative strutture di dati interne per queste ricerche. Tuttavia, le ricerche in base al valore possono essere usate anche per il valore di qualsiasi proprietà o combinazione di proprietà. Ad esempio, per trovare tutti i post archiviati:

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

Questa ricerca richiede un'analisi di tutte le istanze rilevate Post e quindi sarà meno efficiente delle ricerche chiave. Tuttavia, in genere è ancora più veloce rispetto alle query ingenui che usano ChangeTracker.Entries<TEntity>().

Infine, è anche possibile eseguire ricerche su chiavi composite, altre combinazioni di più proprietà o quando il tipo di proprietà non è noto in fase di compilazione. Ad esempio:

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

Compilazione di modelli

Le colonne discriminatorie hanno lunghezza massima

In EF8, le colonne discriminatorie di stringa usate per il mapping dell'ereditarietà TPH sono ora configurate con una lunghezza massima. Questa lunghezza viene calcolata come il numero di Fibonacci più piccolo che copre tutti i valori discriminatori definiti. Si consideri ad esempio la gerarchia seguente:

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

Con la convenzione di utilizzare i nomi di classe per i valori discriminatori, i valori possibili sono "PaperbackEdition", "HardbackEdition" e "Magazine" e quindi la colonna discriminatoria è configurata per una lunghezza massima di 21. Ad esempio, quando si usa 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]),

Suggerimento

I numeri di Fibonacci vengono usati per limitare il numero di volte in cui viene generata una migrazione per modificare la lunghezza della colonna man mano che vengono aggiunti nuovi tipi alla gerarchia.

DateOnly/TimeOnly supportato in SQL Server

I DateOnly tipi e TimeOnly sono stati introdotti in .NET 6 e sono stati supportati per diversi provider di database ( ad esempio SQLite, MySQL e PostgreSQL) dopo l'introduzione. Per SQL Server, la versione recente di un pacchetto Microsoft.Data.SqlClient destinato a .NET 6 ha consentito a ErikEJ di aggiungere il supporto per questi tipi a livello di ADO.NET. Questo a sua volta ha aperto la strada per il supporto in EF8 per DateOnly e TimeOnly come proprietà nei tipi di entità.

Suggerimento

DateOnly e TimeOnly possono essere usati in EF Core 6 e 7 usando il pacchetto della community ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly da @ErikEJ.

Si consideri ad esempio il modello EF seguente per le scuole britanniche:

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

Suggerimento

Il codice illustrato di seguito proviene da DateOnlyTimeOnlySample.cs.

Nota

Questo modello rappresenta solo scuole britanniche e archivia orari come orari locali (GMT). La gestione di fusi orari diversi complica notevolmente questo codice. Si noti che l'uso DateTimeOffset di non può essere utile in questo caso, poiché le ore di apertura e chiusura hanno offset diversi a seconda che l'ora legale sia attiva o meno.

Questi tipi di entità vengono mappati alle tabelle seguenti quando si usa SQL Server. Si noti che le proprietà eseguono il DateOnly mapping alle date colonne e le proprietà eseguono il TimeOnly mapping alle time colonne.

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

Le query che usano DateOnly e TimeOnly funzionano nel modo previsto. Ad esempio, la query LINQ seguente trova le scuole attualmente aperte:

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

Questa query viene convertita nel codice SQL seguente, come illustrato da 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 e TimeOnly possono essere usati anche nelle colonne JSON. Ad esempio, OpeningHours può essere salvato come documento JSON, ottenendo dati simili al seguente:

Colonna Valore
ID. 2
Nome Farr High School
Fondate 1964-05-01
OpeningHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": null }
]

Combinando due funzionalità di EF8, è ora possibile eseguire query per le ore di apertura tramite l'indicizzazione nella raccolta JSON. Ad esempio:

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

Questa query viene convertita nel codice SQL seguente, come illustrato da 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

Infine, gli aggiornamenti e le eliminazioni possono essere eseguiti con il rilevamento e SaveChanges oppure usando ExecuteUpdate/ExecuteDelete. Ad esempio:

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

Questo aggiornamento viene convertito nel codice SQL seguente:

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 e Dynamics 365 TDS

Ef8 reverse engineering (a.k.a. scaffolding da un database esistente) supporta ora i database endpoint Synapse Serverless e Dynamics 365 TDS Endpoint .

Avviso

Questi sistemi di database presentano differenze rispetto ai normali database SQL Server e SQL di Azure. Queste differenze indicano che non tutte le funzionalità di EF Core sono supportate durante la scrittura di query su o l'esecuzione di altre operazioni con questi sistemi di database.

Miglioramenti alle traduzioni matematiche

Le interfacce matematiche generiche sono state introdotte in .NET 7. Tipi concreti come double e float implementati da queste interfacce aggiungendo nuove API per il mirroring delle funzionalità esistenti di Math e MathF.

EF Core 8 converte le chiamate a queste API matematiche generiche in LINQ usando le traduzioni SQL esistenti dei provider per Math e MathF. Ciò significa che è ora possibile scegliere tra chiamate come Math.Sin o double.Sin nelle query ef.

Si è lavorato con il team .NET per aggiungere due nuovi metodi matematici generici in .NET 8 implementati in double e float. Queste vengono convertite anche in SQL in EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Infine, abbiamo lavorato con Eric Sink nel progetto SQLitePCLRaw per abilitare le funzioni matematiche SQLite nelle loro build della libreria SQLite nativa. Ciò include la libreria nativa che si ottiene per impostazione predefinita quando si installa il provider SQLite di EF Core. Ciò consente diverse nuove traduzioni SQL in LINQ, tra cui: Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log2, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh e Truncate.

Controllo delle modifiche al modello in sospeso

È stato aggiunto un nuovo dotnet ef comando per verificare se sono state apportate modifiche al modello dall'ultima migrazione. Questo può essere utile negli scenari CI/CD per assicurarsi che l'utente o un compagno di squadra non abbia dimenticato di aggiungere una migrazione.

dotnet ef migrations has-pending-model-changes

È anche possibile eseguire questo controllo a livello di codice nell'applicazione o nei test usando il nuovo dbContext.Database.HasPendingModelChanges() metodo.

Miglioramenti allo scaffolding di SQLite

SQLite supporta solo quattro tipi di dati primitivi: INTEGER, REAL, TEXT e BLOB. In precedenza, ciò significava che, quando si inverteva la progettazione di un database SQLite per eseguire lo scaffolding di un modello EF Core, i tipi di entità risultanti includevano solo proprietà di tipo long, double, stringe byte[]. I tipi .NET aggiuntivi sono supportati dal provider SQLite di EF Core convertendoli tra di essi e uno dei quattro tipi SQLite primitivi.

In EF Core 8 si usano ora il formato dati e il nome del tipo di colonna oltre al tipo SQLite per determinare un tipo .NET più appropriato da usare nel modello. Le tabelle seguenti mostrano alcuni dei casi in cui le informazioni aggiuntive portano a tipi di proprietà migliori nel modello.

Nome tipo di colonna Tipo .NET
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
bigint long
STRING byte[]string
Formato dati Tipo .NET
'0.0' stringadecimale
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' Guid stringa

Valori di Sentinel e valori predefiniti del database

I database consentono la configurazione delle colonne per generare un valore predefinito se non viene specificato alcun valore durante l'inserimento di una riga. Questa operazione può essere rappresentata in Entity Framework usando HasDefaultValue per le costanti:

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

Oppure HasDefaultValueSql per clausole SQL arbitrarie:

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

Suggerimento

Il codice riportato di seguito proviene da DefaultConstraintSample.cs.

Affinché Entity Framework usi questa operazione, deve determinare quando e quando non inviare un valore per la colonna. Per impostazione predefinita, EF usa il valore predefinito CLR come sentinel. Ovvero, quando il valore di Status o LeaseDate negli esempi precedenti sono le impostazioni predefinite CLR per questi tipi, EF interpreta che per indicare che la proprietà non è stata impostata e quindi non invia un valore al database. Ciò funziona bene per i tipi di riferimento, ad esempio se la string proprietà Status è null, EF non invia null al database, ma non include alcun valore in modo che venga usato il database predefinito ("Hidden"). Analogamente, per la DateTime proprietà LeaseDate, EF non inserirà il valore predefinito CLR di 1/1/0001 12:00:00 AM, ma ometterà invece questo valore in modo che venga usato il database predefinito.

In alcuni casi, tuttavia, il valore predefinito CLR è un valore valido da inserire. EF8 gestisce questa operazione consentendo al valore sentinel di modificare una colonna. Si consideri, ad esempio, una colonna integer configurata con un database predefinito:

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

In questo caso, si vuole che la nuova entità venga inserita con il numero specificato di crediti, a meno che non venga specificato, nel qual caso vengono assegnati 10 crediti. Tuttavia, ciò significa che l'inserimento di un record con zero crediti non è possibile, poiché zero è l'impostazione predefinita CLR e pertanto EF non invierà alcun valore. In EF8, questo problema può essere risolto modificando sentinel per la proprietà da zero a -1:

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

Ef userà ora il valore predefinito del database solo se Credits è impostato su . Verrà inserito un valore pari a -1zero come qualsiasi altro importo.

Spesso può essere utile riflettere questo valore nel tipo di entità e nella configurazione di Entity Framework. Ad esempio:

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

Ciò significa che il valore sentinel di -1 viene impostato automaticamente quando viene creata l'istanza, ovvero la proprietà inizia nello stato "non impostato".

Suggerimento

Se si vuole configurare il vincolo predefinito del database da usare quando Migrations si crea la colonna, ma si vuole che EF inserisca sempre un valore, configurare la proprietà come non generata. Ad esempio: b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Valori predefiniti del database per i valori booleani

Le proprietà booleane presentano una forma estrema di questo problema, poiché l'impostazione predefinita CLR (false) è uno dei soli due valori validi. Ciò significa che una bool proprietà con un vincolo predefinito del database avrà un valore inserito solo se tale valore è true. Quando il valore predefinito del database è false, questo significa che quando il valore della proprietà è false, verrà usato il valore predefinito del database, ovvero false. In caso contrario, se il valore della proprietà è true, true verrà inserito. Pertanto, quando l'impostazione predefinita del database è false, la colonna del database termina con il valore corretto.

D'altra parte, se il valore predefinito del database è true, questo significa che quando il valore della proprietà è false, verrà usato il valore predefinito del database, ovvero true! E quando il valore della proprietà è true, true verrà inserito. Pertanto, il valore nella colonna terminerà true sempre nel database, indipendentemente dal valore della proprietà.

EF8 risolve questo problema impostando sentinel per le proprietà bool sullo stesso valore del valore predefinito del database. Entrambi i casi precedenti comportano quindi l'inserimento del valore corretto, indipendentemente dal fatto che il database predefinito sia true o false.

Suggerimento

Quando si esegue lo scaffolding da un database esistente, EF8 analizza e quindi include valori predefiniti semplici nelle HasDefaultValue chiamate. In precedenza, tutti i valori predefiniti venivano scaffolding come chiamate opache HasDefaultValueSql . Ciò significa che le colonne bool non nullable con un true database predefinito o false costante non vengono più scaffolding come nullable.

Valori predefiniti del database per le enumerazioni

Le proprietà di enumerazione possono avere problemi simili alle bool proprietà perché le enumerazioni in genere hanno un set molto ridotto di valori validi e l'impostazione predefinita CLR può essere uno di questi valori. Si consideri ad esempio questo tipo di entità ed enumerazione:

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

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

La Level proprietà viene quindi configurata con un database predefinito:

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

Con questa configurazione, Entity Framework esclude l'invio del valore al database quando è impostato su Level.Beginnere viene invece Level.Intermediate assegnato dal database. Questo non è ciò che è stato previsto!

Il problema non si è verificato se l'enumerazione è stata definita con il valore "sconosciuto" o "non specificato" come predefinito del database:

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

Tuttavia, non è sempre possibile modificare un'enumerazione esistente, quindi in EF8 è possibile specificare nuovamente sentinel. Ad esempio, tornare all'enumerazione originale:

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

Ora Level.Beginner verrà inserito come di consueto e il database predefinito verrà usato solo quando il valore della proprietà è Level.Unspecified. Può anche essere utile riflettere questo valore nel tipo di entità stesso. Ad esempio:

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

Uso di un campo sottostante nullable

Un modo più generale per gestire il problema descritto in precedenza consiste nel creare un campo sottostante nullable per la proprietà non nullable. Si consideri ad esempio il tipo di entità seguente con una bool proprietà :

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

Alla proprietà può essere assegnato un campo sottostante nullable:

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

    private bool? _isActive;

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

Il campo sottostante rimarrà a null meno che non venga effettivamente chiamato il setter della proprietà. Ovvero, il valore del campo sottostante è un'indicazione migliore del fatto che la proprietà sia stata impostata o meno dell'impostazione predefinita CLR della proprietà. Questa operazione è predefinita con Entity Framework, poiché ENTITY userà il campo sottostante per leggere e scrivere la proprietà per impostazione predefinita.

Miglioramento di ExecuteUpdate e ExecuteDelete

I comandi SQL che eseguono aggiornamenti ed eliminazioni, ad esempio quelli generati da ExecuteUpdate e ExecuteDelete , devono essere destinati a una singola tabella di database. Tuttavia, in EF7 ExecuteUpdate e ExecuteDelete non supportava gli aggiornamenti che accedono a più tipi di entità anche quando la query in ultima analisi ha interessato una singola tabella. EF8 rimuove questa limitazione. Si consideri ad esempio un Customer tipo di entità con CustomerInfo tipo di proprietà:

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

Entrambi questi tipi di entità eseguono il mapping alla Customers tabella. Tuttavia, l'aggiornamento bulk seguente non riesce in EF7 perché usa entrambi i tipi di entità:

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

In EF8 questa operazione viene ora convertita nel codice SQL seguente quando si usa 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

Analogamente, le istanze restituite da una Union query possono essere aggiornate purché gli aggiornamenti siano destinati alla stessa tabella. Ad esempio, è possibile aggiornare qualsiasi Customer con un'area di Francee contemporaneamente, tutti Customer gli utenti che hanno visitato un archivio con l'area 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"));

In EF8 questa query genera quanto segue quando si usa 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]

Come esempio finale, in EF8, ExecuteUpdate può essere usato per aggiornare le entità in una gerarchia TPT, purché tutte le proprietà aggiornate vengano mappate alla stessa tabella. Si consideri, ad esempio, questi tipi di entità mappati tramite 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; }
}

Con EF8, la Note proprietà può essere aggiornata:

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

In alternativa, è possibile aggiornare la Name proprietà:

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

Tuttavia, EF8 non riesce a tentare di aggiornare sia le Name proprietà che le Note proprietà perché sono mappate a tabelle diverse. Ad esempio:

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

Genera l'eccezione seguente:

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.

Uso migliore delle IN query

Quando l'operatore Contains LINQ viene usato con una sottoquery, EF Core genera ora query migliori usando SQL IN anziché EXISTS, oltre a produrre SQL più leggibile, in alcuni casi ciò può comportare query notevolmente più veloci. Si consideri ad esempio la query LINQ seguente:

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

EF7 genera quanto segue per PostgreSQL:

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

Poiché la sottoquery fa riferimento alla tabella esterna Blogs (tramite b."Id"), si tratta di una sottoquery correlata, ovvero la Posts sottoquery deve essere eseguita per ogni riga della Blogs tabella. In EF8 viene invece generato il codice SQL seguente:

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

Poiché la sottoquery non fa Blogspiù riferimento a , può essere valutata una sola volta, producendo enormi miglioramenti delle prestazioni nella maggior parte dei sistemi di database. Tuttavia, alcuni sistemi di database, in particolare SQL Server, il database è in grado di ottimizzare la prima query alla seconda query in modo che le prestazioni siano le stesse.

Righe numeriche per SQL Azure/SQL Server

La concorrenza ottimistica automatica di SQL Server viene gestita tramite rowversion colonne. Un rowversion è un valore opaco a 8 byte passato tra database, client e server. Per impostazione predefinita, SqlClient espone i rowversion tipi come byte[], nonostante i tipi riferimento modificabili siano una corrispondenza non valida per rowversion la semantica. In EF8 è invece facile eseguire il mapping rowversion delle colonne alle long proprietà o ulong . Ad esempio:

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

Eliminazione parentesi

La generazione di SQL leggibili è un obiettivo importante per EF Core. In EF8, sql generato è più leggibile tramite l'eliminazione automatica di parentesi non necessario. Ad esempio, la query LINQ seguente:

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

Viene convertito nel codice SQL di Azure seguente quando si usa 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)

Che è stato migliorato al seguente quando si usa 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

Rifiuto esplicito specifico per la clausola RETURNING/OUTPUT

EF7 ha modificato l'aggiornamento predefinito di SQL da usare RETURNING/OUTPUT per il recupero di colonne generate dal database. Alcuni casi in cui è stato identificato dove questo non funziona e quindi EF8 introduce espliciti rifiuto esplicito per questo comportamento.

Ad esempio, per rifiutare OUTPUT esplicitamente quando si usa il provider SQL Server/Azure SQL:

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

In alternativa, rifiutare RETURNING esplicitamente quando si usa il provider SQLite:

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

Altre modifiche secondarie

Oltre ai miglioramenti descritti in precedenza, sono state apportate molte modifiche più piccole a EF8. Valuta gli ambiti seguenti: