How to model this `has_one` `belongs_to` relationship? - ruby-on-rails

User and Organization have a many-to-many association through Relationship. The Relationship model includes several boolean variables about the relationship, such as moderator (true/false) and member (true/false). Also, I added a boolean called default that sets the default organization.
I require a validation that if (and only if) a user is a member of one or more organizations (member == true), one (and exactly 1) of these organizations has to have default == true.
So basically this means that if a user is member of multiple organizations, one of these organizations needs to be the default ánd if the user is a member of multiple organizations such a default organization has to exist.
How to write this validation? My current validation generates the following error upon seeding:
PG::SyntaxError: ERROR: syntax error at or near "default"
LINE 1: ...ERE (user_id = 1) AND (member = 't' and default = ...
^
: SELECT COUNT(*) FROM "relationships" WHERE (user_id = 1) AND (member = 't' and default = 't')
My implementation in the Relationship model:
validate :default
private
def default
#relationships = Relationship.where('user_id = ?', self.user_id)
#members = #relationships.where('member = ?', true)
#defaults = #members.where('default = ?', true)
# If more than 1 organization has been set as default for user
if #defaults.count > 1
#defaults.drop(0).each do |invalid|
invalid.update_columns(default: false)
end
end
# If user is member but has no default organization yet
if !#defaults.any? && #members.any?
#members.first.update_columns(default: true)
end
end
Update On the looks of it, I understand I shouldn't model it this way, and instead should use a has_one belongs_to relationship as #DavidAldridge suggests in his answer. But I don't understand how to model this relationship (see my comment below the answer). Any advice is very much appreciated.

The reason for this being difficult is that your data model is incorrect. The identity of a user's default organisation is an attribute of the user, not of the relationship, because there can be only one default per user. If you had a primary, secondary, tertiary organisation, then that would be an attribute of the relationship.
Instead of placing a "relationship is default for user" attribute on the Relationship, place a "default_relationship_id" attribute on the User so it ...
belongs_to :default_relationship
... and ...
has_one :default_organisation, :through => :default_relationship
This guarantees that:
Only one organisation can be the default for the user
There has to be a relationship between the user and its default organisation
You can also use :dependent => :nullify on the inverse association of :default_relationship, and easily test whether an individual relationship is the default based on whether:
self == user.default_relationship.
So something like:
class User << ActiveRecord::Base
has_many :relationships, :inverse_of => :user, :dependent => :destroy
has_many :organisations, :through => :relationships, :dependent => :destroy
belongs_to :default_relationship, :class_name => "Relationship", :foreign_key => :default_relationship_id, :inverse_of => :default_for_user
has_one :default_organisation, :through => :default_relationship, :source => :organisation
class Relationship << ActiveRecord::Base
belongs_to :user , :inverse_of => :relationships
belongs_to :organisation, :inverse_of => :relationships
has_one :default_for_user, :class_name => "User", :foreign_key => :default_relationship_id, :inverse_of => :default_relationship, :dependent => :nullify
class Organisation << ActiveRecord::Base
has_many :relationships, :inverse_of => :organisation, :dependent => :destroy
has_many :users , :through => :relationships
has_many :default_for_users, :through => :relationships, :source => :default_for_user
Hence you can do such simple matters as:
#user = User.find(34)
#user.default_organisation
Default organisation is also easily eager-loaded (not that it couldn't be otherwise, but no scope is required to do so).

#Brad Werth's correct that your validate method would work better as a callback.
I'd recommend something like this in your Relationship model:
before_save :set_default
private
def set_default
self.default = true unless self.user.relationships.where(member: true, default: true).any?
end
This should enforce that a user's relationship is set to default if none of the user's other relationships already are.

Change default to is_default (as pointed out by another user in comments, default is postgres keyword). Create separate migration for this. (Or you could quote it everywhere if you prefer to leave it be as it is.)
Then, there are two points.
First, why you need to check for single is_default organization every time? You just need to migrate your current data set, and then keep it consistent.
To migrate your current data set, create migration and write something like this there:
def self.up
invalid_defaults = Relationship.
where(member: true, is_default: true).
group(:user_id).
having("COUNT(*) > 1")
invalid_defaults.each do |relationship|
this_user_relationships = relationship.user.relationships.where(member: true, is_default: true)
this_user_relationships.where.not(id: this_user_relationships.first.id).update_all(is_default: false)
end
end
Just make sure to run this migration in off-peak hours, as it could take considerable amount of time to finish. Alternatevely, you can just run that code snippet from the server console itself (just test in in development environment beforehand, of course).
Then, use callback (as rightfully suggested by another commenter) to set the default organization when the record is updated
before_save :set_default
private
def set_default
relationships = Relationship.where(user_id: self.user_id)
members = relationships.where(member: true)
defaults = members.where(is_default: true)
# No need to migrate records in-place
# Change #any? to #exists?, to check existance via SQL, without actually fetching all the records
if !defaults.exists? && members.exists?
# Choosing the earliest record
members.first.update_columns(is_default: true)
end
end
To take the case into account where Organization is being edited, callback to organization should be added as well:
class Organization
before_save :unset_default
after_commit :set_default
private
# Just quque is_default for update...
def remember_and_unset_default
if self.is_default_changed? && self.is_default
#default_was_set = true
self.is_default = false
end
end
# And now update it in a multi-thread safe way: let the database handle multiple queries being sent at once,
# and let only one of them to actually complete, keeping base in always consistent state
def set_default
if #default_was_set
self.class.
# update this record...
where(id: self.id).
# but only if there is still ZERO default organizations for this user
# (thread-safety will be handled by database)
where(
"id IN (SELECT id FROM organizations WHERE member = ?, is_default = ?, user_id = ? GROUP BY user_id HAVING COUNT(*)=0)",
true, true, self.user_id
)
end
end

Related

Rails 5 how to form association between tables on multiple shared attributes

In Rails 5, given a relationship between two tables that involves joining them on multiple shared attributes, how can I form an association between the models corresponding to these tables?
SQL:
SELECT *
FROM trips
JOIN stop_times ON trips.guid = stop_times.trip_guid AND trips.schedule_id = stop_times.schedule_id
I tried the following configuration, which works in general...
class Trip < ApplicationRecord
has_many :stop_times, ->(trip){ where("stop_times.schedule_id = ?", trip.schedule_id) }, :inverse_of => :trip, :primary_key => :guid, :foreign_key => :trip_guid, :dependent => :destroy
end
class StopTime < ApplicationRecord
belongs_to :trip, :inverse_of => :stop_times, :primary_key => :guid, :foreign_key => :trip_guid
end
Trip.first.stop_times.first #> StopTime object, as expected
Trip.first.stop_times.first.trip #> Trip object, as expected
... but when I try to use it in more advanced queries, it triggers ArgumentError: The association scope 'stop_times' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported....
Trip.joins(:stop_times).first #=> the unexpected ArgumentError
StopTime.joins(:trip).first #> StopTime object, as expected
I understand what the error is referencing, but I'm unsure of how to fix it.
EDIT:
I was hoping a single association would be sufficient, but it has been noted two different associations can do the job:
class Trip < ApplicationRecord
has_many :stop_times,
->(trip){ where("stop_times.schedule_id = ?", trip.schedule_id) },
:primary_key => :guid,
:foreign_key => :trip_guid # use trip.stop_times instead of trip.joined_stop_times to avoid error about missing attribute due to missing join clause
has_many :joined_stop_times,
->{ where("stop_times.schedule_id = trips.schedule_id") },
:class_name => "StopTime",
:primary_key => :guid,
:foreign_key => :trip_guid # use joins(:joined_stop_times) instead of joins(:stop_times) to avoid error about instance-specific association
end
Trip.first.stop_times
Trip.eager_load(:joined_stop_times).to_a.first.joined_stop_times # executes a single query
If anyone reading this knows how to use a single association, please at-mention me.
I don't think it is the right solution, but it can help. You can add another similar instance independent association that will be used for preloading only. It will work with :joins and :eager_load but not with :preload.
Note that :includes might internally use either :eager_load or :preload. So, :includes will not always work with that association. You should explicitly use :eager_load instead.
class Trip < ApplicationRecord
has_many :preloaded_stop_times,
-> { where("stop_times.schedule_id = trips.schedule_id") },
class_name: "StopTime",
primary_key: :guid,
foreign_key: :trip_guid
end
# Usage
trips = Trip.joins(:preloaded_stop_times).where(...)
# ...
# with :eager_load
trips = Trip.eager_load(:preloaded_stop_times)
trips.each do |trip|
stop_times = trip.preloaded_stop_times
# ...
end

Active record: group results with included associations

I have these associations
class Application (id, priority, registration_id, uni_id)
belongs_to :registration
belongs_to :uni
end
class Uni (id, name)
has_many :applications
end
class Registration (id, fname, lname, total_points)
has_many :applications, :dependent => :destroy
has_many :unis, :through => :applications
end
Now I run query like
#registration = Registration.includes(:applications, :unis).where('applications.priority = ?', true).references(:applications).order(total_points: :desc)
So far it works okay. This outputs the registrations which have
1. application priority true and
2. order the registrations by total_points.
Now I want to get the registrations which have
1. application priority true
2. order the registrations by total_points
3. group the registrations by uni id.
For example, registrations for uni id 1, should be in one group and should be ordered by total_points, similarly registrations for uni id 2 should be in second group and should be ordered by total_points and so on. I tried to do something like this below
#registration = Registration.includes(:applications, :unis).where('applications.priority = ?', true).references(:applications).group('unis.id').order(total_points: :desc)
But this doesn't give me what I want. How should I change my query to get my expected result?
If what you want is, say, a Hash of uni_id keys and applications in the values, I don't think you can do this using ActiveRecord but you can do it with native Ruby using the group_by method on Array:
#registration = Registration.includes(:applications, :unis).where('applications.priority = ?', true).references(:applications).order(total_points: :desc).to_a.group_by('unis.id')
However be careful with this, since you're pulling all matching records into memory. This can quickly blow up in to a huge data structure, so whatever computations you can delegate to the db you should consider doing so.
For the record, you might also want to consider defining a scope:
class Application (id, priority, registration_id, uni_id)
belongs_to :registration
belongs_to :uni
scope :priority, ->{ where priority: true}
end
Registration.joins(:application).merge(Application.priority)
or a scoped association:
class Registration (id, fname, lname, total_points)
has_many :applications, :dependent => :destroy
has_many :unis, :through => :applications
has_many :priority_applications, ->{ where priority: true }
end
Registration.includes(:priority_applications)

How to check whether an object is a result of has_one or has_many?

I build a query dynamically, based on either a has_one or has_many relation. So, I can end up with either an object, or CollectionProxy. How can I test, based on this result, whether the query used the has_one or the has_many relation?
I thought of checking the type, but the CollectionProxy's type subclasses the related model's type.
This dynamic query involves calling an attribute on an object, which can be either a has_one or a has_many relation. Something like:
class User < ActiveRecord::Base
has_one :profile
has_many :names
user = User.new
attr = 'profile' # or 'names'
user.send(attr) # I want to check whether this is a result of which of the two relations
You can use Active Record's reflection:
User.reflect_on_association(:profile)
#=> #<ActiveRecord::Reflection::HasOneReflection:0x007fd2b76705c0 ...>
User.reflect_on_association(:names)
#=> #<ActiveRecord::Reflection::HasManyReflection:0x007fd2b767de78 ...>
Within a case statement:
klass = User
attr = :profile
case klass.reflect_on_association(attr)
when ActiveRecord::Reflection::HasOneReflection
# ...
when ActiveRecord::Reflection::HasManyReflection
# ...
end
### OR by macro
case klass.reflect_on_association(attr).macro
when :belongs_to
# ...
when :has_many
# ...
when :has_one
# ...
end
This works based on the association declaration in your model (user.rb), i.e. without accessing the database.
You can actually check the type of the result. You just have to check if it's an ActiveRecord::Base or an ActiveRecord::Associations::CollectionProxy.
Following your example:
class User < ActiveRecord::Base
has_one :profile
has_many :names
user = User.new
attr = 'profile'
user.send(attr).is_a? ActiveRecord::Base # true
user.send(attr).is_a? ActiveRecord::Associations::CollectionProxy # false
attr = 'names'
user.send(attr).is_a? ActiveRecord::Base # false
user.send(attr).is_a? ActiveRecord::Associations::CollectionProxy # true
This was tested on a Rails 4.1.4 but the classes are the same since Rails 3, apparently.
Consider using try like this:
post.try(:owner)
this way a has_one relation will return the owner and has_many won't.
It some situations it may be inconclusive, but should suffice for most of them.
owner is just an example:
class Post
has_one :owner
class Owner
belongs_to :post
post = Post.create ...
post.try(:owner)
returns owner if class Post has_one :owner, and nil if class Post has_many :owners
For your example: user.try(:profile)

Update attributes on has_many through associations and working with the unsaved object

This has something to do with my last quesion about unsaved objects, but now it is more about a specific problem how to use rails.
The models I have are:
class User < ActiveRecord::Base
has_many :project_participations
has_many :projects, through: :project_participations, inverse_of: :users
end
class ProjectParticipation < ActiveRecord::Base
belongs_to :user
belongs_to :project
enum role: { member: 0, manager: 1 }
end
class Project < ActiveRecord::Base
has_many :project_participations
has_many :users, through: :project_participations, inverse_of: :projects
accepts_nested_attributes_for :project_participations
end
With this models, when I create a new project I can do it by a form (fields_for etc) and then I can call update_attributes in the controller. So if I have users in the database already, I can do this:
u = Users.create # save one user in database (so we have at least one saved user)
p = Project.new
# add the user to the project as a manager
# the attributes could come from a form with `.fields_for :project_participations`
p.update_attributes(project_participations_attributes: [{user_id: u.id, role: 1}])
=> true
This works fine until I want to do something with the users of a project. For example I want add a validations that there must be at least one user for a project:
class Project < ActiveRecord::Base
...
validates :users, presence: true # there must be at least one user in a project
...
end
This now gives:
u = Users.create
p = Project.new
p.update_attributes(project_participations_attributes: [{user_id: u.id, role: 1}])
=> false
p.errors
=> #<ActiveModel::Errors:... #base=#<Project id: nil>, #messages={:users=>["can't be blank"]}>
p.users
=> #<ActiveRecord::Associations::CollectionProxy []>
p.project_participations
=> #<ActiveRecord::Associations::CollectionProxy [#<ProjectParticipation id: nil, user_id: 1, project_id: nil>]>
So on unsaved projects the .users is empty. This already bugs me (see my last quesion about unsaved objects). But in this case I can of course now work around this by doing validates :project_participations, presence: true instead of validates :users, presence: true and it should mean the same.
But this would mean I should never use the .users method (in any helper, model, view, ...) unless I am totally sure that I work with a saved object. Which in fact renders the .users method unusable (like it does with the validation of user`s presence).
If I call update_attributes like this, the validations works and it saves:
p.update_attributes(users: [u])
With this it creates the project_participation by itself so p.users works as expected. But here I cannot set any data like role for project_participation of that user.
So my questions are: Can I make the .users method work whether or not the object is saved (I think not)? But then, how can I add users to a unsaved project as a manager/member and work with the unsaved project?
I hope my problem is clear.
I think I understand you question, and you're correct in assuming that you cannot use the .users method whether or not the project model is saved. The reason for this is that in defining an association in Project (ie. has_many :users, through: :project_participations, inverse_of: :projects) you're telling rails to read the users attribute out of the database via the project_participations join table and when you haven't saved the project you have nothing to read out of the database.
In order to add a User to your project in a particular role you will need to create a new ProjectParticipation model which you will then associate to your project. If you then remove the users association and write your own users method you should be able to access your collection of users regardless of whether or not the project has been saved.
class Project < ActiveRecord::Base
has_many :project_participations
...
def users
project_participations.collect { |pp| pp.user }
end
end
Then something like:
u = Users.create
p = Project.new
pp = ProjectParticipation.new({user: u, project: p, role: 1})
p.project_participations << pp
p.users
Hopefully that helps.

Multiple entries in a :has_many through association

I need some help with a rails development that I'm working on, using rails 3.
This app was given to me a few months ago just after it's inception and I have since become rather fond of Ruby.
I have a set of Projects that can have resources assigned through a teams table.
A team record has a start date and a end date(i.e. when a resource was assigned and de-assigned from the project).
If a user has been assigned and deassigned from a project and at a later date they are to be assigned back onto the project,
instead of over writting the end date, I want to create a new entry in the Teams table, to be able to keep a track of the dates that a resource was assigned to a certain project.
So my question is, is it possible to have multiple entries in a :has_many through association?
Here's my associations:
class Resource < ActiveRecord::Base
has_many :teams
has_many :projects, :through => :teams
end
class Project < ActiveRecord::Base
has_many :teams
has_many :resources, :through => :teams
end
class Team < ActiveRecord::Base
belongs_to :project
belongs_to :resource
end
I also have the following function in Project.rb:
after_save :update_team_and_job
private
def update_team_and_job
# self.member_ids is the selected resource ids for a project
if self.member_ids.blank?
self.teams.each do |team|
unless team.deassociated
team.deassociated = Week.current.id + 1
team.save
end
end
else
self.teams.each do |team|
#assigning/re-assigning a resource
if self.member_ids.include?(team.resource_id.to_s)
if team.deassociated != nil
team.deassociated = nil
team.save
end
else
#de-assigning a resource
if team.deassociated == nil
team.deassociated = Week.current.id + 1
team.save
end
end
end
y = self.member_ids - self.resource_ids
self.resource_ids = self.resource_ids.concat(y)
self.member_ids = nil
end
end
end
Sure, you can have multiple associations. has_many takes a :uniq option, which you can set to false, and as the documentation notes, it is particularly useful for :through rel'ns.
Your code is finding an existing team and setting deassociated though, rather than adding a new Team (which would be better named TeamMembership I think)
I think you want to just do something like this:
add an assoc for active memberships (but in this one use uniq: => true:
has_many :teams
has_many :resources, :through => :teams, :uniq => false
has_many :active_resources,
:through => :teams,
:class_name => 'Resource',
:conditions => {:deassociated => nil},
:uniq => true
when adding, add to the active_resources if it doesn't exist, and "deassociate" any teams that have been removed:
member_ids.each do |id|
resource = Resource.find(id) #you'll probably want to optimize with an include or pre-fetch
active_resources << resource # let :uniq => true handle uniquing for us
end
teams.each do |team|
team.deassociate! unless member_ids.include?(team.resource.id) # encapsulate whatever the deassociate logic is into a method
end
much less code, and much more idiomatic. Also the code now more explicitly reflects the business modelling
caveat: i did not write a test app for this, code may be missing a detail or two

Resources