giant robots smashing into other giant robots

Written by thoughtbot

lolconomy

Search by quacking like ActiveRecord

The story we’re trying to implement here is: as a user I want to search and filter houses so I can narrow my list of results.

To make this form and controller easier to handle we can use #form_for and pretend that the search is an ActiveRecord object. This allows the form to show fields that have been filled in or any errors on the search using existing Rails infrastructure.

The trick is that we are not storing the search in a database; we are merely pretending to be an ActiveRecord object.

So we begin from the outside with a Cucumber test:

Feature: Searching and filtering houses
  Scenario: Searching for a house by keyword
    Given a house named "Glass houses" exists with a description of "Don't throw rocks at me"
    And a house named "Straw houses" exists with a description of "Don't blow me down"
    When I go to the house search page
    And I fill in "Keyword" with "rocks"
    And I press "Search"
    Then I should see "Glass houses"
    And I should not see "Straw houses"
  Scenario: Filtering a house by price
    Given a house named "Glass houses" exists that costs 25 thousand dollars
    And a house named "Straw houses" exists that costs 10 thousand dollars
    When I go to the house search page
    And I select "20-30k" from "Price range"
    And I press "Search"
    Then I should see "Glass houses"
    And I should not see "Straw houses"

From this we can write the controller test in a straight-forward manner using test spies in mocha:

class HouseSearchesTest < ActionController::TestCase
  should_route :get, '/house_searches',
    :controller => :house_searches,
    :action => :index

  context "GET to index with houses" do
    setup do
      @houses = [Factory.stub(:house), Factory.stub(:house)]
      @search = HouseSearch.new
      HouseSearch.stubs(:new).returns(@search)
      @search.stubs(:results).returns(@houses)
      @params = 'the params'
      get :index, :house_search => @params
    end

    should_render_template :index

    should "assign the houses to @houses" do
      assert_received(@search, :results) {|expects| expects.with()}
      assert_equal @houses, assigns(:houses)
    end

    should "assign the house search to @house_search" do
      assert_received(HouseSearch, :new) {|expects| expects.with(@params)}
      assert_equal @search, assigns(:house_search)
    end
  end
end

That gives us the interface we expect HouseSearch to conform to. We’d also typically write view tests for the form stating that it has the right action and fields and a submit button, but this blog post is too long already.

First it needs to pretend to be an ActiveRecord object:

class HouseSearchTest < ActiveSupport::TestCase
  should "build a new HouseSearch with the expected params" do
    params = {'keyword' => 'some keyword',
              'price' => '20-30'}
    house_search = HouseSearch.new(params)
    params.each do |field, value|
      assert_equal value, house_search.send(field)
    end
  end

  should "handle nil on #new" do
    assert_nothing_raised do
      search = HouseSearch.new(nil)
    end
  end

  should "produce nil on #id" do
    search = HouseSearch.new
    assert_nil search.id
  end

  should "produce true on #new_record?" do
    search = HouseSearch.new
    assert search.new_record?
  end
end

Then it also needs to produce results when you ask it to:

class HouseSearchTest < ActiveSupport::TestCase
  should "produce only houses with the keyword in the description when sent #results for such a search" do
    matching_house = Factory(:house, :description => 'foo')
    nonmatching_house = Factory(:house, :description => 'bar')
    results = HouseSearch.new('keyword' => 'fo').results
    assert_all(results) {|house| house.description =~ /fo/}
  end

  should "produce only houses within the price range when sent #results for such a search" do
    matching_house = Factory(:house, :price => 10)
    nonmatching_house = Factory(:house, :price => 50)
    results = HouseSearch.new('price' => '30-100').results
    assert_all(results) {|house| 30 <= house.price && house.price <= 100}
  end
end

The last requirement to make the Cucumber pass are the view tests, as I mentioned. The outcome of the view tests (the implementation) looks like this:

<% form_for @house_search do |form| %>
  <div>
    <%= form.label :keyword %>
    <%= form.text_field :keyword %>
  </div>
  <div>
    <%= form.label :price %>
    <%= form.select :price, [['20-30', '20-30k']] %>
  </div>
  <div>
    <%= form.submit 'Search' %>
  </div>
<% end %>

The key to all of this is the HouseSearch class that acts like an ActiveRecord class just enough for us to use #form_for and to simplify our controller.

An extension to this is support for #errors, so that you can use in your form.

class HouseSearchTest < ActiveSupport::TestCase
  should "produce an error on the :price field when sent #new with an invalid price range" do
    house_search = HouseSearch.new(:price => 'unknown')
    assert_kind_of ActiveRecord::Errors, house_search.errors
    assert_not_nil house_search.errors.on(:price)
  end
end

Bonus source code!

Some of you may want the resulting code. This isn’t as exciting but here it is anyway:

class HouseSearchesController < ApplicationController
  def index
    @house_search = HouseSearch.new(params[:house_search])
    @houses = @house_search.results
  end
end
class HouseSearch
  attr_accessor :keyword, :price

  def initialize(params)
    params ||= {}
    params.each do |key, value|
      self.send("#{key}=", value)
    end
  end

  def id
    nil
  end

  def new_record?
    true
  end

  def results
    results = House.all
    results = results.within_price_range(self.price) unless self.price.blank?
    results
  end

  def errors
    @errors ||= ActiveRecord::Errors.new(self)
    @errors.add(:price, "must be a price range") unless self.price =~ /-/
    @errors
  end
end

jyurek

What good is a flexible paperclip?

Originally found at http://flickr.com/photos/toofarnorth/9984261

Since it’s the Holidays, I’ve been spending a bit more time than normal on Paperclip. And since that time has been particularly fruitful and there’s been a release or two, I figured I should probably tell someone about it before I friggin’ explode. It’s all about making Paperclip more flexible, more adaptable, and more friendly to use. Can you believe there’s more to file uploads than avatars?

Newer, more sensible defaults.

Overall, this is actually a bit small on the change meter, but it may affect some of you, so it’s up front. The :path and :url defaults have changed. By default now, files will be saved to :rails_root/public/system/:attachments/:id/:style/:basename/:extension. It’s the “system” part of that that’s important, because now it means that if you’re deploying with Capistrano, you don’t have to do anything and your attachments will survive deployments. This was not previously the case, regrettably, but it is now!

Callbacks and such.

Thanks to the callback methods pioneered by ActiveRecord itself with the fantastic before_save and family, Paperclip now defines a before_post_process and after_post_process callback, which can be used exactly like all the AR callbacks. Not only that, if you’re the kind of person who likes to have more than one attachment on a model, there are per-attachment callbacks as well, called before_<attachment>_post_process and after_<attachment>_post_process. The before_ callbacks are fully capable of stopping processing if they need to, simply by returning false (not nil, but false, which is a distinction ActiveRecord makes, as well). Thus, if you are uploading images that have “E” in the name, you can write a before_post_process that looks like this:

class User < ActiveRecord::Base
  has_attached_file :avatar, :styles => {:tiny => "32x32#" }
  before_post_process :check_avatar_name_for_capital_e

  def check_avatar_name_for_capital_e
    not self.avatar.original_filename.match(/E/)
  end
end

This will prevent the image from being thumbnailed. It will not prevent the attachment from being saved, though. Just from being processed. As a bonus, something else that will prevent attachments from being processed is failing validation. If you have a size, content_type, or presence validation that fails, the attachment will not go through processing (which means ImageMagick won’t try to convert that Word Doc into a PNG if you don’t want it to).

Expanded Post-Processing.

I realize that there’s more to image uploads than thumbnailing. And there’s more to file uploads than images. But until now, all you could do was thumbnail your images. What gives! Well, starting now you can define your own processors that can do whatever you want to your uploads. You can:

  • add rounded corners
  • invert
  • rotate
  • OCR
  • pick out every third word of your text docs
  • run spellcheck
  • automatically print them out

… well, you can if you can write code, since none of those exist yet. The only one that’s written is still the thumbnailer/format converter.

But the point is that now you can have Paperclip do whatever you want. Check out the Paperclip::Processor class documentation for more info on exactly what you need to do to make a Processor, but the gist is that you’ll take in a file and some options, and you spit back out a file. That’s pretty much it, and Paperclip places no limits on what you can do, say, or call during that time (so if you spend 30 seconds rendering a POVRay scene, that’s your fault for making your users wait).

Paperclip will automatically detect files in your Rails app’s lib/paperclip_processors directory, so just drop them there and you’ll be running in no time.

The Code!

As always, the code is available for forking and cloning on GitHub, and the documentation is available on our site.

If you’d like to contribute to paperclip with a patch, bug report, or feature request, don’t hesitate to get on over to the Paperclip Google group, or our Paperclip Lighthouse. As much as we like GitHub, we don’t really work well with pull requests from there. Creating a LH ticket with a link to the branch you want us to pull works much better.