Yet Another ASP.NET MVC CRUD Tutorial
Posted
by Ricardo Peres
on ASP.net Weblogs
See other posts from ASP.net Weblogs
or by Ricardo Peres
Published on Mon, 28 May 2012 11:34:21 GMT
Indexed on
2012/05/30
16:42 UTC
Read the original article
Hit count: 831
I know that I have not posted much on MVC, mostly because I don’t use it on my daily life, but since I find it so interesting, and since it is gaining such popularity, I will be talking about it much more. This time, it’s about the most basic of scenarios: CRUD.
Although there are several ASP.NET MVC tutorials out there that cover ordinary CRUD operations, I couldn’t find any that would explain how we can have also AJAX, optimistic concurrency control and validation, using Entity Framework Code First, so I set out to write one! I won’t go into explaining what is MVC, Code First or optimistic concurrency control, or AJAX, I assume you are all familiar with these concepts by now.
Let’s consider an hypothetical use case, products. For simplicity, we only want to be able to either view a single product or edit this product.
First, we need our model:
1: public class Product
2: {
3: public Product()
4: {
5: this.Details = new HashSet<OrderDetail>();
6: }
7:
8: [Required]
9: [StringLength(50)]
10: public String Name
11: {
12: get;
13: set;
14: }
15:
16: [Key]
17: [ScaffoldColumn(false)]
18: [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
19: public Int32 ProductId
20: {
21: get;
22: set;
23: }
24:
25: [Required]
26: [Range(1, 100)]
27: public Decimal Price
28: {
29: get;
30: set;
31: }
32:
33: public virtual ISet<OrderDetail> Details
34: {
35: get;
36: protected set;
37: }
38:
39: [Timestamp]
40: [ScaffoldColumn(false)]
41: public Byte[] RowVersion
42: {
43: get;
44: set;
45: }
46: }
Keep in mind that this is a simple scenario. Let’s see what we have:
- A class Product, that maps to a product record on the database;
- A product has a required (RequiredAttribute) Name property which can contain up to 50 characters (StringLengthAttribute);
- The product’s Price must be a decimal value between 1 and 100 (RangeAttribute);
- It contains a set of order details, for each time that it has been ordered, which we will not talk about (Details);
- The record’s primary key (mapped to property ProductId) comes from a SQL Server IDENTITY column generated by the database (KeyAttribute, DatabaseGeneratedAttribute);
- The table uses a SQL Server ROWVERSION (previously known as TIMESTAMP) column for optimistic concurrency control mapped to property RowVersion (TimestampAttribute).
Then we will need a controller for viewing product details, which will located on folder ~/Controllers under the name ProductController:
1: public class ProductController : Controller
2: {
3: [HttpGet]
4: public ViewResult Get(Int32 id = 0)
5: {
6: if (id != 0)
7: {
8: using (ProductContext ctx = new ProductContext())
9: {
10: return (this.View("Single", ctx.Products.Find(id) ?? new Product()));
11: }
12: }
13: else
14: {
15: return (this.View("Single", new Product()));
16: }
17: }
18: }
If the requested product does not exist, or one was not requested at all, one with default values will be returned. I am using a view named Single to display the product’s details, more on that later.
As you can see, it delegates the loading of products to an Entity Framework context, which is defined as:
1: public class ProductContext: DbContext
2: {
3: public DbSet<Product> Products
4: {
5: get;
6: set;
7: }
8: }
Like I said before, I’ll keep it simple for now, only aggregate root Product is available.
The controller will use the standard routes defined by the Visual Studio ASP.NET MVC 3 template:
1: routes.MapRoute(
2: "Default", // Route name
3: "{controller}/{action}/{id}", // URL with parameters
4: new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
5: );
Next, we need a view for displaying the product details, let’s call it Single, and have it located under ~/Views/Product:
1: <%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage<Product>" %>
2: <!DOCTYPE html>
3:
4: <html>
5: <head runat="server">
6: <title>Product</title>
7: <script src="/Scripts/jquery-1.7.2.js" type="text/javascript"></script>1:
2: <script src="/Scripts/jquery-ui-1.8.19.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.unobtrusive-ajax.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.validate.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.validate.unobtrusive.js" type="text/javascript">1: </script>
2: <script type="text/javascript">3: function onFailure(error)4: {
5: }
6:
7: function onComplete(ctx)8: {
9: }
10:
11:
</script>
8: </head>
9: <body>
10: <div>
11: <%1: : this.Html.ValidationSummary(false)%>
12: <%1: using (this.Ajax.BeginForm("Edit", "Product", new AjaxOptions{ HttpMethod = FormMethod.Post.ToString(), OnSuccess = "onSuccess", OnFailure = "onFailure" })) {%>
13: <%1: : this.Html.EditorForModel()%>
14: <input type="submit" name="submit" value="Submit" />
15: <% 1: }
%>
16: </div>
17: </body>
18: </html>
Yes… I am using ASPX syntax… sorry about that!
I implemented an editor template for the Product class, which must be located on the ~/Views/Shared/EditorTemplates folder as file Product.ascx:
1: <%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<Product>" %>
2: <div>
3: <%: this.Html.HiddenFor(model => model.ProductId) %>
4: <%: this.Html.HiddenFor(model => model.RowVersion) %>
5: <fieldset>
6: <legend>Product</legend>
7: <div class="editor-label">
8: <%: this.Html.LabelFor(model => model.Name) %>
9: </div>
10: <div class="editor-field">
11: <%: this.Html.TextBoxFor(model => model.Name) %>
12: <%: this.Html.ValidationMessageFor(model => model.Name) %>
13: </div>
14: <div class="editor-label">
15: <%= this.Html.LabelFor(model => model.Price) %>
16: </div>
17: <div class="editor-field">
18: <%= this.Html.TextBoxFor(model => model.Price) %>
19: <%: this.Html.ValidationMessageFor(model => model.Price) %>
20: </div>
21: </fieldset>
22: </div>
One thing you’ll notice is, I am including both the ProductId and the RowVersion properties as hidden fields; they will come handy later or, so that we know what product and version we are editing. The other thing is the included JavaScript files: jQuery, jQuery UI and unobtrusive validations. Also, I am not using the Content extension method for translating relative URLs, because that way I would lose JavaScript intellisense for jQuery functions.
OK, so, at this moment, I want to add support for AJAX and optimistic concurrency control. So I write a controller method like this:
1: [HttpPost]
2: [AjaxOnly]
3: [Authorize]
4: public JsonResult Edit(Product product)
5: {
6: if (this.TryValidateModel(product) == true)
7: {
8: using (BlogContext ctx = new BlogContext())
9: {
10: Boolean success = false;
11:
12: ctx.Entry(product).State = (product.ProductId == 0) ? EntityState.Added : EntityState.Modified;
13:
14: try
15: {
16: success = (ctx.SaveChanges() == 1);
17: }
18: catch (DbUpdateConcurrencyException)
19: {
20: ctx.Entry(product).Reload();
21: }
22:
23: return (this.Json(new { Success = success, ProductId = product.ProductId, RowVersion = Convert.ToBase64String(product.RowVersion) }));
24: }
25: }
26: else
27: {
28: return (this.Json(new { Success = false, ProductId = 0, RowVersion = String.Empty }));
29: }
30: }
So, this method is only valid for HTTP POST requests (HttpPost), coming from AJAX (AjaxOnly, from MVC Futures), and from authenticated users (Authorize). It returns a JSON object, which is what you would normally use for AJAX requests, containing three properties:
- Success: a boolean flag;
- RowVersion: the current version of the ROWVERSION column as a Base-64 string;
- ProductId: the inserted product id, as coming from the database.
If the product is new, it will be inserted into the database, and its primary key will be returned into the ProductId property. Success will be set to true;
If a DbUpdateConcurrencyException occurs, it means that the value in the RowVersion property does not match the current ROWVERSION column value on the database, so the record must have been modified between the time that the page was loaded and the time we attempted to save the product. In this case, the controller just gets the new value from the database and returns it in the JSON object; Success will be false.
Otherwise, it will be updated, and Success, ProductId and RowVersion will all have their values set accordingly.
So let’s see how we can react to these situations on the client side. Specifically, we want to deal with these situations:
- The user is not logged in when the update/create request is made, perhaps the cookie expired;
- The optimistic concurrency check failed;
- All went well.
So, let’s change our view:
1: <%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage<Product>" %>
2: <%@ Import Namespace="System.Web.Security" %>
3:
4: <!DOCTYPE html>
5:
6: <html>
7: <head runat="server">
8: <title>Product</title>
9: <script src="/Scripts/jquery-1.7.2.js" type="text/javascript"></script>1:
2: <script src="/Scripts/jquery-ui-1.8.19.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.unobtrusive-ajax.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.validate.js" type="text/javascript">1: </script>
2: <script src="/Scripts/jquery.validate.unobtrusive.js" type="text/javascript">1: </script>
2: <script type="text/javascript">3: function onFailure(error)4: {
5: window.alert('An error occurred: ' + error);6: }
7:
8: function onSuccess(ctx)9: {
10: if (typeof (ctx.Success) != 'undefined')11: {
12: $('input#ProductId').val(ctx.ProductId);13: $('input#RowVersion').val(ctx.RowVersion);14:
15: if (ctx.Success == false)16: {
17: window.alert('An error occurred while updating the entity: it may have been modified by third parties. Please try again.');18: }
19: else20: {
21: window.alert('Saved successfully');22: }
23: }
24: else25: {
26: if (window.confirm('Not logged in. Login now?') == true)27: {
28: document.location.href = '<%: FormsAuthentication.LoginUrl %>?ReturnURL=' + document.location.pathname;29: }
30: }
31: }
32:
33:
</script>
10: </head>
11: <body>
12: <div>
13: <%1: : this.Html.ValidationSummary(false)%>
14: <%1: using (this.Ajax.BeginForm("Edit", "Product", new AjaxOptions{ HttpMethod = FormMethod.Post.ToString(), OnSuccess = "onSuccess", OnFailure = "onFailure" })) {%>
15: <%1: : this.Html.EditorForModel()%>
16: <input type="submit" name="submit" value="Submit" />
17: <% 1: }
%>
18: </div>
19: </body>
20: </html>
The implementation of the onSuccess function first checks if the response contains a Success property, if not, the most likely cause is the request was redirected to the login page (using Forms Authentication), because it wasn’t authenticated, so we navigate there as well, keeping the reference to the current page. It then saves the current values of the ProductId and RowVersion properties to their respective hidden fields. They will be sent on each successive post and will be used in determining if the request is for adding a new product or to updating an existing one.
The only thing missing is the ability to insert a new product, after inserting/editing an existing one, which can be easily achieved using this snippet:
1: <input type="button" value="New" onclick="$('input#ProductId').val('');$('input#RowVersion').val('');"/>
And that’s it.
© ASP.net Weblogs or respective owner