#112
Jun 02, 2008

Anonymous Scopes

The scoped method allows you to generate named scopes on the fly. In this episode I show how to use anonymous scopes to improve the conditional logic which was used in the previous episode on advanced search form.
Download (23.5 MB, 8:49)
alternative download for iPod & Apple TV (12.8 MB, 8:49)

Resources

# config/initializers/global_named_scopes.rb
class ActiveRecord::Base
  named_scope :conditions, lambda { |*args| {:conditions => args} }
end

# models/search.rb
def find_products
  scope = Product.scoped({})
  scope = scope.conditions "products.name LIKE ?", "%#{keywords}%" unless keywords.blank?
  scope = scope.conditions "products.price >= ?", minimum_price unless minimum_price.blank?
  scope = scope.conditions "products.price <= ?", maximum_price unless maximum_price.blank?
  scope = scope.conditions "products.category_id = ?", category_id unless category_id.blank?
  scope
end

RSS Feed for Episode Comments 42 comments

1. Kieran Jun 02, 2008 at 01:49

Nice new features. I upgraded to Rails 2.1.0 earlier. Really easy. Looking forward to using named scopes. Just need to find a nice bit to use it for.

Would you consider giving feedback on some code I've written if you have 5 mins? I'm new to Rails (3-4 weeks) and I've gotten to the point where I have some untidy spots I can't optimize with my current knowledge. Email sent with this comment. Understand if you're too busy.

Keep up the great work.


2. Mig Jun 02, 2008 at 07:24

Ryan, your screencasts get better as your skill increases. It's a lovely thing to watch.


3. James Edward Gray II Jun 02, 2008 at 07:53

I've been thinking about your cleaning up the `scope = scope.scoped …` code, so I'll throw out some ideas.

We can definitely use inject() to remove all of the assignments. I also like the idea of staring with the model class itself, instead of an empty scope. Using just that much, we can build something like:

    ….inject(Product) do |scope, …|
      scope.scoped …
    end

The tricky part here is knowing what to iterate over. Obviously, the find() parameters are needed, but you also only conditionally apply them. That made me want to try something like:

    [ minimum_price.blank?, {:conditions => ["products.price >= ?", minimum_price],
      … ].inject(Product) do |scope, (cond, params)|
      scope.scoped params unless cond
    end

Of course, that's just way too ugly. I haven't come up with the best way to improve that yet though.


4. rawdd Jun 02, 2008 at 08:14

thax


5. Jim Fisher Jun 02, 2008 at 08:48

Whenever I see code like this I think blocks to clean it up.

I'd look at doing something along the lines of:

Product.scoped({}) do |scope|
  scope.conditions "products.name LIKE ?", "%#{keywords}%" unless keywords.blank?
  scope.conditions "products.price >= ?", minimum_price unless minimum_price.blank?
end

def scoped(conditions, &block)
   ....
end

I'll leave it up to everyone else to do the hard work. :)


6. Ryan Bates Jun 02, 2008 at 09:27

@James, interesting idea. I hadn't thought of using inject here.

The problem with just starting with Product instead of Product.scoped is that if no further scopes are applied to it then it will simply return Product, not a scope. This means you can't iterate through the result or treat it like an array as you can with a scope.

@Jim, what kind of object is being passed to the block here? It can't be a normal named scope because calling further named scopes upon it returns a new named scope, it doesn't change the existing scope.

This means we would have to set up a new object which gathers up the called methods and then reapplies them to the scope. That solution seems a bit too complicated.


7. Matt Jun 03, 2008 at 09:59

Any idea how this could be combined with will_paginate?


8. Ben Jun 03, 2008 at 13:37

As always: nice screencast Ryan :)

A suggestion for future screencast(s): Tests. I think this is a very interesting topic, because i think there are a lot of programmers out there not testing at all... There are also a lot of testing frameworks, and it all can be a bit confusing at first...

Just a suggestion ;)

Ben


9. Ryan Bates Jun 04, 2008 at 14:36

@Matt, since we're using scopes here I *think* you can just call ".paginate" on the resulting scope and it will just work. I haven't tested this yet.

@Ben, good point. Testing is something I hope to cover more in future episodes.


10. kino Jun 06, 2008 at 02:20

Nice!!!


11. Taylor luk Jun 09, 2008 at 06:38

Very interesting.. Thanks again ryan..

Will be interesting to see, if future episodes included testing as part of the vidoe..

I know there is bunch of condition builders out there, wouldn't it cool if your named_scope tutorials end up being a simple condition builder implemented on rails 2.1


12. Matthew Higgins Jun 11, 2008 at 17:04

One way to remove the duplication is by making a mixin module that you call like this inside of ProductSearch:

searches Product, {
  :minimum_price => "products.price >= ?",
  :maximum_price => "maximum_price <= ?",
  :category_id => "products.category_id = ?"
}

(There is not much room in this comment window). The module code would have this:
scope = klass.scoped({})
non_blanks = scopes.keys.select { |attr| !send(attr).blank? }
...

You can then use inject or a simple each to build up the scope inside of a loop over 'non_blanks'. To support the 'LIKE' statement, it would need to support lambda.


13. Randal "sw0rdfish" Santia Jun 13, 2008 at 08:25

Hey Ryan,

how could you make this OR instead of AND. For example I want to search some products and I want to search that my keyword is in the name OR the description OR the manufacture OR the distributor etc..

I just made one big find statement, and that's working for me... but I'd like to make it OR OR OR and hten have the ands at the end so that I could to the above, and also price below X amount, etc..

Thanks in advance if you have a chance.


14. Dave Nolan Jun 16, 2008 at 16:03

I'd drop the named_scope on AR::Base and do this:

# using a class variable here for convenience only...

@@searches = {
keywords => "products.name LIKE CONCAT('%', ?)",
minimum_price => "products.price >= ?",
maximum_price => "products.price <= ?",
category_id => "products.category_id = ?"
}

def find_products
@@searches.inject(Product.scoped({})) { |scope, search| search.first.blank? && scope || scope.scoped(:conditions => search.reverse) }
end

(http://pastie.org/216179)


15. Dave Nolan Jun 16, 2008 at 16:08

oops or rather

@@searches = {
:keywords => "products.name LIKE CONCAT('%', ?)",
:minimum_price => "products.price >= ?",
:maximum_price => "products.price <= ?",
:category_id => "products.category_id = ?"
}

etc.


16. Alex Moore Jun 18, 2008 at 17:36

A big issue i've come accross is that performing joins are not merged when using named scopes. In this example where we're doing a complex search, joining to other tables to filter results is really common. Just think of say, tags or particular codes.

Ryan, I noticed you posted in response to:
http://blog.teksol.info/2008/5/26/why-are-activerecord-scopes-not-merged

There aren't any active tickets in lighthouse for this unfortunately.


17. Davis Zanetti Cabral Jun 23, 2008 at 21:43

How to combine this with find_tagged_with from acts_as_taggable_on_steroids??


18. Thomas Maurer Jun 26, 2008 at 14:52

Just some bits I created after watching this episode:

With the presented conditions scope, you can only pass and array. I liked to pass any possible conditions "variant" (e.g. a hash) along and accomplished this with this little hack:

named_scope :conditions, proc { |*args| h = {}; h.store(:conditions, *args); h }

This way you say e.g.: SomeModel.conditions :some_attribute => value


19. jerome Jun 29, 2008 at 23:56

@Davis:

:conditions => "products.id IN (SELECT taggable_id FROM taggings WHERE tag_id IN(?) AND taggable_type = 'Products'", tag


20. jerome Jun 29, 2008 at 23:58

oops, didn't closed the parenthesis after the subselect

tag is a Tag object which will be turned into an integer in this statement.


21. Moustache Jun 30, 2008 at 03:03

I'm having an issue with concatened scopes:

I use Thomas' named_scope :conditions, proc { |*args| h = {}; h.store(:conditions, *args); h }

and I also have many named scopes already defined. I wish I could send a hash to a class method so that parsed arguments are wether sent to the right scope else to the default "conditions" method.

def self.search(*args)
  args.extract_options!.stringify_keys.to_a.inject(scoped({})) do |scope, search|
    scopes.include?(search.first) ? scope.send(search.first, search.last) : scope.conditions(search.first => search.last)
  end
end

But this is really unstable, I can only mix a defined scope and the "conditons" and nothing more.

# this works
User.search( :name => "foo", :city_begins_with => "c")

# this not:
User.search( :name => "foo", :city => "chicago")

# this not either
User.search( :name_begins_with => "f", :city_begins_with => "c")

Any idea ?


22. Davis Zanetti Cabral Jul 05, 2008 at 11:18

@jerome: Thanks! This works great!


23. bill siggelkow Jul 10, 2008 at 11:20

The link to 111 above is actually pointing to 108.


24. Fredrik W Jul 11, 2008 at 05:26

<code>
named_scope :published, {:order => "created_at DESC", :conditions => {:published => true}}
  named_scope :newest, { :order => "created_at DESC" }

def self.search(params)
  params.inject(Video) {|v,val| v.send(val) } == Video.newest.published
end
</code>

Seems to work just as fine, without any need of hacking AR::Base :)


25. Fredrik W Jul 11, 2008 at 05:27

Forgot the call itself,

Video.search(["published","newest"]).

You may also want to check if Video responds_to? the method you're sending and if it's allowed (keep an array of allowed methods or something in the class itself)


26. Fredrik W Jul 11, 2008 at 05:47

The previous code example was really bad since I was so excited, hence a better one follows:

http://pastie.org/231945

Please replace the previous example with this one :)


27. Fredrik W Jul 11, 2008 at 05:50

My bad, seems as if my test cases didn't take into account the joining issues related to named_scopes. The code doesn't seem to work at all. Might be a start though, I'm going to continue digging :(


28. Fredrik W Jul 11, 2008 at 06:03

Alright, finally got it working!

http://pastie.org/231954

Cheers


29. Danimal Jul 16, 2008 at 15:05

Ryan,

Love the episode. One quick question: What is the key-combination in TextMate to do the multi-line block select and replace it with "conditions" where it added that on each line?

I don't even know what you'd call that, otherwise I'd probably be able to figure it out.

Thanks!


30. Matt Aug 01, 2008 at 15:53

I would do something like this:

cons = {:keywords=>["products.name LIKE ?","%#{keywords}%"], :minimum_price=>["products.price >= ?",minimum_price], :maximum_price=>["products.price <= ?",maximum_price], :category_id=>["products.category_id = ?",category_id]}

scope = Product.scoped({})
cons.each{|attribute,carray| scope = scope.conditions carray[0], carray[1] unless send(attribute).blank?}
scope


31. Willem Aug 07, 2008 at 10:56

I just built and released a little Rails plugin that uses this technique to easily search your models using a scope. This can be really useful if you want to combine a search with another scope or will_paginate:

class MyModel < ActiveRecord::Base
  named_scope :my_scope, { ... }
  searchable_on :several, :fields
end

MyModel.my_scope.search_for("search query").paginate( ... )

Get the source at http://github.com/wvanbergen/scoped_search/


32. Matt Aug 07, 2008 at 16:47

One thing I've noticed with these 'scope' methods is that performing count doesn't work if you're selecting from more than one table (eg :from => 'table1, table2') It only uses the first table. It seems I have to use the size method on the returned array since the actual scope is handled properly. Where would I report a bug like this?


33. Yann Aug 18, 2008 at 00:27

Hello, first, thank you Ryan, you are doing a very good job here!

I just want to point out that the link to the "EPISODE 111" LEADS actually to "EPISODE 108".

But many people here are smart enough to get through that issue.


34. Ryan Bates Sep 29, 2008 at 07:20

@Yann, fixed now. Thanks for pointing this out.


35. Todd Miller Oct 21, 2008 at 07:00

Very useful! However, can you use scopes when you have polymorphic associations? The :include method wouldn't work, so is there anything you can do?


36. scooter Oct 22, 2008 at 18:05

this cleans up the code very nice.
useful screencast again ryan
go on with the show - we love it ;-)


37. wotlk cd key Nov 13, 2008 at 01:25

good,it help me much again!


38. Perfect World money Nov 20, 2008 at 17:43

Thanks Ryan,I think this is one of the most wonderful sites. I have great admiration for you.


39. buy aion gold Nov 21, 2008 at 20:21

I have great admiration for you.


40. lily Nov 27, 2008 at 18:51

Thank you Ryan, your screencast is good. Please look at our URL, if necessary we can learn from each other.


41. evden eve nakliyat Dec 01, 2008 at 01:13

evden eve nakliyat


42. scooter Dec 03, 2008 at 01:31

Thank you ryan - Another great screencast!

Add your comment:

(SKIP THIS ONE)

(required)

(not shown)


(use pastie or gist for code)

sponsored by:
if you want to help:
required:
Get Quicktime Player