Abril de 2018

Volumen 33, número 4

Puntos de datos: entidades en propiedad de EF Core 2 y soluciones alternativas temporales

Por Julie Lerman

Julie LermanLa nueva característica de entidad en propiedad de EF Core 2.0 reemplaza la característica de tipo complejo de la versión clásica de Entity Framework (de EF a EF6). Las entidades en propiedad permiten asignar objetos de valor al almacén de datos. Es bastante común tener una regla de negocio que permita que las propiedades basadas en objetos de valor tengan un valor null. Además, dado que los objetos de valor son inmutables, también es importante poder reemplazar las propiedades que contienen un objeto de valor. La versión actual de EF Core no admite ninguno de estos escenarios de forma predeterminada, aunque ambos se admitirán en iteraciones futuras. Mientras tanto, en lugar de tratar estas limitaciones como fallos críticos, para aquellos de nosotros que adoramos las ventajas de los patrones de diseño basado en dominio (DDD), este artículo mostrará como superar dichas limitaciones. Un profesional de DDD podría rechazar estos patrones temporales porque no siguen los principios de DDD lo suficientemente de cerca, pero satisfacen mi pragmatismo, aunque soy consciente de que son simples soluciones temporales.

Tenga en cuenta que he dicho “de forma predeterminada”. Resulta que hay una forma de que EF Core admita la responsabilidad de aplicar su propia regla sobre las entidades en propiedad de valor null y que permita el reemplazo de objetos de valor, sin que esto afecte drásticamente a las clases de dominio ni a las reglas de negocio. En esta columna, le mostraré cómo hacerlo.

Hay otra forma de abordar el problema de la nulabilidad, que consiste, simplemente, en asignar el tipo de objeto de valor a su propia tabla y, así, dividir físicamente los datos del objeto de valor del resto del objeto al que pertenece. Aunque esto puede ser una buena solución en algunos escenarios, por lo general, prefiero no usarla. Así pues, prefiero usar mi solución alternativa, que es el tema central de esta columna. En primer lugar, quiero asegurarme de que entiende por qué este problema y esta solución son tan importantes como para que les dedique esta columna.

Breve introducción a los objetos de valor

Los objetos de valor son un tipo que permite encapsular varios valores en una única propiedad. Una cadena es un gran ejemplo de un objeto de valor. Las cadenas están formadas por una colección de chars. Y son inmutables: la inmutabilidad es una faceta crítica de un objeto de valor. La combinación y el orden de las letras c, a y r tienen un significado concreto. Si los modificara, por ejemplo, cambiando la última letra por una “t”, cambiaría su sentido por completo.  El objeto se define por la combinación de todos sus valores. Como tal, el hecho de que el objeto no se pueda modificar forma parte de su contrato. También hay otro aspecto importante de un objeto de valor: no tiene identidad propia. Se puede usar solo como propiedad de otra clase, como una cadena. Los objetos de valor tienen otras reglas contractuales, pero estas son las más importantes por las que debe empezar si este concepto es nuevo para usted.

Dado que un objeto de valor está formado por sus propiedades y, después, en conjunto, se usa como propiedad en otra clase, la persistencia de sus datos requiere un esfuerzo especial. Con una base de datos no relacional, como una base de datos de documentos, resulta fácil almacenar el gráfico de un objeto y sus objetos de valor incrustados. Pero ese no es el caso cuando se almacena en una base de datos relacional. Empezando por la primera versión, Entity Framework incluyó el elemento ComplexType, que podía asignar las propiedades de la propiedad a la base de datos en que tenía lugar la persistencia de datos de EF. Un ejemplo de objeto de valor común es PersonName, que puede estar formado por la propiedad FirstName y la propiedad LastName. Si tiene un tipo Contact con una propiedad PersonName, de forma predeterminada, EF Core almacenará los valores FirstName y LastName como columnas adicionales en la tabla a la que está asignada Contact.

Ejemplo de objeto de valor en uso

He descubierto que observar una variedad de ejemplos de objetos de valor me ayuda a comprender mejor el concepto, de modo que usaré otro ejemplo: una entidad SalesOrder y un objeto de valor PostalAddress. Un pedido suele incluir una dirección de envío y una dirección de facturación. Aunque estas direcciones pueden existir con otra finalidad, dentro del contexto del pedido, son una parte integral de su definición. Si una persona se traslada a una nueva ubicación, querrá saber igualmente dónde se envío el pedido, por lo que tiene sentido integrar las direcciones en el pedido. Pero, para tratar las direcciones de un modo coherente en mi sistema, prefiero encapsular los valores que forman una dirección en su propia clase, PostalAddress, como se muestra en la Figura 1.

Figura 1 ValueObject de PostalAddress

public class PostalAddress : ValueObject<PostalAddress>
{
  public static PostalAddress Create (string street, string city,
                                      string region, string postalCode)   {
    return new PostalAddress (street, city, region, postalCode);
  }
  private PostalAddress () { }
  private PostalAddress (string street, string city, string region,
                         string postalCode)   {
    Street = street;
    City = city;
    Region = region;
    PostalCode = postalCode;
  }
  public string Street { get; private set; }
  public string City { get; private set; }
  public string Region { get; private set; }
  public string PostalCode { get; private set; }
  public PostalAddress CopyOf ()   {
    return new PostalAddress (Street, City, Region, PostalCode);
  }
}

PostalAddress se hereda de una clase base ValueObject que creó Jimmy Bogard (bit.ly/2EpKydG). ValueObject proporciona parte de la lógica obligatoria que se requiere de un objeto de valor. Por ejemplo, tiene un reemplazo de Object.Equals, lo que garantiza que se comparan todas las propiedades. Recuerde que hace un uso intensivo de la reflexión, lo que puede afectar el rendimiento de una aplicación de producción.

Otras dos características importantes de mi objeto de valor PostalAddress son que no tiene ninguna propiedad clave de identidad y que su constructor aplica la regla invariable de la que se debe rellenar cada propiedad. Sin embargo, para que una entidad en propiedad se pueda asignar a un tipo definido como objeto de valor, la única regla es que no tenga ninguna clave de identidad propia. Otros atributos de un objeto de valor no son relevantes para un entidad en propiedad.

Una vez definido el elemento PostalAddress, puedo usarlo como propiedades Shipping­Address y BillingAddress en mi clase SalesOrder (consulte la Figura 2). No son propiedades de navegación a datos relacionados, sino, más bien, propiedades similares a las escalares Notes y OrderDate.

Figura 2 La clase SalesOrder contiene propiedades que son tipos PostalAddress

public class SalesOrder {
  public SalesOrder (DateTime orderDate, decimal orderTotal)   {
    OrderDate = orderDate;
    OrderTotal = orderTotal;
    Id = Guid.NewGuid ();
  }
  private SalesOrder () { }
  public Guid Id { get; private set; }
  public DateTime OrderDate { get; private set; }
  public decimal OrderTotal { get; private set; }
  private PostalAddress _shippingAddress;
  public PostalAddress ShippingAddress => _shippingAddress;
  public void SetShippingAddress (PostalAddress shipping)
  {
    _shippingAddress = shipping;
  }
  private PostalAddress _billingAddress;
  public PostalAddress BillingAddress => _billingAddress;
  public void CopyShippingAddressToBillingAddress ()
  {
    _billingAddress = _shippingAddress?.CopyOf ();
  }
  public void SetBillingAddress (PostalAddress billing)
  {
    _billingAddress = billing;
  }
}

Ahora, estas direcciones residen en SalesOrder y pueden proporcionar información precisa independientemente de la dirección actual de la persona que realizó el pedido. Siempre sabré dónde fue el pedido.

Asignación de un objeto de valor como entidad en propiedad de EF Core

En versiones anteriores, EF podía reconocer clases automáticamente que se debían asignar mediante ComplexType, ya que detectaba que la clase se usaba como propiedad de otra entidad y que no tenía propiedad clave. Sin embargo, EF Core no puede inferir entidades en propiedad automáticamente. Debe especificarlo en las asignaciones de la API fluida de DbContext en el método OnModelCreating mediante el nuevo método OwnsOne para especificar qué propiedad de la entidad es la entidad en propiedad:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<SalesOrder>().OwnsOne(s=>s.BillingAddress);
  modelBuilder.Entity<SalesOrder>().OwnsOne(s=>s.ShippingAddress);
}

He usado migraciones de EF Core para crear un archivo de migración que describa la base de datos a la que se asigna mi modelo. En la Figura 3 se muestra la sección de la migración que representa la tabla SalesOrder. Puede ver que EF Core comprendió que las propiedades de PostalAddress para cada una de las dos direcciones forman parte de SalesOrder. Los nombres de columna siguen la convención de EF Core, aunque puede alterarlos con la API fluida.

Figura 3 Migración de la tabla SalesOrder, incluidas todas las columnas de las propiedades PostalAddress

migrationBuilder.CreateTable(
  name: "SalesOrders",
  columns: table => new
  {
    Id = table.Column(nullable: false)
              .Annotation("Sqlite:Autoincrement", true),
    OrderDate = table.Column(nullable: false),
    OrderTotal = table.Column(nullable: false),
    BillingAddress_City = table.Column(nullable: true),
    BillingAddress_PostalCode = table.Column(nullable: true),
    BillingAddress_Region = table.Column(nullable: true),
    BillingAddress_Street = table.Column(nullable: true),
    ShippingAddress_City = table.Column(nullable: true),
    ShippingAddress_PostalCode = table.Column(nullable: true),
    ShippingAddress_Region = table.Column(nullable: true),
    ShippingAddress_Street = table.Column(nullable: true)
  }

Además, como se mencionó previamente, poner las direcciones en la tabla SalesOrder es una convención, y mi preferencia. Este código alternativo las divide en tablas distintas y evita el problema de nulabilidad por completo:

modelBuilder.Entity<SalesOrder> ().OwnsOne (
  s => s.BillingAddress).ToTable("BillingAddresses");
modelBuilder.Entity<SalesOrder> ().OwnsOne (
  s => s.ShippingAddress).ToTable("ShippingAddresses");

Crear SalesOrder en el código

Insertar un pedido de ventas con la dirección de facturación y envío es sencillo:

private static void InsertNewOrder()
{
  var order=new SalesOrder{OrderDate=DateTime.Today, OrderTotal=100.00M};
  order.SetShippingAddress (PostalAddress.Create (
    "One Main", "Burlington", "VT", "05000"));
  order.SetBillingAddress (PostalAddress.Create (
    "Two Main", "Burlington", "VT", "05000"));
  using(var context=new OrderContext()){
    context.SalesOrders.Add(order);
    context.SaveChanges();
  }
}

Pero, imaginemos, por ejemplo, que mis reglas de negocio permitan almacenar un pedido incluso si no se han especificado las direcciones de envío y facturación, y que un usuario puede completar el pedido en otro momento. A continuación, comentaré el código que rellena la propiedad BillingAddress:

// order.BillingAddress=new Address("Two Main","Burlington", "VT", "05000");

Cuando se llama a SaveChanges, EF Core intenta averiguar cuáles son las propiedades de BillingAddress para poder insertarlas en la tabla SalesOrder. Sin embargo, en este caso, se genera un error porque el valor de BillingAddress es null. Internamente, EF Core tiene una regla que indica que una propiedad de tipo en propiedad asignada convencionalmente no puede tener un valor null.

EF Core asume que el tipo en propiedad está disponible y que sus propiedades se pueden leer. Los desarrolladores pueden considerarlo un obstáculo para poder usar objetos de valor o, aún peor, para poder usar EF Core, ya que los objetos de valor son críticos para su diseño de software. Al principio, yo lo percibía así, pero pude crear una solución alternativa.

Solución alternativa temporal para permitir objetos de valor null

El objetivo de la solución alternativa es garantizar que EF Core reciba un valor de ShippingAddress, BillingAddress u otro tipo en propiedad, tanto si el usuario lo proporcionó como si no. Esto significa que no se obliga al usuario a proporcionar una dirección de envío o facturación solo para satisfacer la capa de persistencia. Si el usuario no la proporciona, DbContext agrega un objeto PostalAddress con valores null en sus propiedades cuando llega el momento de guardar un elemento SalesOrder.

He hecho una pequeña adaptación a la clase PostalAddress: he agregado un segundo método Factory Method (Empty) para que DbContext pueda crear fácilmente un valor de PostalAddress vacío:

public static PostalAddress Empty()
{
  return new PostalAddress(null,null,null,null);
}

Además, he mejorado la clase base ValueObject con un nuevo método (IsEmpty), que se muestra en la Figura 4, para permitir que el código determine fácilmente si un objeto tiene valores null en todas sus propiedades. IsEmpty aprovecha el código que ya existe de la clase ValueObject. Se itera por las propiedades y, si alguna de ellas tiene un valor, devuelve false, lo que indica que el objeto no está vacío; de lo contrario, devuelve true.

Figura 4 Método IsEmpty agregado a la clase base ValueObject

public bool IsEmpty ()
{
  Type t = GetType ();
  FieldInfo[] fields = t.GetFields
    (BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
  foreach (FieldInfo field in fields)
  {
    object value = field.GetValue (this);
    if (value != null)
    {
      return false;
    }
  }
  return true;
}

Pero mi solución para permitir entidades en propiedad de valor null aún no estaba completa. Necesitaba usar toda esta nueva lógica para garantizar que los nuevos valores de SalesOrders siempre tendrían un valor de ShippingAddress y BillingAddress para que EF Core pudiera almacenarlos en la base de datos. Cuando, inicialmente, agregué esta última parte de mi solución, no estaba contenta con ella porque ese último bit de código (que no me molestaré en compartir) hacía que la clase SalesOrder aplicara la regla de EF Core: el problema del diseño basado en dominio.

Voila! Una solución elegante

Por suerte, di una charla en DevIntersection, como cada otoño, donde también hicieron sus presentaciones Diego Vega y Andrew Peters, del equipo de EF. Les expliqué mi solución alternativa y lo que me preocupaba: la necesidad de aplicar valores distintos de null para ShippingAddress y BillingAddress en SalesOrder, y estuvieron de acuerdo. Andrew ideó rápidamente una forma de usar el trabajo que había hecho en la clase base ValueObject y la modificación que hice en PostalAddress para forzar a EF Core a abordar el problema sin que la responsabilidad recaiga en SalesOrder. La magia se produce en el reemplazo del método SaveChanges de mi clase DbContext, como se muestra en la Figura 5.

Figura 5 Reemplazo de SaveChanges para proporcionar valores a tipos en propiedad null

public override int SaveChanges()
{
  foreach (var entry in ChangeTracker.Entries()
             .Where(e => e.Entity is SalesOrder && e.State == EntityState.Added))
  {
    if (entry.Entity is SalesOrder)
    {
      if (entry.Reference("ShippingAddress").CurrentValue == null)
      {
        entry.Reference("ShippingAddress").CurrentValue = PostalAddress.Empty();
      }
      if (entry.Reference("BillingAddress").CurrentValue == null)
      {
        entry.Reference("BillingAddress").CurrentValue = PostalAddress.Empty();
      }
  }
  return base.SaveChanges();
}

Desde la colección de entradas de las que hace un seguimiento DbContext, SaveChanges se iterará por las marcadas como SalesOrders para agregarlas a la base de datos y se asegurará de que se rellenen como sus equivalentes en blanco.

¿Se pueden consultar estos tipos en propiedad vacíos?

Después de satisfacer la necesidad de EF Core de almacenar objetos de valor null, es el momento de volver a consultarlos desde la base de datos. Pero EF Core resuelve estas propiedades en su estado vacío. Cualquier valor de ShippingAddress o BillingAddress que, originalmente, era null, vuelve como instancia con valores null en sus propiedades. Después de cualquier consulta, necesito mi lógica para reemplazar cualquier propiedad PostalAddress vacía con el valor null.

Dediqué mucho tiempo a buscar una forma elegante de conseguirlo. Pera aún no existe ningún enlace de ciclo de vida para modificar objetos a medida que se materializan a partir de resultados de consulta. Existe un servicio reemplazable en la canalización de consulta llamado CreateReadValueExpression en la clase EntityMaterializerSource interna, pero solo se puede usar en valores escalares, no en objetos. He intentado muchos otros enfoques que eran mucho más complicados y, finalmente, tuve una larga conversación conmigo misma acerca del hecho de que se trata de una solución alternativa temporal, de modo que puedo aceptar una solución más sencilla aunque tenga un poco de hediondez de código. Esta tarea no es demasiado difícil de controlar si las consultas se encapsulan en una clase dedicada a hacer llamadas de EF Core a la base de datos.

He llamado al método FixOptionalValueObjects:

private static void FixOptionalValueObjects (SalesOrder order) {
  if (order.ShippingAddress.IsEmpty ()) { order.SetShippingAddress (null); }
  if (order.BillingAddress.IsEmpty ()) { order.SetBillingAddress (null); }
}

Ahora tengo una solución en la que el usuario puede dejar objetos de valor null y permitir que EF Core los almacene y recupere como valores distintos de null, pero los devuelva al código base como valores null de todos modos.

Reemplazo de objetos de valor

He mencionado otra limitación en la versión actual de EF Core 2: la incapacidad de reemplazar entidades en propiedad. Los objetos de valor son, por definición, inmutables. Así que, si necesita cambiar alguno, la única forma es reemplazarlo. Por lógica, esto significa que está modificando SalesOrder, como si hubiera cambiado su propiedad OrderDate. Pero, debido al modo en que EF Core hace el seguimiento de las entidades en propiedad, siempre pensará que el reemplazo se agrega, aunque su host, SalesOrder, por ejemplo, no sea nuevo.

He hecho un cambio en el reemplazo de SaveChanges para corregir este problema (consulte la Figura 6). Ahora, el reemplazo filtra los elementos SalesOrder que se agregan o modifican y, con las dos nuevas líneas de código que modifican el estado de las propiedades de referencia, se asegura de que ShippingAddress y BillingAddress tengan el mismo estado que el pedido: Added o Modified. Ahora, los objetos SalesOrder modificados también podrán incluir los valores de las propiedades ShippingAddress y BillingAddress en sus comandos UPDATE.

Figura 6 Hacer que SaveChanges comprenda los tipos en propiedad reemplazados marcándolos como Modified

public override int SaveChanges () {
  foreach (var entry in ChangeTracker.Entries ().Where (
    e => e.Entity is SalesOrder &&
    (e.State == EntityState.Added || e.State == EntityState.Modified))) {
    if (entry.Entity is SalesOrder order) {
      if (entry.Reference ("ShippingAddress").CurrentValue == null) {
        entry.Reference ("ShippingAddress").CurrentValue = PostalAddress.Empty ();
      }
      if (entry.Reference ("BillingAddress").CurrentValue == null) {
        entry.Reference ("BillingAddress").CurrentValue = PostalAddress.Empty ();
      }
      entry.Reference ("ShippingAddress").TargetEntry.State = entry.State;
      entry.Reference ("BillingAddress").TargetEntry.State = entry.State;
    }
  }
  return base.SaveChanges ();
}

Este patrón funciona porque estoy guardando con una instancia de OrderContext distinta de la que consulté y que, por lo tanto, no tiene ninguna noción preconcebida del estado de los objetos PostalAddress. Puede buscar un patrón alternativo para los objetos de los que se hace un seguimiento en los comentarios del número de GitHub bit.ly/2sxMECT.

Solución pragmática a corto plazo

Si los cambios para permitir entidades en propiedad opcionales y reemplazar entidades en propiedad no estuvieran en el horizonte, probablemente, tomaría medidas para crear un modelo de datos independiente para controlar la persistencia de datos en mi software. Pero esta solución temporal me ahorra el trabajo y la inversión adicionales y sé que pronto podré prescindir de mis soluciones alternativas y asignar de forma fácil mis modelos de dominio directamente a mi base de datos al permitir que EF Core defina el modelo de datos. Estoy satisfecha de haber invertido el tiempo, el esfuerzo y la reflexión necesarios para encontrar soluciones alternativas que me permiten usar objetos de valor y EF Core 2 al diseñar mis soluciones, así como para ayudar a otros a hacerlo.

Tenga en cuenta que la descarga que acompaña a este artículo se encuentra en una aplicación de consola para probar la solución, además de persistir los datos en una base de datos SQLite. He usado la base de datos en lugar de crear pruebas únicamente con el proveedor InMemory porque quería inspeccionar la base de datos para estar 100 % segura de que los datos se almacenan como pretendo.


Julie Lerman es directora regional de Microsoft, MVP de Microsoft, instructora y consultora del equipo de software. Vive en las colinas de Vermont. Puede encontrarla haciendo presentaciones sobre el acceso a datos y otros temas en grupos de usuarios y en conferencias en todo el mundo. Su blog es thedatafarm.com/blog y es la autora de "Programming Entity Framework", así como de una edición de Code First y una edición de DbContext, de O’Reilly Media. Sígala en Twitter en @julielerman y vea sus cursos de Pluralsight en juliel.me/PS-Videos.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Andriy Svyryd
Andriy Svyryd es un desarrollador de .NET ucraniano que ha trabajado en el equipo de Entity Framework desde 2010. Consulte todos los proyectos a los que contribuye en github.com/AndriySvyryd.


Discuta sobre este artículo en el foro de MSDN Magazine