Introduction
Using Microsoft ASP.NET MVC to build functional business systems with technologies such as Scaffolding is easy, as long as you use Microsoft SQL Server. I have found that convincing business users to switch to Microsoft Azure Table Storage is an unwinnable encounter, especially when they hear that it will take longer to develop.
With Microsoft Visual Studio 2013 Update 2, Microsoft enabled MVC Scaffolding extensions to be developed enabling developers to extend the behavior of the scaffolding system.
I have used these methods to develop a scaffolder that generates controllers and views for Microsoft Azure Table Storage entities.
Source code available on GitHub
Install the Visual Studio Extension
Download the Azure Table Storage Scaffolder extension from the Visual Studio Gallery. In Visual Studio 2013 open the Tools and click on the Extensions and Updates menu item.
After downloading the extension, follow the steps to install it into your copy of Visual Studio 2013.
After this step is completed, the extension will be available to use from any ASP.NET MVC project.
Create a new MVC Project
To start scaffolding Azure Table Storage entities, create a new ASP.NET Web Application in Visual Studio 2013.
On the ASP.NET One Project wizard, choose the MVC template, and select the MVC and Web API core references.
After the project is created, Install the following NuGet packages using the Package Manager Console.
install-package WindowsAzure.Storage
install-package BlueMarble.Shared
install-package BlueMarble.Shared.Azure
install-package KendoUIWeb
Add Models
Next we can add a couple of models that we would like to scaffold controllers and views for. For this sample, we will be doing a simple book + author example. We will have two tables, one to list the buthors, and one to list the books. Each book has to have an author.
The author entity has only one property, the author name.
public class Author : BlueMarble.Shared.Azure.Storage.Table.Entity
{
public Author() : base() { }
public Author(string publicId) : base(publicId) { }
public string Name { get; set; }
}
The book entity has two properties, the name of the book and reference to the author entity.
public class Book : BlueMarble.Shared.Azure.Storage.Table.Entity
{
public Book() : base() { }
public Book(string publicId) : base(publicId) { }
public string Name { get; set; }
[RelatedTable(Type = typeof(WebApplication18.Models.Author))]
public string AuthorPublicId { get; set; }
}
In the book entity, the reference to the author entity is determined by the RelatedTable attribute above the field. The field has to be a string, because it will store the PublicId value of the author table. The PublicId is generated internally by the entity, and is a combination of the PartitionKey and the RowKey with Base64 encoding applied.
It is a good practice to post-fix any related field name with PublicId, so that you can identify the data that will be stored in any given column.
Add a Storage Context
Much like the DataContext component that is available in Entity Framework, you have to provide the scaffolder with a StorageContext. The StorageContext is a class that inherits from BlueMarble.Shared.Azure.Storage.Table.StorageContext and enables the scaffolder to extend the scaffolder with the required table properties and methods. This is done by creating a partial class for each entity.
public partial class StorageContext : BlueMarble.Shared.Azure.Storage.Table.StorageContext
{
public StorageContext(Microsoft.WindowsAzure.Storage.CloudStorageAccount StorageAccount)
: base(StorageAccount)
{
}
public StorageContext()
: base(new Microsoft.WindowsAzure.Storage.CloudStorageAccount(
new Microsoft.WindowsAzure.Storage.Auth.StorageCredentials(
Properties.Settings.Default.StorageAccountName,
Properties.Settings.Default.StorageAccountKey), true))
{
}
public override void InitializeTables()
{
base.InitializeTables();
}
}
Because the scaffolding of the storage context relies on partial classes, the StorageContext that you create has to be a partial class. You need to implement two constructors, and one method (InitializeTables). The constructors allow your code to easily create storage contexts that are bound to different storage accounts.
You will also need to create two Application Properties for the StorageAccountName and StorageAccountKey. This can be done on the Settings tab in the Project Properties.
The InitializeTables method looks for all methods that are decorated with the InitializeTable attribute and executes them. These methods are automatically generated as part of the scaffolding process, and enable the automatic initialization of each table in Azure Storage.
Almost ready to Scaffold
Include the following lines in your App_Start/BundleConfig.cs file to enable KendoUI.
bundles.Add(new ScriptBundle("~/bundles/kendo").Include(
"~/Scripts/kendo/2014.1.318/kendo.web.min.js"));
bundles.Add(new StyleBundle("~/Content/kendocss").Include(
"~/Content/kendo/2014.1.318/kendo.common-bootstrap.min.css",
"~/Content/kendo/2014.1.318/kendo.bootstrap.min.css"));
The Azure Table Storage Scaffolder uses KendoUI Web edition to render the User Interface components, such as Drop Down Lists and Date Time Pickers. It also uses the KendoUI MVVM components to databind controls.
Update the <head> tag in Views/Shared/_Layout.cshtml file to include the kendocss so that it is available globally. The KendoUI scripts are added to each page where they are required as part of the scaffolding process.
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewBag.Title - My ASP.NET Application</title>
@Styles.Render("~/Content/css")
@Styles.Render("~/Content/kendocss")
@Scripts.Render("~/bundles/modernizr")
</head>
Scaffold
Right click on any item in your Solution Explorer and choose Add > New Scaffolded Item.
Next choose Razor Pages using Microsoft Azure Table Storage.
On the scaffold screen, select all of the models that you want to generate code for, and the storage context.
You can choose to only generate the controllers, api controllers, storage contexts, views or scripts. As a first time execution, it is always best to start off with all of the required components.
The Results
Views
The following views are the resulting output from the scaffolding process.
Index
The index view uses datatables.net with reactive extensions to render the grid of data. This enables the grid to collapse unused columns when the screen real-estate is limited.
Details
The details view uses HTML5 label and span elements with data-bind attributes to render content. The content is data-bound using KendoUI MVVM, and retrieves the data from the API using JavaScript after the HTML has loaded in the browser.
Delete
The delete view uses HTML5 label and span elements with data-bind attributes to render content. The content is data-bound using KendoUI MVVM, and retrieves the data from the API using JavaScript after the HTML has loaded in the browser.
Edit
The edit view uses HTML5 label and input elements with data-bind attributes to render content. The content is data-bound using KendoUI MVVM, and retrieves the data from the API using JavaScript after the HTML has loaded in the browser.
Create
The create view uses HTML5 label and input elements with data-bind attributes to render content. The content is data-bound using KendoUI MVVM, and retrieves the data from the API using JavaScript after the HTML has loaded in the browser.
Generated Code
The following code is generated from the scaffolding process.
Controller
The controller code is kept to a bare minimum to render the view. No functional code is executed inside of the controller, this is to ensure that the page is delivered to the users browser as quickly as possible. This then enables the browser to query the api and makes the user feel as if the site is more responsive.
public class AuthorController: Controller
{
public async Task<ActionResult> Index()
{
return View();
}
public async Task<ActionResult> Create()
{
return View();
}
public async Task<ActionResult> Edit(string id)
{
ViewBag.PublicId = id;
return View();
}
public async Task<ActionResult> Details(string id)
{
ViewBag.PublicId = id;
return View();
}
public async Task<ActionResult> Delete(string id)
{
ViewBag.PublicId = id;
return View();
}
public void Dispose()
{
}
}
Api Controller
The api controller code is where the StorageContext is used to read and write data to Azure Storage. It implements Get (all), Get (specific), Post (insert), Put (update) and Delete HTTP commands.
public class AuthorController: ApiController
{
protected StorageContext db = new StorageContext();
[HttpGet]
public async Task<IEnumerable<Author>> Get()
{
return db.GetAuthors();
}
[HttpGet]
public async Task<Author> Get(string id)
{
var privateEntity = BlueMarble.Shared.Azure.Storage.Table.Entity.GetPrivateEntity(new Author(id));
return db.GetAuthor(privateEntity.PartitionKey, privateEntity.RowKey).GetPublicEntity<Author>();
}
[HttpPost]
public string Post(Author entity)
{
if (ModelState.IsValid)
{
db.InsertAuthor(entity);
entity.PublicId = entity.GetPublicId();
return entity.GetPublicEntity<Author>().PublicId;
}
return string.Empty;
}
[HttpPut]
public void Put(string id, Author entity)
{
if (ModelState.IsValid)
{
db.UpdateAuthor(entity.GetPrivateEntity<Author>());
}
}
[HttpDelete]
public async Task Delete(string id)
{
var privateEntity = BlueMarble.Shared.Azure.Storage.Table.Entity.GetPrivateEntity(new Author() { PublicId = id });
var entity = db.GetAuthor(privateEntity.PartitionKey, privateEntity.RowKey);
if (entity == null)
throw new System.Exception("Author entity not found, delete failed.");
await db.DeleteAuthorAsync(entity);
}
protected override void Dispose(bool disposing)
{
db.Dispose(disposing);
base.Dispose(disposing);
}
}
Storage Context
The storage context code is a partial class to extend the project StorageContext class. This class contains all of the logic to initialize the CloudTable object in the storage account. It also contains all of the logic to perform the CRUD operations against the storage account.
public partial class StorageContext
{
#region Services
#region Constants
internal partial class Constants
{
internal partial class StorageTableNames
{
public const string Authors = "authors";
}
internal partial class StoragePartitionNames
{
public const string Author = "author";
}
}
#endregion
#region Initialize Table
[InitializeTable]
public void InitializeAuthorTables()
{
Authors = CloudTableClient.GetTableReference(Constants.StorageTableNames.Authors);
Authors.CreateIfNotExists();
}
public CloudTable Authors { get; set; }
#endregion
#region Data Access Methods
public IQueryable<Author> GetAuthors()
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.NotEqual, string.Empty);
var query = new TableQuery<Author>().Where(partitionKeyFilter);
var collection = Authors.ExecuteQuery(query);
return collection.AsQueryable();
}
public async Task<IQueryable<Author>> GetAuthorsAsync()
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.NotEqual, string.Empty);
var query = new TableQuery<Author>().Where(partitionKeyFilter);
var returnList = await ExecuteSegmentedQueryAsync<Author>(query);
return returnList.AsQueryable();
}
public IQueryable<Author> GetAuthors(string PartitionKey)
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, PartitionKey);
var query = new TableQuery<Author>().Where(partitionKeyFilter);
var collection = Authors.ExecuteQuery(query);
return collection.AsQueryable();
}
public async Task<IQueryable<Author>> GetAuthorsAsync(string PartitionKey)
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, PartitionKey);
var query = new TableQuery<Author>().Where(partitionKeyFilter);
var returnList = await ExecuteSegmentedQueryAsync<Author>(query);
return returnList.AsQueryable();
}
public Author GetAuthor(string PartitionKey, string RowKey)
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, PartitionKey);
var rowKeyFilter = TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.Equal, RowKey);
var queryFilter = TableQuery.CombineFilters(partitionKeyFilter, TableOperators.And, rowKeyFilter);
var query = new TableQuery<Author>().Where(queryFilter);
var collection = Authors.ExecuteQuery(query);
return collection.FirstOrDefault();
}
public async Task<Author> GetAuthorAsync(string PartitionKey, string RowKey)
{
var partitionKeyFilter = TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, PartitionKey);
var rowKeyFilter = TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.Equal, RowKey);
var queryFilter = TableQuery.CombineFilters(partitionKeyFilter, TableOperators.And, rowKeyFilter);
var query = new TableQuery<Author>().Where(queryFilter);
var returnList = await ExecuteSegmentedQueryAsync<Author>(query);
return returnList.FirstOrDefault();
}
public void InsertAuthor(Author Author)
{
Author.PartitionKey = GetAuthorPartitionKey(Author);
Author.RowKey = GetAuthorRowKey(Author);
Author.PublicId = Author.GetPublicId();
Insert<Author>(Author, Authors);
}
public async Task InsertAuthorAsync(Author Author)
{
Author.PartitionKey = GetAuthorPartitionKey(Author);
Author.RowKey = GetAuthorRowKey(Author);
Author.PublicId = Author.GetPublicId();
await InsertAsync<Author>(Author, Authors);
}
public void InsertAuthorBatch(IEnumerable<Author> Authors)
{
InsertBatch<Author>(Authors, this.Authors);
}
public async Task InsertAuthorBatchAsync(IEnumerable<Author> Authors)
{
await InsertBatchAsync<Author>(Authors, this.Authors);
}
public void UpdateAuthor(Author Author)
{
Replace<Author>(Author, Authors);
}
public async Task UpdateAuthorAsync(Author Author)
{
await ReplaceAsync<Author>(Author, Authors);
}
public void DeleteAuthor(Author Author)
{
Delete<Author>(Author, Authors);
}
public async Task DeleteAuthorAsync(Author Author)
{
await DeleteAsync<Author>(Author, Authors);
}
#endregion
#endregion
}
Storage Context Key Helper
The storage context key helper is a partial class that extends the project StorageContext by providing methods to generate the PartitionKey and RowKey properties for each entity.
Because of the requirements brought into play because of the PartitionKey and RowKey properties and how they affect storage in Azure Storage, you have to edit these files after they are generated. The default behaviour of these helpers after scaffolding is to allow one record to be stored. Any subsequent records will be rejected with a 409 Conflict HTTP response.
public partial class StorageContext
{
#region Key Helpers
public string GetAuthorPartitionKey(Author Author)
{
return GetPartitionKey(Constants.StorageTableNames.Authors);
}
public string GetAuthorPartitionKey()
{
return GetPartitionKey(Constants.StorageTableNames.Authors);
}
public string GetAuthorRowKey(Author Author)
{
return GetRowKey();
}
public string GetAuthorRowKey()
{
return GetRowKey();
}
#endregion
}
The BlueMarble.Shared.Azure.Storage.Table.Entity has a public property ModelGuid, which is initialized with Guid.NewGuid() in the constructor. To enable multiple records, modify the code to look like this:
public string GetAuthorRowKey(Author Author)
{
return GetRowKey(Author.ModelGuid.ToString());
}
public string GetAuthorRowKey(string ModelGuid)
{
return GetRowKey(ModelGuid);
}
This uses the unique ModelGuid for each entity to define the RowKey. Even if all of the data is in a single partition, as defined above by the constant value in Constants.StorageTableNames.Authors, each entity will have a unique ModelGuid, and thus RowKey.
I was going to make the scaffolding templates apply this logic upfront, but decided against this idea. The reason for this is that when it comes to Azure Storage, you have to think about the PartitionKey and RowKey logic for each entity. This is not SQL Server where you can simply rely on a foreign key and an auto incrementing identity column. It is important for transaction cost, and performance to identify the correct partition and row stragegies.
JavaScript
Two JavaScript files are generated, one is used for all insert, update, delete, and data binding functionality. The other is used by forms where a combo box requires to be data-bound.
var AuthorsData = AuthorsData || (function(){
return {
AuthorsDataSource: new kendo.data.DataSource({
transport: {
read: {
url: '/api/Author',
dataType: 'json'
}
},
schema: {
model: {
Name: 'Name',
}
}
})
};
}());
The following JavaScript file contains all of the logic to data-bind to a form, and to do all of the insert, update and delete behavior.
var AuthorData = AuthorData || (function(){
return {
PublicId: 'NOTSET',
Delete: function(e) {
var that = this;
e.preventDefault();
$.ajax({
url: '/api/Author/' + that.PublicId,
type: 'DELETE',
dataType: 'json',
success: function(data) {
window.location.href = '/Author/';
},
error: function(error) {
that.LogError(error);
}
});
},
Update: function(e) {
var that = this;
e.preventDefault();
$.ajax({
url: '/api/Author/' + that.PublicId,
type: 'PUT',
dataType: 'json',
data: {
Name: that.Name,
PublicId: this.PublicId
},
success: function(data) {
window.location.href = '/Author/Edit/' + that.PublicId;
},
error: function(error) {
that.LogError(error);
}
});
},
Create: function(e) {
var that = this;
e.preventDefault();
$.ajax({
url: '/api/Author',
type: 'POST',
dataType: 'json',
data: {
Name: that.Name,
PublicId: that.PublicId
},
success: function(data) {
window.location.href = '/Author/Details/' + data;
},
error: function(error) {
that.LogError(error);
}
});
},
Load: function (form) {
var that = this;
that.ViewModel.loading = true;
that.ViewModel.loaded = false;
that.Bind(form);
$.ajax({
url: '/api/Author/' + that.PublicId,
type: 'GET',
dataType: 'json',
success: function (data) {
that.LoadEntity(data, form);
},
error: function (error) {
that.LogError(error);
}
});
},
LoadEntity: function(data, form){
var that = this;
that.ViewModel.Name = data.Name;
that.ViewModel.PublicId = data.PublicId;
that.ViewModel.loading = false;
that.ViewModel.loaded = true;
that.Bind(form);
},
ViewModel: kendo.observable({
PublicId: '',
Name: '',
hasChanges: false,
saving: false,
saved: false,
creating: false,
created: false,
deleting: false,
deleted: false,
loading: true,
loaded: false,
error: false,
errorMessage: '',
update: undefined,
delete: undefined,
create: undefined
}),
Bind: function(form) {
kendo.bind(form, this.ViewModel);
},
LogError: function(error) {
var that = this;
that.ViewModel.error = true;
that.ViewModel.errorMessage = error.responseJSON.ExceptionMessage;
console.log(error);
},
Init: function(publicId) {
var that = this;
that.PublicId = publicId;
that.ViewModel.PublicId = publicId;
that.ViewModel.Name = '';
that.ViewModel.update = that.Update;
that.ViewModel.delete = that.Delete;
that.ViewModel.create = that.Create;
}
};
}());
Views
The scaffolding process generates Index, Create, Edit, Details, and Delete views which have generally the same behavior as the views generated by the standard Entity Framework based scaffolder from Microsoft.
The only difference is that these views do not rely on the standard controller to populate the data and to handle the post-backs. These views use the KendoUI MVVM to data-bind the controls to the data, and jQuery ajax calls to populate the data and handle form post-backs.
History
Keep a running update of any changes or improvements you've made here.