IndexedDB

<< Web Storage API API de Geolocalización >>

IndexedDB es una base de datos de tipo NoSQL implementada en todos los navegadores modernos. Almacena los datos como objetos, de foma similar a otros gestores de bases de datos como MongoDB. Como la API de Web Storage, IndexedDB crea las bases de datos diferenciadas por origen (dominio:puerto) donde se está ejecutando una aplicación web. Solo podemos acceder a los datos del dominio asociado a la aplicación.

Esta base de datos no tiene las limitaciones de almacenamiento de Local Storage (unos 10MB). Aunque dependiendo del navegador, cuando una web excede de una cuota determinada (50MB en Chrome, por ejemplo), pedirá al usuario permiso para poder almacenar más información y sobrepasar ese límite.

IndexedDB trabaja con operaciones asíncronas, usando eventos para procesar las respuestas. Podemos englobar dichas operaciones en promesas para aprovechar sus ventajas, como la de concatenar operaciones.

La información se almacena dentro de almacenes de objetos (object stores), equivalentes tablas en SQL, aunque no es exactamente lo mismo. Los almacenes guardan parejas de clave-valor, pero al contrario que Local Storage, se almacenan objetos como valor en lugar de cadenas. La clave asociada a cada objeto puede ser autogenerada, o una propiedad del objeto (que no se repita). Sería equivalente a la clave primaria en una base de datos relacional.

Crear / Abrir una base de datos

Para leer o modificar una base de datos, debes abrirla primero llamando a IndexedDB.open. Esta operación es asíncrona y debemos manejar su resultado mediante los eventos correspondientes. El método recibe el nombre de la base de datos (string) y la versión (número entero). Estos son los eventos que pueden ocurrir:

  • Si la base de datos no existe o la versión enviada es mayor que la existente, se lanzará un evento del tipo upgradeneeded. Es en este momento cuando podremos modificar la estructura de la base de datos y añadir o eliminar almacenes de objetos.
  • Si la base de datos existe (o después de haberla creado / actualizado), se lanzará un evento de tipo success. A partir de aquí, podremos añadir, borrar y modificar objetos en los almacenes.
  • Un evento de tipo error se lanzará cuando algo vaya mal.
  • Si la base de datos ya está abierta (se abrió anteriormente sin cerrar la conexión), se lanza un evento blocked.

Vamos a ver como englobar todo esto dentro de una promesa, para gestionar todo lo arriba descrito en una única operación, que devolverá un objeto para manipular la base de datos si todo ha ido bien, o un error si algo falla:

function openDatabase() {
      return new Promise((resolve, reject) => {
          let openReq = indexedDB.open('example', 1);

          openReq.addEventListener('upgradeneeded', e => {  // Base de datos no existe
              const db = e.target.result;

              if (!db.objectStoreNames.contains('products')) { // Almacén de objetos ‘products’ no existe → creamos
                  db.createObjectStore('products', { autoIncrement: true, keyPath: 'id' }); // Campo id autogenerado
              }
          });

          openReq.addEventListener('success', e => {  // Todo ok
              resolve(e.target.result); // Devolvemos objeto de la base de datos
          });
          openReq.addEventListener('error', e => reject('Error abriendo base de datos!'));  // Lanzamos error
          openReq.addEventListener('blocked', e => reject('La base de datos está abierta')); // Lanzamos error
      });
  }

  async function doSomething() {
      const db = await openDatabase();
      // Resto de operaciones
  }

Borrando una base de datos

Antes de borrar una base de datos, debemos asegurarnos de que está cerrada. Si la hemos abiero, podemos cerrarla llamando al método close(). En ese momento podremos usar indexedDB.deleteDatabase('nombre') para borrarla. Esta operación también es asíncrona.

// Resto del código

function deleteDatabase() {
    return new Promise((resolve, reject) => {
        const deleteReq = indexedDB.deleteDatabase('example');
        deleteReq.addEventListener('success', e => resolve());  // Borrada
        deleteReq.addEventListener('error', e => reject('Error borrando base de datos')); // error
        deleteReq.addEventListener('blocked', e => reject('Debes cerrar la base de datos antes de borrar')); // error
    });
}

async function doSomething() {
    const db = await openDatabase();
    // Operaciones
    db.close(); // Esta llamada sí que es síncrona.
    await deleteDatabase();
    console.log('Base de datos borrada');
}

Operaciones CRUD

Todas las operaciones CRUD (Create, Read, Update, Delete → Creación, Lectura, Actualización, Borrado) deben hacerse usando una transacción. Las transacciones se realizan en modo lectura (readonly) o escritura (readwrite). Las transacciones realizan sobre un almacén de objetos y devuelven un objeto del tipo IDBObjectStore, del cual usaremos sus métodos para las operaciones. Todas ellas son asíncronas. Ejemplos:

Obteniendo todos los objetos de un almacén

function getAllProducts() {
    const store = db.transaction('products', 'readonly').objectStore('products');
    const getReq = store.getAll(); // Operación para obtener todos los objetos

    return new Promise((resolve, reject) => {
        getReq.addEventListener('success', e => resolve(e.target.result)); // Devuelve todos los objetos
        getReq.addEventListener('error', e => reject('No se pudo obtener los productos'));
    });
}

Obteniendo un objeto a partir de su clave

function getProduct(key) {
    const store = db.transaction('products', 'readonly').objectStore('products');
    const getReq = store.get(key); // Obtener el objeto a partir de una clave (id)

    return new Promise((resolve, reject) => {
        getReq.addEventListener('success', e => resolve(e.target.result)); // Devolvemos el producto
        getReq.addEventListener('error', e => reject(`No se pudo obtener producto: ${key}`));
    });
}

Insertando un objeto

function insertProduct(product) {
    const store = db.transaction('products', 'readwrite').objectStore('products');
    const addReq = store.add(product);  // Añadimos el producto

    return new Promise((resolve, reject) => {
        addReq.addEventListener('success', e => resolve(e.target.result)); // Devuelve el producto añadido
        addReq.addEventListener('error', e => reject('No se pudo añadir el producto'));
    });
}

Actualizando un objeto

function updateProduct(product, key = null) {
    const store = db.transaction('products', 'readwrite').objectStore('products');
    // La clave sólo se requiere si esta no es un campo del objeto
    const updateReq = key ? store.put(object, key) : store.put(object);

    return new Promise((resolve, reject) => {
        updateReq.addEventListener('success', e => resolve(e.target.result)); // Devuelve producto actualizado
        updateReq.addEventListener('error', e => reject(`No se pudo actualizar el producto`));
    });
}

Borrando un objeto

function deleteProduct(key) {
    const store = db.transaction('products', 'readwrite').objectStore('products');
    const delReq = store.delete(key);

    return new Promise((resolve, reject) => {
        delReq.addEventListener('success', e => resolve());  // void (si no hay error es que se ha borrado)
        delReq.addEventListener('error', e => reject(`No se pudo borrar el producto: ${key}`));
    });
}

Creación de índices

Crear índices en los almacenes de objetos permite hacer búsquedas a partir de otros atributos del objeto diferentes a la clave elegida (clave primaria). Para ello podemos usar el método createIndex después de crear el almacén de objetos. Este método recibe 3 parámetros: nombre del índice, atributo del objeto (puede ser compuesto → array), y opciones (por ejemplo unique para campos que no admitan valores repetidos).

const store = db.createObjectStore('products', { autoIncrement: true, keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });

A partir del índice, cuando queramos hacer una operación de consulta, podremos crear rangos, y que nos devuelva resultados a partir de ciertos criterios de búsqueda. Por ejemplo:

const store = db.transaction('products', 'readonly').objectStore('products');
const indexName = store.index('name');
// let range = IDBKeyRange.only('Chair');  // Producto con el nombre ‘Chair’
// let range = IDBKeyRange.lowerBound('I'); // Nombres a partir de la letra 'I'
// let range = IDBKeyRange.upperBound('I', true); // Nombres hasta la letra ‘I’ (no incluida)
let range = IDBKeyRange.bound('I', 'M', false, true); // De la 'I' (incluida) hasta la 'M' (no incluida)
const getReq = indexName.getAll(range);  // Obtiene los resultados en el rango especificado

<< Web Storage API API de Geolocalización >>