Go to English Blog

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