Rails 2.3 notes

Main Site : CNK's space : Unix Notes : Rails Notes

ActiveResource

So ActiveResource seems pretty cool. It makes REST calls to a remote server and then turns the resulting XML (or json if you asked for json) into an object that behaves a lot like an ActiveRecord object. There are a couple of ways that ActiveResource object behave differently than ActiveRecord objects. The ones I have found so far:

  1. MyThing.new does NOT know what it's attributes should be - so it doesn't have any. And, because ActiveResource apparently still uses some method_missing stuff to create its getters and setters, not knowing your attributes means that I get 'no method found for yyy' errors when I try to use forms generated with form_for and its friends. I tried creating the attribute methods - for example with attr_accessor but that just creates other problems with refilling the input fields with the submitted values in case of save errors. Instead, just have your controller's new action create MyThing.new with attributes explictly set to nil, e.g. MyThing.new(:name => nil, :description => nil). Or, it looks like someone else had similar problems and created Hyperactive Recource. I haven't tried it, but it claims that it takes care of the no method errors as well as adding support for has_many and belongs_to methods.
  2. As many other folks have pointed out, there are no explicit association method building conveniences. So if you want to associate some local ActiveRecord object with an ActiveResource object, you will need to create any convenience methods you want to use on the ActiveResource side. So if you want local comments about posts that live somewhere else, the ActiveResource Post class will need a explicit comments method that pulls all the comments for the post.
      # use as @post_1.comments
      def comments
        Comment.find(:all, :conditions => {:post_id => self.id})
      end  
      
    The other side of this dance, @comment.post, seems to work just fine (assuming that you set up belongs_to :post in your Comment model).
  3. If you reuse the controller and forms from the ActiveRecord site on the ActiveResouce site, the forms behave just as you expect. Error messages get displayed up top and the related fields are hightlighted in red, just like normal Rails forms. However, the errors from the remote server actually come back as fully constructed error messages - but they are all on 'base', rather than in an errors hash keyed on the field name. So if you are doing unit testing on the ActiveResource class, you can't use most of the Shoulda macros (because they are expecting the errors to be associated with specific fields). So, instead of the following shoulda assertion assert_bad_value(User.new, :ssn, "123", /length/), you need to do something like:
     def
      test_ssn_must_be_nine_digits newusr = User.new(:ssn => '123')
      newusr.save assert_match(/SSN (.*)? length/i, newusr.errors.on_base)
      end 
    Note that I did an explicit save. That is what tries the send to the remote data store which actually does the validation and returns the errors.
  4. The json stuff does not work out of the box. It appears to have something to do with a difference of opinion about what the top level encloser should be. I decided to give up on this before characterizing it enough to file a bug report. But to_json and from_json don't seem to agree on what the top level is. The release notes for Rails 2.3.3 discuss changes in the json parsing - so it might be worth checking to see if the new as_json method makes ActiveResource work out of the box. Or if not, what it would take to make the two match. One of the guys on IRC pointed out a Lighthouse ticket for adding an ActiveResource parameter like the one in config/initializers/new_rails_defaults.rb that allows you to specify ActiveRecord::Base.include_root_in_json = true PS Here is a better description of what I think I was seeing when I tried to convert to json format.
  5. The only reason I was interested in the JSON support is that XML is slow. This blog post offers up a patch for allowing ActiveResource to use gzip - and mentions that the XML parser could stand to be upgraded - but the link to someone else's post on how to do that seems to be timing out.
  6. Some of the ActiveRecord-like methods work - but not the way you expect them to. For example Comment.find(:all, :limit => 5) drags ALL of the comments over the wire (serializing to XML and then back to objects) and then just hands you the first 5. And conditions seem to be ignored entirely: john = User.find(:all, :conditions => {:first_name => "John"}) returns all users regardless of first name. Looking at the docs, there docs seem to be a way to do this - but sending params - but unless your server's index action is looking for those specific parameters, you just get handed back all the records.
  7. And one can create custom methods outside of the restful conventions - but at the cost of getting back a hash or set of hashes rather than an object of the expected type. That behavior is noted in the ActiveResource::CustomMethods docs - with the suggestion that you use find + the "from" attribute. However, I had trouble making the find from a custom method work if I just wanted it to return a single, specific record; all the examples act on collections and then return either all records, or the first or last record. I finally got mad and read the source code. What seemed logical to me does NOT work, Expert.find(3200, :from => :initiatives) That just calls the show method to retrieve the record for 3200 and totally ignores the from parameter. But constructing the url by hand does work:
     Expert.find(:one, :from => "/experts/3200/initiatives.xml")
  8. But the straw that broke the camel's back was not being able to easily support file uploads. ActiveResource does not itself create multipart posts. And not being able to upload pictures is a deal breaker for my current project. I looked around and found a few alternatives but was having trouble figuring out how to get at the uploaded file in order to make thse work. RestClient looked quite promising, but I was having trouble figuring out how to integrate it with my rails app. And at this point I figured that it was just not worth it to try to use ActiveResource for sharing our Experts Guide information amongst our sites.

Engines/plugins

So ActiveResource is kind of interesting - especially if you are trying to consume someone else's RESTful web service. But compared to the convenience of a normal, ActiveRecord-based web site, it is pretty cumbersome. AND I was finding that I copied the controller and view code from my data source site into the data client sites. That was sort of OK - especially as I expect to customize the client forms to only allow editing of certain fields by some of my clients. But it did send up some warning signals.

I have always thought Engines were a great idea but more or less gave up on them when they broke at every minor Rails release. Now that they are 80% in standard Rails, I think I should try to create some, not just use ones that someone else wrote.

A good starting point looks to be thse posts about how Thoughtbot converted Clearance to be an engine: http://giantrobots.thoughtbot.com/2009/4/22/clearance-is-a-rails-engine and http://giantrobots.thoughtbot.com/2009/4/23/tips-for-writing-your-own-rails-engine

One question I have is how do I test engines/plugins. Some resources I found:

Tagging and Searching

I have a site that needs the ability to search on categorized tags. I found a couple of great plugins and a fair amount of documentation for each - but it was surprisingly hard to put together everything I needed to accomplish what I wanted. For my tagging plugin, I chose acts-as-taggable-on because it was the one that allowed you to have different sets of tags for the same object. The plugin calls the sets of tags contexts. And for searching, I use searchlogic.

The first thing I wanted to do was create an interface that let me use AJAX to tag programs with existing tags in each context and add new tags as needed.

Model: 

  acts_as_taggable_on :tags, :audience, :discipline, :location, :sponsor, :activity_type

View: 
<h2>Tags</h2>

<% for context in %W(audience discipline location sponsor activity_type) %>
  <div class="taggings" id="<%= context %>">
    <%= tag_checkboxes(context, @program) %>
  </div>
<% end %>

Helper:
  # Is this program already tagged with this tag?
  def current_tag?(tag, current_program_tags)
    current_program_tags.include?(tag) ? true : false
  end

  # Given a context (what kind of tag) and the current program,
  # this helper builds a list of tags for that context with the
  # currently selected tags checked; Includes the javascript for
  # being able to check and uncheck AJAXily
  def tag_checkboxes(context, program)
    widget = "<b>"+pretty_context_name(context)+":</b> "

    # Build checkboxes for all the tags in current context
    all_tags = Program.tag_counts_on(context)
    current_tag_list = program.tags_on(context)

    all_tags.each do |tag|
      widget <<  check_box_tag("tag[#{tag.id}]", "1", current_tag?(tag, current_tag_list), {:onclick => remote_function(:url => url_for(:action => :toggle_tag, :id => program.id, :tag_name => tag.name, :context => context), :failure => "alert
('Problem changing tagging.')")}) + "#{tag.name} (#{tag.count})"
    end

    # Now add a form for adding new tags
    form = form_tag({:action => :tag, :id => program.id, :context => context}, :id => "new_#{context}")
    form << '<input type="text" name="tag_name" size="10" /> '
    form << submit_to_remote("new_tag", "New #{context.humanize.downcase}", :url => {:action => :tag, :id => program.id, :context => context})
    form << "</form>"
    widget << form

    return widget
  end

  def pretty_context_name(context)
    context.humanize.pluralize.capitalize
  end

Routes:
  map.resources :programs, :member => { :toggle_tag => :post, :tag => :post }

Controller: 
  # POST /programs/1/toggle_tag                                                                                           
  def toggle_tag
    begin
      @context = params[:context]
      @program = Program.find(params[:id])
      current_tag_list = @program.send("#{@context}_list")

      if current_tag_list.include?(params[:tag_name])
        # toggle off                                                                                                      
        current_tag_list.remove(params[:tag_name])
      else
        # toggle on                                                                                                       
        current_tag_list.add(params[:tag_name])
      end
      @program.set_tag_list_on(@context, current_tag_list)
      @program.save
      render :update do |page|
        page.replace_html @context, tag_checkboxes(@context, @program)
      end
    rescue
      render :text => "problem tagging", :layout => false
    end
  end

  # POST /programs/1/tag                                                                                                  
  # add a new tag                                                                                                         
  def tag
    begin
      @program = Program.find(params[:id])
      @program.send(params[:context]+"_list").add(params[:tag_name])
      @program.save
      respond_to do |format|
        format.html { redirect_to program_path(@program) }
        format.js  { @context = params[:context]
                     render :update do |page|
                       page.replace_html @context, tag_checkboxes(@context, @program)
                    end  }
        end
    rescue
      render :js => "alert('problem tagging')"
    end
  end

Now to implement the search, we need named scopes for each context. The way I would like the search to behave is that checking boxes within a context gives you val1 OR val2 or val3. But that checking boxes in different contexts gives you AND, e.g. (val1 OR val2) AND val3.

Model:
  # named scopes to make searching easier - thanks to http://gist.github.com/199027 
  # These methods are hard coded to OR the tags passed in                          
  if respond_to?(:tag_types)
    tag_types.each do |tag_type|
      class_eval <<-EVAL
        scope_procedure :any_tagged_with_#{tag_type}, lambda { |tags|
        tagged_with(tags, :on => :#{tag_type}, :any => true)
        }   
        EVAL
    end
  end

Routes:
  map.program_search '/program_search', :controller => 'program_search', :action => 'index'

Controller: 

  def index
    @search = Program.search(params[:search])
    @programs = @search.conditions.empty? ? [] : @search.all
  end

View:

<% form_for @search do |f| %>
  <% for context in %w(audience discipline location sponsor activity_type) %>
  <% all_tags = Program.tag_counts_on(context) %>
    <div class="taggings" id="<%= context %>">
      <%= f.label "any_tagged_with_#{context}", pretty_context_name(context) %>
      <% all_tags.each do |tag| %>
        <%= f.check_box("any_tagged_with_#{context}", {:name => "search[any_tagged_with_#{context}][]", :value => "#{tag.name}"}, tag.name, "") + "#{tag.name} (#{tag.count})" %>
      <% end %>
    </div>
  <% end %>
  <%= submit_tag("Search") %>
<% end %>

<% if @programs.size > 0 %>
<h1>Programs</h1>

<%= render :partial => "programs/program", :collection => @programs %>
<% end %>

The code above mostly works as I want it to but I have one oddity I need to overcome. When I first come to the page, no checkboxes are selected and the query returns no results. However, if I add a search, submit, then go back and uncheck all the boxes, then the search returns all rows in the programs table. I can't seem to figure out how to access the correct attribute in the search object to check if we have any search conditions and supress the output if the user removed all the tings they were searching for.

Misc.

The current sqlite3-ruby gem (1.3.3) requires a more recent version of sqlite3 than is stock on RHEL5 (Error message: sqlite3-ruby only supports sqlite3 versions 3.6.16+, please upgrade!). So I installed a new version of sqlite3 from source using stow. But it took a bit of fishing around (and searching on StackOverflow) to find the right options to get the gem to install. For future reference:

gem install sqlite3 -- --with-sqlite3-include=/home/cnk/software/include --with-sqlite3-lib=/home/cnk/software/lib

Other Sections


cnk@ugcs.caltech.edu