Managing data with transactions

This article shows how to open IndexedDB object stores and how to use them to retrieve and save data. Doing so, however, requires an understanding of transactions, for all IndexedDB data operations occur within the context of a transaction. A transaction is a group of related operations that either all succeed or all fail.

Here we discuss the following:

  • Understanding transactions
  • Writing data with transactions
  • Record by record actions using IndexedDB
  • Working with multiple object stores.
  • Summary

Understanding transactions

IndexedDB uses transactions to group related operations together. There are different types of transactions, and different transaction types allow (and block) certain activities:

  • Read-only transactions allow data to be read, but not modified. They don't block (prevent) other transactions. You can fetch (retrieve) data from an object store, but you cannot change it.
  • Read-write transactions allow data to be read and modified, but they don't allow database objects (such as object stores and indexes) to be created or deleted. In addition, read-write transactions block other read-write transactions; however, they allow read-only transactions.
  • Version change transactions allow you to create and modify database objects, including object stores, indexes, and data. Version change transactions block all other transactions.

When a transaction is complete, the data is saved (or committed). Keep in mind that this happens when all requests created within the scope of the transaction are complete. As you'll see, it's possible to create a number of requests during a transaction

Transactions can fail. If a request triggers an exception or an error occurs, the transaction is canceled (or aborted). When this happens, all changes made during the transaction are canceled (rolled back).

Version change transactions are created only when you create a database or when you open a database using a higher version number than was previously used.

To create read/write or read-only transactions, use the transaction method of a database object. The following sections show multiple examples.

Writing data with transactions

You can add data to an object store during a version change event or during a read/write transaction, as shown here:

function saveRecord( oNewRecord ) {

   if ( hDB == null ) {
      updateResults( "Can't save data; the database is not open." );
   } else {
   
     var hTransaction = hDB.transaction( "ObjectStoreName", "readwrite" );
     var hObjectStore = hTransaction.objectStore( "ObjectStoreName" );
     var hImageReq = hObjectStore.put( oNewRecord );
     hImageReq.onsuccess = handleRequestEvent;
     hImageReq.onerror = handleRequestEvent;
}

The put method updates a record if it already exists in the object store, as determined by the key value in the key path. If the record doesn't exist in the object store, it will be added.

If you call the put method with an object that doesn't include a value for the key path, a DataError exception is raised and the transaction is canceled. As a result, it might be necessary to determine if the record exists in the object store and, if so, to copy the key value to the replacement record.

To illustrate, imagine that the ImageDetails object store uses an auto-increment index as a key, an index value that is automatically generated when the record is first saved.

Further, assume that you use the image gallery form to update the description of the image. This next example shows how to use an index to ensure that the record containing the new description replaces the record containing the previous description.

   if ( hDB == null ) {
      updateResults( "Can't save data; the database is not open." );
   } else {
   
     var hTransaction = hDB.transaction( "ImageDetails", "readwrite" );
     var hObjectStore = hTransaction.objectStore( "ImageDetails" );

     var oNewRecord = getNewRecord();
     var sFilename = oNewRecord.FileName;
     var hIndex = hObjectStore.index( "IxImagesByName" );
     var hIndexReq = hIndex.openCursor( sFilename );

     hIndexReq._NewRecord = oNewRecord;
     hIndexReq.onerror = handleRequestEvent;
     hIndexReq.onsuccess = function( evt ) {
        
        var oRecord = this._NewRecord ];
        var oTarget = evt.target; 
        if ( oTarget.result != null ) {
           var sKeyPath = oTarget.source.objectStore.keyPath;
           oRecord[ sKeyPath ] = oTarget.result.primaryKey;
        }
        var hRecordReq = oTarget.source.objectStore.put( oRecord );
        hRecordReq.onerror = handleRequestEvent;
        hRecordReq.onsuccess = handleRequestEvent;
     }
   }

In this example, the filename associated with an image is considered unique; that is, only one record is allowed for each unique filename. In order to update existing records, it's therefore necessary to determine whether a record update is new to the database or should replace an existing record. If the latter, the key value of the original record needs to be added to the replacement record.

As a result, it's necessary to search the image details for the filename associated with the new record. The search is performed by opening a cursor that contains only records with the same filename as the one associated with the new record. When the success event is triggered for the hIndexReq request, the search is complete.

Search results are reported in the target.result property of the object passed to the success event handler. This property is undefined when no matches are found by the search. When there is a match, the cursor will only contain one record because the filename is unique for this example. This is the record that the new record replaces.

An attribute is then added to the new record. The name of the attribute is set to the key path of the original object store and the value is set to the primaryKey value returned by the search.

If the search doesn't find any matches, the new record is added to the object store. Because the key path is an auto-increment key, it's not necessary to add the key path attribute to new records. The new key is calculated automatically when the record is added to the object store.

As you can see, the composition of your object stores and indexes directly impacts the amount of work needed to manage records effectively. In this case, the use of unique indexes and auto-incrementing keys requires careful record management. If you receive DataError or ConstraintError exceptions when trying to add or update records, review the attributes of your records and ensure their values are appropriate for the object store and its indexes.

Record by record actions using IndexedDB

Sometimes, the complexity of a database transaction isn't obvious until later in the project. For example, it's very straightforward to add the first set of tags associated with an image, as shown here:

   var aImageTags = getImageTags();
   var iImageID = getImageID();

   var hTransaction = hDB.transaction( "ImageTags", "readwrite" );
   var hObjectStore = hTransaction.objectStore( "ImageTags" );
   for ( var iArrayIndex = 0; iArrayIndex < ( iArraySize ); iArrayIndex++ ) {

      var oRecord = { imageid : iImageID, 
                      tag : aImageTags[ iArrayIndex ] };
      var hRequest = hObjectStore.put( oRecord );
      hRequest.onsuccess = handleRequestEvent;
      hRequest.onerror = handleRequestEvent;
   }

However, when the user chooses to update the tags associated with an image, perhaps by adding a new tag or removing one, the process becomes more complicated.

Two lists need to be reconciled; the list of new tags needs to be compared to the list of older (previous) tags. Specifically:

  • New tags that aren't in the list of older tags need to be added.
  • Older tags are that aren't in the new list need to be deleted.
  • Tags in both lists can be ignored.

To determine the tags associated with a given image (the older tags), use an index to open a cursor containing the tags for a given IndexID value, as shown here:

   var aNewTags = getNewTags();
   var iImageID = getImageID();
   var hTransaction = hDB.transaction( "ImageTags", "readwrite" );
   var hObjectStore = hTransaction.objectStore( "ImageTags" );
   var hIndex = hObjectStore.index( "IxTagsByID" );
   var hIndexReq = hIndex.openCursor( iImageID );
   hIndexReq.onerror = handleRequestEvent;
   hIndexReq.onsuccess = function( evt ) {
      var oCursor = evt.target.result;
      if (oCursor) {
         doSomething( oCursor.value );
         oCursor.continue();
      }
   }

When the success event is triggered for the hIndexReq object, the target.result property of the object passed to the event (evt in this example) points to a cursor pointing to the first record matching the IndexID.

When you call the continue method for this cursor, another success event is triggered, however, the cursor now points to the next matching record. In effect, this cursor triggers a success event for each tag previously associated with the image. The new tags are listed in the aNewTags array.

To reconcile the two lists, the old tags in the cursor are compared to the new tags in the array.

  • If an old tag doesn't appear in the array, it's removed from the object store.
  • If an old tag appears in the array, it's removed from the array.
  • When all the old tags are processed, the new tags that remain in the array are added to the object store.

This example shows the complete process:

   var aNewTags = getNewTags();
   var iImageID = getImageID();

   var hTransaction = hDB.transaction( "ImageTags", "readwrite" );
   var hObjectStore = hTransaction.objectStore( "ImageTags" );
   var hIndex = hObjectStore.index( "IxTagsByID" );

   var hRequest = hIndex.openCursor( iImageID );
   hRequest._LocalTags = aNewTags;
   hRequest._ImageID = iImageID;
   hRequest.onerror = handleRequestEvent;
   hRequest.onsuccess = function( evt ) {
   
      var oCursor = evt.target.result;
      if ( oCursor ) { 
      
         var sTag = oCursor.value.name;
         var iIndexNo = this._LocalTags.indexOf( sTag );
         if ( iIndexNo == -1 ) {
            var hDelReq = oCursor.source.objectStore.delete( oCursor.primaryKey );
            hDelReq.onsuccess = handleRequestEvent;
            hDelReq.onerror = handleRequestEvent;
         } else {
            this._LocalTags[ iIndexNo ] = "";   
         }
         oCursor.continue();
         
      } else {
      
           // add remaining tags to objectstore.
           for (var iIndex = 0; iIndex < this._LocalTags.length; iIndex++) {
              var sNewTag = this._LocalTags[ iIndex ];
              if ( sNewTag != "" ) { 
                 var oNewTag = { indexid : this_ImageID, 
                                 tagword : sNewTag };
                 var reqTagAdd = evt.target.source.objectStore.add( oNewTag );
                 reqNewAdd.onsuccess = handleRequestEvent;
                 reqNewAdd.onerror = handleRequestEvent;
              }
           }
        }
     }

Notice that the imageID and the new tag array is passed to each new request as custom properties defined on the request object. Because IndexedDB is an asynchronous API, there's no guarantee that requests are processed in the same scope of as the block making the request. As a result, you cannot rely on the variables declared inside the block making the request.

You can use custom properties to pass data between requests, though use care to maintain the property values, as demonstrated in the example.

Working with multiple object stores.

When you reviewed the image gallery webpage shown in an earlier example, you might have noticed that a function called doPageSetup was registered to the DOMContentLoaded event. This function retrieves the previously-saved details for the image displayed in the webpage, if any. To do so, a read-only transaction collects the details and then passes them to the function that updates the form, as shown here:

function doPageSetup() {
   var oImage = document.getElementById( 'iGalleryImage' );
   var sFilename = reduceFilename( oImage.src );
   getImageDetails( sFilename );
}

function getImageDetails( sFilename ) {
   if ( hDB == null ) {
      hDB = openImageDatabase();
   } else {
   
     var hTransaction = hDB.transaction( [ "ImageDetails", "ImageTags" ], "readonly" );
     var hDetails = hTransaction.objectStore( "ImageDetails" );
     var hIdxImage = hDetails.index( "IxImagesByName" );
     var hImageReq = hIdxIndex.get( sFilename );
     hRequest.onsuccess = function( evt ) {

        var oDetails = evt.target.result;
        if ( oDetails ) {

        // collect image details
        oDetails.ImageID = oDetails.primaryKey;
        oDetails.ImageTitle = oDetails.value.ImageTitle;
        oDetails.ImageDesc = oDetails.value.ImageDesc;

        var hTags = evt.target.transaction.objectStore( "ImageTags" );
        var hIdxTags = hTags.index( "IxTagsByImage" );
        var hTagsReq = hIdxTags.openCursor( oDetails.ImageID );
        hTagsReq._imageDetails = oDetails;
        hTagsReq._imageTags = [];
        hTagReq.onsuccess = function( evt ) {

           var oCursor = evt.target.result;
           if (oCursor) {
             this._imageTags[ this.imageTags.length ] = oCursor.value.TagWord;
           } else {
              updateFormData( this._imageDetails, this._imageTags );
           }
        }
     }
  }
}

Because the details and the tags are stored in separate object stores, the transaction method is passed an array containing the names of the both object stores.

If you attempt to open an object store that is not associated with a transaction, it triggers a NotFoundError exception, which in turn cancels the transaction.

This example uses the image filename and an index to obtain the details from the ImageDetails object store. When the details are returned, they're collected into an object and a new request collects the tags from the ImageTags object store. Individual tags are saved in an array. As with earlier examples, custom request properties ensure that the data is passed to each new request.

When the tags have been collected, the values in the custom request properties are passed to the function that updates the form on the webpage.

Summary

Transactions and requests are important concepts to understand when using IndexedDB. This article showed how to use transactions to manage data. The examples in this article also showed how to store the user's tags in the underlying object stores.

Now that the database contains tags, it's possible to create the tag cloud and present it to user. This is the focus of the next article, Creating and using a tag cloud.

Contoso Images photo gallery

Creating and using a tag cloud

How to create a tag cloud using IndexedDB

Internet Explorer 10 Samples and Tutorials