Associações entre modelos

Para testar as associações entre os nossos modelos, temos que criar um novo, pois só temos um. Vamos criar um scaffold novo com o modelo Book, que terá os seguintes atributos:

  • title - Título
  • published_at - Data de publicação
  • text - Texto descritivo do livro
  • value - Valor do livro
  • person_id - Autor do livro

Criando o novo scaffold:

1 $ rails g scaffold Book title:string published_at:date text:text value:decima\
2 l person:references
3       invoke  active_record
4       create    db/migrate/20170311144612_create_books.rb
5       create    app/models/book.rb
6       invoke    test_unit
7       create      test/models/book_test.rb
8       create      test/fixtures/books.yml
9       ...

Agora é adaptar e rodar as migrations, alterar os testes unitários e funcionais, limitar o acesso ao controlador de livros para somente quem tiver feito o login, verificar o layout do controlador e rodar os testes para ver se está tudo ok.

Nem vamos escrever por aqui como faz isso, pois é basicamente o que fizemos com o controlador de pessoas, somente algumas observações para a migration, vista aqui já alterada:

 1 class CreateBooks < ActiveRecord::Migration[5.0]
 2   def change
 3     create_table :books do |t|
 4       t.string :title, limit: 100, null: false
 5       t.date :published_at, null: false
 6       t.text :text, null: false
 7       t.decimal :value, precision: 10, scale: 2, null: false
 8       t.references :person, foreign_key: true
 9       t.timestamps
10     end
11   end
12 end

Dando uma olhada no que customizamos nas colunas da tabela do banco de dados:

  • A coluna title vai ter um limite de 100 caracteres (limit: 100) e não pode ter o seu valor nulo (null: false).
  • A coluna published_at não pode ter o seu valor nulo.
  • A coluna text não pode ter o seu valor nulo.
  • A coluna value não pode ter o seu valor nulo, tem que ter precisão de 10 dígitos (precision: 10) sendo que 2 digitos (scale: 2) são utilizados como casas decimais, ou seja, temos um tamanho de 8 dígitos antes da casa decimal.
  • Foi criada uma referência para outra tabela, através de :person (ou seja, referenciando a tabela People, de acordo com as convenções), sendo definida como chave estrangeira4. Isso leva a criar uma coluna chamada person_id na tabela Book, que é a chave estrangeira que aponta para o id da tabela People que está referenciado em person_id.

Vamos adaptar a nossa fixture de livro:

 1 one:
 2    title: Conhecendo Ruby
 3    published_at: 2013-06-29
 4    text: Livro prático sobre a linguagem Ruby
 5    value: 1.00
 6    person: admin
 7 
 8 two:
 9    title: Conhecendo o Git
10    published_at: 2013-06-24
11    text: Quer aprender Git de forma rápida e prática? 
12    value: 10.00 
13    person: admin

Reparem que utilizei, ao invés de person_id, o nome da associação, person, e a chave da fixture de pessoas, admin, para indicar na fixture que o livro está associado com a pessoa da outra fixture. Eu poderia ter utilizado autor, mas fui xarope e utilizei admin para fazer propagandas dos meus livros e ebooks. ;-)

Isso funciona pois automagicamente quando utilizamos references ao criar a migration, o Rails já embutiu código dentro do modelo, que vamos ver logo abaixo. Antes de mais nada vamos alterar nossos testes (que, após a adaptação da fixture acima já devem estar rodando de boa) para refletir o comportamento que queremos que o modelo do livro apresente, seja baseado no que definimos no banco de dados (que é o que vamos fazer aqui, limitar pelas constraints inseridas no banco) ou em alguma regra específica para ele.

Vamos alterar nosso teste do modelo Book, presente em test/models/book_test.rb para:

 1 require 'test_helper'
 2 
 3 class BookTest < ActiveSupport::TestCase
 4   setup do
 5     @book = books(:one)
 6   end
 7 
 8   # título
 9   test 'deve ter um título' do
10     @book.title = nil
11     assert !@book.valid?
12   end
13 
14   test 'não pode ter mais que 100 caracteres' do
15     @book.title = '*' * 101
16     assert !@book.valid?
17   end
18 
19   # data de publicação
20   test 'deve ter data de publicação' do
21     @book.title = nil
22     assert !@book.valid?
23   end
24 
25   # texto
26   test 'deve ter texto' do
27     @book.text = nil
28     assert !@book.valid?
29   end
30 
31   # valor
32   test 'deve ter valor' do
33     @book.value = nil
34     assert !@book.valid?
35   end
36 
37   test 'deve ser um número' do
38     @book.value = 'bla'
39     assert !@book.valid?
40   end
41 
42   test 'não deve ser maior que o permitido' do
43     @book.value = 100000000.00
44     assert !@book.valid?
45   end
46 
47   # pessoa
48   test 'deve ter pessoa' do
49     @book.person = nil
50     assert !@book.valid?
51   end
52 end

Associação do tipo um-para-um

Nosso livro (o da aplicação, não esse que você está lendo) precisa de uma pessoa que seja o autor, que desejamos acessar como um método chamado person, em um objeto representando um livro. Esse foi um dos testes que inserimos acima, o último, por sinal, no arquivo de teste unitário de livro.

Vamos dar uma olhada em arquivo do modelo de Book, ainda sem as validações necessárias para passar nos testes, mas já com o resultado da automagicamente criada coluna especificada na migration através de references:

1 class Book < ApplicationRecord
2   belongs_to :person
3 end

Ali no modelo foi indicado automagicamente (lógico que podemos inserir e alterar isso na hora que quisermos) que um livro pertence à uma pessoa, utilizando o método belongs_to, ou seja pertence à. Dessa forma, foi criada uma associação no ORM indicando uma dependência (forte, por sinal, é uma foreign key do livro para a pessoa.

Essa associação, por padrão, a partir do Rails 5, não pode ficar vazia, ou seja, se um livro pertence à uma pessoa, deve obrigatoriamente conter um registro válido, uma foreign key válida, ali. Se por acaso desejarmos por algum motivo que essa verificação seja afrouxada, podemos especificar no final a Hash optional: true, dessa forma:

1 class Book < ApplicationRecord
2   belongs_to :person, optional: true
3 end

Isso é útil para migrar aplicações de versões anteriores que permitiam esse comportamento. Podemos até definir esse comportamento para a aplicação inteira, utilizando o seguinte código em um initializer:

1 Rails.application.config.active_record.belongs_to_required_by_default = false

Mas vamos deixar sem o optional e manter o que já vem definido por padrão.

Vamos terminar de inserir as validações necessárias para o modelo passar nos testes:

 1 class Book < ApplicationRecord
 2   validates :title, presence: true, length: { maximum: 100 }
 3   validates :published_at, presence: true
 4   validates :text, presence: true
 5   validates :value, presence: true, numericality: { less_than_or_equal_to: 99\
 6 999999.99 }
 7   validates :person, presence: true
 8 
 9   belongs_to :person
10 end

Vamos ver como funcionam essas validações logo mais no livro, mas tem algumas ali que até já são auto-explicativas. Se rodarmos nossos testes agora, todos devem passar.

Como temos a associação com pessoa, em nossos objetos de livros, ela já pode ser testada no console ou no navegador:

 1 book = Book.new
 2 => #<Book id: nil, title: nil, published_at: nil, text: nil, value: nil, pers\
 3 on_id: nil, created_at: nil, updated_at: nil> 
 4 
 5 book.title = "Ruby - Conhecendo a Linguagem"
 6 => "Ruby - Conhecendo a Linguagem" 
 7 
 8 book.published_at = "2006-03-01"
 9 => "2006-03-01" 
10 
11 book.text = "Tinha, mas acabou."
12 => "Tinha, mas acabou." 
13 
14 book.value = 40.00
15 => 40.0 
16 
17 book.person = Person.last
18 Person Load (0.3ms)  SELECT "people".* FROM "people" ORDER BY name DESC LIMIT\
19  1
20 => #<Person id: 2, name: "Eustáquio Rangel", email: "taq@bluefish.com.br",
21 password: "9733340c840c719779f234407ee0bac26ae8904b", born_at: "1971-04-06",
22 admin: true, created_at: "2013-07-06 22:51:34", updated_at: "2013-07-07
23 22:48:20"> 
24 
25 book.person.name
26 => "Eustáquio Rangel" 
27 
28 book.save
29 (0.1ms)  begin transaction
30 SQL (28.9ms)  INSERT INTO "books" ("created_at", "person_id", "published_at",
31 "text", "title", "updated_at", "value") VALUES (?, ?, ?, ?, ?, ?, ?)
32 [["created_at", Tue, 09 Jul 2013 15:15:46 UTC +00:00], ["person_id", 2],
33 ["published_at", Wed, 01 Mar 2006], ["text", "Tinha, mas acabou."], ["title",
34 "Ruby - Conhecendo a Linguagem"], ["updated_at", Tue, 09 Jul 2013 15:15:46 UTC
35 +00:00], ["value", #<BigDecimal:ae70608,'0.4E2',9(45)>]]
36 (114.1ms)  commit transaction
37 => true 

Ficando assim:

Livro pertence à pessoa
Livro pertence à pessoa

Associação do tipo um-para-muitos

Já que um livro pertence à uma pessoa, agora podemos especificar que uma pessoa pode ter vários livros. Primeiro, no teste unitário:

1 test "deve ter uma coleção de livros" do
2   person = people(:admin)
3   assert_respond_to person, :books
4   assert_kind_of Book, person.books.first
5 end

E no modelo, estabelecendo a associação, usando has_many:

1     has_many :books

Através do has_many temos uma coleção de livros no objeto da pessoa, como podemos verificar pelo console:

 1 p = Person.last
 2 Person Load (0.3ms)  SELECT "people".* FROM "people" ORDER BY name DESC LIMIT\
 3  1
 4 => #<Person id: 2, name: "Eustáquio Rangel", email: "taq@bluefish.com.br",
 5 password: "9733340c840c719779f234407ee0bac26ae8904b", born_at: "1971-04-06",
 6 admin: true, created_at: "2013-07-06 22:51:34", updated_at: "2013-07-07
 7 22:48:20"> 
 8 
 9 p.books
10 Book Load (0.1ms)  SELECT "books".* FROM "books" WHERE "books"."person_id" = 2
11 => [#<Book id: 1, title: "Ruby - Conhecendo a Linguagem", published_at:
12 "2006-03-01", text: "Tinha, mas acabou.", value:
13 #<BigDecimal:aaae9c0,'0.4E2',9(36)>, person_id: 2, created_at: "2013-07-09
14 15:15:46", updated_at: "2013-07-09 15:15:46">] 
15 
16 p.book_ids
17 => [1] 
18 
19 p.books.last.title
20 => "Ruby - Conhecendo a Linguagem" 

Ficando assim:

Pessoa tem muitos livros
Pessoa tem muitos livros

Fica aqui uma dica sobre o que acontece quando apagamos o registro da pessoa com vários livros. O comportamento padrão é não-intrusivo e não faz nada, mas podemos especificar o que deve ser feito através de :dependent, da seguinte forma:

1 has_many :books, dependent: :destroy

Os seguintes comportamentos estão disponíveis para :dependent:

  • destroy - Os objetos associados são destruídos junto com o objeto corrente, chamando os métodos destroy de cada objeto.
  • delete_all - Os objetos associados são apagados sem chamar o método destroy de cada um.
  • nullify - Todas as chaves estrangeiras dos objetos associados são transformadas em nulo, sem chamar os callbacks de atualização dos objetos.
  • restrict_with_error - Evita que o objeto seja apagado, retornando false se tem mais objetos associados.
  • restrict_with_exception - Evita que o objeto seja apagado, disparando uma exceção se tem mais objetos associados.

Podemos fazer testes unitários em Person para verificar que o comportamento de :restrict_with_exception ou :destroy são obedecidos, e para termos certeza de que o modelo está configurado corretamente de acordo com a nossa escolha.

Para o teste de :restrict_with_exception, nesse caso alterando o modelo para

1 has_many :books, dependent: :restrict_with_exception

podemos utilizar:

 1 test "não pode apagar a pessoa se ela tiver livros" do
 2   person = people(:admin)
 3   assert person.books.size > 0
 4 
 5   assert_no_difference('Person.count') do
 6     assert_no_difference('Book.count') do
 7       assert_raise ActiveRecord::DeleteRestrictionError do
 8         assert !person.destroy, "não deveria apagar com #{person.books.size} \
 9 livro(s)"
10       end
11     end
12   end
13 end

Rodando os testes, podemos ver que tudo está funcionando de acordo. Agora, para o teste de :destroy, vamos alterar o modelo para

1 has_many :books, dependent: :destroy

e nos testes podemos comentar o teste anterior e inserir o seguinte código:

 1 test "deve apagar os livros quando apagar a pessoa" do
 2   person = people(:admin)
 3   assert person.books.size > 0
 4 
 5   assert_difference('Person.count', -1) do
 6     assert_difference('Book.count',person.books.size * -1) do
 7       assert person.destroy, "deveria apagar a pessoa"
 8     end
 9   end
10 end

Podemos notar que o primeiro comportamento e teste é para evitar que a pessoa seja apagada se tiver itens na coleção, chutando o pau da barraca e disparando uma Exception se isso ocorrer, enquanto que o segundo é para garantir que os itens da coleção sejam apagados se a pessoa for apagada.

Associações de muitos para muitos

Vamos criar um novo scaffold para cadastrarmos as categorias que nossos livros pertencem. Os livros cadastrados até aqui já pertencem à duas categorias que podemos definir como “tecnologia” e “programação”. Vamos criar mais um scaffold bem simples para gerenciar as categorias, e já rodar as migrations:

1 $ rails g scaffold Category name:string
2       invoke  active_record
3       create    db/migrate/20170312135948_create_categories.rb
4       create    app/models/category.rb
5       invoke    test_unit
6 $ rails db:migrate

Após o scaffold criado, podemos acessar http://localhost:3000/categories e criar as categorias mencionadas acima. Se quiserem adicionar outras para ver como fica a seleção, fiquem à vontade. Vamos adaptar também as fixtures criadas para refletir nas chaves algo mais identificável nas categorias, não esquecendo de trocar nos testes funcionais as chaves onde está sendo referenciado one para alguma das que vamos criar agora:

1 tech:
2   name: Tecnologia
3 
4 dev:
5   name: Desenvolvimento

Para deixar as categorias disponíveis no gerenciamento de livros, no controlador de livros devemos pedir para que sejam carregadas, através do método before_action:

 1 class BooksController < ApplicationController
 2   before_action :set_book, only: [:show, :edit, :update, :destroy]
 3   before_action :load_categories, only: [:new, :edit, :create, :update]
 4   ...
 5 
 6   private
 7   def load_categories
 8      @categories = Category.all
 9   end
10   ...
11 end

Agora que temos disponíveis as categorias, precisamos de um meio de indicar que um livro tem várias categorias, e que a categoria tem vários livros. Para isso vamos utilizar uma tabela auxiliar, ou join table, para armazenar o id do livro e os ids (zero ou mais) das categorias dos livros. Vamos utilizar o método has_and_belongs_to_many, que até o Rails 4 ficou na controvérsia se seria ainda utilizado ou se seria marcado como deprecated, mas que até agora está funcionando muito bem, e é bem simples de implementar.

Vamos criar a join table com os nomes dos dois modelos, em ordem alfabética, isso é muito importante, criando a seguinte migration:

1 $ rails g migration CreateJoinTableBookCategory book category
2       invoke  active_record
3       create    db/migrate/20170312145600_create_join_table_book_category.rb

Ela vai ter um conteúdo como esse, já removidos os comentários:

1 class CreateJoinTableBookCategory < ActiveRecord::Migration[5.0]
2   def change
3     create_join_table :books, :categories do |t|
4       t.index [:book_id, :category_id]
5       t.index [:category_id, :book_id]
6     end
7   end
8 end

Rodando a migration, vai ser criada a tabela books_categories, que não vai ter um modelo associado, e nem um id. Esses são detalhes muito importantes em uma join table desse tipo:

1 $ rails db:migrate
2 == 20170312145600 CreateJoinTableBookCategory: migrating ====================\
3 ==
4 -- create_join_table(:books, :categories)
5    -> 0.0076s
6    == 20170312145600 CreateJoinTableBookCategory: migrated (0.0076s)
7    =============

Agora vamos indicar no modelo de livro que ele tem vários relacionamentos com esse modelo novo, e que tem várias categorias. Antes de mais nada, testes no modelo de livro:

1 # categorias
2 test 'deve ter categorias' do
3   assert_respond_to @book, :categories
4 end
5 
6 test 'deve ter categorias do tipo correto' do
7   @book.categories << categories(:one)
8   assert_kind_of Category, @book.categories.first
9 end

E agora sim podemos alterar o modelo de livro indicando que ele pertence à várias categorias:

1 class Book < ApplicationRecord
2   ...
3   has_and_belongs_to_many :categories
4   ...
5 end

Isso nos dá os seguintes métodos em Book:

 1 > b = Book.first
 2   Book Load (0.1ms)  SELECT  "books".* FROM "books" ORDER BY "books"."id" ASC
 3   LIMIT ?  [["LIMIT", 1]] => #<Book id: 14, title: "Conhecendo Ruby",
 4   published_at: "2013-06-29", text: "Livro prático sobre a linguagem Ruby",
 5   value: #<BigDecimal:1701a10,'0.1E1',9(18)>, person_id: 15, created_at:
 6   "2017-03-12 15:22:07", updated_at: "2017-03-12 15:22:07"> 
 7 
 8 > b.categories
 9   Category Load (0.2ms)  SELECT "categories".* FROM "categories" INNER JOIN
10   "books_categories" ON "categories"."id" = "books_categories"."category_id"
11   WHERE "books_categories"."book_id" = ?  [["book_id", 14]] =>
12   #<ActiveRecord::Associations::CollectionProxy [#<Category id: 1, name:
13   "Tecnologia", created_at: "2017-03-12 14:02:51", updated_at: "2017-03-12
14   14:02:51">, #<Category id: 2, name: "Programação", created_at: "2017-03-12
15   14:02:51", updated_at: "2017-03-12 14:02:51">]> 
16 
17 > b.category_ids
18  => [1, 2] 

E agora podemos fazer a mesma coisa no modelo de categoria:

1 class Category < ApplicationRecord
2   has_and_belongs_to_many :books
3 end

Que nos dá os métodos:

 1 > c = Category.first
 2 Category Load (0.4ms)  SELECT  "categories".* FROM "categories" ORDER BY
 3 "categories"."id" ASC LIMIT ?  [["LIMIT", 1]]
 4 => #<Category id: 1, name: "Tecnologia", created_at: "2017-03-12 14:02:51",
 5 updated_at: "2017-03-12 14:02:51"> 
 6 
 7 > c.books
 8   Book Load (0.3ms)  SELECT "books".* FROM "books" INNER JOIN "books_categori\
 9 es"
10   ON "books"."id" = "books_categories"."book_id" WHERE
11   "books_categories"."category_id" = ?  [["category_id", 1]] =>
12   #<ActiveRecord::Associations::CollectionProxy [#<Book id: 14, title:
13   "Conhecendo Ruby", published_at: "2013-06-29", text: "Livro prático sobre a
14   linguagem Ruby", value: #<BigDecimal:3799958,'0.1E1',9(18)>, person_id: 15,
15   created_at: "2017-03-12 15:22:07", updated_at: "2017-03-12 15:22:07">, #<Bo\
16 ok
17   id: 15, title: "Conhecendo o Git", published_at: "2013-06-24", text: "Quer
18   aprender Git de forma rápida e prática?", value:
19   #<BigDecimal:378fef8,'0.1E2',9(18)>, person_id: 15, created_at: "2017-03-12
20   15:22:07", updated_at: "2017-03-12 15:22:07">]> 
21 
22 > c.book_ids
23  => [14, 15] 

Ficando assim:

Livros e categorias
Livros e categorias

Vamos aproveitar o método category_ids de Book para fazer a nossa escolha de categorias. Primeiro, vamos liberar nos strong parameters do controlador dos livros o envio desse atributo, dessa forma:

1 def book_params
2   params.require(:book).permit(:title, :published_at, :text, :value, :person_\
3 id, category_ids: [])
4 end

E listamos as categorias disponíveis no formulário dos livros em app/views/books/_form.html.erb através da coleção já presente no controlador na variável @categories, com o método collection_check_boxes:

1   <div class="field">
2     <h2>Categorias</h2>  
3     <%= collection_check_boxes :book, :category_ids, @categories, :id, :name \
4 %>
5   </div>

O que vai nos dar uma lista com as categorias cadastradas na forma de checkboxes no formulário de edição dos livros. Ficou legal, mas podemos melhorar para deixar com uma semântica mais adequada. Particularmente, eu prefiro gerar um elemento ul com um elemento li para cada categoria. Podemos personalizar da seguinte forma:

1 <ul>
2 <%= collection_check_boxes :book, :category_ids, @categories, :id, :name do |\
3 builder| %>
4   <li>
5     <%= builder.label { builder.check_box + builder.text } %>
6   </li>
7 <% end %>
8 </ul>

Agora sim ficou legal. Vamos alterar a view app/views/books/show.html.erb para listar as categorias cadastradas do livro:

1 <h2>Categorias</h2>
2 <ul>
3   <% for category in @book.categories %>
4     <li><%= category.name %></li>
5   <% end %>
6 </ul>

Associações de muitos para muitos, através

Agora que temos uma pessoa com vários livros, um livro que pertence à uma pessoa e tem várias categorias, sendo que uma categoria tem vários livros, podemos pensar o seguinte: e se quisermos saber quais as categorias de livros que uma pessoa tem? Podemos ver que está tudo conectado na modelagem conforme descrito, só precisamos de um jeito de, quando estivemos na pessoa, recuperarmos a categoria através (essa palavra é muito importante) aqui dos livros. Para isso, vamos usar a associação has_many :through. Primeiro, vamos alterar a fixture dos livros para incluírem as categorias, enviadas como um Array, dessa forma:

 1 one:
 2   title: Conhecendo Ruby
 3   published_at: 2013-06-29
 4   text: Livro prático sobre a linguagem Ruby
 5   value: 1.00
 6   person: admin
 7   categories: [ tech, dev ]
 8 
 9 two:
10   title: Conhecendo o Git
11   published_at: 2013-06-24
12   text: Quer aprender Git de forma rápida e prática? 
13   value: 10.00 
14   person: admin
15   categories: [ tech, dev ]

Agora um teste de pessoa:

 1 # categorias
 2 test 'deve ter várias categorias, através de livros' do
 3   assert_respond_to @person, :categories
 4 end
 5 
 6 test 'deve ter uma categoria do tipo correto' do
 7   assert_kind_of Category, @person.categories.first
 8 end
 9 
10 test 'deve ter apenas duas categorias' do
11   assert_equal 2, @person.categories.count
12 end

Convém atentar para o último teste. Ele procura garantir que são encontradas apenas 2 categorias, mas temos 2 livros com 2 categorias cada um, de modo que vão ser retornadas 4 categorias. Vamos resolver isso já, mas antes vamos ver o método tradicional de indicar que pessoa tem várias categorias através de livro, inserindo no arquivo do modelo de pessoa a seguinte linha:

1 ...
2 has_many :categories, through: :books
3 ...

Novamente, uma configuração bem simples, que nos dá as categorias da pessoa:

 1 > Person.last.categories
 2   Person Load (0.4ms)  SELECT  "people".* FROM "people" ORDER BY "people"."na\
 3 me"
 4   DESC LIMIT ?  [["LIMIT", 1]] Category Load (0.1ms)  SELECT "categories".* F\
 5 ROM
 6   "categories" INNER JOIN "books_categories" ON "categories"."id" =
 7   "books_categories"."category_id" INNER JOIN "books" ON
 8   "books_categories"."book_id" = "books"."id" WHERE "books"."person_id" = ?
 9   [["person_id", 15]]
10  => #<ActiveRecord::Associations::CollectionProxy [#<Category id: 1, name:
11  "Tecnologia", created_at: "2017-03-12 14:02:51", updated_at: "2017-03-12
12  14:02:51">, #<Category id: 2, name: "Programação", created_at: "2017-03-12
13  14:02:51", updated_at: "2017-03-12 14:02:51">, #<Category id: 1, name:
14  "Tecnologia", created_at: "2017-03-12 14:02:51", updated_at: "2017-03-12
15  14:02:51">, #<Category id: 2, name: "Programação", created_at: "2017-03-12
16  14:02:51", updated_at: "2017-03-12 14:02:51">]> 

Só que, como já identificamos no teste, vai trazer as categorias repetidas. Para evitar isso, precisamos pegar os resultados distintos, e se alguém conhece SQL por aí, sabe que isso podemos recuperar com uma cláusula DISTINCT, que é o que vamos utilizar enviando através de uma lambda para a associação:

1 has_many :categories, -> { distinct }, through: :books

Pronto! Agora rodando os testes, vamos constatar que as categorias estão retornando corretamente, sem duplicação.

Essa associação ficou da seguinte forma, representada pelo traço pontilhado:

Pessoa tem várias categorias através de livros
Pessoa tem várias categorias através de livros

Acelerando as consultas nas associações

Temos alguns jeitos de utilizarmos as associações, sendo que estamos vendo até agora o jeito padrão que já vem com o Rails, onde o ORM cuida de bastante coisas para nós, mas onde também podemos dar uma mãozinha para que as coisas fiquem mais eficientes.

Joins

Vamos imaginar que queremos descobrir as pessoas que tem livros associados. Um jeito de não fazer isso seria assim:

1 > Person.select { |person| person.books.count > 0 }
2   Person Load (0.1ms)  SELECT "people".* FROM "people" ORDER BY "people"."nam\
3 e" ASC
4    (0.1ms)  SELECT COUNT(*) FROM "books" WHERE "books"."person_id" = ?  [["pe\
5 rson_id", 4]]
6    (0.1ms)  SELECT COUNT(*) FROM "books" WHERE "books"."person_id" = ?  [["pe\
7 rson_id", 3]]

Vejam que eu percorremos toda a tabela de pessoas e estamos fazendo várias outras consultas para pegar cada pessoa e selecionar os livros de cada uma delas. Ficou uma mistura de iteradores Ruby (o método select) com código SQL (o where dentro de cada bloco). Podemos fazer bem melhor utilizando joins:

1 Person.joins(:books).distinct
2   Person Load (0.2ms)  SELECT DISTINCT "people".* FROM "people" INNER JOIN
3   "books" ON "books"."person_id" = "people"."id" ORDER BY "people"."name" ASC
4  => #<ActiveRecord::Relation [#<Person id: 3, name: "Eustáquio Rangel de
5  Oliveira Jr.", email: "taq@bluefish.com.br", password_digest: ...

Bem melhor, não? Ali o INNER JOIN produzido pelo método joins já relacionou as duas tabelas, fazendo o filtro de uma vez só no comando SQL, procurando obrigatoriamente pessoas que tem livros. Vejam que utilizamos distinct no final, para evitar registros duplicados se a pessoa tiver mais de um livro.

Includes

Temos um problema ali: e se precisarmos saber os títulos dos livros encontrados? O resultado nos trouxe somente uma coleção de objetos tipo Person.

Vamos fazer um teste:

1 > Person.joins(:books).distinct.map { |person| { person.name => person.books.\
2 pluck(:title) } }
3   Person Load (0.3ms)  SELECT DISTINCT "people".* FROM "people" INNER JOIN
4   "books" ON "books"."person_id" = "people"."id" ORDER BY "people"."name" ASC
5    (0.1ms)  SELECT "books"."title" FROM "books" WHERE "books"."person_id" = ?\
6   [["person_id", 3]]
7  => [{"Eustáquio Rangel de Oliveira Jr."=>["Conhecendo Ruby", "Conhecendo o G\
8 it"]}]

Reparem que para cada pessoa, foi executada outra consulta para recuperar os livros (e também o nome da pessoa, como chave da Hash) e retornar somente o título. O que ocorre é o método joins estabelece o relacionamento, mas não deixa disponivel os dados da outra associação, não faz eager loading. Para remediar isso, podemos utilizar o método includes.

Antes de aplicarmos o método includes na nossa consulta, vale mencionar que ele também pode ser utilizado, além do eager loading, como um LEFT OUTER JOIN:

1 Person.includes(:books).distinct.map { |person| { person.name => person.books\
2 .pluck(:title) } }
3   Person Load (0.1ms)  SELECT DISTINCT "people".* FROM "people" ORDER BY "peo\
4 ple"."name" ASC
5   Book Load (0.4ms)  SELECT "books".* FROM "books" WHERE "books"."person_id" \
6 IN (4, 3)
7  => [{"Ana Carolina"=>[]}, {"Eustáquio Rangel de Oliveira Jr."=>["Conhecendo \
8 Ruby", "Conhecendo o Git"]}]

Podemos ver que para as pessoas que não tinham livros, foi retornado uma coleção vazia e que ao invés de várias consultas aos livros (o que ocorreria se tivéssemos várias pessoas com livros) foi feita uma, especificando os ids de todas as pessoas.

Juntando joins e includes

Mas vamos voltar para resolver nosso problema de performance. Se pensarmos que joins faz a associação com a outra tabela e includes faz o eager loading, é só juntarmos os dois:

 1 > Person.joins(:books).includes(:books).distinct.map { |person| { person.name\
 2  => person.books.pluck(:title) } }
 3   SQL (0.2ms)  SELECT DISTINCT "people"."id" AS t0_r0, "people"."name" AS t0_\
 4 r1,
 5   "people"."email" AS t0_r2, "people"."password_digest" AS t0_r3,
 6   "people"."born_at" AS t0_r4, "people"."admin" AS t0_r5, "people"."created_a\
 7 t"
 8   AS t0_r6, "people"."updated_at" AS t0_r7, "books"."id" AS t1_r0,
 9   "books"."title" AS t1_r1, "books"."published_at" AS t1_r2, "books"."text" AS
10   t1_r3, "books"."value" AS t1_r4, "books"."person_id" AS t1_r5,
11   "books"."created_at" AS t1_r6, "books"."updated_at" AS t1_r7, "books"."stoc\
12 k"
13   AS t1_r8, "books"."lock_version" AS t1_r9 FROM "people" INNER JOIN "books" \
14 ON
15   "books"."person_id" = "people"."id" ORDER BY "people"."name" ASC
16  => [{"Eustáquio Rangel de Oliveira Jr."=>["Conhecendo Ruby", "Conhecendo o G\
17 it"]}]

Agora sim! Temos uma consulta única de onde são extraídas todas as informações que precisamos. Vejam como o ORM altera o nome dos campos e sabe acessar cada um de maneira transparente.

Pluck versus select

Vimos que utilizamos o método pluck. Esse método extrai somente os atributos que indicamos, sem retornar um objeto da associação. Assim:

1 > Person.pluck(:name)
2    (0.3ms)  SELECT "people"."name" FROM "people" ORDER BY "people"."name" ASC
3  => ["Ana Carolina", "Eustáquio Rangel de Oliveira Jr."] 

Podemos indicar mais de um atributo:

1 > Person.pluck(:name, :email)
2    (0.2ms)  SELECT "people"."name", "people"."email" FROM "people" ORDER BY "\
3 people"."name" ASC
4  => [["Ana Carolina", "carol@bluefish.com.br"], ["Eustáquio Rangel de Oliveir\
5 a Jr.", "taq@bluefish.com.br"]]

Vejam que são retornados POROs (Plain Old Ruby Objects), no formato de Arrays.

O método select, por sua vez, retorna um objeto com apenas os atributos selecionados preenchidos, ou seja, não vamos conseguir acessar os outros. Assim, isso funciona:

 1 > Person.select(:name, :email)
 2   Person Load (0.1ms)  SELECT "people"."name", "people"."email" FROM "people"
 3   ORDER BY "people"."name" ASC => #<ActiveRecord::Relation [#<Person id: nil,
 4   name: "Ana Carolina", email: "carol@bluefish.com.br">, #<Person id: nil, na\
 5 me:
 6   "Eustáquio Rangel de Oliveira Jr.", email: "taq@bluefish.com.br">]> 
 7 
 8 > Person.select(:name, :email).first.name
 9   Person Load (0.3ms)  SELECT  "people"."name", "people"."email" FROM "people"
10   ORDER BY "people"."name" ASC LIMIT ?  [["LIMIT", 1]]
11  => "Ana Carolina" 
12 
13 > Person.select(:name, :email).first.email
14   Person Load (0.2ms)  SELECT  "people"."name", "people"."email" FROM "people"
15   ORDER BY "people"."name" ASC LIMIT ?  [["LIMIT", 1]]
16  => "carol@bluefish.com.br" 

Já isso, não:

1 > Person.select(:name, :email).first.admin
2   Person Load (0.1ms)  SELECT  "people"."name", "people"."email" FROM "people"
3   ORDER BY "people"."name" ASC LIMIT ?  [["LIMIT", 1]]
4 ActiveModel::MissingAttributeError: missing attribute: admin

Criando um outro tipo de associação um-para-um

Podemos utilizar um outro tipo de associação um-para-um, com uma semântica diferente, utilizando has_one. Enquanto que em belongs_to a foreign key estava no modelo que especificava a relação, em has_one está no outro modelo.

Podemos, por exemplo, dizer que as pessoas da nossa aplicação tem cada uma, uma imagem. Para isso, vamos aprender como fazer upload de arquivos.