Search code examples
ruby-on-railspostgresqlrails-activejob

Rails: Build Association when id Method is Non-standard


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)

Solution

  • 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