The Practical Client
Browsing Objects with IndexedDB
Peter shows how to browse a set of objects in an IndexedDB ObjectStore and, along the way, finishes up his TypeScript/IndexedDB utilities for storing and retrieving large amounts of data on the client.
The HTML5 specification includes the IndexedDB -- a database accessible through JavaScript -- that you can use to hold large amounts of data at the client. In my last column, "Leveraging TypeScript When Working with IndexedDB," I showed what a "TypeScript-oriented" solution to create a set of reusable classes to simplify working with the IndexedDB would look like. However, that code only supported working with a single object at a time. This column has the code for retrieving and browsing a "cursor's-worth" of objects (and also integrating it into my reusable classes, of course).
Also in that last column, I drew parallels between the way IndexedDB worked and a "real" relational database. In this column, however, I'm acknowledging the major difference between IndexedDB and a relation ObjectStore: IndexedDB stores objects in an ObjectStore, not rows in a table.
My design has two classes. One class, called ManageDatabase, has methods for initializing an IndexedDB database and populating it with ObjectStores. I also have an ObjectStoreInfo class that lets me pass the specifications for an ObjectStore to ManageDatabase. Effectively, those ObjectStoreInfo objects form a simpleminded schema for your IndexedDB database (my ObjectStoreInfo objects don't, for example, provide the functionality of something as sophisticated as idb-schema).
My other class, ManageObjectStore (originally called "ManageTable"), provides support for working with one ObjectStore in an IndexedDB database. Last month, I integrated code to support the Create, Read, Update and Delete (CRUD) activities on a single object in an ObjectStore. To support ManageObjectStore, I also defined an IDBCallback interface that developers would use when reading a single object from the IndexedDB asynchronously.
One warning: I'm not going to implement full support for IndexedDB cursors. For example, the IndexedDB supports scrolling both forward and backward through a collection of objects -- I'm just going to provide scroll-forward ability.
Reading All the Objects
I'll start off with the obvious case: Reading all the objects from an ObjectStore. With IndexedDB, reading objects is done asynchronously so I need to pass one of my IDBCallback classes to the method -- when reading objects, I'll call a method in that IDBCallback class to process each object as I retrieve it. But, surely, if all I'm going to call is a single method, requiring the developer to pass a whole class to my method is overkill. For my ReadAllObjects function (and for my original "read-a-single-object" function), I can just specify a format for a callback function as a parameter to my method and let the developer just pass a function to my method.
That callback function is easy to design: It just needs to accept an object of the type held in the ObjectStore and return nothing. Fortunately, when I defined my ManageObjectStore class, I set it up as a generic class that allows the developer to specify the type of the object held in the ObjectStore. I can simply recycle ManageObjectStore's type placeholder and use it in the parameter for my callback parameter. The code that defines my ManageObjectStore class and my new ReadAllObjects method that accepts a callback function as its parameter looks like this:
class ManageObjectStore<T> {
public db: IDBDatabase;
ReadAllObjects(callback: (obj: T) => void) {
Within my method, to read a group of objects, I first need to create a transaction on the ObjectStore with which the ManageObjectStore class is working. The name of that ObjectStore is held in the class's ObjectStoreInfo structure that holds all the information about the ObjectStore. Once that transaction is created, I enlist my ObjectStore into the transaction and then call the ObjectStore's OpenCursor method. That methods returns an IDBRequest object that will manage the cursor.
All that code looks like this:
var trans: IDBTransaction;
var objStore: IDBObjectStore;
var req: IDBRequest;
trans = this.db.transaction([this.tInfo.ObjectStoreName], "readonly");
objStore = trans.objectStore(this.tInfo.ObjectStoreName);
req = objstr.openCursor();
To actually process the objects returned by the cursor, I must assign a function to that IDBRequest object's success property. That success function will be passed an Event object that holds either an IDBCursorWithValue object (if some objects were found) or an IDBCursor object (if no objects were found). If I do get an IDBCursorWithValue object, it will have a value property holding the first object retrieved.
I pass that object the callback function that the developer passed to my ReadAllObjects method. When the callback finishes, I move onto the next object by calling the IDBCursorWithValue's continue method, which re-executes my success function. Here's what that success function looks like:
req.onsuccess = function (e: any) {
if (e.target.result instanceof IDBCursorWithValue) {
var obj: T;
var curs: IDBCursorWithValue;
curs = e.target.result;
obj = curs.value;
callback(obj);
curs.continue();}}}
Using ReadAllObjects
To use my ManageObjectStore class, you first create an ObjectStoreInfo object that describes the ObjectStore. You then instantiate ManageObjectStore specifying the type of object held in the ObjectStore and passing the ObjectStoreInfo object. You also need to tie the ManageObjectStore class to the ManageDatabase responsible for the database. Typical code will look like this:
var oInfo: ObjectStoreInfo;
oInfo = new ObjectStoreInfo();
oInfo.ObjectStoreName = "CustOrders";
oInfo.PrimaryFieldName = "CustId";
oInfo.PrimaryIndexName = "CustIdIndex";
var moCust: ManageObjectStore<Customer>;
moCust = new ManageObjectStore<Customer>(oInfo);
moCust.db = md.db;
With that work done, I can call ReadAllObjects, passing a callback function. The callback function in this example adds each object to the UI as a row in an HTML table:
moCust.ReadAllObjects(function (cust){
$("#customers").append("<tr>" + "<td>" + cust.CustId + "</td>" + "<td>" + cust.Name + "</td>" +
"</tr>");}}
Filtering Objects
But more often than not, you'll only want to retrieve some of the objects in an ObjectStore. Fortunately, not much changes as part of creating a method that returns only selected objects. You just need to specify the index you'll use when filtering your objects and provide the criteria for that search.
The IndexedDB tool for specifying criteria for selecting objects is IDBKeyRange. With IDBKeyRange, you specify a lower bound and a higher bound for the entries you want from an ObjectSource (you can also specify whether objects that match the lower/higher bounds are to be included in the result).
To work with IndexedDB, then, my method must accept two additional parameters beyond the callback: The index to be used when filtering and the IDBKeyRange to be applied. In my method instead of opening the cursor on the ObjectStore, I open the cursor on the specified index.
Here's the start of that method:
ReadSomeObjects(indexName: string, criteria: IDBKeyRange, callback: (obj: T) => void) {
var trans: IDBTransaction;
trans = this.db.transaction([this.tInfo.ObjectStoreName], "readonly");
var objStore: IDBObjectStore = trans.objectStore(this.tInfo.ObjectStoreName);
var idx: IDBIndex;
idx = objStore.index(indexName);
idx.openCursor(criteria).onsuccess = function (e: any) {
After this point, the code in ReadSomeObjects is identical to ReadAllObjects.
A typical call to this method, specifying that I want those objects with values in the CustIdIndex that are greater than "B," would look like this:
moCust.ReadSomeObjects("CustIdIndex", IDBKeyRange.lowerBound("B"),
function (cust){$("#customers").append("<tr>" + "<td>" + cust.CustId + "</td>" + "<td>" +
cust.Name + "</td>" + "</tr>");});
Enhancements
I suspect that a developer using both ReadSomeObjects and ReadAllObjects in the same application might want to use the same callback function with both methods. If that's the case, it might make sense for that developer to define a variable using the signature of the callback function and then store the callback function in that variable (for more about how to manage functions in TypeScript, see my previous column). The developer will then be able to use that variable wherever the callback function would've been needed.
For example, the code to define a variable called AddObject and store the callback function in it would look like this:
var AddObject: ((cust: Customer) => void) =
(cust => $("#customers").append("<tr>" + "<td>" + cust.CustId + "</td>" + "<td>" +
cust.Name + "</td>" + "</tr>"));
But, with a variable like AddObject around, the developer not only eliminates some code duplication but makes the calls to ReadAllObjects and ReadSomeObjects much simpler. Here's what those calls look like with AddObject set up correctly:
moCust.ReadAllObjects(AddObject);
moCust.ReadSomeObjects("CustIdIndex", IDBKeyRange.lowerBound("B"), AddObject)
Given how similar ReadAllObjects and ReadSomeObjects are, any sensible developer would consider merging them into one method, probably called something like ReadObjects. The first parameter to this new method would be the callback function because it's common to both of the old methods. The two parameters required for filtering objects (indexName and the criteria parameter) would become optional parameters on ReadObjects. When the optional parameters are missing, the method would use the ObjectStore to create the IDBRequest object; when the IndexName and criteria parameters are provided, the method would use the IDBIndex object.
The resulting code would look something like Listing 1.
Listing 1: Single Method for Reading Objects from IndexedDB
ReadObjects(callback: (obj: T) => void, indexName: string = null, criteria: IDBKeyRange = null) {
var trans: IDBTransaction;
var objStore: IDBObjectStore;
var req: IDBRequest;
trans = this.db.transaction([this.tInfo.TableName], "readonly");
objStore = trans.objectStore(this.tInfo.TableName);
if (indexName) {
var idx: IDBIndex;
idx = objStore.index(indexName);
req = idx.openCursor(criteria);
}
else {
req = objStore.openCursor();
}
req.onsuccess = function (e: any) {
if (e.target.result instanceof IDBCursorWithValue) {
var curs: IDBCursorWithValue;
curs = e.target.result;
var obj: T;
obj = curs.value;
callback(obj);
curs.continue();
}
else {
callback(null);
}}
The code to read all the objects would now look like this:
moCust.ReadObjects(AddObject);
The code to read just some of the objects would now look like this:
moCust.ReadObjects(AddObject, "CustIdIndex", IDBKeyRange.lowerBound("B"));
Wiring up ManageObjectStore
Of course, none of this is useful unless you can attach it to buttons on your page. The first step in that process is to define an array of ObjectStoreInfo objects to hold the specifications for the ObjectStores in the database:
var oInfo: ObjectStoreInfo;
var oInfos: Array<ObjectStoreInfo>;
oInfos = new Array<ObjectStoreInfo>();
The second step is to define at least one ObjectStore and add it to the array. Here's the code that defines the CustOrders database I used for testing:
oInfo = new ObjectStoreInfo();
oInfo.TableName = "CustOrders";
oInfo.PrimaryFieldName = "CustId";
oInfo.PrimaryIndexName = "CustIdIndex";
oInfos[0] = oInfo;
Now you can pass the name of your database and an array of ObjectInfoStore objects to the ManageDatabase's constructor. If the database exists, ManageDatabase will do nothing. If the database doesn't exist (or if the ObjectStores have been modified) then the IndexedDB engine will create or upgrade the database. I'd also grasp this moment to create one of my own ManageObjectStore object's for each ObjectStore with which I intended to work:
md = new ManageDatabase("CustomerOrder", oInfos)
moCust = new ManageObjectStore<Customer>(oInfo);
With all the pieces in place, I can attach methods in my classes to events in the page. The first line of the following code wires up the ResetDB method on the ManageDatabase object to a button on the page (this allows the user to re-initialize the database):
$("#ResetDb").click(() => md.ResetDB());
$("#BrowseDb").click(GetAllCustomers);
function GetAllCustomers() {
mt.db = md.db;
moCust.ReadObjects(AddObject);
}
I'm using the arrow syntax in the first line of code to reduce problems with the this keyword. The second line of code wires up a method that uses my ReadObjects method. It's followed by a typical function that uses my ReadObjects method, passing the variable holding the callback function I discussed earlier.
As I've written these three columns on using IndexedDB with TypeScript, my goals have changed. In my first column about IndexedDB, I just wanted to show that moving to TypeScript from JavaScript didn't involve a great deal of work. In my second column, I wanted to show that, if you did move to TypeScript, you had an opportunity to structure your code to take advantage of TypeScript's features (and, I admit, to point out some limitations in TypeScript). In this column, in addition to showing how to retrieve a cursor's worth of objects, I also wanted to show that every design can always be improved.
I'm sure there are more opportunities for improvement (adding an array of indexes-to-be-add-to-the-ObjectStore to the ObjectStoreInfo class would be a great idea, for example). But it's time for me to move on.
One goal I never had, though, was to provide a wrapper for all of the functionality that the IndexedDB provides. Instead, these classes are an example of the façade pattern: They provide a convenient way to work with the 80 percent of the functionality that the IndexedDB provides.
If you want more … well, I've included the code for the final version of these classes in a download for this month and you can, of course, extend these classes. However, I suspect that if you want to do more with IndexedDB than these classes support you'd be better off working with the IndexedDB directly (which is also part of the façade pattern). That's what I'd do, and I wrote these classes.
About the Author
Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.