Чистый способ поиска объектов ActiveRecord по идентификатору в указанном порядке

StackOverflow https://stackoverflow.com/questions/801824

Вопрос

Я хочу получить массив объектов ActiveRecord с заданным массивом идентификаторов.

Я предполагал, что

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

Вернет массив с объектом 5, объектом 2, затем объектом 3 в этом порядке, но вместо этого я получаю массив, упорядоченный как объект 2, объект 3 и затем объект 5.

База ActiveRecord найти API метода упоминает, что не следует ожидать этого в указанном порядке (другая документация не дает такого предупреждения).

Одно из возможных решений было дано в Найти по массиву идентификаторов в том же порядке?, но опция заказа, похоже, недопустима для SQLite.

Я могу написать некоторый Ruby-код для сортировки объектов самостоятельно (либо простой и плохо масштабируемый, либо более масштабируемый и более сложный), но есть ли лучший способ?

Это было полезно?

Решение

Дело не в том, что MySQL и другие БД сортируют вещи самостоятельно, а в том, что они их не сортируют.Когда ты звонишь Model.find([5, 2, 3]), сгенерированный SQL выглядит примерно так:

SELECT * FROM models WHERE models.id IN (5, 2, 3)

Здесь не указывается порядок, а только набор записей, которые вы хотите вернуть.Оказывается, MySQL обычно возвращает строки базы данных в 'id' заказ, но нет никакой гарантии этого.

Единственный способ заставить базу данных возвращать записи в гарантированном порядке — добавить предложение порядка.Если ваши записи всегда будут возвращаться в определенном порядке, вы можете добавить столбец сортировки в базу данных и сделать Model.find([5, 2, 3], :order => 'sort_column').Если это не так, вам придется выполнить сортировку в коде:

ids = [5, 2, 3]
records = Model.find(ids)
sorted_records = ids.collect {|id| records.detect {|x| x.id == id}} 

Другие советы

Основываясь на моем предыдущем комментарии Йероену ван Дейку, вы можете сделать это более эффективно и в две строки, используя 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]}

Для справки вот тест, который я использовал

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

Теперь другой

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

Обновлять

Вы можете сделать это в большинстве случаев, используя операторы порядка и случая. Вот метод класса, который вы можете использовать.

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]

Судя по всему, MySQL и другие системы управления БД сортируют вещи самостоятельно.Я думаю, что вы можете обойти это, выполнив:

ids = [5,2,3]
@things = Object.find( ids, :order => "field(id,#{ids.join(',')})" )

Переносимым решением было бы использование оператора SQL CASE в вашем ORDER BY.В ORDER BY можно использовать практически любое выражение, а CASE можно использовать в качестве встроенной таблицы поиска.Например, нужный вам SQL будет выглядеть так:

select ...
order by
    case id
    when 5 then 0
    when 2 then 1
    when 3 then 2
    end

Это довольно легко создать с помощью Ruby:

ids = [5, 2, 3]
order = 'case id ' + (0 .. ids.length).map { |i| "when #{ids[i]} then #{i}" }.join(' ') + ' end'

Вышеупомянутое предполагает, что вы работаете с числами или другими безопасными значениями в ids;если это не так, вы бы хотели использовать connection.quote или один из Методы очистки ActiveRecord SQL правильно процитировать ваше ids.

Затем используйте order строка в качестве условия заказа:

Object.find(ids, :order => order)

или в современном мире:

Object.where(:id => ids).order(order)

Это немного многословно, но оно должно работать одинаково с любой базой данных SQL, и скрыть это уродство не так уж и сложно.

Как я ответил здесь, я только что выпустил драгоценный камень (order_as_specified), который позволяет вам выполнять собственный порядок SQL следующим образом:

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

Только что протестировал и работает в SQLite.

Джастин Вайс написал статья в блоге об этой проблеме всего два дня назад.

Кажется, это хороший подход — сообщить базе данных о предпочтительном порядке и загрузить все записи, отсортированные в этом порядке, непосредственно из базы данных.Пример из его статья в блоге:

# 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)

Это позволяет вам написать:

Object.find_ordered([2, 1, 3]) # => [2, 1, 3]

Вот производительный однострочный метод (поиск по хешу, а не поиск по массиву O(n), как при обнаружении!):

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

Другой (возможно, более эффективный) способ сделать это в 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] }

Вот самое простое, что я смог придумать:

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)}

Вероятно, это самый простой способ, при условии, что вам нужно найти не слишком много объектов, поскольку для каждого идентификатора требуется обращение к базе данных.

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top