Modified MVC AccountController for Preview 5

I just downloaded the ASP.NET MVC Preview 5 bits from Codeplex and started on my first experiment.

One of the first things I did was to modify the default AccountController to use the new Form Posting and Form Validation features of the Preview 5, somebody probably overlooked updating those :)

If anyone else wants the reworked code, feel free to copy paste.

Note this was something done during lunch break in a hurry, it seems to all work logically, but it's possible I'll have to tune it a bit later on.

Controller:

C#:
  1. [HandleError]
  2. [OutputCache(Location = OutputCacheLocation.None)]
  3. public class AccountController : Controller
  4. {
  5.     public AccountController()
  6.         : this(null, null)
  7.     {
  8.     }
  9.  
  10.     public AccountController(IFormsAuthentication formsAuth, MembershipProvider provider)
  11.     {
  12.         FormsAuth = formsAuth ?? new FormsAuthenticationWrapper();
  13.         Provider = provider ?? Membership.Provider;
  14.     }
  15.  
  16.     public IFormsAuthentication FormsAuth
  17.     {
  18.         get;
  19.         private set;
  20.     }
  21.  
  22.     public MembershipProvider Provider
  23.     {
  24.         get;
  25.         private set;
  26.     }
  27.  
  28.     [Authorize]
  29.     [AcceptVerbs("GET")]
  30.     public ActionResult ChangePassword()
  31.     {
  32.         ViewData["Title"] = "Change Password";
  33.         ViewData["PasswordLength"] = Provider.MinRequiredPasswordLength;
  34.  
  35.         return View();
  36.     }
  37.  
  38.     [Authorize]
  39.     [AcceptVerbs("POST")]
  40.     public ActionResult ChangePassword(string currentPassword, string newPassword, string confirmPassword)
  41.     {
  42.         // Basic parameter validation
  43.         if (String.IsNullOrEmpty(currentPassword))
  44.         {
  45.             ViewData.ModelState.AddModelError("currentPassword", currentPassword, "You must specify a current password.");
  46.         }
  47.         if (newPassword == null || newPassword.Length <Provider.MinRequiredPasswordLength)
  48.         {
  49.             ViewData.ModelState.AddModelError("newPassword", newPassword, String.Format(CultureInfo.InvariantCulture,
  50.                      "You must specify a new password of {0} or more characters.",
  51.                      Provider.MinRequiredPasswordLength));
  52.         }
  53.         if (!String.Equals(newPassword, confirmPassword, StringComparison.Ordinal))
  54.         {
  55.             ViewData.ModelState.AddModelError("newPassword", newPassword, "The new password and confirmation password do not match.");
  56.         }
  57.  
  58.         if (ViewData.ModelState.IsValid)
  59.         {
  60.             // Attempt to change password
  61.             MembershipUser currentUser = Provider.GetUser(User.Identity.Name, true /* userIsOnline */);
  62.             bool changeSuccessful = false;
  63.             try
  64.             {
  65.                 changeSuccessful = currentUser.ChangePassword(currentPassword, newPassword);
  66.             }
  67.             catch
  68.             {
  69.                 // An exception is thrown if the new password does not meet the provider's requirements
  70.             }
  71.  
  72.             if (changeSuccessful)
  73.             {
  74.                 return RedirectToAction("ChangePasswordSuccess");
  75.             }
  76.             else
  77.             {
  78.                 ViewData.ModelState.AddModelError("password", currentPassword, "The current password is incorrect or the new password is invalid.");
  79.             }
  80.         }
  81.  
  82.         // If we got this far, something failed, redisplay form
  83.         ViewData["Title"] = "Change Password";
  84.         ViewData["PasswordLength"] = Provider.MinRequiredPasswordLength;
  85.  
  86.         return View();
  87.     }
  88.  
  89.     public ActionResult ChangePasswordSuccess()
  90.     {
  91.         ViewData["Title"] = "Change Password";
  92.  
  93.         return View();
  94.     }
  95.  
  96.     [AcceptVerbs("GET")]
  97.     public ActionResult Login()
  98.     {
  99.         ViewData["Title"] = "Login";
  100.         ViewData["CurrentPage"] = "login";
  101.  
  102.         return View();
  103.     }
  104.  
  105.     [AcceptVerbs("POST")]
  106.     public ActionResult Login(string username, string password, bool? rememberMe)
  107.     {
  108.         // Basic parameter validation
  109.         if (String.IsNullOrEmpty(username))
  110.         {
  111.             ViewData.ModelState.AddModelError("username", username, "You must specify a username.");
  112.         }
  113.  
  114.         if (ViewData.ModelState.IsValid)
  115.         {
  116.             // Attempt to login
  117.             bool loginSuccessful = Provider.ValidateUser(username, password);
  118.  
  119.             if (loginSuccessful)
  120.             {
  121.                 FormsAuth.SetAuthCookie(username, rememberMe ?? false);
  122.                 return RedirectToAction("Index", "Home");
  123.             }
  124.             else
  125.             {
  126.                 ViewData.ModelState.AddModelError("*", username, "The username or password provided is incorrect.");
  127.             }
  128.         }
  129.  
  130.         // If we got this far, something failed, redisplay form
  131.         ViewData["Title"] = "Login";
  132.         ViewData["CurrentPage"] = "login";
  133.         ViewData["username"] = username;
  134.  
  135.         return View();
  136.     }
  137.  
  138.     public ActionResult Logout()
  139.     {
  140.         FormsAuth.SignOut();
  141.         return RedirectToAction("Index", "Home");
  142.     }
  143.  
  144.     protected override void OnActionExecuting(ActionExecutingContext filterContext)
  145.     {
  146.         if (filterContext.HttpContext.User.Identity is WindowsIdentity)
  147.         {
  148.             throw new InvalidOperationException("Windows authentication is not supported.");
  149.         }
  150.     }
  151.  
  152.     [AcceptVerbs("GET")]
  153.     public ActionResult Register()
  154.     {
  155.         ViewData["Title"] = "Register";
  156.         ViewData["PasswordLength"] = Provider.MinRequiredPasswordLength;
  157.  
  158.         return View();
  159.     }
  160.  
  161.     [AcceptVerbs("POST")]
  162.     public ActionResult Register(string username, string email, string password, string confirmPassword)
  163.     {
  164.         // Basic parameter validation
  165.         if (String.IsNullOrEmpty(username))
  166.         {
  167.             ViewData.ModelState.AddModelError("username", username, "You must specify a username.");
  168.         }
  169.  
  170.         if (String.IsNullOrEmpty(email))
  171.         {
  172.             ViewData.ModelState.AddModelError("email", email, "You must specify an email address.");
  173.         }
  174.  
  175.         if (password == null || password.Length <Provider.MinRequiredPasswordLength)
  176.         {
  177.             ViewData.ModelState.AddModelError("password", password, String.Format(CultureInfo.InvariantCulture,
  178.                      "You must specify a password of {0} or more characters.",
  179.                      Provider.MinRequiredPasswordLength));
  180.         }
  181.  
  182.         if (!String.Equals(password, confirmPassword, StringComparison.Ordinal))
  183.         {
  184.             ViewData.ModelState.AddModelError("confirmPassword", confirmPassword, "The password and confirmation do not match.");
  185.         }
  186.  
  187.         if (ViewData.ModelState.IsValid)
  188.         {
  189.  
  190.             // Attempt to register the user
  191.             MembershipCreateStatus createStatus;
  192.             MembershipUser newUser = Provider.CreateUser(username, password, email, null, null, true, null, out createStatus);
  193.  
  194.             if (newUser != null)
  195.             {
  196.                 FormsAuth.SetAuthCookie(username, false /* createPersistentCookie */);
  197.                 return RedirectToAction("Index", "Home");
  198.             }
  199.             else
  200.             {
  201.                 ViewData.ModelState.AddModelError("*", username, ErrorCodeToString(createStatus));
  202.             }
  203.         }
  204.  
  205.         // If we got this far, something failed, redisplay form
  206.         ViewData["Title"] = "Register";
  207.         ViewData["PasswordLength"] = Provider.MinRequiredPasswordLength;
  208.         ViewData["username"] = username;
  209.         ViewData["email"] = email;
  210.  
  211.         return View();
  212.     }
  213.  
  214.     public static string ErrorCodeToString(MembershipCreateStatus createStatus)
  215.     {
  216.         // See http://msdn.microsoft.com/en-us/library/system.web.security.membershipcreatestatus.aspx for
  217.         // a full list of status codes.
  218.         switch (createStatus)
  219.         {
  220.             case MembershipCreateStatus.DuplicateUserName:
  221.                 return "Username already exists. Please enter a different user name.";
  222.  
  223.             case MembershipCreateStatus.DuplicateEmail:
  224.                 return "A username for that e-mail address already exists. Please enter a different e-mail address.";
  225.  
  226.             case MembershipCreateStatus.InvalidPassword:
  227.                 return "The password provided is invalid. Please enter a valid password value.";
  228.  
  229.             case MembershipCreateStatus.InvalidEmail:
  230.                 return "The e-mail address provided is invalid. Please check the value and try again.";
  231.  
  232.             case MembershipCreateStatus.InvalidAnswer:
  233.                 return "The password retrieval answer provided is invalid. Please check the value and try again.";
  234.  
  235.             case MembershipCreateStatus.InvalidQuestion:
  236.                 return "The password retrieval question provided is invalid. Please check the value and try again.";
  237.  
  238.             case MembershipCreateStatus.InvalidUserName:
  239.                 return "The user name provided is invalid. Please check the value and try again.";
  240.  
  241.             case MembershipCreateStatus.ProviderError:
  242.                 return "The authentication provider returned an error. Please verify your entry and try again. If the problem persists, please contact your system administrator.";
  243.  
  244.             case MembershipCreateStatus.UserRejected:
  245.                 return "The user creation request has been canceled. Please verify your entry and try again. If the problem persists, please contact your system administrator.";
  246.  
  247.             default:
  248.                 return "An unknown error occurred. Please verify your entry and try again. If the problem persists, please contact your system administrator.";
  249.         }
  250.     }
  251. }
  252.  
  253. // The FormsAuthentication type is sealed and contains static members, so it is difficult to
  254. // unit test code that calls its members. The interface and helper class below demonstrate
  255. // how to create an abstract wrapper around such a type in order to make the AccountController
  256. // code unit testable.
  257.  
  258. public interface IFormsAuthentication
  259. {
  260.     void SetAuthCookie(string userName, bool createPersistentCookie);
  261.     void SignOut();
  262. }
  263.  
  264. public class FormsAuthenticationWrapper : IFormsAuthentication
  265. {
  266.     public void SetAuthCookie(string userName, bool createPersistentCookie)
  267.     {
  268.         FormsAuthentication.SetAuthCookie(userName, createPersistentCookie);
  269.     }
  270.     public void SignOut()
  271.     {
  272.         FormsAuthentication.SignOut();
  273.     }
  274. }

Login View:

ASP:
  1. <%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="GuildSite.Views.Account.Login" %>
  2.  
  3. <asp:Content ID="loginContent" ContentPlaceHolderID="MainContent" runat="server">
  4.     <h2>Login</h2>
  5.  
  6.     <p>
  7.         Please enter your username and password below. If you don't have an account,
  8.         please <%= Html.ActionLink("register", "Register") %>.
  9.     </p>
  10.  
  11.     <%= Html.ValidationSummary()%>
  12.  
  13.     <form method="post" action="<%= Html.AttributeEncode(Url.Action("Login")) %>">
  14.         <div class="form">
  15.             <table>
  16.                 <tr>
  17.                     <td>Username:</td>
  18.                     <td><%= Html.TextBox("username") %></td>
  19.                 </tr>
  20.                 <tr>
  21.                     <td>Password:</td>
  22.                     <td><%= Html.Password("password") %></td>
  23.                 </tr>
  24.                 <tr>
  25.                     <td></td>
  26.                     <td><input type="checkbox" name="rememberMe" value="true" /> Remember me?</td>
  27.                 </tr>
  28.                 <tr>
  29.                     <td></td>
  30.                     <td><input type="submit" value="Login" /></td>
  31.                 </tr>
  32.             </table>
  33.         </div>
  34.     </form>
  35. </asp:Content>

Register View:

ASP:
  1. <%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Register.aspx.cs" Inherits="GuildSite.Views.Account.Register" %>
  2.  
  3. <asp:Content ID="registerContent" ContentPlaceHolderID="MainContent" runat="server">
  4.     <h2>Account Creation</h2>
  5.  
  6.     <p>
  7.         Use the form below to create a new account.
  8.     </p>
  9.     <p>
  10.         Passwords are required to be a minimum of <%=Html.Encode(ViewData["PasswordLength"])%> characters in length.
  11.     </p>
  12.  
  13.     <%= Html.ValidationSummary()%>
  14.  
  15.     <form method="post" action="<%= Html.AttributeEncode(Url.Action("Register")) %>">
  16.         <div class="form">
  17.             <table>
  18.                 <tr>
  19.                     <td>Username:</td>
  20.                     <td><%= Html.TextBox("username") %></td>
  21.                 </tr>
  22.                 <tr>
  23.                     <td>Email:</td>
  24.                     <td><%= Html.TextBox("email") %></td>
  25.                 </tr>
  26.                 <tr>
  27.                     <td>Password:</td>
  28.                     <td><%= Html.Password("password") %></td>
  29.                 </tr>
  30.                 <tr>
  31.                     <td>Confirm password:</td>
  32.                     <td><%= Html.Password("confirmPassword") %></td>
  33.                 </tr>
  34.                 <tr>
  35.                     <td></td>
  36.                     <td><input type="submit" value="Register" /></td>
  37.                 </tr>
  38.             </table>
  39.         </div>
  40.     </form>
  41. </asp:Content>

Wordpress 2.6 Upgrade – Fix Missing Categories

Apparently I'm not the only one having problems with the categories after a Wordpress 2.6 upgrade.

So, time to give something back to the Wordpress community, some screenshots on how I fixed it.

First of all, I upgraded from 2.2.3 to 2.6, it's possible this solution will work for you, but I don't make any promises.

I started by going to phpMyAdmin and having a look at the wp_term_taxonomy table, and noticed all the descriptions where missing.

When I took a look at the backup file I made, more specifically, the wp_categories piece, I noticed the counts and ids matched up.

Empty Descriptions

I then manually edited each record, just hit the pencil icon, and filled in all my descriptions again. If you know some SQL you could do this faster, but anyway, everyone can do it manually :)

Corrected Descriptions

After I've done this, I visited my admin section and noticed the Descriptions were filled in again. But there was no Name, and all my posts still had empty categories linked to them.

To fix this, visit http://your-site/wp-admin/categories.php?action=edit&cat_ID=1 where 1 is the category id to edit. Fill in a Name and a Slug, the url name for your category, and save it.

Repeat this process for all your categories, until they all have a Name again.

Edit Names and Slugs

And that's it! Categories fixed, posts linked, category urls working again.

All Fixed

Good luck!

Wordpress 2.6 Upgrade – Categories Missing

A moment ago, I decided to upgrade to the latest Wordpress version, I was running a bit behind on 2.2.3 :)

The upgrade went quite smooth, all my plugins still work. But suddenly all my categories became empty strings. And the /tags/ urls didn't work anymore either.

After looking around a bit, I noticed my categories were stored in the wp_term_taxonomy table, with empty descriptions. Thankfully I had a backup and manually entered the descriptions for all of them.

At this point, their names showed up in the admin section again, but the post weren't linked to them yet, neither did the /tags/ work.

A little more looking around, and I figured out to access categories.php?action=edit&cat_ID=20 for each category, where I had to enter the category name and category slug.

For some reason, each category's slug was suddenly named -2-2-2-2-2-2-2-2-2-2-2-2-2, with increasing -2 per category.

After doing all that, my /tags/ work again, and my posts seem to be linked again :)

If you notice anything broken, please comment to let me know!

Efficient Compound Index Usage

Today I was made aware by Filip, a colleague of mine, about the importance of columns used in a where clause with a compound index. I decided to investigate this a bit more in detail, with proper profiling and comparisons on a large data set.

I created a test table, DemoTable, which looks as follows:

SQL:
  1. ColumnA  varchar(6)
  2. ColumnB  int
  3. ColumnC  int
  4. ColumnD  varchar(3)
  5. ..30 more COLUMNS..

The primary key consists out of ColumnA, ColumnB and ColumnC. Which implicitly means it acts as a compound index.

There are about 6.5 million records in the table.

Full Primary Key Lookup

The most obvious query to get data from this table is a simple select, using all values of the primary key, as follows:

SQL:
  1. SELECT *
  2.   FROM DemoTable
  3.  WHERE ColumnA = 'DCU'
  4.    AND ColumnB = 1
  5.    AND ColumnC = 1

Taking a look at the query plan, this does exactly what most people expect it to do, it uses a very efficient Index Seek to pick out the matching record.

A, B, C

Partial Primary Key Lookup

A variation to the above query, is the following one, using only part of the primary key.

SQL:
  1. SELECT *
  2.   FROM DemoTable
  3.  WHERE ColumnA = 'DCU'
  4.    AND ColumnB = 1

The query plan shows us that this query is also very efficient, since it can make full use of the index to get the results back.

A, B

Out of curiosity and to be thorough, I also checked the results of the following query:

SQL:
  1. SELECT *
  2.   FROM DemoTable
  3.  WHERE ColumnA = 'DCU'

Same story, our index is ordered on ColumnA, ColumnB and ColumnC, so it can easily use a binary search on it and give back all results for ColumnA.

A

The fun stuff begins when we don't just remove columns from the where clause in the same order the index is created, for example this query:

SQL:
  1. SELECT *
  2.   FROM DemoTable
  3.  WHERE ColumnA = 'DCU'
  4.    AND ColumnC = 1

SQL Server will first use an efficient index seek to get all results for the correct ColumnA, since the index is ordered by ColumnA first, and will then search in the remaining results for the correct ColumnC.

In the query plan, you can identify this as the Predicate, which displays ColumnC. In the end, this is still an efficient query, but if the majority of your queries would be filtering on ColumnA and ColumnC, it would be a good idea to change your column order to ColumnA, ColumnC, ColumnB in the index. This way, the overall efficiency of your index would increase.

A, C

Another variation of a partial key lookup is the following query:

SQL:
  1. SELECT *
  2.   FROM DemoTable
  3.  WHERE ColumnB = 1
  4.    AND ColumnC = 1

And it's at this point you can say goodbye to performance. To get the requested results, SQL Server will do a full scan on every value in your index to find matches. Making it go over 6.5 million pieces of data in my example.

If lots of your queries would be like this, I'd suggest to consider changing the column order, or add an index on ColumnB and ColumnC. Keep in mind that adding indexes has a drawback on performance of INSERTs and DELETEs, it's up to each project to define this delicate balance.

B, C

Partial Primary Key Lookup Sorted

Another case I wanted to check, was how SQL Server handles ORDER BY clauses with regards to columns in an index.

I started simple by doing:

SQL:
  1. SELECT *
  2.     FROM DemoTable
  3.    WHERE ColumnA = 'DCU'
  4.      AND ColumnB = 1
  5. ORDER BY ColumnC

As expected, SQL Server simply does an index scan for ColumnA and ColumnB and returns the results, since they are already ordered by ColumnC in the index. As efficient as possible.

A, B Order C

A small variation to this query leaves out the first column of the index, resulting in:

SQL:
  1. SELECT *
  2.     FROM DemoTable
  3.    WHERE ColumnB = 1
  4. ORDER BY ColumnC

Again, since our WHERE clause is not using the columns in the order of the index, we are invoking a full index scan for the value of ColumnB. Afterwards SQL Server calls Sort on the results to get them in the correct order. As inefficient as possible :)

B, Order C

Conclusions

It should be clear that the order of your columns in an index are very important. I've made up some guidelines for myself regarding this:

  • Place the most limiting columns first in an index.
    By this, I mean to first determine which queries I will be running against the database, determining their frequency and then ordering the columns according to the most frequently run queries.
     
  • Place an additional index if needed
    After I've ordered my columns to be as efficient as possible and see that another set of queries is run almost as frequently that they need optimization, possibly add another index on a subset of the columns in the first index. Keeping in mind for which purpose the table should be optimised (eg: SELECT vs INSERT/DELETE).

I'll be more than happy to receive some comments on possible additional tests to analyze and make my conclusions better.