Ruby Forum Rails-ES > Sweeper en modelo intermedio de relación has_many through

Posted by Guillermo (Guest)
on 13.08.2008 18:30
(Received via mailing list)
Buenas. A ver si alguien sabe algo.

pongamos este caso:

class User < ActiveRecord::Base
  has_many :users_friends
  has_many :friends, :through => :users_friends, :extend => 
UsersProperties
end

class UsersFriend < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
end

class UsersFriendSweeper < ActionController::Caching::Sweeper
  observe UsersFriend
  def after_save(user_relatioship)
    expire_fragment("barra_de_amigos_#{user_relatioship.user.id}")
  end
end

EOF

El sweeper funciona normalmente, excepto en casos como:

User.find_by_username("guillermo").friends <<
User.find_by_username("cientifico")

Donde el swepper no es llamado. Me parece lógico (salvo que esté
metiendo la pata), que al no intervenir el model UsersFriend
(internamente tirará de joins de la base de datos), directamente el
Observer no se ejecute.

Lo que pregunto es ¿Qué soluciones le véis a este problema? Cuando
digo soluciones, me refiero a soluciones algo más limpias que poner el
código en la acción, o no usar las relaciones de rails y crear
amistades a la vieja usanda UsersFriend.create(:user =>
User.find_by_username("guillermo"), :friend=>
User.find_by_username("cientifico")). Estoy pensando en alguna forma
de hacer que AR invoque el swepper a mano... pero no se me ocurre.

Muchas gracias.
Posted by Isaac Feliu Pérez (Guest)
on 13.08.2008 19:36
(Received via mailing list)
Guillermo,

sin haber provado el cdigo, y findome de lo que dices, creo que esto
deberia ser considerado un BUG de ActiveRecord, ya que se est  
haciendo un insert en la tabla al crear la relacin con el operador <<

Como observacin, en el sweeper no faltaria un after_destroy? Se
supone que si tu y el cientifico os enfadais y dejais de ser amigos
deberia reflejarse en la barra de amigos. ;)

Has provado utilizando after_create en lugar de after_save?

Salutaciones,
--
Isaac Feliu
Posted by Daniel Rodriguez Troitiño (Guest)
on 14.08.2008 00:26
(Received via mailing list)
2008/8/13 Guillermo <guillermo@cientifico.net>:
>  belongs_to :user
> EOF
>
> --
> Guillermo Álvarez

Acabo de hacer una prueba (Rails 2.1, pero he visto el código de Rails
2.0 y 1.2.6 y no era muy diferente) y ha funcionado correctamente.

Mí código es el siguiente (los "cambios" los explico en comentarios 
inline):

---
class User < ActiveRecord::Base
  # Pongo el class_name pq llamé a la clase en plural y no me apeticia
cambiarla,
  # no debería ser importante
  has_many :users_friends, :class_name => 'UsersFriends'

  # Falta tu extend que no debería influir
  has_many :friends, :through => :users_friends
end

# Creo que esta es igual excepto el nombre
class UsersFriends < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id'
end

# Esta también es igual pero en vez del expire hago un log para verlo.
class UsersFriendsSweeper < ActionController::Caching::Sweeper
  observe UsersFriends
  def after_save(user_friend)
    user_friend.logger.info('¡Ay omá que rica!')
  end
end
---

Para disparar el sweeper en vez de montar una aplicación entera he
montado un script en lib con una técnica que discutimos en la lista
hace una semana:

---
# require 'ruby-debug'
require 'action_controller/test_process'

ActiveRecord::Base.observers = [UsersFriendsSweeper]
ActiveRecord::Base.instantiate_observers

UsersFriendsSweeper.instance.controller = ActionController::Base.new

tr = ActionController::TestRequest.new
UsersFriendsSweeper.instance.controller.request = tr
UsersFriendsSweeper.instance.controller.instance_eval do
  @url = ActionController::UrlRewriter.new(tr, {})
end

u1 = User.create :name => 'usuario' + rand(1000).to_s
u2 = User.create :name => 'usuario' + rand(1000).to_s

# debugger

u1.friends << u2
---

Como ves es "similar" al ejemplo que pones tú, y en el log aparece:

---
User Create (0.000907)   INSERT INTO "users" ("name", "updated_at",
"created_at") VALUES('usuario518', '2008-08-13 22:23:22', '2008-08-13
22:23:22')
  User Create (0.000451)   INSERT INTO "users" ("name", "updated_at",
"created_at") VALUES('usuario417', '2008-08-13 22:23:22', '2008-08-13
22:23:22')
  UsersFriends Create (0.000560)   INSERT INTO "users_friends"
("updated_at", "user_id", "friend_id", "created_at")
VALUES('2008-08-13 22:23:22', 13, 14, '2008-08-13 22:23:22')
¡Ay omá que rica!
---

Es decir, el sweeper sí se dispara (y se crean todos los registros
correspondientes a los modelos).

Comprueba si en tu log aparece la línea "UsersFriends Create", y
comprueba que tu sweeper se carga en environement.rb o similar (aunque
si dices que "funciona normalmente" en otras situaciones debería estar
cargado correctamente).

Suerte.
Posted by Guillermo (Guest)
on 14.08.2008 01:20
(Received via mailing list)
2008/8/14 Daniel Rodriguez Troitiño <notzcoolx@yahoo.es>:
> Es decir, el sweeper sí se dispara (y se crean todos los registros
> correspondientes a los modelos).
>

Lo acabo de comprobar, (yo si me hice la aplicación entera).

> Comprueba si en tu log aparece la línea "UsersFriends Create", y
> comprueba que tu sweeper se carga en environement.rb o similar (aunque
> si dices que "funciona normalmente" en otras situaciones debería estar
> cargado correctamente).

Solo me queda que en 1.2.6 no funcione, o lo que cada vez veo más
factible, que la haya cagado en algún punto.

Mañana lo comento.

Muchas gracias por las respuestas, y si resulta que metí la pata: Mis 
disculpas.
Posted by Guillermo (Guest)
on 14.08.2008 12:57
(Received via mailing list)
Vale. Se ha juntado algún fallo mio, con lo que creo que es un bug de 
rails

Ya encuentro por que falla.
El método << que es el que se utiliza para añadir a los amigos, sí
llama al observer.
El método delete, NO llama al observer.
Más claro.
Pongamos acción destroy, y si ponemos
    @users_friend = UsersFriend.find(params[:id])
    @users_friend.user.friends.delete(@users_friend.friend)
No se llama al observer

Sin embargo si ponemos
    @users_friend.destroy

Si se llama.

He creado un ticket[1], ya que si cosas como:
    @users_friend.user.friends   <<   (@users_friend.friend)
si invocan al sweeper,
    @users_friend.user.friends.delete(@users_friend.friend)
creo que también debería invocarlo.

¿Qué opinan?


[1] 
http://rails.lighthouseapp.com/projects/8994/tickets/826-delete-in-a-relation-doesn-t-call-sweeper#ticket-826-1
Posted by Daniel Rodriguez Troitiño (Guest)
on 14.08.2008 14:02
(Received via mailing list)
2008/8/14 Guillermo <guillermo@cientifico.net>:
> No se llama al observer
> creo que también debería invocarlo.
>
> ¿Qué opinan?
>
>
> [1] http://rails.lighthouseapp.com/projects/8994/tickets/826-delete-in-a-relation-doesn-t-call-sweeper#ticket-826-1
>

Parece ser que cuando haces delete de una asociación has_many :through
se hace delete de los registros, y no destroy, por lo que nunca se
dispararán los callbacks.

Una solución alternativa parece ser utilizar callbacks :after_remove
de la asociación (se pasan como opción a la asociación), pero no he
conseguido hacer que funcionen desde un Observer/Sweeper (ni desde
User ni desde UsersFriend). Según la documentación un Observer puede
utilizar los callbacks del módulo Callback, por desgracia
:after_remove no está en él (y por lo poco que he encontrado en Google
estos callbacks no funcionaban en has_many:through hasta la 2.1).

En Rails-doc (según Google) hay alguna nota sobre estos callbacks,
pero ahora está en mantenimiento y no he podido leerla, quizá aclare
algo.

Suerte.
Posted by Guillermo (Guest)
on 14.08.2008 15:04
(Received via mailing list)
Comprendo que un user.friends.delete(friend) no llame a los callbacks.
Hay que estar atento, por que a nivel informático es lógico (por lo
menos para mi) que no lo haga, sin embargo a nivel conceptual, si
defino unos callbacks para antes de que una relación se destruya, esos
callbacks, considero que se debería ejecutar.

Soy consciente de que user.friends << friend no es el mísmo método,
pero veo poco coherente este comportamiento (En un caso sí y en otro
no)

Si tu tienes una abstracción de la base de datos, esta debería de ser
uniforme independientemente de como accedas a los datos que esta
contiene. Es esa la razón por lo que considero que es un bug.

Creo que no debería tener cuidado de que manera borro una/unas filas
de la base de datos.

Y hasta que esto se solucionase, creo que debería de desactivarse el
uso de los callbacks (y notificarse en la documentación) hasta que
esté soportado en todos los métodos. Soy programador y hago un
software en función de la api. No debería de tener que pensar o
revisar el intríngulis del asunto.

Si este fin de semana saco tiempo, haré la prueba con datamapper, para
ver si le pasa lo mismo.

Un Saludo.
Posted by Daniel Rodriguez Troitiño (Guest)
on 14.08.2008 16:38
(Received via mailing list)
2008/8/14 Guillermo <guillermo@cientifico.net>:
> Si tu tienes una abstracción de la base de datos, esta debería de ser
> revisar el intríngulis del asunto.
>
> Si este fin de semana saco tiempo, haré la prueba con datamapper, para
> ver si le pasa lo mismo.
>
> Un Saludo.
>
>
> --
> Guillermo Álvarez

Sí, los callbacks en Rails a veces lian más las cosas de las que las
arreglan. Sólo hay que mirar que los counter de las asociaciones son
incrementados o no dependiendo del método utilizado. Y en realidad
todo es un poco culpa de no querer confiar en las bases de datos,
porque los trigger de las bases de datos se ejecutarían siempre, ya
utilizasemos destroy o delete (sí, ya se que escribir todo en Ruby es
más cómodo ;) ).

He encontrado una (rebuscada) forma para que funcione... al menos en
Rails 2.1 y Ruby 1.8.6:

---
class User < ActiveRecord::Base
  has_many :users_friends, :class_name => 'UsersFriends'
  has_many :friends, :through => :users_friends,
    :before_remove => :fire_before_remove_of_users_friends

private
  def fire_before_remove_of_users_friends(friend)
    uf = self.users_friends.find_by_friend_id(friend.id)
    uf.class.changed
    uf.class.notify_observers(:before_remove, uf)
  end
end
---

Utilizo before_remove y no after_remove porque en el momento de
disparse este último ya no existe el UsersFriends. Supongo que tu
callback únicamente borra la cache y no realiza más acciones, pero
debes tener en cuenta que puede que la asociación no sea destruida
(algo que no importa si borras la cache, ya que al regenerarla se
volverá a crear correctamente).

Luego es cuestion de tener un before_remove en tu Sweeper y todo listo.

Espero que te sirva. Suerte.
Posted by Francesc Esplugas (fesplugas)
on 14.08.2008 20:47
(Received via mailing list)
Te has mirado la documentación de Rails. Yo creo que lo dice bien claro:

delete(id) [1]

"Delete an object (or multiple objects) where the id given matches the
primary_key. A SQL DELETE command is executed on the database which
means that no callbacks are fired off running this. This is an
efficient method of deleting records that don't need cleaning up after
or other actions to be taken.
Objects are not instantiated with this method."

[1] http://api.rubyonrails.org/classes/ActiveRecord/Base.html#M001306

Hace un SQL DELETE sin instanciar los objetos. Es una manera eficiente
de borrar por ejemplo unos cuantos centenares de registros.
Posted by Daniel Rodriguez Troitiño (Guest)
on 14.08.2008 21:45
(Received via mailing list)
On Thu, Aug 14, 2008 at 20:46, Francesc Esplugas
<francesc.esplugas@gmail.com> wrote:
>
> [1] http://api.rubyonrails.org/classes/ActiveRecord/Base.html#M001306
>
> Hace un SQL DELETE sin instanciar los objetos. Es una manera eficiente
> de borrar por ejemplo unos cuantos centenares de registros.

Sí, en ese punto de la documentación está muy claro, pero en la
documentación de has_many solo pone lo siguiente:

collection.delete(object, ...) - Removes one or more objects from the
collection by setting their foreign keys to NULL. This will also
destroy the objects if they're declared as belongs_to and dependent on
this model.

En el primer caso utiliza "remove" que no deja claro si es un "delete"
o un "destroy", y luego comenta algo sobre "destroy" y "belongs_to"...
pero en la documentación de belongs_to dice:

:dependent - If set to :destroy, the associated object is destroyed
when this object is. If set to :delete, the associated object is
deleted without calling its destroy method. This option should not be
specified when belongs_to is used in conjunction with a has_many
relationship on another class because of the potential to leave
orphaned records behind.

Así que no lo entiendo, en un lado te dicen que declares en el
belongs_to que eres dependent del otro modelo, y luego te dicen que no
deberías en relaciones has_many. Habría que hacer las pruebas
pertinentes, pero quizá con un par de dependent en los belongs_to del
join model consiga dispararse el callback (al ser destruido el objeto
y no eliminado).

Pero la queda de Guillermo (y la mía) es que en ese punto Rails es
contraproductivo: tu esperas que si eliminas un registro sus callbacks
after_destroy sean invocados. Digamos que si utilizas
ActiveRecord::Base#delete **a posta** sabes que los callbacks no van a
ser invocados, pero si utilizas un método como collection.delete Rails
no te da la oportunidad de "activar" o "desactivar" los callbacks.
Posted by Xavier Noria (fxn)
on 17.08.2008 04:02
(Received via mailing list)
Esto no esta muy bien documentado, le echare un repaso.

En las has_many normales #delete invoca al destroy del hijo si la
opcion :dependent es :destroy. En las has_many :trough no es que haya
un bug, es que no esta hecho:

        # TODO - add dependent option support
        def delete_records(records)
          klass = @reflection.through_reflection.klass
          records.each do |associate|
            klass.delete_all(construct_join_attributes(associate))
          end
        end

Ahi procede un parchecito veraniego :-).
Posted by Daniel Rodriguez Troitiño (Guest)
on 17.08.2008 11:45
(Received via mailing list)
2008/8/17 Xavier Noria <fxn@hashref.com>:
>            klass.delete_all(construct_join_attributes(associate))
>          end
>        end
>
> Ahi procede un parchecito veraniego :-).

No entiendo una cosa sobre la documentación y el código. Me parece que
la opción dependent está funcionando en dos situaciones algo
diferentes y no se si son compatibles o al menos una de ellas debería
estar reflejada en la documentación (ahora no lo está).

Me explico: según la documentación de has_many la opción :dependent 
sirve para:

:dependent - If set to :destroy all the associated objects are
destroyed alongside this object by calling their destroy method. If
set to :delete_all all associated objects are deleted without calling
their destroy method. If set to :nullify all associated objects'
foreign keys are set to NULL without calling their save callbacks.
Warning: This option is ignored when also using the :through option.

En la primera frase dice que si utilizas :destroy los objetos
asociados se destruyen cuando el objeto se destruye, pero en la
segunda y tercera frases me dan a entender que que también la opción
se utiliza para decidir que sucede cuando se eliminan objetos de la
asociación. Obviamente la última frase se refiere al TODO que has
comentado.

Supongo que ese TODO está ahí porque has_many :through evoluciona
desde habtm, donde no existen tales problemas al no existir un modelo
join. En mi opinión, con un modelo join debería hacerse caso de la
opción :dependent para eliminar o destruir los registros del modelo
join (y de esta forma se puedan disparar los callbacks que necesitaba
Guillermo).

No me parece un parche complicado (más o menos es utilizar el
delete_records de has_many, ignorando la opción por defecto de
:nullify). Y a la vez podría modificarse la documentación para
explicar exactamente el funcionamiento de la opción :dependent, y
aclarar el funcionamiento por defecto cuando se utiliza la opción
:through.

Cuando envies el parche no olvides mandar el enlace a la lista para
que lo evaluemos y le demos nuestros +1 ;).

Suerte.
Posted by Guillermo (Guest)
on 18.08.2008 13:43
(Received via mailing list)
2008/8/17 Daniel Rodriguez Troitiño <notzcoolx@yahoo.es>:
> No me parece un parche complicado (más o menos es utilizar el
> delete_records de has_many, ignorando la opción por defecto de
> :nullify). Y a la vez podría modificarse la documentación para
> explicar exactamente el funcionamiento de la opción :dependent, y
> aclarar el funcionamiento por defecto cuando se utiliza la opción
> :through.

El problema que hay es más filosófico, y dependerá de la filosofía que
lleven ahora los cores.
El no instanciar objetos para realizar un simple sql da un rendimiento
más o menos óptimo. Esto por ejemplo nos permite borrar más o menos
rápido sin consumo de cpu/memoria, todas sus relaciones (posts,
mensajes, acciones, etc...).
Para que se llamen a los callbacks, habría que instanciar cada objeto
y hacer un destroy. Esto multiplicaría el número de deletes de uno por
relación a el número total de elementos. Instanciar cada fila para
hacer un destroy puede llegar a ser muy costoso.

La verdad, yo no me decanto por cual de las dos opciones es mejor.
Pero si no puedes construir una api uniforme... la solución creo que
es simplificar y no permitirlo nunca, o remarcar mucho más de lo que
está ahora en que casos.

Todavía no he mirado como lo hace datamapper.
Posted by Daniel Rodriguez Troitiño (Guest)
on 18.08.2008 14:10
(Received via mailing list)
2008/8/18 Guillermo <guillermo@cientifico.net>:
> El no instanciar objetos para realizar un simple sql da un rendimiento
> es simplificar y no permitirlo nunca, o remarcar mucho más de lo que
> está ahora en que casos.
>

Sí, la sobrecarga de un destroy sobre cada objeto la entiendo (frente
a un simple "DELETE FROM table"), pero las relaciones has_many
proporcionan la opción de pedir *explicitamente* que se utilicen
destroys en vez de deletes (bueno, en realidad en las relaciones
has_many por defecto simplemente ponen a null el foreign key, sin
disparar callbacks, por cierto).

Obviamente los desarrolladores han escogido la opción menos cara
(actualizar, frente a borrar, frente a destruir), pero al menos han
proporcionado la opción de que el usuario elija una de las otras.

En el caso de has_many :through la opción de poner a null el foreign
key no tiene mucho sentido (se quedarían modelos join por ahí
colgando), por lo que la opción de eliminar es la que tiene más
sentido y es menos cara. El problema es que los desarrolladores han
dejado como TODO proporcionar al usuario la opción de decidir que
política se seguirá, cuando, imho, no es tan complicado añadirlo y
documentarlo.

Espero que el parche sea aceptado, porque recurrir a las triquiñuelas
que se han comentado en el hilo me parece... poco profesional.
Posted by Guillermo (Guest)
on 18.08.2008 15:02
(Received via mailing list)
2008/8/18 Daniel Rodriguez Troitiño <notzcoolx@yahoo.es>:
> El problema es que los desarrolladores han
> dejado como TODO proporcionar al usuario la opción de decidir que
> política se seguirá, cuando, imho, no es tan complicado añadirlo y
> documentarlo.

Añadirlo no es complicado. Añadirlo y que el rendimiento sea
comparable al actual, creo que si.

>
> Espero que el parche sea aceptado, porque recurrir a las triquiñuelas
> que se han comentado en el hilo me parece... poco profesional.

No se si lo comenté más arriba, pero al final decidí, incluso
instaurármelo como costumbre, el hacerlo sin magia.
UserFriend.create(:user_id => current_user.id, :friend_id => 
params[:id])
y
UserFriend.find(:first, :conditions => "...").destroy

Así me aseguro que siempre se llaman los callbacks.

Un Saludo.
Posted by Daniel Rodriguez Troitiño (Guest)
on 18.08.2008 16:57
(Received via mailing list)
2008/8/18 Guillermo <guillermo@cientifico.net>:
> 2008/8/18 Daniel Rodriguez Troitiño <notzcoolx@yahoo.es>:
>> El problema es que los desarrolladores han
>> dejado como TODO proporcionar al usuario la opción de decidir que
>> política se seguirá, cuando, imho, no es tan complicado añadirlo y
>> documentarlo.
>
> Añadirlo no es complicado. Añadirlo y que el rendimiento sea
> comparable al actual, creo que si.

El rendimiento sería el mismo para la opción por defecto. El
rendimiento con :destroy sería el mismo que la misma opción de
has_many (no :through) con :destroy. Bueno, vale, hay que añadirle un
if, pero, a pesar que creo que Ruby tiene algún que otro problema de
eficiencia, yo no me preocuparía por un if...else.

> Así me aseguro que siempre se llaman los callbacks.
>

Acuerdate de documentarlo y explicarlo, para que el que venga después
no se rasque la cabeza pensando porqué has hecho eso, o peor aún lo
cambié sin pensar :D.