Lighthouse has a new layout. Prefer the old one? Return to the old layout, and switch back any time from the link at the top of each page.

This project is archived and is in readonly mode.

Real scope support for ActiveRecord's uniqueness validation

#4325

Hello,

The 'scope' option of AR's uniqueness validation is quite confusing. Contrary to what one might expect, this option doesn't take a scope, but an (array of) attribute(s). The intended behaviour being that the current record should be unique, regarding the validated field and these 'scope' attributes.

I'm currently working on a project where this behavior doesn't cover my needs, but real scope support appears to be quite easy to implement.

The following patch (waiting for ticket number to send the patch) modifies AR's uniqueness validator to expect a scope name (or at least a class method name, that should return a relation). The old behavior is still accessible using the 'attributes' option. Both 'scope' and 'attributes' may be used simultaneously.

I realize the main issue with this patch is that it may break legacy code, but I would like to know what people think of it. If people like this idea, I will write/update both tests and documentation.

Reported by Gaël Deest · April 4th, 2010 @ 01:54 PM

State: wontfix
Milestone: 3.0.2
Assigned to: José Valim José Valim
Importance: Medium

Activity

  1. Gaël Deest
  2. Elliot Winkler
    Elliot Winkler

    So if I understand correctly, you want to be able to say something like

    class Foo < ActiveRecord::Base
      named_scope :enabled, :conditions => {:enabled => true}
      validates_uniqueness_of :guid, :scope => :enabled
    end
    

    and so in that case the validates_uniqueness_of would actually run something like

    foo = Foo.new(:guid => "dk93dkd", :enabled => true)
    Foo.enabled.exists?(:guid => foo.guid)
    

    instead of something like

    Foo.exists?(:guid => foo.guid, :enabled => true)
    

    (I think I got what v_u_o does right, ha. Sorry for the 2.x syntax but you get the idea)

    Yeah, you are going to break a lot of people's code if you change the meaning of :scope ;) It is unfortunate that :scope is named what it is but it may be set in stone now... I'm not sure.

    I wonder if it would be a good idea to mix the two meanings -- so :scope would include any class methods that happen to match as well as any attributes that happen to match? Maybe that would be confusing though.

    Anyway, I can't say I've ever needed this ability; usually attributes work fine for me (thinks like :user_id or :enabled or whatever). Maybe you can provide us with an example?

    April 6th, 2010 @ 03:22 PM

  3. Gaël Deest
    Gaël Deest

    Here is a (trimmed down, but real life) example. I'm working on a project where my customers will have their own subdomains. Subdomains are represented by a state machine, based on their activation state. They can go through four states: 'reserved', 'available', 'unavailable' and 'removed'. Active subdomains should be unique, but I'd also like to make "removed" subdomains available again to new customers. So basically I'd like to do something like this:

        class Subdomain < ActiveRecord::Base
          scope :active, where(:state => ['reserved', 'available', 'unavailable'])
          validates :name, :unique => {:scope => :active}
          
          state_machine :initial => :reserved do
            state :reserved
            state :available
            state :unavailable
            state :removed
            ...
          end
        end
    

    I may of course create a new boolean field called 'enabled' or something similar and that's probably what I'll end up doing. Yet I find it pretty redundant.

    I thought about your mixed solution. I can see two problems with it:
    - An attribute may have the same name as a class method. What should be do then ? - :scope currently accepts several attributes, while it isn't possible to mix several scopes (unless I'm missing something). That may not be a big problem though.

    April 6th, 2010 @ 03:58 PM

  4. Gaël Deest
    Gaël Deest

    The 'validates' line should have read:

        validates :name, :uniqueness => {:scope => :active}
    

    Also, I'm actually using class methods and not true 'scopes', but that's another issue.

    April 6th, 2010 @ 04:06 PM

  5. José Valim
    José Valim
    • Milestone cleared.
    • Assigned user set to José Valim

    I like the idea, but I don't like the fact it will be completely backward incompatible. Maybe we could allow the following to work:

    class Subdomain < ActiveRecord::Base
      scope :active, where(:state => ['reserved', 'available', 'unavailable'])
      validates :name, :uniqueness => { :scope => Subdomain.active }
    end
    

    So when the :scope is a Symbol, it should use the previous behavior, otherwise use the one you propose. What do you think?

    April 10th, 2010 @ 12:48 PM

  6. Gaël Deest
    Gaël Deest

    Really nice AND flexible idea (thanks arel !). Coming back soon with an updated patch with tests and documentation.

    April 10th, 2010 @ 02:23 PM

  7. Gaël Deest
    Gaël Deest

    It turns out to be much more difficult to implement than I first thought, and my first attempt was actually broken.

    The problem is that when saving a new record, there is no way that I know of to determine whether it matches the specified relation, and therefore to decide whether the unicity scope applies. I must say I'm stuck here. Does anyone have any idea ?

    April 10th, 2010 @ 05:53 PM

  8. José Valim
    José Valim

    If a :scope is provided and it's not a symbol or array, you should use it for the querying. So I guess you want to do this:

    if options[:scope].is_a?(ActiveRecord::Relation)
      scopes = []
      table  = options[:scope]
    else
      scopes = Array(options[:scope])
      table  = finder_class.unscoped
    end
    

    April 12th, 2010 @ 10:04 AM

  9. Gaël Deest
    Gaël Deest

    Yes, I had done something like that and was writing some test cases when I realized it did not work.

    Here is a model:

    class Employee < ActiveRecord::Base
      scope :active, where(:status => ["trainee", "veteran"])
      validates_uniqueness_of :office_id, :scope => self.active
    end
    

    And here is a test case:

      def test_validate_uniqueness_with_scope_relation
        paul = Employee.create(:office_id => 1, :status => "trainee")
        puts paul.id
        assert paul.valid?, "Saving paul"
        
        fred = Employee.create(:office_id => 1, :status => "veteran")
        assert !fred.valid?, "Saving fred. Duplicate office_id."
        
        fred.status = "retired"
        assert fred.save, "Saving fred. Duplicate (but allowed) office_id."
      end
    

    The first two assertions will pass, but the third wont, the reason being that office_id already exists within the specified scope. We should not care, because the model does not match: the employee being saved is neither a "trainee" nor a "veteran". The problem is, there is no way to know whether a new record matches a given scope !

    To make it work, one would have to write:

    class Employee < ActiveRecord::Base
      scope :active, where(:status => ["trainee", "veteran"])
      validates_uniqueness_of :office_id, :scope => self.active, :if => {|e| ["trainee", "veteran"].include?(e.status)}
    end
    

    I don't know about you, but I find it awkward. Yet it does work, and allows something impossible before, so if it seems acceptable to you I will submit my patch, but I had dreamed of something simpler :(

    April 12th, 2010 @ 10:26 AM

  10. José Valim
    José Valim

    Yes, I agree it's awkward and had this issue in mind because we had a similar discussion in another ticket. Someone thought that the behavior you just described as awkward, is completely normal.

    So, in your opinion, is the patch still worth? Do see an use case for it?

    April 25th, 2010 @ 09:25 AM

  11. José Valim
    José Valim
    • State changed from new to wontfix

    I'm marking as won't fix. If you are still in mood to work on a patch, please let me know and I will reopen it.

    June 7th, 2010 @ 09:36 AM

  12. Jeremy Kemper
    Jeremy Kemper
    • Milestone set to 3.0.2
    • Importance changed from to Medium

    October 15th, 2010 @ 11:01 PM