Using IndexedDB with Dart

IndexedDB is a transactional database system like a SQL-based RDBMS. However, while the latter uses tables with fixed columns, IndexedDB is a JavaScript-based object-oriented database. IndexedDB lets you store and retrieve objects that are indexed with a key. You need to specify the database schema, open a connection to your database, and retrieve and update the data within a series of transactions. Operations in IndexedDB are done asynchronously, so as to not block the rest of the application's running.

We will learn how to work with IndexedDB and JSON web services through the indexed_db_spirals project (https://github.com/dzenanr/indexed_db_spirals), which is a todo app like the ones we've built in the previous chapters. However, this stores its data in IndexedDB. Get a copy of the code with a git clone:

https://github.com/dzenanr/indexed_db_spirals.git.

Spiral s00

In this spiral, the todo tasks can be entered. They are stored in IndexedDB. The following is a screenshot:

Spiral s00

The Tasks screen of Spiral s00

Our model class is called Task and it lives in model.dart; with toDb and FromDb, it can transform an object to or make an object from a map:

class Task {
  String title;
  bool completed = false;
  DateTime updated = new DateTime.now();
  var key;

  Task(this.title);

  Task.fromDb(this.key, Map value):
    title = value['title'],
    updated = DateTime.parse(value['updated']),
    completed = value['completed'] == 'true' {
  }

  
Map toDb() {
    return {
      'title': title,
      'completed': completed.toString(),
      'updated': updated.toString()
    };
  }
}

Besides Task, the model also contains the TasksStore class, which contains List<Task> and interacts with IndexedDB. To do this, we need to import the dart:indexed_db library to model.dart, which will provide the Dart API to use IndexedDB:

import 'dart:indexed_db';

The web page is view.html and references view.dart; this contains all the UI code setup from the main() entry point:

import 'dart:html';
import 'model.dart';

Element taskElements;
TasksStore tasksStore;

main() {
   taskElements = querySelector('#task-list'),
   tasksStore = new TasksStore();    
   tasksStore.open().then((_) {                             (1)
     loadElements(tasksStore.tasks);
   });

  InputElement newTask = querySelector('#new-task'),
  newTask.onKeyPress.listen((KeyboardEvent e) {
    if (e.keyCode == KeyCode.ENTER) {
      var title = newTask.value.trim();
      if (title != '') {
        tasksStore.add(title).then((task) {                 (2)
          addElement(task);
        });
        newTask.value = '';
      }
    }
  });

  ButtonElement clear = querySelector('#clear-tasks'),
  clear.onClick.listen((MouseEvent e) {
    tasksStore.clear().then((_) {                          (3)
      clearElements();
    });
  });
  }
  Element newElement(Task task) {
    return new Element.html('''
      <li>
        ${task.title}
      </li>
    '''),
  }

  addElement(Task task) {
    var taskElement = newElement(task);
    taskElements.nodes.add(taskElement);
  }

  loadElements(List tasks) {
    for (Task task in tasks) {
      addElement(task);
    }
  }

  clearElements() {
    taskElements.nodes.clear();
  }
}

All the interactions with IndexedDB are asynchronous and return the Future objects; this is why we use then in lines (1) to (3), respectively, while opening a database and adding a task or removing all the tasks. The loadElements, addElement, and clearElements callback functions update the screen after the database has been changed (the code is straightforward; see view.dart). In line (1), we see that for the parameter of the then callback function, (_) is written; this means there is one parameter, but we don't need it, so we won't name it. What happens now, in line (1) in the preceding code, with the open() call on TaskStore? You can imagine that we need to open the database or create it the first time the page is requested. This is done with a call to window.indexedDB.open in line (4) in model.dart:

class TasksStore {
  static const String TASKS_STORE = 'tasksStore';
  final List<Task> tasks = new List();
  Database _db;

  Future open() {
    return window.indexedDB.open('tasksDb00',                 (4)
        version: 1,
        onUpgradeNeeded: _initDb)                             (5)
       .then(_loadDb);	                                        (6)
  }
// code left out

The open() method takes three parameters: the first two are listed in an alphabetical order by the name of the database and the third by the version number. When the app is first started on a client, a database with its name and version 1 is created; every subsequent time, it is simply opened. On creating it, the third onUpgradeNeeded parameter kicks in to fire an upgrade event, which calls _initDb in line (5). You can upgrade a database to a higher version by opening it with a new version number. Then, an upgrade event will take place and the previous version of the database will not exist anymore. Our tasksDb00 database needs one or more object stores; these can only be created during an upgrade event. Here, in _initDb, we get a reference to the database object in line (7), and, in line (8), we create an object store (that can contain data records) named TasksStore. The value of the constant is TASKS_STORE:

void _initDb(VersionChangeEvent e) {
    var db = (e.target as Request).result;                     (7)
    var objectStore = db.createObjectStore(TASKS_STORE         (8)
      autoIncrement: true);
}

The autoIncrement property, when true, lets the database generate unique primary keys for you. In a later spiral, we will also create an index to enhance query speed. A database can contain multiple object stores if needed and our app can have access to multiple databases at once. After initialization, the then callback in line (6) kicks in, calling _loadDb:

Future _loadDb(Database db) {
    _db = db;
    var trans = db.transaction(TASKS_STORE, 'readonly'),       (9)
    var store = trans.objectStore(TASKS_STORE);
    var cursors = store.openCursor(autoAdvance:               (10)
      true).asBroadcastStream();
    cursors.listen((cursor) {                                 (11)
      var task = new Task.fromDb(cursor.key, cursor.value);
      tasks.add(task);
    });

    return cursors.length.then((_) {                          (12)
      return tasks.length;
    });
  }

To make sure it is reliable, every operation on the database happens within a transaction. So, this transaction is created in line (9) and attached to the object store (this is also required for reads; the second argument can be readonly, readwrite, or versionchange). Database transactions take time, so the results are always provided via the Future objects. Getting records from a database is mostly done using a Cursor object, which is created here in line (10) with the openCursor method on the object store. The cursors object indicates the current position in the object store, and returns the records one by one through a Stream object automatically because of the autoAdvance parameter (otherwise, use the next() method). For each record that returns, a listen event fires the code defined in line (11). Here, the cursor.key and cursor.value database values are passed to the task constructor named fromDb, so a new task is created and added to the list. A BroadcastStream method also returns the length of the cursor as the final event in line (12), which is also the length of the tasks collection. When a task is added, the add method on the store will be called in line (2):

Future<Task> add(String title) {
    var task = new Task(title);
    var taskMap = task.toDb();                               (13)

    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    var objectStore = transaction.objectStore(TASKS_STORE);

    objectStore.add(taskMap).then((addedKey) {               (14)
      task.key = addedKey;
    });

    return transaction.completed.then((_) {                  (15)
      tasks.add(task);
      return task;
    });
  }

The Task class has a toDb method called in line (13) to transform the Task object into a map. A read/write transaction is created and the add method is called on the store in line (14) with the task data. This also returns a the Future object, resulting in the key generated by the database (addedKey), which is also stored in the task object. When the transaction completes in line (15), the task is added to the collection and returned as the Future object's result, which is then used in view.dart to update the view. Removing all the objects from the store is easy; calling clear on the store in line (3) executes:

Future clear() {
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).clear();
    return transaction.completed.then((_) {
      tasks.clear();
    });
}

This results in the clearing of the tasks collection and then the updating of the view. To see the data in your IndexedDB database at any moment, navigate to Chrome View | Developer | Developer Tools, and then choose Resources from the tabs along the top of the window:

Spiral s00

Viewing IndexedDb with developer tools

Spiral s01

No new functionality is added here, but the start up web page is renamed to app.html and the layout is improved through CSS. Furthermore, the methods are made private where possible and the app architecture is refactored to MVC by introducing a library in line (1); all of the UI code is moved from view.dart to the TasksView class in lib/view/view.dart, and the model code to lib/model/model.dart. Both are now contained in the lib/indexed_db.dart library file:

library indexed_db;                                           (1)

import 'dart:async';
import 'dart:html';
import 'dart:indexed_db';

part 'model/model.dart';
part 'view/view.dart';

The main app.dart Dart file imports our new library in line (2) and uses a TasksView object:

import 'package:indexed_db/indexed_db.dart';                   (2)

main() {
  var tasksStore = new TasksStore();
  var tasksView = new TasksView(tasksStore);
  tasksStore.open().then((_) {
    tasksView.loadElements(tasksStore.tasks);
  });
}

Spiral s02

Now, we can remove a task or mark it as completed using the X button, as shown in the following screenshot:

Spiral s02

The screen of Spiral s02

To accomplish this, the newElement method is expanded a bit to draw the checkbox and remove button:

Element _newElement(Task task) {
    return new Element.html('''
        <li>
          <button class='task-button remove-task'>X</button>
          <input class='task-completed' type='checkbox'
            ${task.completed ? 'checked' : ''}>
          <label class='task-title'>${task.title}</label>
        </li>
    '''),
  }

The Element.html file performs default validations and removes all the scriptable elements and attributes so that no code injection takes place.

In _addElement, a click event handler on the X button is added, which removes the task from the object store:

   tasksStore.remove(task).then((_) {
        _taskElements.nodes.remove(taskElement);
        _updateFooter();
   });

The click event handler then calls the remove method in the TasksStore class to delete it in the object store:

Future remove(Task task) {
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).delete(task.key);
    return transaction.completed
      .then((_) {
      task.key = null;
      tasks.remove(task);
      });
}

Clicking the checkbox to signal a task as complete invokes an update on the object store:

taskElement.query('.task-completed').onClick.listen((MouseEvent e)
{
      task.completed = !task.completed;
      task.updated = new DateTime.now();
      tasksStore.update(task);
});

The update method transforms the Task object to a map (which is needed to store it in IndexedDB) and calls the put method on the object store:

Future update(Task task) {
    var taskMap = task.toDb();
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).put(taskMap, task.key);
    return transaction.completed;
}

Spiral s03

The new elements in this spiral are: a checkbox above the textbox to mark all the tasks as completed, the number of active (incomplete) tasks shown at the bottom, and a button to clear (remove) all the completed tasks (their number is indicated in the parentheses) are added. The active and completed tasks are returned by the getters in the TasksStore class, for instance:

List<Task> get activeTasks {
    var active = new List<Task>();
    for (var task in tasks) {
      if (!task.completed) {
        active.add(task);
      }
    }
    return active;
}   

In _initDb, we create a new database by changing the name and creating a unique index on the title by calling createIndex on the object store:

      store.createIndex(TITLE_INDEX, 'title', unique: true);

This speeds up the search on tasks' titles. The click handler for the complete all tasks button calls the following method:

Future completeTasks() {
    Future future;
    for (var task in tasks) {
      if (!task.completed) {
        task.completed = true;
        task.updated = new DateTime.now();
        future = update(task);
      }
    }
    return future;
}

In fact, this will only return the Future object of the last update. In this example, it will be the last one to get completed. However, it would be more correct to return a Future objects that completes when all the updates are complete. We'll make improvements in spirals s06 and s07.

All kinds of detailed screen updates are now assembled in _updateDisplay() in view.dart.

Spiral s04

Now, our todo application is getting more and more functional with nice links to all the active and completed task lists, the possibility of editing the task's title, and the persistence for the data in IndexedDB, as shown in the following screenshot:

Spiral s04

The screen of Spiral s04

The following code changes are worth discussing. In the previous spiral, a unique index on the title was created. However, no error message was shown when a duplicate task was entered; the double task was simply not added and the IndexedDB error was ignored. Now, we do better by using the catchError method in line (1) and changing the Keypress event handler of the input field to:

    InputElement newTask = query('#new-task'),
    newTask.onKeyPress.listen((KeyboardEvent e) {
      if (e.keyCode == KeyCode.ENTER) {
        var title = newTask.value.trim();
        if (title != '') {
          _tasksStore.add(title)
            .then((task) {
              _addElement(task);
              newTask.value = '';
              _updateFilter();
            })
         .catchError((e) {  // IndexedDB error unique index    (1)
              newTask.value = '${title} : title not unique';
              newTask.select();
            });
        }
      }
});

The edit functionality is coded as follows, when the task title is double-clicked on in line (2) and a editTitle textbox, in line (3), is selected:

    Element title = taskElement.query('.task-title'),
    InputElement editTitle = taskElement.query('.edit-title'),
    editTitle.hidden = true;
    title.onDoubleClick.listen((MouseEvent e) {                (2)
      title.hidden = true;
      editTitle.hidden = false;                                (3)
      editTitle.select();
    });

The editTitle variable also has an onKeyPress event handler that calls an update on the store and shows a nonunique error when this occurs.

The model now has a find method based on querying the index in line along with get in line (4):

Future<Task> find(String title) {
    var trans = _db.transaction(TASKS_STORE, 'readonly'),
    var store = trans.objectStore(TASKS_STORE);
    var index = store.index(TITLE_INDEX);
    var future = index.get(title);                             (4)
    return future
      .then((taskMap) {
        var task = new Task.fromDbWoutKey(taskMap);
        return task;
      });
  }

This is used in the _showActive and _showCompleted methods of the view:

 _showCompleted() {
    _setSelectedFilter(_completedElements);
    for (LIElement element in _taskElements.children) {
      Element titleLabel = element.query('.task-title'),
      String title = titleLabel.text;
      _tasksStore.find(title)
        .then((task) {
          element.hidden = !task.completed;                   (5)
        })
        .catchError((e) {});
    }
  }

In line (5), we see that the task in the list is hidden when it is not yet completed.

Spiral s05

In this spiral, the UI and functionality remain the same, but the model is reorganized. The model code, which contains the Task class and a Tasks collection class (with methods such as sort, contains, find, add, remove, and display0 in lib/model/model.dart), is cleanly decoupled from the data access code: the TasksDb and TasksStore classes in lib/model/idb.dart. This makes it easier to change or enhance the model code, or use another data source by switching to a different data access layer, which is what we do in Spiral s05_1.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.119.158.134