Rails 5, Polymorphic Associations and multiple has_ones - ruby-on-rails

I have a rails app as follows:
A location model which stores some geo-stuff (a location basically), a post model and a user model. A post model can have a location. A user model can have a location as home location and another one as remote location:
class Location < ApplicationRecord
belongs_to :locationable, polymorphic: true
end
class Post < ApplicationRecord
has_one :location, as: :locationable
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
has_one :homelocation, as: :locationable, class_name: 'Location'
has_one :remotelocation, as: :locationable, class_name: 'Location'
accepts_nested_attributes_for :homelocation, :remotelocation
end
The post and location stuff works great. If I delete one of the ´has_one´ lines from the user model and rename homelocation to location, everything works great too. If I want a user to have two different locations though, I get an 'Unpermitted parameters: homelocation, remotelocation' error when trying to save changes.
My users_controller has a
def user_params
params.require(:user).permit(:admin, :name, :motto, homelocation_attributes: [:id, :address], remotelocation_attributes: [:id, :address])
end
just as the posts_controller has a
def post_params
params.require(:post).permit(:title, :content, location_attributes: [:id, :address])
end
My forms look like this:
.form-group.string.required.user_homelocation_address
label.control-label.string.required for="user_homelocation_address"
abbr title="required"
| Home Location
input#user_homelocation_address.form-control.string.required name="user[homelocation][address]" type="text"
.form-group.string.required.user_remotelocation_address
label.control-label.string.required for="user_remotelocation_address"
abbr title="required"
| Remote Location
input#user_remotelocation_address.form-control.string.required name="user[remotelocation][address]" type="text"
So why does this work for one 'has_one', but not for two?

The issue is really that the Location does not know if it is the User's homelocation or remotelocation. The solution to this is to make the User belong to the Location
class Location < ApplicationRecord
end
class Post < ApplicationRecord
belongs_to :location
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
belongs_to :homelocation, class_name: 'Location'
belongs_to :remotelocation, class_name: 'Location'
accepts_nested_attributes_for :homelocation, :remotelocation
end
And, obviously, change the tables to match.
There is no easy way to navigate back from a Location to it's owner. Is this a requirement?
Update 1
Something I didn't say initially, and obviously with a vague understanding of the requirements, is that I consider polymorphic belongs_to to be one of those 'considered evil' topics. It is almost always a bad code smell, it means you can't implement foreign keys which I consider an essential practice and there are other ways to solve the problems it is trying to solve. My instinct would be to create 2 models and 2 tables, UserLocation and PostLocation.
As I said initially the problem remains, how do you know if a Location is a home location or a remote location, or in other words what does location.locationable = some_user set? There is no way for Rails to know and this is really what you need to solve.
Given the model above, there are ways to navigate from the Location to it's owner but to make it perform decently I would suggest that you add a type field to the Location table so that you know if it is a post, home or remote location. You could then write:
class Location < ApplicationRecord
def owner
case type
when 'post' Post.where(location_id: self).first
when 'home' User.where(homelocation_id: self).first
when 'remote' User.where(remotelocation_id: self).first
end
# or #
def owner
case type
when 'post' Post.where(location_id: self).first
else User.where('homelocation_id = ? OR remotelocation_id = ?', self, self).first
end
end
You could in theory do the same thing using STI wih a Location class and UserLocation and PostLocation subclasses.
Option 2
Having thought about it, I might implement this would be:
class Location < ApplicationRecord
belongs_to :locationable, polymorphic: true
## Again add a `type` field ##
end
class Post < ApplicationRecord
has_one :location, as: :locationable
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
has_many :locations, as: :locationable, class_name: 'Location'
has_one :homelocation, class_name: 'Location', foreign_key: 'locationable_id', -> { where(type: 'home') }
has_one :remotelocation, class_name: 'Location', foreign_key: 'locationable_id', -> { where(type: 'remote') }
accepts_nested_attributes_for :homelocation, :remotelocation
end
Though my thoughts about belongs_to polymorphic still stand :)
In all cases, you might also need to write code to create the homelocation and remotelocation instances rather than using accepts_nested_attributes. Also these options would not perform well if you are retrieving many records and trying to solve the n+1 problem.

Related

How to structure a has_many association with a dynamic scope?

I have a users table in my db. A user can be either of type 'admin' or 'manager'.
Given the models and schema below, I would like that for each instance of 'manager' user, an 'admin' user could select one, some or all the locations of the tenant that the manager belongs to in order to select which locations the manager can have control over.
My models
class User < ActiveRecord::Base
belongs_to :tenant
class Tenant < ActiveRecord::Base
has_many :users, dependent: :destroy
has_many :locations, dependent: :destroy
class Location < ActiveRecord::Base
belongs_to :tenant, inverse_of: :locations
I've tried two paths
First, trying to establish a scoped has_many association between the User and the Location models. However, I can't wrap my head around structuring this scope so that an 'admin' user could select which locations the 'manager' users can control.
Second, setting up a controlled_locations attribute in the users table. Then I set up some code so that an 'admin' user can select which locations a 'manager' can control, populating its 'controlled_locations' attribute. However, what gets saved in the database (inside the controlled_locations array) is strings instead of instances of locations.
Here's the code that I tried for the second path:
The migration
def change
add_column :users, :controlled_locations, :string, array: true, default: []
end
In the view
= f.input :controlled_locations, label: 'Select', collection: #tenant_locations, include_blank: "Anything", wrapper_html: { class: 'form-group' }, as: :check_boxes, include_hidden: false, input_html: {multiple: true}
In the users controller (inside the update method)
if params["user"]["controlled_locations"]
params["user"]["controlled_locations"].each do |l|
resource.controlled_locations << Location.find(l.to_i)
end
resource.save!
end
What I expect
First of all, I'm not quite sure the second path that I tried is a good approach (storing arrays in the db). So my best choice would be to set up a scoped association if it's possible.
In case the second path is feasible, what I would like to get is something like this. Let's say that logging in an Admin, I selected that the user with ID 1 (a manager) can control one location (Boston Stadium):
user = User.find(1)
user.controlled_locations = [#<Location id: 55, name: "Boston Stadium", created_at: "2018-10-03 12:45:58", updated_at: "2018-10-03 12:45:58", tenant_id: 5>]
Instead, what I get after trying is this:
user = User.find(1)
user.controlled_locations = ["#<Location:0x007fd2be0717a8>"]
Instead of instances of locations, what gets saved in the array is just plain strings.
First, your code is missing the locations association in the Tenant class.
class Tenant < ActiveRecord::Base
has_many :users, dependent: :destroy
has_many :locations
Let's say the variable manager has a User record. Then the locations it can control are:
manager.tenant.locations
If you want, you can shorten this with a delegate statement.
class User < ActiveRecord::Base
belongs_to :tenant
delegate :locations, to: :tenant
then you can call this with
manager.locations
A common pattern used for authorization is roles:
class User < ApplicationRecord
has_many :user_roles
has_many :roles, through: :user_roles
def add_role(name, location)
self.roles << Role.find_or_create_by(name: name, location: location)
end
def has_role?(name, location)
self.roles.exists?(name: name, location: location)
end
end
# rails g model role name:string
# make sure you add a unique index on name and location
class Role < ApplicationRecord
belongs_to :location
has_many :user_roles
has_many :users, through: :user_roles
validates_uniqueness_of :name, scope: :location_id
end
# rails g model user_role user:references role:references
# make sure you add a unique compound index on role_id and user_id
class UserRole < ApplicationRecord
belongs_to :role
belongs_to :user
validates_uniqueness_of :user_id, scope: :role_id
end
class Location < ApplicationRecord
has_many :roles
has_many :users, through: :roles
end
By making the system a bit more generic than say a controlled_locations association you can re-use it for different cases.
Let's say that logging in an Admin, I selected that the user with ID 1
(a manager) can control one location (Boston Stadium)
User.find(1)
.add_role(:manager, Location.find_by(name: "Boston Stadium"))
In actual MVC terms you can do this by setting up roles as a nested resource that can be CRUD'ed just like any other resource. Editing multiple roles in a single form can be done with accepts_nested_attributes or AJAX.
If you want to scope a query by the presence of a role then join the roles and user roles table:
Location.joins(roles: :user_roles)
.where(roles: { name: :manager })
.where(user_roles: { user_id: 1 })
To authenticate a single resource you would do:
class ApplicationController < ActionController::Base
protected
def deny_access
redirect_to "your/sign_in/path", error: 'You are not authorized.'
end
end
class LocationsController < ApplicationController
# ...
def update
#location = Location.find(params[:location_id])
deny_access and return unless current_user.has_role?(:manger, #location)
# ...
end
end
Instead of rolling your own authorization system though I would consider using rolify and pundit.

How to implement dynamic class_name for the association has_many? For the same table, different engines

Please tell me the way how to implement dynamic associative link, which is itself determined by the attribute model.
I have two engines(Tbitcoin, Tstripe) each of them have a table payment. The model User has pay_currency attribute, which is the managing.
class User < ActiveRecord::Base
has_many :payments, ~> { where "pay_currency = 'real'" } , class_name: Tstripe::Payment, foreign_key: :uid
has_many :payments, ~> { where "pay_currency = 'bitcoin'" } ,class_name: Tbitcoin::Payment, foreign_key: :uid
end
What are the ways to dynamically determine the engine using User.last.payments.create ?
I think that you need a regular method instead of has_many association which will find proper payments associated with the user according to pay_currency value. Example:
class User < ActiveRecord::Base
def payments
payment_class = case pay_currency
when "real"
Tstripe::Payment
when "bitcoin"
Tbitcoin::Payment
end
payment_class.for_user(self)
end
end
class Tstripe::Payment < ActiveRecord::Base
belongs_to :user
def self.for_user(user)
where(user_id: user.uid)
end
end

Can I add both sides of a has_many and belongs_to relationship in a concern?

Edit: In retrospect this isn't that great of an idea. You are putting functionality that belongs to ZipWithCBSA into the models of others. The models receiving the concern act as they are supposed to, and the fact that ZipWithCBSA responds to :store_locations should be obvious in some capacity from ZipWithCBSA. It has nothing to do with the other models/concern. Props to Robert Nubel for making this obvious with his potential solutions.
Is it possible to both has_many and belongs_to relationships in a single concern?
Overview
I have a table ZipWithCBSA that essentially includes a bunch of zip code meta information.
I have two models that have zip codes: StoreLocation and PriceSheetLocation. Essentially:
class ZipWithCBSA < ActiveRecord::Base
self.primary_key = :zip
has_many :store_locations, foreign_key: :zip
has_many :price_sheet_locations, foreign_key: :zip
end
class StoreLocation< ActiveRecord::Base
belongs_to :zip_with_CBSA, foreign_key: :zip
...
end
class PriceSheetLocation < ActiveRecord::Base
belongs_to :zip_with_CBSA, foreign_key: :zip
...
end
There are two properties from ZipWithCBSA that I always want returned with my other models, including on #index pages. To prevent joining this table for each item every time I query it, I want to cache two of these fields into the models themselves -- i.e.
# quick schema overview~~~
ZipWithCBSA
- zip
- cbsa_name
- cbsa_state
- some_other_stuff
- that_I_usually_dont_want
- but_still_need_access_to_occasionally
PriceSheetLocation
- store
- zip
- cbsa_name
- cbsa_state
StoreLocation
- zip
- generic_typical
- location_address_stuff
- cbsa_name
- cbsa_state
So, I've added
after_save :store_cbsa_data_locally, if: ->(obj){ obj.zip_changed? }
private
def store_cbsa_data_locally
if zip_with_cbsa.present?
update_column(:cbsa_name, zip_with_cbsa.cbsa_name)
update_column(:cbsa_state, zip_with_cbsa.cbsa_state)
else
update_column(:cbsa_name, nil)
update_column(:cbsa_state, nil)
end
end
I'm looking to move these into concerns, so I've done:
# models/concerns/UsesCBSA.rb
module UsesCBSA
extend ActiveSupport::Concern
included do
belongs_to :zip_with_cbsa, foreign_key: 'zip'
after_save :store_cbsa_data_locally, if: ->(obj){ obj.zip_changed? }
def store_cbsa_data_locally
if zip_with_cbsa.present?
update_column(:cbsa_name, zip_with_cbsa.cbsa_name)
update_column(:cbsa_state, zip_with_cbsa.cbsa_state)
else
update_column(:cbsa_name, nil)
update_column(:cbsa_state, nil)
end
end
private :store_cbsa_data_locally
end
end
# ~~models~~
class StoreLocation < ActiveRecord::Base
include UsesCBSA
end
class PriceSheetLocation < ActiveRecord::Base
include UsesCBSA
end
This is all working great-- but I still need to manually add the has_many relationships to the ZipWithCBSA model:
class ZipWithCBSA < ActiveRecord::Base
has_many :store_locations, foreign_key: zip
has_many :price_sheet_locations, foreign_key: zip
end
Finally! The Question!
Is it possible to re-open ZipWithCBSA and add the has_many relationships from the concern? Or in any other more-automatic way that allows me to specify one single time that these particular series of models are bffs?
I tried
# models/concerns/uses_cbsa.rb
...
def ZipWithCBSA
has_many self.name.underscore.to_sym, foregin_key: :zip
end
...
and also
# models/concerns/uses_cbsa.rb
...
ZipWithCBSA.has_many self.name.underscore.to_sym, foregin_key: :zip
...
but neither worked. I'm guessing it has to do with the fact that those relationships aren't added during the models own initialization... but is it possible to define the relationship of a model in a separate file?
I'm not very fluent in metaprogramming with Ruby yet.
Your best bet is probably to add the relation onto your ZipWithCBSA model at the time your concern is included into the related models, using class_exec on the model class itself. E.g., inside the included block of your concern, add:
relation_name = self.table_name
ZipWithCBSA.class_exec do
has_many relation_name, foreign_key: :zip
end

On Rails, how to add a new instance with what's "through"ing?

So, I'm using rails. And here are three classes
class User < ActiveRecord::Base
has_many :ownerships, as: :ownable
has_many :products, through: :ownerships
end
class Manufacturer < ActiveRecord::Base
has_many :ownerships, as: :ownable
has_many :products, through: :ownerships
end
class Product < ActiveRecord::Base
has_many :ownerships
end
# ...
# ownable_id :uuid
# ownable_type :enum
# type :string
# status :enum
class Ownership < ActiveRecord::Base
belongs_to :product
belongs_to :ownable, polymorphic: true
end
So, the situation is, User and Manufacturer can have Product through ownership. And both has ownership as polymorphic association.
And in my controller, I'd like to add a new product right off a user with { type: "PrimaryOwner", status: "Approved" } for the ownership that's going to get created.
The code I'd like to run is simply this...
# product_params is
# {
# name: "My new product ASD-Z23",
# description: "It's a product at the storage number #123QWERTY",
# ownership: {
# type: "PrimaryOwner",
# status: "Approved"
# }
# }
current_user.products.new(product_params)
And what I expect is that code will create a product with the ownership. (Since in the User model, I already told it that it can have product "through" a ownership.)
And of course, this code wasn't working.
Is there any good rails way to do that?
Thanks!
It's not clear if you are trying to create a new ownership for the product, or the ownership already exists and you're trying to use that association. If the latter, then you need to use nested routes to specify the ownership (ie ownerships/2/products/new). current_user will just need to be merged with the params.
If you're trying to create a new ownership and the ownership params in product_params are always going to be the same, then you can just run an :after_create callback which calls a method that creates an ownership.
If not an you need the form for both a ownership and a product at the same time, then that'll be a bit trickier. Let me know if that's the case.

Rails association with multiple foreign keys

I want to be able to use two columns on one table to define a relationship. So using a task app as an example.
Attempt 1:
class User < ActiveRecord::Base
has_many :tasks
end
class Task < ActiveRecord::Base
belongs_to :owner, class_name: "User", foreign_key: "owner_id"
belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end
So then Task.create(owner_id:1, assignee_id: 2)
This allows me to perform Task.first.owner which returns user one and Task.first.assignee which returns user two but User.first.task returns nothing. Which is because task doesn't belong to a user, they belong to owner and assignee. So,
Attempt 2:
class User < ActiveRecord::Base
has_many :tasks, foreign_key: [:owner_id, :assignee_id]
end
class Task < ActiveRecord::Base
belongs_to :user
end
That just fails altogether as two foreign keys don't seem to be supported.
So what I want is to be able to say User.tasks and get both the users owned and assigned tasks.
Basically somehow build a relationship that would equal a query of Task.where(owner_id || assignee_id == 1)
Is that possible?
Update
I'm not looking to use finder_sql, but this issue's unaccepted answer looks to be close to what I want: Rails - Multiple Index Key Association
So this method would look like this,
Attempt 3:
class Task < ActiveRecord::Base
def self.by_person(person)
where("assignee_id => :person_id OR owner_id => :person_id", :person_id => person.id
end
end
class Person < ActiveRecord::Base
def tasks
Task.by_person(self)
end
end
Though I can get it to work in Rails 4, I keep getting the following error:
ActiveRecord::PreparedStatementInvalid: missing value for :owner_id in :donor_id => :person_id OR assignee_id => :person_id
TL;DR
class User < ActiveRecord::Base
def tasks
Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
end
end
Remove has_many :tasks in User class.
Using has_many :tasks doesn't make sense at all as we do not have any column named user_id in table tasks.
What I did to solve the issue in my case is:
class User < ActiveRecord::Base
has_many :owned_tasks, class_name: "Task", foreign_key: "owner_id"
has_many :assigned_tasks, class_name: "Task", foreign_key: "assignee_id"
end
class Task < ActiveRecord::Base
belongs_to :owner, class_name: "User"
belongs_to :assignee, class_name: "User"
# Mentioning `foreign_keys` is not necessary in this class, since
# we've already mentioned `belongs_to :owner`, and Rails will anticipate
# foreign_keys automatically. Thanks to #jeffdill2 for mentioning this thing
# in the comment.
end
This way, you can call User.first.assigned_tasks as well as User.first.owned_tasks.
Now, you can define a method called tasks that returns the combination of assigned_tasks and owned_tasks.
That could be a good solution as far the readability goes, but from performance point of view, it wouldn't be that much good as now, in order to get the tasks, two queries will be issued instead of once, and then, the result of those two queries need to be joined as well.
So in order to get the tasks that belong to a user, we would define a custom tasks method in User class in the following way:
def tasks
Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
end
This way, it will fetch all the results in one single query, and we wouldn't have to merge or combine any results.
Extending upon #dre-hh's answer above, which I found no longer works as expected in Rails 5. It appears Rails 5 now includes a default where clause to the effect of WHERE tasks.user_id = ?, which fails as there is no user_id column in this scenario.
I've found it is still possible to get it working with a has_many association, you just need to unscope this additional where clause added by Rails.
class User < ApplicationRecord
has_many :tasks, ->(user) {
unscope(:where).where(owner: user).or(where(assignee: user)
}
end
Rails 5:
you need to unscope the default where clause
see #Dwight answer if you still want a has_many associaiton.
Though User.joins(:tasks) gives me
ArgumentError: The association scope 'tasks' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported.
As it is no longer possible you can use #Arslan Ali solution as well.
Rails 4:
class User < ActiveRecord::Base
has_many :tasks, ->(user){ where("tasks.owner_id = :user_id OR tasks.assignee_id = :user_id", user_id: user.id) }
end
Update1:
Regarding #JonathanSimmons comment
Having to pass the user object into the scope on the User model seems like a backwards approach
You don't have to pass the user model to this scope.
The current user instance is passed automatically to this lambda.
Call it like this:
user = User.find(9001)
user.tasks
Update2:
if possible could you expand this answer to explain what's happening? I'd like to understand it better so I can implement something similar. thanks
Calling has_many :tasks on ActiveRecord class will store a lambda function in some class variable and is just a fancy way to generate a tasks method on its object, which will call this lambda. The generated method would look similar to following pseudocode:
class User
def tasks
#define join query
query = self.class.joins('tasks ON ...')
#execute tasks_lambda on the query instance and pass self to the lambda
query.instance_exec(self, self.class.tasks_lambda)
end
end
I worked out a solution for this. I'm open to any pointers on how I can make this better.
class User < ActiveRecord::Base
def tasks
Task.by_person(self.id)
end
end
class Task < ActiveRecord::Base
scope :completed, -> { where(completed: true) }
belongs_to :owner, class_name: "User", foreign_key: "owner_id"
belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
def self.by_person(user_id)
where("owner_id = :person_id OR assignee_id = :person_id", person_id: user_id)
end
end
This basically overrides the has_many association but still returns the ActiveRecord::Relation object I was looking for.
So now I can do something like this:
User.first.tasks.completed and the result is all completed task owned or assigned to the first user.
Since Rails 5 you can also do that which is the ActiveRecord safer way:
def tasks
Task.where(owner: self).or(Task.where(assignee: self))
end
My answer to Associations and (multiple) foreign keys in rails (3.2) : how to describe them in the model, and write up migrations is just for you!
As for your code,here are my modifications
class User < ActiveRecord::Base
has_many :tasks, ->(user) { unscope(where: :user_id).where("owner_id = ? OR assignee_id = ?", user.id, user.id) }, class_name: 'Task'
end
class Task < ActiveRecord::Base
belongs_to :owner, class_name: "User", foreign_key: "owner_id"
belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end
Warning:
If you are using RailsAdmin and need to create new record or edit existing record,please don't do what I've suggested.Because this hack will cause problem when you do something like this:
current_user.tasks.build(params)
The reason is that rails will try to use current_user.id to fill task.user_id,only to find that there is nothing like user_id.
So,consider my hack method as an way outside the box,but don't do that.
Better way is using polymorphic association:
task.rb
class Task < ActiveRecord::Base
belongs_to :taskable, polymorphic: true
end
assigned_task.rb
class AssignedTask < Task
end
owned_task.rb
class OwnedTask < Task
end
user.rb
class User < ActiveRecord::Base
has_many :assigned_tasks, as: :taskable, dependent: :destroy
has_many :owned_tasks, as: :taskable, dependent: :destroy
end
In result, we can use it so:
new_user = User.create(...)
AssignedTask.create(taskable: new_user, ...)
OwnedTask.create(taskable: new_user, ...)
pp user.assigned_tasks
pp user.owned_tasks

Resources