Validation the presence of an ActiveRecord belongs_to association should imply a valid associated object?

This behavior has been around for awhile (since at least 3.2.x; I didn’t test further back). I’m a bit surprised this didn’t trip my up before, but I’m sure it did and I just took the quick way out.

To set some groundwork here is what the Active Record Validations Guide presence validation (emphasis mine):

If you want to be sure that an association is present, you’ll need to test whether the associated object itself is present, and not the foreign key used to map the association.

class LineItem < ActiveRecord::Base
belongs_to :order
validates :order, presence: true
end

``

In order to validate associated records whose presence is required, you must specify the :inverse_of option for the association:

class Order < ActiveRecord::Base
has_many :line_items, inverse_of: :order
end

``

If you validate the presence of an object associated via a has_one or has_many relationship, it will check that the object is neither blank? nor marked_for_destruction?.

However, using inverse_of doesn’t work. Note that this section of docs states that only has_one and has_many relationships will check only blank? and marked_for_destruction?.

Compare this with the API docs for the same validation:

Validates that the specified attributes are not blank (as defined by Object#blank?), and, if the attribute is an association, that the associated object is not marked for destruction. Happens by default on save.

That gives the indication that it doesn’t seem to matter if you use inverse_of or not. Nor, will the presence validator ever validate the association. However, this causes some very odd behavior. Namely, it’s possibly to save a record which, when pulled immediately back out of the database is not valid. This is because a null value has been saved in the place of the association’s reference id.

Here’s code which demonstrates this:

Activate the gem you are reporting the issue against.

gem ‘activerecord’, ‘4.1.6.rc2’
require ‘active_record’
require ‘minitest/autorun’
require ‘logger’

Ensure backward compatibility with Minitest 4

Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test)

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 do |t|
t.string :name
end

create_table :comments do |t|
t.integer :post_id
end
end

class Post < ActiveRecord::Base
has_many :comments, inverse_of: :post
validates_presence_of :name
end

class Comment < ActiveRecord::Base
belongs_to :post, inverse_of: :comments
validates_presence_of :post
end

class BugTest < Minitest::Test
def setup
Post.delete_all
Comment.delete_all
end

def test_association_persists_valid_objects
post = Post.new(name: “Demoing confusing behavior”)
assert post.valid?
refute post.persisted?

comment = Comment.create(post: post)
assert comment.valid?
assert comment.persisted?
assert post.persisted?
assert_equal 1, Post.count
assert_equal 1, Comment.count

end

def test_valid_present_belongs_to_association
post = Post.new
refute post.valid?, “Post was valid but should be invalid”

Comment.create post: post
assert_equal 0, Comment.count, "Comment was saved with invalid Post"

end

def test_showing_why_presence_should_mean_valid
comment = Comment.create(post: Post.new)

if (persisted_comment = Comment.first)
  assert_equal comment, persisted_comment, "Assigned and persisted comments are different"
  assert persisted_comment.valid?, "Persisted comment isn't valid"
else
  refute comment.valid?, "Assigned comment is valid"
end

end
end

``

Searches online seem to just take this as by design. Yet, it doesn’t really make sense. The suggestions to “workaround” this are to either set the presence validation on the reference id field or adding an association validation.

Setting the presence validation on the reference id field has the downside that it doesn’t verify that the associated model object exists (without foreign keys defined in the database). The alternative is to use both presence and associated validations:

class Comment < ActiveRecord::Base
belongs_to :post, inverse_of: :comments
validates :post, presence: true, associated: true
end

``

Am I completely missing something with this behavior?

Well, the presence validation does exactly what it says, no matter which type of attribute we’re talking about. For example, it doesn’t work with booleans because it’d never allow false to be saved.

To actually validate that the associated object is valid itself, you should add the association validation, or add the validate option to the belongs_to call, which will validate the associated object when saving the parent.

Hope that helps :slight_smile:

I could be wrong but I think the first feature described in this rails
4.2 article solves the problem you're describing.

http://raysrashmi.com/2014/09/02/new-features-in-rails-4-2

TL;DR

You can do this:

class User < ActiveRecord::Base
  belongs_to :account, required: true
  has_one :profile, required: true
end

Hope that helps,

Allen Madsen
http://www.allenmadsen.com

Except that’s not completely accurate. The presence already special cases a check to make sure the object was not marked for deletion. The reasoning in the docs:

adding the check to see that an associated object is not marked for destruction. This prevents the parent object from validating successfully and saving, which then deletes the associated object, thus putting the parent object into an invalid state

To me, this reasoning should also not apply to a new, but invalid, associated object. Rails already attempts attempt to persist the associated object. By the same reasoning as above, it should then make sure that object was actually persisted.

I think the underlying issue here behavior for a “new” versus “existing” record. The need for ‘validate :associated’ is a red herring. I agree, that for already persisted objects the existing Rails behavior of not validating it is correct. I find it extremely surprising behavior for a new non-persisted object.

Perhaps a simple persisted? check is all that is necessary in the logic?

Thanks for the link. I came across it yesterday as well. I just tried it against master. Just adding required: true has the same issue. If you use the suggested null: false in the migration to add a constraint on the DB you get an extremely ugly ActiveRecord::StatementInvalid error due to violating the DB constraint:

    ActiveRecord::StatementInvalid: SQLite3::ConstraintException: comments.post_id may not be NULL: INSERT INTO "comments" DEFAULT VALUES
~/.gem/ruby/2.1.2/gems/sqlite3-1.3.9/lib/sqlite3/statement.rb:108:in `step'
~/.gem/ruby/2.1.2/gems/sqlite3-1.3.9/lib/sqlite3/statement.rb:108:in `block in each'
~/.gem/ruby/2.1.2/gems/sqlite3-1.3.9/lib/sqlite3/statement.rb:107:in `loop'
~/.gem/ruby/2.1.2/gems/sqlite3-1.3.9/lib/sqlite3/statement.rb:107:in `each'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb:305:in `to_a'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb:305:in `block in exec_query'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb:464:in `block in log'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/notifications/instrumenter.rb:20:in `instrument'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb:458:in `log'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb:299:in `exec_query'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:76:in `exec_insert'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:103:in `insert'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb:14:in `insert'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/relation.rb:66:in `insert'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/persistence.rb:521:in `_create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/counter_cache.rb:139:in `_create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/attribute_methods/dirty.rb:122:in `_create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/callbacks.rb:306:in `block in _create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:83:in `run_callbacks'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/callbacks.rb:306:in `_create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/timestamp.rb:57:in `_create_record'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/persistence.rb:501:in `create_or_update'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/callbacks.rb:302:in `block in create_or_update'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:114:in `call'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:114:in `call'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:166:in `block in halting'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:87:in `call'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activesupport/lib/active_support/callbacks.rb:87:in `run_callbacks'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/callbacks.rb:302:in `create_or_update'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/persistence.rb:120:in `save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/validations.rb:36:in `save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/attribute_methods/dirty.rb:21:in `save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:284:in `block (2 levels) in save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:345:in `block in with_transaction_returning_status'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:208:in `block in transaction'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb:188:in `within_new_transaction'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:208:in `transaction'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:218:in `transaction'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:342:in `with_transaction_returning_status'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:284:in `block in save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:299:in `rollback_active_record_state!'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/transactions.rb:283:in `save'
~/.gem/ruby/2.1.2/bundler/gems/rails-893b7b8b0cd8/activerecord/lib/active_record/persistence.rb:34:in `create'

This would result in a server error instead of a possibly kicked-back error.

Hmm…I actually just tested destroying the associated object before creation and presence: true doesn’t actually do what the docs claim either. It incorrectly saved the object.