Rails - Validate presence of one of two belongs_to columns - ruby-on-rails

I have a model called Log that has relations to two models ButtonPress and LinkClick. I need to validate the presence of either (for analytics reasons, I'm required to do this without using polymorphic relations)
My Spec file is as follows:
RSpec.describe RedirectLog, type: :model do
it { is_expected.to belong_to(:button_press).optional }
it { is_expected.to belong_to(:link_click).optional }
it "has either a button_press or a link_click" do
log = build(:log, button_press: nil, link_click: nil)
expect(log).not_to be_valid
end
end
To achieve this I created the model as follows:
class Log < ApplicationRecord
belongs_to :button_press, optional: true
belongs_to :link_click, optional: true
validates :button_press, presence: true, unless: :link_click
end
When I run this, I get the following error:
Failure/Error: it { is_expected.to belong_to(:button_press).optional }
Expected Log to have a belongs_to association called button_press (and for the record not to fail validation if :button_press is unset; i.e., either the association should have been defined with `optional: true`, or there should not be a presence validation on :button_press)
So in short, I cant have the belongs_to association if I want to validate its presence, even conditionally. My question is, what is the Rails way to have a validation on one of two belongs_to columns without using polymorphism?

One way to do it is with a custom validator. I'd make it completely formal with its own file and such, but for a proof-of-concept you could do this:
class Log < ApplicationRecord
###################################################################
#
# Associations
#
###################################################################
belongs_to :button_press, optional: true
belongs_to :link_click, optional: true
###################################################################
#
# Validations
#
###################################################################
validate :button_press_or_link_click_presence
###################################################################
#
# In-Model Custom Validators (Private)
#
###################################################################
def button_press_or_link_click_presence
self.errors.add(:base, 'Either a Button Press or a Link Click is required, but both are missing.') if button_press.blank? && link_click.blank?
end
private :button_press_or_link_click_presence
end

Related

Rails: querying associations during validation

During validation, I want to query associations but neither solution seems to be good because ActiveRecord’s style of validation. Here is an example:
class User < ApplicationRecord
has_many :borrowed_books
validate :should_only_borrow_good_books
def should_only_borrow_good_books
# What I want but it does not work:
#
# unless borrowed_books.where(condition: "bad").empty?
# errors.add(:borrowed_books, "only good books can be borrowed")
# end
#
# ^ this query always returns an empty array
# This approach works but it's not ideal:
unless borrowed_books.all? { |b| b.condition == "good" }
errors.add(:borrowed_books, "only good books can be borrowed")
end
end
end
class BorrowedBook < ApplicationRecord
belongs_to :user
# attr: condition - ["bad", "good"]
end
One more option is to move the validation to BorrowedBook with something like validates :condition, inclusion: { in: %w(good) }, if: -> { user_id.present? } and perhaps validate association in User like validates_associated :borrowed_books. But I don't like this approach because it complicates things by moving the logic belonging to User to BorrowedBook. A few validations like this and your app might become really messy.
This validation should definitely stay in the user model. I do disagree that it will look messy if there are multiple conditions. If it makes messy, it often indicate that you should split the model or refactor the code there. It's the models job to enable you to access and validate the data from db. A way to improve the current code is to convert the condition column to enum, ref: https://api.rubyonrails.org/v5.1/classes/ActiveRecord/Enum.html you can write it like this
class User < ApplicationRecord
has_many :borrowed_books
validate :should_only_borrow_good_books
private
def should_only_borrow_good_books
return unless books.not_good.any?
errors.add(:borrowed_books, "only good books can be borrowed")
end
end
end
class BorrowedBook < ApplicationRecord
belongs_to :user
enum status: [ :good, :bad ]
end

Best way to prevent ActiveRecord association validations on specific actions

I have 2 models: User and Purchase. Purchases belong to Users.
class User < ApplicationRecord
has_many :purchases
end
class Purchase < ApplicationRecord
belongs_to :user
enum status: %i[succeeded pending failed refunded]
end
In Rails 5.2, a validation error is raised when any modifications are made to a Purchase with no associated User. This works great for new purchases, but it also throws errors when simply trying to save data on an existing purchase with a user that no longer exists in the database.
For example:
user = User.find(1)
# Fails because no user is passed
purchase = Purchase.create(status: 'succeeded')
# Succeeds
purchase = Purchase.create(user: user, status: 'succeeded')
purchase.status = 'failed'
purchase.save
user.destroy
# Fails because user doesn't exist
purchase.status = 'refunded'
purchase.save
I know I can prevent the second update from failing by making the association optional with belongs_to :user, optional: true in the purchase model, but that cancels out the first validation for purchase creation as well.
I could also just build my own custom validations for the user association, but I'm looking for a more conventional Rails way to do this.
You can use validation contexts https://guides.rubyonrails.org/active_record_validations.html#on
You can make the relationship optional and then add a validation only on create but not on update (default behaviour is on save):
belongs_to :user, optional: true
validates :user, presence: true, on: :create
You can use the if: and unless: options to make validations conditional:
class Purchase < ApplicationRecord
belongs_to :user, optional: true # suppress generation of the default validation
validates_presence_of :user, unless: :refunded?
enum status: %i[succeeded pending failed refunded]
end
You can pass the name of a method or a lambda. These shouldn't be confused with the if and unless keywords - they are just keyword arguments.
The optional: option for belongs_to really just adds a validates_presence_of validation with no options. Which is nice as a shorthand but not really that flexible.

Rspec testing association results in failure

I'm trying to create "components" for my project and each component belongs to one of four types (Section, Composition, LearningIntentions, or EssentialQuestion). A section is a component and can contain multiple components, but a section cannot contain a section.
But when I run my spec, I get the error Expected Component to have a has_many association called components (no association called components). But it works when I move the spec from section_spec.rb to component_spec.rb. What is happening here?
component.rb (model):
class Component < ApplicationRecord
# == Constants =============================
TYPES = %w( Section Composition LearningIntentions EssentialQuestion )
# == Associations ==========================
belongs_to :project
belongs_to :section,
class_name: 'Component',
foreign_key: :section_id,
optional: true
# # == Validations ===========================
validates :type,
presence: true,
inclusion: { in: TYPES }
end
section.rb (model):
class Section < Component
# == Associations ==========================
has_many :components,
class_name: 'Component',
foreign_key: :section_id
# == Validations ===========================
validates :section_id, presence: false
end
section_spec.rb:
RSpec.describe Section, type: :model do
subject! { build(:component, :section) }
# == Associations ==========================
it { is_expected.to have_many(:components) }
end
Reason for failed test
But it works when I move the spec from section_spec.rb to component_spec.rb
The location of your rspec is not the cause of failure here, as it looks to me. What you are doing wrong is, assigning a Component instance in your section_spec.rb as the subject and so, it expects the association :components to be present on that Component instance.
Fix for failed test
Change the code to below and see if it works:
RSpec.describe Section, type: :model do
subject { build(:section) }
it { is_expected.to have_many(:components) }
end
Using traits in factory
If you want to build a Section instance with components assigned to it, then define a trait and use it as below:
# Factory
factory :section do
trait :with_components do
after(:build) do |s|
# Modify this to create components of different types if you want to
s.components = [create(:component)]
end
end
end
# Rspec
subject { build(:section, :with_components) }
Couple of more things
This association in your Component model looks fishy:
belongs_to :section,
class_name: 'Component',
foreign_key: :section_id,
optional: true
Are you sure you want to link a Component instance to another Component instance?
And while doing that, you want to use foreign key :section_id instead of conventional component_id?
You already have a model Section in your application. It might cause confusions. See below:
component = Component.first
component.section
=> #<Component id: 10001> # I would expect it to be a `Section` instance looking at the association name

Rails validate association count before removing

My models are
class Company
has_many :admins
validate :has_one_admin_validation
private
def has_one_admin_validation
errors.add(:admins, :not_enough) if admins.size < 1
end
end
class Admin
belong_to :company
end
Now, suppose I have a controller that can remove admins. How do I prevent removing the admin (ie generate errors) if it is the only admin of its company ?
If I understand well, I have to remove the admin from the memory object, and try to "save/destroy" if by validating the company first ?
I don't think you need a custom validation at all on the Company model. You can use the 'length' validation on your association.
validates :admins, length: { minimum: 1 }
If that doesn't work, you should also be able to check the 'marked_for_destruction?' property. You should also be able to validate the reciprocal relationship with a 'presence: true' validation.
class Company
has_many :admins
validate :has_one_admin_validation
private
def has_one_admin_validation
errors.add :admins, "You need at least one admin" if admins.reject(&:marked_for_destruction?).empty?
end
end
class Admin
belongs_to :company, presence: true
end
You may also want to look at using the before_destroy callback in your Admin class.
before_destroy :has_company_with_no_other_admins
...
private
def has_company_with_no_other_admins
return false if company.admins.length < 2
true
end
There's a pretty good description of using before_destroy here: https://stackoverflow.com/a/123190/6441528
That's worth looking at because implementations vary based on your Rails version.

How do I set up a model validation test on update only if association exists?

How do I set up a test that checks validations only on update if the model has an association? Below are the 2 classes and the test.
user.rb
class User < ActiveRecord::Base
has_one :survey
validates :first_name, presence: true, on: :update, if: :survey_completed?
private
def survey_completed?
survey.present?
end
end
survey.rb
class Survey < ActiveRecord::Base
belongs_to :user
validates :user, presence: true
end
user_spec.rb
describe "if survey is completed" do
let(:user) {User.create(email: 'test#test.com')}
let(:survey) {Survey.create(user_id: user.id)}
it "returns error if phone number has not been completed" do
user.first_name = 'test'
expect(user.reload.valid?).to be false
end
end
The above test fails, it returns true. Having looked in the console, I can see that the test is failing because there is no survey for my user, so the validations aren't being run.
I've tried variations with save!, using reload, and not using reload and the test always returns true. How do I set up the test so that there is a survey associated with the user?
For simplicity, I've only shown the relevant code.
In rspec let is lazy. It isn't evaluated until you use the variable in your code. Since your test does not have survey in it, the line let(:survey) {Survey.create(user_id: user.id)} is never run.
For this test, I would explicitly create a survey on user (i.e. user.survey.create) so the validation will run properly.

Resources