Entendendo os contadores no Ruby on Rails

02/12/08

Aparentemente, os counters são as coisas que mais geram confusão para quem está começando no Ruby on Rails. Afinal, existem diversas maneiras de se obter o total de itens de uma coleção. Imagine que você possua dois modelos:

class User < ActiveRecord::Base
  has_many :things
end
 
class Thing < ActiveRecord::Base
  belongs_to :user
end

Vamos popular o banco de dados com alguns registros. À partir do console, execute os comandos abaixo.

user = User.create
100.times { user.items.create }

A primeira maneira e que poderá causar problemas é carregando todos os itens da coleção em memória, para depois usar o método size. Ela é ruim se você possuir uma base de dados com muitos registros. Pensando bem, ela é ruim sempre!

user.things.all.size
# => SELECT * FROM "things" WHERE ("things".user_id = 1)

Você também pode utilizar o método length. Ele funciona como o método acima, só que de maneira mais automática.

user.things.length
# => SELECT * FROM "things" WHERE ("things".user_id = 1)

Outra maneira é utilizar o método size diretamente na associação. Ela irá gerar uma consulta SQL para fazer a contagem.

user.things.size
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1)

Assim como o método size, o método count também irá gerar uma consulta SQL para fazer a consulta. A diferença é que você pode enviar condições, já que o método size não permite que você faça isso.

user.things.count
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1) 
 
user.things.count(:conditions => ["created_at > ?", 2.days.ago])
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1 AND (created_at > '2008-11-30 10:26:05'))

Estamos no caminho. A saída, pelos exemplos acima, é utilizar sempre o método count. Uma grande desvantagem, no entanto, é que isso trará problemas de performance quando você possuir uma base de milhões de registros fazendo counts para todo lado. Para nossa sorte, o Rails possui um recurso chamado counter cache.

O counter cache nada mais é que um campo na tabela que irá refletir o total de itens de um relacionamento, evitando uma consulta SQL para isso. Toda vez que um registro é criado, este campo é incrementado; quando um registro é destruído, esse campo é decrementado. A única desvatangem do counter cache é que ele só pode ser utilizado em relacionamentos.

Para adicionar um counter cache, basta adicionar um campo que segue o padrão [associação]_count. Lembre-se de atualizar a contagem após adicionar o campo.

class AddThingsCountToUser < ActiveRecord::Migration
  def self.up
    add_column :users, :things_count, :integer, :default => 0, :null => false
 
    User.all.each do |user|
      User.update_counters user.id, :things_count => user.things.count
    end
  end
 
  def self.down
    remove_column :users, :things_count
  end
end

Para que o Rails saiba que este campo existe, você deve adicionar a opção :counter_cache => true.

class Thing < ActiveRecord::Base
  belongs_to :user, :counter_cache => true
end

Agora, toda vez que um registro for criado ou destruído, o Rails irá incrementar/decrementar este campo automaticamente. E toda vez que você utilizar o método count, ele irá utilizar o valor armazenado neste campo. Vamos criar um novo registro para ver a consulta gerada:

user.things.create
# => INSERT INTO "things" ("updated_at", "user_id", "created_at") VALUES('2008-12-02 10:59:25', 1, '2008-12-02 10:59:25')
# => UPDATE "users" SET "things_count" = COALESCE("things_count", 0) + 1 WHERE ("id" = 1)

Para saber quantas coisas um usuário possui, você deve utilizar o método size. Se você utilizar o método count, a consulta será feita diretamente no banco. Na dúvida, utilize o método things_count.

user.things.size
# => 101
 
user.things.count
# => 101
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1)
 
user.things_count
# => 101

Se precisa trabalhar com contadores personalizados (que dependem de condições para serem incrementados ou não), pode utilizar callbacks no seu modelo juntamente com os métodos increment_counter e decrement_counter. Imagine que o modelo User possua um contador chamado cool_things_count que refletirá o total de registros com o atributo kind com o valor cool.

class Thing < ActiveRecord::Base
  belongs_to :user, :counter_cache => true
 
  after_save :increment_cool_things_count
  after_destroy :decrement_cool_things_count
 
  private
    def increment_cool_things_count
      User.increment_counter(:cool_things_count, user_id) if kind == 'cool'
    end
 
    def decrement_cool_things_count
      User.decrement_counter(:cool_things_count, user_id) if kind == 'cool'
    end
end

É importante notar que você não pode usar nenhum relacionamento quando estiver tratando o callback after_destroy, já que o objeto não existe mais.

Criando seu primeiro projeto com Merb e DataMapper

21/11/08

No começo do mês foi lançada a versão 1.0 do Merb. Esta versão trouxe a facilidade de instalação que faltava nas versões anteriores, permitindo configurar o ambiente de maneira bastante simples.

Merb é um framework web para Ruby totalmente agnóstico. Você escolher o seu ORM de preferência, seu framework de testes, bibliotecas Javascript. Totalmente modular, permite carregar somente o que será necessário para criar sua aplicação.

Eu já tinha tentado escrever sobre ele em ocasiões anteriores, sem sucesso. Agora, resolvi colocar a coisa para funcionar e, para minha surpresa, foi mais simples do que eu esperava, como você pode conferir.

Todo o código está no Github: http://github.com/fnando/merb-contacts-app; diverta-se!

Instalando o Merb

A maneira mais fácil de instalar o Merb é utilizando o pacote completo.

sudo gem install merb

Esta gem irá instalar muitas dependências e pode demorar um pouco!

Gerando a estrutura do aplicativo

Para criar um novo aplicativo, você utilizar o comando merb-gen.

$ merb-gen app contacts
Generating with app generator:
     [ADDED]  tasks/merb.thor
     [ADDED]  .gitignore
     [ADDED]  public/.htaccess
     [ADDED]  tasks/doc.thor
     [ADDED]  public/javascripts/jquery.js
     [ADDED]  doc/rdoc/generators/merb_generator.rb
     [ADDED]  doc/rdoc/generators/template/merb/api_grease.js
     [ADDED]  doc/rdoc/generators/template/merb/index.html.erb
     [ADDED]  doc/rdoc/generators/template/merb/merb.css
     [ADDED]  doc/rdoc/generators/template/merb/merb.rb
     [ADDED]  doc/rdoc/generators/template/merb/merb_doc_styles.css
     [ADDED]  doc/rdoc/generators/template/merb/prototype.js
     [ADDED]  public/favicon.ico
     [ADDED]  public/images/merb.jpg
     [ADDED]  public/merb.fcgi
     [ADDED]  public/robots.txt
     [ADDED]  Rakefile
     [ADDED]  app/controllers/application.rb
     [ADDED]  app/controllers/exceptions.rb
     [ADDED]  app/helpers/global_helpers.rb
     [ADDED]  app/models/user.rb
     [ADDED]  app/views/exceptions/not_acceptable.html.erb
     [ADDED]  app/views/exceptions/not_found.html.erb
     [ADDED]  autotest/discover.rb
     [ADDED]  autotest/merb.rb
     [ADDED]  autotest/merb_rspec.rb
     [ADDED]  config/database.yml
     [ADDED]  config/dependencies.rb
     [ADDED]  config/environments/development.rb
     [ADDED]  config/environments/production.rb
     [ADDED]  config/environments/rake.rb
     [ADDED]  config/environments/staging.rb
     [ADDED]  config/environments/test.rb
     [ADDED]  config/init.rb
     [ADDED]  config/rack.rb
     [ADDED]  config/router.rb
     [ADDED]  public/javascripts/application.js
     [ADDED]  public/stylesheets/master.css
     [ADDED]  merb/merb-auth/setup.rb
     [ADDED]  merb/merb-auth/strategies.rb
     [ADDED]  merb/session/session.rb
     [ADDED]  spec
     [ADDED]  gems
     [ADDED]  app/views/layout/application.html.erb

Por padrão, o Merb vem configurado para utilizar o RSpec como framework de testes e o DataMapper como ORM. Ao contrário do Rails, você pode utilizar outros frameworks transparentemente.

Nota sobre o DataMapper: Se você armazena objetos no Memcache, o DataMapper pode não ser o ORM mais adequado. Acontece que seus objetos não podem passar pelo Marshal, devido à maneira como o DataMapper foi implementado. Para acompanhar os detalhes deste problema, veja este ticket no Lighthouse.

Para iniciar o servidor de desenvolvimento, basta digitar o comando merb à partir da raíz de seu projeto. Ele responderá na porta 4000.

$ merb
Loading init file from /Users/fnando/Sites/contacts/config/init.rb
Loading /Users/fnando/Sites/contacts/config/environments/development.rb
 ~ Connecting to database...
 ~ Loaded slice 'MerbAuthSlicePassword' ...
 ~ Parent pid: 21182
 ~ Compiling routes...
 ~ Activating slice 'MerbAuthSlicePassword' ...
merb : worker (port 4000) ~ Starting Mongrel at port 4000
merb : worker (port 4000) ~ Successfully bound to port 4000

Ao acessar o endereço http://localhost:4000, você verá uma página como esta:

Página inicial do Merb

Agora que já geramos a estrutura de nosso projeto, vamos fazer algo real!

Criando nosso aplicativo

Já deu para perceber que quase todos os artigos de frameworks web envolvem blogs e listas de tarefas? Para provar esta regra, seremos a exceção. Vamos criar um aplicativo onde os usuários podem se cadastrar e adicionar seus contatos. Será uma versão simplificada de uma agenda de contatos.

O primeiro passo é criar a área de registro e autenticação de usuários. O Merb possui um pacote chamado MerbAuth, um framework de autenticação simples, porém completo. Ele permite criar estratégias de autenticação, visto que cada aplicação pode possuir diferentes tipos de autententicação (HTTP, formulário, OpenID, Google Account, etc).

A classe User é criada por padrão quando um aplicativo é gerado. Abra o arquivo "app/models/user.rb".

class User
  include DataMapper::Resource
 
  property :id,     Serial
  property :login,  String
 
end

Nossa classe não possui nenhuma validação. Pelo menos é isso que aparenta o código acima. Na verdade, o MerbAuth já faz algumas validações como verificar a presença da senha e se a confirmação de senha está correta. Você provavelmente fará algumas validações adicionais para se certificar que o login é único e possui um tamanho mínimo.

class User
  include DataMapper::Resource
 
  property :id,     Serial
  property :login,  String
 
  validates_is_unique :login
  validates_length :login, :within => 3..30
  validates_length :password, :min => 4, :if => :password_required?
end

O MerbAuth também adiciona o método password_required?, que permite verificar se a senha é necessária ou não.

Vamos fazer alguns testes no console. Para abrir o console, execute o comando abaixo:

merb -i

Será preciso instalar o Webrat, que pode ser feito com o comando sudo gem install webrat.

Assim que você estiver no console, execute o comando DataMapper.auto_migrate! para executar a migração de seu banco de dados.

>> DataMapper.auto_migrate!
 ~ DROP TABLE IF EXISTS "sessions"
 ~ DROP TABLE IF EXISTS "users"
 ~ PRAGMA table_info('users')
 ~ SELECT sqlite_version(*)
 ~ CREATE TABLE "users" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "login" VARCHAR(50), "crypted_password" VARCHAR(50), "salt" VARCHAR(50))
 ~ PRAGMA table_info('sessions')
 ~ CREATE TABLE "sessions" ("session_id" VARCHAR(32) NOT NULL, "data" TEXT DEFAULT 'BAh7AA== ', "created_at" DATETIME, PRIMARY KEY("session_id"))
=> [User, Merb::DataMapperSessionStore]

Você também pode executar as migrações rodado a tarefa rake db:automigrate à partir da linha de comando.

Para criar um usuário, você pode chamar o método create, semelhante ao ActiveRecord.

>> User.create :login => 'fnando', :password => 'test', :password_confirmation => 'test'
 ~ SELECT "id" FROM "users" WHERE ("login" = 'fnando') ORDER BY "id" LIMIT 1
 ~ INSERT INTO "users" ("crypted_password", "login", "salt") VALUES ('769bd718b654e5da69a79d1dc34a7ab5f8dea58b', 'fnando', '050f1d293dc86e105ed1e0cdada6b5802e71dc85')
=> #<User id=1 login="fnando" crypted_password="769bd718b654e5da69a79d1dc34a7ab5f8dea58b" salt="050f1d293dc86e105ed1e0cdada6b5802e71dc85">

Ótimo! Já conseguimos criar um usuário através do console. Agora, podemos implementar uma interface para fazermos isso de maneira mais agradável. Vamos criar nosso controller users, que será responsável por esta tarefa. Execute o comando merb-gen controller users.

$ merb-gen controller users
Loading init file from /Users/fnando/Sites/contacts/config/init.rb
Loading /Users/fnando/Sites/contacts/config/environments/development.rb
Generating with controller generator:
Loading init file from /Users/fnando/Sites/contacts/config/init.rb
Loading /Users/fnando/Sites/contacts/config/environments/development.rb
     [ADDED]  app/controllers/users.rb
     [ADDED]  app/views/users/index.html.erb
     [ADDED]  spec/requests/users_spec.rb
     [ADDED]  app/helpers/users_helper.rb
 
Don't forget to add request/controller tests first.

Vamos alterar nossas rotas, adicionando o recurso (resource) de usuários. Abra o arquivo "config/router.rb" e adicione a linha resources :users.

Merb.logger.info("Compiling routes...")
Merb::Router.prepare do
  resources :users
 
  slice(:merb_auth_slice_password, :name_prefix => nil, :path_prefix => "")
  default_routes
end

Ao contrário do Rails, o Merb não nomeia os controllers como "_controller.rb". Abra o arquivo "app/controllers/users.rb". Ele é parecido com isto:

class Users < Application
  # ...and remember, everything returned from an action
  # goes to the client...
  def index
    render
  end
end

Como o comentário diz, tudo o que for retornado de uma ação será renderizado ao cliente. Sendo assim, se tivermos uma ação como o exemplo abaixo, a string "Yay! I'm alive!" será exibida. Particularmente, prefiro a maneira como isso é feito no Rails, já que o conteúdo que será renderizado fica explícito (a precedência é do render, depois é a view, etc).

class Users < Application
  def index
    "Yay! I'm alive!"
  end
end

Acesse o endereço "http://localhost:4000/users" e você verá uma página muito simples sendo renderizada.

Página padrão de usuários

Vamos melhorar as coisas. Primeiro, substituia o código do arquivo "app/views/layouts/application.html.erb" pelo abaixo.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
 
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-us" lang="en-us">
<head>
  <title>Fresh Merb App</title>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  <link rel="stylesheet" href="/stylesheets/master.css" type="text/css" media="screen" />
</head>
 
<body>
<div id="page">
  <div id="header">
    <h1>Contacts</h1>
  </div>
 
  <div id="content">
    <div id="main">
      <%= all_messages %>
      <%= catch_content :for_layout %>
    </div>
  </div>
</div>
</body>
</html>

Iremos precisar do helper all_messages, que exibirá todas as mensagens do tipo flash messages. Para adicioná-lo, abra o arquivo "app/helpers/global_helpers.rb".

module Merb
  module GlobalHelpers
    def all_messages
      message.collect {|type, text| tag(:p, text, :class => "message #{type}") }.join
    end
  end
end

Agora, para estilizar a página, substitua o conteúdo do arquivo "public/stylesheets/master.css" por este:

* {
  margin: 0;
  padding: 0;
}
 
body {
  background: #727272 url(../images/bg.png) repeat-x;
  font-family: "Lucida Grande", Arial;
  font-size: 13px;
  padding: 20px;
}
 
#page {
  background: #fff;
  border: 1px solid #666;
  margin: 0 auto;
  padding: 20px;
  width: 410px;
  -webkit-box-shadow: 2px 2px #444;
}
 
h1, h2, h3 {
  font-family: "Arial Rounded MT Bold", Arial;
}
 
h1 {
  color: #03c;
}
 
h2 {
  color: #777;
  font-size: 20px;
}
 
p, ul {
  padding: 10px 0;
}
 
ul {
  padding-left: 30px;
}
 
label {
  display: block;
  font-size: 14px;
  padding-bottom: 3px;
}
 
label span {
  color: #999;
}
 
input[type=text],
input[type=password] {
  font-size: 18px;
  padding: 5px;
  width: 390px;
}
 
a {
  color: #03c;
}
 
a:hover {
  color: #900;
}
 
p.submit a {
  color: #f00;
  padding: 5px;
}
 
p.submit a:hover {
  background: #f00;
  color: #fff;
}
 
/* error message */
div.error {
  background: #fcc url(../images/error.png) no-repeat 15px 15px;
  margin: 15px 0 25px;
  min-height: 50px;
  padding: 15px 15px 15px 70px;
  -webkit-border-radius: 10px;
  -moz-border-radius: 10px;
}
 
div.error h2 {
  color: #900;
  font-size: 14px;
  padding-bottom: 5px;
}
 
div.error li {
  font-size: 12px;
  padding-bottom: 5px;
}
 
div.error ul {
  padding-left: 15px;
}
 
p.message {
  background-repeat: no-repeat;
  background-position: 20px center;
  font-size: 15px;
  margin: 20px 0;
  padding: 15px 15px 15px 60px;
  -moz-border-radius: 10px;
  -webkit-border-radius: 10px;
}
 
p.notice {
  background-color: #E2F9E3;
  background-image: url(../images/ok.png);
  color: #0E6F0E;
}
 
p.error {
  background-color: #fcc;
  background-image: url(../images/error.png);
  color: #c00;
}
 
p.warning {
  background-color: #ffc;
  background-image: url(../images/warning.png);
  color: #333;
}
 
/* highlight */
p.highlight {
  background: #FFE640;
  margin-top: 15px;
  text-align: center;
}
 
p.highlight a {
  color: #900;
  font-weight: bold;
  font-size: 18px;
}
 
/* header */
#header {
  position: relative;
}
 
p.login-info {
  background: #ddd;
  font-size: 10px;
  padding: 5px;
  position: absolute;
  right: 0;
  top: 0;
 
  -webkit-border-radius: 6px;
  -moz-border-radius: 6px;
}
 
/* vcard */
#contacts {
  padding-top: 10px;
}
 
.vcard {
  background: #e9e9e9;
  border: 1px solid #ddd;
  font-size: 11px;
  margin-bottom: 15px;
  padding: 10px 10px 20px 60px;
  position: relative;
  -webkit-border-radius: 10px;
  -moz-border-radius: 10px;
}
 
img.photo {
  left: 10px;
  position: absolute;
}
 
.vcard span {
  font-weight: bold;
}
 
.vcard span.email,
.vcard span.street-address,
.vcard span.organization-name {
  font-weight: normal;
}
 
.vcard .type:after,
.vcard .label:after {
  content: ":";
}
 
.vcard .email-group .label {
  content: url(../images/mail.gif);
}
 
.vcard .adr .type {
  content: url(../images/home.gif);
}
 
.vcard .mobile .type {
  content: url(../images/mobile.gif);
}
 
.vcard .phone .type {
  content: url(../images/phone.gif);
}
 
.vcard .org .label {
  content: url(../images/globe.gif);
}
 
.vcard h3 {
  font-size: 16px;
}
 
.vcard div {
  padding-bottom: 3px;
}
 
.vcard .actions {
  display: none;
  padding: 0;
}
 
.vcard:hover .actions {
  display: block;
}
 
.vcard .actions form {
  bottom: 5px;
  cursor: pointer;
  display: inline;
  position: absolute;
  right: 5px;
}
 
.vcard .actions input {
  background: none;
  border: none;
  color: #333;
  cursor: pointer;
}
 
.vcard .actions a {
  bottom: 5px;
  color: #333;
  position: absolute;
  right: 50px;
  text-decoration: none;
}
 
.vcard .actions input,
.vcard .actions a {
  font-size: 11px;
}
 
.vcard .actions form:before {
  content: " | ";
}

Voltando ao nosso aplicativo… Nós não iremos listar os usuários que se cadastraram em nosso serviço. Renomeie esta ação para new. Nós precisamos instanciar um objeto User. Só que em vez de usar o método render utilizamos um outro, o método display.

class Users < Application
  def new
    @user = User.new
    display @user
  end
end

O Merb tenta seguir o protocolo HTTP até onde pode. Assim, toda URL representa um recurso e deve ser exibido de acordo com a requisição. O método display irá detectar o tipo de resposta esperado (XML, Javascript, JSON, etc.) e automaticamente irá chamar o método correspondente (to_json, to_xml, etc), caso ele tenha sido configurado para responder diferentes tipos de requisição.

Agora é a hora de criar nossa view. Crie o arquivo "app/views/users/new.html.erb" e adicione o conteúdo abaixo. Ele é bastante semelhante a um formulário do Rails.

<h2>Create your account</h2>
 
<p>
  Signup for a account! It's free. And it's quick.
</p>
 
<%= error_messages_for @user %>
 
<%= form_for @user, :action => url(:users) do %>
  <p>
    <%= text_field :login, :label => "Login: " %>
  </p>
 
  <p>
    <%= password_field :password, :label => "Password: " %>
  </p>
 
  <p>
    <%= password_field :password_confirmation, :label => "Password Confirmation: " %>
  </p>
 
  <p class="submit">
    <%= submit "Sign up" %>
  </p>
<% end =%>

Você precisa ficar atento a duas coisas: você deve utilizar abrir o bloco com <%= %> e fechá-lo com <% =%>. Se você não fizer isso, saberá, já que uma exceção referente a sintaxe inválida será lançada.

Temos que criar a ação que receberá os dados enviados pelo usuário. Ela irá se chamar create e também é bem simples e muito parecida com o que fazemos no Rails.

def create(user)
  @user = User.new(user)
 
  if @user.save
    message[:notice] = "Welcome to Contacts! Please login to access your account."
    redirect url(:session, :new), :message => message
  else
    render :new
  end
end

Uma coisa que você pode estar estranhando é o argumento passado ao método create. A gem merb-action-args é responsável por esta funcionalidade, que retorna para o método os parâmetros enviados na requisição e que estão disponíveis no método params. A única obrigatoriedade é que você precisa utilizar o mesmo nome do parâmetro como nome da variável.

Outra diferença em relação ao Rails, é a forma como uma flash message é definida no Merb; você especifica mensagem no próprio redirecionamento. Note que ao usar o MerbAuth, uma string horrível é enviada na hora do redirecionamento. Será algo como

http://localhost:4000/session/new?_message=BAh7BjoLbm90aWNlIj5XZWxjb21lIHRvI[...]

Ao enviar um formulário com erros, uma tela como esta será exibida:

Formulário com mensagem de erro

Agora, os usuários cadastrados precisam acessar nosso aplicativo. O MerbAuth já possui uma tela de login implementada. Você pode se autenticar no endereço "http://localhost:4000/login". Como você dificilmente vai utilizar a tela padrão, vamos criar nossa própria tela. Para isso, crie um novo arquivo em "app/views/exceptions/unauthorized.html.erb". Coloque o seguinte conteúdo:

<h2>Access your account</h2>
 
<p>
  If you don't have an account yet, <%= link_to "signup for free!", url(:new_user) %>
</p>
 
<%= error_messages_for session.authentication %>
 
<%= form :action => url(:login), :method => :put do %>
  <p>
    <%= text_field :login, :label => "Login: ", :id => 'login' %>
  </p>
 
  <p>
    <%= password_field :password, :label => "Password: ", :id => 'password' %>
  </p>
 
  <p class="submit">
    <%= submit "Log me in" %>
  </p>
<% end =%>

Perceba que o método error_messages_for recebe o objeto session.authentication. Ele possui diversos métodos que serão usados ao longo deste artigo.

Quando um usuário não consegue se autenticar, uma mensagem é exibida.

Erro de autenticação

Se você conseguir se autenticar, verá uma mensagem de erro, já que a página que você foi redirecionado ainda não foi criada. Vamos criar o controller home com o comando merb-gen controller home. Altere a view "app/views/home/index.html.erb", adicionando o conteúdo abaixo.

<h2>Welcome to Contacts!</h2>
 
<p>
  Contacts provides a flexible and convenient way to store contact 
  information for family, friends, and colleagues online.
</p>
 
<ul>
  <li>Keep contact info centralized, sharable, and safe online.</li>
  <li>It's free. And it's simple.</li>
</ul>
 
<p class="highlight">
  <%= link_to 'Signup for free', url(:new_user) %> or
  <%= link_to 'Access your account', url(:login) %>
  <br/>
  Signup and be using Contacts in less than 30 seconds!
</p>

Precisamos diferenciar usuários logados de não-logados. Vamos exibir uma mensagem com um link para fazer o logout. Altere o layout "app/views/layouts/application.html.erb", adicionando o código abaixo.

<div id="header">
  <h1>Contacts</h1>
 
  <% if session.authenticated? %>
    <p class="login-info">
      Hi, <%= current_user.login %> | 
      <%= link_to 'Logout', url(:logout) %>
    </p>
  <% end %>
</div>

O helper current_user deve ser adicionado ao arquivo "app/controllers/application.rb", tornando o método disponível tanto para o controller quanto para as views. Ao contrário do Rails, você não precisa definir um helper com o método helper_method.

class Application < Merb::Controller
  private
    def current_user
      session.authentication.user
    end
end

A página inicial pode ser modificada para exibir uma mensagem diferente para usuários logados. Altere o arquivo "app/views/home/index.html.erb", adicionando uma condição que verifica a autenticação.

<h2>Welcome to Contacts!</h2>
 
<p>
  Contacts provides a flexible and convenient way to store contact 
  information for family, friends, and colleagues online.
</p>
 
<ul>
  <li>Keep contact info centralized, sharable, and safe online.</li>
  <li>It's free. And it's simple.</li>
</ul>
 
<p class="highlight">
  <% if session.authenticated? %>
    <%= link_to 'View your contacts', url(:contacts) %> or
    <%= link_to 'Create one', url(:new_contact) %>
    <br />
    It's so simple to manage your contacts. Check it out!
  <% else %>
    <%= link_to 'Signup for free', url(:new_user) %> or
    <%= link_to 'Access your account', url(:login) %>
    <br/>
    Signup and be using Contacts in less than 30 seconds!
  <% end %>
</p>

Se você tentar acessar a página inicial, verá que ela deixou de funcionar. O erro foi gerado porque não temos um recurso contacts criado e a rota para ele ainda não existe. Abra o arquivo "config/router.rb" e adicione-o.

Merb.logger.info("Compiling routes...")
Merb::Router.prepare do
  resources :users
  resources :session
  resources :contacts
 
  slice(:merb_auth_slice_password, :name_prefix => nil, :path_prefix => "")
 
  match('/signup').to(:controller => 'users', :action =>'new')
  match('/').to(:controller => 'home', :action =>'index')
 
  default_routes
end

Agora, já podemos criar o modelo Contact; execute o comando merb-gen model Contact. Abra o arquivo "app/models/contact.rb" e adicione a estrutura abaixo.

class Contact
  include DataMapper::Resource
 
  property :id, Serial
  property :name, String
  property :company, String
  property :email, String 
  property :phone, String
  property :mobile, String
  property :address, String
end

À partir do terminal, execute o comando rake db:autoupgrade para criar a tabela.

ATENÇÃO: Se você executar o comando rake db:automigrate, as tabelas serão recriadas e os dados apagados! Utilize SEMPRE o comando rake db:autoupgrade quando não quiser recriar suas tabelas. Com certeza rake db:reset seria um nome muito melhor.

Agora, iremos aplicar algumas validações ao nosso modelo.

  • O nome é um campo obrigatório.
  • O e-mail, quando informado, deve ser válido.

Em vez de usarmos os métodos validates_*, vamos usar um recurso muito interessante do DataMapper chamado validação implícita. Basta adicionar alguns parâmetros ao método property que a validação será feita automaticamente.

class Contact
  include DataMapper::Resource
 
  property :id, Serial
  property :name, String, :nullable => false
  property :company, String
  property :email, String, :format => :email_address
  property :phone, String
  property :mobile, String
  property :address, String
end

A opção :format aceita um único formato :email_address; segundo a documentação, mais formatos serão implementados.

Agora, precisamos adicionar os relacionamentos. Um usuário possui muitos contatos, um contato pertence a um usuário. No modelo User devemos adicionar o método has n:

class User
  include DataMapper::Resource
 
  property :id,     Serial
  property :login,  String
 
  validates_is_unique :login
  validates_length :login, :within => 3..30
  validates_length :password, :min => 4, :if => :password_required?
 
  has n, :contacts
end

No modelo Contact devemos adicionar o método belongs_to:

class Contact
  include DataMapper::Resource
 
  property :id, Serial
  property :name, String, :nullable => false
  property :company, String
  property :email, String, :format => :email_address
  property :phone, String
  property :mobile, String
  property :address, String
 
  belongs_to :user
end

Novamente, execute o comando rake db:autoupgrade.

Agora que nosso modelo está pronto, podemos partir para o controller. Crie-o com o comando merb-gen controller contacts.

Nossa primeira ação será new, que irá exibir o formulário de contatos.

class Contacts < Application
  def new
    @contact = Contact.new
    display @contact
  end
end

Vamos criar a view que permitirá adicionar novos contatos. Crie o arquivo "app/views/contacts/new.html.erb" e adicione o conteúdo abaixo.

<h2>Create a new contact</h2>
 
<%= error_messages_for @contact %>
 
<%= form_for @contact, :action => url(:contacts) do %>
  <%= partial :form %>
 
  <p class="submit">
    <%= submit "Add contact" %> or
    <%= link_to "Cancel", url(:contacts) %>
  </p>
<% end =%>

No Merb, partials podem ser usadas através do método partial. Ele pode receber um símbolo ou uma string. Se você fornecer um símbolo, o arquivo será procurado no diretório da view corrente. Se você fornecer uma string como "shared/sidebar", pode utilizar outros diretórios. No nosso caso, temos que criar um arquivo em "app/views/contacts/_form.html.erb".

<p>
  <%= text_field :name, :label => "Name: " %>
</p>
 
<p>
  <%= text_field :email, :label => "E-mail: <span>(optional)</span>" %>
</p>
 
<p>
  <%= text_field :company, :label => "Company: <span>(optional)</span>" %>
</p>
 
<p>
  <%= text_field :mobile, :label => "Mobile: <span>(optional)</span>" %>
</p>
 
<p>
  <%= text_field :phone, :label => "Phone: <span>(optional)</span>" %>
</p>
 
<p>
  <%= text_field :address, :label => "Address: <span>(optional)</span>" %>
</p>

Nosso formulário será submetido para a ação create.

def create(contact)
  @contact = current_user.contacts.build(contact)
 
  if @contact.save
    message[:notice] = "A new contact has been added!"
    redirect url(:contacts), :message => message
  else
    render :new
  end
end

Ao acessar o endereço "http://localhost:4000/contacts/new", você verá uma tela como esta:

Tela de novos contatos

Quando você criar um contato, será redirecionado para a listagem de contatos. Como ela ainda não foi criada, você verá a página de erro. Adicione a ação index ao controller.

def index
  @contacts = current_user.contacts.sorted
  display @contacts
end

Na ação index estamos listando todos os contatos do usuário logado, ordenados alfabeticamente. O método sorted é um named scope e deve ser adicionado ao seu modelo Contact; basta adicionar um método de classe com as suas definições.

class Contact
  include DataMapper::Resource
 
  property :id, Serial
  property :name, String, :nullable => false
  property :company, String
  property :email, String, :format => :email_address
  property :phone, String
  property :mobile, String
  property :address, String
 
  belongs_to :user
 
  def self.sorted
    all :order => [:name.asc]
  end
end

O DataMapper adiciona diversos métodos à classe Symbol, permitindo coisas como :name.asc, :title.like, dentre outros.

Agora precisamos criar a view index. Crie o arquivo "app/views/contacts/index.html.erb".

<h2>Your contacts</h2>
 
<p>
  <%= link_to "Add contact", url(:new_contact) %>
</p>
 
<div id="contacts">
  <% unless @contacts.empty? %>
    <%= partial :contact, :with => @contacts %>
  <% else %>
    <p>You have no contacts yet!</p>
  <% end %>
</div>

A linha que renderiza a coleção @contacts na partial contact é semelhante a render :partial => 'contact', :collection => @contacts, presente no Rails. Adicione o conteúdo abaixo ao um novo arquivo em "app/views/contacts/_contact.html.erb":

<div class="vcard">
  <%= photo_tag contact.email %>
 
  <h3 class="n">
    <%= contact.name %>
  </h3>
 
  <div class="email-group">
    <span class="label">Email</span> 
    <span class="email"><%= link_to h(contact.email), "mailto:#{h contact.email}" %></span>
  </div>
 
  <% unless contact.company.blank? %>
    <div class="org">
      <span class="label">Company</span>
      <span class="organization-name"><%= h contact.company %></span>
    </div>
  <% end %>
 
  <% unless contact.address.blank? %>
    <div class="adr">
      <span class="type">Home</span>
      <span class="street-address"><%= h contact.address %></span>
    </div>
  <% end %>
 
  <% unless contact.mobile.blank? %>  
    <div class="tel mobile">
      <span class="type">Mobile</span> 
      <%= h contact.mobile %>
    </div>
  <% end %>
 
  <% unless contact.phone.blank? %>  
    <div class="tel phone">
      <span class="type">Phone</span> 
      <%= h contact.phone %>
    </div>
  <% end %>
 
  <div class="actions">
    <%= delete_button contact %> 
    <%= link_to "Edit", url(:edit_contact, contact) %>
  </div>
</div>

Vamos analisar dois pontos importantes desta partial individualmente. Primeiro, temos um helper photo_tag, que irá montar a URL para o Gravatar à partir do e-mail do contato. Adicione este método ao arquivo "app/helpers/global_helpers.rb".

def photo_tag(email)
  info = [
    Digest::MD5.hexdigest(email), # => hash
    36, # => size
    'http%3A%2F%2Ff.simplesideias.com.br%2Fgravatar.gif' # => default gravatar
  ]
 
  src = "http://www.gravatar.com/avatar/%s?s=%s&amp;r=g&amp;d=%s" % info
 
  '<img src="%s" alt="" class="photo" />' % src
end

O outro ponto é o helper delete_button, que irá criar um formulário com método DESTROY para o recurso contact. Ele é semelhante ao button_to do Rails (button_to 'Delete', :method => :destroy).

Pronto! A listagem de contatos já deve estar funcionando. Ela se parece com isto:

Tela de contatos

Agora, temos que adicionar a funcionalidade de editar um contato. Crie a ação edit, que irá retornar o objeto para o formulário.

def edit(id)
  @contact = current_user.contacts.get(id)
  raise NotFound unless @contact
 
  display @contact
end

Se o contato não for encontrado, a exceção NotFound será lançada e a página de 404 será exibida. O DataMapper não possui o método find; em vez dele, você usar o método get.

Nossa view não será muito diferente da que usamos para criar um novo contato. A única diferença importante é que o formulário será enviado através do método PUT.

<h2>Edit contact</h2>
 
<%= error_messages_for @contact %>
 
<%= form_for @contact, :action => url(:contact, @contact), :method => :put do %>
  <%= partial :form %>
 
  <p class="submit">
    <%= submit "Edit contact" %> or
    <%= link_to "Cancel", url(:contacts) %>
  </p>
<% end =%>

A ação responsável por salvar as alterações no contato é update.

def update(id, contact)
  @contact = current_user.contacts.get(id)
  raise NotFound unless @contact
 
  if @contact.update_attributes(contact)
    message[:notice] = "The contact has been updated!"
    redirect url(:contacts), :message => message
  else
    render :edit
  end
end

Novamente, buscamos o contato e exibimos a página de 404 se ele não for encontrado. Depois, atualizamos o contato com os dados enviados pelo formulário. Se o formulário não tiver nenhum erro é redirecionado para a listagem; caso contrário, o formulário é novamente renderizado.

Para finalizar, precisamos permitir que contatos sejam removidos. A ação que irá fazer este trabalho é destroy, que como você pode notar, também é simples e muito parecida com as ações edit e update.

def destroy(id)
  @contact = current_user.contacts.get(id)
  raise NotFound unless @contact
 
  if @contact.destroy
    message[:notice] = "The contact has been removed"
    redirect url(:contacts), :message => message
  else
    raise InternalServerError
  end
end

Não sei se você percebeu, mas ainda não falamos que o recurso contacts só pode ser acessado por usuários autenticados. Se você não estiver logado e tentar listar os contatos, verá uma página de erro.

O MerbAuth permite que você defina áreas restritas de duas maneiras diferentes. Você pode usar o método authenticate diretamente no arquivo "router.rb":

authenticate do
  resources :contacts
end

Ou pode utilizar o método ensure_authenticated no controller:

class Contacts < Application
  before :ensure_authenticated
 
  # ...
end

A vantagem do primeiro método é que você para o processamento bem antes da execução do filtro ensure_authenticated do controller. Porém, para utilizá-lo, você precisa proteger o recurso inteiro, não podendo especificar ações que são públicas. Sempre que puder utilize o método authenticate.

E é isso! O Contacts está quase pronto. Muitas outras coisas poderiam ser feitas. Você pode, como exercício, adicionar os testes (talvez eu escreva sobre isso em um outro artigo) e criar a página de atualização de senha do usuário logado.

Para finalizar…

O Ruby on Rails é excelente, mas você não deve se prender a um único framework. Conheça o Merb. Experimente o Sinatra, um framework muito rápido se você pretende disponibilizar APIs. Veja outras opções para não ficar limitado. E lembre-se: conhecimento nunca é demais!

Compactando Javascript e CSS antes de fazer um commit no Git

19/11/08

O Git tem alguns hooks que permitem fazer coisas bem interessantes. Eu, malandro que sou, escrevi um para compactar arquivos Javascript e CSS utilizando YUI Compressor, desenvolvido pelo Yahoo!.

Em seu repositório Git, crie o arquivo ".git/hooks/pre-commit" com o conteúdo abaixo:

#!/bin/bash
cd "$0/../../.."
rake git:precommit

Execute o comando chmod a+x .git/hooks/pre-commit. Se você não fizer isso, o Git não irá executar este hook. Ele irá executar a tarefa Rake git:precommit.

Acesse a Yahoo! Developer Network e baixe o YUI Compressor. É um arquivo "jar", o que significa que você precisa ter Java 1.4 ou superior instalado. Alternativamente, você pode executar os comandos abaixo à partir da raíz de seu projeto Ruby on Rails.

wget http://www.julienlecomte.net/yuicompressor/yuicompressor-2.4.2.zip -O yuicompressor.zip
unzip yuicompressor.zip
mkdir tools
cp yuicompressor-2.4.2/build/yuicompressor-2.4.2.jar tools/yuicompressor.jar
rm -rf yuicompressor-2.4.2/

Agora, podemos fazer todo o trabalho sujo utilizando código Ruby. Crie o arquivo lib/tasks/dev.rake e adicione o código abaixo:

require "config/environment"
 
def run_compressor(type, files, except=[])
  output_refs = {:css => "stylesheets", :js => "javascripts"}
  output_dir = "public/#{output_refs[type]}"
 
  files.each do |input|
    output_name = File.basename(input)
    output_name.gsub!(/\.(js|css)$/, "-min#{File.extname(output_name)}")
    output = "#{output_dir}/#{output_name}"
 
    system "java -jar tools/yuicompressor.jar --type=#{type} #{input} > #{output}"
 
    original = file_size(input)
    compressed = file_size(output)
 
    puts " - #{File.basename(input)} [#{original}] => #{output_name} [#{compressed}]"
  end
end
 
def file_size(file)
  ActionController::Base.helpers.number_to_human_size(File.size(file))
end
 
namespace :git do
  desc "Run before a Git commit"
  task :precommit do
    Rake::Task['compress:css'].invoke
    Rake::Task['compress:javascript'].invoke
  end
end
 
namespace :compress do
  desc "Compress all javascript files using YUI Compressor"
  task :javascript do
    puts "\nCompressing javascript files"
 
    files = Dir['public/javascripts/*.js'].reject do |f|
      f =~ /-min\.js$/ || f =~ /\/jquery\.js/
    end
 
    run_compressor(:js, files)
  end
 
  desc "Compress all stylesheet files using YUI Compressor"
  task :css do
    puts "\nCompressing stylesheet files"
 
    files = Dir['public/stylesheets/*.css'].reject do |f|
      f =~ /-min\.css$/
    end
 
    run_compressor(:css, files)
  end
end

É um código bastante simples. Ele simplesmente pega todos os arquivos Javascript e CSS que não possuem "-min" no nome e aplica a compactação. O arquivo "jquery.js" não é compactado pois sempre utilizado a versão reduzida.

Como saber se está funcionando? Faça um commit! Se os arquivos compactados foram gerados, tudo saiu como esperado. Você receberá uma saída como esta:

Compressing stylesheet files
 - application.css [15.1 KB] => application-min.css [12.3 KB]
 
Compressing javascript files
 - application.js [4.6 KB] => application-min.js [3.1 KB]
 - facebox.js [9.2 KB] => facebox-min.js [4.9 KB]
 - jquery.form.js [22.3 KB] => jquery.form-min.js [8.2 KB]
 - rails.js [1 KB] => rails-min.js [466 Bytes]

Eu estou utilizando dois helpers que exibem a versão original dos arquivos caso eu esteja no ambiente de desenvolvimento, tornando a tarefa de depurar erros de Javascript e CSS mais simples.

module ApplicationHelper
  def compressed_stylesheets(*files)
    files.collect! do |f| 
      f.gsub!(/\.css$/, '')
      "#{f}-min.css"
    end unless Rails.env == "development"
 
    stylesheet_link_tag *files
  end
 
  def compressed_javascripts(*files)
    files.collect! do |f| 
      f.gsub!(/\.js$/, '')
      "#{f}-min.js"
    end unless Rails.env == "development"
 
    javascript_include_tag *files
  end
end

Para utilizá-los, basta passar o nome dos arquivos compactados.

<%= compressed_stylesheets 'application' %>
<%= compressed_javascripts 'rails', 'facebox', 'jquery.form.js', 'application'  %>

Importante!

Tenha sempre em mente que se seu Javascript for ruim — por ruim quero dizer nas coxas, mal-feito, gambiarra, tosco, nojento, um código que nem seu pior inimigo deveria ter acesso — a compactação irá, provavelmente, gerar erros de Javascript (sintaxe inválida).

Para garantir nada irá quebrar, siga os passos explicados na página do JSLint; eu já sigo há um bom tempo e nunca tive nenhum problema em versões compactadas de meus códigos! Veja os erros mais comuns:

  • não finalizar linhas de código com ponto-e-vírgula
  • não utilize eval; as funções setInterval e setTimeout também devem ser evitadas se o argumento for uma string. Utilize algo como setInterval(function(){ /* do something */ }, 1000).
  • evite operadores de incremento (++) e decremento (--); prefira algo como i += 1 e i -= 1

Para ver muitas outras dicas, leia a documentação do JSLint.

Infelizmente, não é possível garantir que bibliotecas de terceiros irão funcionar em 100% dos casos. Leia o código antes de aplicar a compactação, já que você pode estar lidando com algo bastante ruim.