Boas pessoas desenvolvedoras precisam saber fazer CRUD não é mesmo? Então já pensou em ser capaz de produzir um CRUD com um banco de dados NoSQL montado para alta escalabilidade e ainda mais utilizando uma linguagem elegante e simples? Não? Pois muito que bem, nesse artigo você vai aprender como construir uma CLI utilizando a gem dry-cli
consumindo o banco de dados ScyllaDB com a gem cassandra-driver
Para saber um pouco mais sobre o que é ScyllaDB e em quais contextos essa ferramenta é útil, recomendo ler a documentação oficial
Disclaimer: Esse artigo assume conhecimento básico com bancos de dados visto que o objetivo vai ser focar na utilização em conjunto com ruby, para mais informações especificas sobre Scylla DB recomendo:
- Os artigos criados pelo Developer Advocate da ScyllaDB DanielHe4rt no Dev.To
- Os cursos gratuitos promovidos pela própria ScyllaDB no ScyllaDB University
Table of Contents
- Iniciando o projeto
- Definindo a camada de injeção de dependências
- Definindo o boilerplate para nossa CLI
- Implementando nossos comandos
- Conclusão
1. Iniciando o projeto
1.1 Resolvendo bibliotecas de sistema para instalar o driver
A gem que vamos utilizar para comunicar com o ScyllaDB se chama cassandra-driver, infelizmente ela exige bibliotecas de sistema relacionadas ao banco de dados cassandra para serem instaladas, a forma mais simples de ter essas bibliotecas instaladas é instalar o próprio cassandra na maquina:
Disclaimer: Não vamos utilizar o banco cassandra para nada na prática desse artigo, apenas precisamos do pacote instalado para que a gem consiga buscar as bibliotecas necessárias ao seu funcionamento. Visto que o ScyllaDB é baseado originalmente no Cassandra podemos utilizar tranquilamente.
Para instalar o cassandra no MacOS:
brew install cassandra
Para instalar o cassandra no Linux(Debian):
sudo apt-get install -y cassandra
Caso sua Distro/Sistema operacional não tenha sido listada, recomendo sempre recorrer a Documentação Oficial
1.2 Iniciando o projeto
Com as bibliotecas de sistema corretamente instaladas, podemos iniciar nosso projeto utilizando bundler:
mkdir project_scylla && cd project_scylla && bundle init
1.3 Instalando dependências de projeto
Agora podemos finalmente adicionar as gems necessárias para o projeto:
bundle add cassandra-driver
bundle add dry-auto_inject
bundle add dry-system
bundle add zeitwerk
bundle add dry-cli
bundle add dotenv
1.4 Definindo um REPL
Como somos bons rubistas devemos fazer o setup do nosso IRB como a primeira ação no projeto correto?
Para isso crie um script executável em bin/console
com:
touch bin/console && chmod +x bin/console
Nesse arquivo vamos incluir um setup básico com IRB + Dotenv:
#!/usr/bin/env ruby
require 'dotenv/load'
require 'irb'
IRB.start
Excelente! Agora temos o básico do nosso setup concluído! Vamos prosseguir definindo a camada de injeção de dependência e um provedor para incorporar a funcionalidade do ScyllaDB.
2. Definindo a camada de injeção de dependências
Nesse artigo vamos seguir um modelo igual mostrado no meu artigo sobre injeção de dependências com sinatra, para um detalhamento maior de como esse setup e feito e o porquê é feito, por favor referencie a esse link.
2.1 Criando o container principal
Como descrito no artigo referenciada acima, para nossa camada de injeção de dependências funcionar precisamos definir um container principal que vai servir de referencia para buscar dependências e registrar novos providers.
Agora vamos criar um arquivo em config/application.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/system'
class Application < Dry::System::Container
configure do |config|
config.root = Pathname('.')
config.component_dirs.loader = Dry::System::Loader::Autoloading
config.component_dirs.add 'lib'
config.component_dirs.add 'config'
end
end
loader = Zeitwerk::Loader.new
loader.push_dir(Application.config.root.join('lib').realpath)
loader.push_dir(Application.config.root.join('config').realpath)
loader.setup
Neste arquivo recém criado estamos definindo a localização dos componentes ao longo da nossa aplicação, a principio isso vai definir auto loading desses arquivos para que não precisemos usar require
sempre que usamos alguma classe de algum arquivo.
Com o container definido já podemos modificar nosso script de REPL para que contenha o método finalize!
da nossa aplicação recém definida.
#!/usr/bin/env ruby
require 'dotenv/load'
require_relative '../config/application'
Application.finalize!
require 'irb'
IRB.start
Também vamos criar um arquivo main.rb
na raiz do nosso projeto apenas com o finalize!
para servir como um ponto de entrada na nossa CLI.
require 'dotenv/load'
require_relative '../config/application'
Application.finalize!
2.2 Criando o provider de banco de dados
Agora que temos um container principal para nossa aplicação, podemos definir o único provider desse projeto que vai ser a conexão com o ScyllaDB.
Para isso crie um arquivo em config/provider/database.rb
com o seguinte conteúdo:
# frozen_string_literal: true
Application.register_provider(:database) do
prepare do
require 'cassandra'
require_relative '../../lib/migration_utils'
require_relative '../constants'
end
start do
cluster = Cassandra.cluster(
username: ENV.fetch('DB_USER', nil),
password: ENV.fetch('DB_PASSWORD', nil),
hosts: ENV.fetch('DB_HOSTS', nil).split(',')
)
connection = cluster.connect
MigrationUtils.create_keyspace(session: connection) if MigrationUtils.keyspace_exist?(session: connection)
MigrationUtils.create_table(session: connection) if MigrationUtils.table_exist?(session: connection)
connection = cluster.connect(KEYSPACE_NAME)
register('database.connection', connection)
end
end
Para as credenciais recomendo utilizar o serviço cloud ScyllaDB, nele você consegue criar um cluster super rápido e ganhar acesso a todas as credenciais de maneira super simples.
Um exemplo .env pode ser visto abaixo:
DB_USER=scylla
DB_PASSWORD=password
DB_HOSTS=node-0.amazonaws,node-1.amazonaws,node-2.amazonaws
Caso você tenha lido o artigo sobre injeção de dependências mencionado acima esse provider parece bem simples, mas também temos o uso de algumas classes novas que ainda não criamos e portanto vamos verificá-las passo a passo:
Definindo as constantes para nosso projeto
Nessa aplicação vamos manter nomes de keyspace e tabela definidos em constantes, você pode alterar para receber por parâmetro ou variável de ambiente, mas para simplicidade vamos deixar apenas com uma constante mesmo.
Crie um arquivo em config/constants.rb
com o seguinte conteúdo:
# frozen_string_literal: true
KEYSPACE_NAME = 'media_player'
TABLE_NAME = 'playlist'
Criando uma classe utilitária para criar nosso banco
Como podemos ver no exemplo do provider anterior, estamos usando a classe MigrationUtils
para produzir atividades comuns para a inicialização do nosso keyspace e da nossa tabela.
Agora vamos seguir passo a passo pelos métodos necessários para a criação do nosso keyspace e tabelas.
Checando se um keyspace ou tabela existe
Antes de prosseguirmos com a criação dos nossos keyspaces e tabelas, é crucial verificar se eles já existem a fim de evitar a execução desnecessária da função. Para isso, vamos implementar métodos booleanos da seguinte maneira:
Primeiramente, criaremos um arquivo chamado migration_utils.rb
, localizado em
lib/migration_utils.rb
, e o preencheremos com o código descrito abaixo:
class MigrationUtils
# @param session [Cassandra#Cluster]
# @return [Boolean]
def self.keyspace_exist?(session:)
query = <<~SQL
select keyspace_name from system_schema.keyspaces WHERE keyspace_name=?
SQL
session.execute_async(query, arguments: [KEYSPACE_NAME]).join.rows.size.zero?
end
# @param session [Cassandra#Cluster]
# @return [Boolean]
def self.table_exist?(session:)
query = <<~SQL
select keyspace_name, table_name from system_schema.tables where keyspace_name = ? AND table_name = ?
SQL
session.execute_async(query, arguments: [KEYSPACE_NAME, TABLE_NAME]).join.rows.size.zero?
end
end
Aqui, estamos implementando métodos estáticos que serão executados antes de configurarmos a camada de injeção de dependência. Por essa razão, aceitamos a sessão do banco de dados como parâmetro para essas funções. Com essa sessão, podemos usar o método execute_async
para enviar uma consulta CQL. Esse método também nos permite utilizar placeholders ?
para os parâmetros e especificar os valores em um objeto arguments: []
.
Como esse método funciona de forma assíncrona, precisamos usar o método join
para esperar essa Future finalizar e nos retornar um objeto. Com o objeto em mãos, podemos acessar a propriedade rows sendo esta uma lista contendo todas as linhas referentes a query mostrada acima.
Para finalizar a implementação do método retornando um booleano, vamos checar se a lista esta vazia ou não checando se o tamanho da lista é zero com .size.zero?
Disclaimer: Precisamos usar o
.size.zero?
pois o retorno é um Enumerator, que não possui o método.empty?
Criando os keyspaces e tabelas
Agora que criamos métodos responsáveis por checar a existência de um keyspace e uma tabela, precisamos criar os métodos que vão criar eles caso já não existam correto?
Para isso, vamos continuar trabalhando na classe localizada em lib/migration_utils.rb
com o seguinte conteúdo:
class MigrationUtils
# @param session [Cassandra#Cluster]
# @return [void]
def self.create_keyspace(session:)
query = <<~SQL
CREATE KEYSPACE #{KEYSPACE_NAME}
WITH replication = {
'class': 'NetworkTopologyStrategy',
'replication_factor': '3'
}
AND durable_writes = true
SQL
session.execute_async(query).join
end
# @param session [Cassandra#Cluster]
# @return [void]
def self.create_table(session:)
query = <<~SQL
CREATE TABLE #{KEYSPACE_NAME}.#{TABLE_NAME} (
id uuid,
title text,
album text,
artist text,
created_at timestamp,
PRIMARY KEY (id, created_at)
);
SQL
session.execute_async(query).join
end
end
Aqui vamos seguir o mesmo padrão onde recebemos o session
como parâmetro e o usamos para executar uma query assíncrona com execute_async
, como não precisamos lidar com o retorno dessas queries podemos apenas usar o join
para esperar ela finalizar.
Note: Para qualquer duvida referente as queries em si recomendo fortemente os links mencionados da ScyllaDB University.
2.3 Carregando o novo provider na nossa aplicação
Agora que definimos e entendemos o nosso provider de banco de dados, precisamos carregá-lo para que seja injetado nos nossos dois pontos principais da aplicação:
No main.rb
vamos adicionar o require:
require_relative 'config/provider/database'
E no bin/console
a mesma coisa:
require_relative '../config/provider/database'
3. Definindo o boilerplate para nossa CLI
Agora que temos uma camada de banco de dados e auto requiring pronta pra ser usada, vamos utilizar a outra grande gem desse projeto para definir os comandos da nossa CLI. Vem ai dry-cli
senhoras e senhores!
Nesse primeiro momento vamos nos preocupar em apenas definir o boilerplate para nossa CLI, sem nos preocupar com a implementação real certo?
Para isso, vamos definir o modulo que vai registrar todos os comandos, localizado em lib/cli.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/cli'
module Cli
extend Dry::CLI::Registry
register 'add', Commands::Add
end
Nesse modulo inicial podemos usar uma DSL para registrar novos comandos com o register
, essa DSL é fornecida ao extender o modulo Dry::CLI::Registry
.
Agora que registramos um comando Add
, vamos criar a classe referente a ele localizada em lib/cli/commands/add.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/cli'
module Cli
module Commands
class Add < Dry::CLI::Command
desc 'This command add a new song to the playlist'
argument :title, type: :string, required: true, desc: 'The title of the song'
argument :album, type: :string, required: true, desc: 'The name of the album of that song'
argument :artist, type: :string, required: true, desc: 'The name of the artist of band'
def call(title:, album:, artist:)
puts "Add command -> Title: #{title}, Album: #{album}, Artist: #{artist}"
end
end
end
end
Nessa classe podemos ver outra DSL fornecida por herdar a classe Dry::CLI::Command
, com ela podemos prover uma descrição para o comando usando desc
, declarar quais argumentos vamos receber para esse comando junto com seu tipo, validação e descrição com argument
e muito mais!
Logo após definir os metadados do nosso comando, definimos um método call
que vai receber os argumentos definidos como parâmetros nomeados.
Em nosso arquivo main.rb
podemos inicializar a nossa CLI com:
require 'dry/cli'
Dry::CLI.new(Cli).call
Finalmente, executando nossa aplicação com ruby main.rb
devemos ter o seguinte output:
$ ruby main.rb
Commands:
main.rb add TITLE ALBUM ARTIST # This command add a new song to the playlist
4. Implementando nossos comandos
Agora que temos um boilerplate e um entendimento básico quanto ao funcionamento da gem dry-cli
podemos nos preocupar em implementar alguns comandos simples para o nosso CRUD.
4.1 Implementando o primeiro comando Add
Como já temos a classe para esse comando criada, vamos apenas começar a trabalhar na implementação do método call
da seguinte forma:
# frozen_string_literal: true
require 'dry/cli'
module Cli
module Commands
class Add < Dry::CLI::Command
desc 'This command add a new song to the playlist'
argument :title, type: :string, required: true, desc: 'The title of the song'
argument :album, type: :string, required: true, desc: 'The name of the album of that song'
argument :artist, type: :string, required: true, desc: 'The name of the artist of band'
def initialize
super
@repo = Application['database.connection']
end
def call(title:, album:, artist:)
query = <<~SQL
INSERT INTO #{KEYSPACE_NAME}.#{TABLE_NAME} (id,title,artist,album,created_at) VALUES (now(),?,?,?,?);
SQL
@repo.execute_async(query, arguments: [title, artist, album, Time.now]).join
puts "Song '#{title}' from artist '#{artist}' Added!"
end
end
end
end
Nesta implementação, primeiro vamos injetar nossa conexão com o banco de dados por meio da camada de injeção de dependência no construtor da classe e então iremos utilizar o método já conhecido execute_async
para inserir um novo dado na tabela correta.
Um ponto importante a ressaltar é o uso da função now()
na query, essa função é nativa do banco de dados e serve para inserir um novo UUID no padrão esperado pelo ScyllaDB, dessa forma não precisamos lidar com geração de UUID pelo lado da linguagem.
Bem simples certo? vamos continuar com os próximos comandos do nosso CRUD seguindo a mesma arquitetura já proposta.
4.2 Implementando o comando List
Após ter elaborado um comando para adicionar novas músicas, avançaremos para a criação de um comando destinado a listar todas as músicas já criadas. Para isso vamos criar um arquivo em lib/cli/commands/list.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/cli'
module Cli
module Commands
class List < Dry::CLI::Command
desc 'This command shows all the created songs'
def initialize
super
@repo = Application['database.connection']
end
def call
query = <<~SQL
SELECT * FROM #{KEYSPACE_NAME}.#{TABLE_NAME};
SQL
@repo.execute_async(query).join.rows.each do |song|
puts <<~MSG
ID: #{song['id']} | Song Name: #{song['title']} | Album: #{song['album']} | Created At: #{song['created_at']}
MSG
end
end
end
end
end
Novamente, estamos aqui fazendo uma query SELECT
simples e percorrendo pelos resultados no array rows
para mostrar ao usuário final, é importante ressaltar que os fields acessados com row['title']
correspondem aos fields que criamos quando fizemos o CREATE TABLE
no provider.
Não podemos esquecer de registrar esse comando então vamos modificar o arquivo lib/cli.rb
:
# frozen_string_literal: true
require 'dry/cli'
module Cli
extend Dry::CLI::Registry
register 'add', Commands::Add
register 'list', Commands::List # <= Novo comando
end
Perfeito! Agora podemos tanto adicionar quanto listar musicas :D
Uma simples demo do que temos agora tanto com add quanto list:
4.3 Implementando o comando Delete
Vamos explorar agora um comando bastante interessante. Neste caso, iremos exibir uma lista de músicas acompanhadas por seus indices, permitindo ao usuário selecionar uma música específica através da posição correspondente.
Para realizar isso, vamos desenvolver o comando e implementar um método que seja responsável por listar as músicas com suas respectivas posições numéricas. Além disso, iremos também aguardar o input do usuário.
A seguir, vamos criar um novo arquivo localizado em lib/cli/commands/delete.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/cli'
module Cli
module Commands
class Delete < Dry::CLI::Command
desc 'This command will prompt for a song to be deleted and then delete it'
def initialize
super
@repo = Application['database.connection']
end
def call
songs = @repo.execute_async("SELECT * FROM #{KEYSPACE_NAME}.#{TABLE_NAME}").join.rows
song_to_delete_index = select_song_to_delete(songs:)
query = <<~SQL
DELETE FROM #{KEYSPACE_NAME}.#{TABLE_NAME} WHERE id = ?
SQL
song_to_delete = songs.to_a[song_to_delete_index]
@repo.execute_async(query, arguments: [song_to_delete['id']]).join
end
private
# @param songs [Array<Hash>]
def select_song_to_delete(songs:)
songs.each_with_index do |song, index|
puts <<~DESC
#{index + 1}) Song: #{song['title']} | Album: #{song['album']} | Artist: #{song['artist']} | Created At: #{song['created_at']}
DESC
end
print 'Select a song to be deleted: '
$stdin.gets.chomp.to_i - 1
end
end
end
end
No método select_song_to_delete
nós recebemos uma lista de musicas e percorremos por ela com um índice usando o método each_with_index
, dessa forma conseguimos mostrar uma mensagem no modelo 1) Song: | Album: | Artist: | Created At:
. Ainda nesse método esperamos o input do usuário com o $stdin.gets.chomp
e repassamos no retorno como um integer convertendo com .to_i
.
Já no método call
, começamos fazendo uma consulta para obter todas as músicas registradas na tabela. Em seguida, passamos esse conjunto de dados para o método que permite ao usuário selecionar uma em especifico e retornar o índice da mesma. Usamos esse índice para escolher uma música específica no array e, em seguida, executamos uma consulta DELETE
para removê-la.
Disclaimer: Antes de selecionar um item especifico, precisamos transformar em um array com
.to_a
visto que essas linhas são um Enumerator.
Uma demo mostrando o uso prático do comando:
Não podemos esquecer de registrar esse comando então vamos modificar o arquivo lib/cli.rb
:
# frozen_string_literal: true
require 'dry/cli'
module Cli
extend Dry::CLI::Registry
register 'add', Commands::Add
register 'list', Commands::List
register 'delete', Commands::Delete # <= Novo comando
end
4.4 Implementando o comando Update
Agora vamos criar um comando que vai juntar todos os conceitos mostrados
anteriormente, este será um update
e vai performar da seguinte forma:
- Vamos aceitar parâmetros como
title
,album
,artist
para usar como parte do update (semelhante ao que fizemos no comandoadd
) - Vamos mostrar para o usuário uma lista das musicas cadastradas e esperar o input do usuário com um índice (semelhante ao que fizemos no comando
delete
)
Perfeito! Vamos criar um comando localizado em lib/cli/commands/update.rb
com o seguinte conteúdo:
# frozen_string_literal: true
require 'dry/cli'
module Cli
module Commands
class Update < Dry::CLI::Command
desc 'This command will prompt for a song to be updated and use the argument information to updated it'
argument :title, type: :string, required: true, desc: 'The title of the song'
argument :album, type: :string, required: true, desc: 'The name of the album of that song'
argument :artist, type: :string, required: true, desc: 'The name of the artist of band'
def initialize
super
@repo = Application['database.connection']
end
def call(title:, album:, artist:)
songs = @repo.execute_async("SELECT * FROM #{KEYSPACE_NAME}.#{TABLE_NAME}").join.rows
song_to_update_index = select_song_to_update(songs:)
query = <<~SQL
UPDATE #{KEYSPACE_NAME}.#{TABLE_NAME} SET title = ?, artist = ?, album = ? WHERE id = ? AND created_at = ?
SQL
song_to_update = songs.to_a[song_to_update_index]
@repo.execute_async(query, arguments: [title, artist, album, song_to_update['id'] song_to_update['created_at']]).join
end
private
# @param songs [Array<Hash>]
def select_song_to_update(songs:)
songs.each_with_index do |song, index|
puts <<~DESC
#{index + 1}) Song: #{song['title']} | Album: #{song['album']} | Artist: #{song['artist']} | Created At: #{song['created_at']}
DESC
end
print 'Select a song to be updated: '
$stdin.gets.chomp.to_i - 1
end
end
end
end
Como pode ver, esse comando é realmente uma junção entre os conceitos do comando add
(arguments) e conceitos do comando delete
(método select pelo índice).
Sobre a query update
é importante ressaltar que no ScyllaDB temos duas primary keys(nesse caso id
e created_at
), portanto precisamos utilizar ambas as informações para que o banco de dados ache corretamente nossa linha.
Uma demo mostrando o funcionamento do comando:
Conclusão
Espero que esse artigo tenha sido útil! Tentei ao máximo focar na integração entre Ruby e ScyllaDB pois não encontrei nada com uma linguagem simples para iniciantes. Para duvidas direcionadas especificamente ao ScyllaDB recomendo fortemente os artigos do DanielHe4rt e o ScyllaDB University.
E não se esqueça de dar continuidade a este projeto que você acaba de criar! Deixo como um desafio a busca por aprimoramentos na arquitetura que construímos. Abaixo, elenco alguns pontos de melhoria evidentes, porém, encorajo você a identificar outros que possam ter passado despercebidos por mim. A prática é o segredo para se tornar uma pessoa desenvolvedora habilidosa!
😄
- As funções
select_song_to_delete
eselect_song_to_update
são iguais, talvez mover ela para algum local comum? - No comando
add
nós printamos uma mensagem de sucesso para o usuário, mas não seguimos isso nos outros comandos, podemos melhorar essa experiencia de usuário?
May the force be with you! 🍒