ruby-on-railsrubyeager-loadinghas-many

Eager load Rails' has_many with two primary_keys and foreign_keys


I have two models

class TimeEntry < ApplicationRecord
  belongs_to :contract
end

class Timesheet < ApplicationRecord
  belongs_to :contract
  has_many :time_entries, primary_key: :contract_id, foreign_key: :contract_id
end

Additionally, both models have a date column.

The problem: A Timesheet is only for a fixed date and by scoping only to contract_id I always get all time_entries of a contract for each Timesheet.

I tried to scope it like this:

has_many :time_entries, ->(sheet) { where(date: sheet.date) }, primary_key: :contract_id, foreign_key: :contract_id

This works, but unforunately it is not eager loadable:

irb(main):019:0> Timesheet.where(id: [1,2,3]).includes(:time_entries).to_a
  Timesheet Load (117.9ms)  SELECT "timesheets".* FROM "timesheets" WHERE "timesheets"."id" IN ($1, $2, $3)  [["id", 1], ["id", 2], ["id", 3]]
  TimeEntry Load (0.3ms)  SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2  [["date", "2014-11-21"], ["contract_id", 1]]
  TimeEntry Load (0.3ms)  SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2  [["date", "2014-11-22"], ["contract_id", 1]]
  TimeEntry Load (0.3ms)  SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2  [["date", "2014-11-23"], ["contract_id", 1]]

Is it possible, to provide Rails with two primary_keys AND foreign_keys? Or how could I make the example above eager loadable to avoid n+1 queries?


Solution

  • You can use a custom SQL query for the association to retrieve the TimeEntry records for a given Timesheet in this way:

    class Timesheet < ApplicationRecord
      belongs_to :contract
      has_many :time_entries, lambda {
        select('*')
          .from('time_entries')
          .where('time_entries.date = timesheets.date')
          .where('time_entries.contract_id = timesheets.contract_id')
      }, primary_key: :contract_id, foreign_key: :contract_id
    end
    

    Then, can use

    timesheets = Timesheet.where(id: [1,2,3]).eager_load(:time_entries)
    time_entries = timesheets.first.time_entries
    

    Note:- this will only work with while eager loading, not preloading. That's why explicitly using the keyword instead of includes.