Ernie Miller

No, I don't work in NYC, DC, or the valley, and I'm cool with that.

RSS Feed

MetaSearch, Object-based searching for Rails 3

Posted by Ernie on March 7, 2010 at 8:18 pm

This weekend, I spent some time playing around with Rails 3‘s new Arel-based finder code, and put together a new object-based search gem for use with form_for. I’m calling it MetaSearch, both in reference to this domain, and all the metaprogramming fun in writing it. Here are the basics…

Getting Started

Add a line to your Gemfile:

  gem "meta_search"

In your controller, do something like:

  def index
    @search = Article.search(params[:search])
    @articles = @search.all
  end

And in your view, something like this:

  <% form_for :search, @search, :html => {:method => :get} do |f| %>
    <%= f.label :title_contains %>
    <%= f.text_field :title_contains %><br />
    <%= f.label :comments_created_at_greater_than, 'With comments after' %>
    <%= f.datetime_select :comments_created_at_greater_than,
        :include_blank => true %><br />
    <!-- etc... -->
    <%= f.submit %>
  <% end %>

The search method your models gain is returning an instance of MetaSearch::Builder, which wraps your model and intelligently builds up an ActiveRecord::Relation based on your search parameters. It delegates to this relation for methods like all, count and so on, so for the most part, it will quack like a relation in terms of the typical things you might do in a controller, and won’t execute the query against the database until the data is needed.

By default, you will be able to search all persisted attributes (columns) in your model, and all of its associations, named by association (where applicable), attribute, and condition, joined by an underscore. The bundled conditions include the most common use cases (equals, contains, starts_with, ends_with, less_than, greater_than, and so on) but you can add your own custom “wheres” as well.

Adding a new Where

Create an initializer (/config/initializers/meta_search.rb, for instance), and place lines like this in it:

  MetaSearch::Where.add :between, :btw,
    {:condition => 'BETWEEN', :substitutions => '? AND ?'}

You can also set custom formatters for the parameters which are evaluated at runtime (to get things like ‘%param% and so on in your substitution params). Check out the rdoc for more info.

If your Where has multiple substitutions (like the one above), it will expect an array to be passed to it in the search, which means you’ll be using Rails multiparameter attributes. To make this a little bit easier, MetaSearch adds a FormBuilder method, multiparameter_field.

multiparameter_field

  <%= f.multiparameter_field :moderations_value_between,
      {:field_type => :text_field}, {:field_type => :text_field}, :size => 5 %>

multiparameter_field works pretty much like the other FormBuilder helpers, but it lets you sandwich a list of fields, each in hash format, between the attribute and the usual options hash, which gets applied as a default to each field. You can set up type casting per attribute, as well. Check out the rdoc for more info.

The last little thing you might find useful is the ability to exclude certain attributes or associations from being searchable. Since searches are usually done via an HTTP get, it makes it really easy for people to experiment with creating custom queries using attributes that aren’t in your form. Ryan Bates outlined this in his Railscast on Searchlogic some time ago, and MetaSearch makes filtering these parameters very easy.

Excluding attributes and associations

If you’d like to prevent certain associations or attributes from being searchable, just merge the relevant options into your search:

  @search = Article.search(
    params[:search].merge(
      :search_options => {
        :exclude_associations => :comments,
        :exclude_attributes => [:created_at, :updated_at]
      }
    )
  )

Excluding certain attributes of an association is not (yet) supported. (Update)

That’s about it for now. Go do a gem install meta_search, read the docs, and try it out!

Filed under Blog
You can leave a comment, or trackback from your own site.
  • http://billsaysthis.com BillSaysThis

    This looks sweet and very timely for me. But the use case is a little different than your examples here or in rdoc: I want a search box in the top of every page which allows a user to search on one field (people names).

    Can I put a before_filter in application_controller.rb that sets up the @search instance var for use in the form?

    Thanks!

  • http://thebalance.metautonomo.us Ernie

    I don’t see why not — the @search object just needs to be instantiated before you use it in your form_for.

  • http://billsaysthis.com BillSaysThis

    Working so far but one question I have I don’t see answered in the docs: What is in the params[:search] in the controller example code?

    Also, since my search is on all pages and so not necessarily connected to the data/controller on the current page, how do I parameterize the form_for to call the right action? Or perhaps a better question is, what parameter is passed to the method called from the form?

  • http://thebalance.metautonomo.us Ernie

    Bill,

    params[:search] is passed back from the form created by the form_for. To control the destination URL of the form’s submit, check out the :url option.

  • http://billsaysthis.com BillSaysThis

    I must be missing something but I thought the controller method got called before the page load, since it sets up the @search variable.

  • http://thebalance.metautonomo.us Ernie

    Bill,

    It does. @search is just an empty object if no params have yet been submitted. It’s not really much different than the way that an ActiveRecord object gets created in a controller’s “new” action.

  • http://billsaysthis.com BillSaysThis

    Still not getting this right, sorry to be a pain but your help is surely appreciated:

    No route matches {:action=>”search”, :controller=>”people”, :id=>nil}

    Extracted source (around line #2):

    2: search_person_path(@person), :html => {:method => :get} do |f| %>

    However:

    wgd1$ rake routes |grep person
    search_person GET /people/:id/search(.:format) {:controller=>”people”, :action=>”search”}

  • http://thebalance.metautonomo.us Ernie

    Looks like you need to set up search as a collection route and not member. If you’re sending the user to search_person_path, you’re not going to pass a person object to it. Check out this Railscast for more on routes: http://railscasts.com/episodes/203-routing-in-rails-3

  • http://billsaysthis.com BillSaysThis

    Awesome, working now!

    In the controller method I’m testing the number of matching people found and rendering a template based on that (if i, render show; more than 1 render :index, else show a flash error and redirect_to :back).

    Is this how you do it?

  • http://thebalance.metautonomo.us Ernie

    That’s one way. Here’s the way I did it in my last project:

    if @results && @num_results == 1
      flash[:notice] = 'Your search returned one result. Here are the details!'
      redirect_to detail_path(@results.first) and return
    else
      render
    end

  • http://billsaysthis.com BillSaysThis

    Thanks.

  • Pingback: metautonomo.us » Blog Archive » Why (fork) Arel?

  • OKR

    Thank you for a wonderful plugin, I am currently transitioning a rails app i have from searchlogic to meta-search.

    Is there a way to use this to search multiple attributes of one model. For example, could I use one textfield in a form to search like this

  • OKR

    Whoops, my code didn’t show up in my comment, maybe this will work…

    f.text_field :title_or_body_contains

  • http://thebalance.metautonomo.us Ernie

    Hey there. I don’t currently support this — it wouldn’t be that hard to add, but I’ve always thought that it was a bit clunky in the form.

    Currently, I recommend creating a search method that does what you’re looking for, instead. Something like:

    scope :contains_string, lambda {|str| where(:title.matches % "%#{str}%" | :body.matches % "%#{str}%")}
    search_methods :contains_string

    It leads to a much less annoying form syntax and (imho) better-worded default label helpers:

    <%= f.label :contains_string %> <%= f.text_field :contains_string %>
    <!-- vs -->
    <%= f.label :title_or_body_contains %> <%= f.text_field :title_or_body_contains %>

    This is perhaps a simple example, but once you get to several fields in one condition, the Searchlogic way of doing it just starts to look ridiculous in your forms.

  • http://thebalance.metautonomo.us Ernie

    Oh, one last thing: The code above assumes you’re also using MetaWhere. If you’re not, season your condition to taste.

  • OKR

    That’s great, thanks so much for your quick reply!

  • Dan Jantea

    Hi Ernie,

    I just started playing with meta_search gem for one of my projects, and I have the following problem. The logic here is that I have Tasks, and each task belongs to a Category. I want to be able to search tasks which belong to any of the user selected categories OR has “no category” (i.e. where category_id is null).

    Given app/model/task.rb:

    class Task < ActiveRecord::Base

    belongs_to :category

    scope :in_any_of_categories, lambda { |category_ids|
    where('category_id IN (?) OR category_id IS NULL', category_ids)
    }
    search_methods :in_any_of_categories
    end

    And given app/views/tasks/index.html.erb:


    {:method => :get} do |f| %>

    All works fine when searching, i.e. the search results are correctly shown depending on which category check boxes are checked.

    The problem is that when the results page is displayed, the category check boxes that were checked before submitting the search form do not remain checked (the do not keep their status, all are unchecked). This does not happened if i use the standard where :category_id_in instead of the custom :in_any_of_categories. Am i missing something?

    Thanks.

  • Dan Jantea

    Here is the code again for the above comment (me stupid not using xhtml tags for code):

    app/model/task.rb:

    class Task < ActiveRecord::Base
     
      belongs_to :category
      belongs_to :context
     
      scope :in_any_of_categories, lambda { |category_ids|
        where("category_id IN (?) OR category_id IS NULL", category_ids)
      }
      search_methods :in_any_of_categories
    end

    app/views/tasks/index.html.erb:

     {:method => :get} do |f| %>

  • http://thebalance.metautonomo.us Ernie

    Dan, I can’t seem to see the form part of your code. I assume you’re using f.check_boxes or f.collection_check_boxes, though.

    Keep in mind that unless you are casting your parameters to integers, they won’t technically be equal to the value you are comparing them to in the check box array. What that means is that if you’re intending to make the values the user selects become integers, you need to supply the :type option to search_methods:

    search_method :in_any_of_categories, :type => :integer

    should do the trick.

  • Dan Jantea

    Thank you Ernie, you are right, the problem was that the parameters were strings, and :type => :integer solved the problem.

    As you figured out, i want to give the user the possibility to search the tasks which belong to the category or categories that she selects from the list of category check boxes. This works fine, but i want to add one more check box to the list of category check boxes for searching those tasks which are in no category (their category_id is null in the database). The user should be able to check any of the check boxes (the special “No category” one or any other category, in any combination). So the resulting SQL would be something like:
    - “…. ( category_id IS NULL OR category_id in (1,7) )” if the user selects the “No category” and categories 1 and 7, or
    - “…. category_id in (1,7)” if she selects categories 1 and 7, or
    - “…. category_id IS NULL” if the selects just the “No category”

    I wasn’t yet able to combine the two conditions – the “category_id IS NULL” and the “category_id in (….)”. Any suggestions?

    I am aware of the alternative approach of simply defining the “No category” category in the database and use its specific id as any other category_id instead of using NULL for TASK.CATEGORY_ID. I am trying to explore all possibilities before, though. Is it possible to use meta_search in a scenario like this (mixing IS NULL with other SQL predicates as search criteria for the same database column)?

  • http://thebalance.metautonomo.us Ernie

    Dan,

    Set the value for your “null” checkbox to 0, and then do something like this (warning: completely untested, but this should get you close):

    scope :in_any_of_categories, lambda { |category_ids|
      ids = category_ids.compact # drop nils since IN (NULL) is invalid SQL
      include_null = ids.delete(0)
      if include_null && ids.any?
        where("category_id IN (?) OR category_id IS NULL", ids)
      elsif ids.any?
        where("category_id IN (?)", ids)
      elsif include_null
        where("category_id IS NULL")
      else # Somehow we got nothing in the array
        self
      end
    }

  • Dan Jantea

    Ernie,

    Thank you for the prompt response. Your approach is similar to the one that I had in mind. Here’s how it worked:

    app/models/task.rb:

    class Task  false, :type => :integer
    end
    

    app/views/tasks/index.html.erb

     {:method => :get} do |f| %>
    
       true}, 0, nil %>
    

  • Dan Jantea

    I must admit that I do not know how to post code here. Neither ... or

    ...

    worked.

  • http://thebalance.metautonomo.us Ernie

    Yeah, I need to switch syntax highlighting plugins, I think. This one can be kind of annoying.

  • robert kviberg

    Hi Ernie, your MetaSearch gem is exactly what I need to play with in a small app. The thing is that my experience whit RoR is very limited, but I’m a quick learner ;) Could you help with a super small example where you show how to use MS in the controller, view and how you routes the hole thing. I suppose there are not extra code in the model? ex if I have a model ‘Product’ with product-name, product-desc, price. how to write a proper CRUD for this small example with MS implemented? Your help will be appreciated. a small SOS from a beginner :)

  • http://www.icekreamkoan.com Peter

    Ernie,
    Found your plug-in in my quest for a good search framework that’s R3 compatible. I’m going to deploy my app on Heroku and was kicking around using SOLR. Any pros/cons with regard to metasearch?

  • http://thebalance.metautonomo.us Ernie

    Hi Peter,

    The main thing to keep in mind is that MetaSearch is designed to do something a good deal different than a fulltext search engine. It’s more geared toward the kind of “advanced” search form that you might provide users in order to search based on specific attributes of their choosing. It should play nicely with others, though.

  • Hans Hofman

    Ernie, I now use MetaSearch in Rails3. What I don’t know how to specify the select from SearchLogic:

    Please explain this to me.

  • http://thebalance.metautonomo.us Ernie

    Hans, I’m not sure I follow the question. If you’re looking to only select certain columns from the rows returned, you can still do this by chaining select as you otherwise might.

  • Hans Hofman

    I’m sorry, the example string disappeared after the send

  • Hans Hofman

    Again f.select :cat [string, string]

  • http://thebalance.metautonomo.us Ernie

    That’s a standard Rails FormBuilder option. If you’re selecting a category id, for instance, you want to do:

    <%= f.collection_select :category_id, Category.all, :id, :name %>

    Or something similar.

  • Marlon

    How to change the locale for meta_search, i need data format dd/nn/yyyy

  • Luke B

    Hi there,

    Thanks for a great plugin. I noticed that when I load my page with the search form it loads a default set of results.

    Is there a way to have it return nothing until the form is submitted?

    Add if params[:search] throws errors for me.

    I look forward to your response.

    Kind regards,

    LB

  • http://erniemiller.org Ernie

    Luke, if you don’t want to return anything until there’s a search, you can check to see if there is anything in @search.search_attributes before rendering your results. For instance:

    if @search.search_attributes.values.any?(&:present?)

  • Dave

    Great tool, extremely easy to setup. Will definitely be part of my projects when required now.

  • http://www.everydayblog.net Shane

    Your gems were featured on Ryan Bates Railscasts and just in time for me! I am trying to work up a “checks” or a “collection_checks” implementation. Unfortunately, the params passed to the query string don’t seem right. I am seeing:

    &search[profiles_level_abbreviation_contains][]=ABC&search[profiles_level_abbreviation_contains][]=ADHC&search[profiles_level_abbreviation_contains][]=APH&commit=Search

    If I remove the trailing [] from the &search[profiles_level_abbreviation_contains][] it works, but only for one term.

    I am sure I am missing something, but I can’t see it…

  • http://erniemiller.org Ernie

    Shane,

    This probably isn’t the best forum for support. Can you create a lighthouse ticket? Be sure to include the code you’re using to generate the checks as well.

  • http://three99.com Vassilis Terzopoulos

    Great plugin and really easy to setup!

    Is there a way to prevent the search attr from showing up in the URL?

    I mean instead of

    &search[first_name_or_last_name_or_email_contains]=test

    it would be nice if we could have just &search=test

    Many Thanks!

  • http://gamov.info gamov

    Hello.
    I discovered metaSearch today and I’m loving it.
    I don’t know if it’s the right place to post this… anyway.
    I have a Company model STI with different type. I’m trying to replace my manual hacking with metasearch, I’m a bit stuck for the last bit. This works fine to select a subtype during search:
    f.select(:type_in, options_for_select([''] + Company.company_types, params[:search] ? params[:search][:type_in] : ”))
    However, I would like to have ‘ALL’ instead of ” (empty string) in the dropbox for searching through all types. I could also doctor the params[:search] in the controller but I’m wondering what’s best…

    Lastly, I tried also using f.checks :type_in, Company.company_types.map{|c| [c,c]}, it works fine but the default is all checkboxes unchecked. Is it a easy way of checking them all if nothing is coming from the params?

    Ok, i continue my discovery. Already thanks for your time if you reply.
    Cheers,
    Gam.

  • http://gamov.info gamov

    I have another (stupid?) question:
    I’m trying to figure when to use meta_search and meta_where and I reach the conclusion that I can use meta_search pretty everywhere even when no form are involved… When should I use meta_where instead?

  • Daniel

    hey ernie,

    just tried meta_search (and meta_where beforehand). it comes in quite handy and has a nice feeling to it, but speaking honestly: I don’t like the fact that you are generating the queries from naming conventions in the view. what i mean is: a user can change the DOM to generate a different search query. Im not so much worrying about injections here, rather than that a user might blindly query for things he maybe shouldnt see, even more since your gem automatically probes for associations.

    just thought id share that for people who are unaware of this :) keep up the good work!

    /ps: sorry for any mistakes, im no native speaker

  • http://erniemiller.org Ernie Miller

    I agree, but that’s why I built in the capability to limit searching
    certain attributes or associations. See the documentation for details.

  • http://twitter.com/hackeron Roman Gaufman

    How do I specify a default order? — I tried to add:   default_scope :order => ‘startdate DESC’   to my models file but that stops MetaSearch from being able to apply any ordering at all :/

  • Eric R.

    Is there a way to make this work with acts_as_taggable_on?

About

I'm Ernie Miller. But then, you probably knew that by looking at the page title, or the URL. I'm a Ruby programmer in Louisville, Kentucky. This blog used to be called "metautonomo.us", which I thought was kind of clever, but nobody, including me, could type it. Lesson learned.