Validando objetos Ruby simples associados

Em um aplicativo no qual estou trabalhando, as pessoas podem deixar comentários em um registro. Quando eles comentam, eles também podem escolher enviar esse comentário por e-mail para seus colegas de trabalho. O cliente queria que o aplicativo validasse esses endereços de e-mail e mostrasse um erro se o comentarista inserisse um endereço de e-mail incorreto.

Os endereços de e-mail não são mantidos. Não há razão para isso, pois eles são usados ​​apenas para enviar o e-mail. Portanto, não existe um modelo ActiveRecord para CommentRecipentou algo parecido. Mas a sutileza da validação no estilo ActiveRecord era exatamente o que eu estava procurando. Tudo que eu queria que o controlador de comentários fizesse era algo assim:

def create
...
if comment.save?
# Send emails and notify the commenter of success
else
# notify commenter of problems with comment
end
end

Agora, provavelmente a maneira mais completa de abordar isso seria criar um ou dois objetos Ruby simples e antigos e fazer com que eles passassem nos testes de Lint do ActiveRecord e depois fazer com que usassem a validação do ActiveRecord. Funciona, e já fiz isso no passado. Mas desta vez eu mirei em algo um pouco mais leve. O que eu queria era que o modelo de comentário tivesse estas linhas:

validates_associated :recipients

def recipients
@recipients ||= CommentRecipients.new
end

E então um comentário poderia gerenciar seus destinatários com uma API como:

@comment.recipients.add "someemail@test.com"
@comment.recipients.valid?
@comment.recipients.each do {|recipient| send_email_to(recipient)}

Isso é tudo que eu precisava.

Olhando a documentação validates_associated , vejo que ele eventualmente chama este código:

if Array.wrap(value).reject {|r| r.marked_for_destruction? || r.valid?}.any?
record
.errors.add(attribute, :invalid, options.merge(:value => value))
end

Então, para meu objeto de destinatários, ele precisa responder para valid? e . Válido? faz sentido, mas , o que é isso? Aqui está a descrição da documentação do Rails:marked_for_destruction?marked_for_destruction?

Returns whether or not this record will be destroyed as part of the parents save transaction.

Visto que os recipientes nunca são perisistidos, eles nunca são marcados para destruição. Então, posso simplesmente ter esse retorno falso. Então, agora eu sei a maioria dos métodos que meu objeto de destinatários terá que lidar:

  • válido?
  • marcado para destruição?
  • cada
  • adicionar

E aqui está uma primeira passagem:

class CommentRecipients
include
Enumerable

def add(address)
recipients
<< address
end

def recipients
@recipients ||= []
end

def each(&block)
@recipients.each do |r|
block
.call(r)
end
end

#This method is here just so Comment can use validates_associated
def marked_for_destruction?
false
end

def valid?
end

private
end

Incluir Enumerable me permite fazer isso funcionar como uma coleção. A coleção de destinatários vive em um Array (um Set pode ser melhor, na verdade), que aumenta com o uso add. Eu defini #each do apenas iterar através da coleção de destinatários e codifiquei para sempre retornar false. A única coisa que falta aqui é válida?marked_for_destruction?

Você poderia fazer isso de duas maneiras. Defina válido para iterar por meio de @recipients e retornar, a falsemenos que todos tenham endereços de e-mail válidos. Ou você pode extrair um CommentRecipent para sua própria classe. Eu fiz o último. Todo o CommentRecipient tem de responder a é new, valide fornecer uma maneira de definir o seu endereço:

class CommentRecipient
attr_accessor
:address

def initialize(address="")
self.address = address
end

def address=(x)
@address = x.strip
end

def valid?
/^.+@.+$/.match(address)
end
end

Não há muito para explicar lá. A única parte estranha pode ser minha válida? regex, pois é muito menos complexo do que a maioria dos padrões de validação de e-mail. Fui assim depois de ler esta postagem do blog sobre o incômodo da validação de e-mail baseada em regex. Este padrão super-burro é bom o suficiente por enquanto.

A introdução do novo CommentRecipient introduz algumas alterações na minha coleção de destinatários:

class CommentRecipients
include
Enumerable

def add(address)
recipients
<< CommentRecipient.new(address)
end

...

def each(&block)
@recipients.each do |r|
block
.call(r.address)
end
end

...

def valid?
invalid_recipients
.empty?
end

private

def invalid_recipients
recipients
.select {|r| !r.valid?}
end
end

Normalmente, cada definição retornaria cada destinatário, mas eu a faço retornando cada endereço com o propósito de integração com algum outro código que provavelmente deveria ser reescrito.

E isso funciona. Um comentário agora valida seus destinatários e não será salvo se um deles for inválido. E o aplicativo retorna esta mensagem de erro incrível:

Recipient is invalid

Bem, isso é super útil! Vamos transformar isso em algo que um humano possa entender. Eu poderia definir a coleção de erros e configurar as mensagens lá. Ou eu poderia usar a biblioteca de internacionalização do Rails, que é muito mais fácil para este caso simples. Se eu tivesse uma situação em que um Destinatário pudesse ser inválido por vários motivos, eu usaria a abordagem de erros mais robusta. Mas, nesta situação, eu poderia apenas adicionar o seguinte aconfig/locals/en.yml

activerecord:
attributes
:
comment
:
recipients
: "Other recipients"
errors
:
models
:
comment
:
attributes
:
recipients
:
invalid
: "can only contain valid email addresses. Separate multiple addresses with a comma."

E agora temos uma mensagem de validação muito melhor.