Rails: Build Association when id Method is Non-standard

85 Views Asked by At

I'm trying to build a JobReport model that holds the return value from GoodJob jobs. The two fields I could build an association on, id and active_job_id, are problematic. The id field is set to return the active_job_id in the Job class:

# from good_job-3.12.1/app/models/good_job/job.rb
def id
  active_job_id
end

The good_jobs.active_job_id field has no uniqueness constraint, and setting it as a foreign key throws a postgres error.

How can I link these two tables?


Here's the migration I'm using to create the job_reports table:

class CreateJobReports < ActiveRecord::Migration[7.0]
  def change
    create_table :job_reports do |t|
      t.text :report
      t.uuid :good_job_id
      t.timestamps
    end

    add_foreign_key :job_reports, :good_jobs, column: :good_job_id, primary_key: :id
  end
end

My JobReport model:

class JobReport < ApplicationRecord
  belongs_to :good_job, class_name: 'GoodJob::Job', foreign_key: 'id'
end

And my good_job.rb initializer contains:

GoodJob::Job.class_eval do
  has_one :job_report, dependent: :destroy
end

When I create a JobReport, tie it to a Job, and save it, postgres complains that the id doesn't exist in good_jobs, because it's trying to use the active_job_id:

irb(main):001:0> jr = JobReport.new; gj = GoodJob::Job.last
=> 
#<GoodJob::Job:0x00007ff6950cda30                           
...                                           
irb(main):002:0> jr.good_job = gj
=> 
#<GoodJob::Job:0x00007ff6950cda30                           
...                                              
irb(main):003:0> jr.save
/usr/local/bundle/gems/activerecord-7.0.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': PG::ForeignKeyViolation: ERROR:  insert or update on table "job_reports" violates foreign key constraint "fk_rails_6135bfd69e" (ActiveRecord::InvalidForeignKey)                                                
DETAIL:  Key (good_job_id)=(fdc02e75-a06a-4727-b790-9a846f61ed7d) is not present in table "good_jobs".                                          
/usr/local/bundle/gems/activerecord-7.0.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': ERROR:  insert or update on table "job_reports" violates foreign key constraint "fk_rails_6135bfd69e" (PG::ForeignKeyViolation)
DETAIL:  Key (good_job_id)=(fdc02e75-a06a-4727-b790-9a846f61ed7d) is not present in table "good_jobs".                                          
irb(main):004:0> gj.id
=> "fdc02e75-a06a-4727-b790-9a846f61ed7d"
irb(main):005:0> gj.active_job_id
=> "fdc02e75-a06a-4727-b790-9a846f61ed7d"
irb(main):006:0> gj.attributes["id"]
=> "edc27b66-975d-4017-a09f-2d0cec332a0c"

As I mentioned before, if I give up on the ID column and switch to the active_job_id column, postgres says I can't use it as a foreign key b/c there's no uniqueness constraint. Sure, I could edit the GoodJob tables, but I'd prefer to use the drop-in form of the gem without tampering with it for upgrades and whatnot down the road.


Edit: I implemented Max's suggestion, but it's still trying to use the active_job_id column of the good_jobs table instead of the id column.

class JobReport < ApplicationRecord
  belongs_to :good_job, class_name: 'GoodJob::Job', foreign_key: 'good_job_id', primary_key: 'id'
end
irb(main):010:0> jr = JobReport.new; gj = GoodJob::Job.last
=> 
#<GoodJob::Job:0x00007f70ec430918                           
...                                                   
irb(main):011:0> jr.good_job = gj
=> 
#<GoodJob::Job:0x00007f70ec430918                           
...                                                   
irb(main):012:0> jr.save
/usr/local/bundle/gems/activerecord-7.0.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': PG::ForeignKeyViolation: ERROR:  insert or update on table "job_reports" violates foreign key constraint "fk_rails_6135bfd69e" (ActiveRecord::InvalidForeignKey)                      
DETAIL:  Key (good_job_id)=(fdc02e75-a06a-4727-b790-9a846f61ed7d) is not present in table "good_jobs".
/usr/local/bundle/gems/activerecord-7.0.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': ERROR:  insert or update on table "job_reports" violates foreign key constraint "fk_rails_6135bfd69e" (PG::ForeignKeyViolation)
DETAIL:  Key (good_job_id)=(fdc02e75-a06a-4727-b790-9a846f61ed7d) is not present in table "good_jobs".
irb(main):013:0> gj.id
=> "fdc02e75-a06a-4727-b790-9a846f61ed7d"
irb(main):014:0> gj.active_job_id
=> "fdc02e75-a06a-4727-b790-9a846f61ed7d"
irb(main):015:0> gj.attributes['id']
=> "edc27b66-975d-4017-a09f-2d0cec332a0c"

Here's the schema of the two tables:

development=# \d good_jobs
                                    Table "public.good_jobs"
       Column        |              Type              | Collation | Nullable |      Default
---------------------+--------------------------------+-----------+----------+-------------------
 id                  | uuid                           |           | not null | gen_random_uuid()
 queue_name          | text                           |           |          |
 priority            | integer                        |           |          |
 serialized_params   | jsonb                          |           |          |
 scheduled_at        | timestamp(6) without time zone |           |          |
 performed_at        | timestamp(6) without time zone |           |          |
 finished_at         | timestamp(6) without time zone |           |          |
 error               | text                           |           |          |
 created_at          | timestamp(6) without time zone |           | not null |
 updated_at          | timestamp(6) without time zone |           | not null |
 active_job_id       | uuid                           |           |          |
 concurrency_key     | text                           |           |          |
 cron_key            | text                           |           |          |
 retried_good_job_id | uuid                           |           |          |
 cron_at             | timestamp(6) without time zone |           |          |
 batch_id            | uuid                           |           |          |
 batch_callback_id   | uuid                           |           |          |
Indexes:
    "good_jobs_pkey" PRIMARY KEY, btree (id)
    "index_good_jobs_on_cron_key_and_cron_at" UNIQUE, btree (cron_key, cron_at)
    "index_good_jobs_jobs_on_finished_at" btree (finished_at) WHERE retried_good_job_id IS NULL AND finished_at IS NOT NULL
    "index_good_jobs_jobs_on_priority_created_at_when_unfinished" btree (priority DESC NULLS LAST, created_at) WHERE finished_at IS NULL
    "index_good_jobs_on_active_job_id" btree (active_job_id)
    "index_good_jobs_on_active_job_id_and_created_at" btree (active_job_id, created_at)
    "index_good_jobs_on_batch_callback_id" btree (batch_callback_id) WHERE batch_callback_id IS NOT NULL
    "index_good_jobs_on_batch_id" btree (batch_id) WHERE batch_id IS NOT NULL
    "index_good_jobs_on_concurrency_key_when_unfinished" btree (concurrency_key) WHERE finished_at IS NULL
    "index_good_jobs_on_cron_key_and_created_at" btree (cron_key, created_at)
    "index_good_jobs_on_queue_name_and_scheduled_at" btree (queue_name, scheduled_at) WHERE finished_at IS NULL
    "index_good_jobs_on_scheduled_at" btree (scheduled_at) WHERE finished_at IS NULL
Referenced by:
    TABLE "job_reports" CONSTRAINT "fk_rails_6135bfd69e" FOREIGN KEY (good_job_id) REFERENCES good_jobs(id)

development=# \d job_reports
                                          Table "public.job_reports"
   Column    |              Type              | Collation | Nullable |                 Default
-------------+--------------------------------+-----------+----------+-----------------------------------------
 id          | bigint                         |           | not null | nextval('job_reports_id_seq'::regclass)
 report      | text                           |           |          |
 good_job_id | uuid                           |           |          |
 created_at  | timestamp(6) without time zone |           | not null |
 updated_at  | timestamp(6) without time zone |           | not null |
Indexes:
    "job_reports_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "fk_rails_6135bfd69e" FOREIGN KEY (good_job_id) REFERENCES good_jobs(id)
1

There are 1 best solutions below

1
Alex On BEST ANSWER

id is whatever primary_key is set to, even if they didn't override id method, they're also setting primary_key to active_job_id.

>> GoodJob::Job.last.id
=> "d781edac-1932-4d52-bfaa-61e4d80be5e8"
>> puts GoodJob::Job.instance_method(:id).source
    def id
      active_job_id
    end

>> GoodJob::Job.remove_method(:id)
# now `id` method comes from `ActiveRecord`
# https://github.com/rails/rails/blob/v7.0.4.2/activerecord/lib/active_record/attribute_methods/primary_key.rb#L18
>> puts GoodJob::Job.instance_method(:id).source
      def id
        _read_attribute(@primary_key)
      end

# `id` still returns `active_job_id`
>> GoodJob::Job.last.id
=> "d781edac-1932-4d52-bfaa-61e4d80be5e8"
# because
> GoodJob::Job.instance_variable_get("@primary_key")
=> "active_job_id"

ActiveRecord tries hard to use primary_key setting whenever id is mentioned:
https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/PrimaryKey.html


I have a few workarounds:

class JobReport < ApplicationRecord
  belongs_to :good_job, class_name: "OkJob", foreign_key: :good_job_id
end

class OkJob < GoodJob::Job
  self.primary_key = :id

  def id
    attributes["id"]
  end

  has_one :job_report, foreign_key: :good_job_id, dependent: :destroy
end
>> jr = JobReport.new; gj = OkJob.last; jr.good_job = gj; jr.save!
  OkJob Load (2.3ms)  SELECT "good_jobs".* FROM "good_jobs" WHERE "good_jobs"."retried_good_job_id" IS NULL ORDER BY "good_jobs"."id" DESC LIMIT $1  [["LIMIT", 1]]
  TRANSACTION (0.4ms)  BEGIN
  JobReport Create (1.2ms)  INSERT INTO "job_reports" ("report", "good_job_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["report", nil], ["good_job_id", "5301c9c7-2863-46cc-b8ea-7c959ed26474"], ["created_at", "2023-02-21 08:55:43.967530"], ["updated_at", "2023-02-21 08:55:43.967530"]]
  TRANSACTION (2.8ms)  COMMIT
=> true
>> jr.good_job_id == gj.attributes["id"]
=> true

This was my first attempt, it turned out to be a little awkward:

class JobReport < ApplicationRecord
  # works when you're reading the association
  belongs_to :good_job, class_name: "GoodJob::Job",
    foreign_key: :good_job_id,
    primary_key: :id,
    optional: true
  # but doesn't work when writing it, 
  # so this monstrosity takes care of it:
  belongs_to :good_job_writer, class_name: "GoodJob::Job",
    foreign_key: :good_job_id,
    primary_key: :id_attribute,
    optional: true
  alias_method :good_job=, :good_job_writer=
end

module GoodJobJobDecorator
  # doesn't work at all
  # def self.prepended base
  #   base.has_one :job_report, primary_key: :id_attribute, foreign_key: :good_job_id, dependent: :destroy
  # end                                       ^
  #          i thought this was supposed to read from `id_attribute` method
  #          but it doesn't. oh, well ¯\_(ツ)_/¯

  def job_report
    JobReport.where(good_job_id: id_attribute).first
  end
  # `dependent: :destroy` is just a callback, you can add it manually

  def id_attribute
    attributes["id"]
  end
end
GoodJob::Job.prepend(GoodJobJobDecorator)
>> jr = JobReport.new; gj = GoodJob::Job.last; jr.good_job = gj; jr.save!
  GoodJob::Job Load (2.3ms)  SELECT "good_jobs".* FROM "good_jobs" WHERE "good_jobs"."retried_good_job_id" IS NULL ORDER BY "good_jobs"."active_job_id" DESC LIMIT $1  [["LIMIT", 1]]
  TRANSACTION (0.4ms)  BEGIN
  JobReport Create (1.3ms)  INSERT INTO "job_reports" ("report", "good_job_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["report", nil], ["good_job_id", "5301c9c7-2863-46cc-b8ea-7c959ed26474"], ["created_at", "2023-02-21 09:12:59.543168"], ["updated_at", "2023-02-21 09:12:59.543168"]]
  TRANSACTION (2.9ms)  COMMIT
=> true
>> jr.good_job_id == gj.attributes["id"]
=> true
>> JobReport.last.good_job
=> #<GoodJob::Job:0x00007ff789df8c60>

You can certainly skip using associations, which is limiting, but makes a simpler set up:

class JobReport < ApplicationRecord
  def good_job
    GoodJob::Job.where(id: good_job_id).first
  end

  def good_job= job
    self.good_job_id = job.attributes["id"]
  end
end
>> jr = JobReport.new; gj = GoodJob::Job.last; jr.good_job = gj; jr.save!
  GoodJob::Job Load (2.3ms)  SELECT "good_jobs".* FROM "good_jobs" WHERE "good_jobs"."retried_good_job_id" IS NULL ORDER BY "good_jobs"."active_job_id" DESC LIMIT $1  [["LIMIT", 1]]
  TRANSACTION (0.4ms)  BEGIN
  JobReport Create (1.2ms)  INSERT INTO "job_reports" ("report", "good_job_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["report", nil], ["good_job_id", "5301c9c7-2863-46cc-b8ea-7c959ed26474"], ["created_at", "2023-02-21 09:32:52.589234"], ["updated_at", "2023-02-21 09:32:52.589234"]]
  TRANSACTION (3.0ms)  COMMIT
=> true
>> jr.good_job_id == gj.attributes["id"]
=> true