Sigo con mi proyecto final de carrera y poco a poco vamos aprendiendo cosillas nuevas de Rails. Al mismo tiempo que voy picando código en el controlador, modelo y vistas voy testeando, dos frentes de aprendizaje pero que recomiendo a todos los que comiencen en Rails. Aunque al principio avanzas de forma más lenta y te das (como estoy haciendo yo) de bruces contra un muro una y otra vez, a la larga aprendes mucho tanto de tus múltiples errores como de tus aciertos.
Una vez tenía hecho el test de un update supuestamente correcto obtuve un error al actualizar los atributos de un objeto. En primera instancia pensé que había olvidado en el modelo alguna cosa, pero también estaba correcto. Tras una búsqueda en Google encontré la solución.
Extrayendo la explicación del artículo
Introduce read-only records. If you call object.readonly! then it will mark the object as read-only and raise ReadOnlyRecord if you call object.save. object.readonly? reports whether the object is read-only. Passing :readonly => true to any finder method will mark returned records as read-only. The :joins option now implies :readonly, so if you use this option, saving the same record will now fail. Use find_by_sql to work around.
O lo que es lo mismo, si tu objeto lo obtienes tirando de relaciones, por ejemplo, un has_many :through, por debajo está haciendo un INNER JOIN con el modelo intermedio, y ahi está el problema, que el objeto pasa a ser de solo lectura. Las soluciones podrían ser dos:
- Obligar al objeto a que no sea solo lectura indicando como parámetro del find :readonly => false
- Utilizar include en lugar de join, si es posible
Hasta aquí lo que venías buscando. Pero si quieres ver un ejercicio práctico sobre relaciones y cómo evitar el ActiveRecord::ReadOnlyRecord en él sigue leyendo.
Ejemplo práctico
Imaginemos que estamos en el WoW (oh dios, no puedo creer que esté escribiendo esto). Tenemos hermandades (guild) y jugadores (users). Las hermandades pueden tener varios jugadores, de los cuales varios pueden ser Maestre, algo así como los “admin” de la hermandad, y puede haber más de un Maestre por hermandad. Además los jugadores pueden estar en varias hermandades.
Seguro que se puede hacer de varias formas, pero nosotros vamos a hacerlo mediante un has_many :through, utilizando como modelo intermedio membership, donde tenemos un campo owner que nos indica si es o no maestre de la hermandad.
Empezamos con los modelos:
guild.rb
has_many :memberships
has_many :users, :through => :memberships, :uniq => true
has_many :owners, :through => :memberships, :source => :user, :conditions => { "memberships.owner" => true }
validates_presence_of :name
De esta manera si tenemos una hermandad en @guild podemos saber quienes son todos sus miembros haciendo @guild.users y sus maestres @guild.owners.
IMPORTANTE: Este ejemplo no está completo. Es decir, si hacemos @guild.owners << User.find(5) (por ejemplo) no crea adecuadamente el membership, ya que faltaría marcar el campo owner del membership creado a true. Eso lo dejo para otro post y no seguir complicando las cosas.
user.rb
has_many :memberships
has_many :guilds, :through => :memberships
has_many :owned_guilds, :through => :memberships, :source => :owner, :conditions => { "memberships.owner" => true }
De esta manera podemos saber qué hermandades lidera un jugador haciendo @user.owned_guilds y a cuales pertenece (sea maestre o no) haciendo @user.guilds.
De nuevo, importante: Este ejemplo no está completo. Es decir, si hacemos @user.owned_guilds << Guild.find(7) (por ejemplo) no crea adecuadamente el membership, ya que faltaría marcar el campo owner del membership creado a true.
ownership.rb
belongs_to :user
belongs_to :owner, :class_name => "User"
belongs_to :guild
Hasta aquí el ejercicio práctico de relaciones, ahora vamos al lío. Imaginemos que queremos actualizar el nombre de nuestra hermandad. Para hacer esto debes ser maestre de la hermandad. Si tenemos en cuenta que no hemos metido aquí un sistema de permisos (rollo CanCan o similares) podemos solucionar esto de dos formas:
Buscar y modificar si es posible
guilds_controller.rb
def edit
@guild = Guild.find(params[:id])
end
def update
@guild = Guild.find(params[:id])
if @guild.update_attributes(params[:guild])
redirect_to guild_path(@guild), :success => "Guild actualizada"
else
render :edit
end
end
Algo de refactoring básico:
guilds_controller.rb
before_filter :get_guild
def edit
end
def update
if @guild.update_attributes(params[:guild])
redirect_to guild_path(@guild), :success => "Guild actualizada"
else
render :edit
end
end
protected
def get_guild
@guild = Guild.find(params[:id])
end
Además vamos a tener aquello de que actualizar el nombre de la hermandad solo puede hacerlo el maestre.
guilds_controller.rb
before_filter :get_guild
before_filter :check_ownerhip
def edit
end
def update
if @guild.update_attributes(params[:guild])
redirect_to guild_path(@guild), :success => "Guild actualizada"
else
flash[:error] = "No ha sido posible actualizar los datos de la guild"
render :edit
end
end
protected
def get_guild
@guild = Guild.find(params[:id])
end
def check_ownership
redirect_to user_path(current_user), :error => "No tienes permisos" unless current_user.owned_guilds.include?(@guild)
end
En este primer ejemplo, no obtenemos un ActiveRecord::ReadOnlyRecord ya que obtenemos la @guild directamente (sin el uso del join de la has_many :through) y el objeto @guild no es de solo lectura. La consulta que se hace a base de datos sería:
SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 1 LIMIT 1
Tirar de relaciones y 404
Esta es la segunda forma, donde no vamos a utilizar un before_filter para comprobar si el jugador es maestre de la hermandad. En su lugar vamos a aprovecharnos de que al buscar entre las owned_guilds de un jugador una de la que no sea maestre en Rails obtenemos un ActiveRecord::RecordNotFound, o lo que es lo mismo, un 404.
guilds_controller.rb
before_filter :get_guild
protected
def get_guild
@guild = current_user.owned_guilds.find(params[:id])
end
El resto de acciones del controlador permanece intacto (exceptuando como dije, el before_filter :check_ownership que no estaría en este segundo ejemplo).
Y ahora… por fin, ¿y el ActiveRecord::ReadOnlyRecord donde anda en este segundo ejemplo? Pues al hacer @guild = current_user.owned_guilds.find(params[:id]), @guild es de solo lectura, al haber tirado de relaciones.
Suponiendo que el User con id igual a 1 quiere actualizar la Guild con id igual a 1. Lo primera lanzaría la siguiente consulta:
SELECT `guilds`.* FROM `guilds` INNER JOIN `ownerships` ON `guilds`.id = `ownerships`.guild_id WHERE `guilds`.`id` = 1 AND ((`ownerships`.user_id = 1)) LIMIT 1
Ahí podéis ver el JOIN al que antes hacía mención. Habiendo un JOIN de por medio, el objeto es de solo lectura. Y no podremos actualizarlo.
Tío, termina ya, so pesao
Pues la solución para mantener el segundo ejemplo tal cual y poder actualizar es tan simple como indicar que no es de solo lectura. ¿Cómo?
before_filter :get_guild
protected
def get_guild
@guild = current_user.owned_guilds.find(params[:id], :readonly => false)
end
Vaya artículo largo. No tengo remedio. Al menos espero que le sirva a alguien. Y como siempre, si alguien tiene algo que aportar o corregir, dispone de los comentarios para ello, yo lo agradezco un montón.