Is it ever OK to validate the presence of a primary key of a belongs_to association?

I can see this topic from 2007 which is really similar to what I’m describing.

I’d assume that these three definitions would behave the same

class Comment < Comment
  # Option 1
  belongs_to :post
  validates :post_id, presence: true

  # Option 2
  belongs_to :post
  validates :post, presence: true

  # Option 3
  belongs_to :post, optional: false
end

Option #3 would be my goto, but I have seen codebases using option #1, and the behaviour of that definition differs slightly from option #2 and #3. It differs when we want to create both an unpersisted post and an unpersisted comment at the same time.

Question: Are there any valid use cases to use validates :post_id, presence: true at all?

Here are some tests to illustrate how the definitions differ:

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails"
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"

  gem "sqlite3"
  gem "debug"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
# ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
end

class Comment1 < Comment
  belongs_to :post
  validates :post_id, presence: true
end

class Comment2 < Comment
  belongs_to :post
  validates :post, presence: true
end

class Comment3 < Comment
  belongs_to :post, optional: false
end

class TestCommentCreation < Minitest::Test
  def test_create_comment_with_persisted_post
    post = Post.create!

    assert Comment1.create!(post: post)
    assert Comment2.create!(post: post)
    assert Comment3.create!(post: post)
  end

  def test_create_comment_with_unpersisted_post_that_gets_saved
    # Comment1 - post_id must exist
    post = Post.new
    comment1 = Comment1.new(post: post)
    post.save!
    error = assert_raises(ActiveRecord::RecordInvalid) do
      comment1.save!
    end
    assert_equal "Validation failed: Post can't be blank", error.message

    # Comment2 - post must exist
    post = Post.new
    comment2 = Comment2.new(post: post)
    post.save!
    comment2.save!
    assert_equal Post.last, comment2.reload.post

    # Comment3 - post optional false
    post = Post.new
    comment3 = Comment3.new(post: post)
    post.save!
    comment3.save!
    assert_equal Post.last, comment3.reload.post
  end

  def test_create_comment_with_unpersisted_post
    # Comment1 - post_id must exist
    error = assert_raises(ActiveRecord::RecordInvalid) do
      Comment1.create!(post: Post.new)
    end
    assert_equal "Validation failed: Post can't be blank", error.message

    # Comment2 - post must exist
    comment2 = Comment2.create!(post: Post.new)
    assert_equal Post.last, comment2.reload.post

    # Comment3 - post optional false
    comment3 = Comment3.create!(post: Post.new)
    assert_equal Post.last, comment3.reload.post
  end
end

Option #3 would be my preference. I would say if they are using validates presence on a belongs_to it’s maybe either old code or the person didn’t understand the API of belongs_to well enough.

I’ve used this combo a few times:

  belongs_to :affiliate, optional: true

  validates :affiliate, presence: true, if: :affiliate_id

This is handy if you want to allow a null value but when it’s not null you want to make sure the association is valid. This will prevent you from putting an invalid reference such as affiliate_id: 424242 when that ID doesn’t exist.

1 Like

Would you say that this code isn’t correct validates :post_id, presence: true and should be updated to reference the association record instead?

Looking at the combo, would you expect the same behaviour whether you assign a destroyed post or a destroyed post id?

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post, optional: true
  validates :post, presence: true, if: :post_id
end

class TestCommentCreation < Minitest::Test
  def test_invalid_post_id
    post = Post.create!
    post.destroy!

    comment1 = Comment.new(post: post)
    comment2 = Comment.new(post_id: post.id)

    # Comment 1 saved successfully - unexpected?
    assert comment1.save

    comment1.reload
    assert_equal post.id, comment1.post_id # post_id set with destroyed post.id
    assert_nil comment1.post

    # Comment 2 not saved as expected
    refute comment2.save
    assert_equal ["Post can't be blank"], comment2.errors.full_messages
  end
end

Adding a foreign key to the db on post_id will prevent the insert but it will raise because the validation does not catch ActiveRecord::InvalidForeignKey error when calling comment1.save

You may want to have a similar test case for:

  belongs_to :post

Because I think by default Rails will set validates :post, presence: true in the above case. Ultimately this means your unexpected test case for the optional case from my example combo would react the same as the default case.

The docs for belongs_to (ActiveRecord::Associations::ClassMethods) - APIdock mention required: true sets that validation and the current default behavior is required: true even though that is deprecated nowadays.

I see thanks Nick, I haven’t tested it but if you’re sticking to the default behaviour and offer an extra check for an optional association when the association is not loaded but the id provided. That makes sense.

From what I understand assigning post_id or the association post does not behave the same way. Assigning both like Comment.new(post_id: id, post: post) depends on the order of assignment and behave differently when nil is the argument too.

Unless we’re trying to convey the idea that a persisted record is required by validating the foreign key I would think that the Rails recommendation is to not validate foreign keys but associations only.

I’m trying to figure whether this assumption is true or not and I can’t find anything on the topic.

Rails validations are only useful for when users need to fix things. The best way to figure out if a validation is needed is by asking “can a user do something to fix this issue?”. If the answer is no, just error out, because you aren’t trying to tell a user something, you’re trying to tell an engineer something. For that we use error notifications, not validations.

I will say more: even if you have a dropdown select element on a page that only lets you choose a specific item, don’t validate that the item is in the list. Just straight up explode if the item is wrong. (If you use, say, enums, that should be enough. If it’s not in the enum, it will explode.) Because the only way a user could possibly do that is if they submitted a crafted request using some kind of console, in which case you don’t owe them much. :slight_smile:

If you’re actually implementing a public API, then your “users” are now API client engineers, so validations are now appropriate in more cases. In that case, the “select dropdown” might deserve a proper validation because it’s easy to supply and invalid value via API, so you should probably provide an informative error.

If you have a front-end team, and they are the ones making the mistake, you can instruct them to view the logs and see what they submitted incorrectly, instead of trying to craft informative responses just for them. Unless it’s a big company with isolated services maintained by isolated departments, then treat it as a public API.

In your case a database constraint is probably all you need (and the optional: false). However, if you have a form, where a user must choose the one item that will get associated to this record, and the UI allows them to submit this form without choosing an item, then a presence validation is a good way to tell them about their mistake. However, you would normally have the kind of UI that makes this situation impossible without a hand-written request, so you probably don’t have to do that.

3 Likes

Thanks @hakunin. This is a good point between meaningful messages and raised errors. It makes sense.

I think my initial question isn’t well phrased, and I’ve given it more thought.

Question: Putting aside belongs_to :post, optional: false for a moment and given we want the validation, would there there be any reason to favour:

  • this: validates :post_id, presence: true
  • over this: validates :post, presence: true

If so what are they?

validates :post_id do not trigger an extra SQL query

1 Like

Aha, thanks @fatkodima. That is indeed the main difference. I understand now why someone would validate the foreign key and not the association.

Note: I saw recently that defining a belongs_to association is probably the best anyway as it tries to mitigate extra SQL queries when the association hasn’t changed. I guess this allows to capture orphan record with application code and nice validation messages instead of DB foreign key errors.

I think the belongs_to definition now tries to make the validation a bit smarter in rails 7

Yeah, I actually made that change in Avoid validating `belongs_to` association if it has not changed by fatkodima · Pull Request #46522 · rails/rails · GitHub :slight_smile:

1 Like

Ha! That’s funny. That’s a great change thank you.