Как построить запрос в Ruby на Rails, который соединяется только с максимумом отношения has_many и включает фильтр выбора для этого отношения? - PullRequest
2 голосов
/ 05 марта 2020

Я изо всех сил пытаюсь сделать так, чтобы Ruby на Rails выполнял этот запрос правильно ... короче: присоединиться к отношению has_many, но только через самую последнюю запись в этом отношении и затем можно применить фильтр / выбрать к этому отношению.

Вот очень простой вариант, который отражает мою борьбу:


Допустим, у меня есть таблица Employees и таблица Employments. employee has_many employments. employment имеет status из :active или :inactive.

class Employee < ActiveRecord::Base
  has_many :employments
end

class Employment < ActiveRecord::Base
  belongs_to :employee
end

Для простоты, скажем, есть один employee: Дэн, и у него есть два 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)

Таким образом, вы можете сказать: «Дэн работал дважды и в настоящее время активно busy. "

Я хочу, чтобы запрос Rails сказал:" найди мне неактивных сотрудников ". И это должно вернуть пустой набор, потому что Dan latest employment равен :active. Поэтому я не могу просто сделать: Employee.joins(:employments).where(employments: { status: :inactive }), потому что он будет соответствовать old employment и, таким образом, вернет запись Dan employee.

Мне нужен способ сказать: " найти неактивных сотрудников на основании только самой последней записи о занятости ".

Но я не знаю, как это сделать в Rails.

Я чувствую, что Я что-то упускаю ... это должно быть довольно просто ... но я не могу понять это.

Спасибо!

Ответы [ 7 ]

3 голосов
/ 06 марта 2020

Я немного боролся с точно такой же проблемой в приложении с огромным количеством строк, и после того, как я попробовал различные новые решения, такие как боковые объединения и подзапросы, самым эффективным и, безусловно, самым простым решением было просто добавить внешний ключ к таблица, которая указывает на последнюю строку и использует обратный вызов ассоциации (или db триггер ) для установки внешнего ключа.

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 связь.

1 голос
/ 06 марта 2020

Поработав некоторое время (и попробовав все эти предложения, которые вы все выдвинули, а также некоторые другие), я придумал это. Это работает, но, возможно, не самый элегантный.

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)

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

Мне это не нравится, но это понятно и работает.

Я действительно ценю все входные данные.

One большая отдача для меня (и любого другого, кто сталкивается с этой же / подобной проблемой): ответ Макса помог мне понять, что моя борьба с этим кодом - это «запах» того, что данные не моделируются идеальным способом. Согласно предложению Макса, если в таблице Employee имеется ссылка на последнюю версию Employment, и она поддерживается в актуальном и точном состоянии, то это становится тривиально легко и быстро.

Пища для размышлений.

1 голос
/ 06 марта 2020

Самое простое решение (основанное на сложности кода), которое я могу придумать, - сначала получить идентификаторы занятости с их максимальными значениями, а затем скомпилировать новый запрос с результатом.

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

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

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

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

0 голосов
/ 06 марта 2020

Одной из альтернатив является использование LATERAL JOIN, который является Postgres 9.3+ спецификацией c, которую можно описать как нечто вроде SQL foreach l oop.

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

Он выбирает самую последнюю строку из занятости и затем использует ее в предложении WHERE для фильтрации строк из сотрудников.

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" = $1 LIMIT $2 

Это будет очень быстро по сравнению с WHERE id IN subquery, если набор данных большой. Конечно стоимость ограничена мобильностью.

0 голосов
/ 06 марта 2020

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

{ ссылка }

0 голосов
/ 06 марта 2020

+ 1 к ответу @ max.

Альтернативой является добавление атрибута start_date и end_date к Employment. Чтобы получить активных сотрудников, вы можете сделать

Employee
  .joins(:employments)
  .where('end_date is NULL OR ? BETWEEN start_date AND end_date', Date.today)
0 голосов
/ 05 марта 2020

Так как название включает 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'
...