Bug: composite_primary_keys and belongs_to with :class_name option

Posted by Ryan Kinderman Sat, 17 Nov 2007 02:39:44 GMT

For those of you using the composite_primary_keys gem as of version 0.9.0, you may encounter an issue if you try to do something like:

class Reading < ActiveRecord::Base
  belongs_to :reader, :class_name => "User"
end

When a User is loaded up from the database via the reader association, the CPK modification to ActiveRecord::Reflection::AssociationReflection#primary_key_name incorrectly returns "user_id" as the primary key name. If you encounter this issue, I've submitted a patch against revision 124 that can be obtained here.

Hopefully this will get fixed in the next release. More hopefully, I won't need to care by then.

Passing Arrays and Nested Params to url_for in Rails 9

Posted by Ryan Kinderman Wed, 07 Feb 2007 21:12:00 GMT

A few weeks ago (okay, more than a few weeks ago, it took me a while to write this), I discussed the problems involved with passing nested hash parameters to named routes in Rails. My coding pair and I discovered another bug (still using rev 5522) when passing hash parameters to a named route in Rails, this time when the hash contains arrays. For example, consider the following call to a named route:

person_url(:name => ['Ryan', 'Kinderman'])
In order for the params hash to get decoded properly on the server, the resulting URL must be encoded to look like this:
"http://someurl.com/people?name[]=Ryan&name[]=Kinderman"
Unfortunately, it gets encoded to look like this:
"http://someurl.com/people?name=Ryan%2FKinderman"
For those of you unfamiliar with CGI escaping, the %2F translates into the '/' character. So, you end up with a params hash in the controller where params[:name] == ['Ryan/Kinderman']. How disappointing. To get around this in the past, I've chosen to either split the hash value on '/', or use my own encoding of arrays that Rails can handle, and then simply decode them myself within the controller. In the above example, I could have done something like:
person_url(:name => {0 => 'Ryan', 1 => 'Kinderman})
Of course, without the patch I described a few weeks ago, this kind of thing would not be possible either, because Rails can't encode nested hash parameters.

What I present here is a detailed explanation of the problem, with instructions at the end on how to install my plugin patch to fix it. My explanation and patch address the issues for both nested and array parameters. There are a number of methods involved in the solution to this problem. It may be useful at this point for you to refer to Jamis Buck's excellent articles on the gory details of Rails route recognition and generation.

The Rails methods involved in processing route parameters are listed below, in their general call sequence:

When you call link_to or url_for, either explicitly or through the named route *_url methods, they roughly follow the call sequence depicted in the following diagram to generate the query string for the resulting URL: The problems start in the call to options_as_params. This method is not recursive, and processing nested parameters is a recursive problem. The next issue with options_as_params is not actually in the method, but in the to_param method that it calls. If you look at the Rails implementation of Array#to_param, you'll see that all it's doing is joining the elements into a '/' separated string. This doesn't get processed back into separate array elements when the request is received by the controller. So, in the case when value is an Array instance during a call to options_as_params, the resulting string is encoded incorrectly.

The other specific issue lies in the Route#build_query_string method. Take a look at the method, and notice the part that looks like:

if value.class == Array
  key <<  '[]'
else    
  value = [ value ] 
end     
The check for the Array class causes a problem when passing an array to url_for as an option parameter when that array comes from the params hash from within a controller action (*whew*, that was a mouthful!). This is because what you thought was an array is actually an instance of ActionController::Routing::PathSegment::Result. To be honest, I don't know why this is happening. I looked at the code and realized that it'd take me longer to figure out than what I wanted to spend at the time. However, if someone could explain it to me, I'd love to hear it. In any case, to solve this particular problem, the conditional needs to be changed from a check for only Array to Array and any subclasses using something like the is_a? method.

So, those are the issues involved in why array and nested hash parameters don't work properly in calls to url_for. Rather than going through my solution, I'm offering it as a Rails plugin with full unit test coverage, and plan to submit it as an actual patch to the Rails team, with the code cleaned up a bit more. Maybe there are reasons why this sort of thing isn't supported, but I can't think what they might be. I'll post updates here if and when I get more information on this. If you have comments or questions on this patch or parts of the code, please let me know.

You can install the Rails plugin by typing the following into your command-line: ruby script/plugin install http://svn.ryankinderman.net/nested_params_patch To see the issues I've discussed first-hand, after installing the plugin, take a look at controller_test.rb.

Addendum: I checked, and as of revision 6141 of Rails, the issues covered by this article are still present, and the plugin still fixes them.

Addendum (2007/04/03): I've just got around to confirming that, as rwd's commented, the bug has been fixed. If you're using revision 6343 or later of Rails, you probably aren't going to need this patch. Yay!

Nested Hash Params with Named Routes in Ruby on Rails 3

Posted by Ryan Kinderman Fri, 17 Nov 2006 04:41:00 GMT

I've never (as of rev 5522) been able to pass a nested hash as parameters to url_for and have it flatten it properly on the generated URL. I encountered the problem again the other day and, having had just about enough, decided to try and nip it in the bud.

The problem I was having occurred when tried to make an HTTP request to a named route such as:

people_url(:person => { :name => 'Bob', :profession => 'Developer' })
This translates to a URL such as http://localhost:3000/people?person=nameBobprofessionDeveloper for a POST request. This obviously doesn't decode the hash in a format that can be encoded back its original form.

When I've encountered this problem in the past, I could never find any information from someone who's actually solved the problem. There are a few patches submitted as tickets to the Rails devs, but one seems to be a duplicate of the other, and neither seems to fix this problem on Edge, at least not for my particular use case.

The root of this particular problem lies in a method in the action_controller/routing.rb file within actionpack/actioncontroller, in the options_as_params of the RoutingSet class. The method, without comments, looks like this:

1
2
3
4
5
6
7
def options_as_params(options)
  options_as_params = options[:controller] ? { :action => "index" } : {}
  options.each do |k, value|
    options_as_params[k] = value.to_param
  end
  options_as_params
end
The problem lies on line 4 in the case where value is a Hash. In that case, the to_param method simply converts the Hash instance into its String representation, which is just a pile of keys and values mashed together, as in my people_url example.

To solve this problem, I changed the method to look like this:

def options_as_params(options)
  options_as_params = options[:controller] ? { :action => "index" } : {}
  options.parameterize do |param_key, param_value|
    options_as_params[param_key] = param_value.to_param
  end
  options_as_params
end
The parameterize method is an extension I made to the Hash class that essentially flattens a hash like:
{
  :person => {
    :name => 'Bob',
    :profession => 'Developer'
  }
}
into a new Hash that looks like:
{
  :'person[name]' => 'Bob',
  :'person[profession]' => 'Developer'
}
The new Hash is in a format that can be decoded into a URL query string that will be properly encoded back into a Hash on the request. My Hash extension for the solution looks like this:
class Hash

  def flatten(superkey)
    flattened_hash = {}
    self.each do |key, value|
      flattened_hash["#{superkey}[#{key}]".to_sym] = value
    end
    flattened_hash
  end
  
  def parameterize
    self.each do |k, value|
      if value.is_a?(Hash)
        value.flatten(k).each { |fk, fv| yield fk, fv }
      else
        yield k, value
      end
    end       
  end

end
Note that this has only been tested to work for a single-level nested Hash, as that satisfied my needs at the time I developed the solution.