Ernie Miller

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

RSS Feed

Introducing MetaWhere

Posted by Ernie on April 15, 2010 at 9:10 pm

Recently, I spent some time working on a patch to Rails core to allow ActiveRecord::PredicateBuilder to allow more powerful queries — that is, support for additional Arel predicates beyond Equality and In. If you’re not familiar with PredicateBuilder, that’s where the magic happens when converting the arguments you supply to ActiveRecord::Relation#where or ActiveRecord::Base#find‘s :conditions hash into Arel predicates for a query. Anyway, after a brief discussion with Pratik on #rails-contrib, where he shared a bit of insight into his thoughts on tackling the same problem and encouraged me to create a plugin, I came up with a bit of a hybrid — something that I think scratches this particular itch pretty well for me: MetaWhere. Read on for how it works. [Update: If you're arriving via Ruby5 or the Rails weblog, you might also be interested in the latest MetaWhere updates, why I forked Arel, or the main MetaWhere page. Thanks for visiting!]

MetaWhere offers the ability to call any Arel predicate methods on your model’s attributes instead of the ones normally offered by ActiveRecord’s hash parameters.

Normally, ActiveRecord interprets a conditions hash as an Arel predicate of type Equality or In, depending on whether you supply a single value or an array on the value side of the hash.

MetaWhere allows you to specify what specific Arel “predication” method you would like to use. As I’ve discussed before, these are methods on attributes which cause Arel to generate different SQL on your behalf.

Enough with the description. On to the samples!

Where

You can use MetaWhere in your usual method chain:

Article.where(:title.matches => 'Hello%', :created_at.gt => 3.days.ago)
=> SELECT "articles".* FROM "articles" WHERE ("articles"."title" LIKE 'Hello%')
   AND ("articles"."created_at" > '2010-04-12 18:39:32.592087')

Find condition hash

Until it’s deprecated and eventually removed, MetaWhere keys can be used in your finder :conditions hash like so:

  Article.find(:all,
    :conditions => {
      :title.matches => 'Hello%',
      :created_at.gt => 3.days.ago
    }
  )

Scopes

MetaWhere also works in named scopes as you would expect:

  class Article
    scope :recent, lambda {|v| where(:created_at.gt => v.days.ago)}
  end
 
  Article.recent(14).to_sql
  => SELECT "articles".* FROM "articles"
     WHERE ("articles"."created_at" > '2010-04-01 18:54:37.030951')

Operators

Additionally, you can use certain operators as shorthand for certain Arel predication methods. These are experimental at this point and subject to change. Keep in mind that if you don’t want to enclose other conditions in {}, you should place operator conditions before any hash conditions.

  Article.where(:created_at > 100.days.ago, :title =~ 'Hi%').to_sql
  => SELECT "articles".* FROM "articles"
     WHERE ("articles"."created_at" > '2010-01-05 20:11:44.997446')
     AND ("articles"."title" LIKE 'Hi%')

Operators are:

  • [] (equal)
  • ^ (not equal)
  • + (in array/range)
  • - (not in array/range)
  • =~ (matching – not a regexp but a string for SQL LIKE)
  • !~ (not matching, only available under Ruby 1.9)
  • > (greater than)
  • >= (greater than or equal to)
  • < (less than)
  • <= (less than or equal to)

Compounds

You can use the & and | operators to perform ands and ors within your queries.

With operators:

  Article.where((:title =~ 'Hello%') | (:title =~ 'Goodbye%')).to_sql
  => SELECT "articles".* FROM "articles" WHERE (("articles"."title" LIKE 'Hello%'
     OR "articles"."title" LIKE 'Goodbye%'))

That’s kind of annoying, since operator precedence is such that you have to put parentheses around everything. So MetaWhere also supports a substitution-inspired (String#%) syntax.

With “substitutions”:

  Article.where(:title.matches % 'Hello%' | :title.matches % 'Goodbye%').to_sql
  => SELECT "articles".* FROM "articles" WHERE (("articles"."title" LIKE 'Hello%'
     OR "articles"."title" LIKE 'Goodbye%'))

With hashes:

  Article.where(
    {:created_at.lt => Time.now} & {:created_at.gt => 1.year.ago}
  ).to_sql
  => SELECT "articles".* FROM "articles" WHERE
     ((("articles"."created_at" < '2010-04-16 00:26:30.629467')
      AND ("articles"."created_at" > '2009-04-16 00:26:30.629526')))

With both hashes and substitutions:

  Article.where(
    :title.matches % 'Hello%' &
    {:created_at.lt => Time.now, :created_at.gt => 1.year.ago}
  ).to_sql
  => SELECT "articles".* FROM  "articles" WHERE (("articles"."title" LIKE 'Hello%' AND
     ("articles"."created_at" < '2010-04-16 01:04:38.023615' AND
      "articles"."created_at" > '2009-04-16 01:04:38.023720')))

With insanity complex combinations*:

Article.joins(:comments).where(
  {:title => 'Greetings'} |
  (
    (
      :created_at.gt % 21.days.ago &
      :created_at.lt % 7.days.ago
    ) &
    :body.matches % '%from the past%'
  ) &
  {:comments => [:body =~ '%first post!%']}
).to_sql
=> SELECT "articles".*
   FROM "articles"
     INNER JOIN "comments"
     ON "comments"."article_id" = "articles"."id"
   WHERE 
   ((
     "articles"."title" = 'Greetings'
     OR
     (
       (
         (
           "articles"."created_at" > '2010-03-26 05:57:57.924258'
           AND "articles"."created_at" < '2010-04-09 05:57:57.924984'
         )
         AND "articles"."body" LIKE '%from the past%'
       )
       AND "comments"."body" LIKE '%first post!%'
     )
   ))

My Arel fork

If you encounter any strange behavior, try adding the following to your Gemfile:

gem 'arel', :git => "git://github.com/ernie/arel.git"

This fork has some updates that have not yet been committed upstream, though I hope they will be. I think I covered all of the differences that break functionality with workarounds in MetaWhere, but I don’t really use the main arel fork in my day-to-day development so I can’t be sure.

Two things you will definitely not get without my fork (at the time of this writing) are the :notmatches (NOT LIKE) and :notin (NOT IN (val1, val2)) predications.

A final word

Anyway, I think that about does it, for now. Please feel free to post your suggestions/opinions/flames below. I’ll be working on this a bit in my spare time for at least the near future, and I’m anxious to know what the Rails community thinks about it. So go do a gem install meta_where and give it a spin!

(*) Formatting added for clarity. I said you could do this, not that you should. :)

Filed under Blog
Tagged as , , ,
You can leave a comment, or trackback from your own site.
  • LucaB

    Indeed good news. We all hope that such an approach will become mainstream in ActiveRecord.

  • Pingback: metautonomo.us » Blog Archive » MetaUpdates

  • http://www.bitcetera.com/en/techblog Svoop

    Nice! Is it too late to merge it into Arel before it hits RC status? ;-)

  • http://thebalance.metautonomo.us Ernie

    Well, technically, most functionality will work without an Arel merge — but there are certain features I make available here which do require some changes to Arel from my fork. I rebased my fork against brynary’s version the other day and he mentioned on Twitter that he would be working on it — it’s in his capable hands now. :)

  • http://m.onkey.org Pratik

    Great work Ernie! Looking forward to use this in the next Rails 3 project.

  • http://blog.darkhax.com/ Daniel Huckstep

    This is seriously badass. Nice work!

  • http://thebalance.metautonomo.us Ernie

    Thanks, guys!

  • Stephen

    This fills the gap I noticed immediately when i started using arel. I’m a linq fan on the .net side and arel seemed half baked without it. Great work!

  • Johannes

    Wow. I always envied the merb programmers being able to formulate complex queries in pure ruby through data mapper. Now you can in ActiveRecord. Once I switch to Rails 3, I definitively give this a try.

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

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.