Montenegrodr
Montenegrodr

Reputation: 1636

How to force a has_many association using a different column as foreign key

Stuck in a - at first sight - simple problem in RoR. I am sure that is easy, but none answer here in SO helped me too much.

I have two ActiveRecord models: Foo has many Bars:

class Foo < ApplicationRecord
    has_many :bars
end

class Bar < ApplicationRecord
  belongs_to :foo
end

That works like a charm. But I would like to use another field of Foo as foreign_key. The default is foo_id I would like to use custom_id as my foreign key. So I tried this (as many solutions over the web suggested):

class Foo < ApplicationRecord
    has_many :bars, :foreign_key => 'custom_id', :class_name => 'Bars'
end

class Bars < ApplicationRecord
  belongs_to :foo, :class_name => 'Foo'
end

But that doesn't work. i.e. ActiveRecord keeps binding Foo to Bars using foo_id.

Note: Including a self.primary_key='custom_id' in Foo would partially work. but I don't think that is a good idea. I want to keep foo_id as the primary key

UPDATE:

Given the feedback -Thank you guys-, I uploaded that example here https://github.com/montenegrodr/temporary_repository_ror :

UPDATE #2:

The answers does not satisfy the question above. Why the test is failing, my assumption is that it shouldn't fail.

UPDATE #3:

There're a couple of new answers I still need to assess. Will do that within 24 hours. Thanks.

UPDATE #4:

Thank you guys for all the answers. But none of them satisfied the criteria. I need to have that test passing. If it is not possible, can someone explain why? Is it a rails constraint?

Upvotes: 13

Views: 6701

Answers (6)

EmmanuelB
EmmanuelB

Reputation: 1427

I will leave you a complete file you can save and run with ruby filename.rb that will show the test pass. (The template for this test was taken from the Rails bug_report_templates)

association_test.rb

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activerecord", "5.2.0"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :foos, force: true do |t|
    t.integer :custom_id, index: { unique: true }
    t.timestamps
  end

  create_table :bars, force: true do |t|
    t.integer :custom_id, index: true
    t.timestamps
  end
end

class Foo < ActiveRecord::Base
  has_many :bars, foreign_key: :custom_id, primary_key: :custom_id
end

class Bar < ActiveRecord::Base
  belongs_to :foo, foreign_key: :custom_id, primary_key: :custom_id
end

class BugTest < Minitest::Test
  def test_the_truth
    foo = Foo.new id: 1, custom_id: 100
    bar = Bar.new foo: foo
    assert foo.custom_id == bar.custom_id
  end
end

Explanation

The class of both Foo and Bar can both be infered in the associations, so you dont have to specify class_name in either of them.

If you don't include the primary_key the relation will use the one it has by default: id. This is why your test bar.foo_id == 1, because 1 is the id of Foo, being that the default primary_key

Remember the column id is created in each table by Rails unless you tell it explicitly not to.

Knowing hich column belongs to which table in the relation may be quite confusing, I will leave another example with different column names on each table for clarification. I also changed the model names to better understand which is playing each role.

ActiveRecord::Schema.define do
  create_table :classrooms, force: true do |t|
    t.integer :my_classroom_id, index: { unique: true }
    t.timestamps
  end

  create_table :students, force: true do |t|
    t.integer :student_c_id, index: true
    t.timestamps
  end
end

class Classroom < ActiveRecord::Base
  has_many :students, foreign_key: :student_c_id, primary_key: :my_classroom_id
end

class Student < ActiveRecord::Base
  belongs_to :classroom, foreign_key: :student_c_id, primary_key: :my_classroom_id
end

class BugTest < Minitest::Test
  def test_the_truth
    classroom = Classroom.new id: 1, my_classroom_id: 100
    student = Student.new classroom: classroom
    assert student.student_c_id == classroom.my_classroom_id
  end
end

Adding only the correct primary_key in both your models will make your test pass.

Upvotes: 3

Remigio Arenas
Remigio Arenas

Reputation: 71

Set the foreign_key and the primary_key of Bar model to the desired column name (custom_id in this case).

class Bar < ApplicationRecord
  belongs_to :foo, foreign_key: "custom_id", primary_key: "custom_id"
end

Upvotes: 2

Kyle Ratliff
Kyle Ratliff

Reputation: 598

You need to specify a different primary key for the relationship if you wish to achieve what you are looking to do.

To clarify, this is not the same as changing the primary_key of the model. This way is only changing the primary key used by the relationship. Please see the bottom of this post for examples.

I changed the keys from both using custom_id and changed one to foo_id. This way you have a better idea of what is going on between the models. You can use both custom_id if you wish, but I would suggest keeping the rails norm of foo_id for the belongs_to association.

If you want to use both of custom_id you'll have to add some specific foreign_keys


Here are the models:

Foo

class Foo < ApplicationRecord
  has_many :bars,
           primary_key: :custom_id, 
           foreign_key: :foo_id
end

Bar

class Bar < ApplicationRecord
  belongs_to :foo, 
             primary_key: :custom_id
end

The Migrations

CreateFoos

class CreateFoos < ActiveRecord::Migration[5.2]
  def change
    create_table :foos do |t|

      t.integer :custom_id, index: {unique: true}
      t.timestamps
    end
  end
end

CreateBars

class CreateBars < ActiveRecord::Migration[5.2]
  def change
    create_table :bars do |t|

      t.integer :foo_id, index: true
      t.timestamps
    end
  end
end

Here is the updated Test that should now be passing:

Test

require 'test_helper'

class BarTest < ActiveSupport::TestCase
  test "the truth" do
    foo = Foo.new(id: 1, custom_id: 100)
    bar = Bar.new(foo: foo)

    assert bar.foo_id == foo.custom_id
    # bar.foo_id    = 100
    # foo.custom_id = 100
  end
end

Examples

Foo.find(1) #<Foo id: 1, custom_id: 100>
Bar.first #<Bar id: 1, foo_id: 100>

Bar.first.foo = #<Foo id: 1, custom_id: 100>
Bar.first.foo == Foo.find(1) # true

As you can see, this method does not change the primary key of Foo itself. It changes the primary key the relationship between Foo and Bar uses. Bar is realated to foo via custom_id: 100, but foo is still found with it's id: 1 key, not its custom_id key.

Upvotes: 13

Saiqul Haq
Saiqul Haq

Reputation: 2397

I cloned your repository and run the test code, it was fail

the other answers on your question are correct, but you wrote wrong test code and add unnecessary column on Foo model

actually you only need to add custom_id attribute to Bar model

class CreateBars < ActiveRecord::Migration[5.2]
  def change
    create_table :bars do |t|

      t.integer :custom_id, index: true
      t.timestamps
    end
  end
end

class Bar < ApplicationRecord
  belongs_to :foo, :class_name => "Foo", :foreign_key => "custom_id"
end

and for Foo model

class CreateFoos < ActiveRecord::Migration[5.2]
  def change
    create_table :foos do |t|
      t.timestamps
    end
  end
end
class Foo < ApplicationRecord
  has_many :bars, :class_name => "Bar", :foreign_key => "custom_id"
end

and then to test the relation

require 'test_helper'

class BarTest < ActiveSupport::TestCase
  test 'the truth' do
    foo = Foo.new
    foo.save!
    bar = Bar.new(foo: foo)
    bar.save!

    assert foo.bar_ids.include?(bar.id)
    assert bar.foo_id == foo.id
  end
end

actually this is not how I wrote rails code only for answering your question

Upvotes: 1

Deepak Mahakale
Deepak Mahakale

Reputation: 23661

While the above answer is correct I am adding some explanation to the answer.

For the has_many association to work properly you need to add foreign_key: :custom_id to Foo model. This will search bars table for records with custom_id = id of Foo

class Foo < ApplicationRecord
  has_many :bars, foreign_key: :custom_id
end

Old Query

SELECT "bars".* FROM "bars" WHERE "bars"."foo_id" = $1  [["foo_id", 1]]

New Query

SELECT "bars".* FROM "bars" WHERE "bars"."custom_id" = $1  [["custom_id", 1]]

For the belongs_to association to work properly you need to add foreign_key: :custom_id to Bar model as well. This will search the foos table and return the record with id = custom_id of Foo instead of foo_id

class Bars < ApplicationRecord
  belongs_to :foo, foreign_key: :custom_id
end

Old Query

# <Bar id: 1, foo_id: 1, custom_id: 2, ...>

SELECT  "foos".* FROM "foos" WHERE "foos"."id" = $1 [["id", 1]]

New Query

SELECT  "foos".* FROM "foos" WHERE "foos"."id" = $1 [["id", 2]]

Upvotes: 4

Anand
Anand

Reputation: 6531

1 => Given implementation needs custom_id: integer in Barmodel

class Foo < ApplicationRecord
    has_many :bars, :class_name => "Bar", :foreign_key => "custom_id"
end

class Bar < ApplicationRecord
  belongs_to :foo, :class_name => "Foo", :foreign_key => "custom_id"
end

Upvotes: 1

Related Questions