Rails belongs_to not validating id when optional
Asked Answered
S

2

7

I'm running Rails 5.1.4, and I have a model that looks like this:

class Quota < ActiveRecord::Base
  belongs_to :domain, optional: true
  belongs_to :project, optional: true
end

A quota should belong to a domain OR project, but not both (hence setting optional: true).

However, I can't seem to figure out how to make rails throw errors if an invalid project or domain ID is provided.

Here's what happens:

q = Quota.create!(domain_id: nil, project_id: 'invalid_id')
q.project_id # -> nil

Even if I explicitly pass a project_id, it just magically clears it if it doesn't match a valid project. I tried adding a custom validation method, but by the time the validation method is called, it has already been set to nil. It doesn't even use the project_id= method either; I checked.

Is there a way to get Rails to raise an error if the ID is invalid instead of setting it to nil? (while still allowing a nil value)

Sororicide answered 11/12, 2017 at 23:35 Comment(1)
This seems to be working: validates :project, presence: true, if: -> {project_id.present?}Cyclamen
S
3

The best solution I could come up with is this:

class Quota < ActiveRecord::Base
  belongs_to :domain,  optional: true
  belongs_to :project, optional: true

  validate :validate_associations

  def project_id=(val)
    Project.find(val) unless val.nil?
    super
  end

  def domain_id=(val)
    Domain.find(val) unless val.nil?
    super
  end

  private

  def validate_associations
    errors.add(:base, 'Specify a domain or a project, not both') if domain && project
    errors.add(:base, 'Must specify a domain or a project') if domain.nil? && project.nil?
  end
end

Thanks for helping iron things out @vane-trajkov. I found I really needed to do use the find method when setting the domain_id or project_id, because Rails was happy to set it to an invalid ID. Using project= and domain= work fine as-is since they pretty much ensure the ID has already been set to a valid value.

Sororicide answered 12/12, 2017 at 0:16 Comment(0)
A
2

Here is one possible solution

class Quota < ApplicationRecord
  belongs_to :domain, optional: true
  belongs_to :project, optional: true

  validate :present_domain_or_project?
  validates :domain, presence: true, unless: Proc.new { |q| q.project_id.present? }
  validates :project, presence: true, unless: Proc.new { |q| q.domain_id.present? }

  private

  def present_domain_or_project?
    if domain_id.present? && project_id.present?
      errors.add(:base, "Specify a domain or a project, not both")
    end
  end
end

In the first block, we define the associations and specify optional: true so we overpass the new Rails 5 behavior of validating the presence of the association.

belongs_to :domain, optional: true
belongs_to :project, optional: true

Then, the first thing we do is just simply eliminating the scenario of both the association attributes (project_id and domain_id) are set. This way we avoid hitting the DB twice, in reality, we would only need to hit the DB once.

validate :present_domain_or_project?
...
private 

def present_domain_or_project?
  if domain_id.present? && project_id.present?
    errors.add(:base, "Specify a domain or a project, not both")
  end
end

The last part is to check if one of the association is present(valid) in the absence of the other

validates :domain, presence: true, unless: Proc.new { |q| q.project_id.present? }
validates :project, presence: true, unless: Proc.new { |q| q.domain_id.present? }

Regarding:

Is there a way to get Rails to raise an error if the ID is invalid instead of setting it to nil? (while still allowing a nil value)

When using the create! method, Rails raises a RecordInvalid error if validations fail. The exception should be caught and handled appropriately.

begin
  q = Quota.create!(domain_id: nil, project_id: 'invalid_id')
rescue ActiveRecord::RecordInvalid => invalid
  p invalid.record
  p invalid.record.errors
end

The invalid object should contain the failing model attributes along with the validation errors. Just note that after this block, the value of q is nil since the attributes were not valid and no object is instantiated. This is normal, predefined behavior in Rails.

Another approach is to use the combination of new and save methods. Using the new method, an object can be instantiated without being saved and a call to save will trigger validation and commit the record to the database if valid.

q = Quota.new(domain_id: nil, project_id: 'invalid_id')
if q.save
  # quota model passes validations and is saved in DB
else 
  # quota model fails validations and it not saved in DB
  p q
  p q.errors
end

Here the object instance - q will hold the attribute values and the validation errors if any.

Atonal answered 12/12, 2017 at 2:42 Comment(4)
Thanks @vane-trajkov, but I don't think that solves my main issue of validating the association IDs.Sororicide
Maybe I am missing something, but it does validate the associations. For example you pass domain_id=1 it will check if there is Domain with id=1 in the DB. The validates presence: true rule does the trickAtonal
In my "here's what happens" section, it shows some very strange behavior, that if the id does not map to a valid item, it simply overrides the user's given value to nil. When the association is not optional, it would normally throw an error at that point, but when it IS optional, it just ignores invalid ids and clears the field. Not very intuitive of Rails in my opinion.Sororicide
I have updated the answer adding explanation regarding the "here's what happens" section. Also, if this is not helpful, can you please clarify a bit your question as well? What is the use case for accessing the invalid attributes? Can you paste your full code for handling the creation of Quota and accessing the attributes?Atonal

© 2022 - 2024 — McMap. All rights reserved.