Rails many to many polymorphic on both sides - ruby-on-rails

Rails 5
Looking on how to do a join table with has many through relationships where both sides are polymorphic and the relationships are just parents or children which could be of any class
I'm building a Laboratory Information Management System and inside this system there are approximately 30 "data modules" (each their own model) which fall into three different categories and can generally be parents or children to any of the other 30 data module classes
So lets say I have DataModuleA - DataModuleZ (ie 26 modules one for each letter)
I want any of those modules to be either a parent or a child to other modules and I want to be able to call .child_modules or .parent_modules on any of those objects and get a list of all the parents or children
Right now I have a join table module_relationships
create_table :module_relationships, id: :uuid do |t|
t.references :child_module, polymorphic: true, type: :uuid, index: {:name => 'index_module_relationships_on_child_type_and_id'}
t.references :parent_module, polymorphic: true, type: :uuid, index: {:name => 'index_module_relationships_on_parent_type_and_id'}
t.timestamps
end
And then an abstract base class which all of the modules can inherit from
class ApplicationModule < ApplicationRecord
## maybe should go back to being a concern where individual has many throughs are determined at the class level
self.abstract_class = true
has_many :child_module_relationships, class_name: "ModuleRelationship", foreign_key: "parent_module"
has_many :parent_module_relationships, class_name: "ModuleRelationship", foreign_key: "child_module"
# has_many :child_modules, through: :child_module_relationships ## doesnt work now
# has_many :parent_modules, through: :parent_module_relationships ## doesnt work now
def create_child_module_relationship(child_module)
ModuleRelationship.create(
parent_module_id: self.id,
parent_module_type: self.class.to_s,
child_module_id: child_module.id,
child_module_type: child_module.class.to_s
)
end
def create_parent_module_relationship(parent_module)
ModuleRelationship.create(
child_module_id: self.id,
child_module_type: self.class.to_s,
parent_module_id: parent_module.id,
parent_module_type: parent_module.class.to_s
)
end
end
Right now trying to call .child_modules throws an error:
ActiveRecord::HasManyThroughAssociationPolymorphicSourceError (Cannot have a has_many :through association 'ApplicationModule#child_modules' on the polymorphic object 'ChildModule#child_module' without 'source_type'. Try adding 'source_type: "ChildModule"' to 'has_many :through' definition.)
Which more or less makes sense to me but I thought that was the whole point of defining the type on the join table so it would know where to look for the objects.
Should I just write a scope and do a nasty n+1 query for each ModuleRelationship Record?
Any other thoughts on how I could get the .child_modules and .parent_modules to return an a multi_model list of modules ?

Not sure if I understand your question correctly, but here is something that maybe helps you to generate some ideas for your solution.
An example where I'm storing countries and link countries to companies:
class Admin::SystemValues::Country < ApplicationRecord
has_many :linked_countries, class_name: "Common::LinkedCountry"
has_many :companies, class_name: "Common::Company", through: :linked_countries,
source: :countryable, source_type: "Common::Company"
end
class Common::LinkedCountry < ApplicationRecord
# == Schema Information
#
# Table name: common_linked_countries
#
# id :bigint(8) not null, primary key
# country_id :integer not null
# countryable_id :bigint(8) not null
# countryable_type :string not null
# updated_at :datetime
#
# Indexes
#
# index_common_linked_countries_on_country_and_countryable (country_id,countryable_id,countryable_type) UNIQUE
#
belongs_to :countryable, polymorphic: true
belongs_to :country, class_name: 'Admin::SystemValues::Country', optional: true
end
class Common::Company < ApplicationRecord
has_many :linked_countries, class_name: 'Common::LinkedCountry', as: :countryable
has_many :countries, class_name: 'Admin::SystemValues::Country', through: :linked_countries
end
So basically I can link Country to any model I want through country_id. I can get my countries with Common::Company.first.countries.
Maybe you can somehow store those DataModuleA - DataModuleZ (ie 26 modules one for each letter) within one model and then do something like above. For example maybe you can store an attribute like letter (a..z) and then you have all data in one model and table to query later.

Related

Generic model that has many relationship depending on value of enum

I have the following class:
class Blog < ApplicationRecord
enum platform: {
wordpress: 'wordpress',
drupal: 'drupal',
}
has_many :wordpress_posts
has_many :drupal_posts
end
Besides platform, it also holds things like url and category. It also has a relationship of has many with WordPressPost and DrupalPost:
class WordPressPost < ApplicationRecord
belong_to :blog
end
class DrupalPost < ApplicationRecord
belongs_to :blog
end
I would like to know, if it's possible to infere what has_many relationship should be the valid, depending on the platform value: if the platform value is wordpress, the blog should only contain relationships with wordpress post entities. I'm not sure if there is a Rails way to solve it. I would love if someone can help me and show me the proper Rails way of implement such data model.
Thanks
Your idea of using a enum won't work here since assocations are class level and the value of the enum is only known on the instance level.
If you really wanted to use an enum you could hack something together with an instance method but it won't really behave like an assocation when it comes to stuff like eager loading:
class Blog < ApplicationRecord
# ...
def posts
send("#{platform}_posts")
end
end
What you can do is use Single Table Inheritance to setup classes that share a table yet have different behavior.
First add a type column to the table:
class AddDetailsToBlogs < ActiveRecord::Migration[6.0]
def change
change_table :blogs do |t|
t.remove :platform
t.string :type, index: true, null: false
end
end
end
If you have existing data you should go through it and set the type column based on the value of platform before you drop platform and make type non-nullable.
Then setup the subclasses:
class Blog < ApplicationRecord
# shared behavior
end
class WordPressBlog < Blog
has_many :posts,
class_name: 'WordPressPost',
foreign_key: :blog_id,
inverse_of: :blog
end
class DrupalBlog < Blog
has_many :posts,
class_name: 'DrupalPost',
foreign_key: :blog_id,
inverse_of: :blog
end
The main advantage of STI is that it lets you query as a single table and thus treat it as a homogenous collection, the drawbacks are that you are potentially wasting database space with columns containing largely nulls and it can become quite unweildy if the types differ to much from each other.

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.
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.
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.

Rails: Address model being used twice, should it be separated into two tables?

I am making an ecommerce site, and I have Purchases which has_one :shipping_address and has_one :billing_address
In the past the way I've implemented this is to structure my models like so:
class Address < ActiveRecord::Base
belongs_to :billed_purchase, class_name: Purchase, foreign_key: "billed_purchase_id"
belongs_to :shipped_purchase, class_name: Purchase, foreign_key: "shipped_purchase_id"
belongs_to :state
end
class Purchase < ActiveRecord::Base
INCOMPLETE = 'Incomplete'
belongs_to :user
has_one :shipping_address, class: Address, foreign_key: "shipped_purchase_id"
has_one :billing_address, class: Address, foreign_key: "billed_purchase_id"
...
end
As you can see, I reuse the Address model and just mask it as something else by using different foreign keys.
This works completely find, but is there a cleaner way to do this? Should I be using concerns? I'm sure the behavior of these two models will always be 100% the same, so I'm not sure if splitting them up into two tables is the way to go. Thanks for your tips.
EDIT The original version of this was wrong. I have corrected it and added a note to the bottom.
You probably shouldn't split it into two models unless you have some other compelling reason to do so. One thing you might consider, though, is making the Address model polymorphic. Like this:
First: Remove the specific foreign keys from addresses and add polymorphic type and id columns in a migration:
remove_column :addresses, :shipping_purchase_id
remove_column :addresses, :billing_purchase_id
add_column :addresses, :addressable_type, :string
add_column :addresses, :addressable_id, :integer
add_column :addresses, :address_type, :string
add_index :addresses, [:addressable_type, :addressable_id]
add_index :addresses, :address_type
Second: Remove the associations from the Address model and add a polymorphic association instead:
class Address < ActiveRecord::Base
belongs_to :addressable, polymorphic: true
...
end
Third: Define associations to it from the Purchase model:
class Purchase < ActiveRecord::Base
has_one :billing_address, -> { where(address_type: "billing") }, as: :addressable, class_name: "Address"
has_one :shipping_address, -> { where(address_type: "shipping") }, as: :addressable, class_name: "Address"
end
Now you can work with them like this:
p = Purchase.new
p.build_billing_address(city: "Phoenix", state: "AZ")
p.build_shipping_address(city: "Indianapolis", state: "IN")
p.save!
...
p = Purchase.where(...)
p.billing_address
p.shipping_address
In your controllers and views this will work just like what you have now except that you access the Purchase for an Address by calling address.addressable instead of address.billed_purchase or address.shipped_purchase.
You can now add additional address joins to Purchase or to any other model just by defining the association with the :as option, so it is very flexible without model changes.
There are some disadvantages to polymorphic associations. Most importantly, you can't eager fetch from the Address side in the above setup:
Address.where(...).includes(:addressable) # <= This will fail with an error
But you can still do it from the Purchase side, which is almost certainly where you'd need it anyway.
You can read up on polymorphic associations here: Active Record Association Guide.
EDIT NOTE: In the original version of this, I neglected to add the address_type discriminator column. This is pernicious because it would seem like it is working, but you'd get the wrong address records back after the fact. When you use polymorphic associations, and you want to associate the model to another model in more than one way, you need a third "discriminator" column to keep track of which one is which. Sorry for the mixup!
In addtion to #gwcoffey 's answer.
Another option would be using Single Table Inhertinace which perhaps suits more for that case, because every address has a mostly similar format.

Multiple associations to same table with alias

I have a legacy application not written in Ruby. The legacy app uses a SQL Server DB and my Rails app needs to access this legacy DB.
The database has a User table, ViewKey table, and two bridge tables that implement two many-to-many relationships between User and ViewKey. The bridge tables are UserViewKeyAllowed and UserViewKeyDenied. So each user may have multiple view keys they are allowed to access and multiple view keys they are denied access.
Here are the models I have defined:
class User < ActiveRecord::Base
self.table_name = 'tblUser'
attribute_names.each { |attr| alias_attribute attr.underscore, attr }
has_many :user_groups, foreign_key: :tblUserID
has_many :user_view_key_alloweds, foreign_key: :tblUserID
has_many :user_view_key_denieds, foreign_key: :tblUserID
has_many :groups, through: :user_groups
has_many :view_key_alloweds, through: :user_view_key_alloweds, source: :view_key
has_many :view_key_denieds, through: :user_view_key_denieds, source: :view_key
end
class ViewKey < ActiveRecord::Base
self.table_name = 'tblViewKey'
attribute_names.each { |attr| alias_attribute attr.underscore, attr }
has_many :group_view_keys, foreign_key: :tblViewKeyID
has_many :groups, through: :group_view_keys
has_many :user_view_key_alloweds, foreign_key: 'tblViewKeyID'
has_many :user_view_key_denieds, foreign_key: 'tblUserID'
end
class UserViewKeyDenied < ActiveRecord::Base
self.table_name = 'tblUserViewKeyDenied'
attribute_names.each { |attr| alias_attribute attr.underscore, attr }
belongs_to :view_key, primary_key: 'ID', foreign_key: 'tblViewKeyID'
belongs_to :user, primary_key: 'ID', foreign_key: 'tblViewKeyID'
end
class UserViewKeyAllowed < ActiveRecord::Base
self.table_name = 'tblUserViewKeyAllowed'
attribute_names.each { |attr| alias_attribute attr.underscore, attr }
belongs_to :view_key, primary_key: 'ID', foreign_key: 'tblViewKeyID'
belongs_to :user, primary_key: 'ID', foreign_key: 'tblUserID'
end
I've got the "has_many: ... through:" association working and the following Ruby seems to query the data correctly:
user = User.where(ID: 116).eager_load(:view_key_alloweds, :view_key_denieds).first
I'd like to be able to do something like the following:
user = User.where(ID: 116).eager_load(:view_key_alloweds, :view_key_denieds).where('tblViewKey.IsWebView = 1').first
I need to expand the second "where" in be something like "tblViewKey.IsWebView = 1 AND SomeAlias.IsWebView = 1". Is there a way to specify the alias for the ViewKey model or is there a better way to do this? Does active record have a standard way of naming table aliases?
I'm looking into using a scope but I'm not sure how to define one for a "has_many ... through" association.
After more research I was able to come up with a solution that works and has good performance. Active Record does have a scheme for creating table aliases when joining to the same table multiple times. It seems to be the association name plus the name of the table joining from. So using the models shown I can query the data I want using:
#user = User.where(ID: params[:user_id])
.eager_load(:view_key_alloweds, :view_key_denieds, groups: [:view_keys])
.where('[tblViewKey].IsWebView = 1 OR [view_key_denieds_tblUser].IsWebView = 1 OR [view_keys_tblGroup].IsWebView = 1')
.first
The :view_key_alloweds association is listed first in the call to eager_load so my where does not need a alias for that join to the tblViewKey table. The :view_key_denieds also joins to the tblViewKey table but since it joins from the tblUser table I know the alias will be [view_key_denieds_tblUser]. The association through "groups: [:view_key]" is accomplished through the tblGroups table so the alias will be [view_keys_tblGroup].
My code works but I don't know of a way to tap into the aliases being generated. A future version of Rails and Active Record could break my code. There is a discussion here https://github.com/rails/rails/issues/12224 about aliases but I'm new enough to ruby, rails, and active record that most of it was over my head.

Associations and (multiple) foreign keys in rails (3.2) : how to describe them in the model, and write up migrations

I have 3 models: Question, Option, Rule
Question has_many options;
Option needs a foreign key for question_id
Rule table consists of 3 foreign_keys:
2 columns/references to question_ids -> foreign keys named as 'assumption_question_id' and 'consequent_question_id'
1 column/reference to option_id -> foreign key named as option_id or condition_id
Associations for Rule:
Question has_many rules; and
Option has_one rule
I want to understand how to write up migrations for this, and how that associates to the 'has_many'/'belongs_to' statements I write up in my model, and the ':foreign_key' option I can include in my model.
I had this for my Option migration, but I'm not sure how the "add_index" statement works in terms of foreign keys, and how I can use it for my Rule migration: (my Question and Options models have appropriate has_many and belongs_to statements - and work fine)
class CreateOptions < ActiveRecord::Migration
def change
create_table :options do |t|
t.integer :question_id
t.string :name
t.integer :order
t.timestamps
end
add_index :options, :question_id
end
end
Thank you for the help!
Note: I have found this way to solve the problem.Kindness from China.
If you have RailsAdmin with you,you may notice that you can see all rules of one question as long as one field of both question fields(assumption_question_id,consequent_question_id) equals to id of the question.
I have done detailed test on this and found out that Rails always generates a condition "question_id = [current_id]" which make to_sql outputs
SELECT `rules`.* FROM `rules` WHERE `rules`.`question_id` = 170
And the reason that the following model
class Question < ActiveRecord::Base
has_many :options
# Notice ↓
has_many :rules, ->(question) { where("assumption_question_id = ? OR consequent_question_id = ?", question.id, question.id) }, class_name: 'Rule'
# Notice ↑
end
makes Question.take.rules.to_sql be like this
SELECT `rules`.* FROM `rules` WHERE `rules`.`question_id` = 170 AND (assumption_question_id = 170 OR consequent_question_id = 170)
Is that we have not yet get ride of the annoy question_id so no matter how we describe or condition properly, our condition follows that "AND".
Then,we need to get ride of it.How?
Click here and you will know how,Find sector 8.1,and you can see
Article.where(id: 10, trashed: false).unscope(where: :id)
# SELECT "articles".* FROM "articles" WHERE trashed = 0
Then lets do it:
class Question < ActiveRecord::Base
has_many :options
# Notice ↓
has_many :rules, ->(question) { unscope(where: :question_id).where("assumption_question_id = ? OR consequent_question_id = ?", question.id, question.id) }, class_name: 'Rule'
# Notice ↑
end
class Rule < ActiveRecord::Base
belongs_to :option
belongs_to :assumption_question, class_name: "Question", foreign_key: :assumption_question_id, inverse_of: :assumption_rules
belongs_to :consequent_question, class_name: "Question", foreign_key: :consequent_question_id, inverse_of: :consequent_rules
end
class Option < ActiveRecord::Base
belongs_to :question
has_one :rule
end
All done.
Finally
This is my first answer here at stackoverflow,and this method is never found anywhere else.
Thanks for reading.
add_index adds an index to column specified, nothing more.
Rails does not provide native support in migrations for managing foreign keys. Such functionality is included in gems like foreigner. Read the documentation that gem to learn how it's used.
As for the associations, just add the columns you mentioned in your Question to each table (the migration you provided looks fine; maybe it's missing a :rule_id?)
Then specify the associations in your models. To get you started
class Question < ActiveRecord::Base
has_many :options
has_many :assumption_rules, class_name: "Rule"
has_many :consequent_rules, class_name: "Rule"
end
class Rule < ActiveRecord::Base
belongs_to :option
belongs_to :assumption_question, class_name: "Question", foreign_key: :assumption_question_id, inverse_of: :assumption_rules
belongs_to :consequent_question, class_name: "Question", foreign_key: :consequent_question_id, inverse_of: :consequent_rules
end
class Option < ActiveRecord::Base
belongs_to :question
has_one :rule
end
Note This is just a (untested) start; options may be missing.
I strongly recommend you read
http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html
http://guides.rubyonrails.org/association_basics.html
Edit: To answer the question in your comment
class Option < ActiveRecord::Base
belongs_to :question
# ...
The belongs_to tells rails that the question_id column in your options table stores an id value for a record in your questions table. Rails guesses the name of the column is question_id based on the :question symbol. You could instruct rails to look at a different column in the options table by specifying an option like foreign_key: :question_reference_identifier if that was the name of the column. (Note your Rule class in my code above uses the foreign_key option in this way).
Your migrations are nothing more than instructions which Rails will read and perform commands on your database based from. Your models' associations (has_many, belongs_to, etc...) inform Rails as to how you would like Active Record to work with your data, providing you with a clear and simple way to interact with your data. Models and migrations never interact with one another; they both independently interact with your database.
You can set a foreign key in your model like this:
class Leaf < ActiveRecord::Base
belongs_to :tree, :foreign_key => "leaf_code"
end
You do not need to specify this in a migration, rails will pull the foreign key from the model class definition.

Resources