Parte 8. Razor Pages con EF Core en ASP.NET Core: Simultaneidad

Tom Dykstra y Jon P Smith

En la aplicación web Contoso University se muestra cómo crear aplicaciones web Razor Pages con EF Core y Visual Studio. Para obtener información sobre la serie de tutoriales, consulte el primer tutorial.

Si surgen problemas que no puede resolver, descargue la aplicación completada y compare ese código con el que ha creado siguiendo el tutorial.

En este tutorial se muestra cómo tratar los conflictos cuando varios usuarios actualizan una entidad de forma simultánea.

Conflictos de simultaneidad

Un conflicto de simultaneidad se produce cuando:

  • Un usuario va a la página de edición de una entidad.
  • Otro usuario actualiza la misma entidad antes de que el cambio del primer usuario se escriba en la base de datos.

Si la detección de simultaneidad no está habilitada, quien actualice la base de datos en último lugar sobrescribe los cambios del otro usuario. Si este riesgo es aceptable, es posible que el costo de programar la simultaneidad supere las ventajas.

Simultaneidad pesimista

Una manera de evitar conflictos de simultaneidad consiste en usar bloqueos de base de datos. Esto se denomina simultaneidad pesimista. Antes de que la aplicación lea una fila de base de datos que pretende actualizar, solicita un bloqueo. Una vez que una fila está bloqueada para el acceso de actualización, ningún otro usuario puede bloquear la fila hasta que se libere el primer bloqueo.

Administrar los bloqueos tiene desventajas. Puede ser complejo de programar y causar problemas de rendimiento a medida que aumente el número de usuarios. Entity Framework Core no proporciona compatibilidad integrada alguna para la simultaneidad pesimista.

Simultaneidad optimista

La simultaneidad optimista permite que se produzcan conflictos de simultaneidad y luego reacciona correctamente si ocurren. Por ejemplo, Jane visita la página de edición de Department y cambia el presupuesto para el departamento de inglés de 350.000,00 a 0,00 USD.

Changing budget to 0

Antes de que Jane haga clic en Save, John visita la misma página y cambia el campo Start Date de 9/1/2007 a 9/1/2013.

Changing start date to 2013

Primero, Jane hace clic en Guardar y ve que el cambio surte efecto, ya que en el explorador se muestra la página de índice con el valor cero como la cantidad del presupuesto.

John hace clic en Save en una página Edit que sigue mostrando un presupuesto de 350.000,00 USD. Lo que sucede después viene determinado por cómo se controlan los conflictos de simultaneidad:

  • Realice un seguimiento de la propiedad que ha modificado un usuario y actualice solo las columnas correspondientes de la base de datos.

    En el escenario, no se perderá ningún dato. 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. Este método de actualización puede reducir el número de conflictos que pueden dar lugar a una pérdida de datos. Este enfoque presenta algunos inconvenientes:

    • No puede evitar la pérdida de datos si se realizan cambios paralelos a la misma propiedad.
    • Por lo general, no es práctico en una aplicación web. Requiere mantener un estado significativo para realizar un seguimiento de todos los valores capturados y nuevos. El mantenimiento de grandes cantidades de estado puede afectar al rendimiento de la aplicación.
    • Puede aumentar la complejidad de las aplicaciones en comparación con la detección de simultaneidad en una entidad.
  • Permita que los cambios de John sobrescriban los cambios de Jane.

    La próxima vez que un usuario examine el departamento de inglés, verá 9/1/2013 y el valor de 350.000,00 USD capturado. Este enfoque 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. El código con scaffolding no administra la simultaneidad, Prevalece el cliente se realizará automáticamente.

  • Evite que el cambio de John se actualice en la base de datos. Normalmente, la aplicación podría:

    • Mostrar un mensaje de error.
    • Mostrar el estado actual de los datos.
    • Permitir al usuario volver a aplicar los cambios.

    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. El escenario de Prevalece el almacén se usa en este tutorial. Este método garantiza que ningún cambio se sobrescriba sin que se avise al usuario.

Detección de conflictos en EF Core

Las propiedades configuradas como tokens de simultaneidad se usan para implementar el control de simultaneidad optimista. Cuando SaveChanges o SaveChangesAsync desencadena una operación de actualización o eliminación, el valor del token de simultaneidad de la base de datos se compara con el valor original leído por EF Core:

  • Si los valores coinciden, la operación se puede completar.
  • Si no coinciden, EF Core supone que otro usuario realizó una operación en conflicto, anula la transacción actual e inicia una excepción DbUpdateConcurrencyException.

Otro usuario o proceso que realiza una operación que entra en conflicto con la operación actual se conoce como conflicto de simultaneidad.

En las bases de datos relacionales, EF Core comprueba el valor del token de simultaneidad en la cláusula WHERE de las instrucciones UPDATE y DELETE para detectar un conflicto de simultaneidad.

El modelo de datos debe configurarse para habilitar la detección de conflictos mediante la inclusión de una columna de seguimiento que se puede usar para determinar cuándo se ha cambiado una fila. EF proporciona dos enfoques para los tokens de simultaneidad:

Los detalles de la implementación de SQLite y el enfoque de SQL Server son ligeramente diferentes. Un archivo de diferencias se muestra más adelante en el tutorial donde se incluyen las diferencias. En la pestaña Visual Studio se muestra el enfoque de SQL Server. En la pestaña Visual Studio Code se muestra el enfoque para las bases de datos que no son de SQL Server, como SQLite.

  • En el modelo, incluya una columna de seguimiento que se use para determinar si una fila ha cambiado.
  • Aplique TimestampAttribute a la propiedad de simultaneidad.

Actualice el archivo Models/Department.cs con el siguiente código resaltado:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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; }

        public int? InstructorID { get; set; }

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

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

TimestampAttribute es lo que identifica la columna como columna de seguimiento de simultaneidad. La API fluida es una forma alternativa de especificar la propiedad de seguimiento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("ConcurrencyToken")
  .IsRowVersion();

El atributo [Timestamp] de una propiedad de entidad genera el código siguiente en el método ModelBuilder:

 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

El código anterior:

  • Establece el tipo de propiedad ConcurrencyToken en la matriz de bytes. byte[] es el tipo requerido para SQL Server.
  • Llama a IsConcurrencyToken. IsConcurrencyToken configura la propiedad como token de simultaneidad. En las actualizaciones, el valor del token de simultaneidad de la base de datos se compara con el valor original cuando se garantiza que no ha cambiado desde que se recuperó la instancia de la base de datos. Si ha cambiado, se inicia una excepción DbUpdateConcurrencyException y no se aplican los cambios.
  • Llama a ValueGeneratedOnAddOrUpdate, que configura la propiedad ConcurrencyToken para que se genere automáticamente un valor al agregar o actualizar una entidad.
  • HasColumnType("rowversion") establece el tipo de columna de la base de datos de SQL Server en rowversion.

El código siguiente muestra una parte del T-SQL generado por EF Core cuando se actualiza el nombre de Department:

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

El código resaltado anteriormente muestra la cláusula WHERE que contiene ConcurrencyToken. Si la base de datos ConcurrencyToken no es igual al parámetro ConcurrencyToken@p2, no se ha actualizado ninguna fila.

El código resaltado a continuación muestra el T-SQL que comprueba que se actualizó exactamente una fila:

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT devuelve el número de filas afectadas por la última instrucción. Si no se actualiza ninguna fila, EF Core produce una excepción DbUpdateConcurrencyException.

Agregar una migración

Agregar la propiedad ConcurrencyToken cambia el modelo de datos, lo que requiere una migración.

Compile el proyecto.

Ejecute los comandos siguientes en la Consola del administrador de paquetes:

Add-Migration RowVersion
Update-Database

Los comandos anteriores:

  • Crea el archivo de migración Migrations/{time stamp}_RowVersion.cs.
  • Actualizan el archivo Migrations/SchoolContextModelSnapshot.cs. La actualización agrega el siguiente código al método BuildModel:
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Scaffolding de las páginas Department

Siga las instrucciones de Scaffolding de las páginas Student con las siguientes excepciones:

  • Cree una carpeta Pages/Departments.
  • Use Department para la clase del modelo.
  • Use la clase de contexto existente en lugar de crear una.

Agregar una clase de utilidad

En la carpeta del proyecto, cree la clase Utility con el código siguiente:

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}

La clase Utility proporciona el método GetLastChars usado para mostrar los últimos caracteres del token de simultaneidad. En el código siguiente se muestra el código que funciona con SQLite y SQL Server:

#if SQLiteVersion
using System;

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(Guid token)
        {
            return token.ToString().Substring(
                                    token.ToString().Length - 3);
        }
    }
}
#else
namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}
#endif

La #if SQLiteVersiondirectiva de preprocesador aísla las diferencias en las versiones de SQLite y SQL Server y ayuda a:

  • El autor mantiene una base de código para ambas versiones.
  • Los desarrolladores de SQLite implementan la aplicación en Azure y usan SQL Azure.

Compile el proyecto.

Actualización de la página Index

La herramienta de scaffolding ha creado una columna ConcurrencyToken para la página de índice, pero ese campo no se debería mostrar en una aplicación de producción. En este tutorial, se muestra la última parte de ConcurrencyToken para ayudar a entender el funcionamiento de la simultaneidad. No se garantiza que la última parte sea única por sí misma.

Actualice la página Pages\Departments\Index.cshtml:

  • Reemplace Index por Departments.
  • Cambie el código que contiene ConcurrencyToken para mostrar solo los últimos caracteres.
  • Reemplace FirstMidName por FullName.

En el código siguiente se muestra la página actualizada:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Budget)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].StartDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                Token
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Budget)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.StartDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Administrator.FullName)
                </td>
                <td>
                    @Utility.GetLastChars(item.ConcurrencyToken)
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Actualizar el modelo de la página Edit

Actualice Pages/Departments/Edit.cshtml.cs con el siguiente código:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            // Fetch current department from DB.
            // ConcurrencyToken may have changed.
            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Set ConcurrencyToken to value read in OnGetAsync
            _context.Entry(departmentToUpdate).Property(
                 d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current ConcurrencyToken so next postback
                    // matches unless an new concurrency issue happens.
                    Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
                    // Clear the model error for the next postback.
                    ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error
            // and overides the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

Las actualizaciones de simultaneidad

OriginalValue se actualiza con el valor ConcurrencyToken de la entidad cuando se capturó en el método OnGetAsync. EF Core genera un comando SQL UPDATE con una cláusula WHERE que contiene el valor ConcurrencyToken original. Si no hay ninguna fila afectada por el comando UPDATE, se inicia una excepción DbUpdateConcurrencyException. No hay ninguna fila afectada por el comando UPDATE cuando ninguna fila tiene el valor ConcurrencyToken original.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    // Fetch current department from DB.
    // ConcurrencyToken may have changed.
    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Set ConcurrencyToken to value read in OnGetAsync
    _context.Entry(departmentToUpdate).Property(
         d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

En el código resaltado anterior:

  • El valor de Department.ConcurrencyToken es el valor cuando la entidad se capturó en la solicitud Get para la página Edit. El valor se proporciona al método OnPost por medio de un campo oculto en la página de Razor que muestra la entidad que se va a editar. El enlazador de modelos copia el valor del campo oculto en Department.ConcurrencyToken.
  • OriginalValue es lo que EF Core usa en la cláusula WHERE. Antes de que se ejecute la línea de código resaltada:
    • OriginalValue tiene el valor que estaba en la base de datos cuando se llamó a FirstOrDefaultAsync en este método.
    • Este valor puede ser diferente del que se mostró en la página Editar.
  • El código resaltado garantiza que EF Core usa el valor ConcurrencyToken original de la entidad Department mostrada en la cláusula WHERE de la instrucción UPDATE de SQL.

En el siguiente código se muestra el modelo Department. Department se inicializa en el:

  • Método OnGetAsync mediante la consulta de EF.
  • Método OnPostAsync mediante el campo oculto en la página Razor con el enlace de modelos:
public class EditModel : PageModel
{
    private readonly ContosoUniversity.Data.SchoolContext _context;

    public EditModel(ContosoUniversity.Data.SchoolContext context)
    {
        _context = context;
    }

    [BindProperty]
    public Department Department { get; set; }
    // Replace ViewData["InstructorID"] 
    public SelectList InstructorNameSL { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Department = await _context.Departments
            .Include(d => d.Administrator)  // eager loading
            .AsNoTracking()                 // tracking not required
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (Department == null)
        {
            return NotFound();
        }

        // Use strongly typed data rather than ViewData.
        InstructorNameSL = new SelectList(_context.Instructors,
            "ID", "FirstMidName");

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch current department from DB.
        // ConcurrencyToken may have changed.
        var departmentToUpdate = await _context.Departments
            .Include(i => i.Administrator)
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (departmentToUpdate == null)
        {
            return HandleDeletedDepartment();
        }

        // Set ConcurrencyToken to value read in OnGetAsync
        _context.Entry(departmentToUpdate).Property(
             d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

En el código anterior se muestra que el valor ConcurrencyToken de la entidad Department de la solicitud HTTP POST se establece en el valor ConcurrencyToken de la solicitud HTTP GET.

Cuando se produce un error de simultaneidad, el código resaltado siguiente obtiene los valores de cliente (los valores publicados para este método) y los valores de la base de datos.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

En el código siguiente se agrega un mensaje de error personalizado para cada columna que tiene valores de la base de datos diferentes a los publicados en OnPostAsync:

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

En el código resaltado siguiente se establece el valor ConcurrencyToken en el nuevo valor recuperado de la base de datos. La próxima vez que el usuario haga clic en Save, solo se detectarán los errores de simultaneidad que se produzcan desde la última visualización de la página Edit.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

La instrucción ModelState.Remove es necesaria porque ModelState tiene el valor ConcurrencyToken anterior. En la página de Razor, el valor ModelState de un campo tiene prioridad sobre los valores de propiedad de modelo cuando ambos están presentes.

Diferencias de código entre SQL Server y SQLite

A continuación se muestran las diferencias entre las versiones de SQL Server y SQLite:

+ using System;    // For GUID on SQLite

+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();

 _context.Entry(departmentToUpdate)
    .Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;

Actualizar la página Edit Razor

Actualice Pages/Departments/Edit.cshtml con el siguiente código:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.ConcurrencyToken" />
            <div class="form-group">
                <label>Version</label>
                @Utility.GetLastChars(Model.Department.ConcurrencyToken)
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

El código anterior:

  • Se actualiza la directiva page de @page a @page "{id:int}".
  • Agrega una versión de fila oculta. Se debe agregar ConcurrencyToken para que el proceso postback enlace el valor.
  • Muestra el último byte de ConcurrencyToken para fines de depuración.
  • Reemplaza ViewData con InstructorNameSL fuertemente tipadas.

Comprobar los conflictos de simultaneidad con la página Edit

Abra dos instancias de exploradores de Edit en el departamento de inglés:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Edit del departamento de inglés y seleccione Abrir en nueva pestaña.
  • En la primera pestaña, haga clic en el hipervínculo Edit del departamento de inglés.

Las dos pestañas del explorador muestran la misma información.

Cambie el nombre en la primera pestaña del explorador y haga clic en Save.

Department Edit page 1 after change

El explorador muestra la página de índice con el valor modificado y el indicador ConcurrencyToken actualizado. Tenga en cuenta el indicador ConcurrencyToken actualizado, que se muestra en el segundo postback en la otra pestaña.

Cambie otro campo en la segunda pestaña del explorador.

Department Edit page 2 after change

Haga clic en Save(Guardar). Verá mensajes de error para todos los campos que no coinciden con los valores de la base de datos:

Department Edit page error message

Esta ventana del explorador no planeaba cambiar el campo Name. Copie y pegue el valor actual (Languages) en el campo Name. Presione TAB para salir del campo. La validación del lado cliente quita el mensaje de error.

Vuelva a hacer clic en Save. Se guarda el valor especificado en la segunda pestaña del explorador. Verá los valores guardados en la página de índice.

Actualización del modelo de página Delete

Actualice Pages/Departments/Delete.cshtml.cs con el siguiente código:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.ConcurrencyToken value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La página Delete detecta los conflictos de simultaneidad cuando la entidad ha cambiado después de que se capturase. Department.ConcurrencyToken es la versión de fila cuando se capturó la entidad. Cuando EF Core crea el comando SQL DELETE, incluye una cláusula WHERE con ConcurrencyToken. Si el comando SQL DELETE tiene como resultado cero filas afectadas:

  • El valor ConcurrencyToken del comando SQL DELETE no coincide con el valor ConcurrencyToken de la base de datos.
  • Se inicia una excepción DbUpdateConcurrencyException.
  • Se llama a OnGetAsync con el concurrencyError.

Actualización de la página de Razor Delete

Actualice Pages/Departments/Delete.cshtml con el siguiente código:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
        </dt>
        <dd class="col-sm-10">
            @Utility.GetLastChars(Model.Department.ConcurrencyToken)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.ConcurrencyToken" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

En el código anterior se realizan los cambios siguientes:

  • Se actualiza la directiva page de @page a @page "{id:int}".
  • Se agrega un mensaje de error.
  • Se reemplaza FirstMidName por FullName en el campo Administrator.
  • Se cambia ConcurrencyToken para que muestre el último byte.
  • Agrega una versión de fila oculta. Se debe agregar ConcurrencyToken para que el proceso postback enlace el valor.

Prueba los conflictos de simultaneidad

Cree un departamento de prueba.

Abra dos instancias de exploradores de Delete en el departamento de prueba:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Delete del departamento de prueba y seleccione Abrir en nueva pestaña.
  • Haga clic en el hipervínculo Edit del departamento de prueba.

Las dos pestañas del explorador muestran la misma información.

Cambie el presupuesto en la primera pestaña del explorador y haga clic en Save.

El explorador muestra la página de índice con el valor modificado y el indicador ConcurrencyToken actualizado. Tenga en cuenta el indicador ConcurrencyToken actualizado, que se muestra en el segundo postback en la otra pestaña.

Elimine el departamento de prueba de la segunda pestaña. Se mostrará un error de simultaneidad con los valores actuales de la base de datos. Al hacer clic en Eliminar se elimina la entidad, a menos que se haya actualizado ConcurrencyToken.

Recursos adicionales

Pasos siguientes

Este es el último tutorial de la serie. En la versión para MVC de esta serie de tutoriales se describen temas adicionales.

Este tutorial muestra cómo tratar los conflictos cuando varios usuarios actualizan una entidad de forma simultánea (al mismo tiempo).

Conflictos de simultaneidad

Un conflicto de simultaneidad se produce cuando:

  • Un usuario va a la página de edición de una entidad.
  • Otro usuario actualiza la misma entidad antes de que el cambio del primer usuario se escriba en la base de datos.

Si la detección de simultaneidad no está habilitada, quien actualice la base de datos en último lugar sobrescribe los cambios del otro usuario. Si este riesgo es aceptable, es posible que el costo de programar la simultaneidad supere las ventajas.

Simultaneidad pesimista (bloqueo)

Una manera de evitar conflictos de simultaneidad consiste en usar bloqueos de base de datos. Esto se denomina simultaneidad pesimista. Antes de que la aplicación lea una fila de base de datos que pretende actualizar, solicita un bloqueo. Una vez que una fila está bloqueada para el acceso de actualización, ningún otro usuario puede bloquear la fila hasta que se libere el primer bloqueo.

Administrar los bloqueos tiene desventajas. Puede ser complejo de programar y causar problemas de rendimiento a medida que aumente el número de usuarios. Entity Framework Core no proporciona ninguna compatibilidad integrada para ello y en este tutorial no se muestra cómo implementarla.

Simultaneidad optimista

La simultaneidad optimista permite que se produzcan conflictos de simultaneidad y luego reacciona correctamente si ocurren. Por ejemplo, Jane visita la página de edición de Department y cambia el presupuesto para el departamento de inglés de 350.000,00 a 0,00 USD.

Changing budget to 0

Antes de que Jane haga clic en Save, John visita la misma página y cambia el campo Start Date de 9/1/2007 a 9/1/2013.

Changing start date to 2013

Primero, Jane hace clic en Guardar y ve que el cambio surte efecto, ya que en el explorador se muestra la página de índice con el valor cero como la cantidad del presupuesto.

John hace clic en Save en una página Edit que sigue mostrando un presupuesto de 350.000,00 USD. Lo que sucede después viene determinado por cómo se controlan los conflictos de simultaneidad:

  • 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, no se perderá ningún dato. 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. Este método de actualización puede reducir el número de conflictos que pueden dar lugar a una pérdida de datos. Este enfoque presenta algunos inconvenientes:

    • No puede evitar la pérdida de datos si se realizan cambios paralelos a la misma propiedad.
    • Por lo general, no es práctico en una aplicación web. Requiere mantener un estado significativo para realizar un seguimiento de todos los valores capturados y nuevos. El mantenimiento de grandes cantidades de estado puede afectar al rendimiento de la aplicación.
    • Puede aumentar la complejidad de las aplicaciones en comparación con la detección de simultaneidad en una entidad.
  • Puede permitir que los cambios de John sobrescriban los cambios de Jane.

    La próxima vez que un usuario examine el departamento de inglés, verá 9/1/2013 y el valor de 350.000,00 USD capturado. Este enfoque 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). Si no hace ninguna codificación para el control de la simultaneidad, Prevalece el cliente se realizará automáticamente.

  • Puede evitar que el cambio de John se actualice en la base de datos. Normalmente, la aplicación podría:

    • Mostrar un mensaje de error.
    • Mostrar el estado actual de los datos.
    • Permitir al usuario volver a aplicar los cambios.

    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.

Detección de conflictos en EF Core

EF Core inicia excepciones DbConcurrencyException cuando detecta conflictos. El modelo de datos se debe configurar para habilitar la detección de conflictos. Las opciones para habilitar la detección de conflictos incluyen las siguientes:

  • Configurar EF Core para incluir los valores originales de las columnas configuradas como tokens de simultaneidad en la cláusula Where de los comandos Update y Delete.

    Cuando se llama a SaveChanges, la cláusula Where busca los valores originales de todas las propiedades anotadas con el atributo ConcurrencyCheckAttribute. La instrucción de actualización no encontrará una fila para actualizar si alguna de las propiedades de token de simultaneidad ha cambiado desde la primera vez que se ha leído la fila. EF Core interpreta que se trata de 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 grandes cantidades de estado. Por tanto, generalmente este enfoque no se recomienda y no es el método usado en este tutorial.

  • En la tabla de la base de datos, incluya una columna de seguimiento que pueda usarse para determinar si una fila ha cambiado.

    En una base de datos de SQL Server, el tipo de datos de la columna de seguimiento es 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 (el número de versión de la fila original). Si otro usuario ha cambiado la fila que se va a actualizar, el valor de la columna rowversion es diferente del valor original. En ese caso, la instrucción Update o Delete no puede encontrar la fila para actualizar debido a la cláusula Where. EF Core inicia una excepción de simultaneidad cuando no hay ninguna fila afectada por un comando Update o Delete.

Agrega una propiedad de seguimiento

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

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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; }

        public int? InstructorID { get; set; }

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

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

El atributo TimestampAttribute es lo que identifica la columna como una columna de seguimiento de simultaneidad. La API fluida es una forma alternativa de especificar la propiedad de seguimiento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

En el caso de una base de datos de SQL Server, el atributo [Timestamp] de una propiedad de entidad definida como una matriz de bytes:

  • Hace que la columna se incluya en las cláusulas WHERE de DELETE y UPDATE.
  • Establece el tipo de columna de la base de datos en rowversion.

La base de datos genera un número de versión de fila secuencial que se incrementa cada vez que se actualiza la fila. En un comando Update o Delete, la cláusula Where incluye el valor de versión de fila capturado. Si la fila que se va a actualizar ha cambiado desde que se ha capturado:

  • El valor de la versión de fila actual no coincide con el valor capturado.
  • Los comandos Update o Delete no encuentran una fila porque la cláusula Where busca el valor de versión de la fila capturada.
  • Se produce una excepción DbUpdateConcurrencyException.

El código siguiente muestra una parte del T-SQL generado por EF Core cuando se actualiza el nombre de Department:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

El código resaltado anteriormente muestra la cláusula WHERE que contiene RowVersion. Si la base de datos RowVersion no es igual al parámetro RowVersion (@p2), no se ha actualizado ninguna fila.

El código resaltado a continuación muestra el T-SQL que comprueba que se actualizó exactamente una fila:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT devuelve el número de filas afectadas por la última instrucción. Si no se actualiza ninguna fila, EF Core produce una excepción DbUpdateConcurrencyException.

Actualizar la base de datos

Agregar la propiedad RowVersion cambia el modelo de datos, lo que requiere una migración.

Compile el proyecto.

  • Ejecute el comando siguiente en la Consola del administrador de paquetes:

    Add-Migration RowVersion
    

Este comando:

  • Crea el archivo de migración Migrations/{time stamp}_RowVersion.cs.

  • Actualizan el archivo Migrations/SchoolContextModelSnapshot.cs. La actualización agrega el siguiente código resaltado al método BuildModel:

    modelBuilder.Entity("ContosoUniversity.Models.Department", b =>
        {
            b.Property<int>("DepartmentID")
                .ValueGeneratedOnAdd()
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
    
            b.Property<decimal>("Budget")
                .HasColumnType("money");
    
            b.Property<int?>("InstructorID");
    
            b.Property<string>("Name")
                .HasMaxLength(50);
    
            b.Property<byte[]>("RowVersion")
                .IsConcurrencyToken()
                .ValueGeneratedOnAddOrUpdate();
    
            b.Property<DateTime>("StartDate");
    
            b.HasKey("DepartmentID");
    
            b.HasIndex("InstructorID");
    
            b.ToTable("Department");
        });
    
  • Ejecute el comando siguiente en la Consola del administrador de paquetes:

    Update-Database
    

Scaffolding de las páginas Department

  • Siga las instrucciones de Scaffolding de las páginas Student con las siguientes excepciones:

  • Cree una carpeta Pages/Departments.

  • Use Department para la clase del modelo.

    • Use la clase de contexto existente en lugar de crear una.

Compile el proyecto.

Actualización de la página Index

La herramienta de scaffolding ha creado una columna RowVersion para la página de índice, pero ese campo no se debería mostrar en una aplicación de producción. En este tutorial, se muestra el último byte de RowVersion para ayudar a entender el funcionamiento de la simultaneidad. No se garantiza que el último byte sea único por si mismo.

Actualice la página Pages\Departments\Index.cshtml:

  • Reemplace Index por Departments.
  • Cambie el código que contiene RowVersion para mostrar solo el último byte de la matriz de bytes.
  • Reemplace FirstMidName por FullName.

En el código siguiente se muestra la página actualizada:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Budget)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.StartDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Administrator.FullName)
                </td>
                <td>
                    @item.RowVersion[7]
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Actualizar el modelo de la página Edit

Actualice Pages/Departments/Edit.cshtml.cs con el siguiente código:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await setDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            var deletedDepartment = new Department();
            // ModelState contains the posted data because of the deletion error
            // and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task setDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

OriginalValue se actualiza con el valor rowVersion de la entidad cuando se capturó en el método OnGetAsync. EF Core genera un comando UPDATE de SQL con una cláusula WHERE que contiene el valor RowVersion original. Si no hay ninguna fila afectada por el comando UPDATE (ninguna fila tiene el valor RowVersion original), se produce una excepción DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

En el código resaltado anterior:

  • El valor de Department.RowVersion es lo que estaba en la entidad cuando se capturó originalmente en la solicitud Get para la página Edit. El valor se proporciona al método OnPost por medio de un campo oculto en la página de Razor que muestra la entidad que se va a editar. El enlazador de modelos copia el valor del campo oculto en Department.RowVersion.
  • OriginalValue es lo que EF Core usará en la cláusula Where. Antes de que se ejecute la línea de código resaltada, OriginalValue tiene el valor que estaba en la base de datos cuando se ha llamado a FirstOrDefaultAsync en este método, lo que podría ser diferente de lo que se mostraba en la página Edit.
  • El código resaltado garantiza que EF Core usa el valor RowVersion original de la entidad Department mostrada en la cláusula Where de la instrucción UPDATE de SQL.

Cuando se produce un error de simultaneidad, el código resaltado siguiente obtiene los valores de cliente (los valores publicados para este método) y los valores de la base de datos.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

En el código siguiente se agrega un mensaje de error personalizado para cada columna que tiene valores de la base de datos diferentes a los publicados en OnPostAsync:

private async Task setDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

En el código resaltado siguiente se establece el valor RowVersion en el nuevo valor recuperado de la base de datos. La próxima vez que el usuario haga clic en Save, solo se detectarán los errores de simultaneidad que se produzcan desde la última visualización de la página Edit.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

La instrucción ModelState.Remove es necesaria porque ModelState tiene el valor RowVersion antiguo. En la página de Razor, el valor ModelState de un campo tiene prioridad sobre los valores de propiedad de modelo cuando ambos están presentes.

Actualizar la página Edit

Actualice Pages/Departments/Edit.cshtml con el siguiente código:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

El código anterior:

  • Se actualiza la directiva page de @page a @page "{id:int}".
  • Agrega una versión de fila oculta. Se debe agregar RowVersion para que el proceso postback enlace el valor.
  • Muestra el último byte de RowVersion para fines de depuración.
  • Reemplaza ViewData con InstructorNameSL fuertemente tipadas.

Comprobar los conflictos de simultaneidad con la página Edit

Abra dos instancias de exploradores de Edit en el departamento de inglés:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Edit del departamento de inglés y seleccione Abrir en nueva pestaña.
  • En la primera pestaña, haga clic en el hipervínculo Edit del departamento de inglés.

Las dos pestañas del explorador muestran la misma información.

Cambie el nombre en la primera pestaña del explorador y haga clic en Save.

Department Edit page 1 after change

El explorador muestra la página de índice con el valor modificado y el indicador rowVersion actualizado. Tenga en cuenta el indicador rowVersion actualizado, que se muestra en el segundo postback en la otra pestaña.

Cambie otro campo en la segunda pestaña del explorador.

Department Edit page 2 after change

Haga clic en Save(Guardar). Verá mensajes de error para todos los campos que no coinciden con los valores de la base de datos:

Department Edit page error message

Esta ventana del explorador no planeaba cambiar el campo Name. Copie y pegue el valor actual (Languages) en el campo Name. Presione TAB para salir del campo. La validación del lado cliente quita el mensaje de error.

Vuelva a hacer clic en Save. Se guarda el valor especificado en la segunda pestaña del explorador. Verá los valores guardados en la página de índice.

Actualización del modelo de página Delete

Actualice Pages/Departments/Delete.cshtml.cs con el siguiente código:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La página Delete detecta los conflictos de simultaneidad cuando la entidad ha cambiado después de que se capturase. Department.RowVersion es la versión de fila cuando se capturó la entidad. Cuando EF Core crea el comando DELETE de SQL, incluye una cláusula WHERE con RowVersion. Si el comando DELETE de SQL tiene como resultado cero filas afectadas:

  • El valor RowVersion del comando DELETE de SQL no coincide con el valor RowVersion de la base de datos.
  • Se produce una excepción DbUpdateConcurrencyException.
  • Se llama a OnGetAsync con el concurrencyError.

Actualizar la página Delete

Actualice Pages/Departments/Delete.cshtml con el siguiente código:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-danger" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

En el código anterior se realizan los cambios siguientes:

  • Se actualiza la directiva page de @page a @page "{id:int}".
  • Se agrega un mensaje de error.
  • Se reemplaza FirstMidName por FullName en el campo Administrator.
  • Se cambia RowVersion para que muestre el último byte.
  • Agrega una versión de fila oculta. Se debe agregar RowVersion para que el proceso postback enlace el valor.

Prueba los conflictos de simultaneidad

Cree un departamento de prueba.

Abra dos instancias de exploradores de Delete en el departamento de prueba:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Delete del departamento de prueba y seleccione Abrir en nueva pestaña.
  • Haga clic en el hipervínculo Edit del departamento de prueba.

Las dos pestañas del explorador muestran la misma información.

Cambie el presupuesto en la primera pestaña del explorador y haga clic en Save.

El explorador muestra la página de índice con el valor modificado y el indicador rowVersion actualizado. Tenga en cuenta el indicador rowVersion actualizado, que se muestra en el segundo postback en la otra pestaña.

Elimine el departamento de prueba de la segunda pestaña. Se mostrará un error de simultaneidad con los valores actuales de la base de datos. Al hacer clic en Eliminar se elimina la entidad, a menos que se haya actualizado RowVersion.

Recursos adicionales

Pasos siguientes

Este es el último tutorial de la serie. En la versión para MVC de esta serie de tutoriales se describen temas adicionales.

Este tutorial muestra cómo tratar los conflictos cuando varios usuarios actualizan una entidad de forma simultánea (al mismo tiempo). Si experimenta problemas que no puede resolver, descargue o vea la aplicación completada.Instrucciones de descarga.

Conflictos de simultaneidad

Un conflicto de simultaneidad se produce cuando:

  • Un usuario va a la página de edición de una entidad.
  • Otro usuario actualiza la misma entidad antes de que el cambio del primer usuario se escriba en la base de datos.

Si no está habilitada la detección de simultaneidad, cuando se produzcan actualizaciones simultáneas:

  • Prevalece la última actualización. Es decir, los últimos valores de actualización se guardan en la base de datos.
  • La primera de las actualizaciones actuales se pierde.

Simultaneidad optimista

La simultaneidad optimista permite que se produzcan conflictos de simultaneidad y luego reacciona correctamente si ocurren. Por ejemplo, Jane visita la página de edición de Department y cambia el presupuesto para el departamento de inglés de 350.000,00 a 0,00 USD.

Changing budget to 0

Antes de que Jane haga clic en Save, John visita la misma página y cambia el campo Start Date de 9/1/2007 a 9/1/2013.

Changing start date to 2013

Jane hace clic en Save primero y ve su cambio cuando el explorador muestra la página de índice.

Budget changed to zero

John hace clic en Save en una página Edit que sigue mostrando un presupuesto de 350.000,00 USD. Lo que sucede después viene determinado por cómo controla los conflictos de simultaneidad.

La simultaneidad optimista incluye las siguientes opciones:

  • 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, no se perderá ningún dato. 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. Este método de actualización puede reducir el número de conflictos que pueden dar lugar a una pérdida de datos. Este enfoque:

    • No puede evitar la pérdida de datos si se realizan cambios paralelos a la misma propiedad.
    • Por lo general, no es práctico en una aplicación web. Requiere mantener un estado significativo para realizar un seguimiento de todos los valores capturados y nuevos. El mantenimiento de grandes cantidades de estado puede afectar al rendimiento de la aplicación.
    • Puede aumentar la complejidad de las aplicaciones en comparación con la detección de simultaneidad en una entidad.
  • Puede permitir que los cambios de John sobrescriban los cambios de Jane.

    La próxima vez que un usuario examine el departamento de inglés, verá 9/1/2013 y el valor de 350.000,00 USD capturado. Este enfoque 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). Si no hace ninguna codificación para el control de la simultaneidad, Prevalece el cliente se realizará automáticamente.

  • Puede evitar que el cambio de John se actualice en la base de datos. Normalmente, la aplicación podría:

    • Mostrar un mensaje de error.
    • Mostrar el estado actual de los datos.
    • Permitir al usuario volver a aplicar los cambios.

    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.

Administrar la simultaneidad

Cuando una propiedad se configura como un token de simultaneidad:

  • EF Core comprueba que no se ha modificado la propiedad después de que se capturase. La comprobación se produce cuando se llama a SaveChanges o SaveChangesAsync.
  • Si se ha cambiado la propiedad después de haberla capturado, se produce una excepción DbUpdateConcurrencyException.

Deben configurarse el modelo de datos y la base de datos para que admitan producir una excepción DbUpdateConcurrencyException.

Detectar conflictos de simultaneidad en una propiedad

Se pueden detectar conflictos de simultaneidad en el nivel de propiedad con el atributo ConcurrencyCheck. El atributo se puede aplicar a varias propiedades en el modelo. Para obtener más información, consulte Anotaciones de datos: ConcurrencyCheck.

El atributo [ConcurrencyCheck] no se usa en este tutorial.

Detectar conflictos de simultaneidad en una fila

Para detectar conflictos de simultaneidad, se agrega al modelo una columna de seguimiento rowversion. rowversion:

  • Es específico de SQL Server. Otras bases de datos podrían no proporcionar una característica similar.
  • Se usa para determinar que no se ha cambiado una entidad desde que se capturó de la base de datos.

La base de datos genera un número rowversion secuencial que se incrementa cada vez que se actualiza la fila. En un comando Update o Delete, la cláusula Where incluye el valor capturado de rowversion. Si la fila que se está actualizando ha cambiado:

  • rowversion no coincide con el valor capturado.
  • Los comandos Update o Delete no encuentran una fila porque la cláusula Where incluye la rowversion capturada.
  • Se produce una excepción DbUpdateConcurrencyException.

En EF Core, cuando un comando Update o Delete no han actualizado ninguna fila, se produce una excepción de simultaneidad.

Agregar una propiedad de seguimiento a la entidad Department

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

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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; }

        public int? InstructorID { get; set; }

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

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

El atributo Timestamp especifica que esta columna se incluye en la cláusula Where de los comandos Update y Delete. El atributo se denomina Timestamp porque las versiones anteriores de SQL Server usaban un tipo de datos timestamp antes de que el tipo rowversion de SQL lo sustituyera por otro.

La API fluida también puede especificar la propiedad de seguimiento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

El código siguiente muestra una parte del T-SQL generado por EF Core cuando se actualiza el nombre de Department:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

El código resaltado anteriormente muestra la cláusula WHERE que contiene RowVersion. Si la base de datos RowVersion no es igual al parámetro RowVersion (@p2), no se ha actualizado ninguna fila.

El código resaltado a continuación muestra el T-SQL que comprueba que se actualizó exactamente una fila:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT devuelve el número de filas afectadas por la última instrucción. Si no se actualiza ninguna fila, EF Core produce una excepción DbUpdateConcurrencyException.

Puede ver el T-SQL que genera EF Core en la ventana de salida de Visual Studio.

Actualizar la base de datos

Agregar la propiedad RowVersion cambia el modelo de base de datos, lo que requiere una migración.

Compile el proyecto. Escriba lo siguiente en una ventana de comandos:

dotnet ef migrations add RowVersion
dotnet ef database update

Los comandos anteriores:

  • Agregan el archivo de migración Migrations/{time stamp}_RowVersion.cs.

  • Actualizan el archivo Migrations/SchoolContextModelSnapshot.cs. La actualización agrega el siguiente código resaltado al método BuildModel:

  • Ejecutan las migraciones para actualizar la base de datos.

Aplicar la técnica scaffolding al modelo Departments

Siga las instrucciones que encontrará en Aplicación de scaffolding al modelo de alumnos y use Department para la clase de modelo.

El comando anterior aplica scaffolding al modelo Department. Abra el proyecto en Visual Studio.

Compile el proyecto.

Actualizar la página de índice de Departments

El motor de scaffolding creó una columna RowVersion para la página de índice, pero ese campo no debería mostrarse. En este tutorial, el último byte de la RowVersion se muestra para ayudar a entender la simultaneidad. No se garantiza que el último byte sea único. Una aplicación real no mostraría RowVersion ni el último byte de RowVersion.

Actualice la página Index:

  • Reemplace Index por Departments.
  • Reemplace el marcado que contiene RowVersion por el último byte de RowVersion.
  • Reemplace FirstMidName por FullName.

El marcado siguiente muestra la página actualizada:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Department) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Name)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Budget)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.StartDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Administrator.FullName)
            </td>
            <td>
                @item.RowVersion[7]
            </td>
            <td>
                <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

Actualizar el modelo de la página Edit

Actualice Pages/Departments/Edit.cshtml.cs con el siguiente código:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            // null means Department was deleted by another user.
            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Update the RowVersion to the value when this entity was
            // fetched. If the entity has been updated after it was
            // fetched, RowVersion won't match the DB RowVersion and
            // a DbUpdateConcurrencyException is thrown.
            // A second postback will make them match, unless a new 
            // concurrency issue happens.
            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Must clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

Para detectar un problema de simultaneidad, OriginalValue se actualiza con el valor rowVersion de la entidad de la que se ha capturado. EF Core genera un comando UPDATE de SQL con una cláusula WHERE que contiene el valor RowVersion original. Si no hay ninguna fila afectada por el comando UPDATE (ninguna fila tiene el valor RowVersion original), se produce una excepción DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    // null means Department was deleted by another user.
    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Update the RowVersion to the value when this entity was
    // fetched. If the entity has been updated after it was
    // fetched, RowVersion won't match the DB RowVersion and
    // a DbUpdateConcurrencyException is thrown.
    // A second postback will make them match, unless a new 
    // concurrency issue happens.
    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

En el código anterior, Department.RowVersion es el valor cuando se capturó la entidad. OriginalValue es el valor de la base de datos cuando se llamó a FirstOrDefaultAsync en este método.

El código siguiente obtiene los valores de cliente (es decir, los valores registrados en este método) y los valores de la base de datos:

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

El código siguiente agrega un mensaje de error personalizado para cada columna que tiene valores de la base de datos diferentes de lo que publicado en OnPostAsync:

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

El código resaltado a continuación establece el valor RowVersion para el nuevo valor recuperado de la base de datos. La próxima vez que el usuario haga clic en Save, solo se detectarán los errores de simultaneidad que se produzcan desde la última visualización de la página Edit.

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

La instrucción ModelState.Remove es necesaria porque ModelState tiene el valor RowVersion antiguo. En la página de Razor, el valor ModelState de un campo tiene prioridad sobre los valores de propiedad de modelo cuando ambos están presentes.

Actualizar la página Edit

Actualice Pages/Departments/Edit.cshtml con el marcado siguiente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

El marcado anterior:

  • Actualiza la directiva page de @page a @page "{id:int}".
  • Agrega una versión de fila oculta. Se debe agregar RowVersion para que la devolución enlace el valor.
  • Muestra el último byte de RowVersion para fines de depuración.
  • Reemplaza ViewData con InstructorNameSL fuertemente tipadas.

Comprobar los conflictos de simultaneidad con la página Edit

Abra dos instancias de exploradores de Edit en el departamento de inglés:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Edit del departamento de inglés y seleccione Abrir en nueva pestaña.
  • En la primera pestaña, haga clic en el hipervínculo Edit del departamento de inglés.

Las dos pestañas del explorador muestran la misma información.

Cambie el nombre en la primera pestaña del explorador y haga clic en Save.

Department Edit page 1 after change

El explorador muestra la página de índice con el valor modificado y el indicador rowVersion actualizado. Tenga en cuenta el indicador rowVersion actualizado, que se muestra en el segundo postback en la otra pestaña.

Cambie otro campo en la segunda pestaña del explorador.

Department Edit page 2 after change

Haga clic en Save(Guardar). Verá mensajes de error para todos los campos que no coinciden con los valores de la base de datos:

Department Edit page error message 1

Esta ventana del explorador no planeaba cambiar el campo Name. Copie y pegue el valor actual (Languages) en el campo Name. Presione TAB para salir del campo. La validación del lado cliente quita el mensaje de error.

Department Edit page error message 2

Vuelva a hacer clic en Save. Se guarda el valor especificado en la segunda pestaña del explorador. Verá los valores guardados en la página de índice.

Actualizar la página Delete

Actualice el modelo de la página Delete con el código siguiente:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La página Delete detecta los conflictos de simultaneidad cuando la entidad ha cambiado después de que se capturase. Department.RowVersion es la versión de fila cuando se capturó la entidad. Cuando EF Core crea el comando DELETE de SQL, incluye una cláusula WHERE con RowVersion. Si el comando DELETE de SQL tiene como resultado cero filas afectadas:

  • La RowVersion del comando DELETE de SQL no coincide con la RowVersion de la base de datos.
  • Se produce una excepción DbUpdateConcurrencyException.
  • Se llama a OnGetAsync con el concurrencyError.

Actualizar la página Delete

Actualice Pages/Departments/Delete.cshtml con el siguiente código:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

En el código anterior se realizan los cambios siguientes:

  • Se actualiza la directiva page de @page a @page "{id:int}".
  • Se agrega un mensaje de error.
  • Se reemplaza FirstMidName por FullName en el campo Administrator.
  • Se cambia RowVersion para que muestre el último byte.
  • Agrega una versión de fila oculta. Se debe agregar RowVersion para que la devolución enlace el valor.

Comprobar los conflictos de simultaneidad con la página Delete

Cree un departamento de prueba.

Abra dos instancias de exploradores de Delete en el departamento de prueba:

  • Ejecute la aplicación y seleccione Departments.
  • Haga clic con el botón derecho en el hipervínculo Delete del departamento de prueba y seleccione Abrir en nueva pestaña.
  • Haga clic en el hipervínculo Edit del departamento de prueba.

Las dos pestañas del explorador muestran la misma información.

Cambie el presupuesto en la primera pestaña del explorador y haga clic en Save.

El explorador muestra la página de índice con el valor modificado y el indicador rowVersion actualizado. Tenga en cuenta el indicador rowVersion actualizado, que se muestra en el segundo postback en la otra pestaña.

Elimine el departamento de prueba de la segunda pestaña. Se mostrará un error de simultaneidad con los valores actuales de la base de datos. Al hacer clic en Eliminar se elimina la entidad, a menos que se haya actualizado RowVersion.

Vea Herencia para obtener información sobre cómo se hereda un modelo de datos.

Recursos adicionales