如何在 Ruby on Rails 中构建查询,该查询仅加入 has_many 关系的最大值并包含该关系的 select 过滤器?

How do I build a query in Ruby on Rails that joins on the max of a has_many relation only and includes a select filter on that relation?

我正在努力如何让 Rails 上的 Ruby 正确执行此查询...简而言之:加入 has_many 关系但仅 通过该关系中的最新记录,然后可以对该关系应用 filter/select。

这是一个非常简单的变体,它捕捉到了我的挣扎:


假设我有 Employees 的 table 和 Employments 的 table。一个employee has_many employmentsemployment 具有 :active:inactivestatus

class Employee < ActiveRecord::Base
  has_many :employments
end

class Employment < ActiveRecord::Base
  belongs_to :employee
end

为简单起见,假设有一个 employee:Dan 和他有两个 employments:一个旧的(created_at),即 :inactive和一个新的 :active.

dan = Employee.create(name: 'Dan')
Employment.create(employee: dan, created_at: 2.years.ago, status: :inactive)
Employment.create(employee: dan, created_at: 3.months.ago, status: :active)

所以实际上,你可以说:"Dan has worked twice and is currently actively employed."

我想要的是 Rails 查询语句:"find me the employees who are inactive"。那应该 return 是一个空集,因为 Dan 的 最新 employment:active。所以我不能只做:Employee.joins(:employments).where(employments: { status: :inactive }),因为它会匹配 old employment,因此 return Dan employee 记录。

我需要一种表达方式:"find the employees who are inactive based on the most recent employment record only"。

但我不知道如何在 Rails 中做到这一点。

我觉得我错过了一些东西......它应该很简单......但我想不通。

谢谢!

因为标题包含 ARel。以下内容适用于您的示例:

employees = Employee.arel_table
employments = Employment.arel_table
max_employments = Arel::Table.new('max_employments')
e2 = employments.project(
      employments['employee_id'], 
      employments['id'].maximum.as('max_id')
     ).group(employments['employee_id'])
me_alias = Arel::Nodes::As.new(e2,max_employments)

res = employees.project(Arel.star)
      .join(me_alias).on(max_employments['employee_id'].eq(employees['id'])).
      .join(employments).on(employments['id'].eq(max_employments['max_id']))


Employee.joins(*res.join_sources)
  .where(employments: {status: :inactive})

这应该会产生以下结果

SELECT employees.* 
FROM employees 
INNER JOIN (
    SELECT 
       employments.employee_id, 
       MAX(employments.id) AS max_id 
    FROM employments 
    GROUP BY employments.employee_id
    ) AS max_employments ON max_employments.employee_id = employees.id 
INNER JOIN employments ON employments.id = max_employments.max_id
WHERE 
  employments.status = 'inactive'

我在一个有大量行的应用程序中遇到了完全相同的问题,在尝试了各种新颖的解决方案(如横向连接和子查询)之后,性能最好且迄今为止最简单的解决方案就是添加一个外键到指向最新行的 table 并使用关联回调(或 db trigger)设置外键。

class AddLatestEmploymentToEmployees < ActiveRecord::Migration[6.0]
  def change
    add_reference :employees, :latest_employment, foreign_key: { to_table: :employments }
  end
end

class Employee < ActiveRecord::Base
  has_many :employments, after_add: :set_latest_employment
  belongs_to :latest_employment, 
    class_name: 'Employment',
    optional: true

  private
  def set_latest_employment(employment)
    update_column(:latest_employment_id, employment.id)
  end 
end

Employee.joins(:latest_employment)
        .where(employments: { status: :active })

如果关联记录的数量很大,就像我的情况一样,它真的很棒,因为您可以立即加载最新记录,而不会出现加载整个 has_many 关联时出现的内存问题。

我能想到的最简单的解决方案(基于代码复杂性)是首先获取就业 ID 及其最大值,然后用结果组成一个新查询。

attributes = %i[employee_id created_at]
employments = Employment.group(:employee_id).maximum(:created_at)
              .map { |values| Employee.where(attributes.zip(values).to_h) }
              .reduce(Employment.none, :or)
              .where(status: :inactive)

employees = Employee.where(id: employments.select(:employee_id))

这应该产生以下结果 SQL:

SELECT employments.employee_id, MAX(employments.created_at)
FROM employments
GROUP BY employments.employee_id

生成以下查询的结果:

SELECT employees.*
FROM employees
WHERE employees.id IN (
  SELECT employments.employee_id 
  FROM employments
  WHERE (
    employments.employee_id = ? AND employments.created_at = ?
    OR employments.employee_id = ? AND employments.created_at = ?
    OR employments.employee_id = ? AND employments.created_at = ?
    -- ...
  ) AND employments.status = 'inactive'
)

上述方法不适用于大量记录,因为每增加一名员工,查询就会增加。当我们可以假设较高的 id 最后出现时,事情就会变得容易得多。在那种情况下,以下方法可以解决问题:

employment_ids = Employment.select(Employment.arel_table[:id].maxiumum).group(:employee_id)
employee_ids = Employment.select(:employee_id).where(id: employment_ids, status: :inactive)
employees = Employee.where(id: employee_ids)

这应该会在加载 employees 时生成一个查询。

SELECT employees.*
FROM employees
WHERE employees.id IN (
  SELECT employments.employee_id 
  FROM employments
  WHERE employments.id IN (
    SELECT MAX(employments.id)
    FROM employments
    GROUP BY employments.employee_id
  ) AND employments.status = 'inactive'
)

此解决方案适用于更大的数据集,但您可能需要查看 以获得更好的查找性能。

+1 @max 的回答。

另一种方法是向 Employment 添加 start_dateend_date 属性。要获得活跃的员工,您可以这样做

Employee
  .joins(:employments)
  .where('end_date is NULL OR ? BETWEEN start_date AND end_date', Date.today)

在我看来,您可以先获取那些最大日期,以确保不会获取旧记录,然后只过滤所需的状态。这是做第一部分的例子

经过一段时间的摆弄(并尝试了你们提出的所有这些建议,以及其他一些建议),我想到了这个。它有效,但可能不是最优雅的。

inner_query = Employment.select('distinct on(employee_id) *').order('employee_id').order('created_at DESC')
employee_ids = Employee.from("(#{inner_query.to_sql}) as unique_employments").select("unique_employments.employee_id").where("unique_employments.status='inactive'")
employees = Employee.where(id: employee_ids)

内部查询returns 唯一就业的集合...每个员工的最新信息。 然后基于此,我提取与状态匹配的员工 ID。 最后,从 IDs

中找到那些员工记录

我不喜欢它,但它可以理解并且确实有效。

非常感谢所有的意见。

对我(以及遇到这个 same/similar 问题的任何其他人)来说,一个重要的收获是:max 的回答帮助我意识到我在使用这段代码时遇到的困难是 "smell"数据没有以理想的方式建模。根据 max 的建议,如果 Employee table 引用了最新的 Employment,并且保持最新和准确,那么这将变得非常容易和快速。

深思。

一种替代方法是使用 LATERAL JOIN,它是 Postgres 9.3+ 的特定功能,可以描述为类似于 SQL foreach 循环。

class Employee < ApplicationRecord
  has_many :employments
  def self.in_active_employment
    lat_query = Employment.select(:status)
                      .where('employee_id = employees.id') # lateral reference
                      .order(created_at: :desc)
                      .limit(1)
    joins("JOIN LATERAL(#{lat_query.to_sql}) ce ON true")
      .where(ce: { status: 'active' })
  end
end

这会从 employments 中获取最新的行,然后在 WHERE 子句中使用它来过滤来自 employees 的行。

SELECT "employees".* FROM "employees" 
JOIN LATERAL(
  SELECT "employments"."status" 
  FROM "employments" 
  WHERE (employee_id = employees.id) 
  ORDER BY "employments"."created_at" DESC 
  LIMIT 1
) ce  ON true 
WHERE "ce"."status" =  LIMIT  

如果数据集很大,这与 WHERE id IN subquery 相比会非常快。当然代价是便携性有限