Avoiding `save!` on Has Many Through Association - ruby-on-rails

I have a has_many through association with an attribute and some validations on the "join model". When I try to do something like #user.projects << #project and the association has already been created (thus the uniqueness validation fails), an exception is raised instead of the error being added to the validation errors.
class User
has_many :project_users
has_many :projects, :through => :project_users
class Project
has_many :project_users
has_many :users, :through => :project_users
class ProjectUser
belongs_to :user
belongs_to :project
# ...
if #user.projects << #project
redirect_to 'somewhere'
else
render :new
end
How can I create the association like I would with the << method, but calling save instead of save! so that I can show the validation errors on my form instead of using a rescue to catch this and handle it appropriately?

I don't think you can. From the API:
collection<<(object, …) Adds one or more objects to the collection by
setting their foreign keys to the collection’s primary key. Note that
this operation instantly fires update sql without waiting for the save
or update call on the parent object.
and
If saving fails while replacing the collection (via association=), an
ActiveRecord::RecordNotSaved exception is raised and the assignment is
cancelled.
A workaround might look like this:
if #user.projects.exists? #project
#user.errors.add(:project, "is already assigned to this user") # or something to that effect
render :new
else
#user.projects << #projects
redirect_to 'somewhere'
end
That would allow you to catch the failure where the association already exists. Of course, if other validations on the association could be failing, you still need to catch the exception, so it might not be terribly helpful.

Maybe you could try to add an validation to your project model, like:
validates :user_id, :uniqueness => {:scope => :user_id}, :on => :create
Not sure if that helps to avoid the save! method..

Try declaring the associations as
has_many :projects, :through => :project_users, :uniq => true
Checkout out section 4.3.2.21 in http://guides.rubyonrails.org/association_basics.html.

Related

Rails validation messages for has_many through

I'm having trouble accessing validation messages for a related model when saving. The setup is that a "Record" can link to many other records via a "RecordRelation" which has a label stating what that relation is, e.g. that a record "refers_to" or "replaces" another:
class Record < ApplicationRecord
has_many :record_associations
has_many :linked_records, through: :record_associations
has_many :references, foreign_key: :linked_record_id, class_name: 'Record'
has_many :linking_records, through: :references, source: :record
...
end
class RecordAssociation < ApplicationRecord
belongs_to :record
belongs_to :linked_record, :class_name => 'Record'
validates :label, presence: true
...
end
Creating the record in the controller looks like this:
def create
# Record associations must be added separately due to the through model, and so are extracted first for separate
# processing once the record has been created.
associations = record_params.extract! :record_associations
#record = Record.new(record_params.except :record_associations)
#record.add_associations(associations)
if #record.save
render json: #record, status: :created
else
render json: #record.errors, status: :unprocessable_entity
end
end
And in the model:
def add_associations(associations)
return if associations.empty? or associations.nil?
associations[:record_associations].each do |assoc|
new_association = RecordAssociation.new(
record: self,
linked_record: Record.find(assoc[:linked_record_id]),
label: assoc[:label],
)
record_associations << new_association
end
end
The only problem with this is if the created association is somehow incorrect. Rather than seeing the actual reason, the error I get back is a validation for the Record, i.e.
{"record_associations":["is invalid"]}
Can anyone suggest a means that I might get record_association's validation back? This would be useful information for a user.
For your example, I would rather go with nested_attributes. Then you should easily get access to associated record errors. An additional benefit of using it is removing custom logic you have written for such behavior.
For more information check documentation - https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

has_and_belongs_to_many validation such that users can't apply multiple times to a workshop

Currently I'm building a system where users can apply to workshops... the only problem is that a user can apply multiple times.
This is the code for applying
#apply to workshop
def apply
#workshop = Workshop.find(params[:id])
#workshop.users << #current_user
if #workshop.save
#workshop.activities.create!({:user_id => #current_user.id, :text => "applied to workshop"})
flash[:success] = "You successfully applied for the workshop"
redirect_to workshop_path(#workshop)
else
flash[:error] = "You can't apply multiple times for the same workshop"
redirect_to workshop_path(#workshop)
end
end
The Workshop model does the following validation:
has_and_belongs_to_many :users #relationship with users...
validate :unique_apply
protected
def unique_apply
if self.users.index(self.users.last) != self.users.length - 1
errors.add(:users, "User can't apply multiple times to a workshop")
end
end
And the save fails because the message "You can't apply multiple times for the same workshop" shows up.
But the user is still added to the workshop as an attendee?
I think the problem is that the user is already added to the array before the save applies, then the save fails but the user isn't removed from the array.
How can I fix this issue?
Thanks!
Marcel
UPDATE
Added this in the migration so there are no duplicates in the database only ruby on rails doesn't catch the sql error, so it crashes ugly.
add_index(:users_workshops, [:user_id, :workshop_id], :unique => true)
UPDATE SOLUTION
Fixed the problem by doing the following:
Create a join model instead of a has_and_belongs_to_many relation
This is the join model:
class UserWorkshop < ActiveRecord::Base
belongs_to :user
belongs_to :workshop
validates_uniqueness_of :user_id, :scope => :workshop_id
end
This is the relationship definition in the other models:
In User:
has_many :workshops, :through => :user_workshops
has_many :user_workshops
In Workshop:
has_many :users, :through => :user_workshops, :uniq => true
has_many :user_workshops
Because you can only do a uniqueness validation on the current model you can't validate uniqueness on a has_and_belongs_to_many relation. Now we have a join model where we join users and workshops through, so the relationship in user and workshop stays the same the only BIG difference is that you can do validation in the join model. This is exactly what we want, we want to verify that there is only one :user_id per :workshop_id and therefore we use validates_uniqueness_of :user_id, :scope => :workshop_id
Case solved!
P.S. Watch carefully that you mention the through relation (:user_workshops) as a separate has_many relation otherwise the model can't find the association!!
According to "The Rails 3 Way", the "has_and belongs_to_man" is practically obsolete.
You should use has_many :through with an intermediate table.

My "has_many through" join model has nil reference after saving

I'm trying to create an object and adding an existing object to a "has_many through" association, but after saving my object the reference to my newly created object is set to nil in the join model.
To be specific, I'm creating a Notification object and adding a pre-existing Member object to the Notification.members association. I'm using nested resources and I'm invoking the notification controller's new function using the following relative URL:
/members/1/notifications/new
After filling out the form and submitting, the create function is called, and from what I understand from the Rails Associations guide, section 4.3.3 "When are Objects Saved?", the members associations should be created in the database when the new notification object is saved:
"If the parent object (the one declaring the has_many association) is unsaved (that is, new_record? returns true) then the child objects are not saved when they are added. All unsaved members of the association will automatically be saved when the parent is saved."
After creating the notification object, the following record was created in the database:
select id, notification_id, notifiable_type, notifiable_id from deliveries;
1|<NULL>|Member|1
I worked around the problem by saving the notification object before adding the member object to the association. At first this seemed to be an ok solution for now, but I soon discovered that this has it's downsides. I don't want to save the notification without it's member association since I then have to write workarounds for my callbacks so that they don't start performing tasks on the not yet valid notification object.
What am I doing wrong here? All tips are appreciated. :D
Models
class Notification < ActiveRecord::Base
has_many :deliveries, :as => :notifiable
has_many :members, :through => :deliveries, :source => :notifiable, :source_type => "Member"
has_many :groups, :through => :deliveries, :source => :notifiable, :source_type => "Group"
end
class Member < ActiveRecord::Base
has_many :deliveries, :as => :notifiable
has_many :notifications, :through => :deliveries
end
class Delivery < ActiveRecord::Base
belongs_to :notification
belongs_to :notifiable, :polymorphic => true
end
# Group is not really relevant in this example.
class Group < ActiveRecord::Base
has_many :deliveries, :as => :notifiable
has_many :notifications, :through => :deliveries
end
Controller
class NotificationsController < ApplicationController
def create
#notification = Notification.new(params[:notification])
#member = Member.find(params[:member_id])
#notification.members << #member
respond_to do |format|
if #notification.save
...
end
end
end
end
After posting a bug report, I got som help from one of the Rails gurus. In short, it's impossible to get this working the way I thought.
I decided to proceed with slightly more controller code, seems to be working just fine:
def create
#notification = Notification.new(params[:notification])
#member = Member.find(params[:member_id])
respond_to do |format|
if #notification.save
#member.notifications << #notification
#member.save
...

before_destroy is not firing from update_attributes

I have a student that has many courses. In the student#update action and form, I accept a list of course_ids. When that list changes, I'd like to call a certain function. The code I have does get called if the update_attributes creates a course_student, but does not get called if the update_attributes destroys a course_student. Can I get this to fire, or do I have to detect the changes myself?
# app/models/student.rb
class Student < ActiveRecord::Base
belongs_to :teacher
has_many :grades
has_many :course_students, :dependent => :destroy
has_many :courses, :through => :course_students
has_many :course_efforts, :through => :course_efforts
# Uncommenting this line has no effect:
#accepts_nested_attributes_for :course_students, :allow_destroy => true
#attr_accessible :first_name, :last_name, :email, :course_students_attributes
validates_presence_of :first_name, :last_name
...
end
# app/models/course_student.rb
class CourseStudent < ActiveRecord::Base
after_create :reseed_queues
before_destroy :reseed_queues
belongs_to :course
belongs_to :student
private
def reseed_queues
logger.debug "******** attempting to reseed queues"
self.course.course_efforts.each do |ce|
ce.reseed
end
end
end
# app/controllers/students_controller.rb
def update
params[:student][:course_ids] ||= []
respond_to do |format|
if #student.update_attributes(params[:student])
format.html { redirect_to(#student, :notice => 'Student was successfully updated.') }
format.xml { head :ok }
else
format.html { render :action => "edit" }
format.xml { render :xml => #student.errors, :status => :unprocessable_entity }
end
end
end
It turns out that this behavior is documented, right on the has_many method. From the API documentation:
collection=objects
Replaces the collections content by deleting and adding objects as appropriate. If the :through option is true callbacks in the join models are triggered except destroy callbacks, since deletion is direct.
I'm not sure what "since deletion is direct" means, but there we have it.
When a record is deleted using update/update_attributes, it fires delete method instead of destroy.
#student.update_attributes(params[:student])
Delete method skips callbacks and therefore after_create / before_destroy would not be called.
Instead of this accepts_nested_attributes_for can be used which deletes the record and also support callbacks.
accepts_nested_attributes_for :courses, allow_destroy: true
#student.update_attributes(courses_attributes: [ {id: student_course_association_id, _destroy: 1 } ])
Accepts nested attributes requires a flag to trigger nested destroy. Atleast it did back when.
If you add dependent: :destroy, it will honour that. Note that if you are using has_many through:, one needs to add that option to both.
has_many :direct_associations, dependent: :destroy, autosave: true
has_many :indirect_associations, through: :direct_associations, dependent: :destroy
(I have used that in Rails 3, I bet it will work on Rails 4 too)
Should your CourseStudent specify belongs_to :student, :dependent => :destroy, it seems like a CourseStudent record would not be valid without a Student.
Trying to follow the LH discussion I linked to above, and this one I'd also try moving the before_destroy callback in CourseStudent below the belongs_to. The linked example demonstrates how the order of callbacks matters with an after_create, perhaps the same applies to before_destroy. And of course, since you're using Edge Rails, I'd also try the RC, maybe there was a bug they fixed.
Failing those things, I'd try to make a really simple Rails app with two models that demonstrates the problem and post it to Rails' Lighthouse.
Wasn't able to leave a comment, so I'll just add an answer entry.
Just encountered the same bug. After hours of trying to figure this issue out and an hour of google-ing, I stumbled to this question by chance. Along with the linked LH ticket, and quote from the API, it now makes sense. Thank you!
While googling, found an old ticket. Direct link does not work, but google cache has a copy.
Just check Google for cached version of dev.rubyonrails.org/ticket/7743
Seems like a patch never made it to Rails.

validates_uniqueness_of in destroyed nested model rails

I have a Project model which accepts nested attributes for Task.
class Project < ActiveRecord::Base
has_many :tasks
accepts_nested_attributes_for :tasks, :allow_destroy => :true
end
class Task < ActiveRecord::Base
validates_uniqueness_of :name
end
Uniqueness validation in Task model gives problem while updating Project.
In edit of project i delete a task T1 and then add a new task with same name T1, uniqueness validation restricts the saving of Project.
params hash look something like
task_attributes => { {"id" =>
"1","name" => "T1", "_destroy" =>
"1"},{"name" => "T1"}}
Validation on task is done before destroying the old task. Hence validation fails.Any idea how to validate such that it doesn't consider task to be destroyed?
Andrew France created a patch in this thread, where the validation is done in memory.
class Author
has_many :books
# Could easily be made a validation-style class method of course
validate :validate_unique_books
def validate_unique_books
validate_uniqueness_of_in_memory(
books, [:title, :isbn], 'Duplicate book.')
end
end
module ActiveRecord
class Base
# Validate that the the objects in +collection+ are unique
# when compared against all their non-blank +attrs+. If not
# add +message+ to the base errors.
def validate_uniqueness_of_in_memory(collection, attrs, message)
hashes = collection.inject({}) do |hash, record|
key = attrs.map {|a| record.send(a).to_s }.join
if key.blank? || record.marked_for_destruction?
key = record.object_id
end
hash[key] = record unless hash[key]
hash
end
if collection.length > hashes.length
self.errors.add_to_base(message)
end
end
end
end
As I understand it, Reiner's approach about validating in memory would not be practical in my case, as I have a lot of "books", 500K and growing. That would be a big hit if you want to bring all into memory.
The solution I came up with is to:
Place the uniqueness condition in the database (which I've found is always a good idea, as in my experience Rails does not always do a good job here) by adding the following to your migration file in db/migrate/:
add_index :tasks [ :project_id, :name ], :unique => true
In the controller, place the save or update_attributes inside a transaction, and rescue the Database exception. E.g.,
def update
#project = Project.find(params[:id])
begin
transaction do
if #project.update_attributes(params[:project])
redirect_to(project_path(#project))
else
render(:action => :edit)
end
end
rescue
... we have an exception; make sure is a DB uniqueness violation
... go down params[:project] to see which item is the problem
... and add error to base
render( :action => :edit )
end
end
end
For Rails 4.0.1, this issue is marked as being fixed by this pull request, https://github.com/rails/rails/pull/10417
If you have a table with a unique field index, and you mark a record
for destruction, and you build a new record with the same value as the
unique field, then when you call save, a database level unique index
error will be thrown.
Personally this still doesn't work for me, so I don't think it's completely fixed yet.
Rainer Blessing's answer is good.
But it's better when we can mark which tasks are duplicated.
class Project < ActiveRecord::Base
has_many :tasks, inverse_of: :project
accepts_nested_attributes_for :tasks, :allow_destroy => :true
end
class Task < ActiveRecord::Base
belongs_to :project
validates_each :name do |record, attr, value|
record.errors.add attr, :taken if record.project.tasks.map(&:name).count(value) > 1
end
end
Ref this
Why don't you use :scope
class Task < ActiveRecord::Base
validates_uniqueness_of :name, :scope=>'project_id'
end
this will create unique Task for each project.

Resources