Forma limpia de encontrar objetos ActiveRecord por id en el orden especificado
-
03-07-2019 - |
Pregunta
Quiero obtener una matriz de objetos ActiveRecord dada una matriz de identificadores.
Supuse que
Object.find([5,2,3])
Devuelve una matriz con el objeto 5, el objeto 2, luego el objeto 3 en ese orden, pero en su lugar obtengo una matriz ordenada como objeto 2, objeto 3 y luego objeto 5.
La base ActiveRecord API de método de búsqueda menciona que no debería esperarlo en el orden proporcionado (otra documentación no da esta advertencia).
Se proporcionó una solución potencial en Buscar por conjunto de identificadores en el mismo pedido? , pero la opción de pedido no parece ser válida para SQLite.
Puedo escribir un código ruby ??para ordenar los objetos yo mismo (ya sea algo simple y escalando mal o mejor escalando y más complejo), pero ¿hay una mejor manera?
Solución
No es que MySQL y otros DB clasifiquen las cosas por sí mismos, es que no los clasifican. Cuando llama a Model.find ([5, 2, 3])
, el SQL generado es algo así como:
SELECT * FROM models WHERE models.id IN (5, 2, 3)
Esto no especifica un orden, solo el conjunto de registros que desea devolver. Resulta que generalmente MySQL devolverá las filas de la base de datos en el orden 'id'
, pero no hay garantía de esto.
La única forma de hacer que la base de datos devuelva registros en un pedido garantizado es agregar una cláusula de pedido. Si sus registros siempre serán devueltos en un orden particular, entonces puede agregar una columna de ordenación a la base de datos y hacer Model.find ([5, 2, 3],: order = > 'sort_column') . Si este no es el caso, tendrá que ordenar el código:
ids = [5, 2, 3]
records = Model.find(ids)
sorted_records = ids.collect {|id| records.detect {|x| x.id == id}}
Otros consejos
Basado en mi comentario anterior a Jeroen van Dijk, puede hacerlo de manera más eficiente y en dos líneas usando each_with_object
result_hash = Model.find(ids).each_with_object({}) {|result,result_hash| result_hash[result.id] = result }
ids.map {|id| result_hash[id]}
Para referencia aquí es el punto de referencia que utilicé
ids = [5,3,1,4,11,13,10]
results = Model.find(ids)
Benchmark.measure do
100000.times do
result_hash = results.each_with_object({}) {|result,result_hash| result_hash[result.id] = result }
ids.map {|id| result_hash[id]}
end
end.real
#=> 4.45757484436035 seconds
Ahora el otro
ids = [5,3,1,4,11,13,10]
results = Model.find(ids)
Benchmark.measure do
100000.times do
ids.collect {|id| results.detect {|result| result.id == id}}
end
end.real
# => 6.10875988006592
Actualizar
Puede hacer esto en la mayoría de los casos utilizando declaraciones de orden y caso, aquí hay un método de clase que podría usar.
def self.order_by_ids(ids)
order_by = ["case"]
ids.each_with_index.map do |id, index|
order_by << "WHEN id='#{id}' THEN #{index}"
end
order_by << "end"
order(order_by.join(" "))
end
# User.where(:id => [3,2,1]).order_by_ids([3,2,1]).map(&:id)
# #=> [3,2,1]
Aparentemente, mySQL y otros sistemas de administración de bases de datos clasifican las cosas por su cuenta. Creo que puedes evitar eso haciendo:
ids = [5,2,3]
@things = Object.find( ids, :order => "field(id,#{ids.join(',')})" )
Una solución portátil sería utilizar una instrucción SQL CASE en su ORDER BY. Puede usar casi cualquier expresión en un ORDER BY y un CASE puede usarse como una tabla de búsqueda en línea. Por ejemplo, el SQL que busca se vería así:
select ...
order by
case id
when 5 then 0
when 2 then 1
when 3 then 2
end
Eso es bastante fácil de generar con un poco de Ruby:
ids = [5, 2, 3]
order = 'case id ' + (0 .. ids.length).map { |i| "when #{ids[i]} then #{i}" }.join(' ') + ' end'
Lo anterior supone que está trabajando con números u otros valores seguros en ids
; si ese no es el caso, entonces querrá usar < code> connection.quote o uno de los métodos de desinfectante ActiveRecord SQL para citar correctamente sus ids
.
Luego use la cadena order
como su condición de pedido:
Object.find(ids, :order => order)
o en el mundo moderno:
Object.where(:id => ids).order(order)
Esto es un poco detallado pero debería funcionar igual con cualquier base de datos SQL y no es tan difícil ocultar la fealdad.
Como respondí aquí , acabo de lanzar una gema ( order_as_specified ) que le permite realizar pedidos SQL nativos como este:
Object.where(id: [5, 2, 3]).order_as_specified(id: [5, 2, 3])
Acabo de probar y funciona en SQLite.
Justin Weiss escribió un artículo de blog sobre este problema hace solo dos días.
Parece ser un buen enfoque informar a la base de datos sobre el orden preferido y cargar todos los registros ordenados en ese orden directamente desde la base de datos. Ejemplo de su artículo de blog :
# in config/initializers/find_by_ordered_ids.rb
module FindByOrderedIdsActiveRecordExtension
extend ActiveSupport::Concern
module ClassMethods
def find_ordered(ids)
order_clause = "CASE id "
ids.each_with_index do |id, index|
order_clause << "WHEN #{id} THEN #{index} "
end
order_clause << "ELSE #{ids.length} END"
where(id: ids).order(order_clause)
end
end
end
ActiveRecord::Base.include(FindByOrderedIdsActiveRecordExtension)
Eso te permite escribir:
Object.find_ordered([2, 1, 3]) # => [2, 1, 3]
Aquí hay un performant (búsqueda de hash, no O (n) búsqueda de matriz como en detect!) one-liner, como método:
def find_ordered(model, ids)
model.find(ids).map{|o| [o.id, o]}.to_h.values_at(*ids)
end
# We get:
ids = [3, 3, 2, 1, 3]
Model.find(ids).map(:id) == [1, 2, 3]
find_ordered(Model, ids).map(:id) == ids
Otra forma (probablemente más eficiente) de hacerlo en Ruby:
ids = [5, 2, 3]
records_by_id = Model.find(ids).inject({}) do |result, record|
result[record.id] = record
result
end
sorted_records = ids.map {|id| records_by_id[id] }
Esto es lo más simple que se me ocurrió:
ids = [200, 107, 247, 189]
results = ModelObject.find(ids).group_by(&:id)
sorted_results = ids.map {|id| results[id].first }
@things = [5,2,3].map{|id| Object.find(id)}
Esta es probablemente la forma más fácil, suponiendo que no tenga demasiados objetos para encontrar, ya que requiere un viaje a la base de datos para cada ID.