Estou trabalhando em uma prova de conceito usando Rails e me deparei com uma situação muito comum no dia-a-dia de desenvolvedores: Implementar um auto-complete que no banco de dados refere-se a um relacionamento has_and_belongs_to_many. No meu caso, a tela se parece com algo assim:
Nesse exemplo, o campo Authors permite que conforme você vá digitando, ele abre sugestões de autores já cadastrados no banco de dados. Permite também que você separe vários autores por vírgula e cadastra os autores que não existem ainda no banco de dados.
A implementação do controller nesse caso, usa o componente Autocomplete.Local do Script.aculo.us que não é muito complicado de ser usado e inclusive já vem de brinde com o Rails. Se você quer saber mais detalhes sobre isso, dá uma olhada no arquivo app/views/books/_authors.html.erb. O que vejo ser mais complicado nesse exemplo é a implementação do model Book que deve ter uma lista de autores.
Neste momento você deve estar se perguntando: Mas não tem nada complicado nisso! Eu simplesmente vou receber um parâmetro param[:book][:authors_s] e chamar o método split(",") para obter um array de nomes de autores e chamar o método Author.find_or_create_by_name(author_name) para criá-lo.
E eu respondo: Exatamente! Sua solução está perfeita. Mas tenho uma pequena pergunta: Onde esse código será colocado? [ ] Na classe books_controller no método create. [ ] Na classe book.
Tendenciosa minha pergunta, não? Se você pensar um pouco e lembrar das palestras sobre MVC na hora você responde “Na classe book”. Mas o primeiro instinto é implementar no controller, inclusive já vi centenas de códigos em Rails cometendo exatamente esse erro. Lógica de negócio deve ficar em objeto de negócio (ou de domínio se você preferir) e não no controller.
Logo, no nosso caso, o código da classe Book ficaria algo parecido com isso (repare que o campo Tags, logo abaixo do campo Authors, tem o mesmo comportamento):
O objetivo do código acima é permitir que um objeto da classe Book seja instanciado no controller desta forma:
Vamos dissecar um pouco mais o código:
- Na view, fazemos um bind para o atributo book.authors_s, que é uma representação string do objeto authors criada por nós:
<%= f.text_field :authors_s %>. Logo, o parâmetroparams[:book][:authors_s]no exemplo seria"Kent Beck, Martin Fowler". - Declarar o atributo authors com o comando:
has_and_belongs_to_many :authors, :join_table => "books_authors"permite que a classe receba um array de objetos da classeAuthorcomo o parâmetro:authors. Logo, temos que converter o atributo authors_s para o atributo authors, lembrando da necessidade de gravar os autores novos no banco de dados. - Para resolver esse problema, implementei alguns callbacks:
- O primeiro no método before_save que faz o algoritmo que falamos anteriormente (
split + find_or_create_by_name). Desta forma, os autores novos só serão gravados caso o objeto book seja válido (before_save só é chamado após a validação). - O segundo no método after_find que carrega o atributo authors_s com os valores do atributo author. Desta forma, permite a edição de livros.
Naturalmente é possível fazer algumas melhorias no código, mas a idéia que gostaria de apresentar é essa. Sinta-se à vontade para baixar o código completo no githut e deixar seus comentários/dúvidas/sugestões/críticas abaixo.
Updated 25/02: Meu amigo e co-worker Rodrigo Toledo me deu várias dicas sobre como este exemplo pode ser implementado, inclusive resolvendo alguns bugs que ficaram pra trás. Resumo das alterações:
- Retiramos o controller “autocomplete_controller” incluindo a lógica no helper e simplesmente imprimindo um json na view para auto completar os campos tags e authors.
- Alteramos a lógica do model book.rb para usar os callbacks after_find e before_save para fazer o bind da string separada por vírgulas para um array.
- Pequena alteração para não usar eval no método Book.array_to_string conforme o comentário.
Todo o código já está comitado no github e já fiz as alterações nos pasties mostrados no artigo.
