Entity Framework 6.0 code first y TPT, persistencia de colecciones polimórficas

Muchas veces las estructuras de datos que necesitamos para nuestra aplicación no son tan sencillas como los ejemplos ideales que encontramos en muchos manuales. A veces necesitamos mecanismos más avanzados para la persistencia de nuestras entidades.

En el artículo de hoy, vamos a crear una estructura de entidades que contiene una colección de elementos polimórfica, es decir, que puede contener objetos de varios tipos, heredando todos ellos de una clase padre común.

El problema de las colecciones polimórficas es que, aunque todos sus componentes heredan de una clase común, no comparten las mismas propiedades en su implementación, con lo cual no pueden ser guardadas en la misma tabla de la base de datos, sino que necesitan una diferente para cada tipo de entidad final.

Este problema es muy común aunque no es fácil de explicar. Así que he creado un pequeño ejemplo en un contexto real para intentar explicarlo mejor, usando Entity Framework 6.0 Code First con entidades POCO y la característica TPT (Table per Type) que nos permite hacer EF 6.0 y nos facilita muchísimo la vida.

El artículo incluye el enlace a mi repositorio Git, donde podéis encontrar una solución de ejemplo totalmente ejecutable, que os permitirá ver por vosotros mismos el resultado.

Descarga el proyecto en formato .ZIP
Accede al repositorio GIT

Modelo de datos

Para el ejemplo que nos ocupa, en nuestro escenario imaginario necesitamos una aplicación capaz de mantener una lista de coches junto con todo su despiece. Para hacerlo sencillo, en nuestra clase Coche (Car), solo tendremos el modelo y las piezas que lo componen, con tres posibles tipos piezas: Rueda (Wheel), Puerta (Door) y Asiento (Seat).


Partiremos del punto donde un coche puede tener n piezas, pero en vez de hacer una colección para cada tipo de pieza, haremos una clase única llamada Pieza (CarPart) y tendremos una sola colección de CarPart que contenga todas las piezas aunque sean diferentes. Ésto se consigue haciendo que todas las piezas (Door, Wheel y Seat) hereden directamente de CarPart, que solamente contendrá un nº de serie (todas las piezas deben tener uno).

El problema al que nos enfrentamos al guardar o leer estos datos de nuestra base de datos es que los datos de las piezas no son homogéneos, es decir, una Rueda guardará su ancho, radio, marca, etc… mientras que una puerta guardará su posición delantera, trasera, izquierda o derecha, y un asiento guardará el material, el tipo, etc… con lo cual cada entidad debería ir en una tabla separada.

Por arquitectura de aplicación, nos es mucho más fácil tenerlas todas en una sola colección de CarPart, que no en colecciones separadas para cada tipo de pieza.

Aunque normalmente ésto requeriría código algo complejo en nuestra implementación de la infraestructura, teniendo que comprobar cada objeto uno a uno para guardarlo en su tabla, mediante TPT de Entity Framework se convierte en algo trivial.

Creando nuestro dominio

Comenzaremos creando nuestras entidades POCO. BaseEntity es nuestra entidad base que solamente contiene una propiedad long Id

    public class BaseEntity
    {
        public long Id { get; set; }
    }

La clase central de nuestro dominio es Car, que contiene un modelo y la colección de piezas del coche.

    public class Car : BaseEntity
    {
        public string Model { get; set; }
        public virtual ICollection<CarPart> Parts { get; set; }
    }

La entidad CarPart es nuestro nexo de unión entre las piezas y el coche. Solo contiene la Id del coche al que pertenece (CarId) y un número de serie que será común a todos los tipos de pieza.

    public class CarPart : BaseEntity
    {
        public long CarId { get; set; }
        public virtual Car Car { get; set; }
        public string SerialNo { get; set; }
    }

A partir de aquí, definiremos las clases para cada pieza, heredando de CarPart

    public class Door : CarPart
    {
        public enum DoorPositionEnum
        {
            FrontLeft = 0,
            FrontRight = 1,
            RearLeft = 2,
            RearRight = 3
        }

        public DoorPositionEnum Position { get; set; }
    }
    public class Seat : CarPart
    {
        public enum SeatMaterialEnum
        {
            Fabric = 0,
            Leather = 1
        }

        public enum SeatTypeEnum
        {
            Comfort = 0,
            Sportive = 1
        }

        public string Brand { get; set; }
        public SeatMaterialEnum Material { get; set; }
        public SeatTypeEnum Type { get; set; }
    }
    public class Wheel : CarPart
    {
        public double Radius { get; set; }
        public double Width { get; set; }
        public string Brand { get; set; }
    }

En el proyecto de ejemplo, las clases de pieza tienen constructor y un override de su .ToString() para poder imprimirlas fácil en la consola.

Una vez creadas las entidades POCO, hay que preparar el proyecto de infraestructura de Entity Framework 6.0, y crear los mapeadores de cada clase a base de datos.

Mapeando las entidades a EF 6.0 Code First

Creamos una clase base para mapear la propiedad Id de todas las entidades

    public class BaseEntityConfiguration<TEntity> : EntityTypeConfiguration<TEntity>
        where TEntity : BaseEntity
    {
        public BaseEntityConfiguration()
        {
            this.HasKey(t => t.Id);
            this.Property(t => t.Id)
                .HasDatabaseGeneratedOption(System.ComponentModel.DataAnnotations
                                             .Schema.DatabaseGeneratedOption.Identity);
        }
    }

Como se puede ver en el ejemplo, indicamos que la clave principal de la tabla va a ser siempre Id con HasKey y le añadimos una identity a ese campo para se vayan generando las Id automáticamente en base de datos.

Seguimos con las demás entidades de mapeo:

    public class CarMap : BaseEntityConfiguration<Car>
    {
        public CarMap() : base()
        {
            this.ToTable("Cars");
            this.Property(t => t.Model)
                .IsRequired();
        }
    }

… indicando en cada caso a que tabla se van a enviar esas entidades con ToTable y marcando como requeridas las propiedades necesarias con IsRequired

    public class DoorMap : BaseEntityConfiguration<Door>
    {
        public DoorMap()
        {
            this.ToTable("Doors");

            this.Property(t => t.Position).IsRequired();
        }
    }
    public class SeatMap : BaseEntityConfiguration<Seat>
    {
        public SeatMap()
        {
            this.ToTable("Seats");

            this.Property(t => t.Brand).IsRequired();
            this.Property(t => t.Material).IsRequired();
            this.Property(t => t.Type).IsRequired();
        }
    }
    public class WheelMap : BaseEntityConfiguration<Wheel>
    {
        public WheelMap()
        {
            this.ToTable("Wheels");

            this.Property(t => t.Brand).IsRequired();
            this.Property(t => t.Radius).IsRequired();
            this.Property(t => t.Width).IsRequired();
        }
    }

La verdadera “magia” de EF 6.0 es cuando definimos el mapeo de la clase CarPart. Como hemos visto antes, nuestro coche tendrá una colección de CarPart llamada Parts que contendrá objetos de cualquiera de los tipos que heredan de CarPart. Lo que tenemos que decirle a Entity Framework es qué tiene que hacer con cada clase por separado de esta manera:

    public class CarPartMap : BaseEntityConfiguration<CarPart>
    {
        public CarPartMap() : base()
        {
            this.ToTable("Parts");

            this.Property(t => t.SerialNo)
                .IsRequired();

            this.Map<Wheel>(t => t.ToTable("Wheels"))
                .Map<Door>(t => t.ToTable("Doors"))
                .Map<Seat>(t => t.ToTable("Seats"));

            this.HasRequired(t => t.Car)
                .WithMany(car => car.Parts)
                .HasForeignKey(t => t.CarId);

        }
    }

Vamos a analizar este mapeo. Primero indicamos que se va a crear una tabla “Parts”. Ésta tabla será una tabla intermedia entre el coche y sus piezas.
Acto seguido, indicamos a que tabla debe enviar cada clase utilizando el método Map<T>.
Y finalmente, creamos la relación uno a muchos entre el coche y sus partes.

Con esta información, Entity Framework ya sabe qué tiene que hacer con cada entidad de nuestro dominio. Ahora vamos a ver cómo lo ponemos todo junto en una base de datos.

Creando el contexto

Una vez finalizado el mapeado de las entidades, ahora vamos crear un contexto de base de datos para poder acceder a ellas:

    public class CarBoundContext : DbContext
    {

        public CarBoundContext(): base("CarsDB")
        {
            Database.SetInitializer(new CreateDatabaseIfNotExists<CarBoundContext>());
        }

        public DbSet<Car> Cars { get; set; }
        public DbSet<Door> Doors { get; set; }
        public DbSet<Wheel> Wheels { get; set; }
        public DbSet<Seat> Seats { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Configurations.Add(new CarMap());
            modelBuilder.Configurations.Add(new DoorMap());
            modelBuilder.Configurations.Add(new WheelMap());
            modelBuilder.Configurations.Add(new SeatMap());

            base.OnModelCreating(modelBuilder);
        }
    }

Nuestra clase CarBoundContext hereda de la clase de contexto de Entity Framework DbContext, haciendo override de su métido OnModelCreating para añadir las clases de mapeo que hemos creado antes usando modelBuilder.Configurations.Add(), y declarar que tablas o DbSet vamos a usar. Especial atención a que no hay DbSet para la clase CarPart ya que es una clase puente… pero si quisiesemos acceder directamente a esta tabla podríamos ponerlo sin problema.

En el constructor indicamos que solamente se cree la base de datos si no existe ya con SetInitializer.

Y ya tenemos el contexto creado!

La prueba

Una vez montado nuestro dominio y contexto de base de datos, es el momento de probar. Creamos una aplicación de consola que generará un objeto de tipo Car con varias piezas en su colección Parts, para, acto seguido guardarla en base de datos, y volver a leerla en otra variable.

    class Program
    {

        private static Random _seed = new Random();

        static string NewSerialNumber()
        {
            return string.Format("{0:00000000}", _seed.NextDouble() * (double)100000000);
        }

        static Car NewCar()
        {
            return new Car
            {
                Model = "Chevrolet Cruze LTZ",
                Parts = new List<CarPart>
                {
                    new Wheel(NewSerialNumber(), "Bridgestone",17.0,225.0),
                    new Wheel(NewSerialNumber(), "Bridgestone",17.0,225.0),
                    new Wheel(NewSerialNumber(), "Bridgestone",17.0,225.0),
                    new Wheel(NewSerialNumber(), "Bridgestone",17.0,225.0),
                    new Door(NewSerialNumber(), Door.DoorPositionEnum.FrontLeft),
                    new Door(NewSerialNumber(), Door.DoorPositionEnum.FrontRight),
                    new Seat(NewSerialNumber(),"Sparco",Seat.SeatMaterialEnum.Leather,Seat.SeatTypeEnum.Sportive),
                    new Seat(NewSerialNumber(), "Sparco",Seat.SeatMaterialEnum.Fabric,Seat.SeatTypeEnum.Comfort)
                }
            };
        }

        static void PrintParts(string partName, IEnumerable<CarPart> parts)
        {
            Console.WriteLine("*** {0} ({1}):", partName, parts.Count());
            parts.ToList().ForEach(Console.WriteLine);
        }

        static void Main(string[] args)
        {
            // The database will be created at bin/Debug/ by default
            AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ""));
            // Get a new context for de DB (always "using" to dispose the connection)
            using (var context = new CarBoundContext())
            {
                // If no records, let's create one...
                if (!context.Cars.Any())
                {
                    context.Cars.Add(NewCar());
                    context.SaveChanges();
                }
                // Fetch the record and display its parts!
                var car = context.Cars.First();
                Console.WriteLine("Model: {0}", car.Model);
                Console.WriteLine("Total parts: {0}", car.Parts.Count);

                PrintParts("Doors", car.Parts.Where(part => part is Door));
                PrintParts("Seats", car.Parts.Where(part => part is Seat));
                PrintParts("Wheels", car.Parts.Where(part => part is Wheel));

                Console.ReadLine();
            }
        }
    }

Como se puede ver, solamente haciendo un .Add(NewCar()) todo lo demás ocurre automágicamente bajo el capó, y es totalmente transparente. Se guarda una row en la tabla Car, 8 rows en CarParts apuntando a cada pieza y al coche, y cada pieza diferente va a su tabla definida.

Al leer de nuevo la entidad con context.Cars.First(), de nuevo, la magia del TPT vuelve a buscar los elementos en las tablas necesarias y los añade a la colección polimórfica Parts, donde podemos acceder a todos ellos o filtrar por tipo mediante un .Where(part => part is XXXXXX).

Si ejecutamos la solución obtendremos la siguiente salida en consola:

4AA2255A-41E4-58E3-8C2C-98ADAF2BB45E.jpg

Pero, como ha quedado la BBDD?

Con EF 6.0, es fácil no darse cuenta de lo que está pasando por debajo, así que es bueno echar un ojo a la BBDD que nos ha creado:

61183D31-AA10-44E1-0017-6301D33D779C.jpg

Conclusión y referencias

Esto es solamente una pincelada de Entity Framework 6.0 Code First, si quieres ver más información sobre EF 6.0 CS, te recomiendo que comiences con la documentación de Microsoft.
Para más información sobre TPT (Table per Type), visita éste enlace.
Si quieres ver más proyectos de ejemplo, no olvides visitar mi Repositorio Git

Follow me

Joan Vilariño

Senior .NET Developer at Ohpen
Follow me

4 comentarios

  1. viejo, excelente explicación de un problema que es mucho mas real que los ejemplos simplistas que encuentras por miles en otros blogs. te felicito y gracias por compartir conocimientos.

  2. Joan Vilariño

    03/03/2016 a las 10:52

    Gracias Luis!

    La idea cuando hago un artículo es tratar de plasmar lo que extoy explicando en un ejemplo lo más cercano a la realidad posible, para que las técnicas no queden en “bonitas teorías” y se vea su uso en aplicaciones que nos podemos encontrar en el día a día.

  3. Muy buen artículo. Quería preguntar una duda. ¿Cómo harías si Doors, Seats y Weels podrían formar parte de otra entidad distinta de Car?
    Por ejemplo, Robots tiene brazos y piernas. Tienda vende brazos y piernas. El ejemplo se las trae, pero quiero reflejar que la misma parte puede pertenecer a distintas entidades que no tienen nada que ver una con la otra.
    En tu ejemplo, Robot sería como Car. Tendríamos la entidad RobotParts y Brazo y Pierna serían como Doors y Seats, por ejemplo. Siguiendo tu ejemplo, Brazo y Pierna heredarian de RobotParts pero claro, Brazo y Pierna también quiero usarlos en TiendaParts.
    Igual es un poco lío, pero ¿que se podría hacer?
    Gracias

  4. Joan Vilariño

    16/03/2017 a las 21:20

    @Patxi, Dame un poco de tiempo y miro de montarte un ejemplo en GitHub o Bitbucket.

    Gracias!

Deja un comentario

Tu dirección de correo electrónico no será publicada.

*

A %d blogueros les gusta esto:
Ir a la barra de herramientas