Ruby puro com banco de dados SQLite

Artigos - 13/Jan/2021 - por André Kanamura

Uma das vantagens de usarmos frameworks no desenvolvimento de aplicações é que eles já vêm integrados com ferramentas para executar algumas tarefas comuns em todos os projetos. Em Ruby on Rails, por exemplo, o ActiveRecord integra a lógica de persistência de dados nos bancos de dados aos Models. Embora isso ajude bastante no trabalho diário, para iniciantes em programação, esconder o funcionamento da conexão com o banco dificulta a sua compreensão. Neste artigo vamos mostrar de maneira simplificada como poderíamos implementar uma lógica semelhante ao ActiveRecord com Ruby puro e banco de dados SQL usando a gem SQLite3.

Neste exemplo vamos implementar a classe Task que representa uma tarefa, com um título e uma categoria em strings. Mas antes de implementar a classe, precisamos configurar o banco de dados e, para isso, vamos utilizar o arquivo bin/setup:

#!/usr/bin/env ruby

puts '== Instalando dependências =='
system 'gem install bundler --conservative'
system('bundle check') || system('bundle install')

require 'sqlite3'

puts "\n== Preparando banco de dados =="
begin
  db = SQLite3::Database.open "db/database.db"
  db.execute <<~SQL
    CREATE TABLE Tasks(
      title varchar(255),
      category varchar(255)
    );
  SQL

  seed_data = [["Enviar documentos", "Trabalho"],
               ["Comprar papel higiênico", "Casa"]]

  seed_data.each do |data|
    db.execute "INSERT INTO Tasks VALUES ( ?, ? )", data
  end

rescue SQLite3::Exception => e
  puts e
ensure
  db.close if db
end

Dessa maneira, ao rodarmos bin/setup o código começa instalando as dependências definidas no Gemfile. Neste exemplo o mais importante seria incluir gem 'sqlite3' no arquivo. Depois que tudo está instalado, o banco pode ser configurado. A linha db = SQLite3::Database.open "db/database.db" cria o banco de dados dentro do arquivo database.db. Se o arquivo não estiver presente, ele será criado. Com o comando execute podemos executar comandos SQL no nosso banco. Primeiro é criado o banco Tasks e, logo em seguida, são inseridos alguns dados nessa tabela. Ao final, em caso de erro, mensagens são impressas em tela. Se tudo der certo, o banco é criado e está pronto para podermos interagir com ele.

Vamos voltar a atenção para a classe Task, onde gostaríamos de implementar a lógica de persistência de objetos em banco. Sua implementação inicial poderia ser mais ou menos assim:

require 'sqlite3'

class Task
  attr_accessor :title, :category

  def initialize(title:, category:)
    @title = title
    @category = category
  end

Note que incluímos a linha require 'sqlite3' para que a classe seja capaz de acessar o banco de dados. Se quisermos que essa classe possua um método all que retorna todas as tarefas cadastradas, poderíamos implementar nela algo como:

  def self.all
    db = SQLite3::Database.open "db/database.db"
    db.results_as_hash = true
    tasks = db.execute "SELECT title, category FROM tasks"
    db.close
    tasks.map {|task| new(title: task['title'], category: task['category']) }
  end


Definimos o método com self para indicar que esse é um método de classe. Dentro dele abrimos o banco e executamos uma query SQL buscando todas as tarefas registradas. Depois o banco é fechado e iteramos pelo array de tarefas, criando uma nova instância da classe para cada elemento. Vamos simular o funcionamento da classe no IRB:

2.7.0 :001 > require_relative 'lib/task'
 => true
2.7.0 :002 > Task.all
 => [#<Task:0x0000563da5ecd518 @title="Enviar documentos", @category="Trabalho">, #<Task:0x0000563da5ecd478 @title="Comprar papel higiênico", @category="Casa">]

Chamando o método all na classe Task, são retornados objetos com os valores inseridos no banco quando rodamos o bin/setup/. Agora, vamos implementar um método para que os objetos da classe consigam salvar seus atributos em banco. Ele poderia ficar assim:

  def save_to_db
    db = SQLite3::Database.open "db/database.db"
    db.execute "INSERT INTO tasks VALUES('#{ title }', '#{ category }')"
    db.close
    self
  end

Aqui abrimos o banco e executamos um comando para inserir dados na tabela, recuperando os valores dos atributos title e category do objeto. Ao final, retornamos self para sinalizar qual objeto foi salvo em banco, mas o ideal seria retornar true ou false dependendo do sucesso ou falha na execução. Agora, se você chamar o método all, esse objeto também será retornado junto com os demais.

2.7.0 :001 > require_relative 'lib/task'
 => true
2.7.0 :002 > Task.all
 => [#<Task:0x0000559456eb2fe0 @title="Enviar documentos", @category="Trabalho">, #<Task:0x0000559456eb2f68 @title="Comprar papel higiênico", @category="Casa">]
2.7.0 :003 > task = Task.new(title: "Ligar para Carlos", category: "Trabalho")
2.7.0 :004 > task.save_to_db
 => #<Task:0x0000559456ebb0c8 @title="Ligar para Carlos", @category="Trabalho">
2.7.0 :005 > Task.all
 => [#<Task:0x0000559456f8ae18 @title="Enviar documentos", @category="Trabalho">, #<Task:0x0000559456f8adc8 @title="Comprar papel higiênico", @category="Casa">, #<Task:0x0000559456f8ad78 @title="Ligar para Carlos", @category="Trabalho">]

Já deve estar clara a ideia geral de como interagir com o banco de dentro de uma classe, mas vamos dar mais um exemplo e dessa vez vamos implementar uma busca usando o título da tarefa:

  def self.find_by_title(title)
    db = SQLite3::Database.open "db/database.db"
    db.results_as_hash = true
    tasks = db.execute "SELECT title, category FROM tasks where title='#{title}'"
    db.close
    tasks.map {|task| new(title: task['title'], category: task['category']) }
  end

O método é bastante semelhante ao all, diferindo apenas na query realizada no banco. No IRB, a chamada desse método ficar assim:

2.7.0 :001 > require_relative 'lib/task'
 => true
2.7.0 :002 > Task.all
 => [#<Task:0x0000563da5ecd518 @title="Enviar documentos", @category="Trabalho">, #<Task:0x0000563da5ecd478 @title="Comprar papel higiênico", @category="Casa">]
2.7.0 :003 > Task.find_by_title("Enviar documentos")
 => [#<Task:0x0000563da61a20b8 @title="Enviar documentos", @category="Trabalho">] 

Conclusão

Nesta implementação muitas coisas podem ser melhoradas, a começar, por exemplo, com a maneira como os dados são salvos em banco. O correto seria incluir para cada linha na tabela um ID único como um número inteiro para cada objeto. Além disso, seria interessante que fosse possível fazer uma validação dos atributos da classe no momento de salvar os dados. Indo um pouco além, no lugar de integrar a lógica de persistência dentro da classe, poderia ser criada uma classe separada somente para lidar com o banco de dados. Essa estratégia é um padrão de projeto chamado Repository Pattern. Você também poderia tentar elaborar testes para provar essa implementação.

A partir daqui as possibilidades são muitas e você pode ampliar muito seu conhecimento tentando implementar funcionalidades usando apenas Ruby "puro" (com ajuda de algumas gems).

Referências

Foto de perfil do autor
André Kanamura

Dev na Campus Code