Under the hood: route generation in Rails
I previously wrote about the implementation of the routing DSL in Rails, as well as the internals of route recognition. If you haven’t yet read (and understood) both of those articles, I strongly encourage you to do so before delving into this one, since it builds on the material presented in those.
Route generation is the last stop on this tour of the Rails routing code. It also happens to be the hairiest, most mysterious, and most difficult bit, so make sure you’ve got a recent version of the rails code handy to follow along with (this article outlines the implementation as of revision 5304). Also, don’t be afraid to take this article a bit at a time, and to go over it repeatedly. It’s dense stuff, and unless you’re already familiar with the routing code, it might be hard to swallow in one (or even two) sittings.
Before we dive headlong into this, let’s do a bit of warm-up, starting with some vocabulary.
Vocabulary
I’ll put these terms all up here, at the top of the article, to make it easier to refer back to them as you read. For the most part, these shouldn’t be difficult concepts to grasp, but by defining them here we can hopefully avoid ambiguity and confusion later.
- default route. This is the traditional ”:controller/:action/:id” route that is defined by default in every new Rails application.
- query parameters. This is the set of name/value pairs that were passed in via the query string, that part of the URL that follows the question mark. For example, in the URL ”/foo/bar/15?view=show”, the query string is “view=show”.
- request parameters. This is the set of name/value pairs that were passed in via the body of a POST request.
-
path parameters. This is the set of name/value pairs that were extracted from the URL path itself for the current request. In other words, if the default route is used to recognize ”/foo/bar/15?view=show”, the path parameters will be
{ :controller => "foo", :action => "bar", :id => "15" }
. -
parameters. This is the combination of the query, request, and path parameter hashes. The controller’s
params
object references this hash. -
parameter shell. This is a subset of a route definition’s key/value pairs. It includes all keys that are not explicitly referenced in the route’s mapping, and which have non-regex values. For example, the default route’s parameter shell would not include the
:controller
,:action
and:id
keys, because they are explicitly referenced in the route’s mapping. - path. For this article, “path” refers is the output of route generation, which is a string containing the path portion of a URL. In this article, “URL” will be loosely interchangeable with “path”.
- route. In this article, this refers to the object that encapsulates the logic of generating a path, or URL. It is tempting to use “route” to refer to the output of route generation, but that way leads to madness and despair. Trust me.
Alright, with that out of the way, we can now take another step towards delving into route generation.
Route Generation
Route generation is the magic that happens when you call the link_to
helper, or invoke a named route (foo_url
or foo_path
). In the former case, it takes some hash of options that describe the desired route, and it generates the URL that best matches the options. In the latter case, it attempts to generate the URL for the specific route you request. Ultimately, both approaches share 99% of the implementation.
It sounds like it should be simple: “take some options, and generate a path from them”. The devil, as ever, is in the details. Route generation attempts to be fairly smart about things, and in general it succeeds. Understanding the internals, though, will help you debug things when routing isn’t as smart as you’d like.
Some of the requirements that route generation must meet are:
- The generated path must consume the greatest possible number of keys from the hash you give. If you send in a hash with keys
:a
,:b
and:c
, ideally you’d like the routing code to select the route that uses all three of those keys. If that’s not possible, the generated path must consume as many of them as possible. Any left over keys get tacked on as query parameters, so another way of phrasing this is to say that the generated path must have the shortest possible list of query parameters. - Namespaced controllers must be accounted for. Controller paths must be generated relative to the current controller’s module (if any). This is actually a legacy requirement, and results in frequent surprises when dealing with controllers in modules.
- Relevant parameters from the current request should be reused where possible. That is to say, if the current request is for ”/controller/action/15”, then generating a URL with only
:id => 17
should give me ”/controller/action/17”, where thecontroller
andaction
keys were implied from the current request. - Route generation must be fast. The current implementation could certainly do better on this requirement. Every time you call
link_to
or invoke a named route, a URL gets generated, so a slow implementation can significantly impact the speed at which your page is rendered. Most of the complexity in the current implementation is due to the need to meet this requirement; it is surprisingly hard to get a fast route generator that also meets the above requirements!
Now, bear with me. As much as I’d love to plow onward through this stuff, we really need to pause one last time before we dig in. Item #3 above, though seemingly innocuous, is actually kind of a tricky point. To implement it, routing employs a concept known as “parameter expiration”, and before we go any further, we’d be wise to spend a minute or two explaining it.
Parameter Expiration
As you’ll see shortly, one of the first things the route generation process does is to build an expiry hash. This hash is used to determine which parameters can be reused from the current request. Each key in the expiry hash points to a boolean value, indicating whether or not that key is expired.
The expiration rules work something like this:
- If a path parameter exists in the current request, but was not specified explicitly in the options given to
generate
, the parameter remains unexpired (and may be reused to generate the URL). - If a path parameter exists in the current request, and it has the same value in the options passed to
generate
, the parameter remains unexpired. - If a path parameter exists in the current request, but it’s value differs from that which was passed to
generate
, the parameter is expired and will not be reused to generate the URL. Furthermore, by expiring this value, any other key used later in the same route is also implicitly expired.
It sounds really funky in writing, but it’s honestly not that bad. Looking at a few examples may help to demonstrate how this expiry stuff works in practice. Consider the default route:
map.connect "/:controller/:action/:id" |
Now, given that route, let’s assume we receive a request for ”/foo/bar/15”. This will create the path parameter hash such that the :controller
key is set to “foo”, the :action
key to “bar”, and the :id
key to “15”.
During the course of this request, we generate three URL’s.
1 2 3 |
<%= link_to "A", :id => 17 %> <%= link_to "B", :action => "baz" %> <%= link_to "C", :controller => "quux" %> |
In the first case, the expiry hash will be set such that the :controller
and :action
keys are false, and the :id
hash is true. This is because the options hash differs from the request’s recalled values only by the :id
; the other two keys are absent altogether and thus are not expired. The generated URL, then, will have the same controller and action as the current request, differing only on the id: "/foo/bar/17"
.
For the second case, we now change the :action
, leaving the others unchanged. This results in the expiry hash having the same keys as the previous example, but with :action
set to true and the others set to false. When the URL is generated, this causes all keys following the action to be implicitly expired as well. In other words, because the :action
key is expired, the :id
also gets expired, so the generated route omits it, resulting in the path "/foo/baz"
. (This is why, if you are changing actions but want to use the same :id
as the current request, you have to explicitly include it when you generate the URL.)
The third case changes the :controller
and leaves the others. Using the same rule of implicit expiration as before, this expires all three keys, generating the path "/quux"
(which is the same as "/quux/index"
, since “index” is the default action name).
If that’s not all perfectly clear right now, press on. Keep reading. It is not critical that you thoroughly understand parameter expiration at this point, and it may become clearer once you see how the route generation process uses the expiry hash.
With all that behind now, we’re finally ready to leap into the code itself. Here goes!
RouteSet#generate
Whether you use the link_to
helper, the url_for
method, or a named route, it all eventually gets funneled to RouteSet#generate
. Because this article is long enough already, I leave those code paths for you to explore on your own.
RouteSet#generate
begins on line 1190 of action_controller/routing.rb. It accepts three parameters:
def generate(options, recall={}, method=:generate) |
The options
parameter is a hash that will be used to generate the path. These are the keys and values you explicitly pass in, like :id => 17
in the parameter expiration example, above. The optional recall
parameter, if given, is the hash of path parameters for the current request (which are being “recalled” to help generate the URL). Lastly, the method
parameter specifies which method to call on each route. This is used primarily internally (see RouteSet#generate_extras
). For now, we’ll just pretend it is always its default value, :generate
.
Let’s take the body of the RouteSet#generate
method one piece at a time:
1 2 3 4 5 |
named_route_name = options.delete(:use_route) if named_route_name named_route = named_routes[named_route_name] options = named_route.parameter_shell.merge(options) end |
When you invoke a named route, the options hash it passes to #generate
includes a special key (:use_route
) that specifies the name of the route. First, that option is extracted, and if it exists, we look up the corresponding Route
object and merge the route’s parameter shell with the options that were passed in.
Then:
1 2 |
options = options_as_params(options) expire_on = build_expiry(options, recall) |
The options_as_params
method simply calls to_param
on each value in options
. This trick lets you do things like :id => user
instead of :id => user.id
, since ActiveRecord::Base#to_param
just returns the id.
After that, we build the expiry hash, as described in the “Parameter Expiration” section, above. We use both the options
hash (which is the set of explicit options passed to generate
) as well as the recall
hash (the path parameters from the current request) to build the expiry hash, and assign the result to expire_on
.
Moving on:
1 2 3 4 5 6 |
if !named_route && expire_on[:controller] && options[:controller] && options[:controller][0] != ?/ old_parts = recall[:controller].split('/') new_parts = options[:controller].split('/') parts = old_parts[0..-(new_parts.length + 1)] + new_parts options[:controller] = parts.join('/') end |
This is legacy, here, and is only there because it’s how the original implementation of routes behaved. Oh, how we’d love to throw it out! Basically, all it does is ensure that if the controller has changed, it must change relative to any existing namespace for the recalled parameter. In other words, if you’re currently in the admin/get
controller, and you ask for the set
controller, you should get the admin/set
controller. Unless, that is, the new controller is prefixed with a slash, e.g. /set
. What a mess! I strongly suspect this behavior will go away with the advent of Rails 2.0.
Once we’ve danced that tango of relative controller paths, we can safely strip any leading slash from the controller name, and then merge the recall
and options
hashes:
1 2 |
options[:controller] = options[:controller][1..-1] if options[:controller] && options[:controller][0] == ?/ merged = recall.merge(options) |
This “merged” hash is what is used as the reference hash for route generation, until an expired parameter is encountered. At that point (as you’ll see) the original options
hash becomes the reference, with the merged
hash getting chucked out.
Moving right along, we now encounter the primary difference between routes generated by name, versus by hash:
1 2 3 4 |
if named_route path = named_route.generate(options, merged, expire_on) raise RoutingError, "#{named_route_name}_url failed to generate from #{options.inspect}, expected: #{named_route.requirements.inspect}, diff: #{named_route.requirements.diff(options).inspect}" if path.nil? return path |
If we’ve been asked to generate a named route, things are easy. We just pass the parameters to the route’s #generate
method, and return the result. However, if we aren’t generating a specific named route, things are a bit trickier:
1 2 3 4 5 6 7 8 |
else merged[:action] ||= 'index' options[:action] ||= 'index' controller = merged[:controller] action = merged[:action] raise RoutingError, "Need controller and action!" unless controller && action |
First, we default the :action
key to “index”. (You’ll notice this happens in multiple places in the code—a red flag that some refactoring is needed. Got a good idea for how to reduce the duplication? We’d love a patch!) After extracting both the controller and action values from the hash and making sure that both have been specified (either explicitly or implicitly, since we’re using the merged
hash here), we find the set of routes that might possibly be able to generate the requested path:
routes = routes_by_controller[controller][action][options.keys.sort_by { |x| x.object_id }] |
Funky, funky stuff. The routes_by_controller
method returns a multilevel hash of routes, keyed by controller, action, and the list of options passed in (sorted arbitrarily, but consistently). This is an optimization which causes only a subset of the routes to be considered by the process. (Otherwise, each request to generate the path would potentially have to try each defined route, which would be rather expensive.) Once the list of potential routes is identified, we iterate over them, trying to generate a path from each one until we succeed:
1 2 3 4 5 |
routes.each do |route| results = route.send(method, options, merged, expire_on) return results if results end end |
If, when all is said and done, a route was not generated, we guiltily raise a RoutingError
exception and blame the caller.
That’s the high-level view. From here on, things really start to get hairy.
Route#generate
For simplicity’s sake, let’s assume that the generation method used was :generate
. That being the case, we now jump to line 411, to Route#generate
.
1 2 3 4 |
def generate(options, hash, expire_on = {}) write_generation generate options, hash, expire_on end |
Flashbacks to the recognition code! Route#generate
calls write_generation
to dynamically generate the new #generate
method. As we did in the last article, let’s take a look at the code that gets generated for a few common scenarios. We’ll use the same three routes that we used before, to keep things simple:
1 2 3 4 5 6 7 8 9 10 |
ActionController::Routing::Routes.draw do |map| map.connect "/", :controller => "foo", :action => "index" map.connect "/foo/:action", :controller => "foo" map.connect "/foo/:view/:permalink", :controller => "foo", :action => "show", :view => /plain|fancy/, :permalink => /[-a-z0-9]+/, :conditions => { :method => :get } end |
For the first route, the following three methods get generated:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def generate(options, hash, expire_on = {}) path, hash = generate_raw(options, hash, expire_on) append_query_string(path, hash, extra_keys(hash, expire_on)) end def generate_extras(options, hash, expire_on = {}) path, hash = generate_raw(options, hash, expire_on) [path, extra_keys(hash, expire_on)] end def generate_raw(options, hash, expire_on = {}) path = begin if hash[:controller] == "foo" && hash[:action] == "index" expired = false "/" end end [path, hash] end |
Three methods? The first two, as you can see, are defined in terms of generate_raw
, which is where the real work is actually done. We’ll just focus on that one.
For now, kindly ignore the fact that this is a lot more code than this specific case actually needs; it’s simply an artifact of the code generation process. As you can see, all this method really does is test to see that the controller and action match the values given in the route definition, and then sets the path to ”/”. It then returns the path, as well as the “hash” (which is the “merged” hash). This hash is returned so that the caller (generate
or generate_extras
) can determine what query parameters need to be built.
That was pretty straight-forward. Let’s look at the next route, which is slightly more complex. (We’ll only show the generate_raw
method, although all three methods are always generated for each route):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# map.connect "/foo/:action", :controller => "foo" def generate_raw(options, hash, expire_on = {}) path = begin if hash[:controller] == "foo" expired = false action_value = hash[:action] || "index" expired, hash = true, options if !expired && expire_on[:action] if action_value == "index" "/foo" else "/foo/#{CGI.escape(action_value.to_s)}" end end end [path, hash] end |
First, consider the route definition itself. What is it saying? It is saying that, if a URL is received in the form of /foo/bar
, that the controller to use will be “foo” and the action to invoke will be “bar”. On the other hand, if a URL is received like /foo
, then the action should default to “index”. (That just happens to be implicit in how routes behave: :action
is generally optional, and defaults to “index” if it is not specified.)
So, how does that translate into code? First, we check the :controller
key in the provided hash. (The hash
parameter, again, is the “merged” hash, whereas options
is the hash that was originally passed to the generate
method.)
If the :controller
key is “foo”, then we’ve satisified the explicit condition for the route. We then extract the :action
value from the hash, defaulting to “index” if it isn’t given.
The next line references the expiry hash, expire_on
. It only has a minor effect on this route, basically setting the hash
variable to the options
variable if the :action
key has expired. (Remember, a key is expired if it’s new value differs from what was passed into the request.) In effect, all recalled values are discarded if the action has expired. (You’ll see where this comes in when it comes time to generate any query parameters. It also makes a difference in more complicated routes, as you’ll see.)
If the action is “index”, then we omit it and simply generate ”/foo” as the path. Otherwise, we include the action in the path. Either way, we return an array of the new path, and the hash
variable (which will be either the “merged” hash, or the options
hash, if any parameter expiration occurred).
Whew! Make sure you understand that one before proceeding. (You can safely proceed even if the expiry stuff doesn’t make any sense. Hopefully you’ll understand more of what it is supposed to do before the end of the article.)
Now, for the final route:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# map.connect "/foo/:view/:permalink", :controller => "foo", # :action => "show", :view => /plain|fancy/, # :permalink => /[-a-z0-9]+/, # :conditions => { :method => :get } def generate_raw(options, hash, expire_on = {}) path = begin if hash[:controller] == "foo" && hash[:action] == "show" expired = false view_value = hash[:view] return [nil,nil] unless view_value && /\Aplain|fancy\Z/ =~ view_value expired, hash = true, options if !expired && expire_on[:view] permalink_value = hash[:permalink] return [nil,nil] unless permalink_value && /\A[-a-z0-9]+\Z/ =~ permalink_value expired, hash = true, options if !expired && expire_on[:permalink] "/foo/#{CGI.escape(view_value.to_s)}/#{CGI.escape(permalink_value.to_s)}" end end [path, hash] end |
We begin by testing the parameter shell. If those do not match the values given in the “merged” hash, then this route does not generate the desired URL, and we just return nil
for the path. Otherwise, we jump in and begin testing the constrained values.
First, we extract the value of the :view
parameter. If that value is nonexistent (or nil
), or if it does not match the regular expression that was given, then we just return nil
to indicate that the path could not be generated by this route.
Next, we check whether or not the hash should be expired based on this key. If we haven’t yet expired the hash (which will be true at this point) and the :view
key has been expired, then we expire the merged hash by replacing it with the options
hash, and we set expired
to true. (Note that by replacing the merged hash with the original options
hash, we have effectively expired all subsequent keys in the route—in other words, when we check the next value in the hash, :permalink
, it will be pulled from the options
hash and not from the value used in the last request, if the :view
value has changed.)
Ok, the final stretch. We now grab the :permalink
value from the hash, and do the same hokey-pokey with it. If it is present, and it matches the associated regular expression, we again check for whether or not to expire the hash.
If everything checks out, we build the path for this route using the extracted values, and return!
So, that’s what the generated code looks like. The task now is to decipher how that code gets generated.
Route#write_generation
We begin with the Route@write_generation
method. The relevant part is the first few lines:
1 2 |
body = "expired = false\n#{generation_extraction}\n#{generation_structure}" body = "if #{generation_requirements}\n#{body}\nend" if generation_requirements |
Here, body
is used to accumulate the text that will be evaluated to build the generate_raw
method. The first line builds the collection of statements that actually generates the route’s path. The second line then wraps the first, if there are any prerequisites that must be met first.
The generation_extraction
method, which builds the sequence of statements that “extract” the path from the options, is blessedly short:
1 2 3 4 5 |
def generation_extraction segments.collect do |segment| segment.extraction_code end.compact * "\n" end |
It just takes all of the segments for the route, asks them for their extraction code, removes the nils, and then joins the results with newlines. Elegant! (The “extraction code”, by the by, is the code that you saw in the third example above, where the value is pulled from the hash, assigned to a variable, validated, and then tested for parameter expiration.)
The default Segment#extraction_code
method returns nil
, which means that, by default, routing segments do not encapsulate any data that needs extracting. The only exception to this rule is the DynamicSegment
class, which overrides the default extraction_code
method with the following:
1 2 3 4 5 6 |
def extraction_code s = extract_value vc = value_check s << "\nreturn [nil,nil] unless #{vc}" if vc s << "\n#{expiry_statement}" end |
The call to the extract_value
method creates the default assignment line, which pulls the value from the hash and assigns it to a variable. The value_check
method builds the condition that enforces the constraints for this segment, like comparing the extracted value against a regular expression. The two are then combined, and the expiry_statement
is appended, which spits out the default one-line expiry test (as shown above). The result is then returned and collected with the other segments.
The requirement generation proceeds similarly, but uses the route’s requirements, rather than its segments.
1 2 3 4 5 6 7 8 9 10 11 |
def generation_requirements requirement_conditions = requirements.collect do |key, req| if req.is_a? Regexp value_regexp = Regexp.new "\\A#{req.source}\\Z" "hash[:#{key}] && #{value_regexp.inspect} =~ options[:#{key}]" else "hash[:#{key}] == #{req.inspect}" end end requirement_conditions * ' && ' unless requirement_conditions.empty? end |
Each of the route’s requirements (which are, essentially, any values in the options that were not referenced in the route’s segments) are tested, and a condition built from them. If the value for a requirement is a regular expression, a regular expression test will be generated, otherwise a simple equality is used. The resulting strings are joined with ' && '
, and returned.
Lastly, generation_structure
is invoked to build the code that actually combines the different segments to construct the resulting path:
1 2 3 |
def generation_structure segments.last.string_structure segments[0..-2] end |
For a single line of code, it hides a healthy amount of complexity. But we’re on the final stretch! Hang on just a bit longer as we walk through this last chunk of code.
Route#generation_structure
We can make this clearer if you use a real segment list as an example. Consider the third route in the example given earlier. The segment list would look something like this:
DividerSegment /
StaticSegment foo
DividerSegment /
DynamicSegment :view
DividerSegment /
DynamicSegment :permalink
DividerSegment /
Using this list as a reference, we can tackle generation_structure
. It grabs the last segment in the list, and then invokes the string_structure
method of it, passing in the list of all previous segments. (This “string structure” it refers to is the code that constructs the actual path from the component segments.) Let’s look at the default Segment#string_structure
method:
1 2 3 4 5 |
def string_structure(prior_segments) optional? ? continue_string_structure(prior_segments) : interpolation_statement(prior_segments) end |
In this case, the last DividerSegment
(#7) is the target of the method, and divider segments are always optional. Thus, we take the first branch, and continue_string_structure
get called.
1 2 3 4 5 6 7 8 |
def continue_string_structure(prior_segments) if prior_segments.empty? interpolation_statement(prior_segments) else new_priors = prior_segments[0..-2] prior_segments.last.string_structure(new_priors) end end |
The prior_segments
list will contain, in this case, segments 1 through 6, so the list is definitely not empty. We slice the list to get the new_priors
list (which will contain segments 1 through 5), and then invoke string_structure
on segment #6, the :permalink
segment.
Now, DynamicSegment
has it’s own version of string_structure
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def string_structure(prior_segments) if optional? # We have a conditional to do... # If we should not appear in the url, just write the code for the prior # segments. This occurs if our value is the default value, or, if we are # optional, if we have nil as our value. "if #{local_name} == #{default.inspect}\n" + continue_string_structure(prior_segments) + "\nelse\n" + # Otherwise, write the code up to here "#{interpolation_statement(prior_segments)}\nend" else interpolation_statement(prior_segments) end end |
Well, our :permalink
segment is not optional, since it has no default value. Thus, we take the second branch and build the interpolation_statement
beginning with this segment.
Let’s take a peek at the default Segment#interpolation_statement
method. It accumulates all prior segments and collects the results of their interpolation_chunk
methods:
1 2 3 4 5 |
def interpolation_statement(prior_segments) chunks = prior_segments.collect { |s| s.interpolation_chunk } chunks << interpolation_chunk "\"#{chunks * ''}\"#{all_optionals_available_condition(prior_segments)}" end |
Watch out for that last mouthful, the call to all_optionals_available_condition
. It “simply” examines the list of segments given (segments 1 through 5, in this case) and determines which of them are optional. It then builds an if
statement using the values from those segments, and tacks it onto the joined chunks
array. This prevents the path from being built if any of the optional segments were omitted (but only considering the optional segments that precede the current segment).
Confusing?
Yes!
Keep in mind that the interpolation is built starting with the last segment. If that segment is optional, the the segment preceding it is used as the starting point, and so forth, until a non-optional trailing segment is found. Thus, this all_optionals_available_condition
method makes sure that all the so-called “optional” segments that precede that (non-optional) tail segment have values. In other words, even if a segment thinks it is optional, it cannot be missing if there are any mandatory segments that follow it. Follow that? Wild stuff, man!
Wrapping Up
Whew! If you’ve made it this far, stop and congratulate yourself. That was dense stuff!
You should now have a better-than-general idea of how the Route#generate_raw
method is built. That method gets called by the new Route#generate
method:
1 2 3 4 |
def generate(options, hash, expire_on = {}) path, hash = generate_raw(options, hash, expire_on) append_query_string(path, hash, extra_keys(hash, expire_on)) end |
It takes the path and hash that were generated, and simply passes them on to the append_query_string
method. In the process, it calls the extra_keys
method, which determines which of the keys in the hash were not used to generate the path. Those left-over keys, then, get tacked on as query parameters by the append_query_string
method.
To illustrate: assume we are generating a path using the default route, and we pass in a few additional options:
1 2 |
link_to "A", :controller => "foo", :action => "bar", :id => "15", :view => "show", :format => "xml" |
The generated path would be:
/foo/bar/15?view=show&format=xml |
In other words, controller, action, and id all wind up in the path itself (since the default route references those keys and consumes them when generating the URL). The remaining keys, :view
and :format
, were not consumed, and thus wind up as query parameters.
Finish!
And that’s that. As before, there is a lot that I haven’t covered, but you should at least have some idea of where to look for answers at this point. If, in your own hacking of the routing code, you come upon a way to make route generation faster, please do share! Also, the current implementation of route generation is not very extensible. It’s difficult to monkeypatch, and that means it is hard for people to write plugins that have custom route generation functionality. If you’ve got a solution to that conundrum, I’d love to hear from you.
Reader Comments
I think I’ve found a bug in RouteSet#generate.
The following code is supposed to skip routes that don’t match:
routes.each do |route| results = route.send(method, options, merged, expire_on) return results if results end
This works with method=:generate since Route#generate either returns a string or nil.
However, Route#generate_extras and Route#generate_raw always return an array. The elements of this array will be nil if the Route doesn’t match.
I’ve documented a problem this causes and provided a patch in http://dev.rubyonrails.org/ticket/6300.
16 Oct 2006
Good catch, Phil!
16 Oct 2006
Jamis, thanks for this series—it’s awesome. I always wondered how this worked. Seeing how the language enables it just gives me that much more respect for Ruby.
30 Oct 2006