Préstamos de libros: Persistencia a un medio local


Entre los recursos disponibles de los dispositivos contamos con una API que permite persistir la información localmente, gracias a un motor que soporta el modelo relacional llamado SQLite.

Algunas características de este motor son:
  • es liviano, sólo necesita 250K de memoria para ejecutarse
  • no sólo funciona sino que además viene embebido en la VM de Android (ART)
  • es open-source
  • su distribución es gratuita
  • como dijimos antes es un motor relacional, que 
    • soporta transaccionalidad
    • permite definir PRIMARY KEYs
    • también claves subrogadas (ID autoincrementales)
    • tiene un acotado sistema de tipos, apenas TEXT (String), INTEGER (int o Long), y REAL (double)
Para más detalles recomendamos la lectura de http://www.vogella.com/tutorials/AndroidSQLite/article.html

Definición de estructuras de las tablas

La aplicación corre en el dispositivo, justamente donde necesitamos generar las tablas en el caso en que no existan. Entonces nuestro primer trabajo es definir un objeto que genere la estructura de las tablas Libros y Préstamos (los contactos ya se persisten cuando usamos el ContentProvider de Contacts):

public class PrestamosAppSQLLiteHelper extends SQLiteOpenHelper {

    private static final String DATABASE_NAME = "librex.db";
    private static final int DATABASE_VERSION = 15;
...
@Override
public void onCreate(SQLiteDatabase db) {
    StringBuffer crearTablas = new StringBuffer();
    crearTablas.append("CREATE TABLE Libros (ID INTEGER PRIMARY KEY AUTOINCREMENT,");
    crearTablas.append(" TITULO TEXT NOT NULL,");
    crearTablas.append(" AUTOR TEXT NOT NULL,");
    crearTablas.append(" PRESTADO INTEGER NOT NULL);");
    db.execSQL(crearTablas.toString());

    crearTablas = new StringBuffer();
    crearTablas.append("CREATE TABLE Prestamos (ID INTEGER PRIMARY KEY AUTOINCREMENT,");
    crearTablas.append(" LIBRO_ID INTEGER NOT NULL,");
    crearTablas.append(" CONTACTO_PHONE TEXT NOT NULL,");
    crearTablas.append(" FECHA TEXT NOT NULL,");
    crearTablas.append(" FECHA_DEVOLUCION TEXT NULL);");
    db.execSQL(crearTablas.toString());

}

/**
 * Estrategia para migrar de una version a otra
 */
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    db.execSQL("DROP TABLE IF EXISTS Libros; ");
    db.execSQL("DROP TABLE IF EXISTS Prestamos; ");
    onCreate(db);
}


Los métodos que definimos son:
  • onCreate: el evento que se dispara la primera vez que se crea la base de datos
  • onUpgrade: cuando se sube la versión de la base de datos, la estrategia (discutible) es eliminar las tablas libros y préstamos y volverlos a recrear. Tendríamos que analizar otras variantes si la información es sensible. Teniendo en cuenta que la app tiene fines didácticos no nos detenemos en este punto.

Un nuevo hogar para los libros

Recordemos que tenemos hasta el momento:
  • una interfaz HomeLibros
  • y una implementación concreta MemoryBaseHomeLibros
lo cual parecía una solución un tanto sobrediseñada. No obstante aquí vamos a generar una nueva clase Home, que va a terminar enviando mensajes a la base de datos local



public class SQLLiteRepoLibros implements RepoLibros {

    private String[] CAMPOS_LIBRO = new String[]{"titulo, autor, prestado, id"};

    PrestamosAppSQLLiteHelper db;

    public SQLLiteRepoLibros(Activity activity) {
        db = PrestamosAppSQLLiteHelper.getInstance(activity);
    }

Otra opción hubiera sido implementar un ContentProvider que termine generando los queries de insert, update, delete y select a la base, pero eso implicaría
  1. modificar la Activity para que utilice el nuevo ContentProvider en lugar del home
  2. o generar otro home que delegue a su vez en este ContentProvider, agregando un grado más de indirección (lo que agrega complejidad)

Alternativa 1

Alternativa 2

Descartando entonces la idea del ContentProvider, vemos cómo se implementa el addLibro:

@Override
public void addLibro(Libro libro) {
    SQLiteDatabase con = db.getWritableDatabase();
    int prestado = 0;
    if (libro.estaPrestado()) {
        prestado = 1;
    }
    ContentValues values = new ContentValues();
    values.put("id", libro.getId());
    values.put("titulo", libro.getTitulo());
    values.put("autor", libro.getAutor());
    values.put("prestado", prestado);

    con.insert("Libros", null, values);
}

Claro, tenemos que mapear cada atributo del libro con su correspondiente campo en la tabla Libros. Como los atributos son pocos y además utilizan tipos primitivos (Strings) no parece haber mucho trabajo, pero nos imaginamos que al persistir el préstamo (que tiene una relación con un objeto Libro) la cosa no va a ser tan sencilla.

De la misma manera, tenemos que ver cómo recuperar los libros de la base a objetos libros en memoria:

@Override
public List<Libro> getLibros() {
    SQLiteDatabase con = db.getReadableDatabase();
    List<Libro> result = new ArrayList<Libro>();

    Cursor curLibros = con.query("Libros", CAMPOS_LIBRO, null, null, null, null, null);
    while (curLibros.moveToNext()) {
        result.add(crearLibro(curLibros));
    }
    return result;
}

private Libro crearLibro(Cursor cursor) {
    Long id = new Long(cursor.getInt(cursor.getColumnIndex("ID")));
    String titulo = cursor.getString(cursor.getColumnIndex("TITULO"));
    String autor = cursor.getString(cursor.getColumnIndex("AUTOR"));
    int prestado = cursor.getInt(cursor.getColumnIndex("PRESTADO"));
    Libro libro = new Libro(id, titulo, autor);
    if (prestado == 1) {
        libro.prestar();
    }
    return libro;
}

Claro, ahora el Libro guarda el identificador de SQLite:


public class Libro implements Serializable {
    /*****************************************************
     * Atributos
     ****************************************************/
    Long id;


Modificaciones en el controller
En PrestamosBootstrap.initialize() el MainActivity debería modificar la referencia de CollectionBasedLibros a SQLLiteRepoLibros, pero como ese cambio se disemina por toda la aplicación vamos a generar un objeto que sea responsable de indicarnos cuál es el objeto RepoLibros que debemos utilizar:

public class PrestamosConfig {

    public static RepoLibros getRepoLibros(Activity activity) {
        // PERSISTENTE
        return new SQLLiteRepoLibros(activity);
        // NO PERSISTENTE
        //return CollectionBasedRepoLibros.getInstance();
    }

En la clase PrestamosBootstrap no cambia nada, porque la variable repoLibros toma el tipo RepoLibros genérico:

RepoLibros repoLibros = PrestamosConfig.getRepoLibros(activity);
elAleph = repoLibros.addLibroSiNoExiste(elAleph);
laNovelaDePeron = repoLibros.addLibroSiNoExiste(laNovelaDePeron);

Una vez creadas, las referencias elAleph, laNovelaDePeron, etc. pueden no tener id, si ya existe el libro. Entonces deberíamos adecuar el método addLibroSiNoExiste del Home para que devuelva siempre un libro, el creado o el que ya existía:

@Override
public Libro addLibroSiNoExiste(Libro libro) {
    Libro libroPosta = this.getLibro(libro);
    if (libroPosta != null) return libroPosta;
    this.addLibro(libro);
    return libro;
}

DIagrama general de las clases
TODO: Eliminar LibroListFragment

Repositorios polimórficos

Para que el lector compruebe que los repositorios son efectivamente polimórficos, se puede volver atrás la configuración del home de libros:

public static RepoLibros getRepoLibros(Activity activity) {
    // PERSISTENTE
    //return new SQLLiteRepoLibros(activity);
    // NO PERSISTENTE
    return CollectionBasedRepoLibros.getInstance();
}

Definir el método addLibroSiNoExiste en CollectionBasedLibros de la siguiente manera


@Override
public Libro addLibroSiNoExiste(Libro libro) {
    if (this.getLibro(libro) == null) {
        this.addLibro(libro);
    }
    return libro;
}


Y vemos que el comportamiento para actualizar los libros se mantienen, funcionando los homes en forma polimórfica para las actividades y fragments. Dejamos para que el lector investigue en el ejemplo cómo se resuelve la persistencia de los préstamos.