Advertisement

Working With IndexedDB - Part 3

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

Welcome to the final part of my IndexedDB series. When I began this series my intent was to explain a technology that is not always the most... friendly one to work with. In fact, when I first tried working with IndexedDB, last year, my initial reaction was somewhat negative ("Somewhat negative" much like the Universe is "somewhat old."). It's been a long journey, but I finally feel somewhat comfortable working with IndexedDB and I respect what it allows. It is still a technology that can't be used everywhere (it sadly missed being added to iOS7), but I truly believe it is a technology folks can learn and make use of today.

In this final article, we're going to demonstrate some additional concepts that build upon the "full" demo we built in the last article. To be clear, you must be caught up on the series or this entry will be difficult to follow, so you may also want to check out part one.


Counting Data

Let's start with something simple. Imagine you want to add paging to your data. How would you get a count of your data so you can properly handle that feature? I've already shown you how you can get all your data and certainly you could use that as a way to count data, but that requires fetching everything. If your local database is huge, that could be slow. Luckily the IndexedDB spec provides a much simpler way of doing it.

The count() method, run on an objectStore, will return a count of data. Like everything else we've done this will be asynchronous, but you can simplify the code down to one call. For our note database, I've written a function called doCount() that does just this:

function doCount() {

    db.transaction(["note"],"readonly").objectStore("note").count().onsuccess = function(event) {
        $("#sizeSpan").text("("+event.target.result+" Notes Total)");
    };

}

Remember - if the code above is a bit hard to follow, you can break it up into multiple blocks. See the earlier articles where I demonstrated this. The result handler is passed a result value representing the total number of objects available in the store. I modified the UI of our demo to include an empty span in the header.

<span class="navbar-brand" >Note Database <span id="sizeSpan"></span></span>
Count Example

The final thing I need to do is simply add a call to doCount when the application starts up and after any add or delete operation. Here is one example from the success handler for opening the database.

openRequest.onsuccess = function(e) {
    db = e.target.result;

    db.onerror = function(event) {
      // Generic error handler for all errors targeted at this database's
      // requests!
      alert("Database error: " + event.target.errorCode);
    };

    displayNotes();
    doCount();
};

You can find the full example in the zip you downloaded as fulldemo2. (As an FYI, fulldemo1 is the application as it was at the end of the previous article.)


Filter As You Type

For our next feature, we're going to add a basic filter to the note list. In the earlier articles in this series I covered how IndexedDB does not allow for free form search. You can't (well, not easily) search content that contains a keyword. But with the power of ranges, it is easy to at least support matching at the beginning of a string.

If you remember, a range allows us to grab data from a store that either begins with a certain value, ends with a value, or lies in between. We can use this to implement a basic filter against the title of our note fields. First, we need to add an index for this property. Remember, this can only be done in the onupgradeneeded event.

    if(!thisDb.objectStoreNames.contains("note")) {
        console.log("I need to make the note objectstore");
        objectStore = thisDb.createObjectStore("note", { keyPath: "id", autoIncrement:true });
        objectStore.createIndex("title", "title", { unique: false });
    }

Next, I added a simple form field to the UI:

Filter UI

Then I added a "keyup" handler to the field so I'd see immediate updates while I type.

$("#filterField").on("keyup", function(e) {
    var filter = $(this).val();
    displayNotes(filter);
});

Notice how I'm calling displayNotes. This is the same function I used before to display everything. I'm going to update it to support both a "get everything" action as well as a "get filtered" type action. Let's take a look at it.

function displayNotes(filter) {

    var transaction = db.transaction(["note"], "readonly");  
    var content="<table class='table table-bordered table-striped'><thead><tr><th>Title</th><th>Updated</th><th>& </td></thead><tbody>";

    transaction.oncomplete = function(event) {
        $("#noteList").html(content);
    };

    var handleResult = function(event) {  
      var cursor = event.target.result;  
      if (cursor) {  
        content += "<tr data-key=\""+cursor.key+"\"><td class=\"notetitle\">"+cursor.value.title+"</td>";
        content += "<td>"+dtFormat(cursor.value.updated)+"</td>";

        content += "<td><a class=\"btn btn-primary edit\">Edit</a> <a class=\"btn btn-danger delete\">Delete</a></td>";
        content +="</tr>";
        cursor.continue();  
      }  
      else {  
        content += "</tbody></table>";
      }  
    };

    var objectStore = transaction.objectStore("note");

    if(filter) {
        //Credit: http://stackoverflow.com/a/8961462/52160
        var range = IDBKeyRange.bound(filter, filter + "\uffff");
        var index = objectStore.index("title");
        index.openCursor(range).onsuccess = handleResult;
    } else {
        objectStore.openCursor().onsuccess = handleResult;
    }

}

To be clear, the only change here is at the bottom. Opening a cursor with or without a range gives us the same type of event handler result. That's handy then as it makes this update so trivial. The only complex aspect is in actually building the range. Notice what I've done here. The input, filter, is what the user typed. So imagine this is "The". We want to find notes with a title that begins with "The" and ends in any character. This can be done by simply setting the far end of the range to a high ASCII character. I can't take credit for this idea. See the StackOverflow link in the code for attribution.

You can find this demo in the fulldemo3 folder. Note that this is using a new database so if you've run the previous examples, this one will be empty when you first run it.

While this works, it has one small problem. Imagine a note titled, "Saints Rule." (Because they do. Just saying.) Most likely you will try to search for this by typing "saints". If you do this, the filter won't work because it is case sensitive. How do we get around it?

One way is to simply store a copy of our title in lowercase. This is relatively easy to do. First, I modified the index to use a new property called titlelc.

        objectStore.createIndex("titlelc", "titlelc", { unique: false });

Then I modified the code that stores notes to create a copy of the field:

$("#saveNoteButton").on("click",function() {

    var title = $("#title").val();
    var body = $("#body").val();
    var key = $("#key").val();
    var titlelc = title.toLowerCase();

    var t = db.transaction(["note"], "readwrite");

    if(key === "") {
        t.objectStore("note")
                        .add({title:title,body:body,updated:new Date(),titlelc:titlelc});
    } else {
        t.objectStore("note")
                        .put({title:title,body:body,updated:new Date(),id:Number(key),titlelc:titlelc});
    }

Finally, I modified the search to simply lowercase user input. That way if you enter "Saints" it will work just as well as entering "saints."

        filter = filter.toLowerCase();
        var range = IDBKeyRange.bound(filter, filter + "\uffff");
        var index = objectStore.index("titlelc");

That's it. You can find this version as fulldemo4.


Working With Array Properties

For our final improvement, I'm going to add a new feature to our Note application - tagging. This will
let you add any number of tags (think keywords that describe the note) so that you can later find other
notes with the same tag. Tags will be stored as an array. That by itself isn't such a big deal. I mentioned in the beginning of this series that you could easily store arrays as properties. What is a bit more complex is handling the search. Let's begin by making it so you can add tags to a note.

First, I modified my note form to have a new input field. This will allow the user to enter tags separated by a comma:

Tag UI

I can save this by simply updating my code that handles Note creation/updating.

    var tags = [];
    var tagString = $("#tags").val();
    if(tagString.length) tags = tagString.split(",");

Notice that I'm defaulting the value to an empty array. I only populate it if you typed something in. Saving this is as simple as appending it to the object we pass to IndexedDB:

    if(key === "") {
        t.objectStore("note")
                        .add({title:title,body:body,updated:new Date(),titlelc:titlelc,tags:tags});
    } else {
        t.objectStore("note")
                        .put({title:title,body:body,updated:new Date(),id:Number(key),titlelc:titlelc,tags:tags});
    }

That's it. If you write a few notes and open up Chrome's Resources tab, you can actually see the data being stored.

Chrome DevTools and the Resource View

Now let's add tags to the view when you display a note. For my application, I decided on a simple use case for this. When a note is displayed, if there are tags I'll list them out. Each tag will be a link. If you click that link, I'll show you a list of related notes using the same tag. Let's look at that logic first.

function displayNote(id) {
    var transaction = db.transaction(["note"]);  
    var objectStore = transaction.objectStore("note");  
    var request = objectStore.get(id);

    request.onsuccess = function(event) {  
        var note = request.result;
        var content = "<h2>" + note.title + "</h2>"; 
        if(note.tags.length > 0) {
            content += "<strong>Tags:</strong> ";
            note.tags.forEach(function(elm,idx,arr) {
                content += "<a class='tagLookup' title='Click for Related Notes' data-noteid='"+note.id+"'> " + elm + "</a> ";  
            });
            content += "<br/><div id='relatedNotesDisplay'></div>";
        }
        content += "<p>" + note.body + "</p>";
         I
        $noteDetail.html(content).show();
        $noteForm.hide();           
    };  
}

This function (a new addition to our application) handles the note display code formally bound to the table cell click event. I needed a more abstract version of the code so this fulfills that purpose. For the most part it's the same, but note the logic to check the length of the tags property. If the array is not empty, the content is updated to include a simple list of tags. Each one is wrapped in a link with a particular class I'll use for lookup later. I've also added a div specifically to handle that search.

A note with tags

At this point, I've got the ability to add tags to a note as well as display them later. I've also planned to allow the user to click those tags so they can find other notes using the same tag. Now here comes the complex part.

You've seen how you can fetch content based on an index. But how does that work with array properties? Turns out - the spec has a specific flag for dealing with this: multiEntry. When creating an array-based index, you must set this value to true. Here is how my application handles it:

objectStore.createIndex("tags","tags", {unique:false,multiEntry:true});

That handles the storage aspect well. Now let's talk about search. Here is the click handler for the tag link class:

$(document).on("click", ".tagLookup", function(e) {
    var tag = e.target.text;
    var parentNote = $(this).data("noteid");
    var doneOne = false;
    var content = "<strong>Related Notes:</strong><br/>";

    var transaction = db.transaction(["note"], "readonly");
    var objectStore = transaction.objectStore("note");
    var tagIndex = objectStore.index("tags");
    var range = IDBKeyRange.only(tag);

    transaction.oncomplete = function(event) {
        if(!doneOne) {
            content += "No other notes used this tag."; 
        }
        content += "<p/>";
        $("#relatedNotesDisplay").html(content);
    };

    var handleResult = function(event) {
        var cursor = event.target.result;
        if(cursor) {
            if(cursor.value.id != parentNote) {
                doneOne = true;
                content += "<a class='loadNote' data-noteid='"+cursor.value.id+"'>" + cursor.value.title + "</a><br/> ";
            }
            cursor.continue();
        }           
    };

    tagIndex.openCursor(range).onsuccess = handleResult;

});

There's quite a bit here - but honestly - it is very similar to what we've dicussed before. When you click a tag, my code begins by grabbing the text of the link for the tag value. I create my transaction, objectstore, and index objects as you've seen before. The range is new this time. Instead of creating a range from something and to something, we can use the only() api to specify that we want a range of only one value. And yes - that seemed weird to me as well. But it works great. You can see then we open the cursor and we can iterate over the results as before. There is a bit of additional code to handle cases where there may be no matches. I also take note of the original note, i.e. the one you are viewing now, so that I don't display it as well. And that's really it. I've got one last bit of code that handles click events on those related notes so you can view them easily:

$(document).on("click", ".loadNote", function(e) {
    var noteId = $(this).data("noteid");
    displayNote(noteId);
});

You can find this demo in the folder fulldemo5.


Conclusion

I sincerely hope that this series has been helpful to you. As I said in the beginning, IndexedDB was not a technology I enjoyed using. The more I worked with it, and the more I began to wrap my head around how it did things, the more I began to appreciate how much this technology could help us as web developers. It definitely has room to grow, and I can definitely see people preferring to use wrapper libraries to simplify things, but I think the future for this feature is great!

Advertisement