El equipo de Ionic han hecho un gran trabajo con el servicio Storage, que te permite usar Ionic 3 + SQLite sin apenas trabajo pero perdiendo la gracia que tienen las tablas relacionales.

¿Y qué pasa si quieres explotar la parte relacional de SQLite? Pues que tienes que leer éste artículo. Voy a explicarte como hacerlo.

Voy a tomar como punto de partida la aplicación que se desarrolla en mi curso Aprende Ionic 3 desde cero, que es una aplicación tipo TodoList. En esta aplicación tienes un conjunto de listas de tareas y cada una de ellas puede tener varias tareas (interesante para explorar las foreign keys).

En el curso, se usa un servidor para añadir o recibir los datos, pero para este ejemplo voy a pasar del servidor y lo voy a guardar todo en SQLite.

IMPORTANTE: Recuerda que solo puedes usar SQLite si ejecutas Ionic como aplicación (iOS/Android). En web no funciona el plugin, por eso se le llama plugin «nativo» 🙂

Dependencias

Lo primero, si quieres usar SQLite, es instalar el plugin nativo:

ionic cordova plugin add cordova-sqlite-storage
npm install --save @ionic-native/core@latest @ionic/app-scripts@latest
npm install --save @ionic-native/sqlite

Fíjate que antes de instalar @ionic-native/sqlite he instalado un par de dependencias (@ionic-native/core y @ionic/app-script). Esto es porque el proyecto que tomo como partida se hizo con la versión anterior de la CLI de Ionic. Si creas un proyecto con la CLI más reciente, estos paquetes ya vienen instalados de serie.

La dependencia que quiero usar, SQLite, la tengo que importar en mi módulo principal para pasarla en el array de providers.

app.module.ts

//file: src/app/app.module.ts

//...some imports...
import { SQLite } from '@ionic-native/sqlite';

@NgModule({
  //...some stuff...
  providers: [
    //...some providers...
    SQLite, 
    ]
})
export class AppModule {}

Servicio de base de datos

Sería inapropiado trastear directamente con SQL en los componentes, así que voy a crear un servicio que se encargará de configurar la BBDD. Además, también proporcionará una interfaz al estilo API rest para poder introducir/obtener datos desde los componentes sin necesidad de entender lo que hay debajo.

Creo este nuevo servicio con el nombre de database-service.ts. Te copio a continuación la estructura:

database-service.ts

// file: src/shared/database-service.ts

import { Injectable } from '@angular/core';
import { Platform } from 'ionic-angular';
import { SQLite, SQLiteObject } from '@ionic-native/sqlite';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';


@Injectable()
export class DatabaseService {

  private database: SQLiteObject;
  private dbReady = new BehaviorSubject<boolean>(false);

  constructor(private platform:Platform, private sqlite:SQLite) { }

  private createTables(){ }
  private isReady(){ }

  getLists(){}
  addList(name:string){ }
  getList(id:number){ }
  deleteList(id:number){ }

  getTodosFromList(listId:number){ }
  addTodo(description:string, isImportant:boolean, isDone:boolean, listId:number){ }
  modifyTodo(description:string, isImportant:boolean, isDone:boolean, id:number){ }
  removeTodo(id:number){ }
}

¿Cosas a destacar de este servicio?

  • Tiene un dato miembro privado database donde se guarda una referencia a la base de datos. Es de tipo SQLiteObject.
  • Tiene un dato miembro privado dbReady, que me servirá para determinar cuando está lista la base de datos (te lo explico en unos segundos)
  • Necesito inyectar SQLite (era de esperar), y Platform (relacionado con el punto anterior)
  • Tengo dos métodos privados cuyos nombres son autoexplicativos
  • Tengo varios métodos de perfil CRUD para crear/acceder/modificar/eliminar listas y tablas.

Ahora lo voy a ir implementando, paso a paso.

Antes de que se me olvide, tengo que importar este servicio en el módulo principal:

app.module.ts

//file: src/app/app.module.ts

//...some imports...
import { DatabaseService } from '../shared/database-service';

@NgModule({
  //...some stuff...
  providers: [
    //...some providers...
    DatabaseService, 
    ]
})
export class AppModule {}

Creación de la base de datos

Lo primero es crear la base de datos (o cargarla si ya existe). Lo hago en el constructor de mi nuevo servicio.

La BBDD se crea/carga con SQLite.create, donde hay que pasar un nombre y se aceptan otros parámetros opcionales. El método devuelve una promise con la referencia a la base de datos. Aprovecho esto para guardarme la referencia.

   this.sqlite.create({
     name: 'todos.db',
     location: 'default'
   })
   .then((db:SQLiteObject)=>{
     this.database = db;
   });

Como this.sqlite hace referencia al plugin nativo, antes de usarlo tengo que asegurarme de que el plugin ya está cargado. Por eso se inyecta en el constructor el servicio Platform.

Además, antes de usar la BBDD, tengo que confirmar que ya está creada y lista, porque si no podría hacer peticiones SQL inconsistentes.

Con todo esto en mente, acabo de completar el constructor de mi servicio.

database-service.ts

//...some stuff...

  constructor(private platform:Platform, private sqlite:SQLite) {
    this.platform.ready().then(()=>{
      this.sqlite.create({
        name: 'todos.db',
        location: 'default'
      })
      .then((db:SQLiteObject)=>{
        this.database = db;

        this.createTables().then(()=>{     
          //communicate we are ready!
          this.dbReady.next(true);
        });
      })

    });
  }

//...more stuff...

El método createTables aún no lo he implementado, pero como ves, devolverá una Promise al acabar.

Más curiosidad te habrá despertado el this.dbReady.next(true). Esto es una estrategia elegante para asegurarme en el resto de llamadas que la BBDD está lista. Te lo explico en breve, pero empecemos por el createTables.

Creando tablas

El método createTables es muy simple. Utilizo la sintaxis básica de SQLite para crear las tablas si no existían ya, mediante el método SQLite.executeSql, que espera la query y unos posibles argumentos.

Se podría hacer con transactions, el plugin lo soporta, pero seguiré con queries simples para no complicar el ejemplo.

En mi caso tengo 2 tablas, la de listas y la de tareas. Las listas solo tienen nombre y un identificador (que será su primary key). Las tareas tienen más datos, entre ellos listId, que es una foreign key que relaciona las tareas con las vistas.

NOTA: SQLite no tiene tipo booleano, así que usaré INTEGER en su defecto para los campos isImportant e isDone.

Otra cosa importante es que la tabla tareas necesita que la tabla list haya sido creada previamente por el tema de la foreign key. Por eso, la segunda tabla la creo al resolverse la Promise de la primera operación SQL.

El método para crear mis tablas queda así:

database-service.ts

//...some stuff...

  private createTables(){
    return this.database.executeSql(
      `CREATE TABLE IF NOT EXISTS list (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT
      );`
    ,{})
    .then(()=>{
      return this.database.executeSql(
      `CREATE TABLE IF NOT EXISTS todo (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        description TEXT,
        isImportant INTEGER,
        isDone INTEGER,
        listId INTEGER,
        FOREIGN KEY(listId) REFERENCES list(id)
        );`,{} )
    }).catch((err)=>console.log("error detected creating tables", err));
  }

//...more stuff...

Detectando que la base de datos está lista

Recupero ya el tema del this.dbReady.next(true) que has visto en el constructor.

Si te fijas en la definición de dbReady, es un BehaviorSubject. Lo he declarado así:

  private dbReady = new BehaviorSubject<boolean>(false);

Ya lo expliqué en otro artículo, pero te hago un resumen:

Un BehaviorSubject es un Observable que además tiene la capacidad de emitir eventos, con unas peculiaridades:

  • Siempre tienen un valor (por eso, lo he inicializado a false).
  • Al suscribirte recibes el último valor disponible.
  • Puedes obtener su valor en cualquier momento con getValue()

Éste en concreto, de entrada devuelve el valor false. Cuando decido que ya está la BBDD lista, emito un nuevo valor, en este caso true, con:

this.dbReady.next(true);

¿De qué me sirve todo esto? Voy a implementar el otro método privado, isReady(), para que devuelva una Promise que se resolverá cuando this.dbReady sea true.

OFERTA
Curso

Componentes Angular Nivel PRO

Domina los componentes de Angular (Angular 2/4/5+) como un experto y crea componentes técnicamente brillantes.
Idioma: Español
23 €110 €

Aquí puedes ver su implementación:

database-service.ts

//...some stuff...

  private isReady(){
    return new Promise((resolve, reject) =>{
      //if dbReady is true, resolve
      if(this.dbReady.getValue()){
        resolve();
      }
      //otherwise, wait to resolve until dbReady returns true
      else{
        this.dbReady.subscribe((ready)=>{
          if(ready){ 
            resolve(); 
          }
        });
      }  
    })
  }

//...more stuff...

Como ves, primero compruebo si el valor de mi BehaviorSubject ya es true con getValue(), y en ese caso resuelvo directamente la Promise.

Si el valor aún no es true, lo que hago es suscribirme al BehaviorSubject, de modo que cuando recibo un nuevo dato, resuelvo la Promise si su valor es true.

NOTA: Técnicamente faltaría cancelar la suscripción, pero como en principio solo voy a generar la suscripción una vez (cuando la BBDD está lista ya no me suscribo) y se trata de un servicio que va a estar vivo durante toda mi aplicación, voy a ignorarlo por simplificar el ejemplo.

Ahora que ya tengo el método isReady() listo, solo me falta empezar a implementar los accesos a base de datos…

Obteniendo las listas

Lo más sencillo es empezar por el get, aunque en este caso en realidad sea un select.

Fíjate de entrada cómo lo meto todo dentro de this.isReady().then(), para garantizar que mi BBDD esté lista.

A parte de eso, el método es de lo más simple. Hago un select de todos el contenido de la tabla lista gracias al método executeSql(), y manipulo los resultados para devolver un array con esos datos.

database-service.ts

//...some stuff...

  getLists(){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql("SELECT * from list", [])
      .then((data)=>{
        let lists = [];
        for(let i=0; i<data.rows.length; i++){
          lists.push(data.rows.item(i));
        }
        return lists;
      })
    })
  }

//...more stuff...

El formato de datos que devuelve un select es muy fácil de entender.
Es un objeto con la propiedad rows, que a su vez contiene:

  • la propiedad length: Número de items encontrados
  • el método item(index): Devuelve el item para una posición en concreto (clave/valor)

Añadiendo listas

No tiene mucho sentido obtener listas si aún no tengo ninguna, claro, así que voy a crear un método para añadirlas. De nuevo, sintaxis SQLite tradicional.

database-service.ts

//...some stuff...

  addList(name:string){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`INSERT INTO list(name) VALUES ('${name}');`, {}).then((result)=>{
        if(result.insertId){
          return this.getList(result.insertId);
        }
      })
    });    
  }

//...more stuff...

Fíjate como me aprovecho de los template literal strings de ES6 para pasar el nombre de la lista a la query SQL.

En este caso, además, me espero a obtener el resultado (que contiene un campo insertId), para hacer una nueva consulta y pedir el detalle de la lista que acabo de crear.

El detalle lo pido con un método que aún no está creado, getList(id). Vamos a ello…

Obtener una lista concreta

Obtener una lista concreta es una variación simple de obtener toda la lista. Lo único que necesito es el identificador de la lista, para hacer un WHERE.

database-service.ts

//...some stuff...

  getList(id:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`SELECT * FROM list WHERE id = ${id}`, [])
      .then((data)=>{
        if(data.rows.length){
          return data.rows.item(0);
        }
        return null;
      })
    })    
  }

//...more stuff...

Estoy asumiendo que me va a devolver un único item y todo va a ir bien, pero se podría complicar más para gestionar mejor los potenciales errores.

Eliminando listas

Algo muy típico con las listas es que me canso de ellas y las quiero borrar. No hay problema. He pensado en todo y tengo un método listo para esa situación, ahí va:

database-service.ts

//...some stuff...

  deleteList(id:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`DELETE FROM list WHERE id = ${id}`, [])
    })
  }

//...more stuff...

Obteniendo tareas

A esta alturas ya tengo todo lo que necesita mi app para crear, recuperar y borrar listas. Pero falta lo más importante, su contenido.

Igual que antes empiezo con el clásico get, aunque aquí tengo que especificar un identificador de lista, para saber las tareas que tengo que cargar.

database-service.ts

//...some stuff...

  getTodosFromList(listId:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`SELECT * from todo WHERE listId = ${listId}`, [])
            .then((data)=>{
              let todos = [];
              for(let i=0; i<data.rows.length; i++){
                let todo = data.rows.item(i);
                //cast binary numbers back to booleans
                todo.isImportant = !!todo.isImportant;
                todo.isDone = !!todo.isDone;
                todos.push(todo);
              }
              return todos;
            })
    })
  }

//...more stuff...

La mecánica es la misma que habías visto hasta ahora, pero hay un ligero detalle a tener en cuenta. Los campos isImportant y isDone que recupero de SQLite, no son booleanos sino que son enteros (0/1).

TRUCO RÁPIDO: Utilizo la doble negación (!!) para convertir enteros 0/1 a auténticos booleanos.

Añadir tareas

Seguro que estarás pensando… ya… ¿y como añado tareas?
Pues misma mecánica, claro.

Podría usar template literal strings para introducir todos los datos en la query, pero ahora voy a mostrarte algo distinto. Vas a ver para que sirven los parámetros que se pueden pasar al final de executeSql.

database-service.ts

//...some stuff...

  addTodo(description:string, isImportant:boolean, isDone:boolean, listId:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`INSERT INTO todo 
        (description, isImportant, isDone, listId) VALUES (?, ?, ?, ?);`, 
        //cast booleans to binary numbers        
        [description, isImportant?1:0, isDone?1:0, listId]);
    });      
  }

//...more stuff...

Como ves, los valores a introducir en la query se señalan con interrogantes, y utilizo el segundo parámetro de executeSql para pasar un array con las variables correspondientes, en orden.

Fíjate también como aquí hago el cast inverso de booleanos a enteros con el operador ternario, que sigue la estructura siguiente:

//if(condition){ return x} else {return y}
condition ? x : y

Modificar tareas

Ya vamos complicando la cosa.

Como no podía ser de otra forma, SQLite también me permite modificar un objeto concreto de la base de datos, con una query del estilo: UPDATE table SET field = 'x' WHERE id = 'y'.

database-service.ts

//...some stuff...

  modifyTodo(description:string, isImportant:boolean, isDone:boolean, id:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`UPDATE todo 
        SET description = ?, 
            isImportant = ?,
            isDone = ? 
        WHERE id = ?`, 
        //cast booleans to binary numbers
        [description, isImportant?1:0, isDone?1:0, id]);
    });       
  }

//...more stuff...

Eliminar una tarea

El último método que falta es el de borrar tareas. A estas alturas deberías poder hacerlo con los ojos cerrados.

database-service.ts

//...some stuff...

  removeTodo(id:number){
    return this.isReady()
    .then(()=>{
      return this.database.executeSql(`DELETE FROM todo WHERE id = ${id}`, [])
    })    
  }

//...more stuff...

Resultados

¿Quieres saber como queda esta API cuando se la aplico a mi aplicación de lista de tareas?

¡Dentro vídeo!

Si quieres trastear con el código, lo tienes aquí: https://github.com/kaikcreator/todoListsIonicSQLite

¿Te ha gustado este artículo? ¡Déjame un comentario debajo y compártelo en las redes! ¡¡GRACIAS!!