ENTITY FRAMEWORK CORE 5.0

Contestualmente al recente rilascio di .NET 5.0, che include C# 9, F# 5 e numerose migliorie su diversi fronti, è stato rilasciato anche un nuovo major update di Entity Framework: Entity Framework Core 5.0, che porta con sé diverse novità.
In questo articolo, dopo un’introduzione su Entity Framework, analizzeremo alcune delle novità introdotte con l’ultimo update del framework.

Introduzione

Prima di procedere con l’analisi, tengo molto a spendere qualche parola per coloro che si avvicinano per la prima volta ad Entity Framework e al – meraviglioso – mondo degli ORM.
Entity Framework (EF) è un Object-Relational Mapper (ORM), ovvero uno strumento che permette di astrarre l’interfacciamento di un’applicazione con il DBMS (più precisamente RDBMS), indipendentemente (o quasi) da quello utilizzato. EF, per esempio, è compatibile con diversi database, come SQLServer, SQLite, Cosmos, PostgreSQL, MySQL e Oracle, inoltre può essere configurato per essere un “In-memory” database, utile per il testing.


I software ORM generano oggetti che mappano virtualmente le tabelle del database, permettendo quindi al programmatore di interagire con quest’ultimo e manipolarlo. In particolare, un punto di forza di EF è il concetto di migration: in breve, attraverso un tool fornito dal framework (NuGet Package Microsoft.EntityFrameworkCore.Design), eseguendo una migration, è possibile mantenere aggiornato automaticamente lo schema del database a seguito di cambiamenti avvenuti nel modello dei dati.
Bene, fatta la brevissima ma doverosa introduzione, parliamo della compatibilità e delle novità del nuovo update.

Compatibilità e installazione

EF Core 5.0 richiede .NET Standard 2.1, questo significa che:

  • può girare su .NET Core 3.1, non richiede infatti .NET 5
  • può girare su tutte le piattaforme che supportano .NET Standard 2.1
  • non può girare sulle piattaforme con .NET Standard 2.0 (incluso .NET Framework)

Per quanto riguarda l’installazione, come per una qualsiasi libreria, questa può essere effettuata tramite l’interfaccia del Manage NuGet Manager o tramite la Package Manager Console lanciando il comando

Install-Package Microsoft.EntityFrameworkCore

e il pacchetto Microsoft.EntityFrameworkCore.Design per poter effettuare le migrations.
Infine, per creare alcuni degli esempi riportati in questo articolo, è stato installato anche il package Microsoft.EntityFrameworkCore.SqlServer; questo dipende dal database utilizzato, nel nostro caso è stato utilizzato un SqlServer.
Riporto qui per praticità il – grossolano – modello ER utilizzato per costruire la maggior parte dei casi di esempio nel seguito.

Novità in EF Core 5.0

Include filtrati

Il metodo Include() permette ora di caricare i dati correlati applicando condizioni alla query LINQ, per esempio è possibile effettuare query del genere:
var books = this._dbContext.Authors.Include(i => i.Books.Where(w =>
w.KindOfBook.Contains(“fantasy”)).OrderByDescending(ob=>ob.Rating).Take(10));
In questo modo, con una singola query al DB, è possibile recuperare gli autori i cui libri di genere fantasy rientrano nella top 10 con votazione più alta.
Le operazioni supportate sono: Where(), OrderBy(), OrderByDescending(), ThenBy(), ThenByDescending(), Skip() e Take().
Per quanto riguarda Include() multipli sulla stessa navigation property, è possibile condizionare uno solo degli Include() presenti, o, alternativamente, è possibile riscrivere lo stesso filtraggio.
Vediamo brevemente due esempi:

var query1_books = this._dbContext.Authors
.Include(i => i.Books.Where(w => w.KindOfBook.Contains("fantasy")))
.ThenInclude(ti => ti.BooksLibraries.Where(w => w.Library.Address.Contains("Firenze")))
.Include(i => i.Books)
.ThenInclude(ti => ti.Publisher);
var query1_books = this._dbContext.Authors
.Include(i => i.Books.Where(w => w.KindOfBook.Contains("fantasy")))
.ThenInclude(ti => ti.BooksLibraries.Where(w => w.Library.Address.Contains("Firenze")))
.Include(i => i.Books)
.ThenInclude(ti => ti.Publisher);


Forma alternativa:

var query2_books = this._dbContext.Authors
.Include(i => i.Books.Where(w => w.KindOfBook.Contains("fantasy")))
.ThenInclude(ti => ti.BooksLibraries.Where(w => w.Library.Address.Contains("Firenze")))
.Include(i => i.Books.Where(w => w.KindOfBook.Contains("fantasy"))))
.ThenInclude(ti => ti.Publisher);


In entrambi i casi, la query restituisce lo stesso risultato. Nel secondo caso, provando a cambiare anche solo una virgola della condizione (fantasy->Fantasy), la query non viene eseguita, ma l’eccezione è gestita e ci specifica che due filtri diversi sono stati applicati alla stessa navigation property:

Split di query per Include() e proiezioni

Da EF Core 3.0 in poi, tutte le query LINQ di EF generano una query SQL singola, in modo da assicurare consistenza ai dati recuperati dal DB. Tuttavia, l’utilizzo di molti Include e proiezioni in una singola query può causare rallentamenti nell’esecuzione della query stessa. Con EF Core 5.0 è possibile richiamare il metodo AsSplitQuery() che permette di eseguire query SQL separate per i dati correlati. Ad esempio, la query LINQ:

var query3_books = this._dbContext.Authors.AsSplitQuery()
.Include(i => i.Books).ThenInclude(ti=>ti.BooksLibraries).ToList();

genera le 3 seguenti query SQL:

SELECT b0.BookId, b0.LibraryId, a.AuthorId, b.BookId
FROM Authors AS a
INNER JOIN Books AS b ON a.AuthorId = b.AuthorId
INNER JOIN BooksLibraries AS b0 ON b.BookId = b0.BookId
ORDER BY a.AuthorId, b.BookId

SELECT b.BookId, b.AuthorId, b.KindOfBook, b.PublisherId, b.Rating, b.Title, a.AuthorId
FROM Authors AS a
INNER JOIN Books AS b ON a.AuthorId = b.AuthorId
ORDER BY a.AuthorId, b.BookId

SELECT a.AuthorId, a.Name, a.Surname
FROM Authors AS a
ORDER BY a.AuthorId


In modo simile, quando si applica il metodo AsSplitQuery() in presenza di una proiezione:

var query4_books = this._dbContext.Authors.AsSplitQuery()
.Select(author => new
{
Name = author.Name,
Surname = author.Surname,
Books = author.Books
})
.ToList();

vengono generate 2 query SQL: una recupera “Name” e “Surname” e l’altra effettua l’operazione di JOIN tra le tabelle Authors e Books.
Sarà poi compito di Entity ricostruire i dati recuperati dalle query “splittate” e mapparle nella lista di ritorno: nel caso query4 una lista di anonimi (List<’a>), mentre nel caso query3 una lista di Autori (List).
In conclusione, il risultato ottenuto utilizzando AsSplitQuery() dunque non cambia, bensì cambia il modo in cui vengono eseguite le query sul DB.

Accesso semplificato al SQL generato da EF

Il nuovo EF introduce l’extension method ToQueryString(), tramite il quale è possibile accedere in modo semplice alla query SQL generata da Entity in fase di compilazione. La query viene restituita sotto forma di stringa.

var query5_string = this._dbContext.Authors
.Include(i => i.Books).ToQueryString();

Osservazione: dopo aver visto l’AsSplitQuery() e il ToQueryString() mi sono domandato cosa sarebbe successo utilizzandoli in combo, ovvero:

var query6_string = this._dbContext.Authors.AsSplitQuery()
.Include(i => i.Books).ToQueryString();

purtroppo non quello che mi sarei aspettato, infatti nonostante AsSplitQuery(), che in questo caso genera 2 query SQL, il risultato che ci troviamo all’interno di query6_string è la trascrizione della seconda query SQL generata.

Nuovi Attributes

3 nuovi attributi sono presenti nella nuova versione di EF: [Keyless],[Index],[BackingField].
Il primo può essere applicato sopra le classi entità del modello e permette di creare la relativa tabella sul DB senza chiave primaria; funzione già presente nelle vecchie versioni di EF sotto forma della FluentAPI, HasNoKey().

[Keyless]
public class BookNoPK
{
public string Title { get; set; }
}

L’attributo [Index], già presente nelle vecchie versioni di EF, è stato modificato, infatti non si applica più sopra le singole proprietà, ma può essere applicato solo a livello di classe. Questa rivisitazione dell’attributo prevede il passaggio di alcuni parametri: uno obbligatorio (il nome della colonna o i nomi delle colonne per indici composti) e 2 opzionali (uno per l’unicità dell’indice e uno per il nome). Per definire un indice composto è sufficiente passare i nomi dei campi separati da virgole, mentre per definire più indici sulla stessa tabella è possibile duplicare l’attributo tante volte quanti sono gli indici che si desidera creare.

[Index(nameof(BirthDate), IsUnique = false, Name = "Index_BirthDate")]
[Index(nameof(Name), nameof(Surname), IsUnique = false, Name = "Index_CompleteName")]
public class Author
{
// …
[StringLength(20)]
public string Name { get; set; }
[StringLength(20)]
public string Surname { get; set; }
public DateTime BirthDate { get; set; }
// …
}

Per quanto riguarda l’attributo [BackingField], come nel primo caso, è la “versione attributo” della corrispettiva FluentAPI: HasField(). Questo attributo può essere applicato sopra le proprietà il cui nome non segue le convenzioni classiche (private string _myProperty => public string MyProperty). In questo modo Entity può leggere / scrivere da / sui campi privati, come succede normalmente anche se il campo privato non viene trovato automaticamente.

public class Book
{
private string _mainTitle;
[BackingField(nameof(_mainTitle))]
public string Title
{
get => _mainTitle;
set => _mainTitle = value;
}
}

FluentAPI HasPrecision()

Con questa nuova versione di EF Core è possibile specificare la precision e lo scale per i tipi decimal in fase di definizione nell’override del OnModelCreating() del DbContext:
modelBuilder.Entity().Property(p => p.Price).HasPrecision(5, 2);
Il primo parametro indica la precision (numero totale di cifre, sia prima che dopo la virgola) e con il secondo lo scale (numero di cifre dopo la virgola) che assumerà il campo nella tabella sul database.

Fill factor per gli indici per SQL Server

Per chi avesse necessità di manipolare le prestazioni del database, o, più nello specifico, di modificare il riempimento delle pagine di dati, Entity fornisce adesso una API (anch’essa aggiungibile in fase di definizione nell’override del OnModelCreating()), per impostare il fill factor di un indice:

modelBuilder.Entity().HasIndex("BirthDate").HasFillFactor(95);

Per chi, come me, non conoscesse il fill factor e le sue implicazioni, consiglio un rapido sguardo a questo articolo: https://www.sqlshack.com/sql-server-index-fill-factor-with-performance-benchmark/

Nuova API per il ModelBuilder per le navigation properties

Le navigation properties continuano ad essere definite in fase di configurazione delle relazioni, con le convenzioni di Entity o con le FluentAPI. Questa nuova API Navigation() infatti non va a modificare le relazioni, bensì permette di aggiungere configurazioni alle navigation properties delle relazioni già esistenti (o scansionate e rilevate da Entity). Per esempio, può accadere di avere la necessità di specificare le Annotation (HasAnnotation()) o i BackingField (HasField()):

modelBuilder.Entity().Navigation(nav => nav.BooksLibraries).HasField("_currentBooks");

Database collations

Con la nuova versione di EF Core è possibile definire in fase di modellazione del contesto (e non solo), nell’override del OnModelCreating(), la collation di default per il database, ovvero le regole di confronto. Il lavoro svolto sulle collation riguarda principalmente 3 livelli di applicabilità, proprio come in SQL Server: il contesto (l’intero DB), la singola classe entità (colonna di una tabella del DB) e la singola esecuzione di una query.
La collation di default, almeno sulla macchina dove sono stati effettuati gli esempi, è “SQL_Latin1_General_CP1_CI_AS” dove CI sta per CaseInsensitive e AS sta per per AccentSensitive, ovvero le regole di confronto che vengono applicate per tutte le colonne delle tabelle e per le query su tale database, se non diversamente specificati. Se vi è necessità di cambiare la collate a livello di database tramite EF Core, come anticipato, può essere effettuato agendo sul modelBuilder:

modelBuilder.UseCollation("SQL_Latin1_General_CP1_CI_AI");

Ad esempio, in questo modo la regola di confronto per il database sarà CaseInsensitive e AccentInsensitive (‘à’=’a’).
L’applicazione di una collation solo per una colonna è del tutto analoga a quella appena vista, con la differenza che nel modelBuilder andremo ad agire sulla proprietà:

modelBuilder
.Entity().Property(e => e.Name).UseCollation("SQL_Latin1_General_CP1_CI_AI");

in questo modo tutte le query eseguite sulla colonna “Name” della tabella “Author” utilizzeranno la collation con CaseInsensitive e AccentInsensitive.
Infine, all’insieme dei metodi CLR (Common Language Runtime) disposti da Entity, è stato aggiunto il metodo Collate() che genera la rispettiva query SQL per cambiare collation “al volo” durante l’esecuzione di una query:

var query7_collate = this._dbContext.Authors.Where(w =>
EF.Functions.Collate(w.Name, "SQL_Latin1_General_CP1_CI_AI") == "Niccolò").ToList();

Che genera la seguente query SQL:

Mapping del Discriminator completo

In presenza di un campo discriminator nella nuova versione di EF Core, eseguendo ad esempio una query LINQ per prendere tutti i record della tabella BookDiscriminators (this._dbContext.BookDiscriminators.ToList()), viene generata una query SQL senza WHERE IN:

Nelle versioni precedenti di EF Core il campo discriminator, aggiunto in presenza di ereditarietà con la gestione TPH (Table-Per-Hierarchy), generava SQL in una forma simile:

SELECT [b].[BookId], [b].[Discriminator], [b].[Title], [b].[Scope]
FROM [BookDiscriminators] AS [b]
WHERE [b].[Discriminator] IN (N' BookDiscriminator', N'Magazine')

inserendo sempre la condizione per il discriminator, quando il sottotipo non è conosciuto: inefficiente quando si eseguono query che non richiedono condizioni sul sottotipo.
Nota: è stata aggiunta la classe BookDiscriminator e Magazine che eredita da BookDiscriminator senza specificare nessuna opzione aggiuntiva, ma aggiungendo solamente i relativi DbSet nel context.

Savepoints

EF Core 5 introduce i savepoints, equivalenti a quelli utilizzati in SQL (come, ad esempio, nelle stored procedure), che fungono da punti sicuri dai cui ripartire a seguito di un rollback e risultano molto utili per situazioni in cui all’interno di una singola transaction si svolgono molte operazioni.
Per quanto riguarda EF il metodo si trova all’intero di una IDbContextTransaction():

dbContextTransaction.CreateSavepoint("author_added");

Che inserita in un contesto potrebbe assomigliare a qualcosa di questo tipo:

using (var dbContextTransaction = _dbContext.Database.BeginTransaction())
{
_dbContext.Authors.Add(new Author { // });
_dbContext.SaveChanges();
dbContextTransaction.CreateSavepoint("author_added");
_dbContext.Books.Add(new Book { //});
_dbContext.SaveChanges();
dbContextTransaction.RollbackToSavepoint("book_added");
dbContextTransaction.Commit();
}

Oltre alla gestione manuale dei savepoints, EF in autonomia crea sempre un savepoint quando viene invocato un SaveChanges() con un’altra transaction già aperta sul contesto e, se un errore (per esempio di concorrenza) si presenta, esegue un roll back a tale savepoint. Dopodiché prova a rieseguire la SaveChanges(), tutto questo senza cambiare stato alla transaction.

Relazioni Many to Many

Nelle relazioni molti a molti, con EF Core 5 viene autogenerata la tabella di collegamento, senza quindi doverla specificare manualmente come in passato. Portiamo ad esempio la relazione tra i seguenti due oggetti:

public class Buyer

 public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
public ICollection Books { get; set; }
}
public class Book
{
public int Id { get; set; }
// .. altre prop
public ICollection Buyers { get; set; }
}


viene tradotto in SQL (quando si genera e applica la migration) con tre Create e verrà generata automaticamente la tabella di collegamento con due chiavi esterne e una chiave primaria da quest’ultime:

Inoltre, nel momento in cui si effettua la insert di Book e Buyer, EF automaticamente va a riempire la tabella di collegamento. Per esempio:

var buyer1 = new Buyer { Name = "Don Diego", Surname = "De la Vega" };
var buyer2 = new Buyer { Name = "Kenshiro", Surname = "Kasumi" };
var book1 = new Book
{
// … altre prop
AuthorId = 3,
PublisherId = 1,
Buyers = new List { buyer1, buyer2 }
};
_dbContext.Add(book1);
_dbContext.SaveChanges();

aggiunge sia i Buyer (che non sono stati aggiunti esplicitamente), ma cosa più importante valorizza i record nella tabella di collegato BookBuyer.

Conclusioni

Abbiamo visto solo alcune delle tante nuove funzionalità, grandi e piccole che siano, che sono state aggiunte nella nuova versione di Entity Framework Core. Sicuramente una che ritengo molto interessante ed ergonomica, è la nuova Include() condizionata. Più volte mi è capitato di avere la necessità di aggiungere filtri ad una Include(), e sistematicamente dover ricorrere ad una libreria esterna per farlo (EntityFrameworkPlus).
In futuro mi piacerebbe vedere lavorare in combo il metodo AsSplitQuery() e il ToQueryString() che, come detto in precedenza, al momento non mostra tutte le query splittate, ma solamente l’ultima generata. Potrebbe senz’altro far comodo avere il quadro completo, a colpo d’occhio, di quali e quante query SQL vengono eseguite sul DB.