Sweeper en modelo intermedio de relación has_many through

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.

Guillermo,

sin haber provado el código, y fiándome 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 relación con el operador <<

Como observación, 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. :wink:

Has provado utilizando after_create en lugar de after_save?

Salutaciones,

Isaac Feliu

2008/8/13 Guillermo [email protected]:

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.

2008/8/14 Daniel R. Troitiño [email protected]:

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.

2008/8/14 Guillermo [email protected]:

No se llama al observer
creo que también debería invocarlo.

¿Qué opinan?

[1] #826 delete in a relation doesn't call sweeper - Ruby on Rails - rails

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.

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.

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

2008/8/14 Guillermo [email protected]:

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 :wink: ).

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.

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] ActiveRecord::Base

Hace un SQL DELETE sin instanciar los objetos. Es una manera eficiente
de borrar por ejemplo unos cuantos centenares de registros.

On Thu, Aug 14, 2008 at 20:46, Francesc E.
[email protected] 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.

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 :-).

2008/8/17 Xavier N. [email protected]:

       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.

2008/8/18 Guillermo [email protected]:

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.

2008/8/17 Daniel R. Troitiño [email protected]:

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.

2008/8/18 Guillermo [email protected]:

2008/8/18 Daniel R. Troitiño [email protected]:

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.

2008/8/18 Daniel R. Troitiño [email protected]:

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.