Sobre patrones de creación: doble dep endencia has_many, belongs_to


#1

Hola gente,

Tenemos por aquí el siguiente dilema:

LA DOBLE VINCULACIÓN

Tenemos el modelo Trip y el modelo Destination tal que:

Trip has_many :destination
Destination belongs_to :trip

Una de las condiciones de Trip es que tiene que tener al menos una
Destination. Y una de las condiciones de Destination es que tienen que
pertencer a un Trip.

Como ejemplo simplificado tenemos esto:

class Trip < ActiveRecord::Base
has_many :destinations
def validate
errors.add(:destinations, ‘Debes indicar al menos un destino’)
if destinations.empty?
end
end

class Destination < ActiveRecord::Base
belongs_to :trip
validates_presence_of :trip, :message => “Hay que asociarla a un Trip”
end

Entonces la creación de ambos modelos está sumamente linkada, sobre
todo a la hora de la creación del Trip que hay que crear el Trip y por
lo menos una Destination al mismo tiempo.

Ni corto ni perezoso se me ocurrió hacer esto:

trip =
  Trip.new(
    :name => 'el viaje',
    :destinations => [
      { :name => 'madrid' },
      { :name => 'valencia' }
    ]
  )

Pero me indica este error:
ActiveRecord::AssociationTypeMismatch: Destination(#18941590)
expected, got Hash(#102590)

Vamos que lo que me pide es que le haga esto:

trip =
  Trip.new(
    :name => 'el viaje',
    :destinations => [
      Destination.new( :name => 'madrid' ),
      Destination.new( :name => 'valencia' )
    ]
  )

En este punto empiezo a echar de menos algo de magia, y me empiezo a
preguntar si algo estoy haciendo mal. (Pregunta 1)

Aún así y por culpa de la doble dependencia tenemos que las nuevas
Destination no son correctas y no pueden ser grabadas, así como
tampoco el nuevo Trip:

puts trip.valid?

false

puts trip.errors.full_messages

Destinations is invalid
Destinations is invalid

trip.destinations.each do |destination|
  puts "#{destination.name}: #{destination.errors.full_messages}"
end

madrid: Trip Hay que asociarla a un Trip
valencia: Trip Hay que asociarla a un Trip

ELIMINANDO DOBLE VALIDACIÓN y LA CREACIÓN PASO A PASO

En este punto ya empiezo a ceder y ver que no voy a poder crear todo
de un plumazo. Entonces voy a tener que crear primero el Trip y luego
ir asociándole Destinations una por una.

trip = Trip.create!( :name => 'el viaje' )
trip.destinations << Destination.new( :name => 'madrid' )
trip.destinations << Destination.new( :name => 'madrid' )

Veo que el operador << es bastante inteligente y no sólo instancia los
Destination sino que les asocia el trip_id y los guarda en base de
datos:

Trip Create (0.000594) INSERT INTO “trips” (“name”, “updated_at”,
“created_at”) VALUES(‘el viaje’, ‘2008-12-06 17:21:33’, ‘2008-12-06
17:21:33’)
Trip Load (0.000441) SELECT * FROM “trips” WHERE (“trips”.“id” =
996332878)
Destination Create (0.000205) INSERT INTO “destinations” (“name”,
“updated_at”, “trip_id”, “created_at”) VALUES(‘madrid’, ‘2008-12-06
17:21:33’, 996332878, ‘2008-12-06 17:21:33’)
Trip Load (0.000285) SELECT * FROM “trips” WHERE (“trips”.“id” =
996332878)
Destination Create (0.000120) INSERT INTO “destinations” (“name”,
“updated_at”, “trip_id”, “created_at”) VALUES(‘madrid’, ‘2008-12-06
17:21:33’, 996332878, ‘2008-12-06 17:21:33’)

Ahora ya puedo crear Trips y Destinations y asociarlos unos a otros
pero pierdo la validación de que un Trip nunca pueda estar con su
array de destinations vacío. (Pregunta 2)

EN EL CONTROLADOR

Por otro lado si subimos al controlador nos encontramos con que el
TripsController.create debe crear tanto el nuevo objeto Trip como las
Destinations que el cliente haya definido en el formulario.

Para ello me habría gustado que bastase con esto:

def create
@trip = Trip.new(params[:trip])
render :action => ‘show’
end

De tal modo que esto funcionara:

post(
  :create,
  :trip => {
    :name => 'nombre del viaje',
    :destinations => [
      { :name => 'madrid' },
      { :name => 'madrid' }
    ]
  }
)

Y que generase una petición como esta:
Parameters: {“action”=>“create”, “trip”=>{“name”=>“nombre del viaje”,
“destinations”=>[{“name”=>“madrid”}, {“name”=>“madrid”}]},
“controller”=>“trips”}

Podría crear un formulario web creo que fácilmente usando helpers de
rails para que me generase este tipo de petición, pero no funciona.
Volvemos a tener el error de antes:
ActiveRecord::AssociationTypeMismatch: Destination(#18904540)
expected, got HashWithIndifferentAccess(#9626070)

Entonces veo que tengo que hacer lógica de creación de estos modelos
en el controlador, para crear antes el Trip y luego los Destinations.

No sé muy bien como es la mejor manera de hacer esto. (pregunta 3).
Además de que estoy seguro de que estoy rompiendo alguna regla REST al
crear instancias de un modelo en el controlador del otro.

EL RESUMEN

Entonces resumiendo creo que me quedo con 3 preguntas de todas las que
me podrían surgir:

Pregunta 1: ¿Es ActiveRecord capaz de soportar la creación de
elementos hijos a la par que se crea el padre?
Pregunta 2: ¿Hay alguna manera recomendada de gestionar este tipo de
vinculaciones recíprocas?
Pregunta 3: ¿Hay algún patrón que describa la mejor manera de realizar
creaciones de recursos anidados como este caso?

Perdón por lanzar 3 preguntas en el mismo hilo pero podéis ver que
estar estrechamente unidas y puede que dando respuesta a una se
resuelvan las demás.

ACERCAMIENTOS

He visto este screencast del increible Ryan:
http://railscasts.com/episodes/57-create-model-through-text-field

Veo que usa atributos virtuales para la creación de elementos
asociados y hace uso del before_save.

En este tipo de intentos estamos ahora pero con la doble
vinculacióntenemos un pequeño bloqueo y no sabemos si usar el before_save, el
after_save… hacerlo todo en el controlador, usar transacciones por si
una creación falla eliminar todas las anteriores…

En fín… tengo la esperanza de que una frase adecuada dé solución a
todo este galimatías de dudas. Perdón por el
tostón.
Cualquier comentario es bien venido.
Muchas gracias
f.


#2

Por si a alguien le apetece probar cosillas:

http://github.com/fguillen/trip_destination_test/tree/master

f.


#3

Un idioma normal para hacer esto es por ejemplo:

trip = Trip.new(:name => ‘tname’)
trip.destinations.build(:name => ‘dname’)
trip.save

El save de trip graba en cascada sus destinations, y esto sucede en
una misma transaccion. Las has_many en AR normalmente se manejan desde
el padre, en este caso trip, via la API que genera la macro.

Si tienes configurado esto:

class Trip < AR::Base
has_many :destinations
validates_associated :destinations
end

la validacion de los destinations sucede mientras se valida el trip.
Se recorre la coleccion y se pregunta si valid? a los hijos. En
particular se ejecuta antes de que el trip se haya grabado, de manera
que si ademas tienes

class Destination < AR::Base
belongs_to :trip
validates_presence_of :trip_id
end

esto simplemente no puede funcionar, como te has encontrado, ya que en
ese momento ni siquiera existe un ID en el trip mismo.

En un caso así yo quito el validates_presence_of del hijo. En teoria
se podrían crear objetos hijo sin el trip_id, es cierto, pero no tiene
buena solucion. O bien quitas el validates_associated, o bien lo otro.

La mejor opcion creo que depende del uso. Por ejemplo una linea de
factura no tiene sentido sin la factura, en tal caso se queda el
validates_associated porque la manera natural de crear lineas de
factura es desde su factura padre, no silvestremente. Si los
destination no se puede crear sueltos con garantias de que sean
validos eso se documenta en Destination.

Por otro lado piensa que la validacion empty? en Trip es fragil, ya
que uno podria incluso borrar la tabla DESTINATIONS y los Trips ni se
enterarian.

No tiene facil solucion, son temas circulares y hay que romper el
circulo en algun punto y confiar en que la asociacion se usa de cierto
modo.


#4

Ah, en cuanto a la posibilidad de quitar la validacion empty? tienes
la opcion de meterlo todo en una transaccion:

transaction do
Trip.create!
# raise if some added destination is invalid
end

Pero ahi desplazamos el problema a que un Trip puede quedar invalido
porque se permite crearlo sin destinations, y de nuevo hay que confiar
en como se usa la relacion.

Por cierto que antes se ma ha ido la pinza, aunque uno quite
validates_associated el problema sigue siendo el mismo.


#5

El día 7 de diciembre de 2008 2:44, Xavier N. removed_email_address@domain.invalid
escribió:> Si tienes configurado esto:

class Destination < AR::Base
belongs_to :trip
validates_presence_of :trip_id
end

esto simplemente no puede funcionar, como te has encontrado, ya que en
ese momento ni siquiera existe un ID en el trip mismo.

Juanjo me propuso esta
aproximación:
trip = Trip.new( :name => ‘el viaje’ )
trip.destinations << Destination.new( :name => ‘madrid’, :trip =>
trip )
trip.destinations << Destination.new( :name => ‘valencia’, :trip =>
trip )

Y funciona con la doble validación siempre y cuando la validación en
Destination sea esta:
validates_presence_of :trip, :message => “Hay que asociarla a un Trip”

Y no esta:
validates_presence_of :trip_id, :message => “Hay que asociarla a un
Trip”

Así mismo tu aproximación también funciona:
trip = Trip.new( :name => ‘el viaje’ )
trip.destinations.build( :name => ‘madrid’, :trip => trip )
trip.destinations.build( :name => ‘valencia’, :trip => trip )

Siempre y cuando como ya he dicho se valide la presencia de trip en
Destination y no trip_id

Mirar test_create_04 y 05:
http://github.com/fguillen/trip_destination_test/tree/master/test/unit/trip_test.rb

Por otro lado piensa que la validacion empty? en Trip es fragil, ya
que uno podria incluso borrar la tabla DESTINATIONS y los Trips ni se
enterarian.

Cierto.

Gracias Xavi, con tu ayuda y la de Juanjo ya veo solución a la pregunta 2.

f.


#6

El día 7 de diciembre de 2008 18:55, Xavier N. removed_email_address@domain.invalid
escribió:> :trip_id no vaya a ser blank?:

d = Destination.create!(:name => ‘dname’, :trip => Trip.new)
d.trip # => #Trip:...
d.trip_id # => nil

d.reload
d.trip # => nil
d.trip_id # => nil

No había caído en esta… :confused:

  1. Poner validates_presence_of :trip_id: En este caso sabemos que si
    se graba tiene un :trip_id, pero no sabemos si ese ID es correcto,
    solo sabemos que ahi hay algo. Podemos crear registros invalidos.

Pero claro si activamos esta hay que pasarle un id de un Trip ya
guardado … y no se puede guardar ningún Trip sin por lo menos un
Destination …

  1. Usar el plugin validates_existence_of: Con esto se efectua una
    query que valida que existe el registro padre. Podemos aun crear una
    referencia invalida via una race condition, es mucho mas improbable
    pero puede pasar.

Mola el plugin… genial

En resumen, los dos requerimientos en Trip y Destination son algo
circulares. Hay que sacrificar la robustez de la validacion de un
objeto suelto en uno de los lados, y hay que escoger cual. La
aplicacion necesariamente tendra que crear los objetos de un cierto
modo para que todo cuadre.

En resumen que voy a dejar la validación de Destination con
validate_presence_of :trip … o igual ni ponerla y
yastá.
Gracias Xavi.

f.


#7

2008/12/7 Fernando G. removed_email_address@domain.invalid:

trip = Trip.new( :name => ‘el viaje’ )
trip.destinations << Destination.new( :name => ‘madrid’, :trip => trip )
trip.destinations << Destination.new( :name => ‘valencia’, :trip => trip )

Y funciona con la doble validación siempre y cuando la validación en
Destination sea esta:
validates_presence_of :trip, :message => “Hay que asociarla a un Trip”

Es otra opcion, pero sigues pudiendo crear destinations invalidas.
Para resumir, en este tema hay cuatro soluciones, de mas debil a mas
restrictiva:

  1. No poner validates_presence_of: En ese caso la aplicacion es
    responsable de asignar el :trip_id, por lo general trabajando desde
    trip.destinations.build o equivalentes. Se puede crear una destination
    silvestre invalida claramente.

  2. Poner validates_presence_of :trip: En ese caso destination.trip se
    sabe que no es blank? al grabar, pero no tenemos garantizado que
    :trip_id no vaya a ser blank?:

    d = Destination.create!(:name => ‘dname’, :trip => Trip.new)
    d.trip # => #Trip:...
    d.trip_id # => nil

    d.reload
    d.trip # => nil
    d.trip_id # => nil

Podemos seguir creando destinations invalidas porque todo lo que se
comprueba es que en el momento de validar exista un trip asociado, eso
como ves no implica necesariamente que el registro grabado vaya a
tener un :trip_id.

  1. Poner validates_presence_of :trip_id: En este caso sabemos que si
    se graba tiene un :trip_id, pero no sabemos si ese ID es correcto,
    solo sabemos que ahi hay algo. Podemos crear registros invalidos.

  2. Usar el plugin validates_existence_of: Con esto se efectua una
    query que valida que existe el registro padre. Podemos aun crear una
    referencia invalida via una race condition, es mucho mas improbable
    pero puede pasar.

En resumen, los dos requerimientos en Trip y Destination son algo
circulares. Hay que sacrificar la robustez de la validacion de un
objeto suelto en uno de los lados, y hay que escoger cual. La
aplicacion necesariamente tendra que crear los objetos de un cierto
modo para que todo cuadre.


#8

Atendiendo a la pregunta 3 he encontrado estos screencasts del, de
nuevo increible, Ryan:
http://railscasts.com/episodes/73-complex-forms-part-1
http://railscasts.com/episodes/74-complex-forms-part-2
http://railscasts.com/episodes/75-complex-forms-part-3

Que parecen están pensados para mostrar una propuesta de patrón para
resolver el escenario que proponía.

f.