No artigo “IndexedDB, implementando a persistência com o pattern Data Mapper” implementamos o padrão de projeto Data Mapper para persistir objetos com o IndexedDB. Desta vez, implementaremos a solução de persistência com o pattern Active Record.

O problema

Nossa aplicação define as classes Person e Animal:

// app/person.js
export class Person {
    constructor(name) {
        this._name = name;
    }

    get name() {
        return this._name;
    }
}
// app/animal.js
export class Animal {
    constructor(name) {
        this._name = name;
    }

    get name() {
        return this._name
    }
}

Precisamos persistir instâncias dessas classes no IndexedDB, um banco de dados presente nos navegadores do mercado:

// app/app.js
import { Person } from './Person.js';
import { Animal } from './Animal.js';

const person = new Person('Flávio Almeida');
const animal = new Animal('Calopsita');
// como realizar a persistência no banco IndexedDB?

Podemos obter uma conexão do banco, criar transações e lidar com a persistência no módulo app/app.js, mas queremos uma solução que facilite a vida do desenvolvedor e que também possa ser reutilizada pela aplicação. Nesse sentido, podemos aplicar o padrão de projeto Active Record.

O padrão de projeto Active Record

Segundo Martin Fowler:

“Um objeto traz dados e comportamento. Grande parte desses dados é persistente e precisa ser armazenado em um banco de dados. O Active Record usa a abordagem mais óbvia, colocando lógica de acesso a dados no objeto de domínio. Desta forma, todas as pessoas sabem como ler e escrever seus dados de e para o banco de dados”. - Patterns of Enterprise Application Architecture.

Vamos criar uma API que implemente o pattern Active Record com a finalidade de isolar a complexidade de persistência com o IndexedDB. Depois de pronta, ela funcionará dessa maneira:

// app/app.js
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

(async () => {
    await activeRecord({
        name: 'cangaceiro',
        version: 2, 
        mappers: [
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        ]
    });
        
    const person = new Person('Flávio Almeida');
    const animal = new Animal('Calopsita');
    // As instâncias sabem se persistir
    await person.save();
    await animal.save();
    // Operações que não operam diretamente em instâncias 
    // são invocadas através de métodos estáticos da classe
    const persons = await Person.list();
    persons.forEach(console.log);

    const animals = await Animal.list();
    animals.forEach(console.log);

})().catch(console.log);

O padrão Active Record tem uma forte relação com bancos relacionais. Todavia, nada nos impede de ampliarmos seu uso para bancos noSQL ou orientados a documentos. Por mais que a estrutura de dados desses bancos seja semelhante ao JSON, ainda há impedância (discrepância) entre os dados armazenados no banco (dado) e os mesmos dados em memória (dado e comportamento).

A função activeRecord receberá um objeto com as configurações necessárias para nossa solução de persistência e retornará uma Promise. Sobre as propriedades de configuração temos:

  • name: define o nome do banco. Quando omitida será adotado como nome a string “default”.
  • version: define a versão do banco. Quando omitida será adotado 1 como versão.
  • mappers: contém uma lista de objetos de mapeamento com as propriedades clazz e converter. A primeira recebe a classe que terá uma store criada no banco. A segunda é a lógica de mapeamento dos dados retornados do banco para sua respectiva classe que será utilizada pelo método list().

Internamente, activeRecord criará as stores do banco, inclusive é durante a chamada desta função que o prototype das classes definidas na lista de mappers será modificado para incluir a operação de persistência save(). Já o método list() será adicionado como método estático na própria classe.

Diferente do post deste mesmo autor sobre a implementação de uma solução de persistência através do pattern Data Mapper, o autor decidiu abdicar do pattern Builder utilizado para atribuir as configurações de persistência. O motivo é que passar um objeto como configuração é muito menos verboso e cumpre seu papel indicando as propriedades de configuração.

Sobre os métodos de persistências temos:

  • save: persiste a instância do objeto no qual o método foi invocado. Internamente identifica a classe a qual o objeto pertence para decidir em qual store deve ser salvo. Retorna uma Promise.

  • list: método estático da classe que internamente identifica sua respectiva store retornando os dados persistidos. Faz uso do converter para guiar o processo de transformação dos dados armazenados em instâncias da classe.

Um ponto a se destacar é que podemos usar a mesma conexão durante toda a aplicação sem que seja necessário fechá-la.

Agora que já sabemos até onde queremos chegar, vamos dar início a nossa implementação.

Implementando a função activeRecord

Vamos criar o módulo app/active-record.js que terá em seu escopo o objeto dbConfig. Suas propriedades serão inicializadas com valores padrões. Em seguida, definiremos a função activeRecord que terá acesso ao objeto dbConfig. A função activeRecord será o único artefato exportado pelo módulo active-record.js:

// app/active-record.js
// configuração do banco encapsuladas no módulo
const dbConfig = {
    name: 'default',
    version: 1,
    stores: new Map()
};

export const activeRecord = ({ name, version, mappers }) => {
    // passou as configurações para o objeto dbConfig
    dbConfig.version = version;
    dbConfig.name = name;
    // processa a lista de mappers
    mappers.forEach(mapper => 
        dbConfig.stores.set(mapper.clazz.name, mapper.converter)
    );
};

Atribuímos às propriedades dbConfig.version e dbConfig.name a versão do banco e seu nome respectivamente. Por fim, para cada item da lista de mappers recebida utilizaremos o valor da propriedade clazz.name como key e o valor da propriedade converter como seu valor no Map dbConfig.stores. Esse dado é importante, pois é através dele que saberemos quais stores serão criadas e qual lógica de conversão será utilizada ao obter seus dados.

Objetos recuperados da store só possuem propriedades e nenhum método, por isso é importante definir a lógica de conversão dos dados trazidos do banco para sua respectiva classe.

Vamos criar a página index.html que importará o módulo app/app.js utilizando o sistema de importação de módulos nativo do Chrome:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Active Record</title>
</head>
<body>
<!-- importou o módulo -->
<script type="module" src="app/app.js"></script>
</body>
</html>

É necessário servir a página através de um servidor Web de sua escolha, caso contrário o carregamento dos módulos não funcionará.

Vejamos como fica o uso inicial da função activeRecord no módulo app/app.js:

// app/app.js
import { activeRecord } from './active-record.js';

activeRecord({
    name: 'cangaceiro',
    version: 2, 
    mappers: [
        { 
            clazz: Person,
            converter: data => new Person(data._name)
        },
        { 
            clazz: Animal,
            converter: data => new Animal(data._name)
        }
    ]
});

Todos os módulos que importarem nosso activeRecord trabalharão com a mesma instância.

Excelente, mas no ato de passarmos as configurações para a função activeRecord precisamos criar a conexão com o banco.

Obtendo uma conexão

Vamos criar uma função responsável pela criação da conexão. Ela viverá no escopo do módulo active-record.js e apenas o objeto activeRecord poderá acessá-la.

Como a obtenção de uma conexão é uma operação assíncrona, retornaremos uma Promise como resposta, reduzindo assim a complexidade de termos que trabalhar com callbacks.

Vamos criar a propriedade conn no objeto dbConfig. Ela guardará uma referência para a conexão criada. Seu valor será atribuído através da função createConnection:

// app/active-record.js
const dbConfig = {
    name: 'default',
    version: 1,
    stores: new Map(), 
    conn: null // nova propriedade
};

const createConnection = () => 
    new Promise((resolve, reject) => {
    
    });

// definição da função activeRecord omitida

Vamos solicitar à função indexedDB.open() a abertura da conexão com o banco usando as configurações definidas em dbConfig.name e dbConfig.version. Porém, não temos a garantia de que a conexão foi efetuada, motivo pelo qual precisamos lidar com os eventos onupgradeneeded, onsuccess e onerror:

// app/active-record.js
const dbConfig = {
    name: 'default',
    version: 1,
    stores: new Map(), 
    conn: null
};

const createConnection = () => 
    new Promise((resolve, reject) => {
        // requisitamos a abertura, um evento assíncrono!
        const request = indexedDB.open(dbConfig.name, dbConfig.version);

        request.onupgradeneeded = e => {};

        request.onsuccess = e => {};

        request.onerror = e => {};
    });

// código posterior omitido

O evento onupgradeneeded disponibiliza uma conexão transacional que nos permite criar todas as stores do banco. Utilizaremos as chaves do nosso Map stores como nome das stores:

//app/active-record.js
const dbConfig = {
    name: 'default',
    version: 1,
    stores: new Map(), 
    conn: null
};

const createConnection = () => 
    new Promise((resolve, reject) => {

        const request = indexedDB.open(dbConfig.name, dbConfig.version);

        request.onupgradeneeded = e => {

            const transactionalConn = e.target.result;
            // utilizando for...of e destructuring ao mesmo tempo
            // para ter acesso facilitado à key do Map
            for (let [classKey] of dbConfig.stores) {
                const store = classKey;
                // se já existe, apagamos a store
                if(transactionalConn.objectStoreNames.contains(store)) 
                    transactionalConn.deleteObjectStore(store);
                transactionalConn.createObjectStore(store, { autoIncrement: true });
            }     
        };

        request.onsuccess = e => {
            dbConfig.conn = e.target.result; // guarda uma referência para a conexão
            resolve(); // tudo certo, resolve a Promise!
        }
        // lida com erros, retornando uma mensagem de alto nível
        request.onerror = e => {
            console.log(e.target.error);
            reject('Não foi possível obter a conexão com o banco');
        }; 
    });
// código posterior omitido

A estratégia utilizada durante um eventual upgrade do banco é derrubar todas as stores para em seguida criá-las novamente.

Excelente, agora precisamos chamar a função createConnection através da função ActiveRecord. Vamos tornar a função async para que possamos utilizar a instrução await com a Promise retornada por createConnection:

// app/active-record.js

// código anterior omitido

// tornou a função async
export const activeRecord = async ({ name, version, mappers }) => {
 
    dbConfig.version = version;
    dbConfig.vame = name;
    
    mappers.forEach(mapper => 
        dbConfig.stores.set(mapper.clazz.name, mapper.converter)
    );
    // awaitter na função createConnection
    await createConnection();
}

Agora, em app/app.js, criaremos um wrapper async para que possamos utilizar a instrução await com o método register:

// app/app.js
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';
// wrapper async
import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

(async () => {
    await activeRecord({
        name: 'cangaceiro',
        version: 2, 
        mappers: [
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        ]
    });

})().catch(console.log);

Recarregando a página index.html no Chrome, na aba Application -> Storage -> IndexedDB podemos constatar a criação do banco.

Abra outra aba no Chrome para que possa verificar os dados mais atualizados, pois este autor constatou que o Chrome não realiza o refresh automático da aba Application -> Storage -> IndexedDB.

stores criadas no banco cangaceiro

Agora vamos implementar o método save.

Implementando a função save

A função save da nossa classe ActiveRecord também retornará uma Promise, pois a operação de persistência também é uma operação assíncrona:

// app/active-record.js
const dbConfig = {
    name: 'default',
    version: 1,
    stores: new Map(), 
    conn: null
};

const createConnection = () => 
    // código omitido
);

const save = async function() {

    return new Promise((resolve, reject) => {

        if(!dbConfig.conn) return reject('Você precisa registrar o banco antes de utilizá-lo');

        const object = this;
        const store = this.constructor.name;
        
        const request = dbConfig.conn
            .transaction([store],"readwrite")
            .objectStore(store)
            .add(object);
    
        request.onsuccess = () => resolve();

        request.onerror = e => {
            console.log(e.target.error);
            reject('Não foi possível persistir o objeto');
        };
    });
};

// função activeRecord omitida

Vamos analisar o código anterior. A Promise retornada verifica se a variável dbConfig.conn possui algum valor e caso não possua, rejeitamos a Promise imediatamente. Esse fail fast é importante, pois sinalizará para o desenvolvedor que ele deve realizar antes o registro do banco. O restante do código é padrão da API do IndexedDB.

Agora chegou a hora de implementarmos a função list(), aquela que retornará os dados persistidos.

Implementando o método list

Assim como a função save(), a função list() retornará uma Promise. Ele receberá como parâmetro uma classe e, a partir dela, extrairá o nome da sua respectiva store. Sabemos que o nome da store é a key do Map stores. É através dessa key que temos acesso a lógica de conversão, aquela que sabe converter os dados retornados da store para uma instância da sua respectiva classe.

// app/active-record.js
// código anterior omitido 

const list = async function() {
        
    return new Promise((resolve, reject) => {
    
        const store = this.name;
        
        const transaction = dbConfig.conn
            .transaction([store],'readwrite')
            .objectStore(store); 
        
        const cursor = transaction.openCursor();
    
        const converter = dbConfig.stores.get(store);
    
        const list = [];
    
        cursor.onsuccess = e => {

            const current = e.target.result;

                if(current) {
                    list.push(converter(current.value));
                    current.continue();
            } else resolve(list);
        };

        cursor.onerror = e => {
            console.log(target.error);
            reject(`Não foi possível lista os dados da store ${store}.`);
        };  
    });    
}

Ótimo, para concluirmos o módulo active-record.js, precisamos adicionar a função save como método no prototype das classes que representarão nossas stores para que esteja disponível em todas as instâncias dessas classes. Já o a função list() será adicionada na própria classe, como método estático. Realizamos os passos que faltam na função activeRecord:

// app/active-record.js
// código anterior omitido 
export const activeRecord = async ({ name, version, mappers }) => {
 
    dbConfig.version = version;
    dbConfig.name = name;
    
    mappers.forEach(mapper => {
        dbConfig.stores.set(mapper.clazz.name, mapper.converter);
        // Adiciona save ao proprotype da classe para 
        // que esteja disponível em todas as instâncias
        // da classe.
        mapper.clazz.prototype.save = save;
        // Adiciona list diretamente na classe como método estático
        mapper.clazz.list = list;
    });

    await createConnection();
}

Excelente, nosso módulo app/app.js ficará assim:

import { activeRecord } from './active-record.js';
import { Person } from './Person.js';
import { Animal } from './Animal.js';

(async () => {
    await activeRecord({
        name: 'cangaceiro',
        version: 2, 
        mappers: [
            { 
                clazz: Person,
                converter: data => new Person(data._name)
            },
            { 
                clazz: Animal,
                converter: data => new Animal(data._name)
            }
        ]
    });
        
    const person = new Person('Flávio Almeida');
    const animal = new Animal('Calopsita');

    // salva o método da instância
    await person.save();
    await animal.save();

    // chama o método estático
    const persons = await Person.list();
    persons.forEach(console.log);

    const animals = await Animal.list();
    animals.forEach(console.log);

})().catch(console.log);

Melhor organizado do que se tivéssemos utilizado diretamente a API do IndexedDB. Você pode conferir o código completo deste artigo no meu github.

Conclusão

O padrão de projeto Active Record pode ser aplicado não apenas com o IndexedDB, mas qualquer meio de persistência, inclusive banco de dados utilizados por API.

E você? Já utilizou alguma biblioteca que faz uso desse padrão? Como lidava com persistência no IndexedDB? Deixe sua opinião.