Convenciones personalizadas de Code First

Nota:

Solo EF6 y versiones posteriores: las características, las API, etc. que se tratan en esta página se han incluido a partir de Entity Framework 6. Si usa una versión anterior, no se aplica parte o la totalidad de la información.

Cuando se usa Code First, el modelo se calcula a partir de las clases mediante un conjunto de convenciones. Las convenciones de Code First predeterminadas determinan aspectos como qué propiedad se convierte en la clave principal de una entidad, el nombre de la tabla a la que se asigna una entidad y qué precisión y escala tiene una columna decimal de manera predeterminada.

A veces, estas convenciones predeterminadas no son las ideales para el modelo y es necesario solucionarlas configurando muchas entidades individuales mediante anotaciones de datos o la API de Fluent. Las convenciones personalizadas de Code First permiten definir sus propias convenciones, que proporcionan valores predeterminados de configuración para el modelo. En este tutorial, exploraremos los distintos tipos de convenciones personalizadas y cómo crear cada una de ellas.

Convenciones basadas en un modelo

En esta página, se describe la API DbModelBuilder para convenciones personalizadas. Esta API debe ser suficiente para crear la mayoría de las convenciones personalizadas. Sin embargo, también existe la capacidad de crear convenciones basadas en un modelo (convenciones que manipulan el modelo final una vez creado) para controlar escenarios avanzados. Para más información, consulte Convenciones basadas en un modelo.

 

Nuestro modelo

Comencemos por definir un modelo sencillo que podemos usar con nuestras convenciones. Agregue las siguientes clases al proyecto.

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

 

Introducción a las convenciones personalizadas

Vamos a escribir una convención que configure cualquier propiedad llamada Key para que sea la clave principal de su tipo de entidad.

Las convenciones están habilitadas en el generador de modelos, a las que se puede acceder mediante la invalidación de OnModelCreating en el contexto. Actualice la clase ProductContext de la siguiente manera:

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

Ahora, cualquier propiedad del modelo llamada Key se configurará como la clave principal de cualquier entidad de la que forme parte.

También podríamos hacer que nuestras convenciones sean más específicas filtrando por el tipo de propiedad que vamos a configurar:

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

Esto configurará todas las propiedades llamadas Key para que sean la clave principal de su entidad, pero solo si son un número entero.

Una característica interesante del método IsKey es que es aditivo. Esto significa que si llama a IsKey con varias propiedades, todas se convertirán en parte de una clave compuesta. La única advertencia para esto es que, al especificar varias propiedades para una clave, también debe especificar un orden para esas propiedades. Para ello, llame al método HasColumnOrder como se indica a continuación:

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

Este código configurará los tipos de nuestro modelo para que tengan una clave compuesta formada por la columna de número entero Key y la columna de cadena Name. Si vemos el modelo en el diseñador, tendría este aspecto:

composite Key

Otro ejemplo de convenciones de propiedad sería configurar todas las propiedades DateTime del modelo para asignar el tipo datetime2 en SQL Server en lugar de datetime. Puede lograrlo de la siguiente manera:

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

 

Clases Convention

Otra manera de definir convenciones es usar una clase Convention para encapsular la convención. Cuando se usa una clase Convention, se crea un tipo que hereda de la clase Convention del espacio de nombres System.Data.Entity.ModelConfiguration.Conventions.

Podemos crear una clase Convention con la convención datetime2 que mostramos anteriormente mediante lo siguiente:

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

Para indicar a EF que use esta convención, agréguela a la colección Conventions en OnModelCreating, que, si ha seguido el tutorial, tendrá este aspecto:

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

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

Como puede ver, agregamos una instancia de nuestra convención a la colección de convenciones. Heredar de la clase Convention proporciona una forma cómoda de agrupar y compartir convenciones entre equipos o proyectos. Por ejemplo, podría tener una biblioteca de clases con un conjunto común de convenciones que se usan en todos los proyectos de las organizaciones.

 

Atributos personalizados

Otro gran uso de las convenciones es permitir que se usen nuevos atributos al configurar un modelo. Para ilustrar esto, vamos a crear un atributo que podemos usar para marcar las propiedades de tipo String como no Unicode.

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

Ahora, vamos a crear una convención para aplicar este atributo al modelo:

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

Con esta convención, podemos agregar el atributo NonUnicode a cualquiera de nuestras propiedades de cadena, lo que significa que la columna de la base de datos se almacenará como varchar en lugar de nvarchar.

Una cosa que hay que tener en cuenta sobre esta convención es que si coloca el atributo NonUnicode en algo distinto de una propiedad de cadena, se producirá una excepción. Esto se debe a que no se puede configurar IsUnicode en un tipo que no sea una cadena. Si esto sucede, puede hacer que la convención sea más específica, de modo que filtre todo lo que no sea una cadena.

Aunque la convención anterior funciona para definir atributos personalizados, hay otra API que puede ser mucho más fácil de usar, especialmente cuando desea usar propiedades de la clase de atributo.

En este ejemplo, vamos a actualizar el atributo y cambiarlo a un atributo IsUnicode, por lo que tendrá el siguiente aspecto:

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

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

Una vez que tengamos esto, podemos establecer un valor booleano en el atributo para indicar a la convención si una propiedad debe ser Unicode o no. Podríamos hacerlo en la convención que ya tenemos accediendo al elemento ClrProperty de la clase de configuración de la siguiente manera:

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

Esto es lo suficientemente fácil, pero hay una manera más sucinta de lograrlo con el método Having de la API de convenciones. El método Having tiene un parámetro de tipo Func<PropertyInfo, T> que acepta el elemento PropertyInfo igual que el método Where, pero se espera que devuelva un objeto. Si el objeto devuelto es null, la propiedad no se configurará, lo que significa que puede filtrar las propiedades igual que con Where, pero se diferencia en que también capturará el objeto devuelto y lo pasará al método Configure. Esto funciona de la siguiente manera:

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

Los atributos personalizados no son la única razón para usar el método Having, es útil en cualquier lugar en el que necesite razonar sobre algo por lo que se filtra al configurar los tipos o las propiedades.

 

Configuración de tipos

Hasta ahora, todas nuestras convenciones han sido para las propiedades, pero hay otra área de la API de convenciones para configurar los tipos en el modelo. La experiencia es similar a las convenciones que hemos visto hasta ahora, pero las opciones dentro de la configuración estarán en la entidad en lugar de en el nivel de propiedad.

Una de las cosas para las que las convenciones de nivel de tipo pueden ser realmente útiles es para cambiar la convención de nomenclatura de las tablas, ya sea para asignarlas a un esquema existente que difiera del predeterminado de EF o para crear una nueva base de datos con una convención de nomenclatura diferente. Para ello, primero necesitamos un método que pueda aceptar TypeInfo para un tipo de nuestro modelo y devolver cuál debería ser el nombre de tabla para ese tipo:

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

        return result.ToLower();
    }

Este método toma un tipo y devuelve una cadena que usa minúsculas con caracteres de subrayado en lugar de CamelCase. En nuestro modelo, esto significa que la clase ProductCategory se asignará a una tabla llamada product_category en lugar de ProductCategories.

Una vez que tengamos ese método, podemos llamarlo en una convención de la siguiente manera:

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

Esta convención configura cada tipo del modelo para asignarlo al nombre de la tabla que se devuelve desde el método GetTableName. Esta convención es equivalente a llamar al método ToTable para cada entidad del modelo mediante la API de Fluent.

Una cosa que hay que tener en cuenta es que, cuando se llama a ToTable de EF, se toma la cadena que se proporciona como el nombre exacto de la tabla, sin ninguna de las pluralizaciones que normalmente se harían al determinar los nombres de tabla. Este es el motivo por el que el nombre de la tabla de nuestra convención es product_category en lugar de product_categories. Podemos resolverlo en nuestra convención haciendo una llamada al servicio de pluralización nosotros mismos.

En el código siguiente, usaremos la característica de resolución de dependencias agregada en EF6 para recuperar el servicio de pluralización que habría usado EF y pluralizar el nombre de la tabla.

    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 versión genérica de GetService es un método de extensión del espacio de nombres System.Data.Entity.Infrastructure.DependencyResolution, deberá agregar una instrucción using al contexto para poder usarlo.

ToTable y herencia

Otro aspecto importante de ToTable es que, si asigna explícitamente un tipo a una tabla determinada, puede modificar la estrategia de asignación que usará EF. Si llama a ToTable para cada tipo de una jerarquía de herencia pasando el nombre del tipo como el nombre de la tabla como hicimos anteriormente, cambiará la estrategia de asignación predeterminada de tabla por jerarquía (TPH) a tabla por tipo (TPT). La mejor manera de describir esto es un ejemplo específico:

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

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

De manera predeterminada, tanto los empleados como el administrador se asignan a la misma tabla (Employees) de la base de datos. La tabla contendrá tanto empleados como administradores, con una columna discriminante que indicará qué tipo de instancia se almacena en cada fila. Esta es una asignación de tabla por jerarquía, ya que hay una sola tabla para la jerarquía. Sin embargo, si llama a ToTable en ambas clases, cada tipo se asignará a su propia tabla, lo que se conoce como tabla por tipo, ya que cada tipo tiene su propia tabla.

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

El código anterior realizará una asignación a una estructura de tablas similar a la siguiente:

tpt Example

Puede evitarlo y mantener la asignación de tabla por jerarquía predeterminada de dos maneras:

  1. Llamar a ToTable con el mismo nombre de tabla para cada tipo de la jerarquía.
  2. Llamar a ToTable solo en la clase base de la jerarquía, que en nuestro ejemplo sería la de empleado.

 

Pedido de ejecución

Las convenciones funcionan de forma que la última gana, igual que la API de Fluent. Esto significa que si escribe dos convenciones que configuran la misma opción de la misma propiedad, la última que se ejecute gana. Por ejemplo, en el siguiente código, se establece en 500 la longitud máxima de todas las cadenas, pero después se configuran todas las propiedades llamadas Name del modelo para que tengan una longitud máxima de 250.

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

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

Dado que la convención para establecer la longitud máxima en 250 es posterior a la que establece todas las cadenas en 500, todas las propiedades llamadas Name del modelo tendrán un valor de MaxLength de 250, mientras que en cualquier otra cadena, como las descripciones, sería de 500. El uso de las convenciones de esta manera significa que puede proporcionar una convención general para los tipos o propiedades del modelo y, a continuación, invalidarla para subconjuntos que son diferentes.

También se pueden usar la API de Fluent y las anotaciones de datos para invalidar una convención en casos específicos. En nuestro ejemplo anterior, si hubiéramos usado la API de Fluent para establecer la longitud máxima de una propiedad, podríamos haberla puesto antes o después de la convención, ya que la API de Fluent más específica ganará sobre la convención de configuración más general.

 

Convenciones integradas

Dado que las convenciones personalizadas podrían verse afectadas por las convenciones predeterminadas de Code First, puede resultar útil agregar convenciones para que se ejecuten antes o después de otra convención. Para ello, puede usar los métodos AddBefore y AddAfter de la colección Conventions en el elemento DbContext derivado. El código siguiente agregaría la clase de convención que creamos anteriormente para que se ejecute antes de la convención integrada de detección de claves.

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

Esto va a tener su mayor uso al agregar convenciones que deben ejecutarse antes o después de las convenciones integradas; se puede encontrar una lista de las convenciones integradas aquí: Espacio de nombres System.Data.Entity.ModelConfiguration.Conventions.

También puede quitar convenciones que no quiera aplicar al modelo. Para quitar una convención, use el método Remove. Este es un ejemplo de cómo quitar PluralizingTableNameConvention.

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