Convenzioni code first personalizzate

Nota

Solo EF6 e versioni successive: funzionalità, API e altri argomenti discussi in questa pagina sono stati introdotti in Entity Framework 6. Se si usa una versione precedente, le informazioni qui riportate, o parte di esse, non sono applicabili.

Quando si usa Code First, il modello viene calcolato dalle classi usando un set di convenzioni. Le convenzioni Code First predefinite determinano elementi come la proprietà che diventa la chiave primaria di un'entità, il nome della tabella a cui è mappata un'entità e la precisione e la scala di una colonna decimale per impostazione predefinita.

A volte queste convenzioni predefinite non sono ideali per il modello e devi risolverle configurando molte singole entità usando annotazioni dati o l'API Fluent. Le convenzioni code first personalizzate consentono di definire convenzioni personalizzate che forniscono le impostazioni predefinite di configurazione per il modello. In questa procedura dettagliata verranno esaminati i diversi tipi di convenzioni personalizzate e come crearne ognuno.

Convenzioni basate su modello

Questa pagina illustra l'API DbModelBuilder per le convenzioni personalizzate. Questa API deve essere sufficiente per la creazione della maggior parte delle convenzioni personalizzate. Esiste tuttavia anche la possibilità di creare convenzioni basate su modelli, convenzioni che modificano il modello finale dopo la creazione, per gestire scenari avanzati. Per altre informazioni, vedere Convenzioni basate su modello.

 

Il modello

Per iniziare, definire un modello semplice che è possibile usare con le convenzioni. Aggiungere le classi seguenti al progetto.

    using System;
    using System.Collections.Generic;
    using System.Data.Entity;
    using System.Linq;

    public class ProductContext : DbContext
    {
        static ProductContext()
        {
            Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ProductContext>());
        }

        public DbSet<Product> Products { get; set; }
    }

    public class Product
    {
        public int Key { get; set; }
        public string Name { get; set; }
        public decimal? Price { get; set; }
        public DateTime? ReleaseDate { get; set; }
        public ProductCategory Category { get; set; }
    }

    public class ProductCategory
    {
        public int Key { get; set; }
        public string Name { get; set; }
        public List<Product> Products { get; set; }
    }

 

Introduzione alle convenzioni personalizzate

Si scriverà una convenzione che configura qualsiasi proprietà denominata Key come chiave primaria per il tipo di entità.

Le convenzioni sono abilitate nel generatore di modelli, a cui è possibile accedere eseguendo l'override di OnModelCreating nel contesto. Aggiornare la classe ProductContext come segue:

    public class ProductContext : DbContext
    {
        static ProductContext()
        {
            Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ProductContext>());
        }

        public DbSet<Product> Products { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Properties()
                        .Where(p => p.Name == "Key")
                        .Configure(p => p.IsKey());
        }
    }

A questo momento, qualsiasi proprietà nel modello denominata Key verrà configurata come chiave primaria di qualsiasi entità di cui fa parte.

È anche possibile rendere le convenzioni più specifiche filtrando il tipo di proprietà che si intende configurare:

    modelBuilder.Properties<int>()
                .Where(p => p.Name == "Key")
                .Configure(p => p.IsKey());

Verranno configurate tutte le proprietà denominate Key come chiave primaria dell'entità, ma solo se sono numeri interi.

Una caratteristica interessante del metodo IsKey è che è additivi. Ciò significa che se si chiama IsKey su più proprietà e tutti diventeranno parte di una chiave composita. L'unica avvertenza è che quando si specificano più proprietà per una chiave è necessario specificare anche un ordine per tali proprietà. A tale scopo, chiamare il metodo HasColumnOrder come illustrato di seguito:

    modelBuilder.Properties<int>()
                .Where(x => x.Name == "Key")
                .Configure(x => x.IsKey().HasColumnOrder(1));

    modelBuilder.Properties()
                .Where(x => x.Name == "Name")
                .Configure(x => x.IsKey().HasColumnOrder(2));

Questo codice configurerà i tipi nel modello in modo da avere una chiave composta costituita dalla colonna chiave int e dalla colonna Nome stringa. Se si visualizza il modello nella finestra di progettazione, l'aspetto sarà simile al seguente:

composite Key

Un altro esempio di convenzioni di proprietà consiste nel configurare tutte le proprietà DateTime nel modello per eseguire il mapping al tipo datetime2 in SQL Server anziché a datetime. È possibile ottenere questo risultato con quanto segue:

    modelBuilder.Properties<DateTime>()
                .Configure(c => c.HasColumnType("datetime2"));

 

Classi convention

Un altro modo per definire le convenzioni consiste nell'usare una classe Convention per incapsulare la convenzione. Quando si usa una classe Convention, si crea un tipo che eredita dalla classe Convention nello spazio dei nomi System.Data.Entity.ModelConfiguration.Conventions.

È possibile creare una classe Convention con la convenzione datetime2 illustrata in precedenza eseguendo le operazioni seguenti:

    public class DateTime2Convention : Convention
    {
        public DateTime2Convention()
        {
            this.Properties<DateTime>()
                .Configure(c => c.HasColumnType("datetime2"));        
        }
    }

Per indicare a EF di usare questa convenzione, aggiungerla all'insieme Conventions in OnModelCreating, che se è stato seguito insieme alla procedura dettagliata sarà simile alla seguente:

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Properties<int>()
                    .Where(p => p.Name.EndsWith("Key"))
                    .Configure(p => p.IsKey());

        modelBuilder.Conventions.Add(new DateTime2Convention());
    }

Come si può notare, aggiungere un'istanza della convenzione alla raccolta di convenzioni. L'ereditarietà da Convention consente di raggruppare e condividere convenzioni tra team o progetti. È ad esempio possibile avere una libreria di classi con un set comune di convenzioni usate da tutti i progetti dell'organizzazione.

 

Attributi personalizzati

Un altro uso ottimale delle convenzioni consiste nell'abilitare nuovi attributi da usare durante la configurazione di un modello. Per illustrare questo problema, verrà creato un attributo che è possibile usare per contrassegnare le proprietà String come non Unicode.

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class NonUnicode : Attribute
    {
    }

Si creerà ora una convenzione per applicare questo attributo al modello:

    modelBuilder.Properties()
                .Where(x => x.GetCustomAttributes(false).OfType<NonUnicode>().Any())
                .Configure(c => c.IsUnicode(false));

Con questa convenzione è possibile aggiungere l'attributo NonUnicode a una delle proprietà stringa, ovvero la colonna nel database verrà archiviata come varchar anziché nvarchar.

Una cosa da notare su questa convenzione è che se si inserisce l'attributo NonUnicode su qualsiasi proprietà diversa da una proprietà stringa, verrà generata un'eccezione. Questa operazione viene eseguita perché non è possibile configurare IsUnicode in qualsiasi tipo diverso da una stringa. In questo caso, è possibile rendere la convenzione più specifica, in modo da escludere qualsiasi elemento che non sia una stringa.

Sebbene la convenzione precedente funzioni per la definizione di attributi personalizzati, esiste un'altra API che può essere molto più semplice da usare, soprattutto quando si vogliono usare le proprietà della classe di attributi.

Per questo esempio si aggiornerà l'attributo e lo si modificherà in un attributo IsUnicode, in modo che abbia un aspetto simile al seguente:

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    internal class IsUnicode : Attribute
    {
        public bool Unicode { get; set; }

        public IsUnicode(bool isUnicode)
        {
            Unicode = isUnicode;
        }
    }

Una volta ottenuto questo valore, è possibile impostare un valore bool sull'attributo per indicare alla convenzione se una proprietà deve essere Unicode. È possibile eseguire questa operazione nella convenzione già accedendo a ClrProperty della classe di configurazione come illustrato di seguito:

    modelBuilder.Properties()
                .Where(x => x.GetCustomAttributes(false).OfType<IsUnicode>().Any())
                .Configure(c => c.IsUnicode(c.ClrPropertyInfo.GetCustomAttribute<IsUnicode>().Unicode));

Questo è abbastanza semplice, ma c'è un modo più conciso di raggiungere questo risultato usando il metodo Having dell'API delle convenzioni. Il metodo Having ha un parametro di tipo Func<PropertyInfo, T> che accetta PropertyInfo come il metodo Where, ma deve restituire un oggetto . Se l'oggetto restituito è Null, la proprietà non verrà configurata, il che significa che è possibile filtrare le proprietà con esso esattamente come Where, ma è diverso in quanto acquisirà anche l'oggetto restituito e lo passerà al metodo Configure. Questo funzionamento è simile al seguente:

    modelBuilder.Properties()
                .Having(x => x.GetCustomAttributes(false).OfType<IsUnicode>().FirstOrDefault())
                .Configure((config, att) => config.IsUnicode(att.Unicode));

Gli attributi personalizzati non sono l'unico motivo per usare il metodo Having, è utile ovunque sia necessario ragionare su un elemento che si sta filtrando quando si configurano i tipi o le proprietà.

 

Configurazione dei tipi

Finora tutte le convenzioni sono state per le proprietà, ma esiste un'altra area dell'API delle convenzioni per la configurazione dei tipi nel modello. L'esperienza è simile alle convenzioni illustrate finora, ma le opzioni all'interno della configurazione saranno a livello di entità anziché a livello di proprietà.

Uno degli aspetti che le convenzioni a livello di tipo possono essere davvero utili per è la modifica della convenzione di denominazione della tabella, per eseguire il mapping a uno schema esistente diverso dall'impostazione predefinita di Entity Framework o per creare un nuovo database con una convenzione di denominazione diversa. A tale scopo, è necessario innanzitutto un metodo in grado di accettare TypeInfo per un tipo nel modello e restituire il nome della tabella per tale tipo:

    private string GetTableName(Type type)
    {
        var result = Regex.Replace(type.Name, ".[A-Z]", m => m.Value[0] + "_" + m.Value[1]);

        return result.ToLower();
    }

Questo metodo accetta un tipo e restituisce una stringa che usa lettere minuscole con caratteri di sottolineatura anziché CamelCase. Nel modello questo significa che la classe ProductCategory verrà mappata a una tabella denominata product_category anziché ProductCategories.

Una volta ottenuto questo metodo, è possibile chiamarlo in una convenzione simile alla seguente:

    modelBuilder.Types()
                .Configure(c => c.ToTable(GetTableName(c.ClrType)));

Questa convenzione configura ogni tipo nel modello per eseguire il mapping al nome della tabella restituito dal metodo GetTableName. Questa convenzione equivale a chiamare il metodo ToTable per ogni entità nel modello usando l'API Fluent.

Una cosa da notare è che quando si chiama ToTable EF accetta la stringa specificata come nome esatto della tabella, senza alcuna pluralizzazione che normalmente farebbe quando si determinano i nomi di tabella. Questo è il motivo per cui il nome della tabella della convenzione è product_category anziché product_categories. Possiamo risolvere questo problema nella convenzione effettuando una chiamata al servizio di pluralizzazione noi stessi.

Nel codice seguente si userà la funzionalità risoluzione delle dipendenze aggiunta in EF6 per recuperare il servizio di pluralizzazione usato da EF e pluralizzare il nome della tabella.

    private string GetTableName(Type type)
    {
        var pluralizationService = DbConfiguration.DependencyResolver.GetService<IPluralizationService>();

        var result = pluralizationService.Pluralize(type.Name);

        result = Regex.Replace(result, ".[A-Z]", m => m.Value[0] + "_" + m.Value[1]);

        return result.ToLower();
    }

Nota

La versione generica di GetService è un metodo di estensione nello spazio dei nomi System.Data.Entity.Infrastructure.DependencyResolution. Per usarla, è necessario aggiungere un'istruzione using al contesto.

ToTable e ereditarietà

Un altro aspetto importante di ToTable è che, se si esegue il mapping esplicito di un tipo a una determinata tabella, è possibile modificare la strategia di mapping che verrà usata da Entity Framework. Se si chiama ToTable per ogni tipo in una gerarchia di ereditarietà, passando il nome del tipo come il nome della tabella come illustrato in precedenza, si modificherà la strategia di mapping predefinita Table-Per-Hierarchy (TPH) a Table-Per-Type (TPT). Il modo migliore per descrivere questo è un esempio concreto:

    public class Employee
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }

    public class Manager : Employee
    {
        public string SectionManaged { get; set; }
    }

Per impostazione predefinita, sia i dipendenti che i manager vengono mappati alla stessa tabella (Dipendenti) nel database. La tabella conterrà sia dipendenti che responsabili con una colonna discriminatoria che indicherà il tipo di istanza archiviato in ogni riga. Si tratta del mapping TPH perché è presente una singola tabella per la gerarchia. Tuttavia, se si chiama ToTable in entrambe le classi, ogni tipo verrà invece mappato alla propria tabella, noto anche come TPT perché ogni tipo ha una propria tabella.

    modelBuilder.Types()
                .Configure(c=>c.ToTable(c.ClrType.Name));

Il codice precedente verrà mappato a una struttura di tabella simile alla seguente:

tpt Example

È possibile evitare questo problema e mantenere il mapping TPH predefinito, in due modi:

  1. Chiamare ToTable con lo stesso nome di tabella per ogni tipo nella gerarchia.
  2. Chiamare ToTable solo sulla classe di base della gerarchia, nell'esempio che sarebbe dipendente.

 

Ordine di esecuzione

Le convenzioni funzionano in modo vincente, come l'API Fluent. Ciò significa che se si scrivono due convenzioni che configurano la stessa opzione della stessa proprietà, l'ultima da eseguire vince. Ad esempio, nel codice sotto la lunghezza massima di tutte le stringhe è impostato su 500, ma vengono quindi configurate tutte le proprietà denominate Name nel modello in modo che abbiano una lunghezza massima di 250.

    modelBuilder.Properties<string>()
                .Configure(c => c.HasMaxLength(500));

    modelBuilder.Properties<string>()
                .Where(x => x.Name == "Name")
                .Configure(c => c.HasMaxLength(250));

Poiché la convenzione per impostare la lunghezza massima su 250 è successiva a quella che imposta tutte le stringhe su 500, tutte le proprietà denominate Name nel modello avranno un valore MaxLength di 250 mentre qualsiasi altra stringa, ad esempio le descrizioni, sarà 500. L'uso di convenzioni in questo modo significa che è possibile fornire una convenzione generale per i tipi o le proprietà nel modello e quindi eseguirne l'overide per i subset diversi.

L'API Fluent e le annotazioni dei dati possono essere usate anche per eseguire l'override di una convenzione in casi specifici. Nell'esempio precedente, se è stata usata l'API Fluent per impostare la lunghezza massima di una proprietà, è possibile inserirla prima o dopo la convenzione, perché l'API Fluent più specifica vincerà la convenzione di configurazione più generale.

 

Convenzioni predefinite

Poiché le convenzioni personalizzate potrebbero essere interessate dalle convenzioni Code First predefinite, può essere utile aggiungere convenzioni da eseguire prima o dopo un'altra convenzione. A tale scopo, è possibile utilizzare i metodi AddBefore e AddAfter dell'insieme Conventions nel dbContext derivato. Il codice seguente aggiunge la classe di convenzione creata in precedenza in modo che venga eseguita prima della convenzione di individuazione delle chiavi predefinita.

    modelBuilder.Conventions.AddBefore<IdKeyDiscoveryConvention>(new DateTime2Convention());

Questo sarà il più usato quando si aggiungono convenzioni che devono essere eseguite prima o dopo le convenzioni predefinite, un elenco delle convenzioni predefinite è disponibile qui: Spazio dei nomi System.Data.Entity.ModelConfiguration.Conventions.

È anche possibile rimuovere le convenzioni che non si desidera applicare al modello. Per rimuovere una convenzione, utilizzare il metodo Remove. Di seguito è riportato un esempio di rimozione di PluralizingTableNameConvention.

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
    }