ASP.NET MVC Model Binding (GridView Edit)

2023-07-23

筆記 ASP.NET MVC 5 使用 Model Binding 處理使用者與應用系統之間的資料傳送與處理。

logo

Without Model Binding

// QueryString id
Request["id"]
Request.QueryString["id"]

// Form id
Request.Form["id"]

// Route id
Request.RequestContext.RouteData.Values["id"] as string)

// Files
HttpPostedFileBase file = Request.Files[0]

// Application/json
string jsonData;
using (var reader = new StreamReader(Request.InputStream, Encoding.UTF8))
{
    jsonData = reader.ReadToEnd();
}

var person = JsonConvert.DeserializeObject<Person>(jsonData);

Value Provider 順序

Form Fields > Application/json > Route > Query String > Posted Files

Interesting Topics

  • 簡單型別 / 複雜型別 / 集合

  • Bind Prefix

應用

GridView Edit

Controller

public ActionResult GridviewEdit()
{
    return View(db.Regions.ToList());
}

[HttpPost]
public ActionResult GridviewEdit(IList<Region> regions)
{
    foreach (var region in regions)
    {
        db.Regions.AddOrUpdate(region);
    }
    db.SaveChanges();
    return View(db.Regions.ToList());
}

View

@model IList<NorthwindShop2.Web.Models.Region>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()

<div class="form-horizontal">
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })

    @using (Html.BeginForm("GridViewEdit", "ModelBinding", FormMethod.Post))
    {
        <table class="table table-bordered">
            <thead>
                <tr>
                    <th>Regiod Id</th>
                    <th>Description</th>
                </tr>
            </thead>
            <tbody>
                @for (int i = 0; i < Model.Count; i++)
                {
                    <tr>
                        <td>
                            <div class="form-group">
                                @Html.HiddenFor(m => m[i].RegionID)
                                <input type="text" value="@Model[i].RegionID" class="form-control" disabled />
                            </div>
                        </td>
                        <td>
                            <div class="form-group">
                                @Html.TextBoxFor(m => m[i].RegionDescription, new { @class = "form-control" })
                            </div>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    }
</div>
}

Dual Entity Edit

編輯的 View
成功的綁定
成功的綁定

Model.cs

public partial class Categories
{
    public Categories()
    {
        this.Products = new HashSet<Products>();
    }

    public int CategoryID { get; set; }
    public string CategoryName { get; set; }
    public string Description { get; set; }

    public virtual ICollection<Products> Products { get; set; }
}
public partial class Products
{
    public int ProductID { get; set; }
    public string ProductName { get; set; }
    public int CategoryID { get; set; }
    public Nullable<decimal> UnitPrice { get; set; }
    public Nullable<short> UnitsInStock { get; set; }

    public virtual Categories Categories { get; set; }
}

兩個類別的 EDMX 關聯如圖:

View.cshtml

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
<div class="form-horizontal">
    <h4>Products</h4>
    <hr />
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })
    @Html.HiddenFor(model => model.ProductID)
    @Html.HiddenFor(model => model.Categories.CategoryID)

    <div class="form-group">
        @Html.LabelFor(model => model.ProductName, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.ProductName, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.ProductName, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.CategoryID, "CategoryID", htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.DropDownList("CategoryID", null, htmlAttributes: new { @class = "form-control" })
            @Html.ValidationMessageFor(model => model.CategoryID, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.UnitPrice, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.UnitPrice, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.UnitPrice, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.UnitsInStock, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.UnitsInStock, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.UnitsInStock, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.Categories.CategoryName, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.Categories.CategoryName, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.Categories.CategoryName, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group">
        @Html.LabelFor(model => model.Categories.Description, htmlAttributes: new { @class = "control-label col-md-2" })
        <div class="col-md-10">
            @Html.EditorFor(model => model.Categories.Description, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.Categories.Description, "", new { @class = "text-danger" })
        </div>
    </div>

    <div class="form-group mt-3">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" value="Save" class="btn btn-outline-primary me-3" />
            @Html.ActionLink("Back to List", "Index", null, new {@class= "btn btn-outline-secondary" })
        </div>
    </div>
</div>
}

除了 Scaffolding 提供的欄位外,額外新增了 HiddenFor(model => model.Categories.CategoryID), model.Categories.CategoryName, model.Categories.Description 來達到複數 Entities 的綁定成功。

Controller.cs

// GET: Products/Edit/5
public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Products products = db.Products.Find(id);
    if (products == null)
    {
        return HttpNotFound();
    }
    ViewBag.CategoryID = new SelectList(
        db.Categories, "CategoryID", "CategoryName", 
        products.CategoryID);
    return View(products);
}

// POST: Products/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit([Bind] Products products)
{
    if (ModelState.IsValid)
    {
        db.Entry(products).State = EntityState.Modified;
        db.Entry(products.Categories).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    ViewBag.CategoryID = new SelectList(db.Categories, "CategoryID", "CategoryName", products.CategoryID);
    return View(products);
}

需要加上額外的 db.Entry 來綁定除了主 Entity 外的其他 Entity,並設定為 EntityState.Modifies 來達成儲存置資料庫。

注意在 Post 的 Action 的 Parameter 綁定,有多種寫法達到多個 Entities 的更新,除了範例的方式外,也可以使用以下方式來綁定。

[Bind(Include = ("ProductID, ProductName, CategoryId, UnitPrice, UnitsInStock, Categories"))]

需要注意的是上述兩種綁定方式,因為沒有提供的值會清空資料表原來資料列的值,所枚舉的所有欄位名稱也有可能受到 OverPosting 的攻擊,如果要避免可以用 TryUpdateModel 搭配 Entity 的 Id,先取得 Entity 再透過 TryUpdateModel 去更新 Entity 並只允許特定的欄位。

TryUpdateModel

Model Binding 是被動式的綁定,藉由比對 Controller Action 的參數,透過 DefaultModelBinder 來進行綁定。而也可以進行主動式的綁定,使用 UpdateModel 以及 TryUpdateModel 的方式來達成。

public ActionResult Edit(int Id, FormCollection FormValue)
{
    Product product = db.Product.FirstOrDefault(p => p.Id == Id);
    if (TryUpdateModel(product, FormValue.AllKeys) && ModelState.IsValid)
    {
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(product);
}

Referential integrity constraint violation

A referential integrity constraint violation occurred: The property value(s) of 'Categories.CategoryID' on one end of a relationship do not match the property value(s) of 'Products.CategoryID' on the other end.

需要注意目前的設定方式,如果變更 Product 所屬的 CategoryID 就會發生錯誤。

權衡的緩解方式如下,判斷如果 CategoryID 發生改變,則只改變 Product 本身,不去調整關聯到 Category 內容,否則才依照原本的邏輯進行複數 Entity 調整。

if (ModelState.IsValid)
{
    if (products.CategoryID != products.Categories.CategoryID)
    {
        products.Categories = db.Categories.FirstOrDefault(i => i.CategoryID == products.CategoryID);
        db.Entry(products).State = EntityState.Modified;
    }
    else
    {
        db.Entry(products).State = EntityState.Modified;
        db.Entry(products.Categories).State = EntityState.Modified;
    }

    db.SaveChanges();
    return RedirectToAction("Index");
}

參考資料

ASP.NET MVC - The Features and Foibles of ASP.NET MVC Model Binding

[ASP.NET MVC 小牛之路]15 - Model Binding