Forma limpia de encontrar objetos ActiveRecord por id en el orden especificado


Quiero obtener una matriz de objetos ActiveRecord dada una matriz de ids.

Asumí que

Object.find([5,2,3])

Devolvería una matriz con objeto 5, objeto 2, luego objeto 3 en ese orden, pero en su lugar obtengo una matriz ordenada como objeto 2, objeto 3 y luego objeto 5.

El ActiveRecord Base find method API menciona que no debería esperarlo en el orden proporcionado (otra documentación no da esta advertencia).

Se dio una solución potencial en Buscar por matriz de ids en el mismo orden?, pero la opción order no parece ser válida para SQLite.

Puedo escribir algún código ruby para ordenar los objetos yo mismo (ya sea algo simple y poco escalable o mejor escalado y más complejo), pero ¿hay una Mejor Manera?

Author: mu is too short, 2009-04-29

10 answers

No es que MySQL y otros DBs ordenen las cosas por su cuenta, es que no las ordenan. Cuando se 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 orden 'id', pero no hay garantía de esto.

La única manera de conseguir que la base de datos devuelva registros en un orden garantizado es agregar una cláusula de orden. Si sus registros siempre se devolverá en un orden particular, luego 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 hacer la clasificación en el código:

ids = [5, 2, 3]
records = Model.find(ids)
sorted_records = ids.collect {|id| records.detect {|x| x.id == id}} 
 22
Author: tomafro,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-04-30 07:41:30

Basado en mi comentario anterior a Jeroen van Dijk, puede hacer esto 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í está el punto de referencia que usé

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

Actualización

Puede hacer esto en la mayoría de las sentencias order y case, 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]
 10
Author: Schneems,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-09-24 11:10:07

Aparentemente MySQL y otros sistemas de gestión de bases de datos ordenan las cosas por su cuenta. Creo que se puede evitar que hacer:

ids = [5,2,3]
@things = Object.find( ids, :order => "field(id,#{ids.join(',')})" )
 6
Author: marcgg,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-09-24 11:10:23

Una solución portable sería usar una sentencia SQL CASE en su ORDEN BY. Puede utilizar casi cualquier expresión en un ORDEN POR y un CASO se puede utilizar como una tabla de búsqueda en línea. Por ejemplo, el SQL que buscas 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 asume que está trabajando con números u otros valores seguros en ids; si ese no es el caso, entonces querrá usar connection.quote o uno de los ActiveRecord SQL sanitizer métodos para citar correctamente su 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.

 6
Author: mu is too short,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-05-02 05:59:21

Como respondí aquí , acabo de lanzar una gema (order_as_specified ) que le permite hacer un ordenamiento SQL nativo como este:

Object.where(id: [5, 2, 3]).order_as_specified(id: [5, 2, 3])

Acaba de probarse y funciona en SQLite.

 4
Author: JacobEvelyn,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 10:30:49

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)

Que te permite escribir:

Object.find_ordered([2, 1, 3]) # => [2, 1, 3]
 3
Author: spickermann,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-04-22 02:17:38

Aquí hay una búsqueda performante (hash-lookup, no una búsqueda de matriz O(n) 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
 2
Author: Kanat Bolazar,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-10-29 20:40:57

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] }
 1
Author: Jeroen van Dijk,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-08-24 11:00:35

Aquí está la cosa 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 }
 1
Author: jasongarber,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-06-04 21:08:04
@things = [5,2,3].map{|id| Object.find(id)}

Esta es probablemente la forma más fácil, asumiendo que no tiene demasiados objetos para encontrar, ya que requiere un viaje a la base de datos para cada id.

 -1
Author: jcnnghm,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-04-29 16:44:37