Tutorial: Control de la simultaneidad con EF en una aplicación ASP.NET MVC 5

En los tutoriales anteriores, aprendió a actualizar los datos. Este tutorial muestra cómo usar la simultaneidad optimista para tratar los conflictos cuando varios usuarios actualizan la misma entidad al mismo tiempo. Cambie las páginas web que funcionan con la entidad Department para que controlen los errores de simultaneidad. Las siguientes ilustraciones muestran las páginas Edit y Delete, incluidos algunos mensajes que se muestran si se produce un conflicto de simultaneidad.

Screenshot shows the Edit page with values for Department Name, Budget, Start Date, and Administrator with current values highlighted.

Screenshot shows the Delete page for a record with a message about the delete operation and a Delete button.

En este tutorial ha:

  • Obtiene información sobre los conflictos de simultaneidad
  • Incorporación de la simultaneidad optimista
  • Modificación de un controlador de departamento
  • Prueba del control de la simultaneidad
  • Actualizar la página Delete

Requisitos previos

Conflictos de simultaneidad

Los conflictos de simultaneidad ocurren cuando un usuario muestra los datos de una entidad para editarlos y, después, otro usuario actualiza los datos de la misma entidad antes de que el primer cambio del usuario se escriba en la base de datos. Si no habilita la detección de este tipo de conflictos, quien actualice la base de datos en último lugar sobrescribe los cambios del otro usuario. En muchas aplicaciones, el riesgo es aceptable: si hay pocos usuarios o pocas actualizaciones, o si no es realmente importante si se sobrescriben algunos cambios, el costo de programación para la simultaneidad puede superar el beneficio obtenido. En ese caso, no tendrá que configurar la aplicación para que controle los conflictos de simultaneidad.

Simultaneidad pesimista (bloqueo)

Si la aplicación necesita evitar la pérdida accidental de datos en casos de simultaneidad, una manera de hacerlo es usar los bloqueos de base de datos. Esto se denomina simultaneidad pesimista. Por ejemplo, antes de leer una fila de una base de datos, solicita un bloqueo de solo lectura o para acceso de actualización. Si bloquea una fila para acceso de actualización, no se permite que ningún otro usuario bloquee la fila como solo lectura o para acceso de actualización, porque recibirían una copia de los datos que se están modificando. Si bloquea una fila para acceso de solo lectura, otras personas también pueden bloquearla para acceso de solo lectura pero no para actualización.

Administrar los bloqueos tiene desventajas. Puede ser bastante complicado de programar. Se necesita un número significativo de recursos de administración de base de datos, y puede provocar problemas de rendimiento a medida que aumenta el número de usuarios de una aplicación. Por estos motivos, no todos los sistemas de administración de bases de datos admiten la simultaneidad pesimista. Entity Framework no proporciona ninguna compatibilidad integrada para ello y, en este tutorial, no se muestra cómo implementarla.

Simultaneidad optimista

La alternativa a la simultaneidad pesimista es la simultaneidad optimista. La simultaneidad optimista implica permitir que se produzcan conflictos de simultaneidad y reaccionar correctamente si ocurren. Por ejemplo, John visita la página Editar/Departamento y cambia la cantidad de Presupuesto para el departamento de inglés de $350 000,00 a $0,00.

Antes de que John haga clic en Guardar, Jane visita la misma página y cambia el campo Fecha de inicio de 1/9/2007 a 8/8/2013.

John hace clic en Guardar primero y ve su cambio cuando el explorador vuelve a la página Índice y, a continuación, Jane hace clic en Guardar. Lo que sucede después viene determinado por cómo controla los conflictos de simultaneidad. Algunas de las opciones se exponen a continuación:

  • Puede realizar un seguimiento de la propiedad que ha modificado un usuario y actualizar solo las columnas correspondientes de la base de datos. En el escenario de ejemplo, no se perdería ningún dato porque los dos usuarios actualizaron diferentes propiedades. La próxima vez que un usuario examine el departamento de inglés, verá los cambios tanto de Jane como de John: una fecha de inicio de 8/8/2013 y un presupuesto de cero dólares.

    Este método de actualización puede reducir el número de conflictos que pueden dar lugar a una pérdida de datos, pero no puede evitar la pérdida de datos si se realizan cambios paralelos a la misma propiedad de una entidad. Si Entity Framework funciona de esta manera o no, depende de cómo implemente el código de actualización. A menudo no resulta práctico en una aplicación web, porque puede requerir mantener grandes cantidades de estado con el fin de realizar un seguimiento de todos los valores de propiedad originales de una entidad, así como los valores nuevos. Mantener grandes cantidades de estado puede afectar al rendimiento de la aplicación porque requiere recursos del servidor o se deben incluir en la propia página web (por ejemplo, en campos ocultos) o en una cookie.

  • Puede permitir que los cambios de Jane sobrescriban los de John. La próxima vez que un usuario examine el departamento de inglés, verá 8/8/2013 y el valor de $350 000,00 restaurado. Esto se denomina un escenario de Prevalece el cliente o Prevalece el último. (Todos los valores del cliente tienen prioridad sobre lo que aparece en el almacén de datos). Como se mencionó en la introducción de esta sección, si no hace ninguna codificación para el control de la simultaneidad, se realizará automáticamente.

  • Puede impedir que el cambio de Jane se actualice en la base de datos. Por lo general, mostraría un mensaje de error, le mostraría el estado actual de los datos y le permitiría volver a aplicar sus cambios si todavía quiere realizarlos. Esto se denomina un escenario de Prevalece el almacén. (Los valores del almacén de datos tienen prioridad sobre los valores enviados por el cliente). En este tutorial implementará el escenario de Prevalece el almacén. Este método garantiza que ningún cambio se sobrescriba sin que se avise al usuario de lo que está sucediendo.

Detectar los conflictos de simultaneidad

Puede resolver conflictos mediante el control de excepciones OptimisticConcurrencyException que inicia Entity Framework. Para saber cuándo se producen dichas excepciones, Entity Framework debe ser capaz de detectar conflictos. Por lo tanto, debe configurar correctamente la base de datos y el modelo de datos. Algunas opciones para habilitar la detección de conflictos son las siguientes:

  • En la tabla de la base de datos, incluya una columna de seguimiento que pueda usarse para determinar si una fila ha cambiado. Después, puede configurar Entity Framework para incluir esa columna en la cláusula Where de los comandos Update o Delete de SQL.

    El tipo de datos de la columna de seguimiento suele ser rowversion. El valor rowversion es un número secuencial que se incrementa cada vez que se actualiza la fila. En un comando Update o Delete, la cláusula Where incluye el valor original de la columna de seguimiento (la versión de la fila original). Si otro usuario ha cambiado la fila que se está actualizando, el valor en la columna rowversion es diferente del valor original, por lo que la instrucción Update o Delete no puede encontrar la fila que se va a actualizar debido a la cláusula Where. Cuando Entity Framework encuentra que no se ha actualizado ninguna fila mediante el comando Update o Delete (es decir, cuando el número de filas afectadas es cero), lo interpreta como un conflicto de simultaneidad.

  • Configure Entity Framework para que incluya los valores originales de cada columna de la tabla en la cláusula Where de los comandos Update y Delete.

    Como se muestra en la primera opción, si algo en la fila ha cambiado desde que se leyó por primera vez, la cláusula Where no devolverá una fila para actualizar, lo cual Entity Framework interpreta como un conflicto de simultaneidad. Para las tablas de base de datos que tienen muchas columnas, este enfoque puede dar lugar a cláusulas Where muy grandes y puede requerir mantener grandes cantidades de estado. Tal y como se indicó anteriormente, el mantenimiento de grandes cantidades de estado puede afectar al rendimiento de la aplicación. Por tanto, generalmente este enfoque no se recomienda y no es el método usado en este tutorial.

    Si quiere implementar este enfoque para la simultaneidad, tendrá que marcar todas las propiedades de clave no principal de la entidad de la que quiere realizar un seguimiento de simultaneidad agregándoles el atributo ConcurrencyCheck. Ese cambio permite que Entity Framework incluya todas las columnas en la cláusula WHERE de SQL de las instrucciones UPDATE.

En el resto de este tutorial agregará una propiedad de seguimiento rowversion para la entidad Department, creará un controlador y vistas, y comprobará que todo funciona correctamente.

Incorporación de la simultaneidad optimista

En Models/Department.cs, agregue una propiedad de seguimiento denominada RowVersion:

public class Department
{
    public int DepartmentID { get; set; }

    [StringLength(50, MinimumLength = 3)]
    public string Name { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal Budget { get; set; }

    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    [Display(Name = "Start Date")]
    public DateTime StartDate { get; set; }

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    public virtual Instructor Administrator { get; set; }
    public virtual ICollection<Course> Courses { get; set; }
}

El atributo Timestamp especifica que esta columna se incluirá en la cláusula Where de los comandos Update y Delete enviados a la base de datos. El atributo se denomina Timestamp porque las versiones anteriores de SQL Server usaban un tipo de datos timestamp de SQL antes de que la rowversion de SQL la reemplazara. El tipo .NET de rowversion es una matriz de bytes.

Si prefiere usar la API fluida, puede usar el método IsConcurrencyToken para especificar la propiedad de seguimiento, tal como se muestra en el ejemplo siguiente:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

Al agregar una propiedad cambió el modelo de base de datos, por lo que necesita realizar otra migración. En la Consola del Administrador de paquetes (PMC), escriba los comandos siguientes:

Add-Migration RowVersion
Update-Database

Modificación de un controlador de departamento

En Controllers\DepartmentController.cs, agregue una instrucción using:

using System.Data.Entity.Infrastructure;

En el archivo DepartmentsController.cs, cambie las cuatro repeticiones de "LastName" a "FullName" para que las listas desplegables del administrador del departamento contengan el nombre completo del instructor en lugar de simplemente el apellido.

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

En el método HttpPostEdit, sustituya el código existente por el siguiente código:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };

    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var departmentToUpdate = await db.Departments.FindAsync(id);
    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        TryUpdateModel(deletedDepartment, fieldsToBind);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    if (TryUpdateModel(departmentToUpdate, fieldsToBind))
    {
        try
        {
            db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
            await db.SaveChangesAsync();

            return RedirectToAction("Index");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (Department)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                    ModelState.AddModelError("Name", "Current value: "
                        + databaseValues.Name);
                if (databaseValues.Budget != clientValues.Budget)
                    ModelState.AddModelError("Budget", "Current value: "
                        + String.Format("{0:c}", databaseValues.Budget));
                if (databaseValues.StartDate != clientValues.StartDate)
                    ModelState.AddModelError("StartDate", "Current value: "
                        + String.Format("{0:d}", databaseValues.StartDate));
                if (databaseValues.InstructorID != clientValues.InstructorID)
                    ModelState.AddModelError("InstructorID", "Current value: "
                        + db.Instructors.Find(databaseValues.InstructorID).FullName);
                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                    + "was modified by another user after you got the original value. The "
                    + "edit operation was canceled and the current values in the database "
                    + "have been displayed. If you still want to edit this record, click "
                    + "the Save button again. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = databaseValues.RowVersion;
            }
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.)
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

Si el método FindAsync devuelve NULL, otro usuario eliminó el departamento. El código que se muestra usa los valores del formulario expuesto para crear una entidad Department, por lo que puede volver a mostrarse la página Edit con un mensaje de error. Como alternativa, no tendrá que volver a crear la entidad Department si solo muestra un mensaje de error sin volver a mostrar los campos del departamento.

La vista almacena el valor RowVersion original en un campo oculto, y el método recibe ese valor en el parámetro rowVersion. Antes de llamar a SaveChanges, tendrá que colocar dicho valor de propiedad RowVersion original en la colección OriginalValues para la entidad. Cuando Entity Framework crea un comando UPDATE de SQL, ese comando incluirá una cláusula WHERE que comprueba si hay una fila que tenga el valor RowVersion original.

Si no hay filas afectadas por el comando UPDATE (ninguna fila tiene el valor original RowVersion), Entity Framework produce una excepción DbUpdateConcurrencyException y el código del bloque catch obtiene la entidad Department afectada del objeto de excepción.

var entry = ex.Entries.Single();

Este objeto tiene los nuevos valores especificados por el usuario en su propiedad Entity y puede obtener los valores leídos de la base de datos mediante una llamada al método GetDatabaseValues.

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();

El método GetDatabaseValues devuelve null si alguien ha eliminado la fila de la base de datos; de lo contrario, tiene que convertir el objeto devuelto a la clase Department para tener acceso a las propiedades Department. (Dado que ya ha comprobado la eliminación, databaseEntry solo sería NULL si el departamento se eliminó después de ejecutarse FindAsync y antes de ejecutarse SaveChanges).

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty,
        "Unable to save changes. The department was deleted by another user.");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();

Después, el código agrega un mensaje de error personalizado para cada columna que tenga valores de base de datos diferentes de lo que el usuario especificó en la página Editar:

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

Un mensaje de error más largo explica lo que ha ocurrido y qué hacer sobre él:

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. The"
    + "edit operation was canceled and the current values in the database "
    + "have been displayed. If you still want to edit this record, click "
    + "the Save button again. Otherwise click the Back to List hyperlink.");

Por último, el código establece el valor RowVersion del objeto Department para el nuevo valor recuperado de la base de datos. Este nuevo valor RowVersion se almacenará en el campo oculto cuando se vuelva a mostrar la página Edit y, la próxima vez que el usuario haga clic en Save, solo se detectarán los errores de simultaneidad que se produzcan desde que se vuelva a mostrar la página Edit.

En Views\Department\Edit.cshtml, agregue un campo oculto para guardar el valor de propiedad RowVersion, inmediatamente después de un campo oculto para la propiedad DepartmentID:

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Department</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

Prueba del control de la simultaneidad

Ejecute el sitio y haga clic en Departamentos.

Haga clic con el botón derecho en el hipervínculo Editar del departamento de inglés, seleccione Abrir en nueva pestaña y, después, haga clic en el hipervínculo Editar del departamento de inglés. Las dos pestañas muestran la misma información.

Cambie un campo en la primera pestaña del explorador y haga clic en Save.

El explorador muestra la página de índice con el valor modificado.

Cambie un campo en la segunda pestaña del explorador y haga clic en Guardar. Verá un mensaje de error:

Screenshot shows the Edit page with a message that explains that the operation is canceled because the value has been changed by another user.

Vuelva a hacer clic en Save. El valor especificado en la segunda pestaña del explorador se guarda junto con el valor original de los datos que cambia en el primer explorador. Verá los valores guardados cuando aparezca la página de índice.

Actualizar la página Delete

Para la página Delete, Entity Framework detecta los conflictos de simultaneidad causados por una persona que edita el departamento de forma similar. Cuando el método HttpGetDelete muestra la vista de confirmación, la vista incluye el valor RowVersion original en un campo oculto. Dicho valor está entonces disponible para el método HttpPostDelete al que se llama cuando el usuario confirma la eliminación. Cuando Entity Framework crea el comando DELETE de SQL, incluye una cláusula WHERE con el valor original RowVersion. Si el comando tiene como resultado cero filas afectadas (es decir, la fila se cambió después de que se muestre la página de confirmación de eliminación), se produce una excepción de simultaneidad y el método HttpGet Delete se llama con una marca de error establecida en true para volver a mostrar la página de confirmación con un mensaje de error. También es posible que se vieran afectadas cero filas porque otro usuario eliminó la fila, por lo que en ese caso se muestra otro mensaje de error.

En DepartmentController.cs, reemplace el método HttpGetDelete por el código siguiente:

public async Task<ActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Department department = await db.Departments.FindAsync(id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction("Index");
        }
        return HttpNotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "The delete operation was canceled and the current values in the "
            + "database have been displayed. If you still want to delete this "
            + "record, click the Delete button again. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

El método acepta un parámetro opcional que indica si la página volverá a aparecer después de un error de simultaneidad. Si esta marca es true, se envía un mensaje de error a la vista mediante una propiedad ViewBag.

Reemplace el código en el método HttpPostDelete (denominado DeleteConfirmed) con el código siguiente:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        await db.SaveChangesAsync();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError = true, id=department.DepartmentID });
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

En el código al que se aplicó la técnica scaffolding que acaba de reemplazar, este método solo acepta un identificador de registro:

public async Task<ActionResult> DeleteConfirmed(int id)

Ha cambiado este parámetro por una instancia de la entidad Department creada por el enlazador de modelos. Esto le proporciona acceso al valor de propiedad RowVersion además de la clave de registro.

public async Task<ActionResult> Delete(Department department)

También ha cambiado el nombre del método de acción de DeleteConfirmed a Delete. El código al que se aplicó la técnica scaffolding asignó al método HttpPostDelete el nombre DeleteConfirmed para proporcionar al método HttpPost una firma única. (El CLR requiere métodos sobrecargados para tener parámetros de método diferentes). Ahora que las firmas son únicas, puede ceñirse a la convención MVC y usar el mismo nombre para los métodos de eliminación HttpPost y HttpGet.

Si se detecta un error de simultaneidad, el código vuelve a mostrar la página de confirmación de Delete y proporciona una marca que indica que se debería mostrar un mensaje de error de simultaneidad.

En Views\Departments\Delete.cshtml, reemplace el código al que se aplicó la técnica scaffolding con el siguiente código, que agrega un campo de mensaje de error y campos ocultos para las propiedades DepartmentID y RowVersion. Los cambios aparecen resaltados.

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            Administrator
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Name)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Name)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Budget)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Budget)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.StartDate)
        </dd>

    </dl>

    @using (Html.BeginForm()) {
        @Html.AntiForgeryToken()
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            @Html.ActionLink("Back to List", "Index")
        </div>
    }
</div>

Este código agrega un mensaje de error entre los encabezados h2 y h3:

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

Reemplaza LastName por FullName en el campo Administrator:

<dt>
  Administrator
</dt>
<dd>
  @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

Por último, agrega campos ocultos para las propiedades DepartmentID y RowVersion después de la instrucción Html.BeginForm:

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

Ejecutar la página de Indice de Departamentos. Haga clic con el botón derecho en el hipervínculo Delete del departamento de inglés, seleccione Abrir en nueva pestaña y, después, en la primera pestaña, haga clic en el hipervínculo Editar del departamento de inglés.

En la primera ventana, cambie uno de los valores y haga clic en Guardar.

La página Índice confirma el cambio.

En la segunda pestaña, haga clic en Delete.

Verá el mensaje de error de simultaneidad y se actualizarán los valores de Department con lo que está actualmente en la base de datos.

Department_Delete_confirmation_page_with_concurrency_error

Si vuelve a hacer clic en Delete, se le redirigirá a la página de índice, que muestra que se ha eliminado el departamento.

Obtención del código

Descargar el proyecto completado

Recursos adicionales

Puede encontrar enlaces a otros recursos de Entity Framework en el Acceso a datos de ASP.NET: recursos recomendados.

Para obtener información sobre otras formas de controlar varios escenarios de simultaneidad, consulte Patrones de simultaneidad optimista y Trabajar con valores de propiedad en MSDN. El siguiente tutorial muestra cómo implementar la herencia de tabla por jerarquía para las entidades Instructor y Student.

Pasos siguientes

En este tutorial ha:

  • Obtenido información sobre los conflictos de simultaneidad
  • Se ha agregado simultaneidad optimista
  • Se ha modificado el controlador de departamento
  • Se ha probado el control de la simultaneidad
  • Actualizado la página Delete

Pase al siguiente artículo para aprender a implementar la herencia en el modelo de datos.