Usando PostgreSQL e HStore no Rails 4
Leia em 5 minutos
O Rails 4 adicionou diversas novidades por todo o framework. Isso inclui uma integração muito melhor com o PostgreSQL, como suporte a novos tipos de campos.
Uma alteração particularmente interessante é o suporte ao campo hstore, que permite armazenar uma estrutura de dados chave-valor. Esta funcionalidade é extremamente útil como em casos onde as chaves armazenadas podem variar bastante nos registros, mas que precisam ser eventualmente utilizadas em queries.
O Rails possui suporte para armazenar dados serializados no banco de dados, mas isso possui uma desvantagem muito grande: não é possível fazer buscas em um determinado campo desse dado serializado. Isso não acontece no hstore, pois você pode fazer consultas diferentes, como registros que possuem uma determinada chave ou valor.
O hstore possui uma desvantagem: todos os valores são armazenados como strings. Isso significa que você terá que fazer a coerção dos valores manualmente se isso for importante para você.
Veja abaixo como usar o hstore, com algumas dicas de otimização de índices e coerção dos valores.
Atualizando o PostgreSQL
O PostgreSQL disponível no Ubuntu 12.04 LTS está na versão 9.1.9. Embora o suporte a hstore seja à partir da versão 9.0, a versão do Ubuntu não funciona muito bem com o Rails. Estranhamente, o método que ativa extensões no arquivo de migração não é persistido. Isso não acontece se você estiver rodando o PostgreSQL 9.2+.
Adicione a chave pública GPG.
wget -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
Depois, crie o arquivo com o endereço do repositório.
echo deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main | sudo tee /etc/apt/sources.list.d/postgresql.list
Se você instalou o PostgreSQL anteriormente, remova-o agora.
sudo apt-get remove postgresql-9.1 postgresql-contrib-9.1
Finalmente, atualize a lista de pacotes e instale o novo PostgreSQL.
sudo apt-get update
sudo apt-get install postgresql-9.3 postgresql-contrib-9.3
Crie o usuário que terá acesso ao banco de dados. No exemplo abaixo, mostro como fazer isso para o Vagrant.
sudo -u postgres psql -c "CREATE USER vagrant WITH SUPERUSER CREATEDB ENCRYPTED PASSWORD 'vagrant'"
Na versão disponível no repositório Debian do PostgreSQL, a porta usada é a 5433, diferente da versão do repositório do Ubuntu. Altere-a.
sudo sed -i "s/port = 5433/port = 5432/" /etc/postgresql/9.3/main/postgresql.conf
Se quiser alterar o binding do PostgreSQL para conectar através de uma interface gráfica, execute as linhas abaixo.
sudo tee -a /etc/postgresql/9.3/main/pg_hba.conf <<-TEXT
host all all 0.0.0.0/0 md5
TEXT
sudo sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" /etc/postgresql/9.3/main/postgresql.conf
Agora, reinicie o PostgreSQL.
sudo /etc/init.d/postgresql restart
Ativando a extensão de hstore
Você pode gerar uma migração para ativar esta extensão com o comando rails generate migration create_extension_hstore
. Esta extensão precisa ser ativada para cada banco que você criar.
class CreateExtensionHstore < ActiveRecord::Migration
def change
enable_extension "hstore"
end
end
NOTA: Após executar esta migração, verifique se esta chamada foi persistida no arquivo db/schema.rb
. Se ela não estiver no arquivo, atualize seu PostgreSQL conforme a seção anterior.
Criando seu campo hstore
Definir um novo campo usando o tipo hstore
é muito simples. Basta utilizar o método ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::ColumnMethods#hstore
.
class AddPreferencesOnUsers < ActiveRecord::Migration
def change
add_column :users, :preferences, :hstore
end
end
Se você vai fazer buscas nesse campo, é preciso definir o índice. Existem dois tipos de índices que podem ser usados: GiST e GIN.
Os índices GIN são três vezes mais rápidos para buscar, mas também demoram três vezes mais para serem indexados, além de ocuparem mais espaço. São melhores quando existem mais de 100 mil termos únicos.
Os índices GiST são mais lentos que GIN. Em compensação, são mais rápidos para serem indexados. Funcionam melhor quando existem menos de 100 mil termos únicos.
Para criar o índice, utilize a opção :using
.
class AddPreferencesIndexOnUsers < ActiveRecord::Migration
def change
add_index :orders, :payment_details, using: :gin
end
end
Agora basta identificar o tipo do campo no modelo.
class User < ActiveRecord::Base
store_accessor :preferences
end
Uma vez que seu modelo está configurado, você pode armazenar dados do tipo Hash
.
user = User.new
user.preferences = {
github: "fnando",
twitter: "fnando"
}
user.save!
user.reload
user.preferences[:twitter]
#=> fnando
Uma das desvantagens de usar o campo hstore
é que ele armazena apenas strings. Qualquer tipo diferente de String
será convertido na hora que o dado for armazenado.
user.preferences[:newsletter] = false
user.save!
user.reload
user.preferences[:newsletter].class
#=> String
Isso pode ser um problema, mas existe uma maneira simples de resolver. E para isso iremos usar a biblioteca Virtus.
Usando o Virtus
Virtus é uma biblioteca que permite definir atributos e especificar o tipo desses atributos. Primeiro vamos criar uma classe chamada UserPreferences
.
class UserPreferences
include Virtus.model
attribute :github, String
attribute :twitter, String
attribute :newsletter, Boolean
end
Uma das vantagens do Virtus é que ele faz todo o trabalho “sujo”, incluindo implementar o método UserPreferences#initialize
de modo que ele faça o mass-assignment. E durante esse processo de inicialização ele também faz a coerção dos tipos.
preferences = UserPreferences.new(user.preferences)
preferences.newsletter.class
#=> FalseClass
A única coisa chata é que você precisa instanciar a classe UserPreferences
sempre que quiser acessar os dados. A não ser que exista alguma maneira…
…e existe! Em vez de usar o método ActiveRecord::Base.store_accessor
, podemos definir um serializer personalizado que faz todo o processo de serialização. Altere a classe User
para usar a classe UserPreferences
como serializer.
class User < ActiveRecord::Base
serialize :preferences, UserPreferences
end
O Rails exige que o serializer implemente apenas os métodos dump
e load
. O método dump
será chamado sempre que o objeto precisar ser persistido no banco de dados. No nosso caso, iremos retornar um Hash
. Já o método load
receberá o Hash
retornado pelo campo hstore
.
class UserPreferences
include Virtus.model
attribute :github, String
attribute :twitter, String
attribute :newsletter, Boolean
def self.dump(preferences)
preferences.to_hash
end
def self.load(preferences)
new(preferences)
end
end
Agora, vamos ver se isso funciona!
user = User.first
user.preferences.class
#=> UserPreferences
user.preferences.newsletter = true
user.save!
user.reload
user.preferences.newsletter.class
#=> TrueClass
Você deve estar perguntando porque não fazer a serialização como JSON ou algo do tipo. A grande vantagem de usar o campo hstore
é que você pode consultar os valores armazenados.
Realizando consultas no campo hstore
Para encontrar todos os usuários que possuem a chave github
:
User.where("preferences ? :key", key: "github")
Para encontrar todos os usuários que não possuem a chave github
:
User.where("not preferences ? :key", key: "github")
Para encontrar o usuário que possui o perfil fnando
no Github:
User.where("preferences @> hstore(:key, :value)",
key: "github", value: "fnando"
).first
Para encontrar os usuários cujo perfil do Twitter possuam a substring nan
:
User.where("preferences -> :key LIKE :value",
key: "twitter", value: "%nan%"
)
Note que apenas os operadores @>
, ?
, ?&
e ?|
irão utilizar o índice que você criou. Por isso, evite fazer consultas como a que mostrei no último exemplo, já que isso pode ser muito lento dependendo de quantos registros você tiver.
Existem muitos outros operadores disponíveis. Para ver uma lista completa, acesse a documentação.
Finalizando
O hstore substituiu quase todos os usos que eu tinha para MongoDB, sem perder o poder do banco de dados relacional. Se você tem dúvidas sobre a performance do MongoDB vs PostgreSQL, não deixe de ver esse slidedeck (sim, benchmarks não servem de muita coisa, mas ainda assim vale dar uma lida).