#112 Anonymous Scopes
Jun 02, 2008 | 8 minutes | Active Record, Rails 2.1
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.
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.
Ryan, your screencasts get better as your skill increases. It's a lovely thing to watch.
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.
thax
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. :)
@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.
Any idea how this could be combined with will_paginate?
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
@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.
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
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.
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.
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)
oops or rather
@@searches = {
:keywords => "products.name LIKE CONCAT('%', ?)",
:minimum_price => "products.price >= ?",
:maximum_price => "products.price <= ?",
:category_id => "products.category_id = ?"
}
etc.
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.
How to combine this with find_tagged_with from acts_as_taggable_on_steroids??
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
@Davis:
:conditions => "products.id IN (SELECT taggable_id FROM taggings WHERE tag_id IN(?) AND taggable_type = 'Products'", tag
oops, didn't closed the parenthesis after the subselect
tag is a Tag object which will be turned into an integer in this statement.
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 ?
@jerome: Thanks! This works great!
The link to 111 above is actually pointing to 108.
<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 :)
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)
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 :)
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 :(
Alright, finally got it working!
http://pastie.org/231954
Cheers
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!
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
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/
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?
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.
@Yann, fixed now. Thanks for pointing this out.
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?
Great tutorial, as usual!
Yes, anonymous scopes work well with will_paginate, as:
scope.paginate(:per_page => 18, :page => page, :order => 'updated_at desc')
Just a small typo:
The notes say scope.conditions where it should be "scope.scoped conditions". I have just used info in the (excellent) screencast but this time I just checked the notes for reference.
Never mind, my memory is dodgy. The syntax in the notes is the refined version. Sorry :-)
Not sure if this will even get looked at after all those spam comments, but here it is:
Is there any way to add a dynamic order to the "find." For example:
def find_products
scope = Model.scoped({:order => sort_order('attribute_1')})
scope = scope.scoped :conditions => ...
end
def sort_order(default)
"#{(params[:c] || default.to_s)} #{params[:d] == 'down' ? 'DESC' : 'ASC'}"
end
(borrowed from: http://garbageburrito.com/blog/entry/447/rails-super-cool-simple-column-sorting)
My problem is passing "params" to the model - I can't seem to do it. Any help would be appreciated. These screencasts have really helped my ror education, thanks a lot.
Out of curiosity, I'm also interested in seeing how "OR" logic could be implemented here.
This is actually quite clean given countless alternatives! ;)
You are performing a wonderful service. I'm new to rails and web programming but have been building commercial applications for 30 years.
I tried to implement your anonymous scoped search in a seemingly simple search and end up getting:
You have a nil object when you didn't expect it!
The error occurred while evaluating nil.prospects
Extracted source (around line #3):
1: <h1>"Search" </h1>
2:
3: <%= render :partial => @Prospect.prospects %>
It appears the search is not being performed. What am I missing? Do you put any code into the 'show' def in the search controller?
If you do not have time to assist me perhaps you can suggest a consultant I can employ to help.
Thank you...keep up the good work!
John Lewis
A bit of a folo-up to my email of 11/8/09...
I found the show form would execute its' select correctly and the select syntax for the partial seems to be correct when tested against the prospects table. Unfortunately I still get:
You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occurred while evaluating nil.each
Extracted source (around line #23):
20: <th>Entry</th>
21: </tr>
22:
23: <% for prospect in @prospects %>
24: <tr class="<%= cycle('list-line-odd', 'list-line-even') %>">
25: <td class="list-description">
26: <td><%=h prospect.id %></td>
Suggestions would be very much appreciated.
Please disregard my earlier emails. I finally got it working.
Thanks
At what point is the anonymous scope evaluated? put another way, what is the trigger that says "I have finished chaining scopes, now run the query" thanks
In rails 3 you can use where() for this scope.
hi i am using scope
i have two collection contact and company
company has_many contact
contact belong to company
condition
scopedData = Contact.scoped
scopedData = scopedData.where(:email => "vinay@gmail.com")
then from this scoped data
want to find where company name is "test"
how to use scoped on this
please help me friends
"Out of curiosity, I'm also interested in seeing how "OR" logic could be implemented here.
This is actually quite clean given countless alternatives! ;)" Me too