Razor Pages with EF Core in ASP.NET Core – CRUD – 2 of 8

[ad_1]

By Tom Dykstra, Jon P Smith, and Rick Anderson

The Contoso University web app demonstrates how to create Razor Pages web apps using EF Core and Visual Studio. For information about the tutorial series, see the first tutorial.

In this tutorial, the scaffolded CRUD (create, read, update, delete) code is reviewed and customized.

To minimize complexity and keep these tutorials focused on EF Core, EF Core code is used in the page models. Some developers use a service layer or repository pattern in to create an abstraction layer between the UI (Razor Pages) and the data access layer.

In this tutorial, the Create, Edit, Delete, and Details Razor Pages in the Student folder are examined.

The scaffolded code uses the following pattern for Create, Edit, and Delete pages:

  • Get and display the requested data with the HTTP GET method OnGetAsync.
  • Save changes to the data with the HTTP POST method OnPostAsync.

The Index and Details pages get and display the requested data with the HTTP GET method OnGetAsync

SingleOrDefaultAsync vs FirstOrDefaultAsync

The generated code uses FirstOrDefaultAsync, which is generally preferred over SingleOrDefaultAsync.

FirstOrDefaultAsync is more efficient than SingleOrDefaultAsync at fetching one entity:

  • Unless the code needs to verify that there’s not more than one entity returned from the query.
  • SingleOrDefaultAsync fetches more data and does unnecessary work.
  • SingleOrDefaultAsync throws an exception if there’s more than one entity that fits the filter part.
  • FirstOrDefaultAsync doesn’t throw if there’s more than one entity that fits the filter part.

FindAsync

In much of the scaffolded code, FindAsync can be used in place of FirstOrDefaultAsync.

FindAsync:

  • Finds an entity with the primary key (PK). If an entity with the PK is being tracked by the context, it’s returned without a request to the DB.
  • Is simple and concise.
  • Is optimized to look up a single entity.
  • Can have perf benefits in some situations, but they rarely happens for typical web apps.
  • Implicitly uses FirstAsync instead of SingleAsync.

But if you want to Include other entities, then FindAsync is no longer appropriate. This means that you may need to abandon FindAsync and move to a query as your app progresses.

Customize the Details page

Browse to Pages/Students page. The Edit, Details, and Delete links are generated by the Anchor Tag Helper
in the Pages/Students/Index.cshtml file.

<td>
    <a asp-page="./Edit" asp-route-id="@item.ID">Edit</a> |
    <a asp-page="./Details" asp-route-id="@item.ID">Details</a> |
    <a asp-page="./Delete" asp-route-id="@item.ID">Delete</a>
</td>

Run the app and select a Details link. The URL is of the form http://localhost:5000/Students/Details?id=2. The Student ID is passed using a query string (?id=2).

Update the Edit, Details, and Delete Razor Pages to use the "id:int" route template. Change the page directive for each of these pages from @page to @page "id:int".

A request to the page with the “id:int” route template that does not include a integer route value returns an HTTP 404 (not found) error. For example, http://localhost:5000/Students/Details returns a 404 error. To make the ID optional, append ? to the route constraint:

@page "id:int?"

Run the app, click on a Details link, and verify the URL is passing the ID as route data (http://localhost:5000/Students/Details/2).

Don’t globally change @page to @page "id:int", doing so breaks the links to the Home and Create pages.

The scaffolded code for the Students Index page doesn’t include the Enrollments property. In this section, the contents of the Enrollments collection is displayed in the Details page.

The OnGetAsync method of Pages/Students/Details.cshtml.cs uses the FirstOrDefaultAsync method to retrieve a single Student entity. Add the following highlighted code:

public async Task<IActionResult> OnGetAsync(int? id)

    if (id == null)
    
        return NotFound();
    

    Student = await _context.Student
                        .Include(s => s.Enrollments)
                            .ThenInclude(e => e.Course)
                        .AsNoTracking()
                        .FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    
        return NotFound();
    
    return Page();

The Include and ThenInclude methods cause the context to load the Student.Enrollments navigation property, and within each enrollment the Enrollment.Course navigation property. These methods are examined in detail in the reading-related data tutorial.

The AsNoTracking method improves performance in scenarios when the entities returned are not updated in the current context. AsNoTracking is discussed later in this tutorial.

Open Pages/Students/Details.cshtml. Add the following highlighted code to display a list of enrollments:

@page "id:int"
@model ContosoUniversity.Pages.Students.DetailsModel

@
    ViewData["Title"] = "Details";


<h2>Details</h2>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd>
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

If code indentation is wrong after the code is pasted, press CTRL-K-D to correct it.

The preceding code loops through the entities in the Enrollments navigation property. For each enrollment, it displays the course title and the grade. The course title is retrieved from the Course entity that’s stored in the Course navigation property of the Enrollments entity.

Run the app, select the Students tab, and click the Details link for a student. The list of courses and grades for the selected student is displayed.

Update the Create page

Update the OnPostAsync method in Pages/Students/Create.cshtml.cs with the following code:

public async Task<IActionResult> OnPostAsync()

    if (!ModelState.IsValid)
    
        return Page();
    

    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    
        _context.Student.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    

    return null;

TryUpdateModelAsync

Examine the TryUpdateModelAsync code:


var emptyStudent = new Student();

if (await TryUpdateModelAsync<Student>(
    emptyStudent,
    "student",   // Prefix for form value.
    s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
{

In the preceding code, TryUpdateModelAsync<Student> tries to update the emptyStudent object using the posted form values from the PageContext property in the PageModel. TryUpdateModelAsync only updates the properties listed (s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate).

In the preceding sample:

  • The second argument ("student", // Prefix) is the prefix uses to look up values. It’s not case sensitive.
  • The posted form values are converted to the types in the Student model using model binding.

Overposting

Using TryUpdateModel to update fields with posted values is a security best practice because it prevents overposting. For example, suppose the Student entity includes a Secret property that this web page shouldn’t update or add:

public class Student

    public int ID  get; set; 
    public string LastName  get; set; 
    public string FirstMidName  get; set; 
    public DateTime EnrollmentDate  get; set; 
    public string Secret  get; set; 

Even if the app doesn’t have a Secret field on the create/update Razor Page, a hacker could set the Secret value by overposting. A hacker could use a tool such as Fiddler, or write some JavaScript, to post a Secret form value. The original code doesn’t limit the fields that the model binder uses when it creates a Student instance.

Whatever value the hacker specified for the Secret form field is updated in the DB. The following image shows the Fiddler tool adding the Secret field (with the value “OverPost”) to the posted form values.

Fiddler adding Secret field

The value “OverPost” is successfully added to the Secret property of the inserted row. The app designer never intended the Secret property to be set with the Create page.

View model

A view model typically contains a subset of the properties included in the model used by the application. The application model is often called the domain model. The domain model typically contains all the properties required by the corresponding entity in the DB. The view model contains only the properties needed for the UI layer (for example, the Create page). In addition to the view model, some apps use a binding model or input model to pass data between the Razor Pages page model class and the browser. Consider the following Student view model:

using System;

namespace ContosoUniversity.Models

    public class StudentVM
    
        public int ID  get; set; 
        public string LastName  get; set; 
        public string FirstMidName  get; set; 
        public DateTime EnrollmentDate  get; set; 
    

View models provide an alternative way to prevent overposting. The view model contains only the properties to view (display) or update.

The following code uses the StudentVM view model to create a new student:

[BindProperty]
public StudentVM StudentVM  get; set; 

public async Task<IActionResult> OnPostAsync()

    if (!ModelState.IsValid)
    
        return Page();
    

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");

The SetValues method sets the values of this object by reading values from another PropertyValues object. SetValues uses property name matching. The view model type doesn’t need to be related to the model type, it just needs to have properties that match.

Using StudentVM requires CreateVM.cshtml be updated to use StudentVM rather than Student.

In Razor Pages, the PageModel derived class is the view model.

Update the Edit page

Update the page model for the Edit page. The major changes are highlighted:

public class EditModel : PageModel

    private readonly SchoolContext _context;

    public EditModel(SchoolContext context)
    
        _context = context;
    

    [BindProperty]
    public Student Student  get; set; 

    public async Task<IActionResult> OnGetAsync(int? id)
    
        if (id == null)
        
            return NotFound();
        

        Student = await _context.Student.FindAsync(id);

        if (Student == null)
        
            return NotFound();
        
        return Page();
    

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

        var studentToUpdate = await _context.Student.FindAsync(id);

        if (await TryUpdateModelAsync<Student>(
            studentToUpdate,
            "student",
            s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
        
            await _context.SaveChangesAsync();
            return RedirectToPage("./Index");
        

        return Page();
    

The code changes are similar to the Create page with a few exceptions:

  • OnPostAsync has an optional id parameter.
  • The current student is fetched from the DB, rather than creating an empty student.
  • FirstOrDefaultAsync has been replaced with FindAsync. FindAsync is a good choice when selecting an entity from the primary key. See FindAsync for more information.

Test the Edit and Create pages

Create and edit a few student entities.

Entity States

The DB context keeps track of whether entities in memory are in sync with their corresponding rows in the DB. The DB context sync information determines what happens when SaveChangesAsync is called. For example, when a new entity is passed to the AddAsync method, that entity’s state is set to Added. When SaveChangesAsync is called, the DB context issues a SQL INSERT command.

An entity may be in one of the following states:

  • Added: The entity doesn’t yet exist in the DB. The SaveChanges method issues an INSERT statement.

  • Unchanged: No changes need to be saved with this entity. An entity has this status when it’s read from the DB.

  • Modified: Some or all of the entity’s property values have been modified. The SaveChanges method issues an UPDATE statement.

  • Deleted: The entity has been marked for deletion. The SaveChanges method issues a DELETE statement.

  • Detached: The entity isn’t being tracked by the DB context.

In a desktop app, state changes are typically set automatically. An entity is read, changes are made, and the entity state to automatically be changed to Modified. Calling SaveChanges generates a SQL UPDATE statement that updates only the changed properties.

In a web app, the DbContext that reads an entity and displays the data is disposed after a page is rendered. When a page’s OnPostAsync method is called, a new web request is made and with a new instance of the DbContext. Re-reading the entity in that new context simulates desktop processing.

Update the Delete page

In this section, code is added to implement a custom error message when the call to SaveChanges fails. Add a string to contain possible error messages:

public class DeleteModel : PageModel
{
    private readonly SchoolContext _context;

    public DeleteModel(SchoolContext context)
    
        _context = context;
    

    [BindProperty]
    public Student Student  get; set; 
    public string ErrorMessage  get; set; 

Replace the OnGetAsync method with the following code:

public async Task<IActionResult> OnGetAsync(int? id, bool? saveChangesError = false)

    if (id == null)
    
        return NotFound();
    

    Student = await _context.Student
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    
        return NotFound();
    

    if (saveChangesError.GetValueOrDefault())
    
        ErrorMessage = "Delete failed. Try again";
    

    return Page();

The preceding code contains the optional parameter saveChangesError. saveChangesError indicates whether the method was called after a failure to delete the student object. The delete operation might fail because of transient network problems. Transient network errors are more likely in the cloud. saveChangesErroris false when the Delete page OnGetAsync is called from the UI. When OnGetAsync is called by OnPostAsync (because the delete operation failed), the saveChangesError parameter is true.

The Delete pages OnPostAsync method

Replace the OnPostAsync with the following code:

public async Task<IActionResult> OnPostAsync(int? id)

    if (id == null)
    
        return NotFound();
    

    var student = await _context.Student
                    .AsNoTracking()
                    .FirstOrDefaultAsync(m => m.ID == id);

    if (student == null)
    
        return NotFound();
    

    try
    
        _context.Student.Remove(student);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    
    catch (DbUpdateException /* ex */)
    
        //Log the error (uncomment ex variable name and write a log.)
        return RedirectToAction("./Delete",
                             new  id, saveChangesError = true );
    

The preceding code retrieves the selected entity, then calls the Remove method to set the entity’s status to Deleted. When SaveChanges is called, a SQL DELETE command is generated. If Remove fails:

  • The DB exception is caught.
  • The Delete pages OnGetAsync method is called with saveChangesError=true.

Update the Delete Razor Page

Add the following highlighted error message to the Delete Razor Page.

@page "id:int"
@model ContosoUniversity.Pages.Students.DeleteModel

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


<h2>Delete</h2>

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

<h3>Are you sure you want to delete this?</h3>
<div>

Test Delete.

Common errors

Student/Home or other links don’t work:

Verify the Razor Page contains the correct @page directive. For example, The Student/Home Razor Page should not contain a route template:

@page "id:int"

Each Razor Page must include the @page directive.

[ad_2]

source_link
https://www.asp.net