Cómo se usa IndexedDB

En el post anterior hicimos una introducción rápida a IndexedDB, una base de datos NoSQL que podemos utilizar para almacenar información localmente en el navegador cuando desarrollamos aplicaciones web.

Con lo que vimos en ese post deberíamos tener ya una idea de que IndexedDB se organiza alrededor de «colecciones» de objectos llamadas objectStores en las que guardamos objetos asociados a una clave a partir de la cual podemos recuperar el objeto, y que además podemos definir índices que nos ayuden a recuperar objetos a partir de otros valores que no sean la clave.

En este post vamos a ver con un poco más de detalle como se realizan estas operaciones de lectura y escritura en la base de datos. No vamos a llegar un nivel muy detallado pero por lo menos podremos tener unos cónocimientos básicos sobre el tema. Puedes obtener información más completa en la documentación de Mozilla sobre IndexedDB.

Transacciones

IndexedDB es una base de datos transaccional, y es a partir del API de transacciones como se realizan las lecturas y escrituras en la base de datos. Cuando iniciamos una transacción debemos indicar qué objectStores queremos utilizar y el nivel de acceso a ellos (sólo lectura o lectura y escritura), e IndexedDB se encargará de coordinar el acceso a esos recursos. En el momento en que una transacción obtiene permisos de escritura sobre un objectStore, ninguna transacción más puede acceder a él hasta que ésta termine; sin embargo, si las transacciones son sólo de lectura, pueden solaparse sin problemas.

El ciclo de vida de una transacción en IndexedDB es (al menos desde mi punto de vista) un poco confuso. Cuando se inicia una transacción se obtiene un objeto request (como en todas las operaciones de IndexedDB) y podemos empezar a utilizarlo para generar más operaciones. Mientras estemos generando operaciones, ya sea directamente o a través de eventos lanzados por operaciones anteriores, la transacción sigue activa. Cuando no hay más que hacer, la transacción se completa «automáticamente». Si queremos cancelar la transacción, debemos utilizar explícitamente el método abort.

En código, queda algo parecido a esto:

// Abrimos una transacción sobre los objectStores 'people' y 
// 'products' con acceso de lectura y escritura
var tx = db.transaction(['people', 'products'], 'readwrite');

tx.oncomplete = function(e) {
  // ...
};

tx.onerror = function(e) {
  // ...
};

A partir de la transacción podemos acceder a los objectStore que hayamos solicitado y trabajar con ellos.

var tx = db.transaction(['people', 'products'], 'readwrite');

var peopleStore = tx.objectStore('people');

// Hacer lo que sea con peopleStore...

Guardando información

Una vez que tenemos una transacción y podemos acceder a un objectStore, almacenar datos en él es muy sencillo:

var store = tx.objectStore('people');

var addRequest = store.add({
  name: 'Manolo',
  email: 'manolo@gmail.com',
  city: 'Cuenca'
});

addRequest.onsuccess = function(event) {
  // Hemos guardado al pobre Manolo en el objectStore

  // event.target.result contiene la clave asociada a Manolo,
  // si estás siguiendo el ejemplo completo, sería un valor
  // numérico autogenerado y guardado en la propiedad id del
  // objeto almacenado en el objectStore
};

addRequest.onerror = function(e) {
  // Algo ha ido mal
}

Como decíamos antes, las transacciones siguen vivas mientra tengan operaciones pendientes de realizar. En este caso sólo hemos iniciado una operación, el add, por lo que si después de ejecutarse el onsuccess y onerror no hacemos nada más, la transacción se dará por terminada.

Para actualizar información se usa un API similar, pero hay que tener en cuenta que en este caso el objeto que estamos almacendo deberá tener ya una clave asignada o deberemos indicarla en el momento de actualizar:

// Cambiamos la ciudad de Manolo. En este caso necesitamos indicar la clave
// del registro que estamos actualizando, que en nuestro caso está almacenada
// en la propiedad "id"
var putRequest = store.put({
  id: 512,
  name: 'Manolo',
  email: 'manolo@gmail.com',
  city: 'Murcia'
});

Por último, si quisiéramos eliminar un objeto deberíamos hacerlo a partir de su clave:

// Adios Manolo
store.delete(512);

Recuperando información

Como ya hemos dicho un par de veces en estos posts, IndexedDB es una base de datos que almacena pares clave/valor, por lo que la forma más directa de recuperar un objeto es a partir de su clave (y siempre dentro de una transacción, claro):

var tx = db.transaction('people', 'readonly');
var store = tx.objectStore('people');

// objectStore.get devuelve un request, como todas las APIs
// de IndexedDB, pero si no controlamos los errores 
// individualmente, podemos simplificar un poco el código

store.get(512).onsuccess = function(event) {
  var manolo = event.target.result;
};

Una cosa a tener en cuenta es que, pese a que esto es Javascript, no se aplican las coerciones de tipos habituales, por lo que si la clave de un registro es un número, deberemos pasarle a get un número, no vale un string con ese número.

Por supuesto, también podemos buscar a partir de un índice:

var tx = db.transaction('people', 'readonly');
var store = tx.objectStore('people');
var index = store.index('idx_email');

index.get('manolo@gmail.com').onsuccess = function(event) {
  var manolo = event.target.result;
};

Cursores

Todo esto está muy bien para cargar un único registro de la base de datos, pero si queremos cargar varios registros, o incluso recorrer todo un objectStore, necesitamos hacer uso de cursores. La idea es similar a la de los cursores de SQL (que seguro que los más ancianos del lugar recuerdan): un cursor nos permite iterar sobre un conjunto de resultados para procesarlo secuencialmente e incluso ir actualizando esos objetos en la base de datos según los procesamos.

En su forma más básica, podemos iterar sobre todo un objectStore:

var tx = db.transaction('records', 'readonly');
var store = tx.objectStore('records');

// Desgraciadamente, todo sigue siendo asíncrono y lo que podría ser
// un sencillo y lineal bucle se convierte en callbacks 

store.openCursor().onsuccess = function(event) {
  var cursor = event.target.result;
  // cursor será truthy mientras haya elementos que procesar
  if (cursor) {
    // En cursor.value tenemos el elemento actual
    var current = cursor.value;

    // Pasamos a procesar el siguiente resultado
    cursor.continue();
  }
};

También podemos utilizar cursores para recorrer índices, y podemos establecer la dirección y los límites (superior e inferior) que usaremos para acotar el conjunto de resultados:

var tx = db.transaction('records', 'readonly');
var store = tx.objectStore('records');
var index = store.index('idx_city');

// Definimos un rango de consulta entre dos valores 
var range = IDBKeyRange.bound('Cuenca', 'Sevilla');

index.openCursor(range).onsuccess = function(event) {
  // Recorremos los registros encontramos dentro de
  // ese rango igual que hicimos en el ejemplo anterior
}

Resumen

En este post hemos visto como realizar interacciones básicas con IndexedDB para gestionar transacciones, almacenar información y recuperarla. Entre esto y lo que vimos en el post anterior sobre cómo crear una base de datos con IndexedDB estamos en condiciones de empezar a trastear con esta base de datos NoSQL.

Una cosa que resulta incómoda de IndexedDB es la cantidad de código que requiere para cualquier cosa. Entre eventos para procesar resultados, eventos para gestionar errores, manejo de transacciones, etc., el código resulta feo y tedioso de escribir.

Aunque existen librerías que hacen el manejo de IndexedDB más agradable y probablemente dentro de poco veamos alguna de ellas, he preferido empezar por ver IndexedDB a un nivel más bajo porque creo que siempre es importante saber lo que estás haciendo.