Talon
Talon

Reputation: 73

Rails has_one association with multiple primary keys

Ruby 2.6.5 on Rails 5.2.4.1

Current Situation

I've got a table of Products, and I'd like to associate products as compatible with one another. For example, Product 1 is compatible with Product 2.

When creating the first ProductCompatibility, I populate it as follows:

#<ProductLibrary::ProductCompatibility id: 1, product_id: 1, compatible_product_id: 2>

At the moment, I can perform the following:

    0> ProductLibrary::Product.find(1).compatible_products
    => #<ActiveRecord::Associations::CollectionProxy [#<ProductLibrary::Product id: 2>]

But I would also like to be able to perform the following, without creating an additional record:

    0> ProductLibrary::Product.find(2).compatible_products
    => #<ActiveRecord::Associations::CollectionProxy [#<ProductLibrary::Product id: 1>]

Currently, the above returns the following:

    0> ProductLibrary::Product.find(2).compatible_products
    => #<ActiveRecord::Associations::CollectionProxy [#<ProductLibrary::Product id: 2>]

Current Code

My models look like this:

module ProductLibrary
  class Product < ApplicationRecord

    has_many :product_compatibilities, ->(p) {
      unscope(where: :product_id)
        .where(product_id: p.id)
        .or(ProductLibrary::ProductCompatibility.where(compatible_product_id: p.id))
    }

    has_many :compatible_products, through: :product_compatibilities

  end
end
module ProductLibrary
  class ProductCompatibility < ApplicationRecord

    belongs_to :product

    has_one :compatible_product,
            primary_key: :compatible_product_id,
            foreign_key: :id,
            class_name: 'ProductLibrary::Product'
  end
end

Intention

The primary_key in compatible_product is why I'm getting Product 2 when I request Product 2's compatible products (instead of Product 1).

What I'd like is for the has_one :compatible_product association to return products where the primary key is both :compatible_product_id and :product_id, but I can't figure out how to do that without writing multiple associations and compiling them in a helper method (which feels clunky and unconventional).

I'm not even sure it's possible, but it seems like it's along the lines of a
ProductLibrary::Product.where(id: [:product_id, :compatible_product_id])
which I couldn't get to work as association logic.

Upvotes: 1

Views: 1190

Answers (2)

Talon
Talon

Reputation: 73

Here's what I ended up with, thanks to some help from @max

module ProductLibrary
  class Product < ApplicationRecord

    has_many :product_compatibilities, ->(p) {
      unscope(where: :product_id)
        .where(product_id: p.id)
        .or(ProductLibrary::ProductCompatibility.where(compatible_product_id: p.id))
    }

    has_many :compatible_products, through: :product_compatibilities
    has_many :inverse_compatible_products, through: :product_compatibilities

    def all_compatible
      (self.compatible_products + self.inverse_compatible_products).uniq.sort - [self]
    end
  end
end
module ProductLibrary
  class ProductCompatibility < ApplicationRecord

    belongs_to :product

    belongs_to :compatible_product,
               class_name: 'ProductLibrary::Product'

    belongs_to :inverse_compatible_product,
               foreign_key: :product_id,
               class_name:  'ProductLibrary::Product'
  end
end

I'll probably rename some things, and we may need to implement a boolean to drive whether a product can be compatible with itself (for now I assume not).

It's kind of what I was trying to avoid, but it looks like this is a correct solution.

Upvotes: 1

max
max

Reputation: 102443

You should be using belongs_to instead of has_one.

module ProductLibrary
  class ProductCompatibility < ApplicationRecord
    belongs_to :product
    belongs_to :compatible_product, 
       class_name: 'ProductLibrary::Product'
  end
end

The semantics of has_one and belongs_to are a really common source of confusion but the difference is with belongs_to the foreign key column is on this models table and with has_one the FKC is on the other model.

What you are creating here is really just a join table and join model with the slight difference that both foreign keys happen to point to the same table instead of two different tables.

Upvotes: 3

Related Questions