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).