Puntos de datos

¿Por qué Entity Framework vuelve a insertar objetos existentes en mi base de datos?

Julie Lerman

Descargar el ejemplo de código

Justo cuando ya era momento de que surgiera una idea para esta columna, tres personas me preguntaron, a través de Twitter y por correo electrónico, la razón por la cual Entity Framework vuelve a insertar objetos existentes en sus bases de datos. Así es que fue fácil tomar la decisión sobre qué escribir.

Debido a sus capacidades de administración de estado, cuando Entity Framework trabaja con gráficos, su comportamiento de estado de entidad no siempre se alinea con las ideas que se tienen respecto a cómo debiera funcionar. Analicemos un ejemplo típico.

Imaginemos que tengo dos clases, Presentación en pantalla y Tema, donde a cada presentación en pantalla se le asigna un solo tema, tal como se muestra en la figura 1.

Figura 1 Las clases Presentación en pantalla y Tema

public class Screencast
{
  public int Id { get; set; }
  public string Title { get; set; }
  public string Description { get; set; }
  public Topic Topic { get; set; }
  public int TopicId { get; set; }
}
public class Topic
{
  public int Id { get; set; }
  public string Name { get; set; }
}

Si tuviese que recuperar una lista de temas, asignar uno de ellos a una nueva presentación en pantalla y luego guardar, con todo el conjunto de operaciones en un contexto único, no habría problema, tal como lo demuestra el siguiente ejemplo:

using (var context = new ScreencastContext())
{
  var dataTopic = 
    context.Topics.FirstOrDefault(t=>t.Name.Contains("Data"));
  context.Screencasts.Add(new Screencast
                               {
                                 Title="EF101",
                                 Description = "Entity Framework 101",
                                 Topic = dataTopic
                               });
  context.SaveChanges();
}

Una sola presentación se pantalla se insertaría en la base de datos con la clave externa adecuada para el tema seleccionado.

Cuando trabaja en aplicaciones cliente o realiza estos pasos dentro de una sola unidad de trabajo donde el contexto hace seguimiento de toda la actividad, probablemente este sea el comportamiento que espera. Sin embargo, si trabaja con datos desconectados el comportamiento es muy diferente, lo que ha sorprendido a muchos desarrolladores.

Gráficos agregados en escenarios desconectados

Un patrón común que uso para controlar las listas de referencias es usar un contexto independiente, el que no estaría dentro del alcance en el momento de guardar cualquier modificación del usuario. Esta situación es común para servicios y aplicaciones web, pero también puede producirse en una aplicación del lado cliente. El siguiente ejemplo usa un repositorio para datos de referencia con el siguiente método GetTopicList para recuperar una lista de temas:

public class SimpleRepository
{
  public List<Topic> GetTopicList()
  {
    using (var context = new ScreencastContext())
    {
      return context.Topics.ToList();
    }
  }
 ...
}

Luego podría presentar los temas en una lista en un formulario de Windows Presentation Foundation que permite que los usuarios creen una nueva presentación en pantalla, tal como la que aparece en la figura 2.

A Windows Presentation Foundation Form for Entering New Screencasts
Figura 2 Un formulario de Windows Presentation Foundation para ingresar nuevas presentaciones en pantalla

En una aplicación cliente, como el formulario WPF de la figura 2, podría definir el elemento seleccionado de la lista desplegable en la propiedad de tema de la nueva presentación en pantalla con un código como el siguiente:

private void Save_Click(object sender, RoutedEventArgs e)
{
  repo.SaveNewScreencast(new Screencast
                {
                  Title = titleTextBox.Text,
                  Description = descriptionTextBox.Text,
                  Topic = topicListBox.SelectedItem as Topic
                });
}

Ahora la variable Screencast es un gráfico que contiene la nueva presentación en pantalla y la instancia de tema. Al mover esa variable al método SaveNewScreencast del repositorio, el gráfico se agrega a una nueva instancia de contexto y luego se guarda en la base de datos, de la siguiente manera:

public void SaveNewScreencast(Screencast screencast)
{
  using (var context = new ScreencastContext())
  {
    context.Screencasts.Add(screencast);
    context.SaveChanges();
  }
}

Generar el perfil de la actividad de base de datos revela que no solo se inserta la presentación en pantalla sino que, antes de eso, se inserta una fila nueva para el tema Desarrollador de datos en la tabla Temas, a pesar de que ese tema ya exista:

    exec sp_executesql N'insert [dbo].[Topics]([Name])
    values (@0)
    select [Id]
    from [dbo].[Topics]
    where @@ROWCOUNT > 0 and [Id] = 
      scope_identity()',N'@0 nvarchar(max) ',@0=N'Data Dev'

Este comportamiento ha desconcertado a muchos desarrolladores. La razón por la que esto ocurre es que, cuando usa el método DbSet.Add (es decir, Screencasts.Add), no solo se marca el estado de la entidad raíz como "Agregado", sino que también se marcan como Agregado todos los elementos del gráfico que el contexto anteriormente no reconocía. A pesar de que es posible que el desarrollador sepa que el tema tiene un valor de identificador existente, Entity Framework cumple con su EntityState (Agregado) y crea un comando de base de datos de inserción (Insert) para el tema, independientemente del identificador existente.

A pesar de que es posible que muchos desarrolladores prevean este comportamiento, hay muchos que no lo hacen. Y en ese caso, si no está generando el perfil de la actividad de base de datos, es posible que no se dé cuenta de que se está produciendo hasta la próxima vez que usted (o un usuario) detecte elementos duplicados en la lista de temas.

Nota: si no sabe cómo EF inserta filas nuevas, es posible que desee saber sobre la selección al medio del SQL anterior. Es para asegurarse de que EF devolverá el valor de identificador de la presentación en pantalla recién creada para que pueda definir el valor en la instancia de presentación en pantalla.

No es un problema solo cuando se agregan gráficos completos

Observemos otro escenario donde existe la posibilidad de que se produzca este problema.

¿Qué ocurriría si, en lugar de mover un gráfico al repositorio, el método de repositorio solicite la nueva presentación en pantalla y el tema seleccionado como parámetros? En lugar de agregar un gráfico completo, agrega la entidad Screencast y luego define su propiedad de navegación Topic:

public void SaveNewScreencastWithTopic(Screencast screencast,
  Topic topic)
{
  using (var context = new ScreencastContext())
  {
    context.Screencasts.Add(screencast);
    screencast.Topic = topic;
    context.SaveChanges();
  }
}

En este caso, el comportamiento SaveChanges es el mismo que con el gráfico agregado. Puede que ya conozca el uso del método Attach de EF para adjuntar una entidad no rastreada a un contexto. En ese caso, el estado de la entidad comienza como Unchanged. Pero aquí, donde asignamos Topic a la instancia Screencast, no al contexto, EF la considera como una entidad no reconocida y su comportamiento predeterminado para las entidades no reconocidas y que no tengan estado es marcarlas como Added. Por lo tanto, nuevamente Topic se insertará en la base de datos cuando se llama a SaveChanges.

Es posible controlar el estado, pero esto requiere una mayor comprensión del comportamiento de EF. Por ejemplo, si fuese a adjuntar directamente el tema al contexto, en lugar de hacerlo a la pantalla agregada, su EntityState comenzaría como Unchanged. Si se define en screencast.Topic no se modificaría el estado, porque el contexto ya está consciente de Topic. El siguiente es el código modificado que demuestra esta lógica:

using (var context = new ScreencastContext())
{
  context.Screencasts.Add(screencast);
  context.Topics.Attach(topic);
  screencast.Topic = topic;
  context.SaveChanges();
}

De manera alternativa, en lugar de context.Topics.Attach(topic), podría definir el estado del tema antes o después del hecho, definiendo su estado de manera explícita en Unchanged:

context.Entry(topic).State = EntityState.Unchanged

Si se llama a este código antes de que el contexto reconozca el tema se hará que el contexto se conecte al tema y luego al estado.

A pesar de que estos son patrones correctos para manejar este problema, no son obvios. A menos que haya aprendido sobre este comportamiento y sobre los patrones de código necesarios antes, tenderá a escribir código que parezca lógico para luego encontrarse con este problema y recién en ese punto, intentar descubrir cuál es el problema.

Ahórrese los problemas y uso esa clave externa

Pero existe una forma mucho más simple de evitar entrar a este estado de confusión (perdón por el juego de palabras), que es aprovechar las propiedades de la clave externa.

En lugar de definir la propiedad de navegación y de tener que preocuparse sobre el estado del tema, simplemente defina la propiedad TopicId, porque no tiene acceso a ese valor en la instancia de tema. Esto es algo que con frecuencia sugiero a los desarrolladores. Incluso en Twitter veo la pregunta: "¿Por qué EF inserta datos que ya existen?" Y a menudo acierto en la respuesta: "¿Define una propiedad de navegación en una entidad nueva en lugar de una clave externa? J”

Así es que volvamos a revisar el método Save_Click en el formulario WPF y definamos la propiedad TopicId en lugar de la propiedad de navegación Topic:

repo.SaveNewScreencast(new Screencast
               {
                 Title = titleTextBox.Text,
                 Description = descriptionTextBox.Text,
                 TopicId = (int)topicListBox.SelectedValue)
               });

La presentación en pantalla que se ha enviado al método del repositorio ahora solo es la entidad, no un gráfico. Entity Framework puede usar la propiedad de clave externa para definir directamente la propiedad TopicId de la tabla. De ese modo, es simple (y más rápido) que EF cree un método de inserción para la entidad de presentación en pantalla que incluya TopicId (en mi caso, el valor 2):

    exec sp_executesql N'insert [dbo].[Screencasts]([Title], [Description], [TopicId])
    values (@0, @1, @2)
    select [Id]
    from [dbo].[Screencasts]
    where @@ROWCOUNT > 0 and [Id] = scope_identity()',
    N'@0 nvarchar(max) ,@1 nvarchar(max) ,@2 int',
      @0=N'EFFK101',@1=N'Using Foreign Keys When Setting Navigations',@2=2

Si quisiera mantener la lógica de la construcción en el repositorio y no obligar a que el desarrollador de IU se preocupe por la definición de la clave externa, podría especificar una presentación en pantalla y el identificador del tema como parámetros para el método del repositorio y definir el valor en el método de la siguiente manera:

public void SaveNewScreencastWithTopicId(Screencast screencast, 
  int topicId)
{
  using (var context = new ScreencastContext())
  {
    screencast.TopicId = topicId;
    context.Screencasts.Add(screencast);
    context.SaveChanges();
  }
}

En nuestras eternas preocupaciones con respecto a lo que podría ocurrir, debemos considerar la posibilidad de que un desarrollador de todas maneras pueda definir la propiedad de navegación Topic. En otras palabras, incluso cuando deseamos usar la clave externa para evitar el problema de EntityState, ¿qué ocurriría si la instancia de tema fuese parte del gráfico, tal como ocurre en este código alternativo para el botón Save_Click:

repo.SaveNewScreencastWithTopicId(new Screencast
    {
      Title = titleTextBox.Text,
      Description = descriptionTextBox.Text,
      Topic=topicListBox.SelectedItem as Topic
    },
  (int) topicListBox.SelectedValue);

Lamentablemente esto lleva de vuelta al problema original: EF ve la entidad Tema (Topic) en el gráfico y la agrega al contexto junto con Screencast (Presentación en pantalla), incluso a pesar de que se ha definido la propiedad Screencast.TopicId. Nuevamente, el EntityState de la instancia de tema crea confusión: EF insertará un nuevo tema y usará el valor para el identificador de la fila nueva cuando inserte la presentación en pantalla.

La manera más segura de evitar esto es definir la propiedad Topic en nulo cuando defina el valor de la clave externa. Si otras IU usarán el método del repositorio cuando no pueda estar seguro de que solo se usarán temas existentes, es posible que incluso desee brindar la posibilidad de que se pase un tema creado recientemente. La figura 3 muestra el método del repositorio modificado una vez más para realizar esta tarea.

Figura 3 Método de repositorio diseñado como protección contra la inserción accidental de la propiedad de navegación en la base de datos

public void SaveNewScreencastWithTopicId(Screencast screencast, 
  int topicId)
{
  if (topicId > 0)
  {
    screencast.Topic = null;
    screencast.TopicId = topicId;
  }
  using (var context = new ScreencastContext())
  {
    context.Screencasts.Add(screencast);
    context.SaveChanges();
  }
}

Ahora tengo un método de repositorio que abarca diversos escenarios, incluso brindando la lógica para acomodar nuevos temas que se pasan al método.

Código generado por scaffolding de ASP.NET MVC 4 evita el problema

A pesar de que este problema es inherente a las aplicaciones desconectadas, es importante señalar que si usa scaffolding de ASP.NET MVC 4 para generar vistas y controladores de MVC, evitará el problema de que se inserten entidades de navegación duplicadas en la base de datos.

Dada la relación uno a varios entre la presentación en pantalla y el tema, así como la propiedad TopicId que es la clave externa en el tipo Screencast, scaffolding genera el siguiente método Create en el controlador:

public ActionResult Create()
{
  ViewBag.TopicId = new SelectList(db.Topics, "Id", "Name");
  return View();
}

Ha creado una lista de temas pasar a la vista y denominó a esa lista como TopicId, es decir, el mismo nombre de la propiedad de clave externa.

Scaffolding también ha incluido la siguiente lista en el marcado de la vista Create:

<div class="editor-field">
  @Html.DropDownList("TopicId", String.Empty)
  @Html.ValidationMessageFor(model => model.TopicId)
</div>

Cuando se devuelve la vista, HttpRequest.Form incluye un valor de cadena de consulta denominado TopicId que proviene de la propiedad ViewBag. El valor de TopicId corresponde al del elemento seleccionado de la DropDownList. Debido a que el nombre de la cadena de consulta coincide con el nombre de la propiedad de la presentación en pantalla, el enlace de modelos de ASP.NET MVC usa el valor para la propiedad TopicId de la instancia Screencast que crea para el parámetro del método, tal como se observa en la figura 4.

The New Screencast Gets Its TopicId from the Matching HttpRequest Query-String Value
Figura 4 La nueva presentación en pantalla obtiene su TopicId del valor de la cadena de consulta HttpRequest coincidente

Para comprobarlo, podría cambiar las variables de TopicId del controlador a otro valor, como TopicIdX, y hacer el mismo cambio en la cadena "TopicId" en @Html.DropDownList de la vista, y el valor de la cadena de consulta (ahora TopicIdX) se omitiría y screencast.TopicId sería 0.

No hay ninguna instancia de tema que se devuelva por la canalización. De ese modo, ASP.NET MVC depende de la propiedad de clave externa de manera predeterminada y evita el problema específico de volver a insertar un tema duplicado existente en la base de datos.

¡No es su culpa! Los gráficos desconectados son complicados

A pesar de que el equipo de EF ha hecho mucho para lograr que trabajar con datos desconectados sea más fácil en cada versión de EF, sigue siendo un problema que intimida a muchos desarrolladores no muy versados en el comportamiento esperado de EF. En nuestro libro "Programming Entity Framework: DbContext” (O’Reilly Media, 2012), Rowan Miller y yo dedicamos todo un capítulo al trabajo con entidades y gráficos desconectados. Y cuando se creó un curso reciente de Pluralsight, agregué 25 minutos que no estaban planificados, los que se centraban en la complejidad de los gráficos desconectados en los repositorios.

Trabajar con gráficos cuando se consultan y se interactúa con datos es muy conveniente, pero en lo que se refiere a crear relaciones con datos existentes, las claves externas son sus amigas. Consulte mi columna de enero de 2012, “Cómo apañárselas sin claves externas” (msdn.microsoft.com/magazine/hh708747), que también se refiere a algunas de las dificultades que se encuentran en el momento de codificar sin claves externas.

En una próxima columna seguiré en mi labor en pos de alivianar algunos de los problemas que los desarrolladores encuentran cuando trabajan con gráficos en escenarios desconectados. Esa columna, que será la segunda parte sobre este tema, se centrará en cómo controlar el EntityState en relaciones varios a varios y en recopilaciones de navegación.

Julie Lerman ha recibido el premio al Profesional más valioso (MVP) de Microsoft, es consultora y mentor de .NET, y vive en las colinas de Vermont. Realiza presentaciones sobre acceso a datos y otros temas de Microsoft .NET en grupos de usuarios y congresos en todo el mundo. Mantiene un blog en thedatafarm.com/blog y es la autora de “Programming Entity Framework” (2010), además de una edición para Code First (2011) y una para DbContext (2012), todos de O’Reilly Media. También puede seguir a Julie en Twitter (twitter.com/julielerman).

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Diego Vega (Microsoft)