Matt Scilipoti
Matt Scilipoti

Reputation: 1101

Rails: How to limit number of items in has_many association (from Parent)

I would like to limit the number of items in an association. I want to ensure the User doesn't have more than X Things. This question was asked before and the solution had the logic in the child:

The offered solution (for similar issue):

class User < ActiveRecord::Base
  has_many :things, :dependent => :destroy
end

class Thing <ActiveRecord::Base
  belongs_to :user
  validate :thing_count_within_limit, :on => :create

  def thing_count_within_limit
    if self.user.things(:reload).count >= 5
      errors.add(:base, "Exceeded thing limit")
    end
  end
end

The hard coded "5" is an issue. My limit changes based on the parent. The collection of Things knows its limit relative to a User. In our case, a Manager may adjust the limit (of Things) for each User, so the User must limit its collection of Things. We could have thing_count_within_limit request the limit from its user:

if self.user.things(:reload).count >= self.user.thing_limit

But, that's a lot of user introspection from Thing. Multiple calls to user and, especially, that (:reload) are red flags to me.

Thoughts toward a more appropriate solution:

I thought has_many :things, :before_add => :limit_things would work, but we must raise an exception to stop the chain. That forces me to update the things_controller to handle exceptions instead of the rails convention of if valid? or if save.

class User
  has_many :things, :before_add => limit_things

  private
  def limit_things
    if things.size >= thing_limit
      fail "Limited to #{thing_limit} things")
    end
  end
end

This is Rails. If I have to work this hard, I'm probably doing something wrong.

To do this, I have to update the parent model, the child's controller, AND I can't follow convention? Am I missing something? Am I misusing has_many, :before_add? I looked for an example using :before_add, but couldn't find any.

I thought about moving the validation to User, but that only occurs on User save/update. I don't see a way to use it to stop the addition of a Thing.

I prefer a solution for Rails 3 (if that matters for this problem).

Upvotes: 23

Views: 15233

Answers (8)

Christopher Davies
Christopher Davies

Reputation: 4551

I thought I'd chime in here. It seems like most of the answers here fail under race-conditions. I'm trying to limit the number of users who can sign up at a certain price point in our app. Checking the limit in Rails means that 10 simultaneous registrations could get through, even if it exceeds the limit I'm trying to set.

For example, say I want to restrict registrations to no more than 10. Let's say that I already have 5 users registered. Let's also say that 6 new users attempt to register at the same time. In 6 different threads, Rails reads the number of slots remaining, and it gets the answer 5. This passes validation. Rails then allows all registrations to go through, and I have 11 registrations. :/

Here's how I solved this problem:

def reserve_slot(price_point)
  num_updated = PricePoint.where(id: price_point.id)
    .where('num_remaining <= max_enrollments')
    .update_all('num_remaining = num_remaining + 1')

  if num_updated == 0
    raise ActiveRecord::Rollback
  end
end

Using this approach, I never allow more registrations than max_enrollments, even when the app is under load. This is because the validation and increment is done in a single, atomic database operation. Note, too that I always call this method from within a transaction, so it rolls back under failure.

Upvotes: 3

Little Jack
Little Jack

Reputation: 558

try this, just like string:

class User < ActiveRecord::Base
  has_many :things, :dependent => :destroy
  validates :things, length: {maximum: 4}
end

Upvotes: 4

skilleo
skilleo

Reputation: 2481

In Rails 4, perhaps earlier versions you can simply validate on the value of the counter_cache.

class User
  has_many :things
  validates :things_count, numericality: { less_than: 5 }
end

class Thing
  belongs_to :user, counter_cache: true
  validates_associated :user
end

note that I've used :less_than because :less_than_or_equal_to would allow the things_count to be 6 since it is validated after the counter cache update.

If you want to set a limit on a per user basis, you can create a things_limit column to dynamically compare with the limit value you've set.

validates :things_count, numericality: { less_than: :things_limit }

Upvotes: 1

rbinsztock
rbinsztock

Reputation: 3205

So if you want a different limit for each user you can add things_limit:integer into User and do

class User
  has_many :things
  validates_each :things do |user, attr, value|
   user.errors.add attr, "too much things for user" if user.things.size > user.things_limit
  end
end

class Thing
  belongs_to :user
  validates_associated :user, :message => "You have already too much things."
end

with this code you can't update the user.things_limit to a number lower than all the things he already got, and of course it restrict the user to create things by his user.things_limit.

Application example Rails 4 :

https://github.com/senayar/user_things_limit

Upvotes: 28

Matt Connolly
Matt Connolly

Reputation: 9857

Validating on the current count causes the count to become greater than the limit once the save has completed. The only way I've found way to prevent the create from occurring is to validate that before the create, the number of things is less than the limit.

This is not to say that it isn't useful having a validation on the count in the User model, but doing so doesn't prevent User.things.create from being called because the user's count collection is valid until the new Thing object is saved, and then becomes invalid after the save.

class User
  has_many :things
end

class Thing
  belongs_to :user
  validate :on => :create do
    if user && user.things.length >= thing_limit
      errors.add(:user, :too_many_things)
    end
  end
end

Upvotes: 6

user1676813
user1676813

Reputation: 13

You should try this.

class Thing <ActiveRecord::Base
  belongs_to :user
  validate :thing_count, :on => :create

  def thing_count
      user = User.find(id)
      errors.add(:base, "Exceeded thing limit") if user.things.count >= 5
  end
end

Upvotes: -4

Tomas Mattia
Tomas Mattia

Reputation: 371

You could try validates_length_of and validates_associated:

class Client < ActiveRecord::Base

  has_many :orders
  validates :orders, :length => { :maximum => 3 }

end

class Order < ActiveRecord::Base

  belongs_to :client
  validates_associated :client

end

A quick test shows that the valid? method works as expected, but it does not stop you from adding new objects.

Upvotes: 0

twmills
twmills

Reputation: 3015

How you investigated using accepts_nested_attributes_for?

accepts_nested_attributes_for :things, :limit => 5

http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

That said, I think accepts_nested_attributes_for seems to only be appropriate for certain types of situations. For example, if you were creating a command line API, I think it's a pretty awful solution. However, if you have a nested form it works well enough (most of the time).

Upvotes: 0

Related Questions