Finders dinámicos en ActiveRecord
Desde que trabajo con Ruby on Rails de manera intensiva cada día, una de las cosas que más me incordia es abandonar el entorno de script/console para lanzar la (al menos para mí) menos cómoda interfaz de mysql La razón por la que me veo obligado a hacer esto con cierta frecuencia es para encontrar los ids de los objetos ActiveRecord que me interesan para investigar o depurar alguna funcionalidad. Cuando la aplicación tiene cientos de registros en las tablas, viene muy bien poder hacer consultas por el nombre aproximado con la construcción LIKE de SQL. ActiveRecord nos permite escribir nuestro propio SQL pero como soy un vago me resulta bastante tedioso escribir la condición completa con la sintaxis que pide Rails, así que normalmente ando saliendo de la consola, escribiendo una sentencia SELECT ... LIKE en mysql y volviendo a la consola con el id apuntado.
Hasta hoy, que me he terminado de cansar y he escrito un plugin que permite hacer sentencias LIKE desde ActiveRecord de manera dinámica.
Finders dinámicos en ActiveRecord
Los finders dinámicos de ActiveRecord son una de las primeras perlas de Ruby con las que uno se encuentra cuando investiga las tripas de Rails. Supongo que serán conocidos, pero no está de más repasarlos un poco.
Supongamos que tenemos una clase ActiveRecord mapeada sobre una tabla MyTable con los atributos atributo1, atributo2 y atributo3, sabemos que podemos escribir:
Mytable.find_by_id(87) |
Y ActiveRecord tratará de encontrar el registro identificado con el id 87. No parece nada del otro mundo, porque entre otras convenciones ActiveRecord asume que la clave primaria en nuestras tablas será una columna llamada id, por lo que podría ser que existiese un método find_by_id en ActiveRecord::Base
Pero resulta algo más sorprendente toparse con que sentencias como:
Mytable.find_by_atributo1('Valor') |
también funcionan. Aquí comienza a saborearse el dinamismo de Ruby, y podemos asumir que ActiveRecord, de manera astuta, construye tantas funciones Mytable::find_by_... como atributos tengamos en la tabla, que lanzarán la correspondiente consulta SQL a la base de datos.
Pero.. ¡un momento!
1 2 |
Mytable.find_by_atributo1_and_atributo3('Valor', 58) Mytable.find_by_atributo1_and_atributo2_and_atributo3('Valor',58,'Valor3') |
¡También! funcionan. Y sería ridículo pensar que ActiveRecord construye funciones de manera dinámica para todas las combinaciones posibles de los atributos (y, además, en cualquier orden que queramos)
Nuestro propósito es añadir más finders dinámicos, que en lugar de búsquedas exactas hagan búsquedas aproximadas, invocándose de la siguiente manera:
Mytable.find_like_atributo1('al')
Para hacerlo, tenemos que entender bien qué hace ActiveRecord con estas misteriosas funciones dinámicas…
La magia de method_missing
A estas alturas ya nos imaginamos que no se añaden métodos para cada atributo, sino que hay algún otro mecanismo actuando en este caso. Se trata de method_missing que es el método que ejecuta una clase Ruby cuando se le invoca un método que no tiene implementado. Aquí podeis ver otro uso muy creativo de method_missing
ActiveRecord se aprovecha de method_missing para articular los finders dinámicos. Si abrís el fichero lib/active_record/base.rb vereis que la clase ActiveRecord::Base define un method_missing muy especial (si no estais viendo ese código, hacedlo ahora: leer código del core de Rails es siempre un ejercicio inspirador). Este método se activa cuando llamamos a Mytable.find_by_attributo('valor'), y lo primero que hace es comprobar con una expresión regular si el método invocado tenía la pinta find_by o find_all_by, en cuyo caso pasa a construir una sentencia SQL de búsqueda según los atributos y parámetros recibidos. Parece evidente que nosotros tendremos que puentear este método y hacer algo parecido por nuestra cuenta pero construyendo sentencias SQL con el modificador LIKE.
Cómo construir nuestro propio finder
Lo primero es preparar un plugin, lo cual es tan fácil como crear un directorio find_like en vendor/plugins, y ahí escribiremos un fichero init.rb con el siguiente contenido:
require 'find_like' |
A continuación, creamos el directorio vendor/plugins/find_like/lib y ahí pondremos el código de nuestro plugin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
module ActiveRecord class Base class << self private def construct_conditions_from_like_arguments(attribute_names, arguments) conditions = [] attribute_names.each_with_index{ |name, idx| conditions <<= "#{table_name}.#{connection.quote_column_name(name)} LIKE '%%" << arguments[idx] << "%%'"} [conditions.join(" AND "), *arguments[0..attribute_names.length]] end alias_method :previous_method_missing, :method_missing def method_missing(method_id, *arguments) if match = /find_(all_like|like)_([_a-zA-Z]\w*)/.match(method_id.to_s) finder = (match.captures.first == 'all_like' ? :find_every : :find_initial) attribute_names = extract_attribute_names_from_match(match) super unless all_attributes_exists?(attribute_names) conditions = construct_conditions_from_like_arguments (attribute_names, arguments) options = {:conditions => conditions} set_readonly_option!(options) send(finder, options) else previous_method_missing(method_id, *arguments) end end end end end |
El código más o menos es fácil de seguir: usamos class << self para abrir la clase ActiveRecord::Base y modificar sus propias tripas. Dado que estamos volviendo a escribir el método method_missing no querremos perder la funcionalidad ofrecida por el method_missing originalmente incluido con ActiveRecord::Base (el que veíamos antes en base.rb) así que simplemente haremos un alias a este método y lo llamaremos desde nuestro method_missing si detectamos que el método invocado no es uno de los que nosotros queremos controlar (find_like o find_all_like)
Tras esto, podemos lanzar una consola y comprobar que, por arte de birlibirloque, ya tenemos nuestros finders operativos:
1 2 3 4 5 6 7 8 9 |
Tamarindo:~/src/repos/dconrails juan$ script/console development Loading development environment. >> Algoritmo.find_all_like_nombre("oyal") => [#<Algoritmo:0x245c908 @attributes={"status"=>"BEGIN", "nombre"=>"RoyalRoadDemo"}>, #<Algoritmo:0x245c82c @attributes={"status"=>"RUN", "nombre"=>"RoyalRoad2">] >> Algoritmo.find_all_like_nombre_and_status("oyal", "UN") => [#<Algoritmo:0x243dabc @attributes={"status"=>"RUN", "nombre"=>"RoyalRoad2"}>] |
