Rails 3查询has_one关联的匹配属性,该属性是has_many关联的子集

时间:2012-07-16 22:42:14

标签: sql ruby-on-rails-3

标题令人困惑,但请允许我解释一下。我有一个Car模型,它具有多个具有不同时间戳的数据点。我们几乎总是关注其最新状态的属性。所以模型has_many状态,以及has_one可以轻松访问它的最新状态:

class Car < ActiveRecord::Base
  has_many :statuses, class_name: 'CarStatus', order: "timestamp DESC"
  has_one :latest_status, class_name: 'CarStatus', order: "timestamp DESC"

  delegate :location, :timestamp, to: 'latest_status', prefix: 'latest', allow_nil: true

  # ...
end

让您了解状态的含义:

loc = Car.first.latest_location   # Location object (id = 1 for example)
loc.name                          # "Miami, FL"

假设我想拥有一个(可链接的)范围来查找最新位置ID为1的所有汽车。目前我有一种复杂的方法:

# car.rb
def self.by_location_id(id)
  ids = []
  find_each(include: :latest_status) do |car|
    ids << car.id if car.latest_status.try(:location_id) == id.to_i
  end
  where("id in (?)", ids)
end

使用SQL可能有更快捷的方法,但不知道如何只获取每辆车的最新状态。可能有很多状态记录,其location_id为1,但如果这不是汽车的最新位置,则不应包含该记录。

为了更难......让我们添加另一个级别,并能够按位置名称进行范围调整。我有这个方法,预加载状态及其位置对象,以便能够访问名称:

def by_location_name(loc)
  ids = []
  find_each(include: {latest_status: :location}) do |car|
    ids << car.id if car.latest_location.try(:name) =~ /#{loc}/i
  end
  where("id in (?)", ids)
end

这将与上面的位置匹配“miami”,“fl”,“MIA”等等......有没有人对我如何使这更简洁/高效有任何建议?以不同方式定义我的关联会更好吗?或者它可能需要一些SQL忍者技能,我承认它没有。

使用Postgres 9.1(在Heroku雪松堆栈上托管)

1 个答案:

答案 0 :(得分:2)

好的。因为你像我一样使用postgres 9.1,所以我会对此有所了解。首先处理第一个问题(根据上一个状态的位置过滤范围):

此解决方案利用了PostGres对分析函数的支持,如下所述:http://explainextended.com/2009/11/26/postgresql-selecting-records-holding-group-wise-maximum/

我认为以下内容为您提供了所需内容的一部分(自然地替换/插入您对'?'感兴趣的位置ID):

select * 
from (
  select cars.id as car_id, statuses.id as status_id, statuses.location_id, statuses.created_at, row_number() over (partition by statuses.id order by statuses.created_at) as rn 
  from cars join statuses on cars.id = statuses.car_id
) q
where rn = 1 and location_id = ?

此查询将返回car_idstatus_idlocation_id和时间戳(默认情况下称为created_at,但如果其他名称更易于使用,则可以使用别名)。

现在说服Rails基于此返回结果。因为你可能想要使用eager loading,所以find_by_sql几乎完全没有用。我发现了一个技巧,使用.joins加入子查询。这大概就是它的样子:

def self.by_location(loc)
  joins(
    self.escape_sql('join (
    select * 
    from (
      select cars.id as car_id, statuses.id as status_id, statuses.location_id, statuses.created_at, row_number() over (partition by statuses.id order by statuses.created_at) as rn 
      from cars join statuses on cars.id = statuses.car_id
    ) q
    where rn = 1 and location_id = ?
    ) as subquery on subquery.car_id = cars.id order by subquery.created_at desc', loc)
  )
end

Join将充当过滤器,仅为您提供子查询中涉及的Car对象。

注意:为了像上面那样引用escape_sql,你需要稍微修改一下ActiveRecord :: Base。我这样做是通过将其添加到应用程序中的初始化程序(我放在app / config / initializers / active_record.rb中):

class ActiveRecord::Base
  def self.escape_sql(clause, *rest)
    self.send(:sanitize_sql_array, rest.empty? ? clause : ([clause] + rest))
  end
end

这允许您在任何基于AR :: B的模型上调用.escape_sql。我发现这非常有用,但是如果你有其他方法来清理sql,请随意使用它。

对于问题的第二部分 - 除非有多个位置具有相同的名称,否则我只需要Location.find_by_name将其转换为id以传递到上面。基本上这个:

def self.by_location_name(name)
 loc = Location.find_by_name(name)
 by_location(loc)
end