Introducción
Al momento de relacionar entidades algunas pueden necesitar asociarse con colecciones de otras entidades, pero que sucede cuando esto se necesita en ambas direcciones?, es aquí donde entran las relaciones mucho a muchos, en donde a nivel de base de datos se va a requerir de una tabla intermedia para poder implementar la relación.
En este articulo veremos como lograr esta asociación y analizaremos que pasa a nivel de base de datos, el objetivo final será lograr un modelo de objeto como el siguiente:
y su equivalente en el modelo de datos:
Definición de las entidades
En el proyecto “Entities” serán definidas las clases que modelan los empleados y territorios, cada uno define una propiedad del tipo colección hacia a otra.
public class Territory { public int TerritoryID { get; set; } public string TerritoryDescription { get; set; } public virtual ICollection<Employee> Employees { get; set; } }
public class Employee { public int EmployeeID { get; set; } public string LastName { get; set; } public string FirstName { get; set; } public string Address { get; set; } public string City { get; set; } public string Region { get; set; } public string PostalCode { get; set; } public string Country { get; set; } public virtual ICollection<Territory> Territories { get; set; } }
Definición del contexto
La clase de context, la cual hereda de Dbcontext, define las propiedades que representan cada entidad, utilizando el tipo DbSet<>.
La sobrecarga del método OnModelCreating será el responsable de la configuración del mapping, para que no quede toda el código junto en un solo sitio y sea mantenible en el tiempo se hará uso de clases separadas para cada entidad, estas clases heredan de EntityTypeConfiguration<>.
public class NorthWindContext : DbContext { public NorthWindContext() : base("NorthwindDb") { this.Configuration.LazyLoadingEnabled = false; this.Configuration.ProxyCreationEnabled = false; } public DbSet<Territory> Territories { get; set; } public DbSet<Employee> Employees { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Configurations.Add(new TerritoryMap()); modelBuilder.Configurations.Add(new EmployeeMap()); base.OnModelCreating(modelBuilder); } }
En este caso se decidió utilizar la entidad del territorio para definir la relación mucho a muchos, esto se logra definiendo el HasMany()
La relación requiere definir el nombre que tendrá la tabla intermedia, así como los campos claves que actuaran en la relación.
public class TerritoryMap : EntityTypeConfiguration<Territory> { public TerritoryMap() { HasKey(x => x.TerritoryID); Property(x => x.TerritoryDescription).HasColumnType("nchar").HasMaxLength(50).IsRequired(); HasMany(x => x.Employees) .WithMany(x => x.Territories) .Map(mc => { mc.MapLeftKey("TerrytoryID"); mc.MapRightKey("EmployeeID"); mc.ToTable("EmployeeTerritories"); }); } }
Nota: la definición de la relación no era obligatorio hacerlo en la entidad Territory, se podría haber realizado en la clase Map del Employee sin ningún problema, solo hay que tener en cuenta cambiaran las definiciones de MapLeftKey() y MapRighKey()
El empleado define el mapeo normalmente
public class EmployeeMap : EntityTypeConfiguration<Employee> { public EmployeeMap() { HasKey(x => x.EmployeeID); Property(x => x.LastName).HasMaxLength(20).IsRequired(); Property(x => x.FirstName).HasMaxLength(10).IsRequired(); Property(x => x.Address).HasMaxLength(60); Property(x => x.City).HasMaxLength(15); Property(x => x.Region).HasMaxLength(15); Property(x => x.PostalCode).HasMaxLength(10); Property(x => x.Country).HasMaxLength(15); } }
Especialización del repositorio
Para poder operar con las colecciones se requiere estar dentro del contexto de EF, pero sucede que la implementación del repositorio desconecta las instancias de las entidades al devolverás para poder ser usadas desde fuera por lo tanto aplicar cambios en estas no ejecutara las acciones de actualización de forma correcta.
Es aquí donde se requiere personalizar el repositorio, por ejemplo en el repositorio del territorio definir métodos que permiten agregar o quitar empleados.
Si bien los parámetros de estos métodos son tipos de cada entidad, se podrían haber utilizado solo los IDs de las mismas
public class TerritoryRepository : BaseRepository<Territory> { public void AddEmployees(Territory territory, List<Employee> employes) { using (NorthWindContext context = new NorthWindContext()) { //marcamos el territorio para que no reciba cambios context.Entry(territory).State = EntityState.Unchanged; if (territory.Employees == null) territory.Employees = new List<Employee>(); //recorremos cada empleado que se quiera asociar employes.ForEach(x => { //el empleado tampoco debe recibir cambios context.Entry(x).State = EntityState.Unchanged; //asociamos a la colecion de empleados del territorio el nuevo item //este si recibira cambios territory.Employees.Add(x); }); context.SaveChanges(); } } public void RemoveEmployees(Territory territory, List<Employee> employees) { //validamos que haya algo que remover if (employees == null || employees.Count == 0) return; using (NorthWindContext context = new NorthWindContext()) { //recuperamos el terrotorio y sus empleados //esto es necesario porque el objeto donde se debe remover tiene que estar dentro del contexto de EF Territory territorySel = context.Set<Territory>().Include("Employees").FirstOrDefault(x => x.TerritoryID == territory.TerritoryID); if (territory.Employees == null || territory.Employees.Count == 0) return; employees.ForEach(x => { //localizamos al empleado dentro de la coleccion que se recupero anteriormente Employee employeeRemove = territorySel.Employees.First(e => e.EmployeeID == x.EmployeeID); //se remueve de la coleccion haciendo uso de la instancia territorySel.Employees.Remove(employeeRemove); }); context.SaveChanges(); } } }
Test – Obtener territorio con empleados asociados
Empezaremos con un test simple, en donde solo se relacionen las entidades y puedan recuperarse
[TestMethod] public void Get_ListEmployee_Territory() { TerritoryRepository repoTerritory = new TerritoryRepository(); Territory territoryNew = new Territory() { TerritoryDescription = "territoty 1", Employees = new List<Employee>() { new Employee() { FirstName = "Name 1", LastName ="LastaName 1" }, new Employee() { FirstName = "Name 2", LastName ="LastaName 2" } } }; repoTerritory.Create(territoryNew); var territorySel = repoTerritory.Single(x => x.TerritoryID == territoryNew.TerritoryID, new List<Expression<Func<Territory,object>>>(){ x=>x.Employees }); Assert.IsNotNull(territorySel); Assert.IsNotNull(territorySel.Employees); Assert.AreEqual(territorySel.Employees.Count, 2); }
La creación del territorio implicara varias acciones como puede observarse en la captura del profiler
Se insertan los empleados, a continuación el territorio y por ultimo los registros que relacionan ambas entidades
Para recuperar las relaciones es necesario definir la propiedad como parte del Include()
En una sola instrucción SELECT se recuperan el territorio y los empleados asociados a esta.
Test – Agregar empleados a territorio existentes
Seria muy raro que al momento de crear un territorio se disponga de los empleados que se asocian al mismo, y solo estos conformen la relación, lo común es tener entidades independientes y asociarlas de forma dinámica, por lo general mediante acciones del usuario en la aplicación.
[TestMethod] public void Update_ExistingEmployee_Territory() { TerritoryRepository repoTerritory = new TerritoryRepository(); EmployeeRepository repoEmployee = new EmployeeRepository(); //se crean los empleados Employee employeeNew1 = new Employee() { FirstName = "Name 1", LastName ="LastaName 1" }; repoEmployee.Create(employeeNew1); Employee employeeNew2 = new Employee() { FirstName = "Name 2", LastName ="LastaName 2" }; repoEmployee.Create(employeeNew2); //se crea el territorio Territory territoryNew = new Territory() { TerritoryDescription = "territoty 1" }; repoTerritory.Create(territoryNew); //asignamos los empleados al territorio existente repoTerritory.AddEmployees(territoryNew, new List<Employee>(new Employee[]{ employeeNew1, employeeNew2 })); //validamos que la asignacion se haya realizado correctamente //recuperando la entidad y sus relaciones var territorySel = repoTerritory.Single(x => x.TerritoryID == territoryNew.TerritoryID, new List<Expression<Func<Territory, object>>>() { x => x.Employees }); Assert.IsNotNull(territorySel); Assert.IsNotNull(territorySel.Employees); Assert.AreEqual(territorySel.Employees.Count, 2); }
La primer parte de la ejecución del test crea las entidades de forma independiente
Luego se añade los empleados al territorio
y al final para validar que todo este correcto recuperando el territorio y su relación con los empleados
Test – Remover un empleado asociado
Hasta el momento hemos agregando entidades a la colección, pero es muy común querer quitarlas, para esto haremos uso del método creado especialmente en el repositorio de la entidad Territorio.
Antes de seguir hay que aclarar que la estructura de las tablas que crea Entity Framework desde el modelo asigna en las relaciones la opción de borrado en cascada, es necesario conocer esto si es que se quiere eliminar una entidad completa, ya que el hacerlo provocara que todas las asociaciones que esta tenga también sean eliminadas
Veamos el código del test que remueve un empleado en concreto de la colección
[TestMethod] public void Delete_AssignedEmployee_Territory() { TerritoryRepository repoTerritory = new TerritoryRepository(); EmployeeRepository repoEmployee = new EmployeeRepository(); //se crean los empleados Employee employeeNew1 = new Employee() { FirstName = "Name 1", LastName = "LastaName 1" }; repoEmployee.Create(employeeNew1); Employee employeeNew2 = new Employee() { FirstName = "Name 2", LastName = "LastaName 2" }; repoEmployee.Create(employeeNew2); //se crea el territorio Territory territoryNew = new Territory() { TerritoryDescription = "territoty 1" }; repoTerritory.Create(territoryNew); //asignamos los empleados al territorio existente repoTerritory.AddEmployees(territoryNew, new List<Employee>(new Employee[] { employeeNew1, employeeNew2 })); //validamos que la asignacion se haya realizado correctamente //recuperando la entidad y sus relaciones var territorySel = repoTerritory.Single(x => x.TerritoryID == territoryNew.TerritoryID, new List<Expression<Func<Territory, object>>>() { x => x.Employees }); Assert.IsNotNull(territorySel); Assert.IsNotNull(territorySel.Employees); Assert.AreEqual(territorySel.Employees.Count, 2); //removemos uno de los empleados asignados repoTerritory.RemoveEmployees(territoryNew, new List<Employee>(new Employee[] { employeeNew1 })); //recuperamos el territorio para validar que se haya eliminado el empleado var territorySel2 = repoTerritory.Single(x => x.TerritoryID == territoryNew.TerritoryID, new List<Expression<Func<Territory, object>>>() { x => x.Employees }); Assert.IsNotNull(territorySel2); Assert.IsNotNull(territorySel2.Employees); Assert.AreEqual(territorySel2.Employees.Count, 1); Employee employeeSel = territorySel2.Employees.First(); Assert.AreEqual(employeeSel.FirstName, employeeNew2.FirstName); Assert.AreEqual(employeeSel.LastName, employeeNew2.LastName); }
La primer parte es idéntica al Test anterior, se crean las entidades individualmente, se asignan los empleados al territorio y se validad que todo se haya realizado correctamente.
Luego se procede a quitar uno de los empleados, ejecutándose la instrucción SQL que remueve el registro
Documentación de referencia
Associations in EF Code First: Part 6 – Many-valued Associations
Configuring a Many-to-Many Relationship
Código
Se utilizo Visual Studio 2012, no se adjunta ninguna base de datos, esta será creada al ejecutar los test utilizando el modelo definido en el contexto de EF.
[C#]
|