Sane, the dev–friendly Classic ASP MVC framework
This is an MVC framework all written in 100% VBScript. Think of it as sort of like Spring Boot for Classic ASP. ?
Controller Example
Repository Method Example
Enumerables (with Lambdas!)
Trivial Viewmodel Iteration
Automapping (and Unit Tests!)
Database Migrations
Irreversible Migrations Too
Overview
Sane is a relatively full-featured MVC framework that brings sanity to Classic ASP. It has some similarities in style to both .NET
MVC and Rails, but doesn\’t exactly resemble either one. It is opinionated in that it assumes controllers will exist in a specific
folder location, but that location is somewhat configurable.
Major features distinguishing this framework include:
- Extensive use of domain repositories with domain model classes, and separate view model classes
- Automapper functionality, allowing mapping recordsets to domain objects (and objects to objects etc)
- Extensive use of linked lists and iterators
- Enumerable methods like
All/Anyboolean tests,Min/Max/Sum,Map/Selectfor projections, andWherefor filters, with basic lambda-style expressions supported - Database migrations with
UpandDownsteppable migrations — version controlling database changes - OWASP Top 10 mitigations
- Use of a key-value array data structure to easily pass key-value pairs between methods without creating a
Scripting.Dictionaryobject each time - Tons of HTML helpers, many of which use the
KVArrayand its helper methods to make building HTML easy - Form state serialization baked in — easily serialize/deserialize an entire form to/from the session in one line, for preserving state when returning forms to the user to fix validation errors
- Chocolate gravy
All of this was written in VBScript. Really.
Sane is licensed under the terms of GPLv3.
Note: This framework was extracted from a real-world internal workflow routing project, so it has a few rough edges.
Code Examples: Products
This gives a quick overview of the code flow for one controller and the models and views it uses. It shows how much of the functionality listed above is actually used together.
- ProductsController
- ProductsRepository with domain model
- Products view models
- Products Index view
- Product Edit view
Aren\’t there other MVC-style frameworks?
\”There are many like it, but this one is mine.\”
But… why??
Mostly because it was an interesting project that pushes the limits of Classic ASP. The vast majority of developers hate on VBScript and Classic ASP, mostly with good reason.
Many of the issues that plague Classic ASP stem from the constraints of the time in which it was developed, the mid-1990s.
Developers were unable to use what today are considered fundamental practices (widely using classes, etc) because the
language was not designed to execute in a manner we would call \”fast\” and using these practices would cause the application
to bog down and crash. Because of this the ASP community was forced to use ASP the same way PHP was used — as an inline
page-based template processor, not a full application framework in its own right. Plus, let\’s be honest, Microsoft
marketed ASP at everyone regardless of skill level, and most of the tutorials found online were horrible and encouraged
horribly bad practices.
Today we know better, and thanks to Moore\’s Law computing power has risen roughly 500-fold since the mid-90s, so we can
afford to do things that were unthinkable a few years back.
This framework was extracted from a real project that was built in this fashion. It worked quite well, and there should be
no reason (functionally speaking) that it shouldn\’t work as a viable application framework. That said, realistically if we
need to develop an application today we would use a modern framework such as .NET MVC or one of its competitors, so this is
really just here in case it is helpful to someone else. Plus it was fun to build. 🙂
Installation
Dependency: The demo was built against the Microsoft Northwind sample database. Download the SQL Server BAK file here and restore it into a SQL Server instance. SQL Scripts and MDF file are also available.
- Download the release to your directory of choice.
- In Visual Studio,
File -> Open Web Site...and select theDemodirectory - Open file
App\\DAL\\lib.DAL.aspand modify the connection string to point to your database. - Start the website (F5 or CTRL-F5) at
/index.asp
The file index.asp will automatically redirect you to the Home controller, /App/Controllers/HomeController.asp and will load the
default action, Index.
Features
A few of the features below have corresponding ASPUnit tests. Find them in the Tests directory.
Controllers
<% Class OrdersController Public Model Public Sub Show MVC.RequirePost dim id : id = Request(\"Id\") set Model = new Show_ViewModel_Class set Model.Order = OrderRepository.FindById(id) %> <!--#include file=\"../../Views/Orders/Index.asp\"--> <% End Sub End Class MVC.Dispatch %>
The controller only knows about the domain, not the database. Joy!
Actions are functions without parameters (like Rails and unlike .NET MVC) — params are pulled from the Request object as in traditional ASP. MVC.RequirePost allows you to restrict the action to only respond to POST requests — errors out otherwise.
MVC.Dispatch is the magic sauce entry point to the framework. Since views are #included into the controller, and since an
app can have 1..n controllers each with 1..n actions, having a central monolithic MVC dispatcher is not feasible beyond
a few simple controllers. This is because ASP loads and compiles the entire #included file for every page view. Given that,
the framework instead delegates instantiation out to the controllers themselves, making them responsible for kicking off the
framework instead of making the framework responsible for loading and instantiating all controllers and loading and compiling all
views for every request. The framework does instantiate the controller dynamically based on naming conventions, but only one
controller is parsed and loaded per request instead of all controllers. By only loading one controller we can use the savings to
instead load up a lot of helpful libraries that make development much more developer friendly.
Because of this approach the \”routing engine\” is really just a class that knows how to build URLs to controller files, so
Routes.UrlTo(\"Orders\", \"Show\", Array(\"Id\", order.Id)) generates the URL /App/Controllers/OrdersController.asp?_A=Show&Id=123
(for order.Id = 123). URLs point to controllers and provide the name of the action to be executed via the _A parameter. Action
parameters are passed via a KVArray data structure, which is simply an array of key/value pairs that is used extensively
throughout the framework. For example, here are two KVArrays used in one of the many HTML helpers:
<%= HTML.LinkToExt(\"View Orders\", _ \"Orders\", _ \"List\", _ array(\"param1\", \"value1\", \"param2\", \"value2\"), _ array(\"class\", \"btn btn-primary\", \"id\", \"orders-button\")) %>
Behind the scenes this method builds an anchor that routes to the correct controller/action combo, passes the specified parameters
via the querystring, and has the specified HTML class and id attributes. KVArrays are easily handled thanks to a few helper
methods such as KeyVal and KVUnzip.
More about KVArrays
The KVArray data structure is fundamental to much of the framework and greatly simplifies coding. Fundamentally a KVArray is nothing more than a standard VBScript array that should always be consumed in groups of two. In other words, to build a KVArray we just need to build an array with element 0 being the first key and element 1 its value, element 2 the second key and element 3 its value, etc.
In essence you can imagine a KVArray as a way to use System.Object style calls as is done in .NET\’s Html.ActionLink.
For example:
dim kvarray : kvarray = Array(6) \'Element 1: Name = Bob kvarray(0) = \"Name\" kvarray(1) = \"Bob\" \'Element 2: Age = 35 kvarray(2) = \"Age\" kvarray(3) = 35 \'Element 3: FavoriteColor = Blue kvarray(4) = \"FavoriteColor\" kvarray(5) = \"Blue\"
But in reality you would never write it like that, instead you would use the inline Array constructor like this:
dim params : params = Array(\"Name\", \"Bob\", \"Age\", 35, \"FavoriteColor\", \"Blue\")
Or for more readability:
dim params : params = Array( _ \"Name\", \"Bob\", _ \"Age\", 35, _ \"FavoriteColor\", \"Blue\" _ )
To iterate over this array step by 2 and use KeyVal to get the current key and value:
dim idx, the_key, the_val For idx = 0 to UBound(kvarray) step 2 KeyVal kvarray, idx, the_key, the_val Next
On each iteration, the_key will contain the current key (e.g. \”Name\”, \”Age\”, or \”FavoriteColor\”) and the_val will contain the key\’s corresponding value.
But why not use a Dictionary?
Dictionaries are great, but they are COM components and were at least historically expensive to instantiate and because of threading should not be placed in the session. They are also cumbersome to work with for the use cases in this framework and there is no easy way to instantiate them inline with a dynamic number of parameters.
What we actually need is a fast, forward-only key-value data structure that allows us to iterate over the values and pluck out each key and value to build something like an HTML tag with arbitrary attributes or SQL where clause with arbitrary columns, not fast lookup of individual keys. So we need a hybrid of the array and dictionary that meets our specific needs and allows inline declaration of an arbitrary number of parameters. The KVArray allows us to very naturally write code like the LinkToExt example above, or manually building URLs using Routes.UrlTo():
<% <a href=\"<%= Routes.UrlTo(\"Users\", \"Edit\", array(\"Id\", user.Id)) %>\"> <i class=\"glyphicon glyphicon-user\"></a> </a> %>
We can also create generic repository Find methods that can be used like this:
set expensive_products_starting_with_C = ProductRepository.Find( _ array(\"name like ?\", \"C%\", _ \"price > ?\", expensive_price _ ) _ ) set cheap_products_ending_with_Z = ProductRepository.Find( _ array(\"name like ?\", \"%Z\", _ \"price < ?\", cheap_price _ ) _ )
There are examples of this in the demo repositories, where KVUnzip is also used very effectively to help easily build the sql where clause. The below example is from the ProductRepository.Find() method which accepts a KVArray containing predicate key-value pairs and unzips it into two separate arrays that are used to build the query:
If Not IsEmpty(where_kvarray) then sql = sql & \" WHERE \" dim where_keys, where_values KVUnzip where_kvarray, where_keys, where_values dim i For i = 0 to UBound(where_keys) If i > 0 then sql = sql & \" AND \" sql = sql & \" \" & where_keys(i) & \" \" Next End If ... dim rs : set rs = DAL.Query(sql, where_values) set Find = ProductList(rs)
Domain Models
<% Class OrderModel_Class Public Validator Public OrderNumber, DateOrdered, CustomerName, LineItems Public Property Get SaleTotal SaleTotal = Enumerable(LineItems).Sum(\"item_.Subtotal\") \' whaaaa? End Property Public Sub Class_Initialize ValidatePattern Me, OrderNumber, \"^\\d{9}[\\d|X]$\", \"Order number format is incorrect.\" ValidateExists Me, DateOrdered, \"DateOrdered cannot be blank.\" ValidateExists Me, CustomerName, \"Customer name cannot be blank.\" End Sub End Class Class OrderLineItemModel_Class Public ProductName, Price, Quantity, Subtotal End Class %>
Model Validations
Validate models by calling the appropriate Validate* helper method from within the model\’s Class_Initialize constructor:
Private Sub Class_Initialize ValidateExists Me, \"Name\", \"Name must exist.\" ValidateMaxLength Me, \"Name\", 10, \"Name cannot be more than 10 characters long.\" ValidateMinLength Me, \"Name\", 2, \"Name cannot be less than 2 characters long.\" ValidateNumeric Me, \"Quantity\", \"Quantity must be numeric.\" ValidatePattern Me, \"Email\", \"[\\w-]+@([\\w-]+\\.)+[\\w-]+\", \"E-mail format is invalid.\" End Sub
Currently only ValidateExists, ValidateMinLength, ValidateMaxLength, ValidateNumeric, and ValidatePattern are included.
What these helper methods actually do is create a new instance of the corresponding validation class and attach it to the model\’s
Validator property. For example, when a model declares a validation using ValidateExists Me, \"Name\", \"Name must exist.\" the
following is what actually happens behind the scenes:
Sub ValidateExists(instance, field_name, message) if not IsObject(instance.Validator) then set instance.Validator = new Validator_Class instance.Validator.AddValidation new ExistsValidation_Class.Initialize(instance, field_name, message) End Sub
Here Me is the domain model instance. The Validator_Class is then used (via YourModel.Validator) to validate all registered
validation rules, setting the Errors and HasErrors fields if errors are found. This is similar to the Observer pattern. The
reason we pass Me is because this allows us to have a conveniently-worded method for each validation that has strong semantic
meaning, e.g. ValidateExists. It takes a bit of code-jutsu but its worth it.
Adding new validations is easy, just add a new validation class and helper Sub. For example, to add a validation that requires
that a string start with the letter \”A\” you would create a StartsWithLetterAValidation_Class and helper method
Sub ValidateStartsWithA(instance, field_name, message), then call it via ValidateStartsWithA Me, \"MyField\", \"Field must start with A.\"
Domain Repositories
Domain models can be built by converting an ADO Recordset into a linked list of domain models via Automapper-style transforms. Say Whaaat?
Class OrderRepository_Class Public Function GetAll() dim sql : sql = \"select OrderNumber, DateOrdered, CustomerName from Orders\" dim rs : set rs = DAL.Query(sql, empty) \'optional second parameter, can be scalar or array of binds dim list : set list = new LinkedList_Class Do until rs.EOF list.Push Automapper.AutoMap(rs, new OrderModel_Class) \' keanuwhoa.jpg rs.MoveNext Loop set GetAll = list Destroy rs \' no passing around recordsets, no open connections to deal with End Function End Class \' Convenience wrapper lazy-loads the repository dim OrderRepository__Singleton Function OrderRepository() If IsEmpty(OrderRepository__Singleton) then set OrderRepository__Singleton = new OrderRepository_Class End If set OrderRepository = OrderRepository__Singleton End Function
The use of the empty keyword is a common approach taken by this framework. A common complaint of VBScript is that it does not
allow optional parameters. While this is technically true it is easy to work around, yet virtually every example found online
involves passing empty strings, or null values, or a similar approach. Using the built-in VBScript keyword empty is a semantically-meaningful way to handle optional parameters, making it clear that we specifically intended to ignore the optional parameter. In this case the DAL.Query method accepts two parameters, the SQL query and an optional second parameter containing bind values. The second parameter can be either a single value as in DAL.Query(\"select a from b where a = ?\", \"foo\") or an array of binds e.g.
DAL.Query(\"select a from b where a = ? and c = ?\", Array(\"foo\", \"bar\"). In the above example it is explicitly ignored since there are no bind variables in the SQL.
In this example the DAL variable is simply an instance
of the Database_Class from lib.Data.asp. In the original project the DAL was a custom class that acted as an entry point for
a set of lazy-loaded Database_Class instances, allowing data to be shared and moved between databases during the workflow.
The Automapper object is a VBScript class that attempts to map each field in the source object to a corresponding field in the
target object. The source object can be a recordset or a custom class. The function can map to a new or existing object. The
Automapper object contains three methods: AutoMap which attempts to map all properties; FlexMap which allows you to choose
a subset of properties to map, e.g. Automapper.FlexMap(rs, new OrderModel_Class, array(\"DateOrdered\", \"CustomerName\"))
will only copy the two specified fields from the source recordset to the new model instance; and DynMap which allows you to
dynamically remap values, for a contrived example see:
Automapper.DynMap(rs, new OrderModel_Class, _ array(\"target.CustomerName = UCase(src.CustomerName)\", _ \"target.LikedOrder = src.CustomerWasHappy\"))
Because both source and target can be any object with instance methods this is a very useful way to manage model binding in CRUD
methods, for example:
Public Sub CreatePost dim new_product_model : set new_product_model = Automapper.AutoMap(Request.Form, new ProductModel_Class) ... etc End Sub
Views
Because it is #included into the controller action, the view has full access to the controller\’s Model instance. Here it accesses
the Order property of the view model and iterates over the LineItems property (which would be a LinkedList_Class instance built
inside the repository) to build the view. Using view models you can create rich views that are not tied to a specific recordset
structure. See the HomeController in the demo for an example view model that contains four separate lists of domain objects to
build a dashboard summary view.
The MVC.RequireModel method provides the ability to strongly-type the view, mimicking the @model directive in .NET MVC.
<% MVC.RequireModel Model, \"Show_ViewModel_Class\" %> <h2>Order Summary</h2> <div class=\"row\"> <div class=\"col-md-2\"> Order #<%= Model.Order.OrderNumber %> </div> <div class=\"col-md-10\"> Ordered on <%= Model.Order.DateOrdered %> by <%= Model.Order.CustomerName %> for <%= FormatCurrency(Model.Order.SaleTotal) %> </div> </div> <table class=\"table\"> <thead> <tr> <th>Product</th> <th>Price</th> <th>Qty</th> <th>Subtotal</th> </tr> <% dim it : set it = Model.Order.LineItems.Iterator %> <% dim item %> <% While it.HasNext %> <% set item = it.GetNext() %> <tr> <td><%= item.ProductName %></td> <td><%= item.Price %></td> <td><%= item.Quantity %></td> <td><%= item.Subtotal %></td> </tr> <% Wend %> </thead> </table>
Enumerable Builder
Provides chainable lambda-style calls on a list. From the unit tests:
Enumerable(list) _ .Where(\"len(item_) > 5\") _ .Map(\"set V_ = new ChainedExample_Class : V_.Data = item_ : V_.Length = len(item_)\") _ .Max(\"item_.Length\")
V_ is a special instance variable used by the Map method to represent the result of the \”lambda\” expression. item_ is
another special instance variable that represents the current item being processed. So in this case, Map iterates over each
item in the list and executes the passed \”lambda\” expression. The result of Map is a new instance of EnumerableHelper_Class
containing a list of ChainedExample_Class instances built by the expression. This enumerable is then processed by Max to
return a single value, the maximum length.
Database Class
Wraps connection details and access to the database. In addition to the examples already shown it can also handle:
- Execution of SQL without return values:
DAL.Execute \"delete from Orders where OrderId = ?\", id - Paged queries:
set rs = DAL.PagedQuery(sql, params, per_page, page_num)- See the demo ProductsController.asp for a usage example
- Note: This uses recordset paging, you must implement your own server-side paging if needed.
- Transactions:
DAL.BeginTransaction,DAL.CommitTransaction, andDAL.RollbackTransaction
The class also automatically closes and destroys the wrapped connection via the Class_Terminate method which is called when the
class is ready for destruction.
Database Migrations
Class Migration_01_Create_Orders_Table Public Migration Public Sub Up Migration.Do \"create table Orders \" &_ \"(OrderNumber varchar(10) not null, DateOrdered datetime, CustomerName varchar(50))\" End Sub Public Sub Down Migration.Do \"drop table Orders\" End Sub End Class Migrations.Add \"Migration_01_Create_Orders_Table\"
Migrations can be stepped up and down via web interface located at migrate.asp. Migration.Do executes SQL commands. Migrations are processed in the order loaded. Recommend following a structured naming scheme as shown above for easy ordering. There are a few special commands, such as
Migration.Irreversible that let you stop a down migration from proceeding, etc.
The real-world project from which the framework was extracted contained approximately 3 dozen migrations, so it worked very well
for versioning the DB during development.
Note: the migrations web interface is very basic and non-pretty
Dependency: To use the migrations feature you must first create the table meta_migrations using the script [! Create Migrations Table.sql](Sane/Framework/Data/Migrations/! Create Migrations Table.sql).
Debugging Helpers
Since the use of step-through debugging is not always possible in Classic ASP, these make debugging and tracing much easier.
Dump outputs objects in a meaningful way:
dim a : a = GetSomeArray() Dump a
Output:
[Array:
0 => «elt1»
1 => «elt2»
2 => «elt3»
]
It even handles custom classes, using the Class_Get_Properties field:
Dump Product
Output:
{ProductModel_Class:
Id : Long => «17»,
Name : String => «Alice Mutton»,
CategoryId : Long => «6»,
Category : Empty => «»,
CategoryName : String => «Meat/Poultry»,
SupplierId : Long => «7»,
Supplier : Empty => «»,
SupplierName : String => «Pavlova, Ltd.»,
UnitPrice : Currency => «250»,
UnitsInStock : Integer => «23»,
UnitsOnOrder : Integer => «0»,
ReorderLevel : Integer => «0»,
Discontinued : Boolean => «True»
}
And it handles nesting, as seen here when a call to Dump Model was placed in the Show action of OrdersController.asp in the Demo:
{OrderModel_Class: Id : Long => «11074», CustomerId : String => «SIMOB», OrderDate : Date => «5/6/1998», RequiredDate : Date => «6/3/1998», ShippedDate : Null => «», ShipName : String => «Simons bistro», ShipAddress : String => «Vinbæltet 34», ShipCity : String => «Kobenhavn», ShipCountry : String => «Denmark», LineItems : LinkedList_Class => [List: 1 => {OrderLineItemModel_Class: ProductId : Long => «16», ProductName : String => «Pavlova», UnitPrice : Currency => «17.45», Quantity : Integer => «14», Discount : Single => «0.05», ExtendedPrice : Currency => «232.09» } ]}
quit immediately halts execution. die \"some message\" halts execution and outputs an \”some message\” to the screen. trace \"text\"
and comment \"text\" both write HTML comments containing \”text\”, useful for tracing behind the scenes without disrupting layout.
Rails-style \”Flash\” Messages
Flash.Success = \"Product updated.\", Flash.Errors = model.Validator.Errors, etc.
Form Serialization
If errors are encountered when creating a model we should be able to re-display the form with the user\’s content still filled in. To
simplify this the framework provides the FormCache object which serializes/deserializes form data via the session.
For example, in a Create action we can have:
Public Sub Create
dim form_params : set form_params = FormCache.DeserializeForm(\"NewProduct\")
If Not form_params Is Nothing then
set Model = Automapper.AutoMap(form_params, new <span
