Forum: Rails-ES sobre patrones de creación: doble dep endencia has_many, belongs_to

Announcement (2017-05-07): www.ruby-forum.com is now read-only since I unfortunately do not have the time to support and maintain the forum any more. Please see rubyonrails.org/community and ruby-lang.org/en/community for other Rails- und Ruby-related community platforms.
Fernando G. (Guest)
on 2008-12-06 20:51
(Received via mailing list)
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-thr...

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.
Fernando G. (Guest)
on 2008-12-07 00:34
(Received via mailing list)
Por si a alguien le apetece probar cosillas:

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

f.
Xavier N. (Guest)
on 2008-12-07 03:45
(Received via mailing list)
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.
Xavier N. (Guest)
on 2008-12-07 04:25
(Received via mailing list)
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.
Fernando G. (Guest)
on 2008-12-07 13:52
(Received via mailing list)
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/t...


>
> 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.
Xavier N. (Guest)
on 2008-12-07 19:56
(Received via mailing list)
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.

3. 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.

4. 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.
Fernando G. (Guest)
on 2008-12-07 20:35
(Received via mailing list)
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.. :/

>
> 3. 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 ..

>
> 4. 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.
Fernando G. (Guest)
on 2008-12-07 20:41
(Received via mailing list)
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.
This topic is locked and can not be replied to.