【问题标题】:Clean way to find ActiveRecord objects by id in the order specified按指定顺序按 id 查找 ActiveRecord 对象的干净方法
【发布时间】:2010-10-22 13:21:50
【问题描述】:

我想获得一个给定 id 数组的 ActiveRecord 对象数组。

我认为

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

将返回一个包含对象 5、对象 2、然后是对象 3 的数组,但我得到的数组顺序为对象 2、对象 3 和对象 5。

ActiveRecord Base find method API 提到您不应该按照提供的顺序期待它(其他文档没有给出此警告)。

Find by array of ids in the same order? 中给出了一个可能的解决方案,但 order 选项似乎对 SQLite 无效。

我可以自己编写一些 ruby​​ 代码来对对象进行排序(有点简单但缩放效果不佳,或者缩放效果更好但更复杂),但有更好的方法吗?

【问题讨论】:

  • 这些 id 是从哪里来的?如果是 UI(通过用户选择它们),那么缩放应该不是问题,即用户不太可能花时间选择 1000 个 id)。如果是数据库(例如,来自连接表),您能否将订单存储在连接表中并根据它发出查找?
  • 看起来这是 Rails 5 中的no longer true

标签: ruby-on-rails sqlite activerecord find rails-activerecord


【解决方案1】:

并不是 MySQL 和其他 DB 自己对事物进行排序,而是它们不会对它们进行排序。当你调用Model.find([5, 2, 3]) 时,生成的 SQL 是这样的:

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

这并没有指定顺序,只是您想要返回的记录集。事实证明,一般 MySQL 会以'id' 的顺序返回数据库行,但不能保证这一点。

让数据库以保证的顺序返回记录的唯一方法是添加一个 order 子句。如果您的记录将始终以特定顺序返回,那么您可以向数据库添加一个排序列并执行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}} 

【讨论】:

  • 另一种比使用#detect 来避免 O(N) 性能更高效的解决方案:records = Model.find(ids).group_by(&:id); sorted_records = ids.map { |id|记录[id].first }
【解决方案2】:

根据我之前对 Jeroen van Dijk 的评论,您可以使用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

更新

您可以在大多数使用 order 和 case 语句中执行此操作,这是您可以使用的类方法。

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]

【讨论】:

    【解决方案3】:

    显然 mySQL 和其他数据库管理系统会自行排序。我认为您可以绕过这样做:

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

    【讨论】:

    • 这是链接“按相同顺序查找 id 数组?”中建议的答案,但它似乎不适用于 SQLite。
    • 这不再有效。最新的 Rails:Object.where(id: ids).order("field(id, #{ids.join ','})")
    • 改进:Object.where(id: ids).order(ids.present? &amp;&amp; "field(id, #{ids.join ','})")。如果 IDs 数组为空或 nil,这可以防止 SQL 错误。
    【解决方案4】:

    一种可移植的解决方案是在您的 ORDER BY 中使用 SQL CASE 语句。您可以在 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.quoteActiveRecord SQL sanitizer methods 之一来正确引用您的ids

    然后使用order 字符串作为您的订购条件:

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

    或者在现代世界中:

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

    这有点冗长,但它应该适用于任何 SQL 数据库,并且隐藏丑陋并不难。

    【讨论】:

    • 我是用Object.order('case id ' + ids.each_with_index.map { |id, i| "when #{id} then #{i}" }.join(' ') + ' end')做的
    • 或 sql 注入逃逸,将其放在地图块中:sanitize_sql_array(["when ? then ? ", id, i])(适用于 AR 模型)
    • @nruth:有一个未说明的假设,即ids 包含数字,假设是一个坏主意(当它们不明确时更是如此),所以我希望能澄清这一点。
    • @muistooshort 它仍然是一个很好的答案,并且通常是一个合理的假设,尤其是在 2013 年。但是随着 javascript 前端和时尚 uuids 的出现,它似乎值得一提,因为谷歌在这里指出解决排序问题问题。
    【解决方案5】:

    当我回答 here 时,我刚刚发布了一个 gem (order_as_specified),它允许您像这样进行本机 SQL 排序:

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

    刚刚测试过,它可以在 SQLite 中运行。

    【讨论】:

      【解决方案6】:

      贾斯汀·韦斯两天前写了一封blog article about this problem

      告诉数据库首选顺序并直接从数据库加载按该顺序排序的所有记录似乎是一种好方法。来自他的blog article 示例:

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

      【讨论】:

      • 不错的解决方案,但仅适用于id。能够动态确定要排序的属性会很好,比如uid 或其他东西。
      • @JoshPinter:只需在方法中添加一个参数field_name 并使用field_name 而不是"CASE id",如下所示:"CASE #{field_name} "
      • 好的,感谢您的跟进。更多地考虑允许find_ordered_by_uid(uids),但这只是建立在你所说的基础上。干杯。
      • 建议把方法名改成find_by_ordered_ids更明确,和模块文件名匹配。
      • 如果使用与id 不同的字段而不是Integer,请确保将#{id} 用括号括起来,例如`"WHEN '#{uid}' THEN #{指数} ”。非常感谢@spickermann 的这段小代码 - 效果很好!
      【解决方案7】:

      这是一种高性能(散列查找,而不是检测中的 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
      

      【讨论】:

        【解决方案8】:

        另一种(可能更有效)在 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] }
        

        【讨论】:

        • 您可以使用 each_with_object 将此代码设为两行 FWIW。还进行了一些基准测试,如果您在一个大集合上进行迭代,看起来这段代码会快得多
        • 感谢您的评论。我不知道#each_with_object (api.rubyonrails.org/classes/…) 的存在。上面的#inject 和#each_with_object 方法有显着区别吗?
        • 不,each_with_object 基本上与 inject 相同,只是您不必在块的末尾返回您修改的对象(在您的情况下为 result)。它简化了构建哈希恕我直言。
        【解决方案9】:

        这是我能想到的最简单的方法:

        ids = [200, 107, 247, 189]
        results = ModelObject.find(ids).group_by(&:id)
        sorted_results = ids.map {|id| results[id].first }
        

        【讨论】:

          【解决方案10】:
          @things = [5,2,3].map{|id| Object.find(id)}
          

          这可能是最简单的方法,假设您没有太多要查找的对象,因为它需要为每个 id 访问数据库。

          【讨论】:

          • 你不应该这样做(N+1 个查询)
          • 这是 N 个查询,而不是 N+1 个。有很多更好的方法可以做到这一点,但正如我所说,这是最简单的。为了提高性能,将它们全部查找,然后将它们转储到以 id 作为键的哈希中。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2018-05-01
          • 2012-04-26
          • 2010-09-23
          • 2012-09-27
          • 2014-08-01
          相关资源
          最近更新 更多